mirror of
https://github.com/claude-code-best/claude-code.git
synced 2026-06-23 08:45:50 +00:00
feat: 添加 GitHub 集成命令(issue、share、autofix-pr)
- /issue: 通过 gh CLI 创建 GitHub issue,支持标签/指派 - /share: 会话日志分享到 GitHub Gist,支持密钥脱敏 - /autofix-pr: 自动修复 CI 失败的 PR,进度追踪 - launchCommand: 共享命令启动器 Co-Authored-By: glm-5-turbo <zai-org@claude-code-best.win>
This commit is contained in:
79
src/commands/autofix-pr/__tests__/AutofixProgress.test.tsx
Normal file
79
src/commands/autofix-pr/__tests__/AutofixProgress.test.tsx
Normal file
@@ -0,0 +1,79 @@
|
||||
/**
|
||||
* Tests for AutofixProgress.tsx
|
||||
* Uses src/utils/staticRender to render Ink components to strings.
|
||||
* Covers: all AutofixPhase values + sessionUrl + errorMessage branches.
|
||||
*/
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import * as React from 'react';
|
||||
import { renderToString } from '../../../utils/staticRender.js';
|
||||
import { AutofixProgress } from '../AutofixProgress.js';
|
||||
|
||||
describe('AutofixProgress', () => {
|
||||
test('renders target in header', async () => {
|
||||
const out = await renderToString(<AutofixProgress phase="detecting" target="acme/myrepo#42" />);
|
||||
expect(out).toContain('acme/myrepo#42');
|
||||
expect(out).toContain('Autofix PR');
|
||||
});
|
||||
|
||||
test('detecting phase shows arrow on detecting step', async () => {
|
||||
const out = await renderToString(<AutofixProgress phase="detecting" target="owner/repo#1" />);
|
||||
// detecting step should be active (→) and later steps pending (·)
|
||||
expect(out).toContain('Detecting repository');
|
||||
});
|
||||
|
||||
test('checking_eligibility phase renders eligibility label', async () => {
|
||||
const out = await renderToString(<AutofixProgress phase="checking_eligibility" target="owner/repo#2" />);
|
||||
expect(out).toContain('Checking remote agent eligibility');
|
||||
});
|
||||
|
||||
test('acquiring_lock phase renders lock label', async () => {
|
||||
const out = await renderToString(<AutofixProgress phase="acquiring_lock" target="owner/repo#3" />);
|
||||
expect(out).toContain('Acquiring monitor lock');
|
||||
});
|
||||
|
||||
test('launching phase renders launching label', async () => {
|
||||
const out = await renderToString(<AutofixProgress phase="launching" target="owner/repo#4" />);
|
||||
expect(out).toContain('Launching remote session');
|
||||
});
|
||||
|
||||
test('registered phase renders registered label', async () => {
|
||||
const out = await renderToString(<AutofixProgress phase="registered" target="owner/repo#5" />);
|
||||
expect(out).toContain('Session registered');
|
||||
});
|
||||
|
||||
test('done phase renders done label', async () => {
|
||||
const out = await renderToString(<AutofixProgress phase="done" target="owner/repo#6" />);
|
||||
expect(out).toContain('Autofix launched');
|
||||
});
|
||||
|
||||
test('error phase renders error message when provided', async () => {
|
||||
const out = await renderToString(
|
||||
<AutofixProgress phase="error" target="owner/repo#7" errorMessage="Something went wrong" />,
|
||||
);
|
||||
expect(out).toContain('Something went wrong');
|
||||
});
|
||||
|
||||
test('error phase with errorMessage shows the message', async () => {
|
||||
const out = await renderToString(
|
||||
<AutofixProgress phase="error" target="owner/repo#8" errorMessage="session_create_failed" />,
|
||||
);
|
||||
expect(out).toContain('session_create_failed');
|
||||
});
|
||||
|
||||
test('error phase without errorMessage does not crash', async () => {
|
||||
const out = await renderToString(<AutofixProgress phase="error" target="owner/repo#9" />);
|
||||
expect(out).toContain('owner/repo#9');
|
||||
});
|
||||
|
||||
test('sessionUrl is rendered when provided', async () => {
|
||||
const url = 'https://claude.ai/session/abc123';
|
||||
const out = await renderToString(<AutofixProgress phase="done" target="owner/repo#10" sessionUrl={url} />);
|
||||
expect(out).toContain(url);
|
||||
expect(out).toContain('Track');
|
||||
});
|
||||
|
||||
test('sessionUrl absent — no Track line shown', async () => {
|
||||
const out = await renderToString(<AutofixProgress phase="registered" target="owner/repo#11" />);
|
||||
expect(out).not.toContain('Track');
|
||||
});
|
||||
});
|
||||
74
src/commands/autofix-pr/__tests__/index.test.ts
Normal file
74
src/commands/autofix-pr/__tests__/index.test.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import { beforeAll, describe, expect, mock, test } from 'bun:test'
|
||||
|
||||
// Must mock bun:bundle before importing index
|
||||
mock.module('bun:bundle', () => ({
|
||||
feature: (_name: string) => true,
|
||||
}))
|
||||
|
||||
let cmd: {
|
||||
isEnabled?: () => boolean
|
||||
getBridgeInvocationError?: (args: string) => string | undefined
|
||||
load?: () => Promise<unknown>
|
||||
}
|
||||
let getBridgeInvocationError: ((args: string) => string | undefined) | undefined
|
||||
|
||||
beforeAll(async () => {
|
||||
const mod = await import('../index.js')
|
||||
cmd = mod.default as typeof cmd
|
||||
getBridgeInvocationError = cmd.getBridgeInvocationError
|
||||
})
|
||||
|
||||
describe('autofixPr isEnabled', () => {
|
||||
test('isEnabled returns a boolean', () => {
|
||||
// In Bun test environment, feature() from bun:bundle is a compile-time macro.
|
||||
// The mock.module('bun:bundle') intercept is used to allow the import to
|
||||
// succeed, but the actual macro value is resolved at build time (not runtime).
|
||||
// In the test runner (non-bundle mode) feature() returns false.
|
||||
// We just verify the function is callable and returns a boolean.
|
||||
const result = cmd.isEnabled?.()
|
||||
expect(typeof result).toBe('boolean')
|
||||
})
|
||||
})
|
||||
|
||||
describe('autofixPr load', () => {
|
||||
test('load function exists on the command', () => {
|
||||
// Just verify load is a function (don't call it — calling it imports
|
||||
// launchAutofixPr.js which would set process-level mocks interfering
|
||||
// with launchAutofixPr.test.ts)
|
||||
expect(typeof cmd.load).toBe('function')
|
||||
})
|
||||
})
|
||||
|
||||
describe('autofixPr getBridgeInvocationError', () => {
|
||||
test('empty string returns error', () => {
|
||||
const err = getBridgeInvocationError?.('')
|
||||
expect(err).toBe('PR number required, e.g. /autofix-pr 386')
|
||||
})
|
||||
|
||||
test('"stop" returns undefined (no error)', () => {
|
||||
expect(getBridgeInvocationError?.('stop')).toBeUndefined()
|
||||
})
|
||||
|
||||
test('"off" returns undefined (no error)', () => {
|
||||
expect(getBridgeInvocationError?.('off')).toBeUndefined()
|
||||
})
|
||||
|
||||
test('digit-only returns undefined (no error)', () => {
|
||||
expect(getBridgeInvocationError?.('386')).toBeUndefined()
|
||||
})
|
||||
|
||||
test('cross-repo syntax returns undefined (no error)', () => {
|
||||
expect(
|
||||
getBridgeInvocationError?.('anthropics/claude-code#999'),
|
||||
).toBeUndefined()
|
||||
})
|
||||
|
||||
test('invalid args returns error string', () => {
|
||||
const err = getBridgeInvocationError?.('not valid!!')
|
||||
expect(err).toMatch(/Invalid args/)
|
||||
})
|
||||
|
||||
test('load is defined as an async function', () => {
|
||||
expect(typeof cmd.load).toBe('function')
|
||||
})
|
||||
})
|
||||
392
src/commands/autofix-pr/__tests__/launchAutofixPr.test.ts
Normal file
392
src/commands/autofix-pr/__tests__/launchAutofixPr.test.ts
Normal file
@@ -0,0 +1,392 @@
|
||||
import {
|
||||
afterEach,
|
||||
beforeAll,
|
||||
beforeEach,
|
||||
describe,
|
||||
expect,
|
||||
mock,
|
||||
test,
|
||||
} from 'bun:test'
|
||||
import type { LocalJSXCommandCall } from '../../../types/command.js'
|
||||
import { debugMock } from '../../../../tests/mocks/debug.js'
|
||||
import { logMock } from '../../../../tests/mocks/log.js'
|
||||
|
||||
// ── Mock module-level side effects before any imports ──
|
||||
mock.module('src/utils/log.ts', logMock)
|
||||
mock.module('src/utils/debug.ts', debugMock)
|
||||
mock.module('bun:bundle', () => ({
|
||||
feature: (_name: string) => true,
|
||||
}))
|
||||
|
||||
// ── Core dependencies ──
|
||||
type TeleportResult = { id: string; title: string } | null
|
||||
const teleportMock = mock(
|
||||
(): Promise<TeleportResult> =>
|
||||
Promise.resolve({ id: 'session-123', title: 'Autofix PR: acme/myrepo#42' }),
|
||||
)
|
||||
mock.module('src/utils/teleport.js', () => ({
|
||||
teleportToRemote: teleportMock,
|
||||
// Stubs for other exports — Bun mock-module is process-level, so when
|
||||
// run combined with teleport-command tests these would otherwise leak as
|
||||
// undefined and crash. Keep here in sync with utils/teleport.tsx exports
|
||||
// that any other test in this process might import transitively.
|
||||
teleportResumeCodeSession: mock(() =>
|
||||
Promise.resolve({ branch: null, messages: [], error: null }),
|
||||
),
|
||||
validateGitState: mock(() => Promise.resolve()),
|
||||
validateSessionRepository: mock(() => Promise.resolve({ ok: true })),
|
||||
checkOutTeleportedSessionBranch: mock(() =>
|
||||
Promise.resolve({ branchName: 'main', branchError: null }),
|
||||
),
|
||||
processMessagesForTeleportResume: mock((m: unknown[]) => m),
|
||||
teleportFromSessionsAPI: mock(() =>
|
||||
Promise.resolve({ branch: null, messages: [], error: null }),
|
||||
),
|
||||
teleportToRemoteWithErrorHandling: mock(() => Promise.resolve(null)),
|
||||
}))
|
||||
|
||||
const registerMock = mock(() => ({
|
||||
taskId: 'task-abc',
|
||||
sessionId: 'session-123',
|
||||
cleanup: () => {},
|
||||
}))
|
||||
const checkEligibilityMock = mock(() =>
|
||||
Promise.resolve({ eligible: true as const }),
|
||||
)
|
||||
const getSessionUrlMock = mock(
|
||||
(id: string) => `https://claude.ai/session/${id}`,
|
||||
)
|
||||
|
||||
mock.module('src/tasks/RemoteAgentTask/RemoteAgentTask.js', () => ({
|
||||
checkRemoteAgentEligibility: checkEligibilityMock,
|
||||
registerRemoteAgentTask: registerMock,
|
||||
getRemoteTaskSessionUrl: getSessionUrlMock,
|
||||
formatPreconditionError: (e: { type: string }) => e.type,
|
||||
}))
|
||||
|
||||
const detectRepoMock = mock(() =>
|
||||
Promise.resolve({ host: 'github.com', owner: 'acme', name: 'myrepo' }),
|
||||
)
|
||||
mock.module('src/utils/detectRepository.js', () => ({
|
||||
detectCurrentRepositoryWithHost: detectRepoMock,
|
||||
}))
|
||||
|
||||
const logEventMock = mock(() => {})
|
||||
mock.module('src/services/analytics/index.js', () => ({
|
||||
logEvent: logEventMock,
|
||||
logEventAsync: mock(() => Promise.resolve()),
|
||||
_resetForTesting: mock(() => {}),
|
||||
attachAnalyticsSink: mock(() => {}),
|
||||
stripProtoFields: mock((v: unknown) => v),
|
||||
}))
|
||||
|
||||
const noop = () => {}
|
||||
mock.module('src/bootstrap/state.js', () => ({
|
||||
getSessionId: () => 'parent-session-id',
|
||||
getParentSessionId: () => undefined,
|
||||
// Additional exports needed by transitive imports (e.g. cwd.ts, sandbox-adapter.ts)
|
||||
getCwdState: () => '/mock/cwd',
|
||||
getOriginalCwd: () => '/mock/cwd',
|
||||
getSessionProjectDir: () => null,
|
||||
getProjectRoot: () => '/mock/project',
|
||||
setCwdState: noop,
|
||||
setOriginalCwd: noop,
|
||||
setLastAPIRequestMessages: noop,
|
||||
getIsNonInteractiveSession: () => false,
|
||||
addSlowOperation: noop,
|
||||
}))
|
||||
|
||||
// Mock skillDetect so initialMessage is deterministic across CI environments
|
||||
// (real existsSync would depend on .claude/skills/* in the working dir).
|
||||
mock.module('src/commands/autofix-pr/skillDetect.js', () => ({
|
||||
detectAutofixSkills: () => [] as string[],
|
||||
formatSkillsHint: () => '',
|
||||
}))
|
||||
|
||||
// ── Import SUT after mocks ──
|
||||
let callAutofixPr: LocalJSXCommandCall
|
||||
let clearActiveMonitor: () => void
|
||||
let getActiveMonitor: () => unknown
|
||||
|
||||
beforeAll(async () => {
|
||||
const sut = await import('../launchAutofixPr.js')
|
||||
callAutofixPr = sut.callAutofixPr
|
||||
const state = await import('../monitorState.js')
|
||||
clearActiveMonitor = state.clearActiveMonitor
|
||||
getActiveMonitor = state.getActiveMonitor
|
||||
})
|
||||
|
||||
// Helper context
|
||||
function makeContext() {
|
||||
return { abortController: new AbortController() } as Parameters<
|
||||
typeof callAutofixPr
|
||||
>[1]
|
||||
}
|
||||
|
||||
const onDone = mock((_result?: string, _opts?: unknown) => {})
|
||||
|
||||
beforeEach(() => {
|
||||
teleportMock.mockClear()
|
||||
registerMock.mockClear()
|
||||
detectRepoMock.mockClear()
|
||||
checkEligibilityMock.mockClear()
|
||||
logEventMock.mockClear()
|
||||
onDone.mockClear()
|
||||
clearActiveMonitor()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
clearActiveMonitor()
|
||||
})
|
||||
|
||||
describe('callAutofixPr', () => {
|
||||
test('start with PR number teleports with correct args', async () => {
|
||||
await callAutofixPr(onDone, makeContext(), '42')
|
||||
expect(teleportMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
source: 'autofix_pr',
|
||||
useDefaultEnvironment: true,
|
||||
githubPr: { owner: 'acme', repo: 'myrepo', number: 42 },
|
||||
branchName: 'refs/pull/42/head',
|
||||
skipBundle: true,
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
test('teleport call does NOT pass reuseOutcomeBranch (refs/pull/*/head is not pushable)', async () => {
|
||||
await callAutofixPr(onDone, makeContext(), '42')
|
||||
expect(teleportMock).toHaveBeenCalled()
|
||||
expect(teleportMock).not.toHaveBeenCalledWith(
|
||||
expect.objectContaining({ reuseOutcomeBranch: expect.anything() }),
|
||||
)
|
||||
})
|
||||
|
||||
test('start registers remote agent task with correct type', async () => {
|
||||
await callAutofixPr(onDone, makeContext(), '42')
|
||||
expect(registerMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
remoteTaskType: 'autofix-pr',
|
||||
isLongRunning: true,
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
test('cross-repo syntax matching cwd repo is accepted', async () => {
|
||||
// detectRepo mock returns acme/myrepo by default — pass a matching
|
||||
// cross-repo arg and verify teleport is called normally.
|
||||
await callAutofixPr(onDone, makeContext(), 'acme/myrepo#999')
|
||||
expect(teleportMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
githubPr: { owner: 'acme', repo: 'myrepo', number: 999 },
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
test('cross-repo syntax NOT matching cwd repo is rejected with repo_mismatch', async () => {
|
||||
// detectRepo mock returns acme/myrepo; pass a mismatching cross-repo arg.
|
||||
await callAutofixPr(onDone, makeContext(), 'anthropics/claude-code#999')
|
||||
expect(teleportMock).not.toHaveBeenCalled()
|
||||
const firstArg = onDone.mock.calls[0]?.[0] as string
|
||||
expect(firstArg).toMatch(/Cross-repo autofix is not supported/)
|
||||
})
|
||||
|
||||
test('singleton lock blocks second start for different PR', async () => {
|
||||
await callAutofixPr(onDone, makeContext(), '42')
|
||||
onDone.mockClear()
|
||||
await callAutofixPr(onDone, makeContext(), '99')
|
||||
const firstArg = onDone.mock.calls[0]?.[0] as string
|
||||
expect(firstArg).toMatch(/already monitoring/)
|
||||
expect(firstArg).toMatch(/Run \/autofix-pr stop first/)
|
||||
})
|
||||
|
||||
test('same PR number while monitoring returns already monitoring message', async () => {
|
||||
await callAutofixPr(onDone, makeContext(), '42')
|
||||
onDone.mockClear()
|
||||
await callAutofixPr(onDone, makeContext(), '42')
|
||||
const firstArg = onDone.mock.calls[0]?.[0] as string
|
||||
expect(firstArg).toMatch(/Already monitoring/)
|
||||
})
|
||||
|
||||
test('stop sub-command clears monitor and calls onDone', async () => {
|
||||
await callAutofixPr(onDone, makeContext(), '42')
|
||||
onDone.mockClear()
|
||||
await callAutofixPr(onDone, makeContext(), 'stop')
|
||||
expect(getActiveMonitor()).toBeNull()
|
||||
const firstArg = onDone.mock.calls[0]?.[0] as string
|
||||
expect(firstArg).toMatch(/Stopped local monitoring/)
|
||||
})
|
||||
|
||||
test('stop with no active monitor reports no active monitor', async () => {
|
||||
await callAutofixPr(onDone, makeContext(), 'stop')
|
||||
const firstArg = onDone.mock.calls[0]?.[0] as string
|
||||
expect(firstArg).toMatch(/No active autofix monitor/)
|
||||
})
|
||||
|
||||
test('freeform prompt returns not supported message', async () => {
|
||||
await callAutofixPr(onDone, makeContext(), 'please fix the failing test')
|
||||
const firstArg = onDone.mock.calls[0]?.[0] as string
|
||||
expect(firstArg).toMatch(/not yet supported/)
|
||||
})
|
||||
|
||||
test('teleport failure calls onDone with error', async () => {
|
||||
teleportMock.mockImplementationOnce(() => Promise.resolve(null))
|
||||
await callAutofixPr(onDone, makeContext(), '42')
|
||||
const firstArg = onDone.mock.calls[0]?.[0] as string
|
||||
expect(firstArg).toMatch(/Autofix PR failed/)
|
||||
expect(logEventMock).toHaveBeenCalledWith(
|
||||
'tengu_autofix_pr_result',
|
||||
expect.objectContaining({
|
||||
result: 'failed',
|
||||
error_code: 'session_create_failed',
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
test('repo not on github.com calls onDone with error', async () => {
|
||||
detectRepoMock.mockImplementationOnce(() =>
|
||||
Promise.resolve({ host: 'bitbucket.org', owner: 'acme', name: 'myrepo' }),
|
||||
)
|
||||
await callAutofixPr(onDone, makeContext(), '42')
|
||||
const firstArg = onDone.mock.calls[0]?.[0] as string
|
||||
expect(firstArg).toMatch(/Autofix PR failed/)
|
||||
})
|
||||
|
||||
test('eligibility check blocks non-no_remote_environment errors', async () => {
|
||||
checkEligibilityMock.mockImplementationOnce(() =>
|
||||
Promise.resolve({
|
||||
eligible: false,
|
||||
errors: [{ type: 'not_authenticated' }],
|
||||
} as unknown as { eligible: true }),
|
||||
)
|
||||
await callAutofixPr(onDone, makeContext(), '42')
|
||||
const firstArg = onDone.mock.calls[0]?.[0] as string
|
||||
expect(firstArg).toMatch(/Autofix PR failed/)
|
||||
expect(teleportMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
test('invalid args → invalid action message (lines 72-78)', async () => {
|
||||
// parseAutofixArgs('') returns { action: 'invalid', reason: 'empty' }
|
||||
await callAutofixPr(onDone, makeContext(), '')
|
||||
const firstArg = onDone.mock.calls[0]?.[0] as string
|
||||
expect(firstArg).toMatch(/Invalid args/)
|
||||
expect(teleportMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
test('cross-repo with pr_number_out_of_range → invalid action (lines 72-78)', async () => {
|
||||
// parsePrNumber('0') returns null → invalid action
|
||||
await callAutofixPr(onDone, makeContext(), 'acme/myrepo#0')
|
||||
const firstArg = onDone.mock.calls[0]?.[0] as string
|
||||
expect(firstArg).toMatch(/Invalid args/)
|
||||
})
|
||||
|
||||
test('detectCurrentRepositoryWithHost throws → session_create_failed (lines 70-76)', async () => {
|
||||
detectRepoMock.mockImplementationOnce(() =>
|
||||
Promise.reject(new Error('git error: not a repository')),
|
||||
)
|
||||
await callAutofixPr(onDone, makeContext(), '42')
|
||||
const firstArg = onDone.mock.calls[0]?.[0] as string
|
||||
expect(firstArg).toMatch(/Autofix PR failed/)
|
||||
expect(teleportMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
test('detectCurrentRepositoryWithHost returns null → session_create_failed (lines 108-115)', async () => {
|
||||
detectRepoMock.mockImplementationOnce(() =>
|
||||
Promise.resolve(
|
||||
null as unknown as { host: string; owner: string; name: string },
|
||||
),
|
||||
)
|
||||
await callAutofixPr(onDone, makeContext(), '42')
|
||||
const firstArg = onDone.mock.calls[0]?.[0] as string
|
||||
expect(firstArg).toMatch(/Autofix PR failed/)
|
||||
expect(firstArg).toMatch(/Cannot detect GitHub repo/)
|
||||
expect(teleportMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
test('teleportToRemote throws → teleport_failed error (lines 253-259)', async () => {
|
||||
teleportMock.mockImplementationOnce(() =>
|
||||
Promise.reject(new Error('network timeout')),
|
||||
)
|
||||
await callAutofixPr(onDone, makeContext(), '42')
|
||||
const firstArg = onDone.mock.calls[0]?.[0] as string
|
||||
expect(firstArg).toMatch(/Autofix PR failed/)
|
||||
expect(firstArg).toMatch(/teleport failed/)
|
||||
// Lock must be released
|
||||
const { getActiveMonitor } = await import('../monitorState.js')
|
||||
expect(getActiveMonitor()).toBeNull()
|
||||
})
|
||||
|
||||
test('registerRemoteAgentTask throws → registration_failed error (lines 287-296)', async () => {
|
||||
registerMock.mockImplementationOnce(() => {
|
||||
throw new Error('registration error: session limit exceeded')
|
||||
})
|
||||
await callAutofixPr(onDone, makeContext(), '42')
|
||||
const firstArg = onDone.mock.calls[0]?.[0] as string
|
||||
expect(firstArg).toMatch(/Autofix PR failed/)
|
||||
expect(firstArg).toMatch(/task registration failed/)
|
||||
// Lock must be released
|
||||
const { getActiveMonitor } = await import('../monitorState.js')
|
||||
expect(getActiveMonitor()).toBeNull()
|
||||
})
|
||||
|
||||
test('outer catch: checkRemoteAgentEligibility throws → outer catch (lines 315-323)', async () => {
|
||||
// checkRemoteAgentEligibility is awaited without an inner try/catch.
|
||||
// If it throws, the error bubbles to the outermost catch at lines 315-323.
|
||||
checkEligibilityMock.mockImplementationOnce(() =>
|
||||
Promise.reject(new Error('unexpected eligibility check error')),
|
||||
)
|
||||
await callAutofixPr(onDone, makeContext(), '42')
|
||||
const firstArg = onDone.mock.calls[0]?.[0] as string
|
||||
expect(firstArg).toMatch(/Autofix PR failed/)
|
||||
expect(logEventMock).toHaveBeenCalledWith(
|
||||
'tengu_autofix_pr_result',
|
||||
expect.objectContaining({ error_code: 'exception' }),
|
||||
)
|
||||
})
|
||||
|
||||
test('captureFailMsg called via onBundleFail when teleport returns null (line 237)', async () => {
|
||||
// When teleportToRemote calls onBundleFail before returning null,
|
||||
// captureFailMsg captures the message and it's used in the !session branch.
|
||||
teleportMock.mockImplementationOnce(
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
((opts: any) => {
|
||||
opts?.onBundleFail?.('bundle creation failed: disk full')
|
||||
return Promise.resolve(null)
|
||||
}) as unknown as Parameters<
|
||||
typeof teleportMock.mockImplementationOnce
|
||||
>[0],
|
||||
)
|
||||
await callAutofixPr(onDone, makeContext(), '42')
|
||||
const firstArg = onDone.mock.calls[0]?.[0] as string
|
||||
expect(firstArg).toMatch(/Autofix PR failed/)
|
||||
// The captured message should appear in the error
|
||||
expect(firstArg).toMatch(/bundle creation failed/)
|
||||
})
|
||||
|
||||
test('eligibility check passes through no_remote_environment error', async () => {
|
||||
checkEligibilityMock.mockImplementationOnce(() =>
|
||||
Promise.resolve({
|
||||
eligible: false,
|
||||
errors: [{ type: 'no_remote_environment' }],
|
||||
} as unknown as { eligible: true }),
|
||||
)
|
||||
await callAutofixPr(onDone, makeContext(), '42')
|
||||
// Should still proceed — no_remote_environment is tolerated
|
||||
expect(teleportMock).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
// Cover ../index.ts load() — placed in this test file so all the heavy mocks
|
||||
// (teleport / detectRepository / RemoteAgentTask / bootstrap-state / analytics /
|
||||
// skillDetect) are already registered when load() dynamically imports
|
||||
// launchAutofixPr.js. Doing this in autofix-pr/__tests__/index.test.ts would
|
||||
// pollute this file's mocks via cross-file ESM symbol binding.
|
||||
describe('autofix-pr/index.ts load()', () => {
|
||||
test('load() resolves and exposes call function', async () => {
|
||||
const { default: cmd } = await import('../index.js')
|
||||
const loaded = await (
|
||||
cmd as unknown as { load: () => Promise<{ call: unknown }> }
|
||||
).load()
|
||||
expect(loaded.call).toBeDefined()
|
||||
expect(typeof loaded.call).toBe('function')
|
||||
})
|
||||
})
|
||||
79
src/commands/autofix-pr/__tests__/monitorState.test.ts
Normal file
79
src/commands/autofix-pr/__tests__/monitorState.test.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import { beforeEach, describe, expect, test } from 'bun:test'
|
||||
import {
|
||||
clearActiveMonitor,
|
||||
getActiveMonitor,
|
||||
isMonitoring,
|
||||
setActiveMonitor,
|
||||
trySetActiveMonitor,
|
||||
} from '../monitorState.js'
|
||||
|
||||
function makeState(
|
||||
overrides?: Partial<Parameters<typeof setActiveMonitor>[0]>,
|
||||
) {
|
||||
return {
|
||||
taskId: 'task-1',
|
||||
owner: 'acme',
|
||||
repo: 'myrepo',
|
||||
prNumber: 42,
|
||||
abortController: new AbortController(),
|
||||
startedAt: Date.now(),
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
describe('monitorState', () => {
|
||||
beforeEach(() => {
|
||||
clearActiveMonitor()
|
||||
})
|
||||
|
||||
test('getActiveMonitor returns null when nothing set', () => {
|
||||
expect(getActiveMonitor()).toBeNull()
|
||||
})
|
||||
|
||||
test('setActiveMonitor stores state and getActiveMonitor returns it', () => {
|
||||
const state = makeState()
|
||||
setActiveMonitor(state)
|
||||
expect(getActiveMonitor()).toBe(state)
|
||||
})
|
||||
|
||||
test('clearActiveMonitor resets state to null', () => {
|
||||
setActiveMonitor(makeState())
|
||||
clearActiveMonitor()
|
||||
expect(getActiveMonitor()).toBeNull()
|
||||
})
|
||||
|
||||
test('isMonitoring returns true for matching owner/repo/prNumber', () => {
|
||||
setActiveMonitor(makeState())
|
||||
expect(isMonitoring('acme', 'myrepo', 42)).toBe(true)
|
||||
})
|
||||
|
||||
test('isMonitoring returns false when not monitoring', () => {
|
||||
expect(isMonitoring('acme', 'myrepo', 42)).toBe(false)
|
||||
})
|
||||
|
||||
test('setActiveMonitor throws when already active', () => {
|
||||
setActiveMonitor(makeState())
|
||||
expect(() => setActiveMonitor(makeState({ prNumber: 99 }))).toThrow(
|
||||
/Monitor already active/,
|
||||
)
|
||||
})
|
||||
|
||||
test('clearActiveMonitor calls abort on the controller', () => {
|
||||
const abortController = new AbortController()
|
||||
setActiveMonitor(makeState({ abortController }))
|
||||
clearActiveMonitor()
|
||||
expect(abortController.signal.aborted).toBe(true)
|
||||
})
|
||||
|
||||
test('trySetActiveMonitor returns true when no active monitor', () => {
|
||||
expect(trySetActiveMonitor(makeState())).toBe(true)
|
||||
expect(getActiveMonitor()).not.toBeNull()
|
||||
})
|
||||
|
||||
test('trySetActiveMonitor returns false when monitor already active', () => {
|
||||
expect(trySetActiveMonitor(makeState({ prNumber: 1 }))).toBe(true)
|
||||
expect(trySetActiveMonitor(makeState({ prNumber: 2 }))).toBe(false)
|
||||
// First state remains
|
||||
expect(getActiveMonitor()?.prNumber).toBe(1)
|
||||
})
|
||||
})
|
||||
63
src/commands/autofix-pr/__tests__/parseArgs.test.ts
Normal file
63
src/commands/autofix-pr/__tests__/parseArgs.test.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
import { parseAutofixArgs } from '../parseArgs.js'
|
||||
|
||||
describe('parseAutofixArgs', () => {
|
||||
test('empty string returns invalid', () => {
|
||||
expect(parseAutofixArgs('')).toEqual({ action: 'invalid', reason: 'empty' })
|
||||
})
|
||||
|
||||
test('whitespace-only returns invalid', () => {
|
||||
expect(parseAutofixArgs(' ')).toEqual({
|
||||
action: 'invalid',
|
||||
reason: 'empty',
|
||||
})
|
||||
})
|
||||
|
||||
test('"stop" returns stop action', () => {
|
||||
expect(parseAutofixArgs('stop')).toEqual({ action: 'stop' })
|
||||
})
|
||||
|
||||
test('"off" returns stop action', () => {
|
||||
expect(parseAutofixArgs('off')).toEqual({ action: 'stop' })
|
||||
})
|
||||
|
||||
test('"stop" with surrounding whitespace returns stop action', () => {
|
||||
expect(parseAutofixArgs(' stop ')).toEqual({ action: 'stop' })
|
||||
})
|
||||
|
||||
test('digit-only string returns start with prNumber', () => {
|
||||
expect(parseAutofixArgs('386')).toEqual({ action: 'start', prNumber: 386 })
|
||||
})
|
||||
|
||||
test('cross-repo owner/repo#n returns start with owner/repo/prNumber', () => {
|
||||
expect(parseAutofixArgs('anthropics/claude-code#999')).toEqual({
|
||||
action: 'start',
|
||||
owner: 'anthropics',
|
||||
repo: 'claude-code',
|
||||
prNumber: 999,
|
||||
})
|
||||
})
|
||||
|
||||
test('cross-repo with dots in owner/repo', () => {
|
||||
expect(parseAutofixArgs('my.org/my.repo#42')).toEqual({
|
||||
action: 'start',
|
||||
owner: 'my.org',
|
||||
repo: 'my.repo',
|
||||
prNumber: 42,
|
||||
})
|
||||
})
|
||||
|
||||
test('freeform text returns freeform action', () => {
|
||||
expect(parseAutofixArgs('fix the CI please')).toEqual({
|
||||
action: 'freeform',
|
||||
prompt: 'fix the CI please',
|
||||
})
|
||||
})
|
||||
|
||||
test('invalid pattern (no hash) returns freeform', () => {
|
||||
expect(parseAutofixArgs('owner/repo')).toEqual({
|
||||
action: 'freeform',
|
||||
prompt: 'owner/repo',
|
||||
})
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user