Fix stale test mocks after runner migration (#516)
This commit is contained in:
parent
b84a05bf69
commit
e5890f8e3f
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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' })
|
||||
|
|
|
|||
|
|
@ -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([
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
})
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue