Fix stale test mocks after runner migration (#516)

This commit is contained in:
Neil 2026-04-11 22:47:30 -07:00 committed by GitHub
parent b84a05bf69
commit e5890f8e3f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 238 additions and 269 deletions

View File

@ -1,44 +1,16 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { execFileMock, execFileSyncMock } = vi.hoisted(() => ({
execFileMock: vi.fn(),
execFileSyncMock: vi.fn()
const { gitExecFileAsyncMock, gitExecFileSyncMock } = vi.hoisted(() => ({
gitExecFileAsyncMock: vi.fn(),
gitExecFileSyncMock: vi.fn()
}))
vi.mock('child_process', () => ({
execFile: execFileMock,
execFileSync: execFileSyncMock,
// runner.ts imports spawn from child_process; stub prevents
// "missing export" errors when the mock is resolved transitively.
spawn: vi.fn()
vi.mock('./runner', () => ({
gitExecFileAsync: gitExecFileAsyncMock,
gitExecFileSync: gitExecFileSyncMock,
translateWslOutputPaths: (output: string) => output
}))
// Why: runner.ts uses promisify(execFile). The default promisify of a test
// mock doesn't return { stdout, stderr } because the mock lacks Node's
// util.promisify.custom symbol. Return a wrapper that invokes the callback-
// style execFileMock and shapes the result correctly.
vi.mock('util', async () => {
const actual = await vi.importActual('util')
return {
...actual,
promisify: vi.fn(() =>
(...args: unknown[]) =>
new Promise((resolve, reject) => {
execFileMock(
...args,
(error: Error | null, stdout: string, stderr: string) => {
if (error) {
reject(Object.assign(error, { stdout, stderr }))
return
}
resolve({ stdout, stderr })
}
)
})
)
}
})
import { removeWorktree } from './worktree'
type MockResult = {
@ -49,35 +21,28 @@ type MockResult = {
function mockGitCommands(results: Record<string, MockResult>): void {
const callCounts = new Map<string, number>()
execFileMock.mockImplementation(
(
file: string,
args: string[],
options: { cwd: string; encoding: string } | ((...params: unknown[]) => void),
callback?: (...params: unknown[]) => void
) => {
const resolvedCallback = typeof options === 'function' ? options : callback
const key = `${file} ${args.join(' ')}`
const callCount = (callCounts.get(key) ?? 0) + 1
callCounts.set(key, callCount)
const result = results[`${key}#${callCount}`] ?? results[key] ?? {}
gitExecFileAsyncMock.mockImplementation((args: string[]) => {
const key = `git ${args.join(' ')}`
const callCount = (callCounts.get(key) ?? 0) + 1
callCounts.set(key, callCount)
const result = results[`${key}#${callCount}`] ?? results[key] ?? {}
if (result.error) {
const error = Object.assign(result.error, {
stdout: result.stdout ?? '',
stderr: result.stderr ?? ''
})
resolvedCallback?.(error, result.stdout ?? '', result.stderr ?? '')
return
}
resolvedCallback?.(null, result.stdout ?? '', result.stderr ?? '')
if (result.error) {
throw Object.assign(result.error, {
stdout: result.stdout ?? '',
stderr: result.stderr ?? ''
})
}
)
return {
stdout: result.stdout ?? '',
stderr: result.stderr ?? ''
}
})
}
function getGitCalls(): string[] {
return execFileMock.mock.calls.map((call) => `${call[0]} ${call[1].join(' ')}`)
return gitExecFileAsyncMock.mock.calls.map((call) => `git ${call[0].join(' ')}`)
}
function expectGitCallOrder(calls: string[], beforeCall: string, afterCall: string): void {
@ -87,8 +52,8 @@ function expectGitCallOrder(calls: string[], beforeCall: string, afterCall: stri
describe('removeWorktree', () => {
beforeEach(() => {
execFileMock.mockReset()
execFileSyncMock.mockReset()
gitExecFileAsyncMock.mockReset()
gitExecFileSyncMock.mockReset()
})
it('removes the worktree, prunes stale refs, and deletes its local branch', async () => {

View File

@ -2,20 +2,19 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import path from 'path'
const { execFileAsyncMock, readFileMock, rmMock, existsSyncMock } = vi.hoisted(() => ({
execFileAsyncMock: vi.fn(),
readFileMock: vi.fn(),
rmMock: vi.fn(),
existsSyncMock: vi.fn()
}))
const { gitExecFileAsyncMock, gitExecFileAsyncBufferMock, readFileMock, rmMock, existsSyncMock } =
vi.hoisted(() => ({
gitExecFileAsyncMock: vi.fn(),
gitExecFileAsyncBufferMock: vi.fn(),
readFileMock: vi.fn(),
rmMock: vi.fn(),
existsSyncMock: vi.fn()
}))
vi.mock('util', async () => {
const actual = await vi.importActual('util')
return {
...actual,
promisify: vi.fn(() => execFileAsyncMock)
}
})
vi.mock('./runner', () => ({
gitExecFileAsync: gitExecFileAsyncMock,
gitExecFileAsyncBuffer: gitExecFileAsyncBufferMock
}))
vi.mock('fs/promises', () => ({
readFile: readFileMock,
@ -39,44 +38,41 @@ import {
describe('discardChanges', () => {
beforeEach(() => {
execFileAsyncMock.mockReset()
gitExecFileAsyncMock.mockReset()
gitExecFileAsyncBufferMock.mockReset()
readFileMock.mockReset()
rmMock.mockReset()
})
it('restores tracked files from HEAD', async () => {
execFileAsyncMock.mockResolvedValueOnce({ stdout: 'src/file.ts\n' })
execFileAsyncMock.mockResolvedValueOnce({ stdout: '' })
gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: 'src/file.ts\n' })
gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: '' })
await discardChanges('/repo', 'src/file.ts')
expect(execFileAsyncMock).toHaveBeenNthCalledWith(
expect(gitExecFileAsyncMock).toHaveBeenNthCalledWith(
1,
'git',
['ls-files', '--error-unmatch', '--', 'src/file.ts'],
{
cwd: '/repo',
encoding: 'utf-8'
cwd: '/repo'
}
)
expect(execFileAsyncMock).toHaveBeenNthCalledWith(
expect(gitExecFileAsyncMock).toHaveBeenNthCalledWith(
2,
'git',
['restore', '--worktree', '--source=HEAD', '--', 'src/file.ts'],
{
cwd: '/repo',
encoding: 'utf-8'
cwd: '/repo'
}
)
expect(rmMock).not.toHaveBeenCalled()
})
it('removes untracked files from disk', async () => {
execFileAsyncMock.mockRejectedValueOnce(new Error('not tracked'))
gitExecFileAsyncMock.mockRejectedValueOnce(new Error('not tracked'))
await discardChanges('/repo', 'src/new-file.ts')
expect(execFileAsyncMock).toHaveBeenCalledTimes(1)
expect(gitExecFileAsyncMock).toHaveBeenCalledTimes(1)
// Why: discardChanges uses path.resolve(worktreePath, filePath) to build
// the absolute rm target, which on Windows prepends a drive letter.
expect(rmMock).toHaveBeenCalledWith(path.resolve('/repo', 'src', 'new-file.ts'), {
@ -90,7 +86,7 @@ describe('discardChanges', () => {
'resolves outside the worktree'
)
expect(execFileAsyncMock).not.toHaveBeenCalled()
expect(gitExecFileAsyncMock).not.toHaveBeenCalled()
expect(rmMock).not.toHaveBeenCalled()
})
@ -101,50 +97,44 @@ describe('discardChanges', () => {
describe('bulk git helpers', () => {
beforeEach(() => {
execFileAsyncMock.mockReset()
gitExecFileAsyncMock.mockReset()
})
it('chunks bulk stage requests to avoid oversized argv payloads', async () => {
execFileAsyncMock.mockResolvedValue({ stdout: '' })
gitExecFileAsyncMock.mockResolvedValue({ stdout: '' })
const filePaths = Array.from({ length: 201 }, (_, i) => `src/file-${i}.ts`)
await bulkStageFiles('/repo', filePaths)
expect(execFileAsyncMock).toHaveBeenCalledTimes(3)
expect(execFileAsyncMock).toHaveBeenNthCalledWith(
expect(gitExecFileAsyncMock).toHaveBeenCalledTimes(3)
expect(gitExecFileAsyncMock).toHaveBeenNthCalledWith(
1,
'git',
['add', '--', ...filePaths.slice(0, 100)],
{
cwd: '/repo',
encoding: 'utf-8'
cwd: '/repo'
}
)
expect(execFileAsyncMock).toHaveBeenNthCalledWith(
expect(gitExecFileAsyncMock).toHaveBeenNthCalledWith(
3,
'git',
['add', '--', ...filePaths.slice(200)],
{
cwd: '/repo',
encoding: 'utf-8'
cwd: '/repo'
}
)
})
it('chunks bulk unstage requests to avoid oversized argv payloads', async () => {
execFileAsyncMock.mockResolvedValue({ stdout: '' })
gitExecFileAsyncMock.mockResolvedValue({ stdout: '' })
const filePaths = Array.from({ length: 101 }, (_, i) => `src/file-${i}.ts`)
await bulkUnstageFiles('/repo', filePaths)
expect(execFileAsyncMock).toHaveBeenCalledTimes(2)
expect(execFileAsyncMock).toHaveBeenNthCalledWith(
expect(gitExecFileAsyncMock).toHaveBeenCalledTimes(2)
expect(gitExecFileAsyncMock).toHaveBeenNthCalledWith(
2,
'git',
['restore', '--staged', '--', ...filePaths.slice(100)],
{
cwd: '/repo',
encoding: 'utf-8'
cwd: '/repo'
}
)
})
@ -152,26 +142,22 @@ describe('bulk git helpers', () => {
describe('getDiff', () => {
beforeEach(() => {
execFileAsyncMock.mockReset()
gitExecFileAsyncMock.mockReset()
gitExecFileAsyncBufferMock.mockReset()
readFileMock.mockReset()
existsSyncMock.mockReset()
})
it('uses the index as the left side for unstaged diffs when present', async () => {
execFileAsyncMock.mockResolvedValueOnce({ stdout: Buffer.from('index-content\n') })
gitExecFileAsyncBufferMock.mockResolvedValueOnce({ stdout: Buffer.from('index-content\n') })
readFileMock.mockResolvedValue(Buffer.from('working-tree-content'))
const result = await getDiff('/repo', 'src/file.ts', false)
expect(execFileAsyncMock).toHaveBeenCalledWith(
'git',
['show', ':src/file.ts'],
expect.objectContaining({
cwd: '/repo',
encoding: 'buffer',
maxBuffer: 10 * 1024 * 1024
})
)
expect(gitExecFileAsyncBufferMock).toHaveBeenCalledWith(['show', ':src/file.ts'], {
cwd: '/repo',
maxBuffer: 10 * 1024 * 1024
})
expect(readFileMock).toHaveBeenCalledWith(path.join('/repo', 'src/file.ts'))
expect(result).toEqual({
kind: 'text',
@ -183,29 +169,23 @@ describe('getDiff', () => {
})
it('falls back to HEAD for unstaged diffs when the file is not in the index', async () => {
execFileAsyncMock
gitExecFileAsyncBufferMock
.mockRejectedValueOnce(new Error('missing index'))
.mockResolvedValueOnce({ stdout: Buffer.from('head-content\n') })
readFileMock.mockResolvedValue(Buffer.from('working-tree-content'))
const result = await getDiff('/repo', 'src/file.ts', false)
expect(execFileAsyncMock).toHaveBeenNthCalledWith(
2,
'git',
['show', 'HEAD:src/file.ts'],
expect.objectContaining({
cwd: '/repo',
encoding: 'buffer',
maxBuffer: 10 * 1024 * 1024
})
)
expect(gitExecFileAsyncBufferMock).toHaveBeenNthCalledWith(2, ['show', 'HEAD:src/file.ts'], {
cwd: '/repo',
maxBuffer: 10 * 1024 * 1024
})
expect(result.originalContent).toBe('head-content\n')
expect(result.modifiedContent).toBe('working-tree-content')
})
it('marks binary content in the diff payload', async () => {
execFileAsyncMock.mockResolvedValueOnce({ stdout: Buffer.from([0x00, 0x61, 0x62]) })
gitExecFileAsyncBufferMock.mockResolvedValueOnce({ stdout: Buffer.from([0x00, 0x61, 0x62]) })
readFileMock.mockResolvedValue(Buffer.from('working-tree-content'))
const result = await getDiff('/repo', 'src/file.bin', false)
@ -217,7 +197,7 @@ describe('getDiff', () => {
it('includes preview metadata for pdf diffs', async () => {
const pdfBuffer = Buffer.from([0x25, 0x50, 0x44, 0x46, 0x00])
execFileAsyncMock.mockResolvedValueOnce({ stdout: pdfBuffer })
gitExecFileAsyncBufferMock.mockResolvedValueOnce({ stdout: pdfBuffer })
readFileMock.mockResolvedValue(pdfBuffer)
const result = await getDiff('/repo', 'docs/spec.pdf', false)
@ -236,7 +216,8 @@ describe('getDiff', () => {
describe('getStatus', () => {
beforeEach(() => {
execFileAsyncMock.mockReset()
gitExecFileAsyncMock.mockReset()
gitExecFileAsyncBufferMock.mockReset()
readFileMock.mockReset()
existsSyncMock.mockReset()
})
@ -244,7 +225,7 @@ describe('getStatus', () => {
it('parses unmerged porcelain v2 entries into unresolved conflict rows', async () => {
readFileMock.mockResolvedValue('gitdir: /repo/.git/worktrees/feature\n')
existsSyncMock.mockImplementation((target: string) => target.endsWith('MERGE_HEAD'))
execFileAsyncMock.mockResolvedValueOnce({
gitExecFileAsyncMock.mockResolvedValueOnce({
stdout:
'u UU N... 100644 100644 100644 100644 aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb cccccccccccccccccccccccccccccccccccccccc src/app.ts\n'
})
@ -266,7 +247,7 @@ describe('getStatus', () => {
it('maps deleted conflicts to deleted when the working tree file is absent', async () => {
readFileMock.mockResolvedValue('gitdir: /repo/.git/worktrees/feature\n')
existsSyncMock.mockReturnValue(false)
execFileAsyncMock.mockResolvedValueOnce({
gitExecFileAsyncMock.mockResolvedValueOnce({
stdout:
'u UD N... 100644 100644 000000 100644 aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb cccccccccccccccccccccccccccccccccccccccc src/deleted.ts\n'
})
@ -287,7 +268,7 @@ describe('getStatus', () => {
existsSyncMock.mockImplementation(() => {
throw new Error('stat failed')
})
execFileAsyncMock.mockResolvedValueOnce({
gitExecFileAsyncMock.mockResolvedValueOnce({
stdout:
'u AU N... 100644 100644 100644 100644 aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb cccccccccccccccccccccccccccccccccccccccc src/new.ts\n'
})
@ -334,12 +315,13 @@ describe('detectConflictOperation', () => {
describe('getBranchCompare', () => {
beforeEach(() => {
execFileAsyncMock.mockReset()
gitExecFileAsyncMock.mockReset()
gitExecFileAsyncBufferMock.mockReset()
readFileMock.mockReset()
})
it('returns a pinned branch compare snapshot and parsed branch entries', async () => {
execFileAsyncMock
gitExecFileAsyncMock
.mockResolvedValueOnce({ stdout: 'main\n' })
.mockResolvedValueOnce({ stdout: 'head-oid\n' })
.mockResolvedValueOnce({ stdout: 'base-oid\n' })
@ -369,7 +351,7 @@ describe('getBranchCompare', () => {
})
it('returns invalid-base when the compare ref does not resolve', async () => {
execFileAsyncMock
gitExecFileAsyncMock
.mockResolvedValueOnce({ stdout: 'main\n' })
.mockResolvedValueOnce({ stdout: 'head-oid\n' })
.mockRejectedValueOnce(new Error('missing base'))
@ -382,7 +364,9 @@ describe('getBranchCompare', () => {
})
it('returns unborn-head when HEAD cannot be resolved', async () => {
execFileAsyncMock.mockRejectedValueOnce(new Error('unborn'))
gitExecFileAsyncMock
.mockResolvedValueOnce({ stdout: 'main\n' })
.mockRejectedValueOnce(new Error('unborn'))
const result = await getBranchCompare('/repo', 'origin/main')
@ -392,7 +376,7 @@ describe('getBranchCompare', () => {
})
it('returns no-merge-base when histories do not intersect', async () => {
execFileAsyncMock
gitExecFileAsyncMock
.mockResolvedValueOnce({ stdout: 'main\n' })
.mockResolvedValueOnce({ stdout: 'head-oid\n' })
.mockResolvedValueOnce({ stdout: 'base-oid\n' })

View File

@ -1,56 +1,73 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { execFileAsyncMock } = vi.hoisted(() => ({
execFileAsyncMock: vi.fn()
const {
execFileAsyncMock,
ghExecFileAsyncMock,
getOwnerRepoMock,
gitExecFileAsyncMock,
acquireMock,
releaseMock
} = vi.hoisted(() => ({
execFileAsyncMock: vi.fn(),
ghExecFileAsyncMock: vi.fn(),
getOwnerRepoMock: vi.fn(),
gitExecFileAsyncMock: vi.fn(),
acquireMock: vi.fn(),
releaseMock: vi.fn()
}))
vi.mock('util', async () => {
const actual = await vi.importActual('util')
return {
...actual,
promisify: vi.fn(() => execFileAsyncMock)
}
})
vi.mock('./gh-utils', () => ({
execFileAsync: execFileAsyncMock,
ghExecFileAsync: ghExecFileAsyncMock,
getOwnerRepo: getOwnerRepoMock,
acquire: acquireMock,
release: releaseMock,
_resetOwnerRepoCache: vi.fn()
}))
vi.mock('../git/runner', () => ({
gitExecFileAsync: gitExecFileAsyncMock
}))
import { getPRForBranch, getPRChecks, _resetOwnerRepoCache } from './client'
describe('getPRForBranch', () => {
beforeEach(() => {
execFileAsyncMock.mockReset()
ghExecFileAsyncMock.mockReset()
getOwnerRepoMock.mockReset()
gitExecFileAsyncMock.mockReset()
acquireMock.mockReset()
releaseMock.mockReset()
acquireMock.mockResolvedValue(undefined)
_resetOwnerRepoCache()
})
it('queries GitHub by head branch when the remote is on github.com', async () => {
execFileAsyncMock
.mockResolvedValueOnce({ stdout: 'git@github.com:acme/widgets.git\n' })
.mockResolvedValueOnce({
stdout: JSON.stringify([
{
number: 42,
title: 'Fix PR discovery',
state: 'OPEN',
url: 'https://github.com/acme/widgets/pull/42',
statusCheckRollup: [],
updatedAt: '2026-03-28T00:00:00Z',
isDraft: false,
mergeable: 'MERGEABLE',
baseRefName: 'main',
headRefName: 'feature/test',
baseRefOid: 'base-oid',
headRefOid: 'head-oid'
}
])
})
getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' })
ghExecFileAsyncMock.mockResolvedValueOnce({
stdout: JSON.stringify([
{
number: 42,
title: 'Fix PR discovery',
state: 'OPEN',
url: 'https://github.com/acme/widgets/pull/42',
statusCheckRollup: [],
updatedAt: '2026-03-28T00:00:00Z',
isDraft: false,
mergeable: 'MERGEABLE',
baseRefName: 'main',
headRefName: 'feature/test',
baseRefOid: 'base-oid',
headRefOid: 'head-oid'
}
])
})
const pr = await getPRForBranch('/repo-root', 'refs/heads/feature/test')
expect(execFileAsyncMock).toHaveBeenNthCalledWith(1, 'git', ['remote', 'get-url', 'origin'], {
cwd: '/repo-root',
encoding: 'utf-8'
})
expect(execFileAsyncMock).toHaveBeenNthCalledWith(
2,
'gh',
expect(getOwnerRepoMock).toHaveBeenCalledWith('/repo-root')
expect(ghExecFileAsyncMock).toHaveBeenCalledWith(
[
'pr',
'list',
@ -65,7 +82,7 @@ describe('getPRForBranch', () => {
'--json',
'number,title,state,url,statusCheckRollup,updatedAt,isDraft,mergeable,baseRefName,headRefName,baseRefOid,headRefOid'
],
{ cwd: '/repo-root', encoding: 'utf-8' }
{ cwd: '/repo-root' }
)
expect(pr?.number).toBe(42)
expect(pr?.state).toBe('open')
@ -73,7 +90,8 @@ describe('getPRForBranch', () => {
})
it('falls back to gh pr view when the remote cannot be resolved to GitHub', async () => {
execFileAsyncMock.mockRejectedValueOnce(new Error('no origin')).mockResolvedValueOnce({
getOwnerRepoMock.mockResolvedValueOnce(null)
ghExecFileAsyncMock.mockResolvedValueOnce({
stdout: JSON.stringify({
number: 7,
title: 'Fallback lookup',
@ -92,9 +110,7 @@ describe('getPRForBranch', () => {
const pr = await getPRForBranch('/non-github-repo', 'feature/test')
expect(execFileAsyncMock).toHaveBeenNthCalledWith(
2,
'gh',
expect(ghExecFileAsyncMock).toHaveBeenCalledWith(
[
'pr',
'view',
@ -102,7 +118,7 @@ describe('getPRForBranch', () => {
'--json',
'number,title,state,url,statusCheckRollup,updatedAt,isDraft,mergeable,baseRefName,headRefName,baseRefOid,headRefOid'
],
{ cwd: '/non-github-repo', encoding: 'utf-8' }
{ cwd: '/non-github-repo' }
)
expect(pr?.number).toBe(7)
expect(pr?.state).toBe('draft')
@ -110,26 +126,26 @@ describe('getPRForBranch', () => {
})
it('derives a read-only conflict summary for conflicting PRs when the base ref exists locally', async () => {
execFileAsyncMock
.mockResolvedValueOnce({ stdout: 'git@github.com:acme/widgets.git\n' })
.mockResolvedValueOnce({
stdout: JSON.stringify([
{
number: 42,
title: 'Fix PR discovery',
state: 'OPEN',
url: 'https://github.com/acme/widgets/pull/42',
statusCheckRollup: [],
updatedAt: '2026-03-28T00:00:00Z',
isDraft: false,
mergeable: 'CONFLICTING',
baseRefName: 'main',
headRefName: 'feature/test',
baseRefOid: 'base-oid',
headRefOid: 'head-oid'
}
])
})
getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' })
ghExecFileAsyncMock.mockResolvedValueOnce({
stdout: JSON.stringify([
{
number: 42,
title: 'Fix PR discovery',
state: 'OPEN',
url: 'https://github.com/acme/widgets/pull/42',
statusCheckRollup: [],
updatedAt: '2026-03-28T00:00:00Z',
isDraft: false,
mergeable: 'CONFLICTING',
baseRefName: 'main',
headRefName: 'feature/test',
baseRefOid: 'base-oid',
headRefOid: 'head-oid'
}
])
})
gitExecFileAsyncMock
.mockResolvedValueOnce({ stdout: '' })
.mockResolvedValueOnce({ stdout: 'latest-base-oid\n' })
.mockResolvedValueOnce({ stdout: 'merge-base-oid\n' })
@ -147,26 +163,26 @@ describe('getPRForBranch', () => {
})
it('keeps conflicted file paths when git merge-tree exits 1 with stdout', async () => {
execFileAsyncMock
.mockResolvedValueOnce({ stdout: 'git@github.com:acme/widgets.git\n' })
.mockResolvedValueOnce({
stdout: JSON.stringify([
{
number: 42,
title: 'Fix PR discovery',
state: 'OPEN',
url: 'https://github.com/acme/widgets/pull/42',
statusCheckRollup: [],
updatedAt: '2026-03-28T00:00:00Z',
isDraft: false,
mergeable: 'CONFLICTING',
baseRefName: 'main',
headRefName: 'feature/test',
baseRefOid: 'base-oid',
headRefOid: 'head-oid'
}
])
})
getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' })
ghExecFileAsyncMock.mockResolvedValueOnce({
stdout: JSON.stringify([
{
number: 42,
title: 'Fix PR discovery',
state: 'OPEN',
url: 'https://github.com/acme/widgets/pull/42',
statusCheckRollup: [],
updatedAt: '2026-03-28T00:00:00Z',
isDraft: false,
mergeable: 'CONFLICTING',
baseRefName: 'main',
headRefName: 'feature/test',
baseRefOid: 'base-oid',
headRefOid: 'head-oid'
}
])
})
gitExecFileAsyncMock
.mockResolvedValueOnce({ stdout: '' })
.mockResolvedValueOnce({ stdout: 'latest-base-oid\n' })
.mockResolvedValueOnce({ stdout: 'merge-base-oid\n' })
@ -181,26 +197,26 @@ describe('getPRForBranch', () => {
})
it('falls back to GitHub baseRefOid when fetching or resolving the base ref fails', async () => {
execFileAsyncMock
.mockResolvedValueOnce({ stdout: 'git@github.com:acme/widgets.git\n' })
.mockResolvedValueOnce({
stdout: JSON.stringify([
{
number: 42,
title: 'Fix PR discovery',
state: 'OPEN',
url: 'https://github.com/acme/widgets/pull/42',
statusCheckRollup: [],
updatedAt: '2026-03-28T00:00:00Z',
isDraft: false,
mergeable: 'CONFLICTING',
baseRefName: 'main',
headRefName: 'feature/test',
baseRefOid: 'base-oid',
headRefOid: 'head-oid'
}
])
})
getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' })
ghExecFileAsyncMock.mockResolvedValueOnce({
stdout: JSON.stringify([
{
number: 42,
title: 'Fix PR discovery',
state: 'OPEN',
url: 'https://github.com/acme/widgets/pull/42',
statusCheckRollup: [],
updatedAt: '2026-03-28T00:00:00Z',
isDraft: false,
mergeable: 'CONFLICTING',
baseRefName: 'main',
headRefName: 'feature/test',
baseRefOid: 'base-oid',
headRefOid: 'head-oid'
}
])
})
gitExecFileAsyncMock
.mockRejectedValueOnce(new Error('fetch failed'))
.mockRejectedValueOnce(new Error('missing refs/remotes/origin/main'))
.mockRejectedValueOnce(new Error('missing origin/main'))
@ -245,33 +261,36 @@ describe('getPRForBranch', () => {
describe('getPRChecks', () => {
beforeEach(() => {
execFileAsyncMock.mockReset()
ghExecFileAsyncMock.mockReset()
getOwnerRepoMock.mockReset()
gitExecFileAsyncMock.mockReset()
acquireMock.mockReset()
releaseMock.mockReset()
acquireMock.mockResolvedValue(undefined)
_resetOwnerRepoCache()
})
it('queries check-runs by PR head SHA when GitHub remote metadata is available', async () => {
execFileAsyncMock
.mockResolvedValueOnce({ stdout: 'git@github.com:acme/widgets.git\n' })
.mockResolvedValueOnce({
stdout: JSON.stringify({
check_runs: [
{
name: 'build',
status: 'completed',
conclusion: 'success',
html_url: 'https://github.com/acme/widgets/actions/runs/1',
details_url: null
}
]
})
getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' })
ghExecFileAsyncMock.mockResolvedValueOnce({
stdout: JSON.stringify({
check_runs: [
{
name: 'build',
status: 'completed',
conclusion: 'success',
html_url: 'https://github.com/acme/widgets/actions/runs/1',
details_url: null
}
]
})
})
const checks = await getPRChecks('/repo-root', 42, 'head-oid')
expect(execFileAsyncMock).toHaveBeenNthCalledWith(
2,
'gh',
expect(ghExecFileAsyncMock).toHaveBeenCalledWith(
['api', '--cache', '60s', 'repos/acme/widgets/commits/head-oid/check-runs?per_page=100'],
{ cwd: '/repo-root', encoding: 'utf-8' }
{ cwd: '/repo-root' }
)
expect(checks).toEqual([
{
@ -284,8 +303,8 @@ describe('getPRChecks', () => {
})
it('falls back to gh pr checks when the cached head SHA no longer resolves', async () => {
execFileAsyncMock
.mockResolvedValueOnce({ stdout: 'git@github.com:acme/widgets.git\n' })
getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' })
ghExecFileAsyncMock
.mockRejectedValueOnce(new Error('gh: No commit found for SHA: stale-head (HTTP 422)'))
.mockResolvedValueOnce({
stdout: JSON.stringify([{ name: 'lint', state: 'PASS', link: 'https://example.com/lint' }])
@ -293,11 +312,10 @@ describe('getPRChecks', () => {
const checks = await getPRChecks('/repo-root', 42, 'stale-head')
expect(execFileAsyncMock).toHaveBeenNthCalledWith(
3,
'gh',
expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith(
2,
['pr', 'checks', '42', '--json', 'name,state,link'],
{ cwd: '/repo-root', encoding: 'utf-8' }
{ cwd: '/repo-root' }
)
expect(checks).toEqual([
{

View File

@ -1,7 +1,8 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { handleMock, execFileAsyncMock } = vi.hoisted(() => ({
const { handleMock, execFileMock, execFileAsyncMock } = vi.hoisted(() => ({
handleMock: vi.fn(),
execFileMock: vi.fn(),
execFileAsyncMock: vi.fn()
}))
@ -11,11 +12,12 @@ vi.mock('electron', () => ({
}
}))
vi.mock('util', async () => {
const actual = await vi.importActual('util')
vi.mock('child_process', () => {
const execFileWithPromisify = Object.assign(execFileMock, {
[Symbol.for('nodejs.util.promisify.custom')]: execFileAsyncMock
})
return {
...actual,
promisify: vi.fn(() => execFileAsyncMock)
execFile: execFileWithPromisify
}
})