1459 lines
45 KiB
TypeScript
1459 lines
45 KiB
TypeScript
/* eslint-disable max-lines -- Why: runtime behavior is stateful and cross-cutting, so these tests stay in one file to preserve the end-to-end invariants around handles, waits, and graph sync. */
|
|
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
import type { WorktreeMeta } from '../../shared/types'
|
|
import { addWorktree, listWorktrees, removeWorktree } from '../git/worktree'
|
|
import { createSetupRunnerScript, getEffectiveHooks, runHook } from '../hooks'
|
|
import { OrchestrationDb } from './orchestration/db'
|
|
import { OrcaRuntimeService } from './orca-runtime'
|
|
|
|
const {
|
|
MOCK_GIT_WORKTREES,
|
|
addWorktreeMock,
|
|
removeWorktreeMock,
|
|
computeWorktreePathMock,
|
|
ensurePathWithinWorkspaceMock,
|
|
invalidateAuthorizedRootsCacheMock
|
|
} = vi.hoisted(() => ({
|
|
MOCK_GIT_WORKTREES: [
|
|
{
|
|
path: '/tmp/worktree-a',
|
|
head: 'abc',
|
|
branch: 'feature/foo',
|
|
isBare: false,
|
|
isMainWorktree: false
|
|
}
|
|
],
|
|
addWorktreeMock: vi.fn(),
|
|
removeWorktreeMock: vi.fn(),
|
|
computeWorktreePathMock: vi.fn(),
|
|
ensurePathWithinWorkspaceMock: vi.fn(),
|
|
invalidateAuthorizedRootsCacheMock: vi.fn()
|
|
}))
|
|
|
|
vi.mock('../git/worktree', () => ({
|
|
listWorktrees: vi.fn().mockResolvedValue(MOCK_GIT_WORKTREES),
|
|
addWorktree: addWorktreeMock,
|
|
removeWorktree: removeWorktreeMock
|
|
}))
|
|
|
|
vi.mock('../hooks', () => ({
|
|
createSetupRunnerScript: vi.fn(),
|
|
getEffectiveHooks: vi.fn().mockReturnValue(null),
|
|
runHook: vi.fn().mockResolvedValue({ success: true, output: '' })
|
|
}))
|
|
|
|
vi.mock('../ipc/worktree-logic', async (importOriginal) => {
|
|
const actual = (await importOriginal()) as Record<string, unknown>
|
|
return {
|
|
...actual,
|
|
computeWorktreePath: computeWorktreePathMock,
|
|
ensurePathWithinWorkspace: ensurePathWithinWorkspaceMock
|
|
}
|
|
})
|
|
|
|
vi.mock('../ipc/filesystem-auth', () => ({
|
|
invalidateAuthorizedRootsCache: invalidateAuthorizedRootsCacheMock
|
|
}))
|
|
|
|
// Why: the CLI create-worktree path calls getDefaultBaseRef to resolve a
|
|
// fallback base branch. Real resolution shells out to `git` against the
|
|
// test's fabricated repo path, which has no refs, so we stub it to a
|
|
// predictable 'origin/main'. The runtime no longer silently fabricates this
|
|
// default, so tests that want the legacy behavior must express it via the mock.
|
|
vi.mock('../git/repo', async (importOriginal) => {
|
|
const actual = (await importOriginal()) as Record<string, unknown>
|
|
return {
|
|
...actual,
|
|
getDefaultBaseRef: vi.fn().mockReturnValue('origin/main'),
|
|
getBranchConflictKind: vi.fn().mockResolvedValue(null),
|
|
getGitUsername: vi.fn().mockReturnValue('')
|
|
}
|
|
})
|
|
|
|
afterEach(() => {
|
|
vi.mocked(listWorktrees).mockResolvedValue(MOCK_GIT_WORKTREES)
|
|
vi.mocked(addWorktree).mockReset()
|
|
vi.mocked(removeWorktree).mockReset()
|
|
vi.mocked(createSetupRunnerScript).mockReset()
|
|
vi.mocked(getEffectiveHooks).mockReset()
|
|
vi.mocked(runHook).mockReset()
|
|
vi.mocked(getEffectiveHooks).mockReturnValue(null)
|
|
computeWorktreePathMock.mockReset()
|
|
ensurePathWithinWorkspaceMock.mockReset()
|
|
invalidateAuthorizedRootsCacheMock.mockReset()
|
|
})
|
|
|
|
function syncSinglePty(runtime: OrcaRuntimeService, ptyId: string | null = 'pty-1'): void {
|
|
runtime.attachWindow(1)
|
|
runtime.syncWindowGraph(1, {
|
|
tabs: [
|
|
{
|
|
tabId: 'tab-1',
|
|
worktreeId: TEST_WORKTREE_ID,
|
|
title: 'Codex',
|
|
activeLeafId: 'pane:1',
|
|
layout: null
|
|
}
|
|
],
|
|
leaves: [
|
|
{
|
|
tabId: 'tab-1',
|
|
worktreeId: TEST_WORKTREE_ID,
|
|
leafId: 'pane:1',
|
|
paneRuntimeId: 1,
|
|
ptyId,
|
|
paneTitle: null
|
|
}
|
|
]
|
|
})
|
|
}
|
|
|
|
const TEST_WINDOW_ID = 1
|
|
const TEST_REPO_ID = 'repo-1'
|
|
const TEST_REPO_PATH = '/tmp/repo'
|
|
const TEST_WORKTREE_PATH = '/tmp/worktree-a'
|
|
const TEST_WORKTREE_ID = `${TEST_REPO_ID}::${TEST_WORKTREE_PATH}`
|
|
|
|
function createRuntime(): OrcaRuntimeService {
|
|
return new OrcaRuntimeService(store)
|
|
}
|
|
|
|
const store = {
|
|
getRepo: (id: string) => store.getRepos().find((repo) => repo.id === id),
|
|
getRepos: () => [
|
|
{
|
|
id: TEST_REPO_ID,
|
|
path: TEST_REPO_PATH,
|
|
displayName: 'repo',
|
|
badgeColor: 'blue',
|
|
addedAt: 1
|
|
}
|
|
],
|
|
addRepo: () => {},
|
|
updateRepo: (id: string, updates: Record<string, unknown>) =>
|
|
({
|
|
...store.getRepo(id),
|
|
...updates
|
|
}) as never,
|
|
getAllWorktreeMeta: () => ({
|
|
[TEST_WORKTREE_ID]: {
|
|
displayName: 'foo',
|
|
comment: '',
|
|
linkedIssue: 123,
|
|
linkedPR: null,
|
|
linkedLinearIssue: null,
|
|
isArchived: false,
|
|
isUnread: false,
|
|
isPinned: false,
|
|
sortOrder: 0,
|
|
lastActivityAt: 0
|
|
}
|
|
}),
|
|
getWorktreeMeta: (worktreeId: string) => store.getAllWorktreeMeta()[worktreeId],
|
|
setWorktreeMeta: (_worktreeId: string, meta: Record<string, unknown>) =>
|
|
({
|
|
...store.getAllWorktreeMeta()[TEST_WORKTREE_ID],
|
|
...meta
|
|
}) as never,
|
|
removeWorktreeMeta: () => {},
|
|
getSettings: () => ({
|
|
workspaceDir: '/tmp/workspaces',
|
|
nestWorkspaces: false,
|
|
refreshLocalBaseRefOnWorktreeCreate: false,
|
|
branchPrefix: 'none',
|
|
branchPrefixCustom: ''
|
|
})
|
|
}
|
|
|
|
computeWorktreePathMock.mockImplementation(
|
|
(
|
|
sanitizedName: string,
|
|
repoPath: string,
|
|
settings: { nestWorkspaces: boolean; workspaceDir: string }
|
|
) => {
|
|
if (settings.nestWorkspaces) {
|
|
const repoName =
|
|
repoPath
|
|
.split(/[\\/]/)
|
|
.at(-1)
|
|
?.replace(/\.git$/, '') ?? 'repo'
|
|
return `${settings.workspaceDir}/${repoName}/${sanitizedName}`
|
|
}
|
|
return `${settings.workspaceDir}/${sanitizedName}`
|
|
}
|
|
)
|
|
ensurePathWithinWorkspaceMock.mockImplementation((targetPath: string) => targetPath)
|
|
|
|
describe('OrcaRuntimeService', () => {
|
|
it('starts unavailable with no authoritative window', () => {
|
|
const runtime = createRuntime()
|
|
|
|
expect(runtime.getStatus()).toMatchObject({
|
|
graphStatus: 'unavailable',
|
|
authoritativeWindowId: null,
|
|
rendererGraphEpoch: 0
|
|
})
|
|
expect(runtime.getRuntimeId()).toBeTruthy()
|
|
})
|
|
|
|
it('claims the first window as authoritative and ignores later windows', () => {
|
|
const runtime = createRuntime()
|
|
|
|
runtime.attachWindow(TEST_WINDOW_ID)
|
|
runtime.attachWindow(2)
|
|
|
|
expect(runtime.getStatus().authoritativeWindowId).toBe(TEST_WINDOW_ID)
|
|
})
|
|
|
|
it('bumps the epoch and enters reloading when the authoritative window reloads', () => {
|
|
const runtime = createRuntime()
|
|
|
|
runtime.attachWindow(TEST_WINDOW_ID)
|
|
runtime.markGraphReady(TEST_WINDOW_ID)
|
|
runtime.markRendererReloading(TEST_WINDOW_ID)
|
|
|
|
expect(runtime.getStatus()).toMatchObject({
|
|
graphStatus: 'reloading',
|
|
rendererGraphEpoch: 1
|
|
})
|
|
})
|
|
|
|
it('can mark the graph ready for the authoritative window', () => {
|
|
const runtime = createRuntime()
|
|
|
|
runtime.attachWindow(TEST_WINDOW_ID)
|
|
runtime.markGraphReady(TEST_WINDOW_ID)
|
|
runtime.markRendererReloading(TEST_WINDOW_ID)
|
|
runtime.markGraphReady(TEST_WINDOW_ID)
|
|
|
|
expect(runtime.getStatus().graphStatus).toBe('ready')
|
|
})
|
|
|
|
it('drops back to unavailable and clears authority when the window disappears', () => {
|
|
const runtime = createRuntime()
|
|
|
|
runtime.attachWindow(TEST_WINDOW_ID)
|
|
runtime.markGraphReady(TEST_WINDOW_ID)
|
|
runtime.markRendererReloading(TEST_WINDOW_ID)
|
|
runtime.markGraphUnavailable(TEST_WINDOW_ID)
|
|
|
|
expect(runtime.getStatus()).toMatchObject({
|
|
graphStatus: 'unavailable',
|
|
authoritativeWindowId: null,
|
|
rendererGraphEpoch: 2
|
|
})
|
|
})
|
|
|
|
it('stays unavailable during initial loads before a graph is published', () => {
|
|
const runtime = createRuntime()
|
|
|
|
runtime.attachWindow(TEST_WINDOW_ID)
|
|
runtime.markRendererReloading(TEST_WINDOW_ID)
|
|
|
|
expect(runtime.getStatus()).toMatchObject({
|
|
graphStatus: 'unavailable',
|
|
rendererGraphEpoch: 0
|
|
})
|
|
})
|
|
|
|
it('lists live terminals and issues stable handles for synced leaves', async () => {
|
|
const runtime = new OrcaRuntimeService(store)
|
|
|
|
runtime.attachWindow(1)
|
|
runtime.syncWindowGraph(1, {
|
|
tabs: [
|
|
{
|
|
tabId: 'tab-1',
|
|
worktreeId: 'repo-1::/tmp/worktree-a',
|
|
title: 'Claude',
|
|
activeLeafId: 'pane:1',
|
|
layout: null
|
|
}
|
|
],
|
|
leaves: [
|
|
{
|
|
tabId: 'tab-1',
|
|
worktreeId: 'repo-1::/tmp/worktree-a',
|
|
leafId: 'pane:1',
|
|
paneRuntimeId: 1,
|
|
ptyId: 'pty-1'
|
|
}
|
|
]
|
|
})
|
|
runtime.onPtyData('pty-1', 'hello from terminal\n', 123)
|
|
|
|
const terminals = await runtime.listTerminals('branch:feature/foo')
|
|
expect(terminals.terminals).toHaveLength(1)
|
|
expect(terminals.terminals[0]).toMatchObject({
|
|
worktreeId: 'repo-1::/tmp/worktree-a',
|
|
branch: 'feature/foo',
|
|
title: 'Claude',
|
|
preview: 'hello from terminal'
|
|
})
|
|
|
|
const shown = await runtime.showTerminal(terminals.terminals[0].handle)
|
|
expect(shown.handle).toBe(terminals.terminals[0].handle)
|
|
expect(shown.ptyId).toBe('pty-1')
|
|
})
|
|
|
|
it('resolves branch selectors when worktrees store refs/heads-prefixed branches', async () => {
|
|
vi.mocked(listWorktrees).mockResolvedValueOnce([
|
|
{
|
|
path: '/tmp/worktree-a',
|
|
head: 'abc',
|
|
branch: 'refs/heads/Jinwoo-H/test-3a',
|
|
isBare: false,
|
|
isMainWorktree: false
|
|
}
|
|
])
|
|
|
|
const runtime = new OrcaRuntimeService(store)
|
|
|
|
const worktree = await runtime.showManagedWorktree('branch:Jinwoo-H/test-3a')
|
|
expect(worktree).toMatchObject({
|
|
branch: 'refs/heads/Jinwoo-H/test-3a',
|
|
path: '/tmp/worktree-a'
|
|
})
|
|
})
|
|
|
|
it('does not interpret active as a runtime-global worktree selector', async () => {
|
|
const runtime = new OrcaRuntimeService(store)
|
|
|
|
await expect(runtime.showManagedWorktree('active')).rejects.toThrow('selector_not_found')
|
|
})
|
|
|
|
it('reads bounded terminal output and writes through the PTY controller', async () => {
|
|
const writes: string[] = []
|
|
const runtime = new OrcaRuntimeService(store)
|
|
runtime.setPtyController({
|
|
write: (_ptyId, data) => {
|
|
writes.push(data)
|
|
return true
|
|
},
|
|
kill: () => true,
|
|
getForegroundProcess: async () => null
|
|
})
|
|
|
|
runtime.attachWindow(1)
|
|
runtime.syncWindowGraph(1, {
|
|
tabs: [
|
|
{
|
|
tabId: 'tab-1',
|
|
worktreeId: 'repo-1::/tmp/worktree-a',
|
|
title: 'Claude',
|
|
activeLeafId: 'pane:1',
|
|
layout: null
|
|
}
|
|
],
|
|
leaves: [
|
|
{
|
|
tabId: 'tab-1',
|
|
worktreeId: 'repo-1::/tmp/worktree-a',
|
|
leafId: 'pane:1',
|
|
paneRuntimeId: 1,
|
|
ptyId: 'pty-1'
|
|
}
|
|
]
|
|
})
|
|
runtime.onPtyData('pty-1', '\u001b[32mhello\u001b[0m\nworld\n', 123)
|
|
|
|
const [terminal] = (await runtime.listTerminals()).terminals
|
|
const read = await runtime.readTerminal(terminal.handle)
|
|
expect(read).toMatchObject({
|
|
handle: terminal.handle,
|
|
status: 'running',
|
|
tail: ['hello', 'world'],
|
|
truncated: false,
|
|
nextCursor: expect.any(String)
|
|
})
|
|
|
|
const send = await runtime.sendTerminal(terminal.handle, {
|
|
text: 'continue',
|
|
enter: true
|
|
})
|
|
expect(send).toMatchObject({
|
|
handle: terminal.handle,
|
|
accepted: true
|
|
})
|
|
expect(writes).toEqual(['continue', '\r'])
|
|
})
|
|
|
|
it('waits for terminal exit and resolves with the exit status', async () => {
|
|
const runtime = new OrcaRuntimeService(store)
|
|
|
|
runtime.attachWindow(1)
|
|
runtime.syncWindowGraph(1, {
|
|
tabs: [
|
|
{
|
|
tabId: 'tab-1',
|
|
worktreeId: 'repo-1::/tmp/worktree-a',
|
|
title: 'Claude',
|
|
activeLeafId: 'pane:1',
|
|
layout: null
|
|
}
|
|
],
|
|
leaves: [
|
|
{
|
|
tabId: 'tab-1',
|
|
worktreeId: 'repo-1::/tmp/worktree-a',
|
|
leafId: 'pane:1',
|
|
paneRuntimeId: 1,
|
|
ptyId: 'pty-1'
|
|
}
|
|
]
|
|
})
|
|
|
|
const [terminal] = (await runtime.listTerminals()).terminals
|
|
const waitPromise = runtime.waitForTerminal(terminal.handle, { timeoutMs: 1000 })
|
|
runtime.onPtyExit('pty-1', 7)
|
|
|
|
await expect(waitPromise).resolves.toMatchObject({
|
|
handle: terminal.handle,
|
|
condition: 'exit',
|
|
satisfied: true,
|
|
status: 'exited',
|
|
exitCode: 7
|
|
})
|
|
})
|
|
|
|
it('keeps partial-line output readable across cursor-based pagination', async () => {
|
|
const runtime = new OrcaRuntimeService(store)
|
|
|
|
runtime.attachWindow(1)
|
|
runtime.syncWindowGraph(1, {
|
|
tabs: [
|
|
{
|
|
tabId: 'tab-1',
|
|
worktreeId: 'repo-1::/tmp/worktree-a',
|
|
title: 'Claude',
|
|
activeLeafId: 'pane:1',
|
|
layout: null
|
|
}
|
|
],
|
|
leaves: [
|
|
{
|
|
tabId: 'tab-1',
|
|
worktreeId: 'repo-1::/tmp/worktree-a',
|
|
leafId: 'pane:1',
|
|
paneRuntimeId: 1,
|
|
ptyId: 'pty-1'
|
|
}
|
|
]
|
|
})
|
|
|
|
const [terminal] = (await runtime.listTerminals()).terminals
|
|
runtime.onPtyData('pty-1', 'hel', 100)
|
|
|
|
// Non-cursor reads include the partial line for UI display
|
|
const firstRead = await runtime.readTerminal(terminal.handle)
|
|
expect(firstRead.tail).toEqual(['hel'])
|
|
expect(firstRead.nextCursor).toBe('0')
|
|
|
|
runtime.onPtyData('pty-1', 'lo', 101)
|
|
|
|
// Cursor-based reads exclude partial lines to prevent duplication:
|
|
// without this, the consumer would see "hello" now as a partial, then
|
|
// see "hello" again as a completed line on the next read.
|
|
const secondRead = await runtime.readTerminal(terminal.handle, {
|
|
cursor: Number(firstRead.nextCursor)
|
|
})
|
|
expect(secondRead.tail).toEqual([])
|
|
expect(secondRead.nextCursor).toBe('0')
|
|
|
|
runtime.onPtyData('pty-1', '\nworld\n', 102)
|
|
|
|
const thirdRead = await runtime.readTerminal(terminal.handle, {
|
|
cursor: Number(secondRead.nextCursor)
|
|
})
|
|
expect(thirdRead.tail).toEqual(['hello', 'world'])
|
|
expect(thirdRead.nextCursor).toBe('2')
|
|
})
|
|
|
|
it('delivers pending orchestration messages to an already-idle agent', async () => {
|
|
vi.useFakeTimers()
|
|
const runtime = new OrcaRuntimeService(store)
|
|
const db = new OrchestrationDb(':memory:')
|
|
const write = vi.fn().mockReturnValue(true)
|
|
runtime.setOrchestrationDb(db)
|
|
runtime.setPtyController({
|
|
write,
|
|
kill: vi.fn(),
|
|
getForegroundProcess: async () => null
|
|
})
|
|
syncSinglePty(runtime)
|
|
|
|
const [terminal] = (await runtime.listTerminals()).terminals
|
|
runtime.onPtyData('pty-1', '\x1b]0;Codex working\x07', 100)
|
|
runtime.onPtyData('pty-1', '\x1b]0;Codex done\x07', 101)
|
|
db.insertMessage({ from: 'term_sender', to: terminal.handle, subject: 'hello' })
|
|
|
|
runtime.deliverPendingMessagesForHandle(terminal.handle)
|
|
|
|
expect(write).toHaveBeenCalledWith('pty-1', expect.stringContaining('Subject: hello'))
|
|
// Why: markAsRead is deferred until the 500ms delayed Enter is confirmed,
|
|
// so we must advance timers past the split-write delay.
|
|
await vi.advanceTimersByTimeAsync(500)
|
|
expect(write).toHaveBeenCalledWith('pty-1', '\r')
|
|
expect(db.getUnreadMessages(terminal.handle)).toHaveLength(0)
|
|
db.close()
|
|
vi.useRealTimers()
|
|
})
|
|
|
|
it('adopts preallocated ORCA_TERMINAL_HANDLE as a valid runtime handle', async () => {
|
|
const runtime = new OrcaRuntimeService(store)
|
|
const handle = runtime.preAllocateHandleForPty('pty-1')
|
|
|
|
syncSinglePty(runtime)
|
|
runtime.onPtyData('pty-1', 'ready\n', 100)
|
|
|
|
const read = await runtime.readTerminal(handle)
|
|
expect(read.handle).toBe(handle)
|
|
expect(read.tail).toEqual(['ready'])
|
|
})
|
|
|
|
it('keeps preallocated terminal handles valid across renderer reloads', async () => {
|
|
const runtime = new OrcaRuntimeService(store)
|
|
const handle = runtime.preAllocateHandleForPty('pty-1')
|
|
|
|
syncSinglePty(runtime)
|
|
runtime.markRendererReloading(1)
|
|
syncSinglePty(runtime, null)
|
|
runtime.onPtyData('pty-1', 'after reload\n', 100)
|
|
|
|
const read = await runtime.readTerminal(handle)
|
|
expect(read.tail).toEqual(['after reload'])
|
|
})
|
|
|
|
it('keeps preallocated terminal handles valid when a reload graph omits the live leaf', async () => {
|
|
const runtime = new OrcaRuntimeService(store)
|
|
const handle = runtime.preAllocateHandleForPty('pty-1')
|
|
|
|
syncSinglePty(runtime)
|
|
runtime.markRendererReloading(1)
|
|
runtime.syncWindowGraph(1, {
|
|
tabs: [],
|
|
leaves: []
|
|
})
|
|
runtime.onPtyData('pty-1', 'after omitted leaf\n', 100)
|
|
|
|
const read = await runtime.readTerminal(handle)
|
|
expect(read.tail).toEqual(['after omitted leaf'])
|
|
})
|
|
|
|
it('keeps preallocated terminal handles valid after graph unavailable during reload', async () => {
|
|
const runtime = new OrcaRuntimeService(store)
|
|
const handle = runtime.preAllocateHandleForPty('pty-1')
|
|
|
|
syncSinglePty(runtime)
|
|
runtime.markGraphUnavailable(1)
|
|
runtime.attachWindow(1)
|
|
runtime.syncWindowGraph(1, {
|
|
tabs: [],
|
|
leaves: []
|
|
})
|
|
runtime.onPtyData('pty-1', 'after unavailable\n', 100)
|
|
|
|
const read = await runtime.readTerminal(handle)
|
|
expect(read.tail).toEqual(['after unavailable'])
|
|
})
|
|
|
|
it('keeps already-idle status after tui-idle wait for immediate message delivery', async () => {
|
|
const runtime = new OrcaRuntimeService(store)
|
|
const db = new OrchestrationDb(':memory:')
|
|
const write = vi.fn().mockReturnValue(true)
|
|
runtime.setOrchestrationDb(db)
|
|
runtime.setPtyController({
|
|
write,
|
|
kill: vi.fn(),
|
|
getForegroundProcess: async () => null
|
|
})
|
|
syncSinglePty(runtime)
|
|
|
|
const [terminal] = (await runtime.listTerminals()).terminals
|
|
runtime.onPtyData('pty-1', '\x1b]0;Codex working\x07', 100)
|
|
runtime.onPtyData('pty-1', '\x1b]0;Codex done\x07', 101)
|
|
await runtime.waitForTerminal(terminal.handle, { condition: 'tui-idle' })
|
|
db.insertMessage({ from: 'sender', to: terminal.handle, subject: 'after wait' })
|
|
|
|
runtime.deliverPendingMessagesForHandle(terminal.handle)
|
|
|
|
expect(write).toHaveBeenCalledWith('pty-1', expect.stringContaining('Subject: after wait'))
|
|
db.close()
|
|
})
|
|
|
|
it('resolves message waiters when notifyMessageArrived is called', async () => {
|
|
const runtime = new OrcaRuntimeService(store)
|
|
|
|
const waitPromise = runtime.waitForMessage('term_abc', { timeoutMs: 5000 })
|
|
runtime.notifyMessageArrived('term_abc')
|
|
await waitPromise
|
|
})
|
|
|
|
it('resolves message waiters on timeout when no message arrives', async () => {
|
|
const runtime = new OrcaRuntimeService(store)
|
|
|
|
const start = Date.now()
|
|
await runtime.waitForMessage('term_abc', { timeoutMs: 100 })
|
|
const elapsed = Date.now() - start
|
|
expect(elapsed).toBeGreaterThanOrEqual(90)
|
|
expect(elapsed).toBeLessThan(500)
|
|
})
|
|
|
|
it('fails terminal waits closed when the handle goes stale during reload', async () => {
|
|
const runtime = new OrcaRuntimeService(store)
|
|
|
|
runtime.attachWindow(1)
|
|
runtime.syncWindowGraph(1, {
|
|
tabs: [
|
|
{
|
|
tabId: 'tab-1',
|
|
worktreeId: 'repo-1::/tmp/worktree-a',
|
|
title: 'Claude',
|
|
activeLeafId: 'pane:1',
|
|
layout: null
|
|
}
|
|
],
|
|
leaves: [
|
|
{
|
|
tabId: 'tab-1',
|
|
worktreeId: 'repo-1::/tmp/worktree-a',
|
|
leafId: 'pane:1',
|
|
paneRuntimeId: 1,
|
|
ptyId: 'pty-1'
|
|
}
|
|
]
|
|
})
|
|
|
|
const [terminal] = (await runtime.listTerminals()).terminals
|
|
const waitPromise = runtime.waitForTerminal(terminal.handle, { timeoutMs: 1000 })
|
|
runtime.markRendererReloading(1)
|
|
|
|
await expect(waitPromise).rejects.toThrow('terminal_handle_stale')
|
|
})
|
|
|
|
it('tui-idle times out when PTY data has no agent OSC title transitions', async () => {
|
|
vi.useFakeTimers()
|
|
try {
|
|
const runtime = new OrcaRuntimeService(store)
|
|
|
|
runtime.attachWindow(1)
|
|
runtime.syncWindowGraph(1, {
|
|
tabs: [
|
|
{
|
|
tabId: 'tab-1',
|
|
worktreeId: 'repo-1::/tmp/worktree-a',
|
|
title: 'Terminal 1',
|
|
activeLeafId: 'pane:1',
|
|
layout: null
|
|
}
|
|
],
|
|
leaves: [
|
|
{
|
|
tabId: 'tab-1',
|
|
worktreeId: 'repo-1::/tmp/worktree-a',
|
|
leafId: 'pane:1',
|
|
paneRuntimeId: 1,
|
|
ptyId: 'pty-1'
|
|
}
|
|
]
|
|
})
|
|
runtime.onPtyData('pty-1', 'running migration step 4/9\n', 123)
|
|
|
|
const [terminal] = (await runtime.listTerminals()).terminals
|
|
const waitPromise = runtime.waitForTerminal(terminal.handle, {
|
|
condition: 'tui-idle',
|
|
timeoutMs: 1_000
|
|
})
|
|
const timeoutAssertion = expect(waitPromise).rejects.toThrow('timeout')
|
|
|
|
await vi.advanceTimersByTimeAsync(12_000)
|
|
|
|
await timeoutAssertion
|
|
} finally {
|
|
vi.useRealTimers()
|
|
}
|
|
})
|
|
|
|
it('tui-idle resolves on agent working→idle OSC title transition', async () => {
|
|
const runtime = new OrcaRuntimeService(store)
|
|
|
|
runtime.attachWindow(1)
|
|
runtime.syncWindowGraph(1, {
|
|
tabs: [
|
|
{
|
|
tabId: 'tab-1',
|
|
worktreeId: 'repo-1::/tmp/worktree-a',
|
|
title: 'Claude',
|
|
activeLeafId: 'pane:1',
|
|
layout: null
|
|
}
|
|
],
|
|
leaves: [
|
|
{
|
|
tabId: 'tab-1',
|
|
worktreeId: 'repo-1::/tmp/worktree-a',
|
|
leafId: 'pane:1',
|
|
paneRuntimeId: 1,
|
|
ptyId: 'pty-1'
|
|
}
|
|
]
|
|
})
|
|
|
|
// Simulate agent starting work (braille spinner = working)
|
|
runtime.onPtyData('pty-1', '\x1b]0;\u280b Working on task\x07output\n', 100)
|
|
|
|
const [terminal] = (await runtime.listTerminals()).terminals
|
|
const waitPromise = runtime.waitForTerminal(terminal.handle, {
|
|
condition: 'tui-idle',
|
|
timeoutMs: 5_000
|
|
})
|
|
|
|
// Simulate agent finishing (✳ = Claude Code idle)
|
|
runtime.onPtyData('pty-1', '\x1b]0;\u2733 Task complete\x07done\n', 200)
|
|
|
|
const result = await waitPromise
|
|
expect(result.condition).toBe('tui-idle')
|
|
expect(result.satisfied).toBe(true)
|
|
})
|
|
|
|
it('builds a compact worktree summary from persisted and live runtime state', async () => {
|
|
const runtime = new OrcaRuntimeService(store)
|
|
|
|
runtime.attachWindow(1)
|
|
runtime.syncWindowGraph(1, {
|
|
tabs: [
|
|
{
|
|
tabId: 'tab-1',
|
|
worktreeId: 'repo-1::/tmp/worktree-a',
|
|
title: 'Claude',
|
|
activeLeafId: 'pane:1',
|
|
layout: null
|
|
}
|
|
],
|
|
leaves: [
|
|
{
|
|
tabId: 'tab-1',
|
|
worktreeId: 'repo-1::/tmp/worktree-a',
|
|
leafId: 'pane:1',
|
|
paneRuntimeId: 1,
|
|
ptyId: 'pty-1'
|
|
}
|
|
]
|
|
})
|
|
runtime.onPtyData('pty-1', 'build green\n', 321)
|
|
|
|
const summaries = await runtime.getWorktreePs()
|
|
expect(summaries).toEqual({
|
|
worktrees: [
|
|
{
|
|
worktreeId: 'repo-1::/tmp/worktree-a',
|
|
repoId: 'repo-1',
|
|
repo: 'repo',
|
|
path: '/tmp/worktree-a',
|
|
branch: 'feature/foo',
|
|
linkedIssue: 123,
|
|
unread: false,
|
|
liveTerminalCount: 1,
|
|
hasAttachedPty: true,
|
|
lastOutputAt: 321,
|
|
preview: 'build green'
|
|
}
|
|
],
|
|
totalCount: 1,
|
|
truncated: false
|
|
})
|
|
})
|
|
|
|
it('fails terminal stop closed while the renderer graph is reloading', async () => {
|
|
const runtime = new OrcaRuntimeService(store)
|
|
let killed = false
|
|
runtime.setPtyController({
|
|
write: () => true,
|
|
kill: () => {
|
|
killed = true
|
|
return true
|
|
},
|
|
getForegroundProcess: async () => null
|
|
})
|
|
|
|
runtime.attachWindow(1)
|
|
runtime.syncWindowGraph(1, {
|
|
tabs: [
|
|
{
|
|
tabId: 'tab-1',
|
|
worktreeId: 'repo-1::/tmp/worktree-a',
|
|
title: 'Claude',
|
|
activeLeafId: 'pane:1',
|
|
layout: null
|
|
}
|
|
],
|
|
leaves: [
|
|
{
|
|
tabId: 'tab-1',
|
|
worktreeId: 'repo-1::/tmp/worktree-a',
|
|
leafId: 'pane:1',
|
|
paneRuntimeId: 1,
|
|
ptyId: 'pty-1'
|
|
}
|
|
]
|
|
})
|
|
runtime.markRendererReloading(1)
|
|
|
|
await expect(runtime.stopTerminalsForWorktree('id:repo-1::/tmp/worktree-a')).rejects.toThrow(
|
|
'runtime_unavailable'
|
|
)
|
|
expect(killed).toBe(false)
|
|
})
|
|
|
|
it('fails terminal listing closed if the graph reloads during selector resolution', async () => {
|
|
const runtime = new OrcaRuntimeService(store)
|
|
|
|
runtime.attachWindow(1)
|
|
runtime.syncWindowGraph(1, {
|
|
tabs: [
|
|
{
|
|
tabId: 'tab-1',
|
|
worktreeId: 'repo-1::/tmp/worktree-a',
|
|
title: 'Claude',
|
|
activeLeafId: 'pane:1',
|
|
layout: null
|
|
}
|
|
],
|
|
leaves: [
|
|
{
|
|
tabId: 'tab-1',
|
|
worktreeId: 'repo-1::/tmp/worktree-a',
|
|
leafId: 'pane:1',
|
|
paneRuntimeId: 1,
|
|
ptyId: 'pty-1'
|
|
}
|
|
]
|
|
})
|
|
|
|
let releaseListWorktrees = () => {}
|
|
vi.mocked(listWorktrees).mockImplementationOnce(
|
|
() =>
|
|
new Promise((resolve) => {
|
|
releaseListWorktrees = () => resolve(MOCK_GIT_WORKTREES)
|
|
})
|
|
)
|
|
|
|
const listPromise = runtime.listTerminals('branch:feature/foo')
|
|
runtime.markRendererReloading(1)
|
|
releaseListWorktrees()
|
|
|
|
await expect(listPromise).rejects.toThrow('runtime_unavailable')
|
|
})
|
|
|
|
it('fails terminal stop closed if the graph reloads during selector resolution', async () => {
|
|
const runtime = new OrcaRuntimeService(store)
|
|
let killed = false
|
|
runtime.setPtyController({
|
|
write: () => true,
|
|
kill: () => {
|
|
killed = true
|
|
return true
|
|
},
|
|
getForegroundProcess: async () => null
|
|
})
|
|
|
|
runtime.attachWindow(1)
|
|
runtime.syncWindowGraph(1, {
|
|
tabs: [
|
|
{
|
|
tabId: 'tab-1',
|
|
worktreeId: 'repo-1::/tmp/worktree-a',
|
|
title: 'Claude',
|
|
activeLeafId: 'pane:1',
|
|
layout: null
|
|
}
|
|
],
|
|
leaves: [
|
|
{
|
|
tabId: 'tab-1',
|
|
worktreeId: 'repo-1::/tmp/worktree-a',
|
|
leafId: 'pane:1',
|
|
paneRuntimeId: 1,
|
|
ptyId: 'pty-1'
|
|
}
|
|
]
|
|
})
|
|
|
|
let releaseListWorktrees = () => {}
|
|
vi.mocked(listWorktrees).mockImplementationOnce(
|
|
() =>
|
|
new Promise((resolve) => {
|
|
releaseListWorktrees = () => resolve(MOCK_GIT_WORKTREES)
|
|
})
|
|
)
|
|
|
|
const stopPromise = runtime.stopTerminalsForWorktree('branch:feature/foo')
|
|
runtime.markRendererReloading(1)
|
|
releaseListWorktrees()
|
|
|
|
await expect(stopPromise).rejects.toThrow('runtime_unavailable')
|
|
expect(killed).toBe(false)
|
|
})
|
|
|
|
it('rejects invalid positive limits for bounded list commands', async () => {
|
|
const runtime = new OrcaRuntimeService(store)
|
|
|
|
await expect(runtime.getWorktreePs(-1)).rejects.toThrow('invalid_limit')
|
|
await expect(runtime.listManagedWorktrees(undefined, 0)).rejects.toThrow('invalid_limit')
|
|
await expect(runtime.searchRepoRefs('id:repo-1', 'main', -5)).rejects.toThrow('invalid_limit')
|
|
})
|
|
|
|
it('returns a setup launch payload for CLI-created worktrees when hooks are explicitly enabled', async () => {
|
|
const runtime = new OrcaRuntimeService(store)
|
|
const activateWorktree = vi.fn()
|
|
runtime.setNotifier({
|
|
worktreesChanged: vi.fn(),
|
|
reposChanged: vi.fn(),
|
|
activateWorktree,
|
|
createTerminal: vi.fn(),
|
|
splitTerminal: vi.fn(),
|
|
renameTerminal: vi.fn(),
|
|
focusTerminal: vi.fn(),
|
|
closeTerminal: vi.fn()
|
|
})
|
|
runtime.attachWindow(1)
|
|
|
|
computeWorktreePathMock.mockReturnValue('/tmp/workspaces/runtime-hook-test')
|
|
ensurePathWithinWorkspaceMock.mockReturnValue('/tmp/workspaces/runtime-hook-test')
|
|
vi.mocked(getEffectiveHooks).mockReturnValue({
|
|
scripts: {
|
|
setup: 'pnpm worktree:setup'
|
|
}
|
|
})
|
|
vi.mocked(createSetupRunnerScript).mockReturnValue({
|
|
runnerScriptPath: '/tmp/repo/.git/orca/setup-runner.sh',
|
|
envVars: {
|
|
ORCA_ROOT_PATH: '/tmp/repo',
|
|
ORCA_WORKTREE_PATH: '/tmp/workspaces/runtime-hook-test'
|
|
}
|
|
})
|
|
vi.mocked(listWorktrees).mockResolvedValueOnce([
|
|
{
|
|
path: '/tmp/workspaces/runtime-hook-test',
|
|
head: 'def',
|
|
branch: 'runtime-hook-test',
|
|
isBare: false,
|
|
isMainWorktree: false
|
|
}
|
|
])
|
|
|
|
const result = await runtime.createManagedWorktree({
|
|
repoSelector: 'id:repo-1',
|
|
name: 'runtime-hook-test',
|
|
runHooks: true
|
|
})
|
|
|
|
expect(createSetupRunnerScript).toHaveBeenCalledWith(
|
|
expect.objectContaining({ id: 'repo-1', path: '/tmp/repo' }),
|
|
'/tmp/workspaces/runtime-hook-test',
|
|
'pnpm worktree:setup'
|
|
)
|
|
expect(runHook).not.toHaveBeenCalled()
|
|
expect(addWorktree).toHaveBeenCalledWith(
|
|
'/tmp/repo',
|
|
'/tmp/workspaces/runtime-hook-test',
|
|
'runtime-hook-test',
|
|
'origin/main',
|
|
false
|
|
)
|
|
expect(result).toEqual({
|
|
worktree: expect.objectContaining({
|
|
repoId: 'repo-1',
|
|
path: '/tmp/workspaces/runtime-hook-test',
|
|
branch: 'runtime-hook-test'
|
|
}),
|
|
setup: {
|
|
runnerScriptPath: '/tmp/repo/.git/orca/setup-runner.sh',
|
|
envVars: {
|
|
ORCA_ROOT_PATH: '/tmp/repo',
|
|
ORCA_WORKTREE_PATH: '/tmp/workspaces/runtime-hook-test'
|
|
}
|
|
}
|
|
})
|
|
expect(activateWorktree).toHaveBeenCalledWith('repo-1', expect.any(String), result.setup)
|
|
})
|
|
|
|
it('skips setup hooks for CLI-created worktrees by default', async () => {
|
|
const runtime = new OrcaRuntimeService(store)
|
|
const activateWorktree = vi.fn()
|
|
runtime.setNotifier({
|
|
worktreesChanged: vi.fn(),
|
|
reposChanged: vi.fn(),
|
|
activateWorktree,
|
|
createTerminal: vi.fn(),
|
|
splitTerminal: vi.fn(),
|
|
renameTerminal: vi.fn(),
|
|
focusTerminal: vi.fn(),
|
|
closeTerminal: vi.fn()
|
|
})
|
|
runtime.attachWindow(1)
|
|
|
|
computeWorktreePathMock.mockReturnValue('/tmp/workspaces/runtime-hook-skip')
|
|
ensurePathWithinWorkspaceMock.mockReturnValue('/tmp/workspaces/runtime-hook-skip')
|
|
vi.mocked(getEffectiveHooks).mockReturnValue({
|
|
scripts: {
|
|
setup: 'pnpm worktree:setup'
|
|
}
|
|
})
|
|
vi.mocked(listWorktrees).mockResolvedValueOnce([
|
|
{
|
|
path: '/tmp/workspaces/runtime-hook-skip',
|
|
head: 'def',
|
|
branch: 'runtime-hook-skip',
|
|
isBare: false,
|
|
isMainWorktree: false
|
|
}
|
|
])
|
|
|
|
const result = await runtime.createManagedWorktree({
|
|
repoSelector: 'id:repo-1',
|
|
name: 'runtime-hook-skip'
|
|
})
|
|
|
|
expect(createSetupRunnerScript).not.toHaveBeenCalled()
|
|
expect(runHook).not.toHaveBeenCalled()
|
|
expect(result).toEqual({
|
|
worktree: expect.objectContaining({
|
|
repoId: 'repo-1',
|
|
path: '/tmp/workspaces/runtime-hook-skip',
|
|
branch: 'runtime-hook-skip'
|
|
}),
|
|
warning:
|
|
'orca.yaml setup hook skipped for /tmp/workspaces/runtime-hook-skip; pass --run-hooks to run it.'
|
|
})
|
|
expect(activateWorktree).toHaveBeenCalledWith('repo-1', expect.any(String), undefined)
|
|
})
|
|
|
|
it('skips archive hooks for CLI worktree removal by default', async () => {
|
|
const runtime = new OrcaRuntimeService(store)
|
|
vi.mocked(getEffectiveHooks).mockReturnValue({
|
|
scripts: {
|
|
archive: 'pnpm worktree:archive'
|
|
}
|
|
})
|
|
vi.mocked(removeWorktree).mockResolvedValue(undefined)
|
|
|
|
const result = await runtime.removeManagedWorktree(TEST_WORKTREE_ID)
|
|
|
|
expect(runHook).not.toHaveBeenCalled()
|
|
expect(removeWorktree).toHaveBeenCalledWith(TEST_REPO_PATH, TEST_WORKTREE_PATH, false)
|
|
expect(result.warning).toBe(
|
|
`orca.yaml archive hook skipped for ${TEST_WORKTREE_PATH}; pass --run-hooks to run it.`
|
|
)
|
|
})
|
|
|
|
it('runs archive hooks for CLI worktree removal when hooks are explicitly enabled', async () => {
|
|
const runtime = new OrcaRuntimeService(store)
|
|
vi.mocked(getEffectiveHooks).mockReturnValue({
|
|
scripts: {
|
|
archive: 'pnpm worktree:archive'
|
|
}
|
|
})
|
|
vi.mocked(runHook).mockResolvedValue({ success: true, output: '' })
|
|
vi.mocked(removeWorktree).mockResolvedValue(undefined)
|
|
|
|
await runtime.removeManagedWorktree(TEST_WORKTREE_ID, false, true)
|
|
|
|
expect(runHook).toHaveBeenCalledWith(
|
|
'archive',
|
|
TEST_WORKTREE_PATH,
|
|
expect.objectContaining({ id: TEST_REPO_ID, path: TEST_REPO_PATH })
|
|
)
|
|
expect(removeWorktree).toHaveBeenCalledWith(TEST_REPO_PATH, TEST_WORKTREE_PATH, false)
|
|
})
|
|
|
|
it('invalidates the filesystem-auth cache after CLI worktree creation', async () => {
|
|
// Reproduces: CLI-created worktrees fail with "Access denied: unknown
|
|
// repository or worktree path" because the filesystem-auth cache was
|
|
// not invalidated, so git:branchCompare could not resolve the new path.
|
|
const runtime = new OrcaRuntimeService(store)
|
|
runtime.setNotifier({
|
|
worktreesChanged: vi.fn(),
|
|
reposChanged: vi.fn(),
|
|
activateWorktree: vi.fn(),
|
|
createTerminal: vi.fn(),
|
|
splitTerminal: vi.fn(),
|
|
renameTerminal: vi.fn(),
|
|
focusTerminal: vi.fn(),
|
|
closeTerminal: vi.fn()
|
|
})
|
|
|
|
computeWorktreePathMock.mockReturnValue('/tmp/workspaces/cli-worktree')
|
|
ensurePathWithinWorkspaceMock.mockReturnValue('/tmp/workspaces/cli-worktree')
|
|
vi.mocked(listWorktrees).mockResolvedValueOnce([
|
|
{
|
|
path: '/tmp/workspaces/cli-worktree',
|
|
head: 'abc',
|
|
branch: 'cli-worktree',
|
|
isBare: false,
|
|
isMainWorktree: false
|
|
}
|
|
])
|
|
|
|
await runtime.createManagedWorktree({
|
|
repoSelector: 'id:repo-1',
|
|
name: 'cli-worktree'
|
|
})
|
|
|
|
expect(invalidateAuthorizedRootsCacheMock).toHaveBeenCalled()
|
|
})
|
|
|
|
it('preserves create-time metadata on later runtime listings when Windows path formatting differs', async () => {
|
|
const metaById: Record<string, WorktreeMeta> = {}
|
|
const runtimeStore = {
|
|
getRepo: (id: string) => runtimeStore.getRepos().find((repo) => repo.id === id),
|
|
getRepos: () => [
|
|
{
|
|
id: 'repo-1',
|
|
path: 'C:\\repo',
|
|
displayName: 'repo',
|
|
badgeColor: 'blue',
|
|
addedAt: 1
|
|
}
|
|
],
|
|
addRepo: () => {},
|
|
updateRepo: () => undefined as never,
|
|
getAllWorktreeMeta: () => metaById,
|
|
getWorktreeMeta: (worktreeId: string) => metaById[worktreeId],
|
|
setWorktreeMeta: (worktreeId: string, meta: Partial<WorktreeMeta>) => {
|
|
const existingMeta = metaById[worktreeId]
|
|
const nextMeta: WorktreeMeta = {
|
|
displayName: meta.displayName ?? existingMeta?.displayName ?? '',
|
|
comment: meta.comment ?? existingMeta?.comment ?? '',
|
|
linkedIssue: meta.linkedIssue ?? existingMeta?.linkedIssue ?? null,
|
|
linkedPR: meta.linkedPR ?? existingMeta?.linkedPR ?? null,
|
|
linkedLinearIssue: meta.linkedLinearIssue ?? existingMeta?.linkedLinearIssue ?? null,
|
|
isArchived: meta.isArchived ?? existingMeta?.isArchived ?? false,
|
|
isUnread: meta.isUnread ?? existingMeta?.isUnread ?? false,
|
|
isPinned: meta.isPinned ?? existingMeta?.isPinned ?? false,
|
|
sortOrder: meta.sortOrder ?? existingMeta?.sortOrder ?? 0,
|
|
lastActivityAt: meta.lastActivityAt ?? existingMeta?.lastActivityAt ?? 0
|
|
}
|
|
metaById[worktreeId] = nextMeta
|
|
return nextMeta
|
|
},
|
|
removeWorktreeMeta: () => {},
|
|
getSettings: () => ({
|
|
workspaceDir: 'C:\\workspaces',
|
|
nestWorkspaces: false,
|
|
refreshLocalBaseRefOnWorktreeCreate: false,
|
|
branchPrefix: 'none',
|
|
branchPrefixCustom: ''
|
|
})
|
|
}
|
|
computeWorktreePathMock.mockReturnValue('C:\\workspaces\\improve-dashboard')
|
|
ensurePathWithinWorkspaceMock.mockReturnValue('C:\\workspaces\\improve-dashboard')
|
|
vi.mocked(listWorktrees)
|
|
.mockResolvedValueOnce([
|
|
{
|
|
path: 'C:/workspaces/improve-dashboard',
|
|
head: 'abc',
|
|
branch: 'refs/heads/improve-dashboard',
|
|
isBare: false,
|
|
isMainWorktree: false
|
|
}
|
|
])
|
|
.mockResolvedValueOnce([
|
|
{
|
|
path: 'C:/workspaces/improve-dashboard',
|
|
head: 'abc',
|
|
branch: 'refs/heads/improve-dashboard',
|
|
isBare: false,
|
|
isMainWorktree: false
|
|
}
|
|
])
|
|
|
|
const runtime = new OrcaRuntimeService(runtimeStore)
|
|
await runtime.createManagedWorktree({
|
|
repoSelector: 'id:repo-1',
|
|
name: 'Improve Dashboard'
|
|
})
|
|
const listed = await runtime.listManagedWorktrees('id:repo-1')
|
|
|
|
expect(listed.worktrees).toMatchObject([
|
|
{
|
|
id: 'repo-1::C:/workspaces/improve-dashboard',
|
|
displayName: 'Improve Dashboard'
|
|
}
|
|
])
|
|
})
|
|
|
|
describe('browser page targeting', () => {
|
|
it('passes explicit page ids through without resolving the current worktree', async () => {
|
|
vi.mocked(listWorktrees).mockClear()
|
|
const runtime = createRuntime()
|
|
const snapshotMock = vi.fn().mockResolvedValue({
|
|
browserPageId: 'page-1',
|
|
snapshot: 'tree',
|
|
refs: [],
|
|
url: 'https://example.com',
|
|
title: 'Example'
|
|
})
|
|
|
|
runtime.setAgentBrowserBridge({
|
|
snapshot: snapshotMock
|
|
} as never)
|
|
|
|
const result = await runtime.browserSnapshot({ page: 'page-1' })
|
|
|
|
expect(result.browserPageId).toBe('page-1')
|
|
expect(snapshotMock).toHaveBeenCalledWith(undefined, 'page-1')
|
|
expect(listWorktrees).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('resolves explicit worktree selectors when page ids are also provided', async () => {
|
|
vi.mocked(listWorktrees).mockClear()
|
|
const runtime = createRuntime()
|
|
const snapshotMock = vi.fn().mockResolvedValue({
|
|
browserPageId: 'page-1',
|
|
snapshot: 'tree',
|
|
refs: [],
|
|
url: 'https://example.com',
|
|
title: 'Example'
|
|
})
|
|
|
|
runtime.setAgentBrowserBridge({
|
|
snapshot: snapshotMock,
|
|
getRegisteredTabs: vi.fn(() => new Map([['page-1', 1]]))
|
|
} as never)
|
|
|
|
await runtime.browserSnapshot({
|
|
worktree: 'branch:feature/foo',
|
|
page: 'page-1'
|
|
})
|
|
|
|
expect(snapshotMock).toHaveBeenCalledWith(TEST_WORKTREE_ID, 'page-1')
|
|
})
|
|
|
|
it('routes tab switch and capture start by explicit page id', async () => {
|
|
const runtime = createRuntime()
|
|
const tabSwitchMock = vi.fn().mockResolvedValue({
|
|
switched: 2,
|
|
browserPageId: 'page-2'
|
|
})
|
|
const captureStartMock = vi.fn().mockResolvedValue({
|
|
capturing: true
|
|
})
|
|
|
|
runtime.setAgentBrowserBridge({
|
|
tabSwitch: tabSwitchMock,
|
|
captureStart: captureStartMock
|
|
} as never)
|
|
|
|
await expect(runtime.browserTabSwitch({ page: 'page-2' })).resolves.toEqual({
|
|
switched: 2,
|
|
browserPageId: 'page-2'
|
|
})
|
|
await expect(runtime.browserCaptureStart({ page: 'page-2' })).resolves.toEqual({
|
|
capturing: true
|
|
})
|
|
expect(tabSwitchMock).toHaveBeenCalledWith(undefined, undefined, 'page-2')
|
|
expect(captureStartMock).toHaveBeenCalledWith(undefined, 'page-2')
|
|
})
|
|
|
|
it('does not silently drop invalid explicit worktree selectors for page-targeted commands', async () => {
|
|
vi.mocked(listWorktrees).mockResolvedValue(MOCK_GIT_WORKTREES)
|
|
const runtime = createRuntime()
|
|
const snapshotMock = vi.fn()
|
|
|
|
runtime.setAgentBrowserBridge({
|
|
snapshot: snapshotMock,
|
|
getRegisteredTabs: vi.fn(() => new Map([['page-1', 1]]))
|
|
} as never)
|
|
|
|
await expect(
|
|
runtime.browserSnapshot({
|
|
worktree: 'path:/tmp/missing-worktree',
|
|
page: 'page-1'
|
|
})
|
|
).rejects.toThrow('selector_not_found')
|
|
expect(snapshotMock).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('does not silently drop invalid explicit worktree selectors for non-page browser commands', async () => {
|
|
vi.mocked(listWorktrees).mockResolvedValue(MOCK_GIT_WORKTREES)
|
|
const runtime = createRuntime()
|
|
const tabListMock = vi.fn()
|
|
|
|
runtime.setAgentBrowserBridge({
|
|
tabList: tabListMock
|
|
} as never)
|
|
|
|
await expect(
|
|
runtime.browserTabList({
|
|
worktree: 'path:/tmp/missing-worktree'
|
|
})
|
|
).rejects.toThrow('selector_not_found')
|
|
expect(tabListMock).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('rejects closing an unknown page id instead of treating it as success', async () => {
|
|
vi.mocked(listWorktrees).mockResolvedValue(MOCK_GIT_WORKTREES)
|
|
const runtime = createRuntime()
|
|
|
|
runtime.setAgentBrowserBridge({
|
|
getRegisteredTabs: vi.fn(() => new Map([['page-1', 1]]))
|
|
} as never)
|
|
|
|
await expect(
|
|
runtime.browserTabClose({
|
|
page: 'missing-page'
|
|
})
|
|
).rejects.toThrow('Browser page missing-page was not found')
|
|
})
|
|
|
|
it('rejects closing a page outside the explicitly scoped worktree', async () => {
|
|
vi.mocked(listWorktrees).mockResolvedValue([
|
|
...MOCK_GIT_WORKTREES,
|
|
{
|
|
path: '/tmp/worktree-b',
|
|
head: 'def',
|
|
branch: 'feature/bar',
|
|
isBare: false,
|
|
isMainWorktree: false
|
|
}
|
|
])
|
|
const runtime = createRuntime()
|
|
const getRegisteredTabsMock = vi.fn((worktreeId?: string) =>
|
|
worktreeId === `${TEST_REPO_ID}::/tmp/worktree-b` ? new Map() : new Map([['page-1', 1]])
|
|
)
|
|
|
|
runtime.setAgentBrowserBridge({
|
|
getRegisteredTabs: getRegisteredTabsMock
|
|
} as never)
|
|
|
|
await expect(
|
|
runtime.browserTabClose({
|
|
page: 'page-1',
|
|
worktree: 'path:/tmp/worktree-b'
|
|
})
|
|
).rejects.toThrow('Browser page page-1 was not found in this worktree')
|
|
expect(getRegisteredTabsMock).toHaveBeenCalledWith(`${TEST_REPO_ID}::/tmp/worktree-b`)
|
|
})
|
|
})
|
|
|
|
describe('removeManagedWorktree PTY teardown (design §4.3)', () => {
|
|
function createProviderStub(
|
|
listProcesses: () => Promise<{ id: string; cwd: string; title: string }[]>
|
|
): {
|
|
spawn: ReturnType<typeof vi.fn>
|
|
attach: ReturnType<typeof vi.fn>
|
|
write: ReturnType<typeof vi.fn>
|
|
resize: ReturnType<typeof vi.fn>
|
|
shutdown: ReturnType<typeof vi.fn>
|
|
sendSignal: ReturnType<typeof vi.fn>
|
|
getCwd: ReturnType<typeof vi.fn>
|
|
getInitialCwd: ReturnType<typeof vi.fn>
|
|
clearBuffer: ReturnType<typeof vi.fn>
|
|
acknowledgeDataEvent: ReturnType<typeof vi.fn>
|
|
hasChildProcesses: ReturnType<typeof vi.fn>
|
|
getForegroundProcess: ReturnType<typeof vi.fn>
|
|
serialize: ReturnType<typeof vi.fn>
|
|
revive: ReturnType<typeof vi.fn>
|
|
listProcesses: ReturnType<typeof vi.fn>
|
|
getDefaultShell: ReturnType<typeof vi.fn>
|
|
getProfiles: ReturnType<typeof vi.fn>
|
|
onData: ReturnType<typeof vi.fn>
|
|
onReplay: ReturnType<typeof vi.fn>
|
|
onExit: ReturnType<typeof vi.fn>
|
|
} {
|
|
return {
|
|
spawn: vi.fn(),
|
|
attach: vi.fn(),
|
|
write: vi.fn(),
|
|
resize: vi.fn(),
|
|
shutdown: vi.fn().mockResolvedValue(undefined),
|
|
sendSignal: vi.fn(),
|
|
getCwd: vi.fn(),
|
|
getInitialCwd: vi.fn(),
|
|
clearBuffer: vi.fn(),
|
|
acknowledgeDataEvent: vi.fn(),
|
|
hasChildProcesses: vi.fn(),
|
|
getForegroundProcess: vi.fn(),
|
|
serialize: vi.fn(),
|
|
revive: vi.fn(),
|
|
listProcesses: vi.fn(listProcesses),
|
|
getDefaultShell: vi.fn(),
|
|
getProfiles: vi.fn(),
|
|
onData: vi.fn().mockReturnValue(() => {}),
|
|
onReplay: vi.fn().mockReturnValue(() => {}),
|
|
onExit: vi.fn().mockReturnValue(() => {})
|
|
}
|
|
}
|
|
|
|
it('RPC-initiated delete kills matching PTYs before git', async () => {
|
|
// Seed the runtime with a live leaf whose worktreeId matches the target.
|
|
const killSpy = vi.fn().mockReturnValue(true)
|
|
const localProvider = createProviderStub(async () => [])
|
|
const callOrder: string[] = []
|
|
vi.mocked(removeWorktree).mockImplementation(async () => {
|
|
callOrder.push('git-removeWorktree')
|
|
})
|
|
|
|
const runtime = new OrcaRuntimeService(store, undefined, {
|
|
getLocalProvider: () => {
|
|
callOrder.push('getLocalProvider')
|
|
return localProvider as never
|
|
}
|
|
})
|
|
runtime.setPtyController({
|
|
write: () => true,
|
|
kill: (id) => {
|
|
callOrder.push(`kill:${id}`)
|
|
return killSpy(id) as boolean
|
|
},
|
|
getForegroundProcess: async () => null
|
|
})
|
|
syncSinglePty(runtime, 'pty-1')
|
|
|
|
await runtime.removeManagedWorktree(TEST_WORKTREE_ID)
|
|
|
|
expect(killSpy).toHaveBeenCalledWith('pty-1')
|
|
// The provider-prefix sweep and the git removal must happen AFTER the
|
|
// runtime-graph kill. Git removal must NOT happen before any kill.
|
|
const killIdx = callOrder.indexOf('kill:pty-1')
|
|
const gitIdx = callOrder.indexOf('git-removeWorktree')
|
|
expect(killIdx).toBeGreaterThanOrEqual(0)
|
|
expect(gitIdx).toBeGreaterThan(killIdx)
|
|
})
|
|
|
|
it('thunk resolves the installed provider lazily, not at construction time', async () => {
|
|
// Simulates the daemon adapter being installed AFTER OrcaRuntimeService
|
|
// construction (setLocalPtyProvider(routedAdapter) in daemon-init).
|
|
// A capture-at-construction refactor would break this test.
|
|
const preDaemonProvider = createProviderStub(async () => [
|
|
{ id: '1', cwd: '/tmp', title: 'shell' },
|
|
{ id: '2', cwd: '/tmp', title: 'shell' }
|
|
])
|
|
const postDaemonProvider = createProviderStub(async () => [
|
|
{ id: `${TEST_WORKTREE_ID}@@aaaaaaaa`, cwd: '/tmp', title: 'shell' }
|
|
])
|
|
let currentProvider: ReturnType<typeof createProviderStub> = preDaemonProvider
|
|
|
|
const runtime = new OrcaRuntimeService(store, undefined, {
|
|
getLocalProvider: () => currentProvider as never
|
|
})
|
|
vi.mocked(removeWorktree).mockResolvedValue(undefined)
|
|
|
|
// Simulate daemon-init swapping the provider after construction.
|
|
currentProvider = postDaemonProvider
|
|
|
|
await runtime.removeManagedWorktree(TEST_WORKTREE_ID)
|
|
|
|
// The post-daemon provider's prefix-matching session must have been
|
|
// shut down, proving the thunk resolved lazily at call time.
|
|
expect(postDaemonProvider.shutdown).toHaveBeenCalledWith(
|
|
`${TEST_WORKTREE_ID}@@aaaaaaaa`,
|
|
true
|
|
)
|
|
// The pre-daemon provider must not have been consulted for the kill.
|
|
expect(preDaemonProvider.shutdown).not.toHaveBeenCalled()
|
|
})
|
|
})
|
|
})
|