mirror of
https://github.com/stablyai/orca.git
synced 2026-11-15 10:27:33 +00:00
Remove 390 low-value test cases and 18 whole suites (#38209)
Co-authored-by: Codex <codex@openai.com>
This commit is contained in:
@@ -1110,7 +1110,6 @@ jobs:
|
||||
xvfb-run --auto-servernum pnpm exec vitest run --config config/vitest.config.ts
|
||||
--no-file-parallelism
|
||||
src/main/browser/client-pages/renderer/browser-client-page-renderer-lifecycle.electron.test.ts
|
||||
src/main/browser/network/egress/browser-route-webrtc-egress.electron.test.ts
|
||||
src/main/persistence/profile-state/profile-state-writer-stall.electron.test.ts
|
||||
src/main/browser/cookies/browser-google-cookie-clear.electron.test.ts
|
||||
src/main/browser/guest-lifecycle/popups/popup-origin-chrome.electron.test.ts
|
||||
|
||||
@@ -620,7 +620,6 @@
|
||||
1 test-timeout-needs-reason:src/main/browser/cookies/browser-cookie-import-validated-partition.electron.test.ts
|
||||
1 test-timeout-needs-reason:src/main/browser/guest-lifecycle/find/browser-find-frame-traversal.electron.test.ts
|
||||
1 test-timeout-needs-reason:src/main/browser/identity/browser-session-ua-cloudflare-live.electron.test.ts
|
||||
1 test-timeout-needs-reason:src/main/browser/network/egress/browser-route-webrtc-egress.electron.test.ts
|
||||
1 test-timeout-needs-reason:src/main/claude/claude-background-run-isolation-real-cli.test.ts
|
||||
1 test-timeout-needs-reason:src/main/claude/claude-child-exit-proof-ladder.test.ts
|
||||
1 test-timeout-needs-reason:src/main/claude/claude-stop-ack-race.test.ts
|
||||
@@ -1316,7 +1315,6 @@
|
||||
4 test-no-wall-clock:src/main/updater/lifecycle/serve-supervisor-install-park.test.ts
|
||||
4 test-no-wall-clock:src/renderer/src/components/cmd-j/palette-live-status.test.tsx
|
||||
4 test-no-wall-clock:src/renderer/src/components/sidebar/workspace-card/surface/WorktreeCard.compact-hover.test.tsx
|
||||
4 test-no-wall-clock:src/renderer/src/components/status-bar/provider-segment-balance.test.tsx
|
||||
4 test-no-wall-clock:src/renderer/src/components/task-page/jira/task-page-jira-cache-selectors.test.ts
|
||||
4 test-no-wall-clock:tests/e2e/cross-version-wire/release-checkout.unit.test.ts
|
||||
4 test-timeout-needs-reason:src/main/claude/claude-agent-sdk-exit-proof.test.ts
|
||||
|
||||
+85
-201
File diff suppressed because one or more lines are too long
@@ -4268,7 +4268,6 @@
|
||||
"src/main/persistence/terminal-topology/duplicate-pty-pane-repair.test.ts": 2087,
|
||||
"src/main/persistence/terminal-topology/editor-renames-across-partitions.test.ts": 526,
|
||||
"src/main/persistence/terminal-topology/editor-tab-layout-apply.test.ts": 2243,
|
||||
"src/main/persistence/terminal-topology/layout-owner-written-session-golden.test.ts": 1961,
|
||||
"src/main/persistence/terminal-topology/open-in-new-group-layout-apply.test.ts": 1943,
|
||||
"src/main/persistence/terminal-topology/pane-last-grid.test.ts": 5642,
|
||||
"src/main/persistence/terminal-topology/persistence-binding-graft-placement.test.ts": 1949,
|
||||
@@ -5910,7 +5909,6 @@
|
||||
"src/main/runtime/terminal/stop/exact-terminal-stop-liveness.test.ts": 4321,
|
||||
"src/main/runtime/terminal/stop/terminal-update-admission.test.ts": 48,
|
||||
"src/main/runtime/terminal/surface/closed-pane-exit-cleanup-main.test.ts": 4281,
|
||||
"src/main/runtime/terminal/surface/main-default-pane-acquisition.test.ts": 5812,
|
||||
"src/main/runtime/terminal/surface/main-terminal-acquisition.test.ts": 4330,
|
||||
"src/main/runtime/terminal/surface/pty-pane-copies-untouched-main.test.ts": 4585,
|
||||
"src/main/runtime/terminal/surface/terminal-surface-replies-main.test.ts": 4324,
|
||||
@@ -7127,7 +7125,6 @@
|
||||
"src/renderer/src/components/editor/EditorPanel.diff-rich-owner.test.tsx": 2823,
|
||||
"src/renderer/src/components/editor/EditorPanelHeaderPath.test.tsx": 2664,
|
||||
"src/renderer/src/components/editor/external-changes/ExternalFileChangeBanner.test.tsx": 2142,
|
||||
"src/renderer/src/components/editor/ImageViewer.test.tsx": 1872,
|
||||
"src/renderer/src/components/editor/notebook/cells/IpynbCellEditor.external-sync.test.tsx": 1660,
|
||||
"src/renderer/src/components/editor/notebook/kernel/IpynbKernelToolbar.test.tsx": 736,
|
||||
"src/renderer/src/components/editor/notebook/document/IpynbRendering.test.tsx": 2143,
|
||||
@@ -7483,7 +7480,6 @@
|
||||
"src/renderer/src/components/mobile/paired-mobile-devices.test.ts": 192,
|
||||
"src/renderer/src/components/mobile/use-mobile-page-paired-devices.test.ts": 475,
|
||||
"src/renderer/src/components/native-chat-launch-resume-decision.test.tsx": 1844,
|
||||
"src/renderer/src/components/native-chat-restart-action-notifications.test.ts": 2125,
|
||||
"src/renderer/src/components/native-chat-resume-bulk-progress.test.tsx": 3261,
|
||||
"src/renderer/src/components/native-chat-resume-failed-chat-watch.test.ts": 1954,
|
||||
"src/renderer/src/components/native-chat-resume-failure-guidance.test.ts": 240,
|
||||
@@ -7802,7 +7798,6 @@
|
||||
"src/renderer/src/components/network/CustomAddressDialog.test.tsx": 568,
|
||||
"src/renderer/src/components/new-workspace/ComposerParentWorktreePicker.test.tsx": 870,
|
||||
"src/renderer/src/components/new-workspace/DirectWorkItemSetupDialog.test.tsx": 5296,
|
||||
"src/renderer/src/components/new-workspace/NewWorkspaceComposerAgentSection.test.tsx": 895,
|
||||
"src/renderer/src/components/new-workspace/NewWorkspaceComposerNameSection.test.tsx": 2493,
|
||||
"src/renderer/src/components/new-workspace/ProjectCombobox.dialog-handoff.test.tsx": 1043,
|
||||
"src/renderer/src/components/new-workspace/ProjectCombobox.test.tsx": 848,
|
||||
@@ -7905,9 +7900,7 @@
|
||||
"src/renderer/src/components/right-sidebar/file-explorer/panel/FileExplorerToolbar.test.tsx": 1837,
|
||||
"src/renderer/src/components/right-sidebar/file-explorer/mutations/FileExplorerVirtualRows.row-handlers.test.tsx": 3415,
|
||||
"src/renderer/src/components/right-sidebar/hosted-review/folder-checks/FolderWorkspacePrChecksPanel.test.tsx": 863,
|
||||
"src/renderer/src/components/right-sidebar/hosted-review/folder-checks/FolderWorkspacePrChecksRow.test.tsx": 293,
|
||||
"src/renderer/src/components/right-sidebar/FolderWorkspaceWorktreesPanel.test.tsx": 461,
|
||||
"src/renderer/src/components/right-sidebar/hosted-review/provider-actions/HostedReviewActions.draft.test.tsx": 1587,
|
||||
"src/renderer/src/components/right-sidebar/plugin-panels/PluginPanel.test.tsx": 2710,
|
||||
"src/renderer/src/components/right-sidebar/PortsPanel.test.tsx": 1618,
|
||||
"src/renderer/src/components/right-sidebar/hosted-review/creation/PullRequestComposer.generate-tooltip.test.tsx": 1382,
|
||||
@@ -7995,7 +7988,6 @@
|
||||
"src/renderer/src/components/right-sidebar/source-control/ai/runSourceControlAgentActionStart.test.ts": 1917,
|
||||
"src/renderer/src/components/right-sidebar/source-control/panel/source-control-branch-context-row.test.tsx": 790,
|
||||
"src/renderer/src/components/right-sidebar/source-control/review/source-control-create-pr-intent-flow.test.ts": 71,
|
||||
"src/renderer/src/components/right-sidebar/source-control/review/source-control-dropdown-items.create-pr-intent.test.ts": 324,
|
||||
"src/renderer/src/components/right-sidebar/source-control/panel/source-control-dropdown-items.test.ts": 409,
|
||||
"src/renderer/src/components/right-sidebar/source-control/review/source-control-manual-review-url.test.ts": 77,
|
||||
"src/renderer/src/components/right-sidebar/source-control/review/source-control-primary-action.create-pr-intent.test.ts": 340,
|
||||
@@ -8197,7 +8189,6 @@
|
||||
"src/renderer/src/components/sidebar/LocalGitToolchainScanBanner.test.tsx": 2025,
|
||||
"src/renderer/src/components/sidebar/MarkdownImageLightbox.test.tsx": 2284,
|
||||
"src/renderer/src/components/sidebar/project-admission/dialog/NonGitFolderDialog.test.tsx": 2473,
|
||||
"src/renderer/src/components/sidebar/worktree-list/rows/NoticeHostGlyph.test.tsx": 2697,
|
||||
"src/renderer/src/components/sidebar/host-path-browser/RemoteFileBrowser.keyboard.test.tsx": 825,
|
||||
"src/renderer/src/components/sidebar/host-path-browser/RemoteFileBrowser.paste.test.tsx": 708,
|
||||
"src/renderer/src/components/sidebar/SetupGuideSidebarEntry.test.tsx": 386,
|
||||
@@ -8305,7 +8296,6 @@
|
||||
"src/renderer/src/components/sidebar/workspace-board/search/workspace-kanban-search.test.ts": 176,
|
||||
"src/renderer/src/components/sidebar/workspace-board/drag/workspace-kanban-sidebar-drop.test.ts": 1891,
|
||||
"src/renderer/src/components/sidebar/workspace-actions/context-menu/workspace-lineage-menu-actions.test.ts": 235,
|
||||
"src/renderer/src/components/sidebar/worktree-list/controls/workspace-options-menu-items.test.tsx": 630,
|
||||
"src/renderer/src/components/sidebar/workspace-card/review/workspace-reference-detail-read.test.ts": 662,
|
||||
"src/renderer/src/components/sidebar/workspace-card/review/workspace-reference-refresh-targets.test.ts": 945,
|
||||
"src/renderer/src/components/sidebar/worktree-list/parity/workspace-view-evaluator-parity.test.ts": 2374,
|
||||
@@ -8318,7 +8308,6 @@
|
||||
"src/renderer/src/components/sidebar/workspace-card/activity/worktree-agent-row-selectors.test.ts": 376,
|
||||
"src/renderer/src/components/sidebar/workspace-card/activity/worktree-card-compact-agent-row.stable-message.test.tsx": 2166,
|
||||
"src/renderer/src/components/sidebar/workspace-card/activity/worktree-card-idle-child-agents.test.ts": 1899,
|
||||
"src/renderer/src/components/sidebar/workspace-card/surface/worktree-card-option-items.test.tsx": 459,
|
||||
"src/renderer/src/components/sidebar/workspace-card/activity/worktree-card-status-inputs.test.ts": 55,
|
||||
"src/renderer/src/components/sidebar/workspace-actions/deletion/worktree-delete-host-qualification.test.ts": 1225,
|
||||
"src/renderer/src/components/sidebar/worktree-list/navigation/worktree-keyboard-cycle.test.ts": 131,
|
||||
@@ -8426,7 +8415,6 @@
|
||||
"src/renderer/src/components/status-bar/owner-usage-rate-limits.test.ts": 260,
|
||||
"src/renderer/src/components/status-bar/ports-status-popover-rows.test.tsx": 710,
|
||||
"src/renderer/src/components/status-bar/provider-account-sync-key.test.ts": 310,
|
||||
"src/renderer/src/components/status-bar/provider-segment-balance.test.tsx": 2245,
|
||||
"src/renderer/src/components/status-bar/provider-segment-monthly-window.test.tsx": 2106,
|
||||
"src/renderer/src/components/status-bar/provider-segment-stale-warning.test.tsx": 423,
|
||||
"src/renderer/src/components/status-bar/remote-host-connection-status.test.ts": 200,
|
||||
@@ -8656,7 +8644,6 @@
|
||||
"src/renderer/src/components/terminal-pane/cache-timer-seeding.test.ts": 108,
|
||||
"src/renderer/src/components/terminal-pane/claude-detached-pane-restart.test.ts": 1938,
|
||||
"src/renderer/src/components/terminal-pane/claude-diff-panel-notice-banner.test.tsx": 1986,
|
||||
"src/renderer/src/components/terminal-pane/claude-old-terminal-banner.test.tsx": 2146,
|
||||
"src/renderer/src/components/terminal-pane/claude-side-question-completion.test.ts": 2305,
|
||||
"src/renderer/src/components/terminal-pane/closed-terminal-leaf-notice-layout-owner.test.ts": 1032,
|
||||
"src/renderer/src/components/terminal-pane/codex-backfill-error-detector.test.ts": 26,
|
||||
@@ -8891,7 +8878,6 @@
|
||||
"src/renderer/src/components/terminal-pane/split-right-white-screen.test.ts": 53,
|
||||
"src/renderer/src/components/terminal-pane/stale-document-visibility.test.ts": 88,
|
||||
"src/renderer/src/components/terminal-pane/terminal-agent-paste-bracketing.test.ts": 329,
|
||||
"src/renderer/src/components/terminal-pane/terminal-agent-session-continuation.test.ts": 88,
|
||||
"src/renderer/src/components/terminal-pane/terminal-agent-session-fork.test.ts": 1377,
|
||||
"src/renderer/src/components/terminal-pane/terminal-alternate-screen-parse.test.ts": 70,
|
||||
"src/renderer/src/components/terminal-pane/terminal-appearance.test.ts": 339,
|
||||
@@ -9157,7 +9143,6 @@
|
||||
"src/renderer/src/components/workspace-space/SpaceWindowStatePublisher.test.tsx": 2008,
|
||||
"src/renderer/src/components/worktree-creation/PendingNativeChatCreation.test.tsx": 4075,
|
||||
"src/renderer/src/components/worktree-jump/WorktreeJumpPalette.linear-url.test.tsx": 3372,
|
||||
"src/renderer/src/components/worktree-jump/WorktreeJumpPalette.mount-gating.test.tsx": 3635,
|
||||
"src/renderer/src/components/worktree-jump/WorktreeJumpPalette.recent-tabs.behavior.test.tsx": 4445,
|
||||
"src/renderer/src/components/worktree-jump/WorktreeJumpPalette.recent-tabs.test.tsx": 9384,
|
||||
"src/renderer/src/components/worktree-jump/WorktreeJumpPalette.task-search.test.tsx": 10126,
|
||||
@@ -9903,7 +9888,6 @@
|
||||
"src/renderer/src/store/repos/wsl-moved-repo-ui-retention.test.ts": 2920,
|
||||
"src/renderer/src/store/running-terminal-close-confirm.test.ts": 50,
|
||||
"src/renderer/src/store/runtime-status-subscriptions.test.ts": 182,
|
||||
"src/renderer/src/store/selectors.test.ts": 1816,
|
||||
"src/renderer/src/store/slices/active-selection-owner-scoping.test.ts": 1909,
|
||||
"src/renderer/src/store/slices/agent-generated-tab-title.test.ts": 1976,
|
||||
"src/renderer/src/store/slices/agent-hibernation-live-anchor-e2e.test.ts": 1783,
|
||||
|
||||
@@ -51,7 +51,6 @@ export const UNIT_EXCLUDE = [
|
||||
'src/main/browser/guest-lifecycle/popups/popup-origin-chrome.electron.test.ts',
|
||||
'src/main/browser/guest-lifecycle/navigation/browser-link-materialization.electron.test.ts',
|
||||
'src/main/browser/client-pages/renderer/browser-client-page-renderer-lifecycle.electron.test.ts',
|
||||
'src/main/browser/network/egress/browser-route-webrtc-egress.electron.test.ts',
|
||||
'src/main/persistence/profile-state/profile-state-writer-stall.electron.test.ts',
|
||||
'src/main/browser/guest-lifecycle/popups/browser-popup-compatibility.electron.test.ts',
|
||||
'src/main/browser/guest-lifecycle/popups/browser-popup-adapter-compatibility.electron.test.ts',
|
||||
|
||||
@@ -7,7 +7,6 @@ export const LINUX_PACKAGE_TESTS = [
|
||||
'src/main/browser/guest-lifecycle/popups/popup-origin-chrome.electron.test.ts',
|
||||
'src/main/browser/guest-lifecycle/navigation/browser-link-materialization.electron.test.ts',
|
||||
'src/main/browser/client-pages/renderer/browser-client-page-renderer-lifecycle.electron.test.ts',
|
||||
'src/main/browser/network/egress/browser-route-webrtc-egress.electron.test.ts',
|
||||
'src/main/persistence/profile-state/profile-state-writer-stall.electron.test.ts',
|
||||
'src/main/browser/guest-lifecycle/popups/browser-popup-compatibility.electron.test.ts',
|
||||
'src/main/browser/guest-lifecycle/popups/browser-popup-adapter-compatibility.electron.test.ts',
|
||||
@@ -48,7 +47,6 @@ export const WINDOWS_BOUNDARY_TESTS = [
|
||||
'config/scripts/package-electron-runtime-contract.test.mjs',
|
||||
'config/scripts/electron-builder-runtime-resources.test.mjs',
|
||||
'src/main/browser/client-pages/renderer/browser-client-page-renderer-lifecycle.electron.test.ts',
|
||||
'src/main/browser/network/egress/browser-route-webrtc-egress.electron.test.ts',
|
||||
'src/main/persistence/profile-state/profile-state-writer-stall.electron.test.ts',
|
||||
'src/main/providers/windows-conpty-wide-char-duplication.node-pty.test.ts',
|
||||
'src/main/providers/pty-repaint-wide-char-buffer.node-pty.test.ts',
|
||||
|
||||
@@ -65,7 +65,7 @@ describe('unit support routing', () => {
|
||||
'src/renderer/src/components/native-chat/NativeChatStructuredSession.next-test-harness.tsx',
|
||||
'src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx',
|
||||
'src/main/providers/windows-conpty-wide-char-duplication.node-pty.test.ts',
|
||||
'src/main/browser/network/egress/browser-route-webrtc-egress.electron.test.ts'
|
||||
'src/main/browser/client-pages/renderer/browser-client-page-renderer-lifecycle.electron.test.ts'
|
||||
]) {
|
||||
const result = classifyPrJobs([unitHarness, file])
|
||||
expect(result.should_run, file).toBe(true)
|
||||
|
||||
@@ -17,58 +17,6 @@ function finished(update: Partial<ToolCallUpdate>): ToolCallUpdate {
|
||||
}
|
||||
|
||||
describe('Grok subagent session updates', () => {
|
||||
it('reads a spawn with its description, its spawning turn and nothing it cannot show', () => {
|
||||
expect(
|
||||
grokSubagentNotification({
|
||||
sessionUpdate: 'subagent_spawned',
|
||||
subagent_id: 'subagent-1',
|
||||
child_session_id: 'subagent-1',
|
||||
parent_prompt_id: 'prompt-1',
|
||||
subagent_type: 'general-purpose',
|
||||
description: 'Count note lines',
|
||||
model: 'grok-4.7'
|
||||
})
|
||||
).toEqual({ id: 'subagent-1', state: 'working', label: 'Count note lines', turn: 'prompt-1' })
|
||||
})
|
||||
|
||||
it('labels a spawn with no description by its type', () => {
|
||||
expect(
|
||||
grokSubagentNotification({
|
||||
sessionUpdate: 'subagent_spawned',
|
||||
subagent_id: 'subagent-1',
|
||||
subagent_type: 'explore'
|
||||
})
|
||||
).toEqual({ id: 'subagent-1', state: 'working', label: 'explore' })
|
||||
})
|
||||
|
||||
it('reads progress as tokens only', () => {
|
||||
expect(
|
||||
grokSubagentNotification({
|
||||
sessionUpdate: 'subagent_progress',
|
||||
subagent_id: 'subagent-1',
|
||||
tokens_used: 4091
|
||||
})
|
||||
).toEqual({ id: 'subagent-1', tokens: 4091 })
|
||||
})
|
||||
|
||||
it.each([
|
||||
['completed', { state: 'completed', result: '4' }],
|
||||
['failed', { state: 'failed' }],
|
||||
['cancelled', { state: 'stopped' }],
|
||||
['something-new', {}]
|
||||
] as const)('reads a %s finish', (status, expected) => {
|
||||
expect(
|
||||
grokSubagentNotification({
|
||||
sessionUpdate: 'subagent_finished',
|
||||
subagent_id: 'subagent-1',
|
||||
status,
|
||||
output: status === 'completed' ? '4' : undefined,
|
||||
error: status === 'completed' ? undefined : 'Subagent was cancelled',
|
||||
tokens_used: 0
|
||||
})
|
||||
).toEqual({ id: 'subagent-1', ...expected })
|
||||
})
|
||||
|
||||
it('ignores every other update', () => {
|
||||
expect(grokSubagentNotification({ sessionUpdate: 'turn_completed' })).toBeUndefined()
|
||||
expect(grokSubagentNotification({ sessionUpdate: 'subagent_spawned' })).toBeUndefined()
|
||||
@@ -96,21 +44,6 @@ describe('Grok subagent session updates', () => {
|
||||
describe('Grok subagent tool calls', () => {
|
||||
const spawnInput = { description: 'Count note lines', prompt: 'Read notes.txt' }
|
||||
|
||||
it('ends a foreground spawn with its reply, the machine blocks removed', () => {
|
||||
expect(
|
||||
grokToolSubagents(
|
||||
finished({
|
||||
rawOutput: {
|
||||
type: 'SubagentCompleted',
|
||||
subagent_id: 'subagent-1',
|
||||
output: `4${SUBAGENT_RESULT_TAIL}`
|
||||
}
|
||||
}),
|
||||
tool('spawn_subagent', spawnInput)
|
||||
)
|
||||
).toEqual([{ id: 'subagent-1', state: 'completed', label: 'Count note lines', result: '4' }])
|
||||
})
|
||||
|
||||
it('keeps a background spawn running from its acknowledgement, labelled by the prompt when undescribed', () => {
|
||||
expect(
|
||||
grokToolSubagents(
|
||||
@@ -177,13 +110,4 @@ describe('Grok subagent tool calls', () => {
|
||||
])
|
||||
expect(grokToolBackgroundTasks(update, tool('kill_command_or_subagent'))).toEqual([])
|
||||
})
|
||||
|
||||
it('infers a kill with no result from the ids it named', () => {
|
||||
expect(
|
||||
grokToolSubagents(
|
||||
finished({}),
|
||||
tool('kill_command_or_subagent', { task_ids: ['subagent-1', 'subagent-1'] })
|
||||
)
|
||||
).toEqual([{ id: 'subagent-1', state: 'stopped', knownOnly: true }])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -175,15 +175,4 @@ describe('agent launch cancellation', () => {
|
||||
|
||||
expect(result.prompt).toEqual({ delivery: 'submit', outcome: 'handed-to-terminal' })
|
||||
})
|
||||
|
||||
it('hands its signal to the terminal prompt delivery', async () => {
|
||||
const controller = new AbortController()
|
||||
const h = harness({ lineCarriesPrompt: false })
|
||||
|
||||
await h.run(CREATE, controller.signal)
|
||||
|
||||
expect(h.deliverTerminalPrompt).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ signal: controller.signal })
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -55,128 +55,7 @@ const BASE: RuntimeManagedWorktreeCreateArgs = {
|
||||
lineage: { noParent: true }
|
||||
}
|
||||
|
||||
const FULL: RuntimeManagedWorktreeCreateArgs = {
|
||||
...BASE,
|
||||
navigation: 'caller',
|
||||
createdWithAgent: 'claude',
|
||||
startupLaunchPreferences: { model: 'opus', effort: 'high' },
|
||||
startupAgentArgs: '--verbose',
|
||||
startupExtraAgentArgs: '--extra',
|
||||
startupCwd: 'packages/app',
|
||||
startupPaneKey: 'tab-1:leaf-1',
|
||||
pendingFirstAgentMessageRename: true,
|
||||
automationProvenance: {
|
||||
kind: 'created-by-automation',
|
||||
automationId: 'auto-1',
|
||||
automationNameSnapshot: 'Nightly',
|
||||
automationRunId: 'run-1',
|
||||
automationRunTitleSnapshot: 'Nightly run',
|
||||
createdAt: 1,
|
||||
executionTargetType: 'local',
|
||||
executionTargetId: 'local',
|
||||
projectId: 'project-1'
|
||||
},
|
||||
cliProvenance: { kind: 'created-by-cli', createdAt: 1, startupAgent: 'claude' },
|
||||
lineage: { parentWorktree: 'id:parent', callerTerminalHandle: 'term_parent' },
|
||||
comment: 'from the CLI',
|
||||
linkedIssue: 7
|
||||
}
|
||||
|
||||
describe('createWorktreeWithStartupAgent', () => {
|
||||
it('forwards the startup agent and prompt to the create with the chat default on', async () => {
|
||||
const { runtime, created, create } = harness()
|
||||
const prompt = 'x'.repeat(3000)
|
||||
const args = {
|
||||
...FULL,
|
||||
startupAgent: 'claude' as const,
|
||||
startupPrompt: prompt,
|
||||
startupLaunchSource: 'cli'
|
||||
}
|
||||
|
||||
const result = await create(args)
|
||||
|
||||
expect(result).toBe(created)
|
||||
expect(runtime.createManagedWorktree).toHaveBeenCalledTimes(1)
|
||||
expect(runtime.createManagedWorktree).toHaveBeenCalledWith(args)
|
||||
expect(runtime.getStructuredAgentSessionCreateSupport).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('forwards a post-start agent and prompt to the create', async () => {
|
||||
const { runtime, create } = harness()
|
||||
const args = { ...FULL, startupAgent: 'aider' as const, startupPrompt: 'fix the bug' }
|
||||
|
||||
await create(args)
|
||||
|
||||
expect(runtime.createManagedWorktree).toHaveBeenCalledWith(args)
|
||||
})
|
||||
|
||||
it('omits a blank startup prompt from the create request', async () => {
|
||||
for (const startupPrompt of ['', ' \n']) {
|
||||
const { runtime, create } = harness()
|
||||
|
||||
await create({ ...FULL, startupAgent: 'codex', startupPrompt })
|
||||
|
||||
expect(runtime.createManagedWorktree).toHaveBeenCalledWith({ ...FULL, startupAgent: 'codex' })
|
||||
}
|
||||
})
|
||||
|
||||
it('forwards a linked draft through `startupDraft` with its resolved agent', async () => {
|
||||
const { runtime, create } = harness({ draftAgent: 'codex' })
|
||||
const { createdWithAgent: _requested, ...request } = FULL
|
||||
const draft = 'https://github.com/o/r/issues/1'
|
||||
|
||||
await create({ ...request, startupDraft: draft })
|
||||
|
||||
expect(runtime.resolveStartupDraftAgent).toHaveBeenCalledWith(
|
||||
{ id: 'repo-1', connectionId: null, executionHostId: null },
|
||||
undefined
|
||||
)
|
||||
expect(runtime.createManagedWorktree).toHaveBeenCalledWith({
|
||||
...request,
|
||||
startupDraft: draft,
|
||||
startupDraftAgent: 'codex'
|
||||
})
|
||||
})
|
||||
|
||||
it('asks for the requested draft agent and keeps it as the request named it', async () => {
|
||||
const { runtime, create } = harness()
|
||||
|
||||
await create({ ...FULL, startupDraft: 'https://x/1' })
|
||||
|
||||
expect(runtime.resolveStartupDraftAgent).toHaveBeenCalledWith(expect.anything(), 'claude')
|
||||
expect(runtime.createManagedWorktree).toHaveBeenCalledWith({
|
||||
...FULL,
|
||||
startupDraft: 'https://x/1',
|
||||
startupDraftAgent: 'claude'
|
||||
})
|
||||
})
|
||||
|
||||
it('creates without the draft when it resolves no agent, so the create does not detect again', async () => {
|
||||
const { runtime, create } = harness({ draftAgent: null })
|
||||
|
||||
await create({ ...FULL, startupDraft: 'https://x/1' })
|
||||
|
||||
expect(runtime.resolveStartupDraftAgent).toHaveBeenCalledTimes(1)
|
||||
expect(runtime.createManagedWorktree).toHaveBeenCalledWith(FULL)
|
||||
})
|
||||
|
||||
it('passes a request with no agent, a blank draft, or a prebuilt command straight to the create', async () => {
|
||||
for (const args of [
|
||||
BASE,
|
||||
{ ...BASE, startupDraft: ' ' },
|
||||
{
|
||||
...BASE,
|
||||
startupAgent: 'claude' as const,
|
||||
startup: { command: 'claude' }
|
||||
}
|
||||
]) {
|
||||
const { runtime, create } = harness()
|
||||
await create(args)
|
||||
expect(runtime.createManagedWorktree).toHaveBeenCalledWith(args)
|
||||
expect(runtime.showRepo).not.toHaveBeenCalled()
|
||||
}
|
||||
})
|
||||
|
||||
it('builds no second agent when the create spawned none here', async () => {
|
||||
const { runtime, created, create } = harness({
|
||||
created: {
|
||||
@@ -194,13 +73,4 @@ describe('createWorktreeWithStartupAgent', () => {
|
||||
expect(result).toBe(created)
|
||||
expect(runtime.createManagedWorktree).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('surfaces a create failure unchanged', async () => {
|
||||
const failure = new Error(
|
||||
'Selected agent is disabled. Choose an enabled agent before creating.'
|
||||
)
|
||||
const { create } = harness({ createError: failure })
|
||||
|
||||
await expect(create({ ...BASE, startupAgent: 'claude' })).rejects.toBe(failure)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -5,11 +5,7 @@ import {
|
||||
searchHit,
|
||||
searchResults
|
||||
} from '../../shared/ai-vault/search/ai-vault-search-test-fixture'
|
||||
import {
|
||||
searchSessionService,
|
||||
setSessionSearchService,
|
||||
sessionSearchServiceStatus
|
||||
} from './session-search-service-registry'
|
||||
import { searchSessionService, setSessionSearchService } from './session-search-service-registry'
|
||||
import {
|
||||
encodeSessionSearchCursor,
|
||||
decodeSessionSearchCursor,
|
||||
@@ -19,24 +15,6 @@ import {
|
||||
|
||||
afterEach(() => setSessionSearchService(null))
|
||||
|
||||
test.each(['runtime', 'relay'] as const)(
|
||||
'publishes the current supported catalog independently of indexed data on %s',
|
||||
async (transport) => {
|
||||
expect(await sessionSearchServiceStatus({}, transport)).toMatchObject({
|
||||
enabled: false,
|
||||
supportedAgents: [...AI_VAULT_AGENTS],
|
||||
supportsQoderHistory: true,
|
||||
supportsJcodeHistory: true
|
||||
})
|
||||
const service = fakeSearchService()
|
||||
setSessionSearchService(service)
|
||||
expect((await sessionSearchServiceStatus({}, transport)).supportedAgents).toEqual(
|
||||
AI_VAULT_AGENTS
|
||||
)
|
||||
expect(service.status.mock.lastCall).toEqual([])
|
||||
}
|
||||
)
|
||||
|
||||
test.each(['runtime', 'relay'] as const)(
|
||||
'keeps an empty decoder catalog authoritative over both history flags on %s',
|
||||
async (transport) => {
|
||||
@@ -62,30 +40,6 @@ test.each(['runtime', 'relay'] as const)(
|
||||
}
|
||||
)
|
||||
|
||||
test.each(['qoder', 'jcode'] as const)(
|
||||
'projects the independent %s history capability before retrieval',
|
||||
async (agent) => {
|
||||
const service = fakeSearchService()
|
||||
setSessionSearchService(service)
|
||||
const supportField = agent === 'qoder' ? 'supportsQoderHistory' : 'supportsJcodeHistory'
|
||||
await searchSessionService({ query: 'proof', [supportField]: true }, 'relay')
|
||||
expect(service.search).toHaveBeenCalledExactlyOnceWith(
|
||||
{
|
||||
query: 'proof',
|
||||
limit: 20,
|
||||
filters: {
|
||||
agents: AI_VAULT_AGENTS.filter((candidate) =>
|
||||
agent === 'qoder'
|
||||
? !['jcode', 'kiro'].includes(candidate)
|
||||
: !['codebuddy', 'zcode', 'qoder', 'kiro'].includes(candidate)
|
||||
)
|
||||
}
|
||||
},
|
||||
undefined
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
test.each(['runtime', 'relay'] as const)(
|
||||
'keeps an unsupported-only filter narrow through normal service checks on %s',
|
||||
async (transport) => {
|
||||
|
||||
@@ -51,18 +51,6 @@ describe('mergeAiVaultListResults', () => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('keeps the latest input scannedAt instead of restamping the merge', () => {
|
||||
const merged = mergeAiVaultListResults(
|
||||
[
|
||||
{ ...listResult([]), scannedAt: '2026-08-02T00:00:00.000Z' },
|
||||
{ ...listResult([]), scannedAt: '2026-08-02T00:00:05.000Z' }
|
||||
],
|
||||
undefined
|
||||
)
|
||||
|
||||
expect(merged.scannedAt).toBe('2026-08-02T00:00:05.000Z')
|
||||
})
|
||||
|
||||
it('ignores a future or unparsable remote stamp so the merge cannot pin the renderer guard', () => {
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(new Date('2026-08-02T00:00:10.000Z'))
|
||||
@@ -119,51 +107,6 @@ describe('mergeAiVaultListResults', () => {
|
||||
).toBe('2026-08-02T00:00:04.000Z')
|
||||
})
|
||||
|
||||
it('resolves an equal-instant tie the same way in either leg order', () => {
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(new Date('2026-08-02T00:00:10.000Z'))
|
||||
const noMillis = { ...listResult([]), scannedAt: '2026-08-02T00:00:05Z' }
|
||||
const withMillis = { ...listResult([]), scannedAt: '2026-08-02T00:00:05.000Z' }
|
||||
|
||||
expect(mergeAiVaultListResults([noMillis, withMillis], undefined).scannedAt).toBe(
|
||||
'2026-08-02T00:00:05.000Z'
|
||||
)
|
||||
expect(mergeAiVaultListResults([withMillis, noMillis], undefined).scannedAt).toBe(
|
||||
'2026-08-02T00:00:05.000Z'
|
||||
)
|
||||
})
|
||||
|
||||
it('normalizes the merged stamp so a leg ISO variant cannot leak into it', () => {
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(new Date('2026-08-02T00:00:10.000Z'))
|
||||
|
||||
expect(
|
||||
mergeAiVaultListResults(
|
||||
[{ ...listResult([]), scannedAt: '2026-08-01T19:00:05.000-05:00' }],
|
||||
undefined
|
||||
).scannedAt
|
||||
).toBe('2026-08-02T00:00:05.000Z')
|
||||
})
|
||||
|
||||
it('accepts the oldest representable stamp and rejects one millisecond beyond it', () => {
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(new Date('2026-08-02T00:00:10.000Z'))
|
||||
|
||||
expect(
|
||||
mergeAiVaultListResults(
|
||||
[{ ...listResult([]), scannedAt: '-271821-04-20T00:00:00.000Z' }],
|
||||
undefined
|
||||
).scannedAt
|
||||
).toBe('-271821-04-20T00:00:00.000Z')
|
||||
|
||||
expect(
|
||||
mergeAiVaultListResults(
|
||||
[{ ...listResult([]), scannedAt: '-271821-04-19T23:59:59.999Z' }],
|
||||
undefined
|
||||
).scannedAt
|
||||
).toBe('2026-08-02T00:00:10.000Z')
|
||||
})
|
||||
|
||||
it('does not cap an Unlimited all-host merge', () => {
|
||||
const sessions = Array.from({ length: 1001 }, (_, index) => session(index))
|
||||
const merged = mergeAiVaultListResults([{ ...listResult([]), sessions }], undefined, true)
|
||||
|
||||
@@ -73,70 +73,6 @@ async function settleAcknowledgements(): Promise<void> {
|
||||
}
|
||||
|
||||
describe('live screencast image allocation', () => {
|
||||
it('replays captured PNG and JPEG bytes without copying the decoded image again', async () => {
|
||||
const fixtures = [
|
||||
{
|
||||
format: 'png',
|
||||
path: 'docs/site/public/docs/remote-server-add-client.png',
|
||||
width: 832,
|
||||
height: 832
|
||||
},
|
||||
{
|
||||
format: 'jpeg',
|
||||
path: 'docs/site/public/docs/posters/file-drag.jpg',
|
||||
width: 1200,
|
||||
height: 682
|
||||
}
|
||||
] as const
|
||||
const captures = fixtures.map((fixture) => {
|
||||
const bytes = readFileSync(resolve(fixture.path))
|
||||
return { ...fixture, bytes, data: bytes.toString('base64'), expectedDigest: digest(bytes) }
|
||||
})
|
||||
const nativeUint8Array = globalThis.Uint8Array
|
||||
let imageCopyBytes = 0
|
||||
globalThis.Uint8Array = new Proxy(nativeUint8Array, {
|
||||
construct(target, argumentsList, newTarget) {
|
||||
const source: unknown = argumentsList[0]
|
||||
const bytes: unknown = Reflect.construct(target, argumentsList, newTarget)
|
||||
if (!(bytes instanceof nativeUint8Array)) {
|
||||
throw new Error('Unexpected byte constructor result')
|
||||
}
|
||||
if (Buffer.isBuffer(source)) {
|
||||
imageCopyBytes += bytes.byteLength
|
||||
}
|
||||
return bytes
|
||||
}
|
||||
})
|
||||
try {
|
||||
for (const fixture of captures) {
|
||||
const seen: number[] = []
|
||||
const route = createLiveFrameRoute(fixture.format, (wire) => {
|
||||
const frame = decodeBrowserScreencastFrame(wire)
|
||||
if (!frame) {
|
||||
throw new Error('Captured frame was not encoded')
|
||||
}
|
||||
expect(frame.format).toBe(fixture.format)
|
||||
expect(frame.metadata.imageWidth).toBe(fixture.width)
|
||||
expect(frame.metadata.imageHeight).toBe(fixture.height)
|
||||
expect(frame.image.byteLength).toBe(fixture.bytes.byteLength)
|
||||
expect(digest(frame.image)).toBe(fixture.expectedDigest)
|
||||
seen.push(frame.seq)
|
||||
return true
|
||||
})
|
||||
for (let sessionId = 1; sessionId <= 12; sessionId++) {
|
||||
route.emit(fixture.data, sessionId)
|
||||
}
|
||||
await settleAcknowledgements()
|
||||
expect(seen).toEqual(Array.from({ length: 12 }, (_, index) => index))
|
||||
expect(route.acknowledgements).toEqual(Array.from({ length: 12 }, (_, index) => index + 1))
|
||||
route.pacer.clearPending(true)
|
||||
}
|
||||
expect(imageCopyBytes).toBe(0)
|
||||
} finally {
|
||||
globalThis.Uint8Array = nativeUint8Array
|
||||
}
|
||||
})
|
||||
|
||||
it('owns pooled and offset images through refused sends and keeps encoded bytes independent', async () => {
|
||||
vi.useFakeTimers()
|
||||
const bytes = readFileSync(resolve('resources/tray/orca-menu-barTemplate.png'))
|
||||
|
||||
-52
@@ -6,9 +6,6 @@ import {
|
||||
updateContextMenuItem
|
||||
} from './browser-extension-context-menu-model'
|
||||
import {
|
||||
ACTION_MENU_CONTEXTS,
|
||||
arrangeExtensionContextMenuGroup,
|
||||
buildContextMenuOnClickData,
|
||||
contextMenuTargetFromParams,
|
||||
visibleContextMenuTree
|
||||
} from './browser-extension-context-menu-target'
|
||||
@@ -114,14 +111,6 @@ describe('what a right-click shows', () => {
|
||||
])
|
||||
})
|
||||
|
||||
it('shows action items only for the toolbar menu', () => {
|
||||
const toolbar = {
|
||||
contexts: new Set<string>(ACTION_MENU_CONTEXTS),
|
||||
pageUrl: 'https://site.test/'
|
||||
}
|
||||
expect(visibleContextMenuTree(items, toolbar).map((item) => item.key)).toEqual(['"toolbar"'])
|
||||
})
|
||||
|
||||
it('drops a parent whose children are all filtered out', () => {
|
||||
const nested = build(
|
||||
{ id: 'parent', title: 'Parent', contexts: ['all'] },
|
||||
@@ -129,19 +118,6 @@ describe('what a right-click shows', () => {
|
||||
)
|
||||
expect(visibleContextMenuTree(nested, pageClick)).toEqual([])
|
||||
})
|
||||
|
||||
it('shows one item inline and several under a submenu named after the extension', () => {
|
||||
const one = visibleContextMenuTree(build({ id: 'a', title: 'A' }), pageClick)
|
||||
expect(arrangeExtensionContextMenuGroup('ext', 'Vault', one)?.items).toEqual(one)
|
||||
const two = visibleContextMenuTree(
|
||||
build({ id: 'a', title: 'A' }, { id: 'b', title: 'B' }),
|
||||
pageClick
|
||||
)
|
||||
expect(arrangeExtensionContextMenuGroup('ext', 'Vault', two)?.items).toEqual([
|
||||
{ key: '', title: 'Vault', type: 'normal', checked: false, enabled: true, children: two }
|
||||
])
|
||||
expect(arrangeExtensionContextMenuGroup('ext', 'Vault', [])).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('clicking an item', () => {
|
||||
@@ -160,32 +136,4 @@ describe('clicking an item', () => {
|
||||
['r2', true]
|
||||
])
|
||||
})
|
||||
|
||||
it("builds Chrome's OnClickData", () => {
|
||||
const [item] = build({ id: 'fill', title: 'Fill', contexts: ['editable'], parentId: undefined })
|
||||
const target = contextMenuTargetFromParams(
|
||||
{
|
||||
isEditable: true,
|
||||
linkURL: 'https://site.test/next',
|
||||
srcURL: 'https://cdn.test/a.png',
|
||||
frameURL: 'https://frame.test/',
|
||||
selectionText: 'me@site.test'
|
||||
},
|
||||
'https://site.test/login',
|
||||
true
|
||||
)
|
||||
expect(
|
||||
buildContextMenuOnClickData({ item, target, frameId: 12, editable: true, mediaType: 'image' })
|
||||
).toEqual({
|
||||
menuItemId: 'fill',
|
||||
mediaType: 'image',
|
||||
linkUrl: 'https://site.test/next',
|
||||
srcUrl: 'https://cdn.test/a.png',
|
||||
pageUrl: 'https://site.test/login',
|
||||
frameUrl: 'https://frame.test/',
|
||||
frameId: 12,
|
||||
selectionText: 'me@site.test',
|
||||
editable: true
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,199 +0,0 @@
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { createRequire } from 'node:module'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterAll, describe, expect, it } from 'vitest'
|
||||
import { resolveElectronProbeLaunch } from '../../electron-fixtures/electron-probe-display-launch'
|
||||
|
||||
const electronBinary = createRequire(import.meta.url)('electron') as string
|
||||
const fixtureRoots: string[] = []
|
||||
|
||||
type ProbeResult = {
|
||||
packets: number
|
||||
policy: string
|
||||
resolvedProxy: string
|
||||
}
|
||||
|
||||
afterAll(() => {
|
||||
const failures: unknown[] = []
|
||||
for (const root of fixtureRoots) {
|
||||
try {
|
||||
rmSync(root, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 })
|
||||
} catch (error) {
|
||||
failures.push(error)
|
||||
}
|
||||
}
|
||||
if (failures.length > 0) {
|
||||
throw new AggregateError(failures, 'Failed to clean up WebRTC egress fixtures')
|
||||
}
|
||||
})
|
||||
|
||||
function probeMain(resultPath: string, protectedGuest: boolean): string {
|
||||
return `
|
||||
const { app, BrowserWindow, session } = require('electron')
|
||||
// This data-channel probe does not need Linux GPU initialization.
|
||||
if (process.platform === 'linux') app.disableHardwareAcceleration()
|
||||
const dgram = require('node:dgram')
|
||||
const net = require('node:net')
|
||||
const os = require('node:os')
|
||||
const { writeFileSync } = require('node:fs')
|
||||
let phase = 'app-ready'
|
||||
|
||||
function enterPhase(next) {
|
||||
phase = next
|
||||
console.error('[webrtc-egress] ' + phase)
|
||||
}
|
||||
|
||||
function bind(socket, host) {
|
||||
return new Promise((resolve, reject) => {
|
||||
socket.once('error', reject)
|
||||
socket.bind(0, host, () => {
|
||||
socket.off('error', reject)
|
||||
resolve(socket.address())
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function listen(server, host) {
|
||||
return new Promise((resolve, reject) => {
|
||||
server.once('error', reject)
|
||||
server.listen(0, host, () => {
|
||||
server.off('error', reject)
|
||||
resolve(server.address())
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function viewerAddress() {
|
||||
for (const entries of Object.values(os.networkInterfaces())) {
|
||||
for (const entry of entries || []) {
|
||||
if (entry.family === 'IPv4' && !entry.internal) return entry.address
|
||||
}
|
||||
}
|
||||
return '127.0.0.1'
|
||||
}
|
||||
|
||||
async function probe() {
|
||||
const udp = dgram.createSocket('udp4')
|
||||
const tcp = net.createServer((socket) => socket.destroy())
|
||||
const packets = []
|
||||
udp.on('message', (message) => packets.push(message.length))
|
||||
enterPhase('bind-listeners')
|
||||
const [udpAddress, tcpAddress] = await Promise.all([
|
||||
bind(udp, '0.0.0.0'),
|
||||
listen(tcp, '127.0.0.1')
|
||||
])
|
||||
const partition = 'persist:webrtc-egress-${protectedGuest}-' + Date.now()
|
||||
const routeSession = session.fromPartition(partition, { cache: false })
|
||||
enterPhase('configure-proxy')
|
||||
await routeSession.setProxy({
|
||||
mode: 'fixed_servers',
|
||||
proxyRules: 'socks5://127.0.0.1:' + tcpAddress.port,
|
||||
proxyBypassRules: '<-loopback>'
|
||||
})
|
||||
enterPhase('close-connections')
|
||||
await routeSession.closeAllConnections()
|
||||
enterPhase('resolve-proxy')
|
||||
const resolvedProxy = await routeSession.resolveProxy('https://example.invalid/')
|
||||
enterPhase('create-window')
|
||||
const window = new BrowserWindow({
|
||||
show: false,
|
||||
webPreferences: { partition, sandbox: true, nodeIntegration: false, contextIsolation: true }
|
||||
})
|
||||
if (${protectedGuest}) {
|
||||
window.webContents.setWebRTCIPHandlingPolicy('disable_non_proxied_udp')
|
||||
}
|
||||
const policy = window.webContents.getWebRTCIPHandlingPolicy()
|
||||
enterPhase('load-page')
|
||||
await window.loadURL('data:text/html,<title>WebRTC egress probe</title>')
|
||||
const target = viewerAddress()
|
||||
const script = \`
|
||||
(async () => {
|
||||
const peer = new RTCPeerConnection({
|
||||
iceServers: [{ urls: 'stun:\${target}:\${udpAddress.port}' }],
|
||||
iceCandidatePoolSize: 0
|
||||
})
|
||||
globalThis.__webrtcEgressPeer = peer
|
||||
peer.createDataChannel('probe')
|
||||
const offer = await peer.createOffer()
|
||||
await peer.setLocalDescription(offer)
|
||||
})()
|
||||
\`
|
||||
enterPhase('renderer-webrtc')
|
||||
await window.webContents.executeJavaScript(script)
|
||||
// Hidden renderer timers may be throttled; the packet observation clock belongs to the host.
|
||||
enterPhase('observe-packets')
|
||||
await new Promise((resolve) => setTimeout(resolve, 3000))
|
||||
enterPhase('close-peer')
|
||||
await window.webContents.executeJavaScript('globalThis.__webrtcEgressPeer.close()')
|
||||
enterPhase('drain-packets')
|
||||
await new Promise((resolve) => setTimeout(resolve, 500))
|
||||
enterPhase('cleanup')
|
||||
window.destroy()
|
||||
udp.close()
|
||||
tcp.close()
|
||||
return { packets: packets.length, policy, resolvedProxy }
|
||||
}
|
||||
|
||||
async function run() {
|
||||
const timeout = setTimeout(() => {
|
||||
writeFileSync(${JSON.stringify(resultPath)}, JSON.stringify({
|
||||
error: 'WebRTC egress probe timed out', phase, protectedGuest: ${protectedGuest}
|
||||
}))
|
||||
app.exit(2)
|
||||
}, 20000)
|
||||
await app.whenReady()
|
||||
const result = await probe()
|
||||
enterPhase('write-result')
|
||||
writeFileSync(${JSON.stringify(resultPath)}, JSON.stringify(result))
|
||||
clearTimeout(timeout)
|
||||
app.quit()
|
||||
}
|
||||
|
||||
run().catch((error) => {
|
||||
writeFileSync(${JSON.stringify(resultPath)}, JSON.stringify({ error: String(error?.stack || error), phase, protectedGuest: ${protectedGuest} }))
|
||||
app.exit(1)
|
||||
})
|
||||
`
|
||||
}
|
||||
|
||||
function runProbe(protectedGuest: boolean): ProbeResult {
|
||||
const root = mkdtempSync(join(tmpdir(), 'orca-browser-webrtc-egress-'))
|
||||
fixtureRoots.push(root)
|
||||
const mainPath = join(root, 'main.cjs')
|
||||
const resultPath = join(root, 'result.json')
|
||||
writeFileSync(mainPath, probeMain(resultPath, protectedGuest))
|
||||
const { ELECTRON_RUN_AS_NODE: _electronRunAsNode, ...env } = process.env
|
||||
const electronArgs = [mainPath, `--user-data-dir=${join(root, 'profile')}`]
|
||||
const { executable, args } = resolveElectronProbeLaunch({
|
||||
electronBinary,
|
||||
electronArgs,
|
||||
platform: process.platform,
|
||||
display: env.DISPLAY
|
||||
})
|
||||
const run = spawnSync(executable, args, { encoding: 'utf8', env, timeout: 30_000 })
|
||||
const rawResult = existsSync(resultPath) ? readFileSync(resultPath, 'utf8') : 'no result'
|
||||
expect(run.error).toBeUndefined()
|
||||
expect(run.status, `${rawResult}\n${run.stdout}\n${run.stderr}`).toBe(0)
|
||||
if (rawResult === 'no result') {
|
||||
throw new Error(`${rawResult}\n${run.stdout}\n${run.stderr}`)
|
||||
}
|
||||
const parsed = JSON.parse(rawResult) as ProbeResult | { error: string }
|
||||
if ('error' in parsed) {
|
||||
throw new Error(parsed.error)
|
||||
}
|
||||
return parsed
|
||||
}
|
||||
|
||||
describe('browser route WebRTC egress under Electron', () => {
|
||||
it('blocks direct UDP after applying the exact guest policy', () => {
|
||||
const baseline = runProbe(false)
|
||||
const protectedGuest = runProbe(true)
|
||||
|
||||
expect(protectedGuest.resolvedProxy).toMatch(/^SOCKS5 127\.0\.0\.1:\d+$/)
|
||||
expect(baseline.packets).toBeGreaterThan(0)
|
||||
expect(protectedGuest.policy).toBe('disable_non_proxied_udp')
|
||||
expect(protectedGuest.packets).toBe(0)
|
||||
}, 45_000)
|
||||
})
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { ClaudeUsageTokenTotals } from './types'
|
||||
import { MODEL_PRICING, estimateCostUsd, normalizeModelForPricing } from './claude-model-pricing'
|
||||
import { estimateCostUsd, normalizeModelForPricing } from './claude-model-pricing'
|
||||
|
||||
const MTOK = 1_000_000
|
||||
|
||||
@@ -19,10 +19,6 @@ function estimate(model: string, ...counts: number[]): number | null {
|
||||
}
|
||||
|
||||
describe('model pricing name matching', () => {
|
||||
it.each(Object.keys(MODEL_PRICING))('resolves the table key %s to its own row', (key) => {
|
||||
expect(normalizeModelForPricing(key)).toBe(key)
|
||||
})
|
||||
|
||||
it.each([
|
||||
[' ANTHROPIC/claude-opus-4.1-thinking ', 'claude-opus-4-1'],
|
||||
['claude-opus-4-20250514', 'claude-opus-4'],
|
||||
@@ -65,34 +61,7 @@ describe('model pricing name matching', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('point-release rates that differ from their major', () => {
|
||||
it('bills Opus 5.5 below Opus 5 across every bucket', () => {
|
||||
expect(estimate('claude-opus-5-5', 1_000_000, 1_000_000, 1_000_000, 0)).toBeCloseTo(24.2)
|
||||
expect(estimate('claude-opus-5-5', 0, 0, 0, 1_000_000, 400_000)).toBeCloseTo(6.2)
|
||||
})
|
||||
|
||||
it('bills Sonnet 5.5 cache reads at half of Sonnet 5', () => {
|
||||
expect(estimate('claude-sonnet-5-5', 0, 0, 1_000_000, 0)).toBeCloseTo(0.1)
|
||||
expect(estimate('claude-sonnet-5-5', 1_000_000, 1_000_000, 0, 1_000_000, 0)).toBeCloseTo(14.5)
|
||||
expect(estimate('claude-sonnet-5-5', 0, 0, 0, 1_000_000, 1_000_000)).toBeCloseTo(4)
|
||||
})
|
||||
|
||||
it('bills Fable 5.1 cache reads at a quarter of Fable 5', () => {
|
||||
expect(estimate('claude-fable-5-1', 0, 0, 1_000_000, 0)).toBeCloseTo(0.25)
|
||||
expect(estimate('claude-fable-5', 0, 0, 1_000_000, 0)).toBeCloseTo(1)
|
||||
expect(estimate('claude-fable-5-1m', 0, 0, 1_000_000, 0)).toBeCloseTo(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('estimateCostUsd cache-write TTL rates', () => {
|
||||
it('bills 5-minute cache writes at 1.25x base input', () => {
|
||||
expect(estimate('claude-opus-5', 0, 0, 0, 1_000_000, 0)).toBeCloseTo(6.25)
|
||||
})
|
||||
|
||||
it('bills 1-hour cache writes at 2x base input', () => {
|
||||
expect(estimate('claude-opus-5', 0, 0, 0, 1_000_000, 1_000_000)).toBeCloseTo(10)
|
||||
})
|
||||
|
||||
it('splits a mixed write bucket without double-billing the 1-hour share', () => {
|
||||
expect(estimate('claude-opus-5', 0, 0, 0, 1_000_000, 400_000)).toBeCloseTo(7.75)
|
||||
})
|
||||
@@ -101,10 +70,6 @@ describe('estimateCostUsd cache-write TTL rates', () => {
|
||||
expect(estimate('claude-opus-5', 0, 0, 0, 1_000, 5_000)).toBeCloseTo(0.01)
|
||||
})
|
||||
|
||||
it('keeps Sonnet 4.6 one-hour writes flat across its full 1M window', () => {
|
||||
expect(estimate('claude-sonnet-4-6', 0, 0, 0, 400_000, 400_000)).toBeCloseTo(2.4)
|
||||
})
|
||||
|
||||
it('still returns null for unknown models', () => {
|
||||
expect(estimate('gpt-5', 0, 0, 0, 1_000_000, 1_000_000)).toBeNull()
|
||||
})
|
||||
@@ -125,15 +90,6 @@ describe('estimateCostUsd long-context requests', () => {
|
||||
).toBeCloseTo(0.1 + 0.5)
|
||||
})
|
||||
|
||||
it('bills Sonnet 4.5 long-context requests at its premium rates', () => {
|
||||
const long = tokens(MTOK, MTOK, MTOK, MTOK, MTOK)
|
||||
expect(estimateCostUsd('claude-sonnet-4-5', { ...long, longContext: long })).toBeCloseTo(
|
||||
6 + 22.5 + 0.6 + 12
|
||||
)
|
||||
// A large aggregate does not imply any individual request crossed the threshold.
|
||||
expect(estimate('claude-sonnet-4-5', 5 * MTOK)).toBeCloseTo(15)
|
||||
})
|
||||
|
||||
it('ignores a long-context subset for a model with flat rates', () => {
|
||||
const long = tokens(MTOK)
|
||||
expect(estimateCostUsd('claude-sonnet-4-6', { ...long, longContext: long })).toBeCloseTo(3)
|
||||
@@ -147,27 +103,3 @@ describe('estimateCostUsd long-context requests', () => {
|
||||
})
|
||||
|
||||
// Rate source: platform.claude.com/docs/en/about-claude/pricing.
|
||||
describe('estimateCostUsd Claude Sonnet 5 rates', () => {
|
||||
it('bills base input at $2/MTok', () => {
|
||||
expect(estimate('claude-sonnet-5', 1_000_000, 0, 0, 0, 0)).toBeCloseTo(2)
|
||||
})
|
||||
|
||||
it('bills output at $10/MTok', () => {
|
||||
expect(estimate('claude-sonnet-5', 0, 1_000_000, 0, 0, 0)).toBeCloseTo(10)
|
||||
})
|
||||
|
||||
it('bills cache hits at $0.20/MTok', () => {
|
||||
expect(estimate('claude-sonnet-5', 0, 0, 1_000_000, 0, 0)).toBeCloseTo(0.2)
|
||||
})
|
||||
|
||||
it('bills 5-minute cache writes at $2.50/MTok and 1-hour writes at $4/MTok', () => {
|
||||
expect(estimate('claude-sonnet-5', 0, 0, 0, 1_000_000, 0)).toBeCloseTo(2.5)
|
||||
expect(estimate('claude-sonnet-5', 0, 0, 0, 1_000_000, 1_000_000)).toBeCloseTo(4)
|
||||
})
|
||||
|
||||
it('keeps the full 1M window at flat rates with no long-context tier', () => {
|
||||
expect(estimate('claude-sonnet-5', 1_000_000, 0, 0, 0, 0)).toBeCloseTo(
|
||||
estimate('claude-sonnet-5', 500_000, 0, 0, 0, 0)! * 2
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -35,70 +35,6 @@ function sdkOptions(overrides: Partial<SdkSpawnOptions> = {}): SdkSpawnOptions {
|
||||
}
|
||||
|
||||
describe('claude agent SDK process spawn', () => {
|
||||
it('routes the SDK spawn through Orca and retains the pid the lease adjudicates on', () => {
|
||||
const process = fakeSpawn()
|
||||
const spawn = createClaudeCodeProcessSpawn(process.spawnImpl, 'win32')
|
||||
|
||||
expect(spawn.pid).toBeUndefined()
|
||||
expect(spawn.child).toBeNull()
|
||||
const child = spawn.spawn(sdkOptions())
|
||||
|
||||
expect(child).toBe(process.child)
|
||||
expect(spawn.child).toBe(process.child)
|
||||
expect(spawn.pid).toBe(4321)
|
||||
expect(spawn.supervised).toBe(false)
|
||||
expect(process.specs[0]).toEqual({
|
||||
program: '/usr/local/bin/claude',
|
||||
args: ['--output-format', 'stream-json'],
|
||||
cwd: '/work/repo',
|
||||
env: { PATH: '/usr/bin', CLAUDE_CONFIG_DIR: '/accounts/one' },
|
||||
detached: false,
|
||||
stdio: ['pipe', 'pipe', 'pipe']
|
||||
})
|
||||
})
|
||||
|
||||
it.each(['darwin', 'linux'] as const)(
|
||||
'starts Claude under the provider supervisor on %s, which is then the pid the lease records',
|
||||
(platform) => {
|
||||
const process = fakeSpawn()
|
||||
const spawn = createClaudeCodeProcessSpawn(process.spawnImpl, platform)
|
||||
spawn.spawn(sdkOptions())
|
||||
|
||||
const [spec] = process.specs
|
||||
if (!spec) {
|
||||
throw new Error('the spawner never built a spec')
|
||||
}
|
||||
expect(spawn.supervised).toBe(true)
|
||||
expect(spawn.pid).toBe(4321)
|
||||
expect(spec.program).toBe(globalThis.process.execPath)
|
||||
expect(spec.args?.[0]).toBe('-e')
|
||||
expect(spec.args?.slice(2)).toEqual([
|
||||
'--',
|
||||
'/usr/local/bin/claude',
|
||||
'--output-format',
|
||||
'stream-json'
|
||||
])
|
||||
expect(spec.detached).toBe(true)
|
||||
expect(spec.cwd).toBe('/work/repo')
|
||||
const supervisorSpec = JSON.parse(
|
||||
Buffer.from(String(spec.env?.ORCA_PROVIDER_SUPERVISOR_SPEC), 'base64').toString()
|
||||
)
|
||||
// Orca exit must trigger Claude's close ladder, including descendant cleanup.
|
||||
expect(vi.mocked(createProviderSpawnSpec)).toHaveBeenLastCalledWith(
|
||||
expect.anything(),
|
||||
expect.anything(),
|
||||
platform,
|
||||
{ closeRequest: 'stdin-end-and-sigterm' }
|
||||
)
|
||||
expect(supervisorSpec).toMatchObject({
|
||||
closeRequest: 'stdin-end-and-sigterm',
|
||||
cwd: '/work/repo',
|
||||
ownerPid: globalThis.process.pid
|
||||
})
|
||||
expect(spec.env).toMatchObject({ PATH: '/usr/bin', CLAUDE_CONFIG_DIR: '/accounts/one' })
|
||||
}
|
||||
)
|
||||
|
||||
it.each([
|
||||
{ platform: 'darwin', specSupervised: false },
|
||||
{ platform: 'win32', specSupervised: true }
|
||||
@@ -141,14 +77,6 @@ describe('claude agent SDK process spawn', () => {
|
||||
}
|
||||
)
|
||||
|
||||
it('keeps the child out of the SDK abort path so exit proof stays Orca-owned', () => {
|
||||
const process = fakeSpawn()
|
||||
const controller = new AbortController()
|
||||
createClaudeCodeProcessSpawn(process.spawnImpl).spawn(sdkOptions({ signal: controller.signal }))
|
||||
|
||||
expect(process.specs[0]).not.toHaveProperty('signal')
|
||||
})
|
||||
|
||||
it('drains stderr into a bounded tail so an exit error still carries it', async () => {
|
||||
const process = fakeSpawn()
|
||||
const spawn = createClaudeCodeProcessSpawn(process.spawnImpl)
|
||||
|
||||
@@ -4,110 +4,13 @@ import { readManagedHookDetectionResult } from '../agent-hooks/managed-hook-dete
|
||||
import { getClaudeManagedHookPlan } from './claude-managed-hook-events'
|
||||
|
||||
vi.mock('@orca/process-host', () => ({ runProcess: vi.fn() }))
|
||||
import { compareAppVersions } from '../../shared/app-version'
|
||||
import fixture from './__fixtures__/claude-hook-event-enums.json'
|
||||
|
||||
import {
|
||||
CLAUDE_HOOK_EVENT_FIRST_VERSIONS,
|
||||
CLAUDE_STATUS_LINE_FIRST_VERSION,
|
||||
claudeKnowsHookEvent,
|
||||
claudeKnowsStatusLine,
|
||||
parseClaudeCliVersion,
|
||||
parseClaudeCliVersionOutput,
|
||||
probeClaudeCliVersion,
|
||||
UNRESOLVED_CLAUDE_VERSION,
|
||||
type ClaudeHookEventName
|
||||
probeClaudeCliVersion
|
||||
} from './claude-hook-event-versions'
|
||||
|
||||
const enums: Record<string, string[]> = fixture.enums
|
||||
const previousPublished: Record<string, string> = fixture.previousPublishedVersion
|
||||
const topLevelSettings: Record<string, { strict: boolean; keys: string[] }> =
|
||||
fixture.topLevelSettings
|
||||
const versions = Object.keys(enums).sort(compareAppVersions)
|
||||
const isTableEvent = (name: string): name is ClaudeHookEventName =>
|
||||
name in CLAUDE_HOOK_EVENT_FIRST_VERSIONS
|
||||
const tableEntries = Object.keys(CLAUDE_HOOK_EVENT_FIRST_VERSIONS)
|
||||
.filter(isTableEvent)
|
||||
.map((event): [ClaudeHookEventName, string] => [event, CLAUDE_HOOK_EVENT_FIRST_VERSIONS[event]])
|
||||
|
||||
describe('Claude hook event version table', () => {
|
||||
it.each(tableEntries)('pins %s to the first release whose enum knows it', (event, first) => {
|
||||
expect(versions.find((version) => enums[version].includes(event))).toBe(first)
|
||||
for (const version of versions) {
|
||||
expect(enums[version].includes(event), `${event} in ${version}`).toBe(
|
||||
compareAppVersions(version, first) >= 0
|
||||
)
|
||||
}
|
||||
const before = previousPublished[first]
|
||||
expect(enums[before], `enum for ${before}`).toBeDefined()
|
||||
expect(enums[before]).not.toContain(event)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Claude statusLine version floor', () => {
|
||||
it('pins statusLine to the first release whose settings schema knows it', () => {
|
||||
for (const [version, schema] of Object.entries(topLevelSettings)) {
|
||||
expect(schema.keys.includes('statusLine'), version).toBe(
|
||||
compareAppVersions(version, CLAUDE_STATUS_LINE_FIRST_VERSION) >= 0
|
||||
)
|
||||
}
|
||||
const before = previousPublished[CLAUDE_STATUS_LINE_FIRST_VERSION]
|
||||
expect(topLevelSettings[before].strict).toBe(true)
|
||||
expect(topLevelSettings[before].keys).not.toContain('statusLine')
|
||||
})
|
||||
|
||||
it.each([
|
||||
['1.0.63', false],
|
||||
['1.0.64 (Claude Code)', true],
|
||||
[undefined, true]
|
||||
] as const)('%s knows statusLine: %s', (version, expected) => {
|
||||
expect(claudeKnowsStatusLine(version)).toBe(expected)
|
||||
})
|
||||
})
|
||||
|
||||
describe('claudeKnowsHookEvent', () => {
|
||||
it.each([
|
||||
['2.1.77', 'StopFailure', false],
|
||||
['2.1.78 (Claude Code)', 'StopFailure', true],
|
||||
['2.1.75', 'PostCompact', false],
|
||||
['2.1.76', 'PostCompact', true],
|
||||
['2.1.32', 'TeammateIdle', false],
|
||||
['2.0.55', 'PostToolUseFailure', false],
|
||||
['2.0.44', 'PermissionRequest', false],
|
||||
['2.0.42', 'SubagentStart', false],
|
||||
['1.0.84', 'SessionEnd', false],
|
||||
['1.0.61', 'SessionStart', false],
|
||||
['1.0.62', 'SessionStart', true],
|
||||
['1.0.52', 'UserPromptSubmit', false],
|
||||
['1.0.40', 'SubagentStop', false],
|
||||
['1.0.30', 'Stop', false],
|
||||
['1.0.22', 'PreToolUse', false]
|
||||
] as const)('%s knows %s: %s', (version, event, expected) => {
|
||||
expect(claudeKnowsHookEvent(version, event)).toBe(expected)
|
||||
})
|
||||
|
||||
it.each([undefined, null, 'unknown'])(
|
||||
'grants an unresolved version (%s) only the events its assumed release knows',
|
||||
(version) => {
|
||||
const known = tableEntries
|
||||
.filter(([event]) => claudeKnowsHookEvent(version, event))
|
||||
.map(([event]) => event)
|
||||
expect(known.sort()).toEqual(
|
||||
[
|
||||
'PostToolUse',
|
||||
'PreToolUse',
|
||||
'SessionStart',
|
||||
'Stop',
|
||||
'SubagentStop',
|
||||
'UserPromptSubmit'
|
||||
].sort()
|
||||
)
|
||||
for (const event of known) {
|
||||
expect(enums[UNRESOLVED_CLAUDE_VERSION]).toContain(event)
|
||||
}
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
describe('parseClaudeCliVersion', () => {
|
||||
it('extracts Claude Code version output', () => {
|
||||
expect(parseClaudeCliVersion('2.1.261 (Claude Code)')).toBe('2.1.261')
|
||||
|
||||
@@ -98,18 +98,6 @@ describe('claude streamed text checkpoints', () => {
|
||||
expect(rows).toEqual([{ uuid: 'block-1', text: 'partial answer' }])
|
||||
})
|
||||
|
||||
it('keeps a block whose final frame arrived out of the settlement sweep', () => {
|
||||
const { store } = checkpoints()
|
||||
|
||||
store.append(identityOf('block-1'), 'one')
|
||||
store.append(identityOf('block-2'), 'two')
|
||||
store.forget('claude:claude-session:block-1')
|
||||
|
||||
expect(store.pending).toBe(1)
|
||||
store.settle()
|
||||
expect(store.pending).toBe(0)
|
||||
})
|
||||
|
||||
it('flushes text the widening checkpoint interval has not written yet', () => {
|
||||
const { store, rows } = checkpoints()
|
||||
|
||||
@@ -121,18 +109,6 @@ describe('claude streamed text checkpoints', () => {
|
||||
expect(rows).toHaveLength(1)
|
||||
})
|
||||
|
||||
it("stamps a block streamed inside a child with that child's linkage", () => {
|
||||
const producer = scriptedProducer()
|
||||
producer.resolve(CHILD_LINKAGE)
|
||||
const { store, rows, stamps, runWindow } = checkpoints(producer.source)
|
||||
|
||||
store.append(identityOf('block-1'), 'hello', 'toolu_1')
|
||||
runWindow()
|
||||
|
||||
expect(rows).toEqual([{ uuid: 'block-1', text: 'hello' }])
|
||||
expect(stamps).toEqual([CHILD_LINKAGE])
|
||||
})
|
||||
|
||||
it("writes no linkage keys for a block the session's own agent streamed", () => {
|
||||
const { store, stamps, runWindow } = checkpoints()
|
||||
|
||||
@@ -162,22 +138,6 @@ describe('claude streamed text checkpoints', () => {
|
||||
expect(stamps.at(-1)).toEqual(CHILD_LINKAGE)
|
||||
})
|
||||
|
||||
it('writes a held block under the raw reference when no announcement comes', () => {
|
||||
const producer = scriptedProducer()
|
||||
const { store, rows, stamps, runWindow } = checkpoints(producer.source)
|
||||
|
||||
store.append(identityOf('block-1'), 'never announced', 'toolu_1')
|
||||
runWindow()
|
||||
|
||||
expect(rows).toEqual([{ uuid: 'block-1', text: 'never announced' }])
|
||||
expect(stamps).toEqual([
|
||||
{ agentId: 'toolu_1', providerParentRef: 'toolu_1', producerKind: 'agent' }
|
||||
])
|
||||
|
||||
store.reattribute()
|
||||
expect(rows).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('re-resolves a block’s producer on every checkpoint', () => {
|
||||
const producer = scriptedProducer()
|
||||
producer.resolve(CHILD_LINKAGE)
|
||||
|
||||
@@ -1,69 +1,19 @@
|
||||
import { runProcess } from '@orca/process-host'
|
||||
import { chmodSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { chmodSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createAgentHookMemorySftp } from '../agent-hooks/agent-hook-memory-sftp.test-fixture'
|
||||
import { wrapRuntimeHomePosixHookCommand } from '../agent-hooks/runtime-home-hook-command'
|
||||
import { ClaudeHookService } from './hook-service'
|
||||
import {
|
||||
CLAUDE_HOOK_SETTINGS,
|
||||
getManagedEventHook,
|
||||
getManagedLifecycleHook,
|
||||
getRemoteManagedCommand,
|
||||
getRemoteManagedEventCommand
|
||||
} from './hook-settings'
|
||||
|
||||
import { getManagedEventHook } from './hook-settings'
|
||||
|
||||
vi.mock('electron', () => ({ app: { getPath: () => '/tmp/userData' } }))
|
||||
|
||||
const SCRIPT_PATH = '/home/test/.orca/agent-hooks/claude-hook.sh'
|
||||
const CURRENT_CLAUDE = { claudeVersion: '2.1.261 (Claude Code)' }
|
||||
|
||||
// Claude echoes the full command after /compact, so keep it short.
|
||||
const MAX_ECHOED_COMMAND_LENGTH = 400
|
||||
|
||||
afterEach(() => vi.restoreAllMocks())
|
||||
|
||||
describe('PostCompact managed hook command', () => {
|
||||
it.each(['darwin', 'linux'] as const)('is the short POSIX form on %s', (platform) => {
|
||||
vi.spyOn(process, 'platform', 'get').mockReturnValue(platform)
|
||||
const { command } = getManagedEventHook('PostCompact', SCRIPT_PATH)
|
||||
|
||||
expect(command.length).toBeLessThan(MAX_ECHOED_COMMAND_LENGTH)
|
||||
expect(command).toContain('if [ -n "${HOME-}" ] && ')
|
||||
expect(command).toContain('/bin/sh "${HOME-}/.orca/agent-hooks/claude-hook.sh"')
|
||||
expect(command).toContain('ORCA_HOOK_AGENT_PID="${PPID:-}"')
|
||||
expect(command).not.toMatch(/powershell|OSTYPE|\/home\/test/i)
|
||||
})
|
||||
|
||||
it('leaves every other event on the full wrapper', () => {
|
||||
vi.spyOn(process, 'platform', 'get').mockReturnValue('linux')
|
||||
expect(getManagedEventHook('Stop', SCRIPT_PATH)).toEqual(
|
||||
getManagedLifecycleHook(SCRIPT_PATH, CLAUDE_HOOK_SETTINGS)
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps the Windows launcher on win32, whose bare script path is already short', () => {
|
||||
vi.spyOn(process, 'platform', 'get').mockReturnValue('win32')
|
||||
const windowsPath = 'C:\\Users\\alice\\.orca\\agent-hooks\\claude-hook.cmd'
|
||||
expect(getManagedEventHook('PostCompact', windowsPath)).toEqual(
|
||||
getManagedLifecycleHook(windowsPath, CLAUDE_HOOK_SETTINGS)
|
||||
)
|
||||
})
|
||||
|
||||
it.each(['darwin', 'linux', 'win32'] as const)(
|
||||
'uses the short POSIX form only for remote PostCompact from %s',
|
||||
(platform) => {
|
||||
vi.spyOn(process, 'platform', 'get').mockReturnValue(platform)
|
||||
expect(getRemoteManagedEventCommand('PostCompact', SCRIPT_PATH)).toBe(
|
||||
wrapRuntimeHomePosixHookCommand('claude-hook')
|
||||
)
|
||||
expect(getRemoteManagedEventCommand('Stop', SCRIPT_PATH)).toBe(
|
||||
getRemoteManagedCommand(SCRIPT_PATH)
|
||||
)
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
describe.skipIf(process.platform === 'win32')('PostCompact command execution', () => {
|
||||
let home: string
|
||||
|
||||
@@ -109,67 +59,4 @@ describe.skipIf(process.platform === 'win32')('PostCompact command execution', (
|
||||
expect(result.stdout).toBe('')
|
||||
expect(result.stderr).toBe('')
|
||||
})
|
||||
|
||||
it.each([{}, { HOME: '' }])('skips script lookup without a runtime HOME: %j', async (env) => {
|
||||
const lookupProbe = [
|
||||
'probe_test() {',
|
||||
' case "$1" in -f|-r|-x) printf "unexpected script lookup: %s\\n" "$2" >&2; return 1 ;;',
|
||||
' *) command [ "$@" ;; esac',
|
||||
'}',
|
||||
'alias [=probe_test',
|
||||
getManagedEventHook('PostCompact', SCRIPT_PATH).command
|
||||
].join('\n')
|
||||
|
||||
const result = await run(lookupProbe, env)
|
||||
|
||||
expect(result.code).toBe(0)
|
||||
expect(result.stdout).toBe('')
|
||||
expect(result.stderr).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
describe.skipIf(process.platform === 'win32')('ClaudeHookService PostCompact install', () => {
|
||||
it('writes the short form, reports installed, and replaces an old full-wrapper entry', () => {
|
||||
const home = mkdtempSync(join(tmpdir(), 'orca-post-compact-install-'))
|
||||
vi.stubEnv('HOME', home)
|
||||
try {
|
||||
const settingsPath = join(home, '.claude', 'settings.json')
|
||||
mkdirSync(join(home, '.claude'), { recursive: true })
|
||||
const fullWrapper = getManagedLifecycleHook(SCRIPT_PATH).command
|
||||
writeFileSync(
|
||||
settingsPath,
|
||||
JSON.stringify({
|
||||
hooks: {
|
||||
PostCompact: [{ hooks: [{ type: 'command', command: fullWrapper }] }]
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
const status = new ClaudeHookService().install(CURRENT_CLAUDE)
|
||||
|
||||
expect(status.state).toBe('installed')
|
||||
const written = JSON.parse(readFileSync(settingsPath, 'utf-8'))
|
||||
expect(written.hooks.PostCompact).toEqual([
|
||||
{ hooks: [getManagedEventHook('PostCompact', SCRIPT_PATH)] }
|
||||
])
|
||||
expect(written.hooks.Stop[0].hooks[0].command).toBe(fullWrapper)
|
||||
} finally {
|
||||
vi.unstubAllEnvs()
|
||||
rmSync(home, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('ClaudeHookService remote PostCompact install', () => {
|
||||
it('writes the short form for PostCompact and the full wrapper elsewhere', async () => {
|
||||
const { sftp, fs } = createAgentHookMemorySftp()
|
||||
await new ClaudeHookService().installRemote(sftp, '/home/dev', CURRENT_CLAUDE)
|
||||
|
||||
const written = JSON.parse(fs.files.get('/home/dev/.claude/settings.json') ?? '{}')
|
||||
const remoteScript = '/home/dev/.orca/agent-hooks/claude-hook.sh'
|
||||
expect(written.hooks.PostCompact[0].hooks[0].command).toBe(
|
||||
getRemoteManagedEventCommand('PostCompact', remoteScript)
|
||||
)
|
||||
expect(written.hooks.Stop[0].hooks[0].command).toBe(getRemoteManagedCommand(remoteScript))
|
||||
})
|
||||
})
|
||||
|
||||
@@ -134,28 +134,6 @@ describe('a streamed reasoning row the provider never finishes', () => {
|
||||
expect(translator.pendingStreamedBlocks).toBe(0)
|
||||
})
|
||||
|
||||
it('is not rewritten by the token tally Claude sends after every thinking delta', () => {
|
||||
const { translator, message, thinkingDelta, reasoningWrites } = setup()
|
||||
for (let index = 0; index < 20; index += 1) {
|
||||
thinkingDelta(`delta-${index}`, 'more words ', 1_000 + index)
|
||||
translator.handle(
|
||||
message(
|
||||
{
|
||||
type: 'system',
|
||||
subtype: 'thinking_tokens',
|
||||
estimated_tokens: index,
|
||||
estimated_tokens_delta: 1,
|
||||
uuid: `tokens-${index}`
|
||||
},
|
||||
1_000 + index
|
||||
)
|
||||
)
|
||||
}
|
||||
expect(reasoningWrites()).toEqual([])
|
||||
vi.advanceTimersByTime(100)
|
||||
expect(reasoningWrites()).toHaveLength(1)
|
||||
})
|
||||
|
||||
// Timings of the first block in a captured 2.1.280 session with summarized display.
|
||||
it('spans from the block start to its final frame, though text arrives seconds later', () => {
|
||||
const { translator, message, thinkingDelta, reasoningWrites } = setup()
|
||||
|
||||
@@ -53,13 +53,6 @@ function reasoning(text: string, lifecycle: Record<string, unknown>): AgentJourn
|
||||
afterEach(() => vi.useRealTimers())
|
||||
|
||||
describe('structured Claude reasoning', () => {
|
||||
it.each(['', ' ', '\n\t'])('omits blank final thinking %j', (thinking) => {
|
||||
const { rows, final, translator } = setup()
|
||||
final('final-only', [{ type: 'thinking', thinking }])
|
||||
expect([...rows.values()]).toEqual([])
|
||||
translator.dispose()
|
||||
})
|
||||
|
||||
it('writes final-only thinking closed, with no span it never saw', () => {
|
||||
const { rows, final, translator } = setup()
|
||||
final('final-only', [{ type: 'thinking', thinking: 'Inspecting the request' }], 5_000)
|
||||
@@ -201,17 +194,4 @@ describe('structured Claude reasoning', () => {
|
||||
expect(scopes).toEqual([{ kind: 'turn', turnItemId: expect.stringContaining('turn') }])
|
||||
translator.dispose()
|
||||
})
|
||||
|
||||
it('omits whitespace-only thinking deltas', () => {
|
||||
vi.useFakeTimers()
|
||||
const { rows, stream, translator } = setup()
|
||||
stream('delta', {
|
||||
type: 'content_block_delta',
|
||||
index: 0,
|
||||
delta: { type: 'thinking_delta', thinking: ' \n ' }
|
||||
})
|
||||
translator.flush()
|
||||
expect(rows.size).toBe(0)
|
||||
translator.dispose()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -3,14 +3,9 @@ import { describe, expect, it, vi } from 'vitest'
|
||||
import type { AgentSessionJournalIdentity } from '../../../../shared/agent-session/journal/agent-session-journal-identity'
|
||||
import { agentSessionRecordFixture } from '../../../native-chat/agent-session-record-test-fixture'
|
||||
import { LOCAL_EXECUTION_HOST_ID } from '../../../../shared/execution-host'
|
||||
import type { NativeChatVisualsLaunch } from '../../../native-chat/native-chat-visuals-delivery'
|
||||
import {
|
||||
NATIVE_CHAT_SCRATCH_DIR_ENV,
|
||||
type NativeChatVisualsLaunch
|
||||
} from '../../../native-chat/native-chat-visuals-delivery'
|
||||
import {
|
||||
CLAUDE_CLI_FLAG_PROBE_KILL_AFTER_MS,
|
||||
CLAUDE_PLUGIN_DIR_FLAG,
|
||||
CLAUDE_THINKING_DISPLAY_FLAG,
|
||||
type ClaudeCliFlag,
|
||||
type ClaudeCliFlagFact
|
||||
} from '../../claude-cli-flag-support'
|
||||
@@ -59,80 +54,6 @@ function launch(options: {
|
||||
}
|
||||
|
||||
describe('a Claude chat launch with inline visuals', () => {
|
||||
it('loads the skill plugin, grants the chat folder beside the user directories, and names it', async () => {
|
||||
const prepareVisuals = vi.fn(async () => VISUALS)
|
||||
const resolved = await launch({
|
||||
prepareVisuals,
|
||||
supports: async (flag) => flag === CLAUDE_PLUGIN_DIR_FLAG,
|
||||
launchArgs: ['--add-dir', '/shared/notes']
|
||||
})
|
||||
expect(prepareVisuals).toHaveBeenCalledWith(SESSION_ID)
|
||||
expect(resolved.options.plugins).toEqual([{ type: 'local', path: VISUALS.skill.pluginDir }])
|
||||
expect(resolved.options.additionalDirectories).toEqual(['/shared/notes', VISUALS.folder])
|
||||
expect(resolved.env?.[NATIVE_CHAT_SCRATCH_DIR_ENV]).toBe(VISUALS.folder)
|
||||
})
|
||||
|
||||
it.each([
|
||||
{
|
||||
name: 'prepared visuals',
|
||||
visuals: VISUALS,
|
||||
directories: ['/shared/notes', '/state/agent-session-attachments', VISUALS.folder]
|
||||
},
|
||||
{
|
||||
name: 'unavailable visuals',
|
||||
visuals: null,
|
||||
directories: ['/shared/notes', '/state/agent-session-attachments']
|
||||
}
|
||||
])('keeps user folders and attachment access with $name', async ({ visuals, directories }) => {
|
||||
const resolved = await launch({
|
||||
prepareVisuals: async () => visuals,
|
||||
supports: async (flag) => flag === CLAUDE_PLUGIN_DIR_FLAG,
|
||||
launchArgs: ['--add-dir', '/shared/notes'],
|
||||
attachmentDirectory: '/state/agent-session-attachments'
|
||||
})
|
||||
expect(resolved.options.additionalDirectories).toEqual(directories)
|
||||
})
|
||||
|
||||
it('still grants and names the folder when the CLI cannot load a plugin by path', async () => {
|
||||
const resolved = await launch({
|
||||
prepareVisuals: async () => VISUALS,
|
||||
supports: async () => false
|
||||
})
|
||||
expect(resolved.options).not.toHaveProperty('plugins')
|
||||
expect(resolved.options.additionalDirectories).toEqual([VISUALS.folder])
|
||||
expect(resolved.env?.[NATIVE_CHAT_SCRATCH_DIR_ENV]).toBe(VISUALS.folder)
|
||||
})
|
||||
|
||||
it('without a prepared folder, grants nothing and drops a folder inherited from another chat', async () => {
|
||||
const resolved = await launch({
|
||||
prepareVisuals: async () => null,
|
||||
supports: async () => true,
|
||||
env: { [NATIVE_CHAT_SCRATCH_DIR_ENV]: '/state/chat-scratch/other-chat' }
|
||||
})
|
||||
expect(resolved.options).not.toHaveProperty('plugins')
|
||||
expect(resolved.options).not.toHaveProperty('additionalDirectories')
|
||||
expect(resolved.env).not.toHaveProperty(NATIVE_CHAT_SCRATCH_DIR_ENV)
|
||||
})
|
||||
|
||||
it('never asks about the plugin flag on a host that delivers no visuals', async () => {
|
||||
const supports = vi.fn(async (_flag: ClaudeCliFlag) => true)
|
||||
const resolved = await launch({ supports })
|
||||
expect(supports.mock.calls.map(([flag]) => flag)).not.toContain(CLAUDE_PLUGIN_DIR_FLAG)
|
||||
expect(resolved.options).not.toHaveProperty('plugins')
|
||||
})
|
||||
|
||||
it('waits for the version up to the probe kill time for the plugin, never the short budget', async () => {
|
||||
const supports = vi.fn(
|
||||
async (_flag: ClaudeCliFlag, _launch: unknown, _budgetMs?: number) => true
|
||||
)
|
||||
await launch({ prepareVisuals: async () => VISUALS, supports })
|
||||
expect(supports).toHaveBeenCalledWith(
|
||||
CLAUDE_PLUGIN_DIR_FLAG,
|
||||
expect.objectContaining({ command: '/usr/local/bin/claude' }),
|
||||
CLAUDE_CLI_FLAG_PROBE_KILL_AFTER_MS
|
||||
)
|
||||
})
|
||||
|
||||
it('asks for readable thinking again once the plugin check has learned the version', async () => {
|
||||
let versionKnown = false
|
||||
const supports = vi.fn(async (flag: ClaudeCliFlag, _launch: unknown, budgetMs?: number) => {
|
||||
@@ -147,14 +68,4 @@ describe('a Claude chat launch with inline visuals', () => {
|
||||
expect(resolved.options.plugins).toHaveLength(1)
|
||||
expect(resolved.options.extraArgs).toMatchObject({ 'thinking-display': 'summarized' })
|
||||
})
|
||||
|
||||
it('never re-asks for thinking on a host without visuals', async () => {
|
||||
const supports = vi.fn(
|
||||
async (_flag: ClaudeCliFlag, _launch: unknown, _budgetMs?: number) => false
|
||||
)
|
||||
await launch({ supports })
|
||||
expect(
|
||||
supports.mock.calls.filter(([flag]) => flag === CLAUDE_THINKING_DISPLAY_FLAG)
|
||||
).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -8,10 +8,6 @@ import { readHooksJson, writeHooksJson } from '../agent-hooks/installer-utils'
|
||||
import { ClaudeHookService } from './hook-service'
|
||||
import { getWindowsManagedLifecycleHook } from './hook-settings'
|
||||
import { getWindowsClaudeHookEntry, getWindowsClaudeHookPayloadPath } from './windows-hook-files'
|
||||
import { codebuddyHookService } from '../codebuddy/hook-service'
|
||||
import { qoderHookService, qoderCnHookService } from '../qoder/hook-service'
|
||||
import { qwenCodeHookService } from '../qwen-code/hook-service'
|
||||
import { openClaudeHookService } from '../openclaude/hook-service'
|
||||
|
||||
const { home } = vi.hoisted(() => ({ home: { path: '' } }))
|
||||
vi.mock('node:os', async (original) => ({
|
||||
@@ -41,16 +37,6 @@ afterEach(() => {
|
||||
})
|
||||
|
||||
describe('Windows Claude hook files', () => {
|
||||
it('installs a delegating entry and a payload that answers before bounded delivery', () => {
|
||||
expect(service.install().state).toBe('installed')
|
||||
expect(readFileSync(entry, 'utf8')).toBe(getWindowsClaudeHookEntry())
|
||||
const body = readFileSync(payload, 'utf8')
|
||||
expect(body.indexOf('echo {}')).toBeLessThan(body.indexOf('curl.exe'))
|
||||
expect(body).toContain('--connect-timeout 0.5 --max-time 1.5')
|
||||
expect(body).toContain('--data-urlencode "payload@-" >nul 2>&1\r\nexit /b 0')
|
||||
expect(body).toContain('DEVIN_PROJECT_DIR')
|
||||
})
|
||||
|
||||
it('repairs a missing payload from the surviving entry without touching settings', async () => {
|
||||
service.install()
|
||||
rmSync(payload)
|
||||
@@ -140,24 +126,4 @@ describe('Windows Claude hook files', () => {
|
||||
expect(config).toContain('echo user-hook')
|
||||
expect(service.getStatus().state).toBe('installed')
|
||||
})
|
||||
|
||||
it('keeps compatible agents on their existing single-file scripts', async () => {
|
||||
for (const [name, compatible] of [
|
||||
['qoder', qoderHookService],
|
||||
['qoder-cn', qoderCnHookService],
|
||||
['qwen-code', qwenCodeHookService],
|
||||
['codebuddy', codebuddyHookService],
|
||||
['openclaude', openClaudeHookService]
|
||||
] as const) {
|
||||
expect(compatible.install().state).toBe('installed')
|
||||
await compatible.refreshManagedScripts()
|
||||
const path = join(home.path, '.orca', 'agent-hooks', `${name}-hook.cmd`)
|
||||
const source = name === 'openclaude' ? 'claude' : name
|
||||
expect(readFileSync(path, 'utf8')).toContain(`/hook/${source}`)
|
||||
expect(readFileSync(path, 'utf8')).not.toContain('claude-hook-impl.cmd')
|
||||
expect(existsSync(join(home.path, '.orca', 'agent-hooks', `${name}-hook-impl.cmd`))).toBe(
|
||||
false
|
||||
)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -57,10 +57,6 @@ describe('resolveCodexSharedServerBinary', () => {
|
||||
const legacy = installPackage('standalone', 'current')
|
||||
expect(await resolveCodexSharedServerBinary(home)).toBe(legacy)
|
||||
})
|
||||
|
||||
it('is null when no package is installed', async () => {
|
||||
expect(await resolveCodexSharedServerBinary(home)).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('readFeatureEnabled', () => {
|
||||
|
||||
@@ -149,34 +149,4 @@ describe('terminal history restore memory limits', () => {
|
||||
expect(restore?.snapshotAnsi).toBe('safe checkpoint')
|
||||
expect(restore?.oscLinks).toHaveLength(oscLinks.length)
|
||||
})
|
||||
|
||||
// Call the reader directly so fallback behavior cannot hide a parsing regression.
|
||||
it('parses link-dense checkpoints without a structural pre-scan budget', () => {
|
||||
const { sessionPath } = createSession('link-dense-checkpoint')
|
||||
const checkpointPath = join(sessionPath, 'checkpoint.json')
|
||||
writeFileSync(checkpointPath, `{"snapshotAnsi":"","values":[${'0,'.repeat(2_000_000)}0]}`)
|
||||
expect(
|
||||
readTerminalHistoryJson<{ values: number[] }>(
|
||||
checkpointPath,
|
||||
TERMINAL_HISTORY_CHECKPOINT_MAX_BYTES
|
||||
).values
|
||||
).toHaveLength(2_000_001)
|
||||
})
|
||||
|
||||
// V8 parses JSON iteratively; recursive parsing could abort the daemon instead of throwing.
|
||||
it('parses deeply nested checkpoints without the retired nesting-depth cap', () => {
|
||||
const { sessionPath } = createSession('deeply-nested-checkpoint')
|
||||
const checkpointPath = join(sessionPath, 'checkpoint.json')
|
||||
const depth = 50_000
|
||||
writeFileSync(
|
||||
checkpointPath,
|
||||
`{"snapshotAnsi":"","nested":${'['.repeat(depth)}${']'.repeat(depth)}}`
|
||||
)
|
||||
expect(
|
||||
readTerminalHistoryJson<{ nested: unknown[] }>(
|
||||
checkpointPath,
|
||||
TERMINAL_HISTORY_CHECKPOINT_MAX_BYTES
|
||||
).nested
|
||||
).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -8,7 +8,6 @@ import {
|
||||
resetWslWindowsCheckoutGitConfigForTests,
|
||||
UNPROVEN_CHECKOUT_WRITE_GIT_CONFIG_ARGS,
|
||||
usesWindowsCheckoutGitConfig,
|
||||
WINDOWS_CHECKOUT_GIT_CONFIG_ARGS,
|
||||
withWslWindowsCheckoutGitConfig,
|
||||
wslWindowsCheckoutKey,
|
||||
WSL_WINDOWS_CHECKOUT_TTL_MS
|
||||
@@ -93,18 +92,6 @@ describe('which commands run with Windows Git settings', () => {
|
||||
expect(withWslWindowsCheckoutGitConfig(read, CWD, 'Ubuntu', 'win32', NOW)).toEqual(read)
|
||||
})
|
||||
|
||||
it('flags reads as soon as the host answers, before the sample', () => {
|
||||
recordHostAutocrlf(KEY, 'true', NOW)
|
||||
expect(withWslWindowsCheckoutGitConfig(read, CWD, 'Ubuntu', 'win32', NOW)).toEqual([
|
||||
...WINDOWS_CHECKOUT_GIT_CONFIG_ARGS,
|
||||
...read
|
||||
])
|
||||
expect(withWslWindowsCheckoutGitConfig(write, CWD, 'Ubuntu', 'win32', NOW)).toEqual([
|
||||
...UNPROVEN_CHECKOUT_WRITE_GIT_CONFIG_ARGS,
|
||||
...write
|
||||
])
|
||||
})
|
||||
|
||||
it('stops flagging reads once the sample proves an LF checkout', () => {
|
||||
recordHostAutocrlf(KEY, 'true', NOW)
|
||||
recordLineEndingSample(KEY, 'not-windows', 'id', NOW)
|
||||
|
||||
@@ -1,22 +1,13 @@
|
||||
import { existsSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import {
|
||||
getEffectiveKeybindingsForAction,
|
||||
keybindingMatchesAction,
|
||||
LEGACY_TAB_SWITCH_BINDINGS,
|
||||
type KeybindingActionId,
|
||||
type KeybindingInput
|
||||
} from '../../shared/keybindings'
|
||||
import { getEffectiveKeybindingsForAction } from '../../shared/keybindings'
|
||||
import { getUserKeybindingsPath, writeKeybindingOverride } from './keybinding-file'
|
||||
import { KeybindingService } from './keybinding-service'
|
||||
|
||||
// Verify effective bindings and keystroke matching for fresh/existing cohorts across all platforms.
|
||||
|
||||
const PLATFORMS: NodeJS.Platform[] = ['darwin', 'linux', 'win32']
|
||||
const SWAPPED_ACTIONS = Object.keys(LEGACY_TAB_SWITCH_BINDINGS) as KeybindingActionId[]
|
||||
|
||||
function makeCohort(pending: boolean): {
|
||||
controller: { isPending: () => boolean; markSeeded: () => void }
|
||||
seeded: () => boolean
|
||||
@@ -36,22 +27,6 @@ function makeCohort(pending: boolean): {
|
||||
}
|
||||
|
||||
// Physical bracket codes are layout-independent; Mod maps to the platform modifier.
|
||||
function bracketPress(opts: {
|
||||
code: 'BracketLeft' | 'BracketRight'
|
||||
alt: boolean
|
||||
shift: boolean
|
||||
platform: NodeJS.Platform
|
||||
}): KeybindingInput {
|
||||
const isMac = opts.platform === 'darwin'
|
||||
return {
|
||||
code: opts.code,
|
||||
key: opts.code === 'BracketRight' ? ']' : '[',
|
||||
meta: isMac,
|
||||
control: !isMac,
|
||||
alt: opts.alt,
|
||||
shift: opts.shift
|
||||
}
|
||||
}
|
||||
|
||||
describe('KeybindingService tab-switch cohort seeding', () => {
|
||||
let home: string
|
||||
@@ -64,84 +39,6 @@ describe('KeybindingService tab-switch cohort seeding', () => {
|
||||
rmSync(home, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it.each(PLATFORMS)(
|
||||
'fresh install (%s) adopts the swapped defaults and writes no file',
|
||||
(platform) => {
|
||||
const cohort = makeCohort(false)
|
||||
const service = new KeybindingService({
|
||||
homePath: home,
|
||||
platform,
|
||||
legacyTabSwitchSeed: cohort.controller
|
||||
})
|
||||
|
||||
expect(cohort.seeded()).toBe(false)
|
||||
expect(existsSync(getUserKeybindingsPath(home))).toBe(false)
|
||||
|
||||
const overrides = service.getOverrides()
|
||||
expect(getEffectiveKeybindingsForAction('tab.nextAllTypes', platform, overrides)).toEqual([
|
||||
'Mod+Shift+BracketRight'
|
||||
])
|
||||
expect(getEffectiveKeybindingsForAction('tab.nextSameType', platform, overrides)).toEqual([
|
||||
'Mod+Alt+BracketRight'
|
||||
])
|
||||
|
||||
expect(
|
||||
keybindingMatchesAction(
|
||||
'tab.nextAllTypes',
|
||||
bracketPress({ code: 'BracketRight', alt: false, shift: true, platform }),
|
||||
platform,
|
||||
overrides
|
||||
)
|
||||
).toBe(true)
|
||||
expect(
|
||||
keybindingMatchesAction(
|
||||
'tab.nextSameType',
|
||||
bracketPress({ code: 'BracketRight', alt: false, shift: true, platform }),
|
||||
platform,
|
||||
overrides
|
||||
)
|
||||
).toBe(false)
|
||||
}
|
||||
)
|
||||
|
||||
it.each(PLATFORMS)(
|
||||
'existing install (%s) keeps the pre-swap chords exactly, on a clean profile',
|
||||
(platform) => {
|
||||
const cohort = makeCohort(true)
|
||||
const service = new KeybindingService({
|
||||
homePath: home,
|
||||
platform,
|
||||
legacyTabSwitchSeed: cohort.controller
|
||||
})
|
||||
|
||||
expect(cohort.seeded()).toBe(true)
|
||||
const overrides = service.getOverrides()
|
||||
|
||||
for (const actionId of SWAPPED_ACTIONS) {
|
||||
expect(getEffectiveKeybindingsForAction(actionId, platform, overrides)).toEqual(
|
||||
LEGACY_TAB_SWITCH_BINDINGS[actionId]
|
||||
)
|
||||
}
|
||||
|
||||
// Existing installs keep the inverse of fresh-install tab-switch defaults.
|
||||
const shiftRight = bracketPress({ code: 'BracketRight', alt: false, shift: true, platform })
|
||||
expect(keybindingMatchesAction('tab.nextSameType', shiftRight, platform, overrides)).toBe(
|
||||
true
|
||||
)
|
||||
expect(keybindingMatchesAction('tab.nextAllTypes', shiftRight, platform, overrides)).toBe(
|
||||
false
|
||||
)
|
||||
|
||||
const altLeft = bracketPress({ code: 'BracketLeft', alt: true, shift: false, platform })
|
||||
expect(keybindingMatchesAction('tab.previousAllTypes', altLeft, platform, overrides)).toBe(
|
||||
true
|
||||
)
|
||||
expect(keybindingMatchesAction('tab.previousSameType', altLeft, platform, overrides)).toBe(
|
||||
false
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
it('existing install keeps a rebound action AND pins the pre-swap default on the rest', () => {
|
||||
const platform: NodeJS.Platform = 'darwin'
|
||||
writeKeybindingOverride(getUserKeybindingsPath(home), platform, 'tab.nextSameType', ['Mod+K'])
|
||||
@@ -168,30 +65,6 @@ describe('KeybindingService tab-switch cohort seeding', () => {
|
||||
])
|
||||
})
|
||||
|
||||
it('is idempotent: a second launch after the seed changes nothing', () => {
|
||||
const platform: NodeJS.Platform = 'linux'
|
||||
const cohort = makeCohort(true)
|
||||
const first = new KeybindingService({
|
||||
homePath: home,
|
||||
platform,
|
||||
legacyTabSwitchSeed: cohort.controller
|
||||
})
|
||||
const afterFirst = first.getOverrides()
|
||||
|
||||
// Reuse the cohort after markSeeded to model the persisted done flag.
|
||||
const second = new KeybindingService({
|
||||
homePath: home,
|
||||
platform,
|
||||
legacyTabSwitchSeed: cohort.controller
|
||||
})
|
||||
expect(second.getOverrides()).toEqual(afterFirst)
|
||||
for (const actionId of SWAPPED_ACTIONS) {
|
||||
expect(getEffectiveKeybindingsForAction(actionId, platform, second.getOverrides())).toEqual(
|
||||
LEGACY_TAB_SWITCH_BINDINGS[actionId]
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
it('leaves the cohort pending when the seed write fails (retries next launch)', () => {
|
||||
// A file at homePath forces mkdir failure; the cohort must remain pending.
|
||||
const fileHome = join(home, 'not-a-dir')
|
||||
|
||||
@@ -21,7 +21,6 @@ import {
|
||||
remoteNodeRuntimeDir
|
||||
} from './orcad-remote-node-runtime'
|
||||
import {
|
||||
parseRemoteRuntimeExitReport,
|
||||
REMOTE_NODE_RUNTIME_MISSING,
|
||||
REMOTE_NODE_RUNTIME_READY
|
||||
} from '../../ssh/orcad-remote-node-runtime-report'
|
||||
@@ -81,18 +80,6 @@ describe.skipIf(process.platform === 'win32')('remote pinned-Node runtime store'
|
||||
)
|
||||
})
|
||||
|
||||
it('splits the runtime exit status from the loader output it reported', () => {
|
||||
expect(
|
||||
parseRemoteRuntimeExitReport(
|
||||
'ORCA_NODE_RUNTIME_SELFTEST_FAILED\nORCA_RUNTIME_EXIT=126\nsh: node: Permission denied\n'
|
||||
)
|
||||
).toEqual({ exitStatus: 126, output: 'sh: node: Permission denied' })
|
||||
expect(parseRemoteRuntimeExitReport('no report')).toEqual({
|
||||
exitStatus: null,
|
||||
output: 'no report'
|
||||
})
|
||||
})
|
||||
|
||||
it('reports a runtime whose bytes do not hash to the pin as missing', () => {
|
||||
const root = mkdtempSync(join(tmpdir(), 'orcad-runtime-store-'))
|
||||
directories.push(root)
|
||||
|
||||
@@ -14,49 +14,15 @@ import {
|
||||
inventoryRemoteInstallDirs,
|
||||
ORCAD_INSTALL_MODEL,
|
||||
RELAY_INSTALL_MODEL,
|
||||
remoteInstallDirName,
|
||||
remoteInstallDirOwner,
|
||||
remoteInstallGcPermits,
|
||||
remoteInstallListingRegexSource,
|
||||
remoteInstallVersionDirRegex
|
||||
} from './remote-install-model'
|
||||
|
||||
const RELAY_DIRS = ['relay-0.1.0+abcdef123456', 'relay-v0.1.0', 'relay-1.2.3']
|
||||
const ORCAD_DIRS = ['orcad-0.1.0+abcdef123456', 'orcad-v0.1.0', 'orcad-1.2.3']
|
||||
|
||||
it.each(['linux-x64', 'darwin-arm64', 'win32-x64'] as const)(
|
||||
'requires the runtime reference, node-pty and both profile workers on %s',
|
||||
(platform) => {
|
||||
const isWindows = platform.startsWith('win32')
|
||||
const artifacts = ORCAD_INSTALL_MODEL.requiredArtifacts(getRemoteHostPlatform(platform))
|
||||
expect(artifacts).toContain('.runtime-node')
|
||||
expect(artifacts).not.toContain('bun-runtime')
|
||||
expect(artifacts).not.toContain('bun-runtime.exe')
|
||||
expect(artifacts).toContain(
|
||||
`node_modules/node-pty/build/Release/${isWindows ? 'conpty.node' : 'pty.node'}`
|
||||
)
|
||||
expect(artifacts).toContain('profile-state-writer-worker-entry.js')
|
||||
expect(artifacts).toContain('profile-state-backup-worker-entry.js')
|
||||
expect(artifacts.includes('windows-process-tree.node')).toBe(isWindows)
|
||||
}
|
||||
)
|
||||
|
||||
describe('remote install namespace', () => {
|
||||
it('names each model its own version dir', () => {
|
||||
expect(remoteInstallDirName(RELAY_INSTALL_MODEL, '0.1.0+aa')).toBe('relay-0.1.0+aa')
|
||||
expect(remoteInstallDirName(ORCAD_INSTALL_MODEL, '0.1.0+aa')).toBe('orcad-0.1.0+aa')
|
||||
})
|
||||
|
||||
it("requires the host's own search binary in a completed standalone runtime install", () => {
|
||||
for (const platform of ['linux-arm64', 'darwin-x64', 'win32-arm64'] as const) {
|
||||
expect(
|
||||
ORCAD_INSTALL_MODEL.requiredArtifacts(getRemoteHostPlatform(platform)).filter(
|
||||
(file) => file.startsWith('ripgrep/') && !file.startsWith('ripgrep/licenses/')
|
||||
)
|
||||
).toEqual([orcadRipgrepArtifact(platform)])
|
||||
}
|
||||
})
|
||||
|
||||
it.skipIf(process.platform === 'win32')('rejects an install missing its search binary', () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'orcad-remote-probe-'))
|
||||
try {
|
||||
@@ -76,13 +42,6 @@ describe('remote install namespace', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps the relay listing pattern byte-identical to the one it shipped with', () => {
|
||||
// Preserve the shipped listing scope: widening it changes which existing directories GC can see.
|
||||
expect(remoteInstallListingRegexSource(RELAY_INSTALL_MODEL)).toBe(
|
||||
String.raw`^relay-(v?[0-9]+\.[0-9]+\.[0-9]+(\+[0-9a-f]+)?)(\.gc-tombstone\.[0-9]+\.[0-9]+)?$`
|
||||
)
|
||||
})
|
||||
|
||||
it('refuses a dir prefix that could escape a remote glob or quote', () => {
|
||||
const injected = { ...RELAY_INSTALL_MODEL, dirPrefix: "relay'; rm -rf ~" }
|
||||
expect(() => remoteInstallVersionDirRegex(injected)).toThrow('Unsafe remote install dir prefix')
|
||||
@@ -116,13 +75,6 @@ describe('GC ownership — each model collects only its own namespace', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('groups a mixed listing without losing anything to the wrong owner', () => {
|
||||
const inventory = inventoryRemoteInstallDirs([...RELAY_DIRS, ...ORCAD_DIRS, 'something-else'])
|
||||
expect(inventory.relay).toEqual(RELAY_DIRS)
|
||||
expect(inventory.orcad).toEqual(ORCAD_DIRS)
|
||||
expect(inventory.unknown).toEqual(['something-else'])
|
||||
})
|
||||
|
||||
it('gives the shared runtime store its own owner that no version-dir GC may take', () => {
|
||||
expect(remoteInstallDirOwner('runtimes')).toBe('runtimes')
|
||||
expect(remoteInstallGcPermits(RELAY_INSTALL_MODEL, 'runtimes')).toBe(false)
|
||||
|
||||
@@ -88,23 +88,6 @@ it.each([1, 10])(
|
||||
}
|
||||
)
|
||||
|
||||
it('backs off a successful partial target, keeps saved-only reads passive, and retries after expiry', async () => {
|
||||
const probe = vi.fn<AgentModelCatalogProbe>(async () => ({
|
||||
origin: 'probe',
|
||||
models: menu('new')
|
||||
}))
|
||||
const { service, advance } = setup(probe)
|
||||
const params = { agent: 'opencode2', sessionId: 'session-a', selectedModel: B }
|
||||
await service.read({ ...params, savedOnly: true })
|
||||
expect(probe).not.toHaveBeenCalled()
|
||||
await service.read({ ...params, waitForListing: true })
|
||||
await Promise.all(Array.from({ length: 10 }, () => service.demand(params)))
|
||||
expect(probe).toHaveBeenCalledTimes(1)
|
||||
advance()
|
||||
await service.read({ ...params, waitForListing: true })
|
||||
expect(probe).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it.each([
|
||||
{ n: 1, partial: false },
|
||||
{ n: 10, partial: false },
|
||||
|
||||
@@ -10,10 +10,7 @@ import {
|
||||
JOURNAL_DB_SCHEMA_VERSION
|
||||
} from './journal-database-schema'
|
||||
import { journalDatabasePath } from './journal-host-database'
|
||||
import {
|
||||
closeTestJournalHostDatabases,
|
||||
openTestJournalHostDatabase
|
||||
} from './journal-host-database-test-support'
|
||||
import { closeTestJournalHostDatabases } from './journal-host-database-test-support'
|
||||
import { insertCommandReceiptIfAbsent, readCommandReceipt } from './command-receipt-table'
|
||||
import { commandReceiptScope } from './command-receipt-schema'
|
||||
import {
|
||||
@@ -47,49 +44,6 @@ export function registerCommandReceiptSchemaTests(): void {
|
||||
}
|
||||
|
||||
describe('command receipt schema at writable open', () => {
|
||||
it('creates the operation/caller primary key and session index on a fresh host database', () => {
|
||||
const database = openTestJournalHostDatabase(root)
|
||||
expect(hasReceiptTable(database.db)).toBe(true)
|
||||
expect(journalPragmaNumber(database.db, 'user_version')).toBe(JOURNAL_DB_SCHEMA_VERSION)
|
||||
expect(
|
||||
database.db.prepare('PRAGMA table_info(agent_session_command_receipts)').all()
|
||||
).toEqual([
|
||||
expect.objectContaining({ name: 'operation_id', pk: 1 }),
|
||||
expect.objectContaining({ name: 'session_id', pk: 0 }),
|
||||
expect.objectContaining({ name: 'caller_key', pk: 2 }),
|
||||
expect.objectContaining({ name: 'method', pk: 0 }),
|
||||
expect.objectContaining({ name: 'fingerprint', pk: 0 }),
|
||||
expect.objectContaining({ name: 'status', pk: 0 }),
|
||||
expect.objectContaining({ name: 'result_json', pk: 0 }),
|
||||
expect.objectContaining({ name: 'rejection_json', pk: 0 }),
|
||||
expect.objectContaining({ name: 'accepted_at', pk: 0 })
|
||||
])
|
||||
const indexes = database.db.prepare('PRAGMA index_list(agent_session_command_receipts)').all()
|
||||
expect(indexes).toHaveLength(2)
|
||||
expect(indexes).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
name: 'sqlite_autoindex_agent_session_command_receipts_1',
|
||||
origin: 'pk'
|
||||
}),
|
||||
expect.objectContaining({ name: 'agent_session_command_receipts_session', origin: 'c' })
|
||||
])
|
||||
)
|
||||
expect(
|
||||
database.db.prepare('PRAGMA index_info(agent_session_command_receipts_session)').all()
|
||||
).toEqual([expect.objectContaining({ name: 'session_id' })])
|
||||
expect(
|
||||
database.db.prepare('PRAGMA foreign_key_list(agent_session_command_receipts)').all()
|
||||
).toEqual([
|
||||
expect.objectContaining({
|
||||
table: 'agent_session_records',
|
||||
from: 'session_id',
|
||||
to: 'session_id',
|
||||
on_delete: 'CASCADE'
|
||||
})
|
||||
])
|
||||
})
|
||||
|
||||
it.each([3, 4])('adds the table to an older-shaped version %i database', (version) => {
|
||||
const seeded = new Database(dbPath)
|
||||
seeded.exec(createJournalTablesSql())
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { createFixture } from './journal-unsent-send-hold-contract-test-fixture'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { AgentJournalSubmission } from '../../../shared/agent-session/journal/agent-session-journal-types'
|
||||
|
||||
import { USER_MESSAGE_SOURCE } from '../../../shared/agent-session/agent-session-message-source'
|
||||
|
||||
export function registerJournalUnsentSendHoldQueueTests(
|
||||
@@ -136,15 +136,6 @@ export function registerJournalUnsentSendHoldQueueTests(
|
||||
})
|
||||
|
||||
describe('the source a send records', () => {
|
||||
it('is folded from the row; a row without one folds without it', async () => {
|
||||
const journal = await fixture.open()
|
||||
await fixture.accept(journal, 'with-source', { origin: 'host', source: USER_MESSAGE_SOURCE })
|
||||
await fixture.accept(journal, 'without', { origin: 'client' })
|
||||
const folded: AgentJournalSubmission | undefined = journal.submission('with-source')
|
||||
expect(folded?.source).toEqual({ kind: 'user' })
|
||||
expect(journal.submission('without')).not.toHaveProperty('source')
|
||||
})
|
||||
|
||||
it('keeps only the kind of an agent’s source, never its senders', async () => {
|
||||
const journal = await fixture.open()
|
||||
await fixture.accept(journal, 'mail', { origin: 'host', source: fixture.MAIL_SOURCE })
|
||||
|
||||
@@ -74,36 +74,6 @@ describe('failed hook provider frames', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it.each([undefined, null, 'invalid', [null], [{ kind: 'error', text: ' ' }]])(
|
||||
'keeps a named error visible when Codex entries are missing or malformed: %j',
|
||||
(entries) => {
|
||||
expect(
|
||||
unhandledProviderFrameJournalItem('codex', CODEX_KIND, {
|
||||
run: { eventName: 'stop', status: 'failed', entries }
|
||||
})?.body
|
||||
).toMatchObject({ text: 'Codex Stop hook failed.', tone: 'error' })
|
||||
}
|
||||
)
|
||||
|
||||
it('uses a readable fallback when a failed hook carries no name', () => {
|
||||
expect(
|
||||
unhandledProviderFrameJournalItem('codex', CODEX_KIND, {
|
||||
run: { status: 'failed', entries: [{ kind: 'error', text: 'Timed out' }] }
|
||||
})?.body.text
|
||||
).toBe('Codex hook failed: Timed out')
|
||||
expect(
|
||||
unhandledProviderFrameJournalItem('codex', CODEX_KIND, {
|
||||
run: { status: 'failed' },
|
||||
message: 'Hook could not start'
|
||||
})?.body.text
|
||||
).toBe('Codex hook failed: Hook could not start')
|
||||
expect(
|
||||
unhandledProviderFrameJournalItem('claude', CLAUDE_KIND, {
|
||||
outcome: 'error'
|
||||
})?.body.text
|
||||
).toBe('Claude hook failed.')
|
||||
})
|
||||
|
||||
it('shows Claude stderr and the exit code instead of the wire event name', () => {
|
||||
expect(
|
||||
unhandledProviderFrameJournalItem('claude', CLAUDE_KIND, {
|
||||
@@ -146,16 +116,6 @@ describe('failed hook provider frames', () => {
|
||||
).toBe(`Claude SessionStart hook failed: ${expected}`)
|
||||
})
|
||||
|
||||
it('retains a Claude exit code when there is no output', () => {
|
||||
expect(
|
||||
unhandledProviderFrameJournalItem('claude', CLAUDE_KIND, {
|
||||
hook_name: 'SessionStart:startup',
|
||||
outcome: 'error',
|
||||
exit_code: 1
|
||||
})?.body.text
|
||||
).toBe('Claude SessionStart:startup hook failed (exit code 1).')
|
||||
})
|
||||
|
||||
it('keeps successful hooks hidden even when they produce diagnostic output', () => {
|
||||
expect(
|
||||
unhandledProviderFrameJournalItem('codex', CODEX_KIND, {
|
||||
@@ -187,14 +147,4 @@ describe('failed hook provider frames', () => {
|
||||
Buffer.byteLength(row?.body.providerFrame?.payload.head ?? '', 'utf8')
|
||||
).toBeLessThanOrEqual(64)
|
||||
})
|
||||
|
||||
it('leaves other providers and unrelated frame kinds on their existing fallback', () => {
|
||||
const payload = { outcome: 'error', hook_name: 'Other hook', stderr: 'Failure' }
|
||||
expect(
|
||||
unhandledProviderFrameJournalItem('future-provider', CLAUDE_KIND, payload)?.body.text
|
||||
).toBe(`future-provider · ${CLAUDE_KIND}`)
|
||||
expect(
|
||||
unhandledProviderFrameJournalItem('claude', 'message:future/event', payload)?.body.text
|
||||
).toBe('claude · message:future/event')
|
||||
})
|
||||
})
|
||||
|
||||
-17
@@ -176,21 +176,4 @@ describe('what a chat host answers for one send', () => {
|
||||
expect(result).toEqual({ kind })
|
||||
expect(readReceipt).toHaveBeenCalledWith('mail')
|
||||
})
|
||||
|
||||
it.each([4, 40])('answers %i sends with no card left by key, over a long history', (count) => {
|
||||
const j = journal()
|
||||
for (let item = 0; item < 500; item += 1) {
|
||||
j.submit(item * 3 + 1, `history-${item}`)
|
||||
j.handOver(item * 3 + 2, `history-${item}`)
|
||||
j.accept(item * 3 + 3, `history-${item}`)
|
||||
}
|
||||
let receipts = 0
|
||||
for (let send = 0; send < count; send += 1) {
|
||||
const { result, readReceipt } = answer(j.state, `gone-${send}`)
|
||||
expect(result).toEqual({ kind: 'absent' })
|
||||
receipts += readReceipt.mock.calls.length
|
||||
}
|
||||
// One receipt read per send, and no history walk (the reader above throws on one).
|
||||
expect(receipts).toBe(count)
|
||||
})
|
||||
})
|
||||
|
||||
-26
@@ -69,17 +69,6 @@ function lastName(events: readonly AgentSessionStatusEvent[]): string | undefine
|
||||
return event?.type === 'status' ? event.session.conversationName : undefined
|
||||
}
|
||||
|
||||
it('publishes a held chat its saved name once, and not again for an unchanged record', async () => {
|
||||
const { feed, events, name } = await namedFeed()
|
||||
expect(lastName(events)).toBeUndefined()
|
||||
name('Explain the parser')
|
||||
expect(lastName(events)).toBe('Explain the parser')
|
||||
const count = events.length
|
||||
feed.publish('session')
|
||||
feed.publishConversationName('session')
|
||||
expect(events).toHaveLength(count + 1)
|
||||
})
|
||||
|
||||
it('names a chat whose tab closed before naming finished, and keeps it for later subscribers', async () => {
|
||||
const { feed, events, sessions, name } = await namedFeed()
|
||||
feed.revokeLive('session')
|
||||
@@ -94,21 +83,6 @@ it('names a chat whose tab closed before naming finished, and keeps it for later
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps the name on the summary a closing chat retains', async () => {
|
||||
const { feed, events, name } = await namedFeed()
|
||||
name('Explain the parser')
|
||||
feed.close('session')
|
||||
expect(lastName(events)).toBe('Explain the parser')
|
||||
})
|
||||
|
||||
it('publishes naming activity without needing a chat summary', async () => {
|
||||
const { feed, events } = await namedFeed()
|
||||
const count = events.length
|
||||
feed.publishConversationName('unknown-session')
|
||||
expect(events).toHaveLength(count + 1)
|
||||
expect(events.at(-1)).toEqual({ type: 'snapshot', sessions: [], namingActiveSessionIds: [] })
|
||||
})
|
||||
|
||||
it('publishes cold names from durable records across a host restart without a status row', () => {
|
||||
let record = { ...agentSessionRecordFixture(), conversationName: 'Before' }
|
||||
const open = () =>
|
||||
|
||||
-25
@@ -20,31 +20,6 @@ import {
|
||||
journals
|
||||
} from './structured-agent-session-subscriber-contract-test-fixture'
|
||||
|
||||
it('reports every content publication to the journal hook, subscribed or not', async () => {
|
||||
const journal = await journals.open({
|
||||
identity: {
|
||||
sessionId: SESSION,
|
||||
workspaceId: 'workspace-1',
|
||||
hostId: 'local',
|
||||
agent: 'codex',
|
||||
providerHandle: codexProviderHandle('thread-1')
|
||||
},
|
||||
stateDirectory: join(fixture.root, 'hook-journal')
|
||||
})
|
||||
const published: string[] = []
|
||||
const subscribers = new AgentSessionSubscribers({
|
||||
onJournalPublished: (sessionId, published_journal) => {
|
||||
expect(published_journal).toBe(journal)
|
||||
published.push(sessionId)
|
||||
}
|
||||
})
|
||||
|
||||
subscribers.publish(SESSION, journal)
|
||||
subscribers.snapshot(SESSION, journal, 1)
|
||||
|
||||
expect(published).toEqual([SESSION, SESSION])
|
||||
})
|
||||
|
||||
it('settles a session nobody is reading, from running to idle', async () => {
|
||||
// Never open a subscriber: status must publish independently of transcript readers.
|
||||
const journal = await journals.open({
|
||||
|
||||
@@ -1,197 +0,0 @@
|
||||
import type * as NodeCrypto from 'node:crypto'
|
||||
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
LAYOUT_OWNER_WRITTEN_SESSION_GOLDEN_PATH,
|
||||
LAYOUT_OWNER_WRITTEN_SESSION_WORKTREE_ID as WORKTREE
|
||||
} from '../../../shared/__fixtures__/layout-owner-written-session-golden-paths'
|
||||
import type { LayoutCommand } from '../../../shared/workspace-layout/workspace-layout-command-types'
|
||||
import type { WorkspaceSessionState } from '../../../shared/workspace-session/workspace-session-state-types'
|
||||
import type { Store } from '../loading-store/store'
|
||||
import { ProfileStateSqliteAuthority } from '../profile-state/profile-state-sqlite-authority'
|
||||
import { windowViewSaveForMain } from '../window-save-view'
|
||||
import {
|
||||
emptyTerminalSessionProfile,
|
||||
FIXTURE_GIT_WORKTREE_ID,
|
||||
openTopologyStore
|
||||
} from './terminal-topology-profile-fixture'
|
||||
|
||||
vi.mock('../../../shared/layout-owner-switch', () => ({ ORCA_LAYOUT_OWNER_IS_MAIN: true }))
|
||||
vi.mock('../../telemetry/client', () => ({ track: vi.fn() }))
|
||||
vi.mock('../../ssh/config/ssh-config-parser', () => ({
|
||||
loadUserSshConfig: () => ({ hosts: [] }),
|
||||
sshConfigHostsToTargets: () => []
|
||||
}))
|
||||
// Deterministic minted IDs keep persisted bytes stable.
|
||||
const minting = vi.hoisted(() => ({ on: false, next: 0 }))
|
||||
vi.mock('node:crypto', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof NodeCrypto>()
|
||||
return {
|
||||
...actual,
|
||||
randomUUID: () =>
|
||||
minting.on
|
||||
? `00000000-0000-4000-8000-${String(++minting.next).padStart(12, '0')}`
|
||||
: actual.randomUUID()
|
||||
}
|
||||
})
|
||||
|
||||
const UPDATE_ENV = 'ORCA_UPDATE_LAYOUT_OWNER_GOLDEN'
|
||||
const leaf = (n: number): string => `${n}${n}${n}${n}${n}${n}${n}${n}-1111-4111-8111-111111111111`
|
||||
const on = { workspace: WORKTREE }
|
||||
const directories: string[] = []
|
||||
|
||||
afterEach(() => {
|
||||
minting.on = false
|
||||
vi.useRealTimers()
|
||||
for (const directory of directories.splice(0)) {
|
||||
rmSync(directory, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
const COMMANDS: LayoutCommand[] = [
|
||||
{ ...on, type: 'createTerminalTab', tabId: 'tab-shell', leafId: leaf(1), createdAt: 1 },
|
||||
{ ...on, type: 'createTerminalTab', tabId: 'tab-build', leafId: leaf(2), createdAt: 2 },
|
||||
{
|
||||
...on,
|
||||
type: 'splitPane',
|
||||
tabId: 'tab-shell',
|
||||
leafId: leaf(1),
|
||||
direction: 'vertical',
|
||||
newLeafId: leaf(3)
|
||||
},
|
||||
{ ...on, type: 'renameTab', tabId: 'tab-build', kind: 'custom', title: 'Build' },
|
||||
{
|
||||
...on,
|
||||
type: 'openEditorTab',
|
||||
tabId: 'tab-readme',
|
||||
fileId: '/fixture/local/README.md',
|
||||
contentType: 'editor',
|
||||
createdAt: 3,
|
||||
file: { filePath: '/fixture/local/README.md', relativePath: 'README.md', language: 'markdown' }
|
||||
},
|
||||
{
|
||||
...on,
|
||||
type: 'openBrowserTab',
|
||||
tabId: 'tab-docs',
|
||||
browserTabId: 'browser-docs',
|
||||
pageId: 'page-docs',
|
||||
createdAt: 4,
|
||||
browserCreatedAt: 4
|
||||
},
|
||||
{ ...on, type: 'createTerminalTab', tabId: 'tab-scratch', leafId: leaf(4), createdAt: 5 }
|
||||
]
|
||||
|
||||
function windowSave(stored: WorkspaceSessionState): WorkspaceSessionState {
|
||||
const page = {
|
||||
id: 'page-docs',
|
||||
workspaceId: 'browser-docs',
|
||||
worktreeId: WORKTREE,
|
||||
url: 'https://example.com/docs',
|
||||
title: 'Docs',
|
||||
loading: false,
|
||||
faviconUrl: null,
|
||||
canGoBack: false,
|
||||
canGoForward: false,
|
||||
loadError: null,
|
||||
createdAt: 4
|
||||
}
|
||||
return {
|
||||
...stored,
|
||||
activeRepoId: 'repo-local',
|
||||
activeWorktreeId: WORKTREE,
|
||||
activeTabId: 'tab-shell',
|
||||
browserPagesByWorkspace: { 'browser-docs': [page] },
|
||||
browserTabsByWorktree: {
|
||||
[WORKTREE]: (stored.browserTabsByWorktree?.[WORKTREE] ?? []).map((tab) => ({
|
||||
...tab,
|
||||
url: page.url,
|
||||
title: page.title
|
||||
}))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function apply(store: Store, command: LayoutCommand): Promise<void> {
|
||||
const result = await store.applyLayoutCommand(command)
|
||||
expect(result.ok, command.type).toBe(true)
|
||||
}
|
||||
|
||||
function writtenSession(directory: string): string {
|
||||
const authority = new ProfileStateSqliteAuthority(join(directory, 'profile-state.db'), 'topology')
|
||||
const serialized = authority.readSerializedState()
|
||||
authority.close()
|
||||
const profile: { workspaceSession: unknown } = JSON.parse(serialized ?? '{}')
|
||||
return `${JSON.stringify(profile.workspaceSession, null, 2)}\n`
|
||||
}
|
||||
|
||||
// This probes current switch-off readers, not compatibility with older releases.
|
||||
describe('the session a layout-owner build writes (downgrade probe, ledger L8)', () => {
|
||||
it('is the checked-in golden, byte for byte', async () => {
|
||||
expect(WORKTREE).toBe(FIXTURE_GIT_WORKTREE_ID)
|
||||
const directory = mkdtempSync(join(tmpdir(), 'orca-layout-owner-golden-'))
|
||||
directories.push(directory)
|
||||
const store = await openTopologyStore(directory, emptyTerminalSessionProfile())
|
||||
vi.useFakeTimers({ toFake: ['Date'], now: 1_700_000_000_000 })
|
||||
minting.on = true
|
||||
|
||||
for (const command of COMMANDS.slice(0, 4)) {
|
||||
await apply(store, command)
|
||||
}
|
||||
const shellGroup = store.getWorkspaceSession().tabGroups?.[WORKTREE]?.[0]?.id
|
||||
await apply(store, {
|
||||
...on,
|
||||
type: 'splitGroup',
|
||||
tabId: 'tab-build',
|
||||
besideGroupId: shellGroup!,
|
||||
direction: 'right'
|
||||
})
|
||||
for (const command of COMMANDS.slice(4)) {
|
||||
await apply(store, command)
|
||||
}
|
||||
await store.persistPtyBinding({
|
||||
worktreeId: WORKTREE,
|
||||
tabId: 'tab-shell',
|
||||
leafId: leaf(1),
|
||||
ptyId: 'pty-shell',
|
||||
incarnationId: 'inc-shell'
|
||||
})
|
||||
// Retain closed terminals for exit events arriving after close.
|
||||
await store.persistPtyBinding({
|
||||
worktreeId: WORKTREE,
|
||||
tabId: 'tab-scratch',
|
||||
leafId: leaf(4),
|
||||
ptyId: 'pty-scratch',
|
||||
incarnationId: 'inc-scratch'
|
||||
})
|
||||
await apply(store, {
|
||||
...on,
|
||||
type: 'closeTabs',
|
||||
tabIds: ['tab-scratch'],
|
||||
force: true,
|
||||
recordClose: 'user'
|
||||
})
|
||||
minting.on = false
|
||||
// Simulate session:set with renderer selection and browser-page state.
|
||||
const stored = store.getWorkspaceSession()
|
||||
store.setWorkspaceSession(windowViewSaveForMain(stored, windowSave(stored), undefined))
|
||||
await store.flushPendingOrThrowAsync()
|
||||
await store.freezeWritesAsync()
|
||||
|
||||
const written = writtenSession(directory)
|
||||
// Reject machine-specific paths; clocks and minted IDs are deterministic.
|
||||
expect(written).not.toContain(tmpdir())
|
||||
if (process.env[UPDATE_ENV] === '1') {
|
||||
writeFileSync(LAYOUT_OWNER_WRITTEN_SESSION_GOLDEN_PATH, written)
|
||||
}
|
||||
const golden = JSON.parse(readFileSync(LAYOUT_OWNER_WRITTEN_SESSION_GOLDEN_PATH, 'utf8'))
|
||||
// Ignore formatter whitespace while retaining key order and values.
|
||||
expect(
|
||||
written,
|
||||
`the switch-on save format changed: regenerate with ${UPDATE_ENV}=1, then the switch-off ` +
|
||||
'restore tests (layout-owner-written-session-switch-off-load, ' +
|
||||
'layout-owner-written-session-hydration) must still pass: they are the downgrade proof'
|
||||
).toBe(`${JSON.stringify(golden, null, 2)}\n`)
|
||||
})
|
||||
})
|
||||
@@ -1,6 +1,5 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
getWindowsCmdPath,
|
||||
resolveWindowsPowerShellExecutablePath,
|
||||
resolveWindowsPowerShellSpawnChain
|
||||
} from './windows-powershell-executable'
|
||||
@@ -13,7 +12,6 @@ const WIN_ENV: NodeJS.ProcessEnv = {
|
||||
ComSpec: 'C:\\Windows\\System32\\cmd.exe'
|
||||
}
|
||||
|
||||
const PWSH7 = 'C:\\Program Files\\PowerShell\\7\\pwsh.exe'
|
||||
const PATH_PWSH7 = 'D:\\Tools\\PowerShell\\7\\pwsh.exe'
|
||||
const WINDOWS_POWERSHELL = 'C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe'
|
||||
// Store alias reparse points fail ConPTY CreateProcessW with access-denied (5).
|
||||
@@ -22,26 +20,6 @@ const PWSH_STORE_EXE =
|
||||
'C:\\Program Files\\WindowsApps\\Microsoft.PowerShell_7.6.3.0_x64__8wekyb3d8bbwe\\pwsh.exe'
|
||||
|
||||
describe('resolveWindowsPowerShellExecutablePath', () => {
|
||||
it('returns null on non-Windows platforms', () => {
|
||||
expect(
|
||||
resolveWindowsPowerShellExecutablePath('pwsh.exe', {
|
||||
platform: 'linux',
|
||||
env: WIN_ENV,
|
||||
isRealExecutable: () => true
|
||||
})
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
it('resolves pwsh.exe to a real absolute path under Program Files', () => {
|
||||
expect(
|
||||
resolveWindowsPowerShellExecutablePath('pwsh.exe', {
|
||||
platform: 'win32',
|
||||
env: WIN_ENV,
|
||||
isRealExecutable: (p) => p === PWSH7
|
||||
})
|
||||
).toBe(PWSH7)
|
||||
})
|
||||
|
||||
it('repro: resolves pwsh.exe from a real PATH entry when standard roots miss', () => {
|
||||
expect(
|
||||
resolveWindowsPowerShellExecutablePath('pwsh.exe', {
|
||||
@@ -59,16 +37,6 @@ describe('resolveWindowsPowerShellExecutablePath', () => {
|
||||
).toBe(PATH_PWSH7)
|
||||
})
|
||||
|
||||
it('resolves powershell.exe to inbox System32 WindowsPowerShell', () => {
|
||||
expect(
|
||||
resolveWindowsPowerShellExecutablePath('powershell.exe', {
|
||||
platform: 'win32',
|
||||
env: WIN_ENV,
|
||||
isRealExecutable: (p) => p === WINDOWS_POWERSHELL
|
||||
})
|
||||
).toBe(WINDOWS_POWERSHELL)
|
||||
})
|
||||
|
||||
it('repro: never resolves pwsh.exe to the Store App Execution Alias stub', () => {
|
||||
// Alias existence cannot prove a ConPTY-launchable executable.
|
||||
const resolved = resolveWindowsPowerShellExecutablePath('pwsh.exe', {
|
||||
@@ -108,28 +76,9 @@ describe('resolveWindowsPowerShellExecutablePath', () => {
|
||||
expect(resolved).toBeNull()
|
||||
}
|
||||
)
|
||||
|
||||
it('returns null for pwsh when no real executable is found', () => {
|
||||
expect(
|
||||
resolveWindowsPowerShellExecutablePath('pwsh.exe', {
|
||||
platform: 'win32',
|
||||
env: WIN_ENV,
|
||||
isRealExecutable: () => false
|
||||
})
|
||||
).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('resolveWindowsPowerShellSpawnChain', () => {
|
||||
it('orders pwsh -> Windows PowerShell -> cmd.exe when all resolve', () => {
|
||||
const chain = resolveWindowsPowerShellSpawnChain('pwsh.exe', {
|
||||
platform: 'win32',
|
||||
env: WIN_ENV,
|
||||
isRealExecutable: (p) => p === PWSH7 || p === WINDOWS_POWERSHELL
|
||||
})
|
||||
expect(chain).toEqual([PWSH7, WINDOWS_POWERSHELL, WIN_ENV.ComSpec])
|
||||
})
|
||||
|
||||
it('falls back to Windows PowerShell + cmd.exe when pwsh is only a Store alias', () => {
|
||||
const chain = resolveWindowsPowerShellSpawnChain('pwsh.exe', {
|
||||
platform: 'win32',
|
||||
@@ -139,57 +88,4 @@ describe('resolveWindowsPowerShellSpawnChain', () => {
|
||||
expect(chain).toEqual([WINDOWS_POWERSHELL, WIN_ENV.ComSpec])
|
||||
expect(chain).not.toContain(PWSH_STORE_ALIAS)
|
||||
})
|
||||
|
||||
it('uses the real Store package executable ahead of Windows PowerShell', () => {
|
||||
const chain = resolveWindowsPowerShellSpawnChain('pwsh.exe', {
|
||||
platform: 'win32',
|
||||
env: {
|
||||
...WIN_ENV,
|
||||
Path: 'C:\\Users\\dev\\AppData\\Local\\Microsoft\\WindowsApps'
|
||||
},
|
||||
isRealExecutable: (p) => p === PWSH_STORE_EXE || p === WINDOWS_POWERSHELL,
|
||||
resolveAppExecutionAlias: (p) => (p === PWSH_STORE_ALIAS ? PWSH_STORE_EXE : null)
|
||||
})
|
||||
expect(chain).toEqual([PWSH_STORE_EXE, WINDOWS_POWERSHELL, WIN_ENV.ComSpec])
|
||||
})
|
||||
|
||||
it('orders PATH-resolved pwsh before Windows PowerShell when standard roots miss', () => {
|
||||
const chain = resolveWindowsPowerShellSpawnChain('pwsh.exe', {
|
||||
platform: 'win32',
|
||||
env: {
|
||||
...WIN_ENV,
|
||||
Path: 'D:\\Tools\\PowerShell\\7'
|
||||
},
|
||||
isRealExecutable: (p) => p === PATH_PWSH7 || p === WINDOWS_POWERSHELL
|
||||
})
|
||||
expect(chain).toEqual([PATH_PWSH7, WINDOWS_POWERSHELL, WIN_ENV.ComSpec])
|
||||
})
|
||||
|
||||
it('always ends with cmd.exe even when no PowerShell resolves', () => {
|
||||
const chain = resolveWindowsPowerShellSpawnChain('powershell.exe', {
|
||||
platform: 'win32',
|
||||
env: WIN_ENV,
|
||||
isRealExecutable: () => false
|
||||
})
|
||||
expect(chain).toEqual([WIN_ENV.ComSpec])
|
||||
})
|
||||
|
||||
it('does not duplicate cmd.exe when ComSpec is missing', () => {
|
||||
const chain = resolveWindowsPowerShellSpawnChain('powershell.exe', {
|
||||
platform: 'win32',
|
||||
env: { SystemRoot: 'C:\\Windows' },
|
||||
isRealExecutable: () => false
|
||||
})
|
||||
expect(chain).toEqual(['C:\\Windows\\System32\\cmd.exe'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('getWindowsCmdPath', () => {
|
||||
it('prefers ComSpec when set', () => {
|
||||
expect(getWindowsCmdPath(WIN_ENV)).toBe('C:\\Windows\\System32\\cmd.exe')
|
||||
})
|
||||
|
||||
it('derives cmd.exe from SystemRoot when ComSpec is absent', () => {
|
||||
expect(getWindowsCmdPath({ SystemRoot: 'D:\\Win' })).toBe('D:\\Win\\System32\\cmd.exe')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,19 +1,11 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const getAllProcessesMock = vi.fn()
|
||||
|
||||
import { __setWindowsProcessTreeLoaderForTests } from '../../windows/windows-process-table'
|
||||
import {
|
||||
classifyWindowsTreeKillTarget,
|
||||
verifyWindowsTreeKillTarget,
|
||||
WINDOWS_ROOT_IDENTITY_TIMEOUT_MS
|
||||
} from './windows-pty-root-identity'
|
||||
// The native reader rejects snapshots that omit the querying process.
|
||||
const SELF_PROCESS_ROW = { pid: process.pid, ppid: 0, name: 'vitest.exe', commandLine: 'vitest' }
|
||||
const withSelf = <T>(rows: readonly T[]): (T | typeof SELF_PROCESS_ROW)[] => [
|
||||
SELF_PROCESS_ROW,
|
||||
...rows
|
||||
]
|
||||
|
||||
const ORCA_PID = 5000
|
||||
|
||||
@@ -34,12 +26,6 @@ describe('classifyWindowsTreeKillTarget', () => {
|
||||
expect(classifyWindowsTreeKillTarget(4242, rows, ORCA_PID)).toBe('own')
|
||||
})
|
||||
|
||||
it('documents that a recycled PID under another Orca pane still classifies as own', () => {
|
||||
// Recycled as a tool under another pane, PID 4242 still has Orca ancestry.
|
||||
const rows = [link(ORCA_PID, 900), link(7000, ORCA_PID), link(7100, 7000), link(4242, 7100)]
|
||||
expect(classifyWindowsTreeKillTarget(4242, rows, ORCA_PID)).toBe('own')
|
||||
})
|
||||
|
||||
it('rejects a recycled pid whose ancestry never reaches this process', () => {
|
||||
const rows = [...SYSTEM_CHAIN, link(ORCA_PID, 900), link(4242, 900)]
|
||||
expect(classifyWindowsTreeKillTarget(4242, rows, ORCA_PID)).toBe('foreign')
|
||||
@@ -72,10 +58,6 @@ describe('classifyWindowsTreeKillTarget', () => {
|
||||
expect(classifyWindowsTreeKillTarget(ORCA_PID, rows, ORCA_PID)).toBe('foreign')
|
||||
})
|
||||
|
||||
it.each([0, -1, 1.5, Number.NaN])('rejects the invalid root pid %s', (rootPid) => {
|
||||
expect(classifyWindowsTreeKillTarget(rootPid, [link(4242, ORCA_PID)], ORCA_PID)).toBe('foreign')
|
||||
})
|
||||
|
||||
it('treats duplicate rows for the root as unknown, not as ownership', () => {
|
||||
const rows = [link(4242, ORCA_PID), link(4242, 900)]
|
||||
expect(classifyWindowsTreeKillTarget(4242, rows, ORCA_PID)).toBe('unknown')
|
||||
@@ -88,21 +70,6 @@ describe('classifyWindowsTreeKillTarget', () => {
|
||||
})
|
||||
|
||||
describe('verifyWindowsTreeKillTarget', () => {
|
||||
it('classifies a live win32 root against the fresh process table', async () => {
|
||||
const readRows = vi.fn().mockResolvedValue([link(4242, ORCA_PID)])
|
||||
await expect(
|
||||
verifyWindowsTreeKillTarget(4242, { readRows, ownerPid: ORCA_PID, platform: 'win32' })
|
||||
).resolves.toBe('own')
|
||||
expect(readRows).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('detects the recycled-pid case that taskkill /T /F must not touch', async () => {
|
||||
const readRows = vi.fn().mockResolvedValue([link(4242, 900), link(900, 4)])
|
||||
await expect(
|
||||
verifyWindowsTreeKillTarget(4242, { readRows, ownerPid: ORCA_PID, platform: 'win32' })
|
||||
).resolves.toBe('foreign')
|
||||
})
|
||||
|
||||
it('returns unknown when the Windows process table is unavailable', async () => {
|
||||
const readRows = vi.fn().mockResolvedValue(null)
|
||||
await expect(
|
||||
@@ -141,45 +108,4 @@ describe('verifyWindowsTreeKillTarget', () => {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('skips the probe off Windows so POSIX teardown keeps its own guards', async () => {
|
||||
const readRows = vi.fn()
|
||||
await expect(
|
||||
verifyWindowsTreeKillTarget(4242, { readRows, ownerPid: ORCA_PID, platform: 'darwin' })
|
||||
).resolves.toBe('unknown')
|
||||
expect(readRows).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('verifyWindowsTreeKillTarget scan volume', () => {
|
||||
const NATIVE_ROWS = [
|
||||
{ pid: ORCA_PID, ppid: 900, name: 'orca.exe', commandLine: 'orca.exe' },
|
||||
{ pid: 4242, ppid: ORCA_PID, name: 'pwsh.exe', commandLine: 'pwsh.exe' }
|
||||
]
|
||||
|
||||
beforeEach(() => {
|
||||
getAllProcessesMock.mockReset()
|
||||
getAllProcessesMock.mockImplementation((cb: (snapshot: unknown) => void) => {
|
||||
cb(withSelf(NATIVE_ROWS))
|
||||
})
|
||||
__setWindowsProcessTreeLoaderForTests(() => ({
|
||||
ProcessDataFlag: { None: 0, Memory: 1, CommandLine: 2, CreationTime: 4 },
|
||||
getAllProcesses: getAllProcessesMock
|
||||
}))
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
__setWindowsProcessTreeLoaderForTests()
|
||||
})
|
||||
|
||||
it('collapses a 32-wide teardown burst into a single process-table scan', async () => {
|
||||
const verdicts = await Promise.all(
|
||||
Array.from({ length: 32 }, () =>
|
||||
verifyWindowsTreeKillTarget(4242, { ownerPid: ORCA_PID, platform: 'win32' })
|
||||
)
|
||||
)
|
||||
|
||||
expect(getAllProcessesMock.mock.calls.length).toBe(1)
|
||||
expect(new Set(verdicts)).toEqual(new Set(['own']))
|
||||
})
|
||||
})
|
||||
|
||||
@@ -3,7 +3,7 @@ import { chmodSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:f
|
||||
import { tmpdir } from 'node:os'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { getFishCodexShellLaunchPreflight } from '../../../shared/codex-shell-function'
|
||||
|
||||
import {
|
||||
fishRequirementViolation,
|
||||
resolveFishBinary
|
||||
@@ -11,28 +11,12 @@ import {
|
||||
import {
|
||||
buildFishVendorConfWrapperFile,
|
||||
FISH_XDG_DATA_DIRS_PREFIX_ENV,
|
||||
getFishVendorConfSnippet,
|
||||
getFishXdgDataDirsLaunchEnv
|
||||
} from './fish-xdg-data-dirs-handoff'
|
||||
|
||||
const ROOT = '/orca/shell-wrappers/abc'
|
||||
const DATA_DIR = `${ROOT}/fish-xdg-data`
|
||||
|
||||
describe('getFishXdgDataDirsLaunchEnv', () => {
|
||||
it('adds the XDG default after Orca when the variable is unset', () => {
|
||||
const expected = `${DATA_DIR}:/usr/local/share:/usr/share`
|
||||
expect(getFishXdgDataDirsLaunchEnv(ROOT, undefined)).toEqual({
|
||||
XDG_DATA_DIRS: expected,
|
||||
[FISH_XDG_DATA_DIRS_PREFIX_ENV]: expected
|
||||
})
|
||||
})
|
||||
|
||||
it('treats an empty XDG_DATA_DIRS as unset', () => {
|
||||
expect(getFishXdgDataDirsLaunchEnv(ROOT, '')).toEqual(
|
||||
getFishXdgDataDirsLaunchEnv(ROOT, undefined)
|
||||
)
|
||||
})
|
||||
|
||||
it.each([['-N'], ['--no-config'], ['--no-c'], ['-lN'], ['-Ni']])(
|
||||
'skips fish args that disable config: %s',
|
||||
(arg) => {
|
||||
@@ -40,44 +24,11 @@ describe('getFishXdgDataDirsLaunchEnv', () => {
|
||||
}
|
||||
)
|
||||
|
||||
it.for([['-l'], ['-i'], ['--login'], ['--no-execute'], ['--no-'], ['-c', 'echo N']])(
|
||||
'still hands off for fish args %j',
|
||||
(args) => {
|
||||
expect(getFishXdgDataDirsLaunchEnv(ROOT, undefined, args)).not.toEqual({})
|
||||
}
|
||||
)
|
||||
|
||||
it('prepends to an existing value and records only its own entry', () => {
|
||||
expect(getFishXdgDataDirsLaunchEnv(ROOT, '/opt/a:/opt/b')).toEqual({
|
||||
XDG_DATA_DIRS: `${DATA_DIR}:/opt/a:/opt/b`,
|
||||
[FISH_XDG_DATA_DIRS_PREFIX_ENV]: DATA_DIR
|
||||
})
|
||||
})
|
||||
|
||||
it('prepends again when the inherited value already names the dir', () => {
|
||||
const inherited = `${DATA_DIR}:/opt/a`
|
||||
expect(getFishXdgDataDirsLaunchEnv(ROOT, inherited).XDG_DATA_DIRS).toBe(
|
||||
`${DATA_DIR}:${inherited}`
|
||||
)
|
||||
})
|
||||
|
||||
it('skips a dir that XDG_DATA_DIRS cannot represent', () => {
|
||||
expect(getFishXdgDataDirsLaunchEnv('/odd:root', undefined)).toEqual({})
|
||||
})
|
||||
})
|
||||
|
||||
describe('fish vendor snippet', () => {
|
||||
it('embeds the shared fish codex function verbatim', () => {
|
||||
expect(getFishVendorConfSnippet()).toContain(getFishCodexShellLaunchPreflight())
|
||||
})
|
||||
|
||||
it('lives where fish looks for vendor snippets under the data dir', () => {
|
||||
expect(buildFishVendorConfWrapperFile(ROOT)[0]).toBe(
|
||||
`${DATA_DIR}/fish/vendor_conf.d/orca-shell-integration.fish`
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
const fishLookup = resolveFishBinary(3)
|
||||
// The test shells run with a minimal PATH.
|
||||
const fish = fishLookup.available
|
||||
|
||||
@@ -52,31 +52,6 @@ describe('parseOpenCodeGoStatusPayload', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('accepts numeric microCents', () => {
|
||||
const parsed = parseOpenCodeGoStatusPayload(
|
||||
JSON.stringify({
|
||||
access: {
|
||||
meters: {
|
||||
fiveHour: {
|
||||
resetsAt: '2026-09-18T12:42:04.962Z',
|
||||
limitMicroCents: 100,
|
||||
usedMicroCents: 25
|
||||
},
|
||||
week: {
|
||||
resetsAt: '2026-09-21T00:00:00.000Z',
|
||||
limitMicroCents: 200,
|
||||
usedMicroCents: 50
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
expect(parsed?.session?.usedPercent).toBe(25)
|
||||
expect(parsed?.weekly?.usedPercent).toBe(25)
|
||||
expect(parsed?.monthly).toBeNull()
|
||||
})
|
||||
|
||||
it('caps usedPercent at 100 and floors at 0', () => {
|
||||
const parsed = parseOpenCodeGoStatusPayload(
|
||||
JSON.stringify({
|
||||
@@ -100,26 +75,6 @@ describe('parseOpenCodeGoStatusPayload', () => {
|
||||
expect(parsed?.session?.usedPercent).toBe(100)
|
||||
expect(parsed?.weekly?.usedPercent).toBe(0)
|
||||
})
|
||||
|
||||
it('returns null for HTML and other non-JSON bodies', () => {
|
||||
expect(parseOpenCodeGoStatusPayload('<html>rollingUsage:{usagePercent:30}</html>')).toBeNull()
|
||||
expect(parseOpenCodeGoStatusPayload('')).toBeNull()
|
||||
expect(parseOpenCodeGoStatusPayload('{not json')).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null when fiveHour or week meters are missing', () => {
|
||||
expect(
|
||||
parseOpenCodeGoStatusPayload(
|
||||
JSON.stringify({
|
||||
access: {
|
||||
meters: {
|
||||
week: { limitMicroCents: '100', usedMicroCents: '10' }
|
||||
}
|
||||
}
|
||||
})
|
||||
)
|
||||
).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('parseOpenCodeGoBillingStatusPayload', () => {
|
||||
@@ -170,12 +125,6 @@ describe('parseOpenCodeGoBillingStatusPayload', () => {
|
||||
])('rejects unsupported or unsafe billing payloads: $billingMode/$mode', (payload) => {
|
||||
expect(parseOpenCodeGoBillingStatusPayload(JSON.stringify(payload))).toBeNull()
|
||||
})
|
||||
|
||||
it('fails closed for malformed and non-object bodies', () => {
|
||||
expect(parseOpenCodeGoBillingStatusPayload('{not json')).toBeNull()
|
||||
expect(parseOpenCodeGoBillingStatusPayload('[]')).toBeNull()
|
||||
expect(parseOpenCodeGoBillingStatusPayload('')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('isOpenCodeGoExplicitNoAccessPayload', () => {
|
||||
@@ -212,14 +161,4 @@ describe('parseOpenCodeGoUsageApiPayload', () => {
|
||||
expect(parsed?.weekly.usedPercent).toBe(0)
|
||||
expect(parsed?.monthly).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null for the console error bodies and other non-usage payloads', () => {
|
||||
expect(
|
||||
parseOpenCodeGoUsageApiPayload(
|
||||
JSON.stringify({ type: 'error', error: { type: 'AuthError', message: 'Unauthorized' } })
|
||||
)
|
||||
).toBeNull()
|
||||
expect(parseOpenCodeGoUsageApiPayload('{not json')).toBeNull()
|
||||
expect(parseOpenCodeGoUsageApiPayload('')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
import { join } from 'node:path'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { RemoteLayoutSnapshot } from '../../shared/remote-layout-snapshot'
|
||||
import type { RuntimeRpcResponse } from '../../shared/runtime-rpc-envelope'
|
||||
import { scanSourceTree, stripComments } from '../../shared/source-scan/source-tree-scan'
|
||||
|
||||
import {
|
||||
forgetRemoteLayoutSnapshot,
|
||||
recordRemoteLayoutFrame,
|
||||
type RemoteLayoutSnapshotStore
|
||||
} from './remote-layout-snapshot-recorder'
|
||||
@@ -113,33 +111,6 @@ describe('remote layout snapshot recorder', () => {
|
||||
expect(workspacesOf(snapshots)).toEqual({ a: tabs('a', 'vim') })
|
||||
})
|
||||
|
||||
it('saves at once only when tabs or groups change; title ticks ride the next save', () => {
|
||||
const { store, saves } = memoryStore()
|
||||
const census = (snapshots: unknown[]) => frame({ type: 'snapshots', snapshots })
|
||||
const update = (result: Record<string, unknown>) => frame({ type: 'updated', ...result })
|
||||
recordRemoteLayoutFrame(store, 'env', METHOD, census([tabs('a')]))
|
||||
recordRemoteLayoutFrame(store, 'env', METHOD, update(tabs('a', 'thinking.')))
|
||||
recordRemoteLayoutFrame(store, 'env', METHOD, update(tabs('a', 'thinking..')))
|
||||
const twoTabs = {
|
||||
...tabs('a'),
|
||||
tabs: [...tabs('a').tabs, { type: 'terminal', id: 'second', title: 'shell' }]
|
||||
}
|
||||
recordRemoteLayoutFrame(store, 'env', METHOD, update(twoTabs))
|
||||
const grouped = {
|
||||
...twoTabs,
|
||||
tabGroups: [{ id: 'g', activeTabId: null, tabOrder: ['second'] }]
|
||||
}
|
||||
recordRemoteLayoutFrame(store, 'env', METHOD, update(grouped))
|
||||
recordRemoteLayoutFrame(
|
||||
store,
|
||||
'env',
|
||||
METHOD,
|
||||
update({ ...grouped, tabGroups: [{ id: 'g', activeTabId: 'second', tabOrder: ['second'] }] })
|
||||
)
|
||||
recordRemoteLayoutFrame(store, 'env', METHOD, update({ ...tabs('a'), tabs: [], removed: true }))
|
||||
expect(saves).toEqual(['now', 'later', 'later', 'now', 'now', 'later', 'now'])
|
||||
})
|
||||
|
||||
it('keeps each server in its own entry', () => {
|
||||
const { store, snapshots } = memoryStore()
|
||||
recordRemoteLayoutFrame(
|
||||
@@ -169,22 +140,4 @@ describe('remote layout snapshot recorder', () => {
|
||||
recordRemoteLayoutFrame(store, 'env', METHOD, frame({ type: 'updated', worktree: 'a' }))
|
||||
expect(saves).toEqual([])
|
||||
})
|
||||
|
||||
it('forgets a removed server', () => {
|
||||
const { store, snapshots } = memoryStore()
|
||||
recordRemoteLayoutFrame(store, 'env', METHOD, frame({ type: 'snapshots', snapshots: [] }))
|
||||
forgetRemoteLayoutSnapshot(store, 'env')
|
||||
expect(snapshots.has('env')).toBe(false)
|
||||
})
|
||||
|
||||
it('is the only code that writes the snapshot', () => {
|
||||
const src = join(__dirname, '../..')
|
||||
const writers = scanSourceTree(src)
|
||||
.filter(({ source }) => stripComments(source).includes('setRemoteLayoutSnapshot('))
|
||||
.map(({ relativePath }) => relativePath)
|
||||
expect(writers.sort()).toEqual([
|
||||
'main/remote-layout-snapshot/remote-layout-snapshot-persistence.ts',
|
||||
'main/remote-layout-snapshot/remote-layout-snapshot-recorder.ts'
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -105,16 +105,6 @@ describe('DesktopBrowserBridgeRegistry', () => {
|
||||
expect(MAX_PENDING_PER_ATTACHMENT).toBeLessThan(32)
|
||||
})
|
||||
|
||||
it('gives the desktop a deadline that ends before orcad stops waiting', () => {
|
||||
const registry = new DesktopBrowserBridgeRegistry()
|
||||
const events: DesktopBrowserBridgeEvent[] = []
|
||||
registry.attach({ connectionId: 'c', pairedDeviceId: 'd', emit: (event) => events.push(event) })
|
||||
|
||||
registry.forward({ method: 'browser.click', params: {} }, { timeoutMs: 60_000 }).catch(() => {})
|
||||
|
||||
expect(events[0]).toMatchObject({ type: 'request', timeoutMs: 50_000 })
|
||||
})
|
||||
|
||||
it('tells a cancel-capable desktop to drop a command the CLI cancelled', async () => {
|
||||
const registry = new DesktopBrowserBridgeRegistry()
|
||||
const events: DesktopBrowserBridgeEvent[] = []
|
||||
|
||||
@@ -71,13 +71,6 @@ describe('a folder whose project group mixes local and SSH repos (layout-owner b
|
||||
const { snapshot } = runtime.subscribeWorkspaceLayouts(() => {})
|
||||
expect(snapshot.map((entry) => entry.key)).toEqual([GIT_KEY, FOLDER_KEY])
|
||||
})
|
||||
|
||||
it('applies a layout command for it to the local partition', async () => {
|
||||
const { runtime, applyLayoutCommand } = runtimeWithMixedFolder()
|
||||
const reply = await runtime.applyLayoutCommand(RATIOS)
|
||||
expect(reply.ok).toBe(true)
|
||||
expect(applyLayoutCommand).toHaveBeenCalledWith(RATIOS, ['local'], {})
|
||||
})
|
||||
})
|
||||
|
||||
describe('every folder kind routes where the window saves it (layout-owner build)', () => {
|
||||
@@ -88,15 +81,6 @@ describe('every folder kind routes where the window saves it (layout-owner build
|
||||
})
|
||||
})
|
||||
|
||||
describe('the same folder in a window-owned build', () => {
|
||||
it('keeps refusing to route it', () => {
|
||||
mocks.layoutOwnerIsMain = false
|
||||
vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
const { runtime } = runtimeWithMixedFolder()
|
||||
expect(runtime.subscribeWorkspaceLayouts(() => {}).snapshot).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('a workspace the router throws on', () => {
|
||||
function streamThrowingOn(key: string) {
|
||||
const session = localDesktopSession()
|
||||
@@ -151,21 +135,6 @@ describe('a workspace the router throws on', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('a routing warning never hides a later listener failure', async () => {
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
const { stream, route, write } = streamThrowingOn(FOLDER_KEY)
|
||||
stream.subscribe(() => {
|
||||
throw new Error('listener failed')
|
||||
})
|
||||
route.throws = false
|
||||
write()
|
||||
await flush()
|
||||
expect(warn.mock.calls.map((call) => call[0])).toEqual([
|
||||
'[workspace-layout] layout stream routing failed:',
|
||||
'[workspace-layout] layout stream listener failed:'
|
||||
])
|
||||
})
|
||||
|
||||
it('still fails the whole projection in a window-owned build', () => {
|
||||
mocks.layoutOwnerIsMain = false
|
||||
vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { StructuredAgentSessionLogger } from '../native-chat/agent-session-wire/structured-agent-session-logger'
|
||||
|
||||
const installed = vi.hoisted(() => {
|
||||
@@ -26,142 +26,6 @@ vi.mock('./agent-session/structured-agent-session-runtime', () => ({
|
||||
}))
|
||||
|
||||
import { OrcaRuntimeService } from './orca-runtime'
|
||||
import { _resetTracerForTests, setActiveSink } from '../observability/tracer'
|
||||
import type { StructuredAgentSessionStatusSink } from '../native-chat/agent-session-wire/structured-agent-session-status-feed'
|
||||
import * as structuredMailTarget from './orchestration/structured-session-mail-target'
|
||||
import { OrcaRuntimeWithRuntimeId } from './construction/runtime-identity-state'
|
||||
|
||||
describe('structured status sink wiring', () => {
|
||||
afterEach(() => {
|
||||
_resetTracerForTests()
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('hands the host the sink the runtime was constructed with', async () => {
|
||||
installed.deps = null
|
||||
const sink: StructuredAgentSessionStatusSink = { publish: vi.fn(), forget: vi.fn() }
|
||||
const runtime = new OrcaRuntimeService(null, undefined, { structuredAgentStatusSink: sink })
|
||||
|
||||
await runtime.ensureStructuredAgentSessionHost()
|
||||
|
||||
expect(installed.deps?.['statusSink']).toBe(sink)
|
||||
})
|
||||
|
||||
it('hands the host the trace-file logger', async () => {
|
||||
installed.logger = null
|
||||
const push = vi.fn()
|
||||
setActiveSink({ push, flush: () => {}, close: () => {} })
|
||||
vi.spyOn(console, 'warn').mockImplementation(() => undefined)
|
||||
const runtime = new OrcaRuntimeService()
|
||||
|
||||
await runtime.ensureStructuredAgentSessionHost()
|
||||
const installedLogger = (): StructuredAgentSessionLogger | null => installed.logger
|
||||
installedLogger()?.warn('renewing a chat lease failed', {
|
||||
scope: 'lease-renewal',
|
||||
sessionId: 'session-1',
|
||||
error: new Error('database is locked')
|
||||
})
|
||||
|
||||
expect(push).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
name: 'agentSession.lease-renewal',
|
||||
attributes: expect.objectContaining({ sessionId: 'session-1' }),
|
||||
exit: expect.objectContaining({ _tag: 'Failure' })
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('installs without a sink when none was provided', async () => {
|
||||
installed.deps = null
|
||||
const runtime = new OrcaRuntimeService()
|
||||
|
||||
await runtime.ensureStructuredAgentSessionHost()
|
||||
|
||||
expect(installed.deps).not.toBeNull()
|
||||
expect('statusSink' in (installed.deps ?? {})).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('mail dispatch through the current runtime', () => {
|
||||
afterEach(() => vi.restoreAllMocks())
|
||||
|
||||
it('uses runtime journal and delivery overrides on the idle edge', () => {
|
||||
const target = new OrcaRuntimeService()
|
||||
vi.spyOn(structuredMailTarget, 'structuredSessionIdleEdgeMailboxes').mockReturnValue([
|
||||
'term_mailbox'
|
||||
])
|
||||
const events: string[] = []
|
||||
Object.defineProperties(target, {
|
||||
notifyStructuredSessionJournalActivity: {
|
||||
value: function (this: OrcaRuntimeService, sessionId: string) {
|
||||
expect(this).toBe(target)
|
||||
expect(arguments.length).toBe(1)
|
||||
events.push(`journal:${sessionId}`)
|
||||
}
|
||||
},
|
||||
deliverPendingMessagesForHandle: {
|
||||
value: function (this: OrcaRuntimeService, handle: string) {
|
||||
expect(this).toBe(target)
|
||||
expect(arguments.length).toBe(1)
|
||||
events.push(`delivery:${handle}`)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
target.onStructuredSessionStatusForMail({ sessionId: 'session-mailbox', status: 'working' })
|
||||
expect(events).toEqual(['journal:session-mailbox'])
|
||||
events.length = 0
|
||||
target.onStructuredSessionStatusForMail({ sessionId: 'session-mailbox', status: 'idle' })
|
||||
expect(events).toEqual(['journal:session-mailbox', 'delivery:term_mailbox'])
|
||||
})
|
||||
|
||||
it('uses the runtime delivery override during repoint repair', () => {
|
||||
const target = new OrcaRuntimeService()
|
||||
const delivery = vi.fn(function (this: OrcaRuntimeService, handle: string) {
|
||||
expect(this).toBe(target)
|
||||
expect(arguments.length).toBe(1)
|
||||
expect(handle).toBe('term_mailbox')
|
||||
})
|
||||
Object.defineProperty(target, 'deliverPendingMessagesForHandle', { value: delivery })
|
||||
|
||||
target['repointPendingMessagesForHandle']('term_mailbox')
|
||||
expect(delivery).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('resolves a mailbox target through the runtime custody override', () => {
|
||||
const target = new OrcaRuntimeService()
|
||||
Object.defineProperty(target, 'structuredMailboxCustody', {
|
||||
value: function (this: OrcaRuntimeService, handle: string) {
|
||||
expect(this).toBe(target)
|
||||
expect(arguments.length).toBe(1)
|
||||
return handle === 'run:run-mailbox'
|
||||
? { sessionId: 'coordinator-session', dispatchId: null }
|
||||
: 'elsewhere'
|
||||
}
|
||||
})
|
||||
|
||||
expect(target['resolveStructuredMailboxTarget']('run:run-mailbox')).toEqual({
|
||||
sessionId: 'coordinator-session',
|
||||
dispatchId: null
|
||||
})
|
||||
expect(target['resolveStructuredMailboxTarget']('term_mailbox')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
abstract class MailSchedulerPhaseProbe extends OrcaRuntimeWithRuntimeId {
|
||||
readonly pendingMailDuringConstruction = this.mailPointerRepointScheduler.pendingCount
|
||||
}
|
||||
|
||||
describe('input state before later runtime layers initialize', () => {
|
||||
it('makes the scheduler available immediately after the original base layer', () => {
|
||||
expect(
|
||||
Object.getOwnPropertyDescriptor(
|
||||
Reflect.construct(MailSchedulerPhaseProbe, []),
|
||||
'pendingMailDuringConstruction'
|
||||
)?.value
|
||||
).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('checked runtime construction', () => {
|
||||
it('installs subscription callbacks on the runtime with their real registry receiver', () => {
|
||||
@@ -173,17 +37,4 @@ describe('checked runtime construction', () => {
|
||||
runtime.cleanupSubscription('construction-subscription')
|
||||
expect(cleanup).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('keeps constructor callbacks lazy and the canonical status producer identity', () => {
|
||||
const snapshot = vi.fn(() => [])
|
||||
const stopped = vi.fn()
|
||||
const runtime = new OrcaRuntimeService(null, undefined, {
|
||||
getAgentStatusSnapshot: snapshot,
|
||||
onPtyStopped: stopped
|
||||
})
|
||||
expect(runtime.getAgentStatusSnapshotFn).toBe(snapshot)
|
||||
expect(runtime['onPtyStopped']).toBe(stopped)
|
||||
expect(snapshot).not.toHaveBeenCalled()
|
||||
expect(stopped).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
/** Chat and terminal agents must receive equivalent preambles and pointers, apart from address and chat wording. */
|
||||
import { afterAll, afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
getAppEnvironment,
|
||||
@@ -6,8 +5,7 @@ import {
|
||||
setAppEnvironment,
|
||||
type AppEnvironment
|
||||
} from '../../../shared/app-environment'
|
||||
import type { OrcaRuntimeService } from '../orca-runtime'
|
||||
import { deliverWorkerDispatchPreamble } from '../rpc/methods/orchestration/worker/deliver-worker-dispatch-preamble'
|
||||
|
||||
import {
|
||||
localOrchestrationCliCommand,
|
||||
resolveTerminalOrchestrationCliCommand,
|
||||
@@ -16,30 +14,15 @@ import {
|
||||
} from './cli-command'
|
||||
import { OrchestrationDb } from './db'
|
||||
import { OrchestrationStructuredMailboxPointerDelivery } from './structured-mailbox-pointer-delivery'
|
||||
import {
|
||||
CHAT_REDISPATCH_PARAGRAPH as CHAT_REDISPATCH,
|
||||
TERMINAL_REDISPATCH_PARAGRAPH as TERMINAL_REDISPATCH
|
||||
} from './preamble'
|
||||
|
||||
const sent = vi.hoisted((): { preambles: string[] } => ({ preambles: [] }))
|
||||
vi.mock('../rpc/methods/orchestration-structured-worker-session', () => ({
|
||||
sendStructuredWorkerPreamble: async (args: { preamble: string }) => {
|
||||
sent.preambles.push(args.preamble)
|
||||
}
|
||||
}))
|
||||
|
||||
const CHAT_SESSION = '4a1f6c2e-8b3d-4e7a-9c15-0d2b6e8f1a37'
|
||||
const CHAT_ADDRESS = `orca_session_id:${CHAT_SESSION}`
|
||||
const TERMINAL_HANDLE = 'term_worker'
|
||||
// The structured worker's hidden mailbox handle differs from its visible session address.
|
||||
const CHAT_WORKER_HANDLE = 'structworker_1'
|
||||
|
||||
const db = new OrchestrationDb(':memory:')
|
||||
const SELF_LINE = `\nYour Orca session ID is: ${CHAT_ADDRESS}`
|
||||
|
||||
const previousEnvironment = hasAppEnvironment() ? getAppEnvironment() : null
|
||||
|
||||
afterEach(() => {
|
||||
sent.preambles = []
|
||||
if (previousEnvironment) {
|
||||
setAppEnvironment(previousEnvironment)
|
||||
}
|
||||
@@ -61,52 +44,6 @@ function installApp(isPackaged: boolean): void {
|
||||
} satisfies AppEnvironment)
|
||||
}
|
||||
|
||||
function runtime(prompts: string[]): OrcaRuntimeService {
|
||||
const fake: Pick<
|
||||
OrcaRuntimeService,
|
||||
'getNestedWorkerMaxDepth' | 'getTerminalOrchestrationCliCommand' | 'sendTerminalAgentPrompt'
|
||||
> & {
|
||||
orchestrationSenderNames: Pick<OrcaRuntimeService['orchestrationSenderNames'], 'snapshotOf'>
|
||||
} = {
|
||||
orchestrationSenderNames: { snapshotOf: () => ({ name: null, agent: null }) },
|
||||
getNestedWorkerMaxDepth: () => 2,
|
||||
getTerminalOrchestrationCliCommand: () => 'orca',
|
||||
sendTerminalAgentPrompt: async (handle, text) => {
|
||||
prompts.push(text)
|
||||
return { handle, accepted: true, bytesWritten: text.length }
|
||||
}
|
||||
}
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: preamble delivery reads only the members the fake implements.
|
||||
return fake as OrcaRuntimeService
|
||||
}
|
||||
|
||||
type StructuredSession = Parameters<typeof deliverWorkerDispatchPreamble>[0]['structuredSession']
|
||||
|
||||
function structuredSession(): StructuredSession {
|
||||
const session = { host: {}, identity: { sessionId: CHAT_SESSION, agent: 'codex' } }
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: delivery reads only identity.sessionId, and the mocked send ignores host.
|
||||
return session as unknown as StructuredSession
|
||||
}
|
||||
|
||||
async function renderPreamble(worker: 'chat' | 'terminal'): Promise<string> {
|
||||
const prompts: string[] = []
|
||||
await deliverWorkerDispatchPreamble({
|
||||
runtime: runtime(prompts),
|
||||
db,
|
||||
structuredSession: worker === 'chat' ? structuredSession() : null,
|
||||
terminalHandle: worker === 'chat' ? CHAT_WORKER_HANDLE : TERMINAL_HANDLE,
|
||||
dispatchId: 'ctx_1',
|
||||
dispatchDepth: 1,
|
||||
runId: 'run_1',
|
||||
taskId: 'task_1',
|
||||
taskSpec: 'do it',
|
||||
coordinatorHandle: 'term_coord',
|
||||
devMode: false,
|
||||
requestId: 'req_1'
|
||||
})
|
||||
return worker === 'chat' ? sent.preambles[0]! : prompts[0]!
|
||||
}
|
||||
|
||||
/** Render what the structured lane hands a chat for one mailbox message. */
|
||||
async function renderChatMail(mailbox: string): Promise<{ text: string; id: string }> {
|
||||
const texts: string[] = []
|
||||
@@ -138,27 +75,6 @@ async function renderChatMail(mailbox: string): Promise<{ text: string; id: stri
|
||||
}
|
||||
|
||||
describe('a chat agent and a terminal agent see the same text but for how each is named and called', () => {
|
||||
it("teaches a chat worker a terminal worker's preamble but for its identity lines and the chat wording", async () => {
|
||||
const chat = await renderPreamble('chat')
|
||||
const terminal = await renderPreamble('terminal')
|
||||
const withoutRedispatch = chat.replace(CHAT_REDISPATCH, TERMINAL_REDISPATCH)
|
||||
|
||||
expect(terminal).not.toContain('Orca session ID')
|
||||
expect(chat).toContain(`Your task ID is: task_1${SELF_LINE}\n`)
|
||||
expect(chat).not.toContain(CHAT_WORKER_HANDLE)
|
||||
expect(chat).not.toContain('this terminal')
|
||||
expect(withoutRedispatch).not.toBe(chat)
|
||||
expect(withoutRedispatch.split('this chat')).toHaveLength(2)
|
||||
expect(
|
||||
withoutRedispatch
|
||||
.replace(SELF_LINE, '')
|
||||
.split(CHAT_ADDRESS)
|
||||
.join(TERMINAL_HANDLE)
|
||||
.split('this chat')
|
||||
.join('this terminal')
|
||||
).toBe(terminal)
|
||||
})
|
||||
|
||||
it.each([
|
||||
['a packaged app', true],
|
||||
['a dev build', false]
|
||||
|
||||
@@ -62,7 +62,6 @@ describe('opening a terminal worker Orca closed', () => {
|
||||
}
|
||||
|
||||
const open = (address: string) => h.call('orchestration.partyOpen', { address })
|
||||
const locate = (address: string) => h.call('orchestration.partyLocation', { address })
|
||||
|
||||
it('resumes its conversation in a new terminal, and a click after it reports reuses it', async () => {
|
||||
const { resource, resume, report } = await closedHelper()
|
||||
@@ -83,18 +82,6 @@ describe('opening a terminal worker Orca closed', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps the lookup answer without a saved conversation', async () => {
|
||||
const { resource, resume } = await closedHelper(false)
|
||||
expect(await open(resource.terminal_handle)).toEqual(await locate(resource.terminal_handle))
|
||||
expect(resume).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps the lookup answer when the conversation was recorded on another host', async () => {
|
||||
const { resource, resume } = await closedHelper()
|
||||
resume.mockResolvedValue({ ok: false, reason: 'no_resume_command' })
|
||||
expect(await open(resource.terminal_handle)).toEqual(await locate(resource.terminal_handle))
|
||||
})
|
||||
|
||||
it('sends a later follow-up into the reopened terminal, not a second one', async () => {
|
||||
const { helper, resource, resume, report } = await closedHelper()
|
||||
await open(resource.terminal_handle)
|
||||
|
||||
+1
-57
@@ -1,8 +1,6 @@
|
||||
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
|
||||
import { createOrchestrationWorkerReleaseHarness } from './worker-release.test-support'
|
||||
import { TERMINAL_SEND_METHODS } from '../../terminal/terminal-send-method'
|
||||
import { sendTerminalStreamInput } from '../../terminal/terminal-input-delivery'
|
||||
import { eraseRpcMethods, isStreamingMethod, type RpcMethod } from '../../../core'
|
||||
|
||||
import { registerBoundPty } from '../../../../bind-first-pane.test-fixture'
|
||||
|
||||
const h = createOrchestrationWorkerReleaseHarness()
|
||||
@@ -61,60 +59,6 @@ it('an unknown handle does not fence another worker or access the database', asy
|
||||
).resolves.toMatchObject({ state: 'released' })
|
||||
})
|
||||
|
||||
it.each(['unary', 'stream'])('mobile %s bytes do no orchestration database work', async (lane) => {
|
||||
const worker = await h.startSettledWorker()
|
||||
const runtime = h.runtime
|
||||
runtime.registerPreAllocatedHandleForPty('pty-worker', 'term_worker')
|
||||
registerBoundPty(runtime, 'pty-worker', 'repo::worktree', null, {
|
||||
tabId: 'tab_worker',
|
||||
leafId: 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb',
|
||||
incarnationId: 'runtime_test:term_worker:1'
|
||||
})
|
||||
const write = vi.fn(() => true)
|
||||
runtime.setPtyController({ write, kill: () => true, getForegroundProcess: async () => null })
|
||||
const commit = vi.fn(async () => {})
|
||||
vi.spyOn(runtime, 'beginMobileInputFloor').mockReturnValue({ commit, rollback: vi.fn() })
|
||||
const dbAccess = vi.spyOn(runtime, 'getOrchestrationDb')
|
||||
const prepare = vi.spyOn(h.db.db, 'prepare')
|
||||
const exec = vi.spyOn(h.db.db, 'exec')
|
||||
const params = {
|
||||
terminal: 'term_worker',
|
||||
text: 'x',
|
||||
client: { id: 'phone', type: 'mobile' as const }
|
||||
}
|
||||
if (lane === 'stream') {
|
||||
await expect(sendTerminalStreamInput(runtime, { ...params, isMobile: true })).resolves.toBe(
|
||||
'delivered'
|
||||
)
|
||||
} else {
|
||||
const method = eraseRpcMethods(TERMINAL_SEND_METHODS).find(
|
||||
(m): m is RpcMethod => m.name === 'terminal.send' && !isStreamingMethod(m)
|
||||
)!
|
||||
await expect(
|
||||
method.handler(method.params!.parse(params) as never, { runtime } as never)
|
||||
).resolves.toMatchObject({ send: { accepted: true } })
|
||||
}
|
||||
expect(write).toHaveBeenCalledWith('pty-worker', 'x', 'driving')
|
||||
expect(commit).toHaveBeenCalledTimes(1)
|
||||
expect(dbAccess).not.toHaveBeenCalled()
|
||||
expect(prepare).not.toHaveBeenCalled()
|
||||
expect(exec).not.toHaveBeenCalled()
|
||||
expect(h.db.getWorkerTerminalResourceByOwner(worker.dispatchId)?.ownership_state).toBe('owned')
|
||||
})
|
||||
|
||||
it('Keep open and the countdown are reachable from a mobile-scoped device token', async () => {
|
||||
// An older phone's typing report must keep resolving too, or it would surface errors.
|
||||
const { MOBILE_RPC_METHOD_ALLOWLIST } =
|
||||
await import('../../../../runtime-rpc/runtime-rpc-mobile-method-allowlist')
|
||||
for (const method of [
|
||||
'orchestration.workerAutoClose',
|
||||
'orchestration.workerKeepOpen',
|
||||
'orchestration.workerTerminalUserInput'
|
||||
]) {
|
||||
expect(MOBILE_RPC_METHOD_ALLOWLIST.has(method)).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
// Boot-wait Keep open must find custody written at terminal creation, or a later release closes a kept pane (#19337, #19608).
|
||||
it('a phone Keep open during the boot wait keeps the pane and fences the later release', async () => {
|
||||
const gate = h.deferred<unknown>()
|
||||
|
||||
@@ -143,39 +143,4 @@ describe('stored reference lookup', () => {
|
||||
expect(result.matches[1].agents[0].terminal).toBe('remote-handle')
|
||||
expect(result.matches[1].workspace.hostId).toBe('ssh:build')
|
||||
})
|
||||
|
||||
it('handles 1000 workspaces with 23 references each without discovering agents for misses', async () => {
|
||||
const workspaces = Array.from({ length: 1000 }, (_, i) =>
|
||||
workspace(
|
||||
String(i),
|
||||
Array.from({ length: 23 }, (_, n) => `https://github.com/acme/api/pull/${i * 23 + n + 1}`)
|
||||
)
|
||||
)
|
||||
const agents = vi.fn(() => new Map())
|
||||
const source = { workspaces, agents }
|
||||
expect((await findWorkspaceReferences({ query: 'MISSING-1' }, source)).matches).toEqual([])
|
||||
expect(agents).not.toHaveBeenCalled()
|
||||
expect(
|
||||
(await findWorkspaceReferences({ query: 'https://github.com/acme/api/pull/23000' }, source))
|
||||
.matches[0].workspace.id
|
||||
).toBe('999')
|
||||
expect(agents).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it.each([0, -1, 1.5, Infinity])('rejects invalid limits %s', async (limit) => {
|
||||
await expect(
|
||||
findWorkspaceReferences({ query: linear, limit }, { workspaces: [], agents: () => new Map() })
|
||||
).rejects.toThrow('positive integer')
|
||||
})
|
||||
|
||||
it('rejects bare numbers and conflicting filters before inspecting agents', async () => {
|
||||
const source = { workspaces: [], agents: vi.fn(() => new Map()) }
|
||||
await expect(findWorkspaceReferences({ query: '5123' }, source)).rejects.toThrow(
|
||||
'full reference URL'
|
||||
)
|
||||
await expect(
|
||||
findWorkspaceReferences({ query: linear, worktree: 'one', repo: 'api' }, source)
|
||||
).rejects.toThrow('either --worktree or --repo')
|
||||
expect(source.agents).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -92,17 +92,6 @@ describe("a phone's layout change on a windowless server (switch on)", () => {
|
||||
)
|
||||
})
|
||||
|
||||
it("sends nothing for a group's last tab dropped on its neighbour's facing edge", async () => {
|
||||
const { runtime } = await headlessRuntime()
|
||||
const split = { kind: 'split', tabId: SECOND_TAB_ID, targetGroupId: SAVED_GROUP_ID } as const
|
||||
await runtime.moveMobileSessionTab(WORKTREE, { ...split, splitDirection: 'right' })
|
||||
const apply = vi.spyOn(runtime, 'applyLayoutCommand')
|
||||
|
||||
await runtime.moveMobileSessionTab(WORKTREE, { ...split, splitDirection: 'right' })
|
||||
|
||||
expect(apply).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("answers a split main refuses with main's refusal code", async () => {
|
||||
const { runtime } = await headlessRuntime()
|
||||
vi.spyOn(runtime, 'applyLayoutCommand').mockResolvedValue({ ok: false, code: 'invalid_params' })
|
||||
|
||||
@@ -17,12 +17,6 @@ describe('terminal run facts', () => {
|
||||
facts.recordSpawnCommit({ id: 'pending', incarnationId: 'inc-3' })
|
||||
expect(facts.read('pending', 'inc-3').firstUserInputAt).toBeNull()
|
||||
})
|
||||
it('reads a run main never saw committed as not fresh', () => {
|
||||
expect(new TerminalRunFactsRegister().read('pty-1', 'inc-1')).toEqual({
|
||||
freshSpawn: false,
|
||||
firstUserInputAt: null
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps the first user input across later input and a re-registration of the same process', () => {
|
||||
const facts = new TerminalRunFactsRegister()
|
||||
@@ -72,14 +66,6 @@ describe('terminal run facts', () => {
|
||||
expect(facts.readProcessStartedAt('pty-2', 'inc-9')).toBeNull()
|
||||
})
|
||||
|
||||
it('never reads a reattached process as fresh', () => {
|
||||
const facts = new TerminalRunFactsRegister()
|
||||
|
||||
facts.recordSpawnCommit({ id: 'pty-1', incarnationId: 'inc-1', isReattach: true })
|
||||
|
||||
expect(facts.read('pty-1', 'inc-1').freshSpawn).toBe(false)
|
||||
})
|
||||
|
||||
it('starts clean when a new process commits without an incarnation', () => {
|
||||
const facts = new TerminalRunFactsRegister()
|
||||
facts.recordSpawnCommit({ id: 'pty-1' })
|
||||
|
||||
@@ -105,19 +105,6 @@ for (const host of ['no window', 'a window'] as const) {
|
||||
expect(await phoneShowsPane(harness.runtime, HEADLESS_SECOND_LEAF_ID)).toBe(false)
|
||||
})
|
||||
|
||||
it(`the exit drops the closed-pane record in its one retirement write: ${route}, with ${host}`, async () => {
|
||||
const harness = closingHost(host)
|
||||
await close(harness.runtime)
|
||||
// The fixture's setter may already be a mock carrying the close's own writes.
|
||||
const writes = vi.spyOn(harness.runtime['store']!, 'setWorkspaceSession')
|
||||
writes.mockClear()
|
||||
|
||||
harness.runtime.onPtyExit('pty-b', 0, 'inc-b')
|
||||
|
||||
expect(writes).toHaveBeenCalledTimes(1)
|
||||
expect(writes.mock.calls[0]![0].closedTerminalPanesByPaneKey?.[PANE_B]).toBeUndefined()
|
||||
})
|
||||
|
||||
it(`exit then close retires them too: ${route}, with ${host}`, async () => {
|
||||
const harness = closingHost(host)
|
||||
|
||||
|
||||
@@ -1,183 +0,0 @@
|
||||
import '../../orca-runtime-test-mocks.spec'
|
||||
import { mkdtempSync, rmSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { expect, it, vi } from 'vitest'
|
||||
import { OrcaRuntimeService } from '../../orca-runtime'
|
||||
import { OrchestrationDb } from '../../orchestration/db'
|
||||
import {
|
||||
openTopologyStoreWithAuthority,
|
||||
emptyTerminalSessionProfile,
|
||||
FIXTURE_GIT_WORKTREE_ID
|
||||
} from '../../../persistence/terminal-topology/terminal-topology-profile-fixture'
|
||||
import { WindowSession } from '../../../persistence/terminal-topology/terminal-topology-window-session-fixture'
|
||||
import { getDefaultWorkspaceSession } from '../../../../shared/constants'
|
||||
import * as loader from '../../../../shared/workspace-layout/workspace-layout-load'
|
||||
import * as paneQuery from './pty-pane-query'
|
||||
import * as surfaceIndex from './terminal-surface-index'
|
||||
import type { ResolvedWorktree } from '../../runtime-worktree-path-identity'
|
||||
|
||||
vi.mock('../../../../shared/layout-owner-switch', () => ({ ORCA_LAYOUT_OWNER_IS_MAIN: true }))
|
||||
const leaf = (i: number) => `aaaaaaaa-aaaa-4aaa-8aaa-${String(i).padStart(12, '0')}`
|
||||
const pane = `tab-main-0:${leaf(0)}`
|
||||
|
||||
class DefaultPaneRuntime extends OrcaRuntimeService {
|
||||
protected override async getResolvedWorktreeMap(): Promise<Map<string, ResolvedWorktree>> {
|
||||
const worktree = this.buildResolvedWorktreeFromId(FIXTURE_GIT_WORKTREE_ID)
|
||||
return new Map(worktree ? [[worktree.id, worktree]] : [])
|
||||
}
|
||||
captures = 0
|
||||
protected captureTerminalPaneReader(): paneQuery.PtyPaneOf {
|
||||
this.captures++
|
||||
return super.captureTerminalPaneReader()
|
||||
}
|
||||
seed(id: string, handle: string, spawnPaneKey?: string, connected = true): void {
|
||||
this.recordPtyWorktree(id, FIXTURE_GIT_WORKTREE_ID, { connected, spawnPaneKey })
|
||||
this.terminalIdentity.bindPty(id, handle)
|
||||
this.terminalIdentity.setHandle(handle, {
|
||||
handle,
|
||||
runtimeId: this.runtimeId,
|
||||
rendererGraphEpoch: this.rendererGraphEpoch,
|
||||
worktreeId: FIXTURE_GIT_WORKTREE_ID,
|
||||
tabId: `pty:${id}`,
|
||||
leafId: `pty:${id}`,
|
||||
ptyId: id,
|
||||
ptyGeneration: 0
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async function fixture(population: number) {
|
||||
const directory = mkdtempSync(join(tmpdir(), 'orca-pr34890-finite-'))
|
||||
const { store } = await openTopologyStoreWithAuthority(directory, emptyTerminalSessionProfile())
|
||||
const db = new OrchestrationDb(':memory:')
|
||||
const saved = new WindowSession(getDefaultWorkspaceSession())
|
||||
for (let i = 0; i < population; i++) {
|
||||
saved.addTab(FIXTURE_GIT_WORKTREE_ID, `tab-main-${i}`, leaf(i))
|
||||
saved.bind(`tab-main-${i}`, leaf(i), `pty-main-${i}`)
|
||||
}
|
||||
store.setWorkspaceSession(saved.snapshot(), 'local')
|
||||
await store.flushPendingOrThrowAsync()
|
||||
const runtime = new DefaultPaneRuntime(store)
|
||||
runtime.setOrchestrationDb(db)
|
||||
runtime.seed('coord', 'term_coord')
|
||||
for (let i = 0; i < population; i++) {
|
||||
runtime.seed(`pty-main-${i}`, `term_main_${i}`)
|
||||
}
|
||||
runtime.seed('replacement', 'term_replacement')
|
||||
const run = db.createRun({
|
||||
objective: 'Finite acquisition',
|
||||
coordinatorHandle: 'term_coord',
|
||||
coordinatorPaneKey: 'coord-pane'
|
||||
})
|
||||
const task = db.createTask({ runId: run.id, spec: 'Main worker' })
|
||||
db.createDispatchContext({
|
||||
taskId: task.id,
|
||||
assigneeHandle: 'term_missing_stored',
|
||||
assigneePaneKey: pane,
|
||||
creator: { kind: 'system' },
|
||||
maxDepth: 10
|
||||
})
|
||||
const stop = runtime.watchOrchestrationCrew(() => {})
|
||||
const rebind = async () => {
|
||||
const next = structuredClone(store.getWorkspaceSession())
|
||||
next.terminalLayoutsByTabId['tab-main-0'].ptyIdsByLeafId = { [leaf(0)]: 'replacement' }
|
||||
store.setWorkspaceSession(next, 'local')
|
||||
await store.flushPendingOrThrowAsync()
|
||||
}
|
||||
return {
|
||||
runtime,
|
||||
rebind,
|
||||
close: async () => {
|
||||
stop()
|
||||
db.close()
|
||||
await store.freezeWritesAsync()
|
||||
rmSync(directory, { recursive: true, force: true })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
it('bounds the real public pane lookup and captured Crew read at N and 10N', async () => {
|
||||
vi.useFakeTimers({ toFake: ['Date'] })
|
||||
vi.setSystemTime(new Date('2026-10-29T12:00:00Z'))
|
||||
try {
|
||||
for (const population of [3, 30]) {
|
||||
const f = await fixture(population)
|
||||
try {
|
||||
const loads = vi.spyOn(loader, 'loadWorkspaceLayoutWithoutReport')
|
||||
const capture = vi.spyOn(paneQuery, 'captureMainTerminalPaneLookup')
|
||||
const qualify = vi.spyOn(surfaceIndex, 'terminalHoldsPane')
|
||||
const measure = (read: () => unknown) => {
|
||||
loads.mockClear()
|
||||
capture.mockClear()
|
||||
qualify.mockClear()
|
||||
const value = read()
|
||||
return {
|
||||
value,
|
||||
loads: loads.mock.calls.length,
|
||||
surfaceCandidates: capture.mock.calls.reduce(
|
||||
(sum, [surfaces]) => sum + surfaces.leaves.length,
|
||||
0
|
||||
),
|
||||
factCopies: capture.mock.calls.reduce(
|
||||
(sum, [surfaces]) => sum + surfaces.paneFacts.size,
|
||||
0
|
||||
),
|
||||
qualificationCalls: qualify.mock.calls.length
|
||||
}
|
||||
}
|
||||
expect(f.runtime.getTerminalHandleForPaneKey(pane)).toBe('term_main_0')
|
||||
f.runtime.getOrchestrationCrewSnapshot('term_coord')
|
||||
await f.rebind()
|
||||
const next = measure(() => f.runtime.getTerminalHandleForPaneKey(pane))
|
||||
expect(next.value).toBe('term_replacement')
|
||||
expect(next.loads).toBe(2)
|
||||
expect(next.surfaceCandidates).toBe(population)
|
||||
expect(next.factCopies).toBe(population)
|
||||
expect(next.qualificationCalls).toBeLessThanOrEqual(population + 3)
|
||||
const crew = measure(() => f.runtime.getOrchestrationCrewSnapshot('term_coord'))
|
||||
expect(crew.loads).toBe(1)
|
||||
expect(crew.surfaceCandidates).toBe(population)
|
||||
loads.mockRestore()
|
||||
capture.mockRestore()
|
||||
qualify.mockRestore()
|
||||
} finally {
|
||||
await f.close()
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps absent and exact/reminted connected public points outside global capture', () => {
|
||||
const runtime = new DefaultPaneRuntime(null)
|
||||
expect(runtime.getTerminalHandleForPaneKey(pane)).toBeNull()
|
||||
expect(runtime.captures).toBe(0)
|
||||
runtime.seed('fast', 'term_fast')
|
||||
runtime.attachWindow(1)
|
||||
runtime.syncWindowGraph(1, {
|
||||
tabs: [
|
||||
{
|
||||
tabId: 'tab-main-0',
|
||||
worktreeId: FIXTURE_GIT_WORKTREE_ID,
|
||||
title: 'Terminal',
|
||||
activeLeafId: leaf(0),
|
||||
layout: null
|
||||
}
|
||||
],
|
||||
leaves: [
|
||||
{
|
||||
tabId: 'tab-main-0',
|
||||
worktreeId: FIXTURE_GIT_WORKTREE_ID,
|
||||
leafId: leaf(0),
|
||||
ptyId: 'fast',
|
||||
paneRuntimeId: 1
|
||||
}
|
||||
]
|
||||
})
|
||||
const before = runtime.captures
|
||||
expect(runtime.getTerminalHandleForPaneKey(pane)).toBe('term_fast')
|
||||
expect(runtime.getTerminalHandleForPaneKey(`tab-moved:${leaf(0)}`)).toBe('term_fast')
|
||||
expect(runtime.captures).toBe(before)
|
||||
})
|
||||
@@ -14,7 +14,7 @@ import { installSharedSkill } from './skill-install-service'
|
||||
import { createSkillBundleArchive } from './skill-bundle-creation'
|
||||
import { installSkillBundle } from './skill-bundle-install-service'
|
||||
import { writeSkillTarGzip } from './skill-package-tar'
|
||||
import { detectSkillProvidersInWsl } from './skill-wsl-provider-detection'
|
||||
|
||||
import { createWslSkillInstallFilesystem } from './skill-wsl-install-filesystem'
|
||||
|
||||
const execFileAsync = promisify(execFile)
|
||||
@@ -66,10 +66,6 @@ describe.runIf(RUN_REAL_WSL)('real WSL POSIX skill semantics', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('detects providers from the selected distro', async () => {
|
||||
await expect(detectSkillProvidersInWsl(DISTRO)).resolves.toContain('codex')
|
||||
})
|
||||
|
||||
it('preserves case and applies owner-private executable modes', async () => {
|
||||
const markdown = Buffer.from(
|
||||
'---\nname: wsl-mode-skill\ndescription: WSL modes\n---\n\n# WSL\n'
|
||||
|
||||
@@ -119,12 +119,6 @@ describe('orcad migration blob sender', () => {
|
||||
).rejects.toThrow('orcad_migration_blob_descriptor_conflict:terminal-restore-hints')
|
||||
expect(destination.stageChunk).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('sends nothing and asks nothing when there are no blobs', async () => {
|
||||
const destination = realDestination([])
|
||||
await transferOrcadMigrationBlobs({ source: source(), manifest, blobs: [], destination })
|
||||
expect(destination.readCapabilities).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
function descriptor(
|
||||
|
||||
@@ -92,16 +92,6 @@ it('attaches the alert to the visible main window and explains an unconfirmed wr
|
||||
expect(recordProfileStateWriteFailureReport).toHaveBeenCalledWith(workerExit, 'dialog')
|
||||
})
|
||||
|
||||
it('tells the user saved changes are safe when no write was left unresolved', async () => {
|
||||
fixture.state.mainWindow = new FakeWindow(true)
|
||||
reportProfileStateWriteFailure(conflict)
|
||||
await vi.waitFor(() => expect(fixture.show).toHaveBeenCalledOnce())
|
||||
expect(fixture.show.mock.calls[0][1]).toMatchObject({
|
||||
detail:
|
||||
'Changes saved before this point are safe. New changes will not be saved until you restart Orca.'
|
||||
})
|
||||
})
|
||||
|
||||
it('waits for a hidden window to be shown by the user instead of revealing it', async () => {
|
||||
const window = new FakeWindow(false)
|
||||
fixture.state.mainWindow = window
|
||||
|
||||
@@ -100,39 +100,6 @@ describe('generateBranchNameFromContext', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('does not persist stdout-only branch failure detail that may echo the prompt', async () => {
|
||||
const result = await generateBranchNameFromContext(
|
||||
{ firstPrompt: 'Customer secret in the first prompt' },
|
||||
{
|
||||
agentId: 'custom',
|
||||
model: '',
|
||||
customAgentCommand: 'agent'
|
||||
},
|
||||
{
|
||||
kind: 'remote',
|
||||
cwd: '/repo',
|
||||
missingBinaryLocation: 'remote PATH',
|
||||
execute: async () => ({
|
||||
stdout: 'Customer secret in the first prompt',
|
||||
stderr: '',
|
||||
exitCode: 1,
|
||||
timedOut: false
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
expect(result).toMatchObject({
|
||||
success: false,
|
||||
error: 'agent CLI command failed with code 1.',
|
||||
failureOutput: {
|
||||
label: 'agent',
|
||||
exitCode: 1,
|
||||
stdout: 'Customer secret in the first prompt',
|
||||
stderr: ''
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it('describes a signal-terminated generator without a null exit code', async () => {
|
||||
const result = await generateBranchNameFromContext(
|
||||
{ firstPrompt: 'Fix login flow' },
|
||||
@@ -159,40 +126,6 @@ describe('generateBranchNameFromContext', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps branch-name guidance first without dropping the output contract', async () => {
|
||||
let prompt = ''
|
||||
await generateBranchNameFromContext(
|
||||
{ firstPrompt: 'Fix login flow' },
|
||||
{
|
||||
agentId: 'custom',
|
||||
model: '',
|
||||
customAgentCommand: 'agent',
|
||||
customPrompt: 'Prefer auth terminology.',
|
||||
commandInputTemplate: 'Prefer auth terminology.\n\n{basePrompt}'
|
||||
},
|
||||
{
|
||||
kind: 'remote',
|
||||
cwd: '/repo',
|
||||
missingBinaryLocation: 'remote PATH',
|
||||
execute: async (plan) => {
|
||||
prompt = plan.stdinPayload ?? ''
|
||||
return {
|
||||
stdout: 'fix-login-flow\n',
|
||||
stderr: '',
|
||||
exitCode: 0,
|
||||
timedOut: false
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
expect(prompt.startsWith('Prefer auth terminology.')).toBe(true)
|
||||
expect(prompt).toContain('Prefer auth terminology.')
|
||||
expect(prompt).not.toContain('Additional user prompt:')
|
||||
expect(prompt).toContain('Generate a short git branch name')
|
||||
expect(prompt).toContain('Output ONLY the branch name on a single line')
|
||||
})
|
||||
|
||||
it('reruns Copilot once on its default model when the plan hides the saved model', async () => {
|
||||
const calls: string[][] = []
|
||||
const result = await generateBranchNameFromContext(
|
||||
|
||||
@@ -139,21 +139,6 @@ describe('updater linux package recovery IPC handlers', () => {
|
||||
vi.unstubAllEnvs()
|
||||
})
|
||||
|
||||
it('removes and re-registers both recovery channels', () => {
|
||||
for (const channel of RECOVERY_CHANNELS) {
|
||||
expect(removeHandlerMock).toHaveBeenCalledWith(channel)
|
||||
expect(handleMock.mock.calls.filter(([name]) => name === channel)).toHaveLength(1)
|
||||
}
|
||||
})
|
||||
|
||||
it('routes both channels through the trusted-renderer guard', () => {
|
||||
isTrustedUIRendererMock.mockReturnValue(false)
|
||||
|
||||
expectBothChannelsRejected(webContents({}))
|
||||
|
||||
expect(isTrustedUIRendererMock).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('serves the current main UI renderer', async () => {
|
||||
const event = senderEvent(webContents({}))
|
||||
|
||||
|
||||
@@ -64,19 +64,6 @@ describe('terminatePtyJob', () => {
|
||||
expect(terminatePtyJob(ptyWithHandle(id))).toBe('unavailable')
|
||||
})
|
||||
|
||||
it('passes the shell pid so an id from another backend cannot match', () => {
|
||||
// winpty/ConPTY IDs can collide; always send shell PID so native code can refuse mismatches.
|
||||
const terminateJob = vi.fn().mockReturnValue(true)
|
||||
__setConptyJobNativeForTests(() => ({
|
||||
terminateJob,
|
||||
listJobProcessIds: vi.fn(),
|
||||
assignCurrentProcessToJob: vi.fn().mockReturnValue(true)
|
||||
}))
|
||||
|
||||
terminatePtyJob(ptyWithHandle(3, 9001))
|
||||
expect(terminateJob).toHaveBeenCalledWith(3, 9001)
|
||||
})
|
||||
|
||||
it('reports unavailable when the shell pid is unusable', () => {
|
||||
__setConptyJobNativeForTests(() => ({
|
||||
terminateJob: vi.fn().mockReturnValue(true),
|
||||
@@ -106,16 +93,6 @@ describe('terminatePtyJob', () => {
|
||||
})
|
||||
|
||||
describe('listPtyJobProcessIds', () => {
|
||||
it('returns the live pids, including a detached grandchild', () => {
|
||||
// Detached/reparented grandchildren escape console and parent-PID walks but remain job members.
|
||||
__setConptyJobNativeForTests(() => ({
|
||||
terminateJob: vi.fn(),
|
||||
listJobProcessIds: vi.fn().mockReturnValue([107184, 91480]),
|
||||
assignCurrentProcessToJob: vi.fn().mockReturnValue(true)
|
||||
}))
|
||||
expect(listPtyJobProcessIds(ptyWithHandle(1))).toEqual([107184, 91480])
|
||||
})
|
||||
|
||||
it('reports null when there is no answer to give', () => {
|
||||
// Null is unverifiable, including after shell exit closes the job; it never proves death.
|
||||
__setConptyJobNativeForTests(() => null)
|
||||
@@ -131,16 +108,6 @@ describe('listPtyJobProcessIds', () => {
|
||||
})
|
||||
|
||||
describe('assignHostProcessToKillOnCloseJob', () => {
|
||||
it('reports whether the host process is now kill-on-close', () => {
|
||||
const assignCurrentProcessToJob = vi.fn().mockReturnValue(true)
|
||||
__setConptyJobNativeForTests(() => ({
|
||||
terminateJob: vi.fn(),
|
||||
listJobProcessIds: vi.fn(),
|
||||
assignCurrentProcessToJob
|
||||
}))
|
||||
expect(assignHostProcessToKillOnCloseJob()).toBe(true)
|
||||
})
|
||||
|
||||
it('degrades to false when the OS refuses the assignment', () => {
|
||||
// An outer job that forbids nesting; orphan behaviour is then simply what
|
||||
// it was before, rather than a crash.
|
||||
@@ -165,24 +132,6 @@ describe('PTY Job Object teardown breadcrumbs', () => {
|
||||
}))
|
||||
}
|
||||
|
||||
it('records the shell pid whose job it terminated', () => {
|
||||
withNative(() => true)
|
||||
|
||||
expect(terminatePtyJob(ptyWithHandle(7, 4242))).toBe('terminated')
|
||||
expect(recordSelfInitiatedTreeKillMock).toHaveBeenCalledWith({
|
||||
pid: 4242,
|
||||
site: 'windows-pty-job-teardown',
|
||||
scope: 'win-pty-job'
|
||||
})
|
||||
})
|
||||
|
||||
it('records nothing when the native side refused', () => {
|
||||
withNative(() => false)
|
||||
|
||||
expect(terminatePtyJob(ptyWithHandle(7))).toBe('unavailable')
|
||||
expect(recordSelfInitiatedTreeKillMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('never downgrades a real termination to unavailable because diagnostics failed', () => {
|
||||
// Breadcrumb failure must not downgrade termination to unavailable and escalate taskkill.
|
||||
withNative(() => true)
|
||||
|
||||
@@ -52,16 +52,6 @@ beforeEach(() => {
|
||||
})
|
||||
|
||||
describe('isWorkspaceNotificationMutedBySidebarFilters', () => {
|
||||
it('mutes a workspace a Filters switch hides while the setting is off', () => {
|
||||
expect(muted(CLI_ID)).toBe(true)
|
||||
expect(muted(PLAIN_ID)).toBe(false)
|
||||
})
|
||||
|
||||
it('mutes nothing when Notify about hidden workspaces is on', () => {
|
||||
setNotifyHiddenWorkspaces(true)
|
||||
expect(muted(CLI_ID)).toBe(false)
|
||||
})
|
||||
|
||||
it('fails open before settings or UI state hydrate, and for an unknown workspace', () => {
|
||||
expect(muted('repo1::/tmp/unknown')).toBe(false)
|
||||
store.setState({ persistedUIReady: false })
|
||||
@@ -133,11 +123,6 @@ describe('isWorkspaceNotificationMutedBySidebarFilters', () => {
|
||||
store.setState({ hideWorkspacesFromOtherDevices: true })
|
||||
expect(muted(folderWorkspaceKey('f1'))).toBe(true)
|
||||
})
|
||||
|
||||
it('ignores the project filter, which is not a Filters switch', () => {
|
||||
store.setState({ hideCliCreatedWorkspaces: false, filterRepoIds: ['other-repo'] })
|
||||
expect(muted(PLAIN_ID)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('muted workspace attention', () => {
|
||||
|
||||
@@ -1,223 +0,0 @@
|
||||
import type * as ReactModule from 'react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const reactHookRuntime = vi.hoisted(() => ({
|
||||
states: [] as unknown[],
|
||||
index: 0
|
||||
}))
|
||||
|
||||
vi.mock('react', async () => {
|
||||
const actual = await vi.importActual<typeof ReactModule>('react')
|
||||
return {
|
||||
...actual,
|
||||
useMemo<T>(factory: () => T) {
|
||||
return factory()
|
||||
},
|
||||
useCallback<T extends (...args: never[]) => unknown>(callback: T) {
|
||||
return callback
|
||||
},
|
||||
useEffect() {
|
||||
return undefined
|
||||
},
|
||||
useContext() {
|
||||
return false
|
||||
},
|
||||
useRef<T>(initial: T) {
|
||||
return { current: initial }
|
||||
},
|
||||
useState<T>(initial: T | (() => T)) {
|
||||
const stateIndex = reactHookRuntime.index++
|
||||
if (!(stateIndex in reactHookRuntime.states)) {
|
||||
reactHookRuntime.states[stateIndex] =
|
||||
typeof initial === 'function' ? (initial as () => T)() : initial
|
||||
}
|
||||
const setState = (next: T | ((previous: T) => T)): void => {
|
||||
reactHookRuntime.states[stateIndex] =
|
||||
typeof next === 'function'
|
||||
? (next as (previous: T) => T)(reactHookRuntime.states[stateIndex] as T)
|
||||
: next
|
||||
}
|
||||
return [reactHookRuntime.states[stateIndex] as T, setState] as const
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('lucide-react', () => ({
|
||||
Image: function Image(props: Record<string, unknown>) {
|
||||
return { type: 'Image', props }
|
||||
},
|
||||
RotateCcw: function RotateCcw(props: Record<string, unknown>) {
|
||||
return { type: 'RotateCcw', props }
|
||||
},
|
||||
X: function X(props: Record<string, unknown>) {
|
||||
return { type: 'X', props }
|
||||
},
|
||||
ZoomIn: function ZoomIn(props: Record<string, unknown>) {
|
||||
return { type: 'ZoomIn', props }
|
||||
},
|
||||
ZoomOut: function ZoomOut(props: Record<string, unknown>) {
|
||||
return { type: 'ZoomOut', props }
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/components/ui/dialog', () => ({
|
||||
Dialog: function Dialog(props: { children?: unknown }) {
|
||||
return { type: 'Dialog', props }
|
||||
},
|
||||
DialogContent: function DialogContent(props: { children?: unknown }) {
|
||||
return { type: 'DialogContent', props }
|
||||
},
|
||||
DialogDescription: function DialogDescription(props: { children?: unknown }) {
|
||||
return { type: 'DialogDescription', props }
|
||||
},
|
||||
DialogTitle: function DialogTitle(props: { children?: unknown }) {
|
||||
return { type: 'DialogTitle', props }
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('./PdfViewer', () => ({
|
||||
default: function PdfViewer(props: Record<string, unknown>) {
|
||||
return { type: 'PdfViewer', props }
|
||||
}
|
||||
}))
|
||||
|
||||
type ReactElementLike = {
|
||||
type: unknown
|
||||
props: Record<string, unknown>
|
||||
}
|
||||
|
||||
function expandNode(node: unknown): unknown {
|
||||
if (node == null || typeof node === 'string' || typeof node === 'number') {
|
||||
return node
|
||||
}
|
||||
if (Array.isArray(node)) {
|
||||
return node.map(expandNode)
|
||||
}
|
||||
const el = node as ReactElementLike
|
||||
if (typeof el.type === 'function') {
|
||||
return expandNode(el.type(el.props))
|
||||
}
|
||||
return {
|
||||
...el,
|
||||
props: {
|
||||
...el.props,
|
||||
children: expandNode(el.props?.children)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function findElementsByType(node: unknown, typeName: string): ReactElementLike[] {
|
||||
const results: ReactElementLike[] = []
|
||||
const visit = (current: unknown): void => {
|
||||
if (current == null || typeof current === 'string' || typeof current === 'number') {
|
||||
return
|
||||
}
|
||||
if (Array.isArray(current)) {
|
||||
for (const child of current) {
|
||||
visit(child)
|
||||
}
|
||||
return
|
||||
}
|
||||
const el = current as ReactElementLike
|
||||
if (el.type === typeName) {
|
||||
results.push(el)
|
||||
}
|
||||
visit(el.props?.children)
|
||||
}
|
||||
visit(node)
|
||||
return results
|
||||
}
|
||||
|
||||
function findPreviewImage(node: unknown): ReactElementLike {
|
||||
const image = findElementsByType(node, 'img').find((element) => element.props.onError)
|
||||
if (!image) {
|
||||
throw new Error('preview image not found')
|
||||
}
|
||||
return image
|
||||
}
|
||||
|
||||
async function renderExpandedImageViewer(content: string): Promise<unknown> {
|
||||
reactHookRuntime.index = 0
|
||||
const module = await import('./ImageViewer')
|
||||
return expandNode(
|
||||
module.default({
|
||||
content,
|
||||
filePath: '/repo/preview.png',
|
||||
mimeType: 'image/png'
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
function pngBase64(width: number): string {
|
||||
const bytes = Buffer.alloc(24)
|
||||
Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]).copy(bytes)
|
||||
bytes.writeUInt32BE(13, 8)
|
||||
bytes.write('IHDR', 12, 'ascii')
|
||||
bytes.writeUInt32BE(width, 16)
|
||||
bytes.writeUInt32BE(1, 20)
|
||||
return bytes.toString('base64')
|
||||
}
|
||||
|
||||
describe('ImageViewer preview source retry', () => {
|
||||
beforeEach(() => {
|
||||
reactHookRuntime.states = []
|
||||
reactHookRuntime.index = 0
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('retries an earlier failed source after a later source loads successfully', async () => {
|
||||
const failedContent = pngBase64(1)
|
||||
const loadedContent = pngBase64(2)
|
||||
|
||||
const firstRender = await renderExpandedImageViewer(failedContent)
|
||||
const firstImage = findPreviewImage(firstRender)
|
||||
expect(firstImage.props.src).toBe(`data:image/png;base64,${failedContent}`)
|
||||
|
||||
;(firstImage.props.onError as () => void)()
|
||||
const failedRender = await renderExpandedImageViewer(failedContent)
|
||||
expect(findElementsByType(failedRender, 'Image')).toHaveLength(1)
|
||||
expect(findElementsByType(failedRender, 'img')).toHaveLength(0)
|
||||
|
||||
const loadedRender = await renderExpandedImageViewer(loadedContent)
|
||||
const loadedImage = findPreviewImage(loadedRender)
|
||||
;(
|
||||
loadedImage.props.onLoad as (event: {
|
||||
currentTarget: { naturalWidth: number; naturalHeight: number }
|
||||
}) => void
|
||||
)({ currentTarget: { naturalWidth: 12, naturalHeight: 10 } })
|
||||
|
||||
const retryRender = await renderExpandedImageViewer(failedContent)
|
||||
const retryImage = findPreviewImage(retryRender)
|
||||
expect(retryImage.props.src).toBe(`data:image/png;base64,${failedContent}`)
|
||||
})
|
||||
|
||||
it('shows a failure instead of loading an unsafe raster forever', async () => {
|
||||
const rendered = await renderExpandedImageViewer(pngBase64(32_769))
|
||||
|
||||
expect(findElementsByType(rendered, 'Image')).toHaveLength(1)
|
||||
expect(findElementsByType(rendered, 'img')).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('ImageViewer reading its bytes by URL', () => {
|
||||
beforeEach(() => {
|
||||
reactHookRuntime.states = []
|
||||
reactHookRuntime.index = 0
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('shows an image from its orca-media URL, sized from the grant', async () => {
|
||||
reactHookRuntime.index = 0
|
||||
const module = await import('./ImageViewer')
|
||||
const rendered = expandNode(
|
||||
module.default({
|
||||
src: 'orca-media://file/token?revision=r1',
|
||||
byteSize: 2048,
|
||||
filePath: '/repo/preview.png',
|
||||
mimeType: 'image/png'
|
||||
})
|
||||
)
|
||||
expect(findPreviewImage(rendered).props.src).toBe('orca-media://file/token?revision=r1')
|
||||
expect(JSON.stringify(rendered)).toContain('2.0 KB')
|
||||
})
|
||||
})
|
||||
-11
@@ -195,15 +195,4 @@ describe('combined diff word-wrap lifecycle', () => {
|
||||
|
||||
expect(syncWordWrap).toHaveBeenLastCalledWith(mountedEditors[1]?.editor, false)
|
||||
})
|
||||
|
||||
// The Monaco wrapper calls updateOptions on every options identity change, once per mounted section.
|
||||
it('keeps editor options stable across re-renders that change no option', () => {
|
||||
const view = render(<DiffSectionBody {...props} />)
|
||||
view.rerender(<DiffSectionBody {...props} onMount={vi.fn()} onRetrySection={vi.fn()} />)
|
||||
expect(renderedOptions.at(-1)).toBe(renderedOptions[0])
|
||||
|
||||
view.rerender(<DiffSectionBody {...props} sideBySide={false} />)
|
||||
expect(renderedOptions.at(-1)).not.toBe(renderedOptions[0])
|
||||
expect(renderedOptions.at(-1)).toMatchObject({ renderSideBySide: false })
|
||||
})
|
||||
})
|
||||
|
||||
-31
@@ -48,14 +48,6 @@ describe('markdown preview search highlighting keeps react-owned DOM intact (cra
|
||||
return body
|
||||
}
|
||||
|
||||
it('applies highlights without mutating the react-owned subtree', () => {
|
||||
const body = render(['hello world'])
|
||||
const matches = applyMarkdownPreviewSearchHighlights(instance, body, 'lo')
|
||||
expect(matches.length).toBe(1)
|
||||
expect(body.querySelector('mark')).toBeNull()
|
||||
expect(body.textContent).toBe('hello world')
|
||||
})
|
||||
|
||||
it('survives a streamed re-render while highlights are active', () => {
|
||||
const body = render(['alpha ', 'beta'])
|
||||
applyMarkdownPreviewSearchHighlights(instance, body, 'beta')
|
||||
@@ -108,15 +100,6 @@ describe('markdown preview search painting with the CSS Custom Highlight API', (
|
||||
return div
|
||||
}
|
||||
|
||||
it('paints many matches without a constructor-arg spread', () => {
|
||||
const instance = {}
|
||||
const body = bodyWithText('a a a a a a a a')
|
||||
expect(() => applyMarkdownPreviewSearchHighlights(instance, body, 'a')).not.toThrow()
|
||||
expect(registry.entries.get(SEARCH_HIGHLIGHT_NAME)?.ranges.size).toBe(8)
|
||||
clearMarkdownPreviewSearchHighlights(instance)
|
||||
body.remove()
|
||||
})
|
||||
|
||||
it('unions highlights across previews so a second Find does not clobber the first', () => {
|
||||
const a = {}
|
||||
const b = {}
|
||||
@@ -140,20 +123,6 @@ describe('markdown preview search painting with the CSS Custom Highlight API', (
|
||||
bodyB.remove()
|
||||
})
|
||||
|
||||
it('navigation repaints only the active highlight, not the full match set', () => {
|
||||
const instance = {}
|
||||
const body = bodyWithText('a a a a')
|
||||
const matches = applyMarkdownPreviewSearchHighlights(instance, body, 'a')
|
||||
const paintedAfterApply = registry.entries.get(SEARCH_HIGHLIGHT_NAME)
|
||||
setActiveMarkdownPreviewSearchMatch(instance, matches, 0)
|
||||
setActiveMarkdownPreviewSearchMatch(instance, matches, 1)
|
||||
// Navigation must retain the search highlight while moving the active match.
|
||||
expect(registry.entries.get(SEARCH_HIGHLIGHT_NAME)).toBe(paintedAfterApply)
|
||||
expect(registry.entries.get(ACTIVE_SEARCH_HIGHLIGHT_NAME)?.ranges.has(matches[1])).toBe(true)
|
||||
clearMarkdownPreviewSearchHighlights(instance)
|
||||
body.remove()
|
||||
})
|
||||
|
||||
it('re-apply drops the active highlight until the caller repaints it (same-count rerender)', () => {
|
||||
const instance = {}
|
||||
const body = bodyWithText('one two one two')
|
||||
|
||||
-25
@@ -129,24 +129,6 @@ function buildScenario(): {
|
||||
}
|
||||
|
||||
describe('disposeClosedEditorModels', () => {
|
||||
it('scans the model registry once per batch instead of twice per closed diff tab', () => {
|
||||
const batched = buildScenario()
|
||||
const batchedRegistry = createRegistry(batched.models)
|
||||
disposeClosedEditorModels(batchedRegistry, batched.closedTabs)
|
||||
|
||||
const perTab = buildScenario()
|
||||
const perTabRegistry = createRegistry(perTab.models)
|
||||
disposeByPrefixPerTab(perTabRegistry, perTab.prefixes)
|
||||
|
||||
expect(perTabRegistry.counters.getModelsCalls).toBe(CLOSED_DIFF_TAB_COUNT * 2)
|
||||
expect(perTabRegistry.counters.uriToStringCalls).toBe(
|
||||
CLOSED_DIFF_TAB_COUNT * 2 * perTab.models.length * 2
|
||||
)
|
||||
|
||||
expect(batchedRegistry.counters.getModelsCalls).toBe(1)
|
||||
expect(batchedRegistry.counters.uriToStringCalls).toBeLessThanOrEqual(batched.models.length * 2)
|
||||
})
|
||||
|
||||
it('disposes exactly the models the per-tab sweep disposed', () => {
|
||||
const batched = buildScenario()
|
||||
disposeClosedEditorModels(createRegistry(batched.models), batched.closedTabs)
|
||||
@@ -187,11 +169,4 @@ describe('disposeClosedEditorModels', () => {
|
||||
closed.modifiedModelPath
|
||||
])
|
||||
})
|
||||
|
||||
it('is a no-op when nothing closed', () => {
|
||||
const registry = createRegistry([createModel('diff:original:tab-1:tab-1')])
|
||||
disposeClosedEditorModels(registry, [])
|
||||
expect(registry.counters.getModelsCalls).toBe(0)
|
||||
expect(registry.models[0].disposed).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
+1
-18
@@ -8,7 +8,7 @@ import { TaskList, TaskItem } from '@tiptap/extension-list'
|
||||
import { Table, TableCell, TableHeader, TableRow } from '@tiptap/extension-table'
|
||||
import { createIsolatedMarkdownExtensionForTests } from '../schema/isolated-markdown-extension-for-tests'
|
||||
import { handleRichMarkdownCut } from './rich-markdown-cut-handler'
|
||||
import { normalizeEmptyListItems, normalizeSoftBreaks } from '../schema/rich-markdown-normalize'
|
||||
import { normalizeEmptyListItems } from '../schema/rich-markdown-normalize'
|
||||
|
||||
const showCutLimitErrorMock = vi.hoisted(() => vi.fn())
|
||||
|
||||
@@ -75,23 +75,6 @@ function createClipboardEventMock(options?: { failReadback?: boolean }): {
|
||||
}
|
||||
|
||||
describe('rich markdown cut handler behavior', () => {
|
||||
it('soft-break normalization still creates visible paragraph breaks', () => {
|
||||
const editor = createEditor('Line one\nLine two\nLine three\n')
|
||||
normalizeSoftBreaks(editor)
|
||||
|
||||
expect(countParagraphs(editor)).toBe(3)
|
||||
const paragraphs: string[] = []
|
||||
editor.state.doc.forEach((node) => {
|
||||
if (node.type.name === 'paragraph') {
|
||||
expect(node.textContent).not.toContain('\n')
|
||||
paragraphs.push(node.textContent)
|
||||
}
|
||||
})
|
||||
expect(paragraphs).toEqual(['Line one', 'Line two', 'Line three'])
|
||||
|
||||
editor.destroy()
|
||||
})
|
||||
|
||||
it('Cmd+X cuts only a visual line inside a hard-wrapped paragraph', () => {
|
||||
const editor = createEditor('Alpha segment stays\nMiddle segment is cut\nOmega segment stays')
|
||||
try {
|
||||
|
||||
-118
@@ -1,11 +1,7 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
extractDetailsSummaryHtml,
|
||||
isEditableDetailsHtmlBlock,
|
||||
matchDetailsHtmlBlock,
|
||||
normalizeDetailsOpeningTag,
|
||||
parseDetailsAttributes,
|
||||
parseToggleHeadingVariant,
|
||||
type DetailsHtmlBlock
|
||||
} from './details-markdown-html'
|
||||
|
||||
@@ -27,110 +23,6 @@ afterEach(() => {
|
||||
})
|
||||
|
||||
describe('details markdown html', () => {
|
||||
it.each([
|
||||
['<details>', '<details class="orca-details">'],
|
||||
['<details open="open">', '<details class="orca-details" open>'],
|
||||
['<details CLASS="orca-details">', '<details class="orca-details">'],
|
||||
["<details Class='orca-details'>", '<details class="orca-details">'],
|
||||
['<details cLaSs=orca-details>', '<details class="orca-details">'],
|
||||
[
|
||||
"<details open data-orca-toggle = 'heading-2' class='orca-details'>",
|
||||
'<details class="orca-details" data-orca-toggle="heading-2" open>'
|
||||
]
|
||||
])('normalizes supported opening tag %s like the serializer', (input, expected) => {
|
||||
expect(normalizeDetailsOpeningTag(input)).toBe(expected)
|
||||
})
|
||||
|
||||
it.each([
|
||||
'<details id="keep">',
|
||||
'<details class="custom">',
|
||||
'<details class="ORCA-DETAILS">',
|
||||
"<details CLASS='Orca-Details'>",
|
||||
'<details Class=ORCA-DETAILS>',
|
||||
'<details data-orca-toggle="heading-6">',
|
||||
'<details open="false">',
|
||||
'<detailsish>',
|
||||
'</details>',
|
||||
'<summary>',
|
||||
'<!-- <details> -->'
|
||||
])('leaves noncanonical or unrelated fragment %s unchanged', (fragment) => {
|
||||
expect(normalizeDetailsOpeningTag(fragment)).toBe(fragment)
|
||||
})
|
||||
|
||||
it.each(['ORCA-DETAILS', 'Orca-Details'])(
|
||||
'keeps case-sensitive class %s out of editable details nodes',
|
||||
(className) => {
|
||||
expect(
|
||||
isEditableHtml(`<details class="${className}"><summary>Toggle</summary>Body</details>`)
|
||||
).toBe(false)
|
||||
}
|
||||
)
|
||||
|
||||
it('extracts leading summary html without regex capture', () => {
|
||||
const matchSpy = vi.spyOn(String.prototype, 'match')
|
||||
const inner = `\n<SUMMARY>${'Heading line\n'.repeat(1_000)}</SUMMARY><p>Body</p>`
|
||||
|
||||
const summary = extractDetailsSummaryHtml(inner)
|
||||
|
||||
expect(summary?.content).toContain('Heading line')
|
||||
expect(summary?.rawLength).toBe(inner.indexOf('<p>Body</p>'))
|
||||
const usedSummaryCapture = matchSpy.mock.calls.some(
|
||||
([pattern]) =>
|
||||
pattern instanceof RegExp &&
|
||||
pattern.source.startsWith('^\\s*<summary') &&
|
||||
pattern.source.includes('[\\s\\S]')
|
||||
)
|
||||
expect(usedSummaryCapture).toBe(false)
|
||||
})
|
||||
|
||||
it('accepts editable details blocks with newline-heavy summaries without summary matching', () => {
|
||||
const matchSpy = vi.spyOn(String.prototype, 'match')
|
||||
const block: DetailsHtmlBlock = {
|
||||
raw: '',
|
||||
openingAttributes: '',
|
||||
inner: `<summary>${'Heading line\n'.repeat(1_000)}</summary><p>Body</p>`
|
||||
}
|
||||
|
||||
expect(isEditableDetailsHtmlBlock(block)).toBe(true)
|
||||
const usedSummaryCapture = matchSpy.mock.calls.some(
|
||||
([pattern]) =>
|
||||
pattern instanceof RegExp &&
|
||||
pattern.source.startsWith('^\\s*<summary') &&
|
||||
pattern.source.includes('[\\s\\S]')
|
||||
)
|
||||
expect(usedSummaryCapture).toBe(false)
|
||||
})
|
||||
|
||||
it('accepts heading-5 toggle variants and rejects unsupported levels', () => {
|
||||
expect(parseToggleHeadingVariant('heading-5')).toBe('heading-5')
|
||||
expect(parseToggleHeadingVariant('heading-6')).toBeNull()
|
||||
expect(parseDetailsAttributes(' data-orca-toggle="heading-5"')).toMatchObject({
|
||||
variant: 'heading-5'
|
||||
})
|
||||
expect(parseDetailsAttributes(' data-orca-toggle="heading-6"')).toMatchObject({
|
||||
variant: null
|
||||
})
|
||||
|
||||
const editableHeading5: DetailsHtmlBlock = {
|
||||
raw: '',
|
||||
openingAttributes: ' data-orca-toggle="heading-5"',
|
||||
inner: '<summary>Toggle</summary><p>Body</p>'
|
||||
}
|
||||
const unsupportedHeading6: DetailsHtmlBlock = {
|
||||
raw: '',
|
||||
openingAttributes: ' data-orca-toggle="heading-6"',
|
||||
inner: '<summary>Toggle</summary><p>Body</p>'
|
||||
}
|
||||
|
||||
expect(isEditableDetailsHtmlBlock(editableHeading5)).toBe(true)
|
||||
expect(isEditableDetailsHtmlBlock(unsupportedHeading6)).toBe(false)
|
||||
})
|
||||
|
||||
it('accepts nested toggles whose descendants are all editable', () => {
|
||||
expect(isEditableHtml(nestedToggles(2))).toBe(true)
|
||||
expect(isEditableHtml(nestedToggles(16))).toBe(true)
|
||||
})
|
||||
|
||||
it('bounds each of many sibling nested toggles independently', () => {
|
||||
const siblings = (count: number, extra = ''): string =>
|
||||
Array.from(
|
||||
@@ -146,16 +38,6 @@ describe('details markdown html', () => {
|
||||
expect(isEditableHtml(wrap(`${siblings(20)}\n\n${siblings(1, ' id="x"')}`))).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects a toggle whose nested toggle is not itself editable', () => {
|
||||
const block: DetailsHtmlBlock = {
|
||||
raw: '',
|
||||
openingAttributes: ' class="orca-details"',
|
||||
inner: '<summary>Outer</summary><details id="x"><summary>Inner</summary><p>Body</p></details>'
|
||||
}
|
||||
|
||||
expect(isEditableDetailsHtmlBlock(block)).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects nesting past the recursion guard instead of recursing without bound', () => {
|
||||
expect(isEditableHtml(nestedToggles(17))).toBe(false)
|
||||
expect(isEditableHtml(nestedToggles(400))).toBe(false)
|
||||
|
||||
@@ -136,21 +136,6 @@ describe('restored client-local editor tabs', () => {
|
||||
}
|
||||
)
|
||||
|
||||
it('reads a local tab stored outside its own project as user-named', async () => {
|
||||
const filePath = '/Users/me/notes/audit.md'
|
||||
const activeFile = createFloatingFile(filePath, {
|
||||
relativePath: filePath,
|
||||
worktreeId: 'repo-local::/Users/me/project'
|
||||
})
|
||||
|
||||
await act(async () => root?.render(<HookProbe activeFile={activeFile} />))
|
||||
|
||||
await vi.waitFor(() => expect(latestFileContents[activeFile.id]?.content).toBe('# local'))
|
||||
expect(mocks.readRuntimeFileContent).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ filePath, access: { kind: 'user-file' } })
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps an absolute floating file in its panel when a project also owns the path', async () => {
|
||||
const filePath = '/project/readme.md'
|
||||
const activeFile = createFloatingFile(filePath, { relativePath: filePath })
|
||||
@@ -168,18 +153,4 @@ describe('restored client-local editor tabs', () => {
|
||||
expect.objectContaining({ filePath, access: { kind: 'user-file' } })
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps a project tab inside its root', async () => {
|
||||
const activeFile = createFloatingFile('/Users/me/project/README.md', {
|
||||
relativePath: 'README.md',
|
||||
worktreeId: 'repo::/Users/me/project'
|
||||
})
|
||||
|
||||
await act(async () => root?.render(<HookProbe activeFile={activeFile} />))
|
||||
|
||||
await vi.waitFor(() => expect(latestFileContents[activeFile.id]?.content).toBe('# local'))
|
||||
expect(mocks.readRuntimeFileContent).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ access: undefined })
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -101,7 +101,6 @@ vi.mock('@/store', () => ({
|
||||
useAppStore: <T,>(selector: (state: typeof storeState) => T): T => selector(storeState)
|
||||
}))
|
||||
|
||||
// Isolate dot visibility here; store/selectors.test.ts covers the real unread selector.
|
||||
vi.mock('@/store/selectors', () => ({
|
||||
selectFloatingWorkspaceHasUnread: (state: typeof storeState): boolean => state.hasFloatingUnread
|
||||
}))
|
||||
|
||||
@@ -1,182 +0,0 @@
|
||||
import { isValidElement } from 'react'
|
||||
import { renderToStaticMarkup } from 'react-dom/server'
|
||||
import { toast } from 'sonner'
|
||||
import { beforeEach, expect, it, vi } from 'vitest'
|
||||
import { announceRestartResults } from './native-chat-restart-action-notifications'
|
||||
import { lastToastShow } from './native-chat-resume-toast.test-support'
|
||||
|
||||
vi.mock('sonner', async () =>
|
||||
(await import('./native-chat-resume-sonner-mock.test-support')).resumeSonnerMock()
|
||||
)
|
||||
|
||||
const show = vi.fn()
|
||||
const refusedBoth = [
|
||||
{ sessionId: 'a', outcome: 'refused' as const },
|
||||
{ sessionId: 'b', outcome: 'refused' as const }
|
||||
]
|
||||
|
||||
beforeEach(() => vi.clearAllMocks())
|
||||
|
||||
function titles(): unknown[] {
|
||||
return vi.mocked(toast).mock.calls.map(([text]) => text)
|
||||
}
|
||||
|
||||
it.each([true, false])('offers View chats on a success toast with a failure list: %s', (listed) => {
|
||||
announceRestartResults(
|
||||
['a', 'b'],
|
||||
[
|
||||
{ sessionId: 'a', outcome: 'continued' },
|
||||
{ sessionId: 'b', outcome: 'continued' }
|
||||
],
|
||||
listed ? [] : undefined,
|
||||
show
|
||||
)
|
||||
expect(titles()).toEqual(['Resumed 2 chats'])
|
||||
expect(toast.success).toHaveBeenCalledTimes(1)
|
||||
expect(vi.mocked(toast).mock.calls[0]?.[1]?.action).toEqual({
|
||||
label: 'View chats',
|
||||
onClick: expect.any(Function)
|
||||
})
|
||||
expect(lastToastShow()).toBeDefined()
|
||||
})
|
||||
|
||||
it('offers Review on an error toast for chats a resume could not carry on', () => {
|
||||
announceRestartResults(['a', 'b'], refusedBoth, refusedBoth, show)
|
||||
expect(titles()).toEqual(['2 chats couldn’t be resumed'])
|
||||
const options = vi.mocked(toast).mock.calls[0]?.[1]
|
||||
expect(options).not.toHaveProperty('description')
|
||||
expect(toast.error).toHaveBeenCalledTimes(1)
|
||||
expect(options?.action).toEqual({ label: 'Review', onClick: expect.any(Function) })
|
||||
lastToastShow()?.()
|
||||
expect(show).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('offers Review on a warning toast when a resume could not be confirmed', () => {
|
||||
announceRestartResults(
|
||||
['a', 'b'],
|
||||
[
|
||||
{ sessionId: 'a', outcome: 'continued' },
|
||||
{ sessionId: 'b', outcome: 'unknown' }
|
||||
],
|
||||
[{ sessionId: 'b', outcome: 'unconfirmed' }],
|
||||
show
|
||||
)
|
||||
expect(titles()).toEqual(['Couldn’t confirm 1 chat was resumed'])
|
||||
expect(toast.warning).toHaveBeenCalledTimes(1)
|
||||
expect(vi.mocked(toast).mock.calls[0]?.[1]?.action).toEqual({
|
||||
label: 'Review',
|
||||
onClick: expect.any(Function)
|
||||
})
|
||||
})
|
||||
|
||||
it('reports a mixed resume in one toast', () => {
|
||||
announceRestartResults(
|
||||
['a', 'b', 'c'],
|
||||
[
|
||||
{ sessionId: 'a', outcome: 'continued' },
|
||||
{ sessionId: 'b', outcome: 'continued' },
|
||||
{ sessionId: 'c', outcome: 'refused' }
|
||||
],
|
||||
[{ sessionId: 'c', outcome: 'refused' }],
|
||||
show
|
||||
)
|
||||
expect(vi.mocked(toast).mock.calls).toEqual([
|
||||
['1 chat couldn’t be resumed', expect.objectContaining({ description: 'Resumed 2 chats' })]
|
||||
])
|
||||
})
|
||||
|
||||
it('puts each extra count on its own line when a resume had all three outcomes', () => {
|
||||
announceRestartResults(
|
||||
['a', 'b', 'c'],
|
||||
[
|
||||
{ sessionId: 'a', outcome: 'continued' },
|
||||
{ sessionId: 'b', outcome: 'refused' },
|
||||
{ sessionId: 'c', outcome: 'unknown' }
|
||||
],
|
||||
[
|
||||
{ sessionId: 'b', outcome: 'refused' },
|
||||
{ sessionId: 'c', outcome: 'unconfirmed' }
|
||||
],
|
||||
show
|
||||
)
|
||||
expect(toast).toHaveBeenCalledTimes(1)
|
||||
const [title, options] = vi.mocked(toast).mock.calls[0]!
|
||||
expect(title).toBe('1 chat couldn’t be resumed')
|
||||
const description = options?.description
|
||||
if (!isValidElement(description)) {
|
||||
throw new Error('Expected one line per count')
|
||||
}
|
||||
expect(renderToStaticMarkup(description)).toBe(
|
||||
'<span class="block">Couldn’t confirm 1 other chat was resumed</span>' +
|
||||
'<span class="block">Resumed 1 chat</span>'
|
||||
)
|
||||
})
|
||||
|
||||
it('counts only the requested chats the host still lists as failed', () => {
|
||||
announceRestartResults(['a', 'b'], refusedBoth, [{ sessionId: 'a', outcome: 'refused' }], show)
|
||||
expect(titles()).toEqual(['1 chat couldn’t be resumed'])
|
||||
})
|
||||
|
||||
it('says nothing when the host lists none of them as failed', () => {
|
||||
announceRestartResults(['a', 'b'], refusedBoth, [], show)
|
||||
expect(toast).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('counts every chat not carried on when no failure list was read, with nothing to open', () => {
|
||||
announceRestartResults(['a', 'b'], [], undefined, show)
|
||||
expect(titles()).toEqual(['2 chats couldn’t be resumed'])
|
||||
expect(vi.mocked(toast).mock.calls[0]?.[1]).not.toHaveProperty('action')
|
||||
})
|
||||
|
||||
it('counts every chat as unconfirmed when the answer carried no outcomes', () => {
|
||||
announceRestartResults(['a', 'b'], undefined, undefined, show)
|
||||
expect(titles()).toEqual(['Couldn’t confirm 2 chats were resumed'])
|
||||
})
|
||||
|
||||
// The host retires an unconfirmed send once the agent is seen carrying on it; the resume must still
|
||||
// report the chat, and as resumed, not as a failure the list can no longer show.
|
||||
it('preserves normalized uncertainty when a pending-list read omits the chat', () => {
|
||||
announceRestartResults(['a'], [{ sessionId: 'a', outcome: 'unknown' }], [], show)
|
||||
expect(titles()).toEqual(['Couldn’t confirm 1 chat was resumed'])
|
||||
})
|
||||
|
||||
// Unconfirmed means the agent may well be working; "couldn't be resumed" would invite a second send.
|
||||
// The normalized action result takes precedence over a different host observation.
|
||||
it('counts normalized uncertainty separately from matching refusal', () => {
|
||||
announceRestartResults(
|
||||
['a', 'b'],
|
||||
[
|
||||
{ sessionId: 'a', outcome: 'refused' },
|
||||
{ sessionId: 'b', outcome: 'unknown' }
|
||||
],
|
||||
[
|
||||
{ sessionId: 'a', outcome: 'unconfirmed' },
|
||||
{ sessionId: 'b', outcome: 'refused' }
|
||||
],
|
||||
show
|
||||
)
|
||||
expect(vi.mocked(toast).mock.calls).toEqual([
|
||||
[
|
||||
'1 chat couldn’t be resumed',
|
||||
expect.objectContaining({ description: 'Couldn’t confirm 1 other chat was resumed' })
|
||||
]
|
||||
])
|
||||
})
|
||||
|
||||
it('leads with the unconfirmed count when nothing was refused', () => {
|
||||
announceRestartResults(
|
||||
['a', 'b'],
|
||||
[
|
||||
{ sessionId: 'a', outcome: 'unknown' },
|
||||
{ sessionId: 'b', outcome: 'pending' }
|
||||
],
|
||||
undefined,
|
||||
show
|
||||
)
|
||||
expect(vi.mocked(toast).mock.calls).toEqual([
|
||||
[
|
||||
'Couldn’t confirm 2 chats were resumed',
|
||||
expect.not.objectContaining({ description: expect.anything() })
|
||||
]
|
||||
])
|
||||
})
|
||||
-28
@@ -163,33 +163,5 @@ describe.each([['states each row’s turn', true]])(
|
||||
screen.getByText('SECOND DONE')
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps that order once both turns settle', () => {
|
||||
render(
|
||||
<NativeChatMessageList
|
||||
session={session('ready')}
|
||||
journalItems={journal('completed', statesScope)}
|
||||
journalSubmissions={[]}
|
||||
isWorking={false}
|
||||
workingStartedAt={null}
|
||||
settledTurns={
|
||||
new Map([
|
||||
['user-a', { startedAt: 1, workedSeconds: 17 }],
|
||||
['user-b', { startedAt: 2, workedSeconds: 51 }]
|
||||
])
|
||||
}
|
||||
expandSignal={false}
|
||||
/>
|
||||
)
|
||||
|
||||
expectInOrder(
|
||||
screen.getByText('Run the first job'),
|
||||
bar('Worked for 17s'),
|
||||
screen.getByText('FIRST DONE'),
|
||||
screen.getByText('Then run the second job'),
|
||||
bar('Worked for 51s'),
|
||||
screen.getByText('SECOND DONE')
|
||||
)
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
-15
@@ -145,21 +145,6 @@ describe('useStructuredAgentSession context usage', () => {
|
||||
expect(optionReads()).toBe(1)
|
||||
})
|
||||
|
||||
it('asks the host again when a live turn revision fell outside the loaded window', async () => {
|
||||
answerOptions([
|
||||
{ used: estimate(150_000), window: WINDOW },
|
||||
{ used: estimate(170_000), window: WINDOW }
|
||||
])
|
||||
const { result, rerender } = render()
|
||||
await waitFor(() => expect(result.current.contextUsage).toMatchObject({ usedTokens: 150_000 }))
|
||||
|
||||
unloadedTurnRevisions = 1
|
||||
rerender()
|
||||
|
||||
await waitFor(() => expect(result.current.contextUsage).toMatchObject({ usedTokens: 170_000 }))
|
||||
expect(optionReads()).toBe(2)
|
||||
})
|
||||
|
||||
it('keeps one refresh in flight and one behind it however many revisions it missed', async () => {
|
||||
const replies: ((used: number) => void)[] = []
|
||||
mocks.call.mockImplementation((_target, method) =>
|
||||
|
||||
-18
@@ -179,22 +179,4 @@ describe('desktop structured prompt answers', () => {
|
||||
})
|
||||
expect(respondCall()).not.toHaveProperty('answers')
|
||||
})
|
||||
|
||||
it('sends an approval decision without probing the host', async () => {
|
||||
const { result } = renderHook(() =>
|
||||
useStructuredAgentSession({
|
||||
sessionId: 'session-1',
|
||||
target,
|
||||
agent: 'claude',
|
||||
isVisible: true
|
||||
})
|
||||
)
|
||||
|
||||
await act(async () => {
|
||||
await result.current.respond(approval, { kind: 'option', optionId: 'allow' })
|
||||
})
|
||||
|
||||
expect(mocks.questionAnswersSupported).not.toHaveBeenCalled()
|
||||
expect(respondCall()).toMatchObject({ itemId: 'approval-1', optionId: 'allow' })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,140 +0,0 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { TooltipProvider } from '@/components/ui/tooltip'
|
||||
import { AGENT_CATALOG } from '@/lib/agent-catalog'
|
||||
import { NewWorkspaceComposerAgentSection } from './NewWorkspaceComposerAgentSection'
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
describe('NewWorkspaceComposerAgentSection agent picker', () => {
|
||||
it.each([
|
||||
{ kind: 'project', selectedRepoIsGit: true, matched: true },
|
||||
{ kind: 'project', selectedRepoIsGit: false, matched: false },
|
||||
{ kind: 'project-group', selectedRepoIsGit: true, matched: false }
|
||||
] as const)(
|
||||
'keeps Blank Terminal and scopes menu width to Create worktree: $kind / $selectedRepoIsGit',
|
||||
({ kind, selectedRepoIsGit, matched }) => {
|
||||
const work: { size: number; initial: number; update: number; targetChange: number }[] = []
|
||||
for (const size of [32, 320]) {
|
||||
let optionReads = 0
|
||||
const projectOptions = Array.from({ length: size }, (_, index) => {
|
||||
const optionKind = index === 0 ? (kind === 'project' ? 'project-group' : 'project') : kind
|
||||
return {
|
||||
get id() {
|
||||
optionReads++
|
||||
return index === size - 1 ? 'target' : index === 0 ? 'alternate' : `option-${index}`
|
||||
},
|
||||
displayName: `Target ${index}`,
|
||||
badgeColor: '',
|
||||
detail: `/target-${index}`,
|
||||
...(optionKind === 'project'
|
||||
? { kind: optionKind, projectId: `target-${index}` }
|
||||
: {
|
||||
kind: optionKind,
|
||||
projectGroupId: `target-${index}`,
|
||||
parentPath: `/target-${index}`,
|
||||
connectionId: null
|
||||
})
|
||||
}
|
||||
})
|
||||
const onQuickAgentChange = vi.fn()
|
||||
const section = (selectedProjectId: string, advancedOpen: boolean) => (
|
||||
<TooltipProvider>
|
||||
<NewWorkspaceComposerAgentSection
|
||||
quickAgent="codex"
|
||||
selectedRepoIsGit={selectedRepoIsGit}
|
||||
selectedProjectId={selectedProjectId}
|
||||
projectOptions={projectOptions}
|
||||
agentSurfaceRows={{
|
||||
model: {
|
||||
state: 'settled',
|
||||
rows: [{ agent: 'codex', kind: 'terminal' }],
|
||||
detection: {
|
||||
detectedIds: ['codex'],
|
||||
isLoading: false,
|
||||
isRefreshing: false,
|
||||
detectionFailed: false,
|
||||
needsServerUpdate: false,
|
||||
refresh: async () => ['codex']
|
||||
}
|
||||
},
|
||||
selection: { ready: true, cli: false },
|
||||
contextKey: 'test',
|
||||
onPick: vi.fn()
|
||||
}}
|
||||
onQuickAgentChange={onQuickAgentChange}
|
||||
onOpenAgentSettings={vi.fn()}
|
||||
createDisabled={false}
|
||||
onCreate={vi.fn()}
|
||||
advancedOpen={advancedOpen}
|
||||
onToggleAdvanced={vi.fn()}
|
||||
visibleQuickAgents={AGENT_CATALOG}
|
||||
defaultTuiAgent="blank"
|
||||
handleSetDefaultAgent={vi.fn()}
|
||||
/>
|
||||
</TooltipProvider>
|
||||
)
|
||||
const { rerender, unmount } = render(section('target', false))
|
||||
const initial = optionReads
|
||||
expect(initial).toBeLessThanOrEqual(selectedRepoIsGit ? size : 0)
|
||||
expect(initial).toBeGreaterThanOrEqual(selectedRepoIsGit ? 1 : 0)
|
||||
|
||||
expect(screen.getByRole('button', { name: 'Select agent' }).textContent).not.toContain(
|
||||
'Chat'
|
||||
)
|
||||
expect(screen.getByRole('button', { name: 'Select agent' }).textContent).not.toContain(
|
||||
'(CLI)'
|
||||
)
|
||||
fireEvent.pointerDown(screen.getByRole('button', { name: 'Select agent' }), {
|
||||
button: 0,
|
||||
ctrlKey: false
|
||||
})
|
||||
const blankTerminal = screen.getByRole('menuitem', { name: 'Blank Terminal' })
|
||||
const menu = screen.getByRole('menu')
|
||||
expect(menu.classList.contains('w-(--radix-dropdown-menu-trigger-width)')).toBe(matched)
|
||||
expect(menu.classList.contains('min-w-(--radix-dropdown-menu-trigger-width)')).toBe(matched)
|
||||
expect(menu.classList.contains('min-w-64')).toBe(!matched)
|
||||
expect(blankTerminal.querySelector('svg')).not.toBeNull()
|
||||
expect(screen.queryByRole('menuitem', { name: 'Setup only' })).toBeNull()
|
||||
expect(screen.getByRole('menuitem', { name: 'Agent settings…' })).toBeTruthy()
|
||||
fireEvent.click(blankTerminal)
|
||||
|
||||
expect(onQuickAgentChange).toHaveBeenCalledWith(null)
|
||||
|
||||
optionReads = 0
|
||||
rerender(section('target', true))
|
||||
const update = optionReads
|
||||
expect(update).toBeLessThanOrEqual(selectedRepoIsGit ? size : 0)
|
||||
expect(update).toBeGreaterThanOrEqual(selectedRepoIsGit ? 1 : 0)
|
||||
|
||||
optionReads = 0
|
||||
rerender(section('alternate', true))
|
||||
const targetChange = optionReads
|
||||
expect(targetChange).toBeLessThanOrEqual(selectedRepoIsGit ? 1 : 0)
|
||||
expect(targetChange).toBeGreaterThanOrEqual(selectedRepoIsGit ? 1 : 0)
|
||||
fireEvent.pointerDown(screen.getByRole('button', { name: 'Select agent' }), {
|
||||
button: 0,
|
||||
ctrlKey: false
|
||||
})
|
||||
const changedMenu = screen.getByRole('menu')
|
||||
const changedMatched = selectedRepoIsGit && kind === 'project-group'
|
||||
expect(changedMenu.classList.contains('w-(--radix-dropdown-menu-trigger-width)')).toBe(
|
||||
changedMatched
|
||||
)
|
||||
expect(changedMenu.classList.contains('min-w-(--radix-dropdown-menu-trigger-width)')).toBe(
|
||||
changedMatched
|
||||
)
|
||||
expect(changedMenu.classList.contains('min-w-64')).toBe(!changedMatched)
|
||||
expect(onQuickAgentChange).toHaveBeenCalledExactlyOnceWith(null)
|
||||
work.push({ size, initial, update, targetChange })
|
||||
unmount()
|
||||
}
|
||||
expect(work[1].initial).toBeLessThanOrEqual(10 * work[0].initial)
|
||||
expect(work[1].update).toBeLessThanOrEqual(10 * work[0].update)
|
||||
expect(work[1].targetChange).toBeLessThanOrEqual(work[0].targetChange)
|
||||
console.info('project option reads', { kind, selectedRepoIsGit, work })
|
||||
}
|
||||
)
|
||||
})
|
||||
@@ -84,31 +84,6 @@ afterEach(() => {
|
||||
})
|
||||
|
||||
describe('SetProjectLocationDialog', () => {
|
||||
it('keeps the parent caller in charge of close and saves an existing folder', async () => {
|
||||
const onClose = vi.fn()
|
||||
const onReady = vi.fn()
|
||||
const user = renderDialog({ onClose, onReady })
|
||||
|
||||
expect(screen.getByTestId('set-project-location-dialog')).toBeTruthy()
|
||||
expect(screen.getByText('Set project location')).toBeTruthy()
|
||||
expect(screen.getByText('Choose where orca lives on openclaw.')).toBeTruthy()
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /Browse folder/ }))
|
||||
await user.click(screen.getByRole('button', { name: 'Browse host filesystem' }))
|
||||
await user.click(screen.getByRole('button', { name: 'Select remote folder' }))
|
||||
await user.click(screen.getByRole('button', { name: 'Set location' }))
|
||||
|
||||
expect(storeMocks.setupProjectExistingFolder).toHaveBeenCalledWith({
|
||||
projectId: 'project-orca',
|
||||
hostId: 'ssh:openclaw',
|
||||
path: '/remote/orca',
|
||||
kind: 'git',
|
||||
displayName: 'orca'
|
||||
})
|
||||
expect(onReady).toHaveBeenCalledWith(JSON.stringify(['setup-openclaw', 'ssh:openclaw']))
|
||||
expect(onClose).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('backs out of the host browser on Escape instead of discarding the form', async () => {
|
||||
const onClose = vi.fn()
|
||||
const user = renderDialog({ onClose })
|
||||
|
||||
@@ -3,30 +3,15 @@ import type {
|
||||
ComputerUsePermissionSetupResult,
|
||||
ComputerUsePermissionStatusResult
|
||||
} from '../../../../shared/computer-use-permissions-types'
|
||||
import {
|
||||
COMPUTER_USE_SKILL_NAME,
|
||||
ORCA_CLI_SKILL_NAME,
|
||||
ORCA_LINEAR_SKILL_NAME,
|
||||
ORCHESTRATION_SKILL_NAME
|
||||
} from '@/lib/agent-feature-skill-names'
|
||||
import { ORCHESTRATION_SKILL_NAME } from '@/lib/agent-feature-skill-names'
|
||||
import { BROWSER_USE_ENABLED_STORAGE_KEY } from '@/lib/browser-use-setup-state'
|
||||
import {
|
||||
ORCHESTRATION_ENABLED_STORAGE_KEY,
|
||||
ORCHESTRATION_SETUP_DISMISSED_STORAGE_KEY
|
||||
} from '@/lib/orchestration-setup-state'
|
||||
import { ORCHESTRATION_ENABLED_STORAGE_KEY } from '@/lib/orchestration-setup-state'
|
||||
import {
|
||||
runOnboardingFeatureSetup,
|
||||
type OnboardingFeatureSetupDeps,
|
||||
type OnboardingFeatureSetupSelection
|
||||
} from './onboarding-feature-setup'
|
||||
|
||||
const ALL_SKILL_NAMES = [
|
||||
ORCA_CLI_SKILL_NAME,
|
||||
COMPUTER_USE_SKILL_NAME,
|
||||
ORCHESTRATION_SKILL_NAME,
|
||||
ORCA_LINEAR_SKILL_NAME
|
||||
]
|
||||
|
||||
const GRANTED_COMPUTER_USE_STATUS: ComputerUsePermissionStatusResult = {
|
||||
platform: 'darwin',
|
||||
helperAppPath: '/Applications/Orca Computer Use.app',
|
||||
@@ -70,40 +55,6 @@ describe('onboarding feature setup runner', () => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
it('runs selected feature setup through injected deps only', async () => {
|
||||
const deps = createDeps({
|
||||
getComputerUsePermissionStatus: vi.fn(
|
||||
async (): Promise<ComputerUsePermissionStatusResult> => ({
|
||||
platform: 'darwin',
|
||||
helperAppPath: '/Applications/Orca Computer Use.app',
|
||||
helperUnavailableReason: null,
|
||||
permissions: [
|
||||
{ id: 'accessibility', status: 'not-granted' },
|
||||
{ id: 'screenshots', status: 'granted' }
|
||||
]
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
const result = await runOnboardingFeatureSetup(
|
||||
{ browserUse: true, computerUse: true, orchestration: true, linearTickets: true },
|
||||
deps
|
||||
)
|
||||
|
||||
expect(result).toEqual({
|
||||
selectedIds: ['browserUse', 'computerUse', 'orchestration', 'linearTickets'],
|
||||
skillNames: ALL_SKILL_NAMES,
|
||||
computerUsePermissionsOpened: true,
|
||||
warnings: []
|
||||
})
|
||||
expect(deps.getComputerUsePermissionStatus).toHaveBeenCalledTimes(1)
|
||||
expect(deps.openComputerUsePermissionSetup).toHaveBeenCalledTimes(1)
|
||||
expect(deps.storage.get(BROWSER_USE_ENABLED_STORAGE_KEY)).toBe('1')
|
||||
expect(deps.storage.get(ORCHESTRATION_ENABLED_STORAGE_KEY)).toBe('1')
|
||||
expect(deps.removeStorageItem).toHaveBeenCalledWith(ORCHESTRATION_SETUP_DISMISSED_STORAGE_KEY)
|
||||
expect(deps.notifyOrchestrationStateChanged).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('keeps invasive Browser Use and Computer Use setup untouched when only Orchestration is selected', async () => {
|
||||
const deps = createDeps()
|
||||
const selection: OnboardingFeatureSetupSelection = {
|
||||
@@ -124,25 +75,6 @@ describe('onboarding feature setup runner', () => {
|
||||
expect(deps.storage.get(ORCHESTRATION_ENABLED_STORAGE_KEY)).toBe('1')
|
||||
})
|
||||
|
||||
it('clears feature markers when no setup items are selected', async () => {
|
||||
const deps = createDeps()
|
||||
|
||||
const result = await runOnboardingFeatureSetup(
|
||||
{ browserUse: false, computerUse: false, orchestration: false, linearTickets: false },
|
||||
deps
|
||||
)
|
||||
|
||||
expect(result).toEqual({
|
||||
selectedIds: [],
|
||||
skillNames: [],
|
||||
computerUsePermissionsOpened: false,
|
||||
warnings: []
|
||||
})
|
||||
expect(deps.storage.get(BROWSER_USE_ENABLED_STORAGE_KEY)).toBe('0')
|
||||
expect(deps.storage.get(ORCHESTRATION_ENABLED_STORAGE_KEY)).toBe('0')
|
||||
expect(deps.getComputerUsePermissionStatus).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps main’s skill selection but skips permissions when the Computer Use helper is unavailable', async () => {
|
||||
// Missing helpers report every permission ungranted; do not call openSetup, which would throw.
|
||||
const unavailableStatus: ComputerUsePermissionStatusResult = {
|
||||
|
||||
+1
-15
@@ -17,7 +17,7 @@ import {
|
||||
import { aiVaultSearchHitToSession } from '../search/ai-vault-search-session'
|
||||
import type { AiVaultSessionVirtualList } from '../session-list/AiVaultSessionVirtualList'
|
||||
import type { AiVaultResumeInChatEligibility } from '../session-actions/ai-vault-session-resume-in-chat'
|
||||
import { resolveAiVaultPanelSessionListRequest } from './ai-vault-panel-session-list-request'
|
||||
|
||||
import {
|
||||
writeAiVaultViewOptions,
|
||||
readAiVaultViewOptions
|
||||
@@ -120,20 +120,6 @@ afterEach(() => {
|
||||
window.localStorage.clear()
|
||||
})
|
||||
|
||||
it('builds the exact request the Session History panel sends for the active workspace', async () => {
|
||||
const { default: AiVaultPanel } = await import('../panel/AiVaultPanel')
|
||||
render(<AiVaultPanel />)
|
||||
|
||||
const request = resolveAiVaultPanelSessionListRequest(useAppStore.getState(), WORKTREE_ID)
|
||||
expect(request.scopePaths).toEqual(['/repo/wt'])
|
||||
expect(request.sessionLimit).toBe(500)
|
||||
expect(panelRequests.at(-1)).toEqual(request)
|
||||
fireEvent.click(screen.getByRole('radio', { name: /^All$/ }))
|
||||
expect(panelRequests.at(-1)?.scopePaths).toEqual(['/repo/wt', '/repo/sibling', '/repos/repo-1'])
|
||||
fireEvent.click(screen.getByRole('radio', { name: /^Workspace$/ }))
|
||||
expect(panelRequests.at(-1)).toEqual(request)
|
||||
})
|
||||
|
||||
it.each([false, true])(
|
||||
'delivers alias rows to native resume in Workspace and All (bound: %s)',
|
||||
async (bound) => {
|
||||
|
||||
+1
-94
@@ -5,11 +5,7 @@ import { createRoot, type Root } from 'react-dom/client'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { TEST_REPO, makeWorktree } from '@/store/slices/worktrees-slice-test-fixtures'
|
||||
import type { AiVaultSessionResumeTargetState } from '../session-actions/ai-vault-session-resume'
|
||||
import {
|
||||
buildAiVaultHostScopeOptions,
|
||||
buildRuntimeAiVaultHostScopeOptions,
|
||||
useAiVaultExecutionHostScope
|
||||
} from './ai-vault-host-scope'
|
||||
import { useAiVaultExecutionHostScope } from './ai-vault-host-scope'
|
||||
import type { ExecutionHostId, ExecutionHostScope } from '../../../../../../shared/execution-host'
|
||||
|
||||
type HostScopeResult = ReturnType<typeof useAiVaultExecutionHostScope>
|
||||
@@ -80,33 +76,6 @@ afterEach(() => {
|
||||
})
|
||||
|
||||
describe('useAiVaultExecutionHostScope', () => {
|
||||
it('defaults SSH worktrees to their SSH execution host', async () => {
|
||||
await renderHook({
|
||||
activeWorktreeId: 'repo-1::/remote/repo',
|
||||
resumeTargetState: stateForWorktree({
|
||||
worktreeId: 'repo-1::/remote/repo',
|
||||
repoId: 'repo-1',
|
||||
hostId: 'ssh:dev-box'
|
||||
})
|
||||
})
|
||||
|
||||
expect(latest?.executionHostScope).toBe('ssh:dev-box')
|
||||
expect(latest?.activeExecutionHostScope).toBe('ssh:dev-box')
|
||||
})
|
||||
|
||||
it('defaults local worktrees to local history', async () => {
|
||||
await renderHook({
|
||||
activeWorktreeId: 'repo-1::/local/repo',
|
||||
resumeTargetState: stateForWorktree({
|
||||
worktreeId: 'repo-1::/local/repo',
|
||||
repoId: 'repo-1'
|
||||
})
|
||||
})
|
||||
|
||||
expect(latest?.executionHostScope).toBe('local')
|
||||
expect(latest?.activeExecutionHostScope).toBeNull()
|
||||
})
|
||||
|
||||
it('does not claim local history when the active workspace host cannot be resolved (#13713)', async () => {
|
||||
await renderHook({
|
||||
activeWorktreeId: 'repo-1::/remote/repo',
|
||||
@@ -122,35 +91,6 @@ describe('useAiVaultExecutionHostScope', () => {
|
||||
expect(latest?.executionHostScope).toBe('all')
|
||||
})
|
||||
|
||||
it('keeps local history when no workspace is selected at all', async () => {
|
||||
await renderHook({
|
||||
activeWorktreeId: null,
|
||||
resumeTargetState: {
|
||||
folderWorkspaces: [],
|
||||
projectGroups: [],
|
||||
repos: [],
|
||||
worktreesByRepo: {}
|
||||
}
|
||||
})
|
||||
|
||||
expect(latest?.executionHostScope).toBe('local')
|
||||
})
|
||||
|
||||
it('defaults runtime worktrees to their runtime execution host', async () => {
|
||||
await renderHook({
|
||||
activeWorktreeId: 'repo-1::/runtime/repo',
|
||||
resumeTargetState: stateForWorktree({
|
||||
worktreeId: 'repo-1::/runtime/repo',
|
||||
repoId: 'repo-1',
|
||||
executionHostId: 'runtime:remote-server'
|
||||
}),
|
||||
availableExecutionHostScopes: ['runtime:remote-server'] as const
|
||||
})
|
||||
|
||||
expect(latest?.executionHostScope).toBe('runtime:remote-server')
|
||||
expect(latest?.activeExecutionHostScope).toBe('runtime:remote-server')
|
||||
})
|
||||
|
||||
it('preserves manual host scope changes across unrelated rerenders', async () => {
|
||||
const props = {
|
||||
activeWorktreeId: 'repo-1::/remote/repo',
|
||||
@@ -189,36 +129,3 @@ describe('useAiVaultExecutionHostScope', () => {
|
||||
expect(latest?.executionHostScope).toBe('runtime:remote-server')
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildAiVaultHostScopeOptions', () => {
|
||||
it('adds saved runtime hosts between active SSH and all hosts', () => {
|
||||
const runtimeHostOptions = buildRuntimeAiVaultHostScopeOptions([
|
||||
{ id: 'remote-server', name: 'VPS Orca Server' }
|
||||
])
|
||||
|
||||
expect(
|
||||
buildAiVaultHostScopeOptions({
|
||||
activeExecutionHostScope: 'ssh:dev-box',
|
||||
runtimeHostOptions
|
||||
})
|
||||
).toEqual([
|
||||
{ id: 'local', label: expect.any(String) },
|
||||
{ id: 'ssh:dev-box', label: 'dev-box' },
|
||||
{ id: 'runtime:remote-server', label: 'VPS Orca Server' },
|
||||
{ id: 'all', label: 'All hosts' }
|
||||
])
|
||||
})
|
||||
|
||||
it('keeps a runtime active host visible even before the saved environment list hydrates', () => {
|
||||
expect(
|
||||
buildAiVaultHostScopeOptions({
|
||||
activeExecutionHostScope: 'runtime:remote-server',
|
||||
runtimeHostOptions: []
|
||||
})
|
||||
).toEqual([
|
||||
{ id: 'local', label: expect.any(String) },
|
||||
{ id: 'runtime:remote-server', label: 'remote-server' },
|
||||
{ id: 'all', label: 'All hosts' }
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
+1
-88
@@ -1,6 +1,6 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { searchHit } from '../../../../../../shared/ai-vault/search/ai-vault-search-test-fixture'
|
||||
import * as correspondence from '../../../../../../shared/ai-vault/scanning/ai-vault-session-cwd-aliases'
|
||||
|
||||
import { resolveAiVaultSessionResumeInChatEligibility } from '../session-actions/ai-vault-session-resume-in-chat'
|
||||
import {
|
||||
aiVaultSearchHitToSession,
|
||||
@@ -12,43 +12,6 @@ import {
|
||||
afterEach(() => vi.restoreAllMocks())
|
||||
|
||||
describe('withAiVaultHistoryPreviews', () => {
|
||||
it('bounds identity indexing and correspondence work when history adds aliases at N and 10N', () => {
|
||||
const copy = vi.spyOn(correspondence, 'withAiVaultSessionCwdAliases')
|
||||
for (const size of [100, 1000]) {
|
||||
let identityReads = 0
|
||||
const hits = Array.from({ length: size }, (_, index) => {
|
||||
const row = aiVaultSearchHitToSession(
|
||||
{ ...searchHit(), sessionId: `session-${index}`, cwd: '/private/var/example' },
|
||||
'local'
|
||||
)
|
||||
return {
|
||||
...row,
|
||||
get sessionId() {
|
||||
identityReads += 1
|
||||
return row.sessionId
|
||||
}
|
||||
}
|
||||
})
|
||||
const history = hits.map((hit) => ({ ...hit }))
|
||||
expect(withAiVaultHistoryPreviews(hits, history).every((row) => !row.cwdAliases)).toBe(true)
|
||||
const updated = history.map((row) => ({
|
||||
...row,
|
||||
cwdAliases: ['/var/example'],
|
||||
get sessionId() {
|
||||
identityReads += 1
|
||||
return row.sessionId
|
||||
}
|
||||
}))
|
||||
identityReads = 0
|
||||
copy.mockClear()
|
||||
const hydrated = withAiVaultHistoryPreviews(hits, updated)
|
||||
expect(hydrated.every((row) => row.cwdAliases?.[0] === '/var/example')).toBe(true)
|
||||
expect(copy).toHaveBeenCalledTimes(size)
|
||||
expect(identityReads).toBeGreaterThanOrEqual(2 * size)
|
||||
expect(identityReads).toBeLessThanOrEqual(4 * size)
|
||||
}
|
||||
})
|
||||
|
||||
it('restores resume correspondence when history arrives after a search hit, even with an existing preview', () => {
|
||||
const hit = aiVaultSearchHitToSession(
|
||||
{ ...searchHit(), agent: 'claude', cwd: '/private/var/example' },
|
||||
@@ -81,35 +44,6 @@ describe('withAiVaultHistoryPreviews', () => {
|
||||
expect(withAiVaultHistoryPreviews([hit], rows)[0].cwdAliases).toBeUndefined()
|
||||
}
|
||||
})
|
||||
|
||||
it("shows a paired server hit's conversation preview from its scanned history row", () => {
|
||||
// A relay hit arrives without its transcript path, so its row id differs from the scanned row's.
|
||||
const hitSession = aiVaultSearchHitToSession(
|
||||
{ ...searchHit(), source: { presence: 'present' as const } },
|
||||
'runtime:env-1'
|
||||
)
|
||||
const turns = [
|
||||
{ role: 'user' as const, text: 'Fix the flaky test', timestamp: null },
|
||||
{ role: 'assistant' as const, text: 'Done', timestamp: null }
|
||||
]
|
||||
|
||||
const [hydrated] = withAiVaultHistoryPreviews(
|
||||
[hitSession],
|
||||
[
|
||||
{
|
||||
...hitSession,
|
||||
id: 'runtime:env-1:codex:host-session:/host/transcript.jsonl',
|
||||
filePath: '/host/transcript.jsonl',
|
||||
previewMessages: turns,
|
||||
firstUserPrompt: 'Fix the flaky test'
|
||||
}
|
||||
]
|
||||
)
|
||||
|
||||
expect(hydrated.previewMessages).toEqual(turns)
|
||||
expect(hydrated.firstUserPrompt).toBe('Fix the flaky test')
|
||||
expect(hydrated.resumeCommand).toBe('host-resume-command')
|
||||
})
|
||||
})
|
||||
|
||||
describe('aiVaultSearchHitToSession', () => {
|
||||
@@ -131,20 +65,6 @@ describe('aiVaultSearchHitToSession', () => {
|
||||
expect(session.accountWslDistro).toBe('Ubuntu')
|
||||
})
|
||||
|
||||
it('does not invent a path or resume command when transport withholds them', () => {
|
||||
const hit = {
|
||||
...searchHit(),
|
||||
source: { presence: 'unverifiable' as const },
|
||||
resumeCommand: undefined
|
||||
}
|
||||
const session = aiVaultSearchHitToSession(hit, 'runtime:cloud')
|
||||
|
||||
expect(session.filePath).toBe('')
|
||||
expect(session.resumeCommand).toBe('')
|
||||
expect(canResumeAiVaultSearchHit(hit)).toBe(false)
|
||||
expect(hasAiVaultSearchHitPath(hit)).toBe(false)
|
||||
})
|
||||
|
||||
it('opens a native chat hit through its owner when transport withholds the command', () => {
|
||||
const hit = {
|
||||
...searchHit(),
|
||||
@@ -156,11 +76,4 @@ describe('aiVaultSearchHitToSession', () => {
|
||||
expect(canResumeAiVaultSearchHit(hit)).toBe(true)
|
||||
expect(hasAiVaultSearchHitPath(hit)).toBe(false)
|
||||
})
|
||||
|
||||
it('gates resume and path actions on their own source fields', () => {
|
||||
const withoutResume = { ...searchHit(), resumeCommand: undefined }
|
||||
|
||||
expect(canResumeAiVaultSearchHit(withoutResume)).toBe(false)
|
||||
expect(hasAiVaultSearchHitPath(withoutResume)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
-13
@@ -107,12 +107,6 @@ describe('resuming a conversation from history in a chat', () => {
|
||||
expect(resumeInChat()).toEqual({ available: false, reason: 'workspace' })
|
||||
})
|
||||
|
||||
it('is offered in a workspace on the machine that recorded it', () => {
|
||||
mocks.owner.mockReturnValue('local')
|
||||
|
||||
expect(resumeInChat()).toEqual({ available: true, workspaceId: PAIRED_WORKSPACE })
|
||||
})
|
||||
|
||||
it('asks the host that would run it whether it can resume, not another', () => {
|
||||
mocks.owner.mockReturnValue('local')
|
||||
mocks.localCapabilities.mockReturnValue([])
|
||||
@@ -128,13 +122,6 @@ describe('paired runtime conversation ownership', () => {
|
||||
mocks.owner.mockReturnValue('runtime:server-1')
|
||||
})
|
||||
|
||||
it('offers a conversation on its recording runtime', () => {
|
||||
expect(resumeInChat(remoteSession)).toEqual({
|
||||
available: true,
|
||||
workspaceId: PAIRED_WORKSPACE
|
||||
})
|
||||
})
|
||||
|
||||
it.each(['local', 'runtime:server-2', null])(
|
||||
'refuses moving the remote conversation to %s',
|
||||
(owner) => {
|
||||
|
||||
+1
-130
@@ -1,9 +1,5 @@
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
import {
|
||||
ACTIVE_CHECKS_STATUS_INPUT_KEYS,
|
||||
clearActiveChecksStatusCacheForTests,
|
||||
getActiveChecksStatus
|
||||
} from './active-checks-status'
|
||||
import { clearActiveChecksStatusCacheForTests, getActiveChecksStatus } from './active-checks-status'
|
||||
import type { AppState } from '../../../store/types'
|
||||
import type { PRInfo } from '../../../../../shared/github/pull-request-types'
|
||||
|
||||
@@ -46,44 +42,6 @@ describe('getActiveChecksStatus', () => {
|
||||
expect(getActiveChecksStatus(state)).toBe('success')
|
||||
})
|
||||
|
||||
it('uses GitLab MR pipeline status when the active branch has no GitHub PR cache entry', () => {
|
||||
const state = {
|
||||
activeWorktreeId: 'wt-1',
|
||||
repos: [{ id: 'repo-1', path: '/repo' }],
|
||||
worktreesByRepo: {
|
||||
'repo-1': [
|
||||
{
|
||||
id: 'wt-1',
|
||||
repoId: 'repo-1',
|
||||
branch: 'refs/heads/feature/gitlab',
|
||||
linkedGitLabMR: 7
|
||||
}
|
||||
]
|
||||
},
|
||||
prCache: {},
|
||||
hostedReviewCache: {
|
||||
'local::repo-1::feature/gitlab': {
|
||||
data: {
|
||||
provider: 'gitlab',
|
||||
number: 7,
|
||||
title: 'GitLab MR',
|
||||
state: 'open',
|
||||
url: 'https://gitlab.com/acme/orca/-/merge_requests/7',
|
||||
status: 'success',
|
||||
updatedAt: '2026-05-20T00:00:00Z',
|
||||
mergeable: 'MERGEABLE'
|
||||
},
|
||||
fetchedAt: 2
|
||||
}
|
||||
}
|
||||
} as unknown as Pick<
|
||||
AppState,
|
||||
'activeWorktreeId' | 'repos' | 'worktreesByRepo' | 'prCache' | 'hostedReviewCache'
|
||||
>
|
||||
|
||||
expect(getActiveChecksStatus(state)).toBe('success')
|
||||
})
|
||||
|
||||
it('does not show stale GitHub PR status for a linked GitLab MR while MR status is loading', () => {
|
||||
const state = {
|
||||
activeWorktreeId: 'wt-1',
|
||||
@@ -109,91 +67,4 @@ describe('getActiveChecksStatus', () => {
|
||||
|
||||
expect(getActiveChecksStatus(state)).toBeNull()
|
||||
})
|
||||
|
||||
it('hides the matching suppressed GitHub PR status', () => {
|
||||
const state = {
|
||||
activeWorktreeId: 'wt-1',
|
||||
repos: [{ id: 'repo-1', path: '/repo' }],
|
||||
worktreesByRepo: {
|
||||
'repo-1': [
|
||||
{
|
||||
id: 'wt-1',
|
||||
repoId: 'repo-1',
|
||||
branch: 'refs/heads/feature/test',
|
||||
linkedPR: null,
|
||||
suppressedGitHubPR: 12
|
||||
}
|
||||
]
|
||||
},
|
||||
prCache: {
|
||||
'repo-1::feature/test': { data: makePR('failure'), fetchedAt: 2 }
|
||||
}
|
||||
} as unknown as Pick<AppState, 'activeWorktreeId' | 'repos' | 'worktreesByRepo' | 'prCache'>
|
||||
|
||||
expect(getActiveChecksStatus(state)).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('getActiveChecksStatus caching', () => {
|
||||
beforeEach(() => {
|
||||
clearActiveChecksStatusCacheForTests()
|
||||
})
|
||||
|
||||
function makeState(prCache: Record<string, unknown>) {
|
||||
return {
|
||||
activeWorktreeId: 'wt-1',
|
||||
repos: [{ id: 'repo-1', path: '/repo' }],
|
||||
worktreesByRepo: {
|
||||
'repo-1': [{ id: 'wt-1', repoId: 'repo-1', branch: 'refs/heads/feature/test' }]
|
||||
},
|
||||
prCache
|
||||
} as unknown as Pick<AppState, 'activeWorktreeId' | 'repos' | 'worktreesByRepo' | 'prCache'>
|
||||
}
|
||||
|
||||
it('reuses the cached status when every input reference is unchanged', () => {
|
||||
const prCache = { 'repo-1::feature/test': { data: makePR('success'), fetchedAt: 2 } }
|
||||
const state = makeState(prCache)
|
||||
|
||||
expect(getActiveChecksStatus(state)).toBe('success')
|
||||
// A different state object carrying the same field references must still hit the cache.
|
||||
expect(getActiveChecksStatus({ ...state })).toBe('success')
|
||||
})
|
||||
|
||||
it('recomputes when a keyed input reference changes', () => {
|
||||
expect(
|
||||
getActiveChecksStatus(
|
||||
makeState({ 'repo-1::feature/test': { data: makePR('success'), fetchedAt: 2 } })
|
||||
)
|
||||
).toBe('success')
|
||||
expect(
|
||||
getActiveChecksStatus(
|
||||
makeState({ 'repo-1::feature/test': { data: makePR('failure'), fetchedAt: 3 } })
|
||||
)
|
||||
).toBe('failure')
|
||||
})
|
||||
|
||||
it('reads no store field the cache is not keyed on', () => {
|
||||
// Every property read must invalidate the cache, even if a cast bypasses its derived input type.
|
||||
const reads = new Set<PropertyKey>()
|
||||
const keyed = new Set<PropertyKey>(ACTIVE_CHECKS_STATUS_INPUT_KEYS)
|
||||
const state = new Proxy(
|
||||
makeState({ 'repo-1::feature/test': { data: makePR('success'), fetchedAt: 2 } }),
|
||||
{
|
||||
get(target, prop, receiver) {
|
||||
reads.add(prop)
|
||||
// oxlint-disable-next-line anti-slop/no-reflect-get -- Proxy get trap default forward.
|
||||
return Reflect.get(target, prop, receiver)
|
||||
},
|
||||
has(target, prop) {
|
||||
reads.add(prop)
|
||||
return Reflect.has(target, prop)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
expect(getActiveChecksStatus(state)).toBe('success')
|
||||
expect([...reads].filter((prop) => !keyed.has(prop))).toEqual([])
|
||||
// Require a nonempty read set so the guard cannot pass vacuously.
|
||||
expect(reads.has('prCache')).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
+2
-24
@@ -2,14 +2,10 @@ import { afterEach, describe, expect, it } from 'vitest'
|
||||
import type { Worktree } from '../../../../../../shared/worktree/types'
|
||||
import { useAppStore } from '@/store/index'
|
||||
import { folderWorkspaceKey } from '../../../../../../shared/workspace-scope'
|
||||
import {
|
||||
FLOATING_TERMINAL_WORKTREE_ID,
|
||||
getDefaultSettings
|
||||
} from '../../../../../../shared/constants'
|
||||
|
||||
import {
|
||||
captureFileExplorerOperationGuard,
|
||||
getFileExplorerOperationOwner,
|
||||
getFileExplorerOperationOwnerFromState
|
||||
getFileExplorerOperationOwner
|
||||
} from '../navigation/file-explorer-operation-owner'
|
||||
|
||||
const initialState = useAppStore.getInitialState()
|
||||
@@ -31,24 +27,6 @@ afterEach(() => {
|
||||
})
|
||||
|
||||
describe('file explorer operation generations', () => {
|
||||
it('routes floating file ownership and mutations locally while a runtime is focused', () => {
|
||||
useAppStore.setState({
|
||||
settings: { ...getDefaultSettings('/home/me'), activeRuntimeEnvironmentId: 'focused-env' },
|
||||
activeWorktreeId: FLOATING_TERMINAL_WORKTREE_ID,
|
||||
activeWorkspaceExecutionHostId: 'runtime:focused-env'
|
||||
})
|
||||
const owner = getFileExplorerOperationOwner(FLOATING_TERMINAL_WORKTREE_ID)
|
||||
const guard = captureFileExplorerOperationGuard(FLOATING_TERMINAL_WORKTREE_ID, owner)
|
||||
|
||||
expect(owner).toEqual({ kind: 'local' })
|
||||
expect(
|
||||
getFileExplorerOperationOwnerFromState(useAppStore.getState(), FLOATING_TERMINAL_WORKTREE_ID)
|
||||
).toEqual(owner)
|
||||
expect(guard.route.target).toEqual({ kind: 'local' })
|
||||
expect(guard.route.expectedExecutionHostId).toBe('local')
|
||||
expect(() => guard.assertCurrent()).not.toThrow()
|
||||
})
|
||||
|
||||
it('invalidates a nested SSH mutation when that target reconnects', () => {
|
||||
useAppStore.setState({
|
||||
repos: [],
|
||||
|
||||
-27
@@ -70,27 +70,6 @@ it('runs one bounded retry across root and expanded-wave loading transitions', a
|
||||
expect(props.refreshTree).toHaveBeenCalledTimes(1)
|
||||
expect(props.loadDir).not.toHaveBeenCalled()
|
||||
})
|
||||
it.each(['fresh-empty', 'directory-error', 'root-error'])('does not loop on %s', async (state) => {
|
||||
const { props, stale } = params()
|
||||
if (state === 'fresh-empty') {
|
||||
stale.clear()
|
||||
}
|
||||
if (state === 'directory-error') {
|
||||
props.dirCache['/repo/link'].error = 'ENOENT'
|
||||
}
|
||||
if (state === 'root-error') {
|
||||
props.rootError = 'ETIMEDOUT'
|
||||
}
|
||||
const hook = renderHook(useFileExplorerTreeLoadEffects, { initialProps: props })
|
||||
for (let i = 0; i < 3; i++) {
|
||||
hook.rerender({ ...props, loadingDirPaths: new Set() })
|
||||
await act(async () => {
|
||||
await Promise.resolve()
|
||||
})
|
||||
}
|
||||
expect(props.refreshTree).not.toHaveBeenCalled()
|
||||
expect(props.loadDir).not.toHaveBeenCalled()
|
||||
})
|
||||
it.each(['root', 'directory'])('stops after a retry records a %s error', async (failure) => {
|
||||
const { props } = params()
|
||||
const pending = pendingRefresh()
|
||||
@@ -136,12 +115,6 @@ it('does not let an old workspace completion release a new workspace retry', asy
|
||||
})
|
||||
expect(second.props.refreshTree).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
it("only the user's own switch to a workspace lets its open read start a stopped WSL distro", () => {
|
||||
const { props } = params()
|
||||
noteUserWorkspaceSwitch(props.workspaceKey)
|
||||
renderHook(useFileExplorerTreeLoadEffects, { initialProps: props })
|
||||
expect(props.resetAndLoad).toHaveBeenCalledExactlyOnceWith({ wakeWslDistro: true })
|
||||
})
|
||||
it('an old switch no consumer used is not redeemed by a later mount (E9)', () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
|
||||
-233
@@ -1,233 +0,0 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { act, type ReactNode } from 'react'
|
||||
import { createRoot, type Root } from 'react-dom/client'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { CheckStatus } from '../../../../../../shared/github/pull-request-types'
|
||||
import type { Repo } from '../../../../../../shared/repo-types'
|
||||
import type { Worktree } from '../../../../../../shared/worktree/types'
|
||||
import type { ParentPrChecksRow, ParentPrChecksRowStatus } from './parent-pr-checks-row-types'
|
||||
|
||||
vi.mock('@/i18n/i18n', () => ({
|
||||
translate: (_key: string, fallback: string, values?: Record<string, unknown>) =>
|
||||
values ? fallback.replace('{{value0}}', String(values.value0)) : fallback
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/app-http-link-open', () => ({
|
||||
openAppHttpLink: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/components/ui/tooltip', () => ({
|
||||
Tooltip: ({ children }: { children: ReactNode }) => <>{children}</>,
|
||||
TooltipContent: ({ children }: { children: ReactNode }) => <span>{children}</span>,
|
||||
TooltipTrigger: ({ children }: { children: ReactNode }) => <>{children}</>
|
||||
}))
|
||||
|
||||
vi.mock('../../checks-panel/presentation/check-presentation', () => ({
|
||||
CHECK_COLOR: {
|
||||
success: 'success-color',
|
||||
failure: 'failure-color',
|
||||
pending: 'pending-color',
|
||||
neutral: 'neutral-color'
|
||||
},
|
||||
CHECK_ICON: {
|
||||
success: (props: { className?: string }) => <span data-icon="success" {...props} />,
|
||||
failure: (props: { className?: string }) => <span data-icon="failure" {...props} />,
|
||||
pending: (props: { className?: string }) => <span data-icon="pending" {...props} />,
|
||||
neutral: (props: { className?: string }) => <span data-icon="neutral" {...props} />
|
||||
},
|
||||
PullRequestIcon: (props: { className?: string }) => <span data-icon="review" {...props} />,
|
||||
prStateColor: () => 'state-color'
|
||||
}))
|
||||
|
||||
vi.mock('../../checks-panel/presentation/checks-list', () => ({
|
||||
ChecksList: () => <div data-testid="checks-list" />
|
||||
}))
|
||||
|
||||
import { FolderWorkspacePrChecksRow } from './FolderWorkspacePrChecksRow'
|
||||
|
||||
let container: HTMLDivElement
|
||||
let root: Root
|
||||
|
||||
function makeWorktree(): Worktree {
|
||||
return {
|
||||
id: 'repo-1::/child',
|
||||
path: '/child',
|
||||
head: 'abc',
|
||||
branch: 'refs/heads/feature',
|
||||
isBare: false,
|
||||
isMainWorktree: false,
|
||||
repoId: 'repo-1',
|
||||
displayName: 'Enable child worktrees',
|
||||
comment: '',
|
||||
linkedIssue: null,
|
||||
linkedPR: 12,
|
||||
linkedLinearIssue: null,
|
||||
linkedGitLabMR: null,
|
||||
linkedGitLabIssue: null,
|
||||
isArchived: false,
|
||||
isUnread: false,
|
||||
isPinned: false,
|
||||
sortOrder: 0,
|
||||
lastActivityAt: 0
|
||||
}
|
||||
}
|
||||
|
||||
function makeRepo(): Repo {
|
||||
return {
|
||||
id: 'repo-1',
|
||||
path: '/repo',
|
||||
displayName: 'Repo',
|
||||
badgeColor: '#fff',
|
||||
addedAt: 1,
|
||||
kind: 'git'
|
||||
}
|
||||
}
|
||||
|
||||
function makeRow(
|
||||
overrides: Partial<ParentPrChecksRow> & {
|
||||
status?: ParentPrChecksRowStatus
|
||||
checkTone?: CheckStatus
|
||||
} = {}
|
||||
): ParentPrChecksRow {
|
||||
return {
|
||||
id: 'row-1',
|
||||
refreshIdentity: 'row-1::feature',
|
||||
worktree: makeWorktree(),
|
||||
repo: makeRepo(),
|
||||
branch: 'feature',
|
||||
status: 'success',
|
||||
group: 'passing',
|
||||
checkTone: 'success',
|
||||
title: 'Review title',
|
||||
reviewNumber: 12,
|
||||
reviewLabel: '#12',
|
||||
reviewUrl: 'https://example.test/pr/12',
|
||||
reviewState: 'open',
|
||||
reviewStatus: 'success',
|
||||
provider: 'github',
|
||||
summary: 'Checks passing',
|
||||
detailNames: [],
|
||||
checks: [],
|
||||
isRefreshing: false,
|
||||
hasLinkedReview: true,
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
function renderRow(row: ParentPrChecksRow): void {
|
||||
act(() => {
|
||||
root.render(
|
||||
<FolderWorkspacePrChecksRow
|
||||
row={row}
|
||||
expanded={false}
|
||||
onToggle={vi.fn()}
|
||||
onLoadCheckDetails={vi.fn(async () => null)}
|
||||
/>
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
function iconNames(): string[] {
|
||||
return [...container.querySelectorAll<HTMLElement>('[data-icon]')].map(
|
||||
(icon) => icon.dataset.icon ?? ''
|
||||
)
|
||||
}
|
||||
|
||||
describe('FolderWorkspacePrChecksRow', () => {
|
||||
beforeEach(() => {
|
||||
container = document.createElement('div')
|
||||
document.body.appendChild(container)
|
||||
root = createRoot(container)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
act(() => root.unmount())
|
||||
container.remove()
|
||||
})
|
||||
|
||||
it.each([
|
||||
{
|
||||
status: 'success' as const,
|
||||
checkTone: 'success' as const,
|
||||
icon: 'success',
|
||||
color: 'success-color',
|
||||
summary: 'Checks passing'
|
||||
},
|
||||
{
|
||||
status: 'failing' as const,
|
||||
checkTone: 'failure' as const,
|
||||
icon: 'failure',
|
||||
color: 'failure-color',
|
||||
summary: 'Checks failing'
|
||||
}
|
||||
])('shows $icon status as summary metadata after the review identity', (state) => {
|
||||
renderRow(
|
||||
makeRow({
|
||||
status: state.status,
|
||||
checkTone: state.checkTone,
|
||||
summary: state.summary
|
||||
})
|
||||
)
|
||||
|
||||
expect(iconNames()).toEqual(['review', state.icon])
|
||||
expect(container.textContent).toContain('Enable child worktrees')
|
||||
expect(container.textContent).toContain(state.summary)
|
||||
expect(container.querySelector(`[data-icon="${state.icon}"]`)?.className).toContain(state.color)
|
||||
expect(container.querySelector(`[data-icon="${state.icon}"]`)?.className).not.toContain(
|
||||
'animate-spin'
|
||||
)
|
||||
})
|
||||
|
||||
it('spins pending summary status like regular pending check rows', () => {
|
||||
renderRow(
|
||||
makeRow({
|
||||
status: 'pending',
|
||||
group: 'pending',
|
||||
checkTone: 'pending',
|
||||
summary: 'Checks pending'
|
||||
})
|
||||
)
|
||||
|
||||
expect(iconNames()).toEqual(['review', 'pending'])
|
||||
expect(container.querySelector('[data-icon="pending"]')?.className).toContain('pending-color')
|
||||
expect(container.querySelector('[data-icon="pending"]')?.className).toContain('animate-spin')
|
||||
})
|
||||
|
||||
it('spins loading summary status because it uses the pending check icon', () => {
|
||||
renderRow(
|
||||
makeRow({
|
||||
status: 'loading',
|
||||
group: 'unavailable',
|
||||
checkTone: 'pending',
|
||||
reviewLabel: null,
|
||||
reviewUrl: null,
|
||||
reviewState: null,
|
||||
provider: null,
|
||||
summary: 'Checking review status...'
|
||||
})
|
||||
)
|
||||
|
||||
expect(iconNames()).toEqual(['review', 'pending'])
|
||||
expect(container.querySelector('[data-icon="pending"]')?.className).toContain('animate-spin')
|
||||
})
|
||||
|
||||
it('does not render a dashed neutral status glyph for rows without a known check state', () => {
|
||||
renderRow(
|
||||
makeRow({
|
||||
status: 'noReview',
|
||||
group: 'noPr',
|
||||
checkTone: 'neutral',
|
||||
reviewLabel: null,
|
||||
reviewUrl: null,
|
||||
reviewState: null,
|
||||
provider: null,
|
||||
summary: 'No PR linked'
|
||||
})
|
||||
)
|
||||
|
||||
expect(iconNames()).toEqual(['review'])
|
||||
expect(container.querySelector('[data-icon="neutral"]')).toBeNull()
|
||||
expect(container.textContent).toContain('No PR linked')
|
||||
})
|
||||
})
|
||||
-204
@@ -1,204 +0,0 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { renderToStaticMarkup } from 'react-dom/server'
|
||||
import { describe, expect, it, vi, type Mock } from 'vitest'
|
||||
import type { Repo } from '../../../../../../shared/repo-types'
|
||||
import type { Worktree } from '../../../../../../shared/worktree/types'
|
||||
import HostedReviewActions from './HostedReviewActions'
|
||||
import type { HostedReviewActionInfo } from './use-hosted-review-actions'
|
||||
import type { PRInfo } from '../../../../../../shared/github/pull-request-types'
|
||||
import { hostedReviewInfoFromGitHubPRInfo as gitHubPRToChecksPanelReview } from '../../../../../../shared/source-control/hosted-review/hosted-review-github'
|
||||
|
||||
const actionMocks = vi.hoisted(
|
||||
(): {
|
||||
actionError: string | null
|
||||
handleMarkReadyForReview: Mock
|
||||
handleCloseReview: Mock
|
||||
} => ({
|
||||
actionError: null,
|
||||
handleMarkReadyForReview: vi.fn(),
|
||||
handleCloseReview: vi.fn()
|
||||
})
|
||||
)
|
||||
|
||||
vi.mock('@/store/index', () => ({ useAppStore: () => false }))
|
||||
vi.mock('./use-hosted-review-actions', () => ({
|
||||
useHostedReviewActions: () => ({
|
||||
merging: false,
|
||||
readying: false,
|
||||
stateUpdating: null,
|
||||
actionError: actionMocks.actionError,
|
||||
handleMerge: vi.fn(),
|
||||
handleAutoMerge: vi.fn(),
|
||||
handleMarkReadyForReview: actionMocks.handleMarkReadyForReview,
|
||||
handleCloseReview: actionMocks.handleCloseReview,
|
||||
handleReopenReview: vi.fn()
|
||||
})
|
||||
}))
|
||||
vi.mock('@/components/ui/dropdown-menu', () => ({
|
||||
DropdownMenu: ({ children }: { children: ReactNode }) => <>{children}</>,
|
||||
DropdownMenuTrigger: ({ children }: { children: ReactNode }) => <>{children}</>,
|
||||
DropdownMenuContent: ({ children }: { children: ReactNode }) => <div>{children}</div>,
|
||||
DropdownMenuItem: ({ children }: { children: ReactNode }) => <div>{children}</div>,
|
||||
DropdownMenuSeparator: () => <hr />
|
||||
}))
|
||||
|
||||
const repo = { id: 'repo-1', path: '/repo' } as Repo
|
||||
const worktree = { id: 'worktree-1' } as Worktree
|
||||
|
||||
function renderDraft(provider: HostedReviewActionInfo['provider']): string {
|
||||
return renderToStaticMarkup(
|
||||
<HostedReviewActions
|
||||
review={{
|
||||
provider,
|
||||
number: 42,
|
||||
state: 'draft',
|
||||
status: 'success',
|
||||
mergeable: 'UNKNOWN'
|
||||
}}
|
||||
repo={repo}
|
||||
worktree={worktree}
|
||||
onRefreshReview={vi.fn().mockResolvedValue(undefined)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
describe('HostedReviewActions draft state', () => {
|
||||
it.each([
|
||||
['github', 'PR'],
|
||||
['gitlab', 'MR']
|
||||
] as const)('renders Ready as primary and Close as secondary for %s', (provider, shortLabel) => {
|
||||
const markup = renderDraft(provider)
|
||||
|
||||
expect(markup).toContain('Mark ready for review')
|
||||
expect(markup).toContain(`Close ${shortLabel}`)
|
||||
expect(markup).not.toContain('Merge')
|
||||
expect(markup).not.toContain('auto-merge')
|
||||
})
|
||||
|
||||
it.each(['azure-devops', 'gitea'] as const)(
|
||||
'does not misroute unsupported %s drafts through GitHub',
|
||||
(provider) => {
|
||||
expect(renderDraft(provider)).toBe('')
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
describe('HostedReviewActions GitHub merge selection', () => {
|
||||
it.each([
|
||||
{
|
||||
mergeable: 'MERGEABLE',
|
||||
mergeStateStatus: 'UNSTABLE',
|
||||
status: 'pending',
|
||||
label: 'Squash and merge',
|
||||
autoMerge: false
|
||||
},
|
||||
{
|
||||
mergeable: 'MERGEABLE',
|
||||
mergeStateStatus: 'UNSTABLE',
|
||||
status: 'failure',
|
||||
label: 'Squash and merge',
|
||||
autoMerge: false
|
||||
},
|
||||
{
|
||||
mergeable: 'UNKNOWN',
|
||||
mergeStateStatus: 'UNKNOWN',
|
||||
status: 'pending',
|
||||
label: 'Checking',
|
||||
autoMerge: false
|
||||
},
|
||||
{ mergeable: 'UNKNOWN', status: 'pending', label: 'Checking', autoMerge: false },
|
||||
{
|
||||
mergeable: 'MERGEABLE',
|
||||
mergeStateStatus: 'BLOCKED',
|
||||
status: 'pending',
|
||||
label: 'Enable auto-merge',
|
||||
autoMerge: true
|
||||
},
|
||||
{
|
||||
mergeable: 'MERGEABLE',
|
||||
mergeStateStatus: 'BLOCKED',
|
||||
reviewDecision: 'REVIEW_REQUIRED',
|
||||
status: 'success',
|
||||
label: 'Enable auto-merge',
|
||||
autoMerge: true
|
||||
},
|
||||
{
|
||||
mergeable: 'MERGEABLE',
|
||||
mergeStateStatus: 'BLOCKED',
|
||||
autoMergeEnabled: true,
|
||||
status: 'pending',
|
||||
label: 'Disable auto-merge',
|
||||
autoMerge: true
|
||||
},
|
||||
{
|
||||
mergeable: 'MERGEABLE',
|
||||
mergeStateStatus: 'UNSTABLE',
|
||||
mergeQueueRequired: true,
|
||||
status: 'pending',
|
||||
label: 'Merge when ready',
|
||||
autoMerge: false
|
||||
},
|
||||
{
|
||||
mergeable: 'CONFLICTING',
|
||||
mergeStateStatus: 'DIRTY',
|
||||
mergeQueueRequired: true,
|
||||
status: 'pending',
|
||||
label: 'Conflicts',
|
||||
autoMerge: false
|
||||
}
|
||||
] as const)('renders $label for $mergeStateStatus / $status', (scenario) => {
|
||||
const pr: PRInfo = {
|
||||
number: 31259,
|
||||
title: 'fix(i18n): restore localized editor safety copy',
|
||||
state: 'open',
|
||||
url: 'https://github.com/stablyai/orca/pull/31259',
|
||||
updatedAt: '2026-10-21T16:31:00Z',
|
||||
headSha: 'e4353ead012043474fc723e50aea198a76d32979',
|
||||
autoMergeAllowed: true,
|
||||
checksStatus: scenario.status,
|
||||
...scenario
|
||||
}
|
||||
const markup = renderToStaticMarkup(
|
||||
<HostedReviewActions
|
||||
review={gitHubPRToChecksPanelReview(pr)}
|
||||
githubPR={pr}
|
||||
repo={repo}
|
||||
worktree={worktree}
|
||||
onRefreshReview={vi.fn().mockResolvedValue(undefined)}
|
||||
/>
|
||||
)
|
||||
expect(markup).toContain(scenario.label)
|
||||
expect(markup.includes('Enable auto-merge') || markup.includes('Disable auto-merge')).toBe(
|
||||
scenario.autoMerge
|
||||
)
|
||||
if (scenario.label === 'Checking' || scenario.label === 'Conflicts') {
|
||||
expect(markup).toMatch(/<button[^>]*disabled=""/)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('HostedReviewActions merged state', () => {
|
||||
it('keeps a failed merge error visible when the reread shows the review merged', () => {
|
||||
actionMocks.actionError = 'Connection lost'
|
||||
try {
|
||||
const markup = renderToStaticMarkup(
|
||||
<HostedReviewActions
|
||||
review={{
|
||||
provider: 'github',
|
||||
number: 42,
|
||||
state: 'merged',
|
||||
status: 'success',
|
||||
mergeable: 'UNKNOWN'
|
||||
}}
|
||||
repo={repo}
|
||||
worktree={worktree}
|
||||
onRefreshReview={vi.fn().mockResolvedValue(undefined)}
|
||||
/>
|
||||
)
|
||||
expect(markup).toContain('Delete Workspace')
|
||||
expect(markup).toContain('Connection lost')
|
||||
} finally {
|
||||
actionMocks.actionError = null
|
||||
}
|
||||
})
|
||||
})
|
||||
-188
@@ -1,188 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { resolveDropdownItems } from '../panel/source-control-dropdown-items'
|
||||
import type {
|
||||
DropdownActionInputs,
|
||||
DropdownItem
|
||||
} from '../../../../../../shared/source-control/dropdown/source-control-dropdown-item-types'
|
||||
|
||||
function inputs(overrides: Partial<DropdownActionInputs> = {}): DropdownActionInputs {
|
||||
return {
|
||||
stagedCount: 0,
|
||||
hasUnstagedChanges: false,
|
||||
hasStageableChanges: false,
|
||||
hasPartiallyStagedChanges: false,
|
||||
hasMessage: false,
|
||||
hasUnresolvedConflicts: false,
|
||||
isCommitting: false,
|
||||
isRemoteOperationActive: false,
|
||||
upstreamStatus: undefined,
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
describe('resolveDropdownItems Create PR intent', () => {
|
||||
it('enables the push-before-PR recovery action when review creation is only blocked by unpushed commits', () => {
|
||||
const items = resolveDropdownItems(
|
||||
inputs({
|
||||
upstreamStatus: { hasUpstream: true, ahead: 2, behind: 0 },
|
||||
hostedReviewCreation: {
|
||||
provider: 'github',
|
||||
review: null,
|
||||
canCreate: false,
|
||||
blockedReason: 'needs_push',
|
||||
nextAction: 'push',
|
||||
reviewLookupOutcome: 'not_found'
|
||||
}
|
||||
})
|
||||
)
|
||||
const byKind = Object.fromEntries(
|
||||
items.filter((e) => e.kind !== 'separator').map((e) => [e.kind, e])
|
||||
)
|
||||
expect(byKind.create_pr.disabled).toBe(false)
|
||||
expect(byKind.create_pr.hint).toBe('Push first')
|
||||
expect(byKind.push_create_pr.label).toBe('Push before PR')
|
||||
expect(byKind.push_create_pr.disabled).toBe(false)
|
||||
})
|
||||
|
||||
it.each([
|
||||
{
|
||||
name: 'push',
|
||||
provider: 'github' as const,
|
||||
upstreamStatus: { hasUpstream: true as const, ahead: 2, behind: 0 },
|
||||
blockedReason: 'needs_push' as const,
|
||||
expectedTitle: 'Push local commits before creating a pull request'
|
||||
},
|
||||
{
|
||||
name: 'force push',
|
||||
provider: 'gitlab' as const,
|
||||
upstreamStatus: {
|
||||
hasUpstream: true as const,
|
||||
upstreamName: 'origin/feature',
|
||||
ahead: 2,
|
||||
behind: 1,
|
||||
behindCommitsArePatchEquivalent: true
|
||||
},
|
||||
blockedReason: 'needs_sync' as const,
|
||||
expectedTitle: 'Force push with lease before creating a merge request'
|
||||
}
|
||||
])(
|
||||
'keeps $name-before-review recovery available when review lookup is unavailable',
|
||||
({ provider, upstreamStatus, blockedReason, expectedTitle }) => {
|
||||
const items = resolveDropdownItems(
|
||||
inputs({
|
||||
branchCommitsAhead: 2,
|
||||
upstreamStatus,
|
||||
hostedReviewCreation: {
|
||||
provider,
|
||||
review: null,
|
||||
canCreate: false,
|
||||
blockedReason,
|
||||
nextAction: blockedReason === 'needs_push' ? 'push' : 'sync',
|
||||
reviewLookupOutcome: 'unavailable'
|
||||
}
|
||||
})
|
||||
)
|
||||
const pushCreate = items.find((item): item is DropdownItem => item.kind === 'push_create_pr')
|
||||
|
||||
expect(pushCreate?.disabled).toBe(false)
|
||||
expect(pushCreate?.title).toBe(expectedTitle)
|
||||
expect(pushCreate?.hint).toBeUndefined()
|
||||
}
|
||||
)
|
||||
|
||||
it('uses GitLab MR copy for create and push-before-create rows', () => {
|
||||
const items = resolveDropdownItems(
|
||||
inputs({
|
||||
upstreamStatus: { hasUpstream: true, ahead: 2, behind: 0 },
|
||||
hostedReviewCreation: {
|
||||
provider: 'gitlab',
|
||||
review: null,
|
||||
canCreate: false,
|
||||
blockedReason: 'needs_push',
|
||||
nextAction: 'push',
|
||||
reviewLookupOutcome: 'not_found'
|
||||
}
|
||||
})
|
||||
)
|
||||
const byKind = Object.fromEntries(
|
||||
items.filter((e) => e.kind !== 'separator').map((e) => [e.kind, e])
|
||||
)
|
||||
expect(byKind.create_pr.label).toBe('Create MR')
|
||||
expect(byKind.create_pr.hint).toBe('Push first')
|
||||
expect(byKind.create_pr.disabled).toBe(false)
|
||||
expect(byKind.push_create_pr.label).toBe('Push before MR')
|
||||
expect(byKind.push_create_pr.title).toBe('Push local commits before creating a merge request')
|
||||
expect(byKind.push_create_pr.disabled).toBe(false)
|
||||
})
|
||||
|
||||
it.each(['azure-devops', 'gitea'] as const)(
|
||||
'enables push-before-PR recovery for %s review creation',
|
||||
(provider) => {
|
||||
const items = resolveDropdownItems(
|
||||
inputs({
|
||||
upstreamStatus: { hasUpstream: true, ahead: 2, behind: 0 },
|
||||
hostedReviewCreation: {
|
||||
provider,
|
||||
review: null,
|
||||
canCreate: false,
|
||||
blockedReason: 'needs_push',
|
||||
nextAction: 'push',
|
||||
reviewLookupOutcome: 'not_found'
|
||||
}
|
||||
})
|
||||
)
|
||||
const byKind = Object.fromEntries(
|
||||
items.filter((e) => e.kind !== 'separator').map((e) => [e.kind, e])
|
||||
)
|
||||
expect(byKind.create_pr.label).toBe('Create PR')
|
||||
expect(byKind.create_pr.hint).toBe('Push first')
|
||||
expect(byKind.create_pr.disabled).toBe(false)
|
||||
expect(byKind.push_create_pr.label).toBe('Push before PR')
|
||||
expect(byKind.push_create_pr.title).toBe('Push local commits before creating a pull request')
|
||||
expect(byKind.push_create_pr.disabled).toBe(false)
|
||||
}
|
||||
)
|
||||
|
||||
it.each([
|
||||
['azure-devops', 'Set ORCA_AZURE_DEVOPS_TOKEN in this environment'],
|
||||
['gitea', 'Set ORCA_GITEA_TOKEN in this environment']
|
||||
] as const)('uses token auth copy when %s PR creation needs authentication', (provider, hint) => {
|
||||
const items = resolveDropdownItems(
|
||||
inputs({
|
||||
upstreamStatus: { hasUpstream: true, ahead: 0, behind: 0 },
|
||||
hostedReviewCreation: {
|
||||
provider,
|
||||
review: null,
|
||||
canCreate: false,
|
||||
blockedReason: 'auth_required',
|
||||
nextAction: 'authenticate',
|
||||
reviewLookupOutcome: 'not_found'
|
||||
}
|
||||
})
|
||||
)
|
||||
const byKind = Object.fromEntries(
|
||||
items.filter((e) => e.kind !== 'separator').map((e) => [e.kind, e])
|
||||
)
|
||||
expect(byKind.create_pr.hint).toBe(hint)
|
||||
})
|
||||
|
||||
it('uses GitLab auth copy when MR creation needs authentication', () => {
|
||||
const items = resolveDropdownItems(
|
||||
inputs({
|
||||
upstreamStatus: { hasUpstream: true, ahead: 0, behind: 0 },
|
||||
hostedReviewCreation: {
|
||||
provider: 'gitlab',
|
||||
review: null,
|
||||
canCreate: false,
|
||||
blockedReason: 'auth_required',
|
||||
nextAction: 'authenticate',
|
||||
reviewLookupOutcome: 'not_found'
|
||||
}
|
||||
})
|
||||
)
|
||||
const byKind = Object.fromEntries(
|
||||
items.filter((e) => e.kind !== 'separator').map((e) => [e.kind, e])
|
||||
)
|
||||
expect(byKind.create_pr.hint).toBe('Run glab auth login in this environment')
|
||||
})
|
||||
})
|
||||
@@ -130,23 +130,4 @@ describe('Chat settings deep links', () => {
|
||||
)
|
||||
await waitFor(() => expect(scrollIntoView.mock.contexts).toContain(visuals))
|
||||
})
|
||||
|
||||
it('activates Chat and scrolls to the separate naming section for its deep link', async () => {
|
||||
const scrollIntoView = vi.spyOn(HTMLElement.prototype, 'scrollIntoView')
|
||||
const { container } = render(
|
||||
<NavigationHarness initialSection="appearance" targetSection="chat-names" />
|
||||
)
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('status', { name: 'Selected settings page' }).textContent).toBe(
|
||||
'chat'
|
||||
)
|
||||
})
|
||||
const names = container.querySelector('#chat-names')
|
||||
const appearance = container.querySelector('#chat-appearance')
|
||||
expect(names?.parentElement).toBe(appearance?.parentElement)
|
||||
expect(names?.querySelector('[role="switch"]')).toBe(
|
||||
screen.getByRole('switch', { name: 'Name chats automatically' })
|
||||
)
|
||||
await waitFor(() => expect(scrollIntoView.mock.contexts).toContain(names))
|
||||
})
|
||||
})
|
||||
|
||||
-47
@@ -123,53 +123,6 @@ describe('EphemeralVmsPane', () => {
|
||||
document.body.replaceChildren()
|
||||
})
|
||||
|
||||
it('renders the skill panel and recipe, and opens the composer with the recipe selected', async () => {
|
||||
const container = await renderPane()
|
||||
|
||||
await vi.waitFor(() => expect(container.textContent).toContain('Cloud Sandbox'))
|
||||
await vi.waitFor(() => expect(container.textContent).toContain('Cloud VM setup skill'))
|
||||
expect(container.textContent).toContain('What the skill does, with you')
|
||||
const useButton = [...container.querySelectorAll('button')].find(
|
||||
(button) => button.textContent === 'Use in workspace'
|
||||
)
|
||||
expect(useButton).toBeDefined()
|
||||
|
||||
await act(async () => {
|
||||
useButton?.dispatchEvent(new MouseEvent('click', { bubbles: true }))
|
||||
})
|
||||
|
||||
expect(storeMocks.openModal).toHaveBeenCalledWith('new-workspace-composer', {
|
||||
initialRepoId: 'repo-1',
|
||||
initialEphemeralVmRecipeId: 'cloud-sandbox',
|
||||
telemetrySource: 'settings'
|
||||
})
|
||||
})
|
||||
|
||||
it('does not schedule a reset when clipboard completion arrives after unmount', async () => {
|
||||
let finishClipboard!: () => void
|
||||
vi.mocked(window.api.ui.writeClipboardText).mockReturnValueOnce(
|
||||
new Promise<void>((resolve) => {
|
||||
finishClipboard = resolve
|
||||
})
|
||||
)
|
||||
const container = await renderPane()
|
||||
const setTimeout = vi.spyOn(window, 'setTimeout')
|
||||
try {
|
||||
await act(async () => {
|
||||
container.querySelector<HTMLButtonElement>('button[aria-label="Copy"]')?.click()
|
||||
})
|
||||
await act(async () => roots.pop()?.unmount())
|
||||
setTimeout.mockClear()
|
||||
await act(async () => {
|
||||
finishClipboard()
|
||||
await Promise.resolve()
|
||||
})
|
||||
expect(setTimeout.mock.calls.filter(([, delay]) => delay === 1500)).toHaveLength(0)
|
||||
} finally {
|
||||
setTimeout.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
it('ignores an older refresh that finishes after a plugin-content refresh', async () => {
|
||||
const listRecipeCatalog = window.api.ephemeralVm.listRecipeCatalog as ReturnType<typeof vi.fn>
|
||||
let resolveInitial!: (catalog: unknown[]) => void
|
||||
|
||||
-85
@@ -48,53 +48,6 @@ function makeState(overrides: Partial<AddRepoSkipFinalizationState>): AddRepoSki
|
||||
}
|
||||
|
||||
describe('finalizeImportedRepoAfterSkip', () => {
|
||||
it('keeps skipped imported worktrees visible without activating a worktree', () => {
|
||||
const state = makeState({
|
||||
activeRepoId: 'repo-old',
|
||||
filterRepoIds: ['repo-old'],
|
||||
showActiveOnly: true,
|
||||
hideDefaultBranchWorkspace: false,
|
||||
worktreesByRepo: {
|
||||
'repo-new': [makeWorktree({ id: 'repo-new::/repo/feature', repoId: 'repo-new' })]
|
||||
}
|
||||
})
|
||||
|
||||
finalizeImportedRepoAfterSkip(state, 'repo-new')
|
||||
|
||||
expect(state.setActiveRepo).toHaveBeenCalledWith('repo-new')
|
||||
expect(state.setFilterRepoIds).toHaveBeenCalledWith(['repo-old', 'repo-new'])
|
||||
expect(state.setShowActiveOnly).toHaveBeenCalledWith(false)
|
||||
expect(state.setHideDefaultBranchWorkspace).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('leaves the project filter off when the import lands with no filter', () => {
|
||||
const state = makeState({ filterRepoIds: [] })
|
||||
|
||||
finalizeImportedRepoAfterSkip(state, 'repo-new')
|
||||
|
||||
expect(state.setFilterRepoIds).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('clears default-branch hiding when it would hide every imported worktree', () => {
|
||||
const state = makeState({
|
||||
hideDefaultBranchWorkspace: true,
|
||||
worktreesByRepo: {
|
||||
'repo-new': [
|
||||
makeWorktree({
|
||||
id: 'repo-new::/repo/main',
|
||||
repoId: 'repo-new',
|
||||
isMainWorktree: true,
|
||||
branch: 'refs/heads/main'
|
||||
})
|
||||
]
|
||||
}
|
||||
})
|
||||
|
||||
finalizeImportedRepoAfterSkip(state, 'repo-new')
|
||||
|
||||
expect(state.setHideDefaultBranchWorkspace).toHaveBeenCalledWith(false)
|
||||
})
|
||||
|
||||
it('preserves default-branch hiding when importing a folder project with only its root', () => {
|
||||
const state = makeState({
|
||||
hideDefaultBranchWorkspace: true,
|
||||
@@ -178,42 +131,4 @@ describe('finalizeImportedRepoAfterSkip', () => {
|
||||
|
||||
expect(state.setAlwaysShowDefaultBranchWorkspace).toHaveBeenCalledWith(true)
|
||||
})
|
||||
|
||||
it('leaves the default-branch exemption alone when sleeping workspaces are shown', () => {
|
||||
const state = makeState({
|
||||
showSleepingWorkspaces: true,
|
||||
alwaysShowDefaultBranchWorkspace: false,
|
||||
worktreesByRepo: {
|
||||
'repo-new': [
|
||||
makeWorktree({
|
||||
id: 'repo-new::/repo/main',
|
||||
repoId: 'repo-new',
|
||||
isMainWorktree: true,
|
||||
branch: 'refs/heads/main'
|
||||
})
|
||||
]
|
||||
}
|
||||
})
|
||||
|
||||
finalizeImportedRepoAfterSkip(state, 'repo-new')
|
||||
|
||||
expect(state.setAlwaysShowDefaultBranchWorkspace).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('still reveals the imported repo when it has no discovered worktrees yet', () => {
|
||||
const state = makeState({
|
||||
activeRepoId: 'repo-old',
|
||||
filterRepoIds: ['repo-old'],
|
||||
showActiveOnly: true,
|
||||
hideDefaultBranchWorkspace: true,
|
||||
worktreesByRepo: { 'repo-new': [] }
|
||||
})
|
||||
|
||||
finalizeImportedRepoAfterSkip(state, 'repo-new')
|
||||
|
||||
expect(state.setActiveRepo).toHaveBeenCalledWith('repo-new')
|
||||
expect(state.setFilterRepoIds).toHaveBeenCalledWith(['repo-old', 'repo-new'])
|
||||
expect(state.setShowActiveOnly).toHaveBeenCalledWith(false)
|
||||
expect(state.setHideDefaultBranchWorkspace).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
-13
@@ -69,19 +69,6 @@ async function review(): Promise<void> {
|
||||
}
|
||||
|
||||
describe('nested deletion confirmation', () => {
|
||||
it('lists hidden Git registrations and warns about lost changes before any deletion', async () => {
|
||||
render(
|
||||
<DeleteNestedWorktreesDialog target={target} worktreeName="parent" dismissToast={vi.fn()} />
|
||||
)
|
||||
await review()
|
||||
expect(mocks.preview).toHaveBeenCalledWith({ worktreeId: target.id, hostId: 'local' })
|
||||
expect(screen.getByRole('dialog')).toHaveTextContent('including uncommitted changes')
|
||||
expect(screen.getAllByRole('listitem')).toHaveLength(2)
|
||||
expect(mocks.remove).not.toHaveBeenCalled()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Cancel' }))
|
||||
expect(mocks.remove).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('sends only the reviewed set to the confirmed host and refreshes after success', async () => {
|
||||
const dismissToast = vi.fn()
|
||||
const onDeleted = vi.fn()
|
||||
|
||||
-12
@@ -81,18 +81,6 @@ afterEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('keeps the main Force Delete button a one-time action', async () => {
|
||||
const savePreference = vi.fn().mockResolvedValue(undefined)
|
||||
const { container, onForceDelete } = renderRecovery(savePreference)
|
||||
expect(container.querySelector('button[aria-label="More force delete options"]')).not.toBeNull()
|
||||
expect(document.querySelector('[role="menuitem"]')).toBeNull()
|
||||
|
||||
await act(async () => click(container, 'button[data-variant="destructive"]'))
|
||||
|
||||
expect(savePreference).not.toHaveBeenCalled()
|
||||
expect(onForceDelete).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('keeps portaled menu focus from triggering toast focus restoration', async () => {
|
||||
const { container, onOutsideBlur } = renderRecovery(vi.fn())
|
||||
const trigger = container.querySelector('button[aria-label="More force delete options"]')
|
||||
|
||||
-22
@@ -100,28 +100,6 @@ afterEach(cleanup)
|
||||
beforeEach(() => vi.clearAllMocks())
|
||||
|
||||
describe('loaded deletion disclosure and existing hydration', () => {
|
||||
it('shows pending and failed detail checks alongside a known dirty warning', async () => {
|
||||
let failRead: (error: Error) => void = () => {
|
||||
throw new Error('Status read has not started')
|
||||
}
|
||||
vi.mocked(getRuntimeGitStatus).mockImplementationOnce(
|
||||
() =>
|
||||
new Promise((_resolve, reject) => {
|
||||
failRead = reject
|
||||
})
|
||||
)
|
||||
render(<Preview worktree={target('known-dirty')} />)
|
||||
expect(screen.getByText('Uncommitted or untracked changes')).toBeVisible()
|
||||
expect(screen.getByText('· Checking…')).toBeVisible()
|
||||
await act(async () => {
|
||||
failRead(new Error('Details unavailable'))
|
||||
})
|
||||
expect(screen.getByText('Uncommitted or untracked changes')).toBeVisible()
|
||||
expect(screen.getByText('· Details unavailable')).toBeVisible()
|
||||
expect(screen.queryByRole('button')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText(/No uncommitted|0 changes/)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('expands and collapses a hydrated snapshot without requesting status again', async () => {
|
||||
vi.mocked(getRuntimeGitStatus).mockResolvedValue({
|
||||
entries: [
|
||||
|
||||
-156
@@ -1,156 +0,0 @@
|
||||
import { renderToStaticMarkup } from 'react-dom/server'
|
||||
import type { ReactNode } from 'react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { i18n } from '@/i18n/i18n'
|
||||
import { WorktreeCardStatusSlot } from './WorktreeCardStatusSlot'
|
||||
import { getWorktreeCardPrDisplay } from '../review/worktree-card-pr-display'
|
||||
import { gitlabMrCheckCases } from '../../../../../../shared/source-control/checks/gitlab/gitlab-mr-checks.test-fixture'
|
||||
import { buildParentPrChecksProjection } from '../../../right-sidebar/hosted-review/folder-checks/parent-pr-checks-rows'
|
||||
import { FolderWorkspacePrChecksRow } from '../../../right-sidebar/hosted-review/folder-checks/FolderWorkspacePrChecksRow'
|
||||
import { presentGitLabMRMergeState } from '../../../right-sidebar/hosted-review/provider-actions/gitlab-mr-merge-state'
|
||||
import { getActiveChecksStatus } from '../../../right-sidebar/checks-panel/active-checks-status'
|
||||
import {
|
||||
ReviewChecksBadge,
|
||||
ReviewChecksIndicator
|
||||
} from '../metadata/WorktreeCardMetadataStatusBadges'
|
||||
import { WorktreeReferenceStack } from '../review/WorktreeReferenceStack'
|
||||
import type { WorkspaceReferenceDetailsMap } from '../review/workspace-reference-details'
|
||||
import { getFolderWorkspaceCardPrDisplay } from '../review/folder-workspace-card-pr-display'
|
||||
import { formatHostedReviewLabel } from '../../../status-bar/hosted-review-label'
|
||||
import { reviewRefreshIntervalMs } from '../../../../../../shared/review-refresh-policy'
|
||||
import { summarizeWorkspaceReviewChecks } from '../../../../../../shared/workspace-review-checks'
|
||||
import { getWorkspaceAttachmentKey } from '../../../../../../shared/workspace-attachments/workspace-attachment-normalization'
|
||||
import {
|
||||
referenceRepo,
|
||||
referenceWorkspace
|
||||
} from '../review/workspace-reference-fixtures.test-support'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
status: 'active',
|
||||
sleeping: false
|
||||
}))
|
||||
|
||||
vi.mock('@/components/ui/tooltip', () => ({
|
||||
Tooltip: ({ children }: { children: ReactNode }) => <span data-tooltip-root="">{children}</span>,
|
||||
TooltipContent: ({ children }: { children: ReactNode }) => (
|
||||
<span data-tooltip-content="">{children}</span>
|
||||
),
|
||||
TooltipTrigger: ({ children }: { children: ReactNode }) => <>{children}</>
|
||||
}))
|
||||
|
||||
vi.mock('../activity/use-worktree-activity-status', () => ({
|
||||
useWorktreeActivityStatus: () => mocks.status
|
||||
}))
|
||||
|
||||
vi.mock('../../workspace-actions/sleep/use-worktree-sleep-state', () => ({
|
||||
useIsSleepingWorktree: () => mocks.sleeping
|
||||
}))
|
||||
|
||||
describe('WorktreeCardStatusSlot', () => {
|
||||
afterEach(async () => {
|
||||
await i18n.changeLanguage('en')
|
||||
})
|
||||
beforeEach(() => {
|
||||
mocks.status = 'active'
|
||||
mocks.sleeping = false
|
||||
})
|
||||
|
||||
it.each(gitlabMrCheckCases)(
|
||||
'presents the normalized $name without claiming passing',
|
||||
({ review: mapped, cardLabel, tone, rowSummary, mergeLabel, directMergeAvailable }) => {
|
||||
const { status } = mapped
|
||||
const worktree = { ...referenceWorkspace, linkedPR: null, linkedGitLabMR: 7 }
|
||||
const hostedReviewCache = {
|
||||
'local::repo::feature': { data: mapped, fetchedAt: 1, linkedReviewHintKey: 'gitlab:7' }
|
||||
}
|
||||
const context = {
|
||||
worktrees: [worktree],
|
||||
repos: [referenceRepo],
|
||||
settings: null,
|
||||
hostedReviewCache,
|
||||
prCache: {},
|
||||
checksCache: {}
|
||||
}
|
||||
const projection = buildParentPrChecksProjection(context)
|
||||
const card = renderToStaticMarkup(
|
||||
<WorktreeCardStatusSlot
|
||||
worktreeId={worktree.id}
|
||||
showStatus
|
||||
showUnreadAction={false}
|
||||
isUnread={false}
|
||||
unreadTooltip=""
|
||||
onPointerDown={vi.fn()}
|
||||
onToggleUnread={vi.fn()}
|
||||
newCardStyle
|
||||
prDisplay={getWorktreeCardPrDisplay(mapped, null, 7)}
|
||||
/>
|
||||
)
|
||||
expect(card).toContain(cardLabel)
|
||||
expect(card).toContain(tone)
|
||||
expect(card).not.toContain('Passing')
|
||||
const row = renderToStaticMarkup(
|
||||
<FolderWorkspacePrChecksRow
|
||||
row={projection.rows[0]}
|
||||
expanded={false}
|
||||
onToggle={vi.fn()}
|
||||
onLoadCheckDetails={async () => null}
|
||||
/>
|
||||
)
|
||||
expect(row).toContain(rowSummary)
|
||||
expect(row).not.toMatch(/Checks passing|lucide-circle-check/)
|
||||
expect(projection.summary.passing).toBe(0)
|
||||
expect(presentGitLabMRMergeState(mapped)).toMatchObject({
|
||||
label: mergeLabel,
|
||||
directMergeAvailable
|
||||
})
|
||||
expect(
|
||||
getActiveChecksStatus({
|
||||
...context,
|
||||
activeWorktreeId: worktree.id,
|
||||
worktreesByRepo: { repo: [worktree] }
|
||||
})
|
||||
).toBe(status)
|
||||
const item = { provider: 'gitlab', type: 'mr', number: 7 } as const
|
||||
const details = {
|
||||
[getWorkspaceAttachmentKey(item)]: { title: mapped.title, url: mapped.url, review: mapped }
|
||||
} satisfies WorkspaceReferenceDetailsMap
|
||||
expect(summarizeWorkspaceReviewChecks([item], details)).toMatchObject({
|
||||
state: status,
|
||||
passed: 0
|
||||
})
|
||||
expect(
|
||||
renderToStaticMarkup(<WorktreeReferenceStack items={[item]} details={details} />)
|
||||
).toContain(tone)
|
||||
const checkLabel = { failure: 'Failing', pending: 'Pending', neutral: '' }[status]
|
||||
for (const Badge of [ReviewChecksBadge, ReviewChecksIndicator]) {
|
||||
const badge = renderToStaticMarkup(<Badge status={mapped.status} />)
|
||||
expect(badge).toEqual(checkLabel ? expect.stringContaining(`Checks: ${checkLabel}`) : '')
|
||||
}
|
||||
expect(
|
||||
getFolderWorkspaceCardPrDisplay({
|
||||
...context,
|
||||
folderWorkspaceId: 'folder',
|
||||
worktreeMap: new Map([[worktree.id, worktree]]),
|
||||
repoMap: new Map([['repo', referenceRepo]]),
|
||||
worktreeLineageById: {},
|
||||
workspaceLineageByChildKey: {
|
||||
child: {
|
||||
childWorkspaceKey: `worktree:${worktree.id}`,
|
||||
parentWorkspaceKey: 'folder:folder',
|
||||
origin: 'cli',
|
||||
capture: { source: 'env-workspace', confidence: 'inferred' },
|
||||
createdAt: 1
|
||||
}
|
||||
}
|
||||
})
|
||||
).toMatchObject({ state: 'open', status })
|
||||
expect(formatHostedReviewLabel(mapped)).toBe(`MR !7 Open, ${status}`)
|
||||
expect(reviewRefreshIntervalMs({ state: mapped.state, checksStatus: mapped.status })).toBe(
|
||||
120_000
|
||||
)
|
||||
expect(reviewRefreshIntervalMs({ state: 'merged', checksStatus: mapped.status })).toBe(
|
||||
status === 'pending' ? 60_000 : null
|
||||
)
|
||||
}
|
||||
)
|
||||
})
|
||||
-224
@@ -1,224 +0,0 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { act, type ReactNode } from 'react'
|
||||
import { createRoot, type Root } from 'react-dom/client'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
WorktreeCardDisplayMenuItem,
|
||||
WorktreeCardLayoutMenuItem,
|
||||
WorktreeCardPropertiesMenuItem
|
||||
} from './worktree-card-option-items'
|
||||
|
||||
;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true
|
||||
|
||||
const setWorktreeCardMode = vi.fn()
|
||||
const setWorktreeCardProperties = vi.fn()
|
||||
const setAgentActivityDisplayMode = vi.fn()
|
||||
const setShowAgentModels = vi.fn()
|
||||
let showAgentModels: boolean | undefined
|
||||
|
||||
let settings = { compactWorktreeCards: false, experimentalNewWorktreeCardStyle: false }
|
||||
let projectGroups: unknown[] = []
|
||||
let worktreeCardProperties = [
|
||||
'status',
|
||||
'unread',
|
||||
'issue',
|
||||
'linear-issue',
|
||||
'jira-issue',
|
||||
'pr',
|
||||
'automation',
|
||||
'comment',
|
||||
'ports',
|
||||
'inline-agents'
|
||||
]
|
||||
|
||||
vi.mock('@/store', () => ({
|
||||
useAppStore: (selector: (state: unknown) => unknown) =>
|
||||
selector({
|
||||
agentActivityDisplayMode: 'compact',
|
||||
persistedUIReady: true,
|
||||
showAgentModels,
|
||||
setShowAgentModels,
|
||||
projectGroups,
|
||||
setAgentActivityDisplayMode,
|
||||
setWorktreeCardMode,
|
||||
setWorktreeCardProperties,
|
||||
settings,
|
||||
worktreeCardProperties
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/components/ui/dropdown-menu', async () => {
|
||||
const ReactModule = await import('react')
|
||||
type RadioItemProps = {
|
||||
children: ReactNode
|
||||
onSelect?: (event: { preventDefault: () => void }) => void
|
||||
onValueChange?: (value: string) => void
|
||||
value: string
|
||||
}
|
||||
return {
|
||||
DropdownMenuCheckboxItem: ({
|
||||
children,
|
||||
checked,
|
||||
onCheckedChange
|
||||
}: {
|
||||
children: ReactNode
|
||||
checked?: boolean
|
||||
onCheckedChange?: (checked: boolean) => void
|
||||
}) => (
|
||||
<button
|
||||
type="button"
|
||||
data-checked={checked ? 'true' : 'false'}
|
||||
onClick={() => onCheckedChange?.(!checked)}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
),
|
||||
DropdownMenuLabel: ({ children }: { children: ReactNode }) => <div>{children}</div>,
|
||||
DropdownMenuRadioGroup: ({
|
||||
children,
|
||||
onValueChange,
|
||||
value
|
||||
}: {
|
||||
children: ReactNode
|
||||
onValueChange?: (value: string) => void
|
||||
value?: string
|
||||
}) => (
|
||||
<div data-radio-group-value={value}>
|
||||
{ReactModule.Children.map(children, (child) =>
|
||||
ReactModule.isValidElement<RadioItemProps>(child)
|
||||
? ReactModule.cloneElement(child, { onValueChange })
|
||||
: child
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
DropdownMenuRadioItem: ({ children, onSelect, onValueChange, value }: RadioItemProps) => (
|
||||
<button
|
||||
type="button"
|
||||
data-radio-item-value={value}
|
||||
onClick={() => {
|
||||
onSelect?.({ preventDefault: vi.fn() })
|
||||
onValueChange?.(value)
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
),
|
||||
DropdownMenuSeparator: () => <hr />,
|
||||
DropdownMenuSub: ({ children }: { children: ReactNode }) => <>{children}</>,
|
||||
DropdownMenuSubContent: ({ children }: { children: ReactNode }) => <div>{children}</div>,
|
||||
DropdownMenuSubTrigger: ({ children }: { children: ReactNode }) => <div>{children}</div>
|
||||
}
|
||||
})
|
||||
|
||||
let root: Root | null = null
|
||||
let container: HTMLDivElement | null = null
|
||||
|
||||
function renderMenu(): void {
|
||||
container = document.createElement('div')
|
||||
document.body.appendChild(container)
|
||||
root = createRoot(container)
|
||||
act(() => {
|
||||
root?.render(
|
||||
settings.experimentalNewWorktreeCardStyle ? (
|
||||
<WorktreeCardDisplayMenuItem preserveWorkspaceBoardOpen={false} />
|
||||
) : (
|
||||
<>
|
||||
<WorktreeCardLayoutMenuItem preserveWorkspaceBoardOpen={false} />
|
||||
<WorktreeCardPropertiesMenuItem preserveWorkspaceBoardOpen={false} />
|
||||
</>
|
||||
)
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
settings = { compactWorktreeCards: false, experimentalNewWorktreeCardStyle: false }
|
||||
projectGroups = []
|
||||
worktreeCardProperties = [
|
||||
'status',
|
||||
'unread',
|
||||
'issue',
|
||||
'linear-issue',
|
||||
'jira-issue',
|
||||
'pr',
|
||||
'automation',
|
||||
'comment',
|
||||
'ports',
|
||||
'inline-agents'
|
||||
]
|
||||
showAgentModels = undefined
|
||||
setShowAgentModels.mockReset()
|
||||
setAgentActivityDisplayMode.mockReset()
|
||||
setWorktreeCardMode.mockReset()
|
||||
setWorktreeCardProperties.mockReset()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
act(() => {
|
||||
root?.unmount()
|
||||
})
|
||||
root = null
|
||||
container?.remove()
|
||||
container = null
|
||||
document.body.innerHTML = ''
|
||||
})
|
||||
|
||||
describe('worktree card menu items', () => {
|
||||
it.each([false, true])(
|
||||
'toggles model badges in the existing property menu (new style: %s)',
|
||||
(newStyle) => {
|
||||
settings.experimentalNewWorktreeCardStyle = newStyle
|
||||
renderMenu()
|
||||
const button = [...document.querySelectorAll('button')].find(
|
||||
(candidate) => candidate.textContent === 'Agent model'
|
||||
)
|
||||
expect(button?.dataset.checked).toBe('true')
|
||||
act(() => button?.click())
|
||||
expect(setShowAgentModels).toHaveBeenCalledWith(false)
|
||||
showAgentModels = false
|
||||
act(() => root?.unmount())
|
||||
root = null
|
||||
renderMenu()
|
||||
const hiddenButton = [...document.querySelectorAll('button')].find(
|
||||
(candidate) =>
|
||||
candidate.textContent === 'Agent model' && candidate.dataset.checked === 'false'
|
||||
)
|
||||
act(() => hiddenButton?.click())
|
||||
expect(setShowAgentModels).toHaveBeenLastCalledWith(true)
|
||||
}
|
||||
)
|
||||
|
||||
it('applies the compact card mode preset from the visible card layout menu', () => {
|
||||
renderMenu()
|
||||
|
||||
const compactLayoutButton = document.querySelector<HTMLButtonElement>(
|
||||
'[data-radio-group-value="detailed"] [data-radio-item-value="compact"]'
|
||||
)
|
||||
expect(compactLayoutButton).not.toBeNull()
|
||||
|
||||
act(() => {
|
||||
compactLayoutButton?.click()
|
||||
})
|
||||
|
||||
expect(setWorktreeCardMode).toHaveBeenCalledWith('Compact')
|
||||
})
|
||||
|
||||
it('keeps branch-only copy when project groups are unavailable', () => {
|
||||
settings = { compactWorktreeCards: false, experimentalNewWorktreeCardStyle: true }
|
||||
|
||||
renderMenu()
|
||||
|
||||
expect(container?.textContent).toContain('Branch name')
|
||||
expect(container?.textContent).not.toContain('Branch / folder path')
|
||||
})
|
||||
|
||||
it('mentions folder paths when project groups can create folder workspaces', () => {
|
||||
settings = { compactWorktreeCards: false, experimentalNewWorktreeCardStyle: true }
|
||||
projectGroups = [{ id: 'group-1' }]
|
||||
|
||||
renderMenu()
|
||||
|
||||
expect(container?.textContent).toContain('Branch / folder path')
|
||||
expect(container?.textContent).not.toContain('Branch name')
|
||||
})
|
||||
})
|
||||
-36
@@ -19,42 +19,6 @@ describe('submitFolderWorkspaceCreate on an Orca server', () => {
|
||||
beforeEach(() => {
|
||||
mocks.activateAndRevealFolderWorkspace.mockReturnValue({ primaryTabId: 'tab-1' })
|
||||
})
|
||||
it('preserves SSH group ownership when creating and activating a folder workspace', async () => {
|
||||
const projectGroup = {
|
||||
...makeProjectGroup(),
|
||||
connectionId: 'ssh-1',
|
||||
executionHostId: 'ssh:ssh-1'
|
||||
}
|
||||
const createFolderWorkspace = vi.fn(async () => makeFolderWorkspace({ connectionId: 'ssh-1' }))
|
||||
const onOpenChange = vi.fn()
|
||||
|
||||
await submitFolderWorkspaceCreate({
|
||||
projectGroup,
|
||||
name: 'SSH workspace',
|
||||
lastAutoName: '',
|
||||
linkedWorkItem: null,
|
||||
note: '',
|
||||
quickAgent: null,
|
||||
autoRenameBranchFromWork: false,
|
||||
agentCmdOverrides: {},
|
||||
isRemote: true,
|
||||
runtimeEnvironmentId: null,
|
||||
createFolderWorkspace,
|
||||
onOpenChange
|
||||
})
|
||||
|
||||
expect(createFolderWorkspace).toHaveBeenCalledWith({
|
||||
projectGroupId: 'group-1',
|
||||
name: 'SSH workspace',
|
||||
connectionId: 'ssh-1',
|
||||
linkedTask: null
|
||||
})
|
||||
expect(onOpenChange).toHaveBeenCalledWith(false)
|
||||
expect(mocks.activateAndRevealFolderWorkspace).toHaveBeenCalledWith('folder-workspace-1', {
|
||||
agent: null,
|
||||
runtimeEnvironmentId: null
|
||||
})
|
||||
})
|
||||
|
||||
it('quotes quick-agent startup for POSIX when the folder group is a local WSL UNC path', async () => {
|
||||
const createFolderWorkspace = vi.fn(async () => makeFolderWorkspace())
|
||||
|
||||
-195
@@ -1,195 +0,0 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { act, type ReactNode } from 'react'
|
||||
import { createRoot, type Root } from 'react-dom/client'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
WORKSPACE_GROUP_BY_CHOICES,
|
||||
WORKSPACE_VIEW_OPTIONS,
|
||||
WORKSPACE_VIEW_SECTION_LABELS,
|
||||
getShownWorkspaceViewSections,
|
||||
type WorkspaceViewOptionContext,
|
||||
type WorkspaceViewOptionKey
|
||||
} from '../../../../../../shared/workspace-view/workspace-view-options'
|
||||
|
||||
;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true
|
||||
|
||||
const mocks = vi.hoisted(() => {
|
||||
const state: Record<string, unknown> = {}
|
||||
const hostOptions: { id: string }[] = []
|
||||
return { state, hostOptions }
|
||||
})
|
||||
|
||||
vi.mock('@/store', () => ({
|
||||
useAppStore: Object.assign(
|
||||
(selector: (state: Record<string, unknown>) => unknown) => selector(mocks.state),
|
||||
{ getState: () => mocks.state }
|
||||
)
|
||||
}))
|
||||
|
||||
vi.mock('./use-sidebar-host-scope-options', () => ({
|
||||
useSidebarHostScopeOptions: () => ({ hostOptions: mocks.hostOptions, hostScopeOptions: [] })
|
||||
}))
|
||||
vi.mock('./sidebar-host-options', () => ({
|
||||
shouldShowHostScopeControls: (hosts: { id: string }[]) => hosts.some((h) => h.id !== 'local'),
|
||||
getSidebarHostVisibilityLabel: () => 'All hosts'
|
||||
}))
|
||||
vi.mock('./SidebarHostScopeMenuSection', () => ({
|
||||
SidebarHostScopeMenuSection: () => <div data-option="hosts">Hosts</div>
|
||||
}))
|
||||
vi.mock('./SidebarRepositoryFilterSection', () => ({
|
||||
default: () => <div data-option="projects">Projects</div>
|
||||
}))
|
||||
vi.mock('@/components/ui/tooltip', () => ({
|
||||
Tooltip: ({ children }: { children: ReactNode }) => <>{children}</>,
|
||||
TooltipTrigger: ({ children }: { children: ReactNode }) => <>{children}</>,
|
||||
TooltipContent: () => null
|
||||
}))
|
||||
vi.mock('@/components/ui/dropdown-menu', () => {
|
||||
const Pass = ({ children }: { children?: ReactNode }) => <div>{children}</div>
|
||||
return {
|
||||
DropdownMenuCheckboxItem: Pass,
|
||||
DropdownMenuLabel: Pass,
|
||||
DropdownMenuRadioGroup: Pass,
|
||||
DropdownMenuRadioItem: Pass,
|
||||
DropdownMenuSeparator: () => <hr />,
|
||||
DropdownMenuShortcut: Pass,
|
||||
DropdownMenuSub: Pass,
|
||||
DropdownMenuSubContent: () => null,
|
||||
DropdownMenuSubTrigger: Pass,
|
||||
DropdownMenuItem: Pass
|
||||
}
|
||||
})
|
||||
|
||||
import { WorkspaceOptionsMenuItems } from './workspace-options-menu-items'
|
||||
|
||||
const BASE_CONTEXT: WorkspaceViewOptionContext = {
|
||||
groupBy: 'none',
|
||||
hideSleepingWorkspaces: false,
|
||||
hideWorkspacesFromOtherDevices: false,
|
||||
newCardStyle: false,
|
||||
hasRemoteHosts: false,
|
||||
projectCount: 1,
|
||||
mayHaveServerHosts: false
|
||||
}
|
||||
|
||||
function setState(context: WorkspaceViewOptionContext, catalogHydrated = true): void {
|
||||
mocks.hostOptions = context.hasRemoteHosts ? [{ id: 'local' }, { id: 'ssh:box' }] : []
|
||||
const noop = vi.fn()
|
||||
mocks.state = {
|
||||
repos: Array.from({ length: context.projectCount }, (_, i) => ({ id: `repo-${i}` })),
|
||||
groupBy: context.groupBy,
|
||||
setGroupBy: noop,
|
||||
sortBy: 'name',
|
||||
setSortBy: noop,
|
||||
projectOrderBy: 'manual',
|
||||
setProjectOrderBy: noop,
|
||||
showSleepingWorkspaces: !context.hideSleepingWorkspaces,
|
||||
setShowSleepingWorkspaces: noop,
|
||||
alwaysShowDefaultBranchWorkspace: true,
|
||||
setAlwaysShowDefaultBranchWorkspace: noop,
|
||||
hideDefaultBranchWorkspace: false,
|
||||
setHideDefaultBranchWorkspace: noop,
|
||||
hideAutomationGeneratedWorkspaces: false,
|
||||
setHideAutomationGeneratedWorkspaces: noop,
|
||||
hideCliCreatedWorkspaces: false,
|
||||
setHideCliCreatedWorkspaces: noop,
|
||||
hideDetachedHeadWorkspaces: false,
|
||||
setHideDetachedHeadWorkspaces: noop,
|
||||
hideWorkspacesFromOtherDevices: context.hideWorkspacesFromOtherDevices,
|
||||
setHideWorkspacesFromOtherDevices: noop,
|
||||
runtimeEnvironments: context.mayHaveServerHosts ? [{ id: 'server' }] : [],
|
||||
runtimeEnvironmentCatalogHydrated: catalogHydrated,
|
||||
settings: {
|
||||
compactWorktreeCards: false,
|
||||
experimentalNewWorktreeCardStyle: context.newCardStyle
|
||||
},
|
||||
worktreeCardProperties: ['status'],
|
||||
setWorktreeCardProperties: noop,
|
||||
setWorktreeCardMode: noop,
|
||||
agentActivityDisplayMode: 'compact',
|
||||
setAgentActivityDisplayMode: noop,
|
||||
projectGroups: [],
|
||||
visibleWorkspaceHostIds: null,
|
||||
setVisibleWorkspaceHostIds: noop,
|
||||
setWorkspaceHostScope: noop
|
||||
}
|
||||
}
|
||||
|
||||
let container: HTMLDivElement
|
||||
let root: Root
|
||||
|
||||
beforeEach(() => {
|
||||
container = document.createElement('div')
|
||||
document.body.appendChild(container)
|
||||
root = createRoot(container)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
act(() => root.unmount())
|
||||
container.remove()
|
||||
})
|
||||
|
||||
// Probe the control’s own text because Group by’s heading belongs to the whole section.
|
||||
const PROBE: Partial<Record<WorkspaceViewOptionKey, string>> = {
|
||||
groupBy: WORKSPACE_GROUP_BY_CHOICES[0].label
|
||||
}
|
||||
|
||||
function renderedOptionKeys(): WorkspaceViewOptionKey[] {
|
||||
const text = container.textContent ?? ''
|
||||
return WORKSPACE_VIEW_OPTIONS.map((option) => ({
|
||||
key: option.key,
|
||||
at: text.indexOf(PROBE[option.key] ?? option.label)
|
||||
}))
|
||||
.filter((option) => option.at >= 0)
|
||||
.sort((a, b) => a.at - b.at)
|
||||
.map((option) => option.key)
|
||||
}
|
||||
|
||||
const CONTEXTS: WorkspaceViewOptionContext[] = [
|
||||
BASE_CONTEXT,
|
||||
{
|
||||
...BASE_CONTEXT,
|
||||
groupBy: 'repo',
|
||||
hideSleepingWorkspaces: true,
|
||||
hasRemoteHosts: true,
|
||||
projectCount: 3,
|
||||
mayHaveServerHosts: true
|
||||
},
|
||||
{ ...BASE_CONTEXT, newCardStyle: true, hideWorkspacesFromOtherDevices: true }
|
||||
]
|
||||
|
||||
describe('Workspace options menu renders from the shared option list', () => {
|
||||
for (const context of CONTEXTS) {
|
||||
it(`shows one control per shown option, in order: ${JSON.stringify(context)}`, () => {
|
||||
setState(context)
|
||||
act(() => root.render(<WorkspaceOptionsMenuItems />))
|
||||
const sections = getShownWorkspaceViewSections(context)
|
||||
expect(renderedOptionKeys()).toEqual(
|
||||
sections.flatMap(({ options }) => options.map((option) => option.key))
|
||||
)
|
||||
for (const { section } of sections) {
|
||||
const heading = WORKSPACE_VIEW_SECTION_LABELS[section]
|
||||
if (heading) {
|
||||
expect(container.textContent).toContain(heading)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
it('renders every option in some context', () => {
|
||||
const everShown = new Set(
|
||||
CONTEXTS.flatMap((context) =>
|
||||
getShownWorkspaceViewSections(context).flatMap(({ options }) =>
|
||||
options.map((option) => option.key)
|
||||
)
|
||||
)
|
||||
)
|
||||
expect([...everShown].sort()).toEqual(WORKSPACE_VIEW_OPTIONS.map((o) => o.key).sort())
|
||||
})
|
||||
|
||||
it('keeps the other-client filter while the server catalog is still loading', () => {
|
||||
setState(BASE_CONTEXT, false)
|
||||
act(() => root.render(<WorkspaceOptionsMenuItems />))
|
||||
expect(renderedOptionKeys()).toContain('hideWorkspacesFromOtherDevices')
|
||||
})
|
||||
})
|
||||
+2
-132
@@ -4,13 +4,10 @@ import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
computeProjectGroupHeaderDropPreview,
|
||||
getProjectGroupHeaderDragBucketKey,
|
||||
getProjectGroupTabOrderUpdatesForSidebarDrop,
|
||||
getSidebarOrderedProjectGroupHeaderIdsByBucket,
|
||||
mapSidebarProjectGroupDropIndexToSiblingInsertIndex
|
||||
getProjectGroupTabOrderUpdatesForSidebarDrop
|
||||
} from './project-group-header-drop'
|
||||
import type { Row } from '../../../../../../shared/sidebar-list/row-types'
|
||||
|
||||
import type { ProjectGroup } from '../../../../../../shared/project-group-types'
|
||||
import type { Repo } from '../../../../../../shared/repo-types'
|
||||
|
||||
function group(id: string, overrides: Partial<ProjectGroup> = {}): ProjectGroup {
|
||||
return {
|
||||
@@ -29,21 +26,6 @@ function group(id: string, overrides: Partial<ProjectGroup> = {}): ProjectGroup
|
||||
}
|
||||
|
||||
describe('getProjectGroupHeaderDragBucketKey', () => {
|
||||
it('uses root for top-level groups', () => {
|
||||
expect(getProjectGroupHeaderDragBucketKey(group('root'))).toBe('root')
|
||||
})
|
||||
|
||||
it('scopes child groups to their parent bucket', () => {
|
||||
const root = group('root')
|
||||
const child = group('child', { parentGroupId: root.id })
|
||||
const groupsById = new Map([
|
||||
[root.id, root],
|
||||
[child.id, child]
|
||||
])
|
||||
|
||||
expect(getProjectGroupHeaderDragBucketKey(child, groupsById)).toBe('parent:root')
|
||||
})
|
||||
|
||||
it('falls back to root when persisted parent metadata is missing', () => {
|
||||
const orphan = group('orphan', { parentGroupId: 'missing' })
|
||||
|
||||
@@ -51,83 +33,6 @@ describe('getProjectGroupHeaderDragBucketKey', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('getSidebarOrderedProjectGroupHeaderIdsByBucket', () => {
|
||||
it('groups Project Group headers by effective parent bucket', () => {
|
||||
const rootA = group('root-a')
|
||||
const rootB = group('root-b')
|
||||
const childA = group('child-a', { parentGroupId: rootA.id })
|
||||
const repo = { id: 'repo-a', projectGroupId: rootA.id } as Repo
|
||||
const groupsById = new Map([
|
||||
[rootA.id, rootA],
|
||||
[rootB.id, rootB],
|
||||
[childA.id, childA]
|
||||
])
|
||||
const rows: Row[] = [
|
||||
{
|
||||
type: 'header',
|
||||
key: 'project-group:root-a',
|
||||
label: 'A',
|
||||
count: 0,
|
||||
icon: { kind: 'project' },
|
||||
projectGroup: rootA
|
||||
},
|
||||
{
|
||||
type: 'header',
|
||||
key: 'repo:repo-a',
|
||||
label: 'repo',
|
||||
count: 0,
|
||||
icon: { kind: 'project' },
|
||||
repo
|
||||
},
|
||||
{
|
||||
type: 'header',
|
||||
key: 'project-group:child-a',
|
||||
label: 'A child',
|
||||
count: 0,
|
||||
icon: { kind: 'project' },
|
||||
projectGroup: childA
|
||||
},
|
||||
{
|
||||
type: 'header',
|
||||
key: 'project-group:root-b',
|
||||
label: 'B',
|
||||
count: 0,
|
||||
icon: { kind: 'project' },
|
||||
projectGroup: rootB
|
||||
}
|
||||
]
|
||||
|
||||
expect(getSidebarOrderedProjectGroupHeaderIdsByBucket(rows, groupsById)).toEqual(
|
||||
new Map([
|
||||
['root', ['root-a', 'root-b']],
|
||||
['parent:root-a', ['child-a']]
|
||||
])
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('mapSidebarProjectGroupDropIndexToSiblingInsertIndex', () => {
|
||||
it('keeps upward drops at the same target index after removing the source', () => {
|
||||
expect(
|
||||
mapSidebarProjectGroupDropIndexToSiblingInsertIndex({
|
||||
sidebarDropIndex: 0,
|
||||
sourceIndex: 2,
|
||||
siblingCount: 2
|
||||
})
|
||||
).toBe(0)
|
||||
})
|
||||
|
||||
it('shifts downward drops because the source header is removed first', () => {
|
||||
expect(
|
||||
mapSidebarProjectGroupDropIndexToSiblingInsertIndex({
|
||||
sidebarDropIndex: 3,
|
||||
sourceIndex: 0,
|
||||
siblingCount: 2
|
||||
})
|
||||
).toBe(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('computeProjectGroupHeaderDropPreview', () => {
|
||||
it('uses row-model header indices instead of mounted subset order', () => {
|
||||
const preview = computeProjectGroupHeaderDropPreview({
|
||||
@@ -167,27 +72,6 @@ describe('computeProjectGroupHeaderDropPreview', () => {
|
||||
|
||||
expect(preview).toBeNull()
|
||||
})
|
||||
|
||||
it('uses the whole Project Group section for the final boundary slot', () => {
|
||||
const preview = computeProjectGroupHeaderDropPreview({
|
||||
pointerY: 400,
|
||||
containerTop: 0,
|
||||
scrollTop: 0,
|
||||
sidebarProjectGroupHeaderIds: ['a', 'b', 'c'],
|
||||
rects: [
|
||||
{
|
||||
groupId: 'c',
|
||||
bucketKey: 'root',
|
||||
headerIndex: 2,
|
||||
top: 300,
|
||||
bottom: 328,
|
||||
sectionBottom: 380
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
expect(preview).toEqual({ dropIndex: 3, dropIndicatorY: 383 })
|
||||
})
|
||||
})
|
||||
|
||||
describe('getProjectGroupTabOrderUpdatesForSidebarDrop', () => {
|
||||
@@ -208,18 +92,4 @@ describe('getProjectGroupTabOrderUpdatesForSidebarDrop', () => {
|
||||
{ groupId: 'c', tabOrder: 3 }
|
||||
])
|
||||
})
|
||||
|
||||
it('returns no updates when the drop keeps the group in the same slot', () => {
|
||||
const groups = [group('a', { tabOrder: 0 }), group('b', { tabOrder: 1 })]
|
||||
const projectGroupById = new Map(groups.map((entry) => [entry.id, entry]))
|
||||
|
||||
expect(
|
||||
getProjectGroupTabOrderUpdatesForSidebarDrop({
|
||||
sidebarProjectGroupHeaderIds: ['a', 'b'],
|
||||
draggedGroupId: 'a',
|
||||
sidebarDropIndex: 1,
|
||||
projectGroupById
|
||||
})
|
||||
).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
-19
@@ -147,24 +147,5 @@ for (const hostId of ['ssh:builder', 'runtime:builder'] as const) {
|
||||
expect(after.some((row) => row.type === 'item' && row.worktree.id === remote.id)).toBe(true)
|
||||
expect(after.some((row) => row.type === 'item' && row.worktree.id === local.id)).toBe(false)
|
||||
})
|
||||
it(`${hostId} ${groupBy}: combined view retains its single section collapse key`, () => {
|
||||
const { local, remote } = setup(hostId)
|
||||
const worktrees = [local, remote]
|
||||
const rows = sections(hostId, groupBy, worktrees, new Set([laneKey]), {}, null)
|
||||
expect(rows.filter((row) => row.type === 'header' && row.key === laneKey)).toHaveLength(1)
|
||||
expect(rows.some((row) => row.type === 'item')).toBe(false)
|
||||
const expanded = sections(
|
||||
hostId,
|
||||
groupBy,
|
||||
worktrees,
|
||||
new Set([`${laneKey}:host:${hostId}`]),
|
||||
{},
|
||||
null
|
||||
)
|
||||
expect(expanded.filter((row) => row.type === 'item')).toHaveLength(2)
|
||||
expect(expanded.some((row) => row.type === 'header' && row.collapseKey !== undefined)).toBe(
|
||||
false
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
-24
@@ -125,30 +125,6 @@ describe('worktree keyboard cycling with a resolved active host', () => {
|
||||
executionHostId: 'local'
|
||||
})
|
||||
})
|
||||
|
||||
it('steps to the previous row when the active host resolved to local', () => {
|
||||
renderProbe('b', 'local')
|
||||
|
||||
press('up')
|
||||
|
||||
expect(activateAndRevealWorktree).toHaveBeenCalledWith('a', {
|
||||
navigationIntent: 'user-open',
|
||||
revealInSidebar: false,
|
||||
executionHostId: 'local'
|
||||
})
|
||||
})
|
||||
|
||||
it('still steps normally when the active host is unqualified', () => {
|
||||
renderProbe('b', null)
|
||||
|
||||
press('down')
|
||||
|
||||
expect(activateAndRevealWorktree).toHaveBeenCalledWith('c', {
|
||||
navigationIntent: 'user-open',
|
||||
revealInSidebar: false,
|
||||
executionHostId: 'local'
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('workspace keyboard cycling after closing the final tab', () => {
|
||||
|
||||
+1
-127
@@ -1,13 +1,7 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { HostSectionRow } from '../../../../../../shared/sidebar-list/host-section-rows'
|
||||
import type { FolderWorkspaceRow } from '../../../../../../shared/sidebar-list/row-types'
|
||||
import {
|
||||
getCyclableRowIdentity,
|
||||
getCyclableWorktreeIds,
|
||||
getCyclableWorktrees,
|
||||
resolveActiveCycleIdentity,
|
||||
resolveCycledWorktreeId
|
||||
} from './worktree-keyboard-cycle'
|
||||
import { getCyclableRowIdentity, getCyclableWorktreeIds } from './worktree-keyboard-cycle'
|
||||
import { getRenderedWorkspaceRowsInSidebarOrder as getCyclableWorktreeRows } from './worktree-sidebar-row-preference'
|
||||
|
||||
const folderRow: FolderWorkspaceRow = {
|
||||
@@ -44,46 +38,6 @@ const folderRow: FolderWorkspaceRow = {
|
||||
groupDepth: 0
|
||||
}
|
||||
|
||||
describe('resolveCycledWorktreeId', () => {
|
||||
const worktreeIds = ['a', 'b', 'c']
|
||||
|
||||
it('steps to the next and previous worktree', () => {
|
||||
expect(resolveCycledWorktreeId({ worktreeIds, activeWorktreeId: 'a', direction: 'down' })).toBe(
|
||||
'b'
|
||||
)
|
||||
expect(resolveCycledWorktreeId({ worktreeIds, activeWorktreeId: 'b', direction: 'up' })).toBe(
|
||||
'a'
|
||||
)
|
||||
})
|
||||
|
||||
it('wraps around at both ends', () => {
|
||||
expect(resolveCycledWorktreeId({ worktreeIds, activeWorktreeId: 'c', direction: 'down' })).toBe(
|
||||
'a'
|
||||
)
|
||||
expect(resolveCycledWorktreeId({ worktreeIds, activeWorktreeId: 'a', direction: 'up' })).toBe(
|
||||
'c'
|
||||
)
|
||||
})
|
||||
|
||||
it('enters from the matching end when the active worktree is not cyclable', () => {
|
||||
expect(
|
||||
resolveCycledWorktreeId({ worktreeIds, activeWorktreeId: 'hidden', direction: 'down' })
|
||||
).toBe('a')
|
||||
expect(
|
||||
resolveCycledWorktreeId({ worktreeIds, activeWorktreeId: 'hidden', direction: 'up' })
|
||||
).toBe('c')
|
||||
expect(
|
||||
resolveCycledWorktreeId({ worktreeIds, activeWorktreeId: null, direction: 'down' })
|
||||
).toBe('a')
|
||||
})
|
||||
|
||||
it('has nothing to cycle to when every group is collapsed', () => {
|
||||
expect(
|
||||
resolveCycledWorktreeId({ worktreeIds: [], activeWorktreeId: 'a', direction: 'down' })
|
||||
).toBe(null)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getCyclableWorktreeIds', () => {
|
||||
const repo = {
|
||||
id: 'repo-1',
|
||||
@@ -107,67 +61,6 @@ describe('getCyclableWorktreeIds', () => {
|
||||
}
|
||||
}
|
||||
|
||||
it('keeps a pinned worktree cyclable when only its natural group is collapsed', () => {
|
||||
const rows: HostSectionRow[] = [worktree('pinned-a', true), worktree('plain-b')]
|
||||
|
||||
expect(getCyclableWorktreeIds(rows, 'single-location')).toEqual(['pinned-a', 'plain-b'])
|
||||
})
|
||||
|
||||
it('counts a duplicated pinned worktree once', () => {
|
||||
const rows: HostSectionRow[] = [
|
||||
worktree('dup', true),
|
||||
{ ...worktree('dup'), rowKey: 'row:dup-natural' },
|
||||
worktree('plain-b')
|
||||
]
|
||||
|
||||
expect(getCyclableWorktreeIds(rows, 'duplicate-in-groups')).toEqual(['dup', 'plain-b'])
|
||||
})
|
||||
|
||||
it('keeps same-id rows on different hosts independently cyclable', () => {
|
||||
const rows: HostSectionRow[] = [
|
||||
{
|
||||
...worktree('shared'),
|
||||
worktree: { id: 'shared', repoId: repo.id, hostId: 'local' } as never
|
||||
},
|
||||
{
|
||||
...worktree('shared'),
|
||||
rowKey: 'row:shared:ssh',
|
||||
worktree: { id: 'shared', repoId: repo.id, hostId: 'ssh:host-b' } as never
|
||||
}
|
||||
]
|
||||
|
||||
expect(getCyclableWorktrees(rows, 'single-location').map((item) => item.hostId)).toEqual([
|
||||
'local',
|
||||
'ssh:host-b'
|
||||
])
|
||||
})
|
||||
|
||||
it('includes folder workspaces between git worktrees in visible order', () => {
|
||||
const rows = [worktree('a'), folderRow, worktree('b')]
|
||||
|
||||
expect(getCyclableWorktreeIds(rows, 'single-location')).toEqual(['a', 'folder:folder-1', 'b'])
|
||||
})
|
||||
|
||||
it('anchors both directions on the active folder workspace', () => {
|
||||
const rows = getCyclableWorktreeRows(
|
||||
[worktree('a'), folderRow, worktree('b')],
|
||||
'single-location'
|
||||
)
|
||||
const activeWorktreeId = resolveActiveCycleIdentity({
|
||||
rows,
|
||||
activeWorktreeId: 'folder:folder-1',
|
||||
activeWorkspaceExecutionHostId: 'local'
|
||||
})
|
||||
const worktreeIds = rows.map(getCyclableRowIdentity)
|
||||
|
||||
expect(resolveCycledWorktreeId({ worktreeIds, activeWorktreeId, direction: 'up' })).toBe(
|
||||
getCyclableRowIdentity(rows[0])
|
||||
)
|
||||
expect(resolveCycledWorktreeId({ worktreeIds, activeWorktreeId, direction: 'down' })).toBe(
|
||||
getCyclableRowIdentity(rows[2])
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps folder placement while preferring a pinned worktree natural row', () => {
|
||||
const rows = [
|
||||
worktree('dup', true),
|
||||
@@ -199,23 +92,4 @@ describe('getCyclableWorktreeIds', () => {
|
||||
'ssh:host-b|folder:folder-1'
|
||||
])
|
||||
})
|
||||
|
||||
it('drops worktrees the sidebar elided inside a collapsed host section', () => {
|
||||
const rows: HostSectionRow[] = [
|
||||
{
|
||||
type: 'host-header',
|
||||
key: 'host:local',
|
||||
hostId: 'local' as never,
|
||||
kind: 'local',
|
||||
label: 'This computer',
|
||||
detail: '',
|
||||
health: 'local',
|
||||
collapsed: true,
|
||||
count: 1
|
||||
},
|
||||
worktree('visible-after-host')
|
||||
]
|
||||
|
||||
expect(getCyclableWorktreeIds(rows, 'single-location')).toEqual(['visible-after-host'])
|
||||
})
|
||||
})
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user