test(autofix-pr): 补齐 completionChecker / 边界 CI 检查覆盖率
针对 codecov patch coverage gap, 补足三块此前未走到的代码路径: prOutcomeCheck.ts (原 96.92%, 2 lines missing): - statusCheckRollup === undefined 路径 (与空数组分支不同, GitHub 在无 checks 配置的 PR 上直接省略字段) - COMPLETED 状态但 conclusion 为 null/空 的 in-flight 检查归为 pending launchAutofixPr.ts (原 58.33%, 15 lines missing): - registerCompletionChecker arrow body: metadata 缺失早返回 / 节流窗口内 返回 null / completed=false 返回 null / completed=true 返回 summary / initialHeadSha 透传到 checkPrAutofixOutcome - registerCompletionHook 的 if(meta) 短路两侧: 有 metadata 时清空节流条目, 无 metadata 时仍释放 active monitor lock 所有新测试沿用现有 mock.module 与 registerXxxMock.mock.calls 拉取注册 回调的模式, 无新增依赖。prOutcomeCheck 11/11 本地通过。
This commit is contained in:
parent
ae7f3e2232
commit
fcdf1f2222
|
|
@ -529,6 +529,147 @@ describe('callAutofixPr · Phase 2 completionChecker integration', () => {
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Phase 2 (cont.): exercise the registered completionChecker arrow body
|
||||||
|
// directly. The earlier suite verifies it was registered but never invokes
|
||||||
|
// the arrow itself, leaving the throttle / metadata-guard / gh-CLI dispatch
|
||||||
|
// branches uncovered.
|
||||||
|
describe('callAutofixPr · Phase 2 completionChecker arrow body', () => {
|
||||||
|
// Pull the most recent registered checker — beforeAll registers once at
|
||||||
|
// module load; nothing else re-registers across this file's tests.
|
||||||
|
function getChecker(): (
|
||||||
|
metadata?: unknown,
|
||||||
|
) => Promise<string | null> {
|
||||||
|
const calls = registerCompletionCheckerMock.mock.calls.filter(
|
||||||
|
c => c[0] === 'autofix-pr',
|
||||||
|
)
|
||||||
|
const fn = calls[calls.length - 1]?.[1]
|
||||||
|
if (typeof fn !== 'function') {
|
||||||
|
throw new Error('completionChecker not registered')
|
||||||
|
}
|
||||||
|
return fn
|
||||||
|
}
|
||||||
|
|
||||||
|
test('returns null when metadata is undefined (early guard)', async () => {
|
||||||
|
const checker = getChecker()
|
||||||
|
expect(await checker(undefined)).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('returns null when checkPrAutofixOutcome reports not completed', async () => {
|
||||||
|
const { checkPrAutofixOutcome } = await import('../prFetch.js')
|
||||||
|
;(checkPrAutofixOutcome as ReturnType<typeof mock>).mockImplementationOnce(
|
||||||
|
() => Promise.resolve({ completed: false }),
|
||||||
|
)
|
||||||
|
const checker = getChecker()
|
||||||
|
// Distinct PR number to dodge the in-process throttle map carried over
|
||||||
|
// from earlier tests.
|
||||||
|
const result = await checker({
|
||||||
|
owner: 'acme',
|
||||||
|
repo: 'myrepo',
|
||||||
|
prNumber: 1001,
|
||||||
|
})
|
||||||
|
expect(result).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('returns the summary string when checkPrAutofixOutcome reports completed', async () => {
|
||||||
|
const { checkPrAutofixOutcome } = await import('../prFetch.js')
|
||||||
|
;(checkPrAutofixOutcome as ReturnType<typeof mock>).mockImplementationOnce(
|
||||||
|
() =>
|
||||||
|
Promise.resolve({
|
||||||
|
completed: true,
|
||||||
|
summary: 'acme/myrepo#1002 merged. Autofix monitoring complete.',
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
const checker = getChecker()
|
||||||
|
const result = await checker({
|
||||||
|
owner: 'acme',
|
||||||
|
repo: 'myrepo',
|
||||||
|
prNumber: 1002,
|
||||||
|
})
|
||||||
|
expect(result).toBe(
|
||||||
|
'acme/myrepo#1002 merged. Autofix monitoring complete.',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('passes initialHeadSha through to checkPrAutofixOutcome', async () => {
|
||||||
|
const { checkPrAutofixOutcome } = await import('../prFetch.js')
|
||||||
|
const checkMock = checkPrAutofixOutcome as ReturnType<typeof mock>
|
||||||
|
checkMock.mockClear()
|
||||||
|
checkMock.mockImplementationOnce(() => Promise.resolve({ completed: false }))
|
||||||
|
const checker = getChecker()
|
||||||
|
await checker({
|
||||||
|
owner: 'acme',
|
||||||
|
repo: 'myrepo',
|
||||||
|
prNumber: 1003,
|
||||||
|
initialHeadSha: 'sha-baseline-xyz',
|
||||||
|
})
|
||||||
|
expect(checkMock).toHaveBeenCalledWith({
|
||||||
|
owner: 'acme',
|
||||||
|
repo: 'myrepo',
|
||||||
|
prNumber: 1003,
|
||||||
|
initialHeadSha: 'sha-baseline-xyz',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test('throttles back-to-back calls for the same PR within CHECK_INTERVAL_MS', async () => {
|
||||||
|
const { checkPrAutofixOutcome } = await import('../prFetch.js')
|
||||||
|
const checkMock = checkPrAutofixOutcome as ReturnType<typeof mock>
|
||||||
|
checkMock.mockClear()
|
||||||
|
checkMock.mockImplementation(() => Promise.resolve({ completed: false }))
|
||||||
|
const checker = getChecker()
|
||||||
|
const meta = { owner: 'acme', repo: 'myrepo', prNumber: 1004 }
|
||||||
|
await checker(meta)
|
||||||
|
// Second call within the 5s throttle window must short-circuit to null
|
||||||
|
// without invoking the gh CLI layer again.
|
||||||
|
const callCountAfterFirst = checkMock.mock.calls.length
|
||||||
|
const result = await checker(meta)
|
||||||
|
expect(result).toBeNull()
|
||||||
|
expect(checkMock.mock.calls.length).toBe(callCountAfterFirst)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('completionHook with metadata clears the throttle entry (re-launch can re-check immediately)', async () => {
|
||||||
|
const { checkPrAutofixOutcome } = await import('../prFetch.js')
|
||||||
|
const checkMock = checkPrAutofixOutcome as ReturnType<typeof mock>
|
||||||
|
checkMock.mockClear()
|
||||||
|
checkMock.mockImplementation(() => Promise.resolve({ completed: false }))
|
||||||
|
const checker = getChecker()
|
||||||
|
const meta = { owner: 'acme', repo: 'myrepo', prNumber: 1005 }
|
||||||
|
await checker(meta) // populate throttle map
|
||||||
|
|
||||||
|
// Invoke the registered completion hook with the same metadata so the
|
||||||
|
// throttle entry is wiped, then verify the next checker call dispatches
|
||||||
|
// gh CLI again instead of short-circuiting.
|
||||||
|
const hookCalls = registerCompletionHookMock.mock.calls.filter(
|
||||||
|
c => c[0] === 'autofix-pr',
|
||||||
|
)
|
||||||
|
const hook = hookCalls[hookCalls.length - 1]?.[1] as (
|
||||||
|
id: string,
|
||||||
|
metadata?: unknown,
|
||||||
|
) => void
|
||||||
|
hook('any-task-id', meta)
|
||||||
|
|
||||||
|
const callCountBefore = checkMock.mock.calls.length
|
||||||
|
await checker(meta)
|
||||||
|
expect(checkMock.mock.calls.length).toBe(callCountBefore + 1)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('completionHook without metadata still clears the active monitor lock', async () => {
|
||||||
|
// Lock is set via callAutofixPr; hook then invoked with undefined metadata
|
||||||
|
// to exercise the `if (meta)` short-circuit branch (the lock-clear half
|
||||||
|
// still has to run regardless of metadata presence).
|
||||||
|
await callAutofixPr(onDone, makeContext(), '42')
|
||||||
|
expect(getActiveMonitor()).not.toBeNull()
|
||||||
|
const hookCalls = registerCompletionHookMock.mock.calls.filter(
|
||||||
|
c => c[0] === 'autofix-pr',
|
||||||
|
)
|
||||||
|
const hook = hookCalls[hookCalls.length - 1]?.[1] as (
|
||||||
|
id: string,
|
||||||
|
metadata?: unknown,
|
||||||
|
) => void
|
||||||
|
hook('framework-task-id', undefined)
|
||||||
|
expect(getActiveMonitor()).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
// Phase 3: content extractor wiring + initialMessage tag instruction
|
// Phase 3: content extractor wiring + initialMessage tag instruction
|
||||||
describe('callAutofixPr · Phase 3 content extractor integration', () => {
|
describe('callAutofixPr · Phase 3 content extractor integration', () => {
|
||||||
test('registerContentExtractor is called at module load with autofix-pr type', () => {
|
test('registerContentExtractor is called at module load with autofix-pr type', () => {
|
||||||
|
|
|
||||||
|
|
@ -133,6 +133,41 @@ describe('summariseAutofixOutcome · OPEN PR with push, CI variations', () => {
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('statusCheckRollup undefined → treated as no checks configured (success)', () => {
|
||||||
|
// Distinct from empty-array: GitHub omits the field entirely on PRs
|
||||||
|
// without any configured checks. The !rollup branch covers undefined.
|
||||||
|
const result = summariseAutofixOutcome(
|
||||||
|
basePayload({
|
||||||
|
state: 'OPEN',
|
||||||
|
headRefOid: 'sha-new',
|
||||||
|
statusCheckRollup: undefined,
|
||||||
|
}),
|
||||||
|
identity(),
|
||||||
|
)
|
||||||
|
expect(result.completed).toBe(true)
|
||||||
|
if (result.completed) {
|
||||||
|
expect(result.summary).toContain('CI green')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test('check with COMPLETED status but empty conclusion → counted as pending', () => {
|
||||||
|
// Edge case: GitHub sometimes reports a check as COMPLETED with a null/
|
||||||
|
// missing conclusion (in-flight result mid-write). The defensive branch
|
||||||
|
// treats empty conclusion after a passed status check as pending.
|
||||||
|
const result = summariseAutofixOutcome(
|
||||||
|
basePayload({
|
||||||
|
state: 'OPEN',
|
||||||
|
headRefOid: 'sha-new',
|
||||||
|
statusCheckRollup: [
|
||||||
|
{ status: 'COMPLETED', conclusion: null, name: 'ci-in-flight' },
|
||||||
|
{ status: 'COMPLETED', conclusion: 'SUCCESS', name: 'lint' },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
identity(),
|
||||||
|
)
|
||||||
|
expect(result).toEqual({ completed: false })
|
||||||
|
})
|
||||||
|
|
||||||
test('neutral / skipped conclusions count as success (not failure)', () => {
|
test('neutral / skipped conclusions count as success (not failure)', () => {
|
||||||
const result = summariseAutofixOutcome(
|
const result = summariseAutofixOutcome(
|
||||||
basePayload({
|
basePayload({
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user