mirror of
https://github.com/stablyai/orca.git
synced 2026-11-14 18:07:45 +00:00
test: remove eight low-value suites and 198 individual cases (#36650)
* test: remove low-value suites and cases * test: fix retained timestamps and prune flaky baseline * test: document malformed persisted-provider fixture cast * test: restore types used by retained divergence regressions
This commit is contained in:
@@ -956,7 +956,7 @@
|
||||
2 test-no-wall-clock:src/main/rate-limits/service-cursor-usage.test.ts
|
||||
12 test-no-wall-clock:src/main/rate-limits/service-minimax-usage.test.ts
|
||||
38 test-no-wall-clock:src/main/rate-limits/service-refresh-orchestration.test.ts
|
||||
6 test-no-wall-clock:src/main/rate-limits/service-zcode-usage.test.ts
|
||||
4 test-no-wall-clock:src/main/rate-limits/service-zcode-usage.test.ts
|
||||
1 test-no-wall-clock:src/main/runtime/agent-session/ownership/agent-session-process-identity-probe.test.ts
|
||||
1 test-no-wall-clock:src/main/runtime/agent-state-rules/agent-state-rules-engine.test.ts
|
||||
1 test-no-wall-clock:src/main/runtime/antigravity-readiness-transcripts.test.ts
|
||||
@@ -1106,7 +1106,6 @@
|
||||
1 test-no-wall-clock:src/renderer/src/components/sidebar/WorktreeList.lineage-agent-expansion-coupling.test.tsx
|
||||
1 test-no-wall-clock:src/renderer/src/components/sidebar/WorktreeList.lineage-child-real-card.test.tsx
|
||||
5 test-no-wall-clock:src/renderer/src/components/sidebar/use-workspace-reference-details.test.tsx
|
||||
1 test-no-wall-clock:src/renderer/src/components/status-bar/RuntimeHostStatusRow.test.tsx
|
||||
5 test-no-wall-clock:src/renderer/src/components/status-bar/inline-usage-bars.test.tsx
|
||||
4 test-no-wall-clock:src/renderer/src/components/status-bar/provider-segment-balance.test.tsx
|
||||
2 test-no-wall-clock:src/renderer/src/components/status-bar/status-bar-antigravity-segment.test.tsx
|
||||
@@ -1127,7 +1126,6 @@
|
||||
1 test-no-wall-clock:src/renderer/src/components/terminal/initial-terminal-structured-launch.test.tsx
|
||||
2 test-no-wall-clock:src/renderer/src/components/workspace-cleanup/use-workspace-cleanup-removal.agent-stop.test.tsx
|
||||
6 test-no-wall-clock:src/renderer/src/components/workspace-cleanup/workspace-cleanup-confirm-remove.test.tsx
|
||||
2 test-no-wall-clock:src/renderer/src/components/worktree-creation/WorktreeCreationPanel.test.tsx
|
||||
2 test-no-wall-clock:src/renderer/src/hooks/ipc-events/agent-status-terminal-title-tab-write.test.ts
|
||||
3 test-no-wall-clock:src/renderer/src/hooks/useAutomationDispatchEvents.test.ts
|
||||
1 test-no-wall-clock:src/renderer/src/hooks/useIpcEvents-agent-status-hook-titles.test.ts
|
||||
@@ -1363,7 +1361,6 @@
|
||||
4 test-timeout-needs-reason:src/packages/process-host/src/run-process.test.ts
|
||||
5 test-timeout-needs-reason:src/renderer/src/components/sidebar/WorktreeCard.compact-hover.test.tsx
|
||||
2 test-timeout-needs-reason:src/renderer/src/components/sidebar/WorktreeCard.host-badge-toggle.test.tsx
|
||||
4 test-timeout-needs-reason:src/renderer/src/components/sidebar/WorktreeCard.pinned-repo-icon.test.tsx
|
||||
7 test-timeout-needs-reason:src/renderer/src/components/sidebar/WorktreeCard.pr-display.test.tsx
|
||||
1 test-timeout-needs-reason:src/renderer/src/components/sidebar/WorktreeCardAgents.send-target.test.tsx
|
||||
1 test-timeout-needs-reason:src/renderer/src/components/sidebar/WorktreeCardAgents.test.tsx
|
||||
|
||||
@@ -10811,7 +10811,7 @@
|
||||
"providers": ["local", "remote-runtime", "ssh", "wsl"],
|
||||
"coveredPlatforms": ["macos"],
|
||||
"coveredProviders": ["local", "remote-runtime", "ssh", "wsl"],
|
||||
"coverageNotes": "Platform-neutral deterministic tests separate the durable Active Server preference from per-client connection, selected-workspace, browser-session, Add Project, and execution-host routing. The composed regression models Local desktop -> connect/navigate Windows 2 -> reveal a local terminal -> restart. Multi-client browser host overrides, multi-server profile caches, generic settings IPC rejection, local and remote workspace ownership, Add Project eligibility/owner capture/stale-result fencing, host-qualified group/folder/worktree identity, and restart reset of transient routing are covered. A visible macOS Orca desktop server plus separate paired Electron and web clients prove selected-runtime Add Project operations and disabled creation when the only listed host is blocked or unavailable; headless-server parity also passed. Live Linux, Windows, WSL, and SSH Add Project journeys remain uncollected.",
|
||||
"coverageNotes": "Platform-neutral deterministic tests separate the durable Active Server preference from per-client connection, selected-workspace, browser-session, Add Project, and execution-host routing. The composed regression models Local desktop -> connect/navigate Windows 2 -> reveal a local terminal -> restart. Multi-client browser host overrides, multi-server profile caches, generic settings IPC rejection, local and remote workspace ownership, Add Project eligibility/owner capture/stale-result fencing, host-qualified group/folder/worktree identity, and restart reset of transient routing are covered. A visible macOS Orca desktop server plus separate paired Electron and web clients prove selected-runtime Add Project operations and disabled creation when the only listed host is blocked or unavailable; headless-server parity also passed. Live Linux, Windows, WSL, and SSH Add Project journeys remain uncollected. The static wrapper-rendering suite was removed during the low-value-test audit; its supplied-prop host-browser and control-visibility output assertions are no longer checked by that suite. This removal supplies no new runtime-routing evidence.",
|
||||
"motivatingLinks": [
|
||||
"https://github.com/stablyai/orca/pull/9687",
|
||||
"https://linear.app/stably/issue/STA-4024/p1-paired-web-add-project-enables-unavailable-host-fallback-after-pr"
|
||||
@@ -10821,7 +10821,7 @@
|
||||
"commands": [
|
||||
"pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/settings.test.ts src/main/ipc/runtime-environments/runtime-environments-pairing.test.ts src/renderer/src/store/slices/settings.test.ts src/renderer/src/store/slices/browser.test.ts src/renderer/src/components/settings/browser-session-host-selection.test.ts src/renderer/src/components/settings/RuntimeEnvironmentsPane.test.ts src/renderer/src/components/sidebar/use-add-repo-host-selection.test.ts src/renderer/src/hooks/useIpcEvents-terminal-create-surfacing.test.ts src/renderer/src/web/web-preload-api-runtime-environment.test.ts --maxWorkers=1",
|
||||
"pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/sidebar/AddProjectFromFolderDialog.test.tsx src/renderer/src/components/sidebar/NonGitFolderDialog.test.tsx src/renderer/src/components/sidebar/AddRepoDialog.default-checkout.test.ts src/renderer/src/components/sidebar/AddRepoSteps.default-checkout.test.ts src/renderer/src/components/sidebar/project-added-default-checkout.test.ts src/renderer/src/components/sidebar/useAddRepoLocalFolderFlow.test.ts src/renderer/src/components/sidebar/useAddRepoNestedImportFlow.test.ts src/renderer/src/components/sidebar/useAddRepoServerPathFlow.test.ts src/renderer/src/components/sidebar/useAddRepoCloneFlow.test.ts src/renderer/src/components/sidebar/useCreateRepo.default-checkout.test.ts src/renderer/src/components/sidebar/worktree-list/listing/host-filtering.test.ts src/renderer/src/lib/resolved-worktree-execution-host.test.ts src/renderer/src/lib/worktree-runtime-owner.test.ts src/renderer/src/store/selectors.test.ts src/renderer/src/store/slices/repos-catalog-reconciliation.test.ts src/renderer/src/store/slices/repos-project-groups.test.ts src/renderer/src/store/slices/repos-selected-owner-routing.test.ts src/renderer/src/store/slices/selected-host-active-workspace-identity.test.ts src/renderer/src/store/slices/worktrees-fetch-owner-routing.test.ts --maxWorkers=1",
|
||||
"pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/sidebar/use-add-repo-host-selection.test.ts src/renderer/src/components/sidebar/add-repo-browse-authority.test.ts src/renderer/src/components/sidebar/AddRepoDialogStepContent.test.tsx src/renderer/src/components/sidebar/AddRepoStartSteps.test.tsx src/renderer/src/components/sidebar/useAddRepoServerPathFlow.test.ts src/renderer/src/store/slices/repos-paired-runtime-add.test.ts --maxWorkers=1",
|
||||
"pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/sidebar/use-add-repo-host-selection.test.ts src/renderer/src/components/sidebar/add-repo-browse-authority.test.ts src/renderer/src/components/sidebar/AddRepoStartSteps.test.tsx src/renderer/src/components/sidebar/useAddRepoServerPathFlow.test.ts src/renderer/src/store/slices/repos-paired-runtime-add.test.ts --maxWorkers=1",
|
||||
"pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/sidebar/AddProjectFromFolderDialog.test.tsx src/renderer/src/components/sidebar/NonGitFolderDialog.test.tsx src/renderer/src/components/sidebar/AddRepoDialog.default-checkout.test.ts src/renderer/src/components/sidebar/AddRepoSteps.default-checkout.test.ts src/renderer/src/components/sidebar/project-added-default-checkout.test.ts src/renderer/src/components/sidebar/useAddRepoLocalFolderFlow.test.ts src/renderer/src/components/sidebar/useAddRepoNestedImportFlow.test.ts src/renderer/src/components/sidebar/useAddRepoServerPathFlow.test.ts src/renderer/src/components/sidebar/useAddRepoCloneFlow.test.ts src/renderer/src/components/sidebar/useCreateRepo.default-checkout.test.ts src/renderer/src/components/sidebar/worktree-list/listing/host-filtering.test.ts src/renderer/src/lib/resolved-worktree-execution-host.test.ts src/renderer/src/lib/worktree-runtime-owner.test.ts src/renderer/src/store/selectors.test.ts src/renderer/src/store/slices/repos-catalog-reconciliation.test.ts src/renderer/src/store/slices/repos-project-groups.test.ts src/renderer/src/store/slices/repos-selected-owner-routing.test.ts src/renderer/src/store/slices/selected-host-active-workspace-identity.test.ts src/renderer/src/store/slices/worktrees-fetch-owner-routing.test.ts src/renderer/src/runtime/web-runtime-session.test.ts --maxWorkers=1",
|
||||
"pnpm run ensure:electron-runtime && npx playwright test tests/e2e/pr11346-selected-runtime-add.spec.ts --config tests/playwright.config.ts --project electron-headful --workers=1 --reporter=line",
|
||||
"pnpm run ensure:electron-runtime && npx playwright test tests/e2e/pr11346-selected-runtime-add.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1 --reporter=line",
|
||||
@@ -10842,7 +10842,6 @@
|
||||
"src/renderer/src/components/sidebar/AddProjectFromFolderDialog.test.tsx",
|
||||
"src/renderer/src/components/sidebar/NonGitFolderDialog.test.tsx",
|
||||
"src/renderer/src/components/sidebar/AddRepoDialog.default-checkout.test.ts",
|
||||
"src/renderer/src/components/sidebar/AddRepoDialogStepContent.test.tsx",
|
||||
"src/renderer/src/components/sidebar/AddRepoStartSteps.test.tsx",
|
||||
"src/renderer/src/components/sidebar/AddRepoSteps.default-checkout.test.ts",
|
||||
"src/renderer/src/components/sidebar/project-added-default-checkout.test.ts",
|
||||
@@ -10879,10 +10878,6 @@
|
||||
"paired runtime browsing opens the host browser without invoking native pickFolders while desktop local and SSH routes remain intact"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/renderer/src/components/sidebar/AddRepoDialogStepContent.test.tsx",
|
||||
"assertions": ["paired runtime browsing renders the in-app host filesystem browser"]
|
||||
},
|
||||
{
|
||||
"file": "src/renderer/src/components/sidebar/AddRepoStartSteps.test.tsx",
|
||||
"assertions": [
|
||||
|
||||
@@ -5469,7 +5469,6 @@
|
||||
"src/main/runtime/rpc/terminal-agent-prompt-send.test.ts": 1761,
|
||||
"src/main/runtime/rpc/terminal-agent-send-guard.test.ts": 1295,
|
||||
"src/main/runtime/rpc/terminal-claude-agent-view.test.ts": 1058,
|
||||
"src/main/runtime/rpc/terminal-geometry-stale-handle.test.ts": 1168,
|
||||
"src/main/runtime/rpc/terminal-lease-stale-handle-survival.test.ts": 1201,
|
||||
"src/main/runtime/rpc/terminal-multiplex-ack-output-budget.test.ts": 1505,
|
||||
"src/main/runtime/rpc/terminal-multiplex-ack-overflow-recovery-history.test.ts": 1306,
|
||||
@@ -7528,7 +7527,6 @@
|
||||
"src/renderer/src/components/feature-tips/feature-tip-startup-gate.test.ts": 105,
|
||||
"src/renderer/src/components/feature-tips/use-session-search-tip-setup.test.tsx": 520,
|
||||
"src/renderer/src/components/feature-wall/AgentCapabilitiesSetupAction.test.ts": 1690,
|
||||
"src/renderer/src/components/feature-wall/ConnectIntegrationsList.test.tsx": 663,
|
||||
"src/renderer/src/components/feature-wall/FeatureWallSetupWorkflowActions.test.tsx": 1589,
|
||||
"src/renderer/src/components/feature-wall/FullDiskAccessSetupPrompt.test.ts": 695,
|
||||
"src/renderer/src/components/feature-wall/browser-animated-visual-sequence.test.ts": 42,
|
||||
@@ -8390,7 +8388,6 @@
|
||||
"src/renderer/src/components/settings/ReleaseChannelSection.test.tsx": 2427,
|
||||
"src/renderer/src/components/settings/repository/RepositoryGitHubAccountSection.inventory-lifetime.test.tsx": 587,
|
||||
"src/renderer/src/components/settings/repository/RepositoryGitHubAccountSection.test.tsx": 662,
|
||||
"src/renderer/src/components/settings/repository/RepositoryPane.test.ts": 2778,
|
||||
"src/renderer/src/components/settings/repository/source-control/RepositorySourceControlAiSection.test.ts": 365,
|
||||
"src/renderer/src/components/settings/RuntimeEnvironmentsPane.test.ts": 201,
|
||||
"src/renderer/src/components/settings/RuntimeHostAccessForm.test.tsx": 307,
|
||||
@@ -8401,7 +8398,6 @@
|
||||
"src/renderer/src/components/settings/SettingsConstants.test.ts": 97,
|
||||
"src/renderer/src/components/settings/SettingsFormControls.font-autocomplete.test.tsx": 789,
|
||||
"src/renderer/src/components/settings/SettingsFormControls.segmented-control.test.tsx": 548,
|
||||
"src/renderer/src/components/settings/SettingsSidebar.test.tsx": 2169,
|
||||
"src/renderer/src/components/settings/ShareSkillsSettingsPane.test.tsx": 601,
|
||||
"src/renderer/src/components/settings/ShortcutFilterRail.test.ts": 157,
|
||||
"src/renderer/src/components/settings/SparsePresetSettingsSection.test.tsx": 785,
|
||||
@@ -8514,7 +8510,6 @@
|
||||
"src/renderer/src/components/sidebar/AddRepoCreateStep.test.tsx": 532,
|
||||
"src/renderer/src/components/sidebar/AddRepoDialog.default-checkout.test.ts": 42,
|
||||
"src/renderer/src/components/sidebar/AddRepoDialogChrome.test.tsx": 495,
|
||||
"src/renderer/src/components/sidebar/AddRepoDialogStepContent.test.tsx": 1988,
|
||||
"src/renderer/src/components/sidebar/AddRepoHostSelector.test.tsx": 390,
|
||||
"src/renderer/src/components/sidebar/AddRepoNestedImportStep.test.tsx": 817,
|
||||
"src/renderer/src/components/sidebar/AddRepoStartSteps.test.tsx": 681,
|
||||
@@ -8575,7 +8570,6 @@
|
||||
"src/renderer/src/components/sidebar/WorktreeCard.host-badge-toggle.test.tsx": 1253,
|
||||
"src/renderer/src/components/sidebar/WorktreeCard.hosted-review-refresh.test.tsx": 1647,
|
||||
"src/renderer/src/components/sidebar/WorktreeCard.merged-pr-display.test.tsx": 1682,
|
||||
"src/renderer/src/components/sidebar/WorktreeCard.pinned-repo-icon.test.tsx": 1604,
|
||||
"src/renderer/src/components/sidebar/WorktreeCard.pr-display.test.tsx": 1246,
|
||||
"src/renderer/src/components/sidebar/WorktreeCard.quick-actions.test.tsx": 1827,
|
||||
"src/renderer/src/components/sidebar/WorktreeCard.single-reference-details.test.tsx": 1662,
|
||||
@@ -8596,7 +8590,6 @@
|
||||
"src/renderer/src/components/sidebar/WorktreeContextMenu.test.ts": 164,
|
||||
"src/renderer/src/components/sidebar/WorktreeList.card-memo-stability.test.tsx": 819,
|
||||
"src/renderer/src/components/sidebar/WorktreeList.folder-workspace-rows.test.ts": 2943,
|
||||
"src/renderer/src/components/sidebar/WorktreeList.group-headers.test.ts": 1786,
|
||||
"src/renderer/src/components/sidebar/WorktreeList.lineage-agent-expansion-coupling.test.tsx": 3116,
|
||||
"src/renderer/src/components/sidebar/WorktreeList.lineage-child-card.test.ts": 4240,
|
||||
"src/renderer/src/components/sidebar/WorktreeList.lineage-child-real-card.test.tsx": 3000,
|
||||
@@ -9756,7 +9749,6 @@
|
||||
"src/renderer/src/components/workspace-space/SpaceWindowStatePublisher.test.tsx": 618,
|
||||
"src/renderer/src/components/workspace-space/space-window-state.test.ts": 162,
|
||||
"src/renderer/src/components/workspace-surface-projection.test.ts": 48,
|
||||
"src/renderer/src/components/worktree-creation/WorktreeCreationPanel.test.tsx": 509,
|
||||
"src/renderer/src/components/worktree-jump-palette-browser-navigation.test.tsx": 2772,
|
||||
"src/renderer/src/components/worktree-jump-palette-interleaved-sections.test.tsx": 2023,
|
||||
"src/renderer/src/components/worktree-jump-palette-primitives.test.tsx": 384,
|
||||
|
||||
@@ -171,48 +171,4 @@ describe('native cookie import preserves destination cookies', () => {
|
||||
expect(removedNames).not.toContain('SID')
|
||||
expect(removedNames).not.toContain('orphan')
|
||||
})
|
||||
|
||||
it('keeps extra destination paths and subdomains alongside imported values', async () => {
|
||||
sessionFromPartitionMock.mockReturnValue({
|
||||
cookies: {
|
||||
get: vi.fn().mockResolvedValue([
|
||||
{
|
||||
name: 'gist-session',
|
||||
value: 'stale',
|
||||
domain: 'gist.github.com',
|
||||
hostOnly: true,
|
||||
path: '/',
|
||||
secure: true,
|
||||
httpOnly: true,
|
||||
sameSite: 'lax'
|
||||
},
|
||||
{
|
||||
name: 'lookalike',
|
||||
value: 'live',
|
||||
domain: 'notgithub.com',
|
||||
hostOnly: true,
|
||||
path: '/',
|
||||
secure: true,
|
||||
httpOnly: true,
|
||||
sameSite: 'lax'
|
||||
}
|
||||
]),
|
||||
set: vi.fn(async () => undefined),
|
||||
remove: cookiesRemoveMock,
|
||||
flushStore: vi.fn().mockResolvedValue(undefined)
|
||||
},
|
||||
clearData: vi.fn().mockResolvedValue(undefined),
|
||||
setUserAgent: vi.fn(),
|
||||
getStoragePath: () => join(tmpDir, 'userData', 'Partitions', 'test')
|
||||
})
|
||||
createChromiumCookieTestDatabase(sourceCookiesPath, [
|
||||
{ domain: '.github.com', name: 'user_session', value: 'imported-github' }
|
||||
]).close()
|
||||
createChromiumCookieTestDatabase(targetCookiesPath, []).close()
|
||||
|
||||
const result = await importCookiesFromBrowser(chromeBrowser(sourceCookiesPath), 'persist:test')
|
||||
|
||||
expect(result.ok).toBe(true)
|
||||
expect(cookiesRemoveMock).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -46,13 +46,6 @@ function node(
|
||||
}
|
||||
|
||||
describe('buildSnapshot', () => {
|
||||
it('returns empty snapshot for empty tree', async () => {
|
||||
const result = await buildSnapshot(makeSender([]))
|
||||
expect(result.snapshot).toBe('')
|
||||
expect(result.refs).toEqual([])
|
||||
expect(result.refMap.size).toBe(0)
|
||||
})
|
||||
|
||||
it('assigns refs to interactive elements', async () => {
|
||||
const nodes: AXNode[] = [
|
||||
node('1', 'WebArea', 'page', { childIds: ['2', '3'] }),
|
||||
@@ -68,48 +61,6 @@ describe('buildSnapshot', () => {
|
||||
expect(result.snapshot).toContain('[@e2] link "Home"')
|
||||
})
|
||||
|
||||
it('renders text inputs with friendly role name', async () => {
|
||||
const nodes: AXNode[] = [
|
||||
node('1', 'WebArea', 'page', { childIds: ['2'] }),
|
||||
node('2', 'textbox', 'Email', { backendDOMNodeId: 10 })
|
||||
]
|
||||
const result = await buildSnapshot(makeSender(nodes))
|
||||
expect(result.snapshot).toContain('text input "Email"')
|
||||
})
|
||||
|
||||
it('renders landmarks without refs', async () => {
|
||||
const nodes: AXNode[] = [
|
||||
node('1', 'WebArea', 'page', { childIds: ['2'] }),
|
||||
node('2', 'navigation', 'Main Nav', { childIds: ['3'] }),
|
||||
node('3', 'link', 'About', { backendDOMNodeId: 10 })
|
||||
]
|
||||
const result = await buildSnapshot(makeSender(nodes))
|
||||
|
||||
expect(result.snapshot).toContain('[Main Nav]')
|
||||
expect(result.refs).toHaveLength(1)
|
||||
expect(result.refs[0].name).toBe('About')
|
||||
})
|
||||
|
||||
it('renders headings without refs', async () => {
|
||||
const nodes: AXNode[] = [
|
||||
node('1', 'WebArea', 'page', { childIds: ['2'] }),
|
||||
node('2', 'heading', 'Welcome')
|
||||
]
|
||||
const result = await buildSnapshot(makeSender(nodes))
|
||||
expect(result.snapshot).toContain('heading "Welcome"')
|
||||
expect(result.refs).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('renders static text without refs', async () => {
|
||||
const nodes: AXNode[] = [
|
||||
node('1', 'WebArea', 'page', { childIds: ['2'] }),
|
||||
node('2', 'staticText', 'Hello world')
|
||||
]
|
||||
const result = await buildSnapshot(makeSender(nodes))
|
||||
expect(result.snapshot).toContain('text "Hello world"')
|
||||
expect(result.refs).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('skips generic/none/presentation roles', async () => {
|
||||
const nodes: AXNode[] = [
|
||||
node('1', 'WebArea', 'page', { childIds: ['2'] }),
|
||||
@@ -156,41 +107,4 @@ describe('buildSnapshot', () => {
|
||||
expect(entry!.role).toBe('checkbox')
|
||||
expect(entry!.name).toBe('Agree')
|
||||
})
|
||||
|
||||
it('indents children under landmarks', async () => {
|
||||
const nodes: AXNode[] = [
|
||||
node('1', 'WebArea', 'page', { childIds: ['2'] }),
|
||||
node('2', 'main', '', { childIds: ['3'] }),
|
||||
node('3', 'button', 'Action', { backendDOMNodeId: 10 })
|
||||
]
|
||||
const result = await buildSnapshot(makeSender(nodes))
|
||||
const lines = result.snapshot.split('\n')
|
||||
const mainLine = lines.find((l) => l.includes('[Main Content]'))
|
||||
const buttonLine = lines.find((l) => l.includes('Action'))
|
||||
expect(mainLine).toBeDefined()
|
||||
expect(buttonLine).toBeDefined()
|
||||
expect(buttonLine!.startsWith(' ')).toBe(true)
|
||||
})
|
||||
|
||||
it('handles a realistic page structure', async () => {
|
||||
const nodes: AXNode[] = [
|
||||
node('1', 'WebArea', 'page', { childIds: ['2', '3', '4'] }),
|
||||
node('2', 'banner', '', { childIds: ['5'] }),
|
||||
node('3', 'main', '', { childIds: ['6', '7', '8'] }),
|
||||
node('4', 'contentinfo', '', {}),
|
||||
node('5', 'link', 'Logo', { backendDOMNodeId: 10 }),
|
||||
node('6', 'heading', 'Dashboard'),
|
||||
node('7', 'textbox', 'Search', { backendDOMNodeId: 20 }),
|
||||
node('8', 'button', 'Go', { backendDOMNodeId: 21 })
|
||||
]
|
||||
const result = await buildSnapshot(makeSender(nodes))
|
||||
|
||||
expect(result.refs).toHaveLength(3)
|
||||
expect(result.refs.map((r) => r.name)).toEqual(['Logo', 'Search', 'Go'])
|
||||
|
||||
expect(result.snapshot).toContain('[Header]')
|
||||
expect(result.snapshot).toContain('[Main Content]')
|
||||
expect(result.snapshot).toContain('[Footer]')
|
||||
expect(result.snapshot).toContain('heading "Dashboard"')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -170,35 +170,4 @@ describe('Claude permission callbacks', () => {
|
||||
expect.objectContaining({ type: 'prompt-cancelled', promptKey: 'perm-4' })
|
||||
)
|
||||
})
|
||||
|
||||
it('settles every in-flight prompt with null when the registry is cleared', async () => {
|
||||
const control = callbacksFor()
|
||||
const first = control.canUseTool(
|
||||
'Bash',
|
||||
{ command: 'a' },
|
||||
permissionOptions('perm-5', 'tool-5', new AbortController().signal)
|
||||
)
|
||||
const second = control.canUseTool(
|
||||
'Bash',
|
||||
{ command: 'b' },
|
||||
permissionOptions('perm-6', 'tool-6', new AbortController().signal)
|
||||
)
|
||||
|
||||
for (const prompt of control.prompts.clear()) {
|
||||
prompt.settle(null)
|
||||
}
|
||||
|
||||
await expect(first).resolves.toBeNull()
|
||||
await expect(second).resolves.toBeNull()
|
||||
})
|
||||
|
||||
it('answers a user dialog deny-safe', async () => {
|
||||
const control = callbacksFor()
|
||||
await expect(
|
||||
control.onUserDialog(
|
||||
{ dialogKind: 'refusal_fallback_prompt', payload: {} },
|
||||
{ signal: new AbortController().signal, requestId: 'dialog-1' }
|
||||
)
|
||||
).resolves.toEqual({ behavior: 'cancelled' })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -2,7 +2,6 @@ import { describe, expect, it, vi } from 'vitest'
|
||||
import { CodexAppServerRequestError } from './app-server/codex-app-server-request-error'
|
||||
import type { CodexSession } from './codex-structured-session-state'
|
||||
import { recoverCodexRewind, rewindCodexSession } from './codex-structured-rewind'
|
||||
import { openCodexThread } from './codex-structured-thread-open'
|
||||
|
||||
function fixture(turns = ['kept', 'drop'], reverted = false) {
|
||||
let history = reverted ? turns.slice(0, turns.indexOf('drop')) : turns
|
||||
@@ -174,15 +173,4 @@ describe('Codex rewind', () => {
|
||||
})
|
||||
expect(request).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
it('captures history mode at both start and resume without changing defaults', async () => {
|
||||
for (const resumeThreadId of [null, 'thread']) {
|
||||
const request = vi.fn(async (_method: string, _params?: unknown) => ({
|
||||
thread: { id: 'thread', historyMode: 'legacy' }
|
||||
}))
|
||||
expect(
|
||||
await openCodexThread({ request }, { cwd: '/workspace', resumeThreadId }, 10)
|
||||
).toMatchObject({ historyMode: 'legacy' })
|
||||
expect(request.mock.calls[0]?.[1]).not.toHaveProperty('historyMode')
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
||||
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
@@ -57,17 +58,6 @@ function mutate(
|
||||
}
|
||||
|
||||
describe('real-home user hook trust rebasing', () => {
|
||||
it('writes without touching config.toml when no hook moves', () => {
|
||||
const user = command('user-hook')
|
||||
mutate(
|
||||
{ Stop: [{ hooks: [user] }] },
|
||||
{ Stop: [{ hooks: [user] }, { hooks: [command('orca')] }] }
|
||||
)
|
||||
|
||||
expect(existsSync(hooksPath)).toBe(true)
|
||||
expect(existsSync(configPath)).toBe(false)
|
||||
})
|
||||
|
||||
it('finds multiple shifted user hooks, including a handler from a mixed group', async () => {
|
||||
const orca = command('orca-hook')
|
||||
const first = command('first-user')
|
||||
|
||||
@@ -172,11 +172,4 @@ describe('repairing configs broken by #22592', () => {
|
||||
expect(repairOrcaDuplicateTrustTables(broken)).toBe(broken)
|
||||
expect(warn).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('does not touch a valid config', () => {
|
||||
const config = ['["projects"."/repo-f"]', '"trust_level" = "trusted"', ''].join('\n')
|
||||
|
||||
expect(repairOrcaDuplicateTrustTables(config)).toBe(config)
|
||||
expect(warn).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -8,10 +8,7 @@ import { DaemonAutomaticWorkGuard } from './daemon-automatic-work-guard'
|
||||
import { DaemonProcessGrowthMonitor } from './daemon-process-growth-monitor'
|
||||
import type { SubprocessHandle } from './session-subprocess-handle'
|
||||
import { TerminalHost, type TerminalHostOptions } from './terminal-host'
|
||||
import {
|
||||
resolveTerminalSpawnAdmissionMode,
|
||||
TERMINAL_SPAWN_ADMISSION_ENV_VAR
|
||||
} from './terminal-spawn-gate'
|
||||
import { resolveTerminalSpawnAdmissionMode } from './terminal-spawn-gate'
|
||||
|
||||
type SpawnSubprocess = TerminalHostOptions['spawnSubprocess']
|
||||
|
||||
@@ -105,19 +102,6 @@ describe('desktop restore admission', () => {
|
||||
})
|
||||
}
|
||||
|
||||
it('enforces on desktop unless the rollback switch asks to observe', () => {
|
||||
expect(resolveTerminalSpawnAdmissionMode({})).toBe('enforce')
|
||||
expect(
|
||||
resolveTerminalSpawnAdmissionMode({ [TERMINAL_SPAWN_ADMISSION_ENV_VAR]: 'enforce' })
|
||||
).toBe('enforce')
|
||||
expect(resolveTerminalSpawnAdmissionMode({ [TERMINAL_SPAWN_ADMISSION_ENV_VAR]: 'bogus' })).toBe(
|
||||
'enforce'
|
||||
)
|
||||
expect(
|
||||
resolveTerminalSpawnAdmissionMode({ [TERMINAL_SPAWN_ADMISSION_ENV_VAR]: ' Observe ' })
|
||||
).toBe('observe')
|
||||
})
|
||||
|
||||
it('restores the visible pane at once, at most 8 hidden agents, and keeps the rest sleeping', async () => {
|
||||
startDesktopDaemon()
|
||||
const hidden = Array.from({ length: 19 }, (_, i) =>
|
||||
|
||||
@@ -195,24 +195,4 @@ describe('createPtySubprocess', () => {
|
||||
Object.assign(process.env, savedGitConfigEnv)
|
||||
}
|
||||
})
|
||||
|
||||
it('guards a trusted daemon agent whose launch command is wrapped', async () => {
|
||||
const proc = mockPtyProcess()
|
||||
spawnMock.mockReturnValue(proc)
|
||||
|
||||
await createPtySubprocess({
|
||||
sessionId: 'trusted-wrapped-agent',
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
command: 'cd /repo && custom-agent-wrapper',
|
||||
launchAgent: 'claude',
|
||||
env: { SHELL: '/bin/bash' }
|
||||
})
|
||||
|
||||
const spawnEnv = spawnMock.mock.calls.at(-1)?.[2]?.env as Record<string, string>
|
||||
expect(spawnEnv.GIT_TERMINAL_PROMPT).toBe('0')
|
||||
expect(spawnEnv.GCM_INTERACTIVE).toBe('never')
|
||||
expect(Object.values(spawnEnv)).toContain('credential.interactive')
|
||||
expect(Object.values(spawnEnv)).toContain('credential.guiPrompt')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -35,50 +35,6 @@ describe('listSimulatorDevices', () => {
|
||||
platformMock.mockReturnValue('darwin')
|
||||
})
|
||||
|
||||
it('parses simctl devices on macOS', async () => {
|
||||
execFileMock.mockImplementation((_command, _args, _options, callback) => {
|
||||
callback(
|
||||
null,
|
||||
JSON.stringify({
|
||||
devices: {
|
||||
'com.apple.CoreSimulator.SimRuntime.iOS-18-0': [
|
||||
{
|
||||
name: 'iPhone 17 Pro',
|
||||
udid: 'AAAAAAAA-BBBB-CCCC-DDDD-EEEEEEEEEEEE',
|
||||
state: 'Shutdown',
|
||||
isAvailable: true
|
||||
}
|
||||
]
|
||||
}
|
||||
}),
|
||||
''
|
||||
)
|
||||
})
|
||||
|
||||
await expect(listSimulatorDevices()).resolves.toEqual([
|
||||
{
|
||||
name: 'iPhone 17 Pro',
|
||||
udid: 'AAAAAAAA-BBBB-CCCC-DDDD-EEEEEEEEEEEE',
|
||||
state: 'Shutdown',
|
||||
runtime: 'com.apple.CoreSimulator.SimRuntime.iOS-18-0',
|
||||
isAvailable: true
|
||||
}
|
||||
])
|
||||
expect(execFileMock).toHaveBeenCalledWith(
|
||||
'xcrun',
|
||||
['simctl', 'list', 'devices', '-j'],
|
||||
{ timeout: 15_000 },
|
||||
expect.any(Function)
|
||||
)
|
||||
})
|
||||
|
||||
it('returns no devices on non-macOS hosts', async () => {
|
||||
platformMock.mockReturnValue('linux')
|
||||
|
||||
await expect(listSimulatorDevices()).resolves.toEqual([])
|
||||
expect(execFileMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('normalizes missing simctl errors for UI and CLI callers', async () => {
|
||||
execFileMock.mockImplementation((_command, _args, _options, callback) => {
|
||||
callback(
|
||||
|
||||
@@ -69,36 +69,6 @@ describe('addSparseWorktree', () => {
|
||||
platformSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('enables long paths on the Windows commands that actually write the deep checkout', async () => {
|
||||
platformSpy.mockReturnValue('win32')
|
||||
gitExecFileAsyncMock.mockResolvedValue({ stdout: '', stderr: '' })
|
||||
|
||||
await addSparseWorktree('C:\\repo', 'C:\\repo-feature', 'feature/test', ['packages/web'])
|
||||
|
||||
const calls = getGitCalls()
|
||||
expect(calls).toEqual(
|
||||
expect.arrayContaining([
|
||||
'git -c core.longpaths=true sparse-checkout set -- packages/web',
|
||||
'git -c core.longpaths=true checkout feature/test'
|
||||
])
|
||||
)
|
||||
})
|
||||
|
||||
it('omits the long-path option on non-Windows hosts', async () => {
|
||||
gitExecFileAsyncMock.mockResolvedValue({ stdout: '', stderr: '' })
|
||||
|
||||
await addSparseWorktree('/repo', '/repo-feature', 'feature/test', ['packages/web'])
|
||||
|
||||
const calls = getGitCalls()
|
||||
expect(calls).toEqual(
|
||||
expect.arrayContaining([
|
||||
'git sparse-checkout set -- packages/web',
|
||||
'git checkout feature/test'
|
||||
])
|
||||
)
|
||||
expect(calls.some((call) => call.includes('core.longpaths'))).toBe(false)
|
||||
})
|
||||
|
||||
it('separates sparse checkout directory operands from options', async () => {
|
||||
gitExecFileAsyncMock.mockResolvedValue({ stdout: '', stderr: '' })
|
||||
|
||||
|
||||
@@ -142,14 +142,6 @@ describe('reconcileDriveCheckoutSuspects', () => {
|
||||
})
|
||||
}
|
||||
|
||||
it('does nothing for checkouts outside the Windows-checkout config', async () => {
|
||||
mocks.appliesTo = new Set()
|
||||
await reconcileDriveCheckoutSuspects(repo, [], {})
|
||||
changed(['a.ts'])
|
||||
expect(await reconcileDriveCheckoutSuspects(repo, [], {})).toEqual([])
|
||||
expect(mocks.gitStreamStdout).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rechecks only suspects minimal status reported clean, keeping the dirty ones', async () => {
|
||||
await touchFiles(['clean.ts', 'hidden edit.ts', 'already.ts'])
|
||||
await reconcileDriveCheckoutSuspects(repo, [], {})
|
||||
|
||||
@@ -4,14 +4,10 @@ const mocks = vi.hoisted(() => ({ gitExecFileAsync: vi.fn() }))
|
||||
|
||||
vi.mock('./runner', () => ({ gitExecFileAsync: mocks.gitExecFileAsync }))
|
||||
|
||||
import { GIT_READ_TIMEOUT_MS } from './command-runner/git-command-timeout'
|
||||
import { GitAdmissionScheduler } from './command-runner/git-subprocess-admission'
|
||||
import type { GitAdmissionTier } from './command-runner/git-exec-options'
|
||||
import { WSL_GIT_READ_ENVIRONMENT_WAIT_MS } from './wsl-git-read-environment'
|
||||
import {
|
||||
measureRetargetDivergence,
|
||||
RETARGET_DIVERGENCE_BUDGET_MS
|
||||
} from './worktree-base-divergence'
|
||||
|
||||
import { measureRetargetDivergence } from './worktree-base-divergence'
|
||||
|
||||
type ExecOptions = {
|
||||
cwd: string
|
||||
@@ -21,10 +17,6 @@ type ExecOptions = {
|
||||
admissionTier?: GitAdmissionTier
|
||||
}
|
||||
|
||||
function callOptions(): ExecOptions[] {
|
||||
return mocks.gitExecFileAsync.mock.calls.map((call) => call[1] as ExecOptions)
|
||||
}
|
||||
|
||||
function subcommands(): string[] {
|
||||
return mocks.gitExecFileAsync.mock.calls.map((call) => (call[0] as string[])[0]!)
|
||||
}
|
||||
@@ -73,32 +65,6 @@ describe('measureRetargetDivergence deadlines', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('puts every probe under one shared budget, not a budget each', async () => {
|
||||
answerProbes('3\n')
|
||||
|
||||
await expect(
|
||||
measureRetargetDivergence('/repo', 'refs/heads/main', 'refs/remotes/origin/main')
|
||||
).resolves.toBe('within')
|
||||
|
||||
expect(subcommands()).toEqual(['rev-list', 'rev-list', 'merge-base'])
|
||||
const signals = callOptions().map((options) => options.signal)
|
||||
// One budget spans all staged probes; per-probe budgets would add together.
|
||||
expect(new Set(signals).size).toBe(1)
|
||||
expect(signals[0]).toBeInstanceOf(AbortSignal)
|
||||
})
|
||||
|
||||
it('also gives each probe a command timeout well below git default read deadline', async () => {
|
||||
answerProbes('3\n')
|
||||
|
||||
await measureRetargetDivergence('/repo', 'refs/heads/main', 'refs/remotes/origin/main')
|
||||
|
||||
// Signals cover pre-spawn waits; command timeouts still bound a hung spawn.
|
||||
for (const options of callOptions()) {
|
||||
expect(options.timeout).toBe(RETARGET_DIVERGENCE_BUDGET_MS)
|
||||
}
|
||||
expect(RETARGET_DIVERGENCE_BUDGET_MS).toBeLessThan(GIT_READ_TIMEOUT_MS)
|
||||
})
|
||||
|
||||
it('really aborts the in-flight probes when the shared budget expires', async () => {
|
||||
mocks.gitExecFileAsync.mockImplementation(
|
||||
(_args: string[], options: ExecOptions) =>
|
||||
@@ -121,12 +87,6 @@ describe('measureRetargetDivergence deadlines', () => {
|
||||
).resolves.toBe('unknown')
|
||||
})
|
||||
|
||||
it('clears the WSL read-environment wait, which starts before any command timeout', () => {
|
||||
// Leave walk headroom after the first WSL environment probe.
|
||||
expect(RETARGET_DIVERGENCE_BUDGET_MS).toBeGreaterThan(WSL_GIT_READ_ENVIRONMENT_WAIT_MS)
|
||||
expect(RETARGET_DIVERGENCE_BUDGET_MS).toBeLessThan(GIT_READ_TIMEOUT_MS)
|
||||
})
|
||||
|
||||
it('stops the probes when the create itself is cancelled, without waiting for the budget', async () => {
|
||||
mocks.gitExecFileAsync.mockImplementation(
|
||||
(_args: string[], options: ExecOptions) =>
|
||||
@@ -194,16 +154,4 @@ describe('measureRetargetDivergence deadlines', () => {
|
||||
).resolves.toBe('exceeded')
|
||||
expect(subcommands()).not.toContain('merge-base')
|
||||
})
|
||||
|
||||
it('routes every probe to the caller-named WSL distro', async () => {
|
||||
answerProbes('1\n')
|
||||
|
||||
await measureRetargetDivergence('/repo', 'refs/heads/main', 'refs/remotes/origin/main', {
|
||||
wslDistro: 'Ubuntu'
|
||||
})
|
||||
|
||||
for (const options of callOptions()) {
|
||||
expect(options).toMatchObject({ cwd: '/repo', wslDistro: 'Ubuntu' })
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -183,12 +183,4 @@ describe('finalizeView verticalGroupByFields normalization', () => {
|
||||
])
|
||||
}
|
||||
})
|
||||
|
||||
it('omits the key entirely when the host never sent it (wire-compat shape)', () => {
|
||||
const finalized = finalizeView(base, [])
|
||||
expect(finalized.ok).toBe(true)
|
||||
if (finalized.ok) {
|
||||
expect('verticalGroupByFields' in finalized.view).toBe(false)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -43,15 +43,6 @@ describe('guarded Grok hook config mutation', () => {
|
||||
expect(readFileSync(configPath, 'utf8')).toBe(userEdit)
|
||||
})
|
||||
|
||||
it('treats a newer user deletion as a generation mismatch', async () => {
|
||||
const configPath = makeConfigPath()
|
||||
const installed = '{"hooks":{"SessionStart":[]}}\n'
|
||||
writeFileSync(configPath, installed)
|
||||
rmSync(configPath)
|
||||
|
||||
await expect(removeGrokHookConfigIfUnchanged(configPath, installed)).resolves.toBe(false)
|
||||
})
|
||||
|
||||
it('does not overwrite a newer user edit', async () => {
|
||||
const configPath = makeConfigPath()
|
||||
const installed = '{"hooks":{"SessionStart":[]}}\n'
|
||||
@@ -152,36 +143,6 @@ describe('guarded Grok hook config mutation', () => {
|
||||
expect(statSync(configPath).mode & 0o777).toBe(0o600)
|
||||
})
|
||||
|
||||
// Grok rejects global hook files with nlink !=1; publishing through a hard link creates a session-failure window.
|
||||
it('never publishes the config through a second hard link', async () => {
|
||||
const configPath = makeConfigPath()
|
||||
const installed = '{"hooks":{"SessionStart":[]}}\n'
|
||||
writeFileSync(configPath, installed)
|
||||
|
||||
const linkCounts: number[] = []
|
||||
const sampler = setInterval(() => {
|
||||
try {
|
||||
linkCounts.push(statSync(configPath).nlink)
|
||||
} catch {
|
||||
/* absent mid-swap is fine; only a second link is not */
|
||||
}
|
||||
}, 0)
|
||||
try {
|
||||
for (let i = 0; i < 40; i += 1) {
|
||||
const next = `{"hooks":{"SessionStart":[],"n":${i}}}\n`
|
||||
await expect(
|
||||
writeGrokHookConfigIfUnchanged(configPath, readFileSync(configPath, 'utf8'), next)
|
||||
).resolves.toBe(true)
|
||||
}
|
||||
} finally {
|
||||
clearInterval(sampler)
|
||||
}
|
||||
|
||||
expect(linkCounts.length).toBeGreaterThan(0)
|
||||
expect(Math.max(...linkCounts)).toBe(1)
|
||||
expect(statSync(configPath).nlink).toBe(1)
|
||||
})
|
||||
|
||||
it('removes the config and reports the removal', async () => {
|
||||
const configPath = makeConfigPath()
|
||||
const installed = '{"hooks":{"SessionStart":[]}}\n'
|
||||
|
||||
@@ -202,16 +202,4 @@ describe('workspace cleanup execution-host routing', () => {
|
||||
expect(result.candidates[0]?.blockers).toContain('git-status-error')
|
||||
expect(result.candidates[0]?.executionHostId).toBe('ssh:alpha')
|
||||
})
|
||||
|
||||
it('still routes a genuinely local repo to this machine', async () => {
|
||||
const store = makeStore([makeRepo({ path: '/local/repo' })], {
|
||||
[WORKTREE_ID]: makeMeta()
|
||||
})
|
||||
|
||||
const result = await scanWorkspaceCleanup(store)
|
||||
|
||||
expect(listRepoWorktreesMock).toHaveBeenCalled()
|
||||
expect(getStatusMock).toHaveBeenCalled()
|
||||
expect(result.candidates[0]?.executionHostId).toBe('local')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import type * as ExecCommandModule from '../../ssh/remote-exec/remote-exec-command'
|
||||
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 { ensureRemoteOrcadNodeRuntime } from './orcad-remote-node-runtime'
|
||||
import {
|
||||
@@ -11,14 +9,9 @@ import {
|
||||
import { execCommand } from '../../ssh/remote-exec/remote-exec-command'
|
||||
import { uploadRemoteInstallDirectory } from './remote-install-transfers'
|
||||
import { getRemoteHostPlatform } from '../../ssh/ssh-remote-platform'
|
||||
import { pinnedNodeRuntimeAsset } from '../../../shared/node-runtime-pin'
|
||||
import { DesktopDownloadUnreachableError } from '../../node-runtime/pinned-runtime-materializer'
|
||||
import { setNodeDistMirrorSettingReader } from '../../node-runtime/node-dist-mirror-setting'
|
||||
import {
|
||||
HOST_NODE_DOWNLOAD_FAILED,
|
||||
HOST_NODE_DOWNLOAD_OK,
|
||||
NodeRuntimeUnreachableError
|
||||
} from './remote-node-runtime-host-download'
|
||||
|
||||
import { HOST_NODE_DOWNLOAD_OK } from './remote-node-runtime-host-download'
|
||||
|
||||
import { unusedOrcadHostTransport } from '../../ssh/unused-host-transport.test-fixture'
|
||||
|
||||
vi.mock('../../ssh/remote-exec/remote-exec-command', async (importOriginal) => ({
|
||||
@@ -33,7 +26,6 @@ const conn = unusedOrcadHostTransport()
|
||||
const host = getRemoteHostPlatform('linux-x64')
|
||||
const TARGET = 'linux-x64-glibc'
|
||||
const OFFLINE_PATH = '/Users/me/Library/Application Support/Orca/orcad-artifacts/node/archives/x'
|
||||
const refused = (): Promise<string> => Promise.reject(new Error('net::ERR_CONNECTION_REFUSED'))
|
||||
|
||||
afterEach(() => {
|
||||
vi.mocked(execCommand).mockReset()
|
||||
@@ -72,18 +64,6 @@ function ensure(archivePath: () => Promise<string>, signal?: AbortSignal) {
|
||||
}
|
||||
|
||||
describe('pinned Node when this computer cannot download it', () => {
|
||||
it('has the host download it and promotes that copy, with nothing uploaded', async () => {
|
||||
const commands = freshHost(HOST_NODE_DOWNLOAD_OK)
|
||||
|
||||
const result = await ensure(refused)
|
||||
|
||||
expect(result.transfer).toBe('host-downloaded')
|
||||
expect(uploadRemoteInstallDirectory).not.toHaveBeenCalled()
|
||||
const promote = commands.find((command) => command.includes('tar -xzf'))
|
||||
expect(promote).toContain(pinnedNodeRuntimeAsset(TARGET).archive)
|
||||
expect(promote).toContain(pinnedNodeRuntimeAsset(TARGET).executableSha256)
|
||||
})
|
||||
|
||||
it('also tries the host when the desktop got a file that failed the pin', async () => {
|
||||
freshHost(HOST_NODE_DOWNLOAD_OK)
|
||||
const blockPage = () => Promise.reject(new Error('Node archive checksum mismatch'))
|
||||
@@ -91,68 +71,6 @@ describe('pinned Node when this computer cannot download it', () => {
|
||||
await expect(ensure(blockPage)).resolves.toMatchObject({ transfer: 'host-downloaded' })
|
||||
})
|
||||
|
||||
it('names both attempts and the offline copy path when the host cannot download either', async () => {
|
||||
freshHost(
|
||||
`${HOST_NODE_DOWNLOAD_FAILED}\nORCA_RUNTIME_EXIT=7\ncurl: (7) Failed to connect to nodejs.org`
|
||||
)
|
||||
|
||||
const failure = await ensure(refused).catch((error: unknown) => error)
|
||||
|
||||
expect(failure).toBeInstanceOf(NodeRuntimeUnreachableError)
|
||||
const message = String(failure)
|
||||
expect(message).toContain('net::ERR_CONNECTION_REFUSED')
|
||||
expect(message).toContain('curl: (7) Failed to connect to nodejs.org')
|
||||
expect(message).toContain(OFFLINE_PATH)
|
||||
expect(message).toContain('Settings > Advanced > Network > Node.js download mirror')
|
||||
expect(message).toContain("NODEJS_ORG_MIRROR in this computer's environment")
|
||||
expect(failure).toMatchObject({ mirror: 'unset' })
|
||||
expect(uploadRemoteInstallDirectory).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('records that both attempts went through the configured mirror', async () => {
|
||||
freshHost(`${HOST_NODE_DOWNLOAD_FAILED}\nmirror: curl exit 7`)
|
||||
setNodeDistMirrorSettingReader(() => 'https://npmmirror.com/mirrors/node')
|
||||
try {
|
||||
const failure = await ensure(refused).catch((error: unknown) => error)
|
||||
expect(failure).toMatchObject({ name: 'NodeRuntimeUnreachableError', mirror: 'tried' })
|
||||
expect(String(failure)).toContain('check the mirror of nodejs.org/dist in Settings')
|
||||
} finally {
|
||||
setNodeDistMirrorSettingReader(null)
|
||||
}
|
||||
})
|
||||
|
||||
it('offers no mirror for a build nodejs.org/dist does not carry', async () => {
|
||||
freshHost(`${HOST_NODE_DOWNLOAD_FAILED}\nnodejs.org: curl exit 7`)
|
||||
const failure = await ensureRemoteOrcadNodeRuntime({
|
||||
conn,
|
||||
host: getRemoteHostPlatform('linux-arm64'),
|
||||
slotDir: '/home/me/.orca-remote/orcad-0.1.0+abc',
|
||||
target: 'linux-arm64-musl',
|
||||
archivePath: refused
|
||||
}).catch((error: unknown) => error)
|
||||
expect(failure).toMatchObject({ name: 'NodeRuntimeUnreachableError', mirror: 'unsupported' })
|
||||
expect(String(failure)).not.toContain('Node.js download mirror')
|
||||
})
|
||||
|
||||
it("quotes only this computer's short reason, not its standalone advice", async () => {
|
||||
freshHost(`${HOST_NODE_DOWNLOAD_FAILED}\nnodejs.org: curl exit 7`)
|
||||
const desktop = () =>
|
||||
Promise.reject(
|
||||
new DesktopDownloadUnreachableError(
|
||||
"This computer couldn't download Node ... so the host doesn't need internet access",
|
||||
'https://nodejs.org/dist/x: net::ERR_CONNECTION_REFUSED',
|
||||
new Error('net::ERR_CONNECTION_REFUSED')
|
||||
)
|
||||
)
|
||||
|
||||
const message = String(await ensure(desktop).catch((error: unknown) => error))
|
||||
|
||||
expect(message).toContain(
|
||||
'This computer: https://nodejs.org/dist/x: net::ERR_CONNECTION_REFUSED.'
|
||||
)
|
||||
expect(message).not.toContain("the host doesn't need internet access")
|
||||
})
|
||||
|
||||
it('does not try the host once the connect was cancelled', async () => {
|
||||
const commands = freshHost(HOST_NODE_DOWNLOAD_OK)
|
||||
const controller = new AbortController()
|
||||
@@ -164,18 +82,4 @@ describe('pinned Node when this computer cannot download it', () => {
|
||||
await expect(ensure(cancelled, controller.signal)).rejects.toThrow('aborted')
|
||||
expect(commands.some((command) => command.includes('command -v curl'))).toBe(false)
|
||||
})
|
||||
|
||||
it('still uploads the desktop copy when there is one', async () => {
|
||||
const commands = freshHost(HOST_NODE_DOWNLOAD_OK)
|
||||
const cache = mkdtempSync(join(tmpdir(), 'orca-runtime-cache-'))
|
||||
const archive = join(cache, pinnedNodeRuntimeAsset(TARGET).archive)
|
||||
writeFileSync(archive, 'archive')
|
||||
try {
|
||||
await expect(ensure(async () => archive)).resolves.toMatchObject({ transfer: 'uploaded' })
|
||||
expect(uploadRemoteInstallDirectory).toHaveBeenCalledTimes(1)
|
||||
expect(commands.some((command) => command.includes('command -v curl'))).toBe(false)
|
||||
} finally {
|
||||
rmSync(cache, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -103,22 +103,6 @@ describe('orcad profile CLI launcher', () => {
|
||||
}
|
||||
)
|
||||
|
||||
it.skipIf(process.platform === 'win32')(
|
||||
'refreshes the launcher when the host switches server slots',
|
||||
async () => {
|
||||
await writeCli()
|
||||
await prepareOrcadCliLauncher()
|
||||
roots.install = join(directory, 'updated server')
|
||||
await writeCli()
|
||||
await prepareOrcadCliLauncher()
|
||||
const launcher = getOrcadCliLauncherPath()
|
||||
if (!launcher) {
|
||||
throw new Error('CLI launcher is missing')
|
||||
}
|
||||
expect(await readFile(launcher, 'utf8')).toContain(roots.install)
|
||||
}
|
||||
)
|
||||
|
||||
it('leaves old slots without a CLI usable and clears a previous launcher', async () => {
|
||||
await writeCli()
|
||||
await prepareOrcadCliLauncher()
|
||||
@@ -127,24 +111,6 @@ describe('orcad profile CLI launcher', () => {
|
||||
expect(getOrcadCliLauncherPath()).toBeNull()
|
||||
})
|
||||
|
||||
describe('on a Linux host', () => {
|
||||
beforeEach(() => {
|
||||
Object.defineProperty(process, 'platform', { configurable: true, value: 'linux' })
|
||||
})
|
||||
|
||||
it('refreshes both CLI names when the runtime switches server slots', async () => {
|
||||
await writeCli()
|
||||
await prepareOrcadCliLauncher()
|
||||
roots.install = join(directory, 'updated server')
|
||||
await writeCli()
|
||||
await prepareOrcadCliLauncher()
|
||||
const binDir = join(roots.profile, 'cli', 'bin')
|
||||
const script = await readFile(join(binDir, 'orca-ide'), 'utf8')
|
||||
expect(script).toContain(roots.install)
|
||||
expect(script).toBe(await readFile(join(binDir, 'orca'), 'utf8'))
|
||||
})
|
||||
})
|
||||
|
||||
describe('on a Windows host', () => {
|
||||
beforeEach(() => {
|
||||
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
|
||||
@@ -192,11 +158,5 @@ describe('orcad profile CLI launcher', () => {
|
||||
expect(await readFile(join(binDir(), 'orca.exe'), 'utf8')).toBe('native launcher v2')
|
||||
expect(await readConfig()).toContain(`cli=${join(roots.install, 'out', 'cli', 'index.js')}`)
|
||||
})
|
||||
|
||||
it('leaves a slot without the native launcher with no orca command', async () => {
|
||||
await writeCli()
|
||||
await prepareOrcadCliLauncher()
|
||||
expect(getOrcadCliLauncherPath()).toBeNull()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -8,7 +8,6 @@ import { RateLimitService } from './service'
|
||||
import { fetchClaudeRateLimits } from './claude-fetcher'
|
||||
import { fetchCodexRateLimits } from './codex-fetcher'
|
||||
import { fetchZcodeRateLimits } from './zcode-usage-fetcher'
|
||||
import { hasZcodePlanApiKey } from '../zcode/zcode-plan-api-key-store'
|
||||
import {
|
||||
deferred,
|
||||
okProvider,
|
||||
@@ -88,39 +87,6 @@ describe('RateLimitService zcode plan credentials', () => {
|
||||
vi.mocked(fetchCodexRateLimits).mockResolvedValue(okProvider('codex', 20))
|
||||
})
|
||||
|
||||
it('fetches zcode with the site-resolved plan credential when a resolver is set', async () => {
|
||||
const service = new RateLimitService()
|
||||
service.setZcodePlanConfigResolver(() => ({ site: 'bigmodel', apiKey: 'glm-key' }))
|
||||
vi.mocked(hasZcodePlanApiKey).mockReturnValue(true)
|
||||
vi.mocked(fetchZcodeRateLimits).mockResolvedValueOnce(okProvider('zcode', 33, Date.now()))
|
||||
|
||||
await service.refresh()
|
||||
|
||||
expect(fetchZcodeRateLimits).toHaveBeenCalledTimes(1)
|
||||
expect(fetchZcodeRateLimits).toHaveBeenCalledWith({
|
||||
signal: expect.any(AbortSignal),
|
||||
planCredential: { apiKey: 'glm-key', baseUrl: 'https://open.bigmodel.cn' }
|
||||
})
|
||||
const state = service.getState()
|
||||
expect(state.zcode?.status).toBe('ok')
|
||||
expect(state.zcode?.session?.usedPercent).toBe(33)
|
||||
expect(state.zcodePlanApiKeyConfigured).toBe(true)
|
||||
})
|
||||
|
||||
it('passes no plan credential while no key is saved and still fetches via the CLI config', async () => {
|
||||
const service = new RateLimitService()
|
||||
service.setZcodePlanConfigResolver(() => ({ site: 'zai', apiKey: '' }))
|
||||
vi.mocked(fetchZcodeRateLimits).mockResolvedValueOnce(okProvider('zcode', 12, Date.now()))
|
||||
|
||||
await service.refresh()
|
||||
|
||||
expect(fetchZcodeRateLimits).toHaveBeenCalledWith({
|
||||
signal: expect.any(AbortSignal),
|
||||
planCredential: null
|
||||
})
|
||||
expect(service.getState().zcode?.session?.usedPercent).toBe(12)
|
||||
})
|
||||
|
||||
it('surfaces a resolver failure as a zcode-only error without fetching', async () => {
|
||||
const service = new RateLimitService()
|
||||
service.setZcodePlanConfigResolver(() => {
|
||||
|
||||
@@ -2,7 +2,8 @@ import { describe, expect, it, vi } from 'vitest'
|
||||
import { mkdtemp, realpath, symlink, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { enoent, resolveAuthorizedPathMock, statMock } from './orca-runtime-files-mock-registry'
|
||||
import { resolveAuthorizedPathMock, statMock } from './orca-runtime-files-mock-registry'
|
||||
|
||||
import {
|
||||
createRuntimeFileCommands,
|
||||
useRuntimeFileCommandsLifecycle
|
||||
@@ -38,34 +39,6 @@ describe('RuntimeFileCommands', () => {
|
||||
describe('resolveTerminalPath', () => {
|
||||
const { tempDirs, tempFile } = useTerminalArtifactTempFiles()
|
||||
|
||||
it('returns an absolute open target for an existing local temp path outside the worktree', async () => {
|
||||
const artifactPath = await tempFile('result.json', '{}')
|
||||
const { commands } = createRuntimeFileCommands({ path: '/repo' })
|
||||
resolveAuthorizedPathMock.mockImplementation(async (p: string) => p)
|
||||
statMock.mockResolvedValue({ isDirectory: () => false, size: 12, dev: 1, ino: 2, mtimeMs: 3 })
|
||||
const canonicalPath = await realpath(artifactPath)
|
||||
|
||||
const result = await resolveTerminalArtifactPath(commands, artifactPath)
|
||||
|
||||
expect(result).toMatchObject({
|
||||
worktree: 'wt-1',
|
||||
relativePath: null,
|
||||
absolutePath: canonicalPath,
|
||||
exists: true,
|
||||
isDirectory: false,
|
||||
openTarget: {
|
||||
kind: 'absolute-file',
|
||||
provider: 'local',
|
||||
absolutePath: canonicalPath
|
||||
}
|
||||
})
|
||||
expect(result.openTarget?.kind === 'absolute-file' ? result.openTarget.grantId : '').toMatch(
|
||||
/\S/
|
||||
)
|
||||
expect(resolveAuthorizedPathMock).not.toHaveBeenCalledWith(canonicalPath, expect.anything())
|
||||
expect(statMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
// Why: a chat link or typed path carries no terminal; the caller's terminal access is the authority.
|
||||
it('grants a writable file outside every workspace with no terminal or chat context', async () => {
|
||||
const artifactPath = await tempFile('handoff.md', '# handoff')
|
||||
@@ -108,16 +81,6 @@ describe('RuntimeFileCommands', () => {
|
||||
).resolves.toEqual({ ok: true })
|
||||
})
|
||||
|
||||
it('reports a missing file outside every workspace as absent', async () => {
|
||||
const { commands } = createRuntimeFileCommands({ path: '/repo' })
|
||||
const missingPath = join(tmpdir(), `orca-missing-${process.pid}-${Date.now()}.md`)
|
||||
|
||||
const result = await commands.resolveTerminalPath('id:wt-1', missingPath, null, 'client-a')
|
||||
|
||||
expect(result).toMatchObject({ relativePath: null, exists: false })
|
||||
expect(result.openTarget).toBeUndefined()
|
||||
})
|
||||
|
||||
// Why: this machine's home is the Windows user's, not the distro user's.
|
||||
it('refuses tilde paths when the workspace runs in WSL', async () => {
|
||||
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
|
||||
@@ -157,24 +120,6 @@ describe('RuntimeFileCommands', () => {
|
||||
).rejects.toThrow('Not a regular file')
|
||||
})
|
||||
|
||||
it('resolves a relative path against the terminal cwd, not a stale client cwd', async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), 'orca-terminal-artifact-'))
|
||||
tempDirs.push(dir)
|
||||
await writeFile(join(dir, 'result.json'), '{}')
|
||||
const { commands } = createRuntimeFileCommands({ path: '/repo' })
|
||||
resolveAuthorizedPathMock.mockImplementation(async (p: string) => p)
|
||||
statMock.mockRejectedValue(enoent())
|
||||
|
||||
const result = await resolveTerminalArtifactPath(commands, 'result.json', dir)
|
||||
|
||||
expect(result).toMatchObject({
|
||||
relativePath: 'result.json',
|
||||
absolutePath: '/repo/result.json',
|
||||
exists: false
|
||||
})
|
||||
expect(result.openTarget).toBeUndefined()
|
||||
})
|
||||
|
||||
it('uses the canonical local temp artifact path for the exact grant', async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), 'orca-terminal-artifact-'))
|
||||
tempDirs.push(dir)
|
||||
|
||||
@@ -87,13 +87,6 @@ async function restore(runtime: InstanceType<typeof OrcaRuntimeService>): Promis
|
||||
}
|
||||
|
||||
describe('restoring a pane its host recorded an agent in', () => {
|
||||
it('resumes the hook store’s conversation instead of replaying the launch command', async () => {
|
||||
const { runtime, spawn } = makeRuntime([codexRow()], 'codex')
|
||||
await restore(runtime)
|
||||
expect(spawn).toHaveBeenCalledOnce()
|
||||
expect(spawn.mock.calls[0]![0].command).toMatch(RESUMES)
|
||||
})
|
||||
|
||||
it('resumes an agent typed into a shell, which no launch command names', async () => {
|
||||
const { runtime, spawn } = makeRuntime([codexRow()])
|
||||
await restore(runtime)
|
||||
@@ -159,22 +152,6 @@ describe('restoring a pane its host recorded an agent in', () => {
|
||||
expectFreshLaunch(spawn.mock.calls[0]![0].command)
|
||||
})
|
||||
|
||||
it('launches the tab’s agent fresh when another pane holds the conversation live', async () => {
|
||||
const otherPaneKey = 'other-tab:44444444-4444-4444-8444-444444444444'
|
||||
const { runtime, spawn } = makeRuntime(
|
||||
[codexRow(), codexRow({ paneKey: otherPaneKey })],
|
||||
'codex',
|
||||
{ presence: 'live' }
|
||||
)
|
||||
vi.spyOn(
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the runtime's protected paneHoldsSession; the other pane stands in for a bound live terminal.
|
||||
runtime as unknown as { paneHoldsSession: (paneKey: string) => boolean },
|
||||
'paneHoldsSession'
|
||||
).mockImplementation((paneKey) => paneKey === otherPaneKey)
|
||||
await restore(runtime)
|
||||
expectFreshLaunch(spawn.mock.calls[0]![0].command)
|
||||
})
|
||||
|
||||
it('falls back to the tab’s agent launch when the newest row is a structured host’s', async () => {
|
||||
const { runtime, spawn } = makeRuntime([codexRow({ structuredHost: 'owned' })], 'codex')
|
||||
await restore(runtime)
|
||||
|
||||
@@ -29,60 +29,6 @@ function makeRequest(method: string, params?: unknown): RpcRequest {
|
||||
}
|
||||
|
||||
describe('ssh RPC methods', () => {
|
||||
it('returns the registered SSH target state', async () => {
|
||||
const state = {
|
||||
targetId: 'ssh-1',
|
||||
status: 'connected',
|
||||
error: null,
|
||||
reconnectAttempt: 0
|
||||
}
|
||||
getRegisteredSshStateMock.mockReturnValueOnce(state)
|
||||
const runtime = { getRuntimeId: () => 'test-runtime' } as unknown as OrcaRuntimeService
|
||||
const dispatcher = new RpcDispatcher({ runtime, methods: SSH_METHODS })
|
||||
|
||||
const response = await dispatcher.dispatch(makeRequest('ssh.getState', { targetId: 'ssh-1' }))
|
||||
|
||||
expect(getRegisteredSshStateMock).toHaveBeenCalledWith('ssh-1')
|
||||
expect(response).toMatchObject({ ok: true, result: { state } })
|
||||
})
|
||||
|
||||
it('connects through the registered desktop SSH lifecycle', async () => {
|
||||
const state = {
|
||||
targetId: 'ssh-1',
|
||||
status: 'connected',
|
||||
error: null,
|
||||
reconnectAttempt: 0
|
||||
}
|
||||
connectRegisteredSshTargetMock.mockResolvedValueOnce(state)
|
||||
const runtime = { getRuntimeId: () => 'test-runtime' } as unknown as OrcaRuntimeService
|
||||
const dispatcher = new RpcDispatcher({ runtime, methods: SSH_METHODS })
|
||||
|
||||
const response = await dispatcher.dispatch(makeRequest('ssh.connect', { targetId: 'ssh-1' }))
|
||||
|
||||
expect(connectRegisteredSshTargetMock).toHaveBeenCalledWith('ssh-1', { automatic: false })
|
||||
expect(response).toMatchObject({ ok: true, result: { state } })
|
||||
})
|
||||
|
||||
it("keeps a paired client's automatic connect from lifting a cancelled sign-in", async () => {
|
||||
connectRegisteredSshTargetMock.mockResolvedValueOnce(null)
|
||||
const runtime = { getRuntimeId: () => 'test-runtime' } as unknown as OrcaRuntimeService
|
||||
const dispatcher = new RpcDispatcher({ runtime, methods: SSH_METHODS })
|
||||
|
||||
await dispatcher.dispatch(makeRequest('ssh.connect', { targetId: 'ssh-1', automatic: true }))
|
||||
|
||||
expect(connectRegisteredSshTargetMock).toHaveBeenCalledWith('ssh-1', { automatic: true })
|
||||
})
|
||||
|
||||
it('returns null when the target has no registered state yet', async () => {
|
||||
getRegisteredSshStateMock.mockReturnValueOnce(undefined)
|
||||
const runtime = { getRuntimeId: () => 'test-runtime' } as unknown as OrcaRuntimeService
|
||||
const dispatcher = new RpcDispatcher({ runtime, methods: SSH_METHODS })
|
||||
|
||||
const response = await dispatcher.dispatch(makeRequest('ssh.getState', { targetId: 'ssh-1' }))
|
||||
|
||||
expect(response).toMatchObject({ ok: true, result: { state: null } })
|
||||
})
|
||||
|
||||
it('redacts HUB-private diagnostics from state and connect failures', async () => {
|
||||
const privateMessage = 'identity /Users/hub/.ssh/private via bastion.internal failed'
|
||||
getRegisteredSshStateMock.mockReturnValue({
|
||||
@@ -152,37 +98,6 @@ describe('ssh RPC methods', () => {
|
||||
expect(JSON.stringify(response)).not.toContain('bastion')
|
||||
})
|
||||
|
||||
it('does not invent a platform before the SSH host has been detected', async () => {
|
||||
listRegisteredSshTargetsMock.mockReturnValueOnce([{ id: 'ssh-1', label: 'Dev box' }])
|
||||
getRegisteredSshStateMock.mockReturnValueOnce(undefined)
|
||||
const runtime = { getRuntimeId: () => 'test-runtime' } as unknown as OrcaRuntimeService
|
||||
const dispatcher = new RpcDispatcher({ runtime, methods: SSH_METHODS })
|
||||
|
||||
const response = await dispatcher.dispatch(makeRequest('ssh.listTargetSummaries'))
|
||||
|
||||
expect(response).toMatchObject({
|
||||
ok: true,
|
||||
result: { targets: [{ id: 'ssh-1', label: 'Dev box' }] }
|
||||
})
|
||||
expect(JSON.stringify(response)).not.toContain('remotePlatform')
|
||||
})
|
||||
|
||||
it('reports disconnected lifecycle states without calling them connected', async () => {
|
||||
listRegisteredSshTargetsMock.mockReturnValueOnce([{ id: 'ssh-1', label: 'Dev box' }])
|
||||
getRegisteredSshStateMock.mockReturnValueOnce({ status: 'reconnecting' })
|
||||
const runtime = { getRuntimeId: () => 'test-runtime' } as unknown as OrcaRuntimeService
|
||||
const dispatcher = new RpcDispatcher({ runtime, methods: SSH_METHODS })
|
||||
|
||||
const response = await dispatcher.dispatch(makeRequest('ssh.listTargetSummaries'))
|
||||
|
||||
expect(response).toMatchObject({
|
||||
ok: true,
|
||||
result: {
|
||||
targets: [{ id: 'ssh-1', connected: false, connectionStatus: 'reconnecting' }]
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it('redacts the legacy target response for older clients', async () => {
|
||||
const targets = [
|
||||
{
|
||||
@@ -209,15 +124,4 @@ describe('ssh RPC methods', () => {
|
||||
expect(JSON.stringify(response)).not.toContain('/secret/key')
|
||||
expect(JSON.stringify(response)).not.toContain('bastion')
|
||||
})
|
||||
|
||||
it('lists removed-target labels for ghost-host display on paired clients', async () => {
|
||||
const labels = { 'ssh-old': 'Dev box' }
|
||||
listRegisteredRemovedSshTargetLabelsMock.mockReturnValueOnce(labels)
|
||||
const runtime = { getRuntimeId: () => 'test-runtime' } as unknown as OrcaRuntimeService
|
||||
const dispatcher = new RpcDispatcher({ runtime, methods: SSH_METHODS })
|
||||
|
||||
const response = await dispatcher.dispatch(makeRequest('ssh.listRemovedTargetLabels'))
|
||||
|
||||
expect(response).toMatchObject({ ok: true, result: { labels } })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,213 +0,0 @@
|
||||
import './unused-default-rpc-methods.test-fixture'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { RpcDispatcher } from './dispatcher'
|
||||
import type { RpcRequest } from './core'
|
||||
import type { OrcaRuntimeService } from '../orca-runtime'
|
||||
import { TERMINAL_METHODS } from './methods/terminal'
|
||||
|
||||
// Geometry RPCs must use resolveLiveLeafForHandle: an unguarded stale handle resolves the replacement PTY and corrupts its display (#7718).
|
||||
|
||||
// Model stale resolution: unguarded lookup returns replacement pty-b; guarded lookup refuses.
|
||||
const NEW_PTY_UNDER_PANE = 'pty-b'
|
||||
|
||||
function stubStaleHandleRuntime(overrides: Partial<OrcaRuntimeService> = {}): OrcaRuntimeService {
|
||||
return {
|
||||
getRuntimeId: () => 'test-runtime',
|
||||
resolveLeafForHandle: vi.fn().mockReturnValue({ ptyId: NEW_PTY_UNDER_PANE }),
|
||||
resolveLiveLeafForHandle: vi.fn(() => {
|
||||
throw new Error('terminal_handle_stale')
|
||||
}),
|
||||
...overrides
|
||||
} as unknown as OrcaRuntimeService
|
||||
}
|
||||
|
||||
function makeRequest(method: string, params?: unknown): RpcRequest {
|
||||
return { id: 'req-1', authToken: 'tok', method, params }
|
||||
}
|
||||
|
||||
async function expectStale(method: string, params: unknown, mutators: string[]): Promise<void> {
|
||||
const spies: Record<string, ReturnType<typeof vi.fn>> = {}
|
||||
for (const name of mutators) {
|
||||
spies[name] = vi.fn()
|
||||
}
|
||||
const runtime = stubStaleHandleRuntime(spies as Partial<OrcaRuntimeService>)
|
||||
const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS })
|
||||
|
||||
const response = await dispatcher.dispatch(makeRequest(method, params))
|
||||
|
||||
expect(response.ok).toBe(false)
|
||||
if (response.ok) {
|
||||
throw new Error(`expected ${method} to reject a stale handle`)
|
||||
}
|
||||
expect(response.error.message).toContain('terminal_handle_stale')
|
||||
for (const name of mutators) {
|
||||
expect(spies[name]).not.toHaveBeenCalled()
|
||||
}
|
||||
}
|
||||
|
||||
describe('terminal geometry family rejects stale handles instead of mutating the wrong PTY', () => {
|
||||
it('terminal.resizeForClient fails with terminal_handle_stale', async () => {
|
||||
await expectStale(
|
||||
'terminal.resizeForClient',
|
||||
{ terminal: 'stale-terminal', mode: 'restore', clientId: 'client-1' },
|
||||
['resizeForClient']
|
||||
)
|
||||
})
|
||||
|
||||
it('terminal.setDisplayMode fails with terminal_handle_stale', async () => {
|
||||
await expectStale(
|
||||
'terminal.setDisplayMode',
|
||||
{
|
||||
terminal: 'stale-terminal',
|
||||
mode: 'auto',
|
||||
client: { id: 'client-1', type: 'mobile' },
|
||||
viewport: { cols: 80, rows: 24 }
|
||||
},
|
||||
[
|
||||
'setMobileDisplayMode',
|
||||
'applyMobileDisplayMode',
|
||||
'updateMobileSubscriberViewport',
|
||||
'markMobileActor'
|
||||
]
|
||||
)
|
||||
})
|
||||
|
||||
it('terminal.restoreFit fails with terminal_handle_stale', async () => {
|
||||
await expectStale('terminal.restoreFit', { terminal: 'stale-terminal' }, [
|
||||
'reclaimTerminalForDesktop'
|
||||
])
|
||||
})
|
||||
|
||||
it('terminal.updateViewport fails with terminal_handle_stale', async () => {
|
||||
await expectStale(
|
||||
'terminal.updateViewport',
|
||||
{
|
||||
terminal: 'stale-terminal',
|
||||
client: { id: 'client-1', type: 'mobile' },
|
||||
viewport: { cols: 80, rows: 24 }
|
||||
},
|
||||
['updateMobileViewport', 'updateDesktopViewport']
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('terminal geometry family still mutates the live PTY for a fresh handle', () => {
|
||||
it('terminal.restoreFit reclaims the resolved PTY when the handle is live', async () => {
|
||||
const reclaimTerminalForDesktop = vi.fn().mockResolvedValue(true)
|
||||
const runtime = {
|
||||
getRuntimeId: () => 'test-runtime',
|
||||
resolveLiveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-a' }),
|
||||
reclaimTerminalForDesktop
|
||||
} as unknown as OrcaRuntimeService
|
||||
const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS })
|
||||
|
||||
const response = await dispatcher.dispatch(
|
||||
makeRequest('terminal.restoreFit', { terminal: 'live-terminal' })
|
||||
)
|
||||
|
||||
expect(response.ok).toBe(true)
|
||||
if (!response.ok) {
|
||||
throw new Error(response.error.message)
|
||||
}
|
||||
expect(response.result).toEqual({ restored: true })
|
||||
expect(reclaimTerminalForDesktop).toHaveBeenCalledWith('pty-a')
|
||||
})
|
||||
|
||||
it('terminal.resizeForClient resizes the resolved PTY when the handle is live', async () => {
|
||||
const resizeForClient = vi.fn().mockResolvedValue({ cols: 80, rows: 24 })
|
||||
const runtime = {
|
||||
getRuntimeId: () => 'test-runtime',
|
||||
resolveLiveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-a' }),
|
||||
resizeForClient
|
||||
} as unknown as OrcaRuntimeService
|
||||
const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS })
|
||||
|
||||
const response = await dispatcher.dispatch(
|
||||
makeRequest('terminal.resizeForClient', {
|
||||
terminal: 'live-terminal',
|
||||
mode: 'restore',
|
||||
clientId: 'client-1'
|
||||
})
|
||||
)
|
||||
|
||||
expect(response.ok).toBe(true)
|
||||
if (!response.ok) {
|
||||
throw new Error(response.error.message)
|
||||
}
|
||||
expect(resizeForClient).toHaveBeenCalledWith(
|
||||
'pty-a',
|
||||
'restore',
|
||||
'client-1',
|
||||
undefined,
|
||||
undefined
|
||||
)
|
||||
})
|
||||
|
||||
it('terminal.setDisplayMode mutates the resolved PTY when the handle is live', async () => {
|
||||
const setMobileDisplayMode = vi.fn()
|
||||
const applyMobileDisplayMode = vi.fn().mockResolvedValue(undefined)
|
||||
const updateMobileSubscriberViewport = vi.fn()
|
||||
const markMobileActor = vi.fn()
|
||||
const runtime = {
|
||||
getRuntimeId: () => 'test-runtime',
|
||||
resolveLiveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-a' }),
|
||||
setMobileDisplayMode,
|
||||
applyMobileDisplayMode,
|
||||
updateMobileSubscriberViewport,
|
||||
markMobileActor,
|
||||
getLayout: vi.fn().mockReturnValue({ seq: 42 })
|
||||
} as unknown as OrcaRuntimeService
|
||||
const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS })
|
||||
|
||||
const response = await dispatcher.dispatch(
|
||||
makeRequest('terminal.setDisplayMode', {
|
||||
terminal: 'live-terminal',
|
||||
mode: 'auto',
|
||||
client: { id: 'client-1', type: 'mobile' },
|
||||
viewport: { cols: 80, rows: 24 }
|
||||
})
|
||||
)
|
||||
|
||||
expect(response.ok).toBe(true)
|
||||
if (!response.ok) {
|
||||
throw new Error(response.error.message)
|
||||
}
|
||||
expect(updateMobileSubscriberViewport).toHaveBeenCalledWith('pty-a', 'client-1', {
|
||||
cols: 80,
|
||||
rows: 24
|
||||
})
|
||||
expect(markMobileActor).toHaveBeenCalledWith('pty-a', 'client-1')
|
||||
expect(setMobileDisplayMode).toHaveBeenCalledWith('pty-a', 'auto')
|
||||
expect(applyMobileDisplayMode).toHaveBeenCalledWith('pty-a')
|
||||
expect(response.result).toEqual({ mode: 'auto', seq: 42 })
|
||||
})
|
||||
|
||||
it('terminal.updateViewport updates the resolved PTY when the handle is live', async () => {
|
||||
const updateMobileViewport = vi.fn().mockResolvedValue({ updated: true, applied: true })
|
||||
const runtime = {
|
||||
getRuntimeId: () => 'test-runtime',
|
||||
resolveLiveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-a' }),
|
||||
updateMobileViewport,
|
||||
getLayout: vi.fn().mockReturnValue({ seq: 7 })
|
||||
} as unknown as OrcaRuntimeService
|
||||
const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS })
|
||||
|
||||
const response = await dispatcher.dispatch(
|
||||
makeRequest('terminal.updateViewport', {
|
||||
terminal: 'live-terminal',
|
||||
client: { id: 'client-1', type: 'mobile' },
|
||||
viewport: { cols: 80, rows: 24 }
|
||||
})
|
||||
)
|
||||
|
||||
expect(response.ok).toBe(true)
|
||||
if (!response.ok) {
|
||||
throw new Error(response.error.message)
|
||||
}
|
||||
expect(updateMobileViewport).toHaveBeenCalledWith('pty-a', 'client-1', {
|
||||
cols: 80,
|
||||
rows: 24
|
||||
})
|
||||
expect(response.result).toEqual({ updated: true, applied: true, seq: 7 })
|
||||
})
|
||||
})
|
||||
@@ -1,20 +1,17 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
agentSessionAttentionKey,
|
||||
type AgentSessionAttentionEdge,
|
||||
type StructuredAttentionState
|
||||
import type {
|
||||
AgentSessionAttentionEdge,
|
||||
StructuredAttentionState
|
||||
} from '../../shared/agent-session-attention'
|
||||
|
||||
import type { AgentSessionStatusSummary } from '../../shared/agent-session-wire'
|
||||
import type { NotificationSettings } from '../../shared/notification-settings-types'
|
||||
import {
|
||||
structuredAgentSessionPaneKey,
|
||||
structuredAgentSessionTabId
|
||||
} from '../../shared/structured-agent-session-projection'
|
||||
|
||||
import {
|
||||
createStructuredAttentionMobileDelivery,
|
||||
readStructuredAttentionWorkspaceLabels,
|
||||
type StructuredAttentionMobileDeliveryDeps
|
||||
} from './structured-agent-session-mobile-attention'
|
||||
|
||||
import type { MobileNotificationDispatchEvent } from './runtime-mobile-notification-controller'
|
||||
|
||||
const SCOPE = {
|
||||
@@ -87,41 +84,6 @@ function harness(overrides: Partial<StructuredAttentionMobileDeliveryDeps> = {})
|
||||
}
|
||||
|
||||
describe('structured attention mobile delivery', () => {
|
||||
it('pushes a raised prompt at once as "needs input", keyed by the prompt', () => {
|
||||
const h = harness()
|
||||
h.delivery.deliver(PROMPT, SUMMARY)
|
||||
const key = agentSessionAttentionKey(PROMPT)
|
||||
expect(h.sent).toEqual([
|
||||
{
|
||||
type: 'notification',
|
||||
emittedAt: 42,
|
||||
source: 'agent-task-complete',
|
||||
title: 'orca / feature - Claude needs input',
|
||||
body: 'May I run the migration?',
|
||||
worktreeId: SCOPE.workspaceId,
|
||||
paneKey: structuredAgentSessionPaneKey(
|
||||
structuredAgentSessionTabId('session-1'),
|
||||
'session-1'
|
||||
),
|
||||
notificationId: key,
|
||||
attentionKey: key,
|
||||
agentState: 'blocked'
|
||||
}
|
||||
])
|
||||
})
|
||||
|
||||
it('words a settled turn by its outcome, and a failure as a failure even while a prompt waits', () => {
|
||||
const h = harness()
|
||||
h.delivery.deliver(completion('success'), SUMMARY)
|
||||
h.delivery.deliver(completion('success', true), SUMMARY)
|
||||
h.delivery.deliver(completion('failure', true), SUMMARY)
|
||||
expect(h.sent.map((event) => [event.agentState, event.title])).toEqual([
|
||||
['done', 'orca / feature - Claude finished'],
|
||||
['blocked', 'orca / feature - Claude needs input'],
|
||||
['done', 'orca / feature - Claude failed']
|
||||
])
|
||||
})
|
||||
|
||||
it('sends nothing for a workspace whose notifications are muted', () => {
|
||||
const isWorkspaceMuted = vi.fn(() => true)
|
||||
const quiet = harness({ isWorkspaceMuted })
|
||||
@@ -160,49 +122,4 @@ describe('structured attention mobile delivery', () => {
|
||||
h.delivery.deliver(edge, SUMMARY)
|
||||
expect(h.sent).toEqual([])
|
||||
})
|
||||
|
||||
it('forwards current prompt state for reconciliation', () => {
|
||||
const h = harness()
|
||||
const state = { scope: SCOPE, sessionId: 'session-1', pendingPromptIds: ['a', 'b'] }
|
||||
h.delivery.reconcile(state)
|
||||
expect(h.reconciled).toEqual([state])
|
||||
})
|
||||
})
|
||||
|
||||
describe('readStructuredAttentionWorkspaceLabels', () => {
|
||||
const store = {
|
||||
getRepo: vi.fn((id: string) => (id === 'repo-1' ? { displayName: 'orca' } : undefined)),
|
||||
getWorktreeMeta: vi.fn((id: string) =>
|
||||
id === SCOPE.workspaceId ? { displayName: 'feature' } : undefined
|
||||
),
|
||||
getFolderWorkspaces: vi.fn(() => [{ id: 'folder-1', name: 'Notes' }])
|
||||
}
|
||||
|
||||
it('reads a worktree and its repo from persisted metadata', () => {
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: only the read fields are stubbed.
|
||||
const labels = readStructuredAttentionWorkspaceLabels(store as never, SCOPE)
|
||||
expect(labels).toEqual({ repoLabel: 'orca', worktreeLabel: 'feature' })
|
||||
})
|
||||
|
||||
it('reads a folder workspace from the folder key a chat scope carries', () => {
|
||||
const labels = readStructuredAttentionWorkspaceLabels(
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: only the read fields are stubbed.
|
||||
store as never,
|
||||
{ ...SCOPE, workspaceId: 'folder:folder-1', workspaceKind: 'folder' }
|
||||
)
|
||||
expect(labels).toEqual({ worktreeLabel: 'Notes' })
|
||||
})
|
||||
})
|
||||
|
||||
it('preserves the missing notification settings failure before dispatch', () => {
|
||||
let reads = 0
|
||||
const h = harness({
|
||||
readNotificationSettings() {
|
||||
reads += 1
|
||||
return undefined
|
||||
}
|
||||
})
|
||||
expect(() => h.delivery.deliver(PROMPT, SUMMARY)).toThrow(TypeError)
|
||||
expect(reads).toBe(1)
|
||||
expect(h.sent).toEqual([])
|
||||
})
|
||||
|
||||
@@ -122,12 +122,6 @@ describe('reading a structured worker through the terminal-read path', () => {
|
||||
expect(refusal).not.toContain('worker-read')
|
||||
})
|
||||
|
||||
it('reports dropped history as truncated rather than pretending the page is whole', async () => {
|
||||
const handle = registerWorker()
|
||||
installHost({ items: [message('i1', 'tail only')], hasOlder: true })
|
||||
expect((await readStructuredWorkerTerminal({ handle, db: null }))?.truncated).toBe(true)
|
||||
})
|
||||
|
||||
it('redacts dispatch capability tokens the same way the archive path does', async () => {
|
||||
const handle = registerWorker()
|
||||
const token = `dcap_${'a'.repeat(32)}`
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
renderServeReadiness,
|
||||
ServeReadinessPublisher,
|
||||
type ServeReadiness
|
||||
} from './serve-readiness'
|
||||
import type { OrcadHealth } from '../orcad/orcad-health'
|
||||
|
||||
const ready: ServeReadiness = {
|
||||
runtimeId: 'runtime-1',
|
||||
@@ -22,81 +22,7 @@ const ready: ServeReadiness = {
|
||||
}
|
||||
}
|
||||
|
||||
const health: OrcadHealth = {
|
||||
buildHash: 'abc123def4567890',
|
||||
buildVersion: '1.4.0',
|
||||
nodeVersion: '20.11.0',
|
||||
nodeAbi: '115',
|
||||
platform: 'linux',
|
||||
arch: 'x64',
|
||||
pid: 1234,
|
||||
terminalDaemon: {
|
||||
state: 'live',
|
||||
ownsFreshSessions: true,
|
||||
pid: 4242,
|
||||
buildVersion: '1.4.0',
|
||||
entryPath: '/opt/orcad/daemon-entry.js',
|
||||
protocolVersion: 36,
|
||||
cgroupUnit: null,
|
||||
selfTest: { ok: true, coverage: 'pty-spawn', verdict: 'healthy', durationMs: 12 }
|
||||
}
|
||||
}
|
||||
|
||||
describe('ServeReadinessPublisher', () => {
|
||||
it('writes one complete human-readable ready block', async () => {
|
||||
const write = vi.fn(async () => {})
|
||||
const publisher = new ServeReadinessPublisher(write)
|
||||
|
||||
await publisher.publish(ready, { mode: 'human' })
|
||||
|
||||
expect(write).toHaveBeenCalledOnce()
|
||||
expect(write).toHaveBeenCalledWith(
|
||||
expect.stringContaining(
|
||||
'Orca server ready\nBound endpoint: ws://0.0.0.0:6768\nAdvertised endpoint: wss://orca.example.test/runtime'
|
||||
)
|
||||
)
|
||||
expect(write).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Pairing URL: orca://pair?code=secret\n')
|
||||
)
|
||||
})
|
||||
|
||||
it('publishes a versioned JSON contract with explicit endpoints and pairing availability', () => {
|
||||
expect(JSON.parse(renderServeReadiness(ready, { mode: 'json' }))).toEqual({
|
||||
type: 'orca_server_ready',
|
||||
schemaVersion: 1,
|
||||
runtimeId: 'runtime-1',
|
||||
endpoint: 'ws://0.0.0.0:6768',
|
||||
boundEndpoint: 'ws://0.0.0.0:6768',
|
||||
advertisedEndpoint: 'wss://orca.example.test/runtime',
|
||||
managedWslCliReconciliation: 'settled',
|
||||
pairing: ready.pairing
|
||||
})
|
||||
})
|
||||
|
||||
it('reports unavailable pairing as an explicit machine-readable object', () => {
|
||||
const unavailable: ServeReadiness = {
|
||||
...ready,
|
||||
advertisedEndpoint: null,
|
||||
pairing: {
|
||||
available: false,
|
||||
reason: 'invalid_advertised_endpoint',
|
||||
guidance: 'Use a reachable address.'
|
||||
}
|
||||
}
|
||||
|
||||
const json = JSON.parse(renderServeReadiness(unavailable, { mode: 'json' }))
|
||||
expect(json.pairing).toEqual(unavailable.pairing)
|
||||
expect(renderServeReadiness(unavailable, { mode: 'human' })).toContain(
|
||||
'Pairing unavailable: invalid_advertised_endpoint\nPairing guidance: Use a reachable address.'
|
||||
)
|
||||
})
|
||||
|
||||
it('preserves the recipe JSON contract', () => {
|
||||
expect(renderServeReadiness(ready, { mode: 'recipe-json', projectRoot: '/workspace' })).toBe(
|
||||
'{"schemaVersion":1,"pairingCode":"orca://pair?code=secret","projectRoot":"/workspace"}'
|
||||
)
|
||||
})
|
||||
|
||||
it('fails recipe output with the unavailable reason and guidance', () => {
|
||||
const unavailable: ServeReadiness = {
|
||||
...ready,
|
||||
@@ -111,49 +37,6 @@ describe('ServeReadinessPublisher', () => {
|
||||
).toThrow('websocket_unavailable. Choose an unused --port.')
|
||||
})
|
||||
|
||||
it('omits the health block entirely when a host does not report one', () => {
|
||||
const payload = JSON.parse(renderServeReadiness(ready, { mode: 'json' }))
|
||||
// Absent health means unreported; it must not imply a green verdict.
|
||||
expect('health' in payload).toBe(false)
|
||||
expect(renderServeReadiness(ready, { mode: 'human' })).not.toContain('Terminal daemon')
|
||||
})
|
||||
|
||||
it('carries build identity, Node ABI and the daemon self-test in the JSON contract', () => {
|
||||
const payload = JSON.parse(renderServeReadiness({ ...ready, health }, { mode: 'json' }))
|
||||
expect(payload.health.buildHash).toBe('abc123def4567890')
|
||||
expect(payload.health.nodeAbi).toBe('115')
|
||||
expect(payload.health.terminalDaemon.selfTest).toEqual({
|
||||
ok: true,
|
||||
coverage: 'pty-spawn',
|
||||
verdict: 'healthy',
|
||||
durationMs: 12
|
||||
})
|
||||
})
|
||||
|
||||
it('says out loud when the daemon self-test failed', () => {
|
||||
const failed: ServeReadiness = {
|
||||
...ready,
|
||||
health: {
|
||||
...health,
|
||||
terminalDaemon: {
|
||||
...health.terminalDaemon,
|
||||
state: 'degraded',
|
||||
ownsFreshSessions: false,
|
||||
selfTest: {
|
||||
ok: false,
|
||||
coverage: 'pty-spawn',
|
||||
verdict: 'pty-spawn-unhealthy',
|
||||
durationMs: 3_000
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
const human = renderServeReadiness(failed, { mode: 'human' })
|
||||
// Make failure explicit to operators rather than requiring inference from missing output.
|
||||
expect(human).toContain('PTY self-test FAILED')
|
||||
expect(human).toContain('terminals survive an orcad restart: NO')
|
||||
})
|
||||
|
||||
it('rejects concurrent and later duplicate publications', async () => {
|
||||
let finishWrite: (() => void) | undefined
|
||||
const publisher = new ServeReadinessPublisher(
|
||||
|
||||
@@ -106,16 +106,6 @@ describe('executeSkillInstallRequest', () => {
|
||||
).toEqual([])
|
||||
})
|
||||
|
||||
it('rejects local paths at the remote request boundary', async () => {
|
||||
const { request, dependencies, archive } = await fixture()
|
||||
await expect(
|
||||
executeSkillInstallRequest(
|
||||
{ ...request, ingress: { kind: 'local-file', path: archive.archivePath } },
|
||||
dependencies
|
||||
)
|
||||
).rejects.toThrow('skill-install-local-ingress-rejected')
|
||||
})
|
||||
|
||||
it('rejects invalid shape, identity, scope, destination, and ingress policy before download', async () => {
|
||||
const { request, dependencies, archive, home } = await fixture()
|
||||
const cases: { input: unknown; error: string }[] = [
|
||||
|
||||
@@ -35,12 +35,9 @@ import { writeAtomicOrcadRemoteRecord } from '../managed-server/install/orcad-re
|
||||
import { deployOrcad } from './orcad-remote-deploy'
|
||||
import { getRemoteHostPlatform } from './ssh-remote-platform'
|
||||
import { ORCAD_SNAPSHOT_SPACE_INSUFFICIENT_CODE } from './orcad-snapshot-room'
|
||||
import { isSnapshotCaptureCommand } from './orcad-snapshot-capture-command'
|
||||
|
||||
import { OrcadFenceLostError } from '../managed-server/activation/orcad-activation-fence-scope'
|
||||
import {
|
||||
emptyOrcadActivationRecord,
|
||||
withActivatedVersion
|
||||
} from '../managed-server/activation/orcad-activation-record'
|
||||
|
||||
import {
|
||||
FakeOrcadHost,
|
||||
NEW,
|
||||
@@ -114,44 +111,6 @@ describe('an update on a host without room for its snapshot (#30491)', () => {
|
||||
expect(host.fence).toBe(false)
|
||||
})
|
||||
|
||||
it('counts free space and reclaimable snapshots apart in the refusal', async () => {
|
||||
answerRoom(400_000, 300_000, 1_000)
|
||||
|
||||
expect(JSON.stringify(await deploy())).toMatch(
|
||||
/has 1000 KB free \(plus 293\.0 MB Orca would reclaim from earlier snapshots\)/u
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps the snapshot the activation record names through both the room check and the prune', async () => {
|
||||
const recorded = 'pre-0.1.0+aa01-500'
|
||||
host.record = JSON.stringify(
|
||||
withActivatedVersion(
|
||||
emptyOrcadActivationRecord(),
|
||||
OLD,
|
||||
{
|
||||
dirName: recorded,
|
||||
takenBeforeVersion: OLD,
|
||||
readableByVersion: null,
|
||||
takenAt: new Date(500).toISOString()
|
||||
},
|
||||
new Date(0)
|
||||
)
|
||||
)
|
||||
answerRoom(400_000, 0, 10_000_000)
|
||||
|
||||
expect(await deploy()).toMatchObject({ outcome: 'installed-and-activated' })
|
||||
const commands = vi.mocked(execCommand).mock.calls.map(([, command]) => command)
|
||||
expect(commands.find((command) => command.includes('SNAPSHOT_SPACE'))).toContain(
|
||||
`('${recorded}')`
|
||||
)
|
||||
expect(commands.find(isSnapshotCaptureCommand)).toMatch(
|
||||
new RegExp(
|
||||
`\\('pre-${NEW.replace('+', '\\+')}-\\d+'\\|'${recorded.replace('+', '\\+')}'\\)`,
|
||||
'u'
|
||||
)
|
||||
)
|
||||
})
|
||||
|
||||
it('measures room before the last terminal recount, so the scan cannot outdate it', async () => {
|
||||
answerRoom(400_000, 0, 10_000_000)
|
||||
const probedBeforeRecount: boolean[] = []
|
||||
|
||||
@@ -10,8 +10,6 @@ import { unservedHostServerMessage, unservedServerStatus } from './ssh-host-unse
|
||||
import { admitSshManagedServerStatus } from '../../shared/ssh-managed-server-status-admission'
|
||||
|
||||
const target: SshTarget = { id: 'ssh-1', label: 'Box', host: 'box', port: 22, username: 'me' }
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the orchestrator reads only the environment id.
|
||||
const environment = { id: 'env-1' } as never
|
||||
|
||||
function fencedHost(phase: 'source-fenced' | 'destination-staged', registered: boolean) {
|
||||
const host: { phase: string | null; registered: boolean } = { phase, registered }
|
||||
@@ -53,36 +51,6 @@ describe('a move to the managed server that stops short', () => {
|
||||
warn.mockRestore()
|
||||
})
|
||||
|
||||
it('resumes the same move on the next connect once the server answers', async () => {
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
|
||||
const { host, deps } = fencedHost('destination-staged', true)
|
||||
const failing = deps({
|
||||
convert: vi.fn(async () => {
|
||||
throw new Error('socket closed')
|
||||
}),
|
||||
abandonConversion: vi.fn(async () => {
|
||||
throw new Error('socket closed')
|
||||
})
|
||||
})
|
||||
await expect(resolveHostServerOnConnect(target, failing)).resolves.toMatchObject({
|
||||
held: true
|
||||
})
|
||||
const convert = vi.fn(async () => {
|
||||
host.phase = 'destination-committed'
|
||||
return { outcome: 'converted' as const, environment, migrationId: 'm' }
|
||||
})
|
||||
const next = deps({ convert })
|
||||
await expect(resolveHostServerOnConnect(target, next)).resolves.toEqual({
|
||||
route: 'managed',
|
||||
environmentId: 'env-1'
|
||||
})
|
||||
// Resumed through the registered server, without a fresh census or a second fence.
|
||||
expect(next.ensureTunnel).toHaveBeenCalledWith('env-1')
|
||||
expect(convert).toHaveBeenCalledWith(target, null)
|
||||
expect(next.relayTerminals).not.toHaveBeenCalled()
|
||||
warn.mockRestore()
|
||||
})
|
||||
|
||||
it('gives the host back, with no lock to report, when the server proves it holds nothing', async () => {
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
|
||||
const { host, deps } = fencedHost('destination-staged', true)
|
||||
@@ -138,39 +106,6 @@ describe('a move to the managed server that stops short', () => {
|
||||
warn.mockRestore()
|
||||
})
|
||||
|
||||
it('finishes, rather than reports a lock, when the abort finds the move already committed', async () => {
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
|
||||
const { host, deps } = fencedHost('destination-staged', true)
|
||||
const lost = deps({
|
||||
convert: vi.fn(async () => {
|
||||
throw new Error('orcad_migration_commit_reply_lost')
|
||||
}),
|
||||
// The abort's re-read found the commit landed and recorded it.
|
||||
abandonConversion: vi.fn(async () => {
|
||||
host.phase = 'destination-committed'
|
||||
})
|
||||
})
|
||||
await expect(resolveHostServerOnConnect(target, lost)).resolves.not.toHaveProperty('held')
|
||||
const next = deps()
|
||||
await expect(resolveHostServerOnConnect(target, next)).resolves.toEqual({
|
||||
route: 'managed',
|
||||
environmentId: 'env-1'
|
||||
})
|
||||
expect(next.convert).not.toHaveBeenCalled()
|
||||
expect(next.retainCommittedSource).toHaveBeenCalledWith(target)
|
||||
warn.mockRestore()
|
||||
})
|
||||
|
||||
it('keeps a refusal’s own guidance even while the host stays locked', () => {
|
||||
const decision = {
|
||||
route: 'unserved' as const,
|
||||
reason: 'refused' as const,
|
||||
detail: 'remove that project from this desktop and connect again',
|
||||
held: true as const
|
||||
}
|
||||
expect(unservedHostServerMessage(decision)).toBe(decision.detail)
|
||||
})
|
||||
|
||||
it('carries the lock to the renderer, and drops anything but a literal true', () => {
|
||||
expect(admitSshManagedServerStatus({ kind: 'relay', reason: 'failed', held: true })).toEqual({
|
||||
managedServer: { kind: 'relay', reason: 'failed', held: true }
|
||||
|
||||
@@ -168,34 +168,10 @@ describe('clipboard image paste for a runtime-owned SSH workspace', () => {
|
||||
expect(fsWriteFileMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps a runtime-host paste (no SSH target) addressed to the runtime itself', async () => {
|
||||
mockRuntimeUpload()
|
||||
|
||||
await expect(
|
||||
saveImageHandler()(rendererEvent, { runtimeEnvironmentId: RUNTIME_ID })
|
||||
).resolves.toBe('/tmp/on-runtime-target.png')
|
||||
|
||||
expect(runtimeCall('clipboard.startImageUpload')?.[3]).toEqual({
|
||||
expectedBase64Length: PNG.toString('base64').length,
|
||||
connectionId: null
|
||||
})
|
||||
})
|
||||
|
||||
it('reports the dropped-connection verdict for a client-dialed SSH target', async () => {
|
||||
await expect(
|
||||
saveImageHandler()(rendererEvent, { connectionId: CLIENT_SSH_TARGET })
|
||||
).rejects.toThrow(SSH_HOST_NOT_SERVED_MESSAGE)
|
||||
expect(callRuntimeEnvironmentMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps a plain local paste on the local temp dir', async () => {
|
||||
fsWriteFileMock.mockResolvedValue(undefined)
|
||||
|
||||
await expect(saveImageHandler()(rendererEvent, undefined)).resolves.toMatch(
|
||||
/orca-paste-.*\.png$/
|
||||
)
|
||||
|
||||
expect(fsWriteFileMock).toHaveBeenCalledTimes(1)
|
||||
expect(callRuntimeEnvironmentMock).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -42,35 +42,6 @@ describe('retirement discovery on WSL', () => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('admits every WSL UNC listing through the bounded filesystem gate', async () => {
|
||||
await discoverRetiredWorktreeNames({
|
||||
workspaceRoots: [DISTRO_ROOT],
|
||||
home: '/nonexistent-home',
|
||||
env: {},
|
||||
resolveWslHome: async () => '\\\\wsl.localhost\\Ubuntu\\home\\ada'
|
||||
})
|
||||
|
||||
// Both UNC listings and absence-proving ancestor probes must share scan admission.
|
||||
const calls = runWslTranscriptFsTaskMock.mock.calls.map(([options]) => options)
|
||||
expect(calls.every((options) => options.priority === 'scan')).toBe(true)
|
||||
expect(calls.map((options) => options.path)).toEqual(
|
||||
expect.arrayContaining([
|
||||
DISTRO_ROOT,
|
||||
join('\\\\wsl.localhost\\Ubuntu\\home\\ada', '.claude', 'projects')
|
||||
])
|
||||
)
|
||||
})
|
||||
|
||||
it('leaves no listing ungated when the workspace root is on the Windows side', async () => {
|
||||
await discoverRetiredWorktreeNames({
|
||||
workspaceRoots: ['C:\\Users\\ada\\orca\\workspaces'],
|
||||
home: '/nonexistent-home',
|
||||
env: {}
|
||||
})
|
||||
|
||||
expect(runWslTranscriptFsTaskMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('reports a gate refusal as incomplete but still returns what the host side could read', async () => {
|
||||
runWslTranscriptFsTaskMock.mockRejectedValue(
|
||||
new WslTranscriptFsError('timeout', 'filesystem access is taking too long')
|
||||
@@ -140,17 +111,6 @@ describe('retirement discovery on WSL', () => {
|
||||
expect(retired.complete).toBe(false)
|
||||
})
|
||||
|
||||
it('stays retryable when the distro home cannot be resolved, rather than memoizing the hole', async () => {
|
||||
const retired = await discoverRetiredWorktreeNames({
|
||||
workspaceRoots: [DISTRO_ROOT],
|
||||
home: '/nonexistent-home',
|
||||
env: {},
|
||||
resolveWslHome: async () => null
|
||||
})
|
||||
|
||||
expect(retired.complete).toBe(false)
|
||||
})
|
||||
|
||||
it('keeps a deleted WSL workspace name spent, so the next create cannot reuse its cwd', async () => {
|
||||
// The distro bucket outlives the deleted workspace and must keep its name retired.
|
||||
const distroHome = await mkdtemp(join(tmpdir(), 'orca-wsl-distro-home-'))
|
||||
|
||||
@@ -101,60 +101,6 @@ describe('stopWslHostServer', () => {
|
||||
warn.mockRestore()
|
||||
})
|
||||
|
||||
it('names each terminal that refused, and still asks the server to stop', async () => {
|
||||
const { all } = steps({
|
||||
endTerminals: vi.fn(async () => [
|
||||
{ handle: 'term_1', worktreeId: 'gone::/home/me/old', reason: 'selector_not_found' }
|
||||
]),
|
||||
census: vi.fn(async () => census(1)),
|
||||
decommission: vi.fn(async (): Promise<OrcadDecommissionResult> => ({
|
||||
outcome: 'refused',
|
||||
verdict: 'live',
|
||||
code: 'orcad_decommission_terminals_running',
|
||||
reason: '1 terminal is still running on this host.'
|
||||
}))
|
||||
})
|
||||
const result = await stopWslHostServer(all)
|
||||
expect(all.decommission).toHaveBeenCalled()
|
||||
expect(result).toEqual({
|
||||
stopped: false,
|
||||
reason:
|
||||
'Terminals that refused to close: term_1 (gone::/home/me/old): selector_not_found 1 terminal session kept running. Orca’s server kept running: 1 terminal is still running on this host.',
|
||||
verdict: 'live'
|
||||
})
|
||||
})
|
||||
|
||||
it('never reports stopped when the decommission could not prove it stopped the serving orcad', async () => {
|
||||
const { all } = steps({
|
||||
decommission: vi.fn(async (): Promise<OrcadDecommissionResult> => ({
|
||||
outcome: 'refused',
|
||||
verdict: 'unverifiable',
|
||||
code: 'orcad_wsl_host_serving_instance_unproven',
|
||||
reason: 'the orcad this computer was connected to is not the one the distro records'
|
||||
}))
|
||||
})
|
||||
await expect(stopWslHostServer(all)).resolves.toMatchObject({
|
||||
stopped: false,
|
||||
verdict: 'unverifiable'
|
||||
})
|
||||
})
|
||||
|
||||
it('calls a census that did not answer unverifiable, not live', async () => {
|
||||
const { all } = steps({
|
||||
census: vi.fn(async () => census(null)),
|
||||
decommission: vi.fn(async (): Promise<OrcadDecommissionResult> => ({
|
||||
outcome: 'refused',
|
||||
verdict: 'unverifiable',
|
||||
code: 'orcad_decommission_census_unavailable',
|
||||
reason: 'The terminal daemon did not answer a session count.'
|
||||
}))
|
||||
})
|
||||
await expect(stopWslHostServer(all)).resolves.toMatchObject({
|
||||
stopped: false,
|
||||
verdict: 'unverifiable'
|
||||
})
|
||||
})
|
||||
|
||||
it('reports a daemon that did not retire', async () => {
|
||||
const { all } = steps({
|
||||
decommission: vi.fn(async (): Promise<OrcadDecommissionResult> => ({
|
||||
|
||||
@@ -63,22 +63,6 @@ afterEach(() => {
|
||||
})
|
||||
|
||||
describe.skipIf(process.platform === 'win32')('WSL connect state snapshot', () => {
|
||||
it('reads a fresh host in one command', async () => {
|
||||
const reader = createWslOrcadConnectStateReader()
|
||||
|
||||
await expect(reader.readActivationRecord(target())).resolves.toEqual(
|
||||
emptyOrcadActivationRecord()
|
||||
)
|
||||
|
||||
expect(mockExec).toHaveBeenCalledOnce()
|
||||
expect(reader.snapshot()).toEqual({
|
||||
activation: { state: 'absent' },
|
||||
transactionAbsent: true,
|
||||
instanceLock: { state: 'absent' },
|
||||
fenceOpen: true
|
||||
})
|
||||
})
|
||||
|
||||
it('reads the record, a pending journal, the instance lock and a held fence together', async () => {
|
||||
write('.orca-remote/orcad-active.json', serializeOrcadActivationRecord(ACTIVE))
|
||||
write('.orca-remote/.orcad-activation-transaction/transaction.json', '{"partial"')
|
||||
@@ -193,12 +177,6 @@ describe('wslOrcadConnectStateHints', () => {
|
||||
fenceOpen: true
|
||||
}
|
||||
|
||||
it('hints while the record the snapshot read is the one acted on', () => {
|
||||
const hints = wslOrcadConnectStateHints(snapshot, ACTIVE)
|
||||
expect(hints.instanceLock(ACTIVE)).toEqual({ state: 'absent' })
|
||||
expect(hints.deployPreRead(ACTIVE)).toEqual({ preRead: { record: ACTIVE, fenceOpen: true } })
|
||||
})
|
||||
|
||||
it('stops hinting once recovery, adoption or admission replaced the record', () => {
|
||||
const hints = wslOrcadConnectStateHints(snapshot, ACTIVE)
|
||||
const replaced = { ...ACTIVE }
|
||||
|
||||
@@ -1,15 +1,11 @@
|
||||
import { ORCAD_WSL_DISTRO_GUID_ENV } from '../orcad/orcad-migration-wsl-destination'
|
||||
import { ORCAD_IDLE_TIMEOUT_ENV } from '../../shared/orcad-idle-exit'
|
||||
import { EventEmitter } from 'node:events'
|
||||
import { PassThrough, Readable } from 'node:stream'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { PipedChildProcess, PipedProcessSpec } from '@orca/process-host/process-spec'
|
||||
import { execCommand } from '../ssh/remote-exec/remote-exec-command'
|
||||
import { isWslOrcadHostTarget } from '../ssh/orcad-host-transport'
|
||||
|
||||
import { WSL_CHANNEL_CLOSE_GRACE_MS } from './wsl-command-channel'
|
||||
import { createWslOrcadHostTransport } from './wsl-orcad-host-transport'
|
||||
import { openOrcadStdioBridgeChannel, resolveOrcadStdioBridge } from '../ssh/orcad-stdio-bridge'
|
||||
import { orcadBashStdioBridgeCommand } from '../managed-server/lifecycle/orcad-stdio-bridge-script'
|
||||
|
||||
type FakeChild = EventEmitter & {
|
||||
stdin: PassThrough
|
||||
@@ -72,32 +68,6 @@ function transportWith(children: FakeChild[]) {
|
||||
}
|
||||
|
||||
describe('createWslOrcadHostTransport', () => {
|
||||
it('runs the command through wsl.exe --exec sh -c in the chosen distro', async () => {
|
||||
const child = fakeChild()
|
||||
const { transport, specs } = transportWith([child])
|
||||
const channel = await transport.exec('echo $HOME')
|
||||
expect(specs[0]?.program).toMatch(/wsl(\.exe)?$/i)
|
||||
expect(specs[0]?.args).toEqual(['-d', 'Ubuntu-24.04', '--exec', 'sh', '-c', 'echo $HOME'])
|
||||
expect(specs[0]?.env?.WSL_UTF8).toBe('1')
|
||||
channel.close()
|
||||
})
|
||||
|
||||
it('hands the bash bridge to sh through --exec, so wsl.exe never expands its $ words', async () => {
|
||||
const child = fakeChild()
|
||||
const { transport, specs } = transportWith([child])
|
||||
const bridge = await resolveOrcadStdioBridge(transport, 6768)
|
||||
const channel = await openOrcadStdioBridgeChannel(transport, bridge)
|
||||
expect(specs[0]?.args).toEqual([
|
||||
'-d',
|
||||
'Ubuntu-24.04',
|
||||
'--exec',
|
||||
'sh',
|
||||
'-c',
|
||||
orcadBashStdioBridgeCommand(6768)
|
||||
])
|
||||
channel.close()
|
||||
})
|
||||
|
||||
it('emits exit before close and reports the exit code on both', async () => {
|
||||
const child = fakeChild()
|
||||
const { transport } = transportWith([child])
|
||||
@@ -189,39 +159,4 @@ describe('createWslOrcadHostTransport', () => {
|
||||
await closed
|
||||
expect(Buffer.concat(received).length).toBe(20 * 1024 * 1024)
|
||||
})
|
||||
|
||||
it('reports a stable WSL identity and never claims the system SSH transport', () => {
|
||||
const { transport } = transportWith([])
|
||||
const target = transport.getTarget()
|
||||
expect(target).toEqual({
|
||||
kind: 'wsl',
|
||||
id: 'env-1',
|
||||
distro: 'Ubuntu-24.04',
|
||||
label: 'My Ubuntu'
|
||||
})
|
||||
expect(isWslOrcadHostTarget(target)).toBe(true)
|
||||
expect(transport.usesSystemSshTransport()).toBe(false)
|
||||
expect(transport.getConnectGeneration()).toBe(transport.getConnectGeneration())
|
||||
})
|
||||
|
||||
// Regression: `nohup … &` dies with its `wsl.exe --exec` on a distro without systemd.
|
||||
it('asks orcad launches to start in their own session', () => {
|
||||
expect(transportWith([]).transport.orcadLaunchDetach).toBe('setsid')
|
||||
})
|
||||
|
||||
it('tells orcad which registration of the distro it runs in, when the registry knows', async () => {
|
||||
const make = (id: string | null) =>
|
||||
createWslOrcadHostTransport({
|
||||
target: { kind: 'wsl', id: 'env-1', distro: 'Ubuntu-24.04', label: 'My Ubuntu' },
|
||||
commandLoops: false,
|
||||
readDistroRegistryId: async (distro) => (distro === 'Ubuntu-24.04' ? id : null)
|
||||
})
|
||||
await expect(make('1a2b')?.orcadLaunchEnv?.({ fullVersion: '0.1.0+abc' })).resolves.toEqual([
|
||||
[ORCAD_WSL_DISTRO_GUID_ENV, '1a2b'],
|
||||
[ORCAD_IDLE_TIMEOUT_ENV, '300000']
|
||||
])
|
||||
await expect(make(null)?.orcadLaunchEnv?.({ fullVersion: '0.1.0+abc' })).resolves.toEqual([
|
||||
[ORCAD_IDLE_TIMEOUT_ENV, '300000']
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
+1
-93
@@ -3,12 +3,7 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { getContextualTour } from '../../../../shared/contextual-tours'
|
||||
import { i18n, loadRendererUiLanguage } from '@/i18n/i18n'
|
||||
import {
|
||||
getContextualTourDisplayProgress,
|
||||
getContextualTourMeasurementAction,
|
||||
measureContextualTourOverlayRenderState,
|
||||
isContextualTourLastDisplayStep
|
||||
} from './contextual-tour-overlay-measurement'
|
||||
import { measureContextualTourOverlayRenderState } from './contextual-tour-overlay-measurement'
|
||||
|
||||
afterEach(async () => {
|
||||
document.body.replaceChildren()
|
||||
@@ -16,56 +11,6 @@ afterEach(async () => {
|
||||
})
|
||||
|
||||
describe('contextual tour overlay measurement', () => {
|
||||
it('renders the automation tour copy in Korean when the UI locale is Korean', async () => {
|
||||
await i18n.changeLanguage(await loadRendererUiLanguage('ko'))
|
||||
const target = document.createElement('button')
|
||||
target.setAttribute('data-contextual-tour-target', 'automations-create')
|
||||
target.getBoundingClientRect = () => new DOMRect(0, 0, 20, 20)
|
||||
document.body.appendChild(target)
|
||||
|
||||
const result = measureContextualTourOverlayRenderState({
|
||||
tour: getContextualTour('automations'),
|
||||
activeStepIndex: 0,
|
||||
sidebarOpen: true,
|
||||
keybindings: undefined,
|
||||
previousTelemetryTotalSteps: 0
|
||||
})
|
||||
|
||||
expect(result.kind).toBe('render')
|
||||
if (result.kind !== 'render') {
|
||||
throw new Error(`Expected render result, received ${result.kind}`)
|
||||
}
|
||||
expect(result.renderState.title).toBe('자동화란 무엇인가요?')
|
||||
expect(result.renderState.body).toBe(
|
||||
'자동화는 일정에 따라 agent 작업을 실행합니다. 이 버튼을 눌러 자동화를 추가하세요.'
|
||||
)
|
||||
})
|
||||
|
||||
it('renders the automation results step in Korean when the UI locale is Korean', async () => {
|
||||
await i18n.changeLanguage(await loadRendererUiLanguage('ko'))
|
||||
const target = document.createElement('div')
|
||||
target.setAttribute('data-contextual-tour-target', 'automations-runs')
|
||||
target.getBoundingClientRect = () => new DOMRect(0, 0, 20, 20)
|
||||
document.body.appendChild(target)
|
||||
|
||||
const result = measureContextualTourOverlayRenderState({
|
||||
tour: getContextualTour('automations'),
|
||||
activeStepIndex: 1,
|
||||
sidebarOpen: true,
|
||||
keybindings: undefined,
|
||||
previousTelemetryTotalSteps: 0
|
||||
})
|
||||
|
||||
expect(result.kind).toBe('render')
|
||||
if (result.kind !== 'render') {
|
||||
throw new Error(`Expected render result, received ${result.kind}`)
|
||||
}
|
||||
expect(result.renderState.title).toBe('결과 확인')
|
||||
expect(result.renderState.body).toBe(
|
||||
'실행 내역에서 자동화가 언제 실행되었는지, 어떤 일이 발생했는지, 출력을 어디서 확인할 수 있는지 볼 수 있습니다.'
|
||||
)
|
||||
})
|
||||
|
||||
it('localizes the title, shortcut body, and action label of non-automation steps', async () => {
|
||||
await i18n.changeLanguage(await loadRendererUiLanguage('ko'))
|
||||
const target = document.createElement('div')
|
||||
@@ -127,43 +72,6 @@ describe('contextual tour overlay measurement', () => {
|
||||
}
|
||||
expect(result.renderState.title).toBe('자동화란 무엇인가요?')
|
||||
})
|
||||
|
||||
it('shows all defined browser steps in progress even when step 3 is hidden', () => {
|
||||
const tour = getContextualTour('browser')
|
||||
|
||||
expect(
|
||||
getContextualTourDisplayProgress({
|
||||
tour,
|
||||
visibleStepIndexes: [0, 1],
|
||||
stepIndex: 1,
|
||||
activeStep: tour.steps[1]
|
||||
})
|
||||
).toEqual({ current: 2, total: 3 })
|
||||
})
|
||||
|
||||
it('waits for the browser cookie step target instead of cancelling', () => {
|
||||
const tour = getContextualTour('browser')
|
||||
|
||||
expect(
|
||||
getContextualTourMeasurementAction({
|
||||
tour,
|
||||
visibleStepIndexes: [0, 1],
|
||||
activeStepIndex: 2
|
||||
})
|
||||
).toEqual({ kind: 'wait' })
|
||||
})
|
||||
|
||||
it('treats browser step 3 as the last display step', () => {
|
||||
const tour = getContextualTour('browser')
|
||||
|
||||
expect(
|
||||
isContextualTourLastDisplayStep({
|
||||
tour,
|
||||
activeStepIndex: 2,
|
||||
progress: { current: 3, total: 3 }
|
||||
})
|
||||
).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
const { rejectAppStoreAccess } = vi.hoisted(() => ({
|
||||
|
||||
-12
@@ -76,18 +76,6 @@ describe('remoteRuntimeTerminalPreviewApi', () => {
|
||||
harness.subscriptions.length = 0
|
||||
})
|
||||
|
||||
it('subscribes on the host named by the pty id with a preview-only client', async () => {
|
||||
const { sub, payloads } = await openPreview()
|
||||
expect(sub.environmentId).toBe('env-a')
|
||||
expect(sub.terminal).toBe('term-1')
|
||||
expect(sub.clientId).toMatch(/^dashboard-preview:/)
|
||||
|
||||
sub.callbacks.onData('live', { seq: 14 })
|
||||
expect(payloads).toEqual([{ type: 'data', ptyId: PTY, data: 'live', bytes: 4 }])
|
||||
await expect(api.input(PTY, 'x')).resolves.toBe(true)
|
||||
expect(sub.stream.sendInput).toHaveBeenCalledWith('x')
|
||||
})
|
||||
|
||||
it('refuses an id without its owning host instead of guessing one', async () => {
|
||||
await expect(api.connect('remote:term-1')).resolves.toEqual({ snapshot: null, replay: [] })
|
||||
expect(harness.subscriptions).toHaveLength(0)
|
||||
|
||||
@@ -1,26 +1,11 @@
|
||||
import { renderToStaticMarkup } from 'react-dom/server'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { OpenFile } from '@/store/slices/editor'
|
||||
|
||||
// Stub lazy renderers because renderToStaticMarkup cannot resolve them; retain the banner branch structure.
|
||||
vi.mock('@/lib/lazy-with-retry', () => ({
|
||||
lazyWithRetry: () => () => null
|
||||
}))
|
||||
|
||||
import { EditorContent, getMarkdownSourceLineOffset } from './EditorContent'
|
||||
|
||||
function createOpenFile(overrides: Partial<OpenFile> = {}): OpenFile {
|
||||
return {
|
||||
id: '/repo/notebook.ipynb',
|
||||
filePath: '/repo/notebook.ipynb',
|
||||
relativePath: 'notebook.ipynb',
|
||||
worktreeId: 'repo::/repo',
|
||||
language: 'notebook',
|
||||
isDirty: false,
|
||||
mode: 'edit',
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
import { getMarkdownSourceLineOffset } from './EditorContent'
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
@@ -33,188 +18,4 @@ describe('EditorContent', () => {
|
||||
expect(getMarkdownSourceLineOffset('---\r\ntitle: x\r\n---\r\n')).toBe(3)
|
||||
expect(getMarkdownSourceLineOffset('---\rtitle: x\n---\r\n')).toBe(3)
|
||||
})
|
||||
|
||||
it('counts newline-heavy front matter offsets without regex match', () => {
|
||||
const matchSpy = vi.spyOn(String.prototype, 'match')
|
||||
const frontMatterRaw = `---\n${'title: x\n'.repeat(12_000)}---\n`
|
||||
|
||||
expect(getMarkdownSourceLineOffset(frontMatterRaw)).toBe(12_002)
|
||||
expect(matchSpy).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('surfaces file load errors before notebook content is parsed', () => {
|
||||
const activeFile = createOpenFile()
|
||||
const html = renderToStaticMarkup(
|
||||
<EditorContent
|
||||
activeFile={activeFile}
|
||||
viewStateScopeId={activeFile.id}
|
||||
fileContents={{
|
||||
[activeFile.id]: {
|
||||
content: '',
|
||||
isBinary: false,
|
||||
loadError: 'Access denied: path resolves outside allowed directories.'
|
||||
}
|
||||
}}
|
||||
diffContents={{}}
|
||||
editBuffers={{}}
|
||||
openFiles={[activeFile]}
|
||||
worktreeEntries={[]}
|
||||
resolvedLanguage="notebook"
|
||||
isMarkdown={false}
|
||||
isMermaid={false}
|
||||
isCsv={false}
|
||||
isNotebook
|
||||
mdViewMode="rich"
|
||||
inlineMarkdownRenderState={null}
|
||||
isChangesMode={false}
|
||||
sideBySide={false}
|
||||
pendingEditorReveal={null}
|
||||
handleContentChange={vi.fn()}
|
||||
handleContentChangeForFile={vi.fn()}
|
||||
handleDirtyStateHint={vi.fn()}
|
||||
handleSave={vi.fn()}
|
||||
handleSaveForFile={vi.fn()}
|
||||
reloadContent={vi.fn()}
|
||||
/>
|
||||
)
|
||||
|
||||
expect(html).toContain('Unable to load file')
|
||||
expect(html).toContain('Access denied')
|
||||
expect(html).not.toContain('Unable to render notebook')
|
||||
})
|
||||
|
||||
it('shows the changed-on-disk banner above a dirty edit tab', () => {
|
||||
const activeFile = createOpenFile({
|
||||
id: '/repo/file.ts',
|
||||
filePath: '/repo/file.ts',
|
||||
relativePath: 'file.ts',
|
||||
language: 'typescript',
|
||||
isDirty: true,
|
||||
externalMutation: 'changed'
|
||||
})
|
||||
const html = renderToStaticMarkup(
|
||||
<EditorContent
|
||||
activeFile={activeFile}
|
||||
viewStateScopeId={activeFile.id}
|
||||
fileContents={{ [activeFile.id]: { content: 'saved text', isBinary: false } }}
|
||||
diffContents={{}}
|
||||
editBuffers={{ [activeFile.id]: 'saved text plus edits' }}
|
||||
openFiles={[activeFile]}
|
||||
worktreeEntries={[]}
|
||||
resolvedLanguage="typescript"
|
||||
isMarkdown={false}
|
||||
isMermaid={false}
|
||||
isCsv={false}
|
||||
isNotebook={false}
|
||||
mdViewMode="rich"
|
||||
inlineMarkdownRenderState={null}
|
||||
isChangesMode={false}
|
||||
sideBySide={false}
|
||||
pendingEditorReveal={null}
|
||||
handleContentChange={vi.fn()}
|
||||
handleContentChangeForFile={vi.fn()}
|
||||
handleDirtyStateHint={vi.fn()}
|
||||
handleSave={vi.fn()}
|
||||
handleSaveForFile={vi.fn()}
|
||||
reloadContent={vi.fn()}
|
||||
/>
|
||||
)
|
||||
|
||||
expect(html).toContain('role="alert"')
|
||||
expect(html).toContain('changed on disk')
|
||||
expect(html).toContain('Reload from Disk')
|
||||
})
|
||||
|
||||
it('shows the changed-on-disk banner above a dirty unstaged diff without collapsing it', () => {
|
||||
const activeFile = createOpenFile({
|
||||
id: 'diff:/repo/file.ts',
|
||||
filePath: '/repo/file.ts',
|
||||
relativePath: 'file.ts',
|
||||
language: 'typescript',
|
||||
mode: 'diff',
|
||||
diffSource: 'unstaged',
|
||||
isDirty: true,
|
||||
externalMutation: 'changed'
|
||||
})
|
||||
const html = renderToStaticMarkup(
|
||||
<EditorContent
|
||||
activeFile={activeFile}
|
||||
viewStateScopeId={activeFile.id}
|
||||
fileContents={{}}
|
||||
diffContents={{
|
||||
[activeFile.id]: { kind: 'text', originalContent: 'old', modifiedContent: 'new' } as never
|
||||
}}
|
||||
editBuffers={{ [activeFile.id]: 'new plus edits' }}
|
||||
openFiles={[activeFile]}
|
||||
worktreeEntries={[]}
|
||||
resolvedLanguage="typescript"
|
||||
isMarkdown={false}
|
||||
isMermaid={false}
|
||||
isCsv={false}
|
||||
isNotebook={false}
|
||||
mdViewMode="rich"
|
||||
inlineMarkdownRenderState={null}
|
||||
isChangesMode={false}
|
||||
sideBySide={false}
|
||||
pendingEditorReveal={null}
|
||||
handleContentChange={vi.fn()}
|
||||
handleContentChangeForFile={vi.fn()}
|
||||
handleDirtyStateHint={vi.fn()}
|
||||
handleSave={vi.fn()}
|
||||
handleSaveForFile={vi.fn()}
|
||||
reloadContent={vi.fn()}
|
||||
/>
|
||||
)
|
||||
|
||||
expect(html).toContain('role="alert"')
|
||||
expect(html).toContain('changed on disk')
|
||||
// A flex-1-only wrapper collapsed the diff to zero height.
|
||||
expect(html).toContain('flex h-full min-h-0 flex-col')
|
||||
})
|
||||
|
||||
it('shows the changed-on-disk banner on a dirty markdown diff in preview mode', () => {
|
||||
const activeFile = createOpenFile({
|
||||
id: 'diff:/repo/notes.md',
|
||||
filePath: '/repo/notes.md',
|
||||
relativePath: 'notes.md',
|
||||
language: 'markdown',
|
||||
mode: 'diff',
|
||||
diffSource: 'unstaged',
|
||||
isDirty: true,
|
||||
externalMutation: 'changed'
|
||||
})
|
||||
const html = renderToStaticMarkup(
|
||||
<EditorContent
|
||||
activeFile={activeFile}
|
||||
viewStateScopeId={activeFile.id}
|
||||
fileContents={{}}
|
||||
diffContents={{
|
||||
[activeFile.id]: { kind: 'text', originalContent: 'old', modifiedContent: 'new' } as never
|
||||
}}
|
||||
editBuffers={{ [activeFile.id]: 'new plus edits' }}
|
||||
openFiles={[activeFile]}
|
||||
worktreeEntries={[]}
|
||||
resolvedLanguage="markdown"
|
||||
isMarkdown
|
||||
isMermaid={false}
|
||||
isCsv={false}
|
||||
isNotebook={false}
|
||||
mdViewMode="preview"
|
||||
inlineMarkdownRenderState={null}
|
||||
isChangesMode={false}
|
||||
sideBySide={false}
|
||||
pendingEditorReveal={null}
|
||||
handleContentChange={vi.fn()}
|
||||
handleContentChangeForFile={vi.fn()}
|
||||
handleDirtyStateHint={vi.fn()}
|
||||
handleSave={vi.fn()}
|
||||
handleSaveForFile={vi.fn()}
|
||||
reloadContent={vi.fn()}
|
||||
/>
|
||||
)
|
||||
|
||||
expect(html).toContain('role="alert"')
|
||||
expect(html).toContain('changed on disk')
|
||||
expect(html).toContain('Previewing the modified version of this diff')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -71,20 +71,6 @@ describe('MediaViewer', () => {
|
||||
expect(video?.getAttribute('src')).toBeNull()
|
||||
})
|
||||
|
||||
it('resumes from the last playback position after the tab remounts', async () => {
|
||||
vi.spyOn(HTMLMediaElement.prototype, 'pause').mockImplementation(() => {})
|
||||
vi.spyOn(HTMLMediaElement.prototype, 'load').mockImplementation(() => {})
|
||||
const first = await renderPlaybackViewer('clip.mp4:pdf')
|
||||
first.video.currentTime = 42
|
||||
await act(async () => first.root.unmount())
|
||||
|
||||
const second = await renderPlaybackViewer('clip.mp4:pdf')
|
||||
second.video.currentTime = 0
|
||||
await act(async () => second.video.dispatchEvent(new Event('loadedmetadata')))
|
||||
expect(second.video.currentTime).toBe(42)
|
||||
await act(async () => second.root.unmount())
|
||||
})
|
||||
|
||||
it('retains the position when leaving before metadata and clamps it when the file shrinks', async () => {
|
||||
vi.spyOn(HTMLMediaElement.prototype, 'pause').mockImplementation(() => {})
|
||||
vi.spyOn(HTMLMediaElement.prototype, 'load').mockImplementation(() => {})
|
||||
@@ -131,29 +117,6 @@ describe('MediaViewer', () => {
|
||||
await act(async () => otherRestored.root.unmount())
|
||||
})
|
||||
|
||||
it('shows a persistent playback error and only offers the default app for local files', async () => {
|
||||
vi.spyOn(HTMLMediaElement.prototype, 'pause').mockImplementation(() => {})
|
||||
vi.spyOn(HTMLMediaElement.prototype, 'load').mockImplementation(() => {})
|
||||
const container = document.createElement('div')
|
||||
const root = createRoot(container)
|
||||
await act(async () =>
|
||||
root.render(
|
||||
<MediaViewer
|
||||
src="orca-media://file/token"
|
||||
mimeType="video/mp4"
|
||||
filePath="remote.mp4"
|
||||
canOpenLocally={false}
|
||||
/>
|
||||
)
|
||||
)
|
||||
await act(async () => container.querySelector('video')?.dispatchEvent(new Event('error')))
|
||||
expect(container.querySelector('[role="alert"]')?.textContent).toContain(
|
||||
'Unable to play this media file'
|
||||
)
|
||||
expect(container.querySelector('button')).toBeNull()
|
||||
await act(async () => root.unmount())
|
||||
})
|
||||
|
||||
it('uses native audio controls and releases the audio source when switching files', async () => {
|
||||
const pause = vi.spyOn(HTMLMediaElement.prototype, 'pause').mockImplementation(() => {})
|
||||
const load = vi.spyOn(HTMLMediaElement.prototype, 'load').mockImplementation(() => {})
|
||||
|
||||
@@ -113,21 +113,6 @@ async function renderFrame(props: {
|
||||
}
|
||||
|
||||
describe('PluginCustomEditorFrame', () => {
|
||||
it('renders the editor in a scripts-only sandbox inside the CSP shell', async () => {
|
||||
const { iframe } = await renderFrame({ content: '{}' })
|
||||
expect(readCustomEditorEntry).toHaveBeenCalledWith({
|
||||
pluginKey: 'stablyai.excalidraw',
|
||||
editorId: 'excalidraw'
|
||||
})
|
||||
// allow-same-origin would expose app DOM, storage and IPC to the plugin.
|
||||
expect(iframe.getAttribute('sandbox')).toBe('allow-scripts')
|
||||
expect(iframe.getAttribute('name')?.startsWith('orca-plugin-panel:editor:')).toBe(true)
|
||||
// window.name is readable in the frame, so it must not name the file.
|
||||
expect(iframe.getAttribute('name')).not.toContain('diagram')
|
||||
expect(iframe.getAttribute('name')).not.toContain('/repo')
|
||||
expect(iframe.getAttribute('srcdoc')).toContain('<main>editor</main>')
|
||||
})
|
||||
|
||||
it('sends the buffer on ready and applies edits without saving', async () => {
|
||||
const { posted, fromFrame, onContentChange, onSave } = await renderFrame({ content: 'scene' })
|
||||
await fromFrame(READY)
|
||||
|
||||
@@ -107,61 +107,6 @@ describe('RichMarkdownErrorBoundary lazy chunk containment', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('records the degraded breadcrumb once across repeated Retry clicks', async () => {
|
||||
window.sessionStorage.setItem(RELOAD_GUARD_KEY, LANDED_RELOAD_GUARD_VALUE)
|
||||
const LazyRejectingImport = lazyWithRetry(
|
||||
() => Promise.reject(new SyntaxError(CORRUPT_CHUNK_PARSE_ERROR)),
|
||||
{ retries: 0 }
|
||||
)
|
||||
;({ container, root } = createContainer())
|
||||
|
||||
await act(async () => {
|
||||
root?.render(
|
||||
<BoundaryHarness>
|
||||
<LazyRejectingImport />
|
||||
</BoundaryHarness>
|
||||
)
|
||||
})
|
||||
await flushReactWork()
|
||||
await flushReactWork()
|
||||
|
||||
for (let click = 0; click < 3; click += 1) {
|
||||
const retry = container?.querySelector('button')
|
||||
expect(retry).not.toBeNull()
|
||||
await act(async () => {
|
||||
retry?.click()
|
||||
})
|
||||
await flushReactWork()
|
||||
}
|
||||
|
||||
expect(container?.textContent).toContain('rich markdown editor')
|
||||
expect(recordBreadcrumbMock).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('records the degraded breadcrumb once across boundary remounts', async () => {
|
||||
window.sessionStorage.setItem(RELOAD_GUARD_KEY, LANDED_RELOAD_GUARD_VALUE)
|
||||
const LazyRejectingImport = lazyWithRetry(
|
||||
() => Promise.reject(new SyntaxError("Unexpected token '<'")),
|
||||
{ retries: 0 }
|
||||
)
|
||||
;({ container, root } = createContainer())
|
||||
|
||||
for (const boundaryKey of ['pane-a', 'pane-b', 'pane-c']) {
|
||||
await act(async () => {
|
||||
root?.render(
|
||||
<BoundaryHarness boundaryKey={boundaryKey}>
|
||||
<LazyRejectingImport />
|
||||
</BoundaryHarness>
|
||||
)
|
||||
})
|
||||
await flushReactWork()
|
||||
await flushReactWork()
|
||||
}
|
||||
|
||||
expect(container?.textContent).toContain('rich markdown editor')
|
||||
expect(recordBreadcrumbMock).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('still reports ordinary render errors', async () => {
|
||||
const error = new Error('ordinary render failure')
|
||||
function BrokenEditor(): ReactElement {
|
||||
|
||||
@@ -75,20 +75,6 @@ function createClipboardEventMock(options?: { failReadback?: boolean }): {
|
||||
}
|
||||
|
||||
describe('rich markdown cut handler behavior', () => {
|
||||
it('hard-wrapped document prose stays one paragraph after normalization', () => {
|
||||
const editor = createEditor('Line one\nLine two\nLine three\n')
|
||||
|
||||
expect(countParagraphs(editor)).toBe(1)
|
||||
expect(editor.state.doc.firstChild!.textContent).toContain('\n')
|
||||
|
||||
normalizeEmptyListItems(editor)
|
||||
|
||||
expect(countParagraphs(editor)).toBe(1)
|
||||
expect(editor.state.doc.firstChild!.textContent).toBe('Line one\nLine two\nLine three')
|
||||
|
||||
editor.destroy()
|
||||
})
|
||||
|
||||
it('soft-break normalization still creates visible paragraph breaks', () => {
|
||||
const editor = createEditor('Line one\nLine two\nLine three\n')
|
||||
normalizeSoftBreaks(editor)
|
||||
@@ -201,28 +187,4 @@ describe('rich markdown cut handler behavior', () => {
|
||||
editor.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
it('normalizeEmptyListItems is idempotent on already-clean documents', () => {
|
||||
const editor = createEditor('First.\n\nSecond.\n\nThird.\n')
|
||||
|
||||
const docBefore = editor.state.doc.toJSON()
|
||||
normalizeEmptyListItems(editor)
|
||||
const docAfter = editor.state.doc.toJSON()
|
||||
|
||||
expect(docAfter).toEqual(docBefore)
|
||||
|
||||
editor.destroy()
|
||||
})
|
||||
|
||||
it('normalizeEmptyListItems does not modify populated list items or blockquotes', () => {
|
||||
const editor = createEditor('- Item 1\n- Item 2\n')
|
||||
|
||||
const docBefore = editor.state.doc.toJSON()
|
||||
normalizeEmptyListItems(editor)
|
||||
const docAfter = editor.state.doc.toJSON()
|
||||
|
||||
expect(docAfter).toEqual(docBefore)
|
||||
|
||||
editor.destroy()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,222 +0,0 @@
|
||||
import { renderToStaticMarkup } from 'react-dom/server'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { PreflightStatus } from '../../../../preload/api-types'
|
||||
import { getProviderRuntimeContextKey } from '@/lib/provider-runtime-context'
|
||||
import { ConnectIntegrationsList } from './ConnectIntegrationsList'
|
||||
|
||||
type StoreState = {
|
||||
activeRepoId: string | null
|
||||
activeWorktreeId: string | null
|
||||
worktreesByRepo: Record<string, unknown[]>
|
||||
repos: unknown[]
|
||||
settings: { activeRuntimeEnvironmentId?: string | null }
|
||||
preflightStatus: PreflightStatus | null
|
||||
preflightStatusChecked: boolean
|
||||
preflightStatusContextKey: string
|
||||
preflightStatusError: string | null
|
||||
preflightStatusLoading: boolean
|
||||
refreshPreflightStatus: () => Promise<void>
|
||||
linearStatus: { connected: boolean; workspaces?: unknown[] }
|
||||
linearStatusChecked: boolean
|
||||
linearStatusContextKey: string | null
|
||||
checkLinearConnection: () => Promise<void>
|
||||
testLinearConnection: () => Promise<{ ok: boolean; error?: string }>
|
||||
disconnectLinear: () => Promise<void>
|
||||
disconnectLinearWorkspace: () => Promise<void>
|
||||
jiraStatus: { connected: boolean; sites?: unknown[] }
|
||||
jiraStatusChecked: boolean
|
||||
jiraStatusContextKey: string | null
|
||||
checkJiraConnection: () => Promise<void>
|
||||
testJiraConnection: () => Promise<{ ok: boolean; error?: string }>
|
||||
disconnectJira: () => Promise<void>
|
||||
}
|
||||
|
||||
const { storeState } = vi.hoisted(() => ({
|
||||
storeState: { current: null as StoreState | null }
|
||||
}))
|
||||
|
||||
vi.mock('@/store', () => ({
|
||||
useAppStore: Object.assign(
|
||||
(selector: (state: StoreState) => unknown) => {
|
||||
if (!storeState.current) {
|
||||
throw new Error('Store state was not installed')
|
||||
}
|
||||
return selector(storeState.current)
|
||||
},
|
||||
{ getState: () => storeState.current }
|
||||
)
|
||||
}))
|
||||
|
||||
vi.mock('@/components/linear-api-key-dialog', () => ({
|
||||
LinearApiKeyDialog: () => null
|
||||
}))
|
||||
|
||||
vi.mock('@/components/jira-connect-dialog', () => ({
|
||||
JiraConnectDialog: () => null
|
||||
}))
|
||||
|
||||
function makePreflightStatus(overrides: Partial<PreflightStatus> = {}): PreflightStatus {
|
||||
const status: PreflightStatus = {
|
||||
git: { installed: true },
|
||||
gh: { installed: true, authenticated: false },
|
||||
glab: { installed: true, authenticated: false },
|
||||
bitbucket: {
|
||||
configured: false,
|
||||
authenticated: false,
|
||||
account: null
|
||||
},
|
||||
azureDevOps: {
|
||||
configured: false,
|
||||
authenticated: false,
|
||||
account: null,
|
||||
baseUrl: null,
|
||||
tokenConfigured: false
|
||||
},
|
||||
gitea: {
|
||||
configured: false,
|
||||
authenticated: false,
|
||||
account: null,
|
||||
baseUrl: null,
|
||||
tokenConfigured: false
|
||||
}
|
||||
}
|
||||
return { ...status, ...overrides }
|
||||
}
|
||||
|
||||
function installStore(preflightStatus: PreflightStatus): void {
|
||||
const settings = { activeRuntimeEnvironmentId: null }
|
||||
const providerContextKey = getProviderRuntimeContextKey(settings)
|
||||
storeState.current = {
|
||||
activeRepoId: null,
|
||||
activeWorktreeId: null,
|
||||
worktreesByRepo: {},
|
||||
repos: [],
|
||||
settings,
|
||||
preflightStatus,
|
||||
preflightStatusChecked: true,
|
||||
preflightStatusContextKey: 'host',
|
||||
preflightStatusError: null,
|
||||
preflightStatusLoading: false,
|
||||
refreshPreflightStatus: vi.fn(async () => {}),
|
||||
linearStatus: { connected: false, workspaces: [] },
|
||||
linearStatusChecked: true,
|
||||
linearStatusContextKey: providerContextKey,
|
||||
checkLinearConnection: vi.fn(async () => {}),
|
||||
testLinearConnection: vi.fn(async () => ({ ok: true })),
|
||||
disconnectLinear: vi.fn(async () => {}),
|
||||
disconnectLinearWorkspace: vi.fn(async () => {}),
|
||||
jiraStatus: { connected: false, sites: [] },
|
||||
jiraStatusChecked: true,
|
||||
jiraStatusContextKey: providerContextKey,
|
||||
checkJiraConnection: vi.fn(async () => {}),
|
||||
testJiraConnection: vi.fn(async () => ({ ok: true })),
|
||||
disconnectJira: vi.fn(async () => {})
|
||||
}
|
||||
}
|
||||
|
||||
async function renderConnectIntegrationsList(): Promise<{
|
||||
markup: string
|
||||
}> {
|
||||
return { markup: renderToStaticMarkup(<ConnectIntegrationsList />) }
|
||||
}
|
||||
|
||||
describe('ConnectIntegrationsList', () => {
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal('window', {
|
||||
api: {
|
||||
shell: {
|
||||
openUrl: vi.fn()
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
storeState.current = null
|
||||
vi.unstubAllGlobals()
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('renders the settings review cards in the review step without an inline auth terminal', async () => {
|
||||
installStore(makePreflightStatus())
|
||||
|
||||
const { markup } = await renderConnectIntegrationsList()
|
||||
|
||||
for (const provider of ['GitHub', 'GitLab', 'Bitbucket', 'Azure DevOps', 'Gitea']) {
|
||||
expect(markup).toContain(provider)
|
||||
}
|
||||
expect(markup).toContain('gh auth login')
|
||||
expect(markup).toContain('glab auth login')
|
||||
expect(markup).not.toContain('Run in terminal')
|
||||
})
|
||||
|
||||
it('keeps the upcoming task step collapsed but openable, not inert', async () => {
|
||||
installStore(makePreflightStatus())
|
||||
|
||||
const { markup } = await renderConnectIntegrationsList()
|
||||
|
||||
// Review is optional ordering: tracker-first users can expand the task step.
|
||||
expect(markup).toContain('Open')
|
||||
expect(markup).not.toContain('opacity-55')
|
||||
expect(markup).not.toContain('Add Linear access')
|
||||
})
|
||||
|
||||
it('collapses the task step to its summary when a tracker connects first', async () => {
|
||||
installStore(makePreflightStatus())
|
||||
if (!storeState.current) {
|
||||
throw new Error('Store state was not installed')
|
||||
}
|
||||
storeState.current.linearStatus = { connected: true, workspaces: [] }
|
||||
|
||||
const { markup } = await renderConnectIntegrationsList()
|
||||
|
||||
expect(markup).toContain('connected for tasks')
|
||||
expect(markup).not.toContain('Connect Jira')
|
||||
})
|
||||
|
||||
it('auto-resolves the task step from a connected code host but keeps it open for trackers', async () => {
|
||||
installStore(makePreflightStatus({ gh: { installed: true, authenticated: true } }))
|
||||
|
||||
const { markup } = await renderConnectIntegrationsList()
|
||||
|
||||
expect(markup).toContain('GitHub')
|
||||
expect(markup).toContain('issues available as tasks')
|
||||
expect(markup).toContain('add Linear or Jira if your team plans work there')
|
||||
expect(markup).not.toContain('Use GitHub issues')
|
||||
// Keep dedicated trackers discoverable even when code-host issues already satisfy tasks.
|
||||
expect(markup).toContain('Add Linear access')
|
||||
expect(markup).toContain('Connect Jira')
|
||||
})
|
||||
|
||||
it('offers GitHub and GitLab as task sources when review came from a non-task provider', async () => {
|
||||
installStore(
|
||||
makePreflightStatus({
|
||||
bitbucket: { configured: true, authenticated: true, account: 'acme' }
|
||||
})
|
||||
)
|
||||
|
||||
const { markup } = await renderConnectIntegrationsList()
|
||||
|
||||
expect(markup).toContain('issues also work as tasks.')
|
||||
expect(markup).toContain('gh auth login')
|
||||
expect(markup).toContain('glab auth login')
|
||||
expect(markup).toContain('Linear')
|
||||
expect(markup).toContain('Jira')
|
||||
})
|
||||
|
||||
it('lists the code host alongside a connected tracker in the task summary', async () => {
|
||||
installStore(makePreflightStatus({ gh: { installed: true, authenticated: true } }))
|
||||
if (!storeState.current) {
|
||||
throw new Error('Store state was not installed')
|
||||
}
|
||||
storeState.current.linearStatus = { connected: true, workspaces: [] }
|
||||
|
||||
const { markup } = await renderConnectIntegrationsList()
|
||||
|
||||
expect(markup).toContain('Linear')
|
||||
expect(markup).toContain('GitHub')
|
||||
expect(markup).toContain('connected for tasks')
|
||||
expect(markup).toContain(' and ')
|
||||
expect(markup).not.toContain('Connect Jira')
|
||||
})
|
||||
})
|
||||
@@ -8,11 +8,8 @@ import type {
|
||||
DeveloperPermissionRequestResult,
|
||||
DeveloperPermissionState
|
||||
} from '../../../../shared/developer-permissions-types'
|
||||
import {
|
||||
FullDiskAccessSetupPrompt,
|
||||
isFullDiskAccessReady,
|
||||
isFullDiskAccessSetupVisible
|
||||
} from './FullDiskAccessSetupPrompt'
|
||||
import { FullDiskAccessSetupPrompt } from './FullDiskAccessSetupPrompt'
|
||||
|
||||
import { toast } from 'sonner'
|
||||
|
||||
vi.mock('sonner', () => ({
|
||||
@@ -75,24 +72,6 @@ describe('FullDiskAccessSetupPrompt state helpers', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('hides the setup prompt before status is known or when unsupported', () => {
|
||||
expect(isFullDiskAccessSetupVisible(undefined)).toBe(false)
|
||||
expect(isFullDiskAccessSetupVisible('unsupported')).toBe(false)
|
||||
})
|
||||
|
||||
it('shows the setup prompt only for macOS statuses users can act on', () => {
|
||||
expect(isFullDiskAccessSetupVisible('unknown')).toBe(true)
|
||||
expect(isFullDiskAccessSetupVisible('denied')).toBe(true)
|
||||
expect(isFullDiskAccessSetupVisible('granted')).toBe(false)
|
||||
expect(isFullDiskAccessSetupVisible('ready')).toBe(false)
|
||||
})
|
||||
|
||||
it('treats granted and entitled statuses as ready', () => {
|
||||
expect(isFullDiskAccessReady('granted')).toBe(true)
|
||||
expect(isFullDiskAccessReady('ready')).toBe(true)
|
||||
expect(isFullDiskAccessReady('unknown')).toBe(false)
|
||||
})
|
||||
|
||||
it('refreshes macOS Full Disk Access status when Orca regains focus', async () => {
|
||||
setUserAgent('Macintosh')
|
||||
const getStatus = vi
|
||||
|
||||
@@ -52,21 +52,6 @@ describe('host lists with a 13,692-host ssh config', () => {
|
||||
expect(screen.getAllByRole('option')[0]?.textContent).toContain(lastName)
|
||||
})
|
||||
|
||||
it('HostCombobox treats re-picking the current host as no change', async () => {
|
||||
const onChange = vi.fn()
|
||||
render(
|
||||
<HostCombobox
|
||||
value="ssh-0"
|
||||
onChange={onChange}
|
||||
ariaLabel="Host"
|
||||
options={names.slice(0, 3).map((name, i) => ({ id: `ssh-${i}`, label: name }))}
|
||||
/>
|
||||
)
|
||||
fireEvent.click(screen.getByRole('combobox', { name: 'Host' }))
|
||||
fireEvent.click(await screen.findByRole('option', { name: names[0] }))
|
||||
expect(onChange).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('Settings > Servers SSH host picker', async () => {
|
||||
const onChange = vi.fn()
|
||||
render(
|
||||
|
||||
@@ -159,15 +159,6 @@ describe('Jira transition control', () => {
|
||||
expect(mutate).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps failures visible and exposes Jira fallback', async () => {
|
||||
await choose(
|
||||
[close],
|
||||
vi.fn().mockResolvedValue({ ok: false, error: 'Resolution is required.' })
|
||||
)
|
||||
expect((await screen.findByRole('alert')).textContent).toContain('Resolution is required.')
|
||||
expect(screen.getByRole('button', { name: 'Open in Jira' })).toBeTruthy()
|
||||
})
|
||||
|
||||
it('keeps a rejected mutation visible and offers Jira fallback', async () => {
|
||||
await choose([close], vi.fn().mockRejectedValue(new Error('Disconnected from Jira host.')))
|
||||
expect((await screen.findByRole('alert')).textContent).toContain('Disconnected from Jira host.')
|
||||
|
||||
@@ -5,11 +5,10 @@ import { NativeChatResumeOnRestartModal } from './NativeChatResumeRuntimeModal'
|
||||
import { NativeChatResumeStatusSegment } from './status-bar/NativeChatResumeStatusSegment'
|
||||
import {
|
||||
createResumeModalFixture,
|
||||
failure,
|
||||
offered,
|
||||
type RestartRpc,
|
||||
type ResumeStatusStream
|
||||
} from './native-chat-resume-modal.test-support'
|
||||
|
||||
import { lastToastShow } from './native-chat-resume-toast.test-support'
|
||||
import { chatBox, dontAskAgain } from './native-chat-resume-on-restart-modal.test-support'
|
||||
import { useAppStore } from '../store'
|
||||
@@ -47,22 +46,6 @@ async function mountSurfaces(): Promise<void> {
|
||||
)
|
||||
}
|
||||
|
||||
it('shows a finished resume as a result, in the past tense, once nothing is left to choose', async () => {
|
||||
fakeHost()
|
||||
await mountSurfaces()
|
||||
await act(async () => button('Resume 2 chats').click())
|
||||
await act(async () => lastToastShow()?.())
|
||||
expect(dialog()?.textContent).toContain('Resumed 2 of 2 chats')
|
||||
expect(dialog()?.textContent).toContain(
|
||||
'Orca restored each one where it stopped, with its full context'
|
||||
)
|
||||
expect(dialog()?.textContent).not.toContain('Resuming restores')
|
||||
expect(tree()?.querySelectorAll('[role="checkbox"]')).toHaveLength(0)
|
||||
expect(tree()?.textContent).not.toContain('Select all')
|
||||
expect(dialog()?.textContent).toContain("Don't ask again")
|
||||
expect(dialog()?.textContent).not.toContain('Resumed automatically')
|
||||
})
|
||||
|
||||
it('saves "Don\'t ask again" ticked on a finished resume the user started', async () => {
|
||||
fakeHost()
|
||||
await mountSurfaces()
|
||||
@@ -151,32 +134,6 @@ it('reopens a collapsed result group with tree keys', async () => {
|
||||
expect(document.activeElement).toBe(disclosure)
|
||||
})
|
||||
|
||||
it('shows a settled nonretryable failure as a result with a Done action', async () => {
|
||||
const failed = { ...failure('a'), outcome: 'refused' as const, retryable: false }
|
||||
let acted = false
|
||||
rpc.mockImplementation(async (_target, method) => {
|
||||
if (method === 'agentSession.restartResumable') {
|
||||
return { sessions: acted ? [] : [offered[0]], failed: acted ? [failed] : [] }
|
||||
}
|
||||
acted = true
|
||||
return {
|
||||
sessions: [],
|
||||
failed: [failed],
|
||||
continued: [{ sessionId: 'a', outcome: 'refused' }]
|
||||
}
|
||||
})
|
||||
await mountSurfaces()
|
||||
await act(async () => button('Resume 1 chat').click())
|
||||
await act(async () => lastToastShow()?.())
|
||||
expect(dialog()?.textContent).toContain('Resumed 0 of 1 chat')
|
||||
expect(tree()?.textContent).not.toContain('Select all')
|
||||
expect(tree()?.querySelectorAll('[role="checkbox"]')).toHaveLength(0)
|
||||
expect(button('Open chat')).toBeTruthy()
|
||||
expect(button('Dismiss "Prompt a" in workspace')).toBeTruthy()
|
||||
expect(button('Done').disabled).toBe(false)
|
||||
expect(dialog()?.textContent).not.toContain('Resume 0 chats')
|
||||
})
|
||||
|
||||
it('does not place an untouched offer in the Need you filter', async () => {
|
||||
fakeHost()
|
||||
await mountSurfaces()
|
||||
@@ -216,14 +173,3 @@ it('says a launch resumed on its own and links to the setting', async () => {
|
||||
})
|
||||
expect(dialog()).toBeNull()
|
||||
})
|
||||
|
||||
it('keeps the checkboxes while a chat can still be chosen, whichever tab is shown', async () => {
|
||||
fakeHost({}, (sessionId) => (sessionId === 'b' ? 'refused' : 'continued'))
|
||||
await mountSurfaces()
|
||||
await act(async () => button('Resume 2 chats').click())
|
||||
await act(async () => button('1 chat failed to resume').click())
|
||||
expect(dialog()?.textContent).toContain('The ones Orca resumed were restored')
|
||||
expect(tree()?.textContent).toContain('Select all')
|
||||
await act(async () => button('Resumed1').click())
|
||||
expect(tree()?.textContent).toContain('Select all')
|
||||
})
|
||||
|
||||
@@ -110,34 +110,6 @@ describe('native chat Mermaid fences', () => {
|
||||
expect(container.querySelector('[data-code-language="mermaid"]')).toBeNull()
|
||||
})
|
||||
|
||||
it('shows partial source while streaming and renders the diagram when the reply finishes', async () => {
|
||||
const { container, rerender } = render(reply(partial, true))
|
||||
expect(container.querySelector('[data-native-chat-code-content]')?.textContent).toContain(
|
||||
partial
|
||||
)
|
||||
expect(container.querySelector('[data-code-language="mermaid"]')).toBeInTheDocument()
|
||||
expect(container.querySelector('.mermaid-block')).toBeNull()
|
||||
|
||||
rerender(reply(complete, true))
|
||||
expect(container.querySelector('pre')?.textContent).toContain(complete)
|
||||
|
||||
rerender(reply(complete, false))
|
||||
await waitFor(() =>
|
||||
expect(container.querySelector('svg')).toHaveTextContent('Sync with main once')
|
||||
)
|
||||
expect(mermaid.render).toHaveBeenCalledWith(expect.any(String), complete)
|
||||
expect(container.querySelector('pre')).toBeNull()
|
||||
expect(screen.queryByText(/Diagram error:/)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('still shows the existing error and source for an invalid finished reply', async () => {
|
||||
mermaid.render.mockRejectedValue(new Error('Invalid mermaid syntax'))
|
||||
const { container } = render(reply(partial, false))
|
||||
await screen.findByText(/Diagram error: Invalid mermaid syntax/)
|
||||
expect(container.querySelector('pre')?.textContent).toBe(partial.trimEnd())
|
||||
expect(container.querySelector('svg')).toBeNull()
|
||||
})
|
||||
|
||||
it('keeps the source mounted until a pending diagram fails', async () => {
|
||||
const pending = holdNextRender()
|
||||
const view = render(reply(partial, true))
|
||||
|
||||
-22
@@ -20,28 +20,6 @@ afterEach(() => {
|
||||
})
|
||||
|
||||
describe('checks panel concrete content', () => {
|
||||
it('renders the no-workspace empty state with its guidance', () => {
|
||||
const model = { activeWorktree: null } as ChecksPanelEmptyContentModel
|
||||
|
||||
render(<ChecksPanelEmptyContent model={model} />)
|
||||
|
||||
expect(screen.getByText('No workspace selected')).toBeTruthy()
|
||||
expect(screen.getByText('Select a workspace to view checks')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('renders the folder-workspace empty state without review controls', () => {
|
||||
const model = {
|
||||
activeWorktree: { id: 'folder-1' },
|
||||
isFolder: true
|
||||
} as ChecksPanelEmptyContentModel
|
||||
|
||||
render(<ChecksPanelEmptyContent model={model} />)
|
||||
|
||||
expect(screen.getByText('Checks unavailable')).toBeTruthy()
|
||||
expect(screen.getByText('Checks require a Git branch and hosted review context')).toBeTruthy()
|
||||
expect(screen.queryByRole('button')).toBeNull()
|
||||
})
|
||||
|
||||
it('renders a durable unlinked state that can relink before PR data refetches', () => {
|
||||
const handleLinkSuppressedPullRequest = vi.fn<() => void>()
|
||||
const model = {
|
||||
|
||||
@@ -64,16 +64,6 @@ function openDialog(): HTMLInputElement {
|
||||
}
|
||||
|
||||
describe('Use a Node.js mirror', () => {
|
||||
it('opens from the host row with the suggested mirror, focused and editable', () => {
|
||||
render(<Host status={blocked} />)
|
||||
const input = openDialog()
|
||||
|
||||
expect(screen.getByRole('dialog', { name: 'Use a Node.js mirror' })).toBeInTheDocument()
|
||||
expect(input).toHaveValue('https://npmmirror.com/mirrors/node/')
|
||||
expect(input).toHaveFocus()
|
||||
expect(screen.getByText(/checked against the checksum Orca pins/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('saves to the mirror setting, then retries the connect and closes', async () => {
|
||||
render(<Host status={blocked} />)
|
||||
openDialog()
|
||||
@@ -147,16 +137,6 @@ describe('Use a Node.js mirror', () => {
|
||||
expect(state.activeModal).toBe('none')
|
||||
})
|
||||
|
||||
it('offers nothing in a paired browser, whose settings are not this computer’s', () => {
|
||||
vi.stubGlobal('__ORCA_WEB_CLIENT__', true)
|
||||
try {
|
||||
render(<Host status={blocked} />)
|
||||
expect(screen.queryByRole('button')).toBeNull()
|
||||
} finally {
|
||||
vi.unstubAllGlobals()
|
||||
}
|
||||
})
|
||||
|
||||
it('opens the setting instead when a mirror was set after the toast appeared', () => {
|
||||
useAppStore.getState().setSshConnectionState('ssh-1', {
|
||||
targetId: 'ssh-1',
|
||||
|
||||
@@ -1,226 +0,0 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { renderToStaticMarkup } from 'react-dom/server'
|
||||
import { Bot, GitBranch, Mic, Network, Puzzle } from 'lucide-react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { getDefaultSettings } from '../../../../shared/constants'
|
||||
import { buildSettingsNavigationMetadata } from '@/hooks/useSettingsNavigationMetadata'
|
||||
import type * as ShortcutLabels from '@/hooks/useShortcutLabel'
|
||||
import { SettingsSidebar } from './SettingsSidebar'
|
||||
import { TooltipProvider } from '../ui/tooltip'
|
||||
import type { SettingsSetupGuideProgress } from './settings-setup-guide-progress'
|
||||
import type { GlobalSettings } from '../../../../shared/global-settings-types'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
useSettingsSetupGuideProgress: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/hooks/useShortcutLabel', async () => {
|
||||
const actual = await vi.importActual<typeof ShortcutLabels>('@/hooks/useShortcutLabel')
|
||||
return {
|
||||
...actual,
|
||||
useShortcutLabel: () => '⌘F',
|
||||
useShortcutKeyComboDetails: () => [{ keys: ['⌘', 'F'], doubleTap: false }]
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('./settings-setup-guide-progress', () => ({
|
||||
useSettingsSetupGuideProgress: mocks.useSettingsSetupGuideProgress
|
||||
}))
|
||||
|
||||
function makeSetupGuideProgress(
|
||||
overrides: Partial<SettingsSetupGuideProgress> = {}
|
||||
): SettingsSetupGuideProgress {
|
||||
return {
|
||||
ready: true,
|
||||
doneCount: 5,
|
||||
total: 8,
|
||||
firstIncompleteStepId: 'agent-capabilities',
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
function renderSidebar(
|
||||
activeSectionId = 'orchestration',
|
||||
settings: GlobalSettings = getDefaultSettings('/tmp')
|
||||
): string {
|
||||
return renderToStaticMarkup(
|
||||
<TooltipProvider>
|
||||
<SettingsSidebar
|
||||
activeSectionId={activeSectionId}
|
||||
settings={settings}
|
||||
generalGroups={[
|
||||
{
|
||||
id: 'capabilities',
|
||||
title: 'AI Capabilities',
|
||||
sections: [
|
||||
{
|
||||
id: 'agents',
|
||||
title: 'Agents',
|
||||
icon: Bot
|
||||
},
|
||||
{
|
||||
id: 'orchestration',
|
||||
title: 'Orchestration',
|
||||
icon: Network,
|
||||
installStatus: 'install'
|
||||
},
|
||||
{
|
||||
id: 'voice',
|
||||
title: 'Voice',
|
||||
icon: Mic,
|
||||
installStatus: 'installed'
|
||||
},
|
||||
{
|
||||
id: 'computer-use',
|
||||
title: 'Computer Use',
|
||||
icon: Bot,
|
||||
installStatus: 'up-to-date'
|
||||
},
|
||||
{
|
||||
id: 'voice-loading',
|
||||
title: 'Voice Loading',
|
||||
icon: Mic,
|
||||
installStatus: 'checking'
|
||||
},
|
||||
{
|
||||
id: 'linear',
|
||||
title: 'Linear',
|
||||
icon: GitBranch,
|
||||
installStatus: 'update-available'
|
||||
},
|
||||
{
|
||||
id: 'ephemeral-vms',
|
||||
title: 'Ephemeral VMs',
|
||||
icon: Bot,
|
||||
installStatus: 'needs-attention'
|
||||
},
|
||||
{
|
||||
id: 'plugins',
|
||||
title: 'Plugins',
|
||||
icon: Puzzle
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'setup',
|
||||
title: 'Set Up',
|
||||
sections: [
|
||||
{
|
||||
id: 'accounts',
|
||||
title: 'AI Provider Accounts',
|
||||
icon: Bot,
|
||||
badge: 'Optional'
|
||||
}
|
||||
]
|
||||
}
|
||||
]}
|
||||
repoSections={[]}
|
||||
hasRepos={false}
|
||||
onBack={vi.fn()}
|
||||
onSelectSection={vi.fn()}
|
||||
/>
|
||||
</TooltipProvider>
|
||||
)
|
||||
}
|
||||
|
||||
describe('SettingsSidebar', () => {
|
||||
beforeEach(() => {
|
||||
mocks.useSettingsSetupGuideProgress.mockReset()
|
||||
mocks.useSettingsSetupGuideProgress.mockReturnValue(makeSetupGuideProgress())
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = ''
|
||||
})
|
||||
|
||||
it('renders Chat navigation', () => {
|
||||
const settings = getDefaultSettings('/tmp')
|
||||
const sections = buildSettingsNavigationMetadata({
|
||||
isMac: false,
|
||||
isWindows: false,
|
||||
isWebClient: false,
|
||||
repos: []
|
||||
}).filter((section) => section.group === 'interface')
|
||||
const container = document.createElement('div')
|
||||
container.innerHTML = renderToStaticMarkup(
|
||||
<TooltipProvider>
|
||||
<SettingsSidebar
|
||||
activeSectionId="appearance"
|
||||
settings={settings}
|
||||
generalGroups={[{ id: 'interface', title: 'Interface', sections }]}
|
||||
repoSections={[]}
|
||||
hasRepos={false}
|
||||
onBack={vi.fn()}
|
||||
onSelectSection={vi.fn()}
|
||||
/>
|
||||
</TooltipProvider>
|
||||
)
|
||||
const buttons = Array.from(container.querySelectorAll('button'))
|
||||
expect(buttons.some((button) => button.textContent === 'Chat')).toBe(true)
|
||||
})
|
||||
|
||||
it('applies left sidebar appearance styles to the settings navigation', () => {
|
||||
const markup = renderSidebar('orchestration', {
|
||||
...getDefaultSettings('/tmp'),
|
||||
leftSidebarAppearanceMode: 'match-terminal',
|
||||
terminalColorOverrides: {
|
||||
background: '#101820',
|
||||
foreground: '#f0f4f8'
|
||||
}
|
||||
})
|
||||
|
||||
expect(markup).toContain('--worktree-sidebar:#101820')
|
||||
expect(markup).toContain('--worktree-sidebar-foreground:#f0f4f8')
|
||||
})
|
||||
|
||||
it('reserves install state labels for actionable skill states', () => {
|
||||
const markup = renderSidebar()
|
||||
|
||||
expect(markup).not.toContain('Not installed')
|
||||
expect(markup).not.toContain('Installed')
|
||||
expect(markup).not.toContain('Up to date')
|
||||
expect(markup).not.toContain('Checking...')
|
||||
expect(markup).toContain('Update available')
|
||||
expect(markup).toContain('Review skill')
|
||||
expect(markup).toContain('Optional')
|
||||
})
|
||||
|
||||
it('does not render the setup guide row before progress readiness settles', () => {
|
||||
mocks.useSettingsSetupGuideProgress.mockReturnValue(
|
||||
makeSetupGuideProgress({
|
||||
ready: false,
|
||||
doneCount: 7,
|
||||
firstIncompleteStepId: 'setup-script'
|
||||
})
|
||||
)
|
||||
|
||||
expect(renderSidebar()).not.toContain('Onboarding checklist')
|
||||
})
|
||||
|
||||
it('renders incomplete setup progress with the full checklist total', () => {
|
||||
const markup = renderSidebar()
|
||||
|
||||
expect(markup).toContain('Onboarding checklist')
|
||||
expect(markup).toContain('Onboarding checklist, 5 of 8 done. Show setup guide.')
|
||||
expect(markup).toContain('5 of 8 setup steps complete')
|
||||
})
|
||||
|
||||
it('does not render the setup guide row after every checklist step is complete', () => {
|
||||
mocks.useSettingsSetupGuideProgress.mockReturnValue(
|
||||
makeSetupGuideProgress({
|
||||
doneCount: 8,
|
||||
firstIncompleteStepId: null
|
||||
})
|
||||
)
|
||||
|
||||
expect(renderSidebar()).not.toContain('Onboarding checklist')
|
||||
})
|
||||
|
||||
it('keeps the setup guide row available from Settings when incomplete', () => {
|
||||
const markup = renderSidebar('setup-guide')
|
||||
|
||||
expect(markup).toContain('aria-current="page"')
|
||||
expect(markup).toContain('Onboarding checklist')
|
||||
})
|
||||
})
|
||||
@@ -52,57 +52,6 @@ function button(label: string): HTMLButtonElement {
|
||||
}
|
||||
|
||||
describe('SshTargetForm', () => {
|
||||
it('opens add mode as a dialog with primary fields in the viewport', async () => {
|
||||
const root = await renderForm({})
|
||||
expect(document.body.textContent).toContain('Add SSH host')
|
||||
expect(document.body.textContent).toContain(
|
||||
'Add a persistent machine you can log into over SSH.'
|
||||
)
|
||||
expect(document.querySelector('#ssh-target-host')).not.toBeNull()
|
||||
expect(document.querySelector('#ssh-target-label')).not.toBeNull()
|
||||
expect(document.body.textContent).toContain('Add host')
|
||||
expect(document.body.textContent).not.toContain('Editing')
|
||||
act(() => root.unmount())
|
||||
})
|
||||
|
||||
it('pins header and footer outside the scroll body', async () => {
|
||||
const root = await renderForm({})
|
||||
const content = document.querySelector('[data-slot="dialog-content"]')
|
||||
const header = document.querySelector('[data-slot="dialog-header"]')
|
||||
const footer = document.querySelector('[data-slot="dialog-footer"]')
|
||||
if (!content || !header || !footer) {
|
||||
throw new Error('missing dialog chrome')
|
||||
}
|
||||
expect(content.className).toContain('overflow-hidden')
|
||||
expect(content.className).toContain('flex-col')
|
||||
expect(header.className).toContain('shrink-0')
|
||||
expect(footer.className).toContain('shrink-0')
|
||||
const scrollBody = content.querySelector('.overflow-y-auto')
|
||||
expect(scrollBody).not.toBeNull()
|
||||
expect(scrollBody?.contains(header)).toBe(false)
|
||||
expect(scrollBody?.contains(footer)).toBe(false)
|
||||
act(() => root.unmount())
|
||||
})
|
||||
|
||||
it('shows edit title and context chip for the target being edited', async () => {
|
||||
const root = await renderForm({
|
||||
editingId: 'target-1',
|
||||
form: {
|
||||
...EMPTY_FORM,
|
||||
label: 'dev-box',
|
||||
host: 'dev-box.lan',
|
||||
username: 'alice',
|
||||
port: '2222'
|
||||
}
|
||||
})
|
||||
expect(document.body.textContent).toContain('Edit SSH host')
|
||||
expect(document.body.textContent).toContain('Save Changes')
|
||||
expect(document.body.textContent).toContain('Editing')
|
||||
expect(document.body.textContent).toContain('dev-box')
|
||||
expect(document.body.textContent).toContain('alice@dev-box.lan:2222')
|
||||
act(() => root.unmount())
|
||||
})
|
||||
|
||||
it('submits via the primary action and closes via Cancel', async () => {
|
||||
const onSave = vi.fn()
|
||||
const onOpenChange = vi.fn()
|
||||
@@ -120,13 +69,6 @@ describe('SshTargetForm', () => {
|
||||
act(() => root.unmount())
|
||||
})
|
||||
|
||||
it('does not render dialog content when closed', async () => {
|
||||
const root = await renderForm({ open: false })
|
||||
expect(document.querySelector('#ssh-target-host')).toBeNull()
|
||||
expect(document.body.textContent).not.toContain('Add SSH host')
|
||||
act(() => root.unmount())
|
||||
})
|
||||
|
||||
it('collapses Advanced again after cancel and reopen', async () => {
|
||||
let form: EditingTarget = EMPTY_FORM
|
||||
const onFormChange = vi.fn((updater: (prev: EditingTarget) => EditingTarget) => {
|
||||
@@ -209,19 +151,4 @@ describe('SshTargetForm', () => {
|
||||
expect(onOpenChange).not.toHaveBeenCalled()
|
||||
act(() => root.unmount())
|
||||
})
|
||||
|
||||
it('opens Advanced by default when the target already has advanced values', async () => {
|
||||
const root = await renderForm({
|
||||
editingId: 'target-1',
|
||||
form: {
|
||||
...EMPTY_FORM,
|
||||
label: 'bastion',
|
||||
host: 'bastion.example',
|
||||
jumpHost: 'jump.example'
|
||||
}
|
||||
})
|
||||
expect(button('Advanced').getAttribute('data-state')).toBe('open')
|
||||
expect(document.body.textContent).toContain('Jump Host')
|
||||
act(() => root.unmount())
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,219 +0,0 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import React, { act } from 'react'
|
||||
import { createRoot, type Root } from 'react-dom/client'
|
||||
import { renderToStaticMarkup } from 'react-dom/server'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { getDefaultSettings } from '../../../../../shared/constants'
|
||||
import type { Project } from '../../../../../shared/project-types'
|
||||
import type { Repo } from '../../../../../shared/repo-types'
|
||||
import type { TerminalTab } from '../../../../../shared/terminal-tab-types'
|
||||
import type { AgentStatusEntry } from '../../../../../shared/agent-status-types'
|
||||
import { useAppStore } from '@/store/index'
|
||||
import { getRepositoryPaneSearchEntries } from './repository-search'
|
||||
import { RepositoryPane } from './RepositoryPane'
|
||||
import { matchesRepositoryIdentitySearch } from './identity/repository-identity-search'
|
||||
import { matchesSettingsSearch } from '../settings-search'
|
||||
import { TooltipProvider } from '../../ui/tooltip'
|
||||
|
||||
let container: HTMLDivElement
|
||||
let root: Root
|
||||
|
||||
const repo: Repo = {
|
||||
id: 'repo-1',
|
||||
path: '/tmp/repo',
|
||||
displayName: 'Example Repo',
|
||||
badgeColor: '#000000',
|
||||
addedAt: 1,
|
||||
kind: 'git'
|
||||
}
|
||||
|
||||
const project: Project = {
|
||||
id: 'project-1',
|
||||
displayName: 'Example Project',
|
||||
badgeColor: '#000000',
|
||||
sourceRepoIds: ['repo-1'],
|
||||
createdAt: 1,
|
||||
updatedAt: 1
|
||||
}
|
||||
|
||||
function terminalTab(id: string, worktreeId: string): TerminalTab {
|
||||
return {
|
||||
id,
|
||||
ptyId: null,
|
||||
worktreeId,
|
||||
title: 'Terminal',
|
||||
customTitle: null,
|
||||
color: null,
|
||||
sortOrder: 0,
|
||||
createdAt: 1
|
||||
}
|
||||
}
|
||||
|
||||
function activeAgent(paneKey: string, worktreeId: string): AgentStatusEntry {
|
||||
return {
|
||||
state: 'working',
|
||||
prompt: 'Implement runtime switch',
|
||||
updatedAt: 1,
|
||||
stateStartedAt: 1,
|
||||
agentType: 'codex',
|
||||
paneKey,
|
||||
tabId: paneKey.split(':')[0],
|
||||
worktreeId,
|
||||
stateHistory: []
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
useAppStore.setState(useAppStore.getInitialState(), true)
|
||||
container = document.createElement('div')
|
||||
document.body.appendChild(container)
|
||||
root = createRoot(container)
|
||||
// Provide the fs bridge needed by mounted WorktreeSymlinksSection path suggestions.
|
||||
;(window as unknown as { api: unknown }).api = {
|
||||
preflight: { detectAgents: async () => [] },
|
||||
fs: { readDir: () => Promise.resolve([]) }
|
||||
}
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
act(() => {
|
||||
root.unmount()
|
||||
})
|
||||
container.remove()
|
||||
delete (window as unknown as { api?: unknown }).api
|
||||
useAppStore.setState(useAppStore.getInitialState(), true)
|
||||
})
|
||||
|
||||
describe('RepositoryPane search entries', () => {
|
||||
it('keeps renamed hook sections reachable through settings search', () => {
|
||||
const entries = getRepositoryPaneSearchEntries(repo, { isLocalWindowsProject: true })
|
||||
|
||||
expect(matchesSettingsSearch('setup script', entries)).toBe(true)
|
||||
expect(matchesSettingsSearch('archive script', entries)).toBe(true)
|
||||
expect(matchesSettingsSearch('setup command', entries)).toBe(true)
|
||||
expect(matchesSettingsSearch('archive command', entries)).toBe(true)
|
||||
expect(matchesSettingsSearch('advanced', entries)).toBe(true)
|
||||
expect(matchesSettingsSearch('runtime', entries)).toBe(true)
|
||||
expect(matchesSettingsSearch('wsl', entries)).toBe(true)
|
||||
expect(matchesSettingsSearch('windows host', entries)).toBe(true)
|
||||
expect(matchesSettingsSearch('command source', entries)).toBe(true)
|
||||
expect(matchesSettingsSearch('local settings scripts', entries)).toBe(true)
|
||||
expect(matchesSettingsSearch('../worktrees', entries)).toBe(true)
|
||||
expect(matchesSettingsSearch('worktree path', entries)).toBe(true)
|
||||
})
|
||||
|
||||
it('includes each project search section once', () => {
|
||||
const entries = getRepositoryPaneSearchEntries(repo)
|
||||
|
||||
expect(entries.filter(({ title }) => title === 'Project Icon')).toHaveLength(1)
|
||||
expect(entries.filter(({ title }) => title === 'Default Worktree Base')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('omits project runtime search for remote or unsupported repos', () => {
|
||||
expect(matchesSettingsSearch('project runtime', getRepositoryPaneSearchEntries(repo))).toBe(
|
||||
false
|
||||
)
|
||||
expect(
|
||||
matchesSettingsSearch(
|
||||
'project runtime',
|
||||
getRepositoryPaneSearchEntries(
|
||||
{
|
||||
...repo,
|
||||
connectionId: 'builder',
|
||||
executionHostId: 'ssh:builder'
|
||||
},
|
||||
{ windowsRuntimeSupported: true }
|
||||
)
|
||||
)
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('matches project identity searches on display name and path only', () => {
|
||||
expect(matchesRepositoryIdentitySearch('example repo', repo)).toBe(true)
|
||||
expect(matchesRepositoryIdentitySearch('/tmp/repo', repo)).toBe(true)
|
||||
expect(matchesRepositoryIdentitySearch('setup script', repo)).toBe(false)
|
||||
})
|
||||
|
||||
it('renders full hook controls when search matches the project name', () => {
|
||||
useAppStore.setState({
|
||||
settingsSearchQuery: 'Example Repo',
|
||||
settingsSearchInputQuery: 'Example Repo'
|
||||
})
|
||||
|
||||
try {
|
||||
const html = renderToStaticMarkup(
|
||||
React.createElement(
|
||||
TooltipProvider,
|
||||
null,
|
||||
React.createElement(RepositoryPane, {
|
||||
repo,
|
||||
yamlHooks: null,
|
||||
hasHooksFile: false,
|
||||
hooksInspectionReady: true,
|
||||
mayNeedUpdate: false,
|
||||
updateRepo: vi.fn(),
|
||||
removeProject: vi.fn()
|
||||
})
|
||||
)
|
||||
)
|
||||
|
||||
expect(html).toContain('Worktree Hooks')
|
||||
expect(html).toContain('Setup Script')
|
||||
expect(html).toContain('Archive Script')
|
||||
expect(html).toContain('Custom GitHub Issue Command')
|
||||
} finally {
|
||||
useAppStore.setState({
|
||||
settingsSearchQuery: '',
|
||||
settingsSearchInputQuery: ''
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
it('warns about live terminals and active tasks before project runtime changes', () => {
|
||||
const worktreeId = 'repo-1::/tmp/repo'
|
||||
useAppStore.setState({
|
||||
settingsSearchQuery: 'Example Repo',
|
||||
settingsSearchInputQuery: 'Example Repo',
|
||||
settings: getDefaultSettings('/tmp'),
|
||||
tabsByWorktree: {
|
||||
[worktreeId]: [terminalTab('tab-1', worktreeId), terminalTab('tab-2', worktreeId)]
|
||||
},
|
||||
ptyIdsByTabId: {
|
||||
'tab-1': ['pty-1'],
|
||||
'tab-2': ['pty-2']
|
||||
},
|
||||
agentStatusByPaneKey: {
|
||||
'tab-2:0': activeAgent('tab-2:0', worktreeId)
|
||||
}
|
||||
})
|
||||
|
||||
act(() => {
|
||||
root.render(
|
||||
React.createElement(
|
||||
TooltipProvider,
|
||||
null,
|
||||
React.createElement(RepositoryPane, {
|
||||
repo,
|
||||
project,
|
||||
yamlHooks: null,
|
||||
hasHooksFile: false,
|
||||
hooksInspectionReady: true,
|
||||
mayNeedUpdate: false,
|
||||
updateRepo: vi.fn(),
|
||||
removeProject: vi.fn(),
|
||||
isLocalWindowsProject: true,
|
||||
wslAvailable: true,
|
||||
wslDistros: ['Ubuntu-24.04'],
|
||||
wslCapabilitiesLoading: false,
|
||||
updateProject: vi.fn()
|
||||
})
|
||||
)
|
||||
)
|
||||
})
|
||||
|
||||
expect(container.textContent).toContain('2 live terminals')
|
||||
expect(container.textContent).toContain('1 active task')
|
||||
expect(container.textContent).toContain('finish or restart')
|
||||
})
|
||||
})
|
||||
-23
@@ -158,32 +158,9 @@ describe('RepositoryWorktreeDefaultsSection — worktree path', () => {
|
||||
|
||||
expect(updateRepo).toHaveBeenCalledWith('repo-1', { worktreeBasePath: undefined })
|
||||
})
|
||||
|
||||
it('calls updateRepo with undefined when the value is whitespace-only', () => {
|
||||
const updateRepo = vi.fn()
|
||||
render({ ...BASE_REPO, worktreeBasePath: '../worktrees' }, updateRepo)
|
||||
|
||||
const input = getWorktreePathInput()
|
||||
typeText(input, ' ')
|
||||
blurInput(input)
|
||||
|
||||
expect(updateRepo).toHaveBeenCalledWith('repo-1', { worktreeBasePath: undefined })
|
||||
})
|
||||
})
|
||||
|
||||
describe('RepositoryWorktreeDefaultsSection — external visibility', () => {
|
||||
it('shows the inherited effective value without stamping the repo', () => {
|
||||
render(BASE_REPO, vi.fn(), {
|
||||
settings: {
|
||||
workspaceDir: '/home/user/orca/workspaces',
|
||||
worktreeVisibilityDefaults: { external: 'show' }
|
||||
}
|
||||
})
|
||||
|
||||
expect(container.textContent).toContain('Using global: Show')
|
||||
expect(container.querySelector('select')?.value).toBe('global')
|
||||
})
|
||||
|
||||
it('clears the override with the remote-safe null sentinel and refreshes classification', async () => {
|
||||
const updateRepo = vi.fn().mockResolvedValue(true)
|
||||
const refreshRepo = vi.fn().mockResolvedValue(true)
|
||||
|
||||
@@ -89,28 +89,7 @@ async function openPicker(): Promise<ReturnType<typeof userEvent.setup>> {
|
||||
return user
|
||||
}
|
||||
|
||||
async function openPickerWith(result: Record<string, unknown>): Promise<void> {
|
||||
listConfigHosts.mockResolvedValue({ hasMore: false, ...result })
|
||||
const user = userEvent.setup()
|
||||
render(<AddRemoteHostDialog mode="ssh" onOpenChange={vi.fn()} />)
|
||||
await user.click(screen.getByRole('button', { name: /Fill from/ }))
|
||||
}
|
||||
|
||||
describe('SSH config picker host selection', () => {
|
||||
it('drops a resolve that lands after the user backs out of the picker', async () => {
|
||||
const pending = deferred<SshConfigHostResolution>()
|
||||
resolveConfigHost.mockReturnValueOnce(pending.promise)
|
||||
const user = await openPicker()
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /alpha/ }))
|
||||
await user.click(screen.getByRole('button', { name: 'Back' }))
|
||||
pending.resolve(resolution('alpha', 'alpha.internal'))
|
||||
await waitFor(() => expect(screen.getByLabelText('Host or alias')).toBeDefined())
|
||||
|
||||
expect((screen.getByLabelText('Host or alias') as HTMLInputElement).value).toBe('')
|
||||
expect(toastMocks.success).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps the host the user settled on when an earlier resolve lands late', async () => {
|
||||
const slowAlpha = deferred<SshConfigHostResolution>()
|
||||
resolveConfigHost.mockReturnValueOnce(slowAlpha.promise)
|
||||
@@ -136,76 +115,4 @@ describe('SSH config picker host selection', () => {
|
||||
'bravo.internal'
|
||||
)
|
||||
})
|
||||
|
||||
it('freezes the other rows while a pick is resolving', async () => {
|
||||
const pending = deferred<SshConfigHostResolution>()
|
||||
resolveConfigHost.mockReturnValueOnce(pending.promise)
|
||||
const user = await openPicker()
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /alpha/ }))
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getByRole('button', { name: /bravo/ }).hasAttribute('disabled')).toBe(true)
|
||||
)
|
||||
await act(async () => {
|
||||
pending.resolve(resolution('alpha', 'alpha.internal'))
|
||||
await pending.promise
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('SSH config picker tombstoned hosts', () => {
|
||||
it('keeps a previously removed host pickable while an adopted one stays disabled', async () => {
|
||||
await openPickerWith({
|
||||
hosts: [
|
||||
configHost('removed', { previouslyRemoved: true }),
|
||||
configHost('kept', { alreadyInOrca: true })
|
||||
],
|
||||
totalHostCount: 2,
|
||||
newHostCount: 0,
|
||||
matchCount: 2
|
||||
})
|
||||
|
||||
const removed = await screen.findByRole('button', { name: /removed/ })
|
||||
expect(removed.hasAttribute('disabled')).toBe(false)
|
||||
expect(removed.textContent).toContain('Removed from Orca')
|
||||
|
||||
const kept = screen.getByRole('button', { name: /kept/ })
|
||||
expect(kept.hasAttribute('disabled')).toBe(true)
|
||||
expect(kept.textContent).toContain('In Orca')
|
||||
})
|
||||
|
||||
it('never claims the config is empty when every host is only tombstoned', async () => {
|
||||
await openPickerWith({
|
||||
hosts: [configHost('removed', { previouslyRemoved: true })],
|
||||
totalHostCount: 1,
|
||||
newHostCount: 0,
|
||||
matchCount: 1
|
||||
})
|
||||
|
||||
const addAll = await screen.findByRole('button', { name: 'No new hosts to add' })
|
||||
expect(addAll.hasAttribute('disabled')).toBe(true)
|
||||
expect(screen.queryByText('No hosts in ~/.ssh/config')).toBeNull()
|
||||
})
|
||||
|
||||
it('still shows the empty state when the config really has no hosts', async () => {
|
||||
await openPickerWith({ hosts: [], totalHostCount: 0, newHostCount: 0, matchCount: 0 })
|
||||
|
||||
expect(await screen.findByText('No hosts in ~/.ssh/config')).toBeDefined()
|
||||
expect(screen.getByRole('button', { name: 'Add all to Orca' }).hasAttribute('disabled')).toBe(
|
||||
true
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('SSH config picker bulk add', () => {
|
||||
it('adds only new hosts and never re-adopts deleted aliases', async () => {
|
||||
const user = await openPicker()
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /Add all 2 to Orca/ }))
|
||||
|
||||
await waitFor(() => expect(importConfig).toHaveBeenCalled())
|
||||
expect(importConfig).toHaveBeenCalledWith()
|
||||
expect(importConfig.mock.calls[0][0]).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,198 +0,0 @@
|
||||
import { renderToStaticMarkup } from 'react-dom/server'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { ComponentProps } from 'react'
|
||||
import { Dialog } from '@/components/ui/dialog'
|
||||
import { TooltipProvider } from '@/components/ui/tooltip'
|
||||
import { AddRepoDialogStepContent } from './AddRepoDialogStepContent'
|
||||
import type { NestedRepoScanResult } from '../../../../shared/project-group-types'
|
||||
|
||||
const nestedScan: NestedRepoScanResult = {
|
||||
selectedPath: '/workspace/platform',
|
||||
selectedPathKind: 'non_git_folder',
|
||||
repos: [
|
||||
{ path: '/workspace/platform/api', displayName: 'api', depth: 1 },
|
||||
{ path: '/workspace/platform/cli', displayName: 'cli', depth: 1 }
|
||||
],
|
||||
truncated: false,
|
||||
timedOut: false,
|
||||
stopped: false,
|
||||
durationMs: 5,
|
||||
maxDepth: 3,
|
||||
maxRepos: 100,
|
||||
timeoutMs: null
|
||||
}
|
||||
|
||||
type StepContentProps = ComponentProps<typeof AddRepoDialogStepContent>
|
||||
|
||||
function renderStepContent(overrides: Partial<StepContentProps>): string {
|
||||
const props: StepContentProps = {
|
||||
step: 'nested',
|
||||
isRuntimeEnvironmentActive: false,
|
||||
activeRuntimeEnvironmentId: null,
|
||||
repoCount: 1,
|
||||
isAdding: false,
|
||||
addProjectBusyLabel: null,
|
||||
nestedScanInProgress: false,
|
||||
nestedScanId: null,
|
||||
serverPath: '',
|
||||
isAddingServerPath: false,
|
||||
cloneUrl: '',
|
||||
cloneDestination: '',
|
||||
cloneError: null,
|
||||
cloneProgress: null,
|
||||
isCloning: false,
|
||||
sshTargets: [],
|
||||
selectedTargetId: null,
|
||||
remotePath: '',
|
||||
remoteError: null,
|
||||
isAddingRemote: false,
|
||||
nestedScan,
|
||||
nestedSelectedPaths: new Set(nestedScan.repos.map((repo) => repo.path)),
|
||||
nestedGroupName: 'platform',
|
||||
createName: '',
|
||||
createParent: '',
|
||||
createError: null,
|
||||
isCreating: false,
|
||||
createDefaultParent: '',
|
||||
createGitAvailability: 'unknown',
|
||||
createRuntimeParentStatus: 'idle',
|
||||
createParentDefaultPending: false,
|
||||
onBrowse: vi.fn(),
|
||||
onOpenCloneStep: vi.fn(),
|
||||
onOpenCreateStep: vi.fn(),
|
||||
onOpenRemoteStep: vi.fn(),
|
||||
onStopNestedScan: vi.fn(),
|
||||
onServerPathChange: vi.fn(),
|
||||
onAddServerPath: vi.fn(),
|
||||
onSelectTarget: vi.fn(),
|
||||
onRemotePathChange: vi.fn(),
|
||||
onAddRemoteRepo: vi.fn(),
|
||||
onConnectTarget: vi.fn(),
|
||||
onCloneUrlChange: vi.fn(),
|
||||
onCloneDestinationChange: vi.fn(),
|
||||
onPickCloneDestination: vi.fn(),
|
||||
onClone: vi.fn(),
|
||||
onNestedGroupNameChange: vi.fn(),
|
||||
onNestedSelectedPathsChange: vi.fn(),
|
||||
onImportNestedRepos: vi.fn(),
|
||||
onOpenNestedRootFolder: vi.fn(),
|
||||
onCreateNameChange: vi.fn(),
|
||||
onCreateParentChange: vi.fn(),
|
||||
onPickCreateParent: vi.fn(),
|
||||
onCreate: vi.fn(),
|
||||
...overrides
|
||||
}
|
||||
|
||||
return renderToStaticMarkup(
|
||||
<TooltipProvider>
|
||||
<Dialog open>
|
||||
<AddRepoDialogStepContent {...props} />
|
||||
</Dialog>
|
||||
</TooltipProvider>
|
||||
)
|
||||
}
|
||||
|
||||
function renderNestedStep(repoCount: number): string {
|
||||
return renderStepContent({ repoCount })
|
||||
}
|
||||
|
||||
describe('AddRepoDialogStepContent nested imports', () => {
|
||||
it('asks the grouping question when no repos exist yet', () => {
|
||||
const html = renderNestedStep(0)
|
||||
|
||||
expect(html).toContain('Group these repositories?')
|
||||
expect(html).toContain('aria-label="Group name"')
|
||||
expect(html).toContain('Yes, import as group')
|
||||
expect(html).toContain('No, import separately')
|
||||
expect(html).not.toContain('>Import</button>')
|
||||
})
|
||||
|
||||
it('shows the same grouping import controls after a repo already exists', () => {
|
||||
const html = renderNestedStep(1)
|
||||
|
||||
expect(html).toContain('Group these repositories?')
|
||||
expect(html).toContain('aria-label="Group name"')
|
||||
expect(html).toContain('Yes, import as group')
|
||||
expect(html).toContain('No, import separately')
|
||||
expect(html).not.toContain('>Import</button>')
|
||||
})
|
||||
|
||||
it('offers opening the parent folder when nested import selection is empty', () => {
|
||||
const html = renderStepContent({ nestedSelectedPaths: new Set() })
|
||||
|
||||
expect(html).toContain('No repositories are selected')
|
||||
expect(html).toContain('Open as Folder')
|
||||
expect(html).toMatch(/<button[^>]*disabled=""[^>]*>No, import separately<\/button>/)
|
||||
expect(html).toMatch(/<button[^>]*disabled=""[^>]*>Yes, import as group<\/button>/)
|
||||
})
|
||||
|
||||
it('offers host browsing for remote create project locations', () => {
|
||||
const html = renderStepContent({
|
||||
step: 'create',
|
||||
isRuntimeEnvironmentActive: true,
|
||||
activeRuntimeEnvironmentId: 'env-1'
|
||||
})
|
||||
|
||||
expect(html).toContain('Create a new project')
|
||||
expect(html).toContain('host folder not selected')
|
||||
})
|
||||
|
||||
it('opens the host browser for a filled path that Windows did not hand over', () => {
|
||||
const remote = {
|
||||
step: 'server-path' as const,
|
||||
isRuntimeEnvironmentActive: true,
|
||||
activeRuntimeEnvironmentId: 'env-1',
|
||||
serverPath: '/home/me/app'
|
||||
}
|
||||
const browser = 'Navigate to a directory and click Select to choose it.'
|
||||
expect(renderStepContent(remote)).toContain(browser)
|
||||
expect(renderStepContent({ ...remote, serverPathPrefilled: true })).not.toContain(browser)
|
||||
})
|
||||
|
||||
it('offers host browsing for remote clone destinations', () => {
|
||||
const html = renderStepContent({
|
||||
step: 'clone',
|
||||
isRuntimeEnvironmentActive: true,
|
||||
activeRuntimeEnvironmentId: 'env-1'
|
||||
})
|
||||
|
||||
expect(html).toContain('Clone from URL')
|
||||
expect(html).toContain('aria-label="Browse host filesystem"')
|
||||
})
|
||||
|
||||
it('falls back to the start step when no paired server reaches the SSH hosts', () => {
|
||||
const html = renderStepContent({ step: 'remote', selectedTargetId: 'openclaw-2' })
|
||||
|
||||
expect(html).toContain('Browse folder')
|
||||
expect(html).not.toContain('Host path')
|
||||
})
|
||||
|
||||
it('uses the standard add step for remote Orca server hosts', () => {
|
||||
const html = renderStepContent({
|
||||
step: 'add',
|
||||
isRuntimeEnvironmentActive: true,
|
||||
activeRuntimeEnvironmentId: 'env-1',
|
||||
browseHostKind: 'runtime'
|
||||
})
|
||||
|
||||
expect(html).toContain('Browse folder')
|
||||
expect(html).toContain('Existing Git repository or folder on this host')
|
||||
expect(html).toContain('Clone from URL')
|
||||
expect(html).toContain('Create new project')
|
||||
expect(html).not.toContain('Browse host')
|
||||
expect(html).not.toContain('Create on host')
|
||||
expect(html).not.toContain('Want to import many repos at once?')
|
||||
})
|
||||
|
||||
it('opens the in-app filesystem browser for a paired runtime', () => {
|
||||
const html = renderStepContent({
|
||||
step: 'server-path',
|
||||
isRuntimeEnvironmentActive: true,
|
||||
activeRuntimeEnvironmentId: 'paired-host'
|
||||
})
|
||||
|
||||
expect(html).toContain('Browse host filesystem')
|
||||
expect(html).toContain('Navigate to a directory and click Select to choose it.')
|
||||
expect(html).toContain('Select folder')
|
||||
})
|
||||
})
|
||||
@@ -1,8 +1,9 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { act, useState, type ComponentProps } from 'react'
|
||||
import { act } from 'react'
|
||||
|
||||
import { createRoot, type Root } from 'react-dom/client'
|
||||
import { renderToStaticMarkup } from 'react-dom/server'
|
||||
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { AddRepoNestedImportStep } from './AddRepoNestedImportStep'
|
||||
import { TooltipProvider } from '@/components/ui/tooltip'
|
||||
@@ -26,30 +27,6 @@ const scan: NestedRepoScanResult = {
|
||||
timeoutMs: null
|
||||
}
|
||||
|
||||
function renderStepMarkup(
|
||||
overrides: Partial<ComponentProps<typeof AddRepoNestedImportStep>> = {}
|
||||
): string {
|
||||
return renderToStaticMarkup(
|
||||
<TooltipProvider>
|
||||
<Dialog open>
|
||||
<AddRepoNestedImportStep
|
||||
scan={scan}
|
||||
groupName=""
|
||||
selectedPaths={new Set(scan.repos.map((repo) => repo.path))}
|
||||
isAdding={false}
|
||||
scanInProgress={false}
|
||||
onGroupNameChange={vi.fn()}
|
||||
onSelectedPathsChange={vi.fn()}
|
||||
onImport={vi.fn()}
|
||||
onOpenAsFolder={vi.fn()}
|
||||
onStopScan={vi.fn()}
|
||||
{...overrides}
|
||||
/>
|
||||
</Dialog>
|
||||
</TooltipProvider>
|
||||
)
|
||||
}
|
||||
|
||||
function findButton(container: HTMLElement, label: string): HTMLButtonElement {
|
||||
const button = Array.from(container.querySelectorAll('button')).find((entry) =>
|
||||
entry.textContent?.includes(label)
|
||||
@@ -73,36 +50,6 @@ describe('AddRepoNestedImportStep', () => {
|
||||
container = null
|
||||
})
|
||||
|
||||
it('asks whether the selected folder should be grouped', () => {
|
||||
const html = renderStepMarkup()
|
||||
|
||||
expect(html).toContain('Import repositories from folder')
|
||||
expect(html).toContain('Found 3 repositories in')
|
||||
expect(html).toContain('/workspace/platform')
|
||||
expect(html).toContain('aria-label="Group name"')
|
||||
expect(html).not.toContain('What is a')
|
||||
expect(html).toContain('Group these repositories?')
|
||||
expect(html).toContain('Choose this if these projects belong together')
|
||||
expect(html).toContain('Orca will group them and let you work from the parent folder')
|
||||
expect(html).toContain('No, import separately')
|
||||
expect(html).toContain('Yes, import as group')
|
||||
expect(html).toContain('payments/api')
|
||||
expect(html).toContain('billing/api')
|
||||
expect(html).not.toContain('disabled=""')
|
||||
expect(html).not.toContain('>Back</button>')
|
||||
expect(html).not.toContain('Project group')
|
||||
})
|
||||
|
||||
it('disables both import actions while scanning', () => {
|
||||
const html = renderStepMarkup({ scanInProgress: true })
|
||||
|
||||
expect(html).toContain('Group these repositories?')
|
||||
expect(html).toContain('No, import separately')
|
||||
expect(html).toContain('Yes, import as group')
|
||||
expect(html).toMatch(/<button[^>]*disabled=""[^>]*>No, import separately<\/button>/)
|
||||
expect(html).toMatch(/<button[^>]*disabled=""[^>]*>Yes, import as group<\/button>/)
|
||||
})
|
||||
|
||||
it('maps the group choice to grouped import and the separate choice to separate import', () => {
|
||||
const onImport = vi.fn()
|
||||
const host = document.createElement('div')
|
||||
@@ -140,51 +87,6 @@ describe('AddRepoNestedImportStep', () => {
|
||||
expect(onImport).toHaveBeenNthCalledWith(2, 'separate')
|
||||
})
|
||||
|
||||
it('shows progress only on the clicked import action', () => {
|
||||
const onImport = vi.fn()
|
||||
const host = document.createElement('div')
|
||||
container = host
|
||||
document.body.appendChild(host)
|
||||
root = createRoot(host)
|
||||
|
||||
function Harness(): React.JSX.Element {
|
||||
const [isAdding, setIsAdding] = useState(false)
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<Dialog open>
|
||||
<AddRepoNestedImportStep
|
||||
scan={scan}
|
||||
groupName=""
|
||||
selectedPaths={new Set(scan.repos.map((repo) => repo.path))}
|
||||
isAdding={isAdding}
|
||||
scanInProgress={false}
|
||||
onGroupNameChange={vi.fn()}
|
||||
onSelectedPathsChange={vi.fn()}
|
||||
onImport={(mode) => {
|
||||
onImport(mode)
|
||||
setIsAdding(true)
|
||||
}}
|
||||
onOpenAsFolder={vi.fn()}
|
||||
onStopScan={vi.fn()}
|
||||
/>
|
||||
</Dialog>
|
||||
</TooltipProvider>
|
||||
)
|
||||
}
|
||||
|
||||
act(() => {
|
||||
root?.render(<Harness />)
|
||||
})
|
||||
|
||||
act(() => {
|
||||
findButton(host, 'Yes, import as group').click()
|
||||
})
|
||||
|
||||
expect(onImport).toHaveBeenCalledWith('group')
|
||||
expect(findButton(host, 'Yes, import as group').querySelector('.animate-spin')).not.toBeNull()
|
||||
expect(findButton(host, 'No, import separately').querySelector('.animate-spin')).toBeNull()
|
||||
})
|
||||
|
||||
it('offers opening the parent folder when no repositories are selected', () => {
|
||||
const onOpenAsFolder = vi.fn()
|
||||
const host = document.createElement('div')
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { renderToStaticMarkup } from 'react-dom/server'
|
||||
import type { ReactNode } from 'react'
|
||||
import { act } from 'react'
|
||||
import { createRoot, type Root } from 'react-dom/client'
|
||||
@@ -83,17 +82,6 @@ function makeAllDoneProgress(
|
||||
})
|
||||
}
|
||||
|
||||
function makeOnlyBrowserIncompleteProgress(): FeatureWallSetupProgress {
|
||||
return makeAllDoneProgress({
|
||||
stepDone: {
|
||||
...makeAllDoneProgress().stepDone,
|
||||
browser: false
|
||||
},
|
||||
coreDoneCount: 7,
|
||||
coreTotal: 8
|
||||
})
|
||||
}
|
||||
|
||||
const mountedRoots: Root[] = []
|
||||
|
||||
async function renderSetupGuideSidebarEntry(): Promise<{
|
||||
@@ -132,41 +120,6 @@ describe('SetupGuideSidebarEntry', () => {
|
||||
mocks.useSetupGuideProgress.mockReturnValue(makeProgress())
|
||||
})
|
||||
|
||||
it('does not render before persisted UI hydration is ready', () => {
|
||||
persistedUIReady = false
|
||||
|
||||
expect(renderToStaticMarkup(<SetupGuideSidebarEntry />)).not.toContain('Onboarding checklist')
|
||||
})
|
||||
|
||||
it('does not render before setup progress readiness settles', () => {
|
||||
mocks.useSetupGuideProgress.mockReturnValue(makeProgress({ ready: false }))
|
||||
|
||||
expect(renderToStaticMarkup(<SetupGuideSidebarEntry />)).not.toContain('Onboarding checklist')
|
||||
})
|
||||
|
||||
it('does not render after setup is complete and progress is ready', () => {
|
||||
mocks.useSetupGuideProgress.mockReturnValue(makeAllDoneProgress())
|
||||
|
||||
expect(renderToStaticMarkup(<SetupGuideSidebarEntry />)).not.toContain('Onboarding checklist')
|
||||
})
|
||||
|
||||
it('renders for fresh active users when only the browser step is incomplete', () => {
|
||||
mocks.useSetupGuideProgress.mockReturnValue(makeOnlyBrowserIncompleteProgress())
|
||||
|
||||
expect(renderToStaticMarkup(<SetupGuideSidebarEntry />)).toContain('Onboarding checklist')
|
||||
})
|
||||
|
||||
it('does not render when the sidebar entry was dismissed with only browser incomplete', () => {
|
||||
mocks.useSetupGuideProgress.mockReturnValue(makeOnlyBrowserIncompleteProgress())
|
||||
setupGuideSidebarDismissed = true
|
||||
|
||||
expect(renderToStaticMarkup(<SetupGuideSidebarEntry />)).not.toContain('Onboarding checklist')
|
||||
})
|
||||
|
||||
it('renders after persisted UI and setup progress are ready when setup is incomplete', () => {
|
||||
expect(renderToStaticMarkup(<SetupGuideSidebarEntry />)).toContain('Onboarding checklist')
|
||||
})
|
||||
|
||||
it('keeps the visible entry mounted during transient setup progress refreshes', async () => {
|
||||
const { container, rerender } = await renderSetupGuideSidebarEntry()
|
||||
|
||||
|
||||
@@ -3,8 +3,7 @@
|
||||
import { act } from 'react'
|
||||
import { createRoot, type Root } from 'react-dom/client'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import WorkspaceKanbanSearchField, { overlayReserve } from './WorkspaceKanbanSearchField'
|
||||
|
||||
import WorkspaceKanbanSearchField from './WorkspaceKanbanSearchField'
|
||||
;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true
|
||||
|
||||
let container: HTMLDivElement
|
||||
@@ -97,15 +96,6 @@ describe('WorkspaceKanbanSearchField', () => {
|
||||
expect(onClear).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('hides the visual match count from assistive tech but keeps the clear button named', () => {
|
||||
renderField({ query: 'orca', matchCount: 3, totalCount: 12 })
|
||||
|
||||
const count = container.querySelector('span[aria-hidden="true"]')
|
||||
expect(count?.textContent).toBe('3 / 12')
|
||||
expect(clearButton()?.getAttribute('aria-hidden')).toBeNull()
|
||||
expect(clearButton()?.getAttribute('aria-label')).toBe('Clear search')
|
||||
})
|
||||
|
||||
it('withholds counts for text that never narrows the board', () => {
|
||||
renderField({ query: ' ', isFiltering: false, matchCount: 12, totalCount: 12 })
|
||||
|
||||
@@ -159,18 +149,6 @@ describe('WorkspaceKanbanSearchField', () => {
|
||||
expect(onClose).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('says so when a query was discarded for length instead of silently not filtering', () => {
|
||||
renderField({ query: 'x'.repeat(3000), isFiltering: false, isTooLarge: true })
|
||||
|
||||
expect(container.textContent).toContain('Too long')
|
||||
expect(input().getAttribute('aria-invalid')).toBe('true')
|
||||
expect(liveRegion().textContent).toContain('too long')
|
||||
|
||||
renderField({ query: 'orca', matchCount: 3, totalCount: 12 })
|
||||
expect(container.textContent).not.toContain('Too long')
|
||||
expect(input().getAttribute('aria-invalid')).toBeNull()
|
||||
})
|
||||
|
||||
it('leaves Escape to the IME while a composition is in progress', () => {
|
||||
renderField({ query: '検索', matchCount: 1, totalCount: 12 })
|
||||
|
||||
@@ -188,26 +166,4 @@ describe('WorkspaceKanbanSearchField', () => {
|
||||
expect(onClear).not.toHaveBeenCalled()
|
||||
expect(onClose).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps focus in the field after the clear button unmounts itself', () => {
|
||||
renderField({ query: 'orca', matchCount: 3, totalCount: 12 })
|
||||
act(() => {
|
||||
input().focus()
|
||||
clearButton()?.click()
|
||||
})
|
||||
|
||||
expect(onClear).toHaveBeenCalledOnce()
|
||||
expect(document.activeElement).toBe(input())
|
||||
})
|
||||
|
||||
it('reserves overlay width in font-relative units, capped so text stays visible', () => {
|
||||
// Multi-digit counters need font-relative width rather than a fixed reserve.
|
||||
expect(overlayReserve('298 / 1024')).toContain('10ch')
|
||||
expect(overlayReserve('3 / 9')).toContain('5ch')
|
||||
|
||||
// Cap reserve so wide counters cannot consume the typed-text area.
|
||||
expect(overlayReserve('298 / 1024')).toContain('55%')
|
||||
|
||||
expect(overlayReserve(null)).toBe('32px')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,207 +0,0 @@
|
||||
import { renderToStaticMarkup } from 'react-dom/server'
|
||||
import type { ReactNode } from 'react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { GlobalSettings } from '../../../../shared/global-settings-types'
|
||||
import type { Repo } from '../../../../shared/repo-types'
|
||||
import type { WorktreeCardProperty } from '../../../../shared/ui-chrome-types'
|
||||
import type { Worktree } from '../../../../shared/worktree/types'
|
||||
|
||||
const fetchHostedReviewForBranch = vi.fn()
|
||||
const fetchIssue = vi.fn()
|
||||
const fetchLinearIssue = vi.fn()
|
||||
const openModal = vi.fn()
|
||||
const updateWorktreeMeta = vi.fn()
|
||||
|
||||
let worktreeCardProperties: WorktreeCardProperty[] = []
|
||||
let settings: Partial<GlobalSettings> | null = null
|
||||
const WORKTREE_CARD_IMPORT_TIMEOUT_MS = 15_000
|
||||
|
||||
vi.mock('@/store', () => {
|
||||
const getState = () => ({
|
||||
deleteStateByWorktreeId: {},
|
||||
fetchHostedReviewForBranch,
|
||||
fetchIssue,
|
||||
fetchLinearIssue,
|
||||
gitConflictOperationByWorktree: {},
|
||||
hostedReviewCache: {},
|
||||
issueCache: {},
|
||||
linearIssueCache: {},
|
||||
openModal,
|
||||
projectGroups: [],
|
||||
remoteBranchConflictByWorktreeId: {},
|
||||
settings,
|
||||
sshConnectionStates: new Map(),
|
||||
sshTargetLabels: new Map(),
|
||||
updateWorktreeMeta,
|
||||
workspacePortScan: null,
|
||||
worktreeCardProperties
|
||||
})
|
||||
return {
|
||||
useAppStore: Object.assign((selector: (state: unknown) => unknown) => selector(getState()), {
|
||||
getState,
|
||||
subscribe: () => () => {}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/lib/worktree-activation', () => ({
|
||||
activateAndRevealWorktree: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/components/ui/tooltip', () => ({
|
||||
Tooltip: ({ children }: { children: ReactNode }) => <>{children}</>,
|
||||
TooltipContent: ({ children }: { children: ReactNode }) => <>{children}</>,
|
||||
TooltipTrigger: ({ children }: { children: ReactNode }) => <>{children}</>
|
||||
}))
|
||||
|
||||
vi.mock('./use-worktree-activity-status', () => ({
|
||||
useWorktreeActivityStatus: () => 'idle'
|
||||
}))
|
||||
|
||||
vi.mock('./use-worktree-sleep-state', () => ({
|
||||
useIsSleepingWorktree: () => false
|
||||
}))
|
||||
|
||||
vi.mock('./CacheTimer', () => ({
|
||||
default: () => null,
|
||||
usePromptCacheCountdownStartedAt: () => null
|
||||
}))
|
||||
|
||||
vi.mock('./WorktreeCardAgents', () => ({
|
||||
default: () => null
|
||||
}))
|
||||
|
||||
vi.mock('./WorktreeContextMenu', () => ({
|
||||
default: ({ children }: { children: ReactNode }) => <>{children}</>,
|
||||
WORKTREE_NATIVE_CONTEXT_MENU_ATTR: 'data-worktree-native-context-menu',
|
||||
WORKTREE_CONTEXT_MENU_SCOPE_ATTR: 'data-orca-context-menu-scope'
|
||||
}))
|
||||
|
||||
function makeRepo(overrides: Partial<Repo> = {}): Repo {
|
||||
return {
|
||||
id: 'repo-1',
|
||||
path: '/repo',
|
||||
displayName: 'orca',
|
||||
badgeColor: '#999999',
|
||||
repoIcon: { type: 'emoji', emoji: '🦊' },
|
||||
addedAt: 1,
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
function makeWorktree(overrides: Partial<Worktree> = {}): Worktree {
|
||||
return {
|
||||
id: 'repo-1::/repo/worktrees/pinned',
|
||||
repoId: 'repo-1',
|
||||
path: '/repo/worktrees/pinned',
|
||||
displayName: 'Pinned tree',
|
||||
branch: 'feature/pinned',
|
||||
head: 'abc123',
|
||||
isBare: false,
|
||||
isMainWorktree: false,
|
||||
comment: '',
|
||||
linkedIssue: null,
|
||||
linkedPR: null,
|
||||
linkedLinearIssue: null,
|
||||
isArchived: false,
|
||||
isUnread: false,
|
||||
isPinned: true,
|
||||
sortOrder: 0,
|
||||
lastActivityAt: 1,
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
describe('WorktreeCard pinned repo icon', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
worktreeCardProperties = []
|
||||
settings = null
|
||||
})
|
||||
|
||||
it(
|
||||
'shows the repo icon and project name for pinned cards even when the repo badge is hidden',
|
||||
async () => {
|
||||
const { default: WorktreeCard } = await import('./WorktreeCard')
|
||||
|
||||
const markup = renderToStaticMarkup(
|
||||
<WorktreeCard
|
||||
worktree={makeWorktree()}
|
||||
repo={makeRepo()}
|
||||
isActive={false}
|
||||
inPinnedSection
|
||||
hideRepoBadge
|
||||
/>
|
||||
)
|
||||
|
||||
expect(markup).toContain('🦊')
|
||||
expect(markup).toContain('Project orca')
|
||||
expect(markup).toContain('>orca</span>')
|
||||
},
|
||||
WORKTREE_CARD_IMPORT_TIMEOUT_MS
|
||||
)
|
||||
|
||||
it(
|
||||
'does not render the leading pinned repo icon for non-pinned cards',
|
||||
async () => {
|
||||
const { default: WorktreeCard } = await import('./WorktreeCard')
|
||||
|
||||
const markup = renderToStaticMarkup(
|
||||
<WorktreeCard
|
||||
worktree={makeWorktree({ isPinned: false })}
|
||||
repo={makeRepo()}
|
||||
isActive={false}
|
||||
/>
|
||||
)
|
||||
|
||||
expect(markup).not.toContain('🦊')
|
||||
expect(markup).not.toContain('Project orca')
|
||||
},
|
||||
WORKTREE_CARD_IMPORT_TIMEOUT_MS
|
||||
)
|
||||
|
||||
it(
|
||||
'uses the pinned-style repo icon in new card style instead of a metadata-row badge',
|
||||
async () => {
|
||||
settings = { compactWorktreeCards: false, experimentalNewWorktreeCardStyle: true }
|
||||
worktreeCardProperties = ['status']
|
||||
const { default: WorktreeCard } = await import('./WorktreeCard')
|
||||
|
||||
const markup = renderToStaticMarkup(
|
||||
<WorktreeCard
|
||||
worktree={makeWorktree({ isPinned: false })}
|
||||
repo={makeRepo()}
|
||||
isActive={false}
|
||||
/>
|
||||
)
|
||||
|
||||
expect(markup).toContain('🦊')
|
||||
expect(markup).toContain('Project orca')
|
||||
expect(markup).toContain('>orca</span>')
|
||||
expect(markup).not.toContain('data-worktree-card-meta-row=""')
|
||||
},
|
||||
WORKTREE_CARD_IMPORT_TIMEOUT_MS
|
||||
)
|
||||
|
||||
it(
|
||||
'shows the project name on ungrouped compact cards and hides it when grouped by project',
|
||||
async () => {
|
||||
settings = { compactWorktreeCards: true, experimentalNewWorktreeCardStyle: false }
|
||||
const { default: WorktreeCard } = await import('./WorktreeCard')
|
||||
const worktree = makeWorktree({ isPinned: false, displayName: 'main' })
|
||||
|
||||
const ungrouped = renderToStaticMarkup(
|
||||
<WorktreeCard worktree={worktree} repo={makeRepo()} isActive={false} />
|
||||
)
|
||||
expect(ungrouped).toContain('>orca</span>')
|
||||
expect(ungrouped).toContain('Project orca')
|
||||
|
||||
const groupedByProject = renderToStaticMarkup(
|
||||
<WorktreeCard worktree={worktree} repo={makeRepo()} isActive={false} hideRepoBadge />
|
||||
)
|
||||
expect(groupedByProject).not.toContain('>orca</span>')
|
||||
expect(groupedByProject).not.toContain('Project orca')
|
||||
},
|
||||
WORKTREE_CARD_IMPORT_TIMEOUT_MS
|
||||
)
|
||||
})
|
||||
@@ -1,204 +0,0 @@
|
||||
import { beforeAll, describe, expect, it, vi } from 'vitest'
|
||||
import type { ProjectGroup } from '../../../../shared/project-group-types'
|
||||
import type { Repo } from '../../../../shared/repo-types'
|
||||
import {
|
||||
createAppStoreModuleMock,
|
||||
createDropdownMenuModuleMock,
|
||||
createProjectHeaderDragModuleMock,
|
||||
createReactVirtualModuleMock,
|
||||
createTooltipModuleMock,
|
||||
createVirtualizedScrollAnchorModuleMock,
|
||||
createWorktreeCardAgentsModuleMock,
|
||||
createWorktreeCardModuleMock,
|
||||
createWorktreeContextMenuModuleMock,
|
||||
createWorktreeTitleInlineRenameModuleMock,
|
||||
loadWorktreeList,
|
||||
mockStore,
|
||||
renderWorktreeListMarkup
|
||||
} from './worktree-list-lineage-card-test-harness'
|
||||
import {
|
||||
makeFolderWorkspacePathStatusMockState,
|
||||
makeFolderWorkspacePathStatusState,
|
||||
makeRepo
|
||||
} from './worktree-list-lineage-card-test-fixtures'
|
||||
import { setLineageFixtureState } from './worktree-list-lineage-store-state'
|
||||
import { setPinnedFixtureState } from './worktree-list-pinned-store-state'
|
||||
|
||||
vi.mock('@/store', () => createAppStoreModuleMock())
|
||||
vi.mock('@tanstack/react-virtual', () => createReactVirtualModuleMock())
|
||||
vi.mock('@/hooks/useVirtualizedScrollAnchor', () => createVirtualizedScrollAnchorModuleMock())
|
||||
vi.mock('./project-header-drag', () => createProjectHeaderDragModuleMock())
|
||||
vi.mock('./WorktreeCard', () => createWorktreeCardModuleMock())
|
||||
vi.mock('./WorktreeCardAgents', () => createWorktreeCardAgentsModuleMock())
|
||||
vi.mock('./WorktreeTitleInlineRename', () => createWorktreeTitleInlineRenameModuleMock())
|
||||
vi.mock('./WorktreeContextMenu', () => createWorktreeContextMenuModuleMock())
|
||||
vi.mock('@/components/ui/tooltip', () => createTooltipModuleMock())
|
||||
vi.mock('@/components/ui/dropdown-menu', () => createDropdownMenuModuleMock())
|
||||
|
||||
function setProjectGroupWithoutWorktreeRowsState(
|
||||
filterRepoIds: string[] = [],
|
||||
collapsedGroups = new Set<string>()
|
||||
): void {
|
||||
const group: ProjectGroup = {
|
||||
id: 'group-1',
|
||||
name: 'Imported Services',
|
||||
parentPath: '/tmp/imported-services',
|
||||
parentGroupId: null,
|
||||
createdFrom: 'folder-scan',
|
||||
tabOrder: 0,
|
||||
isCollapsed: false,
|
||||
color: null,
|
||||
createdAt: 1,
|
||||
updatedAt: 1
|
||||
}
|
||||
const repo: Repo = {
|
||||
...makeRepo(),
|
||||
projectGroupId: group.id
|
||||
}
|
||||
|
||||
mockStore.state = {
|
||||
...makeFolderWorkspacePathStatusMockState(),
|
||||
startupWorktreeRefreshCompleted: true,
|
||||
activeModal: '',
|
||||
activeView: 'terminal',
|
||||
activeWorktreeId: null,
|
||||
agentStatusEpoch: 0,
|
||||
agentStatusByPaneKey: {},
|
||||
browserTabsByWorktree: {},
|
||||
clearPendingRevealWorktreeId: vi.fn(),
|
||||
collapsedGroups,
|
||||
deleteStateByWorktreeId: {},
|
||||
filterRepoIds,
|
||||
...makeFolderWorkspacePathStatusState(),
|
||||
groupBy: 'repo',
|
||||
hideDefaultBranchWorkspace: false,
|
||||
issueCache: {},
|
||||
migrationUnsupportedByPtyId: {},
|
||||
openModal: vi.fn(),
|
||||
pendingRevealWorktree: null,
|
||||
prCache: {},
|
||||
prVisibleRefreshGeneration: 0,
|
||||
projectGroups: [group],
|
||||
ptyIdsByTabId: {},
|
||||
reorderRepos: vi.fn(),
|
||||
reportVisibleGitHubPRRefreshCandidates: vi.fn(),
|
||||
retainedAgentsByPaneKey: {},
|
||||
repos: [repo],
|
||||
runtimePaneTitlesByTabId: {},
|
||||
setFilterRepoIds: vi.fn(),
|
||||
setHideDefaultBranchWorkspace: vi.fn(),
|
||||
setRenamingWorktreeId: vi.fn(),
|
||||
setShowSleepingWorkspaces: vi.fn(),
|
||||
setSortBy: vi.fn(),
|
||||
settings: null,
|
||||
renamingWorktreeId: null,
|
||||
showSleepingWorkspaces: true,
|
||||
sortBy: 'recent',
|
||||
sortEpoch: 0,
|
||||
sshConnectedGeneration: 0,
|
||||
sshConnectionStates: new Map(),
|
||||
sshTargetLabels: new Map(),
|
||||
tabsByWorktree: {},
|
||||
terminalLayoutsByTabId: {},
|
||||
toggleCollapsedGroup: vi.fn(),
|
||||
updateWorktreeMeta: vi.fn(),
|
||||
updateWorktreesMeta: vi.fn(),
|
||||
workspaceHostScope: 'all',
|
||||
workspaceStatuses: [],
|
||||
worktreeCardProperties: ['status', 'inline-agents'],
|
||||
worktreeLineageById: {},
|
||||
worktreesByRepo: {
|
||||
[repo.id]: []
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
describe('WorktreeList lineage child card renderer', () => {
|
||||
beforeAll(async () => {
|
||||
await loadWorktreeList()
|
||||
}, 60_000)
|
||||
|
||||
it('renders project group headers when repos import before worktree rows load', async () => {
|
||||
setProjectGroupWithoutWorktreeRowsState()
|
||||
const markup = await renderWorktreeListMarkup()
|
||||
|
||||
expect(markup).toContain('Imported Services')
|
||||
expect(markup).not.toContain('No workspaces found')
|
||||
})
|
||||
|
||||
it('renders a collapse chevron on project group headers with children', async () => {
|
||||
setProjectGroupWithoutWorktreeRowsState()
|
||||
const markup = await renderWorktreeListMarkup()
|
||||
|
||||
expect(markup).toContain('data-repo-header-collapse-affordance=""')
|
||||
expect(markup).toContain('aria-expanded="true"')
|
||||
})
|
||||
|
||||
it('renders collapsed project group header affordance state', async () => {
|
||||
setProjectGroupWithoutWorktreeRowsState([], new Set(['project-group:group-1']))
|
||||
const markup = await renderWorktreeListMarkup()
|
||||
|
||||
expect(markup).toContain('data-repo-header-collapse-affordance=""')
|
||||
expect(markup).toContain('aria-expanded="false"')
|
||||
expect(markup).toContain('-rotate-90')
|
||||
})
|
||||
|
||||
it('does not render the project collapse affordance on flat section headers', async () => {
|
||||
setLineageFixtureState('none')
|
||||
const markup = await renderWorktreeListMarkup()
|
||||
|
||||
expect(markup).not.toContain('data-repo-header-collapse-affordance=""')
|
||||
})
|
||||
|
||||
it('renders a collapse chevron on status group headers with worktrees', async () => {
|
||||
setLineageFixtureState('workspace-status')
|
||||
const markup = await renderWorktreeListMarkup()
|
||||
|
||||
expect(markup).toContain('In progress')
|
||||
expect(markup).toContain('data-workspace-status-drop-target=""')
|
||||
expect(markup).toContain('data-repo-header-collapse-affordance=""')
|
||||
expect(markup).toContain('aria-expanded="true"')
|
||||
})
|
||||
|
||||
it('renders a collapse chevron on the pinned section header with worktrees', async () => {
|
||||
setPinnedFixtureState()
|
||||
const markup = await renderWorktreeListMarkup()
|
||||
|
||||
expect(markup).toContain('Pinned')
|
||||
expect(markup).toContain('data-workspace-pin-drop-target=""')
|
||||
expect(markup).toContain('data-repo-header-collapse-affordance=""')
|
||||
expect(markup).toContain('aria-expanded="true"')
|
||||
})
|
||||
|
||||
it('renders collapsed pinned section header affordance state', async () => {
|
||||
setPinnedFixtureState()
|
||||
mockStore.state = {
|
||||
...mockStore.state,
|
||||
collapsedGroups: new Set(['pinned'])
|
||||
}
|
||||
const markup = await renderWorktreeListMarkup()
|
||||
|
||||
expect(markup).toContain('data-workspace-pin-drop-target=""')
|
||||
expect(markup).toContain('data-repo-header-collapse-affordance=""')
|
||||
expect(markup).toContain('aria-expanded="false"')
|
||||
expect(markup).toContain('-rotate-90')
|
||||
})
|
||||
|
||||
it('renders a collapse chevron on grouped repo headers with worktrees', async () => {
|
||||
setLineageFixtureState('repo')
|
||||
const markup = await renderWorktreeListMarkup()
|
||||
|
||||
expect(markup).toContain('data-repo-header-collapse-affordance=""')
|
||||
expect(markup).toContain('data-repo-header-id="repo-1"')
|
||||
expect(markup).toContain('aria-expanded="true"')
|
||||
})
|
||||
|
||||
it('shows Clear Filters when filters exclude pre-worktree project groups', async () => {
|
||||
setProjectGroupWithoutWorktreeRowsState(['another-repo'])
|
||||
const markup = await renderWorktreeListMarkup()
|
||||
|
||||
expect(markup).toContain('No workspaces found')
|
||||
expect(markup).toContain('Clear Filters')
|
||||
expect(markup).not.toContain('Imported Services')
|
||||
})
|
||||
})
|
||||
@@ -90,38 +90,6 @@ describe('showDeleteWorktreeFailureToast', () => {
|
||||
expect(onForceDelete).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps non-forceable failures destructive without a force action', () => {
|
||||
const onViewChanges = vi.fn()
|
||||
|
||||
showDeleteWorktreeFailureToast({
|
||||
error: 'permission denied',
|
||||
canForceDelete: false,
|
||||
forceDeleteReason: null,
|
||||
onViewChanges,
|
||||
onForceDelete: vi.fn(),
|
||||
onDeleteAnyway: vi.fn(),
|
||||
worktreeId: 'wt-2',
|
||||
worktreeName: 'feature/bar'
|
||||
})
|
||||
|
||||
expect(toast.error).toHaveBeenCalledWith(
|
||||
'Failed to delete workspace feature/bar',
|
||||
expect.objectContaining({
|
||||
id: 'delete-worktree-failure:|wt-2',
|
||||
dismissible: true,
|
||||
duration: 10000
|
||||
})
|
||||
)
|
||||
|
||||
const body = renderToastBody('error')
|
||||
expect(body.textContent).toContain('permission denied')
|
||||
expect(body.textContent).not.toContain('Force Delete')
|
||||
|
||||
clickButton(body, 'View')
|
||||
expect(toast.dismiss).toHaveBeenCalledWith('delete-worktree-failure:|wt-2')
|
||||
expect(onViewChanges).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('offers Delete Anyway when the archive hook refused the removal', () => {
|
||||
const onDeleteAnyway = vi.fn()
|
||||
|
||||
@@ -150,80 +118,4 @@ describe('showDeleteWorktreeFailureToast', () => {
|
||||
expect(toast.dismiss).toHaveBeenCalledWith('delete-worktree-failure:|wt-archive')
|
||||
expect(onDeleteAnyway).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not offer Delete Anyway for an ordinary failure', () => {
|
||||
showDeleteWorktreeFailureToast({
|
||||
error: 'permission denied',
|
||||
canForceDelete: false,
|
||||
forceDeleteReason: null,
|
||||
onViewChanges: vi.fn(),
|
||||
onForceDelete: vi.fn(),
|
||||
onDeleteAnyway: vi.fn(),
|
||||
worktreeId: 'wt-plain',
|
||||
worktreeName: 'feature/plain'
|
||||
})
|
||||
|
||||
expect(renderToastBody('error').textContent).not.toContain('Delete Anyway')
|
||||
})
|
||||
|
||||
it('offers neither force delete nor View for a locked workspace', () => {
|
||||
const onViewChanges = vi.fn()
|
||||
|
||||
showDeleteWorktreeFailureToast({
|
||||
error: 'Worktree is locked by Git.',
|
||||
canForceDelete: false,
|
||||
forceDeleteReason: null,
|
||||
onViewChanges,
|
||||
onForceDelete: vi.fn(),
|
||||
onDeleteAnyway: vi.fn(),
|
||||
worktreeId: 'wt-locked',
|
||||
worktreeName: 'feature/locked'
|
||||
})
|
||||
|
||||
const body = renderToastBody('info')
|
||||
expect(body.textContent).toContain('This workspace is locked by Git.')
|
||||
expect(body.textContent).toContain(
|
||||
'Run git worktree unlock <worktree-path> from its repository, then retry deletion'
|
||||
)
|
||||
expect(body.textContent).not.toContain('Force Delete')
|
||||
expect(body.textContent).not.toContain('View')
|
||||
expect(onViewChanges).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps View for a locked workspace when changed files are known', () => {
|
||||
showDeleteWorktreeFailureToast({
|
||||
error: 'Worktree is locked by Git.',
|
||||
canForceDelete: false,
|
||||
forceDeleteReason: null,
|
||||
lockReason: 'active agent session',
|
||||
hasKnownChanges: true,
|
||||
onViewChanges: vi.fn(),
|
||||
onForceDelete: vi.fn(),
|
||||
onDeleteAnyway: vi.fn(),
|
||||
worktreeId: 'wt-locked-dirty',
|
||||
worktreeName: 'feature/locked-dirty'
|
||||
})
|
||||
|
||||
const body = renderToastBody('info')
|
||||
expect(body.textContent).toContain('Git reported: active agent session')
|
||||
expect(body.textContent).toContain('View')
|
||||
})
|
||||
it('offers a persistent nested deletion review for the exact failed target', () => {
|
||||
Object.assign(window, { api: { worktrees: { previewNestedRemoval: vi.fn() } } })
|
||||
showDeleteWorktreeFailureToast({
|
||||
error:
|
||||
'Refusing to delete worktree because it contains another registered worktree: /parent/child',
|
||||
canForceDelete: false,
|
||||
forceDeleteReason: null,
|
||||
onViewChanges: vi.fn(),
|
||||
onForceDelete: vi.fn(),
|
||||
onDeleteAnyway: vi.fn(),
|
||||
worktreeId: 'repo::/parent',
|
||||
worktreeName: 'parent',
|
||||
nestedRemovalTarget: { id: 'repo::/parent', executionHostId: 'local' }
|
||||
})
|
||||
const body = renderToastBody('error')
|
||||
expect(body.textContent).toContain('Delete with nested worktrees…')
|
||||
expect(vi.mocked(toast.error).mock.calls.at(-1)?.[1]?.duration).toBe(Infinity)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -155,17 +155,6 @@ describe('showPreservedBranchBatchToast', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps older-server branches visible without offering an unsafe delete', () => {
|
||||
showPreservedBranchBatchToast(3, [{ worktreeId: 'repo-a::one', branchName: 'feature/one' }])
|
||||
const body = renderToastBody()
|
||||
|
||||
expect(body.querySelector('button')).toBeNull()
|
||||
expect(toast.warning).toHaveBeenCalledWith(
|
||||
'3 workspaces removed, 1 branch kept',
|
||||
expect.not.objectContaining({ duration: Infinity })
|
||||
)
|
||||
})
|
||||
|
||||
it('serializes branch deletion within one repository', async () => {
|
||||
let resolveFirst: (result: { ok: true; deleted: true }) => void = () => {}
|
||||
mocks.state.forceDeletePreservedBranch
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
|
||||
import { useAppStore } from '@/store'
|
||||
import { makeDetectedResult } from '@/store/slices/worktrees-detected-listing-fixtures'
|
||||
|
||||
import { createVisibleHostedReviewRefreshScheduler } from '@/store/github/visible-hosted-review-refresh-scheduler'
|
||||
import { getHostedReviewCacheKey } from '@/store/slices/hosted-review-cache-identity'
|
||||
import { getWorkspaceReferenceRefreshTargets } from './workspace-reference-refresh-targets'
|
||||
@@ -173,13 +173,3 @@ it('retains both explicit owners when equal visible workspace IDs exist on two h
|
||||
getWorkspaceReferenceRefreshTargets(useAppStore.getState(), { selectedOnly: true })
|
||||
).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('admits attached reviews on visible external detected worktrees', () => {
|
||||
useAppStore.setState({
|
||||
worktreesByRepo: {},
|
||||
detectedWorktreesByRepo: {
|
||||
repo: makeDetectedResult('repo', [workspace])
|
||||
}
|
||||
})
|
||||
expect(update()).toHaveLength(1)
|
||||
})
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { renderToStaticMarkup } from 'react-dom/server'
|
||||
|
||||
import type { ReactNode } from 'react'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { RuntimeHostStatusRow } from './RuntimeHostStatusRow'
|
||||
@@ -38,104 +38,6 @@ vi.mock('@/components/ui/dropdown-menu', () => ({
|
||||
afterEach(cleanup)
|
||||
|
||||
describe('RuntimeHostStatusRow', () => {
|
||||
it('renders reconnecting diagnostics for remote hosts', () => {
|
||||
const markup = renderToStaticMarkup(
|
||||
<RuntimeHostStatusRow label="Dev Box" state="reconnecting" detail="Attempt 3" />
|
||||
)
|
||||
|
||||
expect(markup).toContain('Dev Box')
|
||||
expect(markup).toContain('Reconnecting')
|
||||
expect(markup).toContain('Attempt 3')
|
||||
})
|
||||
|
||||
it('renders disconnected hosts with a connect action', () => {
|
||||
const markup = renderToStaticMarkup(
|
||||
<RuntimeHostStatusRow
|
||||
label="Dev Box"
|
||||
state="disconnected"
|
||||
detail="Last closed: 1006"
|
||||
onConnect={async () => {}}
|
||||
/>
|
||||
)
|
||||
|
||||
expect(markup).toContain('Dev Box')
|
||||
expect(markup).toContain('Remote Server')
|
||||
expect(markup).toContain('Disconnected')
|
||||
expect(markup).toContain('Last closed: 1006')
|
||||
expect(markup).toContain('Connect')
|
||||
})
|
||||
|
||||
it('names the closed workspace window while keeping the disconnect action', () => {
|
||||
const markup = renderToStaticMarkup(
|
||||
<RuntimeHostStatusRow
|
||||
label="Dev Box"
|
||||
state="workspace-window-closed"
|
||||
onConnect={async () => {}}
|
||||
onDisconnect={async () => {}}
|
||||
/>
|
||||
)
|
||||
|
||||
expect(markup).toContain('Dev Box')
|
||||
expect(markup).toContain('Workspace window closed')
|
||||
expect(markup).toContain('Disconnect')
|
||||
expect(markup).not.toContain('>Connect</button>')
|
||||
// The host is still reachable — the row must not read as a lost connection.
|
||||
expect(markup).not.toContain('Disconnected')
|
||||
// Require the disconnect handler; a connect-only row must offer no action for an already reachable host.
|
||||
expect(
|
||||
renderToStaticMarkup(
|
||||
<RuntimeHostStatusRow
|
||||
label="Dev Box"
|
||||
state="workspace-window-closed"
|
||||
onConnect={async () => {}}
|
||||
/>
|
||||
)
|
||||
).not.toContain('<button')
|
||||
})
|
||||
|
||||
it('renders connected hosts with a disconnect action', () => {
|
||||
const markup = renderToStaticMarkup(
|
||||
<RuntimeHostStatusRow label="Dev Box" state="connected" onDisconnect={async () => {}} />
|
||||
)
|
||||
|
||||
expect(markup).toContain('Dev Box')
|
||||
expect(markup).toContain('Connected')
|
||||
expect(markup).toContain('Disconnect')
|
||||
})
|
||||
|
||||
it('renders transport-up runtime-unavailable hosts without implying process death', () => {
|
||||
const { container } = render(
|
||||
<RuntimeHostStatusRow
|
||||
label="OpenClaw"
|
||||
state="runtime-unavailable"
|
||||
detail="status.get refused"
|
||||
onDisconnect={async () => {}}
|
||||
/>
|
||||
)
|
||||
|
||||
expect(container.textContent).toContain('Orca unavailable')
|
||||
expect(container.textContent).toContain('SSH transport is connected')
|
||||
expect(container.textContent).toContain('may still be running')
|
||||
expect(container.textContent).toContain('Disconnect')
|
||||
expect(container.textContent).not.toContain('exited')
|
||||
})
|
||||
|
||||
it('keeps healthy rows free of a submenu trigger', () => {
|
||||
const { container } = render(<RuntimeHostStatusRow label="Dev Box" state="connected" />)
|
||||
|
||||
expect(container.querySelector('[data-slot="dropdown-menu-sub-trigger"]')).toBeNull()
|
||||
})
|
||||
|
||||
it('renders failing rows as submenu triggers', () => {
|
||||
const { container } = render(
|
||||
<RuntimeHostStatusRow label="Dev Box" state="disconnected" detail="Connection refused" />
|
||||
)
|
||||
|
||||
const trigger = container.querySelector('[data-slot="dropdown-menu-sub-trigger"]')
|
||||
expect(trigger).not.toBeNull()
|
||||
expect(trigger?.querySelector('button')).toBeNull()
|
||||
})
|
||||
|
||||
it('keeps the row action invokable for failing hosts', async () => {
|
||||
const onConnect = vi.fn(async () => {})
|
||||
render(
|
||||
@@ -151,51 +53,4 @@ describe('RuntimeHostStatusRow', () => {
|
||||
|
||||
await waitFor(() => expect(onConnect).toHaveBeenCalledOnce())
|
||||
})
|
||||
|
||||
it('renders a long raw error in full inside the submenu', () => {
|
||||
const longError = `Invalid remote endpoint: ${'wss://relay.example.test/path/'.repeat(8)}`
|
||||
const { container } = render(
|
||||
<RuntimeHostStatusRow
|
||||
label="Dev Box"
|
||||
state="disconnected"
|
||||
detail={longError}
|
||||
diagnostics={{
|
||||
state: 'closed',
|
||||
pendingRequestCount: 0,
|
||||
subscriptionCount: 0,
|
||||
reconnectAttempt: 3,
|
||||
lastConnectedAt: null,
|
||||
lastClose: null,
|
||||
lastError: longError
|
||||
}}
|
||||
/>
|
||||
)
|
||||
|
||||
const submenu = container.querySelector('[data-slot="dropdown-menu-sub-content"]')
|
||||
expect(submenu).not.toBeNull()
|
||||
expect(submenu?.textContent).toContain(longError)
|
||||
})
|
||||
|
||||
it('shows the existing connection diagnostics in the submenu', () => {
|
||||
const { container } = render(
|
||||
<RuntimeHostStatusRow
|
||||
label="Dev Box"
|
||||
state="reconnecting"
|
||||
detail="Connection refused"
|
||||
diagnostics={{
|
||||
state: 'reconnecting',
|
||||
pendingRequestCount: 0,
|
||||
subscriptionCount: 0,
|
||||
reconnectAttempt: 2,
|
||||
lastConnectedAt: Date.now() - 60_000,
|
||||
lastClose: null,
|
||||
lastError: 'Connection refused'
|
||||
}}
|
||||
/>
|
||||
)
|
||||
|
||||
const submenu = container.querySelector('[data-slot="dropdown-menu-sub-content"]')
|
||||
expect(submenu?.textContent).toContain('Last connected')
|
||||
expect(submenu?.textContent).toContain('Attempt 3')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -30,46 +30,7 @@ function windowOf(
|
||||
return { usedPercent, windowMinutes, resetsAt, resetDescription: null }
|
||||
}
|
||||
|
||||
// Grok unified-billing accounts surface a monthly window and nothing else.
|
||||
function grokMonthlyLimits(status: ProviderRateLimits['status']): ProviderRateLimits {
|
||||
return {
|
||||
provider: 'grok',
|
||||
session: null,
|
||||
weekly: null,
|
||||
monthly: windowOf(25, 43200),
|
||||
updatedAt: Date.now(),
|
||||
error: null,
|
||||
status
|
||||
}
|
||||
}
|
||||
|
||||
describe('ProviderSegment monthly window', () => {
|
||||
it('renders a monthly-only snapshot in the chip instead of a bare icon', async () => {
|
||||
const { ProviderSegment } = await import('./StatusBar')
|
||||
|
||||
const markup = renderToStaticMarkup(
|
||||
<ProviderSegment p={grokMonthlyLimits('ok')} compact={false} display="used" mode="compact" />
|
||||
)
|
||||
|
||||
expect(markup).toContain('>25% 30d<')
|
||||
})
|
||||
|
||||
it('shows monthly data while fetching instead of the loading placeholder', async () => {
|
||||
const { ProviderSegment } = await import('./StatusBar')
|
||||
|
||||
const markup = renderToStaticMarkup(
|
||||
<ProviderSegment
|
||||
p={grokMonthlyLimits('fetching')}
|
||||
compact={false}
|
||||
display="used"
|
||||
mode="compact"
|
||||
/>
|
||||
)
|
||||
|
||||
expect(markup).toContain('>25% 30d<')
|
||||
expect(markup).not.toContain('···')
|
||||
})
|
||||
|
||||
it('shows only the highest-used window when several windows exist', async () => {
|
||||
const { ProviderSegment } = await import('./StatusBar')
|
||||
|
||||
@@ -78,7 +39,7 @@ describe('ProviderSegment monthly window', () => {
|
||||
session: windowOf(10, 300),
|
||||
weekly: windowOf(20, 10080),
|
||||
monthly: windowOf(30, 43200),
|
||||
updatedAt: Date.now(),
|
||||
updatedAt: 0,
|
||||
error: null,
|
||||
status: 'ok'
|
||||
}
|
||||
@@ -101,7 +62,7 @@ describe('ProviderSegment monthly window', () => {
|
||||
{ ...windowOf(25, 300), name: 'Flash' },
|
||||
{ ...windowOf(80, 300), name: 'Pro' }
|
||||
],
|
||||
updatedAt: Date.now(),
|
||||
updatedAt: 0,
|
||||
error: null,
|
||||
status: 'ok'
|
||||
}
|
||||
@@ -113,74 +74,6 @@ describe('ProviderSegment monthly window', () => {
|
||||
expect(markup).toContain('>80% Pro<')
|
||||
expect(markup).not.toContain('>25%')
|
||||
})
|
||||
|
||||
// Keep the chip’s countdown consistent with the popup for the same resetsAt.
|
||||
it('shows remaining session time on the chip when resetsAt is known (repro-8378)', async () => {
|
||||
const { ProviderSegment } = await import('./StatusBar')
|
||||
const now = 1_700_000_000_000
|
||||
const dateNow = vi.spyOn(Date, 'now').mockReturnValue(now)
|
||||
const remainingMs = 2 * 60 * 60_000 + 33 * 60_000
|
||||
|
||||
try {
|
||||
const limits: ProviderRateLimits = {
|
||||
provider: 'codex',
|
||||
session: windowOf(42, 300, now + remainingMs),
|
||||
weekly: windowOf(10, 10080, now + 6 * 24 * 60 * 60_000),
|
||||
updatedAt: now,
|
||||
error: null,
|
||||
status: 'ok'
|
||||
}
|
||||
const markup = renderToStaticMarkup(
|
||||
<ProviderSegment p={limits} compact={false} display="used" mode="compact" />
|
||||
)
|
||||
|
||||
expect(markup).toContain('>42% 2h 33m<')
|
||||
expect(markup).not.toContain('5h')
|
||||
// The consolidated footer intentionally renders only the tightest window.
|
||||
expect(markup).not.toContain('>10%')
|
||||
expect(markup).not.toContain('wk')
|
||||
} finally {
|
||||
dateNow.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
it('shows the footer bar only in verbose mode', async () => {
|
||||
const { ProviderSegment } = await import('./StatusBar')
|
||||
const limits = grokMonthlyLimits('ok')
|
||||
|
||||
const verbose = renderToStaticMarkup(
|
||||
<ProviderSegment p={limits} compact={false} display="used" mode="verbose" />
|
||||
)
|
||||
const compact = renderToStaticMarkup(
|
||||
<ProviderSegment p={limits} compact={false} display="used" mode="compact" />
|
||||
)
|
||||
|
||||
expect(verbose).toContain('data-usage-bar')
|
||||
expect(compact).not.toContain('data-usage-bar')
|
||||
})
|
||||
|
||||
it('restores every inline window in verbose mode', async () => {
|
||||
const { ProviderSegment } = await import('./StatusBar')
|
||||
const limits: ProviderRateLimits = {
|
||||
provider: 'claude',
|
||||
session: windowOf(10, 300),
|
||||
weekly: windowOf(20, 10_080),
|
||||
fableWeekly: windowOf(30, 10_080),
|
||||
monthly: windowOf(40, 43_200),
|
||||
updatedAt: Date.now(),
|
||||
error: null,
|
||||
status: 'ok'
|
||||
}
|
||||
|
||||
const markup = renderToStaticMarkup(
|
||||
<ProviderSegment p={limits} compact={false} display="used" mode="verbose" />
|
||||
)
|
||||
|
||||
expect(markup).toContain('>10% 5h<')
|
||||
expect(markup).toContain('>20% wk<')
|
||||
expect(markup).toContain('>30% Fable<')
|
||||
expect(markup).not.toContain('>40%')
|
||||
})
|
||||
})
|
||||
|
||||
describe('undefined provider window safety (crash d2c1da69 / bb74236c)', () => {
|
||||
@@ -189,10 +82,11 @@ describe('undefined provider window safety (crash d2c1da69 / bb74236c)', () => {
|
||||
// old `s.window !== null` filter let the undefined-window section through, so
|
||||
// getTightestUsageSection's reduce read `.usedPercent` of undefined and crashed
|
||||
// the status-bar overlay (TypeError in ProviderSegment).
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Deliberately omit session to reproduce malformed persisted provider data.
|
||||
const partialProvider = {
|
||||
provider: 'codex',
|
||||
weekly: windowOf(42, 10080),
|
||||
updatedAt: Date.now(),
|
||||
updatedAt: 0,
|
||||
error: null,
|
||||
status: 'ok'
|
||||
} as unknown as ProviderRateLimits // `session` omitted -> undefined at runtime
|
||||
|
||||
@@ -80,31 +80,6 @@ describe('resource session bindings', () => {
|
||||
expect(index.boundPtyIds.has('pty-closed')).toBe(false)
|
||||
})
|
||||
|
||||
it('counts only daemon sessions without live or restorable tab bindings', () => {
|
||||
const count = countUnboundDaemonSessions(
|
||||
[
|
||||
{ id: 'pty-live', cwd: '/workspace', title: 'live', agentOwnership: 'absent' as const },
|
||||
{
|
||||
id: 'pty-restored',
|
||||
cwd: '/workspace',
|
||||
title: 'restored',
|
||||
agentOwnership: 'absent' as const
|
||||
},
|
||||
{ id: 'pty-orphan', cwd: '/tmp', title: 'orphan', agentOwnership: 'absent' as const }
|
||||
],
|
||||
{
|
||||
ptyIdsByTabId: { 'tab-live': ['pty-live'] },
|
||||
tabsByWorktree: {
|
||||
'repo::/workspace': [makeTab('tab-live'), makeTab('tab-restored', 'pty-restored')]
|
||||
},
|
||||
terminalLayoutsByTabId: {},
|
||||
workspaceSessionReady: true
|
||||
}
|
||||
)
|
||||
|
||||
expect(count).toBe(1)
|
||||
})
|
||||
|
||||
it('binds a session deferred for SSH reattach so it is never offered for cleanup', () => {
|
||||
const inputs = {
|
||||
ptyIdsByTabId: {},
|
||||
@@ -158,32 +133,6 @@ describe('resource session bindings', () => {
|
||||
expect(countUnboundDaemonSessions(sessions, inputs)).toBe(1)
|
||||
})
|
||||
|
||||
it('keeps the advertised count and the selected set identical', () => {
|
||||
const inputs = {
|
||||
ptyIdsByTabId: { 'tab-live': ['pty-live'] },
|
||||
tabsByWorktree: { 'repo::/workspace': [makeTab('tab-live'), makeTab('tab-ssh')] },
|
||||
terminalLayoutsByTabId: {},
|
||||
deferredSshSessionIdsByTabId: { 'tab-ssh': 'pty-deferred' },
|
||||
workspaceSessionReady: true
|
||||
}
|
||||
const sessions = [
|
||||
{ id: 'pty-live', cwd: '/workspace', title: 'live', agentOwnership: 'absent' as const },
|
||||
{
|
||||
id: 'pty-deferred',
|
||||
cwd: '/workspace',
|
||||
title: 'deferred',
|
||||
agentOwnership: 'absent' as const
|
||||
},
|
||||
{ id: 'pty-agent', cwd: '/workspace', title: 'codex', agentOwnership: 'present' as const },
|
||||
{ id: 'pty-orphan', cwd: '/tmp', title: 'orphan', agentOwnership: 'absent' as const }
|
||||
]
|
||||
|
||||
expect(selectUnboundDaemonSessions(sessions, inputs).map((s) => s.id)).toEqual(['pty-orphan'])
|
||||
expect(countUnboundDaemonSessions(sessions, inputs)).toBe(
|
||||
selectUnboundDaemonSessions(sessions, inputs).length
|
||||
)
|
||||
})
|
||||
|
||||
it('never offers a session whose ownership could not be established', () => {
|
||||
// Older daemons/relays may omit ownership claims; unknown cannot establish owner absence.
|
||||
const inputs = {
|
||||
|
||||
@@ -75,29 +75,6 @@ describe('useOwnerUsageRateLimits', () => {
|
||||
})
|
||||
afterEach(() => cleanup())
|
||||
|
||||
it('opens no watcher and keeps local usage when this device owns accounts', () => {
|
||||
const { result } = renderHook(() => useOwnerUsageRateLimits(local))
|
||||
expect(mocks.watchers).toHaveLength(0)
|
||||
expect(result.current.rateLimits).toBe(local)
|
||||
expect(result.current.refreshRemote).toBeNull()
|
||||
expect(result.current.remoteOwner).toBeNull()
|
||||
})
|
||||
|
||||
it("reads usage from the remote owner's accounts stream", () => {
|
||||
mocks.owner = { kind: 'environment', environmentId: 'mini' }
|
||||
const { result } = renderHook(() => useOwnerUsageRateLimits(local))
|
||||
expect(mocks.watchers[0].target).toEqual({ kind: 'environment', environmentId: 'mini' })
|
||||
expect(result.current.remoteOwner).toEqual({ environmentId: 'mini', label: 'Mac Mini' })
|
||||
expect(result.current.rateLimits.claude?.session).toBeNull()
|
||||
|
||||
act(() =>
|
||||
mocks.watchers[0].handlers.onSnapshot(
|
||||
snapshot(createEmptyRateLimitState({ claude: provider('claude', 64) }))
|
||||
)
|
||||
)
|
||||
expect(result.current.rateLimits.claude?.session?.usedPercent).toBe(64)
|
||||
})
|
||||
|
||||
it('reports an older host without published usage as unavailable, not loading', () => {
|
||||
mocks.owner = { kind: 'environment', environmentId: 'mini' }
|
||||
const { result } = renderHook(() => useOwnerUsageRateLimits(local))
|
||||
@@ -107,19 +84,6 @@ describe('useOwnerUsageRateLimits', () => {
|
||||
expect(result.current.rateLimits.claude?.error).toContain('Mac Mini does not report usage')
|
||||
})
|
||||
|
||||
it('turns loss of contact into an unverifiable verdict that keeps known bars', () => {
|
||||
mocks.owner = { kind: 'environment', environmentId: 'mini' }
|
||||
const { result } = renderHook(() => useOwnerUsageRateLimits(local))
|
||||
act(() =>
|
||||
mocks.watchers[0].handlers.onSnapshot(
|
||||
snapshot(createEmptyRateLimitState({ codex: provider('codex', 30) }))
|
||||
)
|
||||
)
|
||||
act(() => mocks.watchers[0].handlers.onError(new Error('closed')))
|
||||
expect(result.current.rateLimits.codex?.session).toBeNull()
|
||||
expect(result.current.rateLimits.codex?.error).toContain('cannot reach Mac Mini')
|
||||
})
|
||||
|
||||
it('never shows server A numbers under server B', () => {
|
||||
mocks.owner = { kind: 'environment', environmentId: 'mini' }
|
||||
const { result, rerender } = renderHook(() => useOwnerUsageRateLimits(local))
|
||||
|
||||
-49
@@ -92,17 +92,6 @@ describe('workspace cleanup facet controls', () => {
|
||||
container = null
|
||||
})
|
||||
|
||||
it('shows a per-facet match count for every group', () => {
|
||||
renderFacets(createDefaultWorkspaceCleanupFilterState(), createPatchMock())
|
||||
|
||||
const counts = [...(container?.querySelectorAll('[data-facet-count]') ?? [])].map(
|
||||
(node) => node.textContent
|
||||
)
|
||||
expect(counts).toHaveLength(10)
|
||||
expect(counts).toContain('2/8')
|
||||
expect(counts).toContain('3/8')
|
||||
})
|
||||
|
||||
it('takes a user-chosen idle threshold in days rather than a fixed enum', () => {
|
||||
const onPatch = createPatchMock()
|
||||
renderFacets(createDefaultWorkspaceCleanupFilterState(), onPatch)
|
||||
@@ -112,27 +101,6 @@ describe('workspace cleanup facet controls', () => {
|
||||
expect(onPatch).toHaveBeenCalledWith('activity', { idleMinDays: 17 })
|
||||
})
|
||||
|
||||
it('keeps every numeric threshold off type=number so a wheel tick cannot set it', () => {
|
||||
// Assert input type because happy-dom cannot reproduce Chromium’s wheel-driven numeric changes.
|
||||
renderFacets(createDefaultWorkspaceCleanupFilterState(), createPatchMock())
|
||||
|
||||
const numericFacets = [
|
||||
'Idle for at least',
|
||||
'At least',
|
||||
'At most',
|
||||
'Commits ahead ≥',
|
||||
'Commits behind ≥'
|
||||
]
|
||||
const found = numericFacets
|
||||
.map((label) => control(label) as HTMLInputElement | null)
|
||||
.filter((input): input is HTMLInputElement => input !== null)
|
||||
expect(found).toHaveLength(numericFacets.length)
|
||||
for (const input of found) {
|
||||
expect(input.type).toBe('text')
|
||||
expect(input.inputMode).toBe('numeric')
|
||||
}
|
||||
})
|
||||
|
||||
it('multi-selects git states', () => {
|
||||
const onPatch = createPatchMock()
|
||||
const filters = createDefaultWorkspaceCleanupFilterState()
|
||||
@@ -144,14 +112,6 @@ describe('workspace cleanup facet controls', () => {
|
||||
expect(onPatch).toHaveBeenCalledWith('git', { states: ['dirty', 'unpushed'] })
|
||||
})
|
||||
|
||||
it('offers the hosts and providers discovered in the fleet, not a hardcoded list', () => {
|
||||
renderFacets(createDefaultWorkspaceCleanupFilterState(), createPatchMock())
|
||||
|
||||
expect(control('GitLab')).not.toBeNull()
|
||||
expect(container?.textContent).toContain('Alpha')
|
||||
expect(container?.textContent).toContain('In progress')
|
||||
})
|
||||
|
||||
it('cycles a tri-state safety facet', () => {
|
||||
const onPatch = createPatchMock()
|
||||
renderFacets(createDefaultWorkspaceCleanupFilterState(), onPatch)
|
||||
@@ -160,15 +120,6 @@ describe('workspace cleanup facet controls', () => {
|
||||
expect(onPatch).toHaveBeenCalledWith('safety', { dismissed: 'only' })
|
||||
})
|
||||
|
||||
it('labels blocker matching without exposing model tokens', () => {
|
||||
renderFacets(createDefaultWorkspaceCleanupFilterState(), createPatchMock())
|
||||
|
||||
expect(control('Blocker match: Has any')).not.toBeNull()
|
||||
expect(control('Blocker match: Has none')).not.toBeNull()
|
||||
expect(container?.textContent).not.toContain('any-of')
|
||||
expect(container?.textContent).not.toContain('none-of')
|
||||
})
|
||||
|
||||
it('toggles the unsized escape hatch for the opt-in space scan', () => {
|
||||
const onPatch = createPatchMock()
|
||||
renderFacets(createDefaultWorkspaceCleanupFilterState(), onPatch)
|
||||
|
||||
@@ -1,203 +0,0 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { act } from 'react'
|
||||
import { createRoot, type Root } from 'react-dom/client'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import WorktreeCreationPanel from './WorktreeCreationPanel'
|
||||
import type { PendingWorktreeCreation } from '@/lib/pending-worktree-creation'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
state: {
|
||||
pendingWorktreeCreations: {
|
||||
'create-1': {
|
||||
creationId: 'create-1',
|
||||
phase: 'creating',
|
||||
status: 'creating',
|
||||
startedAt: Date.now(),
|
||||
indeterminate: false,
|
||||
loaderVisible: true,
|
||||
request: {
|
||||
repoId: 'repo-1',
|
||||
name: 'new-workspace',
|
||||
displayName: 'New workspace',
|
||||
setupDecision: 'skip',
|
||||
agent: null,
|
||||
pendingFirstAgentMessageRename: false,
|
||||
note: '',
|
||||
startupPlan: null,
|
||||
quickPrompt: '',
|
||||
quickTelemetry: null
|
||||
}
|
||||
}
|
||||
} as Record<string, PendingWorktreeCreation>
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/store', () => ({
|
||||
useAppStore: (selector: (state: typeof mocks.state) => unknown) => selector(mocks.state)
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/worktree-creation-flow', () => ({
|
||||
retryBackgroundWorktreeCreation: vi.fn()
|
||||
}))
|
||||
|
||||
const roots: Root[] = []
|
||||
|
||||
async function renderPanel(reserveCollapsedSidebarHeaderSpace: boolean): Promise<HTMLDivElement> {
|
||||
const container = document.createElement('div')
|
||||
document.body.appendChild(container)
|
||||
const root = createRoot(container)
|
||||
roots.push(root)
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<WorktreeCreationPanel
|
||||
creationId="create-1"
|
||||
reserveCollapsedSidebarHeaderSpace={reserveCollapsedSidebarHeaderSpace}
|
||||
/>
|
||||
)
|
||||
})
|
||||
|
||||
return container
|
||||
}
|
||||
|
||||
describe('WorktreeCreationPanel', () => {
|
||||
beforeEach(() => {
|
||||
globalThis.IS_REACT_ACT_ENVIRONMENT = true
|
||||
mocks.state.pendingWorktreeCreations['create-1'] = {
|
||||
creationId: 'create-1',
|
||||
phase: 'creating',
|
||||
status: 'creating',
|
||||
startedAt: Date.now(),
|
||||
indeterminate: false,
|
||||
loaderVisible: true,
|
||||
request: {
|
||||
repoId: 'repo-1',
|
||||
name: 'new-workspace',
|
||||
displayName: 'New workspace',
|
||||
setupDecision: 'skip',
|
||||
agent: null,
|
||||
pendingFirstAgentMessageRename: false,
|
||||
note: '',
|
||||
startupPlan: null,
|
||||
quickPrompt: '',
|
||||
quickTelemetry: null
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
roots.splice(0).forEach((root) => {
|
||||
act(() => root.unmount())
|
||||
})
|
||||
document.body.replaceChildren()
|
||||
})
|
||||
|
||||
it('keeps the faux creation tab visible', async () => {
|
||||
const container = await renderPanel(false)
|
||||
|
||||
expect(container.textContent).toContain('New workspace')
|
||||
expect(container.textContent).toContain('Creating worktree…')
|
||||
const title = [...container.querySelectorAll('span')].find(
|
||||
(node) => node.textContent === 'New workspace'
|
||||
)
|
||||
expect(title?.closest('div')?.className).toContain('border-r')
|
||||
})
|
||||
|
||||
it('reserves collapsed left-titlebar space before the faux tab', async () => {
|
||||
const container = await renderPanel(true)
|
||||
const title = [...container.querySelectorAll('span')].find(
|
||||
(node) => node.textContent === 'New workspace'
|
||||
)
|
||||
const spacer = title?.closest('div')?.previousElementSibling as HTMLElement | null
|
||||
|
||||
expect(spacer?.style.width).toBe('var(--collapsed-sidebar-header-width)')
|
||||
})
|
||||
|
||||
it('does not reserve left-titlebar space when the header is not floating', async () => {
|
||||
const container = await renderPanel(false)
|
||||
const title = [...container.querySelectorAll('span')].find(
|
||||
(node) => node.textContent === 'New workspace'
|
||||
)
|
||||
|
||||
expect(title?.closest('div')?.previousElementSibling).toBeNull()
|
||||
})
|
||||
|
||||
it('shows provisioning logs while a VM recipe is running', async () => {
|
||||
mocks.state.pendingWorktreeCreations['create-1'] = {
|
||||
...mocks.state.pendingWorktreeCreations['create-1'],
|
||||
phase: 'provisioning-vm',
|
||||
provisioningLog: 'creating sandbox\nstarting orca serve\n'
|
||||
}
|
||||
|
||||
const container = await renderPanel(false)
|
||||
|
||||
expect(container.textContent).toContain('Provisioning VM')
|
||||
expect(container.querySelector('pre')?.textContent).toBe(
|
||||
'creating sandbox\nstarting orca serve\n'
|
||||
)
|
||||
expect(container.querySelector('pre')?.className).toContain('h-72')
|
||||
const cancel = [...container.querySelectorAll('button')].find(
|
||||
(button) => button.textContent === 'Cancel'
|
||||
)
|
||||
expect(cancel).toBeTruthy()
|
||||
})
|
||||
|
||||
it('surfaces the captured recipe output when a VM recipe fails', async () => {
|
||||
mocks.state.pendingWorktreeCreations['create-1'] = {
|
||||
...mocks.state.pendingWorktreeCreations['create-1'],
|
||||
phase: 'provisioning-vm',
|
||||
status: 'error',
|
||||
error: 'Recipe exited with code 1.',
|
||||
provisioningLog: 'pulling image…\nERROR: no space left on device\n'
|
||||
}
|
||||
|
||||
const container = await renderPanel(false)
|
||||
|
||||
expect(container.textContent).toContain('Couldn’t create worktree')
|
||||
expect(container.textContent).toContain('Recipe exited with code 1.')
|
||||
const log = container.querySelector('pre')
|
||||
expect(log?.textContent).toBe('pulling image…\nERROR: no space left on device\n')
|
||||
expect(log?.className).toContain('h-72')
|
||||
expect(container.textContent).toContain('Retry')
|
||||
})
|
||||
|
||||
it("shows git's checkout meter as a labelled bar while the checkout runs", async () => {
|
||||
mocks.state.pendingWorktreeCreations['create-1'] = {
|
||||
...mocks.state.pendingWorktreeCreations['create-1'],
|
||||
checkoutProgress: { percent: 42, completed: 420, total: 1000 }
|
||||
}
|
||||
|
||||
const container = await renderPanel(false)
|
||||
|
||||
const bar = container.querySelector('[role="progressbar"]')
|
||||
expect(bar?.getAttribute('aria-valuenow')).toBe('42')
|
||||
const label = document.getElementById(bar?.getAttribute('aria-labelledby') ?? '')
|
||||
expect(label?.textContent).toBe('Checking out files… 42%')
|
||||
})
|
||||
|
||||
it('shows no bar when git printed no checkout meter, or after the create failed', async () => {
|
||||
expect((await renderPanel(false)).querySelector('[role="progressbar"]')).toBeNull()
|
||||
|
||||
mocks.state.pendingWorktreeCreations['create-1'] = {
|
||||
...mocks.state.pendingWorktreeCreations['create-1'],
|
||||
status: 'error',
|
||||
error: 'git worktree add failed',
|
||||
checkoutProgress: { percent: 42, completed: 420, total: 1000 }
|
||||
}
|
||||
expect((await renderPanel(false)).querySelector('[role="progressbar"]')).toBeNull()
|
||||
})
|
||||
|
||||
it('omits the recipe output panel for a non-VM creation failure', async () => {
|
||||
mocks.state.pendingWorktreeCreations['create-1'] = {
|
||||
...mocks.state.pendingWorktreeCreations['create-1'],
|
||||
status: 'error',
|
||||
error: 'git worktree add failed'
|
||||
}
|
||||
|
||||
const container = await renderPanel(false)
|
||||
|
||||
expect(container.textContent).toContain('git worktree add failed')
|
||||
expect(container.querySelector('pre')).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -83,123 +83,4 @@ describe('local composer drop failures', () => {
|
||||
expect(attached).toHaveLength(9)
|
||||
expect(attached).not.toContain('/drop/bad-1.png')
|
||||
})
|
||||
|
||||
it('stays silent when every dropped path attaches', async () => {
|
||||
const { result } = renderDropState(() => {})
|
||||
|
||||
await act(async () => {
|
||||
await result.current.applyLocalComposerDrop(['/drop/ok-0.png', '/drop/ok-1.png'])
|
||||
})
|
||||
|
||||
expect(mocks.toastError).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('says nothing once the composer that owned the drop is gone', async () => {
|
||||
const { result } = renderDropState(() => {})
|
||||
|
||||
await act(async () => {
|
||||
await result.current.applyLocalComposerDrop(dropPaths(12), () => false)
|
||||
})
|
||||
|
||||
expect(mocks.toastError).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
// Why: the upload branch returns early unless a runtime environment or connection is resolved.
|
||||
const RUNTIME_ENVIRONMENT_ID = 'env-1'
|
||||
|
||||
describe('composer upload failures', () => {
|
||||
it('aggregates a mixed runtime import into one toast, and withholds a reason that is not shared', async () => {
|
||||
mocks.importExternalPaths.mockResolvedValue({
|
||||
results: [
|
||||
{
|
||||
sourcePath: '/a.png',
|
||||
status: 'imported',
|
||||
destPath: '/repo/.orca/drops/a.png',
|
||||
kind: 'file',
|
||||
renamed: false
|
||||
},
|
||||
{ sourcePath: '/b.png', status: 'skipped', reason: 'permission-denied' },
|
||||
{ sourcePath: '/c.png', status: 'failed', reason: 'disk full' }
|
||||
]
|
||||
})
|
||||
const { result } = renderDropState(() => {})
|
||||
|
||||
await act(async () => {
|
||||
await result.current.uploadComposerPaths(
|
||||
['/a.png', '/b.png', '/c.png'],
|
||||
RUNTIME_ENVIRONMENT_ID,
|
||||
null,
|
||||
'/repo'
|
||||
)
|
||||
})
|
||||
|
||||
expect(mocks.toastError).toHaveBeenCalledTimes(1)
|
||||
const [title, options] = mocks.toastError.mock.calls[0] ?? []
|
||||
expect(title).toBe('2 of 3 items could not be attached.')
|
||||
expect(options.description).toBeUndefined()
|
||||
})
|
||||
|
||||
it('stays silent when every uploaded path imports', async () => {
|
||||
mocks.importExternalPaths.mockResolvedValue({
|
||||
results: [
|
||||
{
|
||||
sourcePath: '/a.png',
|
||||
status: 'imported',
|
||||
destPath: '/repo/.orca/drops/a.png',
|
||||
kind: 'file',
|
||||
renamed: false
|
||||
}
|
||||
]
|
||||
})
|
||||
const { result } = renderDropState(() => {})
|
||||
|
||||
await act(async () => {
|
||||
await result.current.uploadComposerPaths(['/a.png'], RUNTIME_ENVIRONMENT_ID, null, '/repo')
|
||||
})
|
||||
|
||||
expect(mocks.toastError).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not report after the composer that owned the upload is gone', async () => {
|
||||
mocks.importExternalPaths.mockResolvedValue({
|
||||
results: [{ sourcePath: '/b.png', status: 'skipped', reason: 'missing' }]
|
||||
})
|
||||
const { result } = renderDropState(() => {})
|
||||
|
||||
await act(async () => {
|
||||
await result.current.uploadComposerPaths(
|
||||
['/b.png'],
|
||||
RUNTIME_ENVIRONMENT_ID,
|
||||
null,
|
||||
'/repo',
|
||||
null,
|
||||
() => false
|
||||
)
|
||||
})
|
||||
|
||||
expect(mocks.toastError).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does give the shared reason when every uploaded path failed the same way', async () => {
|
||||
mocks.importExternalPaths.mockResolvedValue({
|
||||
results: [
|
||||
{ sourcePath: '/b.png', status: 'skipped', reason: 'permission-denied' },
|
||||
{ sourcePath: '/c.png', status: 'skipped', reason: 'permission-denied' }
|
||||
]
|
||||
})
|
||||
const { result } = renderDropState(() => {})
|
||||
|
||||
await act(async () => {
|
||||
await result.current.uploadComposerPaths(
|
||||
['/b.png', '/c.png'],
|
||||
RUNTIME_ENVIRONMENT_ID,
|
||||
null,
|
||||
'/repo'
|
||||
)
|
||||
})
|
||||
|
||||
const [, options] = mocks.toastError.mock.calls[0] ?? []
|
||||
expect(options.description).toBe('Permission denied.')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -159,26 +159,3 @@ describe('useReadOnScreenAttention — floating workspace panel visibility', ()
|
||||
expect(selectFloatingWorkspaceHasUnread(state)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
it('does not acknowledge the regular workspace for a colliding floating tab', () => {
|
||||
vi.spyOn(document, 'hasFocus').mockReturnValue(true)
|
||||
seedFloatingCompletion()
|
||||
useAppStore.setState({
|
||||
activeView: 'terminal',
|
||||
activeTabId: FLOATING_TAB_ID,
|
||||
activeWorktreeId: 'regular',
|
||||
unreadAgentCompletionPanes: { [FLOATING_PANE_KEY]: true }
|
||||
})
|
||||
openFloatingPanel()
|
||||
const read = vi.spyOn(useAppStore.getState(), 'readOnScreenAttention')
|
||||
try {
|
||||
renderHook(() => useReadOnScreenAttention())
|
||||
expect(read).toHaveBeenCalled()
|
||||
expect(
|
||||
read.mock.calls.flatMap(([screen]) => screen.targets.map((target) => target.worktreeId))
|
||||
).not.toContain('regular')
|
||||
} finally {
|
||||
cleanup()
|
||||
vi.restoreAllMocks()
|
||||
}
|
||||
})
|
||||
|
||||
@@ -43,22 +43,6 @@ describe('app menu paste', () => {
|
||||
expect(performNativePaste).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('falls back to native paste when no Orca-owned target is focused', async () => {
|
||||
const button = document.createElement('button')
|
||||
document.body.appendChild(button)
|
||||
button.focus()
|
||||
const performNativePaste = vi.fn()
|
||||
|
||||
const result = await handleAppMenuPasteRequest({
|
||||
readClipboardText: vi.fn(async () => 'text'),
|
||||
performNativePaste,
|
||||
dispatchOwnedPasteEvent: () => false
|
||||
})
|
||||
|
||||
expect(result).toEqual({ status: 'native-fallback', reason: 'no-owned-target' })
|
||||
expect(performNativePaste).toHaveBeenCalledWith({ mode: 'paste' })
|
||||
})
|
||||
|
||||
it('preserves paste-and-match-style when falling back to native paste', async () => {
|
||||
const performNativePaste = vi.fn()
|
||||
|
||||
@@ -144,21 +128,6 @@ describe('app menu paste', () => {
|
||||
expect(performNativePaste).toHaveBeenCalledWith({ mode: 'paste' })
|
||||
})
|
||||
|
||||
it('native-pastes an image-only clipboard so the control can take the image', async () => {
|
||||
const textarea = appendTextarea()
|
||||
textarea.focus()
|
||||
const performNativePaste = vi.fn()
|
||||
|
||||
const result = await handleAppMenuPasteRequest({
|
||||
readClipboardText: vi.fn(async () => ''),
|
||||
performNativePaste,
|
||||
dispatchOwnedPasteEvent: () => false
|
||||
})
|
||||
|
||||
expect(result).toEqual({ status: 'native-fallback', reason: 'no-clipboard-text' })
|
||||
expect(performNativePaste).toHaveBeenCalledWith({ mode: 'paste' })
|
||||
})
|
||||
|
||||
it('does not native-fallback when clipboard read fails after focus moves', async () => {
|
||||
const textarea = appendTextarea('unchanged')
|
||||
const other = appendTextarea('other')
|
||||
@@ -186,33 +155,4 @@ describe('app menu paste', () => {
|
||||
expect(other.value).toBe('other')
|
||||
expect(performNativePaste).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects oversized focused text-control paste without native fallback', async () => {
|
||||
const textarea = appendTextarea('unchanged')
|
||||
textarea.focus()
|
||||
const performNativePaste = vi.fn()
|
||||
|
||||
const result = await handleAppMenuPasteRequest({
|
||||
readClipboardText: vi.fn(async () => {
|
||||
throw new Error('Clipboard text is too large for this paste target.')
|
||||
}),
|
||||
performNativePaste,
|
||||
dispatchOwnedPasteEvent: () => false
|
||||
})
|
||||
|
||||
expect(result).toMatchObject({
|
||||
status: 'rejected',
|
||||
target: 'text-control',
|
||||
reason: 'too-large'
|
||||
})
|
||||
expect(result.status === 'rejected' ? result.redactedDiagnostic : '').toContain(
|
||||
'reason=too-large'
|
||||
)
|
||||
expect(result.status === 'rejected' ? result.redactedDiagnostic : '').toContain('durationMs=')
|
||||
expect(result.status === 'rejected' ? result.redactedDiagnostic : '').toContain(
|
||||
'content=redacted'
|
||||
)
|
||||
expect(textarea.value).toBe('unchanged')
|
||||
expect(performNativePaste).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -4,10 +4,8 @@ import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { RuntimeMobileSessionTabsResult } from '../../../shared/runtime-types'
|
||||
import type { Tab } from '../../../shared/tab-types'
|
||||
import { applyLocalStructuredSessionTabSnapshots } from './local-structured-session-tabs-sync/snapshot-apply'
|
||||
import {
|
||||
resetLocalStructuredSessionVersionForTests,
|
||||
localStructuredSessionEpochHistoryByWorktree
|
||||
} from './local-structured-session-tabs-sync/inventory-generation-fence'
|
||||
import { resetLocalStructuredSessionVersionForTests } from './local-structured-session-tabs-sync/inventory-generation-fence'
|
||||
|
||||
import type { WebSessionTabsSyncState } from './web-session-tabs-sync/state'
|
||||
import { resetWebSessionFocusIntentForTests } from './web-session-focus-intent'
|
||||
|
||||
@@ -124,14 +122,6 @@ function replayUntilDrop(
|
||||
}
|
||||
|
||||
describe('retired-epoch repair for a returning publisher', () => {
|
||||
it('POSITIVE CONTROL: the same frame lands when no epoch has been retired', () => {
|
||||
const applied = applyLocalStructuredSessionTabSnapshots(stateWithCoordinatorTerminal(), [
|
||||
frame(RENDERER_EPOCH, 7, 'claude-1')
|
||||
])
|
||||
|
||||
expect(chatTabs(applied)).toEqual(['Claude Chat'])
|
||||
})
|
||||
|
||||
it('drops the returning publisher and reports it to the repair lane', () => {
|
||||
const onRetiredEpochDrop = vi.fn()
|
||||
|
||||
@@ -165,21 +155,4 @@ describe('retired-epoch repair for a returning publisher', () => {
|
||||
|
||||
expect(chatTabs(redelivered)).toEqual([])
|
||||
})
|
||||
|
||||
it('revives only the epoch authority names, leaving other generations fenced', () => {
|
||||
const dropped = replayUntilDrop()
|
||||
applyLocalStructuredSessionTabSnapshots(
|
||||
dropped,
|
||||
[frame(RENDERER_EPOCH, 7, 'claude-1')],
|
||||
undefined,
|
||||
undefined,
|
||||
{ authoritative: true }
|
||||
)
|
||||
|
||||
// Authoritative census restored renderer epoch; delayed frames from displaced headless generation must remain retired.
|
||||
const history = localStructuredSessionEpochHistoryByWorktree.get(WORKTREE)
|
||||
expect(history?.current).toBe(RENDERER_EPOCH)
|
||||
expect(history?.retired).toContain(HEADLESS_EPOCH)
|
||||
expect(history?.retired).not.toContain(RENDERER_EPOCH)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -117,49 +117,6 @@ describe('setWebRuntimeTabProps', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('maps mirrored browser/editor unified ids before setting host tab props', async () => {
|
||||
vi.stubGlobal('__ORCA_WEB_CLIENT__', false)
|
||||
mocks.getState.mockReturnValue(ownedBy(RUNTIME_ROW))
|
||||
mocks.resolveHostSessionTabIdForWebSessionTab.mockImplementation(
|
||||
(_state, args: { tabId: string }) =>
|
||||
args.tabId === 'local-browser-unified' ? 'host-browser-unified' : null
|
||||
)
|
||||
const runtimeCall = vi.fn().mockResolvedValue({ id: 'p', ok: true, result: { updated: true } })
|
||||
stubRuntimeEnvironmentBridge({ call: runtimeCall })
|
||||
|
||||
expect(
|
||||
setWebRuntimeTabProps({
|
||||
worktreeId: WORKTREE_ID,
|
||||
tabId: 'local-browser-unified',
|
||||
color: '#3b82f6'
|
||||
})
|
||||
).toBe(true)
|
||||
|
||||
await vi.waitFor(() => expect(runtimeCall).toHaveBeenCalledTimes(1))
|
||||
expect(runtimeCall).toHaveBeenCalledWith({
|
||||
selector: ENVIRONMENT_ID,
|
||||
method: 'session.tabs.setTabProps',
|
||||
params: {
|
||||
worktree: `id:${WORKTREE_ID}`,
|
||||
tabId: 'host-browser-unified',
|
||||
color: '#3b82f6'
|
||||
},
|
||||
timeoutMs: 15_000
|
||||
})
|
||||
})
|
||||
|
||||
it('no-ops for a worktree with no runtime environment (local tab)', () => {
|
||||
vi.stubGlobal('__ORCA_WEB_CLIENT__', false)
|
||||
mocks.getState.mockReturnValue(ownedBy({ hostId: 'local' }))
|
||||
const runtimeCall = vi.fn()
|
||||
stubRuntimeEnvironmentBridge({ call: runtimeCall })
|
||||
|
||||
expect(
|
||||
setWebRuntimeTabProps({ worktreeId: WORKTREE_ID, tabId: 'local-tab', color: '#fff' })
|
||||
).toBe(false)
|
||||
expect(runtimeCall).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('refuses a local twin of the paired server instead of pushing to either host', () => {
|
||||
vi.stubGlobal('__ORCA_WEB_CLIENT__', false)
|
||||
mocks.getState.mockReturnValue(ownedBy({ hostId: 'local' }, RUNTIME_ROW))
|
||||
|
||||
@@ -22,14 +22,6 @@ const remoteRepo: Repo = {
|
||||
executionHostId: 'runtime:env-1'
|
||||
}
|
||||
|
||||
const localRepo: Repo = {
|
||||
id: 'app',
|
||||
path: '/Users/me/app',
|
||||
displayName: 'app',
|
||||
badgeColor: '#000',
|
||||
addedAt: 1
|
||||
}
|
||||
|
||||
const hostCalls: string[] = []
|
||||
let hostKnowsProject = true
|
||||
let hostSelectorAmbiguous = false
|
||||
@@ -172,20 +164,3 @@ describe('project removal after its server is gone', () => {
|
||||
expect(store.getState().repos).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('project removal error toast', () => {
|
||||
it('shows the host’s message without the IPC wrapper', async () => {
|
||||
removeForHost.mockRejectedValue(
|
||||
new Error("Error invoking remote method 'repos:removeForHost': Error: Project is busy")
|
||||
)
|
||||
const store = createTestStore()
|
||||
store.setState({ repos: [localRepo] })
|
||||
|
||||
await store.getState().removeProject('app', { hostId: 'local', errorFeedback: 'toast' })
|
||||
|
||||
expect(toast.error).toHaveBeenCalledWith(
|
||||
'Failed to remove project',
|
||||
expect.objectContaining({ description: 'Project is busy' })
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -76,16 +76,6 @@ describe('store identity churn probe', () => {
|
||||
expect(readStoreIdentityChurnReport()).toEqual([])
|
||||
})
|
||||
|
||||
it('does not flag a refresh that returns the original reference', () => {
|
||||
const store = createProbeStore()
|
||||
const original = store.getState().rows
|
||||
armStoreIdentityChurnProbe()
|
||||
|
||||
store.getState().refresh(original)
|
||||
|
||||
expect(readStoreIdentityChurnReport()).toEqual([])
|
||||
})
|
||||
|
||||
it('names the write site when capture is requested', () => {
|
||||
const store = createProbeStore()
|
||||
armStoreIdentityChurnProbe({ captureSites: true })
|
||||
@@ -118,15 +108,6 @@ describe('store identity churn probe', () => {
|
||||
expect(churnFor('rows')).toBe(1)
|
||||
})
|
||||
|
||||
it('ignores a write zustand itself drops as identical', () => {
|
||||
const store = createProbeStore()
|
||||
armStoreIdentityChurnProbe()
|
||||
|
||||
store.setState((state) => state)
|
||||
|
||||
expect(readStoreIdentityChurnReport()).toEqual([])
|
||||
})
|
||||
|
||||
it('passes disarmed writes straight through without reading state', () => {
|
||||
const innerSet = vi.fn()
|
||||
const innerGet = vi.fn(() => ({ counter: 0 }))
|
||||
@@ -190,14 +171,6 @@ describe('store identity churn probe', () => {
|
||||
expect(churnFor('rows')).toBe(1)
|
||||
})
|
||||
|
||||
it('records nothing while disarmed', () => {
|
||||
const store = createProbeStore()
|
||||
|
||||
store.getState().refresh([{ id: 'a', label: 'A' }])
|
||||
|
||||
expect(readStoreIdentityChurnReport()).toEqual([])
|
||||
})
|
||||
|
||||
it('treats distinct class instances as changed rather than equal', () => {
|
||||
const store = create<{ value: unknown; put: (value: unknown) => void }>()(
|
||||
withStoreIdentityChurnProbe((set) => ({
|
||||
|
||||
@@ -82,11 +82,6 @@ function withoutTab(frame: PublishedWorkspaceLayout, tabId: string): PublishedWo
|
||||
}
|
||||
|
||||
describe('laying frame slices over the store', () => {
|
||||
it('changes nothing for the frame the store already shows', async () => {
|
||||
const { state, session, frame } = await restored()
|
||||
expect(patchFor(state, session, frame)).toEqual({})
|
||||
})
|
||||
|
||||
it('drops a terminal tab main closed, with its pane layout', async () => {
|
||||
const { state, session, frame } = await restored()
|
||||
const patch = patchFor(state, session, withoutTab(frame, 'tab-shell'))
|
||||
@@ -114,21 +109,6 @@ describe('laying frame slices over the store', () => {
|
||||
).not.toHaveProperty('tab-shell')
|
||||
})
|
||||
|
||||
it('gives a row the frame creates the window’s own fields for it', async () => {
|
||||
const { state, session, frame } = await restored()
|
||||
const shown = patchFor(state, session, withoutTab(frame, 'tab-shell'))
|
||||
const before = { ...state, ...shown }
|
||||
const fields = vi.fn((id: string) =>
|
||||
id === 'tab-shell' ? { pendingActivationSpawn: true } : null
|
||||
)
|
||||
|
||||
const patch = patchFor(before, session, frame, undefined, fields)
|
||||
|
||||
const row = patch.tabsByWorktree?.[GIT_KEY]?.find((tab) => tab.id === 'tab-shell')
|
||||
expect(row?.pendingActivationSpawn).toBe(true)
|
||||
expect(fields).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('keeps an open file’s editor state and place, and drops a file main closed', async () => {
|
||||
const { state, session, frame } = await restored()
|
||||
const file = state.openFiles.find((entry) => entry.worktreeId === GIT_KEY)!
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { GitBranchChangeEntry } from './git-diff-compare-types'
|
||||
|
||||
import type { GitStatusEntry } from './git-status-types'
|
||||
import {
|
||||
buildGitStatusSourceControlTree,
|
||||
@@ -24,22 +24,6 @@ function labels(nodes: ReturnType<typeof flattenSourceControlTree>): string[] {
|
||||
}
|
||||
|
||||
describe('buildSourceControlTree', () => {
|
||||
it('groups changed files by path segments', () => {
|
||||
const tree = buildSourceControlTree('unstaged', [
|
||||
entry({ path: 'src/components/Button.tsx' }),
|
||||
entry({ path: 'src/index.ts' }),
|
||||
entry({ path: 'README.md' })
|
||||
])
|
||||
|
||||
expect(labels(flattenSourceControlTree(tree, new Set()))).toEqual([
|
||||
'directory:src',
|
||||
'directory:src/components',
|
||||
'file:src/components/Button.tsx',
|
||||
'file:src/index.ts',
|
||||
'file:README.md'
|
||||
])
|
||||
})
|
||||
|
||||
it('orders numbered sibling directories naturally, matching the file rows', () => {
|
||||
const tree = buildSourceControlTree('unstaged', [
|
||||
entry({ path: 'migrations/100 - b/a.sql' }),
|
||||
@@ -104,23 +88,6 @@ describe('buildSourceControlTree', () => {
|
||||
])
|
||||
})
|
||||
|
||||
it('groups committed branch entries with branch-scoped directory keys', () => {
|
||||
const entries: GitBranchChangeEntry[] = [
|
||||
{ path: 'packages/app/src/index.ts', status: 'modified' },
|
||||
{ path: 'packages/app/package.json', status: 'added' }
|
||||
]
|
||||
const tree = buildSourceControlTree('branch', entries)
|
||||
|
||||
expect(labels(flattenSourceControlTree(tree, new Set()))).toEqual([
|
||||
'directory:packages',
|
||||
'directory:packages/app',
|
||||
'directory:packages/app/src',
|
||||
'file:packages/app/src/index.ts',
|
||||
'file:packages/app/package.json'
|
||||
])
|
||||
expect(tree[0].key).toBe('dir::branch::packages')
|
||||
})
|
||||
|
||||
it('compacts single-child directory chains for visible source-control rows', () => {
|
||||
const tree = buildSourceControlTree('branch', [
|
||||
{ path: 'src/renderer/src/mockups.css', status: 'modified' },
|
||||
@@ -160,27 +127,6 @@ describe('buildSourceControlTree', () => {
|
||||
])
|
||||
})
|
||||
|
||||
it('can namespace directory keys without changing file entry areas', () => {
|
||||
const tree = compactSourceControlTree(
|
||||
buildGitStatusSourceControlTree('unstaged', [
|
||||
entry({
|
||||
path: 'src/conflict.ts',
|
||||
conflictKind: 'both_modified',
|
||||
conflictStatus: 'unresolved'
|
||||
})
|
||||
])
|
||||
)
|
||||
|
||||
const namespaced = namespaceSourceControlTreeDirectoryKeys(tree, 'conflicts')
|
||||
const rows = flattenSourceControlTree(namespaced, new Set())
|
||||
const directory = rows.find((node) => node.type === 'directory')
|
||||
const file = rows.find((node) => node.type === 'file')
|
||||
|
||||
expect(directory?.key).toBe('dir::conflicts::src')
|
||||
expect(file?.key).toBe('unstaged::src/conflict.ts')
|
||||
expect(file?.area).toBe('unstaged')
|
||||
})
|
||||
|
||||
it('can preserve file node areas from mixed conflict section entries', () => {
|
||||
const tree = compactSourceControlTree(
|
||||
buildGitStatusSourceControlTree('unstaged', [
|
||||
|
||||
Reference in New Issue
Block a user