mirror of
https://github.com/stablyai/orca.git
synced 2026-11-13 09:27:29 +00:00
test: remove 259 low-value tests (#39701)
Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>
This commit is contained in:
@@ -47,7 +47,6 @@ jobs:
|
||||
src/main/ipc/preflight-runnable-local-cli.test.ts
|
||||
src/main/ipc/preflight-wsl-agent-detection.test.ts
|
||||
src/main/ipc/preflight-windows-path-refresh.repro.test.ts
|
||||
src/main/startup/hydrate-shell-path.test.ts
|
||||
src/main/startup/hydrate-shell-path.windows.test.ts
|
||||
src/main/startup/login-shell-environment.test.ts
|
||||
src/packages/process-host/src/process-tree-termination.test.ts
|
||||
|
||||
@@ -658,7 +658,7 @@
|
||||
2 test-no-wall-clock:src/main/runtime/workspace-layout-live-titles.test.ts
|
||||
1 test-no-wall-clock:src/main/runtime/workspaces/process-listing/runtime-worktree-structured-agent-rows-liveness.test.ts
|
||||
6 test-no-wall-clock:src/main/skills/skill-bundle-process-termination.integration.test.ts
|
||||
7 test-no-wall-clock:src/main/skills/skill-install-lock.test.ts
|
||||
6 test-no-wall-clock:src/main/skills/skill-install-lock.test.ts
|
||||
1 test-no-wall-clock:src/main/skills/skill-install-recovery.test.ts
|
||||
1 test-no-wall-clock:src/main/skills/skill-install-request-service.test.ts
|
||||
1 test-no-wall-clock:src/main/skills/skill-install-service.test.ts
|
||||
@@ -859,7 +859,7 @@
|
||||
1 test-timeout-needs-reason:src/main/windows/windows-host-job.win32.test.ts
|
||||
1 test-timeout-needs-reason:src/main/windows/windows-msys-job.win32.test.ts
|
||||
2 test-timeout-needs-reason:src/main/windows/windows-pty-job.win32.test.ts
|
||||
8 test-timeout-needs-reason:src/main/worker-thread-child-process/worker-thread-child-process.test.ts
|
||||
5 test-timeout-needs-reason:src/main/worker-thread-child-process/worker-thread-child-process.test.ts
|
||||
2 test-timeout-needs-reason:src/main/workspace-hooks/hook-archive-timeout-observation.test.ts
|
||||
2 test-timeout-needs-reason:src/main/workspace-hooks/hook-termination-real-process.test.ts
|
||||
2 test-timeout-needs-reason:src/main/workspaces/removal/local-worktree-filesystem-wsl-banner.wsl.test.ts
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -4363,7 +4363,6 @@
|
||||
"src/main/ssh/legacy-relay/ssh-host-relay-windows-inventory.test.ts": 178,
|
||||
"src/main/ssh/legacy-relay/ssh-host-relay-windows-pipe-access.test.ts": 211,
|
||||
"src/main/ssh/legacy-relay/ssh-relay-endpoint-incumbent-shell.integration.test.ts": 1546,
|
||||
"src/main/ssh/legacy-relay/ssh-relay-endpoint-incumbent.test.ts": 96,
|
||||
"src/main/ssh/legacy-relay/ssh-relay-endpoint-runtime.test.ts": 49,
|
||||
"src/main/ssh/legacy-relay/ssh-relay-incumbent-process.test.ts": 5357,
|
||||
"src/main/ssh/legacy-relay/ssh-relay-sentinel-copy-budget.test.ts": 74,
|
||||
@@ -4506,7 +4505,6 @@
|
||||
"src/main/startup/gpu-fallback-marker.test.ts": 61,
|
||||
"src/main/startup/gpu-lifecycle-install-dir-acl-guard.test.ts": 641,
|
||||
"src/main/startup/headless-serve-x11-reexec.test.ts": 76,
|
||||
"src/main/startup/hydrate-shell-path.test.ts": 462,
|
||||
"src/main/startup/hydrate-shell-path.windows.test.ts": 426,
|
||||
"src/main/startup/legacy-worker-renderer-recovery.test.ts": 47,
|
||||
"src/main/startup/linux-keyring-backend-selection.test.ts": 62,
|
||||
@@ -4586,7 +4584,6 @@
|
||||
"src/main/text-generation/commit-message-text-generation-remote-execution.test.ts": 469,
|
||||
"src/main/text-generation/conversation-name-generation-request.test.ts": 507,
|
||||
"src/main/text-generation/jira-issue-summary-requests.test.ts": 276,
|
||||
"src/main/text-generation/jira-issue-summary-text-generation.test.ts": 543,
|
||||
"src/main/text-generation/kimi-generation-failure.test.ts": 151,
|
||||
"src/main/text-generation/pull-request-context.test.ts": 97,
|
||||
"src/main/text-generation/source-control-agent-cli-path.test.ts": 615,
|
||||
@@ -4665,7 +4662,6 @@
|
||||
"src/main/window/createMainWindow-recovery-reload-watchdog.test.ts": 1251,
|
||||
"src/main/window/createMainWindow-renderer-crash-recovery.test.ts": 1613,
|
||||
"src/main/window/createMainWindow-terminal-focus-shortcuts.test.ts": 2628,
|
||||
"src/main/window/createMainWindow-tray-minimize-close.test.ts": 1140,
|
||||
"src/main/window/createMainWindow-user-quit-close.test.ts": 1592,
|
||||
"src/main/window/createMainWindow.test.ts": 1603,
|
||||
"src/main/window/darwin-user-temp-dir.test.ts": 51,
|
||||
@@ -4733,7 +4729,6 @@
|
||||
"src/main/workspaces/creation/preparation/worktree-create-preparation-claim.test.ts": 52,
|
||||
"src/main/workspaces/creation/preparation/worktree-create-preparation-contention.test.ts": 426,
|
||||
"src/main/workspaces/creation/preparation/worktree-create-preparation-stale-cleanup.test.ts": 373,
|
||||
"src/main/workspaces/creation/preparation/worktree-create-preparation-timing.test.ts": 357,
|
||||
"src/main/workspaces/creation/preparation/worktree-create-preparation-wsl-root.test.ts": 402,
|
||||
"src/main/workspaces/creation/preparation/worktree-create-preparation.test.ts": 393,
|
||||
"src/main/workspaces/removal/worktree-failed-removal.test.ts": 749,
|
||||
@@ -4747,7 +4742,6 @@
|
||||
"src/main/workspaces/removal/retirement/worktree-retirement-backfill-scan.test.ts": 55,
|
||||
"src/main/workspaces/removal/retirement/worktree-retirement-backfill-stall.test.ts": 365,
|
||||
"src/main/workspaces/removal/retirement/worktree-retirement-discovery-wsl.test.ts": 355,
|
||||
"src/main/workspaces/removal/retirement/worktree-retirement-namespace.test.ts": 235,
|
||||
"src/main/workspaces/creation/worktree-root-preparation.test.ts": 180,
|
||||
"src/main/workspaces/removal/worktree-trash.test.ts": 126,
|
||||
"src/main/persistence/moving-wsl-projects/wsl-approved-root-race.wsl.test.ts": 102,
|
||||
@@ -4784,7 +4778,6 @@
|
||||
"src/main/wsl/execution/wsl-guest-pane-processes.test.ts": 48,
|
||||
"src/main/wsl/execution/wsl-guest-process-identity.test.ts": 54,
|
||||
"src/main/wsl/host-lifecycle/wsl-host-automation-wake.test.ts": 288,
|
||||
"src/main/wsl/host-lifecycle/wsl-host-gate.test.ts": 156,
|
||||
"src/main/wsl/host-lifecycle/wsl-host-kept-for-peers.test.ts": 159,
|
||||
"src/main/wsl/host-lifecycle/wsl-host-pending-removal.test.ts": 955,
|
||||
"src/main/wsl/host-lifecycle/wsl-host-server-stop.test.ts": 407,
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
import { beforeEach, expect, it, vi } from 'vitest'
|
||||
import { resolveBranchSwitchTarget } from './branch-switch-target'
|
||||
import { gitExecFileAsync } from './runner'
|
||||
vi.mock('./runner', () => ({ gitExecFileAsync: vi.fn() }))
|
||||
beforeEach(() => vi.mocked(gitExecFileAsync).mockReset())
|
||||
it('explains how to recover when remote settings cannot be read', async () => {
|
||||
vi.mocked(gitExecFileAsync)
|
||||
.mockResolvedValueOnce({ stdout: 'refs/remotes/origin/topic\t\t\n', stderr: '' })
|
||||
.mockRejectedValueOnce(new Error('raw remote command failure'))
|
||||
await expect(
|
||||
resolveBranchSwitchTarget('/repo', { kind: 'remote', name: 'origin/topic' }, {})
|
||||
).rejects.toThrow(
|
||||
"Orca couldn't read this repository's remotes. Check its remote settings in a terminal, then try again."
|
||||
)
|
||||
})
|
||||
@@ -1,58 +0,0 @@
|
||||
import { beforeEach, expect, it, vi } from 'vitest'
|
||||
import { gitExecFileAsync } from './runner'
|
||||
import { listLocalBranches } from './checkout'
|
||||
vi.mock('./runner', () => ({ gitExecFileAsync: vi.fn() }))
|
||||
vi.mock('./source-control/git-read-cache-invalidation', () => ({
|
||||
runWithGitReadCacheInvalidation: vi.fn()
|
||||
}))
|
||||
beforeEach(() => vi.mocked(gitExecFileAsync).mockReset())
|
||||
it.each(['unknown field: worktreepath', 'format failed on this host'])(
|
||||
'retries the original listing once after %s',
|
||||
async (message) => {
|
||||
vi.mocked(gitExecFileAsync)
|
||||
.mockRejectedValueOnce(new Error(message))
|
||||
.mockResolvedValueOnce({ stdout: '*\tmain\n \tfeature\n', stderr: '' })
|
||||
expect(await listLocalBranches('/repo')).toEqual({
|
||||
current: 'main',
|
||||
branches: ['main', 'feature']
|
||||
})
|
||||
expect(vi.mocked(gitExecFileAsync).mock.calls[1]?.[0]).toEqual([
|
||||
'for-each-ref',
|
||||
'--format=%(HEAD)%09%(refname:lstrip=2)',
|
||||
'refs/heads/'
|
||||
])
|
||||
expect(gitExecFileAsync).toHaveBeenCalledTimes(2)
|
||||
}
|
||||
)
|
||||
it('propagates the retry error without a third attempt', async () => {
|
||||
const failure = new Error('repository unreadable')
|
||||
vi.mocked(gitExecFileAsync)
|
||||
.mockRejectedValueOnce(new Error('first'))
|
||||
.mockRejectedValueOnce(failure)
|
||||
await expect(listLocalBranches('/repo')).rejects.toBe(failure)
|
||||
expect(gitExecFileAsync).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
it('does not retry cancellation', async () => {
|
||||
const abort = new Error('cancelled')
|
||||
abort.name = 'AbortError'
|
||||
vi.mocked(gitExecFileAsync).mockRejectedValueOnce(abort)
|
||||
await expect(listLocalBranches('/repo')).rejects.toBe(abort)
|
||||
expect(gitExecFileAsync).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('translates occupied WSL branches into the owning workspace path namespace', async () => {
|
||||
const platform = process.platform
|
||||
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
|
||||
try {
|
||||
vi.mocked(gitExecFileAsync).mockResolvedValueOnce({
|
||||
stdout: ' \tfeature\t1700000000\t/home/alice/feature\n',
|
||||
stderr: ''
|
||||
})
|
||||
const result = await listLocalBranches(String.raw`\\wsl.localhost\Ubuntu\home\alice\repo`)
|
||||
expect(result.worktreePaths).toEqual({
|
||||
feature: String.raw`\\wsl.localhost\Ubuntu\home\alice\feature`
|
||||
})
|
||||
} finally {
|
||||
Object.defineProperty(process, 'platform', { configurable: true, value: platform })
|
||||
}
|
||||
})
|
||||
@@ -1,60 +0,0 @@
|
||||
import { beforeEach, expect, it, vi } from 'vitest'
|
||||
import { buildSearchBaseRefsArgv } from './repo-ref-search-command'
|
||||
import { searchRemoteRefPage } from './repo-remote-ref-search'
|
||||
import { gitExecFileAsync } from './runner'
|
||||
import { clearGitCapabilityStateForTests } from './command-runner/git-capability-state'
|
||||
vi.mock('./runner', () => ({ gitExecFileAsync: vi.fn() }))
|
||||
beforeEach(() => {
|
||||
vi.mocked(gitExecFileAsync).mockReset()
|
||||
clearGitCapabilityStateForTests()
|
||||
})
|
||||
it('applies the remote namespace before both modern and legacy candidate caps', () => {
|
||||
for (const excludeRemoteHead of [true, false]) {
|
||||
const args = buildSearchBaseRefsArgv('topic', 51, { namespace: 'remote', excludeRemoteHead })
|
||||
expect(args.filter((arg) => arg.startsWith('refs/'))).toEqual([
|
||||
'refs/remotes/**/*topic*',
|
||||
'refs/remotes/**/*topic*/**'
|
||||
])
|
||||
expect(args).toContain(excludeRemoteHead ? '--count=204' : '--count=304')
|
||||
}
|
||||
})
|
||||
it.each([0, 1])(
|
||||
'propagates failure from read %i rather than returning empty discovery',
|
||||
async (read) => {
|
||||
const failure = new Error('read failed')
|
||||
if (read === 1) {
|
||||
vi.mocked(gitExecFileAsync).mockResolvedValueOnce({ stdout: 'origin\n', stderr: '' })
|
||||
}
|
||||
vi.mocked(gitExecFileAsync).mockRejectedValueOnce(failure)
|
||||
await expect(searchRemoteRefPage('/repo', 'topic', 50)).rejects.toBe(failure)
|
||||
}
|
||||
)
|
||||
it('uses a sentinel and reports a saturated scan even after HEAD filtering', async () => {
|
||||
vi.mocked(gitExecFileAsync)
|
||||
.mockResolvedValueOnce({ stdout: 'origin\n', stderr: '' })
|
||||
.mockResolvedValueOnce({
|
||||
stdout: Array.from({ length: 204 }, () => 'refs/remotes/origin/HEAD\0origin/HEAD').join('\n'),
|
||||
stderr: ''
|
||||
})
|
||||
expect(await searchRemoteRefPage('/repo', '', 50)).toEqual({ refDetails: [], truncated: true })
|
||||
})
|
||||
it.each([100, 1000])(
|
||||
'retains only the requested 50 remote rows from %i candidates',
|
||||
async (size) => {
|
||||
vi.mocked(gitExecFileAsync)
|
||||
.mockResolvedValueOnce({ stdout: 'origin\n', stderr: '' })
|
||||
.mockResolvedValueOnce({
|
||||
stdout: Array.from(
|
||||
{ length: size },
|
||||
(_, n) => `refs/remotes/origin/topic-${n}\0origin/topic-${n}`
|
||||
).join('\n'),
|
||||
stderr: ''
|
||||
})
|
||||
const page = await searchRemoteRefPage('/repo', 'topic', 50)
|
||||
expect(page.refDetails).toHaveLength(50)
|
||||
expect(page.truncated).toBe(true)
|
||||
// The caller needs 50 choices plus a sentinel; Git bounds candidates before returning them.
|
||||
expect(vi.mocked(gitExecFileAsync).mock.calls[1]?.[0]).toContain('--count=204')
|
||||
expect(gitExecFileAsync).toHaveBeenCalledTimes(2)
|
||||
}
|
||||
)
|
||||
@@ -205,68 +205,6 @@ describe('pending PR actions', () => {
|
||||
expect(count('pr_chat_actions')).toBe(0)
|
||||
})
|
||||
|
||||
it('starts each due row once when an expired row ends inside the pass', async () => {
|
||||
const journal = await openJournal()
|
||||
const call = (ordinal: number, pr: number) =>
|
||||
journal.appendItem(
|
||||
{ provider: 'codex', threadId: 'thread-1', turnId: 'turn-1', ordinal },
|
||||
shellCall('gh pr create --fill', `https://github.com/acme/repo/pull/${pr}\n`),
|
||||
{ fence: 0, turnScope: AGENT_JOURNAL_THREAD_SCOPE }
|
||||
)
|
||||
await call(0, 7)
|
||||
clock += 25 * 60 * 60_000
|
||||
await call(1, 8)
|
||||
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] })
|
||||
try {
|
||||
// A, past its expiry, ends at once; recent B's workspace is not resolvable yet.
|
||||
const chatWorkspace = vi.fn(async () => null)
|
||||
const resume = resumeAfterRestart({ chatWorkspace })
|
||||
resume.drain()
|
||||
await vi.waitFor(() => expect(count('pr_pending_actions')).toBe(1))
|
||||
for (let pass = 0; pass < 5; pass++) {
|
||||
resume.drain()
|
||||
}
|
||||
await Promise.resolve()
|
||||
expect(chatWorkspace).toHaveBeenCalledTimes(1)
|
||||
expect(vi.getTimerCount()).toBe(1)
|
||||
resume.clear()
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('backs off a row whose end cannot be saved instead of rerunning it at once', async () => {
|
||||
const journal = await openJournal()
|
||||
await journal.appendItem(
|
||||
{ provider: 'codex', threadId: 'thread-1', turnId: 'turn-1', ordinal: 0 },
|
||||
shellCall('gh pr create --fill', 'https://github.com/acme/repo/pull/7\n'),
|
||||
{ fence: 0, turnScope: AGENT_JOURNAL_THREAD_SCOPE }
|
||||
)
|
||||
clock += 25 * 60 * 60_000
|
||||
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] })
|
||||
try {
|
||||
// The expired row ends at once, but the disk is full: its end commits nothing. A spinning
|
||||
// drain is stopped after a few turns so this fails instead of hanging.
|
||||
let resume: PRPendingActionResume | undefined
|
||||
const settle = vi.fn(() => {
|
||||
if (settle.mock.calls.length > 10) {
|
||||
resume?.clear()
|
||||
}
|
||||
throw new Error('SQLITE_FULL')
|
||||
})
|
||||
resume = resumeAfterRestart({ settle })
|
||||
resume.drain()
|
||||
expect(settle).toHaveBeenCalledTimes(2)
|
||||
settle.mockClear()
|
||||
clock += 60_000
|
||||
vi.advanceTimersByTime(60_000)
|
||||
expect(settle).toHaveBeenCalled()
|
||||
resume.clear()
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('captures nothing from an imported transcript', async () => {
|
||||
const journal = await openJournal()
|
||||
await journal.replaceEpochItems('legacy_import', 0, [
|
||||
|
||||
@@ -1,16 +1,10 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import './runtime-test-module-registration.spec'
|
||||
import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../shared/constants'
|
||||
import '../../../shared/constants'
|
||||
import { OrcaRuntimeService } from '../orca-runtime'
|
||||
import {
|
||||
electronMocks,
|
||||
scanLocalRepoWorktreesForResolutionMock
|
||||
} from '../orca-runtime-test-mocks/setup.spec'
|
||||
import { scanLocalRepoWorktreesForResolutionMock } from '../orca-runtime-test-mocks/setup.spec'
|
||||
import {
|
||||
HEADLESS_LEAF_ID,
|
||||
TEST_WINDOW_ID,
|
||||
TEST_WORKTREE_ID,
|
||||
createRuntime,
|
||||
makeHeadlessTerminalLayout,
|
||||
makeRuntimeStoreWithWorkspaceSession,
|
||||
makeWorkspaceSessionWithHeadlessTerminal,
|
||||
@@ -19,111 +13,6 @@ import {
|
||||
import type { RuntimeWorktreeScanResult } from '../workspaces/catalog/repo-worktree-resolution-scan'
|
||||
|
||||
describe('OrcaRuntimeService', () => {
|
||||
it('starts unavailable with no authoritative window', () => {
|
||||
const runtime = createRuntime()
|
||||
|
||||
expect(runtime.getStatus()).toMatchObject({
|
||||
graphStatus: 'unavailable',
|
||||
authoritativeWindowId: null,
|
||||
desktopWindowStatus: 'openable',
|
||||
rendererGraphEpoch: 0
|
||||
})
|
||||
expect(runtime.getRuntimeId()).toBeTruthy()
|
||||
})
|
||||
|
||||
it('reports the configured Windows terminal shell on status', () => {
|
||||
const runtime = new OrcaRuntimeService({
|
||||
...store,
|
||||
getSettings: () => ({
|
||||
...store.getSettings(),
|
||||
terminalWindowsShell: 'wsl.exe'
|
||||
})
|
||||
} as never)
|
||||
|
||||
expect(runtime.getStatus().terminalWindowsShell).toBe('wsl.exe')
|
||||
})
|
||||
|
||||
it('reports floating workspace availability from settings on status', () => {
|
||||
expect(createRuntime().getStatus().floatingWorkspaceEnabled).toBe(true)
|
||||
|
||||
const disabledRuntime = new OrcaRuntimeService({
|
||||
...store,
|
||||
getSettings: () => ({
|
||||
...store.getSettings(),
|
||||
floatingTerminalEnabled: false
|
||||
})
|
||||
} as never)
|
||||
expect(disabledRuntime.getStatus().floatingWorkspaceEnabled).toBe(false)
|
||||
})
|
||||
|
||||
it('polls floating tabs with targeted PTY liveness and no repo/provider inventory', async () => {
|
||||
const getRepos = vi.fn(store.getRepos)
|
||||
const listProcesses = vi.fn().mockResolvedValue([])
|
||||
const floatingPtyId = `${FLOATING_TERMINAL_WORKTREE_ID}@@pty-1`
|
||||
const { runtimeStore } = makeRuntimeStoreWithWorkspaceSession(
|
||||
makeWorkspaceSessionWithHeadlessTerminal({
|
||||
activeRepoId: null,
|
||||
activeWorktreeId: FLOATING_TERMINAL_WORKTREE_ID,
|
||||
activeTabIdByWorktree: { [FLOATING_TERMINAL_WORKTREE_ID]: 'floating-tab' },
|
||||
tabsByWorktree: {
|
||||
[FLOATING_TERMINAL_WORKTREE_ID]: [
|
||||
{
|
||||
id: 'floating-tab',
|
||||
ptyId: floatingPtyId,
|
||||
worktreeId: FLOATING_TERMINAL_WORKTREE_ID,
|
||||
title: 'Floating Terminal',
|
||||
customTitle: null,
|
||||
color: null,
|
||||
sortOrder: 0,
|
||||
createdAt: 1
|
||||
}
|
||||
]
|
||||
},
|
||||
terminalLayoutsByTabId: {
|
||||
'floating-tab': makeHeadlessTerminalLayout({ [HEADLESS_LEAF_ID]: floatingPtyId })
|
||||
}
|
||||
})
|
||||
)
|
||||
const runtime = new OrcaRuntimeService({ ...runtimeStore, getRepos } as never)
|
||||
const ptyController = {
|
||||
livePtyIds: new Set([floatingPtyId]),
|
||||
write: () => true,
|
||||
kill: () => true,
|
||||
getForegroundProcess: async () => null,
|
||||
hasPty(this: { livePtyIds: Set<string> }, ptyId: string) {
|
||||
return this.livePtyIds.has(ptyId)
|
||||
},
|
||||
listProcesses
|
||||
}
|
||||
const hasPty = vi.spyOn(ptyController, 'hasPty')
|
||||
runtime.setPtyController(ptyController)
|
||||
|
||||
const tabs = await runtime.listMobileSessionTabs(`id:${FLOATING_TERMINAL_WORKTREE_ID}`)
|
||||
const terminals = await runtime.listTerminals(`id:${FLOATING_TERMINAL_WORKTREE_ID}`)
|
||||
await runtime.listMobileSessionTabs(`id:${FLOATING_TERMINAL_WORKTREE_ID}`)
|
||||
await runtime.listTerminals(`id:${FLOATING_TERMINAL_WORKTREE_ID}`)
|
||||
|
||||
expect(tabs.tabs).toEqual([
|
||||
expect.objectContaining({
|
||||
type: 'terminal',
|
||||
parentTabId: 'floating-tab',
|
||||
status: 'ready',
|
||||
terminal: expect.any(String)
|
||||
})
|
||||
])
|
||||
expect(terminals.terminals).toEqual([
|
||||
expect.objectContaining({
|
||||
worktreeId: FLOATING_TERMINAL_WORKTREE_ID,
|
||||
connected: true
|
||||
})
|
||||
])
|
||||
expect(hasPty).toHaveBeenCalledTimes(4)
|
||||
expect(hasPty).toHaveBeenCalledWith(floatingPtyId)
|
||||
expect(listProcesses).not.toHaveBeenCalled()
|
||||
// Floating tabs have no repoId and must not trigger repository hydration (#9343).
|
||||
expect(getRepos).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not block a targeted mobile session tab list on an unrelated worktree scan', async () => {
|
||||
const remoteWorktreeId = 'repo-ssh::/remote/worktree'
|
||||
const remotePtyId = 'ssh:ssh-target@@remote-pty'
|
||||
@@ -214,142 +103,4 @@ describe('OrcaRuntimeService', () => {
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it('hydrates persisted tabs when the store cannot report repos', async () => {
|
||||
// An unavailable repo inventory must not masquerade as an empty one and prune every tab (#9343).
|
||||
const { runtimeStore } = makeRuntimeStoreWithWorkspaceSession(
|
||||
makeWorkspaceSessionWithHeadlessTerminal()
|
||||
)
|
||||
const runtime = new OrcaRuntimeService({
|
||||
...runtimeStore,
|
||||
getRepos: () => undefined
|
||||
} as never)
|
||||
|
||||
const tabs = await runtime.listMobileSessionTabs(`id:${TEST_WORKTREE_ID}`)
|
||||
|
||||
expect(tabs.tabs).toEqual([
|
||||
expect.objectContaining({ type: 'terminal', parentTabId: 'host-tab' })
|
||||
])
|
||||
})
|
||||
|
||||
it('advertises browser screencast only when a renderer window is available', () => {
|
||||
const runtime = createRuntime()
|
||||
electronMocks.BrowserWindow.fromId.mockReturnValue({ isDestroyed: () => false } as never)
|
||||
|
||||
runtime.attachWindow(TEST_WINDOW_ID)
|
||||
|
||||
expect(runtime.getStatus().capabilities).toContain('browser.screencast.v1')
|
||||
})
|
||||
|
||||
it('routes mobile Codex reset consumption through the account mutation coordinator', async () => {
|
||||
const runtime = createRuntime()
|
||||
const expectedScope = {
|
||||
target: { runtime: 'host' as const, wslDistro: null },
|
||||
accountId: 'codex-account',
|
||||
accountRevision: 42,
|
||||
offerRevision: 'v1:offer'
|
||||
}
|
||||
const capturedCodex = {
|
||||
accounts: [],
|
||||
activeAccountId: expectedScope.accountId,
|
||||
activeAccountIdsByRuntime: { host: expectedScope.accountId, wsl: {} }
|
||||
}
|
||||
const capturedRateLimits = {
|
||||
codexTarget: expectedScope.target,
|
||||
marker: 'captured-before-queue-advanced'
|
||||
}
|
||||
const codexAccounts = {
|
||||
consumeRateLimitResetCredit: vi.fn().mockResolvedValue({
|
||||
outcome: 'reset',
|
||||
scope: expectedScope,
|
||||
codex: capturedCodex,
|
||||
rateLimits: capturedRateLimits
|
||||
}),
|
||||
listAccounts: vi.fn(() => ({
|
||||
accounts: [],
|
||||
activeAccountId: 'queued-next-account',
|
||||
activeAccountIdsByRuntime: { host: 'queued-next-account', wsl: {} }
|
||||
}))
|
||||
}
|
||||
const rateLimits = {
|
||||
consumeCodexRateLimitResetCredit: vi.fn(),
|
||||
getState: vi.fn(() => ({
|
||||
codexTarget: expectedScope.target,
|
||||
marker: 'after-queue-advanced'
|
||||
}))
|
||||
}
|
||||
runtime.setAccountServices({
|
||||
claudeAccounts: {
|
||||
listAccounts: vi.fn(() => ({ accounts: [], activeAccountId: null }))
|
||||
},
|
||||
codexAccounts,
|
||||
rateLimits
|
||||
} as never)
|
||||
|
||||
const result = await runtime.consumeCodexRateLimitResetCredit(
|
||||
'11111111-1111-4111-8111-111111111111',
|
||||
expectedScope
|
||||
)
|
||||
|
||||
expect(result).toMatchObject({
|
||||
outcome: 'reset',
|
||||
scope: expectedScope,
|
||||
snapshot: { codex: capturedCodex, rateLimits: capturedRateLimits }
|
||||
})
|
||||
expect(codexAccounts.listAccounts).not.toHaveBeenCalled()
|
||||
expect(rateLimits.getState).not.toHaveBeenCalled()
|
||||
expect(codexAccounts.consumeRateLimitResetCredit).toHaveBeenCalledWith(
|
||||
'11111111-1111-4111-8111-111111111111',
|
||||
expectedScope
|
||||
)
|
||||
expect(rateLimits.consumeCodexRateLimitResetCredit).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('maps a definite pre-provider rejection into an authoritative current snapshot', async () => {
|
||||
const runtime = createRuntime()
|
||||
const expectedScope = {
|
||||
target: { runtime: 'host' as const, wslDistro: null },
|
||||
accountId: 'codex-account',
|
||||
accountRevision: 42,
|
||||
offerRevision: 'v1:stale'
|
||||
}
|
||||
const codex = {
|
||||
accounts: [],
|
||||
activeAccountId: null,
|
||||
activeAccountIdsByRuntime: { host: null, wsl: {} }
|
||||
}
|
||||
const rateLimitState = {
|
||||
codexTarget: expectedScope.target,
|
||||
marker: 'current-after-rejection'
|
||||
}
|
||||
runtime.setAccountServices({
|
||||
claudeAccounts: {
|
||||
listAccounts: vi.fn(() => ({ accounts: [], activeAccountId: null }))
|
||||
},
|
||||
codexAccounts: {
|
||||
consumeRateLimitResetCredit: vi.fn().mockResolvedValue({
|
||||
status: 'rejectedBeforeProvider',
|
||||
retryDisposition: 'discardAttempt',
|
||||
reason: 'offerChanged',
|
||||
scope: expectedScope,
|
||||
codex,
|
||||
rateLimits: rateLimitState
|
||||
})
|
||||
},
|
||||
rateLimits: {}
|
||||
} as never)
|
||||
|
||||
await expect(
|
||||
runtime.consumeCodexRateLimitResetCredit(
|
||||
'11111111-1111-4111-8111-111111111111',
|
||||
expectedScope
|
||||
)
|
||||
).resolves.toMatchObject({
|
||||
status: 'rejectedBeforeProvider',
|
||||
retryDisposition: 'discardAttempt',
|
||||
reason: 'offerChanged',
|
||||
scope: expectedScope,
|
||||
snapshot: { codex, rateLimits: rateLimitState }
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -5,13 +5,11 @@ import {
|
||||
TEST_WINDOW_ID,
|
||||
TEST_WORKTREE_ID,
|
||||
createRuntime,
|
||||
createRuntimeWithSshLease,
|
||||
store
|
||||
createRuntimeWithSshLease
|
||||
} from './orca-runtime-test-fixtures.spec'
|
||||
import './runtime-test-module-registration.spec'
|
||||
import { makePaneKey } from '../../../shared/workspace-layout/stable-pane-id'
|
||||
import { OrcaRuntimeService } from '../orca-runtime'
|
||||
import type { SshRemotePtyLease } from '../../../shared/ssh-types'
|
||||
import '../orca-runtime'
|
||||
|
||||
describe('OrcaRuntimeService', () => {
|
||||
it('keeps a no-incarnation handle across an in-graph pane remint', async () => {
|
||||
@@ -150,39 +148,6 @@ describe('OrcaRuntimeService', () => {
|
||||
await waiting
|
||||
expect(settled).toBe('rejected')
|
||||
})
|
||||
it('recovers a moved SSH pane through the tab it now sits in, not the one its lease froze', async () => {
|
||||
// A moved pane's current coordinates override the lease's frozen old tabId.
|
||||
const leaseTabId = 'tab-before-move'
|
||||
const currentTabId = 'tab-after-move'
|
||||
const appPtyId = 'ssh:ssh-target@@pty-8'
|
||||
const runtime = createRuntimeWithSshLease(appPtyId, leaseTabId)
|
||||
const paneKey = makePaneKey(currentTabId, HEADLESS_LEAF_ID)
|
||||
registerBoundPty(runtime, appPtyId, TEST_WORKTREE_ID, 'ssh-target', {
|
||||
tabId: currentTabId,
|
||||
leafId: HEADLESS_LEAF_ID
|
||||
})
|
||||
const handle = runtime.resolveTerminalPane(paneKey, TEST_WORKTREE_ID).handle
|
||||
runtime.onPtyExit(appPtyId, -1, undefined, { hostExitConfirmed: true })
|
||||
const createTerminal = vi.spyOn(runtime, 'createTerminal').mockResolvedValue({
|
||||
handle: 'term-replacement',
|
||||
tabId: currentTabId,
|
||||
paneKey,
|
||||
ptyId: 'pty-replacement',
|
||||
worktreeId: TEST_WORKTREE_ID,
|
||||
title: null,
|
||||
surface: 'background'
|
||||
})
|
||||
|
||||
await expect(
|
||||
runtime.recoverTerminalPane(paneKey, TEST_WORKTREE_ID, handle)
|
||||
).resolves.toMatchObject({ handle: 'term-replacement' })
|
||||
expect(createTerminal).toHaveBeenCalledWith(`id:${TEST_WORKTREE_ID}`, {
|
||||
tabId: currentTabId,
|
||||
leafId: HEADLESS_LEAF_ID,
|
||||
focus: false,
|
||||
reopensPane: true
|
||||
})
|
||||
})
|
||||
|
||||
it('refuses a moved SSH pane addressed through the tab it left', async () => {
|
||||
// Old-tab coordinates must refuse the moved leaf; otherwise one PTY binds into two tabs.
|
||||
@@ -224,128 +189,3 @@ describe('OrcaRuntimeService', () => {
|
||||
expect(createTerminal).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
// Several leases can share pane coordinates; select an eligible orphan rather than the first match.
|
||||
|
||||
const TARGET = 'ssh-target'
|
||||
const TAB_ID = 'tab-candidacy'
|
||||
|
||||
type LeaseReader = {
|
||||
workspaceSessionWorktreeHasRuntimeOwnedPtyCandidate: (
|
||||
session: { terminalLayoutsByTabId?: Record<string, unknown> },
|
||||
worktreeId: string,
|
||||
tabs: { id: string; ptyId: string | null }[]
|
||||
) => boolean
|
||||
collectRecentExpiredSshLeaseTabIds: (worktreeId: string) => ReadonlySet<string>
|
||||
getRecentExpiredSshLease: (
|
||||
worktreeId: string,
|
||||
tabId: string,
|
||||
leafId: string | undefined,
|
||||
ptyId?: string
|
||||
) => SshRemotePtyLease | null
|
||||
hasRecentExpiredSshLeasePane: (
|
||||
worktreeId: string,
|
||||
tab: { parentTabId: string; leafId: string }
|
||||
) => boolean
|
||||
}
|
||||
|
||||
function leaseFor(ptyId: string, marks: Partial<SshRemotePtyLease> = {}): SshRemotePtyLease {
|
||||
const now = Date.now()
|
||||
return {
|
||||
targetId: TARGET,
|
||||
ptyId,
|
||||
worktreeId: TEST_WORKTREE_ID,
|
||||
tabId: TAB_ID,
|
||||
leafId: HEADLESS_LEAF_ID,
|
||||
state: 'expired',
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
...marks
|
||||
}
|
||||
}
|
||||
|
||||
function readerWithLeases(leases: SshRemotePtyLease[]): LeaseReader {
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The runtime implements these protected lease readers.
|
||||
return new OrcaRuntimeService({
|
||||
...store,
|
||||
getSshRemotePtyLeases: () => leases
|
||||
}) as unknown as LeaseReader
|
||||
}
|
||||
|
||||
const pane = { parentTabId: TAB_ID, leafId: HEADLESS_LEAF_ID }
|
||||
|
||||
describe('recent expired SSH lease candidacy', () => {
|
||||
it('reports a plain expired orphan', () => {
|
||||
const reader = readerWithLeases([leaseFor('pty-1')])
|
||||
|
||||
expect(reader.hasRecentExpiredSshLeasePane(TEST_WORKTREE_ID, pane)).toBe(true)
|
||||
})
|
||||
|
||||
it('does not report a pane whose only recent lease was superseded', () => {
|
||||
// A superseded lease no longer routes to its original shell and cannot offer recovery.
|
||||
const reader = readerWithLeases([leaseFor('pty-1', { supersededBy: 'pty-2' })])
|
||||
|
||||
expect(reader.hasRecentExpiredSshLeasePane(TEST_WORKTREE_ID, pane)).toBe(false)
|
||||
})
|
||||
|
||||
it('does not report a pane whose only recent lease had its relay id recycled', () => {
|
||||
// A recycled relay id belongs to another shell and must never be adopted.
|
||||
const reader = readerWithLeases([leaseFor('pty-1', { relayIdRecycled: true })])
|
||||
|
||||
expect(reader.hasRecentExpiredSshLeasePane(TEST_WORKTREE_ID, pane)).toBe(false)
|
||||
})
|
||||
|
||||
it('picks the eligible orphan over a superseded predecessor that matches first', () => {
|
||||
// Store the superseded lease first to catch first-match recovery.
|
||||
const reader = readerWithLeases([
|
||||
leaseFor('pty-1', { supersededBy: 'pty-2' }),
|
||||
leaseFor('pty-2')
|
||||
])
|
||||
|
||||
expect(reader.getRecentExpiredSshLease(TEST_WORKTREE_ID, TAB_ID, HEADLESS_LEAF_ID)?.ptyId).toBe(
|
||||
'pty-2'
|
||||
)
|
||||
expect(reader.hasRecentExpiredSshLeasePane(TEST_WORKTREE_ID, pane)).toBe(true)
|
||||
})
|
||||
|
||||
it('collects the same tabs the per-tab reader reports, in one sweep of the leases', () => {
|
||||
const leases = [leaseFor('pty-1', { supersededBy: 'pty-2' }), leaseFor('pty-2')]
|
||||
let sweeps = 0
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The runtime implements these protected lease readers.
|
||||
const reader = new OrcaRuntimeService({
|
||||
...store,
|
||||
getSshRemotePtyLeases: () => {
|
||||
sweeps += 1
|
||||
return leases
|
||||
}
|
||||
}) as unknown as LeaseReader
|
||||
const tabs = Array.from({ length: 8 }, (_, index) => ({
|
||||
id: index === 7 ? TAB_ID : `tab-${index}`,
|
||||
ptyId: null
|
||||
}))
|
||||
|
||||
expect(
|
||||
reader.workspaceSessionWorktreeHasRuntimeOwnedPtyCandidate(
|
||||
{ terminalLayoutsByTabId: {} },
|
||||
TEST_WORKTREE_ID,
|
||||
tabs
|
||||
)
|
||||
).toBe(true)
|
||||
expect(sweeps).toBe(1)
|
||||
expect([...reader.collectRecentExpiredSshLeaseTabIds(TEST_WORKTREE_ID)]).toEqual([TAB_ID])
|
||||
})
|
||||
|
||||
it('reports no candidate when no lease names any of the worktree tabs', () => {
|
||||
const reader = readerWithLeases([
|
||||
leaseFor('pty-1', { tabId: 'somewhere-else', leafId: undefined })
|
||||
])
|
||||
|
||||
expect(
|
||||
reader.workspaceSessionWorktreeHasRuntimeOwnedPtyCandidate(
|
||||
{ terminalLayoutsByTabId: {} },
|
||||
TEST_WORKTREE_ID,
|
||||
[{ id: TAB_ID, ptyId: null }]
|
||||
)
|
||||
).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { SkillInstallResult } from '../../shared/skill-install-contract'
|
||||
import type { SkillDiscoveryResult } from '../../shared/skills'
|
||||
|
||||
import type { WslResult, WslSpec } from '../wsl/execution/wsl-runner'
|
||||
import type { discoverSkills } from './discovery'
|
||||
|
||||
@@ -11,15 +11,12 @@ const transport = vi.hoisted(() => ({
|
||||
vi.mock('../wsl/execution/wsl-runner', () => ({ runWslProcess: transport.run }))
|
||||
vi.mock('./discovery', () => ({ discoverSkills: transport.native }))
|
||||
|
||||
import { discoverSkillsInWsl } from './skill-discovery-wsl'
|
||||
import './skill-discovery-wsl'
|
||||
import { verifySkillInstallDiscovery } from './skill-install-discovery-verification'
|
||||
|
||||
const home = '/home/alice'
|
||||
const workspace = '/work/project'
|
||||
const homeAgents = `${home}/.agents/skills`
|
||||
const homeClaude = `${home}/.claude/skills`
|
||||
const repoAgents = `${workspace}/.agents/skills`
|
||||
const repoClaude = `${workspace}/.claude/skills`
|
||||
const pluginRoot = `${home}/.claude/plugins/cache/example/1/skills`
|
||||
type ScanRow = { root: string; directory: string; canonical: string; name: string }
|
||||
|
||||
@@ -94,14 +91,6 @@ function interceptScan(rows: ScanRow[]): void {
|
||||
})
|
||||
}
|
||||
|
||||
function scanScript(): string {
|
||||
const script = transport.run.mock.calls.at(-1)?.[0].script
|
||||
if (!script) {
|
||||
throw new Error('Expected a mandatory scan')
|
||||
}
|
||||
return script
|
||||
}
|
||||
|
||||
const input = {
|
||||
scope: 'global',
|
||||
homeDirectory: home,
|
||||
@@ -123,81 +112,6 @@ beforeEach(() => {
|
||||
})
|
||||
|
||||
describe('WSL install verification at the discovery transport boundary', () => {
|
||||
it.each(['global', 'workspace'] as const)(
|
||||
'uses one fresh scan instead of two processes for a %s install with co-owners',
|
||||
async (scope) => {
|
||||
const canonicalRoot = scope === 'global' ? homeAgents : repoAgents
|
||||
const aliasRoot = scope === 'global' ? homeClaude : repoClaude
|
||||
const coownerRoot = scope === 'global' ? repoAgents : homeClaude
|
||||
const canonical = `${canonicalRoot}/review`
|
||||
const result = installResult([canonical, `${aliasRoot}/review`])
|
||||
interceptScan([
|
||||
skillRow(canonicalRoot),
|
||||
skillRow(aliasRoot, canonical),
|
||||
skillRow(coownerRoot, canonical),
|
||||
skillRow(pluginRoot, canonical)
|
||||
])
|
||||
const args = { ...input, scope, workspaceDirectory: workspace, result }
|
||||
const broad = await verifySkillInstallDiscovery({
|
||||
...args,
|
||||
discover: () => discoverSkillsInWsl({ distro: 'Ubuntu', homeDir: home, cwd: workspace })
|
||||
})
|
||||
expect(broad).toEqual(result)
|
||||
expect(transport.run).toHaveBeenCalledTimes(2)
|
||||
expect(scanScript()).toContain(`'${pluginRoot}'`)
|
||||
transport.run.mockClear()
|
||||
|
||||
expect(await verifySkillInstallDiscovery(args)).toEqual(broad)
|
||||
expect(transport.run).toHaveBeenCalledTimes(1)
|
||||
expect(transport.native).not.toHaveBeenCalled()
|
||||
expect(transport.run).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
distro: 'Ubuntu',
|
||||
shell: 'bash',
|
||||
loginPath: 'none',
|
||||
timeoutMs: 10_000,
|
||||
maxOutputBytes: 128 * 1024 * 1024
|
||||
})
|
||||
)
|
||||
expect(scanScript()).toContain(`'${homeAgents}'`)
|
||||
expect(scanScript()).toContain(`'${repoAgents}'`)
|
||||
expect(scanScript()).toContain("'review') return 0 ;;")
|
||||
expect(scanScript()).not.toContain('read_metadata()')
|
||||
expect(scanScript()).not.toContain(`'${pluginRoot}'`)
|
||||
expect(scanScript()).not.toContain(`'${home}/.codex/plugins/cache'`)
|
||||
}
|
||||
)
|
||||
|
||||
it('keeps an overridden provider root even when it is also an enabled plugin root', async () => {
|
||||
const canonical = `${homeAgents}/review`
|
||||
const result = installResult([canonical, `${pluginRoot}/review`])
|
||||
interceptScan([skillRow(homeAgents), skillRow(pluginRoot, canonical)])
|
||||
const args = {
|
||||
...input,
|
||||
result,
|
||||
providerRootOverrides: {
|
||||
claude:
|
||||
'\\\\wsl.localhost\\Ubuntu\\home\\alice\\.claude\\plugins\\cache\\example\\1\\skills'
|
||||
}
|
||||
}
|
||||
const broad = await verifySkillInstallDiscovery({
|
||||
...args,
|
||||
discover: () =>
|
||||
discoverSkillsInWsl({
|
||||
distro: 'Ubuntu',
|
||||
homeDir: home,
|
||||
providerRootOverrides: { claude: pluginRoot }
|
||||
})
|
||||
})
|
||||
expect(broad).toEqual(result)
|
||||
expect(transport.run).toHaveBeenCalledTimes(2)
|
||||
transport.run.mockClear()
|
||||
expect(await verifySkillInstallDiscovery(args)).toEqual(broad)
|
||||
expect(transport.run).toHaveBeenCalledTimes(1)
|
||||
expect(scanScript()).toContain(`'${pluginRoot}'`)
|
||||
expect(scanScript()).not.toContain('read_metadata()')
|
||||
})
|
||||
|
||||
it('reads again for each item and fails if its canonical placement disappears', async () => {
|
||||
const result = installResult([`${homeAgents}/review`])
|
||||
interceptScan([skillRow(homeAgents)])
|
||||
@@ -232,20 +146,6 @@ describe('WSL install verification at the discovery transport boundary', () => {
|
||||
expect(transport.run).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it.each(['review', 'Review', 'réview'])(
|
||||
'verifies frontmatter name %s in a different directory without accepting unrelated siblings',
|
||||
async (name) => {
|
||||
const result = installResult([`${homeAgents}/review`], name)
|
||||
interceptScan([
|
||||
{ ...skillRow(homeAgents, `${homeAgents}/renamed`, 'renamed'), name },
|
||||
{ ...skillRow(homeAgents, `${homeAgents}/other`, 'other'), name: 'other' }
|
||||
])
|
||||
expect(await verifySkillInstallDiscovery({ ...input, result })).toEqual(result)
|
||||
expect(transport.run).toHaveBeenCalledTimes(1)
|
||||
expect(scanScript()).toContain('matches_requested_name()')
|
||||
}
|
||||
)
|
||||
|
||||
it.each([
|
||||
{ ...wslResult(''), code: 1 },
|
||||
{ ...wslResult(''), timedOut: true },
|
||||
@@ -265,50 +165,4 @@ describe('WSL install verification at the discovery transport boundary', () => {
|
||||
})
|
||||
expect(transport.run).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('leaves native fresh discovery arguments and answered co-owner proof unchanged', async () => {
|
||||
const result = installResult([`${homeAgents}/review`, `${homeClaude}/review`])
|
||||
const discovery: SkillDiscoveryResult = {
|
||||
scannedAt: 1,
|
||||
skills: [
|
||||
{
|
||||
id: 'review',
|
||||
name: 'review',
|
||||
description: null,
|
||||
providers: ['agent-skills', 'claude'],
|
||||
sourceKind: 'home',
|
||||
sourceLabel: 'Home',
|
||||
rootPath: homeAgents,
|
||||
rootPaths: [homeAgents, homeClaude],
|
||||
directoryPath: `${homeAgents}/review`,
|
||||
skillFilePath: `${homeAgents}/review/SKILL.md`,
|
||||
installed: true,
|
||||
updatedAt: null
|
||||
}
|
||||
],
|
||||
sources: [
|
||||
{
|
||||
id: 'home-agents',
|
||||
label: 'Home',
|
||||
path: homeAgents,
|
||||
sourceKind: 'home',
|
||||
providers: ['agent-skills'],
|
||||
owner: null,
|
||||
exists: true,
|
||||
skippedReason: 'unavailable'
|
||||
}
|
||||
]
|
||||
}
|
||||
transport.native.mockResolvedValue(discovery)
|
||||
expect(
|
||||
await verifySkillInstallDiscovery({ result, scope: 'global', homeDirectory: home })
|
||||
).toEqual(result)
|
||||
expect(transport.native).toHaveBeenCalledExactlyOnceWith({
|
||||
homeDir: home,
|
||||
repos: [],
|
||||
refresh: true,
|
||||
includeCwd: false
|
||||
})
|
||||
expect(transport.run).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
reclaimDeadSkillInstallLocks,
|
||||
skillInstallLockPath
|
||||
} from './skill-install-lock'
|
||||
import { readSkillInstallLockOwner } from './skill-install-lock-owner'
|
||||
import './skill-install-lock-owner'
|
||||
|
||||
const roots: string[] = []
|
||||
|
||||
@@ -24,15 +24,6 @@ async function readPublishedOwner(lockPath: string): Promise<Record<string, unkn
|
||||
}
|
||||
|
||||
describe('skill install lock', () => {
|
||||
it.each([0, -1])('rejects a non-positive owner pid (%s)', async (pid) => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'orca-skill-lock-test-'))
|
||||
roots.push(root)
|
||||
const ownerPath = join(root, 'owner.json')
|
||||
await writeFile(ownerPath, JSON.stringify({ token: 'invalid-pid', pid, createdAt: Date.now() }))
|
||||
|
||||
await expect(readSkillInstallLockOwner(ownerPath)).resolves.toBeNull()
|
||||
})
|
||||
|
||||
it('reclaims a fresh legacy lock whose process was killed', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'orca-skill-lock-test-'))
|
||||
roots.push(root)
|
||||
@@ -147,32 +138,6 @@ describe('skill install lock', () => {
|
||||
await release()
|
||||
})
|
||||
|
||||
it('retries when the current owner releases after a contended rename', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'orca-skill-lock-test-'))
|
||||
roots.push(root)
|
||||
const lockPath = skillInstallLockPath(join(root, 'state'), join(root, 'skills', 'alpha'))
|
||||
const firstRelease = await acquireSkillInstallLock({ path: lockPath })
|
||||
let attempts = 0
|
||||
|
||||
const secondRelease = await acquireSkillInstallLock({
|
||||
path: lockPath,
|
||||
timeoutMs: 500,
|
||||
publishLock: async (candidatePath, targetPath) => {
|
||||
attempts += 1
|
||||
if (attempts === 1) {
|
||||
await firstRelease()
|
||||
const error = new Error('injected-contention') as NodeJS.ErrnoException
|
||||
error.code = 'ENOTEMPTY'
|
||||
throw error
|
||||
}
|
||||
await rename(candidatePath, targetPath)
|
||||
}
|
||||
})
|
||||
|
||||
expect(attempts).toBe(2)
|
||||
await secondRelease()
|
||||
})
|
||||
|
||||
it('reclaims abandoned legacy owner files at startup', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'orca-skill-lock-test-'))
|
||||
roots.push(root)
|
||||
|
||||
@@ -55,7 +55,7 @@ import {
|
||||
resetSshCredentialDeclinesForTests
|
||||
} from '../ssh-credential-decline'
|
||||
import { isAuthError } from '../ssh-reconnect-error-classification'
|
||||
import { isSshSessionStartFailure } from '../ssh-session-start-failure'
|
||||
import '../ssh-session-start-failure'
|
||||
import { recordSshUserDisconnected, resetSshUserDisconnectsForTests } from '../ssh-user-disconnect'
|
||||
|
||||
const target = {
|
||||
@@ -84,30 +84,6 @@ describe('SshConnectionManager', () => {
|
||||
await rejected
|
||||
})
|
||||
|
||||
it('tears a failed startup down quietly, so its error is not replaced by a disconnect', async () => {
|
||||
mockState.connectResults.push(Promise.reject(new Error('auth failed')))
|
||||
const manager = new SshConnectionManager({ onStateChange: vi.fn() })
|
||||
await expect(manager.connect(target)).rejects.toThrow('auth failed')
|
||||
expect(mockState.instances.at(-1)?.disconnect).toHaveBeenCalledWith({ quiet: true })
|
||||
expect(manager.getConnection(target.id)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('marks a dial that never reached the host, so setup reports SSH’s reason', async () => {
|
||||
const refused = new Error('connect ECONNREFUSED 10.0.0.1:22')
|
||||
mockState.connectResults.push(Promise.reject(refused))
|
||||
const manager = new SshConnectionManager({ onStateChange: vi.fn() })
|
||||
await expect(manager.connect(target)).rejects.toBe(refused)
|
||||
expect(isSshSessionStartFailure(new Error('Deploy failed', { cause: refused }))).toBe(true)
|
||||
})
|
||||
|
||||
it('removes the registration when bulk teardown fails', async () => {
|
||||
const manager = new SshConnectionManager({ onStateChange: vi.fn() })
|
||||
await manager.connect(target)
|
||||
mockState.instances[0].disconnect.mockRejectedValueOnce(new Error('close unconfirmed'))
|
||||
await manager.disconnectAll()
|
||||
expect(manager.getConnection(target.id)).toBeUndefined()
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
mockState.connectResults.length = 0
|
||||
mockState.instances.length = 0
|
||||
@@ -203,32 +179,6 @@ describe('SshConnectionManager', () => {
|
||||
expect(mockState.instances).toHaveLength(1)
|
||||
expect(manager.getConnection(target.id)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('leaves a connection whose own reconnect is asking the user alone', async () => {
|
||||
const manager = new SshConnectionManager({ onStateChange: vi.fn() })
|
||||
const conn = await manager.connect(target)
|
||||
const opened = mockState.instances.at(-1)!
|
||||
const openedCount = mockState.instances.length
|
||||
// Its reconnect after a drop has the credential dialog open.
|
||||
opened.status = 'connecting'
|
||||
opened.awaitingCredential = true
|
||||
|
||||
await expect(manager.connect(target)).rejects.toThrow('already in progress')
|
||||
|
||||
expect(opened.disconnect).not.toHaveBeenCalled()
|
||||
expect(mockState.instances).toHaveLength(openedCount)
|
||||
expect(manager.getConnection(target.id)).toBe(conn)
|
||||
})
|
||||
|
||||
it('clears the pool entry when the closed connection is still the registered one', async () => {
|
||||
const manager = new SshConnectionManager({ onStateChange: vi.fn() })
|
||||
const conn = await manager.connect(target)
|
||||
|
||||
await manager.disconnectConnection(target.id, conn)
|
||||
|
||||
expect(mockState.instances[0].disconnect).toHaveBeenCalledOnce()
|
||||
expect(manager.getConnection(target.id)).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('SshConnectionManager after a cancelled sign-in', () => {
|
||||
@@ -248,14 +198,6 @@ describe('SshConnectionManager after a cancelled sign-in', () => {
|
||||
expect(mockState.instances).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('still reuses a connection that is already up', async () => {
|
||||
const manager = new SshConnectionManager({ onStateChange: vi.fn() })
|
||||
const conn = await manager.connect(target)
|
||||
recordSshCredentialDeclined(target.id)
|
||||
|
||||
await expect(manager.connect(target)).resolves.toBe(conn)
|
||||
})
|
||||
|
||||
it('refuses a background dial to a host awaiting its first sign-in, saying so', async () => {
|
||||
const manager = new SshConnectionManager({ onStateChange: vi.fn() })
|
||||
recordSshSignInAwaited(target.id)
|
||||
@@ -311,11 +253,4 @@ describe('SshConnectionManager after a user Disconnect', () => {
|
||||
|
||||
await expect(manager.connect(target)).rejects.toThrow('was disconnected')
|
||||
})
|
||||
|
||||
it("dials a user's connection test", async () => {
|
||||
const manager = new SshConnectionManager({ onStateChange: vi.fn() })
|
||||
recordSshUserDisconnected(target.id)
|
||||
|
||||
await expect(manager.connect(target, { probe: true })).resolves.toBeDefined()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -129,17 +129,6 @@ async function run(
|
||||
}
|
||||
|
||||
describe('the relay-era orca CLI on migrated legacy orchestration records', () => {
|
||||
it('attests the bridge-verified worker instead of answering read-only', async () => {
|
||||
const { runtime, database } = legacyRuntime()
|
||||
expect(database.getLegacyAdoption()).toBeTruthy()
|
||||
const check = await run(runtime, ['orchestration', 'check', '--json'])
|
||||
expect(check.exitCode).toBe(0)
|
||||
expect(check.body).toMatchObject({
|
||||
ok: true,
|
||||
result: { legacyCompatibility: { readOnly: false } }
|
||||
})
|
||||
})
|
||||
|
||||
it('never lets a host pane adopt the unbound legacy coordinator by naming its handle', async () => {
|
||||
const { runtime, database, taskId, dispatchId } = legacyRuntime()
|
||||
const escalation = await run(runtime, escalationArgv(taskId, dispatchId))
|
||||
@@ -266,26 +255,4 @@ describe('the relay-era orca CLI on migrated legacy orchestration records', () =
|
||||
]
|
||||
await expect(run(runtime, fresh)).rejects.toThrow(LEGACY_RELAY_CLI_UNVERIFIED)
|
||||
})
|
||||
|
||||
it('vouches only for the exact terminal its attachment carries', () => {
|
||||
const runtime = new OrcaRuntimeService()
|
||||
const terminal = { handle: WORKER, paneKey: WORKER_PANE, processIncarnation: `${PTY_ID}:inc-1` }
|
||||
const host = runtime.registerOrchestrationCompatibilitySshAttachment(TARGET, 'c-1', terminal)
|
||||
const evidence = { terminalHandle: WORKER, paneKey: WORKER_PANE, launchToken: 't', host }
|
||||
expect(runtime.verifyOrchestrationCompatibilityCaller(evidence)).toMatchObject({
|
||||
terminalHandle: WORKER,
|
||||
processIncarnation: `${PTY_ID}:inc-1`,
|
||||
hostScope: { kind: 'ssh', targetId: TARGET }
|
||||
})
|
||||
for (const forged of [
|
||||
{ ...evidence, terminalHandle: 'term_other' },
|
||||
{ ...evidence, paneKey: 'tab_x:55555555-5555-4555-8555-555555555555' },
|
||||
{ ...evidence, host: { ...host, targetId: 'other-target' } },
|
||||
{ ...evidence, host: { ...host, connectionIncarnation: 'c-2' } }
|
||||
]) {
|
||||
expect(runtime.verifyOrchestrationCompatibilityCaller(forged)).toBeNull()
|
||||
}
|
||||
runtime.releaseOrchestrationCompatibilitySshAttachment(host.attachmentId)
|
||||
expect(runtime.verifyOrchestrationCompatibilityCaller(evidence)).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -4,7 +4,7 @@ import { join } from 'node:path'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { CONNECTOR_REPLY_UNAVAILABLE } from '../../../shared/rpc-contract/connector-params'
|
||||
import { toAppSshPtyId } from '../../../shared/ssh-pty-id'
|
||||
import { isRemoteCliControlAllowed } from '../../../shared/ssh-remote-cli-control'
|
||||
import '../../../shared/ssh-remote-cli-control'
|
||||
import { OrcaRuntimeService } from '../../runtime/orca-runtime'
|
||||
import { OrchestrationDb } from '../../runtime/orchestration/db/orchestration-db'
|
||||
import '../../runtime/orchestration/db/contract-constants'
|
||||
@@ -143,20 +143,6 @@ describe('the relay-era orca CLI on the desktop runtime', () => {
|
||||
expect(db.getTask(ids.taskId)?.status).toBe('dispatched')
|
||||
})
|
||||
|
||||
it.each([
|
||||
['no saved value', {}, true],
|
||||
['an explicit true', { allowRemoteCliControl: true }, true],
|
||||
['an explicit false', { allowRemoteCliControl: false }, false]
|
||||
])('admits per the saved switch: %s', async (_label, saved, admitted) => {
|
||||
allowed = isRemoteCliControlAllowed(saved)
|
||||
const check = run(['orchestration', 'check', '--json'])
|
||||
if (admitted) {
|
||||
expect((await check).exitCode).toBe(0)
|
||||
} else {
|
||||
await expect(check).rejects.toThrow(LEGACY_RELAY_CLI_CONTROL_OFF)
|
||||
}
|
||||
})
|
||||
|
||||
it('refuses commands outside orchestration', async () => {
|
||||
await expect(run(['worktree', 'list'])).rejects.toThrow(LEGACY_RELAY_CLI_UNSUPPORTED)
|
||||
await expect(run(['terminal', 'send', '--terminal', COORDINATOR])).rejects.toThrow(
|
||||
@@ -238,25 +224,6 @@ describe('the relay-era orca CLI on the desktop runtime', () => {
|
||||
expect(db.getInbox(100)).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('sends to its own coordinator', async () => {
|
||||
const sent = await run([
|
||||
'orchestration',
|
||||
'send',
|
||||
'--to',
|
||||
COORDINATOR,
|
||||
'--subject',
|
||||
'progress',
|
||||
'--json'
|
||||
])
|
||||
expect(json(sent)).toMatchObject({ ok: true })
|
||||
})
|
||||
|
||||
it('refuses a malformed post-output action', async () => {
|
||||
await expect(
|
||||
server().postOutput({ env: ENV, postOutput: { kind: 'other' } }, new AbortController().signal)
|
||||
).rejects.toThrow('Invalid SSH CLI post-output action.')
|
||||
})
|
||||
|
||||
it('replays a completed worker_done after its Dispatch settled, and nothing new', async () => {
|
||||
const retry = ['--retry-request', '11111111-1111-4111-8111-111111111111']
|
||||
const first = await run([...workerDone(), ...retry])
|
||||
@@ -269,23 +236,6 @@ describe('the relay-era orca CLI on the desktop runtime', () => {
|
||||
).rejects.toThrow(LEGACY_RELAY_CLI_UNVERIFIED)
|
||||
})
|
||||
|
||||
it('answers --help without running the command', async () => {
|
||||
for (const flag of ['--help', '-h']) {
|
||||
expect(await run([...workerDone(), flag])).toMatchObject({ exitCode: 0, stderr: '' })
|
||||
}
|
||||
expect(db.getTask(ids.taskId)?.status).toBe('dispatched')
|
||||
})
|
||||
|
||||
it('refuses an emptied --retry-request instead of starting a new mutation', async () => {
|
||||
for (const argv of [
|
||||
[...workerDone(), '--retry-request='],
|
||||
[...workerDone(), '--retry-request']
|
||||
]) {
|
||||
await expect(run(argv)).rejects.toThrow(/--retry-request/)
|
||||
}
|
||||
expect(db.getTask(ids.taskId)?.status).toBe('dispatched')
|
||||
})
|
||||
|
||||
it('replays a completed worker_done after the terminal took a new assignment', async () => {
|
||||
const retry = ['--retry-request', '44444444-4444-4444-8444-444444444444']
|
||||
const first = await run([...workerDone(), ...retry])
|
||||
|
||||
@@ -1,310 +0,0 @@
|
||||
import type * as ExecCommandModule from '../remote-exec/remote-exec-command'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { RELAY_LSOF_PROBE_JS } from '@orca/process-host/posix-lsof-probe'
|
||||
|
||||
const execCommand = vi.fn()
|
||||
vi.mock('../remote-exec/remote-exec-command', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof ExecCommandModule>()),
|
||||
execCommand: (...args: unknown[]) => execCommand(...args),
|
||||
isUnconfirmedSshCommandTermination: (error: unknown) =>
|
||||
(error as { sshChannelCloseConfirmed?: boolean } | null)?.sshChannelCloseConfirmed === false
|
||||
}))
|
||||
|
||||
import {
|
||||
isReapableRelayHusk,
|
||||
parseRelayEndpointIncumbentProbe,
|
||||
probeRelayEndpointIncumbent,
|
||||
relayEndpointIncumbentProbeCommand
|
||||
} from './ssh-relay-endpoint-incumbent'
|
||||
import { getRemoteHostPlatform } from '../ssh-remote-platform'
|
||||
import { unusedSshConnection } from '../unused-host-transport.test-fixture'
|
||||
|
||||
const connection = unusedSshConnection()
|
||||
|
||||
const SOCK = '/home/u/.orca-remote/relay-0.1.0+aaaa/relay-deadbeef.sock'
|
||||
const POSIX_HOST = getRemoteHostPlatform('linux-x64')
|
||||
const WINDOWS_HOST = getRemoteHostPlatform('win32-x64')
|
||||
|
||||
function probeOutput(lines: string[]): string {
|
||||
return ['ORCA-INCUMBENT-BEGIN', ...lines, 'ORCA-INCUMBENT-END'].join('\n')
|
||||
}
|
||||
|
||||
describe('parseRelayEndpointIncumbentProbe', () => {
|
||||
it('reports live when the socket accepted a connection', () => {
|
||||
const incumbent = parseRelayEndpointIncumbentProbe(
|
||||
SOCK,
|
||||
probeOutput([
|
||||
'PRESENT=yes',
|
||||
'LISTEN=accepted',
|
||||
'HOLDERS_SOURCE=lsof',
|
||||
'HOLDER=4242 yes 13 11'
|
||||
])
|
||||
)
|
||||
expect(incumbent.verdict).toBe('live')
|
||||
expect(incumbent.evidence).toBe('accepted-connection')
|
||||
expect(incumbent.holders).toEqual([
|
||||
{ pid: 4242, matchesRelayArgv: true, childCount: 13, unrecognizedChildCount: 11 }
|
||||
])
|
||||
})
|
||||
|
||||
it('reports live when a process still holds an inode that refuses connections', () => {
|
||||
const incumbent = parseRelayEndpointIncumbentProbe(
|
||||
SOCK,
|
||||
probeOutput(['PRESENT=yes', 'LISTEN=refused', 'HOLDERS_SOURCE=lsof', 'HOLDER=91 yes 2 2'])
|
||||
)
|
||||
expect(incumbent.verdict).toBe('live')
|
||||
expect(incumbent.evidence).toBe('holder-process')
|
||||
})
|
||||
|
||||
it('reports exited only when the connect was refused AND nothing holds the socket', () => {
|
||||
const incumbent = parseRelayEndpointIncumbentProbe(
|
||||
SOCK,
|
||||
probeOutput([
|
||||
'PRESENT=yes',
|
||||
'LISTEN=refused',
|
||||
'FS_TYPE=linux ef53 ef53',
|
||||
'HOLDERS_SOURCE=lsof'
|
||||
])
|
||||
)
|
||||
expect(incumbent.verdict).toBe('exited')
|
||||
expect(incumbent.evidence).toBe('no-holder')
|
||||
expect(incumbent.socketPresent).toBe(true)
|
||||
})
|
||||
|
||||
it('reports exited for a refused socket on a macOS boot-volume filesystem or Linux tmpfs', () => {
|
||||
for (const fsType of ['darwin 1a 1a', 'Linux 1021994 ef53', 'darwin-mount apfs']) {
|
||||
const incumbent = parseRelayEndpointIncumbentProbe(
|
||||
SOCK,
|
||||
probeOutput(['PRESENT=yes', 'LISTEN=refused', `FS_TYPE=${fsType}`, 'HOLDERS_SOURCE=lsof'])
|
||||
)
|
||||
expect(incumbent.verdict).toBe('exited')
|
||||
}
|
||||
})
|
||||
|
||||
// Shared-home sockets may be held on another kernel; local refusal/no-holder evidence cannot prove exit.
|
||||
it('reports unverifiable for a refused socket that another machine may hold', () => {
|
||||
for (const fsType of [
|
||||
'linux 6969 ef53', // nfs
|
||||
'linux ff534d42 ef53', // cifs
|
||||
'linux 65735546 ef53', // fuse (sshfs)
|
||||
'darwin 1c 1a', // macOS nfs/smbfs
|
||||
'darwin-mount nfs',
|
||||
'darwin-mount smbfs',
|
||||
'darwin-mount ', // no mount line matched
|
||||
'linux ', // statfs unavailable
|
||||
'freebsd 35 35'
|
||||
]) {
|
||||
const incumbent = parseRelayEndpointIncumbentProbe(
|
||||
SOCK,
|
||||
probeOutput(['PRESENT=yes', 'LISTEN=refused', `FS_TYPE=${fsType}`, 'HOLDERS_SOURCE=lsof'])
|
||||
)
|
||||
expect(incumbent.verdict).toBe('unverifiable')
|
||||
}
|
||||
const withoutType = parseRelayEndpointIncumbentProbe(
|
||||
SOCK,
|
||||
probeOutput(['PRESENT=yes', 'LISTEN=refused', 'HOLDERS_SOURCE=lsof'])
|
||||
)
|
||||
expect(withoutType.verdict).toBe('unverifiable')
|
||||
})
|
||||
|
||||
it('still reports exited for a socket path that no longer exists, on any filesystem', () => {
|
||||
const incumbent = parseRelayEndpointIncumbentProbe(
|
||||
SOCK,
|
||||
probeOutput(['PRESENT=no', 'LISTEN=absent', 'FS_TYPE=linux 6969 ef53', 'HOLDERS_SOURCE=lsof'])
|
||||
)
|
||||
expect(incumbent.verdict).toBe('exited')
|
||||
})
|
||||
|
||||
it('reports unverifiable when the host cannot enumerate socket holders', () => {
|
||||
const incumbent = parseRelayEndpointIncumbentProbe(
|
||||
SOCK,
|
||||
probeOutput(['PRESENT=yes', 'LISTEN=refused', 'HOLDERS_SOURCE=unavailable'])
|
||||
)
|
||||
expect(incumbent.verdict).toBe('unverifiable')
|
||||
expect(incumbent.holdersEnumerable).toBe(false)
|
||||
})
|
||||
|
||||
it('reports unverifiable when the connect probe timed out', () => {
|
||||
const incumbent = parseRelayEndpointIncumbentProbe(
|
||||
SOCK,
|
||||
probeOutput(['PRESENT=yes', 'LISTEN=unknown', 'HOLDERS_SOURCE=lsof'])
|
||||
)
|
||||
expect(incumbent.verdict).toBe('unverifiable')
|
||||
})
|
||||
|
||||
it('reports unverifiable for truncated or garbled probe output', () => {
|
||||
expect(parseRelayEndpointIncumbentProbe(SOCK, 'PRESENT=yes\nLISTEN=refused').verdict).toBe(
|
||||
'unverifiable'
|
||||
)
|
||||
expect(parseRelayEndpointIncumbentProbe(SOCK, '').verdict).toBe('unverifiable')
|
||||
})
|
||||
|
||||
it('drops holder lines that do not carry a usable pid', () => {
|
||||
const incumbent = parseRelayEndpointIncumbentProbe(
|
||||
SOCK,
|
||||
probeOutput([
|
||||
'PRESENT=yes',
|
||||
'LISTEN=refused',
|
||||
'FS_TYPE=linux ef53 ef53',
|
||||
'HOLDERS_SOURCE=lsof',
|
||||
'HOLDER=- no unknown unknown'
|
||||
])
|
||||
)
|
||||
expect(incumbent.holders).toEqual([])
|
||||
expect(incumbent.verdict).toBe('exited')
|
||||
})
|
||||
|
||||
it('keeps an unreadable child count as null rather than zero', () => {
|
||||
const [holder] = parseRelayEndpointIncumbentProbe(
|
||||
SOCK,
|
||||
probeOutput([
|
||||
'PRESENT=yes',
|
||||
'LISTEN=accepted',
|
||||
'HOLDERS_SOURCE=lsof',
|
||||
'HOLDER=7 yes unknown unknown'
|
||||
])
|
||||
).holders
|
||||
expect(holder.childCount).toBeNull()
|
||||
expect(holder.unrecognizedChildCount).toBeNull()
|
||||
})
|
||||
|
||||
it('keeps a holder line with no unrecognized-child field unreapable', () => {
|
||||
const incumbent = parseRelayEndpointIncumbentProbe(
|
||||
SOCK,
|
||||
probeOutput(['PRESENT=yes', 'LISTEN=accepted', 'HOLDERS_SOURCE=lsof', 'HOLDER=7 yes 0'])
|
||||
)
|
||||
expect(incumbent.holders[0].unrecognizedChildCount).toBeNull()
|
||||
expect(isReapableRelayHusk(incumbent)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('probeRelayEndpointIncumbent', () => {
|
||||
it('keeps the whole probe alive long enough to return a bounded lsof result', async () => {
|
||||
execCommand.mockResolvedValueOnce(
|
||||
probeOutput(['PRESENT=yes', 'LISTEN=refused', 'HOLDERS_SOURCE=unavailable'])
|
||||
)
|
||||
|
||||
await probeRelayEndpointIncumbent(connection, POSIX_HOST, '/usr/bin/node', SOCK)
|
||||
|
||||
expect(execCommand).toHaveBeenCalledWith(expect.anything(), expect.any(String), {
|
||||
wrapCommand: true,
|
||||
signal: undefined
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps a confirmed timeout or rejection unverifiable and unenumerable', async () => {
|
||||
execCommand.mockRejectedValueOnce(
|
||||
Object.assign(new Error('lsof timed out after 5s'), { sshChannelCloseConfirmed: true })
|
||||
)
|
||||
const incumbent = await probeRelayEndpointIncumbent(
|
||||
connection,
|
||||
POSIX_HOST,
|
||||
'/usr/bin/node',
|
||||
SOCK
|
||||
)
|
||||
expect(incumbent.verdict).toBe('unverifiable')
|
||||
expect(incumbent.holdersEnumerable).toBe(false)
|
||||
expect(incumbent.holders).toEqual([])
|
||||
})
|
||||
|
||||
it('rethrows an unconfirmed termination instead of masking it as unverifiable', async () => {
|
||||
const unconfirmed = Object.assign(new Error('remote channel close was not confirmed'), {
|
||||
sshChannelCloseConfirmed: false
|
||||
})
|
||||
execCommand.mockRejectedValueOnce(unconfirmed)
|
||||
|
||||
await expect(
|
||||
probeRelayEndpointIncumbent(connection, POSIX_HOST, '/usr/bin/node', SOCK)
|
||||
).rejects.toBe(unconfirmed)
|
||||
})
|
||||
|
||||
it('does not shell out on Windows hosts, where the endpoint is a named pipe', async () => {
|
||||
execCommand.mockClear()
|
||||
const incumbent = await probeRelayEndpointIncumbent(connection, WINDOWS_HOST, 'node.exe', SOCK)
|
||||
expect(execCommand).not.toHaveBeenCalled()
|
||||
expect(incumbent.verdict).toBe('unverifiable')
|
||||
})
|
||||
})
|
||||
|
||||
describe('relayEndpointIncumbentProbeCommand', () => {
|
||||
it('ANDs the lsof selectors so it cannot match unrelated unix-socket holders', () => {
|
||||
expect(RELAY_LSOF_PROBE_JS).toContain("['-t', '-a', '-U', process.argv[1]]")
|
||||
})
|
||||
|
||||
it('never unlinks the relay endpoint', () => {
|
||||
const command = relayEndpointIncumbentProbeCommand('/usr/bin/node', SOCK)
|
||||
expect(command).not.toMatch(/\brm\b/)
|
||||
})
|
||||
|
||||
it('bounds only lsof and keeps the connect-probe output available', () => {
|
||||
const command = relayEndpointIncumbentProbeCommand('/usr/bin/node', SOCK)
|
||||
expect(RELAY_LSOF_PROBE_JS).toContain("spawn('lsof'")
|
||||
expect(command).toContain('}, 5000)')
|
||||
expect(command).toContain("printf 'HOLDERS_SOURCE=unavailable\\n'")
|
||||
expect(command.indexOf("printf 'LISTEN=%s\\n'")).toBeLessThan(command.indexOf('child = spawn('))
|
||||
})
|
||||
})
|
||||
|
||||
describe('isReapableRelayHusk', () => {
|
||||
const husk = parseRelayEndpointIncumbentProbe(
|
||||
SOCK,
|
||||
probeOutput(['PRESENT=yes', 'LISTEN=accepted', 'HOLDERS_SOURCE=lsof', 'HOLDER=500 yes 0 0'])
|
||||
)
|
||||
|
||||
it('accepts a single proven relay holder with no unaccounted-for children', () => {
|
||||
expect(isReapableRelayHusk(husk)).toBe(true)
|
||||
})
|
||||
|
||||
it('accepts a relay whose only children are its own service processes (#13614)', () => {
|
||||
const withServices = parseRelayEndpointIncumbentProbe(
|
||||
SOCK,
|
||||
probeOutput(['PRESENT=yes', 'LISTEN=accepted', 'HOLDERS_SOURCE=lsof', 'HOLDER=500 yes 2 0'])
|
||||
)
|
||||
expect(withServices.holders[0].childCount).toBe(2)
|
||||
expect(isReapableRelayHusk(withServices)).toBe(true)
|
||||
})
|
||||
|
||||
it('refuses a relay that still holds children it could not account for', () => {
|
||||
expect(
|
||||
isReapableRelayHusk({
|
||||
...husk,
|
||||
holders: [{ pid: 500, matchesRelayArgv: true, childCount: 3, unrecognizedChildCount: 1 }]
|
||||
})
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('refuses a holder whose unrecognized-child count could not be read', () => {
|
||||
expect(
|
||||
isReapableRelayHusk({
|
||||
...husk,
|
||||
holders: [{ pid: 500, matchesRelayArgv: true, childCount: 0, unrecognizedChildCount: null }]
|
||||
})
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('refuses a holder whose argv is not this relay at this socket', () => {
|
||||
expect(
|
||||
isReapableRelayHusk({
|
||||
...husk,
|
||||
holders: [{ pid: 500, matchesRelayArgv: false, childCount: 0, unrecognizedChildCount: 0 }]
|
||||
})
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('refuses when more than one process holds the socket', () => {
|
||||
expect(
|
||||
isReapableRelayHusk({
|
||||
...husk,
|
||||
holders: [
|
||||
{ pid: 500, matchesRelayArgv: true, childCount: 0, unrecognizedChildCount: 0 },
|
||||
{ pid: 501, matchesRelayArgv: true, childCount: 0, unrecognizedChildCount: 0 }
|
||||
]
|
||||
})
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('refuses an unverifiable endpoint however empty it looks', () => {
|
||||
expect(isReapableRelayHusk({ ...husk, verdict: 'unverifiable' })).toBe(false)
|
||||
expect(isReapableRelayHusk({ ...husk, holdersEnumerable: false })).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -1,361 +0,0 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { EventEmitter } from 'node:events'
|
||||
import { delimiter } from 'node:path'
|
||||
import type { ChildProcessWithoutNullStreams } from 'node:child_process'
|
||||
import {
|
||||
_resetHydrateShellPathCache,
|
||||
hydrateShellPath,
|
||||
mergePathSegments,
|
||||
_setLaunchPathForTests,
|
||||
type HydrationResult
|
||||
} from './hydrate-shell-path'
|
||||
|
||||
const { spawnMock } = vi.hoisted(() => ({
|
||||
spawnMock: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('child_process', () => ({
|
||||
spawn: spawnMock
|
||||
}))
|
||||
|
||||
type HydrationSpawner = (shell: string) => Promise<HydrationResult>
|
||||
|
||||
function createMockShellProcess(): ChildProcessWithoutNullStreams {
|
||||
const proc = new EventEmitter() as ChildProcessWithoutNullStreams
|
||||
Object.assign(proc, {
|
||||
stdout: new EventEmitter(),
|
||||
stderr: new EventEmitter(),
|
||||
stdin: new EventEmitter(),
|
||||
kill: vi.fn()
|
||||
})
|
||||
return proc
|
||||
}
|
||||
|
||||
describe('hydrateShellPath', () => {
|
||||
const originalPath = process.env.PATH
|
||||
|
||||
beforeEach(() => {
|
||||
_resetHydrateShellPathCache()
|
||||
spawnMock.mockReset()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
if (originalPath === undefined) {
|
||||
delete process.env.PATH
|
||||
} else {
|
||||
process.env.PATH = originalPath
|
||||
}
|
||||
})
|
||||
|
||||
it('invokes the provided shell with a custom spawner and returns its segments', async () => {
|
||||
let capturedShell = ''
|
||||
const result = await hydrateShellPath({
|
||||
shellOverride: '/bin/zsh',
|
||||
spawner: async (shell) => {
|
||||
capturedShell = shell
|
||||
return {
|
||||
segments: ['/Users/tester/.opencode/bin', '/Users/tester/.cargo/bin'],
|
||||
ok: true,
|
||||
failureReason: 'none'
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
expect(capturedShell).toBe('/bin/zsh')
|
||||
expect(result.ok).toBe(true)
|
||||
expect(result.segments).toEqual(['/Users/tester/.opencode/bin', '/Users/tester/.cargo/bin'])
|
||||
expect(result.failureReason).toBe('none')
|
||||
})
|
||||
|
||||
it('caches the hydration result so repeated calls do not re-spawn', async () => {
|
||||
let spawnCount = 0
|
||||
const spawner: HydrationSpawner = async () => {
|
||||
spawnCount += 1
|
||||
return { segments: ['/a'], ok: true, failureReason: 'none' }
|
||||
}
|
||||
|
||||
await hydrateShellPath({ shellOverride: '/bin/zsh', spawner })
|
||||
await hydrateShellPath({ shellOverride: '/bin/zsh', spawner })
|
||||
await hydrateShellPath({ shellOverride: '/bin/zsh', spawner })
|
||||
|
||||
expect(spawnCount).toBe(1)
|
||||
})
|
||||
|
||||
it('re-spawns when force:true is passed — matches the Refresh button contract', async () => {
|
||||
let spawnCount = 0
|
||||
const spawner: HydrationSpawner = async () => {
|
||||
spawnCount += 1
|
||||
return { segments: ['/a'], ok: true, failureReason: 'none' }
|
||||
}
|
||||
|
||||
await hydrateShellPath({ shellOverride: '/bin/zsh', spawner })
|
||||
await hydrateShellPath({ shellOverride: '/bin/zsh', spawner, force: true })
|
||||
|
||||
expect(spawnCount).toBe(2)
|
||||
})
|
||||
|
||||
it('continues the probe queue after a rejected hydration', async () => {
|
||||
const rejectedSpawner = vi.fn<HydrationSpawner>(async () => {
|
||||
throw new Error('profile failed')
|
||||
})
|
||||
const successfulSpawner = vi.fn<HydrationSpawner>(async () => ({
|
||||
segments: ['/current'],
|
||||
ok: true,
|
||||
failureReason: 'none'
|
||||
}))
|
||||
|
||||
await expect(
|
||||
hydrateShellPath({ shellOverride: '/bin/zsh', spawner: rejectedSpawner, force: true })
|
||||
).rejects.toThrow('profile failed')
|
||||
await expect(
|
||||
hydrateShellPath({ shellOverride: '/bin/bash', spawner: successfulSpawner, force: true })
|
||||
).resolves.toEqual({ segments: ['/current'], ok: true, failureReason: 'none' })
|
||||
|
||||
expect(successfulSpawner).toHaveBeenCalledWith('/bin/bash')
|
||||
})
|
||||
|
||||
it('re-probes after a rejected hydration instead of serving the cached rejection', async () => {
|
||||
const spawner = vi.fn<HydrationSpawner>()
|
||||
spawner
|
||||
.mockRejectedValueOnce(new Error('profile failed'))
|
||||
.mockResolvedValueOnce({ segments: ['/retried'], ok: true, failureReason: 'none' })
|
||||
|
||||
await expect(hydrateShellPath({ shellOverride: '/bin/zsh', spawner })).rejects.toThrow(
|
||||
'profile failed'
|
||||
)
|
||||
await expect(hydrateShellPath({ shellOverride: '/bin/zsh', spawner })).resolves.toEqual({
|
||||
segments: ['/retried'],
|
||||
ok: true,
|
||||
failureReason: 'none'
|
||||
})
|
||||
expect(spawner).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('reports spawn_error instead of rejecting when spawn throws synchronously', async () => {
|
||||
spawnMock.mockImplementation(() => {
|
||||
throw Object.assign(new Error('spawn EACCES'), { code: 'EACCES' })
|
||||
})
|
||||
|
||||
await expect(hydrateShellPath({ shellOverride: '/bin/zsh', force: true })).resolves.toEqual({
|
||||
segments: [],
|
||||
ok: false,
|
||||
failureReason: 'spawn_error'
|
||||
})
|
||||
})
|
||||
|
||||
it('returns failureReason:no_shell when no shell is available (Windows path)', async () => {
|
||||
const result = await hydrateShellPath({
|
||||
shellOverride: null,
|
||||
spawner: async () => {
|
||||
throw new Error('spawner must not run when shell is null')
|
||||
}
|
||||
})
|
||||
|
||||
expect(result).toEqual({ segments: [], ok: false, failureReason: 'no_shell' })
|
||||
})
|
||||
|
||||
// Keep failure classifications distinct; the spawner override isolates propagation from real-shell classification.
|
||||
it('propagates failureReason:timeout from the spawner', async () => {
|
||||
const result = await hydrateShellPath({
|
||||
shellOverride: '/bin/zsh',
|
||||
spawner: async () => ({ segments: [], ok: false, failureReason: 'timeout' })
|
||||
})
|
||||
expect(result).toEqual({ segments: [], ok: false, failureReason: 'timeout' })
|
||||
})
|
||||
|
||||
it('propagates failureReason:spawn_error from the spawner', async () => {
|
||||
const result = await hydrateShellPath({
|
||||
shellOverride: '/bin/zsh',
|
||||
spawner: async () => ({ segments: [], ok: false, failureReason: 'spawn_error' })
|
||||
})
|
||||
expect(result).toEqual({ segments: [], ok: false, failureReason: 'spawn_error' })
|
||||
})
|
||||
|
||||
it('propagates failureReason:empty_path from the spawner', async () => {
|
||||
const result = await hydrateShellPath({
|
||||
shellOverride: '/bin/zsh',
|
||||
spawner: async () => ({ segments: [], ok: false, failureReason: 'empty_path' })
|
||||
})
|
||||
expect(result).toEqual({ segments: [], ok: false, failureReason: 'empty_path' })
|
||||
})
|
||||
|
||||
it('cleans up shell listeners when hydration times out', async () => {
|
||||
vi.useFakeTimers()
|
||||
const proc = createMockShellProcess()
|
||||
spawnMock.mockReturnValue(proc)
|
||||
|
||||
try {
|
||||
const resultPromise = hydrateShellPath({ shellOverride: '/bin/zsh', force: true })
|
||||
const assertion = expect(resultPromise).resolves.toEqual({
|
||||
segments: [],
|
||||
ok: false,
|
||||
failureReason: 'timeout'
|
||||
})
|
||||
|
||||
await vi.advanceTimersByTimeAsync(10_000)
|
||||
|
||||
await assertion
|
||||
expect(proc.kill).toHaveBeenCalledWith('SIGKILL')
|
||||
expect(proc.stdout.listenerCount('data')).toBe(0)
|
||||
expect(proc.listenerCount('error')).toBe(0)
|
||||
expect(proc.listenerCount('close')).toBe(0)
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
async function captureProbeEnv(shell = '/bin/bash'): Promise<NodeJS.ProcessEnv> {
|
||||
const proc = createMockShellProcess()
|
||||
spawnMock.mockReturnValue(proc)
|
||||
const resultPromise = hydrateShellPath({ shellOverride: shell, force: true })
|
||||
await vi.waitFor(() => expect(spawnMock).toHaveBeenCalled())
|
||||
proc.emit('close', 0)
|
||||
await resultPromise
|
||||
return spawnMock.mock.calls[0][2].env
|
||||
}
|
||||
|
||||
it('probes with the launch PATH so seeded fallbacks cannot pin nvm', async () => {
|
||||
process.env.PATH = `/home/u/.nvm/versions/node/v26.7.0/bin${delimiter}/usr/bin`
|
||||
_setLaunchPathForTests('/usr/bin')
|
||||
|
||||
expect((await captureProbeEnv()).PATH).toBe('/usr/bin')
|
||||
})
|
||||
|
||||
it('uses the PATH captured at module load, not a later mutation of process.env', async () => {
|
||||
// The probe must retain launch PATH even after packaged seeding mutates process.env.
|
||||
const seeded = `/seeded/newest-nvm/bin${delimiter}/usr/bin`
|
||||
process.env.PATH = seeded
|
||||
|
||||
expect((await captureProbeEnv()).PATH).not.toBe(seeded)
|
||||
})
|
||||
|
||||
it('sets a probe marker so rc files can take a fast path', async () => {
|
||||
expect((await captureProbeEnv()).ORCA_SHELL_PATH_PROBE).toBe('1')
|
||||
})
|
||||
|
||||
it.skipIf(process.platform === 'win32').each(['/bin/bash', '/bin/zsh'])(
|
||||
'keeps the POSIX login probe for %s',
|
||||
async (shell) => {
|
||||
await captureProbeEnv(shell)
|
||||
|
||||
expect(spawnMock.mock.calls[0][1]).toEqual([
|
||||
'-ilc',
|
||||
`printf '%s' '__ORCA_SHELL_PATH__'; printf '%s' "$PATH"; printf '%s' '__ORCA_SHELL_PATH__'`
|
||||
])
|
||||
}
|
||||
)
|
||||
|
||||
it.skipIf(process.platform === 'win32')(
|
||||
'reads Nushell PATH while preserving interactive login configuration',
|
||||
async () => {
|
||||
const proc = createMockShellProcess()
|
||||
spawnMock.mockReturnValue(proc)
|
||||
const resultPromise = hydrateShellPath({
|
||||
shellOverride: '/run/current-system/sw/bin/nu',
|
||||
force: true
|
||||
})
|
||||
await vi.waitFor(() => expect(spawnMock).toHaveBeenCalled())
|
||||
|
||||
expect(spawnMock.mock.calls[0][1]).toEqual([
|
||||
'-ilc',
|
||||
[
|
||||
"print -n '__ORCA_SHELL_PATH__';",
|
||||
'print -n ($env.PATH | str join (char esep));',
|
||||
"print -n '__ORCA_SHELL_PATH__'"
|
||||
].join(' ')
|
||||
])
|
||||
proc.stdout.emit(
|
||||
'data',
|
||||
Buffer.from('__ORCA_SHELL_PATH__/etc/profiles/per-user/me/bin:/usr/bin__ORCA_SHELL_PATH__')
|
||||
)
|
||||
proc.emit('close', 0)
|
||||
|
||||
await expect(resultPromise).resolves.toEqual({
|
||||
segments: ['/etc/profiles/per-user/me/bin', '/usr/bin'],
|
||||
ok: true,
|
||||
failureReason: 'none'
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
it('overwrites the captured key in place so Windows never carries both Path and PATH', async () => {
|
||||
vi.spyOn(process, 'platform', 'get').mockReturnValue('win32')
|
||||
const originalWindowsPath = process.env.Path
|
||||
delete process.env.PATH
|
||||
process.env.PATH = 'C:\\Users\\u\\.nvm\\versions\\node\\v22.9.0\\bin;C:\\Windows'
|
||||
process.env.Path = 'C:\\Users\\u\\.nvm\\versions\\node\\v22.9.0\\bin;C:\\Windows'
|
||||
_setLaunchPathForTests('C:\\Windows', 'Path')
|
||||
|
||||
try {
|
||||
const env = await captureProbeEnv('C:/Git/bin/bash.exe')
|
||||
expect(env.Path).toBe('C:\\Windows')
|
||||
expect(env.PATH).toBeUndefined()
|
||||
} finally {
|
||||
if (originalWindowsPath === undefined) {
|
||||
delete process.env.Path
|
||||
} else {
|
||||
process.env.Path = originalWindowsPath
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('mergePathSegments', () => {
|
||||
const originalPath = process.env.PATH
|
||||
|
||||
beforeEach(() => {
|
||||
_resetHydrateShellPathCache()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
if (originalPath === undefined) {
|
||||
delete process.env.PATH
|
||||
} else {
|
||||
process.env.PATH = originalPath
|
||||
}
|
||||
})
|
||||
|
||||
// Use the host delimiter so this suite also runs on Windows.
|
||||
const joinPath = (...segments: string[]): string => segments.join(delimiter)
|
||||
|
||||
it('prepends new segments ahead of existing PATH entries', () => {
|
||||
process.env.PATH = joinPath('/usr/bin', '/bin')
|
||||
|
||||
const added = mergePathSegments(['/Users/tester/.opencode/bin', '/Users/tester/.cargo/bin'])
|
||||
|
||||
expect(added).toEqual(['/Users/tester/.opencode/bin', '/Users/tester/.cargo/bin'])
|
||||
expect(process.env.PATH).toBe(
|
||||
joinPath('/Users/tester/.opencode/bin', '/Users/tester/.cargo/bin', '/usr/bin', '/bin')
|
||||
)
|
||||
})
|
||||
|
||||
it('promotes shell segments already on PATH so shell ordering wins', () => {
|
||||
process.env.PATH = joinPath('/Users/tester/.cargo/bin', '/usr/bin')
|
||||
|
||||
const added = mergePathSegments(['/Users/tester/.cargo/bin', '/Users/tester/.opencode/bin'])
|
||||
|
||||
expect(added).toEqual(['/Users/tester/.opencode/bin'])
|
||||
expect(process.env.PATH).toBe(
|
||||
joinPath('/Users/tester/.cargo/bin', '/Users/tester/.opencode/bin', '/usr/bin')
|
||||
)
|
||||
})
|
||||
|
||||
it('moves user-local shell paths ahead of packaged Homebrew fallbacks', () => {
|
||||
process.env.PATH = joinPath('/opt/homebrew/bin', '/Users/tester/.local/bin', '/usr/bin', '/bin')
|
||||
|
||||
const added = mergePathSegments(['/Users/tester/.local/bin', '/opt/homebrew/bin'])
|
||||
|
||||
expect(added).toEqual([])
|
||||
expect(process.env.PATH).toBe(
|
||||
joinPath('/Users/tester/.local/bin', '/opt/homebrew/bin', '/usr/bin', '/bin')
|
||||
)
|
||||
})
|
||||
|
||||
it('returns [] and leaves PATH untouched when given nothing', () => {
|
||||
process.env.PATH = joinPath('/usr/bin', '/bin')
|
||||
|
||||
expect(mergePathSegments([])).toEqual([])
|
||||
expect(process.env.PATH).toBe(joinPath('/usr/bin', '/bin'))
|
||||
})
|
||||
})
|
||||
@@ -1,6 +1,6 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { EventEmitter } from 'node:events'
|
||||
import type { ChildProcessWithoutNullStreams } from 'node:child_process'
|
||||
import 'node:events'
|
||||
|
||||
import {
|
||||
_resetHydrateShellPathCache,
|
||||
configureWindowsShellPathHydration,
|
||||
@@ -32,17 +32,6 @@ function deferred<T>(): Deferred<T> {
|
||||
return { promise, resolve }
|
||||
}
|
||||
|
||||
function createMockShellProcess(): ChildProcessWithoutNullStreams {
|
||||
const proc = new EventEmitter() as ChildProcessWithoutNullStreams
|
||||
Object.assign(proc, {
|
||||
stdout: new EventEmitter(),
|
||||
stderr: new EventEmitter(),
|
||||
stdin: new EventEmitter(),
|
||||
kill: vi.fn()
|
||||
})
|
||||
return proc
|
||||
}
|
||||
|
||||
describe('Windows shell PATH hydration', () => {
|
||||
const originalPath = process.env.PATH
|
||||
|
||||
@@ -61,171 +50,6 @@ describe('Windows shell PATH hydration', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('hydrates the default PowerShell profile PATH on Windows (#13328)', async () => {
|
||||
const spawner = vi.fn<HydrationSpawner>(async () => ({
|
||||
segments: ['C:\\Users\\tester\\AppData\\Local\\fnm'],
|
||||
ok: true,
|
||||
failureReason: 'none'
|
||||
}))
|
||||
|
||||
await expect(hydrateShellPath({ spawner })).resolves.toEqual({
|
||||
segments: ['C:\\Users\\tester\\AppData\\Local\\fnm'],
|
||||
ok: true,
|
||||
failureReason: 'none'
|
||||
})
|
||||
expect(spawner).toHaveBeenCalledWith('powershell.exe')
|
||||
})
|
||||
|
||||
it('uses the configured PowerShell executable', async () => {
|
||||
const spawner = vi.fn<HydrationSpawner>(async () => ({
|
||||
segments: ['C:\\tools'],
|
||||
ok: true,
|
||||
failureReason: 'none'
|
||||
}))
|
||||
|
||||
configureWindowsShellPathHydration('C:\\Program Files\\PowerShell\\7\\pwsh.exe')
|
||||
await hydrateShellPath({ spawner })
|
||||
|
||||
expect(spawner).toHaveBeenCalledWith('C:\\Program Files\\PowerShell\\7\\pwsh.exe')
|
||||
})
|
||||
|
||||
it('falls back to Windows PowerShell when preferred pwsh cannot spawn', async () => {
|
||||
const spawner = vi.fn<HydrationSpawner>()
|
||||
spawner
|
||||
.mockResolvedValueOnce({ segments: [], ok: false, failureReason: 'spawn_error' })
|
||||
.mockResolvedValueOnce({
|
||||
segments: ['C:\\WindowsPowerShell-profile'],
|
||||
ok: true,
|
||||
failureReason: 'none'
|
||||
})
|
||||
configureWindowsShellPathHydration('pwsh.exe', null, 'powershell.exe')
|
||||
|
||||
await expect(hydrateShellPath({ spawner })).resolves.toEqual({
|
||||
segments: ['C:\\WindowsPowerShell-profile'],
|
||||
ok: true,
|
||||
failureReason: 'none'
|
||||
})
|
||||
expect(spawner.mock.calls).toEqual([['pwsh.exe'], ['powershell.exe']])
|
||||
})
|
||||
|
||||
it('falls back to Windows PowerShell when pwsh throws synchronously at spawn', async () => {
|
||||
const proc = createMockShellProcess()
|
||||
spawnMock
|
||||
.mockImplementationOnce(() => {
|
||||
throw Object.assign(new Error('spawn EPERM'), { code: 'EPERM' })
|
||||
})
|
||||
.mockReturnValueOnce(proc)
|
||||
configureWindowsShellPathHydration('pwsh.exe', null, 'powershell.exe')
|
||||
|
||||
const resultPromise = hydrateShellPath()
|
||||
await vi.waitFor(() => expect(spawnMock).toHaveBeenCalledTimes(2))
|
||||
proc.stdout.emit('data', Buffer.from('__ORCA_SHELL_PATH__C:\\inbox-ps__ORCA_SHELL_PATH__'))
|
||||
proc.emit('close', 0, null)
|
||||
|
||||
await expect(resultPromise).resolves.toEqual({
|
||||
segments: ['C:\\inbox-ps'],
|
||||
ok: true,
|
||||
failureReason: 'none'
|
||||
})
|
||||
expect(spawnMock.mock.calls.map((call) => call[0])).toEqual(['pwsh.exe', 'powershell.exe'])
|
||||
})
|
||||
|
||||
it('reports spawn_error when every shell throws synchronously at spawn', async () => {
|
||||
spawnMock.mockImplementation(() => {
|
||||
throw Object.assign(new Error('spawn EPERM'), { code: 'EPERM' })
|
||||
})
|
||||
configureWindowsShellPathHydration('pwsh.exe', null, 'powershell.exe')
|
||||
|
||||
await expect(hydrateShellPath()).resolves.toEqual({
|
||||
segments: [],
|
||||
ok: false,
|
||||
failureReason: 'spawn_error'
|
||||
})
|
||||
})
|
||||
|
||||
it('uses the resolved Git Bash executable for the configured sentinel', async () => {
|
||||
const spawner = vi.fn<HydrationSpawner>(async () => ({
|
||||
segments: ['C:\\tools'],
|
||||
ok: true,
|
||||
failureReason: 'none'
|
||||
}))
|
||||
|
||||
configureWindowsShellPathHydration('git-bash', 'C:\\Program Files\\Git\\bin\\bash.exe')
|
||||
await hydrateShellPath({ spawner })
|
||||
|
||||
expect(spawner).toHaveBeenCalledWith('C:\\Program Files\\Git\\bin\\bash.exe')
|
||||
})
|
||||
|
||||
it('loads PowerShell profiles and parses their Windows PATH', async () => {
|
||||
const proc = createMockShellProcess()
|
||||
spawnMock.mockReturnValue(proc)
|
||||
const resultPromise = hydrateShellPath({ force: true })
|
||||
await vi.waitFor(() => expect(spawnMock).toHaveBeenCalledOnce())
|
||||
|
||||
proc.stdout.emit(
|
||||
'data',
|
||||
Buffer.from(
|
||||
'profile banner\r\n__ORCA_SHELL_PATH__C:\\profile-node;C:\\Windows__ORCA_SHELL_PATH__'
|
||||
)
|
||||
)
|
||||
proc.emit('close', 0, null)
|
||||
|
||||
await expect(resultPromise).resolves.toEqual({
|
||||
segments: ['C:\\profile-node', 'C:\\Windows'],
|
||||
ok: true,
|
||||
failureReason: 'none'
|
||||
})
|
||||
expect(spawnMock).toHaveBeenCalledWith(
|
||||
'powershell.exe',
|
||||
['-NoLogo', '-Command', expect.stringContaining('$env:Path')],
|
||||
expect.objectContaining({ windowsHide: true })
|
||||
)
|
||||
expect(spawnMock.mock.calls[0]?.[1]).not.toContain('-NoProfile')
|
||||
})
|
||||
|
||||
it('converts a Git Bash PATH to Windows segments before parsing it', async () => {
|
||||
const proc = createMockShellProcess()
|
||||
spawnMock.mockReturnValue(proc)
|
||||
const resultPromise = hydrateShellPath({
|
||||
shellOverride: 'C:\\Program Files\\Git\\bin\\bash.exe',
|
||||
force: true
|
||||
})
|
||||
await vi.waitFor(() => expect(spawnMock).toHaveBeenCalledOnce())
|
||||
|
||||
proc.stdout.emit(
|
||||
'data',
|
||||
Buffer.from('__ORCA_SHELL_PATH__C:\\git-node;C:\\Windows__ORCA_SHELL_PATH__')
|
||||
)
|
||||
proc.emit('close', 0, null)
|
||||
|
||||
await expect(resultPromise).resolves.toEqual({
|
||||
segments: ['C:\\git-node', 'C:\\Windows'],
|
||||
ok: true,
|
||||
failureReason: 'none'
|
||||
})
|
||||
expect(spawnMock).toHaveBeenCalledWith(
|
||||
'C:\\Program Files\\Git\\bin\\bash.exe',
|
||||
['-ilc', expect.stringContaining('cygpath -wp "$PATH"')],
|
||||
expect.objectContaining({ windowsHide: true })
|
||||
)
|
||||
})
|
||||
|
||||
it.each(['cmd.exe', 'wsl.exe', 'git-bash', 'C:\\Tools\\zsh.exe'])(
|
||||
'does not merge %s guest or profile-less paths',
|
||||
async (shell) => {
|
||||
const spawner = vi.fn<HydrationSpawner>()
|
||||
|
||||
configureWindowsShellPathHydration(shell)
|
||||
|
||||
await expect(hydrateShellPath({ spawner })).resolves.toEqual({
|
||||
segments: [],
|
||||
ok: false,
|
||||
failureReason: 'no_shell'
|
||||
})
|
||||
expect(spawner).not.toHaveBeenCalled()
|
||||
}
|
||||
)
|
||||
|
||||
it('serializes shell changes, discards stale results, and caches the newest request', async () => {
|
||||
const powerShellResult = deferred<HydrationResult>()
|
||||
const gitBashResult = deferred<HydrationResult>()
|
||||
@@ -297,20 +121,6 @@ describe('Windows shell PATH hydration', () => {
|
||||
expect(spawner).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('deduplicates PATH entries case-insensitively', () => {
|
||||
process.env.PATH = 'C:\\Tools;C:\\Windows'
|
||||
|
||||
expect(mergePathSegments(['c:\\tools', 'C:\\profile-node'])).toEqual(['C:\\profile-node'])
|
||||
expect(process.env.PATH).toBe('c:\\tools;C:\\profile-node;C:\\Windows')
|
||||
})
|
||||
|
||||
it('deduplicates PATH entries with trailing slashes', () => {
|
||||
process.env.PATH = 'C:\\Windows\\;C:\\Tools'
|
||||
|
||||
expect(mergePathSegments(['C:\\Windows', 'C:\\profile-node\\'])).toEqual(['C:\\profile-node\\'])
|
||||
expect(process.env.PATH).toBe('C:\\Windows;C:\\profile-node\\;C:\\Tools')
|
||||
})
|
||||
|
||||
it('removes profile entries when switching to a shell without a profile', () => {
|
||||
process.env.PATH = 'C:\\Inherited;C:\\Windows'
|
||||
mergePathSegments(['C:\\PowerShell-profile', 'C:\\Inherited'])
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { prepareLocalCommitMessageAgentEnv } from './commit-message-agent-environment'
|
||||
import { ManagedCodexHomeTemporarilyUnavailableError } from '../codex-accounts/host-codex-managed-home-ownership'
|
||||
|
||||
@@ -51,20 +51,6 @@ describe('prepareLocalCommitMessageAgentEnv', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('prefers the original OpenCode config root over inherited PTY overlays', async () => {
|
||||
process.env.OPENCODE_CONFIG_DIR = '/tmp/orca-opencode-overlay'
|
||||
process.env.ORCA_OPENCODE_SOURCE_CONFIG_DIR = '/Users/tester/company/opencode'
|
||||
|
||||
const result = await prepareLocalCommitMessageAgentEnv('opencode', undefined)
|
||||
|
||||
expect(result).toEqual({
|
||||
ok: true,
|
||||
env: expect.objectContaining({
|
||||
OPENCODE_CONFIG_DIR: '/Users/tester/company/opencode'
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('hydrates Pi agent dir from shell startup files for headless generation', async () => {
|
||||
const home = makeHome()
|
||||
delete process.env.PI_CODING_AGENT_DIR
|
||||
@@ -80,51 +66,6 @@ describe('prepareLocalCommitMessageAgentEnv', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('prefers the original Pi agent root over inherited PTY overlays', async () => {
|
||||
process.env.PI_CODING_AGENT_DIR = '/tmp/orca-pi-overlay'
|
||||
process.env.ORCA_PI_SOURCE_AGENT_DIR = '/Users/tester/.pi/agent'
|
||||
|
||||
const result = await prepareLocalCommitMessageAgentEnv('pi', undefined)
|
||||
|
||||
expect(result).toEqual({
|
||||
ok: true,
|
||||
env: expect.objectContaining({
|
||||
PI_CODING_AGENT_DIR: '/Users/tester/.pi/agent'
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('does not synthesize env for agents without shell-scoped auth or config roots', async () => {
|
||||
makeHome()
|
||||
|
||||
await expect(prepareLocalCommitMessageAgentEnv('cursor', undefined)).resolves.toEqual({
|
||||
ok: true
|
||||
})
|
||||
})
|
||||
|
||||
it('falls back to inherited env when managed account resolvers are unavailable', async () => {
|
||||
await expect(prepareLocalCommitMessageAgentEnv('codex', undefined)).resolves.toEqual({
|
||||
ok: true
|
||||
})
|
||||
await expect(prepareLocalCommitMessageAgentEnv('claude', undefined)).resolves.toEqual({
|
||||
ok: true
|
||||
})
|
||||
})
|
||||
|
||||
it('sets CODEX_HOME for host managed Codex accounts', async () => {
|
||||
const result = await prepareLocalCommitMessageAgentEnv('codex', {
|
||||
prepareForCodexLaunch: () =>
|
||||
'C:\\Users\\tester\\AppData\\Roaming\\Orca\\codex-accounts\\a\\home'
|
||||
})
|
||||
|
||||
expect(result).toEqual({
|
||||
ok: true,
|
||||
env: expect.objectContaining({
|
||||
CODEX_HOME: 'C:\\Users\\tester\\AppData\\Roaming\\Orca\\codex-accounts\\a\\home'
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// STA-4422: failed managed-home preparation must not fall through to the user's real account while UI shows a managed account.
|
||||
it('fails commit generation instead of building a system-account env when the managed home is unreadable', async () => {
|
||||
process.env.CODEX_HOME = '/home/me/.config/codex'
|
||||
@@ -157,180 +98,4 @@ describe('prepareLocalCommitMessageAgentEnv', () => {
|
||||
expect(env?.CODEX_HOME).toBeUndefined()
|
||||
expect(env?.ORCA_CODEX_HOME).toBeUndefined()
|
||||
})
|
||||
|
||||
it('preserves a user-owned CODEX_HOME when the launch resolves to the real home', async () => {
|
||||
process.env.CODEX_HOME = '/home/me/.config/codex'
|
||||
delete process.env.ORCA_CODEX_HOME
|
||||
|
||||
const result = await prepareLocalCommitMessageAgentEnv('codex', {
|
||||
prepareForCodexLaunch: () => null
|
||||
})
|
||||
|
||||
expect(result.ok).toBe(true)
|
||||
const env = (result as { ok: true; env?: NodeJS.ProcessEnv }).env
|
||||
expect(env?.CODEX_HOME).toBe('/home/me/.config/codex')
|
||||
})
|
||||
|
||||
it('does not pass WSL managed Codex homes to host-local commit generation', async () => {
|
||||
process.env.CODEX_HOME = 'C:\\Users\\tester\\.codex'
|
||||
|
||||
const result = await prepareLocalCommitMessageAgentEnv('codex', {
|
||||
prepareForCodexLaunch: () =>
|
||||
'\\\\wsl.localhost\\Ubuntu\\home\\tester\\.local\\share\\orca\\codex-accounts\\a\\home'
|
||||
})
|
||||
|
||||
expect(result).toEqual({ ok: true })
|
||||
})
|
||||
|
||||
it('passes WSL managed Codex homes as Linux paths for WSL-local commit generation', async () => {
|
||||
const result = await prepareLocalCommitMessageAgentEnv(
|
||||
'codex',
|
||||
{
|
||||
prepareForCodexLaunch: (target) => {
|
||||
expect(target).toEqual({ runtime: 'wsl', wslDistro: 'Ubuntu' })
|
||||
return '\\\\wsl.localhost\\Ubuntu\\home\\tester\\.codex'
|
||||
}
|
||||
},
|
||||
{ runtime: 'wsl', wslDistro: 'Ubuntu' }
|
||||
)
|
||||
|
||||
expect(result).toEqual({
|
||||
ok: true,
|
||||
env: expect.objectContaining({
|
||||
CODEX_HOME: '/home/tester/.codex'
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('does not hydrate host shell config roots for WSL-local commit generation', async () => {
|
||||
process.env.OPENCODE_CONFIG_DIR = 'C:\\Users\\tester\\opencode'
|
||||
|
||||
const result = await prepareLocalCommitMessageAgentEnv('opencode', undefined, {
|
||||
runtime: 'wsl',
|
||||
wslDistro: 'Ubuntu'
|
||||
})
|
||||
|
||||
expect(result).toEqual({ ok: true })
|
||||
})
|
||||
|
||||
describe('login-shell base environment', () => {
|
||||
const envOf = (
|
||||
result: Awaited<ReturnType<typeof prepareLocalCommitMessageAgentEnv>>
|
||||
): NodeJS.ProcessEnv | undefined => (result.ok ? result.env : undefined)
|
||||
const shellBase = {
|
||||
PATH: '/shell/bin',
|
||||
ANTHROPIC_BASE_URL: 'https://proxy.example',
|
||||
ANTHROPIC_AUTH_TOKEN: 'shell-token'
|
||||
}
|
||||
|
||||
it('gives Claude the shell-exported variables Orca was launched without', async () => {
|
||||
delete process.env.ANTHROPIC_BASE_URL
|
||||
delete process.env.ANTHROPIC_AUTH_TOKEN
|
||||
|
||||
const result = await prepareLocalCommitMessageAgentEnv('claude', {
|
||||
resolveBaseEnvironment: async () => shellBase,
|
||||
prepareForClaudeLaunch: async () => ({
|
||||
configDir: '/home/me/.claude',
|
||||
envPatch: {},
|
||||
provenance: 'system'
|
||||
})
|
||||
})
|
||||
|
||||
expect(result).toEqual({ ok: true, env: shellBase })
|
||||
})
|
||||
|
||||
it('lets the selected Claude account set its folder and keeps the shell auth', async () => {
|
||||
const result = await prepareLocalCommitMessageAgentEnv('claude', {
|
||||
resolveBaseEnvironment: async () => shellBase,
|
||||
prepareForClaudeLaunch: async () => ({
|
||||
configDir: '/managed/claude',
|
||||
envPatch: { CLAUDE_CONFIG_DIR: '/managed/claude' },
|
||||
provenance: 'managed:account-1'
|
||||
})
|
||||
})
|
||||
|
||||
const env = envOf(result)
|
||||
expect(env?.CLAUDE_CONFIG_DIR).toBe('/managed/claude')
|
||||
expect(env?.ANTHROPIC_AUTH_TOKEN).toBe('shell-token')
|
||||
expect(env?.PATH).toBe('/shell/bin')
|
||||
})
|
||||
|
||||
it('lets the managed Codex home override a shell CODEX_HOME', async () => {
|
||||
const result = await prepareLocalCommitMessageAgentEnv('codex', {
|
||||
resolveBaseEnvironment: async () => ({ ...shellBase, CODEX_HOME: '/shell/codex' }),
|
||||
prepareForCodexLaunch: () => '/managed/codex-home'
|
||||
})
|
||||
|
||||
expect(result).toEqual({
|
||||
ok: true,
|
||||
env: { ...shellBase, CODEX_HOME: '/managed/codex-home' }
|
||||
})
|
||||
})
|
||||
|
||||
it('gives agents without an account resolver the shell environment', async () => {
|
||||
makeHome()
|
||||
delete process.env.OPENCODE_CONFIG_DIR
|
||||
|
||||
await expect(
|
||||
prepareLocalCommitMessageAgentEnv('cursor', {
|
||||
resolveBaseEnvironment: async () => shellBase
|
||||
})
|
||||
).resolves.toEqual({ ok: true, env: shellBase })
|
||||
await expect(
|
||||
prepareLocalCommitMessageAgentEnv('opencode', {
|
||||
resolveBaseEnvironment: async () => shellBase
|
||||
})
|
||||
).resolves.toEqual({ ok: true, env: shellBase })
|
||||
})
|
||||
|
||||
it("prefers the login shell's config dir over Orca's inherited one", async () => {
|
||||
makeHome()
|
||||
process.env.OPENCODE_CONFIG_DIR = '/old/opencode'
|
||||
delete process.env.ORCA_OPENCODE_SOURCE_CONFIG_DIR
|
||||
|
||||
const result = await prepareLocalCommitMessageAgentEnv('opencode', {
|
||||
resolveBaseEnvironment: async () => ({ ...shellBase, OPENCODE_CONFIG_DIR: '/new/opencode' })
|
||||
})
|
||||
|
||||
expect(envOf(result)?.OPENCODE_CONFIG_DIR).toBe('/new/opencode')
|
||||
})
|
||||
|
||||
it('keeps Orca env for WSL-local generation instead of the host shell', async () => {
|
||||
delete process.env.ANTHROPIC_BASE_URL
|
||||
const resolveBaseEnvironment = vi.fn(async () => shellBase)
|
||||
|
||||
const codex = await prepareLocalCommitMessageAgentEnv(
|
||||
'codex',
|
||||
{ resolveBaseEnvironment, prepareForCodexLaunch: () => null },
|
||||
{ runtime: 'wsl', wslDistro: 'Ubuntu' }
|
||||
)
|
||||
const cursor = await prepareLocalCommitMessageAgentEnv(
|
||||
'cursor',
|
||||
{ resolveBaseEnvironment },
|
||||
{ runtime: 'wsl', wslDistro: 'Ubuntu' }
|
||||
)
|
||||
|
||||
expect(resolveBaseEnvironment).not.toHaveBeenCalled()
|
||||
expect(envOf(codex)?.ANTHROPIC_BASE_URL).toBeUndefined()
|
||||
expect(cursor).toEqual({ ok: true })
|
||||
})
|
||||
|
||||
it('falls back to Orca env when the shell environment cannot be read', async () => {
|
||||
process.env.ORCA_COMMIT_ENV_MARKER = 'process'
|
||||
|
||||
const result = await prepareLocalCommitMessageAgentEnv('codex', {
|
||||
resolveBaseEnvironment: async () => {
|
||||
throw new Error('settings unavailable')
|
||||
},
|
||||
prepareForCodexLaunch: () => '/managed/codex-home'
|
||||
})
|
||||
|
||||
expect(envOf(result)).toEqual(
|
||||
expect.objectContaining({
|
||||
ORCA_COMMIT_ENV_MARKER: 'process',
|
||||
CODEX_HOME: '/managed/codex-home'
|
||||
})
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,327 +0,0 @@
|
||||
import { sourceShapedKimiOutput } from '../../shared/__fixtures__/kimi-generation-source-shaped'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { ChildProcess } from 'node:child_process'
|
||||
import { PassThrough } from 'node:stream'
|
||||
import { generateCommitMessage } from './source-control-text-generation-requests'
|
||||
import type {
|
||||
CommitMessageGenerationTarget,
|
||||
SpawnSourceControlAgent
|
||||
} from './source-control-text-generation-types'
|
||||
import { getAgentModelProbeSpec } from '../../shared/agent-model-probe-spec'
|
||||
import { planModelDiscovery } from './commit-message-model-discovery-policy'
|
||||
import { planCommitMessageGeneration } from '../../shared/source-control/ai/generation/commit-message-plan'
|
||||
import type { CommitMessagePlan } from '../../shared/source-control/ai/generation/commit-message-plan'
|
||||
import { generateJiraIssueSummaryFromContext } from './jira-issue-summary-text-generation'
|
||||
|
||||
function remoteTarget(execute: (plan: CommitMessagePlan) => Promise<string>) {
|
||||
return {
|
||||
kind: 'remote' as const,
|
||||
cwd: '/home/user',
|
||||
missingBinaryLocation: 'remote PATH',
|
||||
execute: async (plan: CommitMessagePlan) => ({
|
||||
stdout: await execute(plan),
|
||||
stderr: '',
|
||||
exitCode: 0,
|
||||
timedOut: false
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
describe('generateJiraIssueSummaryFromContext', () => {
|
||||
it.each(['custom', 'kimi'] as const)('%s Jira summary', async (agentId) => {
|
||||
const result = await generateJiraIssueSummaryFromContext(
|
||||
{ description: 'Login crashes when retried offline.' },
|
||||
{ agentId, model: agentId === 'kimi' ? 'default' : '', customAgentCommand: 'agent' },
|
||||
remoteTarget(async () =>
|
||||
agentId === 'kimi'
|
||||
? sourceShapedKimiOutput('"Fix offline login retry crash"\nextra commentary')
|
||||
: '"Fix offline login retry crash"\nextra commentary'
|
||||
)
|
||||
)
|
||||
|
||||
expect(result).toEqual({
|
||||
success: true,
|
||||
summary: 'Fix offline login retry crash',
|
||||
agentLabel: agentId === 'kimi' ? 'Kimi' : 'agent'
|
||||
})
|
||||
})
|
||||
|
||||
it('sends its own Jira prompt and ignores source-control templates and instructions', async () => {
|
||||
let prompt = ''
|
||||
await generateJiraIssueSummaryFromContext(
|
||||
{
|
||||
description: 'Crash on save',
|
||||
projectName: 'Mobile App',
|
||||
issueTypeName: 'Bug'
|
||||
},
|
||||
{
|
||||
agentId: 'custom',
|
||||
model: '',
|
||||
customAgentCommand: 'agent',
|
||||
customPrompt: 'Prefer kebab-case names.',
|
||||
commandInputTemplate: 'Prefer kebab-case names.\n\n{basePrompt}'
|
||||
},
|
||||
remoteTarget(async (plan) => {
|
||||
prompt = plan.stdinPayload ?? ''
|
||||
return 'Fix crash on save'
|
||||
})
|
||||
)
|
||||
|
||||
expect(prompt).toContain('Generate a concise Jira issue summary')
|
||||
expect(prompt).toContain('Project: <project>Mobile App</project>')
|
||||
expect(prompt).toContain('Issue type: <issue_type>Bug</issue_type>')
|
||||
expect(prompt).toContain('Crash on save')
|
||||
expect(prompt).not.toContain('kebab-case')
|
||||
})
|
||||
|
||||
it('fails when the output sanitizes to an empty summary', async () => {
|
||||
const result = await generateJiraIssueSummaryFromContext(
|
||||
{ description: 'Crash on save' },
|
||||
{ agentId: 'custom', model: '', customAgentCommand: 'agent' },
|
||||
remoteTarget(async () => '""\n')
|
||||
)
|
||||
|
||||
expect(result).toMatchObject({
|
||||
success: false,
|
||||
error: 'Generated summary was empty after sanitization.'
|
||||
})
|
||||
})
|
||||
|
||||
it('propagates agent failures', async () => {
|
||||
const result = await generateJiraIssueSummaryFromContext(
|
||||
{ description: 'Crash on save' },
|
||||
{ agentId: 'custom', model: '', customAgentCommand: 'agent' },
|
||||
{
|
||||
kind: 'remote',
|
||||
cwd: '/home/user',
|
||||
missingBinaryLocation: 'remote PATH',
|
||||
execute: async () => ({
|
||||
stdout: '',
|
||||
stderr: 'No API key found.',
|
||||
exitCode: 1,
|
||||
timedOut: false
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
expect(result).toMatchObject({
|
||||
success: false,
|
||||
error: 'agent CLI command failed with code 1: No API key found.'
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// One owner must cover Jira and ordinary generation through both existing transports.
|
||||
it.each(['local', 'remote'] as const)(
|
||||
'retries the same Claude invocation for Jira and commits on %s',
|
||||
async (kind) => {
|
||||
for (const operation of ['jira-issue-summary', 'commit-message'] as const) {
|
||||
const calls: { args: string[]; other: unknown }[] = []
|
||||
const response = (args: string[]) => {
|
||||
const refused = args.includes('--strict-mcp-config')
|
||||
return {
|
||||
stdout: refused ? '' : 'Fix crash',
|
||||
stderr: refused
|
||||
? 'You cannot use --strict-mcp-config when an enterprise MCP config is present'
|
||||
: '',
|
||||
exitCode: refused ? 1 : 0,
|
||||
timedOut: false
|
||||
}
|
||||
}
|
||||
const spawnAgent: SpawnSourceControlAgent = ({ args, ...other }) => {
|
||||
calls.push({ args, other })
|
||||
const child = new (class extends ChildProcess {
|
||||
override stdin = new PassThrough()
|
||||
override stdout = new PassThrough()
|
||||
override stderr = new PassThrough()
|
||||
})()
|
||||
queueMicrotask(() => {
|
||||
const output = response(args)
|
||||
child.stdout.emit('data', Buffer.from(output.stdout))
|
||||
child.stderr.emit('data', Buffer.from(output.stderr))
|
||||
child.emit('close', output.exitCode)
|
||||
})
|
||||
return child
|
||||
}
|
||||
const target: CommitMessageGenerationTarget =
|
||||
kind === 'local'
|
||||
? { kind, cwd: '/repo' }
|
||||
: {
|
||||
kind,
|
||||
cwd: '/repo',
|
||||
missingBinaryLocation: 'remote PATH',
|
||||
execute: async ({ args, ...other }, cwd, timeout, op) => {
|
||||
expect(op).toBe(operation)
|
||||
calls.push({ args, other: { ...other, cwd, timeout, op } })
|
||||
return response(args)
|
||||
}
|
||||
}
|
||||
const params = { agentId: 'claude' as const, model: 'haiku' }
|
||||
const result =
|
||||
operation === 'jira-issue-summary'
|
||||
? await generateJiraIssueSummaryFromContext(
|
||||
{ description: 'Crash on save' },
|
||||
params,
|
||||
target,
|
||||
spawnAgent
|
||||
)
|
||||
: await generateCommitMessage({
|
||||
context: { branch: 'main', stagedSummary: 'M file', stagedPatch: '+fix' },
|
||||
params,
|
||||
target,
|
||||
spawnAgent
|
||||
})
|
||||
expect(result.success).toBe(true)
|
||||
expect(calls).toHaveLength(2)
|
||||
// The retry loads managed MCP servers, so it denies their tools; nothing else changes.
|
||||
const retryArgs = calls[0].args.filter((arg) => arg !== '--strict-mcp-config')
|
||||
retryArgs.splice(retryArgs.indexOf('--permission-mode'), 0, '--disallowedTools', 'mcp__*')
|
||||
expect(calls[1]).toEqual({ ...calls[0], args: retryArgs })
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
it('keeps the picked model and a non-plan mode on an old CLI under enterprise MCP', async () => {
|
||||
const calls: CommitMessagePlan[] = []
|
||||
const params = { agentId: 'claude' as const, model: 'haiku' }
|
||||
const result = await generateCommitMessage({
|
||||
context: { branch: 'main', stagedSummary: 'M file', stagedPatch: '+fix' },
|
||||
params,
|
||||
target: {
|
||||
kind: 'remote',
|
||||
cwd: '/repo',
|
||||
missingBinaryLocation: 'remote PATH',
|
||||
// Answers like CLI 2.0.30 with a managed MCP config.
|
||||
execute: async (plan) => {
|
||||
calls.push(plan)
|
||||
const stderr = plan.args.includes('dontAsk')
|
||||
? "error: option '--permission-mode <mode>' argument 'dontAsk' is invalid. Allowed choices are acceptEdits, bypassPermissions, default, plan.\n"
|
||||
: plan.args.includes('--strict-mcp-config')
|
||||
? 'You cannot use --strict-mcp-config when an enterprise MCP config is present'
|
||||
: ''
|
||||
return {
|
||||
stdout: stderr ? '' : 'Fix crash',
|
||||
stderr,
|
||||
exitCode: stderr ? 1 : 0,
|
||||
timedOut: false
|
||||
}
|
||||
}
|
||||
},
|
||||
spawnAgent: () => {
|
||||
throw new Error('A remote target spawns nothing locally')
|
||||
}
|
||||
})
|
||||
expect(result).toMatchObject({ success: true, message: 'Fix crash' })
|
||||
const prompt = calls[0].stdinPayload ?? ''
|
||||
const expected = [
|
||||
planCommitMessageGeneration(params, prompt),
|
||||
planCommitMessageGeneration(params, prompt, { legacyToolPolicy: true }),
|
||||
planCommitMessageGeneration(params, prompt, { legacyToolPolicy: true, strictMcp: false })
|
||||
].map((planned) => (planned.ok ? planned.plan : null))
|
||||
expect(calls).toEqual(expected)
|
||||
for (const plan of calls) {
|
||||
expect(plan.args[plan.args.indexOf('--model') + 1]).toBe('haiku')
|
||||
expect(plan.args).not.toContain('plan')
|
||||
}
|
||||
expect(calls[2].args[calls[2].args.indexOf('--permission-mode') + 1]).toBe('default')
|
||||
// Every rebuilt attempt keeps its read grant.
|
||||
expect(calls.map((plan) => plan.args[plan.args.indexOf('--allowedTools') + 1])).toEqual([
|
||||
'Read,Grep,Glob',
|
||||
'Read(//**),Grep(//**),Glob(//**)',
|
||||
'Read(//**),Grep(//**),Glob(//**)'
|
||||
])
|
||||
})
|
||||
|
||||
it('preserves Jira custom output and OpenCode one-attempt behavior', async () => {
|
||||
expect(
|
||||
await generateJiraIssueSummaryFromContext(
|
||||
{ description: 'Crash' },
|
||||
{ agentId: 'custom', model: '', customAgentCommand: 'agent' },
|
||||
remoteTarget(async () => 'Draft thought</think>\nFix crash')
|
||||
)
|
||||
).toMatchObject({ summary: 'Draft thought</think>' })
|
||||
let attempts = 0
|
||||
const result = await generateJiraIssueSummaryFromContext(
|
||||
{ description: 'Crash' },
|
||||
{ agentId: 'opencode', model: 'provider/model', thinkingLevel: 'high' },
|
||||
{
|
||||
kind: 'remote',
|
||||
cwd: '/repo',
|
||||
missingBinaryLocation: 'remote PATH',
|
||||
execute: async () => {
|
||||
attempts++
|
||||
return { stdout: '', stderr: 'Unknown argument: variant', exitCode: 1, timedOut: false }
|
||||
}
|
||||
}
|
||||
)
|
||||
expect(result.success).toBe(false)
|
||||
expect(attempts).toBe(1)
|
||||
})
|
||||
|
||||
it.each(['\'{"model":"haiku"}\'', 'provider-settings.json'])(
|
||||
'uses existing settings override for generation and discovery: %s',
|
||||
(settings) => {
|
||||
const override = `claude --settings ${settings}`
|
||||
const spec = getAgentModelProbeSpec('claude')
|
||||
if (!spec) {
|
||||
throw new Error('Missing Claude spec')
|
||||
}
|
||||
const requests = [
|
||||
planModelDiscovery(spec, override),
|
||||
planCommitMessageGeneration(
|
||||
{ agentId: 'claude', model: 'haiku', agentCommandOverride: override },
|
||||
'prompt'
|
||||
),
|
||||
planCommitMessageGeneration(
|
||||
{ agentId: 'claude', model: 'haiku', agentArgs: `--settings ${settings}` },
|
||||
'prompt'
|
||||
)
|
||||
]
|
||||
for (const planned of requests) {
|
||||
if (!planned.ok) {
|
||||
throw new Error(planned.error)
|
||||
}
|
||||
expect(planned.plan.args.filter((arg) => arg === '--settings')).toHaveLength(1)
|
||||
expect(planned.plan.args[planned.plan.args.indexOf('--settings') + 1]).toBe(
|
||||
settings.replaceAll("'", '')
|
||||
)
|
||||
expect(planned.plan.args).not.toContain('{"disableAllHooks":true}')
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
it('rebuilds without inspecting prompt-like, optional or user strict tokens', async () => {
|
||||
for (const agentArgs of [
|
||||
"--append-subagent-system-prompt '--strict-mcp-config'",
|
||||
'--resume --strict-mcp-config saved-session',
|
||||
'--mcp-config custom.json --strict-mcp-config'
|
||||
]) {
|
||||
const calls: CommitMessagePlan[] = []
|
||||
const params = { agentId: 'claude' as const, model: 'haiku', agentArgs }
|
||||
const result = await generateJiraIssueSummaryFromContext({ description: 'Crash' }, params, {
|
||||
kind: 'remote',
|
||||
cwd: '/repo',
|
||||
missingBinaryLocation: 'remote PATH',
|
||||
execute: async (plan) => {
|
||||
calls.push(plan)
|
||||
return {
|
||||
stdout: '',
|
||||
stderr: 'You cannot use --strict-mcp-config when an enterprise MCP config is present',
|
||||
exitCode: 1,
|
||||
timedOut: false
|
||||
}
|
||||
}
|
||||
})
|
||||
expect(result.success).toBe(false)
|
||||
expect(calls).toHaveLength(2)
|
||||
const first = planCommitMessageGeneration(params, calls[0].stdinPayload ?? '')
|
||||
const second = planCommitMessageGeneration(params, calls[0].stdinPayload ?? '', {
|
||||
strictMcp: false
|
||||
})
|
||||
if (!first.ok || !second.ok) {
|
||||
throw new Error('Invalid fixture')
|
||||
}
|
||||
expect(calls).toEqual([first.plan, second.plan])
|
||||
expect(calls[1].args).toContain('--strict-mcp-config')
|
||||
}
|
||||
})
|
||||
@@ -132,12 +132,6 @@ vi.mock('./linux-package/linux-update-package-type', () => ({
|
||||
isExternallyManagedLinuxInstall: () => false
|
||||
}))
|
||||
|
||||
const DEB_ELEVATION_ERROR =
|
||||
'Error: Command failed: /usr/bin/pkexec --disable-internal-agent "/bin/bash" "-c" "dpkg -i \'/home/u/.cache/orca-updater/pending/orca-ide_1.4.163_amd64.deb\'"\npkexec must be setuid root'
|
||||
|
||||
const WINDOWS_SIGNATURE_MISMATCH_ERROR =
|
||||
'New version 1.4.163 is not signed by the application owner: publisherNames: Orca, Inc.'
|
||||
|
||||
type CapturedSpan = {
|
||||
readonly name: string
|
||||
readonly exit: { readonly _tag: string; readonly cause?: string }
|
||||
@@ -162,10 +156,6 @@ function capturingSink(): TracerModule.TracerSink {
|
||||
}
|
||||
}
|
||||
|
||||
function installSpan(): CapturedSpan | undefined {
|
||||
return spans.find((span) => span.name === 'updater.install')
|
||||
}
|
||||
|
||||
async function reachDownloaded(): Promise<typeof UpdaterModule> {
|
||||
const mainWindow = updaterMainWindow(vi.fn())
|
||||
autoUpdaterMock.checkForUpdates.mockResolvedValue(undefined)
|
||||
@@ -225,100 +215,6 @@ describe('quitAndInstall failure carries the updater cause', () => {
|
||||
tracer = null
|
||||
})
|
||||
|
||||
it('reports the underlying elevation failure rather than a generic restart message', async () => {
|
||||
const { quitAndInstall, getUpdateStatus } = await reachDownloaded()
|
||||
|
||||
autoUpdaterMock.quitAndInstall.mockImplementation(() => {
|
||||
autoUpdaterMock.emit('error', new Error(DEB_ELEVATION_ERROR))
|
||||
})
|
||||
|
||||
quitAndInstall()
|
||||
await vi.advanceTimersByTimeAsync(100)
|
||||
await vi.waitFor(() => {
|
||||
expect(autoUpdaterMock.quitAndInstall).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
const status = getUpdateStatus()
|
||||
expect(status.state).toBe('error')
|
||||
const message = status.state === 'error' ? status.message : ''
|
||||
expect(message).toContain('pkexec')
|
||||
expect(message).toContain('Could not start the update installer.')
|
||||
})
|
||||
|
||||
it('keeps the durable breadcrumb to classification only', async () => {
|
||||
const { quitAndInstall } = await reachDownloaded()
|
||||
|
||||
autoUpdaterMock.quitAndInstall.mockImplementation(() => {
|
||||
autoUpdaterMock.emit('error', new Error(DEB_ELEVATION_ERROR))
|
||||
})
|
||||
|
||||
quitAndInstall()
|
||||
await vi.advanceTimersByTimeAsync(100)
|
||||
await vi.waitFor(() => {
|
||||
expect(autoUpdaterMock.quitAndInstall).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
expect(recordUpdaterLifecycleMock).toHaveBeenCalledWith(
|
||||
'quit_and_install_failed_via_event',
|
||||
{ errorType: 'Error' },
|
||||
expect.anything()
|
||||
)
|
||||
})
|
||||
|
||||
it('exits the install span Failure with the cause', async () => {
|
||||
const { quitAndInstall } = await reachDownloaded()
|
||||
|
||||
autoUpdaterMock.quitAndInstall.mockImplementation(() => {
|
||||
autoUpdaterMock.emit('error', new Error(DEB_ELEVATION_ERROR))
|
||||
})
|
||||
|
||||
quitAndInstall()
|
||||
await vi.advanceTimersByTimeAsync(100)
|
||||
await vi.waitFor(() => {
|
||||
expect(installSpan()).toBeDefined()
|
||||
})
|
||||
|
||||
expect(installSpan()?.exit._tag).toBe('Failure')
|
||||
expect(installSpan()?.exit.cause).toContain('pkexec')
|
||||
})
|
||||
|
||||
it('exits the install span Success when the installer takes over', async () => {
|
||||
const { quitAndInstall } = await reachDownloaded()
|
||||
|
||||
autoUpdaterMock.quitAndInstall.mockImplementation(() => {
|
||||
// The installer runs and the old process is left to exit; no 'error' comes back.
|
||||
})
|
||||
|
||||
quitAndInstall()
|
||||
await vi.advanceTimersByTimeAsync(100)
|
||||
await vi.waitFor(() => {
|
||||
expect(installSpan()).toBeDefined()
|
||||
})
|
||||
|
||||
expect(installSpan()?.exit._tag).toBe('Success')
|
||||
})
|
||||
|
||||
it('keeps a Windows signature verdict unprefixed so its own card still renders', async () => {
|
||||
Object.defineProperty(process, 'platform', {
|
||||
value: 'win32',
|
||||
configurable: true
|
||||
})
|
||||
const { quitAndInstall, getUpdateStatus } = await reachDownloaded()
|
||||
|
||||
autoUpdaterMock.quitAndInstall.mockImplementation(() => {
|
||||
autoUpdaterMock.emit('error', new Error(WINDOWS_SIGNATURE_MISMATCH_ERROR))
|
||||
})
|
||||
|
||||
quitAndInstall()
|
||||
await vi.advanceTimersByTimeAsync(100)
|
||||
await vi.waitFor(() => {
|
||||
expect(autoUpdaterMock.quitAndInstall).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
const status = getUpdateStatus()
|
||||
expect(status.state === 'error' ? status.message : '').toBe(WINDOWS_SIGNATURE_MISMATCH_ERROR)
|
||||
})
|
||||
|
||||
it('redacts the cause the same way the retained-package card does', async () => {
|
||||
const { quitAndInstall, getUpdateStatus } = await reachDownloaded()
|
||||
const home = os.homedir()
|
||||
@@ -343,23 +239,4 @@ describe('quitAndInstall failure carries the updater cause', () => {
|
||||
expect(message).not.toContain(escape)
|
||||
expect(message).toContain("<home>/.cache/orca-updater/pending/orca.deb'")
|
||||
})
|
||||
|
||||
it('carries the cause when quitAndInstall throws instead of dispatching an error event', async () => {
|
||||
const { quitAndInstall, getUpdateStatus } = await reachDownloaded()
|
||||
|
||||
autoUpdaterMock.quitAndInstall.mockImplementation(() => {
|
||||
throw new Error('Squirrel.framework is missing from the app bundle')
|
||||
})
|
||||
|
||||
quitAndInstall()
|
||||
await vi.advanceTimersByTimeAsync(100)
|
||||
await vi.waitFor(() => {
|
||||
expect(getUpdateStatus().state).toBe('error')
|
||||
})
|
||||
|
||||
const status = getUpdateStatus()
|
||||
const message = status.state === 'error' ? status.message : ''
|
||||
expect(message).toContain('Squirrel.framework is missing from the app bundle')
|
||||
expect(message).toContain('Could not start the update installer.')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -3,7 +3,7 @@ import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { PostHog } from 'posthog-node'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { agentTokenUsageSchema } from '../../shared/telemetry-agent-token-usage-schema'
|
||||
import '../../shared/telemetry-agent-token-usage-schema'
|
||||
import {
|
||||
_enableTransportForTests,
|
||||
_setPostHogClientForTests,
|
||||
@@ -112,31 +112,6 @@ describe('agent token usage', () => {
|
||||
expect(captures()).toEqual([])
|
||||
})
|
||||
|
||||
it('resolves every session identity in one batch', async () => {
|
||||
const write = vi.spyOn(UsageCacheSnapshotWriter.prototype, 'write')
|
||||
const second = '00000000-0000-4000-8000-000000000002'
|
||||
identity.mockImplementation(async (ids) =>
|
||||
ids.map((id) => (id === 'second-session' ? second : ID))
|
||||
)
|
||||
const instance = reporter()
|
||||
await instance.report([
|
||||
row,
|
||||
{ ...row, providerSessionId: 'invalid-session', input_tokens: -1 },
|
||||
{ ...row, providerSessionId: 'second-session', input_tokens: 7 }
|
||||
])
|
||||
expect(identity).toHaveBeenCalledOnce()
|
||||
expect(identity).toHaveBeenCalledWith([row.providerSessionId, 'second-session'])
|
||||
expect(captures()).toEqual([
|
||||
expect.objectContaining({ analytics_session_id: ID, input_tokens: 100 }),
|
||||
expect.objectContaining({ analytics_session_id: second, input_tokens: 7 })
|
||||
])
|
||||
expect(write).toHaveBeenCalledTimes(1)
|
||||
write.mockClear()
|
||||
await instance.report([])
|
||||
expect(captures()).toHaveLength(2)
|
||||
expect(write).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not publish a revision whose write failed and retries it', async () => {
|
||||
const instance = reporter()
|
||||
const write = vi
|
||||
@@ -168,45 +143,6 @@ describe('agent token usage', () => {
|
||||
}
|
||||
)
|
||||
|
||||
it('deduplicates acceptance in memory when only part of a batch is accepted', async () => {
|
||||
vi.spyOn(Date, 'now').mockReturnValue(1_800_000_000_000)
|
||||
identity.mockImplementation(async (ids) =>
|
||||
ids.map((_, index) => `00000000-0000-4000-8000-${String(index + 1).padStart(12, '0')}`)
|
||||
)
|
||||
const rows = Array.from({ length: 31 }, (_, index) => ({
|
||||
...row,
|
||||
providerSessionId: `session-${index}`
|
||||
}))
|
||||
const instance = reporter()
|
||||
const write = vi.spyOn(UsageCacheSnapshotWriter.prototype, 'write')
|
||||
await instance.report(rows)
|
||||
expect(captures()).toHaveLength(30)
|
||||
expect(write).toHaveBeenCalledTimes(1)
|
||||
const persisted = readFileSync(file, 'utf8')
|
||||
resetBurstCapsForSession()
|
||||
await instance.report([])
|
||||
expect(write).toHaveBeenCalledTimes(1)
|
||||
expect(readFileSync(file, 'utf8')).toBe(persisted)
|
||||
expect(captures()).toHaveLength(31)
|
||||
expect(captures().at(-1)).toMatchObject({
|
||||
analytics_session_id: '00000000-0000-4000-8000-000000000031',
|
||||
revision: 1
|
||||
})
|
||||
})
|
||||
|
||||
it('retries a throwing capture without inventing a new revision', async () => {
|
||||
telemetry.mock.capture.mockImplementationOnce(() => {
|
||||
throw new Error('enqueue failed')
|
||||
})
|
||||
const instance = reporter()
|
||||
await expect(instance.report([row])).rejects.toThrow('enqueue failed')
|
||||
await instance.report([row])
|
||||
expect(captures()).toHaveLength(2)
|
||||
expect(captures()[1]).toMatchObject({ revision: 1 })
|
||||
await instance.report([row])
|
||||
expect(captures()).toHaveLength(2)
|
||||
})
|
||||
|
||||
it.each(['offline send', 'exit before send'])(
|
||||
'retries after %s with the installed SDK',
|
||||
async (failure) => {
|
||||
@@ -286,15 +222,4 @@ describe('agent token usage', () => {
|
||||
expect(readFileSync(file, 'utf8')).toBe('{broken')
|
||||
expect(captures()).toEqual([])
|
||||
})
|
||||
|
||||
it('rejects invalid counts and unexpected content fields', async () => {
|
||||
await reporter().report([{ ...row, input_tokens: -1 }])
|
||||
const payload = { ...row, analytics_session_id: ID, revision: 1, provider: 'claude' }
|
||||
expect(agentTokenUsageSchema.safeParse(payload).success).toBe(false)
|
||||
const { providerSessionId: _, ...valid } = payload
|
||||
expect(agentTokenUsageSchema.safeParse(valid).success).toBe(true)
|
||||
for (const field of ['prompt', 'path', 'model', 'activity_timestamp', 'estimated_cost']) {
|
||||
expect(agentTokenUsageSchema.safeParse({ ...valid, [field]: 'private' }).success).toBe(false)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,328 +0,0 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { recordAgentSessionRuntimeEndMock } = vi.hoisted(() => ({
|
||||
recordAgentSessionRuntimeEndMock: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('electron', async () =>
|
||||
(await import('./createMainWindow-test-harness')).electronModuleMock()
|
||||
)
|
||||
vi.mock('./macos-tahoe-release', async () =>
|
||||
(await import('./createMainWindow-test-harness')).macosTahoeReleaseMock()
|
||||
)
|
||||
vi.mock('./desktop-integration/app-icon', async () =>
|
||||
(await import('./createMainWindow-test-harness')).appIconMock()
|
||||
)
|
||||
vi.mock('../browser/browser-manager', async () =>
|
||||
(await import('./createMainWindow-test-harness')).browserManagerMock()
|
||||
)
|
||||
vi.mock('../runtime/agent-session/records/agent-session-runtime-end-record', () => ({
|
||||
recordAgentSessionRuntimeEnd: recordAgentSessionRuntimeEndMock
|
||||
}))
|
||||
|
||||
import { createMainWindow } from './createMainWindow'
|
||||
import { ipcMain } from 'electron'
|
||||
import {
|
||||
resetExpectedTeardownStateForTest,
|
||||
resolveExpectedTeardownScope,
|
||||
WINDOWS_SESSION_END_CRASH_SUPPRESSION_WINDOW_MS
|
||||
} from '../crash-reporting/expected-teardown-state'
|
||||
import {
|
||||
clearCrashBreadcrumbsForTest,
|
||||
getCrashBreadcrumbSnapshot
|
||||
} from '../crash-reporting/crash-breadcrumb-store'
|
||||
import {
|
||||
browserWindowMock,
|
||||
notificationMock,
|
||||
notificationShowMock,
|
||||
resetMainWindowMocks
|
||||
} from './createMainWindow-test-harness'
|
||||
|
||||
describe('createMainWindow', () => {
|
||||
beforeEach(() => {
|
||||
resetMainWindowMocks()
|
||||
resetExpectedTeardownStateForTest()
|
||||
clearCrashBreadcrumbsForTest()
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
describe('minimize to tray on close (win32)', () => {
|
||||
const originalPlatform = process.platform
|
||||
|
||||
function setPlatform(platform: NodeJS.Platform): void {
|
||||
Object.defineProperty(process, 'platform', { value: platform, configurable: true })
|
||||
}
|
||||
|
||||
type CloseFixture = {
|
||||
windowHandlers: Record<string, (...args: any[]) => void>
|
||||
webContents: { send: ReturnType<typeof vi.fn> }
|
||||
instance: { hide: ReturnType<typeof vi.fn>; isMinimized: ReturnType<typeof vi.fn> }
|
||||
}
|
||||
|
||||
function setupCloseWindow(): CloseFixture {
|
||||
const windowHandlers: Record<string, (...args: any[]) => void> = {}
|
||||
const webContents = {
|
||||
on: vi.fn((event, handler) => {
|
||||
windowHandlers[event] = handler
|
||||
}),
|
||||
setZoomLevel: vi.fn(),
|
||||
setBackgroundThrottling: vi.fn(),
|
||||
setWebRTCIPHandlingPolicy: vi.fn(),
|
||||
invalidate: vi.fn(),
|
||||
setWindowOpenHandler: vi.fn(),
|
||||
send: vi.fn(),
|
||||
isCrashed: vi.fn(() => false),
|
||||
id: 1
|
||||
}
|
||||
const instance = {
|
||||
webContents,
|
||||
on: vi.fn((event, handler) => {
|
||||
windowHandlers[event] = handler
|
||||
}),
|
||||
isDestroyed: vi.fn(() => false),
|
||||
isMaximized: vi.fn(() => false),
|
||||
isFullScreen: vi.fn(() => false),
|
||||
isMinimized: vi.fn(() => false),
|
||||
getSize: vi.fn(() => [1200, 800]),
|
||||
setSize: vi.fn(),
|
||||
maximize: vi.fn(),
|
||||
show: vi.fn(),
|
||||
hide: vi.fn(),
|
||||
loadFile: vi.fn(() => Promise.resolve()),
|
||||
loadURL: vi.fn(() => Promise.resolve())
|
||||
}
|
||||
browserWindowMock.mockImplementation(function () {
|
||||
return instance
|
||||
})
|
||||
return { windowHandlers, webContents, instance }
|
||||
}
|
||||
|
||||
function makeStore(minimizeToTrayOnClose: boolean, trayMinimizeNoticeShown: boolean) {
|
||||
return {
|
||||
getUI: vi.fn(() => ({ trayMinimizeNoticeShown })),
|
||||
getSettings: vi.fn(() => ({ windowBackgroundBlur: false, minimizeToTrayOnClose })),
|
||||
updateUI: vi.fn()
|
||||
}
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
setPlatform(originalPlatform)
|
||||
clearCrashBreadcrumbsForTest()
|
||||
})
|
||||
|
||||
it('marks production teardown state on irrevocable Windows session end', () => {
|
||||
setPlatform('win32')
|
||||
resetExpectedTeardownStateForTest(() => 1_000)
|
||||
const { windowHandlers } = setupCloseWindow()
|
||||
|
||||
createMainWindow(null)
|
||||
windowHandlers['session-end']?.({} as never)
|
||||
|
||||
expect(
|
||||
resolveExpectedTeardownScope({
|
||||
isQuitting: false,
|
||||
isQuittingForUpdate: false,
|
||||
isExpectedRendererReload: false
|
||||
})
|
||||
).toBe('app-shutdown')
|
||||
})
|
||||
|
||||
it('durably records the session-end reasons so bundles can identify OS shutdown', () => {
|
||||
setPlatform('win32')
|
||||
const { windowHandlers } = setupCloseWindow()
|
||||
|
||||
createMainWindow(null)
|
||||
windowHandlers['session-end']?.({ reasons: ['shutdown', 'critical'] } as never)
|
||||
windowHandlers['session-end']?.({} as never)
|
||||
|
||||
expect(getCrashBreadcrumbSnapshot()).toEqual([
|
||||
expect.objectContaining({
|
||||
name: 'system_session_end',
|
||||
data: expect.objectContaining({ reasons: 'shutdown,critical' })
|
||||
}),
|
||||
expect.objectContaining({
|
||||
name: 'system_session_end',
|
||||
data: expect.objectContaining({ reasons: '' })
|
||||
})
|
||||
])
|
||||
})
|
||||
|
||||
it("records the Orca runtime's end, since Windows emits no will-quit for it", () => {
|
||||
setPlatform('win32')
|
||||
recordAgentSessionRuntimeEndMock.mockClear()
|
||||
const { windowHandlers } = setupCloseWindow()
|
||||
|
||||
createMainWindow(null)
|
||||
windowHandlers['session-end']?.({ reasons: ['logoff'] })
|
||||
|
||||
expect(recordAgentSessionRuntimeEndMock).toHaveBeenCalledExactlyOnceWith('quit')
|
||||
})
|
||||
|
||||
it.each(['darwin', 'linux'] as const)(
|
||||
'does not mark session teardown state on %s',
|
||||
(platform) => {
|
||||
setPlatform(platform)
|
||||
const { windowHandlers } = setupCloseWindow()
|
||||
|
||||
createMainWindow(null)
|
||||
|
||||
expect(windowHandlers['session-end']).toBeUndefined()
|
||||
expect(
|
||||
resolveExpectedTeardownScope({
|
||||
isQuitting: false,
|
||||
isQuittingForUpdate: false,
|
||||
isExpectedRendererReload: false
|
||||
})
|
||||
).toBe('none')
|
||||
}
|
||||
)
|
||||
|
||||
it('still minimizes to tray after the session-end reporting window expires', () => {
|
||||
setPlatform('win32')
|
||||
let now = 1_000
|
||||
resetExpectedTeardownStateForTest(() => now)
|
||||
const { windowHandlers, webContents, instance } = setupCloseWindow()
|
||||
const store = makeStore(true, true)
|
||||
|
||||
createMainWindow(store as never)
|
||||
windowHandlers['session-end']?.({} as never)
|
||||
now += WINDOWS_SESSION_END_CRASH_SUPPRESSION_WINDOW_MS
|
||||
const preventDefault = vi.fn()
|
||||
windowHandlers.close({ preventDefault } as never)
|
||||
|
||||
expect(preventDefault).toHaveBeenCalledOnce()
|
||||
expect(instance.hide).toHaveBeenCalledOnce()
|
||||
expect(webContents.send).not.toHaveBeenCalledWith('window:close-requested', expect.anything())
|
||||
})
|
||||
|
||||
it('hides to the tray instead of closing when the setting is on', () => {
|
||||
setPlatform('win32')
|
||||
const { windowHandlers, webContents, instance } = setupCloseWindow()
|
||||
const store = makeStore(true, true)
|
||||
|
||||
createMainWindow(store as never, { getIsQuitting: () => false })
|
||||
const preventDefault = vi.fn()
|
||||
windowHandlers.close({ preventDefault } as never)
|
||||
|
||||
expect(preventDefault).toHaveBeenCalled()
|
||||
expect(instance.hide).toHaveBeenCalledTimes(1)
|
||||
expect(webContents.send).not.toHaveBeenCalledWith('window:close-requested', expect.anything())
|
||||
expect(notificationMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps the normal close flow when the setting is off', () => {
|
||||
setPlatform('win32')
|
||||
const { windowHandlers, webContents, instance } = setupCloseWindow()
|
||||
const store = makeStore(false, true)
|
||||
|
||||
createMainWindow(store as never, { getIsQuitting: () => false })
|
||||
windowHandlers.close({ preventDefault: vi.fn() } as never)
|
||||
|
||||
expect(instance.hide).not.toHaveBeenCalled()
|
||||
expect(webContents.send).toHaveBeenCalledWith('window:close-requested', {
|
||||
isQuitting: false,
|
||||
requestId: expect.any(Number)
|
||||
})
|
||||
})
|
||||
|
||||
it('does not hide on a real quit even with the setting on', () => {
|
||||
setPlatform('win32')
|
||||
const { windowHandlers, webContents, instance } = setupCloseWindow()
|
||||
const store = makeStore(true, true)
|
||||
|
||||
createMainWindow(store as never, { getIsQuitting: () => true })
|
||||
windowHandlers.close({ preventDefault: vi.fn() } as never)
|
||||
|
||||
expect(instance.hide).not.toHaveBeenCalled()
|
||||
expect(webContents.send).toHaveBeenCalledWith('window:close-requested', {
|
||||
isQuitting: true,
|
||||
requestId: expect.any(Number)
|
||||
})
|
||||
})
|
||||
|
||||
it('does not hide when the renderer process is gone', () => {
|
||||
setPlatform('win32')
|
||||
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
const { windowHandlers, instance } = setupCloseWindow()
|
||||
const store = makeStore(true, true)
|
||||
|
||||
createMainWindow(store as never, { getIsQuitting: () => false })
|
||||
windowHandlers['render-process-gone']?.(
|
||||
{} as never,
|
||||
{ reason: 'crashed', exitCode: 5 } as never
|
||||
)
|
||||
const preventDefault = vi.fn()
|
||||
windowHandlers.close({ preventDefault } as never)
|
||||
|
||||
expect(instance.hide).not.toHaveBeenCalled()
|
||||
expect(preventDefault).not.toHaveBeenCalled()
|
||||
consoleError.mockRestore()
|
||||
})
|
||||
|
||||
it('shows the first-run notification once and persists the flag', () => {
|
||||
setPlatform('win32')
|
||||
const { windowHandlers } = setupCloseWindow()
|
||||
const store = makeStore(true, false)
|
||||
|
||||
createMainWindow(store as never, { getIsQuitting: () => false })
|
||||
windowHandlers.close({ preventDefault: vi.fn() } as never)
|
||||
|
||||
expect(notificationMock).toHaveBeenCalledTimes(1)
|
||||
expect(notificationShowMock).toHaveBeenCalledTimes(1)
|
||||
expect(store.updateUI).toHaveBeenCalledWith({ trayMinimizeNoticeShown: true })
|
||||
})
|
||||
|
||||
it('leaves the close handler unchanged off win32', () => {
|
||||
setPlatform('darwin')
|
||||
const { windowHandlers, webContents, instance } = setupCloseWindow()
|
||||
const store = makeStore(true, true)
|
||||
|
||||
createMainWindow(store as never, { getIsQuitting: () => false })
|
||||
windowHandlers.close({ preventDefault: vi.fn() } as never)
|
||||
|
||||
expect(instance.hide).not.toHaveBeenCalled()
|
||||
expect(webContents.send).toHaveBeenCalledWith('window:close-requested', {
|
||||
isQuitting: false,
|
||||
requestId: expect.any(Number)
|
||||
})
|
||||
})
|
||||
|
||||
// Windows custom X uses window:request-close; the native close guard alone would quit instead of hide.
|
||||
function captureIpcHandlers(): Record<string, (...args: any[]) => void> {
|
||||
const ipcHandlers: Record<string, (...args: any[]) => void> = {}
|
||||
vi.mocked(ipcMain.on).mockImplementation((channel, handler) => {
|
||||
ipcHandlers[channel] = handler as (...args: any[]) => void
|
||||
return ipcMain
|
||||
})
|
||||
return ipcHandlers
|
||||
}
|
||||
|
||||
it('hides to the tray when the renderer-drawn X requests close', () => {
|
||||
setPlatform('win32')
|
||||
const ipcHandlers = captureIpcHandlers()
|
||||
const { webContents, instance } = setupCloseWindow()
|
||||
const store = makeStore(true, true)
|
||||
|
||||
createMainWindow(store as never, { getIsQuitting: () => false })
|
||||
ipcHandlers['window:request-close']?.()
|
||||
|
||||
expect(instance.hide).toHaveBeenCalledTimes(1)
|
||||
expect(webContents.send).not.toHaveBeenCalledWith('window:close-requested', expect.anything())
|
||||
})
|
||||
|
||||
it('forwards window:request-close to the renderer when the setting is off', () => {
|
||||
setPlatform('win32')
|
||||
const ipcHandlers = captureIpcHandlers()
|
||||
const { webContents, instance } = setupCloseWindow()
|
||||
const store = makeStore(false, true)
|
||||
|
||||
createMainWindow(store as never, { getIsQuitting: () => false })
|
||||
ipcHandlers['window:request-close']?.()
|
||||
|
||||
expect(instance.hide).not.toHaveBeenCalled()
|
||||
expect(webContents.send).toHaveBeenCalledWith('window:close-requested', {
|
||||
isQuitting: false
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -4,9 +4,8 @@ import { existsSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { MessageChannel, Worker, type MessagePort } from 'node:worker_threads'
|
||||
import { runProcess } from '@orca/process-host'
|
||||
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest'
|
||||
import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest'
|
||||
import {
|
||||
IDLE_TEARDOWN_MS,
|
||||
WorkerThreadChildProcessHost,
|
||||
type WorkerThreadChildProcess
|
||||
} from './worker-thread-child-process'
|
||||
@@ -15,7 +14,6 @@ import { createWorkerThreadChildProcessTestEntry } from './worker-thread-child-p
|
||||
|
||||
// Use a real MessageChannel so protocol tests include structured-clone behavior.
|
||||
const openPorts: MessagePort[] = []
|
||||
const terminations: number[] = []
|
||||
|
||||
function makeConnectedHost(): WorkerThreadChildProcessHost {
|
||||
return new WorkerThreadChildProcessHost(() => {
|
||||
@@ -28,7 +26,6 @@ function makeConnectedHost(): WorkerThreadChildProcessHost {
|
||||
postMessage: (message: unknown) => port1.postMessage(message),
|
||||
unref: () => port1.unref(),
|
||||
terminate: async () => {
|
||||
terminations.push(Date.now())
|
||||
port1.close()
|
||||
return 0
|
||||
}
|
||||
@@ -81,9 +78,6 @@ process.stdin.on('end', () => { process.stderr.write('bye'); process.exit(7) })
|
||||
const IDLE_SCRIPT = 'setInterval(() => {}, 1000)'
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
vi.restoreAllMocks()
|
||||
terminations.length = 0
|
||||
for (const port of openPorts.splice(0)) {
|
||||
port.close()
|
||||
}
|
||||
@@ -184,16 +178,6 @@ describe('WorkerThreadChildProcessHost over a message channel', () => {
|
||||
await expect(waitForClose(child)).resolves.toEqual([expect.any(Number), null])
|
||||
}, 20_000)
|
||||
|
||||
it('closes a child whose creation threw on the worker', async () => {
|
||||
const child = makeConnectedHost().spawn({ program: '', args: [] })
|
||||
const errors: NodeJS.ErrnoException[] = []
|
||||
child.on('error', (error) => errors.push(error))
|
||||
|
||||
await expect(waitForClose(child)).resolves.toEqual([-1, null])
|
||||
expect(errors).toMatchObject([{ code: 'ERR_INVALID_ARG_VALUE' }])
|
||||
expect(child.exitCode).toBe(-1)
|
||||
}, 20_000)
|
||||
|
||||
it('kills a running child on request', async () => {
|
||||
const child = makeConnectedHost().spawn({
|
||||
program: process.execPath,
|
||||
@@ -207,62 +191,6 @@ describe('WorkerThreadChildProcessHost over a message channel', () => {
|
||||
const [code, signal] = await closed
|
||||
expect(code !== 0 || signal !== null).toBe(true)
|
||||
}, 20_000)
|
||||
|
||||
it('fails closed, after listeners can attach, when no worker can be started', async () => {
|
||||
vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
const host = new WorkerThreadChildProcessHost(() => {
|
||||
throw new Error('entry missing')
|
||||
})
|
||||
|
||||
const child = host.spawn({ program: process.execPath, args: [] })
|
||||
const events: unknown[][] = []
|
||||
child.on('error', (error: Error) => events.push(['error', error.message]))
|
||||
child.on('exit', (code) => events.push(['exit', code]))
|
||||
child.on('close', (code) => events.push(['close', code]))
|
||||
expect(events).toEqual([])
|
||||
await new Promise((resolve) => process.nextTick(resolve))
|
||||
|
||||
expect(events).toEqual([
|
||||
['error', 'Child process worker is unavailable'],
|
||||
['exit', -1],
|
||||
['close', -1]
|
||||
])
|
||||
expect(child.kill()).toBe(false)
|
||||
})
|
||||
|
||||
it('tears the idle worker down only after the last child closes', async () => {
|
||||
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] })
|
||||
const host = makeConnectedHost()
|
||||
const first = host.spawn({ program: process.execPath, args: ['-e', ''] })
|
||||
const second = host.spawn({ program: process.execPath, args: ['-e', IDLE_SCRIPT] })
|
||||
await waitForSpawn(second)
|
||||
await waitForClose(first)
|
||||
await vi.advanceTimersByTimeAsync(IDLE_TEARDOWN_MS)
|
||||
expect(terminations).toHaveLength(0)
|
||||
|
||||
const secondClosed = waitForClose(second)
|
||||
second.kill()
|
||||
await secondClosed
|
||||
await vi.advanceTimersByTimeAsync(IDLE_TEARDOWN_MS - 1)
|
||||
expect(terminations).toHaveLength(0)
|
||||
await vi.advanceTimersByTimeAsync(1)
|
||||
expect(terminations).toHaveLength(1)
|
||||
}, 20_000)
|
||||
|
||||
it('cancels a pending idle teardown when a new child spawns', async () => {
|
||||
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] })
|
||||
const host = makeConnectedHost()
|
||||
await waitForClose(host.spawn({ program: process.execPath, args: ['-e', ''] }))
|
||||
|
||||
await vi.advanceTimersByTimeAsync(IDLE_TEARDOWN_MS - 1)
|
||||
const next = host.spawn({ program: process.execPath, args: ['-e', IDLE_SCRIPT] })
|
||||
await vi.advanceTimersByTimeAsync(IDLE_TEARDOWN_MS)
|
||||
expect(terminations).toHaveLength(0)
|
||||
|
||||
const closed = waitForClose(next)
|
||||
next.kill()
|
||||
await closed
|
||||
}, 20_000)
|
||||
})
|
||||
|
||||
// Real thread death closes child pipes; abandoned probes depend on that graceful stop instead of PID-addressed killing.
|
||||
|
||||
@@ -1,334 +0,0 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { AddWorktreeResult } from '../../../git/worktrees/worktree-operation-options'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
mkdir: vi.fn(),
|
||||
prepare: vi.fn(),
|
||||
finalize: vi.fn(),
|
||||
discard: vi.fn(),
|
||||
listWorktreeGraph: vi.fn(),
|
||||
resolveBaseRef: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('node:fs/promises', () => ({ mkdir: mocks.mkdir }))
|
||||
|
||||
vi.mock('../../../git/worktrees/catalog/worktree-scan-cache', async (importOriginal) => ({
|
||||
...(await importOriginal<Record<string, unknown>>()),
|
||||
listWorktreeGraph: mocks.listWorktreeGraph
|
||||
}))
|
||||
vi.mock('../../../git/worktrees/creation/worktree-create-preparation', () => ({
|
||||
prepareWorktreeCreateCheckout: mocks.prepare,
|
||||
finalizePreparedWorktree: mocks.finalize,
|
||||
discardPreparedWorktree: mocks.discard,
|
||||
unlockPreparedWorktree: vi.fn()
|
||||
}))
|
||||
vi.mock('../../../git/worktrees/creation/worktree-base-ref-probe', () => ({
|
||||
resolveLocalWorktreeBaseRef: mocks.resolveBaseRef
|
||||
}))
|
||||
vi.mock('../../../git/worktrees/creation/worktree-base-divergence', () => ({
|
||||
measureRetargetDivergence: vi.fn()
|
||||
}))
|
||||
vi.mock('../../execution-host/project-runtime-git-options', () => ({
|
||||
getLocalProjectWorktreeGitOptions: vi.fn(),
|
||||
getWorktreeMirrorDistro: vi.fn()
|
||||
}))
|
||||
vi.mock('../../../ipc/worktree-logic', () => ({
|
||||
computeWorkspaceRootAsync: vi.fn(),
|
||||
getWorktreePathSettings: vi.fn()
|
||||
}))
|
||||
|
||||
import {
|
||||
_resetWorktreeCreatePreparationsForTests,
|
||||
consumePreparedWorktreeCreate
|
||||
} from './worktree-create-preparation'
|
||||
import { startPreparation } from './worktree-create-preparation-pool'
|
||||
import { createWorktreeCreateTimingRecorder } from '../worktree-create-timing'
|
||||
import {
|
||||
_resetWorktreeCreateConcurrencyForTests,
|
||||
beginWorktreeCreate
|
||||
} from '../worktree-create-concurrency'
|
||||
|
||||
const request = {
|
||||
repoPath: '/repo',
|
||||
workspaceRoot: '/workspace',
|
||||
worktreePath: '/workspace/feature',
|
||||
branch: 'feature',
|
||||
baseBranch: 'origin/main'
|
||||
}
|
||||
|
||||
function prepare() {
|
||||
return startPreparation({
|
||||
repoPath: request.repoPath,
|
||||
workspaceRoot: request.workspaceRoot,
|
||||
baseBranch: request.baseBranch,
|
||||
canonicalBase: 'refs/remotes/origin/main',
|
||||
options: {}
|
||||
})
|
||||
}
|
||||
|
||||
/** Lets the claim's own phase settle before the test clock moves on to the wait. */
|
||||
function settleClaim(): Promise<void> {
|
||||
return new Promise((resolve) => setImmediate(resolve))
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
mocks.mkdir.mockReset().mockResolvedValue(undefined)
|
||||
mocks.prepare.mockReset().mockResolvedValue(undefined)
|
||||
mocks.finalize.mockReset().mockResolvedValue({})
|
||||
mocks.discard.mockReset().mockResolvedValue(undefined)
|
||||
mocks.listWorktreeGraph.mockReset().mockResolvedValue([])
|
||||
mocks.resolveBaseRef.mockReset()
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await _resetWorktreeCreatePreparationsForTests()
|
||||
_resetWorktreeCreateConcurrencyForTests()
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
describe('prepared checkout create timing', () => {
|
||||
it('separates the remaining preparation wait from finalization', async () => {
|
||||
const checkoutStarted = Promise.withResolvers<void>()
|
||||
const checkout = Promise.withResolvers<void>()
|
||||
const finalizeStarted = Promise.withResolvers<void>()
|
||||
const finalize = Promise.withResolvers<AddWorktreeResult>()
|
||||
mocks.prepare.mockImplementation(() => {
|
||||
checkoutStarted.resolve()
|
||||
return checkout.promise
|
||||
})
|
||||
mocks.finalize.mockImplementation(() => {
|
||||
finalizeStarted.resolve()
|
||||
return finalize.promise
|
||||
})
|
||||
const preparation = prepare()
|
||||
await checkoutStarted.promise
|
||||
|
||||
let now = 40
|
||||
const timing = createWorktreeCreateTimingRecorder(() => now)
|
||||
const create = timing.time('git_worktree_add', () =>
|
||||
consumePreparedWorktreeCreate({ ...request, timing })
|
||||
)
|
||||
await settleClaim()
|
||||
now = 150
|
||||
checkout.resolve()
|
||||
await finalizeStarted.promise
|
||||
now = 180
|
||||
finalize.resolve({})
|
||||
|
||||
expect(await create).toMatchObject({ status: 'hit', retargeted: false, reset: 'none' })
|
||||
await preparation
|
||||
expect(timing.finish()).toEqual({
|
||||
totalDurationMs: 140,
|
||||
preparedCheckout: {
|
||||
status: 'hit',
|
||||
reset: 'none',
|
||||
origin: 'prefetch',
|
||||
buildMs: expect.any(Number),
|
||||
idleMs: 0
|
||||
},
|
||||
phases: [
|
||||
{ phase: 'prepared_checkout_claim', startedAtMs: 0, durationMs: 0 },
|
||||
{ phase: 'prepared_checkout_wait', startedAtMs: 0, durationMs: 110 },
|
||||
{ phase: 'prepared_checkout_finalize', startedAtMs: 110, durationMs: 30 },
|
||||
{ phase: 'git_worktree_add', startedAtMs: 0, durationMs: 140 }
|
||||
]
|
||||
})
|
||||
})
|
||||
|
||||
it('records the wait when preparation fails and the create must fall back', async () => {
|
||||
const checkoutStarted = Promise.withResolvers<void>()
|
||||
const checkout = Promise.withResolvers<void>()
|
||||
mocks.prepare.mockImplementation(() => {
|
||||
checkoutStarted.resolve()
|
||||
return checkout.promise
|
||||
})
|
||||
const preparation = Promise.allSettled([prepare()])
|
||||
await checkoutStarted.promise
|
||||
let now = 0
|
||||
const timing = createWorktreeCreateTimingRecorder(() => now)
|
||||
const create = consumePreparedWorktreeCreate({ ...request, timing })
|
||||
await settleClaim()
|
||||
now = 250
|
||||
checkout.reject(new Error('checkout failed'))
|
||||
|
||||
expect(await create).toEqual({
|
||||
status: 'miss',
|
||||
reason: 'prepare_failed',
|
||||
rearm: expect.any(Function)
|
||||
})
|
||||
await preparation
|
||||
expect(timing.finish().phases).toEqual([
|
||||
{ phase: 'prepared_checkout_claim', startedAtMs: 0, durationMs: 0 },
|
||||
{ phase: 'prepared_checkout_wait', startedAtMs: 0, durationMs: 250 }
|
||||
])
|
||||
expect(mocks.finalize).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('times the cleanup of a failed finalization apart from the finalization', async () => {
|
||||
vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
await prepare()
|
||||
let now = 0
|
||||
const timing = createWorktreeCreateTimingRecorder(() => now)
|
||||
mocks.finalize.mockImplementation(async () => {
|
||||
now = 80
|
||||
throw new Error('move failed')
|
||||
})
|
||||
mocks.discard.mockImplementation(async () => {
|
||||
now = 100
|
||||
})
|
||||
|
||||
expect(await consumePreparedWorktreeCreate({ ...request, timing })).toEqual({
|
||||
status: 'miss',
|
||||
reason: 'finalize_failed',
|
||||
rearm: expect.any(Function)
|
||||
})
|
||||
expect(timing.finish().phases).toEqual([
|
||||
{ phase: 'prepared_checkout_claim', startedAtMs: 0, durationMs: 0 },
|
||||
{ phase: 'prepared_checkout_wait', startedAtMs: 0, durationMs: 0 },
|
||||
{ phase: 'prepared_checkout_finalize', startedAtMs: 0, durationMs: 80 },
|
||||
{ phase: 'prepared_checkout_discard', startedAtMs: 80, durationMs: 20 }
|
||||
])
|
||||
})
|
||||
|
||||
it('reports only the claim, and the miss, when no checkout was armed', async () => {
|
||||
const timing = createWorktreeCreateTimingRecorder(() => 0)
|
||||
expect(await consumePreparedWorktreeCreate({ ...request, timing })).toEqual({
|
||||
status: 'miss',
|
||||
reason: 'none_armed'
|
||||
})
|
||||
expect(timing.finish()).toMatchObject({
|
||||
phases: [{ phase: 'prepared_checkout_claim', startedAtMs: 0, durationMs: 0 }],
|
||||
preparedCheckout: { status: 'miss', reason: 'none_armed' }
|
||||
})
|
||||
})
|
||||
|
||||
it('times a miss probe as the claim, not as the plain add after it', async () => {
|
||||
await startPreparation({
|
||||
repoPath: request.repoPath,
|
||||
workspaceRoot: request.workspaceRoot,
|
||||
baseBranch: 'origin/release',
|
||||
canonicalBase: 'refs/remotes/origin/release',
|
||||
options: {}
|
||||
})
|
||||
let now = 0
|
||||
const timing = createWorktreeCreateTimingRecorder(() => now)
|
||||
mocks.resolveBaseRef.mockImplementation(async () => {
|
||||
now = 400
|
||||
return 'refs/remotes/origin/main'
|
||||
})
|
||||
|
||||
const attempt = await timing.time('git_worktree_add', async () => {
|
||||
const result = await consumePreparedWorktreeCreate({ ...request, timing })
|
||||
now = 500
|
||||
return result
|
||||
})
|
||||
|
||||
expect(attempt).toMatchObject({ status: 'miss', reason: 'base_mismatch' })
|
||||
expect(timing.finish().phases).toEqual([
|
||||
{ phase: 'prepared_checkout_claim', startedAtMs: 0, durationMs: 400 },
|
||||
{ phase: 'git_worktree_add', startedAtMs: 0, durationMs: 500 }
|
||||
])
|
||||
})
|
||||
|
||||
it('reports the reset a hit needed and who armed it', async () => {
|
||||
await startPreparation(
|
||||
{
|
||||
repoPath: request.repoPath,
|
||||
workspaceRoot: request.workspaceRoot,
|
||||
baseBranch: request.baseBranch,
|
||||
canonicalBase: 'refs/remotes/origin/main',
|
||||
options: {}
|
||||
},
|
||||
'automatic'
|
||||
)
|
||||
mocks.finalize.mockResolvedValue({ preparedHeadReset: true })
|
||||
const timing = createWorktreeCreateTimingRecorder(() => 0)
|
||||
|
||||
const attempt = await consumePreparedWorktreeCreate({ ...request, timing })
|
||||
|
||||
expect(attempt).toMatchObject({ status: 'hit', reset: 'base_moved', origin: 'rearm' })
|
||||
expect(attempt.status === 'hit' && attempt.result).toEqual({})
|
||||
expect(timing.finish().preparedCheckout).toMatchObject({
|
||||
status: 'hit',
|
||||
reset: 'base_moved',
|
||||
origin: 'rearm'
|
||||
})
|
||||
})
|
||||
|
||||
it('reports a re-arm the new-worktree UI then asked for as well', async () => {
|
||||
const args = {
|
||||
repoPath: request.repoPath,
|
||||
workspaceRoot: request.workspaceRoot,
|
||||
baseBranch: request.baseBranch,
|
||||
canonicalBase: 'refs/remotes/origin/main',
|
||||
options: {}
|
||||
}
|
||||
await startPreparation(args, 'automatic')
|
||||
await startPreparation(args, 'explicit')
|
||||
expect(mocks.prepare).toHaveBeenCalledOnce()
|
||||
|
||||
const attempt = await consumePreparedWorktreeCreate({ ...request })
|
||||
|
||||
expect(attempt).toMatchObject({ status: 'hit', origin: 'rearm_then_prefetch' })
|
||||
})
|
||||
|
||||
it('reports how long the build took and how long it sat ready before the claim', async () => {
|
||||
let now = 1_000
|
||||
vi.spyOn(performance, 'now').mockImplementation(() => now)
|
||||
const checkout = Promise.withResolvers<void>()
|
||||
mocks.prepare.mockReturnValue(checkout.promise)
|
||||
const preparation = prepare()
|
||||
now = 4_000
|
||||
checkout.resolve()
|
||||
await preparation
|
||||
now = 9_000
|
||||
|
||||
const attempt = await consumePreparedWorktreeCreate({ ...request })
|
||||
|
||||
expect(attempt).toMatchObject({ status: 'hit', buildMs: 3_000, idleMs: 5_000 })
|
||||
})
|
||||
|
||||
it('reports no idle time when the create waited for the build', async () => {
|
||||
let now = 1_000
|
||||
vi.spyOn(performance, 'now').mockImplementation(() => now)
|
||||
const checkoutStarted = Promise.withResolvers<void>()
|
||||
const checkout = Promise.withResolvers<void>()
|
||||
mocks.prepare.mockImplementation(() => {
|
||||
checkoutStarted.resolve()
|
||||
return checkout.promise
|
||||
})
|
||||
const preparation = prepare()
|
||||
await checkoutStarted.promise
|
||||
now = 2_000
|
||||
const create = consumePreparedWorktreeCreate({ ...request })
|
||||
await settleClaim()
|
||||
now = 6_000
|
||||
checkout.resolve()
|
||||
await preparation
|
||||
|
||||
expect(await create).toMatchObject({ status: 'hit', buildMs: 5_000, idleMs: 0 })
|
||||
})
|
||||
|
||||
it('counts a building prepared checkout against other creates but not the one that used it', async () => {
|
||||
const checkoutStarted = Promise.withResolvers<void>()
|
||||
const checkout = Promise.withResolvers<void>()
|
||||
mocks.prepare.mockImplementation(() => {
|
||||
checkoutStarted.resolve()
|
||||
return checkout.promise
|
||||
})
|
||||
const bystander = beginWorktreeCreate()
|
||||
const preparation = prepare()
|
||||
await checkoutStarted.promise
|
||||
const inFlight = beginWorktreeCreate()
|
||||
const timing = createWorktreeCreateTimingRecorder(undefined, inFlight)
|
||||
|
||||
const create = consumePreparedWorktreeCreate({ ...request, timing })
|
||||
await settleClaim()
|
||||
checkout.resolve()
|
||||
await preparation
|
||||
|
||||
expect(await create).toMatchObject({ status: 'hit', origin: 'prefetch' })
|
||||
expect(inFlight.end().preparations).toBe(0)
|
||||
expect(bystander.end().preparations).toBe(1)
|
||||
})
|
||||
})
|
||||
@@ -45,10 +45,6 @@ function resolveOnly(present: string[]): void {
|
||||
})
|
||||
}
|
||||
|
||||
function revParse(ref: string): string[] {
|
||||
return ['rev-parse', '--verify', '--quiet', `${ref}^{commit}`]
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
for (const mock of Object.values(mocks)) {
|
||||
mock.mockReset()
|
||||
@@ -61,117 +57,6 @@ beforeEach(() => {
|
||||
mocks.fetchRemoteWithCache.mockResolvedValue(undefined)
|
||||
})
|
||||
|
||||
describe('prefetchWorktreeCreateBase local git routing', () => {
|
||||
it('resolves the default base and its ref probes inside the selected WSL distro', async () => {
|
||||
resolveOnly(['refs/remotes/origin/main'])
|
||||
|
||||
await expect(
|
||||
prefetchWorktreeCreateBase({ repo, runtime: runtime(), gitOptions: WSL })
|
||||
).resolves.toBe('origin/main')
|
||||
|
||||
expect(mocks.getBaseRefDefault).toHaveBeenCalledWith(repo.path, WSL)
|
||||
expect(mocks.resolveRemoteTrackingBase).toHaveBeenCalledWith(repo.path, 'origin/main', WSL)
|
||||
expect(mocks.gitExecFileAsync).toHaveBeenCalledWith(revParse('refs/remotes/origin/main'), {
|
||||
cwd: repo.path,
|
||||
...WSL
|
||||
})
|
||||
expect(mocks.fetchRemoteWithCache).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('probes a full commit object inside the selected WSL distro', async () => {
|
||||
const sha = 'a'.repeat(40)
|
||||
resolveOnly([sha])
|
||||
|
||||
await expect(
|
||||
prefetchWorktreeCreateBase({ repo, baseBranch: sha, runtime: runtime(), gitOptions: WSL })
|
||||
).resolves.toBe(sha)
|
||||
|
||||
expect(mocks.gitExecFileAsync).toHaveBeenCalledWith(revParse(sha), {
|
||||
cwd: repo.path,
|
||||
...WSL
|
||||
})
|
||||
expect(mocks.fetchRemoteWithCache).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('routes the exact remote-base refresh through the selected WSL distro', async () => {
|
||||
const remoteTrackingBase = {
|
||||
remote: 'origin',
|
||||
branch: 'main',
|
||||
ref: 'refs/remotes/origin/main',
|
||||
base: 'origin/main'
|
||||
}
|
||||
mocks.resolveRemoteTrackingBase.mockResolvedValue(remoteTrackingBase)
|
||||
mocks.hasRemoteTrackingRef.mockResolvedValue(true)
|
||||
|
||||
await expect(
|
||||
prefetchWorktreeCreateBase({
|
||||
repo,
|
||||
baseBranch: 'origin/main',
|
||||
runtime: runtime(),
|
||||
gitOptions: WSL
|
||||
})
|
||||
).resolves.toBe('origin/main')
|
||||
|
||||
expect(mocks.hasRemoteTrackingRef).toHaveBeenCalledWith(repo.path, remoteTrackingBase, WSL)
|
||||
expect(mocks.getOrStartRemoteTrackingBaseRefresh).toHaveBeenCalledWith(
|
||||
repo.path,
|
||||
remoteTrackingBase,
|
||||
WSL
|
||||
)
|
||||
})
|
||||
|
||||
it('routes the broad remote-fetch fallback through the selected WSL distro', async () => {
|
||||
await expect(
|
||||
prefetchWorktreeCreateBase({
|
||||
repo,
|
||||
baseBranch: 'feature/topic',
|
||||
runtime: runtime(),
|
||||
gitOptions: WSL
|
||||
})
|
||||
).resolves.toBe('feature/topic')
|
||||
|
||||
expect(mocks.fetchRemoteWithCache).toHaveBeenCalledWith(repo.path, 'origin', WSL)
|
||||
})
|
||||
|
||||
it('leaves host-routed probes on the git host they used before routing existed', async () => {
|
||||
await expect(
|
||||
prefetchWorktreeCreateBase({
|
||||
repo,
|
||||
baseBranch: 'feature/topic',
|
||||
runtime: runtime(),
|
||||
gitOptions: {}
|
||||
})
|
||||
).resolves.toBe('feature/topic')
|
||||
|
||||
expect(mocks.gitExecFileAsync).toHaveBeenCalledWith(revParse('refs/remotes/feature/topic'), {
|
||||
cwd: repo.path
|
||||
})
|
||||
for (const call of mocks.gitExecFileAsync.mock.calls) {
|
||||
expect(call[1]).toEqual({ cwd: repo.path })
|
||||
}
|
||||
expect(mocks.resolveRemoteTrackingBase).toHaveBeenCalledWith(repo.path, 'feature/topic')
|
||||
expect(mocks.fetchRemoteWithCache).toHaveBeenCalledWith(repo.path, 'origin')
|
||||
})
|
||||
|
||||
it('does not resolve a local base for SSH repos', async () => {
|
||||
const prepareCheckout = vi.fn()
|
||||
|
||||
await expect(
|
||||
prefetchWorktreeCreateBase({
|
||||
repo: { ...repo, connectionId: 'conn-1' },
|
||||
prepareCheckout,
|
||||
baseBranch: 'origin/main',
|
||||
runtime: runtime(),
|
||||
gitOptions: WSL
|
||||
})
|
||||
).resolves.toBeUndefined()
|
||||
|
||||
expect(mocks.gitExecFileAsync).not.toHaveBeenCalled()
|
||||
expect(mocks.resolveRemoteTrackingBase).not.toHaveBeenCalled()
|
||||
expect(prepareCheckout).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('checkout and refresh overlap', () => {
|
||||
it.each([{}, WSL])(
|
||||
'starts one checkout with a shared refresh barrier before fetch settles on %j',
|
||||
@@ -221,58 +106,6 @@ describe('checkout and refresh overlap', () => {
|
||||
}
|
||||
)
|
||||
|
||||
it('waits for refresh when the selected base is not local', async () => {
|
||||
mocks.resolveRemoteTrackingBase.mockResolvedValue({
|
||||
remote: 'origin',
|
||||
branch: 'main',
|
||||
ref: 'refs/remotes/origin/main',
|
||||
base: 'origin/main'
|
||||
})
|
||||
let release!: () => void
|
||||
mocks.getOrStartRemoteTrackingBaseRefresh.mockImplementation(
|
||||
() =>
|
||||
new Promise<{ ok: boolean }>((resolve) => {
|
||||
release = () => resolve({ ok: true })
|
||||
})
|
||||
)
|
||||
const prepareCheckout = vi.fn().mockResolvedValue(undefined)
|
||||
const result = prefetchWorktreeCreateBase({
|
||||
repo,
|
||||
baseBranch: 'origin/main',
|
||||
runtime: runtime(),
|
||||
gitOptions: {},
|
||||
prepareCheckout
|
||||
})
|
||||
await vi.waitFor(() =>
|
||||
expect(mocks.getOrStartRemoteTrackingBaseRefresh).toHaveBeenCalledTimes(1)
|
||||
)
|
||||
expect(prepareCheckout).not.toHaveBeenCalled()
|
||||
release()
|
||||
await result
|
||||
expect(prepareCheckout).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('does not fail a successful refresh because preparation fails', async () => {
|
||||
mocks.resolveRemoteTrackingBase.mockResolvedValue({
|
||||
remote: 'origin',
|
||||
branch: 'main',
|
||||
ref: 'refs/remotes/origin/main',
|
||||
base: 'origin/main'
|
||||
})
|
||||
mocks.hasRemoteTrackingRef.mockResolvedValue(true)
|
||||
const prepareCheckout = vi.fn().mockRejectedValue(new Error('checkout failed'))
|
||||
await expect(
|
||||
prefetchWorktreeCreateBase({
|
||||
repo,
|
||||
baseBranch: 'origin/main',
|
||||
runtime: runtime(),
|
||||
gitOptions: {},
|
||||
prepareCheckout
|
||||
})
|
||||
).resolves.toBe('origin/main')
|
||||
expect(prepareCheckout).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('settles preparation before propagating refresh failure', async () => {
|
||||
mocks.resolveRemoteTrackingBase.mockResolvedValue({
|
||||
remote: 'origin',
|
||||
@@ -336,17 +169,3 @@ describe('checkout and refresh overlap', () => {
|
||||
expect(mocks.getOrStartRemoteTrackingBaseRefresh).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
|
||||
it('does not prepare folder repositories', async () => {
|
||||
const prepareCheckout = vi.fn()
|
||||
await expect(
|
||||
prefetchWorktreeCreateBase({
|
||||
repo: { ...repo, kind: 'folder' },
|
||||
runtime: runtime(),
|
||||
gitOptions: {},
|
||||
prepareCheckout
|
||||
})
|
||||
).resolves.toBeUndefined()
|
||||
expect(prepareCheckout).not.toHaveBeenCalled()
|
||||
expect(mocks.gitExecFileAsync).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
@@ -1,305 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { RetiredNameRegistry } from '../../../../shared/worktree/retired-name-registry'
|
||||
import {
|
||||
MAX_RETIREMENT_NAMESPACES,
|
||||
migrateRetirementNamespaceHostIdentity,
|
||||
recordRetirementNamespaceRegistry,
|
||||
retirementHostIdentity,
|
||||
retirementNamespaceKey,
|
||||
UNKNOWN_SSH_HOST_IDENTITY
|
||||
} from './worktree-retirement-namespace'
|
||||
|
||||
function registry(...names: string[]): RetiredNameRegistry {
|
||||
return { exhaustedTiers: 0, names }
|
||||
}
|
||||
|
||||
const TARGET = {
|
||||
configHost: 'builder',
|
||||
host: 'builder.example.com',
|
||||
port: 22,
|
||||
username: 'dev'
|
||||
}
|
||||
|
||||
describe('recordRetirementNamespaceRegistry', () => {
|
||||
it('evicts the least recently recorded namespace once the map is full', () => {
|
||||
const namespaces: Record<string, RetiredNameRegistry> = {}
|
||||
for (let index = 0; index < MAX_RETIREMENT_NAMESPACES; index += 1) {
|
||||
recordRetirementNamespaceRegistry(namespaces, `local:posix:/w/${index}`, registry('nautilus'))
|
||||
}
|
||||
|
||||
recordRetirementNamespaceRegistry(namespaces, 'local:posix:/w/new', registry('seahorse'))
|
||||
|
||||
expect(Object.keys(namespaces)).toHaveLength(MAX_RETIREMENT_NAMESPACES)
|
||||
expect(namespaces['local:posix:/w/0']).toBeUndefined()
|
||||
expect(namespaces['local:posix:/w/1']).toEqual(registry('nautilus'))
|
||||
expect(namespaces['local:posix:/w/new']).toEqual(registry('seahorse'))
|
||||
})
|
||||
|
||||
it('refreshes a namespace it rewrites so an actively used repo is never the eviction victim', () => {
|
||||
const namespaces: Record<string, RetiredNameRegistry> = {}
|
||||
for (let index = 0; index < MAX_RETIREMENT_NAMESPACES; index += 1) {
|
||||
recordRetirementNamespaceRegistry(namespaces, `local:posix:/w/${index}`, registry('nautilus'))
|
||||
}
|
||||
|
||||
recordRetirementNamespaceRegistry(namespaces, 'local:posix:/w/0', registry('nautilus', 'orca'))
|
||||
recordRetirementNamespaceRegistry(namespaces, 'local:posix:/w/new', registry('seahorse'))
|
||||
|
||||
expect(namespaces['local:posix:/w/0']).toEqual(registry('nautilus', 'orca'))
|
||||
expect(namespaces['local:posix:/w/1']).toBeUndefined()
|
||||
})
|
||||
|
||||
it('stays at one entry per namespace no matter how often it is rewritten', () => {
|
||||
const namespaces: Record<string, RetiredNameRegistry> = {}
|
||||
for (let index = 0; index < MAX_RETIREMENT_NAMESPACES * 2; index += 1) {
|
||||
recordRetirementNamespaceRegistry(namespaces, 'local:posix:/w/a', registry(`name-${index}`))
|
||||
}
|
||||
|
||||
expect(Object.keys(namespaces)).toEqual(['local:posix:/w/a'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('migrateRetirementNamespaceHostIdentity', () => {
|
||||
it('folds a migrated namespace into one the new identity already owns', () => {
|
||||
const namespaces = {
|
||||
'ssh:old-id:posix:/srv/a': registry('nautilus'),
|
||||
'ssh:new|22|dev:posix:/srv/a': registry('seahorse')
|
||||
}
|
||||
|
||||
expect(
|
||||
migrateRetirementNamespaceHostIdentity(namespaces, {
|
||||
moveFrom: ['ssh:old-id'],
|
||||
to: 'ssh:new|22|dev'
|
||||
})
|
||||
).toBe(true)
|
||||
expect(Object.keys(namespaces)).toEqual(['ssh:new|22|dev:posix:/srv/a'])
|
||||
expect(namespaces['ssh:new|22|dev:posix:/srv/a'].names.toSorted()).toEqual([
|
||||
'nautilus',
|
||||
'seahorse'
|
||||
])
|
||||
})
|
||||
|
||||
it('keeps the source bucket when an endpoint identity moves, since a live target may share it', () => {
|
||||
const namespaces = {
|
||||
'ssh:old|22|dev:posix:/srv/a': registry('nautilus')
|
||||
}
|
||||
|
||||
expect(
|
||||
migrateRetirementNamespaceHostIdentity(namespaces, {
|
||||
copyFrom: ['ssh:old|22|dev'],
|
||||
to: 'ssh:new|22|dev'
|
||||
})
|
||||
).toBe(true)
|
||||
expect(Object.keys(namespaces).toSorted()).toEqual([
|
||||
'ssh:new|22|dev:posix:/srv/a',
|
||||
'ssh:old|22|dev:posix:/srv/a'
|
||||
])
|
||||
expect(namespaces['ssh:old|22|dev:posix:/srv/a'].names).toEqual(['nautilus'])
|
||||
expect(namespaces['ssh:new|22|dev:posix:/srv/a'].names).toEqual(['nautilus'])
|
||||
})
|
||||
|
||||
it('reports no change when a repeated copy adds nothing the destination lacks', () => {
|
||||
const namespaces = {
|
||||
'ssh:old|22|dev:posix:/srv/a': registry('nautilus'),
|
||||
'ssh:new|22|dev:posix:/srv/a': registry('nautilus')
|
||||
}
|
||||
|
||||
expect(
|
||||
migrateRetirementNamespaceHostIdentity(namespaces, {
|
||||
copyFrom: ['ssh:old|22|dev'],
|
||||
to: 'ssh:new|22|dev'
|
||||
})
|
||||
).toBe(false)
|
||||
expect(Object.keys(namespaces).toSorted()).toEqual([
|
||||
'ssh:new|22|dev:posix:/srv/a',
|
||||
'ssh:old|22|dev:posix:/srv/a'
|
||||
])
|
||||
expect(namespaces['ssh:old|22|dev:posix:/srv/a'].names).toEqual(['nautilus'])
|
||||
expect(namespaces['ssh:new|22|dev:posix:/srv/a'].names).toEqual(['nautilus'])
|
||||
})
|
||||
|
||||
it('still copies a name the destination folded into its watermark', () => {
|
||||
// Equal sizes can hide a new name when an old one folds into the watermark.
|
||||
const namespaces = {
|
||||
'ssh:old|22|dev:posix:/srv/a': registry('seahorse-2'),
|
||||
'ssh:new|22|dev:posix:/srv/a': { exhaustedTiers: 1, names: ['nautilus'] }
|
||||
}
|
||||
|
||||
expect(
|
||||
migrateRetirementNamespaceHostIdentity(namespaces, {
|
||||
copyFrom: ['ssh:old|22|dev'],
|
||||
to: 'ssh:new|22|dev'
|
||||
})
|
||||
).toBe(true)
|
||||
expect(namespaces['ssh:new|22|dev:posix:/srv/a'].names).toContain('seahorse-2')
|
||||
})
|
||||
|
||||
it('keeps the namespace map within its cap by evicting bystanders, not the copy itself', () => {
|
||||
// Old source buckets must survive trimming too, or copying would effectively move their tombstones.
|
||||
const namespaces: Record<string, RetiredNameRegistry> = {
|
||||
'ssh:old|22|dev:posix:/srv/a': registry('nautilus'),
|
||||
'ssh:old|22|dev:posix:/srv/b': registry('manta')
|
||||
}
|
||||
for (let index = 0; index < MAX_RETIREMENT_NAMESPACES - 2; index += 1) {
|
||||
namespaces[`local:posix:/w/${index}`] = registry('dolphin')
|
||||
}
|
||||
|
||||
expect(
|
||||
migrateRetirementNamespaceHostIdentity(namespaces, {
|
||||
copyFrom: ['ssh:old|22|dev'],
|
||||
to: 'ssh:new|22|dev'
|
||||
})
|
||||
).toBe(true)
|
||||
expect(Object.keys(namespaces).length).toBe(MAX_RETIREMENT_NAMESPACES)
|
||||
expect(namespaces['ssh:old|22|dev:posix:/srv/a']).toBeDefined()
|
||||
expect(namespaces['ssh:old|22|dev:posix:/srv/b']).toBeDefined()
|
||||
expect(namespaces['ssh:new|22|dev:posix:/srv/a']).toBeDefined()
|
||||
expect(namespaces['ssh:new|22|dev:posix:/srv/b']).toBeDefined()
|
||||
expect(namespaces['local:posix:/w/0']).toBeUndefined()
|
||||
expect(namespaces['local:posix:/w/1']).toBeUndefined()
|
||||
expect(namespaces['local:posix:/w/2']).toBeDefined()
|
||||
})
|
||||
|
||||
it('refreshes a retained source even when its own merge added nothing', () => {
|
||||
// A no-op source remains live while another copied namespace grows the map.
|
||||
const namespaces: Record<string, RetiredNameRegistry> = {
|
||||
'ssh:old|22|dev:posix:/srv/a': registry('seahorse'),
|
||||
'ssh:new|22|dev:posix:/srv/a': registry('seahorse'),
|
||||
'ssh:old|22|dev:posix:/srv/b': registry('nautilus')
|
||||
}
|
||||
for (let index = 0; index < MAX_RETIREMENT_NAMESPACES - 3; index += 1) {
|
||||
namespaces[`local:posix:/w/${index}`] = registry('dolphin')
|
||||
}
|
||||
|
||||
migrateRetirementNamespaceHostIdentity(namespaces, {
|
||||
copyFrom: ['ssh:old|22|dev'],
|
||||
to: 'ssh:new|22|dev'
|
||||
})
|
||||
expect(Object.keys(namespaces).length).toBe(MAX_RETIREMENT_NAMESPACES)
|
||||
expect(namespaces['ssh:old|22|dev:posix:/srv/a']).toBeDefined()
|
||||
expect(namespaces['local:posix:/w/0']).toBeUndefined()
|
||||
})
|
||||
|
||||
it('refreshes a move destination that the move just made the only copy', () => {
|
||||
const namespaces: Record<string, RetiredNameRegistry> = {
|
||||
'ssh:new|22|dev:posix:/srv/a': registry('seahorse'),
|
||||
'local:posix:/w/0': registry('dolphin'),
|
||||
'ssh:old-id:posix:/srv/a': registry('seahorse')
|
||||
}
|
||||
|
||||
migrateRetirementNamespaceHostIdentity(namespaces, {
|
||||
moveFrom: ['ssh:old-id'],
|
||||
to: 'ssh:new|22|dev'
|
||||
})
|
||||
expect(Object.keys(namespaces).at(-1)).toBe('ssh:new|22|dev:posix:/srv/a')
|
||||
})
|
||||
|
||||
it('leaves a merged destination newest, so the very next ordinary write cannot evict it', () => {
|
||||
const namespaces: Record<string, RetiredNameRegistry> = {
|
||||
'ssh:new|22|dev:posix:/srv/a': registry('seahorse')
|
||||
}
|
||||
for (let index = 0; index < MAX_RETIREMENT_NAMESPACES - 2; index += 1) {
|
||||
namespaces[`local:posix:/w/${index}`] = registry('dolphin')
|
||||
}
|
||||
namespaces['ssh:old|22|dev:posix:/srv/a'] = registry('nautilus')
|
||||
|
||||
migrateRetirementNamespaceHostIdentity(namespaces, {
|
||||
copyFrom: ['ssh:old|22|dev'],
|
||||
to: 'ssh:new|22|dev'
|
||||
})
|
||||
recordRetirementNamespaceRegistry(namespaces, 'local:posix:/fresh', registry('manta'))
|
||||
|
||||
const destination = namespaces['ssh:new|22|dev:posix:/srv/a']
|
||||
expect(destination).toBeDefined()
|
||||
expect(destination.names.toSorted()).toEqual(['nautilus', 'seahorse'])
|
||||
})
|
||||
|
||||
it('does not let the cap evict a destination it just merged into', () => {
|
||||
const namespaces: Record<string, RetiredNameRegistry> = {
|
||||
'ssh:new|22|dev:posix:/srv/a': registry('seahorse')
|
||||
}
|
||||
for (let index = 0; index < MAX_RETIREMENT_NAMESPACES - 2; index += 1) {
|
||||
namespaces[`local:posix:/w/${index}`] = registry('dolphin')
|
||||
}
|
||||
namespaces['ssh:old|22|dev:posix:/srv/a'] = registry('nautilus')
|
||||
namespaces['ssh:old|22|dev:posix:/srv/b'] = registry('manta')
|
||||
|
||||
expect(
|
||||
migrateRetirementNamespaceHostIdentity(namespaces, {
|
||||
copyFrom: ['ssh:old|22|dev'],
|
||||
to: 'ssh:new|22|dev'
|
||||
})
|
||||
).toBe(true)
|
||||
const destination = namespaces['ssh:new|22|dev:posix:/srv/a']
|
||||
expect(destination).toBeDefined()
|
||||
expect(destination.names.toSorted()).toEqual(['nautilus', 'seahorse'])
|
||||
})
|
||||
|
||||
it('leaves an identity that merely shares a prefix with the old one alone', () => {
|
||||
const namespaces = {
|
||||
'ssh:h|22|dev2:posix:/srv/a': registry('nautilus')
|
||||
}
|
||||
|
||||
expect(
|
||||
migrateRetirementNamespaceHostIdentity(namespaces, {
|
||||
moveFrom: ['ssh:h|22|dev'],
|
||||
to: 'ssh:h|22|other'
|
||||
})
|
||||
).toBe(false)
|
||||
expect(Object.keys(namespaces)).toEqual(['ssh:h|22|dev2:posix:/srv/a'])
|
||||
})
|
||||
|
||||
it('reports no change when there is nothing to move or the identity is unchanged', () => {
|
||||
expect(
|
||||
migrateRetirementNamespaceHostIdentity(undefined, { moveFrom: ['ssh:a'], to: 'ssh:b' })
|
||||
).toBe(false)
|
||||
expect(
|
||||
migrateRetirementNamespaceHostIdentity(
|
||||
{ 'ssh:a:posix:/srv': registry('nautilus') },
|
||||
{ to: 'ssh:a' }
|
||||
)
|
||||
).toBe(false)
|
||||
expect(
|
||||
migrateRetirementNamespaceHostIdentity(
|
||||
{ 'ssh:a:posix:/srv': registry('nautilus') },
|
||||
{ moveFrom: ['ssh:a'], to: 'ssh:a' }
|
||||
)
|
||||
).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('retirementHostIdentity', () => {
|
||||
it('resolves an SSH repo to the endpoint its target reaches, not the target row id', () => {
|
||||
const identity = retirementHostIdentity({ connectionId: 'ssh-1' }, () => TARGET)
|
||||
|
||||
expect(identity).toBe('ssh:builder.example.com|22|dev')
|
||||
expect(retirementHostIdentity({ connectionId: 'ssh-2' }, () => TARGET)).toBe(identity)
|
||||
})
|
||||
|
||||
it('falls back to one shared bucket when the target row is gone', () => {
|
||||
expect(retirementHostIdentity({ connectionId: 'ssh-1' }, () => undefined)).toBe(
|
||||
UNKNOWN_SSH_HOST_IDENTITY
|
||||
)
|
||||
expect(retirementHostIdentity({ connectionId: 'ssh-1' })).toBe(UNKNOWN_SSH_HOST_IDENTITY)
|
||||
})
|
||||
|
||||
it('leaves a non-SSH repo on its execution host id', () => {
|
||||
expect(retirementHostIdentity({})).toBe('local')
|
||||
})
|
||||
})
|
||||
|
||||
describe('retirementNamespaceKey', () => {
|
||||
it('keeps Windows paths case- and separator-insensitive without colliding with a POSIX path', () => {
|
||||
expect(retirementNamespaceKey('local', 'C:\\Workspaces\\Probe')).toBe(
|
||||
retirementNamespaceKey('local', 'C:/workspaces/probe')
|
||||
)
|
||||
expect(retirementNamespaceKey('local', 'C:\\workspaces\\probe')).not.toBe(
|
||||
retirementNamespaceKey('local', '/workspaces/probe')
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps POSIX paths case-sensitive, matching the filesystems they name', () => {
|
||||
expect(retirementNamespaceKey('local', '/srv/Probe')).not.toBe(
|
||||
retirementNamespaceKey('local', '/srv/probe')
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -1,6 +1,6 @@
|
||||
import { mkdtempSync, rmSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import 'node:fs'
|
||||
import 'node:os'
|
||||
import 'node:path'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { ProcessResult } from '@orca/process-host/process-spec'
|
||||
|
||||
@@ -17,23 +17,11 @@ const FAILED = exited('', 1)
|
||||
import {
|
||||
_resetWslCachesForTests,
|
||||
_setWslCachesForTests,
|
||||
listRunningWslDistrosAsync,
|
||||
listRunningWslHomeDirsAsync
|
||||
listRunningWslDistrosAsync
|
||||
} from './distro-catalog'
|
||||
import { filterPathsToRunningWslDistrosAsync } from '../filesystem/wsl-running-path-filter'
|
||||
import { resetWslInteropSpawnDirectoryCache } from '../execution/wsl-interop-spawn-directory'
|
||||
import {
|
||||
observeWslTranscriptRunningState,
|
||||
resetWslTranscriptRunningObserverForTests
|
||||
} from '../../native-chat/wsl-transcript-running-observer'
|
||||
|
||||
function restoreEnv(key: string, value: string | undefined): void {
|
||||
if (value === undefined) {
|
||||
delete process.env[key]
|
||||
} else {
|
||||
process.env[key] = value
|
||||
}
|
||||
}
|
||||
import '../execution/wsl-interop-spawn-directory'
|
||||
import { resetWslTranscriptRunningObserverForTests } from '../../native-chat/wsl-transcript-running-observer'
|
||||
|
||||
async function withPlatform<T>(value: NodeJS.Platform, fn: () => Promise<T>): Promise<T> {
|
||||
const original = process.platform
|
||||
@@ -56,62 +44,6 @@ describe('running WSL distro discovery', () => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('lists only running user distros without starting them', async () => {
|
||||
runProcessMock.mockResolvedValue(exited('Ubuntu\u0000\nDocker-Desktop\u0000\n'))
|
||||
|
||||
await withPlatform('win32', async () => {
|
||||
await expect(listRunningWslDistrosAsync()).resolves.toEqual(['Ubuntu'])
|
||||
expect(runProcessMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
program: 'wsl.exe',
|
||||
args: ['--list', '--running', '--quiet'],
|
||||
env: expect.objectContaining({ WSL_UTF8: '1' }),
|
||||
timeoutMs: 5000
|
||||
})
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it('runs the startup probe from the system root, never the profile (#29845)', async () => {
|
||||
runProcessMock.mockResolvedValue(exited('Ubuntu\n'))
|
||||
const saved = { SystemRoot: process.env.SystemRoot, profile: process.env.ORCA_USER_DATA_PATH }
|
||||
const systemRoot = mkdtempSync(join(tmpdir(), 'orca-wsl-probe-system-root-'))
|
||||
const profile = mkdtempSync(join(tmpdir(), 'orca-wsl-probe-profile-'))
|
||||
process.env.SystemRoot = systemRoot
|
||||
process.env.ORCA_USER_DATA_PATH = profile
|
||||
resetWslInteropSpawnDirectoryCache()
|
||||
try {
|
||||
await withPlatform('win32', () => listRunningWslDistrosAsync({ requireConfirmed: true }))
|
||||
expect(runProcessMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ args: ['--list', '--running', '--quiet'], cwd: systemRoot })
|
||||
)
|
||||
} finally {
|
||||
restoreEnv('SystemRoot', saved.SystemRoot)
|
||||
restoreEnv('ORCA_USER_DATA_PATH', saved.profile)
|
||||
resetWslInteropSpawnDirectoryCache()
|
||||
rmSync(systemRoot, { recursive: true, force: true })
|
||||
rmSync(profile, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('confirms an empty list when no distro runs', async () => {
|
||||
// WSL 2.7 --quiet returns empty stdout with exit 0; non-quiet output is localized and exits -1.
|
||||
runProcessMock.mockResolvedValueOnce(exited('Ubuntu\r\n')).mockResolvedValueOnce(exited(''))
|
||||
|
||||
await withPlatform('win32', async () => {
|
||||
await expect(listRunningWslDistrosAsync()).resolves.toEqual(['Ubuntu'])
|
||||
await expect(listRunningWslDistrosAsync({ requireConfirmed: true })).resolves.toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
it('fails closed when running-distro discovery fails', async () => {
|
||||
runProcessMock.mockResolvedValue(FAILED)
|
||||
|
||||
await withPlatform('win32', async () => {
|
||||
await expect(listRunningWslDistrosAsync()).resolves.toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects stale running state for launch admission through failure and backoff', async () => {
|
||||
vi.useFakeTimers()
|
||||
runProcessMock.mockResolvedValueOnce(exited('Ubuntu\n'))
|
||||
@@ -165,161 +97,4 @@ describe('running WSL distro discovery', () => {
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
it('resolves homes only for the running distro set', async () => {
|
||||
runProcessMock.mockImplementation(
|
||||
({ args }: { args: string[] }) =>
|
||||
new Promise<ProcessResult>((resolve) => {
|
||||
resolve(exited(args.includes('--running') ? 'Ubuntu\n' : '/home/ada\n'))
|
||||
})
|
||||
)
|
||||
|
||||
await withPlatform('win32', async () => {
|
||||
await expect(listRunningWslHomeDirsAsync()).resolves.toEqual([
|
||||
'\\\\wsl.localhost\\Ubuntu\\home\\ada'
|
||||
])
|
||||
expect(runProcessMock.mock.calls.map(([spec]) => spec.args)).toEqual([
|
||||
['--list', '--running', '--quiet'],
|
||||
['-d', 'Ubuntu', '--exec', 'bash', '-c', 'echo $HOME']
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
it('single-flights concurrent probes without caching the result', async () => {
|
||||
let finishProbe: ((output: string) => void) | undefined
|
||||
runProcessMock.mockImplementationOnce(
|
||||
() =>
|
||||
new Promise<ProcessResult>((resolve) => {
|
||||
finishProbe = (output) => resolve(exited(output))
|
||||
})
|
||||
)
|
||||
|
||||
await withPlatform('win32', async () => {
|
||||
const concurrent = [
|
||||
listRunningWslDistrosAsync(),
|
||||
listRunningWslDistrosAsync(),
|
||||
listRunningWslDistrosAsync()
|
||||
]
|
||||
expect(runProcessMock).toHaveBeenCalledTimes(1)
|
||||
finishProbe?.('Ubuntu\n')
|
||||
await expect(Promise.all(concurrent)).resolves.toEqual([['Ubuntu'], ['Ubuntu'], ['Ubuntu']])
|
||||
|
||||
runProcessMock.mockResolvedValueOnce(exited(''))
|
||||
await expect(listRunningWslDistrosAsync()).resolves.toEqual([])
|
||||
expect(runProcessMock).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
})
|
||||
|
||||
it('single-flights cold HOME probes across concurrent callers', async () => {
|
||||
let finishList: ((output: string) => void) | undefined
|
||||
let finishHome: ((output: string) => void) | undefined
|
||||
runProcessMock.mockImplementation(
|
||||
({ args }: { args: string[] }) =>
|
||||
new Promise<ProcessResult>((resolve) => {
|
||||
if (args.includes('--running')) {
|
||||
finishList = (output) => resolve(exited(output))
|
||||
} else {
|
||||
finishHome = (output) => resolve(exited(output))
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
await withPlatform('win32', async () => {
|
||||
const concurrent = [
|
||||
listRunningWslHomeDirsAsync(),
|
||||
listRunningWslHomeDirsAsync(),
|
||||
listRunningWslHomeDirsAsync()
|
||||
]
|
||||
finishList?.('Ubuntu\n')
|
||||
await vi.waitFor(() => expect(runProcessMock).toHaveBeenCalledTimes(2))
|
||||
finishHome?.('/home/ada\n')
|
||||
|
||||
await expect(Promise.all(concurrent)).resolves.toEqual([
|
||||
['\\\\wsl.localhost\\Ubuntu\\home\\ada'],
|
||||
['\\\\wsl.localhost\\Ubuntu\\home\\ada'],
|
||||
['\\\\wsl.localhost\\Ubuntu\\home\\ada']
|
||||
])
|
||||
expect(
|
||||
runProcessMock.mock.calls.filter(([spec]) => spec.args.includes('echo $HOME'))
|
||||
).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
it('shares a confirmed fresh probe between discovery and launch admission', async () => {
|
||||
runProcessMock.mockResolvedValueOnce(exited('Ubuntu\n'))
|
||||
await withPlatform('win32', async () => {
|
||||
expect(
|
||||
await Promise.all([
|
||||
listRunningWslDistrosAsync(),
|
||||
listRunningWslDistrosAsync({ requireConfirmed: true })
|
||||
])
|
||||
).toEqual([['Ubuntu'], ['Ubuntu']])
|
||||
expect(runProcessMock).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
|
||||
it('filters stopped-distro UNC paths while preserving host paths', async () => {
|
||||
runProcessMock.mockResolvedValue(exited('Ubuntu\n'))
|
||||
|
||||
await withPlatform('win32', async () => {
|
||||
await expect(
|
||||
filterPathsToRunningWslDistrosAsync([
|
||||
'C:\\Users\\ada\\codex-home',
|
||||
'\\\\wsl.localhost\\Ubuntu\\home\\ada',
|
||||
'\\\\wsl.localhost\\Debian\\home\\other'
|
||||
])
|
||||
).resolves.toEqual(['C:\\Users\\ada\\codex-home', '\\\\wsl.localhost\\Ubuntu\\home\\ada'])
|
||||
})
|
||||
})
|
||||
|
||||
it('does not probe WSL on non-Windows hosts', async () => {
|
||||
await withPlatform('linux', async () => {
|
||||
await expect(listRunningWslDistrosAsync()).resolves.toEqual([])
|
||||
expect(runProcessMock).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps reporting a session running through a sustained wsl.exe outage, without unbounded spawns', async () => {
|
||||
let spawnCount = 0
|
||||
runProcessMock.mockImplementation(
|
||||
() =>
|
||||
new Promise<ProcessResult>((resolve) => {
|
||||
spawnCount += 1
|
||||
resolve(exited('Ubuntu\n'))
|
||||
})
|
||||
)
|
||||
|
||||
await withPlatform('win32', async () => {
|
||||
await expect(listRunningWslDistrosAsync()).resolves.toEqual(['Ubuntu'])
|
||||
expect(spawnCount).toBe(1)
|
||||
|
||||
runProcessMock.mockImplementation(
|
||||
() =>
|
||||
new Promise<ProcessResult>((resolve) => {
|
||||
spawnCount += 1
|
||||
resolve(FAILED)
|
||||
})
|
||||
)
|
||||
|
||||
vi.useFakeTimers()
|
||||
const observedStates: boolean[] = []
|
||||
const stop = observeWslTranscriptRunningState(
|
||||
'\\\\wsl.localhost\\Ubuntu\\home\\ada\\a.jsonl',
|
||||
() => {
|
||||
observedStates.push(true)
|
||||
},
|
||||
() => {
|
||||
observedStates.push(false)
|
||||
}
|
||||
)
|
||||
|
||||
await vi.advanceTimersByTimeAsync(30 * 60_000)
|
||||
stop()
|
||||
|
||||
expect(observedStates.length).toBeGreaterThan(0)
|
||||
expect(observedStates.every((state) => state === true)).toBe(true)
|
||||
|
||||
expect(spawnCount).toBeLessThan(15)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { getDefaultPersistedState } from '../../../shared/constants'
|
||||
import type { PersistedState } from '../../../shared/persisted-state-types'
|
||||
import { foldersAndAutomationsWslMoverProfile } from '../../persistence/__fixtures__/wsl-mover/wsl-mover-folders-profile'
|
||||
import '../../persistence/__fixtures__/wsl-mover/wsl-mover-folders-profile'
|
||||
import { heavyWslMoverProfile } from '../../persistence/__fixtures__/wsl-mover/wsl-mover-heavy-profile'
|
||||
import {
|
||||
repo,
|
||||
@@ -42,45 +42,7 @@ function convert(state: PersistedState, repoIds: string[], folderWorkspaceIds: s
|
||||
return rewriteWslDriveUncState(state, { distro: WSL_MOVER_DISTRO, repoIds, folderWorkspaceIds })
|
||||
}
|
||||
|
||||
const WINDOWS_GIT = { kind: 'wsl', distro: WSL_MOVER_DISTRO, git: 'windows-host' } as const
|
||||
|
||||
describe('rewriteWslDriveUncState', () => {
|
||||
it('re-keys every session, UI and catalog row of the converted projects', () => {
|
||||
const before = persisted(heavyWslMoverProfile(unc))
|
||||
const { state, worktreeIds } = convert(before, ['repo-a', 'repo-b'])
|
||||
const expected = persisted(heavyWslMoverProfile(drive))
|
||||
for (const [oldId, newId] of worktreeIds) {
|
||||
expected.worktreeMeta[newId] = {
|
||||
...expected.worktreeMeta[newId],
|
||||
priorWorktreeIds: [oldId]
|
||||
}
|
||||
}
|
||||
expected.projects = expected.projects.map((project) => ({
|
||||
...project,
|
||||
localWindowsRuntimePreference: WINDOWS_GIT
|
||||
}))
|
||||
expect(state).toEqual(expected)
|
||||
expect(Object.fromEntries(worktreeIds)).toEqual({
|
||||
[`repo-a::${unc.at('/home/dev/app')}`]: `repo-a::${drive.at('/home/dev/app')}`,
|
||||
[`repo-a::${unc.at('/home/dev/app-feature')}`]: `repo-a::${drive.at('/home/dev/app-feature')}`,
|
||||
[`repo-b::${unc.at('/home/dev/lib', 'wsl$')}`]: `repo-b::${drive.at('/home/dev/lib')}`
|
||||
})
|
||||
})
|
||||
|
||||
it('converts folder workspaces and their group, and keeps automation history as it ran', () => {
|
||||
const before = persisted(foldersAndAutomationsWslMoverProfile(unc))
|
||||
const { state } = convert(before, ['repo-auto'], ['folder-1'])
|
||||
const expected = persisted(foldersAndAutomationsWslMoverProfile(drive))
|
||||
const wtAuto = `repo-auto::${drive.at('/srv/jobs')}`
|
||||
expected.worktreeMeta[wtAuto] = {
|
||||
...expected.worktreeMeta[wtAuto],
|
||||
priorWorktreeIds: [`repo-auto::${unc.at('/srv/jobs')}`]
|
||||
}
|
||||
expected.projects = [{ ...expected.projects[0], localWindowsRuntimePreference: WINDOWS_GIT }]
|
||||
expected.automationRuns = before.automationRuns
|
||||
expect(state).toEqual(expected)
|
||||
})
|
||||
|
||||
it('rewrites minted PTY session ids, keeping the incarnation suffix', () => {
|
||||
const before = persisted(heavyWslMoverProfile(unc))
|
||||
const oldId = `repo-a::${unc.at('/home/dev/app')}`
|
||||
@@ -121,19 +83,6 @@ describe('rewriteWslDriveUncState', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it("keeps a project's own runtime choice and only adds Windows Git to a WSL one", () => {
|
||||
const before = persisted(heavyWslMoverProfile(unc))
|
||||
before.projects = [
|
||||
{ ...before.projects[0], localWindowsRuntimePreference: { kind: 'windows-host' } },
|
||||
{ ...before.projects[1], localWindowsRuntimePreference: { kind: 'wsl', distro: 'Other' } }
|
||||
]
|
||||
const { state } = convert(before, ['repo-a', 'repo-b'])
|
||||
expect(state.projects.map((row) => row.localWindowsRuntimePreference)).toEqual([
|
||||
{ kind: 'windows-host' },
|
||||
{ kind: 'wsl', distro: 'Other', git: 'windows-host' }
|
||||
])
|
||||
})
|
||||
|
||||
it('is idempotent, so a resumed conversion changes nothing', () => {
|
||||
const once = convert(persisted(heavyWslMoverProfile(unc)), ['repo-a', 'repo-b']).state
|
||||
const twice = convert(once, ['repo-a', 'repo-b'])
|
||||
|
||||
@@ -11,21 +11,18 @@ import {
|
||||
assertWslUncPathRunning,
|
||||
guardWslUncPathAccess,
|
||||
runAsWslUserAct,
|
||||
WSL_UNC_RUNNING_LEASE_MS,
|
||||
WslDistroStoppedError,
|
||||
WslDistroUnverifiableError
|
||||
} from './wsl-unc-access-gate'
|
||||
import {
|
||||
_resetWslBackgroundDistroGateForTests,
|
||||
deferUntilWslDistroRunning,
|
||||
noteWslDistroLaunched,
|
||||
noteWslDistroMayHaveStopped,
|
||||
noteWslDistrosRunning,
|
||||
WSL_STOP_SETTLE_MS
|
||||
noteWslDistrosRunning
|
||||
} from '../discovery/wsl-background-distro-gate'
|
||||
import { EXIT_SETTLE_MS } from '../orcad/wsl-orcad-liveness-evidence'
|
||||
import '../orcad/wsl-orcad-liveness-evidence'
|
||||
import { isWslUserActFor } from '../discovery/wsl-user-act-scope'
|
||||
import { isWslDistroNotRunningRefusal } from '../../../shared/wsl-distro-not-running-refusal'
|
||||
import '../../../shared/wsl-distro-not-running-refusal'
|
||||
import {
|
||||
_resetRunningWslDistroCacheForTests,
|
||||
resolveRunningWslDistros
|
||||
@@ -64,43 +61,6 @@ describe('guardWslUncPathAccess', () => {
|
||||
expect(access).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('lets a touch of a running distro through', async () => {
|
||||
await expect(guardWslUncPathAccess(UNC, async () => 'read')).resolves.toBe('read')
|
||||
})
|
||||
|
||||
it('never probes WSL for a non-UNC path or off Windows', async () => {
|
||||
await expect(guardWslUncPathAccess('C:\\repo', async () => 'read')).resolves.toBe('read')
|
||||
Object.defineProperty(process, 'platform', { configurable: true, value: 'linux' })
|
||||
await expect(guardWslUncPathAccess(UNC, async () => 'read')).resolves.toBe('read')
|
||||
expect(probe).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('shares one running read across a burst of touches within the lease', async () => {
|
||||
await Promise.all([1, 2, 3].map(() => guardWslUncPathAccess(UNC, async () => 'read')))
|
||||
await guardWslUncPathAccess(UNC, async () => 'read')
|
||||
expect(probe).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('without a stop signal, trusts a running read for 15 s (at most four wsl.exe a minute)', async () => {
|
||||
await guardWslUncPathAccess(UNC, async () => 'read')
|
||||
const now = performance.now()
|
||||
const clock = vi.spyOn(performance, 'now')
|
||||
for (const later of [5_000, 10_000, WSL_UNC_RUNNING_LEASE_MS - 1_000]) {
|
||||
clock.mockReturnValue(now + later)
|
||||
await guardWslUncPathAccess(UNC, async () => 'read')
|
||||
}
|
||||
expect(WSL_UNC_RUNNING_LEASE_MS).toBe(15_000)
|
||||
expect(probe).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('re-reads once the lease expires', async () => {
|
||||
await guardWslUncPathAccess(UNC, async () => 'read')
|
||||
const now = performance.now()
|
||||
vi.spyOn(performance, 'now').mockReturnValue(now + WSL_UNC_RUNNING_LEASE_MS + 1)
|
||||
await guardWslUncPathAccess(UNC, async () => 'read')
|
||||
expect(probe).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('does not trust a running read that started before the distro may have stopped', async () => {
|
||||
await guardWslUncPathAccess(UNC, async () => 'read')
|
||||
// The terminate: the read above still says running, but a link into the distro just died.
|
||||
@@ -112,13 +72,6 @@ describe('guardWslUncPathAccess', () => {
|
||||
expect(probe).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('retries once past the failure backoff when WSL does not answer, and then reads', async () => {
|
||||
vi.spyOn(console, 'warn').mockImplementation(() => undefined)
|
||||
probe.mockRejectedValueOnce(new Error('wsl.exe timed out'))
|
||||
await expect(guardWslUncPathAccess(UNC, async () => 'read')).resolves.toBe('read')
|
||||
expect(probe).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it("says WSL didn't answer, not that the distro stopped, and never touches the path", async () => {
|
||||
vi.spyOn(console, 'warn').mockImplementation(() => undefined)
|
||||
probe.mockRejectedValue(new Error('wsl.exe timed out'))
|
||||
@@ -134,29 +87,6 @@ describe('guardWslUncPathAccess', () => {
|
||||
expect(access).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('never trusts a read that started while a just-signalled stop was settling', async () => {
|
||||
noteWslDistroMayHaveStopped('Ubuntu')
|
||||
// Another caller's read lands inside the settle window, while WSL still lists the stopping distro.
|
||||
await resolveRunningWslDistros(async () => ['Ubuntu'])
|
||||
running = []
|
||||
const startedAt = performance.now()
|
||||
await expect(guardWslUncPathAccess(UNC, async () => 'read')).rejects.toBeInstanceOf(
|
||||
WslDistroStoppedError
|
||||
)
|
||||
expect(probe).toHaveBeenCalledOnce()
|
||||
expect(performance.now() - startedAt).toBeGreaterThanOrEqual(WSL_STOP_SETTLE_MS - 50)
|
||||
})
|
||||
|
||||
it('treats a stop another read observed like a signalled one', async () => {
|
||||
await guardWslUncPathAccess(UNC, async () => 'read')
|
||||
running = []
|
||||
// Any confirmed read that no longer lists the distro resets the lease (wsl --shutdown, idle stop).
|
||||
await resolveRunningWslDistros(probe)
|
||||
running = ['Ubuntu']
|
||||
await guardWslUncPathAccess(UNC, async () => 'read')
|
||||
expect(probe).toHaveBeenCalledTimes(3)
|
||||
})
|
||||
|
||||
it('a user act touches without asking and, on success, resumes work waiting for the distro', async () => {
|
||||
running = []
|
||||
const resumed = vi.fn()
|
||||
@@ -184,17 +114,6 @@ describe('guardWslUncPathAccess', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('assertWslUncPathRunning for Git', () => {
|
||||
it('covers a host path whose Git runs through wsl.exe in a distro', async () => {
|
||||
running = []
|
||||
await expect(assertWslUncPathRunning('C:\\repo', 'Ubuntu')).rejects.toBeInstanceOf(
|
||||
WslDistroStoppedError
|
||||
)
|
||||
await expect(assertWslUncPathRunning('C:\\repo')).resolves.toBeUndefined()
|
||||
expect(probe).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('runAsWslUserAct', () => {
|
||||
it('lets gated work inside a user act start its own distro, and no other', async () => {
|
||||
running = []
|
||||
@@ -220,31 +139,6 @@ describe('runAsWslUserAct', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('explicit acts that start a distro', () => {
|
||||
it('a wsl.exe launch for a distro last read as stopped makes background reads look again', async () => {
|
||||
running = []
|
||||
await expect(guardWslUncPathAccess(UNC, async () => 'read')).rejects.toBeInstanceOf(
|
||||
WslDistroStoppedError
|
||||
)
|
||||
// A commit or a WSL terminal ran through wsl.exe and started it.
|
||||
running = ['Ubuntu']
|
||||
noteWslDistroLaunched('Ubuntu')
|
||||
await expect(guardWslUncPathAccess(UNC, async () => 'read')).resolves.toBe('read')
|
||||
expect(probe).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('a launch for a distro already read as running costs no extra read', async () => {
|
||||
await guardWslUncPathAccess(UNC, async () => 'read')
|
||||
noteWslDistroLaunched('Ubuntu')
|
||||
await guardWslUncPathAccess(UNC, async () => 'read')
|
||||
expect(probe).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('settles for as long as the orcad liveness exit window', () => {
|
||||
expect(WSL_STOP_SETTLE_MS).toBe(EXIT_SETTLE_MS)
|
||||
})
|
||||
})
|
||||
|
||||
describe('user-act scope does not leak', () => {
|
||||
it('deferred work drained inside a user act runs outside it', async () => {
|
||||
const inherited: boolean[] = []
|
||||
@@ -265,22 +159,3 @@ describe('user-act scope does not leak', () => {
|
||||
expect(inherited).toEqual([false])
|
||||
})
|
||||
})
|
||||
|
||||
describe('a poller recognizes the refusal however it arrives', () => {
|
||||
it.each([
|
||||
['in process', (error: Error) => error],
|
||||
['over IPC', (error: Error) => new Error(`Error invoking remote method 'gh:issue': ${error}`)],
|
||||
['over RPC', (error: Error) => new Error(error.message)]
|
||||
])('%s', (_transport, deliver) => {
|
||||
expect(isWslDistroNotRunningRefusal(deliver(new WslDistroStoppedError('Ubuntu')))).toBe(true)
|
||||
expect(
|
||||
isWslDistroNotRunningRefusal(
|
||||
deliver(new WslDistroStoppedError('Ubuntu', { retryStartsIt: false }))
|
||||
)
|
||||
).toBe(true)
|
||||
expect(isWslDistroNotRunningRefusal(deliver(new WslDistroUnverifiableError('Ubuntu')))).toBe(
|
||||
true
|
||||
)
|
||||
expect(isWslDistroNotRunningRefusal(deliver(new Error('gh: not logged in')))).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,331 +0,0 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
assertWslHostReachable,
|
||||
configureWslHostsGate,
|
||||
isGatedOffWslHost,
|
||||
noteWslHostsLivePolicyBlock,
|
||||
onWslHostsNewWorkChanged,
|
||||
resetWslHostsGateForTests,
|
||||
wslHostsAcceptNewWork,
|
||||
wslHostsOptedIn,
|
||||
wslHostsGateOpen,
|
||||
wslHostsAppliedMovedProjects,
|
||||
wslHostsAppliedPolicyState,
|
||||
wslHostsBringBackMovedProjects,
|
||||
wslHostsGateState,
|
||||
wslHostsPolicyBlocked,
|
||||
wslHostsServing,
|
||||
wslHostsWindowArguments,
|
||||
WSL_HOSTS_GATED_OFF_MESSAGE
|
||||
} from './wsl-host-gate'
|
||||
import {
|
||||
ignoreInheritedWslHostsGateState,
|
||||
isWslHostsServing,
|
||||
WSL_HOSTS_GATE_STATE_ENV
|
||||
} from '../../../shared/wsl-host-internal-gate'
|
||||
|
||||
const wslHost = {
|
||||
orcadDeployment: { kind: 'wsl' as const, distro: 'Ubuntu', localPort: 41000, remotePort: 6768 }
|
||||
}
|
||||
const sshHost = { orcadDeployment: null }
|
||||
const facts = (hasSavedWslHost: boolean, hasWslMoveCutover: boolean) => ({
|
||||
hasSavedWslHost: () => hasSavedWslHost,
|
||||
hasWslMoveCutover: () => hasWslMoveCutover
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs()
|
||||
resetWslHostsGateForTests()
|
||||
})
|
||||
|
||||
describe('WSL hosts gate', () => {
|
||||
it('flag-off with nothing saved: closed, exactly as before', () => {
|
||||
vi.stubEnv('ORCA_INTERNAL_WSL_HOSTS', undefined)
|
||||
configureWslHostsGate(facts(false, false))
|
||||
|
||||
expect(wslHostsGateState()).toBe('closed')
|
||||
expect(wslHostsServing()).toBe(false)
|
||||
expect(isGatedOffWslHost(wslHost)).toBe(true)
|
||||
expect(() => assertWslHostReachable(wslHost)).toThrow(WSL_HOSTS_GATED_OFF_MESSAGE)
|
||||
expect(isGatedOffWslHost(sshHost)).toBe(false)
|
||||
})
|
||||
|
||||
it.each([
|
||||
['a saved host', facts(true, false)],
|
||||
['a move journal entry', facts(false, true)]
|
||||
])('serves %s behind a closed gate without opening it', (_label, saved) => {
|
||||
vi.stubEnv('ORCA_INTERNAL_WSL_HOSTS', undefined)
|
||||
configureWslHostsGate(saved)
|
||||
|
||||
expect(wslHostsGateState()).toBe('serve-only')
|
||||
expect(wslHostsServing()).toBe(true)
|
||||
expect(wslHostsGateOpen()).toBe(false)
|
||||
expect(isGatedOffWslHost(wslHost)).toBe(false)
|
||||
expect(() => assertWslHostReachable(wslHost)).not.toThrow()
|
||||
})
|
||||
|
||||
it('treats unreadable saved state as absent', () => {
|
||||
vi.stubEnv('ORCA_INTERNAL_WSL_HOSTS', undefined)
|
||||
configureWslHostsGate({
|
||||
hasSavedWslHost: () => {
|
||||
throw new Error('unreadable')
|
||||
},
|
||||
hasWslMoveCutover: () => {
|
||||
throw new Error('unreadable')
|
||||
}
|
||||
})
|
||||
|
||||
expect(wslHostsGateState()).toBe('closed')
|
||||
})
|
||||
|
||||
it('stays open with the internal gate on, and reads nothing to decide', () => {
|
||||
vi.stubEnv('ORCA_INTERNAL_WSL_HOSTS', '1')
|
||||
const hasSavedWslHost = vi.fn(() => false)
|
||||
const hasWslMoveCutover = vi.fn(() => false)
|
||||
configureWslHostsGate({ hasSavedWslHost, hasWslMoveCutover })
|
||||
|
||||
expect(wslHostsGateState()).toBe('open')
|
||||
expect(hasSavedWslHost).not.toHaveBeenCalled()
|
||||
expect(hasWslMoveCutover).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('is closed before startup configures it', () => {
|
||||
vi.stubEnv('ORCA_INTERNAL_WSL_HOSTS', undefined)
|
||||
expect(wslHostsGateState()).toBe('closed')
|
||||
})
|
||||
|
||||
it('stamps window argv only in serve-only', () => {
|
||||
vi.stubEnv('ORCA_INTERNAL_WSL_HOSTS', undefined)
|
||||
configureWslHostsGate(facts(false, false))
|
||||
expect(wslHostsWindowArguments()).toEqual([])
|
||||
|
||||
configureWslHostsGate(facts(true, false))
|
||||
expect(wslHostsWindowArguments()).toEqual(['--orca-wsl-hosts-serve-only'])
|
||||
|
||||
vi.stubEnv('ORCA_INTERNAL_WSL_HOSTS', '1')
|
||||
expect(wslHostsWindowArguments()).toEqual([])
|
||||
})
|
||||
|
||||
describe('remote policy block', () => {
|
||||
const blocked = (hasSavedWslHost: boolean, hasWslMoveCutover: boolean) => ({
|
||||
...facts(hasSavedWslHost, hasWslMoveCutover),
|
||||
policy: () => 'block' as const
|
||||
})
|
||||
|
||||
it('closes an open internal gate for the run, in main and in shared readers', () => {
|
||||
vi.stubEnv('ORCA_INTERNAL_WSL_HOSTS', '1')
|
||||
configureWslHostsGate(blocked(false, false))
|
||||
|
||||
expect(wslHostsGateOpen()).toBe(false)
|
||||
expect(wslHostsGateState()).toBe('closed')
|
||||
expect(wslHostsPolicyBlocked()).toBe(true)
|
||||
expect(isGatedOffWslHost(wslHost)).toBe(true)
|
||||
expect(isWslHostsServing(process.env)).toBe(false)
|
||||
expect(wslHostsWindowArguments()).toEqual(['--orca-wsl-hosts-policy-blocked'])
|
||||
})
|
||||
|
||||
it('keeps serving saved WSL work, exactly as a closed internal gate does', () => {
|
||||
vi.stubEnv('ORCA_INTERNAL_WSL_HOSTS', '1')
|
||||
configureWslHostsGate(blocked(true, false))
|
||||
|
||||
expect(wslHostsGateState()).toBe('serve-only')
|
||||
expect(wslHostsGateOpen()).toBe(false)
|
||||
expect(wslHostsServing()).toBe(true)
|
||||
expect(isWslHostsServing(process.env)).toBe(true)
|
||||
expect(wslHostsWindowArguments()).toEqual([
|
||||
'--orca-wsl-hosts-policy-blocked',
|
||||
'--orca-wsl-hosts-serve-only'
|
||||
])
|
||||
})
|
||||
|
||||
it('is not read, and changes nothing, behind a closed internal gate', () => {
|
||||
vi.stubEnv('ORCA_INTERNAL_WSL_HOSTS', undefined)
|
||||
const policy = vi.fn(() => 'block' as const)
|
||||
configureWslHostsGate({ ...facts(true, false), policy })
|
||||
|
||||
expect(policy).not.toHaveBeenCalled()
|
||||
expect(wslHostsPolicyBlocked()).toBe(false)
|
||||
expect(wslHostsAppliedPolicyState()).toBeNull()
|
||||
expect(wslHostsWindowArguments()).toEqual(['--orca-wsl-hosts-serve-only'])
|
||||
})
|
||||
|
||||
it('fails open to the internal gate when the policy cannot be read', () => {
|
||||
vi.stubEnv('ORCA_INTERNAL_WSL_HOSTS', '1')
|
||||
configureWslHostsGate({
|
||||
...facts(false, false),
|
||||
policy: () => {
|
||||
throw new Error('unreadable')
|
||||
}
|
||||
})
|
||||
|
||||
expect(wslHostsGateState()).toBe('open')
|
||||
expect(wslHostsAppliedPolicyState()).toBe('missing')
|
||||
expect(wslHostsWindowArguments()).toEqual([])
|
||||
})
|
||||
|
||||
it('leaves SSH hosts untouched', () => {
|
||||
vi.stubEnv('ORCA_INTERNAL_WSL_HOSTS', '1')
|
||||
configureWslHostsGate(blocked(false, false))
|
||||
|
||||
expect(isGatedOffWslHost(sshHost)).toBe(false)
|
||||
expect(() => assertWslHostReachable(sshHost)).not.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe('publishing the resolved state to child processes', () => {
|
||||
it.each([
|
||||
['open', '1', facts(false, false), undefined],
|
||||
['serve-only', undefined, facts(true, false), undefined],
|
||||
['closed', undefined, facts(false, false), undefined],
|
||||
['closed', '1', facts(false, false), 'block' as const],
|
||||
['serve-only', '1', facts(false, true), 'block' as const]
|
||||
])('publishes %s', (state, gateEnv, saved, policy) => {
|
||||
vi.stubEnv('ORCA_INTERNAL_WSL_HOSTS', gateEnv)
|
||||
configureWslHostsGate({ ...saved, ...(policy ? { policy: () => policy } : {}) })
|
||||
|
||||
expect(process.env[WSL_HOSTS_GATE_STATE_ENV]).toBe(state)
|
||||
})
|
||||
|
||||
// A relaunch or the updater's installer inherits the parent's value; main must recompute.
|
||||
it.each([
|
||||
['closed', '1', 'open', true],
|
||||
['open', undefined, 'closed', false]
|
||||
])(
|
||||
'ignores a stale inherited %s and overwrites it',
|
||||
(inherited, gateEnv, recomputed, serving) => {
|
||||
vi.stubEnv(WSL_HOSTS_GATE_STATE_ENV, inherited)
|
||||
vi.stubEnv('ORCA_INTERNAL_WSL_HOSTS', gateEnv)
|
||||
ignoreInheritedWslHostsGateState()
|
||||
expect(isWslHostsServing(process.env)).toBe(gateEnv === '1')
|
||||
|
||||
configureWslHostsGate(facts(false, false))
|
||||
|
||||
expect(wslHostsGateState()).toBe(recomputed)
|
||||
expect(process.env[WSL_HOSTS_GATE_STATE_ENV]).toBe(recomputed)
|
||||
expect(wslHostsServing()).toBe(serving)
|
||||
expect(isWslHostsServing(process.env)).toBe(serving)
|
||||
}
|
||||
)
|
||||
|
||||
// A blocked run's child inherits ORCA_INTERNAL_WSL_HOSTS=1; only the published state says closed.
|
||||
it('lets a child that never configured the gate trust what main published', () => {
|
||||
const gate = { ORCA_INTERNAL_WSL_HOSTS: '1' }
|
||||
expect(isWslHostsServing({ ...gate, [WSL_HOSTS_GATE_STATE_ENV]: 'closed' })).toBe(false)
|
||||
// Children of a serve-only run see no gate env at all, yet must still serve saved WSL work.
|
||||
expect(isWslHostsServing({ [WSL_HOSTS_GATE_STATE_ENV]: 'serve-only' })).toBe(true)
|
||||
})
|
||||
|
||||
// A child of an older Orca, or a garbled value, must keep today's env-only reading.
|
||||
it('falls back to the internal gate env in a child when nothing valid was published', () => {
|
||||
expect(isWslHostsServing({ ORCA_INTERNAL_WSL_HOSTS: '1' })).toBe(true)
|
||||
// With the gate env on, a garbled value must not read as 'closed'.
|
||||
expect(
|
||||
isWslHostsServing({ ORCA_INTERNAL_WSL_HOSTS: '1', [WSL_HOSTS_GATE_STATE_ENV]: 'bogus' })
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('withdraws it on reset', () => {
|
||||
vi.stubEnv('ORCA_INTERNAL_WSL_HOSTS', '1')
|
||||
configureWslHostsGate(facts(false, false))
|
||||
resetWslHostsGateForTests()
|
||||
|
||||
expect(process.env[WSL_HOSTS_GATE_STATE_ENV]).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('moved projects under a block', () => {
|
||||
it('asks for moved projects back only when the block says so', () => {
|
||||
vi.stubEnv('ORCA_INTERNAL_WSL_HOSTS', '1')
|
||||
configureWslHostsGate({
|
||||
...facts(true, true),
|
||||
policy: () => 'block',
|
||||
movedProjects: () => 'bring-back'
|
||||
})
|
||||
|
||||
expect(wslHostsBringBackMovedProjects()).toBe(true)
|
||||
expect(wslHostsAppliedMovedProjects()).toBe('bring-back')
|
||||
expect(wslHostsGateState()).toBe('serve-only')
|
||||
})
|
||||
|
||||
it('never reads it without a block', () => {
|
||||
vi.stubEnv('ORCA_INTERNAL_WSL_HOSTS', '1')
|
||||
const movedProjects = vi.fn(() => 'bring-back' as const)
|
||||
configureWslHostsGate({ ...facts(true, true), policy: () => 'allow', movedProjects })
|
||||
|
||||
expect(movedProjects).not.toHaveBeenCalled()
|
||||
expect(wslHostsBringBackMovedProjects()).toBe(false)
|
||||
expect(wslHostsAppliedMovedProjects()).toBeNull()
|
||||
})
|
||||
|
||||
it('keeps moved projects served when the choice cannot be read', () => {
|
||||
vi.stubEnv('ORCA_INTERNAL_WSL_HOSTS', '1')
|
||||
configureWslHostsGate({
|
||||
...facts(true, true),
|
||||
policy: () => 'block',
|
||||
movedProjects: () => {
|
||||
throw new Error('unreadable')
|
||||
}
|
||||
})
|
||||
|
||||
expect(wslHostsBringBackMovedProjects()).toBe(false)
|
||||
expect(wslHostsAppliedMovedProjects()).toBe('keep')
|
||||
})
|
||||
|
||||
it('clears on reset', () => {
|
||||
vi.stubEnv('ORCA_INTERNAL_WSL_HOSTS', '1')
|
||||
configureWslHostsGate({
|
||||
...facts(true, true),
|
||||
policy: () => 'block',
|
||||
movedProjects: () => 'bring-back'
|
||||
})
|
||||
resetWslHostsGateForTests()
|
||||
|
||||
expect(wslHostsBringBackMovedProjects()).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('a block learned mid-run', () => {
|
||||
// New work stops at once, but what is served must not change until the next launch.
|
||||
it('stops new work without closing the gate or stranding saved work', () => {
|
||||
vi.stubEnv('ORCA_INTERNAL_WSL_HOSTS', '1')
|
||||
configureWslHostsGate(facts(true, true))
|
||||
const changed = vi.fn()
|
||||
onWslHostsNewWorkChanged(changed)
|
||||
|
||||
noteWslHostsLivePolicyBlock()
|
||||
noteWslHostsLivePolicyBlock()
|
||||
|
||||
expect(wslHostsAcceptNewWork()).toBe(false)
|
||||
expect(changed).toHaveBeenCalledOnce()
|
||||
expect(wslHostsGateOpen()).toBe(true)
|
||||
expect(wslHostsGateState()).toBe('open')
|
||||
expect(wslHostsServing()).toBe(true)
|
||||
expect(isGatedOffWslHost(wslHost)).toBe(false)
|
||||
expect(wslHostsWindowArguments()).toEqual([])
|
||||
})
|
||||
|
||||
// Stage 1's opt-in (D1c Git routing, conversions, new-project Git default) ends with it.
|
||||
it('ends the Stage 1 opt-in even with the setting on', () => {
|
||||
vi.stubEnv('ORCA_INTERNAL_WSL_HOSTS', '1')
|
||||
configureWslHostsGate(facts(false, false))
|
||||
expect(wslHostsOptedIn({ experimentalWslHosts: true })).toBe(true)
|
||||
|
||||
noteWslHostsLivePolicyBlock()
|
||||
|
||||
expect(wslHostsOptedIn({ experimentalWslHosts: true })).toBe(false)
|
||||
})
|
||||
|
||||
// Behind a gate that is already closed there is nothing new to stop, so nobody is told.
|
||||
it('is a no-op behind a closed gate', () => {
|
||||
vi.stubEnv('ORCA_INTERNAL_WSL_HOSTS', undefined)
|
||||
configureWslHostsGate(facts(true, false))
|
||||
const changed = vi.fn()
|
||||
onWslHostsNewWorkChanged(changed)
|
||||
|
||||
noteWslHostsLivePolicyBlock()
|
||||
|
||||
expect(changed).not.toHaveBeenCalled()
|
||||
expect(wslHostsGateState()).toBe('serve-only')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -27,73 +27,6 @@ async function stripLayout(page: Page) {
|
||||
|
||||
test.use({ viewport: { width: 1900, height: 720 } })
|
||||
|
||||
test('content tabs use intrinsic widths and shrink to their minimum before overflowing', async ({
|
||||
page
|
||||
}) => {
|
||||
await openFixture(page, 'nonAgentTabs')
|
||||
const strip = page.locator('.terminal-tab-strip')
|
||||
const assertWidths = async () => {
|
||||
for (const id of ['browser-0', 'file-0']) {
|
||||
expect(await width(slot(page, id))).toBeCloseTo(144, 0)
|
||||
}
|
||||
for (const id of ['tab-1', 'browser-1', 'file-1']) {
|
||||
expect(await width(slot(page, id))).toBeCloseTo(300, 0)
|
||||
const label = slot(page, id).locator('.tab-label')
|
||||
expect(await label.evaluate((node) => node.scrollWidth > node.clientWidth)).toBe(true)
|
||||
expect(await label.evaluate((node) => getComputedStyle(node).textOverflow)).toBe('ellipsis')
|
||||
}
|
||||
}
|
||||
await assertWidths()
|
||||
expect(await strip.evaluate((node) => node.scrollWidth > node.clientWidth)).toBe(false)
|
||||
|
||||
await page.locator('#component-fixture-frame').evaluate((node) => {
|
||||
node.style.width = '1280px'
|
||||
})
|
||||
for (const id of ['tab-0', 'tab-1', 'browser-1', 'file-1']) {
|
||||
await expect.poll(() => width(slot(page, id))).toBeGreaterThan(180)
|
||||
expect(await width(slot(page, id))).toBeLessThan(250)
|
||||
}
|
||||
expect(await strip.evaluate((node) => node.scrollWidth > node.clientWidth)).toBe(false)
|
||||
|
||||
await page.locator('#component-fixture-frame').evaluate((node) => {
|
||||
node.style.width = '420px'
|
||||
})
|
||||
await expect(page.getByRole('button', { name: 'Scroll tabs right' })).toBeVisible()
|
||||
for (const id of ['tab-0', 'browser-0', 'file-0', 'tab-1', 'browser-1', 'file-1']) {
|
||||
expect(await width(slot(page, id))).toBeCloseTo(144, 0)
|
||||
}
|
||||
expect(await strip.evaluate((node) => node.scrollWidth > node.clientWidth)).toBe(true)
|
||||
await test.info().attach('overflowing-content-tabs', {
|
||||
body: await page.locator('#component-fixture-frame').screenshot(),
|
||||
contentType: 'image/png'
|
||||
})
|
||||
})
|
||||
|
||||
test('agent tabs keep their preferred width and share space down to their minimum', async ({
|
||||
page
|
||||
}) => {
|
||||
await openFixture(page, 'twoTabs')
|
||||
expect(await width(slot(page, 'tab-0'))).toBeCloseTo(300, 0)
|
||||
expect(await width(slot(page, 'tab-1'))).toBeCloseTo(300, 0)
|
||||
await page.locator('#component-fixture-frame').evaluate((node) => {
|
||||
node.style.width = '420px'
|
||||
})
|
||||
await expect.poll(() => width(slot(page, 'tab-0'))).toBeLessThan(210)
|
||||
expect(await width(slot(page, 'tab-0'))).toBeGreaterThanOrEqual(144)
|
||||
expect(await width(slot(page, 'tab-0'))).toBeCloseTo(await width(slot(page, 'tab-1')), 0)
|
||||
|
||||
await page.locator('#component-fixture-frame').evaluate((node) => {
|
||||
node.style.width = '280px'
|
||||
})
|
||||
await expect.poll(() => width(slot(page, 'tab-0'))).toBeCloseTo(144, 0)
|
||||
expect(await width(slot(page, 'tab-1'))).toBeCloseTo(144, 0)
|
||||
expect(
|
||||
await page
|
||||
.locator('.terminal-tab-strip')
|
||||
.evaluate((node) => node.scrollWidth > node.clientWidth)
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
for (const fixture of ['nonAgentTabs', 'nonAgentTabsOverflowing']) {
|
||||
test(`renaming reserves usable input space and keeps typing stable in ${fixture}`, async ({
|
||||
page
|
||||
@@ -128,25 +61,3 @@ for (const fixture of ['nonAgentTabs', 'nonAgentTabsOverflowing']) {
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
test('long file tabs share available space before the strip overflows', async ({ page }) => {
|
||||
await openFixture(page, 'constrainedFiles')
|
||||
for (const id of ['file-0', 'file-1']) {
|
||||
expect(await width(slot(page, id))).toBeGreaterThan(240)
|
||||
expect(await width(slot(page, id))).toBeLessThan(270)
|
||||
}
|
||||
expect(
|
||||
await page
|
||||
.locator('.terminal-tab-strip')
|
||||
.evaluate((node) => node.scrollWidth > node.clientWidth)
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
test('terminal sizing stays stable as agent identity appears and disappears', async ({ page }) => {
|
||||
await openFixture(page, 'agentIdentityChanges')
|
||||
for (let index = 0; index < 3; index++) {
|
||||
expect(await width(slot(page, 'tab-0'))).toBeCloseTo(300, 0)
|
||||
expect(await width(slot(page, 'tab-1'))).toBeCloseTo(300, 0)
|
||||
await page.getByRole('button', { name: 'Toggle agent identity' }).click()
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { mkdtempSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { beforeAll, describe, expect, it, vi } from 'vitest'
|
||||
import { beforeAll, describe, expect, it } from 'vitest'
|
||||
import { DeviceRegistry } from '../../../src/main/runtime/rpc/server/pairing/device-registry'
|
||||
import { usesOlderPairingMethod } from '../../../src/main/runtime/rpc/server/pairing/pairing-ticket-exchange'
|
||||
import { PAIRING_TICKET_EXCHANGE_RUNTIME_CAPABILITY } from '../../../src/shared/remote-runtime/pairing/ticket-exchange-capability'
|
||||
import { encodePairingOffer } from '../../../src/shared/remote-runtime/pairing/pairing'
|
||||
import { exchangeRemotePairingTicket } from '../../../src/shared/remote-runtime/pairing/remote-pairing-ticket-exchange'
|
||||
import '../../../src/shared/remote-runtime/pairing/ticket-exchange-capability'
|
||||
import '../../../src/shared/remote-runtime/pairing/pairing'
|
||||
import '../../../src/shared/remote-runtime/pairing/remote-pairing-ticket-exchange'
|
||||
import {
|
||||
importReleaseCheckoutModule,
|
||||
materializeReleaseCheckout,
|
||||
@@ -24,18 +24,15 @@ type ReleasedRegistryLike = {
|
||||
}
|
||||
|
||||
let ReleasedRegistry: new (userDataPath: string) => ReleasedRegistryLike
|
||||
let releasedCapabilities: readonly string[]
|
||||
|
||||
beforeAll(async () => {
|
||||
const checkout = await materializeReleaseCheckout(resolveBaselineReleaseRef())
|
||||
const [registryModule, protocolModule] = await Promise.all([
|
||||
importReleaseCheckoutModule(checkout, 'src/main/runtime/rpc/server/pairing/device-registry.ts'),
|
||||
importReleaseCheckoutModule(checkout, 'src/shared/protocol-version.ts')
|
||||
])
|
||||
const registryModule = await importReleaseCheckoutModule(
|
||||
checkout,
|
||||
'src/main/runtime/rpc/server/pairing/device-registry.ts'
|
||||
)
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the pinned release's DeviceRegistry class; a changed shape fails the assertions below.
|
||||
ReleasedRegistry = registryModule.DeviceRegistry as typeof ReleasedRegistry
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the pinned release's capability list; a changed shape fails the assertions below.
|
||||
releasedCapabilities = protocolModule.RUNTIME_CAPABILITIES as readonly string[]
|
||||
}, SUITE_TIMEOUT_MS)
|
||||
|
||||
function pairedTicket(registry: DeviceRegistry, name: string) {
|
||||
@@ -45,30 +42,6 @@ function pairedTicket(registry: DeviceRegistry, name: string) {
|
||||
}
|
||||
|
||||
describe('cross-version: single-use pairing tickets', () => {
|
||||
it('a released host does not advertise the exchange, so updated clients keep the code', () => {
|
||||
expect(releasedCapabilities).not.toContain(PAIRING_TICKET_EXCHANGE_RUNTIME_CAPABILITY)
|
||||
})
|
||||
|
||||
it('Add server and the CLI save the link unchanged for a released host, without calling it', async () => {
|
||||
const offer = {
|
||||
v: 2 as const,
|
||||
endpoint: 'ws://192.168.1.10:6768',
|
||||
deviceToken: 'link-token',
|
||||
publicKeyB64: 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA='
|
||||
}
|
||||
const pairingCode = encodePairingOffer(offer)
|
||||
const send = vi.fn()
|
||||
await expect(
|
||||
exchangeRemotePairingTicket({
|
||||
pairingCode,
|
||||
offer,
|
||||
hostCapabilities: releasedCapabilities,
|
||||
send
|
||||
})
|
||||
).resolves.toBe(pairingCode)
|
||||
expect(send).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('after a downgrade, exchanged phones keep their own token and older phones their code', () => {
|
||||
const userDataPath = mkdtempSync(join(tmpdir(), 'orca-xv-ticket-'))
|
||||
const current = new DeviceRegistry(userDataPath)
|
||||
|
||||
@@ -208,20 +208,6 @@ test.describe('terminal link click ownership', () => {
|
||||
await sendToTerminal(orcaPage, ptyId, 'q')
|
||||
})
|
||||
|
||||
test('an Orca-owned plain link click emits no child PTY mouse frames', async ({
|
||||
orcaPage
|
||||
}, testInfo) => {
|
||||
const { mouseLogPath, ptyId, target } = await startMouseAwareLinkFixture(orcaPage, testInfo)
|
||||
await orcaPage.mouse.click(target.x, target.y)
|
||||
|
||||
await expect(orcaPage.locator('[data-terminal-link-action-popover]')).toBeVisible()
|
||||
await expect(orcaPage.locator('[data-terminal-link-destination]')).toHaveText(LINK)
|
||||
|
||||
await expectOrcaOwnedMouseOutcome(mouseLogPath)
|
||||
|
||||
await sendToTerminal(orcaPage, ptyId, 'q')
|
||||
})
|
||||
|
||||
test('an Orca-owned OSC link click emits no child PTY mouse frames', async ({
|
||||
orcaPage
|
||||
}, testInfo) => {
|
||||
@@ -241,21 +227,6 @@ test.describe('terminal link click ownership', () => {
|
||||
await sendToTerminal(orcaPage, ptyId, 'q')
|
||||
})
|
||||
|
||||
test('a plain click stays child-owned when link actions are disabled', async ({
|
||||
orcaPage
|
||||
}, testInfo) => {
|
||||
const { mouseLogPath, ptyId, target } = await startMouseAwareLinkFixture(orcaPage, testInfo)
|
||||
await orcaPage.evaluate(async () => {
|
||||
await window.__store?.getState().updateSettings({ terminalLinkActionPopoverEnabled: false })
|
||||
})
|
||||
|
||||
await orcaPage.mouse.click(target.x, target.y)
|
||||
|
||||
await expect(orcaPage.locator('[data-terminal-link-action-popover]')).toHaveCount(0)
|
||||
await expectChildMouseReports(mouseLogPath)
|
||||
await sendToTerminal(orcaPage, ptyId, 'q')
|
||||
})
|
||||
|
||||
test('a drag across a link stays child-owned', async ({ orcaPage }, testInfo) => {
|
||||
const { mouseLogPath, ptyId, target } = await startMouseAwareLinkFixture(orcaPage, testInfo)
|
||||
|
||||
|
||||
@@ -7,10 +7,7 @@ import {
|
||||
setPaneTitleFromTerminalMenu,
|
||||
openTerminalContextMenu
|
||||
} from './helpers/terminal-pane-title-actions'
|
||||
import {
|
||||
readVisibleXtermContainerBox,
|
||||
expectTerminalToReserveTitleSpace
|
||||
} from './helpers/terminal-pane-geometry'
|
||||
import './helpers/terminal-pane-geometry'
|
||||
import { registerTerminalPaneMountReadiness } from './helpers/terminal-pane-mount-readiness'
|
||||
|
||||
async function openPaneTitleContextMenu(page: Page, title: string): Promise<void> {
|
||||
@@ -103,42 +100,6 @@ test.describe('Terminal Panes', () => {
|
||||
await expect(orcaPage.locator('.pane-title-text', { hasText: title })).toHaveCount(1)
|
||||
})
|
||||
|
||||
test('Set Title editor renders in Orca overlay while terminal reserves title space', async ({
|
||||
orcaPage
|
||||
}) => {
|
||||
const title = `Reserved overlay title ${Date.now()}`
|
||||
const terminalBoxBefore = await readVisibleXtermContainerBox(orcaPage)
|
||||
|
||||
await openTerminalContextMenu(orcaPage)
|
||||
await orcaPage.getByText('Set Title…', { exact: true }).click()
|
||||
|
||||
const titleInput = orcaPage.locator('.pane-title-overlay-layer .pane-title-input').first()
|
||||
await expect(titleInput).toBeVisible()
|
||||
await expect(titleInput).toBeFocused()
|
||||
await expect(orcaPage.getByText('Set Title…', { exact: true })).toBeHidden()
|
||||
await expect(orcaPage.locator('.pane .pane-title-input')).toHaveCount(0)
|
||||
await expect(orcaPage.locator('.pane[data-has-title]')).toHaveCount(1)
|
||||
await expect
|
||||
.poll(() =>
|
||||
orcaPage
|
||||
.locator('.pane-title-bar')
|
||||
.first()
|
||||
.evaluate((titleBar) => getComputedStyle(titleBar).backgroundColor)
|
||||
)
|
||||
.not.toBe('rgba(0, 0, 0, 0)')
|
||||
const terminalBoxEditing = await readVisibleXtermContainerBox(orcaPage)
|
||||
expectTerminalToReserveTitleSpace(terminalBoxEditing, terminalBoxBefore)
|
||||
|
||||
await titleInput.fill(title)
|
||||
await titleInput.press('Enter')
|
||||
await expect(orcaPage.locator('.pane-title-text', { hasText: title })).toBeVisible()
|
||||
await expect(orcaPage.locator('.pane[data-has-title]')).toHaveCount(1)
|
||||
expectTerminalToReserveTitleSpace(
|
||||
await readVisibleXtermContainerBox(orcaPage),
|
||||
terminalBoxBefore
|
||||
)
|
||||
})
|
||||
|
||||
test('Set Title context menu opens from the title overlay strip', async ({ orcaPage }) => {
|
||||
const title = `Overlay menu title ${Date.now()}`
|
||||
const updatedTitle = `Overlay menu updated ${Date.now()}`
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import type { Page } from '@stablyai/playwright-test'
|
||||
import { test, expect } from './helpers/orca-app'
|
||||
import {
|
||||
moveTerminalPaneByLeafId,
|
||||
readPaneIdentitySnapshot,
|
||||
readTerminalPaneDomLeafOrder,
|
||||
splitActiveTerminalPane,
|
||||
@@ -133,92 +132,6 @@ test.describe('Terminal Panes', () => {
|
||||
.toEqual({ activeLeafId: otherPane.leafId, targetHasPath: true, otherHasPath: false })
|
||||
})
|
||||
|
||||
test('Set Title overlay follows its pane after same-count pane move', async ({ orcaPage }) => {
|
||||
const title = `Moved overlay title ${Date.now()}`
|
||||
|
||||
await setPaneTitleFromTerminalMenu(orcaPage, title)
|
||||
const initialSnapshot = await waitForPaneIdentitySnapshot(orcaPage, 1)
|
||||
const titledLeafId = initialSnapshot.activeLeafId ?? initialSnapshot.panes[0]?.leafId
|
||||
if (!titledLeafId) {
|
||||
throw new Error('No titled pane leaf id found before move')
|
||||
}
|
||||
|
||||
await splitActiveTerminalPane(orcaPage, 'vertical')
|
||||
await waitForPaneCount(orcaPage, 2)
|
||||
const beforeMove = await waitForPaneIdentitySnapshot(orcaPage, 2)
|
||||
const target = beforeMove.panes.find((pane) => pane.leafId !== titledLeafId)
|
||||
if (!target) {
|
||||
throw new Error('No target pane found for titled pane move')
|
||||
}
|
||||
const beforeOrder = await readTerminalPaneDomLeafOrder(orcaPage)
|
||||
|
||||
await expectPaneTitleAttachedToLeaf(orcaPage, title, titledLeafId)
|
||||
await moveTerminalPaneByLeafId(orcaPage, titledLeafId, target.leafId, 'right')
|
||||
|
||||
await expect
|
||||
.poll(async () => readTerminalPaneDomLeafOrder(orcaPage), {
|
||||
timeout: 10_000,
|
||||
message: 'Pane move did not update DOM order'
|
||||
})
|
||||
.not.toEqual(beforeOrder)
|
||||
await expectPaneTitleAttachedToLeaf(orcaPage, title, titledLeafId)
|
||||
})
|
||||
|
||||
test('Set Title keeps the pane drag handle available over the title strip', async ({
|
||||
orcaPage
|
||||
}) => {
|
||||
const title = `Draggable title ${Date.now()}`
|
||||
|
||||
await setPaneTitleFromTerminalMenu(orcaPage, title)
|
||||
const initialSnapshot = await waitForPaneIdentitySnapshot(orcaPage, 1)
|
||||
const titledLeafId = initialSnapshot.activeLeafId ?? initialSnapshot.panes[0]?.leafId
|
||||
if (!titledLeafId) {
|
||||
throw new Error('No titled pane leaf id found before split')
|
||||
}
|
||||
|
||||
await splitActiveTerminalPane(orcaPage, 'vertical')
|
||||
await waitForPaneCount(orcaPage, 2)
|
||||
await expectPaneTitleAttachedToLeaf(orcaPage, title, titledLeafId)
|
||||
|
||||
const titleTopHit = await orcaPage.evaluate(
|
||||
({ title, titledLeafId }) => {
|
||||
const titleBar = Array.from(document.querySelectorAll<HTMLElement>('.pane-title-bar')).find(
|
||||
(element) => element.textContent?.includes(title)
|
||||
)
|
||||
const titleDragHandle =
|
||||
titleBar?.querySelector<HTMLElement>('.pane-title-drag-handle') ?? null
|
||||
const pane = document.querySelector<HTMLElement>(`.pane[data-leaf-id="${titledLeafId}"]`)
|
||||
if (!titleBar || !pane || !titleDragHandle) {
|
||||
return null
|
||||
}
|
||||
const titleRect = titleBar.getBoundingClientRect()
|
||||
const hitElement = document.elementFromPoint(
|
||||
titleRect.left + titleRect.width / 2,
|
||||
titleRect.top + 4
|
||||
)
|
||||
return {
|
||||
hitDragHandle:
|
||||
hitElement instanceof HTMLElement &&
|
||||
hitElement.closest('.pane-title-drag-handle') !== null,
|
||||
pointerEvents: getComputedStyle(titleDragHandle).pointerEvents,
|
||||
titleTop: titleRect.top,
|
||||
handleTop: titleDragHandle.getBoundingClientRect().top
|
||||
}
|
||||
},
|
||||
{ title, titledLeafId }
|
||||
)
|
||||
|
||||
expect(titleTopHit).not.toBeNull()
|
||||
expect(titleTopHit?.hitDragHandle).toBe(true)
|
||||
expect(titleTopHit?.pointerEvents).toBe('auto')
|
||||
expect(Math.abs((titleTopHit?.handleTop ?? 0) - (titleTopHit?.titleTop ?? 0))).toBeLessThan(1)
|
||||
|
||||
await orcaPage.locator('.pane-title-bar', { hasText: title }).click({
|
||||
position: { x: 20, y: 18 }
|
||||
})
|
||||
await expect(orcaPage.locator('.pane-title-input')).toBeVisible()
|
||||
})
|
||||
|
||||
test('@headful Set Title pane can be dragged from the title strip', async ({ orcaPage }) => {
|
||||
const title = `Dragged title ${Date.now()}`
|
||||
|
||||
|
||||
@@ -1,40 +1,17 @@
|
||||
import type { Locator, Page } from '@stablyai/playwright-test'
|
||||
import { mkdirSync } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
import type { Page } from '@stablyai/playwright-test'
|
||||
import 'node:fs'
|
||||
import 'node:path'
|
||||
import { test, expect } from './helpers/orca-app'
|
||||
import { addRepoWithWorkspaces, linkWorkspaces } from './helpers/repo-with-workspaces'
|
||||
import { SIDEBAR_INPUTS_BUILD } from './helpers/sidebar-inputs-build'
|
||||
import './helpers/repo-with-workspaces'
|
||||
import './helpers/sidebar-inputs-build'
|
||||
import { waitForActiveWorktree, waitForSessionReady } from './helpers/store'
|
||||
import {
|
||||
markWorkspaceTerminalSlept,
|
||||
seedLineageScenario,
|
||||
seedWorkspaceAgentStatus,
|
||||
seedWorkspaceLiveTerminal
|
||||
} from './worktree-lineage-state'
|
||||
import { seedLineageScenario } from './worktree-lineage-state'
|
||||
import { worktreeRow } from './worktree-row-locators'
|
||||
|
||||
function worktreeOption(page: Page, worktreeId: string) {
|
||||
return worktreeRow(page, worktreeId)
|
||||
}
|
||||
|
||||
async function captureEvidence(page: Page, name: string, locator?: Locator): Promise<void> {
|
||||
if (process.env.ORCA_CAPTURE_EVIDENCE !== '1') {
|
||||
return
|
||||
}
|
||||
const outputDir = resolve(process.cwd(), 'pr-evidence')
|
||||
mkdirSync(outputDir, { recursive: true })
|
||||
const path = resolve(outputDir, name)
|
||||
if (locator) {
|
||||
await locator.screenshot({ path })
|
||||
return
|
||||
}
|
||||
await page.screenshot({ path })
|
||||
}
|
||||
|
||||
async function captureSidebarEvidence(page: Page, name: string): Promise<void> {
|
||||
await captureEvidence(page, name, page.locator('[data-worktree-sidebar]').first())
|
||||
}
|
||||
|
||||
test.describe('Worktree Lineage', () => {
|
||||
test.describe.configure({ mode: 'serial' })
|
||||
|
||||
@@ -107,35 +84,6 @@ test.describe('Worktree Lineage', () => {
|
||||
await expect(childRow).toBeVisible()
|
||||
})
|
||||
|
||||
test('renders legacy-only inline lineage when side-map hydration is absent', async ({
|
||||
orcaPage
|
||||
}) => {
|
||||
test.skip(
|
||||
SIDEBAR_INPUTS_BUILD,
|
||||
"the window's legacy inline link is unused when rows come from main"
|
||||
)
|
||||
const { parentId, childId } = await seedLineageScenario(orcaPage, { inlineOnly: true })
|
||||
const parentRow = worktreeOption(orcaPage, parentId)
|
||||
const childRow = worktreeOption(orcaPage, childId)
|
||||
|
||||
await expect(parentRow.getByRole('button', { name: 'Hide 1 child workspace' })).toBeVisible()
|
||||
await expect(childRow).toBeVisible()
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const [parentBox, childBox] = await Promise.all([
|
||||
parentRow.boundingBox(),
|
||||
childRow.boundingBox()
|
||||
])
|
||||
return parentBox && childBox ? childBox.y > parentBox.y : false
|
||||
})
|
||||
.toBe(true)
|
||||
// The link must come from the records alone, not a side map hydrated after the seed.
|
||||
expect(
|
||||
await orcaPage.evaluate((id) => window.__store!.getState().worktreeLineageById[id], childId)
|
||||
).toBeUndefined()
|
||||
await captureSidebarEvidence(orcaPage, 'legacy-inline-lineage-nested.png')
|
||||
})
|
||||
|
||||
test('injects filtered parents structurally without showing a parent badge', async ({
|
||||
orcaPage
|
||||
}) => {
|
||||
@@ -199,101 +147,4 @@ test.describe('Worktree Lineage', () => {
|
||||
expect(positions).not.toBeNull()
|
||||
expect(positions!.childTop).toBeGreaterThan(positions!.parentTop)
|
||||
})
|
||||
|
||||
test('updates nested child preview status when the child terminal sleeps', async ({
|
||||
orcaPage
|
||||
}) => {
|
||||
const { parentId, childId } = await seedLineageScenario(orcaPage)
|
||||
const parentRow = worktreeOption(orcaPage, parentId)
|
||||
const childRow = worktreeOption(orcaPage, childId)
|
||||
|
||||
await expect(parentRow).toBeVisible()
|
||||
await expect(childRow).toBeVisible()
|
||||
|
||||
const childTabId = await seedWorkspaceLiveTerminal(orcaPage, childId)
|
||||
await expect(childRow).toContainText('Active')
|
||||
|
||||
await markWorkspaceTerminalSlept(orcaPage, { worktreeId: childId, tabId: childTabId })
|
||||
await expect(childRow).toContainText('Inactive')
|
||||
})
|
||||
|
||||
test('sleeps a workspace and every descendant from the parent context menu', async ({
|
||||
orcaPage
|
||||
}, testInfo) => {
|
||||
// A repo's own checkout offers no delete, so the parent is one of its worktrees.
|
||||
const repo = await addRepoWithWorkspaces(orcaPage, testInfo, 2)
|
||||
const [parentId, childId] = [repo.ids[0]!, repo.ids[1]!]
|
||||
await linkWorkspaces(orcaPage, [{ childId, parentId }])
|
||||
await orcaPage.evaluate(() => window.__store!.getState().setShowSleepingWorkspaces(true))
|
||||
const parentTabId = await seedWorkspaceLiveTerminal(orcaPage, parentId)
|
||||
const childTabId = await seedWorkspaceLiveTerminal(orcaPage, childId)
|
||||
|
||||
await orcaPage.evaluate(() => {
|
||||
const store = window.__store
|
||||
if (!store) {
|
||||
throw new Error('window.__store is not available')
|
||||
}
|
||||
store.setState({
|
||||
shutdownWorktreeBrowsers: async (worktreeId: string) => {
|
||||
store.setState((current) => ({
|
||||
browserTabsByWorktree: { ...current.browserTabsByWorktree, [worktreeId]: [] }
|
||||
}))
|
||||
},
|
||||
shutdownWorktreeTerminals: async (worktreeId: string) => {
|
||||
const tabIds = (store.getState().tabsByWorktree[worktreeId] ?? []).map((tab) => tab.id)
|
||||
store.setState((current) => ({
|
||||
ptyIdsByTabId: {
|
||||
...current.ptyIdsByTabId,
|
||||
...Object.fromEntries(tabIds.map((tabId) => [tabId, []]))
|
||||
}
|
||||
}))
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
await worktreeOption(orcaPage, parentId).click({ button: 'right' })
|
||||
const sleepSubtree = orcaPage.getByRole('menuitem', {
|
||||
name: 'Sleep with Descendants (1)'
|
||||
})
|
||||
await expect(sleepSubtree).toBeVisible()
|
||||
await expect(sleepSubtree).toBeEnabled()
|
||||
await expect(orcaPage.getByRole('menuitem', { name: 'Delete with Descendants…' })).toBeVisible()
|
||||
await captureEvidence(orcaPage, 'workspace-descendant-actions.png')
|
||||
await sleepSubtree.click()
|
||||
|
||||
await expect
|
||||
.poll(() =>
|
||||
orcaPage.evaluate(
|
||||
({ parentTabId, childTabId }) => {
|
||||
const state = window.__store?.getState()
|
||||
return {
|
||||
parentPtys: state?.ptyIdsByTabId[parentTabId],
|
||||
childPtys: state?.ptyIdsByTabId[childTabId]
|
||||
}
|
||||
},
|
||||
{ parentTabId, childTabId }
|
||||
)
|
||||
)
|
||||
.toEqual({ parentPtys: [], childPtys: [] })
|
||||
})
|
||||
|
||||
test('shows parent and child agent rows while the parent workspace is active', async ({
|
||||
orcaPage
|
||||
}) => {
|
||||
const { parentId, childId } = await seedLineageScenario(orcaPage)
|
||||
const parentRow = worktreeOption(orcaPage, parentId)
|
||||
const childRow = worktreeOption(orcaPage, childId)
|
||||
|
||||
await parentRow.click()
|
||||
await expect(parentRow).toHaveAttribute('aria-current', 'page')
|
||||
await expect(childRow).toBeVisible()
|
||||
|
||||
const parentAgentPrompt = await seedWorkspaceAgentStatus(orcaPage, parentId, 'PARENT')
|
||||
const childAgentPrompt = await seedWorkspaceAgentStatus(orcaPage, childId, 'CHILD')
|
||||
|
||||
await expect(
|
||||
parentRow.getByRole('treeitem').filter({ hasText: parentAgentPrompt })
|
||||
).toBeVisible()
|
||||
await expect(childRow.getByRole('treeitem').filter({ hasText: childAgentPrompt })).toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user