mirror of
https://github.com/claude-code-best/claude-code.git
synced 2026-06-18 14:25: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:
246
src/commands/__tests__/bridge-kick.test.ts
Normal file
246
src/commands/__tests__/bridge-kick.test.ts
Normal file
@@ -0,0 +1,246 @@
|
||||
import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test'
|
||||
|
||||
mock.module('bun:bundle', () => ({
|
||||
feature: (_name: string) => false,
|
||||
}))
|
||||
|
||||
// Capture injected faults and handle calls for assertions
|
||||
let mockHandle: any = null
|
||||
let lastFault: any = null
|
||||
let fireCloseCalled: number | null = null
|
||||
let forceReconnectCalled = false
|
||||
let wakePolled = false
|
||||
let describeResult = 'bridge-status: ok'
|
||||
|
||||
mock.module('src/bridge/bridgeDebug.ts', () => ({
|
||||
getBridgeDebugHandle: () => mockHandle,
|
||||
registerBridgeDebugHandle: () => {},
|
||||
clearBridgeDebugHandle: () => {},
|
||||
injectBridgeFault: () => {},
|
||||
wrapApiForFaultInjection: (api: any) => api,
|
||||
}))
|
||||
|
||||
function makeMockHandle() {
|
||||
return {
|
||||
fireClose: (code: number) => {
|
||||
fireCloseCalled = code
|
||||
},
|
||||
forceReconnect: () => {
|
||||
forceReconnectCalled = true
|
||||
},
|
||||
injectFault: (fault: any) => {
|
||||
lastFault = fault
|
||||
},
|
||||
wakePollLoop: () => {
|
||||
wakePolled = true
|
||||
},
|
||||
describe: () => describeResult,
|
||||
}
|
||||
}
|
||||
|
||||
let bridgeKick: any
|
||||
let callFn:
|
||||
| ((args: string) => Promise<{ type: string; value: string }>)
|
||||
| undefined
|
||||
|
||||
beforeEach(async () => {
|
||||
mockHandle = null
|
||||
lastFault = null
|
||||
fireCloseCalled = null
|
||||
forceReconnectCalled = false
|
||||
wakePolled = false
|
||||
const mod = await import('../bridge-kick.js')
|
||||
bridgeKick = mod.default
|
||||
const loaded = await bridgeKick.load()
|
||||
callFn = loaded.call
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
mockHandle = null
|
||||
})
|
||||
|
||||
describe('bridge-kick command metadata', () => {
|
||||
test('has correct name', () => {
|
||||
expect(bridgeKick.name).toBe('bridge-kick')
|
||||
})
|
||||
|
||||
test('has description', () => {
|
||||
expect(bridgeKick.description).toBeTruthy()
|
||||
})
|
||||
|
||||
test('type is local', () => {
|
||||
expect(bridgeKick.type).toBe('local')
|
||||
})
|
||||
|
||||
test('isEnabled returns true when USER_TYPE=ant', () => {
|
||||
const originalUserType = process.env.USER_TYPE
|
||||
process.env.USER_TYPE = 'ant'
|
||||
expect(bridgeKick.isEnabled()).toBe(true)
|
||||
if (originalUserType === undefined) delete process.env.USER_TYPE
|
||||
else process.env.USER_TYPE = originalUserType
|
||||
})
|
||||
|
||||
test('isEnabled returns false when USER_TYPE is not ant', () => {
|
||||
const originalUserType = process.env.USER_TYPE
|
||||
process.env.USER_TYPE = 'external'
|
||||
expect(bridgeKick.isEnabled()).toBe(false)
|
||||
if (originalUserType === undefined) delete process.env.USER_TYPE
|
||||
else process.env.USER_TYPE = originalUserType
|
||||
})
|
||||
|
||||
test('isEnabled returns false when USER_TYPE not set', () => {
|
||||
const originalUserType = process.env.USER_TYPE
|
||||
delete process.env.USER_TYPE
|
||||
expect(bridgeKick.isEnabled()).toBe(false)
|
||||
if (originalUserType !== undefined) process.env.USER_TYPE = originalUserType
|
||||
})
|
||||
|
||||
test('supportsNonInteractive is false', () => {
|
||||
expect(bridgeKick.supportsNonInteractive).toBe(false)
|
||||
})
|
||||
|
||||
test('has load function', () => {
|
||||
expect(typeof bridgeKick.load).toBe('function')
|
||||
})
|
||||
})
|
||||
|
||||
describe('bridge-kick call - no handle registered', () => {
|
||||
test('returns error message when no handle registered', async () => {
|
||||
mockHandle = null
|
||||
const result = await callFn!('status')
|
||||
expect(result.type).toBe('text')
|
||||
expect(result.value).toContain('No bridge debug handle')
|
||||
})
|
||||
})
|
||||
|
||||
describe('bridge-kick call - with handle', () => {
|
||||
beforeEach(() => {
|
||||
mockHandle = makeMockHandle()
|
||||
})
|
||||
|
||||
test('close with valid code fires close', async () => {
|
||||
const result = await callFn!('close 1002')
|
||||
expect(result.type).toBe('text')
|
||||
expect(result.value).toContain('1002')
|
||||
expect(fireCloseCalled).toBe(1002)
|
||||
})
|
||||
|
||||
test('close with 1006 fires close(1006)', async () => {
|
||||
await callFn!('close 1006')
|
||||
expect(fireCloseCalled).toBe(1006)
|
||||
})
|
||||
|
||||
test('close with non-numeric code returns error', async () => {
|
||||
const result = await callFn!('close abc')
|
||||
expect(result.type).toBe('text')
|
||||
expect(result.value).toContain('need a numeric code')
|
||||
})
|
||||
|
||||
test('poll transient injects transient fault and wakes poll loop', async () => {
|
||||
const result = await callFn!('poll transient')
|
||||
expect(result.type).toBe('text')
|
||||
expect(result.value).toContain('transient')
|
||||
expect(wakePolled).toBe(true)
|
||||
expect(lastFault?.kind).toBe('transient')
|
||||
expect(lastFault?.method).toBe('pollForWork')
|
||||
})
|
||||
|
||||
test('poll 404 injects fatal fault with not_found_error', async () => {
|
||||
const result = await callFn!('poll 404')
|
||||
expect(result.type).toBe('text')
|
||||
expect(lastFault?.kind).toBe('fatal')
|
||||
expect(lastFault?.status).toBe(404)
|
||||
expect(lastFault?.errorType).toBe('not_found_error')
|
||||
expect(wakePolled).toBe(true)
|
||||
})
|
||||
|
||||
test('poll 401 injects fatal fault with authentication_error default', async () => {
|
||||
await callFn!('poll 401')
|
||||
expect(lastFault?.status).toBe(401)
|
||||
expect(lastFault?.errorType).toBe('authentication_error')
|
||||
})
|
||||
|
||||
test('poll 404 with custom type uses provided type', async () => {
|
||||
await callFn!('poll 404 custom_error')
|
||||
expect(lastFault?.errorType).toBe('custom_error')
|
||||
})
|
||||
|
||||
test('poll with non-numeric non-transient returns error', async () => {
|
||||
const result = await callFn!('poll abc')
|
||||
expect(result.type).toBe('text')
|
||||
expect(result.value).toContain('need')
|
||||
})
|
||||
|
||||
test('register fatal injects 403 fatal fault', async () => {
|
||||
const result = await callFn!('register fatal')
|
||||
expect(result.type).toBe('text')
|
||||
expect(result.value).toContain('403')
|
||||
expect(lastFault?.status).toBe(403)
|
||||
expect(lastFault?.kind).toBe('fatal')
|
||||
expect(lastFault?.method).toBe('registerBridgeEnvironment')
|
||||
})
|
||||
|
||||
test('register fail injects transient fault with count 1', async () => {
|
||||
const result = await callFn!('register fail')
|
||||
expect(result.type).toBe('text')
|
||||
expect(lastFault?.kind).toBe('transient')
|
||||
expect(lastFault?.count).toBe(1)
|
||||
})
|
||||
|
||||
test('register fail 3 injects transient fault with count 3', async () => {
|
||||
await callFn!('register fail 3')
|
||||
expect(lastFault?.count).toBe(3)
|
||||
})
|
||||
|
||||
test('reconnect-session fail injects 404 fault for reconnectSession', async () => {
|
||||
const result = await callFn!('reconnect-session fail')
|
||||
expect(result.type).toBe('text')
|
||||
expect(lastFault?.method).toBe('reconnectSession')
|
||||
expect(lastFault?.status).toBe(404)
|
||||
expect(lastFault?.count).toBe(2)
|
||||
})
|
||||
|
||||
test('heartbeat 401 injects authentication_error', async () => {
|
||||
await callFn!('heartbeat 401')
|
||||
expect(lastFault?.method).toBe('heartbeatWork')
|
||||
expect(lastFault?.status).toBe(401)
|
||||
expect(lastFault?.errorType).toBe('authentication_error')
|
||||
})
|
||||
|
||||
test('heartbeat with non-401 status uses not_found_error', async () => {
|
||||
await callFn!('heartbeat 404')
|
||||
expect(lastFault?.status).toBe(404)
|
||||
expect(lastFault?.errorType).toBe('not_found_error')
|
||||
})
|
||||
|
||||
test('heartbeat with no status defaults to 401', async () => {
|
||||
await callFn!('heartbeat')
|
||||
expect(lastFault?.status).toBe(401)
|
||||
})
|
||||
|
||||
test('reconnect calls forceReconnect', async () => {
|
||||
const result = await callFn!('reconnect')
|
||||
expect(result.type).toBe('text')
|
||||
expect(result.value).toContain('reconnect')
|
||||
expect(forceReconnectCalled).toBe(true)
|
||||
})
|
||||
|
||||
test('status returns bridge description', async () => {
|
||||
const result = await callFn!('status')
|
||||
expect(result.type).toBe('text')
|
||||
expect(result.value).toBe(describeResult)
|
||||
})
|
||||
|
||||
test('unknown subcommand returns usage info', async () => {
|
||||
const result = await callFn!('unknown-cmd')
|
||||
expect(result.type).toBe('text')
|
||||
expect(result.value).toContain('bridge-kick')
|
||||
})
|
||||
|
||||
test('empty args returns usage info', async () => {
|
||||
const result = await callFn!('')
|
||||
expect(result.type).toBe('text')
|
||||
// empty trim → undefined sub → default case
|
||||
expect(result.value).toBeTruthy()
|
||||
})
|
||||
})
|
||||
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')
|
||||
})
|
||||
})
|
||||
273
src/commands/__tests__/commit.test.ts
Normal file
273
src/commands/__tests__/commit.test.ts
Normal file
@@ -0,0 +1,273 @@
|
||||
import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test'
|
||||
import type { Command } from '../../commands.js'
|
||||
|
||||
// Mock bun:bundle before any imports that use feature()
|
||||
mock.module('bun:bundle', () => ({
|
||||
feature: (_name: string) => false,
|
||||
}))
|
||||
|
||||
// Mock dependencies to avoid side effects
|
||||
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,
|
||||
}))
|
||||
|
||||
let commit: Command
|
||||
let originalUserType: string | undefined
|
||||
|
||||
beforeEach(async () => {
|
||||
originalUserType = process.env.USER_TYPE
|
||||
const mod = await import('../commit.js')
|
||||
commit = mod.default as Command
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
if (originalUserType === undefined) {
|
||||
delete process.env.USER_TYPE
|
||||
} else {
|
||||
process.env.USER_TYPE = originalUserType
|
||||
}
|
||||
})
|
||||
|
||||
describe('commit command metadata', () => {
|
||||
test('has correct name', () => {
|
||||
expect(commit.name).toBe('commit')
|
||||
})
|
||||
|
||||
test('has description', () => {
|
||||
expect(commit.description).toBeTruthy()
|
||||
expect(typeof commit.description).toBe('string')
|
||||
})
|
||||
|
||||
test('type is prompt', () => {
|
||||
expect(commit.type).toBe('prompt')
|
||||
})
|
||||
|
||||
test('has progressMessage', () => {
|
||||
expect((commit as any).progressMessage).toBeTruthy()
|
||||
})
|
||||
|
||||
test('source is builtin', () => {
|
||||
expect((commit as any).source).toBe('builtin')
|
||||
})
|
||||
|
||||
test('has allowedTools array', () => {
|
||||
const tools = (commit as any).allowedTools
|
||||
expect(Array.isArray(tools)).toBe(true)
|
||||
expect(tools.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
test('allowedTools includes git add', () => {
|
||||
const tools = (commit as any).allowedTools as string[]
|
||||
expect(tools.some(t => t.includes('git add'))).toBe(true)
|
||||
})
|
||||
|
||||
test('allowedTools includes git commit', () => {
|
||||
const tools = (commit as any).allowedTools as string[]
|
||||
expect(tools.some(t => t.includes('git commit'))).toBe(true)
|
||||
})
|
||||
|
||||
test('allowedTools includes git status', () => {
|
||||
const tools = (commit as any).allowedTools as string[]
|
||||
expect(tools.some(t => t.includes('git status'))).toBe(true)
|
||||
})
|
||||
|
||||
test('contentLength is 0 (dynamic)', () => {
|
||||
expect((commit as any).contentLength).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('commit command getPromptForCommand', () => {
|
||||
test('returns array with text type', async () => {
|
||||
const mockContext = {
|
||||
getAppState: () => ({
|
||||
toolPermissionContext: {
|
||||
alwaysAllowRules: { command: [] },
|
||||
},
|
||||
}),
|
||||
}
|
||||
const result = await (commit as any).getPromptForCommand('', mockContext)
|
||||
expect(Array.isArray(result)).toBe(true)
|
||||
expect(result.length).toBeGreaterThan(0)
|
||||
expect(result[0].type).toBe('text')
|
||||
})
|
||||
|
||||
test('result text contains git instructions', async () => {
|
||||
const mockContext = {
|
||||
getAppState: () => ({
|
||||
toolPermissionContext: {
|
||||
alwaysAllowRules: { command: [] },
|
||||
},
|
||||
}),
|
||||
}
|
||||
const result = await (commit as any).getPromptForCommand('', mockContext)
|
||||
expect(result[0].text).toContain('git')
|
||||
})
|
||||
|
||||
test('result text contains git status', async () => {
|
||||
const mockContext = {
|
||||
getAppState: () => ({
|
||||
toolPermissionContext: {
|
||||
alwaysAllowRules: { command: [] },
|
||||
},
|
||||
}),
|
||||
}
|
||||
const result = await (commit as any).getPromptForCommand('', mockContext)
|
||||
expect(result[0].text).toContain('git status')
|
||||
})
|
||||
|
||||
test('result text contains commit message instructions', async () => {
|
||||
const mockContext = {
|
||||
getAppState: () => ({
|
||||
toolPermissionContext: {
|
||||
alwaysAllowRules: { command: [] },
|
||||
},
|
||||
}),
|
||||
}
|
||||
const result = await (commit as any).getPromptForCommand('', mockContext)
|
||||
expect(result[0].text).toContain('commit')
|
||||
})
|
||||
|
||||
test('getAppState override preserves alwaysAllowRules', async () => {
|
||||
let capturedAppState: any
|
||||
const mockContext = {
|
||||
getAppState: () => ({
|
||||
toolPermissionContext: {
|
||||
alwaysAllowRules: { command: ['existing-rule'] },
|
||||
otherProp: 'test',
|
||||
},
|
||||
otherState: 'value',
|
||||
}),
|
||||
}
|
||||
|
||||
// Wrap executeShellCommandsInPrompt to capture context
|
||||
mock.module('src/utils/promptShellExecution.ts', () => ({
|
||||
executeShellCommandsInPrompt: async (content: string, ctx: any) => {
|
||||
capturedAppState = ctx.getAppState()
|
||||
return content
|
||||
},
|
||||
}))
|
||||
|
||||
const mod = await import('../commit.js')
|
||||
const freshCommit = mod.default as any
|
||||
|
||||
await freshCommit.getPromptForCommand('', mockContext)
|
||||
// The override should include alwaysAllowRules with command tools
|
||||
if (capturedAppState) {
|
||||
expect(
|
||||
capturedAppState.toolPermissionContext.alwaysAllowRules.command,
|
||||
).toBeDefined()
|
||||
}
|
||||
})
|
||||
|
||||
test('getPromptForCommand with non-ant user_type does not include undercover prefix', async () => {
|
||||
process.env.USER_TYPE = 'external'
|
||||
const mockContext = {
|
||||
getAppState: () => ({
|
||||
toolPermissionContext: {
|
||||
alwaysAllowRules: { command: [] },
|
||||
},
|
||||
}),
|
||||
}
|
||||
const result = await (commit as any).getPromptForCommand('', mockContext)
|
||||
expect(Array.isArray(result)).toBe(true)
|
||||
})
|
||||
|
||||
test('getPromptForCommand with ant user_type and undercover', async () => {
|
||||
process.env.USER_TYPE = 'ant'
|
||||
// isUndercover is mocked to return false, so prefix stays empty
|
||||
const mockContext = {
|
||||
getAppState: () => ({
|
||||
toolPermissionContext: {
|
||||
alwaysAllowRules: { command: [] },
|
||||
},
|
||||
}),
|
||||
}
|
||||
const result = await (commit as any).getPromptForCommand('', mockContext)
|
||||
expect(Array.isArray(result)).toBe(true)
|
||||
expect(result[0].type).toBe('text')
|
||||
})
|
||||
|
||||
test('ant undercover path prepends undercover instructions', async () => {
|
||||
process.env.USER_TYPE = 'ant'
|
||||
|
||||
mock.module('src/utils/undercover.ts', () => ({
|
||||
isUndercover: () => true,
|
||||
getUndercoverInstructions: () => 'SECRET_UNDERCOVER_PREFIX',
|
||||
shouldShowUndercoverAutoNotice: () => false,
|
||||
}))
|
||||
|
||||
mock.module('src/utils/attribution.ts', () => ({
|
||||
getAttributionTexts: () => ({ commit: 'Co-Authored-By: Claude', pr: '' }),
|
||||
getEnhancedPRAttribution: async () => undefined,
|
||||
countUserPromptsInMessages: () => 0,
|
||||
}))
|
||||
|
||||
const { default: freshCommit } = await import('../commit.js')
|
||||
const mockContext = {
|
||||
getAppState: () => ({
|
||||
toolPermissionContext: {
|
||||
alwaysAllowRules: { command: [] },
|
||||
},
|
||||
}),
|
||||
}
|
||||
|
||||
const result = await (freshCommit as any).getPromptForCommand(
|
||||
'',
|
||||
mockContext,
|
||||
)
|
||||
expect(Array.isArray(result)).toBe(true)
|
||||
expect(result[0].text).toContain('SECRET_UNDERCOVER_PREFIX')
|
||||
expect(result[0].text).toContain('Co-Authored-By')
|
||||
})
|
||||
|
||||
test('getAppState override in context passes ALLOWED_TOOLS', async () => {
|
||||
let capturedCtx: any
|
||||
|
||||
mock.module('src/utils/promptShellExecution.ts', () => ({
|
||||
executeShellCommandsInPrompt: async (content: string, ctx: any) => {
|
||||
capturedCtx = ctx
|
||||
return content
|
||||
},
|
||||
}))
|
||||
|
||||
const { default: freshCommit } = await import('../commit.js')
|
||||
const baseAppState = {
|
||||
toolPermissionContext: {
|
||||
alwaysAllowRules: { command: ['old-rule'] },
|
||||
otherProp: 'keep-this',
|
||||
},
|
||||
globalState: 'preserved',
|
||||
}
|
||||
const mockContext = {
|
||||
getAppState: () => baseAppState,
|
||||
}
|
||||
|
||||
await (freshCommit as any).getPromptForCommand('', mockContext)
|
||||
|
||||
expect(capturedCtx).toBeDefined()
|
||||
const overriddenState = capturedCtx.getAppState()
|
||||
expect(overriddenState.globalState).toBe('preserved')
|
||||
expect(
|
||||
Array.isArray(
|
||||
overriddenState.toolPermissionContext.alwaysAllowRules.command,
|
||||
),
|
||||
).toBe(true)
|
||||
expect(
|
||||
overriddenState.toolPermissionContext.alwaysAllowRules.command.some(
|
||||
(t: string) => t.includes('git add'),
|
||||
),
|
||||
).toBe(true)
|
||||
})
|
||||
})
|
||||
113
src/commands/__tests__/init-verifiers.test.ts
Normal file
113
src/commands/__tests__/init-verifiers.test.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
|
||||
// init-verifiers.ts has no external dependencies that need mocking
|
||||
// It's a simple prompt-type command that returns a static text prompt
|
||||
|
||||
let initVerifiers: any
|
||||
|
||||
// Import once - no async deps
|
||||
const mod = await import('../init-verifiers.js')
|
||||
initVerifiers = mod.default
|
||||
|
||||
describe('init-verifiers command metadata', () => {
|
||||
test('has correct name', () => {
|
||||
expect(initVerifiers.name).toBe('init-verifiers')
|
||||
})
|
||||
|
||||
test('has description', () => {
|
||||
expect(initVerifiers.description).toBeTruthy()
|
||||
expect(typeof initVerifiers.description).toBe('string')
|
||||
})
|
||||
|
||||
test('type is prompt', () => {
|
||||
expect(initVerifiers.type).toBe('prompt')
|
||||
})
|
||||
|
||||
test('has progressMessage', () => {
|
||||
expect(initVerifiers.progressMessage).toBeTruthy()
|
||||
})
|
||||
|
||||
test('source is builtin', () => {
|
||||
expect(initVerifiers.source).toBe('builtin')
|
||||
})
|
||||
|
||||
test('contentLength is 0 (dynamic)', () => {
|
||||
expect(initVerifiers.contentLength).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('init-verifiers getPromptForCommand', () => {
|
||||
test('returns a non-empty array', async () => {
|
||||
const result = await initVerifiers.getPromptForCommand()
|
||||
expect(Array.isArray(result)).toBe(true)
|
||||
expect(result.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
test('first element has type "text"', async () => {
|
||||
const result = await initVerifiers.getPromptForCommand()
|
||||
expect(result[0].type).toBe('text')
|
||||
})
|
||||
|
||||
test('text contains Phase 1 auto-detection instructions', async () => {
|
||||
const result = await initVerifiers.getPromptForCommand()
|
||||
expect(result[0].text).toContain('Phase 1')
|
||||
})
|
||||
|
||||
test('text contains Phase 2 verification tool setup', async () => {
|
||||
const result = await initVerifiers.getPromptForCommand()
|
||||
expect(result[0].text).toContain('Phase 2')
|
||||
})
|
||||
|
||||
test('text contains Phase 3 interactive Q&A', async () => {
|
||||
const result = await initVerifiers.getPromptForCommand()
|
||||
expect(result[0].text).toContain('Phase 3')
|
||||
})
|
||||
|
||||
test('text contains Phase 4 generate verifier skill', async () => {
|
||||
const result = await initVerifiers.getPromptForCommand()
|
||||
expect(result[0].text).toContain('Phase 4')
|
||||
})
|
||||
|
||||
test('text contains Phase 5 confirm creation', async () => {
|
||||
const result = await initVerifiers.getPromptForCommand()
|
||||
expect(result[0].text).toContain('Phase 5')
|
||||
})
|
||||
|
||||
test('text mentions Playwright', async () => {
|
||||
const result = await initVerifiers.getPromptForCommand()
|
||||
expect(result[0].text).toContain('Playwright')
|
||||
})
|
||||
|
||||
test('text mentions SKILL.md template', async () => {
|
||||
const result = await initVerifiers.getPromptForCommand()
|
||||
expect(result[0].text).toContain('SKILL.md')
|
||||
})
|
||||
|
||||
test('text mentions TodoWrite tool', async () => {
|
||||
const result = await initVerifiers.getPromptForCommand()
|
||||
expect(result[0].text).toContain('TodoWrite')
|
||||
})
|
||||
|
||||
test('text mentions verifier naming convention', async () => {
|
||||
const result = await initVerifiers.getPromptForCommand()
|
||||
expect(result[0].text).toContain('verifier')
|
||||
})
|
||||
|
||||
test('text mentions authentication handling', async () => {
|
||||
const result = await initVerifiers.getPromptForCommand()
|
||||
expect(result[0].text).toContain('Authentication')
|
||||
})
|
||||
|
||||
test('text is a non-empty string', async () => {
|
||||
const result = await initVerifiers.getPromptForCommand()
|
||||
expect(typeof result[0].text).toBe('string')
|
||||
expect(result[0].text.length).toBeGreaterThan(100)
|
||||
})
|
||||
|
||||
test('works with no arguments (no args parameter)', async () => {
|
||||
// getPromptForCommand takes no required params
|
||||
const result = await initVerifiers.getPromptForCommand(undefined, undefined)
|
||||
expect(Array.isArray(result)).toBe(true)
|
||||
expect(result.length).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user