From e5890f8e3f6d4d35ec97a2698c0bd8143b0f64b8 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sat, 11 Apr 2026 22:47:30 -0700 Subject: [PATCH] Fix stale test mocks after runner migration (#516) --- src/main/git/remove-worktree.test.ts | 87 +++------ src/main/git/status.test.ts | 138 ++++++-------- src/main/github/client.test.ts | 270 ++++++++++++++------------- src/main/ipc/preflight.test.ts | 12 +- 4 files changed, 238 insertions(+), 269 deletions(-) diff --git a/src/main/git/remove-worktree.test.ts b/src/main/git/remove-worktree.test.ts index 254853124..eecf11344 100644 --- a/src/main/git/remove-worktree.test.ts +++ b/src/main/git/remove-worktree.test.ts @@ -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): void { const callCounts = new Map() - 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 () => { diff --git a/src/main/git/status.test.ts b/src/main/git/status.test.ts index 32a931dbb..26e679b81 100644 --- a/src/main/git/status.test.ts +++ b/src/main/git/status.test.ts @@ -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' }) diff --git a/src/main/github/client.test.ts b/src/main/github/client.test.ts index a3f09515a..b2908ba48 100644 --- a/src/main/github/client.test.ts +++ b/src/main/github/client.test.ts @@ -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([ { diff --git a/src/main/ipc/preflight.test.ts b/src/main/ipc/preflight.test.ts index 829b68fa9..76c254edf 100644 --- a/src/main/ipc/preflight.test.ts +++ b/src/main/ipc/preflight.test.ts @@ -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 } })