mirror of
https://github.com/claude-code-best/claude-code.git
synced 2026-06-18 06:15:51 +00:00
feat: 注册所有新命令到命令系统和工具注册表
- commands.ts: 注册所有新命令(memory-stores、vault、schedule 等), 移除 require() 动态加载,统一为 ESM import - tools.ts: 注册 LocalMemoryRecallTool、VaultHttpFetchTool - 补充命令测试(bridge-kick、commit、commit-push-pr、init-verifiers) - 补充工具测试(AgentTool、RemoteTrigger、SkillTool、WebFetch、WebSearch) - 集成测试:autonomy-lifecycle-user-flow 更新 - 探测脚本和功能文档 Co-Authored-By: glm-5-turbo <zai-org@claude-code-best.win>
This commit is contained in:
330
src/commands/__tests__/commit-push-pr.test.ts
Normal file
330
src/commands/__tests__/commit-push-pr.test.ts
Normal file
@@ -0,0 +1,330 @@
|
||||
import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test'
|
||||
import type { Command } from '../../commands.js'
|
||||
|
||||
mock.module('bun:bundle', () => ({
|
||||
feature: (_name: string) => false,
|
||||
}))
|
||||
|
||||
mock.module('src/utils/attribution.ts', () => ({
|
||||
getAttributionTexts: () => ({ commit: '', pr: '' }),
|
||||
getEnhancedPRAttribution: async () => undefined,
|
||||
countUserPromptsInMessages: () => 0,
|
||||
}))
|
||||
|
||||
mock.module('src/utils/undercover.ts', () => ({
|
||||
isUndercover: () => false,
|
||||
getUndercoverInstructions: () => '',
|
||||
shouldShowUndercoverAutoNotice: () => false,
|
||||
}))
|
||||
|
||||
mock.module('src/utils/promptShellExecution.ts', () => ({
|
||||
executeShellCommandsInPrompt: async (content: string) => content,
|
||||
}))
|
||||
|
||||
// IMPORTANT: mock.module is process-global. findGitRoot/findCanonicalGitRoot
|
||||
// are SYNC in the real impl (returning string | null) — using async stubs
|
||||
// here pollutes downstream callers (e.g. jobs/templates.ts) that consume the
|
||||
// return value as a string. Match the real signatures (sync, string | null)
|
||||
// so other test files in the same process keep working.
|
||||
//
|
||||
// Pure functions (normalizeGitRemoteUrl) are inlined with real semantics so
|
||||
// git.test.ts and other consumers of this mock don't see null returns when
|
||||
// the test runs in the full suite.
|
||||
const isLocalHostForMock = (host: string): boolean => {
|
||||
const lower = host.toLowerCase().split(':')[0] ?? ''
|
||||
return lower === 'localhost' || lower === '127.0.0.1' || lower === '::1'
|
||||
}
|
||||
const realNormalizeGitRemoteUrl = (url: string): string | null => {
|
||||
const trimmed = url.trim()
|
||||
if (!trimmed) return null
|
||||
|
||||
const sshMatch = trimmed.match(/^git@([^:]+):(.+?)(?:\.git)?$/)
|
||||
if (sshMatch && sshMatch[1] && sshMatch[2]) {
|
||||
return `${sshMatch[1]}/${sshMatch[2]}`.toLowerCase()
|
||||
}
|
||||
|
||||
const urlMatch = trimmed.match(
|
||||
/^(?:https?|ssh):\/\/(?:[^@]+@)?([^/]+)\/(.+?)(?:\.git)?$/,
|
||||
)
|
||||
if (urlMatch && urlMatch[1] && urlMatch[2]) {
|
||||
const host = urlMatch[1]
|
||||
const p = urlMatch[2]
|
||||
if (isLocalHostForMock(host) && p.startsWith('git/')) {
|
||||
const proxyPath = p.slice(4)
|
||||
const segments = proxyPath.split('/')
|
||||
if (segments.length >= 3 && segments[0]!.includes('.')) {
|
||||
return proxyPath.toLowerCase()
|
||||
}
|
||||
return `github.com/${proxyPath}`.toLowerCase()
|
||||
}
|
||||
return `${host}/${p}`.toLowerCase()
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
mock.module('src/utils/git.ts', () => ({
|
||||
getDefaultBranch: async () => 'main',
|
||||
findGitRoot: (_startPath?: string) => '/fake/root',
|
||||
findCanonicalGitRoot: (_startPath?: string) => '/fake/root',
|
||||
gitExe: () => 'git',
|
||||
getIsGit: async () => true,
|
||||
getGitDir: async () => null,
|
||||
isAtGitRoot: async () => true,
|
||||
dirIsInGitRepo: async () => true,
|
||||
getHead: async () => 'abc123',
|
||||
getBranch: async () => 'main',
|
||||
// The following exports are referenced by markdownConfigLoader (and other
|
||||
// transitive consumers) — provide minimal stubs so the mock surface covers
|
||||
// every real export and downstream callers don't see undefined.
|
||||
getRemoteUrl: async () => null,
|
||||
normalizeGitRemoteUrl: realNormalizeGitRemoteUrl,
|
||||
getRepoRemoteHash: async () => null,
|
||||
getIsHeadOnRemote: async () => false,
|
||||
hasUnpushedCommits: async () => false,
|
||||
getIsClean: async () => true,
|
||||
getChangedFiles: async () => [] as string[],
|
||||
getFileStatus: async () => ({
|
||||
added: [],
|
||||
modified: [],
|
||||
deleted: [],
|
||||
renamed: [],
|
||||
untracked: [],
|
||||
}),
|
||||
getWorktreeCount: async () => 1,
|
||||
stashToCleanState: async () => false,
|
||||
getGitState: async () => null,
|
||||
getGithubRepo: async () => null,
|
||||
findRemoteBase: async () => null,
|
||||
preserveGitStateForIssue: async () => null,
|
||||
isCurrentDirectoryBareGitRepo: () => false,
|
||||
}))
|
||||
|
||||
let commitPushPr: Command
|
||||
let originalUserType: string | undefined
|
||||
let originalSafeUser: string | undefined
|
||||
let originalUser: string | undefined
|
||||
|
||||
beforeEach(async () => {
|
||||
originalUserType = process.env.USER_TYPE
|
||||
originalSafeUser = process.env.SAFEUSER
|
||||
originalUser = process.env.USER
|
||||
const mod = await import('../commit-push-pr.js')
|
||||
commitPushPr = mod.default as Command
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
if (originalUserType === undefined) delete process.env.USER_TYPE
|
||||
else process.env.USER_TYPE = originalUserType
|
||||
|
||||
if (originalSafeUser === undefined) delete process.env.SAFEUSER
|
||||
else process.env.SAFEUSER = originalSafeUser
|
||||
|
||||
if (originalUser === undefined) delete process.env.USER
|
||||
else process.env.USER = originalUser
|
||||
})
|
||||
|
||||
describe('commit-push-pr command metadata', () => {
|
||||
test('has correct name', () => {
|
||||
expect(commitPushPr.name).toBe('commit-push-pr')
|
||||
})
|
||||
|
||||
test('has description', () => {
|
||||
expect(commitPushPr.description).toBeTruthy()
|
||||
expect(typeof commitPushPr.description).toBe('string')
|
||||
})
|
||||
|
||||
test('type is prompt', () => {
|
||||
expect(commitPushPr.type).toBe('prompt')
|
||||
})
|
||||
|
||||
test('has progressMessage', () => {
|
||||
expect((commitPushPr as any).progressMessage).toBeTruthy()
|
||||
})
|
||||
|
||||
test('source is builtin', () => {
|
||||
expect((commitPushPr as any).source).toBe('builtin')
|
||||
})
|
||||
|
||||
test('has allowedTools array with git and gh tools', () => {
|
||||
const tools = (commitPushPr as any).allowedTools as string[]
|
||||
expect(Array.isArray(tools)).toBe(true)
|
||||
expect(tools.some(t => t.includes('git push'))).toBe(true)
|
||||
expect(tools.some(t => t.includes('gh pr create'))).toBe(true)
|
||||
expect(tools.some(t => t.includes('git add'))).toBe(true)
|
||||
expect(tools.some(t => t.includes('git commit'))).toBe(true)
|
||||
})
|
||||
|
||||
test('contentLength getter returns a number', () => {
|
||||
const len = (commitPushPr as any).contentLength
|
||||
expect(typeof len).toBe('number')
|
||||
expect(len).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('commit-push-pr getPromptForCommand', () => {
|
||||
const makeContext = () => ({
|
||||
getAppState: () => ({
|
||||
toolPermissionContext: {
|
||||
alwaysAllowRules: { command: [] },
|
||||
},
|
||||
}),
|
||||
})
|
||||
|
||||
test('returns array with text type for empty args', async () => {
|
||||
const result = await (commitPushPr as any).getPromptForCommand(
|
||||
'',
|
||||
makeContext(),
|
||||
)
|
||||
expect(Array.isArray(result)).toBe(true)
|
||||
expect(result[0].type).toBe('text')
|
||||
})
|
||||
|
||||
test('result text contains pull request instructions', async () => {
|
||||
const result = await (commitPushPr as any).getPromptForCommand(
|
||||
'',
|
||||
makeContext(),
|
||||
)
|
||||
expect(result[0].text).toContain('PR')
|
||||
})
|
||||
|
||||
test('result text contains default branch', async () => {
|
||||
const result = await (commitPushPr as any).getPromptForCommand(
|
||||
'',
|
||||
makeContext(),
|
||||
)
|
||||
expect(result[0].text).toContain('main')
|
||||
})
|
||||
|
||||
test('appends additional user instructions when args provided', async () => {
|
||||
const result = await (commitPushPr as any).getPromptForCommand(
|
||||
'Fix the bug',
|
||||
makeContext(),
|
||||
)
|
||||
expect(result[0].text).toContain('Fix the bug')
|
||||
expect(result[0].text).toContain('Additional instructions')
|
||||
})
|
||||
|
||||
test('does not append additional instructions section for whitespace-only args', async () => {
|
||||
const result = await (commitPushPr as any).getPromptForCommand(
|
||||
' ',
|
||||
makeContext(),
|
||||
)
|
||||
expect(result[0].text).not.toContain('Additional instructions')
|
||||
})
|
||||
|
||||
test('handles null/undefined args gracefully', async () => {
|
||||
const result = await (commitPushPr as any).getPromptForCommand(
|
||||
undefined,
|
||||
makeContext(),
|
||||
)
|
||||
expect(Array.isArray(result)).toBe(true)
|
||||
expect(result[0].type).toBe('text')
|
||||
})
|
||||
|
||||
test('with ant user type and not undercover, includes reviewer arg', async () => {
|
||||
process.env.USER_TYPE = 'external'
|
||||
const result = await (commitPushPr as any).getPromptForCommand(
|
||||
'',
|
||||
makeContext(),
|
||||
)
|
||||
expect(result[0].text).toContain('gh pr create')
|
||||
})
|
||||
|
||||
test('with SAFEUSER env var set, text contains context', async () => {
|
||||
process.env.SAFEUSER = 'testuser'
|
||||
const result = await (commitPushPr as any).getPromptForCommand(
|
||||
'',
|
||||
makeContext(),
|
||||
)
|
||||
expect(result[0].text).toContain('SAFEUSER')
|
||||
})
|
||||
|
||||
test('with ant user type and undercover, strips reviewer args', async () => {
|
||||
process.env.USER_TYPE = 'ant'
|
||||
// isUndercover is mocked as false, so no prefix should be added
|
||||
const result = await (commitPushPr as any).getPromptForCommand(
|
||||
'',
|
||||
makeContext(),
|
||||
)
|
||||
expect(Array.isArray(result)).toBe(true)
|
||||
})
|
||||
|
||||
test('with args containing newlines, appends full multi-line instructions', async () => {
|
||||
const multiline = 'Line one\nLine two\nLine three'
|
||||
const result = await (commitPushPr as any).getPromptForCommand(
|
||||
multiline,
|
||||
makeContext(),
|
||||
)
|
||||
expect(result[0].text).toContain('Line one')
|
||||
expect(result[0].text).toContain('Line three')
|
||||
})
|
||||
|
||||
test('getAppState override in context includes ALLOWED_TOOLS', async () => {
|
||||
let capturedGetAppState: (() => any) | undefined
|
||||
|
||||
// Re-mock executeShellCommandsInPrompt to capture the context argument
|
||||
mock.module('src/utils/promptShellExecution.ts', () => ({
|
||||
executeShellCommandsInPrompt: async (content: string, ctx: any) => {
|
||||
capturedGetAppState = ctx.getAppState.bind(ctx)
|
||||
return content
|
||||
},
|
||||
}))
|
||||
|
||||
// Re-import to pick up the new mock
|
||||
const { default: freshCmd } = await import('../commit-push-pr.js')
|
||||
|
||||
await (freshCmd as any).getPromptForCommand('', {
|
||||
getAppState: () => ({
|
||||
toolPermissionContext: {
|
||||
alwaysAllowRules: { command: ['pre-existing'] },
|
||||
extra: true,
|
||||
},
|
||||
someState: 'value',
|
||||
}),
|
||||
})
|
||||
|
||||
expect(capturedGetAppState).toBeDefined()
|
||||
const resultState = capturedGetAppState!()
|
||||
expect(
|
||||
Array.isArray(resultState.toolPermissionContext.alwaysAllowRules.command),
|
||||
).toBe(true)
|
||||
// Should have replaced with ALLOWED_TOOLS
|
||||
expect(
|
||||
resultState.toolPermissionContext.alwaysAllowRules.command.length,
|
||||
).toBeGreaterThan(0)
|
||||
expect(resultState.someState).toBe('value')
|
||||
})
|
||||
|
||||
test('ant undercover path strips reviewer/slack/changelog sections', async () => {
|
||||
process.env.USER_TYPE = 'ant'
|
||||
|
||||
// Re-mock undercover to return true for this test
|
||||
mock.module('src/utils/undercover.ts', () => ({
|
||||
isUndercover: () => true,
|
||||
getUndercoverInstructions: () => 'UNDERCOVER_INSTRUCTIONS',
|
||||
shouldShowUndercoverAutoNotice: () => false,
|
||||
}))
|
||||
|
||||
// Also re-mock attribution to return commit text
|
||||
mock.module('src/utils/attribution.ts', () => ({
|
||||
getAttributionTexts: () => ({
|
||||
commit: 'Attribution text',
|
||||
pr: 'PR Attribution',
|
||||
}),
|
||||
getEnhancedPRAttribution: async () => 'Enhanced PR Attribution',
|
||||
countUserPromptsInMessages: () => 0,
|
||||
}))
|
||||
|
||||
const { default: freshCmd } = await import('../commit-push-pr.js')
|
||||
|
||||
const result = await (freshCmd as any).getPromptForCommand(
|
||||
'',
|
||||
makeContext(),
|
||||
)
|
||||
expect(Array.isArray(result)).toBe(true)
|
||||
// The undercover path removes slackStep, changelogSection, and reviewer args
|
||||
// The prompt should not contain those sections
|
||||
expect(result[0].text).not.toContain('CHANGELOG:START')
|
||||
expect(result[0].text).not.toContain('Slack')
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user