Files
orca/src/main/github/client.test.ts
T
Neil d2a53c8fcc New workspace create page (#710)
* New workspace page with agent catalog, composer modal, and terminal integration

* fix lint

* better blank state

* fix: resolve typecheck errors in new-workspace flow

- widen activateAndRevealWorktree's issueCommand to accept direct
  command shape used by NewWorkspacePage, not just the main-process
  runner-script variant
- use a ref object in pty-connection tests to dodge TS narrowing the
  captured callback to never across the mock closure boundary

* fix: bound worktree name auto-suffix loop to prevent OOM in tests

The auto-suffix loop in createLocalWorktree had no termination cap.
When tests (or a misconfigured env) mocked getBranchConflictKind /
getPRForBranch to always return a collision, the loop ran forever and
the vitest worker crashed with "Ineffective mark-compacts near heap
limit".

Cap the search at 100 suffixes, and if all fail, fall back to the
original specific error messages (branch already exists / PR already
owns the name) instead of a generic failure.

Update the two tests that asserted the old throw-on-first-conflict
behavior to verify the new auto-suffix path end-to-end.
2026-04-16 15:41:40 -07:00

260 lines
8.1 KiB
TypeScript

import { beforeEach, describe, expect, it, vi } from 'vitest'
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('./gh-utils', () => ({
execFileAsync: execFileAsyncMock,
ghExecFileAsync: ghExecFileAsyncMock,
getOwnerRepo: getOwnerRepoMock,
acquire: acquireMock,
release: releaseMock,
_resetOwnerRepoCache: vi.fn()
}))
vi.mock('../git/runner', () => ({
gitExecFileAsync: gitExecFileAsyncMock
}))
import { getPRForBranch, _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 () => {
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(getOwnerRepoMock).toHaveBeenCalledWith('/repo-root')
expect(ghExecFileAsyncMock).toHaveBeenCalledWith(
[
'pr',
'list',
'--repo',
'acme/widgets',
'--head',
'feature/test',
'--state',
'all',
'--limit',
'1',
'--json',
'number,title,state,url,statusCheckRollup,updatedAt,isDraft,mergeable,baseRefName,headRefName,baseRefOid,headRefOid'
],
{ cwd: '/repo-root' }
)
expect(pr?.number).toBe(42)
expect(pr?.state).toBe('open')
expect(pr?.mergeable).toBe('MERGEABLE')
})
it('falls back to gh pr view when the remote cannot be resolved to GitHub', async () => {
getOwnerRepoMock.mockResolvedValueOnce(null)
ghExecFileAsyncMock.mockResolvedValueOnce({
stdout: JSON.stringify({
number: 7,
title: 'Fallback lookup',
state: 'OPEN',
url: 'https://example.com/pr/7',
statusCheckRollup: [],
updatedAt: '2026-03-28T00:00:00Z',
isDraft: true,
mergeable: 'CONFLICTING',
baseRefName: 'main',
headRefName: 'feature/test',
baseRefOid: 'base-oid',
headRefOid: 'head-oid'
})
})
const pr = await getPRForBranch('/non-github-repo', 'feature/test')
expect(ghExecFileAsyncMock).toHaveBeenCalledWith(
[
'pr',
'view',
'feature/test',
'--json',
'number,title,state,url,statusCheckRollup,updatedAt,isDraft,mergeable,baseRefName,headRefName,baseRefOid,headRefOid'
],
{ cwd: '/non-github-repo' }
)
expect(pr?.number).toBe(7)
expect(pr?.state).toBe('draft')
expect(pr?.mergeable).toBe('CONFLICTING')
})
it('derives a read-only conflict summary for conflicting PRs when the base ref exists locally', async () => {
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' })
.mockResolvedValueOnce({ stdout: '3\n' })
.mockResolvedValueOnce({ stdout: 'result-tree-oid\u0000src/a.ts\u0000src/b.ts\u0000' })
const pr = await getPRForBranch('/repo-root', 'feature/test')
expect(pr?.conflictSummary).toEqual({
baseRef: 'main',
baseCommit: 'latest-',
commitsBehind: 3,
files: ['src/a.ts', 'src/b.ts']
})
})
it('keeps conflicted file paths when git merge-tree exits 1 with stdout', async () => {
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' })
.mockResolvedValueOnce({ stdout: '2\n' })
.mockRejectedValueOnce({
stdout: 'result-tree-oid\u0000src/conflict.ts\u0000'
})
const pr = await getPRForBranch('/repo-root', 'feature/test')
expect(pr?.conflictSummary?.files).toEqual(['src/conflict.ts'])
})
it('falls back to GitHub baseRefOid when fetching or resolving the base ref fails', async () => {
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'))
.mockResolvedValueOnce({ stdout: 'merge-base-oid\n' })
.mockResolvedValueOnce({ stdout: '1\n' })
.mockResolvedValueOnce({ stdout: 'result-tree-oid\u0000src/fallback.ts\u0000' })
const pr = await getPRForBranch('/repo-root', 'feature/test')
expect(pr?.conflictSummary).toEqual({
baseRef: 'main',
baseCommit: 'base-oi',
commitsBehind: 1,
files: ['src/fallback.ts']
})
})
it('returns null for empty branch (e.g. during rebase with detached HEAD)', async () => {
const pr = await getPRForBranch('/repo-root', '')
expect(pr).toBeNull()
// Should not call gh at all
expect(execFileAsyncMock).not.toHaveBeenCalled()
})
it('returns null for refs/heads/ only branch (detached after strip)', async () => {
const pr = await getPRForBranch('/repo-root', 'refs/heads/')
expect(pr).toBeNull()
expect(execFileAsyncMock).not.toHaveBeenCalled()
})
it('returns null when pr list returns an empty array', async () => {
execFileAsyncMock
.mockResolvedValueOnce({ stdout: 'git@github.com:acme/widgets.git\n' })
.mockResolvedValueOnce({ stdout: '[]' })
const pr = await getPRForBranch('/repo-root', 'no-pr-branch')
expect(pr).toBeNull()
})
})