test: remove 660 low-value tests (#39547)

Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>
This commit is contained in:
OrcaWin
2026-11-07 09:18:17 -08:00
committed by GitHub
co-authored by OrcaWin
parent 3d8c3a899d
commit eb9312fc7c
163 changed files with 64 additions and 13852 deletions
@@ -41,4 +41,4 @@ jobs:
bun-version-file: config/.bun-version
no-cache: true
- name: Verify SQLite ordering, legacy readers, and host routing
run: pnpm test src/main/native-chat/transcript-zcode.test.ts src/main/native-chat/transcript-opencode-sqlite-query.test.ts src/main/native-chat/transcript-opencode-read-bounds.test.ts src/main/ai-vault/scanners/zcode/session-scanner-zcode.test.ts src/main/ai-vault/scanners/opencode/reader-transport/opencode-wsl-runtime-preparation.test.ts src/shared/native-chat/transcript/native-chat-transcript-projection.test.ts src/renderer/src/components/native-chat/structured-agent-session-transcript-order.test.ts
run: pnpm test src/main/native-chat/transcript-zcode.test.ts src/main/native-chat/transcript-opencode-sqlite-query.test.ts src/main/ai-vault/scanners/zcode/session-scanner-zcode.test.ts src/main/ai-vault/scanners/opencode/reader-transport/opencode-wsl-runtime-preparation.test.ts src/shared/native-chat/transcript/native-chat-transcript-projection.test.ts src/renderer/src/components/native-chat/structured-agent-session-transcript-order.test.ts
+3 -13
View File
@@ -61,7 +61,6 @@
1 test-no-polling-loop:src/main/ipc/runtime-environments/runtime-environment-request-connections.test.ts
1 test-no-polling-loop:src/main/ipc/worktrees/creation/worktree-base-directory-poller-marker-fanout.test.ts
2 test-no-polling-loop:src/main/ipc/worktrees/git/worktree-git-status-attribution-real-git.test.ts
1 test-no-polling-loop:src/main/managed-server/install/remote-node-runtime-store-lock-host-release.test.ts
1 test-no-polling-loop:src/main/provider-process/provider-group-orphan-guard.integration.test.ts
1 test-no-polling-loop:src/main/provider-process/provider-process-supervisor.integration.test.ts
1 test-no-polling-loop:src/main/providers/startup-line-typed-length.live-shell.test.ts
@@ -241,10 +240,7 @@
4 test-no-real-time-wait:src/main/ipc/runtime-environments/runtime-environment-request-connections.test.ts
1 test-no-real-time-wait:src/main/ipc/worktrees/creation/worktree-base-directory-poller-marker-fanout.test.ts
2 test-no-real-time-wait:src/main/ipc/worktrees/git/worktree-git-status-attribution-real-git.test.ts
5 test-no-real-time-wait:src/main/managed-server/host-state/orcad-state-mutation-fence-heartbeat.test.ts
2 test-no-real-time-wait:src/main/managed-server/install/remote-node-runtime-store-lock-host-release.test.ts
1 test-no-real-time-wait:src/main/managed-server/lifecycle/orcad-managed-tunnel-local-port.test.ts
1 test-no-real-time-wait:src/main/managed-server/lifecycle/orcad-stdio-bridge-provider.test.ts
1 test-no-real-time-wait:src/main/native-chat/agent-session-wire/host/testing/structured-agent-session-claude-echo-working.test.ts
1 test-no-real-time-wait:src/main/native-chat/agent-session-wire/host/testing/structured-agent-session-queued-quit.test.ts
1 test-no-real-time-wait:src/main/native-chat/agent-session-wire/host/testing/structured-agent-session-queued-refused-card.test.ts
@@ -611,12 +607,9 @@
5 test-no-wall-clock:src/main/ipc/worktrees-orphan-directory-cleanup.test.ts
2 test-no-wall-clock:src/main/ipc/worktrees/git/worktree-git-status-attribution-real-git.test.ts
2 test-no-wall-clock:src/main/macos-permissions/macos-tcc-log-stream-orphans.test.ts
1 test-no-wall-clock:src/main/managed-server/host-state/orcad-state-mutation-fence-heartbeat.test.ts
3 test-no-wall-clock:src/main/managed-server/host-state/orcad-windows-host-fence-ops.test.ts
1 test-no-wall-clock:src/main/managed-server/host-state/orcad-windows-host-fence-ops.test.ts
1 test-no-wall-clock:src/main/managed-server/install/remote-install-lock-exited-owner.test.ts
3 test-no-wall-clock:src/main/managed-server/install/remote-node-runtime-store-gc.test.ts
4 test-no-wall-clock:src/main/managed-server/install/remote-node-runtime-store-lock-host-release.test.ts
3 test-no-wall-clock:src/main/managed-server/install/remote-node-runtime-store-windows.test.ts
1 test-no-wall-clock:src/main/native-chat/agent-session-attachments/agent-session-attachment-sweep.test.ts
1 test-no-wall-clock:src/main/native-chat/transcript-watch-inplace-drain-race.test.ts
1 test-no-wall-clock:src/main/observability/bundle.test.ts
@@ -761,8 +754,7 @@
4 test-no-wall-clock:src/renderer/src/components/sidebar/workspace-card/review/use-workspace-reference-details.test.tsx
1 test-no-wall-clock:src/renderer/src/components/sidebar/workspace-card/surface/WorktreeCard.single-reference-details.test.tsx
1 test-no-wall-clock:src/renderer/src/components/sidebar/worktree-list/listing/WorktreeList.lineage-agent-expansion-coupling.test.tsx
4 test-no-wall-clock:src/renderer/src/components/task-page/jira/task-page-jira-cache-selectors.test.ts
1 test-no-wall-clock:src/renderer/src/components/task-page/jira/task-page-jira-saved-filter-load-failure.test.tsx
2 test-no-wall-clock:src/renderer/src/components/task-page/jira/task-page-jira-cache-selectors.test.ts
1 test-no-wall-clock:src/renderer/src/components/terminal-pane/titles/cache-timer-seeding.test.ts
2 test-no-wall-clock:src/renderer/src/components/terminal-pane/pty-connection/emulation/fish-color-scheme-child-stdin.node-pty.test.ts
2 test-no-wall-clock:src/renderer/src/components/terminal-pane/snapshots/remote-snapshot-alt-screen-host-chain.test.ts
@@ -873,10 +865,8 @@
1 test-timeout-needs-reason:src/main/ipc/preflight-runnable-local-cli.test.ts
1 test-timeout-needs-reason:src/main/ipc/worktrees/git/worktree-git-status-attribution-real-git.test.ts
1 test-timeout-needs-reason:src/main/jcode/hook-gate-script.test.ts
4 test-timeout-needs-reason:src/main/managed-server/host-state/orcad-state-mutation-fence-heartbeat.test.ts
1 test-timeout-needs-reason:src/main/managed-server/host-state/orcad-state-mutation-fence-heartbeat.test.ts
1 test-timeout-needs-reason:src/main/managed-server/host-state/orcad-windows-host-lane.test.ts
1 test-timeout-needs-reason:src/main/managed-server/install/orcad-remote-node-runtime-slow-hash.test.ts
2 test-timeout-needs-reason:src/main/managed-server/install/remote-node-runtime-store-lock-host-release.test.ts
4 test-timeout-needs-reason:src/main/managed-server/lifecycle/orcad-managed-tunnel-transport-real-openssh.test.ts
3 test-timeout-needs-reason:src/main/native-chat/agent-model-catalog/agent-model-catalog-probe-runner.test.ts
1 test-timeout-needs-reason:src/main/notebook/notebook-kernel.test.ts
+17 -21
View File
@@ -2992,7 +2992,8 @@
"A zsh configuration that disables bracketed-paste output emits no safe post-hook protocol signal and retains the existing timeout backstop.",
"The pre-existing timeout backstop can still deliver queued input to a non-shell replacement after it expires; this fallback prevents indefinite startup hangs and is unchanged by the prompt fallback.",
"The fallback restores safe startup-command delivery but cannot recreate OSC 133 hooks or overlay restores already discarded by exec.",
"Startup code running in the launched shell can deliberately spoof the public line-editor enable sequence after configuring matching termios; the existing timeout path already trusts a public OSC marker from the same startup code."
"Startup code running in the launched shell can deliberately spoof the public line-editor enable sequence after configuring matching termios; the existing timeout path already trusts a public OSC marker from the same startup code.",
"Audit1878 removes the ptsName getter/shape table and mocked platform stty-argv table from pty-slave-line-discipline-echo.test.ts, and the unset-PATH sh-versus-/bin/sh helper comparison from shell-process-readiness.test.ts. Retained parser/probe promise state and real-filesystem/process tests have their original platform and injected-process limits; no equivalent replacement or fresh broad readiness-gate result is claimed."
],
"demotionRule": "Keep experimental or demote if prompt fallback enters a normal, child, or line-init read; a non-shell executable triggers prompt fallback; the identified shell PID is not foreground; delivery duplicates; ordinary startup output spawns probes; probe attempts exceed four; or the focused gate flakes without an identified harness defect."
},
@@ -6624,7 +6625,8 @@
"Audit1401: src/main/runtime/orca-runtime-tests/mobile-session-tabs-duplicate-pty-repair.spec.ts removed. Loses listing-on-already-repaired-data alarm; it never established runtime repair invocation. src/main/runtime/orca-runtime-tests/mobile-session-tabs-split-agent-tab.spec.ts removed. Loses actual mobile split sibling shell versus recorded-agent resume command alarms; no equivalent coverage asserted. src/main/runtime/orca-runtime-tests/worktree-native-chat-setup.spec.ts removed. Loses separate setup/startup tab result in that manually assembled path. src/main/runtime/orca-runtime.test.ts reduced. Driver registration/import of a whole-deleted selected spec is removed; directly authored cases remain unchanged. No extra authored removal credit. No equivalent replacement coverage is claimed. Historical removed assertion inventory: []",
"Audit1402: src/renderer/src/components/terminal-pane/pty-connection-park-reveal-model-snapshot.spec.ts reduced. Loses basic paired serialize/initial replay forwarding check; hidden alt-frame grid/precedence policy remains. No real terminal renderer claim. No equivalent replacement coverage is claimed. Existing exact primary-selector receipts remain unchanged.",
"Audit1412: src/main/daemon/terminal-host-concurrent-create.test.ts reduced. Loses race deduplication, attach during unsettled ownership proof, retry after failed spawn, distinct-session parallelism, disposal waiting and shutdown retry-fencing alarms bundled with removed internal-call counts or real-time waits. No replacement-equivalence claim. src/main/daemon/terminal-checkpoint-serializer.test.ts reduced. Loses bounded native conversion/no-whole-candidate materialization and snapshot getter single-read alarms. Retained byte-size outcomes do not establish CPU/allocation bounds. No equivalent replacement coverage is claimed. Historical removed assertion inventory: []",
"Audit1417 intentionally deletes src/renderer/src/components/terminal-pane/pty-connection-hidden-tui-snapshot-replay.spec.ts: Loses rich hidden snapshot sequence replay/grid resize and forced repaint after hidden snapshot alarms, including live-trigger deduplication. No equivalent replacement coverage or fresh result is claimed."
"Audit1417 intentionally deletes src/renderer/src/components/terminal-pane/pty-connection-hidden-tui-snapshot-replay.spec.ts: Loses rich hidden snapshot sequence replay/grid resize and forced repaint after hidden snapshot alarms, including live-trigger deduplication. No equivalent replacement coverage or fresh result is claimed.",
"Audit1878 removes only the direct over-cap abandonment example from terminal-partial-escape-tail.test.ts. The unchanged fixed-sequence split/fold sweep is retained; it is not an independent parser oracle or a new execution of the historical 25.6M-case sweep. Its current seventeen sequences do not substantiate the full alphabet inventory described by the older assertion reference. No equivalent replacement coverage or fresh whole-gate execution is claimed."
],
"demotionRule": "Keep experimental or demote if missing ownership proof changes incumbent behavior, a live TUI loses mouse/focus modes, foreground inspection enters ordinary output/reveal paths, or the focused Electron journey flakes without an identified product or harness fault."
},
@@ -10698,7 +10700,8 @@
"Audit1400: src/main/ipc/ssh/ssh.test.ts reduced. Lose trivial missing target and mocked publication inventory. Preexisting unresolved ../ssh/ssh-port-scanner mock is recorded, not fixed. src/renderer/src/runtime/sync-runtime-graph-scheduling.test.ts removed. Removes whole coalescing call-count suite; no newest-state delivery or replacement claim. src/renderer/src/store/slices/terminal-pty-identity-replacement.test.ts reduced. Removes three no-op/work-count registrations; retains externally observed ownership consistency. No equivalent replacement coverage is claimed. Historical removed assertion inventory: [{\"file\":\"src/renderer/src/store/slices/terminal-pty-identity-replacement.test.ts\",\"assertions\":[\"publishes pane and tab replacement identities in one store commit\"]},{\"file\":\"src/main/ipc/ssh/ssh.test.ts\",\"assertions\":[\"concurrent same-authority connects share one provider attempt\",\"authority rotation starts stale transport cancellation before teardown, concurrent fresh callers share one replacement, and stale completion cannot clobber the fresh session\",\"same-turn disconnect and forward-teardown failures across removal, reset, and terminate keep replacement connects and metadata mutation behind complete target cleanup\"]}]",
"Audit1412: src/main/ssh/ssh-provider-authority.test.ts removed. Loses real AbortController revocation/target isolation/reentrancy outcomes, recreated-target generation-floor/session rollover behavior, stale mutation refusal and settled-registration cleanup alarms. These useful alarms are deliberately lost; no equivalent cancellation coverage is claimed. No equivalent replacement coverage is claimed. Historical removed assertion inventory: [{\"file\":\"src/main/ssh/ssh-provider-authority.test.ts\",\"assertions\":[\"provider epoch and connection generation rotate as one exact authority pair\",\"provider resolution rejects stale or incomplete authority\",\"unknown-target currency probes reject without allocating provider authority state\"]}]",
"Module-layout cleanup splits the former terminals-hydration suite into workspace-terminal-reconnect-hydration and workspace-navigation-history-hydration. The reconnect case retains daemon wake-hint/session-readiness outcomes; its store-publication count assertion is intentionally retired. The navigation case retains hydration-seeded visit/Back behavior. Historical receipts remain verbatim and do not validate these changed test owners.",
"Audit1862 reduces remote-runtime-client-error-classification.test.ts by removing recoverable-code, authentication/protocol-negative and client-code blacklist inventories. Those direct classification alarms are lost; no equivalent replacement or actual retry behavior is claimed. Retained structured-code precedence and unstructured legacy normalization cases do not execute a remote connection. Socket-generation and socket-liveness sources in this selector are unchanged. Existing active receipts use unrelated selectors and remain unchanged; focused survivor execution does not validate the broad reconnect selector or physical platform/provider coverage."
"Audit1862 reduces remote-runtime-client-error-classification.test.ts by removing recoverable-code, authentication/protocol-negative and client-code blacklist inventories. Those direct classification alarms are lost; no equivalent replacement or actual retry behavior is claimed. Retained structured-code precedence and unstructured legacy normalization cases do not execute a remote connection. Socket-generation and socket-liveness sources in this selector are unchanged. Existing active receipts use unrelated selectors and remain unchanged; focused survivor execution does not validate the broad reconnect selector or physical platform/provider coverage.",
"Audit1879 removes the standalone ASCII serving-detail length-cap example from ssh-retained-payload-admission.test.ts. That direct cap alarm is intentionally lost without equivalent replacement coverage. The three retained authority assertion references, commands and testFiles remain unchanged. Focused survivor execution is not new execution evidence for the broad SSH reattach gate or actual remote/platform behavior; existing receipts retain their original commands, results and counts."
],
"demotionRule": "Cannot promote if provider failure can close panes or if the oracle is screenshot-only."
},
@@ -11085,7 +11088,7 @@
"file": "src/shared/terminal/protocol/input-modes/terminal-kitty-keyboard-mode-tracker.test.ts",
"assertions": [
"a fresh PTY's known 0 is distinguishable from a snapshot's unproven 0 fallback",
"restoreSnapshotFlags proves only the active screen and rejects non-safe-integer values",
"restoreSnapshotFlags proves only the active screen",
"provenance survives absolute sets, screen switches, RIS and DECSTR, but a pop past omitted push history returns to unknown rather than a manufactured 0"
]
},
@@ -11219,7 +11222,8 @@
"Audit1396: src/renderer/src/components/terminal-pane/xterm-bypass-policy.test.ts reduced. Lose standalone handled Ctrl, Shift+Space, shifted non-ASCII keydown and active-composition backspace/arrow/text/Process suppression alarms. No actual IME/PTY encoding occurs. No equivalent replacement coverage is claimed. Historical removed assertion inventory: [{\"file\":\"src/renderer/src/components/terminal-pane/xterm-bypass-policy.test.ts\",\"assertions\":[\"macOS standalone Process key behavior and composition-owned key suppression stay intact\"]}]",
"Audit1402: src/renderer/src/lib/keyboard-layout/option-as-alt-probe.test.ts removed. Intentionally loses invalidation, focus/manual refresh gating and stale/newest generation settlement outcomes, including direct stale category suppression. No equivalent replacement or default preload input-source delivery claim. src/renderer/src/components/terminal-pane/terminal-ime-substituted-text-commit.test.ts removed. Intentionally loses substituted sentence punctuation, shifted/backslash/multi-unit/full-width/letter input and Kitty flag/release/report encoding alarms across14 authored registrations. No equivalent replacement or real pane/browser input delivery claim. No equivalent replacement coverage is claimed. Historical removed assertion inventory: [{\"file\":\"src/renderer/src/components/terminal-pane/terminal-ime-substituted-text-commit.test.ts\",\"assertions\":[\"synthetic and native input-source paths are classified for covered fixtures\",\"report-all commits retain the unshifted physical key while associated text carries the committed substitution\"]},{\"file\":\"src/renderer/src/lib/keyboard-layout/option-as-alt-probe.test.ts\",\"assertions\":[\"a stale asynchronous input-source probe cannot overwrite the newest classification\",\"an input-source notification changes the classification to unknown synchronously before refreshing\"]}]",
"Audit1417 intentionally reduces src/renderer/src/components/terminal-pane/terminal-ime-candidate-key-release-guard.test.ts: Loses direct timestamp-formula assertion; no useful independent alarm in removed registration. No equivalent replacement coverage or fresh result is claimed.",
"Audit1876 removes the standalone native keyboard snapshot parser suite, direct input-source invalidation/fanout, quit/native teardown and non-macOS subscription examples, and preview-output fully covered/newer/missing-metadata/unsequenced examples. Those direct alarms are intentionally lost without equivalent replacement coverage. Retained notification cases exercise latest-generation coalescing; retained preview cases exercise only partial sequenced suffix slicing and transformed-overlap replay. Existing native/E2E/trace receipts keep their original commands, results and counts; focused survivor runs do not validate the broad IME selector or physical native/platform behavior."
"Audit1876 removes the standalone native keyboard snapshot parser suite, direct input-source invalidation/fanout, quit/native teardown and non-macOS subscription examples, and preview-output fully covered/newer/missing-metadata/unsequenced examples. Those direct alarms are intentionally lost without equivalent replacement coverage. Retained notification cases exercise latest-generation coalescing; retained preview cases exercise only partial sequenced suffix slicing and transformed-overlap replay. Existing native/E2E/trace receipts keep their original commands, results and counts; focused survivor runs do not validate the broad IME selector or physical native/platform behavior.",
"Audit1878 removes the direct non-negative safe-integer snapshot-flags validator table from terminal-kitty-keyboard-mode-tracker.test.ts. Active-screen proof and known-zero/provenance state tests remain, without equivalent replacement coverage or a new whole-gate result. Existing dated receipts retain their original source scope."
],
"demotionRule": "Cannot promote if success is based only on DOM text without PTY byte/cell evidence."
},
@@ -13173,15 +13177,15 @@
"platforms": ["macos", "linux", "windows"],
"providers": ["local", "daemon", "ssh"],
"coveredPlatforms": ["macos"],
"coveredProviders": ["local", "ssh"],
"coverageNotes": "Deterministic renderer and IPC-transport tests prove count and text ceilings, oldest-reply shedding, explicit query-reply source routing, ordinary-input preservation, acknowledged-write FIFO barriers, single-worker drain reentrancy, one-reply-per-write delivery for OSC, DA1, and CPR replies, real xterm OSC reply generation, drain-failure containment, teardown settlement, and clear/reuse generation fencing. Remote-runtime tests preserve separate query-reply writes across pending input, async validation, and viewport-claim buffering. Host-contract tests prove a later DA1/CPR reply cannot overtake a deferred OSC reply, including a coalesced legacy-client payload. Live macOS Electron tests cover local PTY OSC replies and interactive typing; a macOS-hosted Docker OpenSSH test proves an upstream-node-pty Linux relay keeps OSC/DA1 replies out of the next fish child's stdin. No live daemon, paired-runtime, WSL, physical Linux/Windows client, or binary mixed-version run is registered.",
"coveredProviders": ["local"],
"coverageNotes": "Audit1878 retires the dated reduced-source unit receipt. Only the unchanged macOS local Electron receipts remain current, scoped to their exact commands; reduced unit selectors and SSH have no current execution receipt. Historical pre-audit coverageNotes: Deterministic renderer and IPC-transport tests prove count and text ceilings, oldest-reply shedding, explicit query-reply source routing, ordinary-input preservation, acknowledged-write FIFO barriers, single-worker drain reentrancy, one-reply-per-write delivery for OSC, DA1, and CPR replies, real xterm OSC reply generation, drain-failure containment, teardown settlement, and clear/reuse generation fencing. Remote-runtime tests preserve separate query-reply writes across pending input, async validation, and viewport-claim buffering. Host-contract tests prove a later DA1/CPR reply cannot overtake a deferred OSC reply, including a coalesced legacy-client payload. Live macOS Electron tests cover local PTY OSC replies and interactive typing; a macOS-hosted Docker OpenSSH test proves an upstream-node-pty Linux relay keeps OSC/DA1 replies out of the next fish child's stdin. No live daemon, paired-runtime, WSL, physical Linux/Windows client, or binary mixed-version run is registered.",
"motivatingLinks": [
"https://github.com/stablyai/orca/issues/13137",
"https://github.com/stablyai/orca/issues/7329",
"https://github.com/stablyai/orca/issues/13892"
],
"invariant": "The desktop PTY input queue retains at most 64 explicitly sourced pending terminal query replies and 4096 UTF-16 code units. Every retained reply reaches the provider as one atomic write, and ordinary, acknowledged, and reply input share one invocation-ordered FIFO so no later write overtakes an acknowledged write. Reentrant write callbacks cannot start a second drain worker or strand input admitted after clear/reuse. A reply's own echo is contained on the output side by projecting its known echo shapes; the ESC-initial verbatim shape is matched only when complete, never held as a partial, so a query torn at its own ESC is still answered. Overflow removes only the oldest query replies, never ordinary input except the documented modified-F3/CPR byte collision, and failure or teardown cannot clear a newer queue generation or strand an acknowledged promise.",
"oracle": "Synchronously enqueue separate 10,000-entry OSC and DA1 reply floods before the scheduled drain and assert that only the initial immediate reply and newest 64 pending replies are written, each as one provider write, before a trailing keystroke. Stall an acknowledged write between earlier and later ordinary/reply input, then require teardown to settle it and same-id reuse to receive no stale tail. Reenter the queue synchronously from an acknowledged write with both enqueue and clear/reuse, requiring one drain and fresh input delivery only after the stale acknowledged write settles false. At the host boundary, defer an OSC reply and assert that separate or legacy-coalesced DA1/CPR replies flush after it in observed query order. At the remote-runtime boundary, preserve separate writes around pending ordinary input, async validation, and viewport-claim buffering. Repeat behind 10,000 ordinary inputs and exercise the text ceiling, real xterm generation, provider-write failure, rejected yield, and clear/reuse generation fencing.",
"oracle": "Audit1878 retires the dated reduced-source unit receipt. Only the unchanged macOS local Electron receipts remain current, scoped to their exact commands; reduced unit selectors and SSH have no current execution receipt. Historical pre-audit oracle: Synchronously enqueue separate 10,000-entry OSC and DA1 reply floods before the scheduled drain and assert that only the initial immediate reply and newest 64 pending replies are written, each as one provider write, before a trailing keystroke. Stall an acknowledged write between earlier and later ordinary/reply input, then require teardown to settle it and same-id reuse to receive no stale tail. Reenter the queue synchronously from an acknowledged write with both enqueue and clear/reuse, requiring one drain and fresh input delivery only after the stale acknowledged write settles false. At the host boundary, defer an OSC reply and assert that separate or legacy-coalesced DA1/CPR replies flush after it in observed query order. At the remote-runtime boundary, preserve separate writes around pending ordinary input, async validation, and viewport-claim buffering. Repeat behind 10,000 ordinary inputs and exercise the text ceiling, real xterm generation, provider-write failure, rejected yield, and clear/reuse generation fencing.",
"commands": [
"pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/pty-transport/input/pty-input-write-queue.test.ts src/renderer/src/components/terminal-pane/pty-transport/ipc/pty-transport-input-write.test.ts src/shared/terminal/protocol/queries/terminal-query-reply.test.ts src/shared/terminal/pty/ingress/pty-startup-ingress-live-query-reply.test.ts src/shared/terminal/pty/ingress/pty-startup-reply-echo-shapes.test.ts",
"pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/pty-transport/remote-runtime/output/remote-runtime-pty-batching.test.ts src/renderer/src/components/terminal-pane/pty-transport/remote-runtime/remote-runtime-pty-query-reply-immediate.test.ts src/renderer/src/components/terminal-pane/pty-transport/remote-runtime/remote-runtime-pty-transport-input-coalescing.test.ts",
@@ -13272,16 +13276,6 @@
}
],
"evidenceRuns": [
{
"date": "2026-08-30",
"runner": "local",
"platform": "macos",
"command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/pty-input-write-queue.test.ts src/renderer/src/components/terminal-pane/pty-transport-input-write.test.ts src/shared/terminal-query-reply.test.ts src/shared/pty-startup-ingress-live-query-reply.test.ts src/shared/pty-startup-reply-echo-shapes.test.ts",
"currentCommand": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/pty-transport/input/pty-input-write-queue.test.ts src/renderer/src/components/terminal-pane/pty-transport/ipc/pty-transport-input-write.test.ts src/shared/terminal/protocol/queries/terminal-query-reply.test.ts src/shared/terminal/pty/ingress/pty-startup-ingress-live-query-reply.test.ts src/shared/terminal/pty/ingress/pty-startup-reply-echo-shapes.test.ts",
"result": "passed",
"durationSeconds": 15,
"summary": "Five files and 149 tests passed in 15.16 seconds, including explicit IPC reply-source routing, the 10,000-reply count and text ceilings, 10,000-entry ordinary backlog preservation, acknowledged-write FIFO barriers, synchronous reentrancy fencing, prompt teardown settlement, same-id generation fencing, real xterm OSC query floods, single-shot drain-failure recovery, and one-reply-per-write echo containment."
},
{
"date": "2026-08-09",
"runner": "local",
@@ -13303,11 +13297,11 @@
],
"runtimeBudget": {
"p95Seconds": 120,
"scope": "focused unit, local Electron, and Docker SSH contracts"
"scope": "Historical pre-audit combined-selector budget; reduced unit selectors have no new runtime measurement. Unchanged local Electron receipts retain their exact durations."
},
"flakeHistory": {
"status": "unknown",
"evidence": "Ten consecutive local unit-gate runs passed 1,380 tests in 29.57 seconds total with no fixed timing sleeps. The exact Docker SSH OSC oracle passed three consecutive runs before extraction and then passed both registered full-spec runs. The upstream-node-pty fish handoff oracle passed three local runs, including the final post-rebase run. CI and the normal soak window are not yet available."
"evidence": "Reduced unit selectors have no new soak result after Audit1878; unchanged local Electron receipts apply only to their original commands. Historical account: Ten consecutive local unit-gate runs passed 1,380 tests in 29.57 seconds total with no fixed timing sleeps. The exact Docker SSH OSC oracle passed three consecutive runs before extraction and then passed both registered full-spec runs. The upstream-node-pty fish handoff oracle passed three local runs, including the final post-rebase run. CI and the normal soak window are not yet available."
},
"redGreenEvidence": {
"status": "partial",
@@ -13326,7 +13320,9 @@
"knownGaps": [
"No live daemon, paired-runtime, WSL, physical Linux/Windows client, mixed-version, or network-fault run is registered.",
"Ordinary-input queue growth predates this change and is outside this reply-only gate.",
"Reply overload intentionally sheds the oldest synthetic replies; the modified-F3/CPR byte collision shares that budget, and live application behavior under a pathological query flood is not measured."
"Reply overload intentionally sheds the oldest synthetic replies; the modified-F3/CPR byte collision shares that budget, and live application behavior under a pathological query flood is not measured.",
"Audit1878 retires this complete dated receipt because a source in its selector was reduced. It does not validate the reduced selector. Original receipt verbatim:\n{\n \"date\": \"2026-08-30\",\n \"runner\": \"local\",\n \"platform\": \"macos\",\n \"command\": \"pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/pty-input-write-queue.test.ts src/renderer/src/components/terminal-pane/pty-transport-input-write.test.ts src/shared/terminal-query-reply.test.ts src/shared/pty-startup-ingress-live-query-reply.test.ts src/shared/pty-startup-reply-echo-shapes.test.ts\",\n \"currentCommand\": \"pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/pty-transport/input/pty-input-write-queue.test.ts src/renderer/src/components/terminal-pane/pty-transport/ipc/pty-transport-input-write.test.ts src/shared/terminal/protocol/queries/terminal-query-reply.test.ts src/shared/terminal/pty/ingress/pty-startup-ingress-live-query-reply.test.ts src/shared/terminal/pty/ingress/pty-startup-reply-echo-shapes.test.ts\",\n \"result\": \"passed\",\n \"durationSeconds\": 15,\n \"summary\": \"Five files and 149 tests passed in 15.16 seconds, including explicit IPC reply-source routing, the 10,000-reply count and text ceilings, 10,000-entry ordinary backlog preservation, acknowledged-write FIFO barriers, synchronous reentrancy fencing, prompt teardown settlement, same-id generation fencing, real xterm OSC query floods, single-shot drain-failure recovery, and one-reply-per-write echo containment.\"\n }",
"Audit1878 removes standalone reply-classifier inventory, ordinary-keystroke helper/no-write checks and a latency-order title that never executes the host path from pty-startup-ingress-live-query-reply.test.ts. It also removes projection needle/refusal/holdPartial and ConPTY/WSL string/array inventories from pty-startup-reply-echo-shapes.test.ts, including the direct short-needle and ESC-hold guard alarms. Captured echo matching and actual ingress writes/output state remain, without equivalent replacement coverage. Only the two unchanged macOS local Electron receipts remain active; neither proves the reduced five-file unit selector, SSH, a new soak result, or a new measured budget."
],
"demotionRule": "Keep experimental or revert if ordinary input is evicted, pending replies exceed either ceiling, retained replies are coalesced, a trailing key sits behind more than the bounded reply window, or the gate develops unexplained flakes."
},
@@ -2537,10 +2537,8 @@
"src/main/managed-data-accounts/service-enrollment-persistence.test.ts": 473,
"src/main/managed-data-accounts/service.test.ts": 1376,
"src/main/managed-server/activation/orcad-activation-fence-generation.test.ts": 702,
"src/main/managed-server/activation/orcad-activation-record.test.ts": 124,
"src/main/managed-server/activation/orcad-activation-transaction.test.ts": 351,
"src/main/managed-server/host-state/orcad-machine-home.test.ts": 2218,
"src/main/managed-server/host-state/orcad-state-mutation-exec.test.ts": 224,
"src/main/managed-server/host-state/orcad-state-mutation-fence-heartbeat.test.ts": 2120,
"src/main/managed-server/host-state/orcad-state-snapshot-shell.test.ts": 1009,
"src/main/managed-server/host-state/orcad-state-snapshot-space.test.ts": 366,
@@ -2548,35 +2546,22 @@
"src/main/managed-server/host-state/orcad-windows-host-fence-ops.test.ts": 852,
"src/main/managed-server/host-state/orcad-windows-host-lane.test.ts": 520,
"src/main/managed-server/host-state/orcad-windows-host-state-ops.test.ts": 6060,
"src/main/managed-server/install/orcad-remote-build-hash.integration.test.ts": 300,
"src/main/managed-server/install/orcad-remote-decommission.test.ts": 434,
"src/main/managed-server/install/orcad-remote-install-cached-probe.test.ts": 344,
"src/main/managed-server/install/orcad-remote-launch-file-modes.test.ts": 400,
"src/main/managed-server/install/orcad-remote-launch-setsid.test.ts": 342,
"src/main/managed-server/install/orcad-remote-launch.test.ts": 319,
"src/main/managed-server/install/orcad-remote-lifecycle-windows.test.ts": 398,
"src/main/managed-server/install/orcad-remote-log-tail.test.ts": 343,
"src/main/managed-server/install/orcad-remote-node-runtime-host-fallback.test.ts": 198,
"src/main/managed-server/install/orcad-remote-node-runtime-noexec.test.ts": 233,
"src/main/managed-server/install/orcad-remote-node-runtime-recorded-probe.test.ts": 332,
"src/main/managed-server/install/orcad-remote-node-runtime-slow-hash.test.ts": 1262,
"src/main/managed-server/install/orcad-remote-node-runtime.test.ts": 248,
"src/main/managed-server/install/orcad-remote-primitives.test.ts": 337,
"src/main/managed-server/install/orcad-remote-readiness-wait.test.ts": 9063,
"src/main/managed-server/install/orcad-remote-runtime-control.test.ts": 250,
"src/main/managed-server/install/orcad-remote-runtime.test.ts": 488,
"src/main/managed-server/install/orcad-remote-shell-commands.integration.test.ts": 9000,
"src/main/managed-server/install/orcad-remote-windows-commands.test.ts": 380,
"src/main/managed-server/install/orcad-remote-windows-scripts.test.ts": 3474,
"src/main/managed-server/install/remote-install-gc-claim-termination.test.ts": 214,
"src/main/managed-server/install/remote-install-gc-termination.test.ts": 190,
"src/main/managed-server/install/remote-install-lock-exited-owner.test.ts": 380,
"src/main/managed-server/install/remote-install-lock-termination.test.ts": 215,
"src/main/managed-server/install/remote-install-lock.test.ts": 147,
"src/main/managed-server/install/remote-install-model.test.ts": 164,
"src/main/managed-server/install/remote-install-noisy-sftp-wire.test.ts": 520,
"src/main/managed-server/install/remote-install-out-of-space.test.ts": 209,
"src/main/managed-server/install/remote-install-transfers-exec-fallback.test.ts": 76,
"src/main/managed-server/install/remote-node-runtime-store-gc.test.ts": 2254,
"src/main/managed-server/install/remote-node-runtime-store-lock-host-release.test.ts": 2283,
"src/main/managed-server/install/remote-node-runtime-store-windows.test.ts": 5867,
@@ -2587,28 +2572,18 @@
"src/main/managed-server/lifecycle/orcad-managed-tunnel-local-port.test.ts": 738,
"src/main/managed-server/lifecycle/orcad-managed-tunnel-peer-restart.test.ts": 494,
"src/main/managed-server/lifecycle/orcad-managed-tunnel-transport-real-openssh.test.ts": 360,
"src/main/managed-server/lifecycle/orcad-managed-tunnel-transport.test.ts": 397,
"src/main/managed-server/lifecycle/orcad-managed-update-decision.test.ts": 54,
"src/main/managed-server/lifecycle/orcad-managed-update-deferrals.test.ts": 44,
"src/main/managed-server/lifecycle/orcad-managed-update-on-restore.test.ts": 318,
"src/main/managed-server/lifecycle/orcad-managed-version-gc.test.ts": 306,
"src/main/managed-server/lifecycle/orcad-runtime-forget.test.ts": 857,
"src/main/managed-server/lifecycle/orcad-stdio-bridge-provider.test.ts": 595,
"src/main/media/media-preview-protocol.test.ts": 1027,
"src/main/media/media-range-response.test.ts": 81,
"src/main/media/preview-grant-range-read.test.ts": 691,
"src/main/media/preview-raster-header-check.test.ts": 716,
"src/main/media/remote-file-preview-reader.test.ts": 47,
"src/main/memory/collector-windows-sweep.test.ts": 871,
"src/main/memory/host-malloc-tunables.test.ts": 41,
"src/main/memory/host-memory.test.ts": 80,
"src/main/memory/hydrate-local-pty-registry.test.ts": 1970,
"src/main/memory/verified-local-folder-workspaces.test.ts": 74,
"src/main/mimo/hook-service.test.ts": 115,
"src/main/minimax/minimax-cookie-store.test.ts": 81,
"src/main/muse-usage/scanner.test.ts": 363,
"src/main/muse-usage/snapshot-rollups.test.ts": 43,
"src/main/muse/hook-config-json.test.ts": 77,
"src/main/muse/hook-service.test.ts": 145,
"src/main/native-chat/agent-model-catalog/agent-model-catalog-discovery-freshness.test.ts": 390,
"src/main/native-chat/agent-model-catalog/agent-model-catalog-entry.test.ts": 75,
@@ -2739,7 +2714,6 @@
"src/main/native-chat/agent-session-wire/host/testing/structured-conversation-command.test.ts": 2989,
"src/main/native-chat/agent-session-wire/rewind/testing/structured-rewind-cut.test.ts": 672,
"src/main/native-chat/chat-launcher-command.test.ts": 651,
"src/main/native-chat/host-readable-transcript-path-fs-gate.test.ts": 373,
"src/main/native-chat/host-readable-transcript-path.test.ts": 150,
"src/main/native-chat/native-chat-inline-visuals-setting.test.ts": 751,
"src/main/native-chat/native-chat-scratch-folder-migration.test.ts": 109,
@@ -2747,24 +2721,18 @@
"src/main/native-chat/native-chat-visuals-sweep.test.ts": 123,
"src/main/native-chat/session-file-resolver-wsl-scan-gate.test.ts": 156,
"src/main/native-chat/session-file-resolver.test.ts": 245,
"src/main/native-chat/ssh-transcript-path.test.ts": 48,
"src/main/native-chat/structured-agent-command-guest-path.test.ts": 128,
"src/main/native-chat/structured-agent-command-resolution-wsl-host.test.ts": 108,
"src/main/native-chat/structured-agent-command-resolution.test.ts": 223,
"src/main/native-chat/structured-agent-configured-args.test.ts": 191,
"src/main/native-chat/subagent-entry-id-bounds.test.ts": 42,
"src/main/native-chat/transcript-claude-read-image.test.ts": 389,
"src/main/native-chat/transcript-file-version.test.ts": 121,
"src/main/native-chat/transcript-interruption-message.test.ts": 263,
"src/main/native-chat/transcript-line-decoders-claude-command-output.test.ts": 248,
"src/main/native-chat/transcript-line-decoders-claude-control-bytes.test.ts": 237,
"src/main/native-chat/transcript-line-decoders-claude-image-companion.test.ts": 238,
"src/main/native-chat/transcript-line-decoders-claude-pasted-content.test.ts": 250,
"src/main/native-chat/transcript-line-decoders-claude-queued-prompt.test.ts": 255,
"src/main/native-chat/transcript-line-decoders-claude-stop-hook.test.ts": 211,
"src/main/native-chat/transcript-line-decoders.grok.test.ts": 283,
"src/main/native-chat/transcript-line-decoders.omp.test.ts": 297,
"src/main/native-chat/transcript-opencode-read-bounds.test.ts": 426,
"src/main/native-chat/transcript-opencode-sqlite-query.test.ts": 886,
"src/main/native-chat/transcript-read-cache-refusal-recovery.test.ts": 536,
"src/main/native-chat/transcript-read-cache-wsl-stall.test.ts": 402,
@@ -6592,57 +6560,31 @@
"src/renderer/src/components/tab-group/RetainedPaneHost.test.tsx": 340,
"src/renderer/src/components/tab-group/TabGroupPanel.os-file-drop.test.tsx": 1711,
"src/renderer/src/components/tab-group/TabGroupPanel.pane-actions.test.tsx": 1419,
"src/renderer/src/components/tab-group/TabGroupSplitLayout.drag.test.tsx": 1106,
"src/renderer/src/components/tab-group/TabGroupSplitLayout.test.ts": 883,
"src/renderer/src/components/tab-group/TabPaneColumnSplitDragOverlay.test.tsx": 203,
"src/renderer/src/components/tab-group/editor-reveal-flash.test.ts": 246,
"src/renderer/src/components/tab-group/tab-drag-pointer-sensor.test.ts": 187,
"src/renderer/src/components/tab-group/tab-drag-pointer.test.ts": 46,
"src/renderer/src/components/tab-group/tab-drag-preview-activation.test.ts": 1785,
"src/renderer/src/components/tab-group/tab-drag-retained-guest-passthrough.test.tsx": 2104,
"src/renderer/src/components/tab-group/tab-drop-zone.test.ts": 45,
"src/renderer/src/components/tab-group/tab-group-body-anchor.test.ts": 49,
"src/renderer/src/components/tab-group/tab-group-body-geometry.test.ts": 194,
"src/renderer/src/components/tab-group/tab-group-layout-owner-commands.test.ts": 1764,
"src/renderer/src/components/tab-group/tab-group-panel-split-target.test.ts": 1838,
"src/renderer/src/components/tab-group/tab-insertion.test.ts": 56,
"src/renderer/src/components/tab-group/tab-moves-drawn-from-frames.test.ts": 2105,
"src/renderer/src/components/tab-group/useTabDragSplit.test.ts": 2120,
"src/renderer/src/components/tab-group/useTabGroupCloseScopeCommands.test.tsx": 2023,
"src/renderer/src/components/tab-group/workspace-tab-close-commands.frame-flush.test.ts": 1604,
"src/renderer/src/components/task-page/github/task-page-github-dialog-state-authority.test.ts": 107,
"src/renderer/src/components/task-page/github/task-page-github-landing-refresh-record.test.tsx": 971,
"src/renderer/src/components/task-page/github/task-page-github-list-scroll-restore.test.ts": 197,
"src/renderer/src/components/task-page/github/task-page-github-resume-cache.test.ts": 49,
"src/renderer/src/components/task-page/github/task-page-github-reviewer-suggestions.test.ts": 235,
"src/renderer/src/components/task-page/github/task-page-github-status-actions.test.ts": 50,
"src/renderer/src/components/task-page/github/task-page-github-status-state.test.ts": 42,
"src/renderer/src/components/task-page/github/task-page-github-task-kind.test.ts": 64,
"src/renderer/src/components/task-page/github/task-page-github-work-item-filter-membership.test.ts": 51,
"src/renderer/src/components/task-page/github/task-page-github-work-item-mutation-patches.test.ts": 53,
"src/renderer/src/components/task-page/github/task-page-github-work-item-mutation-regressions.test.ts": 140,
"src/renderer/src/components/task-page/github/task-page-github-work-item-mutations.test.ts": 169,
"src/renderer/src/components/task-page/github/task-page-github-work-item-status.test.ts": 49,
"src/renderer/src/components/task-page/github/use-task-page-github-issue-creation.test.tsx": 1769,
"src/renderer/src/components/task-page/gitlab/use-task-page-gitlab-loading.test.tsx": 1504,
"src/renderer/src/components/task-page/jira/Content.board-owner.test.tsx": 2582,
"src/renderer/src/components/task-page/jira/Filters.linked-mode.test.tsx": 3670,
"src/renderer/src/components/task-page/jira/IssueAssigneeField.test.tsx": 1122,
"src/renderer/src/components/task-page/jira/TaskPageJiraSavedFilters.test.tsx": 1173,
"src/renderer/src/components/task-page/jira/TaskPageJiraStatusFilters.test.tsx": 923,
"src/renderer/src/components/task-page/jira/task-page-jira-board-detail-integration.test.tsx": 2192,
"src/renderer/src/components/task-page/jira/task-page-jira-board-transitions.test.ts": 69,
"src/renderer/src/components/task-page/jira/task-page-jira-board-view.test.tsx": 2287,
"src/renderer/src/components/task-page/jira/task-page-jira-cache-selectors.test.ts": 54,
"src/renderer/src/components/task-page/jira/task-page-jira-create-fields.test.ts": 77,
"src/renderer/src/components/task-page/jira/task-page-jira-favourite-selection.test.tsx": 3326,
"src/renderer/src/components/task-page/jira/task-page-jira-grouping.test.ts": 2324,
"src/renderer/src/components/task-page/jira/task-page-jira-linked-issues.test.ts": 204,
"src/renderer/src/components/task-page/jira/task-page-jira-quick-copy.test.tsx": 3457,
"src/renderer/src/components/task-page/jira/task-page-jira-saved-filter-load-failure.test.tsx": 3903,
"src/renderer/src/components/task-page/jira/task-page-jira-sorting.test.ts": 156,
"src/renderer/src/components/task-page/jira/task-page-jira-text-fallback-notice.test.tsx": 499,
"src/renderer/src/components/task-page/jira/use-task-page-jira-list-effects.test.ts": 3241,
"src/renderer/src/components/task-page/jira/use-task-page-jira-list-state.owner.test.tsx": 653,
"src/renderer/src/components/task-page/jira/use-task-page-jira-project-scope.test.tsx": 2642,
"src/renderer/src/components/task-page/jira/use-task-page-jira-quick-filters.test.tsx": 1466,
@@ -8757,10 +8699,6 @@
"src/shared/project-identity-succession.test.ts": 76,
"src/shared/promise-settlement-waiters.test.ts": 183,
"src/shared/protocol-compat.test.ts": 153,
"src/shared/terminal/pty/diagnostics/pty-delivery-diagnostics.test.ts": 47,
"src/shared/terminal/pty/lifecycle/pty-liveness-verdict.test.ts": 40,
"src/shared/terminal/pty/lifecycle/pty-owner-backend.test.ts": 52,
"src/shared/terminal/pty/ingress/pty-owner-color-query-colors.test.ts": 65,
"src/shared/terminal/pty/lifecycle/pty-running-work-probe.test.ts": 60,
"src/shared/terminal/pty/native-process/pty-slave-line-discipline-echo.test.ts": 51,
"src/shared/terminal/pty/ingress/pty-startup-ingress-kitty.test.ts": 135,
@@ -8890,10 +8828,6 @@
"src/shared/sidebar-list/text-order.test.ts": 59,
"src/shared/sidebar-list/workspace-review-facts.test.ts": 191,
"src/shared/skill-bundle-install-contract.test.ts": 123,
"src/shared/skill-bundle-name.test.ts": 44,
"src/shared/skill-delete-contract.test.ts": 102,
"src/shared/skill-install-contract.test.ts": 107,
"src/shared/skill-install-failure.test.ts": 94,
"src/shared/skill-metadata.test.ts": 41,
"src/shared/skill-package-manifest.test.ts": 188,
"src/shared/skill-path-containment.test.ts": 49,
@@ -8905,16 +8839,10 @@
"src/shared/source-scan/source-tree-walk.test.ts": 62,
"src/shared/ssh-pending-pty-kill.test.ts": 43,
"src/shared/ssh-pty-id.test.ts": 51,
"src/shared/ssh-remote-pty-lease-predicates.test.ts": 45,
"src/shared/ssh-retained-payload-admission.test.ts": 69,
"src/shared/ssh-target-generation.test.ts": 44,
"src/shared/ssh-target-source.test.ts": 40,
"src/shared/workspace-layout/stable-pane-id.test.ts": 42,
"src/shared/startup-command-staging.node-pty.test.ts": 2207,
"src/shared/startup-command-staging.test.ts": 302,
"src/shared/startup-command-submission.test.ts": 37,
"src/shared/status-bar-compact-change-notice.test.ts": 48,
"src/shared/status-bar-usage-mode.test.ts": 51,
"src/shared/structural-value-equality.test.ts": 47,
"src/shared/structured-agent-question-projection.test.ts": 210,
"src/shared/structured-agent-turn-clock-anchor.test.ts": 172,
@@ -8924,24 +8852,15 @@
"src/shared/subprocess-stdin-write.test.ts": 32,
"src/shared/svg-document.test.ts": 51,
"src/shared/tab-agent-from-signals-pi-identity.test.ts": 103,
"src/shared/tab-has-live-pty.test.ts": 37,
"src/shared/tailnet-address.test.ts": 43,
"src/shared/task-providers.test.ts": 47,
"src/shared/task-query.test.ts": 55,
"src/shared/task-source-context.test.ts": 103,
"src/shared/telemetry-agent-dashboard-event-schemas.test.ts": 182,
"src/shared/telemetry-common-props.test.ts": 173,
"src/shared/telemetry-payload-contracts.test.ts": 282,
"src/shared/telemetry-workspace-create-schemas.test.ts": 192,
"src/shared/program-status/terminal-agent-status-processor.test.ts": 74,
"src/shared/terminal/output/terminal-bell-detector.test.ts": 40,
"src/shared/terminal/protocol/queries/terminal-color-scheme-protocol.test.ts": 39,
"src/shared/terminal/input/terminal-composer-draft.test.ts": 73,
"src/shared/terminal-themes/terminal-custom-themes.test.ts": 48,
"src/shared/terminal/pty/lifecycle/terminal-exit-cause.test.ts": 64,
"src/shared/terminal/links/terminal-file-uri-link.test.ts": 51,
"src/shared/terminal/links/terminal-file-url-target.test.ts": 47,
"src/shared/terminal/appearance/terminal-fonts.test.ts": 40,
"src/shared/terminal/links/terminal-github-pr-link-detector.test.ts": 61,
"src/shared/terminal/links/terminal-http-url-extraction.test.ts": 42,
"src/shared/terminal/input/terminal-input.test.ts": 85,
@@ -8968,12 +8887,9 @@
"src/shared/terminal/protocol/escape/terminal-partial-escape-tail.test.ts": 65,
"src/shared/file-links/file-link-path-normalization.test.ts": 50,
"src/shared/terminal/protocol/queries/terminal-query-reply.test.ts": 94,
"src/shared/terminal/quick-commands/terminal-quick-commands.test.ts": 61,
"src/shared/terminal/protocol/queries/terminal-reply-query-extraction.test.ts": 46,
"src/shared/terminal/protocol/queries/terminal-reply-query-scan.test.ts": 51,
"src/shared/terminal/replay/testing/terminal-restore-parity-writes.test.ts": 153,
"src/shared/terminal/replay/terminal-scrollback-policy.test.ts": 43,
"src/shared/terminal/output/terminal-selection-gutter.test.ts": 49,
"src/shared/workspace-session/terminal-session-state-save-failure.test.ts": 46,
"src/shared/terminal/pty/lifecycle/terminal-start-failure.test.ts": 46,
"src/shared/terminal/stream/terminal-stream-json-byte-length.test.ts": 51,
+1 -1
View File
@@ -40,7 +40,7 @@
"test:changed": "node config/scripts/workspace-package-build/build-workspace-packages.mjs && node config/scripts/native-build/node-addons/ensure-native-runtime.mjs --runtime=node && node config/scripts/test-runtime/execution-policy/run-vitest.mjs run --config config/vitest.config.ts --changed=HEAD",
"test:node": "pnpm run build:packages && node config/scripts/native-build/node-addons/ensure-native-runtime.mjs --runtime=node && vitest run --config config/vitest.config.ts",
"test:node-server": "node config/scripts/ci/unit-selection/run-node-server-tests.mjs",
"test:skill-sharing:release": "vitest run --config config/vitest.config.ts src/main/skills src/main/runtime/rpc/methods/skills/skills.test.ts src/shared/skill-bundle-install-contract.test.ts src/shared/skill-install-contract.test.ts src/shared/skill-install-failure.test.ts src/shared/skill-package-manifest.test.ts",
"test:skill-sharing:release": "vitest run --config config/vitest.config.ts src/main/skills src/main/runtime/rpc/methods/skills/skills.test.ts src/shared/skill-bundle-install-contract.test.ts src/shared/skill-package-manifest.test.ts",
"test:repro:remote-agent-session": "pnpm run build:cli && pnpm run build:electron-vite && node config/scripts/diagnostics/remote-agent-authority/remote-agent-session-authority-repro.mjs",
"capture:agent-transcript": "node config/scripts/native-build/node-addons/ensure-native-runtime.mjs --runtime=node && node config/scripts/diagnostics/terminal-transcripts/capture-agent-pty-transcript.mjs",
"check:reliability-gates": "node config/scripts/static-analysis/reliability-policy/check-reliability-gates.mjs",
@@ -42,12 +42,6 @@ describe('shouldUseRendererBackedCodexTerminal', () => {
expect(shouldUseRendererBackedCodexTerminal('codex --no-daemon resume --last')).toBe(true)
expect(shouldUseRendererBackedCodexTerminal('codex --no-daemon exec summarize')).toBe(false)
})
it('ignores non-Codex commands', () => {
expect(shouldUseRendererBackedCodexTerminal(undefined)).toBe(false)
expect(shouldUseRendererBackedCodexTerminal('claude')).toBe(false)
expect(shouldUseRendererBackedCodexTerminal('npm exec codex')).toBe(false)
})
})
describe('shouldUseRendererBackedInteractiveTerminal', () => {
@@ -66,9 +60,4 @@ describe('shouldUseRendererBackedInteractiveTerminal', () => {
expect(shouldUseRendererBackedInteractiveTerminal('claude --help')).toBe(false)
expect(shouldUseRendererBackedInteractiveTerminal('claude --version')).toBe(false)
})
it('preserves Codex command classification', () => {
expect(shouldUseRendererBackedInteractiveTerminal('codex')).toBe(true)
expect(shouldUseRendererBackedInteractiveTerminal('codex exec summarize')).toBe(false)
})
})
@@ -1,127 +0,0 @@
import { describe, expect, it } from 'vitest'
import {
emptyOrcadActivationRecord,
orcadGcPinnedDirNames,
parseOrcadActivationRecord,
serializeOrcadActivationRecord,
withActivatedVersion,
withDeactivatedVersionCommitted,
withRolledBackVersion,
type OrcadStateSnapshot
} from './orcad-activation-record'
import { sameOrcadActivationRecord } from './orcad-activation-transaction'
const SNAPSHOT: OrcadStateSnapshot = {
dirName: 'pre-0.2.0+bb01-1000',
takenBeforeVersion: '0.2.0+bb01',
readableByVersion: '0.1.0+aa01',
takenAt: '2026-01-01T00:00:00.000Z'
}
const NOW = new Date('2026-01-02T00:00:00.000Z')
describe('orcad activation record', () => {
it('reports a newer schema as unreadable, never as absent', () => {
const parsed = parseOrcadActivationRecord(JSON.stringify({ schemaVersion: 2, active: 'x' }))
expect(parsed.state).toBe('unreadable')
})
it('reports corrupt JSON as unreadable, never as absent', () => {
expect(parseOrcadActivationRecord('{not json').state).toBe('unreadable')
})
it('does not let a re-deploy of the active version erase the rollback target', () => {
const before = {
...emptyOrcadActivationRecord(),
active: '0.2.0+bb01',
previous: '0.1.0+aa01',
snapshot: SNAPSHOT
}
const after = withActivatedVersion(before, '0.2.0+bb01', null, NOW)
expect(after).toMatchObject({ active: '0.2.0+bb01', previous: '0.1.0+aa01' })
expect(after.snapshot).toEqual(SNAPSHOT)
})
it('clears the rollback target after rolling back, so it cannot walk into the bad build', () => {
const before = {
...emptyOrcadActivationRecord(),
active: '0.2.0+bb01',
previous: '0.1.0+aa01',
snapshot: SNAPSHOT
}
expect(withRolledBackVersion(before, NOW)).toMatchObject({
active: '0.1.0+aa01',
previous: null,
snapshot: null
})
})
it('pins the active version, the rollback target and the live daemon"s bundle against GC', () => {
const pinned = orcadGcPinnedDirNames(
{
...emptyOrcadActivationRecord(),
active: '0.3.0+cc01',
previous: '0.2.0+bb01'
},
'0.1.0+aa01'
)
expect(pinned).toEqual(['orcad-0.3.0+cc01', 'orcad-0.2.0+bb01', 'orcad-0.1.0+aa01'])
})
it('records which Orca activated each version, and the build a rollback left', () => {
const first = withActivatedVersion(
emptyOrcadActivationRecord(),
'0.1.0+aa01',
null,
NOW,
'1.4.0'
)
const second = withActivatedVersion(first, '0.2.0+bb01', SNAPSHOT, NOW, '1.5.0')
expect(second).toMatchObject({ activeAppVersion: '1.5.0', previousAppVersion: '1.4.0' })
expect(parseOrcadActivationRecord(serializeOrcadActivationRecord(second))).toEqual({
state: 'ok',
record: second
})
const rolledBack = withRolledBackVersion(second, NOW)
expect(rolledBack).toMatchObject({
active: '0.1.0+aa01',
activeAppVersion: '1.4.0',
rolledBackFrom: '0.2.0+bb01'
})
expect(withActivatedVersion(rolledBack, '0.3.0+cc01', null, NOW, '1.6.0')).not.toHaveProperty(
'rolledBackFrom'
)
})
it('names no previous app version when activating over a stopped host', () => {
const served = withActivatedVersion(
emptyOrcadActivationRecord(),
'0.1.0+aa01',
null,
NOW,
'1.5.0'
)
const stopped = withDeactivatedVersionCommitted(served)
expect(stopped).toMatchObject({
active: null,
previous: '0.1.0+aa01',
activeAppVersion: '1.5.0'
})
const redeployed = withActivatedVersion(stopped, '0.2.0+bb01', null, NOW, '1.6.0')
expect(redeployed).toMatchObject({ previous: null, activeAppVersion: '1.6.0' })
expect(redeployed).not.toHaveProperty('previousAppVersion')
})
it('matches a journal from a build that drops the advisory fields', () => {
const current = withActivatedVersion(
emptyOrcadActivationRecord(),
'0.1.0+aa01',
null,
NOW,
'1.5.0'
)
const { activeAppVersion: _dropped, ...older } = current
expect(sameOrcadActivationRecord(current, older)).toBe(true)
expect(sameOrcadActivationRecord(current, { ...older, active: '0.2.0+bb01' })).toBe(false)
})
})
@@ -1,66 +0,0 @@
import { EventEmitter } from 'node:events'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { ClientChannel } from 'ssh2'
import {
ORCAD_STATE_MUTATION_CLIENT_TIMEOUT_MS,
execOrcadStateMutation,
execOrcadStateMutationOr
} from './orcad-state-mutation-exec'
import { isUnconfirmedSshCommandTermination } from '../../ssh/remote-exec/remote-exec-command'
import { getRemoteHostPlatform } from '../../ssh/ssh-remote-platform'
import type { OrcadHostTransport } from '../../ssh/orcad-host-transport'
class SlowChannel extends EventEmitter {
readonly stderr = new EventEmitter()
readonly stdin = this
closed = false
// Model sshd acknowledging channel close without proving the command stopped.
close(): void {
this.closed = true
queueMicrotask(() => this.emit('close', 0))
}
resume(): void {}
finish(output: string): void {
this.emit('data', Buffer.from(output))
this.emit('close', 0)
}
}
let channel: SlowChannel
const conn = {
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: execCommand reads only the channel events, close() and resume() SlowChannel implements.
exec: async () => channel as unknown as ClientChannel,
usesSystemSshTransport: () => false
}
const target = {
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: only exec and usesSystemSshTransport are read.
conn: conn as unknown as OrcadHostTransport,
host: getRemoteHostPlatform('linux-x64')
}
beforeEach(() => {
vi.useFakeTimers()
channel = new SlowChannel()
})
afterEach(() => {
vi.useRealTimers()
})
describe('a snapshot restore that runs past the 30s exec timeout', () => {
it('keeps waiting past 30s and returns the restore’s own answer', async () => {
const restore = execOrcadStateMutation(target, 'restore')
await vi.advanceTimersByTimeAsync(45_000)
expect(channel.closed).toBe(false)
channel.finish('RESTORED\n')
await expect(restore).resolves.toBe('RESTORED\n')
})
it('treats giving up as unconfirmed, even when sshd confirms the channel close', async () => {
const restore = execOrcadStateMutationOr(target, 'restore', 'FALLBACK')
const settled = restore.catch((error: unknown) => error)
await vi.advanceTimersByTimeAsync(ORCAD_STATE_MUTATION_CLIENT_TIMEOUT_MS + 1_000)
const error = await settled
expect(channel.closed).toBe(true)
expect(isUnconfirmedSshCommandTermination(error)).toBe(true)
})
})
@@ -1,33 +1,18 @@
// Exercise real heartbeats and steal commands so long mutations retain activation ownership.
import {
existsSync,
mkdirSync,
mkdtempSync,
readFileSync,
rmSync,
statSync,
writeFileSync
} from 'node:fs'
import { existsSync, mkdirSync, 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 { runProcess, spawnProcess } from '@orca/process-host'
import { runProcess } from '@orca/process-host'
import { RELAY_INSTALL_LOCK_NAME } from '../../../shared/relay-install-lock-name'
import { ORCAD_ACTIVATION_TRANSACTION_DIRNAME } from '../activation/orcad-activation-transaction'
import { serializedStateMutationCommand } from './orcad-state-snapshot'
import { ORCAD_FENCE_OWNER_FILENAME } from '../activation/orcad-activation-fence-scope'
import { tryStealInstallLockCommand } from '../install/remote-install-lock-commands'
import { getRemoteHostPlatform } from '../../ssh/ssh-remote-platform'
const posix = getRemoteHostPlatform('linux-x64')
const STALE_SECONDS = 3
const HEARTBEAT_SECONDS = 0.1
const OBSERVE_HEARTBEATS_MS = 300
async function sh(command: string): Promise<string> {
return (await runProcess({ program: '/bin/sh', args: ['-c', command] })).stdout.trim()
}
const pause = (ms: number): Promise<void> => new Promise((resolve) => setTimeout(resolve, ms))
describe.skipIf(process.platform === 'win32')(
'the fence heartbeat of a running state mutation',
@@ -50,56 +35,6 @@ describe.skipIf(process.platform === 'win32')(
rmSync(base, { recursive: true, force: true })
})
const backdate = (): Promise<string> => sh(`touch -m -t 200001010000 '${fence}'`)
const steal = (): Promise<string> => sh(tryStealInstallLockCommand(posix, fence, STALE_SECONDS))
const age = (): number => Date.now() / 1000 - statSync(fence).mtimeMs / 1000
it('is not stolen while a long mutation runs, and is once its holder dies', async () => {
const run = spawnProcess({
program: '/bin/sh',
args: ['-c', mutation('sleep 30')]
})
try {
const pidFile = join(base, 'orcad-state-mutation.lock', 'pid')
await expect
.poll(() => existsSync(pidFile) && readFileSync(pidFile, 'utf8').trim())
.toBeTruthy()
await backdate()
await expect.poll(age, { timeout: 5_000, interval: 25 }).toBeLessThan(STALE_SECONDS)
expect(age()).toBeLessThan(STALE_SECONDS)
expect(await steal()).toBe('BUSY')
// Kill the owner; subsequent fence freshness must come from no surviving heartbeat.
process.kill(Number(readFileSync(pidFile, 'utf8').trim()), 'SIGKILL')
await pause(OBSERVE_HEARTBEATS_MS)
await backdate()
await pause(OBSERVE_HEARTBEATS_MS)
expect(age()).toBeGreaterThan(STALE_SECONDS)
expect(await steal()).toMatch(/OK$/u)
} finally {
run.kill('SIGKILL')
}
}, 20_000)
it('stops refreshing a fence another run took over mid-mutation, and keeps its token', async () => {
const run = spawnProcess({ program: '/bin/sh', args: ['-c', mutation('sleep 6')] })
try {
await expect
.poll(() => existsSync(join(base, 'orcad-state-mutation.lock', 'pid')))
.toBe(true)
await backdate()
await expect.poll(age, { timeout: 5_000, interval: 25 }).toBeLessThan(STALE_SECONDS)
writeFileSync(join(fence, ORCAD_FENCE_OWNER_FILENAME), 'successor')
await backdate()
await pause(OBSERVE_HEARTBEATS_MS)
expect(age()).toBeGreaterThan(STALE_SECONDS)
expect(readFileSync(join(fence, ORCAD_FENCE_OWNER_FILENAME), 'utf8')).toBe('successor')
} finally {
run.kill('SIGKILL')
}
}, 20_000)
it('never starts a mutation once its run no longer owns the fence', async () => {
writeFileSync(join(fence, ORCAD_FENCE_OWNER_FILENAME), 'successor')
const marker = join(base, 'mutated')
@@ -107,16 +42,5 @@ describe.skipIf(process.platform === 'win32')(
expect(existsSync(marker)).toBe(false)
expect(existsSync(join(base, 'orcad-state-mutation.lock'))).toBe(false)
}, 20_000)
it('stops refreshing once the mutation finishes, and never creates a missing fence', async () => {
await sh(mutation('sleep 0.3'))
await backdate()
await pause(OBSERVE_HEARTBEATS_MS)
expect(age()).toBeGreaterThan(STALE_SECONDS)
rmSync(fence, { recursive: true })
await sh(serializedStateMutationCommand(base, ':', HEARTBEAT_SECONDS, null))
expect(existsSync(fence)).toBe(false)
}, 20_000)
}
)
@@ -12,13 +12,7 @@ import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { runProcess } from '@orca/process-host'
import { captureOrcadStateSnapshotCommand, parseOrcadSnapshotCapture } from './orcad-state-snapshot'
import {
orcadSnapshotFailureReason,
orcadSnapshotShortfallKb,
orcadSnapshotShortfallMessage,
orcadSnapshotSpaceCommand,
parseOrcadSnapshotSpace
} from './orcad-state-snapshot-space'
import { orcadSnapshotFailureReason } from './orcad-state-snapshot-space'
import { getRemoteHostPlatform } from '../../ssh/ssh-remote-platform'
const posix = getRemoteHostPlatform('linux-x64')
@@ -85,61 +79,4 @@ describe.skipIf(process.platform === 'win32')('snapshot room on a real shell (#3
])
expect(existsSync(join(snapshot, 'state.tar'))).toBe(true)
})
it('counts what a prune would reclaim as room, and refuses when even that is short', async () => {
const bin = join(base, 'bin')
mkdirSync(bin)
writeFileSync(
join(bin, 'df'),
"#!/bin/sh\nprintf 'Filesystem 1024-blocks Used Available Capacity Mounted on\\nfiler:/home 100 100 0 100%% /home\\n'\n"
)
chmodSync(join(bin, 'df'), 0o755)
const measure = async (keep: string[]) =>
parseOrcadSnapshotSpace(
await sh(orcadSnapshotSpaceCommand(posix, root, snapshots, remoteBase, keep) ?? '', bin)
)
const keepAll = await measure(['pre-0.1.0+aa01-1', 'pre-0.1.0+bb02-2'])
const keepOne = await measure(['pre-0.1.0+bb02-2'])
expect(keepAll?.availableKb).toBe(0)
expect(keepAll?.neededKb).toBeGreaterThanOrEqual(64)
expect(keepOne?.availableKb).toBeGreaterThanOrEqual(64)
expect(readdirSync(snapshots)).toHaveLength(3)
const shortfall = orcadSnapshotShortfallKb(keepAll)
expect(shortfall).toBeGreaterThan(0)
expect(
orcadSnapshotShortfallMessage(
keepAll ?? { neededKb: 0, availableKb: 0, reclaimKb: 0 },
shortfall ?? 0,
{
userDataDir: '/home/work/.orca',
baseDir: '/home/work/.orca-remote',
fullVersion: '0.1.0+77dfa08c40aa',
active: '0.1.0+aa01'
}
)
).toMatch(
/has 0 KB free, and.*left the current server \(0\.1\.0\+aa01\) in place.*Nothing was stopped/u
)
})
it('answers UNMEASURED, which reads as unknown, when a scan overruns its deadline', async () => {
const bin = join(base, 'bin')
mkdirSync(bin)
// Let syntax/member probes pass; time out only the old-snapshot scan.
writeFileSync(
join(bin, 'timeout'),
'#!/bin/sh\nshift\ncase "$*" in *orcad-state-snapshots*) exit 124;; esac\nexec "$@"\n'
)
chmodSync(join(bin, 'timeout'), 0o755)
const output = await sh(
orcadSnapshotSpaceCommand(posix, root, snapshots, remoteBase, []) ?? '',
bin
)
expect(output.trim()).toBe('SNAPSHOT_SPACE UNMEASURED')
expect(parseOrcadSnapshotSpace(output)).toBeNull()
})
})
@@ -42,15 +42,6 @@ function hostWithFence(token: string) {
}
describe('the Windows host script under an activation fence', () => {
it('runs an op only for the fence’s current holder', async () => {
const host = hostWithFence('successor')
const stale = await host.op('--fence', host.lock, 'stale', 'fence-check')
expect(stale.code).toBe(ORCAD_FENCE_LOST_EXIT)
expect(stale.stdout).toContain(ORCAD_FENCE_LOST_MARKER)
const owner = await host.op('--fence', host.lock, 'successor', 'fence-check')
expect(owner).toMatchObject({ code: 0, stdout: 'OK' })
})
it('releases only its own generation, leaving a successor’s fence and journal', async () => {
const host = hostWithFence('successor')
expect((await host.op('fence-release', host.lock, host.journal, 'stale')).stdout).toBe(
@@ -136,30 +127,6 @@ describe('the Windows host script proving a lock an exited client left', () => {
return { lock, mutation, check }
}
it('names a quiet lock an exited client holds, and nothing else', async () => {
const host = quietHost('t-exited')
expect(await host.check('0', 't-other')).toBe('KEPT')
expect(await host.check('0', 't-other', 't-exited')).toBe('EXITED_OWNER t-exited')
})
it('keeps a lock that is not yet quiet', async () => {
const host = quietHost('t-exited')
utimesSync(host.lock, new Date(), new Date())
expect(await host.check('0', 't-exited')).toBe('KEPT')
})
it('keeps the fence while its state mutation holder may still run', async () => {
const host = quietHost('t-exited')
mkdirSync(host.mutation)
// Unreadable creation time for a live PID cannot prove its owner exited.
writeFileSync(
join(host.mutation, 'owner.json'),
JSON.stringify({ pid: process.pid, creationTimeMs: 1234 })
)
expect(await host.check('1', 't-exited')).toBe('KEPT')
expect(await host.check('0', 't-exited')).toBe('EXITED_OWNER t-exited')
})
it('keeps the fence while any mutation lock exists, even one whose holder exited', async () => {
const host = quietHost('t-exited')
mkdirSync(host.mutation)
@@ -1,42 +0,0 @@
import { execFileSync } from 'node:child_process'
import { createHash } from 'node:crypto'
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, expect, it } from 'vitest'
import { remoteOrcadBuildHashCommand } from './orcad-remote-build-hash'
import { getRemoteHostPlatform } from '../../ssh/ssh-remote-platform'
let directory = ''
beforeEach(() => {
directory = mkdtempSync(join(tmpdir(), "orcad-hash-'quoted "))
})
afterEach(() => {
rmSync(directory, { recursive: true, force: true })
})
it.skipIf(process.platform === 'win32').each([false, true])(
'reads the installed build identity for split entry %s through a real POSIX shell',
(splitEntry) => {
const launcher = join(directory, 'orcad.js')
writeFileSync(launcher, 'launcher\n')
if (splitEntry) {
writeFileSync(join(directory, 'orcad-server.js'), 'server\n')
}
const command = remoteOrcadBuildHashCommand(getRemoteHostPlatform('linux-x64'), directory)
const run = (prefix = ''): string =>
execFileSync('/bin/sh', ['-c', prefix + command], { encoding: 'utf8' }).trim()
const legacyHash = createHash('sha256').update('launcher\n').digest('hex').slice(0, 16)
const expected = `__ORCAD_BUILD_HASH__ ${legacyHash}`
expect(run()).toBe(expected)
expect(run('command() { [ "$2" = shasum ]; }; ')).toBe(expected)
}
)
it.skipIf(process.platform === 'win32')(
'refuses an absent launcher without publishing an empty-file hash',
() => {
const command = remoteOrcadBuildHashCommand(getRemoteHostPlatform('linux-x64'), directory)
expect(() => execFileSync('/bin/sh', ['-c', command], { encoding: 'utf8' })).toThrow()
}
)
@@ -1,95 +0,0 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type * as ExecCommandModule from '../../ssh/remote-exec/remote-exec-command'
vi.mock('../../ssh/remote-exec/remote-exec-command', async (importOriginal) => ({
...(await importOriginal<typeof ExecCommandModule>()),
execCommand: vi.fn()
}))
vi.mock('./orcad-remote-node-runtime', async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
ensureRemoteOrcadNodeRuntime: vi.fn(async () => ({ executable: '/n', transfer: 'cached' }))
}))
vi.mock('./remote-versioned-install', async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
isRemoteInstallComplete: vi.fn(async () => true)
}))
import { execCommand } from '../../ssh/remote-exec/remote-exec-command'
import { getRemoteHostPlatform } from '../../ssh/ssh-remote-platform'
import type { OrcadHostTransport } from '../../ssh/orcad-host-transport'
import { ensureRemoteOrcadNodeRuntime } from './orcad-remote-node-runtime'
import { isRemoteInstallComplete } from './remote-versioned-install'
import { installOrcadBundle } from './orcad-remote-install'
import { cachedOrcadSlotInstalled } from './orcad-remote-install-cached-probe'
const mockExec = vi.mocked(execCommand)
const END = '\n__ORCA_CACHED_SLOT_PROBE_END__\n'
const SLOT = '/home/u/.orca-remote/orcad-0.1.0+aa'
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: execCommand is mocked, so the transport is never used.
const conn = {} as OrcadHostTransport
const linux = getRemoteHostPlatform('linux-x64')
const options = { conn, host: linux, target: 'linux-x64-glibc' as const }
function bundle(recordedNodeRuntimeProbe?: boolean) {
return installOrcadBundle(
{
...options,
localOrcadDir: '/local/orcad',
nodeRuntimeArchive: async () => '/cache/node.tar.gz',
...(recordedNodeRuntimeProbe ? { recordedNodeRuntimeProbe } : {})
},
'0.1.0+aa',
SLOT
)
}
beforeEach(() => {
vi.clearAllMocks()
})
describe('cachedOrcadSlotInstalled', () => {
it('skips the install only when the runtime is cached and the slot complete', async () => {
mockExec.mockResolvedValueOnce(`ORCA_NODE_RUNTIME_READY${END}OK\n`)
await expect(cachedOrcadSlotInstalled(options, SLOT)).resolves.toBe(true)
expect(mockExec).toHaveBeenCalledOnce()
for (const answer of [
`ORCA_NODE_RUNTIME_MISSING${END}OK\n`,
`ORCA_NODE_RUNTIME_READY${END}MISSING\n`,
'ORCA_NODE_RUNTIME_READY\n'
]) {
mockExec.mockResolvedValueOnce(answer)
await expect(cachedOrcadSlotInstalled(options, SLOT)).resolves.toBe(false)
}
mockExec.mockRejectedValueOnce(new Error('exit 1'))
await expect(cachedOrcadSlotInstalled(options, SLOT)).resolves.toBe(false)
})
it('leaves Windows hosts to the separate probes', async () => {
const windows = { ...options, host: getRemoteHostPlatform('win32-x64') }
await expect(cachedOrcadSlotInstalled(windows, SLOT)).resolves.toBe(false)
expect(mockExec).not.toHaveBeenCalled()
})
})
describe('installOrcadBundle with the WSL cached-slot probe', () => {
it('returns after one exec when both answers are cached', async () => {
mockExec.mockResolvedValueOnce(`ORCA_NODE_RUNTIME_READY${END}OK\n`)
await bundle(true)
expect(ensureRemoteOrcadNodeRuntime).not.toHaveBeenCalled()
expect(isRemoteInstallComplete).not.toHaveBeenCalled()
})
it('runs the separate probes when the combined one does not say both', async () => {
mockExec.mockResolvedValueOnce(`ORCA_NODE_RUNTIME_MISSING${END}OK\n`)
await bundle(true)
expect(ensureRemoteOrcadNodeRuntime).toHaveBeenCalledOnce()
expect(isRemoteInstallComplete).toHaveBeenCalledOnce()
})
it('never sends the combined probe without the option (SSH)', async () => {
await bundle()
expect(mockExec).not.toHaveBeenCalled()
expect(ensureRemoteOrcadNodeRuntime).toHaveBeenCalledOnce()
expect(isRemoteInstallComplete).toHaveBeenCalledOnce()
})
})
@@ -6,7 +6,6 @@ import { afterEach, describe, expect, it } from 'vitest'
import { ORCAD_PID_FILENAME, ORCAD_READINESS_FILENAME } from './orcad-remote-host-support'
import { orcadLaunchCommand, type OrcadLaunchSpec } from './orcad-remote-launch'
import { getRemoteHostPlatform } from '../../ssh/ssh-remote-platform'
import { ORCAD_WSL_HOST_ENV } from '../../ipc/wsl-guest-windows-drive-path'
const host = getRemoteHostPlatform('linux-x64')
@@ -30,72 +29,6 @@ describe('the WSL launch (setsid)', () => {
}
})
// WSL without systemd can kill nohup children when wsl.exe exits; detach into a new session.
it('never backgrounds under nohup, and starts the runtime in a session of its own', () => {
const command = orcadLaunchCommand(
host,
spec('/home/u/.orca-remote/orcad-0.2.0+bb01'),
'setsid'
)
expect(command).not.toContain('nohup')
expect(command).toContain('setsid -f "$@"')
// BusyBox lacks -f; background non-interactive children can still create their own session.
expect(command).toContain('else setsid "$@" & fi')
expect(command).toContain(`${ORCAD_WSL_HOST_ENV}=1 sh -c`)
})
it('has the child record its own PID, since the setsid -f parent exits at once', () => {
const command = orcadLaunchCommand(host, spec('/d/orcad-1'), 'setsid')
expect(command.startsWith(`orcad_pid='/d/orcad-1/${ORCAD_PID_FILENAME}';`)).toBe(true)
const removeStale = command.indexOf('rm -f "$orcad_pid"')
const recordOwn = command.indexOf(`echo $$ > "$1" && umask "$2" && shift 2 && exec "$@"`)
expect(removeStale).toBeGreaterThan(-1)
expect(recordOwn).toBeGreaterThan(removeStale)
expect(command.trimEnd().endsWith('cat "$orcad_pid"')).toBe(true)
})
it('passes host facts the guest cannot read, quoted, and refuses a malformed name', () => {
const command = orcadLaunchCommand(host, spec('/d/orcad-1'), 'setsid', [
['ORCA_WSL_DISTRO_GUID', "abc'd"]
])
expect(command).toContain(`${ORCAD_WSL_HOST_ENV}=1 ORCA_WSL_DISTRO_GUID='abc'\\''d' sh -c`)
expect(() =>
orcadLaunchCommand(host, spec('/d/orcad-1'), 'setsid', [['BAD NAME', 'x']])
).toThrow('orcad_launch_env_name_invalid')
})
it('applies a keep-existing host fact only where the launching shell has none', () => {
const command = orcadLaunchCommand(host, spec('/d/orcad-1'), 'setsid', [
['GLIBC_TUNABLES', 'glibc.malloc.mmap_threshold=16384', 'keep-existing']
])
const assignment = 'GLIBC_TUNABLES="${GLIBC_TUNABLES:-glibc.malloc.mmap_threshold=16384}"'
expect(command).toContain(`${assignment} sh -c`)
const seen = (preset: string | undefined): string => {
const { GLIBC_TUNABLES: _drop, ...env } = process.env
return execFileSync('sh', ['-c', `${assignment} sh -c 'printf %s "$GLIBC_TUNABLES"'`], {
env: preset === undefined ? env : { ...env, GLIBC_TUNABLES: preset }
}).toString()
}
expect(seen(undefined)).toBe('glibc.malloc.mmap_threshold=16384')
expect(seen('glibc.malloc.arena_max=2')).toBe('glibc.malloc.arena_max=2')
// An empty value tunes nothing, so it counts as unset.
expect(seen('')).toBe('glibc.malloc.mmap_threshold=16384')
expect(() =>
orcadLaunchCommand(host, spec('/d/orcad-1'), 'setsid', [['X', "a'b", 'keep-existing']])
).toThrow('orcad_launch_env_default_invalid')
expect(() =>
orcadLaunchCommand(host, spec('/d/orcad-1'), 'setsid', [['BAD NAME', 'x', 'keep-existing']])
).toThrow('orcad_launch_env_name_invalid')
})
it('keeps the SSH launch byte-for-byte when no detach is named', () => {
const ssh = orcadLaunchCommand(host, spec('/d/orcad-1'))
expect(ssh).toBe(orcadLaunchCommand(host, spec('/d/orcad-1'), 'nohup'))
expect(ssh).toContain('exec nohup "$orcad_runtime"')
expect(ssh).not.toContain('setsid')
expect(ssh).not.toContain(ORCAD_WSL_HOST_ENV)
})
it.skipIf(process.platform !== 'linux')(
'really detaches: the recorded PID is the runtime, in a new session, and writes readiness',
() => {
@@ -1,255 +0,0 @@
import type * as ExecCommandModule from '../../ssh/remote-exec/remote-exec-command'
import type * as TerminalBarrier from '../lifecycle/orcad-rollback-terminal-barrier'
// Fake Windows lifecycle host must reject POSIX/encoded commands and PowerShell hops on orcad operations.
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type * as RecordFile from './orcad-remote-record-file'
vi.mock('../../ssh/remote-exec/remote-exec-command', async (importOriginal) => ({
...(await importOriginal<typeof ExecCommandModule>()),
execCommand: vi.fn()
}))
vi.mock('./remote-install-lock', () => ({
acquireInstallLock: vi.fn().mockResolvedValue(undefined),
RELAY_INSTALL_LOCK_NAME: '.install-lock'
}))
vi.mock('./remote-install-transfers', () => ({
uploadRemoteInstallDirectory: vi.fn().mockResolvedValue(undefined),
writeRemoteInstallFile: vi.fn().mockResolvedValue(undefined)
}))
vi.mock('./orcad-remote-record-file', async (importOriginal) => ({
...(await importOriginal<typeof RecordFile>()),
writeAtomicOrcadRemoteRecord: vi.fn().mockResolvedValue(undefined)
}))
// Dedicated Windows decommission tests exercise the real stop commands.
vi.mock('../lifecycle/orcad-rollback-terminal-barrier', async (importOriginal) => ({
...(await importOriginal<typeof TerminalBarrier>()),
readOrcadRollbackBarrierTarget: async (_options: unknown, version: string) => ({
state: 'ready',
context: {
version,
runtimeId: 'r1',
instance: { pid: 4242, startedAtMs: 1, nonce: 'n', lockPath: 'C:/l' }
}
}),
stopIncumbentBehindTerminalBarrier: async () => ({ state: 'retired' })
}))
vi.mock('./orcad-remote-node-runtime', () => ({
ensureRemoteOrcadNodeRuntime: vi.fn().mockResolvedValue(undefined)
}))
vi.mock('../../ssh/orcad-local-build-hash', () => ({
computeLocalOrcadBuildHash: () => 'abc123def4567890'
}))
vi.mock('./remote-versioned-install', async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
readLocalFullVersion: () => '0.2.0+bb0100000000',
isRemoteInstallComplete: vi.fn().mockResolvedValue(false),
finalizeInstall: vi.fn().mockResolvedValue(undefined),
abandonInstall: vi.fn().mockResolvedValue(undefined)
}))
import { execCommand } from '../../ssh/remote-exec/remote-exec-command'
import { deployOrcad, type OrcadDeployOptions } from '../../ssh/orcad-remote-deploy'
import { rollbackOrcad } from './orcad-remote-rollback'
import { emptyOrcadActivationRecord } from '../activation/orcad-activation-record'
import { writeAtomicOrcadRemoteRecord } from './orcad-remote-record-file'
import { getRemoteHostPlatform } from '../../ssh/ssh-remote-platform'
import { NODE_RUNTIME_PIN } from '../../../shared/node-runtime-pin'
const mockExec = vi.mocked(execCommand)
const host = getRemoteHostPlatform('win32-x64')
const VERSION = '0.2.0+bb0100000000'
const SLOT_NODE = 'C:\\Users\\u\\.orca-remote\\runtimes\\node-ab\\node.exe'
const encoded = (marker: string, value: string): string =>
`${marker} ${Buffer.from(value).toString('base64')}\r\n`
const TARGET = '0.1.0+aa01'
function readyLine(version = VERSION): string {
return `${JSON.stringify({
type: 'orca_server_ready',
runtimeId: 'r1',
boundEndpoint: 'ws://127.0.0.1:7777',
advertisedEndpoint: null,
managedWslCliReconciliation: 'settled',
pairing: { available: false, reason: 'disabled_by_operator', guidance: 'n/a' },
health: {
buildHash: 'abc123def4567890',
buildVersion: version,
nodeVersion: NODE_RUNTIME_PIN.version,
nodeAbi: '137',
platform: 'win32',
arch: 'x64',
pid: 4242,
stopRequests: 1,
terminalDaemon: {
state: 'live',
ownsFreshSessions: true,
pid: 2,
buildVersion: version,
entryPath: 'C:/x/daemon-entry.js',
protocolVersion: 3,
selfTest: { ok: true, coverage: 'handshake', verdict: 'healthy', durationMs: 5 }
}
}
})}\n`
}
function scriptWindowsHost(log: string[], activeRecord: string | null = null): void {
mockExec.mockImplementation(async (_conn, command: string) => {
const text = String(command)
log.push(text)
const op = /\.js (?:--fence \S+ \S+ )?([a-z-]+)(?: |$)/u.exec(text)?.[1] ?? ''
switch (op) {
case 'record-read':
return activeRecord && text.includes('orcad-active.json')
? encoded('__ORCAD_RECORD_PRESENT__', activeRecord)
: '__ORCAD_RECORD_ABSENT__\r\n'
case 'build-hash':
return '__ORCAD_BUILD_HASH__ abc123def4567890\r\n'
case 'owner-admission':
return 'CLEAR'
case 'slot-runtime':
return encoded('__ORCAD_RUNTIME__', SLOT_NODE)
case 'readiness-wait':
return encoded('__ORCAD_READINESS__', readyLine(text.includes(TARGET) ? TARGET : VERSION))
case 'stop':
return 'STOPPED'
case 'snapshot-probe':
return 'PRESENT'
case 'snapshot-restore':
return 'RESTORED'
case 'state-newest-mtime':
return 'UNKNOWN'
case 'snapshot-capture':
return 'CAPTURED'
case 'remove-file':
case 'remove-tree':
return ''
case 'fence-check':
return 'OK'
case 'fence-release':
return 'RELEASED'
default:
break
}
if (text.includes('--orcad-profile-state-preflight')) {
return JSON.stringify({
type: 'orca_profile_state_ready',
nonce: text.match(/[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}/)?.[0],
runtime: 'node',
runtimeVersion: NODE_RUNTIME_PIN.version,
sqliteVersion: '3.51.0',
artifactVersion: VERSION,
revision: 1
})
}
if (text.includes('--windows-breakaway-launch')) {
return 'ORCA_ORCAD_LAUNCH {"method":"breakaway","pid":4242,"inJob":false}\r\n'
}
// Shared relay install fences retain this pre-existing PowerShell path.
if (text.startsWith('powershell.exe ')) {
return 'OPEN'
}
throw new Error(`unexpected Windows command: ${text}`)
})
}
function options(): OrcadDeployOptions {
const census = { liveSessions: 0, startedSinceActivation: 0, daemonProtocolVersion: 3 }
return {
conn: Object.assign(Object.create(null), { writeFile: vi.fn().mockResolvedValue(undefined) }),
host,
remoteHome: 'C:/Users/u',
localOrcadDir: '/local/out/orcad',
target: 'win32-x64',
nodePath: 'C:/host/node.exe',
userDataDir: 'C:/Users/u/.orca',
bindHost: '127.0.0.1',
port: 7777,
census,
recountCensus: async () => census,
releaseIncumbentAdmission: async () => {},
readinessTimeoutMs: 1_000,
sleep: async () => {},
now: () => new Date('2026-10-02T00:00:00.000Z')
}
}
beforeEach(() => {
mockExec.mockReset()
vi.mocked(writeAtomicOrcadRemoteRecord).mockClear()
})
describe('deployOrcad on a Windows host', () => {
it('activates a first install through node.exe host ops only', async () => {
const log: string[] = []
scriptWindowsHost(log)
const result = await deployOrcad(options())
expect(result).toMatchObject({ outcome: 'installed-and-activated', fullVersion: VERSION })
const orcadOps = log.filter((command) => !command.startsWith('powershell.exe '))
expect(orcadOps.length).toBeGreaterThan(0)
for (const command of orcadOps) {
expect(command).not.toMatch(/EncodedCommand|nohup|kill |head -c|tar |\bsh -c\b/u)
}
const ops = orcadOps.map(
(command) => /\.js (?:--fence \S+ \S+ )?([a-z-]+)/u.exec(command)?.[1] ?? command
)
expect(ops).toContain('owner-admission')
expect(ops).toContain('snapshot-capture')
expect(ops.indexOf('slot-runtime')).toBeLessThan(
ops.findIndex((entry) => entry.includes('--windows-breakaway-launch'))
)
expect(ops).toContain('readiness-wait')
const record = vi
.mocked(writeAtomicOrcadRemoteRecord)
.mock.calls.find(([, path]) => path.endsWith('orcad-active.json'))
expect(JSON.parse(record?.[2] ?? '{}')).toMatchObject({ active: VERSION })
})
it('rolls back by managed stop, directory snapshot and node.exe launch', async () => {
const log: string[] = []
const record = {
...emptyOrcadActivationRecord(),
active: VERSION,
previous: TARGET,
activatedAt: '2026-10-01T00:00:00.000Z',
snapshot: {
dirName: `pre-${VERSION}-1000`,
takenBeforeVersion: VERSION,
readableByVersion: TARGET,
takenAt: '2026-10-01T00:00:00.000Z'
}
}
scriptWindowsHost(log, JSON.stringify(record))
const { localOrcadDir: _dir, target: _target, force: _force, ...base } = options()
const result = await rollbackOrcad({
...base,
record,
targetBuildHash: 'abc123def4567890',
targetDaemonProtocol: { protocolVersion: 3, previousProtocolVersions: [1, 2] }
})
expect(result).toMatchObject({ outcome: 'rolled-back', target: TARGET })
const ops = log.map(
(command) => /\.js (?:--fence \S+ \S+ )?([a-z-]+)/u.exec(command)?.[1] ?? command
)
expect(ops).toEqual([
'record-read',
'snapshot-probe',
'state-newest-mtime',
'build-hash',
'snapshot-capture',
'snapshot-restore',
'slot-runtime',
// Commands outside the host script require a separate fence check immediately before dispatch.
'fence-check',
'--windows-breakaway-launch',
'readiness-wait',
'record-read',
'fence-release'
])
for (const command of log) {
expect(command).not.toMatch(/EncodedCommand|kill |tar |nohup/u)
}
})
})
@@ -1,115 +0,0 @@
import type * as ExecCommandModule from '../../ssh/remote-exec/remote-exec-command'
import { 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 { runProcess } from '@orca/process-host'
import {
ORCAD_LOG_TAIL_LINES,
ORCAD_LOG_TAIL_MAX_BYTES,
orcadLogTailCommand,
parseOrcadLogTail,
withOrcadLogTail
} from './orcad-remote-log-tail'
import { ORCAD_WINDOWS_HOST_SCRIPT } from '../host-state/orcad-windows-host-script'
import { getRemoteHostPlatform } from '../../ssh/ssh-remote-platform'
import { unusedOrcadHostTransport } from '../../ssh/unused-host-transport.test-fixture'
const execCommand = vi.hoisted(() => vi.fn())
vi.mock('../../ssh/remote-exec/remote-exec-command', async (importOriginal) => ({
...(await importOriginal<typeof ExecCommandModule>()),
execCommand
}))
const linux = getRemoteHostPlatform('linux-x64')
const windows = getRemoteHostPlatform('win32-x64')
const SLOT = '/home/me/.orca-remote/orcad-1.0.0'
const WINDOWS_SLOT = 'C:\\Users\\me\\.orca-remote\\orcad-1.0.0'
const conn = unusedOrcadHostTransport()
let dir = ''
beforeEach(() => {
dir = mkdtempSync(join(tmpdir(), 'orcad-log-tail-'))
execCommand.mockReset()
})
afterEach(() => rmSync(dir, { recursive: true, force: true }))
function numberedLines(count: number): string {
return Array.from({ length: count }, (_, index) => `line ${index + 1}`).join('\n')
}
describe('orcad.log tail', () => {
it('keeps only the last lines and bytes, redacting secrets', () => {
const tail = parseOrcadLogTail(linux, `${numberedLines(100)}\ntoken=abc123secret\n`)
const lines = (tail ?? '').split('\n')
expect(lines).toHaveLength(ORCAD_LOG_TAIL_LINES)
expect(lines[0]).toBe('line 62')
expect(tail).not.toContain('abc123secret')
expect(tail).toContain('[redacted')
const huge = parseOrcadLogTail(linux, 'x'.repeat(ORCAD_LOG_TAIL_MAX_BYTES * 4))
expect(Buffer.byteLength(huge ?? '', 'utf8')).toBeLessThanOrEqual(ORCAD_LOG_TAIL_MAX_BYTES)
expect(parseOrcadLogTail(linux, ' \n')).toBeNull()
})
it('reads the log on Windows through the node.exe host script, never an encoded command', () => {
const command = orcadLogTailCommand(windows, WINDOWS_SLOT)
expect(command).toContain('node.exe')
expect(command).toContain(' log-tail ')
expect(command).not.toMatch(/EncodedCommand/iu)
})
it.skipIf(process.platform === 'win32')(
'runs the POSIX command against a real file',
async () => {
const slot = join(dir, 'slot')
mkdirSync(slot)
writeFileSync(join(slot, 'orcad.log'), `${numberedLines(60)}\n`)
const run = await runProcess({
program: '/bin/sh',
args: ['-c', orcadLogTailCommand(linux, slot)],
timeoutMs: 5_000
})
expect(parseOrcadLogTail(linux, run.stdout)?.split('\n').at(-1)).toBe('line 60')
const missing = await runProcess({
program: '/bin/sh',
args: ['-c', orcadLogTailCommand(linux, join(dir, 'absent'))],
timeoutMs: 5_000
})
expect(parseOrcadLogTail(linux, missing.stdout)).toBeNull()
}
)
it('reads the end of the log through the real Windows host script op', async () => {
const script = join(dir, 'host-script.js')
writeFileSync(script, ORCAD_WINDOWS_HOST_SCRIPT)
const log = join(dir, 'orcad.log')
writeFileSync(log, `${'y'.repeat(ORCAD_LOG_TAIL_MAX_BYTES * 2)}\nfatal: bind failed\n`)
const run = await runProcess({
program: process.execPath,
args: [script, 'log-tail', log, String(ORCAD_LOG_TAIL_MAX_BYTES)],
timeoutMs: 15_000
})
const tail = parseOrcadLogTail(windows, run.stdout)
expect(tail?.endsWith('fatal: bind failed')).toBe(true)
const absent = await runProcess({
program: process.execPath,
args: [script, 'log-tail', join(dir, 'absent.log'), '100'],
timeoutMs: 15_000
})
expect(parseOrcadLogTail(windows, absent.stdout)).toBeNull()
})
it('appends the tail to a failure message, and leaves it alone when the host cannot answer', async () => {
execCommand.mockResolvedValueOnce('starting\nError: EADDRINUSE\n')
await expect(withOrcadLogTail({ conn, host: linux }, SLOT, 'Launch failed.')).resolves.toBe(
'Launch failed.\nLast lines of orcad.log:\nstarting\nError: EADDRINUSE'
)
const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
execCommand.mockRejectedValueOnce(new Error('Connection lost'))
await expect(withOrcadLogTail({ conn, host: linux }, SLOT, 'Launch failed.')).resolves.toBe(
'Launch failed.'
)
warn.mockRestore()
})
})
@@ -1,85 +0,0 @@
import type * as ExecCommandModule from '../../ssh/remote-exec/remote-exec-command'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { ensureRemoteOrcadNodeRuntime } from './orcad-remote-node-runtime'
import {
REMOTE_NODE_RUNTIME_MISSING,
REMOTE_NODE_RUNTIME_READY
} from '../../ssh/orcad-remote-node-runtime-report'
import { execCommand } from '../../ssh/remote-exec/remote-exec-command'
import { uploadRemoteInstallDirectory } from './remote-install-transfers'
import { getRemoteHostPlatform } from '../../ssh/ssh-remote-platform'
import { HOST_NODE_DOWNLOAD_OK } from './remote-node-runtime-host-download'
import { unusedOrcadHostTransport } from '../../ssh/unused-host-transport.test-fixture'
vi.mock('../../ssh/remote-exec/remote-exec-command', async (importOriginal) => ({
...(await importOriginal<typeof ExecCommandModule>()),
execCommand: vi.fn()
}))
vi.mock('./remote-install-transfers', () => ({
uploadRemoteInstallDirectory: vi.fn(async () => {})
}))
const conn = unusedOrcadHostTransport()
const host = getRemoteHostPlatform('linux-x64')
const TARGET = 'linux-x64-glibc'
const OFFLINE_PATH = '/Users/me/Library/Application Support/Orca/orcad-artifacts/node/archives/x'
afterEach(() => {
vi.mocked(execCommand).mockReset()
vi.mocked(uploadRemoteInstallDirectory).mockClear()
})
/** Model a fresh host with a configurable curl/wget download verdict. */
function freshHost(download: string): string[] {
const commands: string[] = []
vi.mocked(execCommand).mockImplementation(async (_conn, command) => {
commands.push(command)
if (command.includes('command -v curl')) {
return download
}
if (command.includes('tar -xzf')) {
return REMOTE_NODE_RUNTIME_READY
}
if (command.includes(REMOTE_NODE_RUNTIME_MISSING)) {
return REMOTE_NODE_RUNTIME_MISSING
}
return command.includes('2>/dev/null') ? 'OK' : ''
})
return commands
}
function ensure(archivePath: () => Promise<string>, signal?: AbortSignal) {
return ensureRemoteOrcadNodeRuntime({
conn,
host,
slotDir: '/home/me/.orca-remote/orcad-0.1.0+abc',
target: TARGET,
archivePath,
offlineArchivePath: OFFLINE_PATH,
signal
})
}
describe('pinned Node when this computer cannot download it', () => {
it('also tries the host when the desktop got a file that failed the pin', async () => {
freshHost(HOST_NODE_DOWNLOAD_OK)
const blockPage = () => Promise.reject(new Error('Node archive checksum mismatch'))
await expect(ensure(blockPage)).resolves.toMatchObject({ transfer: 'host-downloaded' })
})
it('does not try the host once the connect was cancelled', async () => {
const commands = freshHost(HOST_NODE_DOWNLOAD_OK)
const controller = new AbortController()
const cancelled = () => {
controller.abort()
return Promise.reject(new Error('aborted'))
}
await expect(ensure(cancelled, controller.signal)).rejects.toThrow('aborted')
expect(commands.some((command) => command.includes('command -v curl'))).toBe(false)
})
})
@@ -1,157 +0,0 @@
import type * as ExecCommandModule from '../../ssh/remote-exec/remote-exec-command'
import {
chmodSync,
existsSync,
mkdirSync,
mkdtempSync,
readdirSync,
rmSync,
writeFileSync
} from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { runProcessSync } from '@orca/process-host'
import {
createExecProbedStageCommand,
ensureRemoteOrcadNodeRuntime
} from './orcad-remote-node-runtime'
import {
assertRemoteNodeRuntimeStoreExecutable,
RemoteNodeRuntimeNoexecError,
REMOTE_NODE_RUNTIME_MISSING,
REMOTE_NODE_RUNTIME_NOEXEC
} from '../../ssh/orcad-remote-node-runtime-report'
import { classifyOrcadHostUnavailable } from '../../orcad/orcad-host-unavailable'
import { execCommand } from '../../ssh/remote-exec/remote-exec-command'
import { uploadRemoteInstallDirectory } from './remote-install-transfers'
import { getRemoteHostPlatform } from '../../ssh/ssh-remote-platform'
import { unusedOrcadHostTransport } from '../../ssh/unused-host-transport.test-fixture'
vi.mock('../../ssh/remote-exec/remote-exec-command', async (importOriginal) => ({
...(await importOriginal<typeof ExecCommandModule>()),
execCommand: vi.fn()
}))
vi.mock('./remote-install-transfers', () => ({
uploadRemoteInstallDirectory: vi.fn(async () => {})
}))
const conn = unusedOrcadHostTransport()
const host = getRemoteHostPlatform('linux-x64')
// Captured dash output for executing from a noexec home filesystem.
const NOEXEC_PROBE_REPLY = [
REMOTE_NODE_RUNTIME_NOEXEC,
'ORCA_RUNTIME_EXIT=126',
'/bin/sh: 1: /root/.orca-remote/runtimes/.stage-node-x-1/.exec-probe: Permission denied'
].join('\n')
const directories: string[] = []
afterEach(() => {
for (const directory of directories.splice(0)) {
rmSync(directory, { recursive: true, force: true })
}
vi.mocked(execCommand).mockReset()
vi.mocked(uploadRemoteInstallDirectory).mockClear()
})
describe('pinned-Node runtime store on a host that refuses to run programs', () => {
it.skipIf(process.platform === 'win32')(
'reports nothing and leaves no probe behind where programs may run',
() => {
const root = mkdtempSync(join(tmpdir(), 'orcad-runtime-noexec-'))
directories.push(root)
const stage = join(root, 'runtimes', '.stage-node-x-1')
const result = runProcessSync({
program: '/bin/sh',
args: ['-c', createExecProbedStageCommand(host, stage)],
timeoutMs: 30_000
})
expect(result.code).toBe(0)
expect(result.stdout.trim()).toBe('')
expect(existsSync(stage)).toBe(true)
expect(readdirSync(stage)).toEqual([])
}
)
it.skipIf(process.platform === 'win32' || process.getuid?.() === 0)(
'skips the probe when the stage can’t be written, leaving a full disk to the upload',
() => {
const root = mkdtempSync(join(tmpdir(), 'orcad-runtime-noexec-'))
directories.push(root)
const stage = join(root, '.stage-node-x-1')
mkdirSync(stage, { mode: 0o500 })
const result = runProcessSync({
program: '/bin/sh',
args: ['-c', createExecProbedStageCommand(host, stage)],
timeoutMs: 30_000
})
chmodSync(stage, 0o700)
expect(result).toMatchObject({ code: 0, stdout: '' })
}
)
it('names the noexec store before fetching or uploading the ~40 MiB runtime', async () => {
vi.mocked(execCommand).mockImplementation(async (_conn, command) => {
if (command.includes(REMOTE_NODE_RUNTIME_MISSING)) {
return REMOTE_NODE_RUNTIME_MISSING
}
if (command.includes('.exec-probe')) {
return NOEXEC_PROBE_REPLY
}
return ''
})
const archivePath = vi.fn(async () => '/never/fetched.tar.gz')
const failure = await ensureRemoteOrcadNodeRuntime({
conn,
host,
slotDir: '/root/.orca-remote/orcad-0.1.0+abc',
target: 'linux-x64-glibc',
archivePath
}).catch((error: unknown) => error)
expect(classifyOrcadHostUnavailable(failure)).toBe('runtime_noexec')
expect(String(failure)).toContain('/root/.orca-remote/runtimes')
expect(archivePath).not.toHaveBeenCalled()
expect(uploadRemoteInstallDirectory).not.toHaveBeenCalled()
const commands = vi.mocked(execCommand).mock.calls.map(([, command]) => command)
expect(commands.at(-1)).toMatch(/^rm -rf '\/root\/\.orca-remote\/runtimes\/\.stage-/)
})
it('reads exit 126 as noexec in any locale', () => {
const localized = `${REMOTE_NODE_RUNTIME_NOEXEC}\nORCA_RUNTIME_EXIT=126\nsh: 1: .exec-probe: Keine Berechtigung`
expect(() =>
assertRemoteNodeRuntimeStoreExecutable(localized, '/root/.orca-remote/runtimes')
).toThrow(RemoteNodeRuntimeNoexecError)
})
it('leaves any other probe failure to the self-test after the upload', async () => {
vi.mocked(execCommand).mockImplementation(async (_conn, command) => {
if (command.includes('tar -xzf')) {
return 'ORCA_NODE_RUNTIME_READY'
}
if (command.includes(REMOTE_NODE_RUNTIME_MISSING)) {
return REMOTE_NODE_RUNTIME_MISSING
}
if (command.includes('.exec-probe')) {
return `${REMOTE_NODE_RUNTIME_NOEXEC}\nORCA_RUNTIME_EXIT=127\n/bin/sh: bad interpreter`
}
return command.includes('2>/dev/null') ? 'OK' : ''
})
await ensureRemoteOrcadNodeRuntime({
conn,
host,
slotDir: '/root/.orca-remote/orcad-0.1.0+abc',
target: 'linux-x64-glibc',
archivePath: async () => archive()
})
expect(uploadRemoteInstallDirectory).toHaveBeenCalledTimes(1)
})
})
function archive(): string {
const dir = mkdtempSync(join(tmpdir(), 'orcad-runtime-noexec-archive-'))
directories.push(dir)
writeFileSync(join(dir, 'node.tar.gz'), 'archive')
return join(dir, 'node.tar.gz')
}
@@ -1,150 +0,0 @@
import {
mkdirSync,
mkdtempSync,
readFileSync,
rmSync,
statSync,
utimesSync,
writeFileSync
} from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { runProcess } from '@orca/process-host'
import { pinnedNodeRuntimeAsset } from '../../../shared/node-runtime-pin'
import {
ensureRemoteOrcadNodeRuntime,
posixNodeRuntimeExecutable,
probeRemoteNodeRuntimeCommand,
remoteNodeRuntimeDir
} from './orcad-remote-node-runtime'
import {
REMOTE_NODE_RUNTIME_MISSING,
REMOTE_NODE_RUNTIME_VERIFIED_MARKER
} from '../../ssh/orcad-remote-node-runtime-report'
import {
recordedNodeRuntimeProbeCommand,
recordedRemoteNodeRuntimeProbeCommand,
REMOTE_NODE_RUNTIME_HASHED_STAT_FILENAME
} from './orcad-remote-node-runtime-recorded-probe'
import { execCommand } from '../../ssh/remote-exec/remote-exec-command'
import type * as ExecCommandModule from '../../ssh/remote-exec/remote-exec-command'
import { getRemoteHostPlatform } from '../../ssh/ssh-remote-platform'
import { unusedOrcadHostTransport } from '../../ssh/unused-host-transport.test-fixture'
vi.mock('../../ssh/remote-exec/remote-exec-command', async (importOriginal) => ({
...(await importOriginal<typeof ExecCommandModule>()),
execCommand: vi.fn()
}))
const conn = unusedOrcadHostTransport()
const host = getRemoteHostPlatform('linux-x64')
const target = 'linux-x64-glibc'
const pin = pinnedNodeRuntimeAsset(target).executableSha256
let root: string
let shim: string
let runtimeDir: string
async function sh(command: string): Promise<string> {
const result = await runProcess({
program: '/bin/sh',
args: ['-c', `PATH=${shim}:$PATH; ${command}`],
timeoutMs: 30_000
})
if (result.code !== 0) {
throw new Error(`exit ${result.code}: ${result.stdout}${result.stderr}`)
}
return result.stdout.trim()
}
function hashCount(): number {
try {
return readFileSync(join(root, 'hashes'), 'utf8').length
} catch {
return 0
}
}
function ensure(recorded: boolean): ReturnType<typeof ensureRemoteOrcadNodeRuntime> {
return ensureRemoteOrcadNodeRuntime({
conn,
host,
slotDir: join(root, '.orca-remote', 'orcad-0.1.0+abc'),
target,
archivePath: () => Promise.reject(new Error('no upload expected')),
...(recorded ? { posixProbeCommand: recordedRemoteNodeRuntimeProbeCommand } : {})
})
}
beforeEach(() => {
root = mkdtempSync(join(tmpdir(), 'runtime-recorded-probe-'))
shim = join(root, 'shim')
mkdirSync(shim)
// Count full executable hashes behind this stubbed pin.
writeFileSync(
join(shim, 'sha256sum'),
`#!/bin/sh\nprintf x >> ${JSON.stringify(join(root, 'hashes'))}\necho "${pin} $1"\n`,
{ mode: 0o755 }
)
runtimeDir = remoteNodeRuntimeDir(host, join(root, '.orca-remote', 'orcad-0.1.0+abc'), target)
mkdirSync(join(runtimeDir, 'bin'), { recursive: true })
writeFileSync(posixNodeRuntimeExecutable(host, runtimeDir), 'node', { mode: 0o755 })
writeFileSync(join(runtimeDir, REMOTE_NODE_RUNTIME_VERIFIED_MARKER), '')
vi.mocked(execCommand).mockReset()
vi.mocked(execCommand).mockImplementation((_conn, command) => sh(command))
})
afterEach(() => {
rmSync(root, { recursive: true, force: true })
})
describe.skipIf(process.platform === 'win32')('recorded runtime probe', () => {
it('hashes once, then trusts the unchanged executable by its stat signature', async () => {
await expect(ensure(true)).resolves.toMatchObject({ transfer: 'cached' })
expect(hashCount()).toBe(1)
expect(
readFileSync(join(runtimeDir, REMOTE_NODE_RUNTIME_HASHED_STAT_FILENAME), 'utf8')
).toMatch(/\S/)
await expect(ensure(true)).resolves.toMatchObject({ transfer: 'cached' })
expect(hashCount()).toBe(1)
})
it('hashes a same-size in-place rewrite after its stat signature changes', async () => {
await ensure(true)
const executable = posixNodeRuntimeExecutable(host, runtimeDir)
const before = statSync(executable)
writeFileSync(executable, 'NODE')
// Force signature change when fast writes share filesystem clock resolution.
utimesSync(executable, before.atime, new Date(before.mtimeMs + 1_000))
const after = statSync(executable)
expect(after.ino).toBe(before.ino)
expect(after.size).toBe(before.size)
expect(after.mtimeMs).toBeGreaterThan(before.mtimeMs)
await ensure(true)
expect(hashCount()).toBe(2)
})
it('never trusts a recorded signature for another pin', async () => {
await ensure(true)
const probe = recordedNodeRuntimeProbeCommand(host, runtimeDir, 'f'.repeat(64))
expect(await sh(probe)).toBe(REMOTE_NODE_RUNTIME_MISSING)
expect(hashCount()).toBe(2)
})
it('reports a runtime without its verified marker as missing, whatever is recorded', async () => {
await ensure(true)
rmSync(join(runtimeDir, REMOTE_NODE_RUNTIME_VERIFIED_MARKER))
const probe = recordedRemoteNodeRuntimeProbeCommand(host, runtimeDir, target)
expect(await sh(probe)).toBe(REMOTE_NODE_RUNTIME_MISSING)
})
it('keeps the default probe byte-identical and hashing on every call (SSH hosts)', async () => {
await ensure(false)
await ensure(false)
const sent = vi.mocked(execCommand).mock.calls.map(([, command]) => command)
expect(sent).toEqual([
probeRemoteNodeRuntimeCommand(host, runtimeDir, target),
probeRemoteNodeRuntimeCommand(host, runtimeDir, target)
])
expect(hashCount()).toBe(2)
})
})
@@ -1,104 +0,0 @@
import type * as ExecCommandModule from '../../ssh/remote-exec/remote-exec-command'
import { existsSync, 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 { pinnedNodeRuntimeAsset } from '../../../shared/node-runtime-pin'
import { runProcess } from '@orca/process-host'
import {
ensureRemoteOrcadNodeRuntime,
posixNodeRuntimeExecutable,
remoteNodeRuntimeDir
} from './orcad-remote-node-runtime'
import {
REMOTE_NODE_RUNTIME_READY,
REMOTE_NODE_RUNTIME_VERIFIED_MARKER
} from '../../ssh/orcad-remote-node-runtime-report'
import { RUNTIME_STORE_LOCK_NAME, withRuntimeStoreLock } from './remote-node-runtime-store-lock'
import { execCommand } from '../../ssh/remote-exec/remote-exec-command'
import { uploadRemoteInstallDirectory } from './remote-install-transfers'
import { getRemoteHostPlatform } from '../../ssh/ssh-remote-platform'
import { unusedOrcadHostTransport } from '../../ssh/unused-host-transport.test-fixture'
vi.mock('../../ssh/remote-exec/remote-exec-command', async (importOriginal) => ({
...(await importOriginal<typeof ExecCommandModule>()),
execCommand: vi.fn()
}))
vi.mock('./remote-install-transfers', () => ({ uploadRemoteInstallDirectory: vi.fn() }))
const conn = unusedOrcadHostTransport()
const host = getRemoteHostPlatform('linux-x64')
const target = 'linux-x64-glibc'
let root: string
let shim: string
async function sh(command: string): Promise<string> {
const result = await runProcess({
program: '/bin/sh',
args: ['-c', `PATH=${shim}:$PATH; ${command}`],
timeoutMs: 30_000
})
if (result.code !== 0) {
throw new Error(`exit ${result.code}: ${result.stdout}${result.stderr}`)
}
return result.stdout
}
beforeEach(() => {
root = mkdtempSync(join(tmpdir(), 'runtime-slow-hash-'))
shim = join(root, 'shim')
mkdirSync(shim)
// Slow storage lets executable hashing outlast client observation.
writeFileSync(
join(shim, 'sha256sum'),
`#!/bin/sh\nsleep 1\necho "${pinnedNodeRuntimeAsset(target).executableSha256} $1"\n`,
{ mode: 0o755 }
)
})
afterEach(() => {
rmSync(root, { recursive: true, force: true })
})
describe.skipIf(process.platform === 'win32')('runtime install on slow storage', () => {
it('frees the store lock once a re-hash the client timed out on finishes', async () => {
const slotDir = join(root, '.orca-remote', 'relay-0.1.0+abc')
const runtimeDir = remoteNodeRuntimeDir(host, slotDir, target)
const store = join(root, '.orca-remote', 'runtimes')
const archive = join(root, 'node.tar.gz')
writeFileSync(archive, 'archive')
// Model sibling publication during this installer's upload.
vi.mocked(uploadRemoteInstallDirectory).mockImplementation(async () => {
mkdirSync(join(runtimeDir, 'bin'), { recursive: true })
writeFileSync(posixNodeRuntimeExecutable(host, runtimeDir), 'node', { mode: 0o755 })
writeFileSync(join(runtimeDir, REMOTE_NODE_RUNTIME_VERIFIED_MARKER), '')
})
let hashes = 0
let orphan: Promise<string> | undefined
vi.mocked(execCommand).mockImplementation(async (_conn, command) => {
if (command.includes('sha256sum') && ++hashes === 2) {
// Stop client observation while the under-lock hash keeps executing on the host.
orphan = sh(command)
throw Object.assign(new Error('timed out'), {
code: 'SSH_EXEC_TIMEOUT',
sshChannelCloseConfirmed: false
})
}
return sh(command)
})
await expect(
ensureRemoteOrcadNodeRuntime({
conn,
host,
slotDir,
target,
archivePath: async () => archive
})
).rejects.toMatchObject({ sshChannelCloseConfirmed: false })
expect(orphan).toBeDefined()
// Lost contact cannot prove the hash exited; its runtime lock must remain held.
expect(existsSync(join(store, RUNTIME_STORE_LOCK_NAME))).toBe(true)
// Host-side release avoids waiting for the 20-minute stale-lock window.
await withRuntimeStoreLock(conn, host, store, async () => {}, AbortSignal.timeout(10_000))
expect((await orphan)?.trim()).toBe(REMOTE_NODE_RUNTIME_READY)
}, 20_000)
})
@@ -1,135 +0,0 @@
import type * as ExecCommandModule from '../../ssh/remote-exec/remote-exec-command'
import { beforeEach, describe, expect, it, vi } from 'vitest'
vi.mock('../../ssh/remote-exec/remote-exec-command', async (importOriginal) => ({
...(await importOriginal<typeof ExecCommandModule>()),
execCommand: vi.fn(),
isUnconfirmedSshCommandTermination: (error: unknown) =>
error instanceof Error && error.message === 'unconfirmed'
}))
import { execCommand } from '../../ssh/remote-exec/remote-exec-command'
import { launchOrcadAndAwaitReadiness } from './orcad-remote-runtime-control'
import { getRemoteHostPlatform } from '../../ssh/ssh-remote-platform'
import type { OrcadLaunchSpec } from './orcad-remote-launch'
import { createSshOperationAbortError } from '../../ssh/connection/ssh-exec-options'
import { launchOrcadSlotAndAwaitReadiness } from '../../ssh/orcad-active-readiness'
const host = getRemoteHostPlatform('linux-arm64')
const spec: OrcadLaunchSpec = {
remoteInstallDir: '/root/.orca-remote/orcad-0.1.0+07d0995735e0',
nodePath: '/usr/bin/node',
fullVersion: '0.1.0+07d0995735e0',
userDataDir: '/root/.orca',
bindHost: '127.0.0.1',
port: 6768,
activationRoot: '/root/.orca-remote/.orcad-activation-transaction'
}
const READY = `${JSON.stringify({ type: 'orca_server_ready', runtimeId: 'r1' })}\n`
const mockExec = vi.mocked(execCommand)
function launch(signal?: AbortSignal) {
return launchOrcadAndAwaitReadiness(
{ conn: Object.create(null), host, readinessTimeoutMs: 60_000, sleep: async () => {}, signal },
spec
)
}
describe('waiting for a launched candidate', () => {
beforeEach(() => {
mockExec.mockReset()
vi.spyOn(console, 'warn').mockImplementation(() => {})
})
it('retries a failed readiness read instead of failing the launch', async () => {
mockExec
.mockResolvedValueOnce('1786\n')
.mockRejectedValueOnce(new Error('(SSH) Channel open failure: open failed'))
.mockResolvedValueOnce(READY)
await expect(launch()).resolves.toMatchObject({
state: 'ready',
readiness: { runtimeId: 'r1' }
})
})
it('still fails at once on an unconfirmed termination, which may have run remotely', async () => {
mockExec.mockResolvedValueOnce('1786\n').mockRejectedValueOnce(new Error('unconfirmed'))
await expect(launch()).rejects.toThrow('unconfirmed')
})
it('stops retrying a readiness read that the retired SSH transport cancelled', async () => {
const cancelled = createSshOperationAbortError()
mockExec
.mockResolvedValueOnce('1786\n')
.mockRejectedValueOnce(cancelled)
.mockResolvedValueOnce(READY)
await expect(launch()).rejects.toBe(cancelled)
expect(mockExec).toHaveBeenCalledTimes(2)
})
it('rejects a readiness reply received after its caller cancelled', async () => {
const controller = new AbortController()
mockExec.mockResolvedValueOnce('1786\n').mockImplementationOnce(async () => {
controller.abort()
return READY
})
await expect(launch(controller.signal)).rejects.toMatchObject({ name: 'AbortError' })
})
})
describe('a launch that exits before it is ready', () => {
const REFUSAL = 'orcad: failed to start: another machine may hold the data root'
let now = 0
beforeEach(() => {
mockExec.mockReset()
now = 0
vi.spyOn(Date, 'now').mockImplementation(() => now)
})
// The host answers each wait at once; only the client's injected sleep moves the clock.
function waitAnswers(exitedOnPoll: number | null) {
let polls = 0
mockExec.mockImplementation(async (_conn, command) => {
if (command.includes('tail -c')) {
return `${REFUSAL}\n`
}
if (command.includes('orcad_readiness_wait_end')) {
polls += 1
return polls === exitedOnPoll ? 'ORCAD_LAUNCH_EXITED\n' : ''
}
return '1786\n'
})
return () => polls
}
function launchSlot() {
return launchOrcadSlotAndAwaitReadiness(
{
conn: Object.create(null),
host,
readinessTimeoutMs: 180_000,
sleep: async (ms) => {
now += ms
}
},
spec,
{ fullVersion: spec.fullVersion, buildHash: 'b1' }
)
}
it('fails at once with the log’s refusal once the host proved the PID gone', async () => {
const polls = waitAnswers(2)
const failure = launchSlot()
await expect(failure).rejects.toMatchObject({ verdict: 'rejected' })
await expect(failure).rejects.toThrow(REFUSAL)
expect(polls()).toBe(2)
expect(now).toBeLessThan(5_000)
})
it('waits out the deadline when the host never proves an exit', async () => {
const polls = waitAnswers(null)
await expect(launchSlot()).rejects.toThrow(REFUSAL)
expect(now).toBeGreaterThanOrEqual(180_000)
expect(polls()).toBeGreaterThan(100)
})
})
@@ -1,94 +0,0 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type * as ExecCommandModule from '../../ssh/remote-exec/remote-exec-command'
vi.mock('../../ssh/remote-exec/remote-exec-command', async (importOriginal) => ({
...(await importOriginal<typeof ExecCommandModule>()),
execCommand: vi.fn()
}))
import { gcOldRemoteInstallVersions } from './remote-install-gc'
import { ORCAD_INSTALL_MODEL } from './remote-install-model'
import { execCommand } from '../../ssh/remote-exec/remote-exec-command'
import { getRemoteHostPlatform } from '../../ssh/ssh-remote-platform'
import { unusedSshConnection } from '../../ssh/unused-host-transport.test-fixture'
const conn = unusedSshConnection()
const host = getRemoteHostPlatform('linux-x64')
const home = '/home/u'
const currentDir = `${home}/.orca-remote/orcad-0.1.0+bbb`
const mockExec = vi.mocked(execCommand)
beforeEach(() => {
mockExec.mockReset()
mockExec.mockResolvedValue('')
})
function failAfter(replies: readonly string[], confirmed = false): Error {
for (const reply of replies) {
mockExec.mockResolvedValueOnce(reply)
}
const error = Object.assign(new Error('SSH command timed out'), {
sshChannelCloseConfirmed: confirmed
})
mockExec.mockRejectedValueOnce(error)
return error
}
function collectVersions(): Promise<boolean> {
return gcOldRemoteInstallVersions(conn, ORCAD_INSTALL_MODEL, home, currentDir, host, {
isDirLive: async (dir) => (await execCommand(conn, `liveness ${dir}`)).trim() !== 'DEAD'
})
}
const versionSteps = [
['listing', 'orcad-0.1.0+aaa'],
['install lock probe', 'OPEN'],
['completion probe', 'COMPLETE'],
['liveness probe', 'DEAD'],
['claim acquisition', 'OK'],
['claim owner write', ''],
['claimed install lock probe', 'OPEN'],
['claimed completion probe', 'COMPLETE'],
['claimed liveness probe', 'DEAD'],
['claim ownership probe', 'OWNED'],
['rename', 'MOVED'],
['claim release', 'RELEASED'],
['tombstone deletion', '']
] as const
describe('version GC termination', () => {
it.each(versionSteps.map(([phase], index) => ({ phase, index })))(
'stops all cleanup after unconfirmed $phase termination',
async ({ index }) => {
const error = failAfter(versionSteps.slice(0, index).map(([, reply]) => reply))
await expect(collectVersions()).rejects.toBe(error)
expect(mockExec).toHaveBeenCalledTimes(index + 1)
}
)
it.each([false, true])(
'stops after an unconfirmed stale lock probe with claim held: %s',
async (claimed) => {
const replies = claimed
? versionSteps.slice(0, 6).map(([, reply]) => String(reply))
: versionSteps.slice(0, 1).map(([, reply]) => String(reply))
const error = failAfter([...replies, 'LOCKED'])
await expect(collectVersions()).rejects.toBe(error)
expect(mockExec).toHaveBeenCalledTimes(replies.length + 2)
}
)
it('stops after unconfirmed abandoned tombstone deletion', async () => {
const error = failAfter([
'orcad-0.1.0+aaa.gc-tombstone.1.1\norcad-0.1.0+ccc.gc-tombstone.1.1\norcad-0.1.0+ddd'
])
await expect(collectVersions()).rejects.toBe(error)
expect(mockExec).toHaveBeenCalledTimes(2)
})
})
@@ -69,23 +69,6 @@ describe.each([
['POSIX', process.platform !== 'win32', getRemoteHostPlatform('linux-x64')],
['Windows PowerShell 5.1', powerShell51, getRemoteHostPlatform('win32-x64')]
] as const)('a %s steal naming an exited holder', (_name, runs, host) => {
it.runIf(runs)('takes a quiet lock that still names that holder', async () => {
const lock = lockOwnedBy('t-exited', 10)
expect(await steal(host, lock)).toBe('EXITED_OWNER_OK')
expect(readFileSync(join(lock, OWNER), 'utf8')).toBe('t-relaunch')
})
it.runIf(runs)('keeps a lock another holder now owns, however quiet', async () => {
const lock = lockOwnedBy('live-successor', 10)
expect(await steal(host, lock)).toBe('BUSY')
expect(readFileSync(join(lock, OWNER), 'utf8')).toBe('live-successor')
})
it.runIf(runs)('keeps a lock that is not yet quiet', async () => {
const lock = lockOwnedBy('t-exited', 1)
expect(await steal(host, lock)).toBe('BUSY')
})
it.runIf(runs)('lets only one concurrent caller take it', async () => {
const lock = lockOwnedBy('t-exited', 10)
const outputs = await Promise.all(Array.from({ length: 8 }, () => steal(host, lock)))
@@ -1,51 +0,0 @@
import type * as ExecCommandModule from '../../ssh/remote-exec/remote-exec-command'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { SshConnection } from '../../ssh/connection/ssh-connection'
vi.mock('../../ssh/remote-exec/remote-exec-command', async (importOriginal) => ({
...(await importOriginal<typeof ExecCommandModule>()),
execCommand: vi.fn(),
isUnconfirmedSshCommandTermination: (error: unknown) =>
error instanceof Error &&
(error as Error & { sshChannelCloseConfirmed?: boolean }).sshChannelCloseConfirmed === false
}))
vi.mock('./remote-install-gc-claim', () => ({
isRelayGcClaimed: vi.fn().mockResolvedValue(false),
waitForRelayGcClaimRelease: vi.fn().mockResolvedValue(undefined)
}))
import { execCommand } from '../../ssh/remote-exec/remote-exec-command'
import { acquireInstallLock } from './remote-install-lock'
import { getRemoteHostPlatform } from '../../ssh/ssh-remote-platform'
describe('acquireInstallLock', () => {
afterEach(() => {
vi.resetAllMocks()
})
it('propagates an unconfirmed lock-create termination so deploy can retain the lock fence', async () => {
const controller = new AbortController()
const termination = Object.assign(new Error('lock creation termination was not confirmed'), {
sshChannelCloseConfirmed: false
})
vi.mocked(execCommand).mockImplementation(async (_conn, command) => {
if (command.includes('.install-lock')) {
controller.abort(
Object.assign(new Error('SSH operation was cancelled'), { name: 'AbortError' })
)
throw termination
}
return ''
})
await expect(
acquireInstallLock(
{} as SshConnection,
'/home/u/.orca-remote/relay-0.1.0',
getRemoteHostPlatform('linux-x64'),
{ signal: controller.signal }
)
).rejects.toBe(termination)
})
})
@@ -1,126 +0,0 @@
import { execFileSync } from 'node:child_process'
import { 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 type { RemoteInstallTransport } from './remote-install-transport'
import { uploadRemoteInstallDirectory } from './remote-install-transfers'
import {
RemoteInstallOutOfSpaceError,
remoteFreeSpaceProbeCommand
} from './remote-install-out-of-space'
import { dfAvailableKb } from '../host-state/remote-filesystem-space'
import { execHostCommand } from '../../ssh/remote-exec/remote-host-exec'
import { getRemoteHostPlatform } from '../../ssh/ssh-remote-platform'
vi.mock('../../ssh/remote-exec/remote-host-exec', () => ({
execHostCommand: vi.fn()
}))
const posix = getRemoteHostPlatform('linux-x64')
const windows = getRemoteHostPlatform('win32-x64')
// OpenSSH collapses ENOSPC into SSH_FX_FAILURE without errno.
const sftpFailure = (): Error => Object.assign(new Error('Failure'), { code: 4 })
const dfLine = (availableKb: number): string =>
`tmpfs 409600 ${409600 - availableKb} ${availableKb} 100% /root\n`
function connection(failure: () => Error): RemoteInstallTransport {
return {
exec: vi.fn(),
usesSystemSshTransport: () => false,
getConnectGeneration: () => 1,
uploadDirectory: vi.fn(async () => {
throw failure()
}),
writeFile: vi.fn(async () => {
throw failure()
})
}
}
let localDir = ''
beforeEach(() => {
localDir = mkdtempSync(join(tmpdir(), 'orca-out-of-space-'))
writeFileSync(join(localDir, 'orcad.js'), Buffer.alloc(2 * 1024 * 1024))
vi.mocked(execHostCommand).mockReset()
})
afterEach(() => rmSync(localDir, { recursive: true, force: true }))
describe('a failed install write on a full host', () => {
it('says the disk is full instead of the bare SFTP "Failure"', async () => {
vi.mocked(execHostCommand).mockResolvedValue(dfLine(12))
const upload = uploadRemoteInstallDirectory(
connection(sftpFailure),
localDir,
'/root/.orca-remote/orcad-0.1.0+abc',
posix
)
await expect(upload).rejects.toBeInstanceOf(RemoteInstallOutOfSpaceError)
await expect(upload).rejects.toThrow(
/out of disk space: the filesystem holding \/root\/\.orca-remote\/orcad-0\.1\.0\+abc has 12 KB free, and Orca needs about 2\.0 MB/
)
expect(vi.mocked(execHostCommand).mock.calls[0]?.[2]).toBe(
remoteFreeSpaceProbeCommand('/root/.orca-remote/orcad-0.1.0+abc')
)
})
it('keeps the original error when the filesystem still has room', async () => {
// Partial uploads may have consumed space; less than the bundle size does not prove exhaustion.
vi.mocked(execHostCommand).mockResolvedValue(dfLine(1_500))
const failure = sftpFailure()
await expect(
uploadRemoteInstallDirectory(
connection(() => failure),
localDir,
'/root/r',
posix
)
).rejects.toBe(failure)
})
it('never probes after unverifiable transport loss, an abort, or on Windows', async () => {
const lost = Object.assign(new Error('SSH command timed out'), {
code: 4,
sshChannelCloseConfirmed: false
})
await expect(
uploadRemoteInstallDirectory(
connection(() => lost),
localDir,
'/root/r',
posix
)
).rejects.toBe(lost)
const controller = new AbortController()
controller.abort()
await expect(
uploadRemoteInstallDirectory(connection(sftpFailure), localDir, '/root/r', posix, {
signal: controller.signal
})
).rejects.toThrow()
const failure = sftpFailure()
await expect(
uploadRemoteInstallDirectory(
connection(() => failure),
localDir,
'C:/Users/u/r',
windows
)
).rejects.toBe(failure)
expect(execHostCommand).not.toHaveBeenCalled()
})
})
describe('remoteFreeSpaceProbeCommand', () => {
it.skipIf(process.platform === 'win32')(
'reads free space from the nearest existing directory through a real /bin/sh',
() => {
const missing = join(localDir, "not yet's made", 'stage dir')
const out = execFileSync('/bin/sh', ['-c', remoteFreeSpaceProbeCommand(missing)], {
encoding: 'utf8'
})
expect(dfAvailableKb(out.trim())).toEqual(expect.any(Number))
}
)
})
@@ -1,163 +0,0 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { SshConnection } from '../../ssh/connection/ssh-connection'
import { createSshOperationAbortError } from '../../ssh/connection/ssh-exec-options'
import {
uploadDirectoryViaExecStdin,
writeStringViaExecStdin
} from '../../ssh/ssh-exec-stdin-file-transfer'
import {
classifySftpFailureForExecFallback,
uploadRemoteInstallDirectory,
writeRemoteInstallFile
} from './remote-install-transfers'
import { getRemoteHostPlatform } from '../../ssh/ssh-remote-platform'
import { tagSftpHandshakeCorruption } from '../../ssh/sftp-handshake-corruption'
vi.mock('../../ssh/ssh-exec-stdin-file-transfer', () => ({
uploadDirectoryViaExecStdin: vi.fn(async () => {}),
writeStringViaExecStdin: vi.fn(async () => {})
}))
const posix = getRemoteHostPlatform('linux-x64')
const windows = getRemoteHostPlatform('win32-x64')
const subsystemRefused = (): Error => new Error('Unable to start subsystem: sftp')
const sandboxed = (): Error => Object.assign(new Error('No such file'), { code: 2 })
const unconfirmed = (): Error =>
Object.assign(new Error('SSH command timed out'), { sshChannelCloseConfirmed: false })
function connection(
failure: () => Error,
overrides: Partial<Record<'usesSystemSshTransport' | 'getConnectGeneration', () => unknown>> = {}
): SshConnection & {
uploadDirectory: ReturnType<typeof vi.fn>
writeFile: ReturnType<typeof vi.fn>
} {
const conn = {
uploadDirectory: vi.fn(async () => {
throw failure()
}),
writeFile: vi.fn(async () => {
throw failure()
}),
usesSystemSshTransport: () => false,
getConnectGeneration: () => 1,
...overrides
}
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the transfers only use the members defined above.
return conn as unknown as SshConnection & {
uploadDirectory: ReturnType<typeof vi.fn>
writeFile: ReturnType<typeof vi.fn>
}
}
beforeEach(() => {
vi.mocked(uploadDirectoryViaExecStdin).mockClear()
vi.mocked(writeStringViaExecStdin).mockClear()
vi.spyOn(console, 'warn').mockImplementation(() => {})
})
describe('classifySftpFailureForExecFallback', () => {
it('falls back only for a parse failure ssh2 reported before the SFTP session was ready', () => {
const corrupted = (): Error =>
Object.assign(new Error('Packet length 1111577416 exceeds max length of 262144'), {
level: 'sftp-protocol'
})
expect(classifySftpFailureForExecFallback(tagSftpHandshakeCorruption(corrupted()))).toBe(
'sftp-unavailable'
)
// Untagged: the same words mid-transfer could follow writes already sent.
expect(classifySftpFailureForExecFallback(corrupted())).toBeNull()
expect(
classifySftpFailureForExecFallback(tagSftpHandshakeCorruption(new Error('No response')))
).toBeNull()
})
it('never treats unverifiable transport loss or a refusal of the write itself as a verdict', () => {
for (const error of [
unconfirmed(),
Object.assign(subsystemRefused(), { sshChannelCloseConfirmed: false }),
new Error('Received unexpected SFTP session termination'),
new Error('SSH SFTP channel timed out'),
new Error('Not connected'),
createSshOperationAbortError(),
Object.assign(new Error('Permission denied'), { code: 3 }),
'Unable to start subsystem: sftp'
]) {
expect(classifySftpFailureForExecFallback(error)).toBeNull()
}
})
})
describe('relay install transfer fallback selection', () => {
it('streams over exec stdin when the subsystem is refused, and skips SFTP for the rest of the connect', async () => {
const conn = connection(subsystemRefused)
await uploadRemoteInstallDirectory(conn, '/local/relay', '/h/.orca-remote/stage/payload', posix)
await writeRemoteInstallFile(conn, posix, '/h/.orca-remote/stage/payload/.version', '0.1.0+abc')
expect(uploadDirectoryViaExecStdin).toHaveBeenCalledWith(
conn,
'/local/relay',
'/h/.orca-remote/stage/payload',
posix,
{ signal: undefined }
)
expect(writeStringViaExecStdin).toHaveBeenCalledWith(
conn,
'/h/.orca-remote/stage/payload/.version',
'0.1.0+abc',
{ signal: undefined }
)
expect(conn.writeFile).not.toHaveBeenCalled()
})
it('asks SFTP again after a reconnect', async () => {
let generation = 1
const conn = connection(subsystemRefused, { getConnectGeneration: () => generation })
await uploadRemoteInstallDirectory(conn, '/l', '/h/r', posix)
generation = 2
await uploadRemoteInstallDirectory(conn, '/l', '/h/r', posix)
expect(conn.uploadDirectory).toHaveBeenCalledTimes(2)
})
it('falls back for a chrooted subsystem without remembering it as a host verdict', async () => {
const conn = connection(sandboxed)
await uploadRemoteInstallDirectory(conn, '/l', '/h/r', posix)
await uploadRemoteInstallDirectory(conn, '/l', '/h/r', posix)
expect(conn.uploadDirectory).toHaveBeenCalledTimes(2)
expect(uploadDirectoryViaExecStdin).toHaveBeenCalledTimes(2)
})
it('surfaces unverifiable transport loss instead of retrying down another path', async () => {
const lost = unconfirmed()
const conn = connection(() => lost)
await expect(uploadRemoteInstallDirectory(conn, '/l', '/h/r', posix)).rejects.toBe(lost)
expect(uploadDirectoryViaExecStdin).not.toHaveBeenCalled()
})
it('does not fall back when the caller aborted', async () => {
const controller = new AbortController()
const conn = connection(() => {
controller.abort()
return subsystemRefused()
})
await expect(
uploadRemoteInstallDirectory(conn, '/l', '/h/r', posix, { signal: controller.signal })
).rejects.toThrow()
expect(uploadDirectoryViaExecStdin).not.toHaveBeenCalled()
})
it('keeps the sandbox diagnosis where exec stdin cannot apply', async () => {
await expect(
uploadRemoteInstallDirectory(connection(sandboxed), '/l', 'C:/Users/u/r', windows)
).rejects.toThrow(/SFTP subsystem sees a different filesystem/)
await expect(
uploadRemoteInstallDirectory(
connection(sandboxed, { usesSystemSshTransport: () => true }),
'/l',
'/h/r',
posix
)
).rejects.toThrow(/SFTP subsystem sees a different filesystem/)
expect(uploadDirectoryViaExecStdin).not.toHaveBeenCalled()
})
})
@@ -1,9 +1,9 @@
import type * as ExecCommandModule from '../../ssh/remote-exec/remote-exec-command'
import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs'
import { mkdtempSync, readFileSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { runProcess, spawnProcess } from '@orca/process-host'
import { runProcess } from '@orca/process-host'
import {
RUNTIME_STORE_LOCK_NAME,
RUNTIME_STORE_LOCK_OWNER_FILENAME,
@@ -35,22 +35,6 @@ async function sh(command: string): Promise<string> {
return result.stdout
}
const unconfirmed = (): Error =>
Object.assign(new Error('timed out after 30s'), {
code: 'SSH_EXEC_TIMEOUT',
sshChannelCloseConfirmed: false
})
async function waitUntil(condition: () => boolean, timeoutMs: number): Promise<void> {
const start = Date.now()
while (!condition()) {
if (Date.now() - start > timeoutMs) {
throw new Error('condition not met')
}
await new Promise((resolve) => setTimeout(resolve, 50))
}
}
beforeEach(() => {
store = mkdtempSync(join(tmpdir(), 'runtime-store-lock-'))
lock = join(store, RUNTIME_STORE_LOCK_NAME)
@@ -61,55 +45,6 @@ afterEach(() => {
})
describe.skipIf(process.platform === 'win32')('runtime store lock released by the host', () => {
it('passes the wrapped command output and exit status through and frees the lock', async () => {
await withRuntimeStoreLock(conn, host, store, async (held) => {
expect(existsSync(lock)).toBe(true)
expect((await sh(held.releasedByHostOnExit('echo ORCA_NODE_RUNTIME_READY'))).trim()).toBe(
'ORCA_NODE_RUNTIME_READY'
)
expect(existsSync(lock)).toBe(false)
await expect(sh(held.releasedByHostOnExit('echo partial; exit 3'))).rejects.toMatchObject({
exitCode: 3,
stdout: 'partial\n'
})
})
expect(existsSync(lock)).toBe(false)
})
it('frees the lock when work the client gave up on finishes, so the next installer proceeds', async () => {
let orphan: Promise<string> | undefined
await expect(
withRuntimeStoreLock(conn, host, store, async (held) => {
orphan = sh(held.releasedByHostOnExit('sleep 1; echo ORCA_NODE_RUNTIME_READY'))
throw unconfirmed()
})
).rejects.toMatchObject({ sshChannelCloseConfirmed: false })
// Lost contact does not prove the host work exited.
expect(existsSync(lock)).toBe(true)
const started = Date.now()
await withRuntimeStoreLock(conn, host, store, async () => {})
// Without host-side release this would wait for the 20-minute stale threshold.
expect(Date.now() - started).toBeLessThan(10_000)
await orphan
}, 20_000)
it('keeps releasing after the client side hangs up on the host shell', async () => {
await expect(
withRuntimeStoreLock(conn, host, store, async (held) => {
const child = spawnProcess({
program: '/bin/sh',
args: ['-c', held.releasedByHostOnExit('sleep 1; echo done')],
stdio: 'ignore'
})
await new Promise((resolve) => setTimeout(resolve, 200))
child.kill('SIGHUP')
throw unconfirmed()
})
).rejects.toMatchObject({ sshChannelCloseConfirmed: false })
expect(existsSync(lock)).toBe(true)
await waitUntil(() => !existsSync(lock), 10_000)
}, 20_000)
it('never removes a lock another holder took after the host released this one', async () => {
await withRuntimeStoreLock(conn, host, store, async (held) => {
await sh(held.releasedByHostOnExit('true'))
@@ -1,31 +1,13 @@
import { spawn, spawnSync } from 'node:child_process'
import { copyFileSync, mkdirSync, mkdtempSync, utimesSync, writeFileSync } from 'node:fs'
import { copyFileSync, mkdirSync, mkdtempSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type * as ExecCommandModule from '../../ssh/remote-exec/remote-exec-command'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
vi.mock('../../ssh/remote-exec/remote-exec-command', async (importOriginal) => ({
...(await importOriginal<typeof ExecCommandModule>()),
execCommand: vi.fn()
}))
import { gcRemoteNodeRuntimeStore, parseSweptRuntimeStages } from './remote-node-runtime-store-gc'
import { RUNTIME_STORE_LOCK_NAME } from './remote-node-runtime-store-lock'
import { parseRuntimeStoreInventory } from './remote-node-runtime-store-inventory'
import {
windowsRuntimeStoreInventoryCommand,
windowsSweepStaleRuntimeStagesCommand
} from './remote-node-runtime-store-windows'
import { execCommand } from '../../ssh/remote-exec/remote-exec-command'
import { getRemoteHostPlatform } from '../../ssh/ssh-remote-platform'
import { windowsRuntimeStoreInventoryCommand } from './remote-node-runtime-store-windows'
import { decodeRemotePowerShellScript } from '../../ssh/ssh-remote-powershell'
import { removeTreeSync } from '../../../shared/windows-transient-lock-removal'
import { unusedSshConnection } from '../../ssh/unused-host-transport.test-fixture'
const conn = unusedSshConnection()
const host = getRemoteHostPlatform('win32-x64')
const mockExec = vi.mocked(execCommand)
const sha = (c: string): string => c.repeat(64)
const powerShell = [
@@ -39,106 +21,6 @@ const powerShell = [
}).status === 0
)
describe('parseRuntimeStoreInventory with Windows output', () => {
it('takes holds from backslash image paths in any case, tombstones included', () => {
const inventory = parseRuntimeStoreInventory(
[
'DIR relay-0.1.0+abc',
`REF ${sha('a')}`,
`ENTRY node-${sha('a')}`,
`ENTRY node-${sha('b')}`,
`VERIFIED node-${sha('b')}`,
'PROCESS_CHECK cim',
`HOLD C:\\Users\\ada\\.orca-remote\\Runtimes\\node-${sha('b').toUpperCase()}\\node.exe`,
`HOLD C:\\Users\\ada\\.orca-remote\\runtimes\\.gc-tombstone-node-${sha('c')}.1.2\\node.exe`,
'HOLD C:\\Program Files\\nodejs\\node.exe',
'__ORCA_RUNTIME_STORE__OK'
].join('\r\n')
)
expect(inventory?.processCheckRan).toBe(true)
expect([...(inventory?.held ?? [])]).toEqual([sha('b'), sha('c')])
expect([...(inventory?.referenced ?? [])]).toEqual([sha('a')])
})
})
describe('gcRemoteNodeRuntimeStore on a Windows host', () => {
beforeEach(() => {
mockExec.mockReset()
vi.spyOn(console, 'log').mockImplementation(() => {})
})
it('runs under the store lock with unwrapped powershell.exe commands and collects an idle runtime', async () => {
const scripts: string[] = []
mockExec.mockImplementation(async (_conn, command, options) => {
expect(command).toMatch(/^powershell\.exe /)
expect(options).toMatchObject({ wrapCommand: false })
const script = decodeRemotePowerShellScript(command)
scripts.push(script)
if (script.includes(RUNTIME_STORE_LOCK_NAME) && script.includes('CreateNew')) {
return 'OK'
}
if (script.includes('Win32_Process')) {
return [
'DIR relay-0.1.0+abc',
`REF ${sha('a')}`,
`ENTRY node-${sha('a')}`,
`ENTRY node-${sha('b')}`,
`ENTRY node-${sha('c')}`,
`VERIFIED node-${sha('a')}`,
`VERIFIED node-${sha('b')}`,
`VERIFIED node-${sha('c')}`,
'PROCESS_CHECK cim',
'__ORCA_RUNTIME_STORE__OK'
].join('\r\n')
}
if (script.includes('Move-Item')) {
return 'MOVED'
}
return ''
})
const result = await gcRemoteNodeRuntimeStore(conn, host, 'C:/Users/ada', {
currentPins: [sha('a')]
})
expect(result).toMatchObject({ state: 'collected', removed: [`node-${sha('c')}`] })
const lockTaken = scripts.findIndex((s) => s.includes('CreateNew'))
const inventoried = scripts.findIndex((s) => s.includes('Win32_Process'))
const released = scripts.findLastIndex(
(s) => s.startsWith('Remove-Item') && s.includes(RUNTIME_STORE_LOCK_NAME)
)
expect(lockTaken).toBe(0)
expect(inventoried).toBeGreaterThan(lockTaken)
expect(released).toBe(scripts.length - 1)
})
it('keeps everything when the process query did not run', async () => {
mockExec.mockImplementation(async (_conn, command) => {
const script = decodeRemotePowerShellScript(command)
if (script.includes('CreateNew')) {
return 'OK'
}
if (script.includes('Win32_Process')) {
return [
`ENTRY node-${sha('c')}`,
`VERIFIED node-${sha('c')}`,
'__ORCA_RUNTIME_STORE__OK'
].join('\n')
}
return ''
})
const result = await gcRemoteNodeRuntimeStore(conn, host, 'C:/Users/ada', {
currentPins: [sha('a')]
})
expect(result).toMatchObject({ state: 'collected', removed: [] })
expect(
mockExec.mock.calls.some(([, command]) =>
decodeRemotePowerShellScript(command).includes('Move-Item')
)
).toBe(false)
})
})
describe.runIf(powerShell)('Windows runtime store commands (real PowerShell)', () => {
let home: string
beforeEach(() => {
@@ -158,27 +40,6 @@ describe.runIf(powerShell)('Windows runtime store commands (real PowerShell)', (
return result.stdout
}
it('inventories refs, entries and verified order', () => {
const remote = join(home, '.orca-remote')
const relay = join(remote, 'relay-0.1.0+abc')
mkdirSync(relay, { recursive: true })
writeFileSync(join(relay, `.runtime-ref-node-${sha('a')}`), `${sha('a')}\n`)
for (const [c, age] of [
['a', 60],
['b', 0]
] as const) {
const entry = join(remote, 'runtimes', `node-${sha(c)}`)
mkdirSync(entry, { recursive: true })
writeFileSync(join(entry, '.verified'), '')
const at = Date.now() / 1000 - age
utimesSync(join(entry, '.verified'), at, at)
}
const inventory = parseRuntimeStoreInventory(run(windowsRuntimeStoreInventoryCommand(remote)))
expect(inventory?.dirNames).toEqual(['relay-0.1.0+abc'])
expect([...(inventory?.referenced ?? [])]).toEqual([sha('a')])
expect(inventory?.verifiedNewestFirst).toEqual([`node-${sha('b')}`, `node-${sha('a')}`])
})
it.runIf(process.platform === 'win32')(
'holds a runtime whose node.exe is running, found by its image path',
{ timeout: 120_000 },
@@ -209,21 +70,4 @@ describe.runIf(powerShell)('Windows runtime store commands (real PowerShell)', (
}
}
)
it('sweeps only stages nothing has written to within the stale rule', () => {
const runtimes = join(home, 'runtimes')
const old = Date.now() / 1000 - 21 * 60
const stage = (name: string, fileAge: number): string => {
const dir = join(runtimes, name)
mkdirSync(dir, { recursive: true })
writeFileSync(join(dir, 'node.zip'), 'x')
utimesSync(join(dir, 'node.zip'), fileAge, fileAge)
utimesSync(dir, old, old)
return dir
}
stage('.stage-node-x-1', old)
stage('.stage-node-x-2', Date.now() / 1000)
const out = run(windowsSweepStaleRuntimeStagesCommand(runtimes, 20, 'SWEPT'))
expect(parseSweptRuntimeStages(out)).toEqual(['.stage-node-x-1'])
})
})
@@ -1,211 +0,0 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { OrcadManagedTunnelTransportProvider } from './orcad-managed-tunnel-transport'
import { knownOrcadTunnelTransport } from '../../ssh/orcad-tunnel-transport-memo'
import type { SshConnection } from '../../ssh/connection/ssh-connection'
import type {
PortForwardStartOptions,
SshPortForwardProvider,
StartedPortForward
} from '../../ssh/port-forwarding/ssh-port-forward-provider'
import type {
ForwardChannelVerdict,
TcpForwardingVerdict
} from '../../ssh/ssh-tcp-forwarding-probe'
let generation = 1
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the selector reads only the transport generation.
const conn = { getConnectGeneration: () => generation } as unknown as SshConnection
const options: PortForwardStartOptions = {
id: 'pf-1',
connectionId: 'ssh-1',
localHost: '127.0.0.1',
localPort: 46_001,
remoteHost: '127.0.0.1',
remotePort: 6768
}
function provider(name: string, canHandle = true) {
const close = vi.fn(async () => {})
return {
name,
close,
canHandle: () => canHandle,
start: vi.fn(async (_conn: SshConnection, _options: PortForwardStartOptions) => {
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the test reads only which provider answered.
return { entry: { id: name }, close } as unknown as StartedPortForward
})
} satisfies SshPortForwardProvider & { name: string; close: unknown }
}
function select(
verdict: TcpForwardingVerdict,
channel: ForwardChannelVerdict = 'unverifiable',
stdioCanHandle = true
) {
const forward = provider('forward')
const stdio = provider('stdio', stdioCanHandle)
const reserveLocalPort = vi.fn(async () => {})
const checkForward = vi.fn(async () => channel)
const transport = new OrcadManagedTunnelTransportProvider({
forwards: [provider('unusable', false), forward],
stdio,
probe: vi.fn(async () => verdict),
checkForward,
reserveLocalPort
})
return { transport, forward, stdio, checkForward, reserveLocalPort }
}
beforeEach(() => {
generation = 1
})
describe('the managed tunnel’s transport', () => {
it('takes the stdio bridge only where sshd refuses forwarding', async () => {
const refused = select('refused')
await expect(refused.transport.start(conn, options)).resolves.toMatchObject({
entry: { id: 'stdio' }
})
expect(refused.stdio.start).toHaveBeenCalledWith(conn, options)
expect(refused.forward.start).not.toHaveBeenCalled()
expect(knownOrcadTunnelTransport('ssh-1')).toBe('stdio_bridge')
})
it('keeps the forward when forwarding is allowed or the answer is unverifiable', async () => {
for (const verdict of ['allowed', 'unverifiable'] as const) {
const chosen = select(verdict)
await expect(chosen.transport.start(conn, options)).resolves.toMatchObject({
entry: { id: 'forward' }
})
expect(chosen.stdio.start).not.toHaveBeenCalled()
expect(knownOrcadTunnelTransport('ssh-1')).toBe('tcp_forward')
}
})
it('replaces a system SSH forward whose channel sshd refused with the stdio bridge', async () => {
// System SSH exposes refusal through the forward's channel check, without an ssh2 client.
const chosen = select('unverifiable', 'refused')
await expect(chosen.transport.start(conn, options)).resolves.toMatchObject({
entry: { id: 'stdio' }
})
expect(chosen.forward.close).toHaveBeenCalledOnce()
expect(chosen.forward.close.mock.invocationCallOrder[0]).toBeLessThan(
chosen.stdio.start.mock.invocationCallOrder[0]
)
expect(chosen.stdio.start).toHaveBeenCalledWith(conn, options)
// Release the persisted port before the bridge binds it.
expect(chosen.reserveLocalPort.mock.invocationCallOrder.at(-1)).toBeGreaterThan(
chosen.forward.close.mock.invocationCallOrder[0]
)
expect(chosen.reserveLocalPort.mock.invocationCallOrder.at(-1)).toBeLessThan(
chosen.stdio.start.mock.invocationCallOrder[0]
)
expect(knownOrcadTunnelTransport('ssh-1')).toBe('stdio_bridge')
})
it('fails loudly, never with the dead forward, when a refused host has no bridge route', async () => {
const chosen = select('unverifiable', 'refused', false)
await expect(chosen.transport.start(conn, options)).rejects.toThrow(
/has no route: sshd refused/
)
expect(chosen.forward.close).toHaveBeenCalledOnce()
})
it('keeps a forward whose channel opened or whose answer is unverifiable', async () => {
for (const channel of ['allowed', 'unverifiable'] as const) {
const chosen = select('unverifiable', channel)
await expect(chosen.transport.start(conn, options)).resolves.toMatchObject({
entry: { id: 'forward' }
})
expect(chosen.forward.close).not.toHaveBeenCalled()
expect(chosen.stdio.start).not.toHaveBeenCalled()
}
})
it('goes straight to the bridge on the transport that refused, and re-checks after a reconnect', async () => {
const chosen = select('unverifiable', 'refused')
await chosen.transport.start(conn, options)
await chosen.transport.start(conn, options)
expect(chosen.forward.start).toHaveBeenCalledOnce()
expect(chosen.stdio.start).toHaveBeenCalledTimes(2)
expect(chosen.reserveLocalPort).toHaveBeenCalledWith(conn, options)
generation = 2
await chosen.transport.start(conn, options)
expect(chosen.forward.start).toHaveBeenCalledTimes(2)
})
it('takes the bridge, never the dead forward, when the forward exits during its check', async () => {
const chosen = select('unverifiable')
const onUnexpectedClose = vi.fn()
chosen.checkForward.mockImplementation(async () => {
chosen.forward.start.mock.calls[0]?.[1].onUnexpectedClose?.(options, {
kind: 'unexpected-exit',
detail: 'System SSH port forward failed (exit 255): Permission denied (publickey).'
})
return 'unverifiable'
})
await expect(
chosen.transport.start(conn, { ...options, onUnexpectedClose })
).resolves.toMatchObject({ entry: { id: 'stdio' } })
expect(chosen.forward.close).toHaveBeenCalledOnce()
expect(knownOrcadTunnelTransport('ssh-1')).toBe('stdio_bridge')
// Late exits from abandoned forwards must not close the replacement bridge.
chosen.forward.start.mock.calls[0]?.[1].onUnexpectedClose?.(options, {
kind: 'unexpected-exit'
})
expect(onUnexpectedClose).toHaveBeenCalledOnce()
})
it('takes the bridge when the forward fails to start at all', async () => {
const chosen = select('unverifiable')
chosen.forward.start.mockRejectedValueOnce(
new Error('System SSH port forward failed (exit 255): Permission denied (publickey).')
)
await expect(chosen.transport.start(conn, options)).resolves.toMatchObject({
entry: { id: 'stdio' }
})
expect(chosen.stdio.start).toHaveBeenCalledWith(conn, options)
expect(chosen.checkForward).not.toHaveBeenCalled()
expect(knownOrcadTunnelTransport('ssh-1')).toBe('stdio_bridge')
await chosen.transport.start(conn, options)
expect(chosen.forward.start).toHaveBeenCalledOnce()
})
it('takes the bridge when the forward never listened', async () => {
const chosen = select('unverifiable', 'not-listening')
await expect(chosen.transport.start(conn, options)).resolves.toMatchObject({
entry: { id: 'stdio' }
})
expect(chosen.forward.close).toHaveBeenCalledOnce()
})
it('frees the persisted port before the forward binds it, not only before the bridge', async () => {
const chosen = select('unverifiable', 'allowed')
await chosen.transport.start(conn, options)
expect(chosen.reserveLocalPort).toHaveBeenCalledWith(conn, options)
expect(chosen.reserveLocalPort.mock.invocationCallOrder[0]).toBeLessThan(
chosen.forward.start.mock.invocationCallOrder[0]
)
})
it('reserves nothing for a port OpenSSH picks itself', async () => {
const chosen = select('unverifiable', 'refused')
await chosen.transport.start(conn, { ...options, localPort: 0 })
expect(chosen.reserveLocalPort).not.toHaveBeenCalled()
})
it('never records a refusal answered before a reconnect against the new transport', async () => {
const chosen = select('unverifiable', 'refused')
chosen.checkForward.mockImplementationOnce(async () => {
generation = 2
return 'refused'
})
await chosen.transport.start(conn, options)
chosen.checkForward.mockResolvedValue('allowed')
await expect(chosen.transport.start(conn, options)).resolves.toMatchObject({
entry: { id: 'forward' }
})
expect(chosen.forward.start).toHaveBeenCalledTimes(2)
})
})
@@ -1,94 +0,0 @@
import { describe, expect, it } from 'vitest'
import {
emptyOrcadActivationRecord,
type OrcadActivationRecord
} from '../activation/orcad-activation-record'
import {
admitOrcadRecordForIntent,
availableManagedOrcadUpdate,
planOrcadUpdateForIntent
} from './orcad-managed-update-decision'
const rolledBack = {
...emptyOrcadActivationRecord(),
active: '1.4.0+aa',
activeAppVersion: '1.4.0',
rolledBackFrom: '1.5.0+ab'
}
const newer = { ...emptyOrcadActivationRecord(), active: '2.0.0+cd', activeAppVersion: '2.0.0' }
const input = { candidateVersion: '1.5.0+ab', appVersion: '1.5.0', failedBefore: true }
describe('the three update intents', () => {
it('skips a build this host refused on a connect only', () => {
expect(planOrcadUpdateForIntent('connect', { ...input, record: rolledBack })).toEqual({
action: 'skip',
reason: 'rolled-back'
})
expect(planOrcadUpdateForIntent('update', { ...input, record: rolledBack })).toEqual({
action: 'update'
})
expect(planOrcadUpdateForIntent('force', { ...input, record: rolledBack })).toEqual({
action: 'update'
})
})
it('keeps a newer host on Update, and lifts it only on Force', () => {
expect(planOrcadUpdateForIntent('update', { ...input, record: newer })).toEqual({
action: 'skip',
reason: 'host-newer'
})
expect(admitOrcadRecordForIntent('update', '1.5.0')(newer, '1.5.0+ab')).toMatch(
/Orca 2\.0\.0 last ran/
)
expect(admitOrcadRecordForIntent('force', '1.5.0')(newer, '1.5.0+ab')).toBeNull()
expect(admitOrcadRecordForIntent('update', '1.5.0')(rolledBack, '1.5.0+ab')).toBeNull()
})
})
describe('the Update offered for a running host', () => {
const running = { ...emptyOrcadActivationRecord(), active: '0.1.0+aa', activeAppVersion: '1.4.0' }
const offer = (
record: OrcadActivationRecord,
appVersion = '1.5.0',
candidateVersion = '0.1.0+ab'
) =>
availableManagedOrcadUpdate({
record,
candidateVersion,
appVersion,
deferredCandidateVersion: null
})
it('offers this build only when it is not older than the Orca that runs the host', () => {
expect(offer(running)).toBe('0.1.0+ab')
expect(offer(running, '1.4.0')).toBe('0.1.0+ab')
expect(offer(running, '1.3.9')).toBeNull()
expect(offer({ ...running, activeAppVersion: '2.0.0' })).toBeNull()
})
it('offers nothing it cannot order, nothing current, and nothing the user rolled back from', () => {
const { activeAppVersion: _unnamed, ...unnamed } = running
expect(offer(unnamed)).toBeNull()
const deferredOn = (deferredCandidateVersion: string) =>
availableManagedOrcadUpdate({
record: unnamed,
candidateVersion: '0.1.0+ab',
appVersion: '1.5.0',
deferredCandidateVersion
})
// A host older than the version field: only the build this desktop already tried.
expect(deferredOn('0.1.0+ab')).toBe('0.1.0+ab')
expect(deferredOn('0.1.0+zz')).toBeNull()
expect(offer(running, '1.5.0', '0.1.0+aa')).toBeNull()
expect(offer({ ...running, active: null })).toBeNull()
expect(offer({ ...running, rolledBackFrom: '0.1.0+ab' })).toBeNull()
expect(
availableManagedOrcadUpdate({
record: running,
candidateVersion: null,
appVersion: '1.5.0',
deferredCandidateVersion: null
})
).toBeNull()
})
})
@@ -1,89 +0,0 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { SshTarget } from '../../../shared/ssh-types'
import type { ManagedOrcadAutoUpdateOutcome } from './orcad-managed-auto-update'
import {
redeployStoppedManagedOrcad,
resetManagedOrcadRestoreUpdatesForTests,
updateManagedOrcadOnRestore,
type ManagedOrcadRestoreUpdateDeps
} from './orcad-managed-update-on-restore'
const target: SshTarget = { id: 'ssh-1', label: 'Box', host: 'box', port: 22, username: 'me' }
function deps(
outcome: ManagedOrcadAutoUpdateOutcome,
overrides: Partial<ManagedOrcadRestoreUpdateDeps> = {}
): ManagedOrcadRestoreUpdateDeps {
return {
target: () => target,
publish: vi.fn(),
autoUpdate: vi.fn(async (_id, options) => {
if (outcome.outcome === 'updated' || outcome.outcome === 'failed') {
options.onUpdating()
}
return outcome
}),
recordedUpdateFailure: () => null,
failedThisLaunch: () => true,
recordUpdateFailure: vi.fn(),
clearUpdateFailure: vi.fn(),
...overrides
}
}
describe('updating a managed server when the launch restores its tunnel', () => {
beforeEach(() => resetManagedOrcadRestoreUpdatesForTests())
it('checks each server once per session, however often its tunnel is ensured', async () => {
const d = deps({ outcome: 'skipped', reason: 'current' })
await updateManagedOrcadOnRestore('env-1', () => d)
expect(updateManagedOrcadOnRestore('env-1', () => d)).toBeNull()
expect(d.autoUpdate).toHaveBeenCalledTimes(1)
})
it('never fails the restore that triggered it', async () => {
const thrown = deps(
{ outcome: 'skipped', reason: 'current' },
{
autoUpdate: async () => {
throw new Error('ssh dropped')
}
}
)
await expect(updateManagedOrcadOnRestore('env-1', () => thrown)).resolves.toBeUndefined()
expect(
updateManagedOrcadOnRestore('env-2', () => {
throw new Error('no SSH store')
})
).toBeNull()
const unlinked = deps({ outcome: 'skipped', reason: 'current' }, { target: () => null })
expect(updateManagedOrcadOnRestore('env-3', () => unlinked)).toBeNull()
expect(unlinked.autoUpdate).not.toHaveBeenCalled()
})
})
describe('redeploying a server another desktop stopped mid-session', () => {
beforeEach(() => resetManagedOrcadRestoreUpdatesForTests())
it('runs even after this session already checked the server, and reports activation', async () => {
await updateManagedOrcadOnRestore('env-1', () =>
deps({ outcome: 'skipped', reason: 'current' })
)
const d = deps({ outcome: 'updated', activeVersion: '0.1.0+b' })
await expect(redeployStoppedManagedOrcad('env-1', () => d)).resolves.toBe(true)
expect(d.autoUpdate).toHaveBeenCalledWith('env-1', expect.anything())
expect(vi.mocked(d.publish).mock.calls.at(-1)).toEqual([target, 'env-1', 'settled', undefined])
})
it('shares one attempt between concurrent calls and retries on the next one', async () => {
const d = deps({ outcome: 'failed', reason: 'upload failed' })
const [first, second] = await Promise.all([
redeployStoppedManagedOrcad('env-1', () => d),
redeployStoppedManagedOrcad('env-1', () => d)
])
expect([first, second]).toEqual([false, false])
expect(d.autoUpdate).toHaveBeenCalledTimes(1)
await redeployStoppedManagedOrcad('env-1', () => d)
expect(d.autoUpdate).toHaveBeenCalledTimes(2)
})
})
@@ -1,113 +0,0 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { ServeReadiness } from '../../server/serve-readiness'
import { NODE_RUNTIME_ASSETS, NODE_RUNTIME_COMPAT_ASSETS } from '../../../shared/node-runtime-pin'
const { gcMock, readRecordMock } = vi.hoisted(() => ({
gcMock: vi.fn(),
readRecordMock: vi.fn()
}))
vi.mock('../install/orcad-remote-gc', () => ({ gcOldOrcadVersions: gcMock }))
vi.mock('../activation/orcad-activation-record-store', () => ({
readOrcadActivationRecord: readRecordMock
}))
import { provenLiveDaemonVersion, pruneManagedOrcadVersions } from './orcad-managed-version-gc'
import { unusedOrcadHostTransport } from '../../ssh/unused-host-transport.test-fixture'
function readiness(terminalDaemon: unknown): ServeReadiness {
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: only health.terminalDaemon is read.
return { health: terminalDaemon === undefined ? undefined : { terminalDaemon } } as ServeReadiness
}
const slot = {
conn: unusedOrcadHostTransport(),
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: GC is mocked; the slot's host is only passed through.
host: { os: 'linux', pathFlavor: 'posix', commandDialect: 'posix' } as never,
remoteHome: '/home/u',
nodePath: 'node',
userDataDir: '/home/u/.orca',
bindHost: '127.0.0.1',
port: 0
}
afterEach(() => {
vi.resetAllMocks()
})
describe('managed orcad version GC after a deploy', () => {
it('pins only a daemon version the readiness proves', () => {
expect(provenLiveDaemonVersion(readiness({ state: 'live', buildVersion: '1.0.0+a' }))).toBe(
'1.0.0+a'
)
expect(provenLiveDaemonVersion(readiness({ state: 'absent', buildVersion: null }))).toBeNull()
expect(
provenLiveDaemonVersion(readiness({ state: 'degraded', buildVersion: '1' }))
).toBeUndefined()
expect(
provenLiveDaemonVersion(readiness({ state: 'live', buildVersion: null }))
).toBeUndefined()
expect(provenLiveDaemonVersion(readiness(undefined))).toBeUndefined()
})
it('runs GC with the live daemon pinned, and keeps everything when it cannot say', async () => {
readRecordMock.mockResolvedValue({ active: '2.0.0+b' })
await pruneManagedOrcadVersions({
slot,
serverTarget: 'linux-x64-glibc',
activeVersion: '2.0.0+b',
readiness: readiness({ state: 'live', buildVersion: '1.0.0+a' })
})
expect(gcMock).toHaveBeenCalledWith(
expect.objectContaining({
liveDaemonVersion: '1.0.0+a',
record: { active: '2.0.0+b' },
// Default and compat builds share the runtime store; preserve both pins.
nodeRuntimePins: [
NODE_RUNTIME_ASSETS['linux-x64-glibc'].executableSha256,
NODE_RUNTIME_COMPAT_ASSETS['linux-x64-glibc217'].executableSha256
]
})
)
gcMock.mockClear()
await pruneManagedOrcadVersions({
slot,
serverTarget: 'linux-x64-glibc',
activeVersion: '2.0.0+b',
readiness: readiness({ state: 'degraded', buildVersion: null })
})
expect(gcMock).not.toHaveBeenCalled()
})
it('never fails the deploy it follows', async () => {
readRecordMock.mockResolvedValue({ active: '2.0.0+b' })
gcMock.mockRejectedValue(new Error('ssh dropped'))
vi.spyOn(console, 'warn').mockImplementation(() => undefined)
await expect(
pruneManagedOrcadVersions({
slot,
serverTarget: 'linux-x64-glibc',
activeVersion: '2.0.0+b',
readiness: readiness({ state: 'absent', buildVersion: null })
})
).resolves.toBe(false)
})
it('reports clean only when GC ran and left nothing behind', async () => {
readRecordMock.mockResolvedValue({ active: '2.0.0+b' })
const args = {
slot,
serverTarget: 'linux-x64-glibc' as const,
activeVersion: '2.0.0+b',
readiness: readiness({ state: 'absent', buildVersion: null })
}
gcMock.mockResolvedValue(true)
await expect(pruneManagedOrcadVersions(args)).resolves.toBe(true)
gcMock.mockResolvedValue(false)
await expect(pruneManagedOrcadVersions(args)).resolves.toBe(false)
await expect(
pruneManagedOrcadVersions({ ...args, readiness: readiness(undefined) })
).resolves.toBe(false)
})
})
@@ -2,11 +2,9 @@ import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { connect, type Server, type Socket } from 'node:net'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { PassThrough } from 'node:stream'
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest'
import type { OrcadStdioBridge } from '../../ssh/orcad-stdio-bridge'
import { ORCAD_STDIO_BRIDGE_MAX_CHANNELS } from './orcad-stdio-bridge-listener'
import { OrcadStdioBridgePortForwardProvider } from './orcad-stdio-bridge-provider'
import { spawnLocalBridgeChannel, startEchoServer } from './orcad-stdio-bridge-test-channel'
import { ORCAD_WINDOWS_HOST_SCRIPT } from '../host-state/orcad-windows-host-script'
@@ -105,64 +103,6 @@ describe('the managed tunnel over the stdio bridge', () => {
expect(openChannel).toHaveBeenCalledTimes(2)
})
it('starts when the check only lost contact, resolving the bridge again per connection', async () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
const resolveBridge = vi
.fn<() => Promise<OrcadStdioBridge>>()
.mockRejectedValueOnce(new Error('platform probe timed out'))
.mockResolvedValue(bridge)
const forward = await startProvider({ resolveBridge })
const socket = await dial(forward.entry.localPort)
expect(await echoOf(socket, 'after')).toBe('after')
expect(resolveBridge).toHaveBeenCalledTimes(2)
warn.mockRestore()
})
it('drops only the socket when its channel is lost, and keeps listening', async () => {
const channels: PassThrough[] = []
const openChannel = vi.fn(async () => {
const channel = Object.assign(new PassThrough(), { stderr: new PassThrough(), close() {} })
channels.push(channel)
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the provider uses only the stream, `stderr`, `close`, and events.
return channel as unknown as RemoteCommandChannel
})
const forward = await startProvider({ openChannel })
const lost = await dial(forward.entry.localPort)
await vi.waitFor(() => expect(channels).toHaveLength(1))
channels[0].emit('close')
await closedOf(lost)
await dial(forward.entry.localPort)
await vi.waitFor(() => expect(channels).toHaveLength(2))
})
it('caps concurrent bridges under sshd’s session limit and admits the next as one closes', async () => {
const channels: PassThrough[] = []
const openChannel = vi.fn(async () => {
const channel = Object.assign(new PassThrough(), {
stderr: new PassThrough(),
close: () => channel.emit('close')
})
channels.push(channel)
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the provider uses only the stream, `stderr`, `close`, and events.
return channel as unknown as RemoteCommandChannel
})
const forward = await startProvider({ openChannel })
const sockets = await Promise.all(
Array.from({ length: ORCAD_STDIO_BRIDGE_MAX_CHANNELS + 1 }, () =>
dial(forward.entry.localPort)
)
)
await vi.waitFor(() =>
expect(openChannel).toHaveBeenCalledTimes(ORCAD_STDIO_BRIDGE_MAX_CHANNELS)
)
await new Promise((resolve) => setTimeout(resolve, 50))
expect(openChannel).toHaveBeenCalledTimes(ORCAD_STDIO_BRIDGE_MAX_CHANNELS)
sockets[0].destroy()
await vi.waitFor(() =>
expect(openChannel).toHaveBeenCalledTimes(ORCAD_STDIO_BRIDGE_MAX_CHANNELS + 1)
)
})
it('closes every bridge and connection when the tunnel closes', async () => {
const forward = await startProvider()
const socket = await dial(forward.entry.localPort)
@@ -1,76 +0,0 @@
import { describe, expect, it, vi } from 'vitest'
import { createMediaRangeResponse } from './media-range-response'
import { MAX_FILE_RANGE_READ_BYTES } from '../../shared/file-range-read'
function fixture(size = 10) {
return {
size,
read: vi.fn(async (offset: number, length: number) =>
Uint8Array.from({ length }, (_, index) => (offset + index) % 256)
),
close: vi.fn(async () => {})
}
}
describe('media range responses', () => {
it.each([
['bytes=2-5', 'bytes 2-5/10', [2, 3, 4, 5]],
['bytes=7-', 'bytes 7-9/10', [7, 8, 9]],
['bytes=-3', 'bytes 7-9/10', [7, 8, 9]],
['bytes=8-100', 'bytes 8-9/10', [8, 9]]
] as const)('seeks using %s', async (range, contentRange, bytes) => {
const reader = fixture()
const response = await createMediaRangeResponse(
new Request('https://video.test', { headers: { range } }),
'video/mp4',
reader
)
expect(response.status).toBe(206)
expect(response.headers.get('Content-Range')).toBe(contentRange)
expect(response.headers.get('Content-Length')).toBe(String(bytes.length))
expect([...new Uint8Array(await response.arrayBuffer())]).toEqual(bytes)
expect(reader.close).toHaveBeenCalledTimes(1)
})
it('bounds reads when streaming a multi-gigabyte file and releases it on cancellation', async () => {
const reader = fixture(4 * 1024 ** 3)
const response = await createMediaRangeResponse(
new Request('https://video.test'),
'video/mp4',
reader
)
expect(response.headers.get('Content-Length')).toBe(String(reader.size))
const body = response.body?.getReader()
expect((await body?.read())?.value?.byteLength).toBe(MAX_FILE_RANGE_READ_BYTES)
await body?.cancel()
expect(reader.read.mock.calls.every(([, length]) => length <= MAX_FILE_RANGE_READ_BYTES)).toBe(
true
)
expect(reader.close).toHaveBeenCalledTimes(1)
})
it('closes a failed read and reports the stream error', async () => {
const reader = fixture()
reader.read.mockRejectedValue(new Error('Connection dropped'))
const response = await createMediaRangeResponse(
new Request('https://video.test'),
'video/mp4',
reader
)
await expect(response.arrayBuffer()).rejects.toThrow('Connection dropped')
expect(reader.close).toHaveBeenCalledTimes(1)
})
it('releases a file when the request is aborted', async () => {
const controller = new AbortController()
const reader = fixture(4 * 1024 ** 3)
const response = await createMediaRangeResponse(
new Request('https://video.test', { signal: controller.signal }),
'video/mp4',
reader
)
controller.abort()
await expect(response.arrayBuffer()).rejects.toThrow('Media request cancelled')
expect(reader.close).toHaveBeenCalledTimes(1)
})
})
@@ -1,22 +0,0 @@
import { describe, expect, it, vi } from 'vitest'
import { incrementalPrefixReader } from './preview-raster-header-check'
describe('incrementalPrefixReader', () => {
it('reads only the new tail of each wider probe, and nothing past EOF', async () => {
const file = Uint8Array.from({ length: 100 }, (_, index) => index)
const readRange = vi.fn(async (offset: number, length: number) =>
file.subarray(offset, offset + length)
)
const readPrefix = incrementalPrefixReader(readRange)
expect(await readPrefix(10)).toEqual(file.subarray(0, 10))
expect(await readPrefix(64)).toEqual(file.subarray(0, 64))
expect(await readPrefix(1024)).toEqual(file)
expect(await readPrefix(4096)).toEqual(file)
expect(readRange.mock.calls).toEqual([
[0, 10],
[10, 54],
[64, 960]
])
})
})
@@ -1,620 +0,0 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import os from 'node:os'
import type { MemorySnapshotStore } from './collector'
import { setAppEnvironment } from '../../shared/app-environment'
type AppMetricFixture = {
pid: number
type: string
cpu: { percentCPUUsage: number }
memory: { workingSetSize: number }
}
const { appMetricsMock, runProcessMock, execMock, listRegisteredPtysMock } = vi.hoisted(() => ({
appMetricsMock: vi.fn<() => AppMetricFixture[]>(() => []),
runProcessMock: vi.fn(),
execMock: vi.fn(),
listRegisteredPtysMock: vi.fn()
}))
vi.mock('child_process', () => ({
exec: (cmd: string, opts: unknown, cb: (err: Error | null, out: { stdout: string }) => void) =>
execMock(cmd, opts, cb)
}))
// Mock the process wrapper; this suite verifies queries, while wrapper tests cover launch policy.
vi.mock('@orca/process-host', () => ({
runProcess: (spec: { program: string; args?: string[] }) => runProcessMock(spec)
}))
vi.mock('./pty-registry', () => ({
listRegisteredPtys: listRegisteredPtysMock
}))
function appEnvironment() {
return {
getPath: () => process.cwd(),
getAppPath: () => process.cwd(),
getVersion: () => '0.0.0-test',
isPackaged: () => false,
onWillQuit: () => {},
exit: () => {},
getAppMetrics: appMetricsMock
}
}
async function loadCollector() {
vi.resetModules()
const { setAppEnvironment: setResetAppEnvironment } = await import('../../shared/app-environment')
setResetAppEnvironment(appEnvironment())
return await import('./collector')
}
const emptyStore = {
getFolderWorkspace: () => undefined,
getProjectGroups: () => [],
getWorktreeMeta: () => undefined,
getRepo: () => undefined
} satisfies MemorySnapshotStore
describe('collectMemorySnapshot on Windows', () => {
beforeEach(() => {
setAppEnvironment(appEnvironment())
vi.restoreAllMocks()
appMetricsMock.mockReset()
appMetricsMock.mockReturnValue([])
runProcessMock.mockReset()
execMock.mockReset()
listRegisteredPtysMock.mockReset()
listRegisteredPtysMock.mockReturnValue([])
})
function mockPsResponse(stdout: string) {
execMock.mockImplementation((_cmd, _opts, cb) => cb(null, { stdout, stderr: '' }))
runProcessMock.mockImplementation((spec: { program: string }) =>
Promise.resolve({
code: 0,
signal: null,
stdout:
spec.program === 'typeperf.exe'
? psFixtureToTypeperfOutput(stdout)
: psFixtureToWindowsProcessOutput(stdout),
stderr: '',
timedOut: false
})
)
}
function psFixtureToWindowsProcessOutput(stdout: string): string {
return stdout
.split('\n')
.map((line) => line.trim())
.filter(Boolean)
.map((line) => {
const [pid, ppid, _cpu, rssKb] = line.split(/\s+/, 4)
const memory = Number.parseInt(rssKb ?? '', 10)
return [
pid ?? '',
ppid ?? '',
Number.isFinite(memory) && memory > 0 ? memory * 1024 : 0,
'0',
'0',
'1'
].join('\t')
})
.join('\r\n')
}
function psFixtureToTypeperfOutput(stdout: string): string {
const rows = stdout
.split('\n')
.map((line) => line.trim())
.filter(Boolean)
.map((line, index) => {
const [pid, ppid, _cpu, rssKb] = line.split(/\s+/, 4)
const memoryKb = Number.parseInt(rssKb ?? '', 10)
return {
instance: `fixture${index}`,
pid: pid ?? '',
ppid: ppid ?? '',
memory: Number.isFinite(memoryKb) && memoryKb > 0 ? memoryKb * 1024 : 0
}
})
const counterColumns = (counter: string): string[] =>
rows.map((row) => `"\\\\HOST\\Process(${row.instance})\\${counter}"`)
const valueColumns = (field: 'pid' | 'ppid' | 'memory'): string[] =>
rows.map((row) => `"${row[field]}"`)
return [
[
'"(PDH-CSV 4.0)"',
...counterColumns('ID Process'),
...counterColumns('Creating Process ID'),
...counterColumns('Working Set')
].join(','),
['"time"', ...valueColumns('pid'), ...valueColumns('ppid'), ...valueColumns('memory')].join(
','
)
].join('\r\n')
}
it('uses one CIM process for Windows memory and CPU sampling', async () => {
vi.spyOn(os, 'platform').mockReturnValue('win32')
mockPsResponse('10 1 0 1024')
const { collectMemorySnapshot } = await loadCollector()
await collectMemorySnapshot(emptyStore)
expect(execMock).not.toHaveBeenCalled()
expect(runProcessMock).toHaveBeenCalledTimes(1)
const spec = runProcessMock.mock.calls[0][0]
expect(spec.program).toBe('powershell.exe')
expect(spec.args.join(' ')).toContain('Get-CimInstance Win32_Process')
expect(spec.args.join(' ')).toContain('KernelModeTime')
expect(spec.args.join(' ')).toContain('UserModeTime')
expect(spec.args.join(' ')).toContain('CreationDate')
expect(spec).toMatchObject({ maxOutputBytes: 10 * 1024 * 1024, timeoutMs: 5_000 })
})
it('attributes Windows process CPU from cumulative time deltas between sweeps', async () => {
vi.spyOn(os, 'platform').mockReturnValue('win32')
vi.spyOn(performance, 'now').mockReturnValueOnce(1_000).mockReturnValueOnce(3_000)
const cpuOutputs = [
'10\t1\t1048576\t10000000\t0\t638830000000000000',
'10\t1\t1048576\t30000000\t0\t638830000000000000'
]
runProcessMock.mockImplementation(() =>
Promise.resolve({
code: 0,
signal: null,
stdout: cpuOutputs.shift() ?? '',
stderr: '',
timedOut: false
})
)
listRegisteredPtysMock.mockReturnValue([
{
ptyId: 'windows-cpu-pty',
worktreeId: 'repo-1::C:\\repo',
sessionId: 'session-1',
paneKey: 'pane-1',
pid: 10
}
])
const { collectMemorySnapshot } = await loadCollector()
const first = await collectMemorySnapshot(emptyStore)
const second = await collectMemorySnapshot(emptyStore)
expect(first.worktrees[0].sessions[0].cpu).toBe(0)
expect(second.worktrees[0].sessions[0].cpu).toBe(100)
expect(runProcessMock.mock.calls.map(([spec]) => spec.program)).toEqual([
'powershell.exe',
'powershell.exe'
])
})
it('does not attribute prior CPU time after Windows reuses a process id', async () => {
vi.spyOn(os, 'platform').mockReturnValue('win32')
vi.spyOn(performance, 'now').mockReturnValueOnce(1_000).mockReturnValueOnce(3_000)
const cpuOutputs = [
'10\t1\t1048576\t10000000\t0\t638830000000000000',
'10\t1\t1048576\t30000000\t0\t638830000000000001'
]
runProcessMock.mockImplementation(() =>
Promise.resolve({
code: 0,
signal: null,
stdout: cpuOutputs.shift() ?? '',
stderr: '',
timedOut: false
})
)
listRegisteredPtysMock.mockReturnValue([
{
ptyId: 'reused-pid-pty',
worktreeId: 'repo-1::C:\\repo',
sessionId: 'session-1',
paneKey: 'pane-1',
pid: 10
}
])
const { collectMemorySnapshot } = await loadCollector()
await collectMemorySnapshot(emptyStore)
const second = await collectMemorySnapshot(emptyStore)
expect(second.worktrees[0].sessions[0].cpu).toBe(0)
})
it('supports cumulative CPU counters above JavaScript safe integers', async () => {
vi.spyOn(os, 'platform').mockReturnValue('win32')
vi.spyOn(performance, 'now').mockReturnValueOnce(1_000).mockReturnValueOnce(3_000)
const cpuOutputs = [
'10\t1\t1048576\t90071992547409920\t0\t638830000000000000',
'10\t1\t1048576\t90071992567409920\t0\t638830000000000000'
]
runProcessMock.mockImplementation(() =>
Promise.resolve({
code: 0,
signal: null,
stdout: cpuOutputs.shift() ?? '',
stderr: '',
timedOut: false
})
)
listRegisteredPtysMock.mockReturnValue([
{
ptyId: 'large-counter-pty',
worktreeId: 'repo-1::C:\\repo',
sessionId: 'session-1',
paneKey: 'pane-1',
pid: 10
}
])
const { collectMemorySnapshot } = await loadCollector()
await collectMemorySnapshot(emptyStore)
const second = await collectMemorySnapshot(emptyStore)
expect(second.worktrees[0].sessions[0].cpu).toBe(100)
})
it('keeps the older CPU baseline when forced snapshots are too close together', async () => {
vi.spyOn(os, 'platform').mockReturnValue('win32')
vi.spyOn(performance, 'now')
.mockReturnValueOnce(1_000)
.mockReturnValueOnce(1_100)
.mockReturnValueOnce(3_000)
const cpuOutputs = [
'10\t1\t1048576\t0\t0\t638830000000000000',
'10\t1\t1048576\t1000000\t0\t638830000000000000',
'10\t1\t1048576\t20000000\t0\t638830000000000000'
]
runProcessMock.mockImplementation(() =>
Promise.resolve({
code: 0,
signal: null,
stdout: cpuOutputs.shift() ?? '',
stderr: '',
timedOut: false
})
)
listRegisteredPtysMock.mockReturnValue([
{
ptyId: 'short-sample-pty',
worktreeId: 'repo-1::C:\\repo',
sessionId: 'session-1',
paneKey: 'pane-1',
pid: 10
}
])
const { collectMemorySnapshot } = await loadCollector()
await collectMemorySnapshot(emptyStore)
const tooSoon = await collectMemorySnapshot(emptyStore)
const normalPoll = await collectMemorySnapshot(emptyStore)
expect(tooSoon.worktrees[0].sessions[0].cpu).toBe(0)
expect(normalPoll.worktrees[0].sessions[0].cpu).toBe(100)
})
it('caps impossible Windows CPU deltas at the host core capacity', async () => {
vi.spyOn(os, 'platform').mockReturnValue('win32')
vi.spyOn(os, 'cpus').mockReturnValue([{}, {}] as ReturnType<typeof os.cpus>)
vi.spyOn(performance, 'now').mockReturnValueOnce(1_000).mockReturnValueOnce(3_000)
const cpuOutputs = [
'10\t1\t1048576\t0\t0\t638830000000000000',
'10\t1\t1048576\t1000000000\t0\t638830000000000000'
]
runProcessMock.mockImplementation(() =>
Promise.resolve({
code: 0,
signal: null,
stdout: cpuOutputs.shift() ?? '',
stderr: '',
timedOut: false
})
)
listRegisteredPtysMock.mockReturnValue([
{
ptyId: 'impossible-cpu-pty',
worktreeId: 'repo-1::C:\\repo',
sessionId: 'session-1',
paneKey: 'pane-1',
pid: 10
}
])
const { collectMemorySnapshot } = await loadCollector()
await collectMemorySnapshot(emptyStore)
const capped = await collectMemorySnapshot(emptyStore)
expect(capped.worktrees[0].sessions[0].cpu).toBe(200)
})
it('warms CPU sampling again after Resource Manager was closed', async () => {
vi.spyOn(os, 'platform').mockReturnValue('win32')
vi.spyOn(performance, 'now').mockReturnValueOnce(1_000).mockReturnValueOnce(12_000)
const cpuOutputs = [
'10\t1\t1048576\t0\t0\t638830000000000000',
'10\t1\t1048576\t100000000\t0\t638830000000000000'
]
runProcessMock.mockImplementation(() =>
Promise.resolve({
code: 0,
signal: null,
stdout: cpuOutputs.shift() ?? '',
stderr: '',
timedOut: false
})
)
listRegisteredPtysMock.mockReturnValue([
{
ptyId: 'stale-counter-pty',
worktreeId: 'repo-1::C:\\repo',
sessionId: 'session-1',
paneKey: 'pane-1',
pid: 10
}
])
const { collectMemorySnapshot } = await loadCollector()
await collectMemorySnapshot(emptyStore)
const reopened = await collectMemorySnapshot(emptyStore)
expect(reopened.worktrees[0].sessions[0].cpu).toBe(0)
})
it('preserves Windows process memory when CPU counters are unavailable', async () => {
vi.spyOn(os, 'platform').mockReturnValue('win32')
runProcessMock.mockImplementation(() =>
Promise.resolve({
code: 0,
signal: null,
stdout: '10\t1\t1048576\t\t\t638830000000000000',
stderr: '',
timedOut: false
})
)
listRegisteredPtysMock.mockReturnValue([
{
ptyId: 'cpu-failure-pty',
worktreeId: 'repo-1::C:\\repo',
sessionId: 'session-1',
paneKey: 'pane-1',
pid: 10
}
])
const { collectMemorySnapshot } = await loadCollector()
const snapshot = await collectMemorySnapshot(emptyStore)
expect(snapshot.worktrees[0].sessions[0]).toMatchObject({ cpu: 0, memory: 1024 * 1024 })
})
it('uses Typeperf during the CIM retry cooldown', async () => {
vi.spyOn(os, 'platform').mockReturnValue('win32')
runProcessMock.mockImplementation((spec: { program: string }) =>
Promise.resolve(
spec.program === 'powershell.exe'
? { code: 1, signal: null, stdout: '', stderr: 'CIM unavailable', timedOut: false }
: {
code: 0,
signal: null,
stdout: psFixtureToTypeperfOutput('10 1 0 1024'),
stderr: '',
timedOut: false
}
)
)
listRegisteredPtysMock.mockReturnValue([
{
ptyId: 'cim-pty',
worktreeId: null,
sessionId: null,
paneKey: null,
pid: 10
}
])
const { collectMemorySnapshot } = await loadCollector()
const first = await collectMemorySnapshot(emptyStore)
const second = await collectMemorySnapshot(emptyStore)
expect(runProcessMock).toHaveBeenCalledTimes(3)
expect(runProcessMock.mock.calls.map(([spec]) => spec.program)).toEqual([
'powershell.exe',
'typeperf.exe',
'typeperf.exe'
])
expect(runProcessMock.mock.calls[1][0]).toMatchObject({ timeoutMs: 5_000 })
expect(first.worktrees[0].memory).toBe(1048576)
expect(second.worktrees[0].memory).toBe(1048576)
})
it('retries CIM after fallback and warms CPU sampling before restoring deltas', async () => {
vi.spyOn(os, 'platform').mockReturnValue('win32')
vi.spyOn(performance, 'now')
.mockReturnValueOnce(1_000)
.mockReturnValueOnce(2_000)
.mockReturnValueOnce(31_001)
.mockReturnValueOnce(32_000)
.mockReturnValueOnce(34_000)
const cimOutputs = [
'10\t1\t1048576\t10000000\t0\t638830000000000000',
'10\t1\t1048576\t30000000\t0\t638830000000000000'
]
let cimCalls = 0
runProcessMock.mockImplementation((spec: { program: string }) => {
if (spec.program === 'typeperf.exe') {
return Promise.resolve({
code: 0,
signal: null,
stdout: psFixtureToTypeperfOutput('10 1 0 1024'),
stderr: '',
timedOut: false
})
}
cimCalls += 1
return Promise.resolve(
cimCalls === 1
? { code: 1, signal: null, stdout: '', stderr: 'transient CIM failure', timedOut: false }
: {
code: 0,
signal: null,
stdout: cimOutputs.shift() ?? '',
stderr: '',
timedOut: false
}
)
})
listRegisteredPtysMock.mockReturnValue([
{
ptyId: 'recovering-cim-pty',
worktreeId: 'repo-1::C:\\repo',
sessionId: 'session-1',
paneKey: 'pane-1',
pid: 10
}
])
const { collectMemorySnapshot } = await loadCollector()
await collectMemorySnapshot(emptyStore)
await collectMemorySnapshot(emptyStore)
const warming = await collectMemorySnapshot(emptyStore)
const recovered = await collectMemorySnapshot(emptyStore)
expect(runProcessMock.mock.calls.map(([spec]) => spec.program)).toEqual([
'powershell.exe',
'typeperf.exe',
'typeperf.exe',
'powershell.exe',
'powershell.exe'
])
expect(warming.worktrees[0].sessions[0].cpu).toBe(0)
expect(recovered.worktrees[0].sessions[0].cpu).toBe(100)
})
it('sums committed private bytes across the whole PTY subtree on Windows', async () => {
vi.spyOn(os, 'platform').mockReturnValue('win32')
const rows = [
'10\t1\t52428800\t0\t0\t638830000000000000\t1048576',
'11\t10\t104857600\t0\t0\t638830000000000000\t2097152',
'12\t11\t52428800\t0\t0\t638830000000000000\t524288',
'900\t1\t20971520\t0\t0\t638830000000000000\t262144'
].join('\r\n')
runProcessMock.mockResolvedValue({
code: 0,
signal: null,
stdout: rows,
stderr: '',
timedOut: false
})
appMetricsMock.mockReturnValue([
{ pid: 900, type: 'Browser', cpu: { percentCPUUsage: 0 }, memory: { workingSetSize: 0 } }
])
listRegisteredPtysMock.mockReturnValue([
{
ptyId: 'pty-1',
worktreeId: 'repo-1::C:\\repo',
sessionId: 'session-1',
paneKey: 'pane-1',
pid: 10
}
])
const { collectMemorySnapshot } = await loadCollector()
const snapshot = await collectMemorySnapshot(emptyStore)
const committedKb = 1048576 + 2097152 + 524288
expect(snapshot.worktrees[0].sessions[0].privateMemory).toBe(committedKb * 1024)
expect(snapshot.worktrees[0].privateMemory).toBe(committedKb * 1024)
expect(snapshot.app.privateMemory).toBe(262144 * 1024)
expect(snapshot.totalPrivateMemory).toBe((committedKb + 262144) * 1024)
expect(snapshot.processCommitMetric).toBe('private-bytes')
expect(snapshot.processMemoryMetric).toBe('working-set')
expect(snapshot.worktrees[0].memory).toBe(52428800 + 104857600 + 52428800)
})
it('omits the commit metric entirely when the Windows sweep cannot report it', async () => {
vi.spyOn(os, 'platform').mockReturnValue('win32')
runProcessMock.mockResolvedValue({
code: 0,
signal: null,
stdout: '10\t1\t52428800\t0\t0\t638830000000000000',
stderr: '',
timedOut: false
})
listRegisteredPtysMock.mockReturnValue([
{
ptyId: 'pty-1',
worktreeId: 'repo-1::C:\\repo',
sessionId: 'session-1',
paneKey: 'pane-1',
pid: 10
}
])
const { collectMemorySnapshot } = await loadCollector()
const snapshot = await collectMemorySnapshot(emptyStore)
// Missing commit measurements must remain distinguishable from measured zero.
expect(snapshot.processCommitMetric).toBeUndefined()
expect(snapshot.totalPrivateMemory).toBeUndefined()
expect(snapshot.worktrees[0].privateMemory).toBeUndefined()
expect(snapshot.worktrees[0].sessions[0].privateMemory).toBeUndefined()
expect(snapshot.app.privateMemory).toBeUndefined()
expect(snapshot.totalMemory).toBe(52428800)
})
it.each(['darwin', 'linux'] as const)(
'reports RSS without commit values on %s',
async (platform) => {
vi.spyOn(os, 'platform').mockReturnValue(platform)
mockPsResponse('10 1 0 1024')
listRegisteredPtysMock.mockReturnValue([
{
ptyId: 'pty-1',
worktreeId: 'repo-1::/repo',
sessionId: 'session-1',
paneKey: 'pane-1',
pid: 10
}
])
const { collectMemorySnapshot } = await loadCollector()
const snapshot = await collectMemorySnapshot(emptyStore)
expect(snapshot.processMemoryMetric).toBe('rss')
expect(snapshot.processCommitMetric).toBeUndefined()
expect(snapshot.totalPrivateMemory).toBeUndefined()
expect(snapshot.worktrees[0].sessions[0].privateMemory).toBeUndefined()
}
)
it("attributes a shared ancestor's commit to one PTY only", async () => {
vi.spyOn(os, 'platform').mockReturnValue('win32')
runProcessMock.mockResolvedValue({
code: 0,
signal: null,
stdout: [
'10\t1\t1024\t0\t0\t638830000000000000\t1024',
'11\t10\t1024\t0\t0\t638830000000000000\t2048'
].join('\r\n'),
stderr: '',
timedOut: false
})
listRegisteredPtysMock.mockReturnValue([
{ ptyId: 'a', worktreeId: 'repo::C:\\a', sessionId: 's-a', paneKey: null, pid: 10 },
{ ptyId: 'b', worktreeId: 'repo::C:\\b', sessionId: 's-b', paneKey: null, pid: 11 }
])
const { collectMemorySnapshot } = await loadCollector()
const snapshot = await collectMemorySnapshot(emptyStore)
expect(snapshot.worktrees[0].sessions[0].privateMemory).toBe((1024 + 2048) * 1024)
expect(snapshot.worktrees[1].sessions[0].privateMemory).toBe(0)
expect(snapshot.totalPrivateMemory).toBe((1024 + 2048) * 1024)
})
})
@@ -1,45 +0,0 @@
import { afterEach, describe, expect, it } from 'vitest'
import {
claimHostMallocTunables,
HOST_MALLOC_TUNABLES,
hostMallocTunablesDaemonEnv,
hostMallocTunablesLaunchEnv,
resetHostMallocTunablesForTests
} from './host-malloc-tunables'
afterEach(() => resetHostMallocTunablesForTests())
describe('host malloc tunables', () => {
it('removes what the launch set from this process, and keeps it only for the daemon', () => {
const env: NodeJS.ProcessEnv = {
PATH: '/bin',
...Object.fromEntries(hostMallocTunablesLaunchEnv())
}
claimHostMallocTunables(env)
expect(env).toEqual({ PATH: '/bin' })
expect(hostMallocTunablesDaemonEnv()).toEqual(Object.fromEntries(hostMallocTunablesLaunchEnv()))
})
it('never touches tunables the user set', () => {
const env: NodeJS.ProcessEnv = { GLIBC_TUNABLES: 'glibc.malloc.arena_max=2' }
claimHostMallocTunables(env)
expect(env).toEqual({ GLIBC_TUNABLES: 'glibc.malloc.arena_max=2' })
expect(hostMallocTunablesDaemonEnv()).toEqual({})
})
it('keeps a user value that replaced the launch’s, dropping only the marker', () => {
const env: NodeJS.ProcessEnv = {
...Object.fromEntries(hostMallocTunablesLaunchEnv()),
GLIBC_TUNABLES: 'glibc.malloc.arena_max=2'
}
claimHostMallocTunables(env)
expect(env).toEqual({ GLIBC_TUNABLES: 'glibc.malloc.arena_max=2' })
expect(hostMallocTunablesDaemonEnv()).toEqual({})
})
it('gives the daemon nothing in a process the launch did not tune', () => {
claimHostMallocTunables({})
expect(hostMallocTunablesDaemonEnv()).toEqual({})
expect(HOST_MALLOC_TUNABLES).toContain('mmap_threshold')
})
})
-12
View File
@@ -54,18 +54,6 @@ describe('MimoCodeHookService buildPtyEnv', () => {
rmSync(userConfigDir, { recursive: true, force: true })
})
// MIMOCODE_HOME also moves auth.json, sessions and state (#9132); only add a config layer.
it('sets only MIMOCODE_CONFIG_DIR to a directory holding just the Orca plugin', () => {
const env = new MimoCodeHookService().buildPtyEnv('pty-1')
const overlay = join(userDataDir, 'mimocode-hooks', 'config')
expect(env).toEqual({ MIMOCODE_CONFIG_DIR: overlay })
expect(readdirSync(overlay)).toEqual(['plugins'])
const pluginSource = readFileSync(join(overlay, 'plugins', 'orca-mimocode-status.js'), 'utf8')
expect(pluginSource).toContain('/hook/mimo-code')
expect(pluginSource).not.toContain('post("SessionStart"')
})
it("layers the user's own MIMOCODE_CONFIG_DIR into the overlay without touching it", () => {
const env = new MimoCodeHookService().buildPtyEnv('pty-1', userConfigDir)
@@ -1,152 +0,0 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type * as MiniMaxCookieStore from './minimax-cookie-store'
import { setSecretStore } from '../../shared/secret-store'
const safeStorageMock = vi.hoisted(() => ({
isEncryptionAvailable: vi.fn(() => true),
encryptString: vi.fn((value: string) => Buffer.from(value)),
decryptString: vi.fn((value: Buffer) => value.toString('utf8'))
}))
const existsSyncMock = vi.fn()
const readFileSyncMock = vi.fn()
const rmSyncMock = vi.fn()
const hardenExistingSecureFileMock = vi.fn()
const writeSecureFileMock = vi.fn()
const homedirMock = vi.fn(() => '/home/test')
vi.mock('node:fs', () => ({
existsSync: existsSyncMock,
readFileSync: readFileSyncMock,
rmSync: rmSyncMock
}))
vi.mock('node:os', () => ({
homedir: homedirMock
}))
vi.mock('node:path', () => ({
join: (...parts: string[]) => parts.join('/')
}))
vi.mock('../../shared/secure-file', () => ({
hardenExistingSecureFile: hardenExistingSecureFileMock,
writeSecureFile: writeSecureFileMock
}))
const storePath = '/home/test/.orca/minimax-session-cookie.enc'
const envelope = (kind: 'encrypted' | 'plaintext', value: string): string =>
`orca-minimax-cookie:v1:${kind}:${Buffer.from(value, 'utf8').toString('base64')}`
async function loadStore(): Promise<typeof MiniMaxCookieStore> {
return await import('./minimax-cookie-store')
}
describe('minimax-cookie-store', () => {
beforeEach(() => {
existsSyncMock.mockReset()
readFileSyncMock.mockReset()
rmSyncMock.mockReset()
hardenExistingSecureFileMock.mockReset()
writeSecureFileMock.mockReset()
safeStorageMock.isEncryptionAvailable.mockReset()
safeStorageMock.encryptString.mockReset()
safeStorageMock.decryptString.mockReset()
safeStorageMock.isEncryptionAvailable.mockReturnValue(true)
safeStorageMock.encryptString.mockImplementation((value: string) => Buffer.from(value))
safeStorageMock.decryptString.mockImplementation((value: Buffer) => value.toString('utf8'))
setSecretStore({ ...safeStorageMock, describeProtectionGap: () => null })
})
afterEach(() => {
vi.resetModules()
})
it('reads decrypted cookie from disk and caches it', async () => {
existsSyncMock.mockReturnValue(true)
readFileSyncMock.mockReturnValue(Buffer.from(envelope('encrypted', 'encrypted-payload')))
safeStorageMock.decryptString.mockReturnValue('_token=cached; minimax_group_id_v2=9')
const store = await loadStore()
const first = store.readMiniMaxSessionCookie()
const second = store.readMiniMaxSessionCookie()
expect(first).toBe('_token=cached; minimax_group_id_v2=9')
expect(second).toBe(first)
expect(hardenExistingSecureFileMock).toHaveBeenCalledTimes(1)
expect(hardenExistingSecureFileMock).toHaveBeenCalledWith(storePath)
expect(safeStorageMock.decryptString).toHaveBeenCalledTimes(1)
expect(safeStorageMock.decryptString).toHaveBeenCalledWith(Buffer.from('encrypted-payload'))
})
it('returns enveloped plaintext when safeStorage is unavailable and reads succeed', async () => {
safeStorageMock.isEncryptionAvailable.mockReturnValue(false)
existsSyncMock.mockReturnValue(true)
readFileSyncMock.mockReturnValue(Buffer.from(envelope('plaintext', '_token=plaintext')))
const store = await loadStore()
expect(store.readMiniMaxSessionCookie()).toBe('_token=plaintext')
})
it('reads legacy plaintext cookies when decrypting is unavailable', async () => {
safeStorageMock.isEncryptionAvailable.mockReturnValue(false)
existsSyncMock.mockReturnValue(true)
readFileSyncMock.mockReturnValue(Buffer.from('_token=legacy'))
const store = await loadStore()
expect(store.readMiniMaxSessionCookie()).toBe('_token=legacy')
})
it('reads legacy plaintext cookies when decrypting fails', async () => {
existsSyncMock.mockReturnValue(true)
readFileSyncMock.mockReturnValue(Buffer.from('_token=legacy'))
safeStorageMock.decryptString.mockImplementation(() => {
throw new Error('boom')
})
const store = await loadStore()
expect(store.readMiniMaxSessionCookie()).toBe('_token=legacy')
})
it('does not treat encrypted legacy bytes as plaintext when safeStorage is unavailable', async () => {
safeStorageMock.isEncryptionAvailable.mockReturnValue(false)
existsSyncMock.mockReturnValue(true)
readFileSyncMock.mockReturnValue(Buffer.from('encrypted-payload'))
const store = await loadStore()
expect(() => store.readMiniMaxSessionCookie()).toThrow(/could not be decrypted/)
})
it('throws for encrypted envelopes when safeStorage is unavailable', async () => {
safeStorageMock.isEncryptionAvailable.mockReturnValue(false)
existsSyncMock.mockReturnValue(true)
readFileSyncMock.mockReturnValue(Buffer.from(envelope('encrypted', 'encrypted-payload')))
const store = await loadStore()
expect(() => store.readMiniMaxSessionCookie()).toThrow(/could not be decrypted/)
})
it('throws when decryption fails', async () => {
existsSyncMock.mockReturnValue(true)
readFileSyncMock.mockReturnValue(Buffer.from(envelope('encrypted', 'encrypted-payload')))
safeStorageMock.decryptString.mockImplementation(() => {
throw new Error('boom')
})
const store = await loadStore()
expect(() => store.readMiniMaxSessionCookie()).toThrow(/could not be decrypted/)
})
it('clears the cached cookie and removes the file', async () => {
existsSyncMock.mockReturnValueOnce(true)
readFileSyncMock.mockReturnValueOnce(Buffer.from(envelope('encrypted', 'encrypted-payload')))
safeStorageMock.decryptString.mockReturnValueOnce('_token=preclear')
const store = await loadStore()
expect(store.readMiniMaxSessionCookie()).toBe('_token=preclear')
store.clearMiniMaxSessionCookie()
expect(rmSyncMock).toHaveBeenCalledWith(storePath, { force: true })
expect(store.readMiniMaxSessionCookie()).toBeNull()
})
describe('getMiniMaxSessionCookieProtection', () => {
it('does not decrypt, so opening Settings cannot trigger a keychain prompt', async () => {
existsSyncMock.mockReturnValue(true)
readFileSyncMock.mockReturnValue(Buffer.from(envelope('encrypted', 'sessionId=abc123')))
const store = await loadStore()
store.getMiniMaxSessionCookieProtection()
expect(safeStorageMock.decryptString).not.toHaveBeenCalled()
})
})
})
-42
View File
@@ -1,42 +0,0 @@
import { describe, expect, it } from 'vitest'
import {
MUSE_MANAGED_HOOK_ENV_VARS,
parseMuseSettingsText,
serializeMuseSettings
} from './hook-config-json'
describe('muse hook-config-json', () => {
it('sets the pointer while preserving user keys and formatting', () => {
const original = '{\n "schema_version": 1,\n "model": "muse-spark-1.2"\n}\n'
const next = serializeMuseSettings(original, '/x/muse-hooks.json')
expect(next).toContain('"model": "muse-spark-1.2"')
expect(JSON.parse(next)).toMatchObject({
schema_version: 1,
managed_hooks_path: '/x/muse-hooks.json'
})
})
it('removes the pointer on remove while keeping user keys', () => {
const original =
'{\n "schema_version": 1,\n "managed_hooks_path": "/x/muse-hooks.json",\n "model": "muse-spark-1.2"\n}\n'
const next = serializeMuseSettings(original, undefined)
const parsed = parseMuseSettingsText(next, 'test')
expect(parsed?.managed_hooks_path).toBeUndefined()
expect(parsed?.model).toBe('muse-spark-1.2')
expect(parsed?.schema_version).toBe(1)
})
it('leaves already-converged text untouched', () => {
const original = JSON.stringify({
schema_version: 1,
managed_hooks_path: '/x/muse-hooks.json',
managed_hooks_env_vars: MUSE_MANAGED_HOOK_ENV_VARS
})
expect(serializeMuseSettings(original, '/x/muse-hooks.json')).toBe(original)
})
it('rejects malformed settings text', () => {
expect(parseMuseSettingsText('{oops', 'test')).toBeNull()
expect(parseMuseSettingsText('[1,2]', 'test')).toBeNull()
})
})
+1 -132
View File
@@ -1,12 +1,9 @@
import { mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs'
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { parseMuseSettingsText } from './hook-config-json'
import { MuseHookService } from './hook-service'
import { MUSE_HOOK_EVENTS, buildMuseManagedHooksFile, getMuseManagedCommand } from './hook-settings'
import { wrapWindowsHookCommand } from '../agent-hooks/windows-hook-command'
import { createAgentHookMemorySftp } from '../agent-hooks/agent-hook-memory-sftp.test-fixture'
// Isolate managed scripts and Muse settings from real HOME/XDG configuration.
let home: string
@@ -45,62 +42,8 @@ afterEach(() => {
})
const configPath = (): string => join(home, '.config', 'muse', 'settings.json')
const managedHooksPath = (): string => join(home, '.orca', 'agent-hooks', 'muse-hooks.json')
const scriptPath = (): string =>
join(
home,
'.orca',
'agent-hooks',
process.platform === 'win32' ? 'muse-hook.cmd' : 'muse-hook.sh'
)
describe('MuseHookService', () => {
it('installs the managed hooks pointer, file, and script', () => {
const status = new MuseHookService().install()
expect(status.state).toBe('installed')
expect(status.managedHooksPresent).toBe(true)
const settings = parseMuseSettingsText(readFileSync(configPath(), 'utf-8'), 'test')
expect(settings?.managed_hooks_path).toBe(managedHooksPath())
expect(settings?.schema_version).toBe(1)
expect(settings?.managed_hooks_env_vars).toContain('ORCA_PANE_KEY')
const managedText = readFileSync(managedHooksPath(), 'utf-8')
expect(managedText).toContain(process.platform === 'win32' ? 'muse-hook.cmd' : 'muse-hook.sh')
expect(MUSE_HOOK_EVENTS.every((event) => managedText.includes(`"${event}"`))).toBe(true)
const script = readFileSync(scriptPath(), 'utf-8')
expect(script).toContain('/hook/muse')
// Stdin payloads avoid oversized command-line EDR signals.
expect(script).toContain(
process.platform === 'win32'
? 'payload@-'
: 'printf \'%s\' "$payload" | LC_ALL= LC_NUMERIC=C curl'
)
})
it('keeps user settings when installing, then drops only the pointer on remove', () => {
mkdirSync(join(home, '.config', 'muse'), { recursive: true })
const userSettings = `{\n "schema_version": 1,\n "model": "muse-spark-1.2",\n "approval_mode": "never"\n}\n`
writeFileSync(configPath(), userSettings)
const service = new MuseHookService()
expect(service.install().state).toBe('installed')
const installed = readFileSync(configPath(), 'utf-8')
expect(installed).toContain('"model": "muse-spark-1.2"')
expect(installed).toContain('"approval_mode": "never"')
service.install()
const reinstalled = readFileSync(configPath(), 'utf-8')
expect((reinstalled.match(/managed_hooks_path/g) ?? []).length).toBe(1)
const removed = service.remove()
expect(removed.state).toBe('not_installed')
const afterRemove = parseMuseSettingsText(readFileSync(configPath(), 'utf-8'), 'test')
expect(afterRemove?.managed_hooks_path).toBeUndefined()
expect(afterRemove?.model).toBe('muse-spark-1.2')
})
it('does not overwrite a user-managed hooks pointer during install', () => {
mkdirSync(join(home, '.config', 'muse'), { recursive: true })
const userPath = '/user-owned/muse-hooks.json'
@@ -112,78 +55,4 @@ describe('MuseHookService', () => {
parseMuseSettingsText(readFileSync(configPath(), 'utf-8'), 'test')?.managed_hooks_path
).toBe(userPath)
})
it('treats malformed managed hook entries as absent instead of throwing', () => {
mkdirSync(join(home, '.config', 'muse'), { recursive: true })
mkdirSync(join(home, '.orca', 'agent-hooks'), { recursive: true })
const managedPath = join(home, '.orca', 'agent-hooks', 'muse-hooks.json')
writeFileSync(configPath(), JSON.stringify({ schema_version: 1 }))
const service = new MuseHookService()
expect(service.install().state).toBe('installed')
const damaged = parseMuseSettingsText(readFileSync(managedPath, 'utf-8'), 'test')
expect(damaged).not.toBeNull()
if (!damaged) {
throw new Error('expected generated Muse hooks')
}
damaged.hooks = {
...(typeof damaged.hooks === 'object' && damaged.hooks !== null ? damaged.hooks : {}),
UserPromptSubmit: [null, { hooks: 'not-an-array' }, { hooks: [null, { command: 42 }] }]
}
writeFileSync(managedPath, JSON.stringify(damaged))
expect(() => service.getStatus()).not.toThrow()
expect(service.getStatus().state).toBe('partial')
})
it.skipIf(process.platform !== 'win32')(
'upgrades encoded registrations once during refresh',
async () => {
const service = new MuseHookService()
service.install()
const settings = readFileSync(configPath(), 'utf8')
writeFileSync(
managedHooksPath(),
buildMuseManagedHooksFile(wrapWindowsHookCommand(scriptPath()))
)
await service.refreshManagedScripts()
const upgraded = readFileSync(managedHooksPath(), 'utf8')
expect(upgraded).toBe(buildMuseManagedHooksFile(getMuseManagedCommand(scriptPath())))
expect(upgraded).toContain(getMuseManagedCommand(scriptPath()))
const modified = statSync(managedHooksPath()).mtimeMs
await service.refreshManagedScripts()
expect(readFileSync(managedHooksPath(), 'utf8')).toBe(upgraded)
expect(statSync(managedHooksPath()).mtimeMs).toBe(modified)
expect(readFileSync(configPath(), 'utf8')).toBe(settings)
expect(service.getStatus().state).toBe('installed')
}
)
it('refresh never registers absent hooks or replaces a user-owned pointer', async () => {
const service = new MuseHookService()
await service.refreshManagedScripts()
expect(service.getStatus().state).toBe('not_installed')
service.install()
const managed = readFileSync(managedHooksPath(), 'utf8')
writeFileSync(configPath(), JSON.stringify({ managed_hooks_path: '/user/hooks.json' }))
await service.refreshManagedScripts()
expect(readFileSync(managedHooksPath(), 'utf8')).toBe(managed)
expect(
parseMuseSettingsText(readFileSync(configPath(), 'utf8'), 'test')?.managed_hooks_path
).toBe('/user/hooks.json')
})
it('installs POSIX hooks on the SSH host even from a Windows client', async () => {
vi.spyOn(process, 'platform', 'get').mockReturnValue('win32')
const remote = createAgentHookMemorySftp()
const service = new MuseHookService()
expect((await service.installRemote(remote.sftp, '/home/alice smith')).state).toBe('installed')
const remoteHooksPath = '/home/alice smith/.orca/agent-hooks/muse-hooks.json'
const installed = remote.fs.files.get(remoteHooksPath)
expect(installed).toContain('/home/alice smith/.orca/agent-hooks/muse-hook.sh')
expect(installed).not.toMatch(/powershell|EncodedCommand|muse-hook.cmd/)
expect(remote.fs.files.get('/home/alice smith/.orca/agent-hooks/muse-hook.sh')).toContain(
'#!/bin/sh'
)
await service.installRemote(remote.sftp, '/home/alice smith')
expect(remote.fs.files.get(remoteHooksPath)).toBe(installed)
})
})
@@ -4,7 +4,6 @@ import {
messageText,
openProviderTimelineRig,
providerItemId,
providerTurnId,
providerTurnItemId,
runningTool
} from './provider-timeline-assembler-test-support'
@@ -12,24 +11,6 @@ import {
afterEach(closeProviderTimelineRigs)
describe("an earlier turn's late end leaves the open turn alone", () => {
it("keeps the open turn's activity line", async () => {
const rig = await openProviderTimelineRig()
const activity: unknown[] = []
const assembler = rig.assemble({
sink: { ...rig.sink, setActivity: (each) => activity.push(each) }
})
assembler.apply({ type: 'turn.open', turn: 't1', at: 1_000 })
assembler.apply({ type: 'turn.end', turn: 't1', at: 1_500, state: 'completed' })
assembler.apply({ type: 'turn.open', turn: 't2', at: 2_000 })
assembler.apply({ type: 'activity', text: 'Thinking' })
activity.length = 0
expect(
assembler.apply({ type: 'turn.end', turn: 't1', at: 2_100, state: 'completed' })
).toEqual({ admission: { accepted: true } })
expect(activity).toEqual([])
expect(assembler.openTurnId).toBe(providerTurnId('t2'))
})
it("keeps the open turn's anonymous reply one message", async () => {
const rig = await openProviderTimelineRig()
rig.assembler.apply({ type: 'turn.open', turn: 't1', at: 1_000 })
@@ -1,123 +0,0 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type * as NodeFsPromisesModule from 'node:fs/promises'
const UBUNTU_HOME = '\\\\wsl.localhost\\Ubuntu\\home\\ada'
const firstGuestPath = '/home/ada/.codex/sessions/first.jsonl'
const secondGuestPath = '/home/ada/.codex/sessions/second.jsonl'
const firstUncPath = '\\\\wsl.localhost\\Ubuntu\\home\\ada\\.codex\\sessions\\first.jsonl'
const secondUncPath = '\\\\wsl.localhost\\Ubuntu\\home\\ada\\.codex\\sessions\\second.jsonl'
const fsMocks = vi.hoisted(() => ({
access: vi.fn<(path: string) => Promise<void>>()
}))
vi.mock('node:fs/promises', async (importOriginal) => ({
...(await importOriginal<typeof NodeFsPromisesModule>()),
access: fsMocks.access
}))
import {
resetHostReadableTranscriptPathCacheForTests,
toHostReadableTranscriptPath
} from './host-readable-transcript-path'
import { WSL_TRANSCRIPT_FS_EXACT_TIMEOUT_MS } from './wsl-transcript-fs-gate'
type AccessControl = {
resolve: () => void
reject: (error: Error) => void
}
function wslDeps(): {
platform: NodeJS.Platform
listWslHomeDirs: () => Promise<string[]>
} {
return { platform: 'win32', listWslHomeDirs: async () => [UBUNTU_HOME] }
}
beforeEach(() => {
resetHostReadableTranscriptPathCacheForTests()
fsMocks.access.mockReset()
})
describe('WSL transcript filesystem gate', () => {
it('serializes distinct probes and releases the permit after failure', async () => {
const controls: AccessControl[] = []
fsMocks.access.mockImplementation(
() =>
new Promise<void>((resolve, reject) => {
controls.push({ resolve: () => resolve(), reject })
})
)
const first = toHostReadableTranscriptPath(firstGuestPath, wslDeps())
const second = toHostReadableTranscriptPath(secondGuestPath, wslDeps())
await vi.waitFor(() => expect(fsMocks.access).toHaveBeenCalledTimes(1))
controls[0].reject(new Error('stopped distro'))
await expect(first).resolves.toBeNull()
await vi.waitFor(() => expect(fsMocks.access).toHaveBeenCalledTimes(2))
controls[1].resolve()
await expect(second).resolves.toBe(secondUncPath)
})
it('deduplicates identical in-flight probes', async () => {
let release: (() => void) | undefined
fsMocks.access.mockImplementation(
() =>
new Promise<void>((resolve) => {
release = resolve
})
)
const first = toHostReadableTranscriptPath(firstGuestPath, wslDeps())
const duplicate = toHostReadableTranscriptPath(firstGuestPath, wslDeps())
await vi.waitFor(() => expect(fsMocks.access).toHaveBeenCalledTimes(1))
release?.()
await expect(Promise.all([first, duplicate])).resolves.toEqual([firstUncPath, firstUncPath])
})
it('does not queue local filesystem work behind a stalled WSL probe', async () => {
let releaseWsl: (() => void) | undefined
fsMocks.access.mockImplementation((path) =>
path === firstUncPath
? new Promise<void>((resolve) => {
releaseWsl = resolve
})
: Promise.resolve()
)
const wslProbe = toHostReadableTranscriptPath(firstGuestPath, wslDeps())
await vi.waitFor(() => expect(fsMocks.access).toHaveBeenCalledWith(firstUncPath))
const localPath = import.meta.filename
await expect(
toHostReadableTranscriptPath(localPath, { platform: process.platform })
).resolves.toBe(localPath)
expect(fsMocks.access).toHaveBeenCalledTimes(1)
releaseWsl?.()
await expect(wslProbe).resolves.toBe(firstUncPath)
})
// Last: the never-settling probe retains its gate permit for this module’s lifetime.
it('falls through to the next distro when a probe exceeds the gate deadline', async () => {
vi.useFakeTimers()
const DEBIAN_HOME = '\\\\wsl.localhost\\Debian\\home\\ada'
const debianUncPath = '\\\\wsl.localhost\\Debian\\home\\ada\\.codex\\sessions\\first.jsonl'
try {
fsMocks.access.mockImplementation((path) =>
path === firstUncPath ? new Promise<void>(() => {}) : Promise.resolve()
)
const resolved = toHostReadableTranscriptPath(firstGuestPath, {
platform: 'win32',
listWslHomeDirs: async () => [UBUNTU_HOME, DEBIAN_HOME]
})
await vi.waitFor(() => expect(fsMocks.access).toHaveBeenCalledWith(firstUncPath))
await vi.advanceTimersByTimeAsync(WSL_TRANSCRIPT_FS_EXACT_TIMEOUT_MS)
await expect(resolved).resolves.toBe(debianUncPath)
} finally {
vi.useRealTimers()
}
})
})
@@ -1,10 +1,9 @@
import { execFileSync } from 'node:child_process'
import { mkdir, mkdtemp, rm, symlink, writeFile, link } from 'node:fs/promises'
import { mkdir, mkdtemp, rm, symlink, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { NATIVE_CHAT_VISUAL_MAX_BYTES } from '../../shared/native-chat/native-chat-visual-directive'
import { nativeChatScratchFolderFor } from './native-chat-scratch-folder'
import { readNativeChatVisualFile } from './native-chat-visual-file-read'
@@ -24,36 +23,6 @@ afterEach(async () => {
})
describe('readNativeChatVisualFile', () => {
it('answers unchanged without the bytes when the client already holds the revision', async () => {
await writeFile(join(folder, 'chart.html'), '<p>a</p>')
const first = await readNativeChatVisualFile(folder, 'chart.html')
if (!first.ok) {
throw new Error('expected a read')
}
const again = await readNativeChatVisualFile(folder, 'chart.html', first.revision)
expect(again).toMatchObject({
ok: true,
revision: first.revision,
sizeBytes: 8,
unchanged: true
})
await writeFile(join(folder, 'chart.html'), '<p>b</p>')
const changed = await readNativeChatVisualFile(folder, 'chart.html', first.revision)
expect(changed).toMatchObject({ ok: true, html: '<p>b</p>' })
})
it('refuses names that are not a bare visual file name', async () => {
await mkdir(join(stateDirectory, 'other'), { recursive: true })
await writeFile(join(stateDirectory, 'other', 'secret.html'), 'secret')
for (const name of ['../other/secret.html', '/etc/hosts', 'chart.txt', 'sub/chart.html']) {
expect(await readNativeChatVisualFile(folder, name)).toEqual({
ok: false,
error: 'outside_folder'
})
}
})
posixIt('refuses a symlink to a sibling chat folder or another workspace', async () => {
const sibling = nativeChatScratchFolderFor(stateDirectory, 'session-beta')
await mkdir(sibling, { recursive: true })
@@ -108,16 +77,6 @@ describe('readNativeChatVisualFile', () => {
})
})
it('refuses a file over the byte cap and accepts one exactly at it', async () => {
await writeFile(join(folder, 'max.html'), 'a'.repeat(NATIVE_CHAT_VISUAL_MAX_BYTES))
expect(await readNativeChatVisualFile(folder, 'max.html')).toMatchObject({ ok: true })
await writeFile(join(folder, 'big.html'), 'a'.repeat(NATIVE_CHAT_VISUAL_MAX_BYTES + 1))
expect(await readNativeChatVisualFile(folder, 'big.html')).toEqual({
ok: false,
error: 'too_large'
})
})
it('refuses binary and invalid UTF-8 content', async () => {
await writeFile(join(folder, 'zero-byte.html'), Buffer.from([0x3c, 0x00, 0x3e]))
await writeFile(join(folder, 'latin1.html'), Buffer.from([0x3c, 0xe9, 0x3e]))
@@ -130,11 +89,4 @@ describe('readNativeChatVisualFile', () => {
error: 'not_text'
})
})
it('reads a hard link the agent made inside its own folder', async () => {
// Hard links grant no more than copying bytes the agent already controls.
await writeFile(join(folder, 'a.html'), '<p>a</p>')
await link(join(folder, 'a.html'), join(folder, 'b.html'))
expect(await readNativeChatVisualFile(folder, 'b.html')).toMatchObject({ ok: true })
})
})
@@ -1,75 +0,0 @@
import { describe, expect, it } from 'vitest'
import { wslHookRelayConnectionId } from '../../shared/wsl-hook-relay-contract'
import {
nativeChatTranscriptPathOnExecutionHost,
parseSshTranscriptPath,
toSshTranscriptPath
} from './ssh-transcript-path'
const REMOTE_PATH = '/home/ada/.claude/projects/p/session-1.jsonl'
const CONNECTION_ID = 'target:1'
function row(connectionId: string | null, transcriptPath = REMOTE_PATH) {
return {
connectionId,
providerSession: { key: 'session_id' as const, id: 'session-1', transcriptPath }
}
}
function readsFrom(path: string | undefined) {
return path ? (parseSshTranscriptPath(path) ?? path) : path
}
describe('nativeChatTranscriptPathOnExecutionHost', () => {
it('names the SSH host the hook store attests the session to', () => {
expect(
readsFrom(
nativeChatTranscriptPathOnExecutionHost([row(CONNECTION_ID)], 'session-1', REMOTE_PATH)
)
).toEqual({ connectionId: CONNECTION_ID, remotePath: REMOTE_PATH })
})
it('leaves local and WSL sessions on this machine', () => {
const wsl = wslHookRelayConnectionId('Ubuntu')
expect(nativeChatTranscriptPathOnExecutionHost([row(null)], 'session-1', REMOTE_PATH)).toBe(
REMOTE_PATH
)
expect(nativeChatTranscriptPathOnExecutionHost([row(wsl)], 'session-1', REMOTE_PATH)).toBe(
REMOTE_PATH
)
})
it('reads an SSH session on its host even when the client asks for another path', () => {
expect(
readsFrom(
nativeChatTranscriptPathOnExecutionHost(
[row(CONNECTION_ID)],
'session-1',
'/tmp/stale.jsonl'
)
)
).toEqual({ connectionId: CONNECTION_ID, remotePath: REMOTE_PATH })
})
it('prefers the SSH row whose transcript the client asked for', () => {
const rows = [row(CONNECTION_ID), row('target:2', '/home/ada/other.jsonl')]
expect(
readsFrom(nativeChatTranscriptPathOnExecutionHost(rows, 'session-1', '/home/ada/other.jsonl'))
).toEqual({ connectionId: 'target:2', remotePath: '/home/ada/other.jsonl' })
})
it('keeps a session on this machine when a local row attests the requested path', () => {
const localPath = '/home/bob/.claude/projects/p/session-1.jsonl'
const rows = [row(CONNECTION_ID), row(null, localPath)]
expect(nativeChatTranscriptPathOnExecutionHost(rows, 'session-1', localPath)).toBe(localPath)
})
it('ignores an SSH-qualified path a client sends itself', () => {
const forged = toSshTranscriptPath(CONNECTION_ID, '/etc/secret.jsonl')
expect(nativeChatTranscriptPathOnExecutionHost([], 'session-1', forged)).toBeUndefined()
})
})
@@ -1,93 +0,0 @@
import { chmodSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type * as DrivePath from '../ipc/wsl-guest-windows-drive-path'
const guest = vi.hoisted((): { mounts: string[] } => ({ mounts: [] }))
vi.mock('../ipc/wsl-guest-windows-drive-path', async (original) => {
const actual = await original<typeof DrivePath>()
return {
...actual,
guestNativePathEnv: (pathEnv: string | null | undefined) =>
actual.guestNativePathEnv(pathEnv, guest.mounts),
// The stock lookup reads mounts inside this helper, not through the export above.
resolveGuestNativeProgram: (...args: Parameters<typeof actual.resolveGuestNativeProgram>) =>
actual.resolveGuestNativeProgram(args[0], args[1], args[2], guest.mounts)
}
})
import { guestNativePathEnv } from '../ipc/wsl-guest-windows-drive-path'
import { resolveStructuredAgentCommand } from './structured-agent-command-resolution'
const scratch: string[] = []
afterEach(() => {
guest.mounts = []
vi.unstubAllEnvs()
for (const dir of scratch.splice(0)) {
rmSync(dir, { recursive: true, force: true })
}
})
function binDir(name: string): string {
const directory = mkdtempSync(join(tmpdir(), `orca-${name}-`))
scratch.push(directory)
for (const binary of ['codex', 'codex-custom']) {
writeFileSync(join(directory, binary), '#!/bin/sh\nexit 0\n')
chmodSync(join(directory, binary), 0o755)
}
return directory
}
// Drive-mount filtering applies to Linux guests; Windows resolves its own shims.
describe.skipIf(process.platform === 'win32')('launch-time CLI resolution in a WSL guest', () => {
it('skips a Windows shim on a drive mount for the Linux CLI behind it', () => {
const windowsDrive = binDir('drive')
const linuxBin = binDir('linux')
guest.mounts = [windowsDrive]
expect(
resolveStructuredAgentCommand('codex', {}, { pathEnv: `${windowsDrive}:${linuxBin}` })
).toEqual({ command: join(linuxBin, 'codex'), prefixArgs: [] })
})
it("filters this process's PATH when the caller gives none", () => {
const windowsDrive = binDir('drive')
const linuxBin = binDir('linux')
guest.mounts = [windowsDrive]
vi.stubEnv('PATH', `${windowsDrive}:${linuxBin}`)
expect(resolveStructuredAgentCommand('codex', {})).toEqual({
command: join(linuxBin, 'codex'),
prefixArgs: []
})
})
it('filters the PATH a Command setting resolves through', () => {
const windowsDrive = binDir('drive')
const linuxBin = binDir('linux')
guest.mounts = [windowsDrive]
const settings = {
agentCmdOverrides: { codex: 'codex-custom' },
agentDefaultEnv: { codex: { PATH: `${windowsDrive}:${linuxBin}` } }
}
expect(resolveStructuredAgentCommand('codex', settings)).toEqual({
command: join(linuxBin, 'codex-custom'),
prefixArgs: []
})
})
it('keeps the first PATH match off a guest', () => {
const first = binDir('first')
const second = binDir('second')
expect(resolveStructuredAgentCommand('codex', {}, { pathEnv: `${first}:${second}` })).toEqual({
command: join(first, 'codex'),
prefixArgs: []
})
})
})
describe('guestNativePathEnv', () => {
it('returns the PATH as given with no drive mounts', () => {
expect(guestNativePathEnv('/mnt/c/bin:/usr/bin')).toBe('/mnt/c/bin:/usr/bin')
expect(guestNativePathEnv(undefined)).toBeUndefined()
})
})
@@ -1,28 +0,0 @@
import { describe, expect, it, vi } from 'vitest'
import type * as GuestDrivePath from '../ipc/wsl-guest-windows-drive-path'
const resolveGuestNativeProgram = vi.hoisted(() => vi.fn<() => string | null>())
vi.mock('../ipc/wsl-guest-windows-drive-path', async (importOriginal) => ({
...(await importOriginal<typeof GuestDrivePath>()),
resolveGuestNativeProgram
}))
import {
resolveAgainAfterRefreshOnMissingProgram,
resolveStructuredAgentCommand
} from './structured-agent-command-resolution'
describe('a chat program on a WSL host with only a Windows copy', () => {
it('rereads the shell once, so a WSL install made since the last read is found', async () => {
resolveGuestNativeProgram.mockReturnValueOnce(null).mockReturnValueOnce('/home/me/bin/claude')
const refresh = vi.fn(async () => {})
await expect(
resolveAgainAfterRefreshOnMissingProgram(
async () => resolveStructuredAgentCommand('claude', {}),
refresh,
{ env: () => ({}) }
)
).resolves.toEqual({ command: '/home/me/bin/claude', prefixArgs: [] })
expect(refresh).toHaveBeenCalledOnce()
})
})
@@ -1,26 +0,0 @@
import { describe, expect, it } from 'vitest'
import { boundSubagentEntryId, MAX_SUBAGENT_ENTRY_ID_CHARS } from './subagent-entry-id-bounds'
const SHARED_HEAD = 'a'.repeat(MAX_SUBAGENT_ENTRY_ID_CHARS)
describe('boundSubagentEntryId', () => {
it('leaves an id that already fits untouched', () => {
const id = 'task-1'
expect(boundSubagentEntryId(id)).toBe(id)
expect(boundSubagentEntryId(SHARED_HEAD)).toBe(SHARED_HEAD)
})
it('keeps two ids sharing the whole cap-length head distinct', () => {
const first = boundSubagentEntryId(`${SHARED_HEAD}-one`)
const second = boundSubagentEntryId(`${SHARED_HEAD}-two`)
expect(first).not.toBe(second)
expect(first).toHaveLength(MAX_SUBAGENT_ENTRY_ID_CHARS)
expect(second).toHaveLength(MAX_SUBAGENT_ENTRY_ID_CHARS)
})
it('is deterministic and a no-op on an already bounded id', () => {
const bounded = boundSubagentEntryId(`${SHARED_HEAD}-one`)
expect(boundSubagentEntryId(`${SHARED_HEAD}-one`)).toBe(bounded)
expect(boundSubagentEntryId(bounded)).toBe(bounded)
})
})
@@ -10,11 +10,3 @@ it('owns only the final 64 bytes of a large read', () => {
chunk.fill('y')
expect(boundary.toString()).toBe(`${'x'.repeat(61)}end`)
})
it('retains the consumed suffix across short reads and reset-sized inputs', () => {
const first = extendTranscriptBoundary(Buffer.alloc(0), Buffer.from('first'))
const next = extendTranscriptBoundary(first, Buffer.from(' next'))
expect(next.toString()).toBe('first next')
expect(next.buffer.byteLength).toBe(next.length)
expect(extendTranscriptBoundary(Buffer.alloc(0), Buffer.alloc(0)).length).toBe(0)
})
@@ -1,46 +0,0 @@
import { describe, expect, it } from 'vitest'
import { decodeClaudeTranscriptLine } from './transcript-line-decoders-claude'
const prompt = 'Summarize the failing tests.\n\nThen propose a fix for each one.'
const wrapped = `\n\n<pasted_content id="7e64">\n${prompt}\n</pasted_content id="7e64">\n`
function decode(text: string, role = 'user', array = false) {
return decodeClaudeTranscriptLine(
JSON.stringify({
type: role,
uuid: 'user',
message: { content: array ? [{ type: 'text', text }] : text }
}),
'fallback'
)!
}
describe('Claude whole-block paste envelope', () => {
it.each([false, true])('decodes host user content (array=%s)', (array) => {
expect(decode(wrapped, 'user', array).blocks).toEqual([{ type: 'text', text: prompt }])
})
it('accepts CRLF and a wrapper without ids', () => {
expect(decode(`<pasted_content>\r\n${prompt}\r\n</pasted_content>`).blocks).toEqual([
{ type: 'text', text: prompt }
])
})
it('decodes a paste whose own text quotes differently identified or bare tags', () => {
const quoted = `<pasted_content id="aa">\nx\n</pasted_content id="aa">\n</pasted_content>`
expect(
decode(`<pasted_content id="7e64">\n${quoted}\n</pasted_content id="7e64">`).blocks
).toEqual([{ type: 'text', text: quoted }])
})
it.each([
`Explain this:\n${wrapped}`,
`<pasted_content id="7e64">\n<pasted_content id="7e64">\nx\n</pasted_content id="7e64">`,
`<pasted_content>\n<pasted_content id="aa">\nx\n</pasted_content id="aa">\n</pasted_content>`,
wrapped.replace('id="7e64">\n', 'id="other">\n'),
wrapped.replace('</pasted_content id="7e64">', '</pasted_content>'),
wrapped.replace('<pasted_content id="7e64">', '<pasted_content>'),
`${wrapped}\n${wrapped}`
])('preserves prose and nonmatching envelopes', (text) => {
expect(decode(text).blocks).toEqual([{ type: 'text', text }])
})
it('leaves assistant text untouched', () => {
expect(decode(wrapped, 'assistant').blocks).toEqual([{ type: 'text', text: wrapped }])
})
})
@@ -1,67 +0,0 @@
import { describe, expect, it, vi } from 'vitest'
import type { NativeChatMessage } from '../../shared/native-chat/native-chat-types'
import {
readOpenCodeNativeChatTranscriptFull,
readOpenCodeNativeChatTranscriptTail
} from './transcript-opencode'
vi.mock('../managed-data-accounts/service', () => ({ getManagedDataAccountService: vi.fn() }))
describe('OpenCode full native read bounds', () => {
it('counts reasoning, answer and image bytes cumulatively across pages', async () => {
let calls = 0
const chunk = 'x'.repeat(1_000_000)
const messages: NativeChatMessage[] = Array.from({ length: 12 }, (_, index) => ({
id: String(index),
role: index % 2 === 0 ? 'reasoning' : 'assistant',
timestamp: 1,
source: 'transcript',
blocks: [{ type: 'text', text: chunk }]
}))
messages.push({
id: 'image',
role: 'user',
timestamp: 1,
source: 'transcript',
blocks: [{ type: 'image-ref', url: `data:image/png;base64,${chunk}` }]
})
const result = await readOpenCodeNativeChatTranscriptFull('session', {
resolveDbPath: async () => 'private-db',
readPage: async () => ({
items: messages.map((message) => ({ rowid: 100 - calls, fingerprint: '', message })),
hasMore: true,
beforeMessageRowId: 100 - ++calls
})
})
expect(result).toEqual({ error: 'OpenCode transcript exceeds its full read limit' })
expect(calls).toBe(6)
})
it.each(['tail', 'full'] as const)(
'keeps cancellation attached to %s discovery and reads',
async (kind) => {
const controller = new AbortController()
const resolveDbPath = vi.fn(async (_sessionId?: string, signal?: AbortSignal) => {
expect(signal).toBe(controller.signal)
return 'private-db'
})
const readPage = vi.fn(async (_args: unknown, signal?: AbortSignal) => {
expect(signal).toBe(controller.signal)
controller.abort(new Error('Cancelled private read'))
signal?.throwIfAborted()
return null
})
const deps = { resolveDbPath, readPage }
const result =
kind === 'tail'
? await readOpenCodeNativeChatTranscriptTail(
{ sessionId: 'session', limit: 1 },
deps,
controller.signal
)
: await readOpenCodeNativeChatTranscriptFull('session', deps, controller.signal)
expect(result).toEqual({ error: 'Cancelled private read' })
expect(readPage).toHaveBeenCalledOnce()
}
)
})
@@ -1,30 +0,0 @@
import { expect, it } from 'vitest'
import { makeTab } from '@/store/terminals/terminal-tab.test-fixture'
import { getProjectRuntimeSessionSummary } from './repository-runtime-session-summary'
it('recomputes when a tab slice is replaced', () => {
const worktreeId = 'repo-0::/tmp/worktree'
const state = {
tabsByWorktree: {
[worktreeId]: [
makeTab({ id: 'tab-a', ptyId: 'pty-a', worktreeId }),
makeTab({ id: 'tab-b', ptyId: 'pty-b', worktreeId })
]
},
ptyIdsByTabId: {},
agentStatusByPaneKey: {}
}
const first = getProjectRuntimeSessionSummary(state, 'repo-0')
const next = getProjectRuntimeSessionSummary(
{
...state,
tabsByWorktree: {
[worktreeId]: [makeTab({ id: 'tab-new', ptyId: 'pty-new', worktreeId })]
}
},
'repo-0'
)
expect(first.liveTerminalCount).toBe(2)
expect(next.liveTerminalCount).toBe(1)
})
@@ -1,249 +0,0 @@
// @vitest-environment happy-dom
// Render real drag handles to prove pointer moves write styles directly and store state commits only on release.
import { act } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
globalThis.IS_REACT_ACT_ENVIRONMENT = true
const setTabGroupSplitRatioMock = vi.fn()
const recordFeatureInteractionMock = vi.fn()
vi.mock('../../store', () => {
const state = (): Record<string, unknown> => ({
recordFeatureInteraction: recordFeatureInteractionMock,
setTabGroupSplitRatio: setTabGroupSplitRatioMock,
layoutByWorktree: {}
})
// The switch-on ratio commit reads the store directly.
const useAppStore = Object.assign(
(selector: (state: Record<string, unknown>) => unknown) => selector(state()),
{ getState: state }
)
return { useAppStore }
})
vi.mock('./TabGroupPanel', () => ({
default: ({ groupId }: { groupId: string }) => <div data-testid={`panel-${groupId}`} />
}))
vi.mock('./useTabDragSplit', () => ({
useTabDragSplit: () => ({
activeDrag: null,
collisionDetection: vi.fn(),
hoveredDropTarget: null,
hoveredTabInsertion: null,
isTabDragActiveRef: { current: false },
onDragCancel: vi.fn(),
onDragEnd: vi.fn(),
onDragMove: vi.fn(),
onDragOver: vi.fn(),
onDragStart: vi.fn(),
sensors: [],
setDragRootNode: vi.fn()
})
}))
import TabGroupSplitLayout from './TabGroupSplitLayout'
function pointerEvent(type: string, init: { pointerId?: number; clientX?: number }): Event {
const event = new Event(type, { bubbles: true, cancelable: true })
Object.defineProperty(event, 'pointerId', { value: init.pointerId ?? 1 })
Object.defineProperty(event, 'clientX', { value: init.clientX ?? 0 })
Object.defineProperty(event, 'clientY', { value: 0 })
return event
}
describe('TabGroupSplitLayout divider drag', () => {
let container: HTMLDivElement
let root: Root | null
let handle: HTMLElement
let firstPane: HTMLElement
let secondPane: HTMLElement
let containerRect: DOMRect
let resizeObserverCallback: ResizeObserverCallback | null
const resizeObserverDisconnectMock = vi.fn()
beforeEach(async () => {
setTabGroupSplitRatioMock.mockClear()
recordFeatureInteractionMock.mockClear()
resizeObserverDisconnectMock.mockClear()
resizeObserverCallback = null
vi.stubGlobal(
'ResizeObserver',
class {
private readonly callback: ResizeObserverCallback
private tracksSplitContainer = false
constructor(callback: ResizeObserverCallback) {
this.callback = callback
}
observe(target: Element): void {
if (target === handle?.parentElement) {
this.tracksSplitContainer = true
resizeObserverCallback = this.callback
}
}
unobserve(): void {}
disconnect(): void {
if (this.tracksSplitContainer) {
resizeObserverDisconnectMock()
}
}
}
)
container = document.createElement('div')
document.body.appendChild(container)
const mountedRoot = createRoot(container)
root = mountedRoot
await act(async () => {
mountedRoot.render(
<TabGroupSplitLayout
layout={{
type: 'split',
direction: 'horizontal',
ratio: 0.5,
first: { type: 'leaf', groupId: 'left-group' },
second: { type: 'leaf', groupId: 'right-group' }
}}
worktreeId="wt-1"
focusedGroupId="right-group"
isWorktreeActive={true}
/>
)
})
handle = container.querySelector('.tab-group-split-resize-handle') as HTMLElement
expect(handle).not.toBeNull()
firstPane = handle.previousElementSibling as HTMLElement
secondPane = handle.nextElementSibling as HTMLElement
const capturedPointers = new Set<number>()
Object.assign(handle, {
setPointerCapture: (pointerId: number) => {
capturedPointers.add(pointerId)
},
releasePointerCapture: (pointerId: number) => {
capturedPointers.delete(pointerId)
},
hasPointerCapture: (pointerId: number) => capturedPointers.has(pointerId)
})
const splitContainer = handle.parentElement as HTMLElement
containerRect = {
left: 0,
top: 0,
width: 1000,
height: 500,
right: 1000,
bottom: 500
} as DOMRect
splitContainer.getBoundingClientRect = vi.fn(() => containerRect)
})
afterEach(() => {
act(() => root?.unmount())
container.remove()
vi.unstubAllGlobals()
})
it('writes pane styles per move and commits the store once on release', async () => {
await act(async () => {
handle.dispatchEvent(pointerEvent('pointerdown', { pointerId: 1 }))
})
expect(recordFeatureInteractionMock).toHaveBeenCalledWith('terminal-panes')
await act(async () => {
handle.dispatchEvent(pointerEvent('pointermove', { pointerId: 1, clientX: 300 }))
handle.dispatchEvent(pointerEvent('pointermove', { pointerId: 1, clientX: 320 }))
handle.dispatchEvent(pointerEvent('pointermove', { pointerId: 1, clientX: 340 }))
})
// Do not publish per-move store writes; direct pane styles track the pointer until release.
expect(setTabGroupSplitRatioMock).not.toHaveBeenCalled()
expect(firstPane.style.flex).toBe('0.34 1 0%')
expect(secondPane.style.flex).toBe('0.66 1 0%')
await act(async () => {
handle.dispatchEvent(pointerEvent('pointerup', { pointerId: 1 }))
})
expect(setTabGroupSplitRatioMock).toHaveBeenCalledTimes(1)
expect(setTabGroupSplitRatioMock).toHaveBeenCalledWith('wt-1', '', 0.34)
})
it('clamps the committed ratio and skips the commit when nothing moved', async () => {
await act(async () => {
handle.dispatchEvent(pointerEvent('pointerdown', { pointerId: 1 }))
handle.dispatchEvent(pointerEvent('pointerup', { pointerId: 1 }))
})
expect(setTabGroupSplitRatioMock).not.toHaveBeenCalled()
await act(async () => {
handle.dispatchEvent(pointerEvent('pointerdown', { pointerId: 1 }))
handle.dispatchEvent(pointerEvent('pointermove', { pointerId: 1, clientX: 20 }))
handle.dispatchEvent(pointerEvent('pointerup', { pointerId: 1 }))
})
expect(setTabGroupSplitRatioMock).toHaveBeenCalledTimes(1)
expect(setTabGroupSplitRatioMock).toHaveBeenCalledWith('wt-1', '', 0.15)
})
it('refreshes drag geometry only when the split container resizes', async () => {
const getRectMock = handle.parentElement?.getBoundingClientRect as ReturnType<typeof vi.fn>
await act(async () => {
handle.dispatchEvent(pointerEvent('pointerdown', { pointerId: 1 }))
handle.dispatchEvent(pointerEvent('pointermove', { pointerId: 1, clientX: 250 }))
})
expect(firstPane.style.flex).toBe('0.25 1 0%')
expect(getRectMock).toHaveBeenCalledOnce()
containerRect = { ...containerRect, width: 500, right: 500 } as DOMRect
resizeObserverCallback?.([], {} as ResizeObserver)
await act(async () => {
handle.dispatchEvent(pointerEvent('pointermove', { pointerId: 1, clientX: 250 }))
handle.dispatchEvent(pointerEvent('pointerup', { pointerId: 1 }))
})
expect(firstPane.style.flex).toBe('0.5 1 0%')
expect(getRectMock).toHaveBeenCalledTimes(2)
expect(setTabGroupSplitRatioMock).toHaveBeenCalledWith('wt-1', '', 0.5)
expect(resizeObserverDisconnectMock).toHaveBeenCalledOnce()
})
it('ignores events from pointers that do not own the drag', async () => {
await act(async () => {
handle.dispatchEvent(pointerEvent('pointerdown', { pointerId: 1 }))
handle.dispatchEvent(pointerEvent('pointermove', { pointerId: 1, clientX: 350 }))
handle.dispatchEvent(pointerEvent('pointerdown', { pointerId: 2 }))
handle.dispatchEvent(pointerEvent('pointermove', { pointerId: 2, clientX: 300 }))
handle.dispatchEvent(pointerEvent('pointerup', { pointerId: 2 }))
})
expect(firstPane.style.flex).toBe('0.35 1 0%')
expect(setTabGroupSplitRatioMock).not.toHaveBeenCalled()
await act(async () => {
handle.dispatchEvent(pointerEvent('pointerup', { pointerId: 1 }))
})
expect(setTabGroupSplitRatioMock).toHaveBeenCalledWith('wt-1', '', 0.35)
})
it.each(['pointercancel', 'lostpointercapture'])('commits on %s', async (eventType) => {
await act(async () => {
handle.dispatchEvent(pointerEvent('pointerdown', { pointerId: 1 }))
handle.dispatchEvent(pointerEvent('pointermove', { pointerId: 1, clientX: 400 }))
handle.dispatchEvent(pointerEvent(eventType, { pointerId: 1 }))
})
expect(setTabGroupSplitRatioMock).toHaveBeenCalledWith('wt-1', '', 0.4)
expect(resizeObserverDisconnectMock).toHaveBeenCalledOnce()
})
it('commits and releases drag resources on unmount', async () => {
await act(async () => {
handle.dispatchEvent(pointerEvent('pointerdown', { pointerId: 1 }))
handle.dispatchEvent(pointerEvent('pointermove', { pointerId: 1, clientX: 450 }))
})
act(() => root?.unmount())
root = null
expect(setTabGroupSplitRatioMock).toHaveBeenCalledWith('wt-1', '', 0.45)
expect(resizeObserverDisconnectMock).toHaveBeenCalledOnce()
})
})
@@ -1,196 +0,0 @@
import React from 'react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const setTabGroupSplitRatioMock = vi.fn()
const recordFeatureInteractionMock = vi.fn()
const setDragRootNodeMock = vi.fn()
const useAppStoreMock = vi.fn(
(
selector: (state: {
recordFeatureInteraction: typeof recordFeatureInteractionMock
setTabGroupSplitRatio: typeof setTabGroupSplitRatioMock
}) => unknown
) =>
selector({
recordFeatureInteraction: recordFeatureInteractionMock,
setTabGroupSplitRatio: setTabGroupSplitRatioMock
})
)
vi.mock('../../store', () => ({
useAppStore: (
selector: (state: {
recordFeatureInteraction: typeof recordFeatureInteractionMock
setTabGroupSplitRatio: typeof setTabGroupSplitRatioMock
}) => unknown
) => useAppStoreMock(selector)
}))
vi.mock('./TabGroupPanel', () => ({
default: (props: unknown) => ({ __mock: 'TabGroupPanel', props })
}))
vi.mock('./useTabDragSplit', () => ({
useTabDragSplit: () => ({
activeDrag: null,
collisionDetection: vi.fn(),
hoveredDropTarget: null,
hoveredTabInsertion: null,
isTabDragActiveRef: { current: false },
onDragCancel: vi.fn(),
onDragEnd: vi.fn(),
onDragMove: vi.fn(),
onDragOver: vi.fn(),
onDragStart: vi.fn(),
sensors: [],
setDragRootNode: setDragRootNodeMock
})
}))
import TabGroupSplitLayout from './TabGroupSplitLayout'
type ReactElementLike = {
type: string | ((props: Record<string, unknown>) => unknown)
props: Record<string, unknown>
}
function asElement(node: unknown): ReactElementLike {
return node as ReactElementLike
}
function invokeComponent(element: ReactElementLike): unknown {
if (typeof element.type === 'function') {
return element.type(element.props)
}
return element
}
describe('TabGroupSplitLayout', () => {
beforeEach(() => {
setTabGroupSplitRatioMock.mockClear()
recordFeatureInteractionMock.mockClear()
setDragRootNodeMock.mockClear()
useAppStoreMock.mockClear()
})
function getLayoutWrapper(element: ReturnType<typeof TabGroupSplitLayout>) {
// The layout composes WorkspaceTabDragLayer (a render prop) around the chrome wrapper:
// invoke it to reach TabDragProvider > DndContext > [wrapper, DragOverlay, split overlay].
const providerElement = asElement(invokeComponent(asElement(element)))
const dndContext = asElement(providerElement.props.children)
return React.Children.toArray(dndContext.props.children as React.ReactNode)[0]
}
function getSplitNodeElement(element: ReturnType<typeof TabGroupSplitLayout>) {
const layoutWrapperChildren = React.Children.toArray(
asElement(getLayoutWrapper(element)).props.children as React.ReactNode
)
const splitBody = layoutWrapperChildren[1]
const treeElement = React.Children.only(
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the wrapper's split body renders exactly the tree element; props are untyped in this shallow-walk rig.
asElement(splitBody).props.children as React.ReactNode
)
// TabGroupSplitNodeTree renders the recursive SplitNode; invoke both to reach the leaf/split.
const splitNodeElement = invokeComponent(asElement(treeElement))
return invokeComponent(asElement(splitNodeElement))
}
function getLeafPanelProps(isWorktreeActive: boolean) {
const element = TabGroupSplitLayout({
layout: { type: 'leaf', groupId: 'group-1' },
worktreeId: 'wt-1',
focusedGroupId: 'group-1',
isWorktreeActive
})
const tabGroupPanelElement = asElement(getSplitNodeElement(element))
return tabGroupPanelElement.props as {
groupId: string
worktreeId: string
isVisible: boolean
isFocused: boolean
hasSplitGroups: boolean
reserveClosedExplorerToggleSpace: boolean
reserveCollapsedSidebarHeaderSpace: boolean
}
}
it('does not mark an offscreen worktree group as focused', () => {
expect(getLeafPanelProps(false)).toEqual(
expect.objectContaining({
groupId: 'group-1',
worktreeId: 'wt-1',
isVisible: false,
isFocused: false,
hasSplitGroups: false,
reserveClosedExplorerToggleSpace: true,
reserveCollapsedSidebarHeaderSpace: true
})
)
})
it('keeps the visible worktree focused group active', () => {
expect(getLeafPanelProps(true)).toEqual(
expect.objectContaining({
groupId: 'group-1',
worktreeId: 'wt-1',
isVisible: true,
isFocused: true,
hasSplitGroups: false,
reserveClosedExplorerToggleSpace: true,
reserveCollapsedSidebarHeaderSpace: true
})
)
})
it('wires the split layout root to drag cleanup ownership', () => {
const element = TabGroupSplitLayout({
layout: { type: 'leaf', groupId: 'group-1' },
worktreeId: 'wt-1',
focusedGroupId: 'group-1',
isWorktreeActive: true
})
expect(asElement(getLayoutWrapper(element)).props.ref).toBe(setDragRootNodeMock)
})
it('only reserves top-right header space for the floating explorer toggle', () => {
const element = TabGroupSplitLayout({
layout: {
type: 'split',
direction: 'horizontal',
ratio: 0.5,
first: { type: 'leaf', groupId: 'left-group' },
second: { type: 'leaf', groupId: 'right-group' }
},
worktreeId: 'wt-1',
focusedGroupId: 'right-group',
isWorktreeActive: true
})
const rootElement = asElement(getSplitNodeElement(element))
const rootChildren = rootElement.props.children as unknown[]
const leftChild = asElement(rootChildren[0]).props.children
const rightChild = asElement(rootChildren[2]).props.children
const leftPanelProps = asElement(invokeComponent(asElement(leftChild))).props as {
reserveClosedExplorerToggleSpace: boolean
reserveCollapsedSidebarHeaderSpace: boolean
}
const rightPanelProps = asElement(invokeComponent(asElement(rightChild))).props as {
reserveClosedExplorerToggleSpace: boolean
reserveCollapsedSidebarHeaderSpace: boolean
}
expect(leftPanelProps).toEqual(
expect.objectContaining({
reserveClosedExplorerToggleSpace: false,
reserveCollapsedSidebarHeaderSpace: true
})
)
expect(rightPanelProps).toEqual(
expect.objectContaining({
reserveClosedExplorerToggleSpace: true,
reserveCollapsedSidebarHeaderSpace: false
})
)
})
})
@@ -1,61 +0,0 @@
import { describe, expect, it, vi } from 'vitest'
const { createPortalMock } = vi.hoisted(() => ({
createPortalMock: vi.fn((node: unknown) => node)
}))
vi.mock('react-dom', () => ({
createPortal: createPortalMock
}))
import TabPaneColumnSplitDragOverlay from './TabPaneColumnSplitDragOverlay'
function rect({
left,
top,
width,
height
}: {
left: number
top: number
width: number
height: number
}): DOMRect {
return {
left,
top,
width,
height,
right: left + width,
bottom: top + height
} as DOMRect
}
type ReactElementLike = {
props: Record<string, unknown>
}
describe('TabPaneColumnSplitDragOverlay', () => {
it('renders from cached panel bounds without reading layout during render', () => {
const querySelector = vi.fn()
vi.stubGlobal('document', {
body: {},
querySelector
})
const overlay = TabPaneColumnSplitDragOverlay({
panelRect: rect({ left: 100, top: 20, width: 400, height: 300 }),
zone: 'right'
})
expect(querySelector).not.toHaveBeenCalled()
expect((overlay as ReactElementLike).props.style).toEqual({
top: 20,
left: 300,
width: 200,
height: 300
})
vi.unstubAllGlobals()
})
})
@@ -1,50 +0,0 @@
// @vitest-environment happy-dom
import { act, render, renderHook } from '@testing-library/react'
import { createElement } from 'react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { EditorRevealFlash } from './EditorRevealFlash'
import {
EDITOR_REVEAL_FLASH_MS,
flashRevealedEditorView,
useEditorRevealFlashKey
} from './editor-reveal-flash'
describe('editor reveal flash', () => {
afterEach(() => {
vi.useRealTimers()
})
it('flashes only the revealed view and clears after the animation', () => {
vi.useFakeTimers()
const target = renderHook(() => useEditorRevealFlashKey('view-a'))
const other = renderHook(() => useEditorRevealFlashKey('view-b'))
act(() => flashRevealedEditorView('view-a'))
expect(target.result.current).not.toBeNull()
expect(other.result.current).toBeNull()
act(() => vi.advanceTimersByTime(EDITOR_REVEAL_FLASH_MS))
expect(target.result.current).toBeNull()
})
it('replaces the overlay and resets its lifetime on a repeat flash', () => {
vi.useFakeTimers()
const { container } = render(createElement(EditorRevealFlash, { viewStateId: 'view-c' }))
act(() => flashRevealedEditorView('view-c'))
const firstOverlay = container.querySelector('.editor-reveal-flash')
expect(firstOverlay).not.toBeNull()
act(() => vi.advanceTimersByTime(EDITOR_REVEAL_FLASH_MS - 100))
act(() => flashRevealedEditorView('view-c'))
const secondOverlay = container.querySelector('.editor-reveal-flash')
expect(secondOverlay).not.toBeNull()
expect(secondOverlay).not.toBe(firstOverlay)
expect(firstOverlay?.isConnected).toBe(false)
act(() => vi.advanceTimersByTime(EDITOR_REVEAL_FLASH_MS - 100))
expect(container.querySelector('.editor-reveal-flash')).toBe(secondOverlay)
act(() => vi.advanceTimersByTime(100))
expect(container.querySelector('.editor-reveal-flash')).toBeNull()
})
})
@@ -1,14 +0,0 @@
import { describe, expect, it } from 'vitest'
import { getDragPointer } from './tab-drag-pointer'
describe('getDragPointer', () => {
it('uses the activator client coordinates plus drag delta', () => {
expect(
getDragPointer({
activatorEvent: { clientX: 100, clientY: 40 },
delta: { x: 250, y: 10 },
active: { rect: { current: { initial: null, translated: null } } }
} as unknown as Parameters<typeof getDragPointer>[0])
).toEqual({ x: 350, y: 50 })
})
})
@@ -1,4 +1,4 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { beforeEach, describe, expect, it } from 'vitest'
import type { Tab } from '../../../../shared/tab-types'
import { useAppStore } from '../../store'
import { captureTabDragActivationSnapshot } from './tab-drag-preview-activation'
@@ -55,30 +55,6 @@ describe('restoreTabDragActivationSnapshot', () => {
})
})
it('does not publish repeated preview and restore actions', () => {
const snapshot = captureTabDragActivationSnapshot(WT)
const subscriber = vi.fn()
const unsubscribe = useAppStore.subscribe(subscriber)
try {
useAppStore.getState().previewDragActiveTab({
worktreeId: WT,
groupId: 'group-1',
tabId: 'tab-1',
activeGroupId: 'group-1'
})
useAppStore.getState().restoreDragActivationSnapshot(WT, snapshot)
useAppStore.getState().restoreSourceGroupActiveTabAfterDrop({
worktreeId: WT,
snapshot,
sourceGroupId: 'group-1',
movedTabId: 'tab-2'
})
expect(subscriber).not.toHaveBeenCalled()
} finally {
unsubscribe()
}
})
it('restores active-surface fields after a drag preview is cancelled', () => {
const snapshot = captureTabDragActivationSnapshot(WT)
@@ -9,7 +9,7 @@
import { act, createElement } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { DragEndEvent } from '@dnd-kit/core'
import {
createRetainedHostFixture,
disposeRetainedHostFixtures,
@@ -41,15 +41,6 @@ function dragStartEvent() {
return { active: { data: { current: dragData() }, rect: { current: { initial: null } } } }
}
function dragEndEvent(): DragEndEvent {
return {
active: { data: { current: dragData() }, rect: { current: { initial: null } } },
over: null,
delta: { x: 0, y: 0 },
activatorEvent: { clientX: 0, clientY: 0 }
} as unknown as DragEndEvent
}
function renderDragHook(): ReturnType<typeof useTabDragSplit> {
let result: ReturnType<typeof useTabDragSplit> | null = null
function Probe(): null {
@@ -90,31 +81,6 @@ afterEach(() => {
})
describe('tab drag passthrough over a client-hosted retained guest', () => {
it('makes the retained host click-through for the length of the drag', async () => {
const rig = await retainedGuest()
const drag = renderDragHook()
act(() => drag.onDragStart(dragStartEvent() as never))
expect(rig.host().style.pointerEvents).toBe('none')
act(() => drag.onDragEnd(dragEndEvent()))
expect(rig.host().style.pointerEvents).toBe('auto')
})
it('releases the retained host when the drag is cancelled', async () => {
const rig = await retainedGuest()
const drag = renderDragHook()
act(() => drag.onDragStart(dragStartEvent() as never))
expect(rig.host().style.pointerEvents).toBe('none')
act(() => drag.onDragCancel())
expect(rig.host().style.pointerEvents).toBe('auto')
})
it('releases the retained host from the missed-end fallback', async () => {
const rig = await retainedGuest()
const drag = renderDragHook()
@@ -1,32 +0,0 @@
import { describe, expect, it } from 'vitest'
import { resolvePaneColumnEdgeZone, TAB_GROUP_TAB_STRIP_HEIGHT_PX } from './tab-drop-zone'
describe('resolvePaneColumnEdgeZone', () => {
const panelRect = { left: 0, top: 0, width: 300, height: 200 }
it('returns right on the outer horizontal band in the body', () => {
expect(resolvePaneColumnEdgeZone(panelRect, { x: 260, y: 100 })).toBe('right')
})
it('returns null in the center band of the body', () => {
expect(resolvePaneColumnEdgeZone(panelRect, { x: 150, y: 100 })).toBeNull()
})
it('does not return up while the pointer is still in the tab strip', () => {
expect(
resolvePaneColumnEdgeZone(panelRect, {
x: 150,
y: TAB_GROUP_TAB_STRIP_HEIGHT_PX - 1
})
).toBeNull()
})
it('returns up on the top edge of the pane body', () => {
expect(
resolvePaneColumnEdgeZone(panelRect, {
x: 150,
y: TAB_GROUP_TAB_STRIP_HEIGHT_PX + 5
})
).toBe('up')
})
})
@@ -1,20 +0,0 @@
import { describe, expect, it } from 'vitest'
import { tabGroupBodyAnchorName } from './tab-group-body-anchor'
describe('tabGroupBodyAnchorName', () => {
it('returns a valid CSS custom anchor name for UUID-style group ids', () => {
const anchorName = tabGroupBodyAnchorName('11111111-1111-4111-8111-111111111111')
expect(anchorName).toMatch(/^--orca-tab-group-body-[0-9a-f-]+$/)
})
it('encodes remote runtime group ids that include path separators', () => {
const anchorName = tabGroupBodyAnchorName(
'headless-terminals:repo::/Users/jinwoohong/orca/workspaces/orca/branch'
)
expect(anchorName).not.toContain(':')
expect(anchorName).not.toContain('/')
expect(anchorName).toMatch(/^--orca-tab-group-body-[0-9a-f-]+$/)
})
})
@@ -22,15 +22,6 @@ function track(cleanup: () => void): () => void {
return cleanup
}
function resizeListenerDelta(
add: ReturnType<typeof vi.spyOn>,
remove: ReturnType<typeof vi.spyOn>
) {
const count = (spy: ReturnType<typeof vi.spyOn>): number =>
spy.mock.calls.filter(([type]) => type === 'resize').length
return count(add) - count(remove)
}
beforeEach(() => {
observers = []
cleanups = []
@@ -63,56 +54,6 @@ afterEach(() => {
})
describe('subscribeTabGroupBodyRect', () => {
it('shares one observer per group and one window listener across groups', () => {
const add = vi.spyOn(window, 'addEventListener')
const remove = vi.spyOn(window, 'removeEventListener')
const container = element(new DOMRect(0, 10, 800, 600))
track(registerTabGroupBody('a', element(new DOMRect(0, 42, 400, 568))))
track(registerTabGroupBody('b', element(new DOMRect(400, 42, 400, 568))))
const first = vi.fn()
const second = vi.fn()
const other = vi.fn()
const unsubscribeFirst = subscribeTabGroupBodyRect('a', container, first)
const unsubscribeSecond = subscribeTabGroupBodyRect('a', container, second)
const unsubscribeOther = subscribeTabGroupBodyRect('b', container, other)
expect(observers).toHaveLength(2)
expect(resizeListenerDelta(add, remove)).toBe(1)
expect(first).toHaveBeenLastCalledWith({ top: 32, left: 0, width: 400, height: 568 })
expect(second.mock.lastCall?.[0]).toBe(first.mock.lastCall?.[0])
expect(other).toHaveBeenLastCalledWith({ top: 32, left: 400, width: 400, height: 568 })
unsubscribeFirst()
expect(observers[0].disconnected).toBe(false)
unsubscribeSecond()
expect(observers[0].disconnected).toBe(true)
expect(resizeListenerDelta(add, remove)).toBe(1)
unsubscribeOther()
expect(observers[1].disconnected).toBe(true)
expect(resizeListenerDelta(add, remove)).toBe(0)
})
it('notifies only on changes larger than a pixel', () => {
const container = element(new DOMRect(0, 0, 800, 600))
let bodyRect = new DOMRect(0, 32, 400, 568)
const body = element(bodyRect)
body.getBoundingClientRect = () => bodyRect
track(registerTabGroupBody('a', body))
const listener = vi.fn()
track(subscribeTabGroupBodyRect('a', container, listener))
listener.mockClear()
bodyRect = new DOMRect(0, 32.5, 400.5, 568)
window.dispatchEvent(new Event('resize'))
expect(listener).not.toHaveBeenCalled()
bodyRect = new DOMRect(0, 32, 380, 568)
observers[0].callback()
expect(listener).toHaveBeenCalledTimes(1)
expect(listener).toHaveBeenLastCalledWith({ top: 32, left: 0, width: 380, height: 568 })
})
it('follows the group body as it mounts, is replaced, and unmounts', () => {
const container = element(new DOMRect(0, 0, 800, 600))
const listener = vi.fn()
@@ -4,10 +4,7 @@ import { createTabsSliceMockApi } from '@/store/slices/tabs/tabs-slice-test-harn
import { beginLayoutFramePositions, endLayoutFramePositions } from '@/runtime/layout-frame-position'
import { createTestStore, seedStore } from '@/store/testing/app-store-test-fixture'
import { makeWorktree } from '@/store/slices/worktrees/worktree.test-fixture'
import {
dropTabThroughLayoutOwner,
setGroupRatioThroughLayoutOwner
} from './tab-group-layout-owner-commands'
import { dropTabThroughLayoutOwner } from './tab-group-layout-owner-commands'
import { LOCAL_LAYOUT_APPLY_ORIGIN } from '@/runtime/server-layout-apply-routes'
const mocks = vi.hoisted(
@@ -70,24 +67,6 @@ beforeEach(() => {
afterEach(() => endLayoutFramePositions())
describe('dropTabThroughLayoutOwner', () => {
it('leaves the move to main’s frame while frames flow, moving nothing itself', () => {
const { right } = seeded()
beginLayoutFramePositions()
expect(
dropTabThroughLayoutOwner(WT, { kind: 'move', tabId: 'a', groupId: right, index: 0 })
).toBe(true)
expect(mocks.send).toHaveBeenCalledWith(
WT,
{ type: 'moveTab', tabId: 'a', toGroupId: right, index: 0, afterTabId: null },
expect.anything()
)
lastHandlers().apply({}, LOCAL_LAYOUT_APPLY_ORIGIN)
expect(groupOf('a')).not.toBe(right)
expect(orderOf(right)).toEqual(['c'])
})
it('selects nothing on main’s answer: the frame that shows the move selects the tab (L120)', () => {
const { left, right } = seeded()
beginLayoutFramePositions()
@@ -102,71 +81,6 @@ describe('dropTabThroughLayoutOwner', () => {
expect(store().getState().activeGroupIdByWorktree[WT]).toBe(left)
})
it('leaves placing a lone tab’s group to main’s frame while frames flow', () => {
const { left, right } = seeded()
const layout = store().getState().layoutByWorktree[WT]
beginLayoutFramePositions()
const selectedBefore = store().getState().activeGroupIdByWorktree[WT]
dropTabThroughLayoutOwner(WT, { kind: 'split', tabId: 'c', groupId: left, direction: 'left' })
lastHandlers().apply({ groupId: right }, LOCAL_LAYOUT_APPLY_ORIGIN)
expect(store().getState().layoutByWorktree[WT]).toBe(layout)
expect(store().getState().activeGroupIdByWorktree[WT]).toBe(selectedBefore)
})
it('leaves a drop the window would skip to today’s path, sending nothing', () => {
const { left, right } = seeded()
expect(
dropTabThroughLayoutOwner(WT, {
kind: 'split',
tabId: 'c',
groupId: left,
direction: 'right'
})
).toBe(false)
expect(mocks.send).not.toHaveBeenCalled()
expect(groupOf('c')).toBe(right)
})
it('leaves a move into the tab’s own group to today’s no-op, sending nothing', () => {
const { left } = seeded()
const before = orderOf(left)
expect(
dropTabThroughLayoutOwner(WT, { kind: 'move', tabId: 'a', groupId: left, index: 1 })
).toBe(false)
expect(mocks.send).not.toHaveBeenCalled()
expect(orderOf(left)).toEqual(before)
})
it('reorders within a group at the index main got, and leaves drawing it to main’s frame', () => {
const { left } = seeded()
dropTabThroughLayoutOwner(WT, {
kind: 'reorder',
tabId: 'a',
groupId: left,
tabOrder: ['b', 'a']
})
expect(mocks.send.mock.calls[0]![1]).toEqual({
type: 'moveTab',
tabId: 'a',
toGroupId: left,
index: 1,
afterTabId: 'b'
})
lastHandlers().apply({}, LOCAL_LAYOUT_APPLY_ORIGIN)
expect(orderOf(left)).toEqual(['a', 'b'])
})
it('anchors a move into another group after the tab drawn before its slot', () => {
const { right } = seeded()
dropTabThroughLayoutOwner(WT, { kind: 'move', tabId: 'a', groupId: right })
expect(mocks.send.mock.calls[0]![1]).toMatchObject({ index: 1, afterTabId: 'c' })
})
it('moves as today when main cannot act, and not at all when main refuses', () => {
const { right } = seeded()
@@ -178,45 +92,4 @@ describe('dropTabThroughLayoutOwner', () => {
lastHandlers().asToday()
expect(orderOf(right)).toEqual(['c', 'a'])
})
it('moves nothing itself while main’s stream is down; the reconnect’s snapshot shows the split', () => {
const { left } = seeded()
dropTabThroughLayoutOwner(WT, { kind: 'split', tabId: 'b', groupId: left, direction: 'down' })
lastHandlers().apply({ groupId: 'group-main' }, LOCAL_LAYOUT_APPLY_ORIGIN)
expect(groupOf('b')).toBe(left)
})
})
describe('setGroupRatioThroughLayoutOwner', () => {
it('sets the ratio here, then sends main that divider’s ratio, not the group tree', () => {
seeded()
setGroupRatioThroughLayoutOwner(WT, '', 0.3)
expect(store().getState().layoutByWorktree[WT]).toMatchObject({ type: 'split', ratio: 0.3 })
expect(mocks.send).toHaveBeenCalledWith(
WT,
{
type: 'setGroupRatios',
ratios: [
{
path: '',
ratio: 0.3,
split: expect.objectContaining({ direction: expect.any(String) })
}
]
},
expect.objectContaining({ drawn: true })
)
})
it('sends nothing for an unchanged ratio', () => {
seeded()
setGroupRatioThroughLayoutOwner(WT, '', 0.5)
expect(mocks.send).not.toHaveBeenCalled()
})
})
@@ -1,590 +0,0 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { TabGroup, TabGroupLayoutNode } from '../../../../shared/tab-types'
import type { TabDragItemData } from './useTabDragSplit'
import {
captureTabGroupPanelGeometrySnapshot,
findTabGroupPanelUnderPointer,
resolveActivePaneColumnSplitTarget,
resolvePanelEdgePaneColumnSplit
} from './tab-group-panel-split-target'
import { TAB_GROUP_TAB_STRIP_HEIGHT_PX } from './tab-drop-zone'
function makeDragData(overrides: Partial<TabDragItemData> = {}): TabDragItemData {
return {
kind: 'tab',
worktreeId: 'wt-1',
groupId: 'group-1',
unifiedTabId: 'tab-1',
visibleTabId: 'tab-1',
tabType: 'terminal',
label: 'tab-1',
...overrides
}
}
function makeEvent({
activeData,
overData = null,
pointer = { x: 0, y: 0 }
}: {
activeData: TabDragItemData
overData?: TabDragItemData | null
pointer?: { x: number; y: number }
}) {
return {
active: { data: { current: activeData } },
over: overData
? {
data: { current: overData },
rect: { left: 500, width: 120, top: 0, height: 32 }
}
: null,
delta: { x: 0, y: 0 },
activatorEvent: { clientX: pointer.x, clientY: pointer.y }
} as unknown as Parameters<typeof resolveActivePaneColumnSplitTarget>[0]['event']
}
function mockTabGroupRects(panelRect: DOMRect, bodyRect: DOMRect): void {
vi.stubGlobal('document', {
querySelector: vi.fn(() => ({
getBoundingClientRect: () => bodyRect,
parentElement: {
getBoundingClientRect: () => panelRect
}
})),
querySelectorAll: vi.fn(() => [
{
dataset: {
tabGroupBodyId: 'group-2',
worktreeId: 'wt-1'
}
}
])
})
}
function rect({
left,
top,
width,
height
}: {
left: number
top: number
width: number
height: number
}): DOMRect {
return {
left,
top,
width,
height,
right: left + width,
bottom: top + height
} as DOMRect
}
function mockTabGroupGeometry(
entries: {
groupId: string
panelRect: DOMRect
bodyRect: DOMRect
counts?: { panelReads: number; bodyReads: number }
}[]
): { queryAll: ReturnType<typeof vi.fn> } {
const bodies = entries.map((entry) => ({
dataset: {
tabGroupBodyId: entry.groupId,
worktreeId: 'wt-1'
},
getBoundingClientRect: () => {
if (entry.counts) {
entry.counts.bodyReads += 1
}
return entry.bodyRect
},
parentElement: {
getBoundingClientRect: () => {
if (entry.counts) {
entry.counts.panelReads += 1
}
return entry.panelRect
}
}
}))
const queryAll = vi.fn(() => bodies)
vi.stubGlobal('document', {
querySelector: vi.fn((selector: string) => {
const match = selector.match(/data-tab-group-body-id="([^"]+)"/)
return bodies.find((body) => body.dataset.tabGroupBodyId === match?.[1]) ?? null
}),
querySelectorAll: queryAll
})
return { queryAll }
}
function fourGroupFixture(): {
counts: { panelReads: number; bodyReads: number }[]
groupsByWorktree: Record<string, TabGroup[]>
layoutByWorktree: Record<string, TabGroupLayoutNode>
queryAll: ReturnType<typeof vi.fn>
} {
const counts = [
{ panelReads: 0, bodyReads: 0 },
{ panelReads: 0, bodyReads: 0 },
{ panelReads: 0, bodyReads: 0 },
{ panelReads: 0, bodyReads: 0 }
]
const { queryAll } = mockTabGroupGeometry([
{
groupId: 'group-1',
panelRect: rect({ left: 0, top: 0, width: 300, height: 600 }),
bodyRect: rect({
left: 0,
top: TAB_GROUP_TAB_STRIP_HEIGHT_PX,
width: 300,
height: 600 - TAB_GROUP_TAB_STRIP_HEIGHT_PX
}),
counts: counts[0]
},
{
groupId: 'group-2',
panelRect: rect({ left: 304, top: 0, width: 300, height: 600 }),
bodyRect: rect({
left: 304,
top: TAB_GROUP_TAB_STRIP_HEIGHT_PX,
width: 300,
height: 600 - TAB_GROUP_TAB_STRIP_HEIGHT_PX
}),
counts: counts[1]
},
{
groupId: 'group-3',
panelRect: rect({ left: 608, top: 0, width: 300, height: 600 }),
bodyRect: rect({
left: 608,
top: TAB_GROUP_TAB_STRIP_HEIGHT_PX,
width: 300,
height: 600 - TAB_GROUP_TAB_STRIP_HEIGHT_PX
}),
counts: counts[2]
},
{
groupId: 'group-4',
panelRect: rect({ left: 912, top: 0, width: 300, height: 600 }),
bodyRect: rect({
left: 912,
top: TAB_GROUP_TAB_STRIP_HEIGHT_PX,
width: 300,
height: 600 - TAB_GROUP_TAB_STRIP_HEIGHT_PX
}),
counts: counts[3]
}
])
return {
counts,
queryAll,
groupsByWorktree: {
'wt-1': [
{ id: 'group-1', worktreeId: 'wt-1', activeTabId: 'tab-1', tabOrder: ['tab-1', 'tab-5'] },
{ id: 'group-2', worktreeId: 'wt-1', activeTabId: 'tab-2', tabOrder: ['tab-2'] },
{ id: 'group-3', worktreeId: 'wt-1', activeTabId: 'tab-3', tabOrder: ['tab-3'] },
{ id: 'group-4', worktreeId: 'wt-1', activeTabId: 'tab-4', tabOrder: ['tab-4'] }
]
},
layoutByWorktree: {
'wt-1': {
type: 'split',
direction: 'horizontal',
ratio: 0.5,
first: {
type: 'split',
direction: 'horizontal',
ratio: 0.5,
first: { type: 'leaf', groupId: 'group-1' },
second: { type: 'leaf', groupId: 'group-2' }
},
second: {
type: 'split',
direction: 'horizontal',
ratio: 0.5,
first: { type: 'leaf', groupId: 'group-3' },
second: { type: 'leaf', groupId: 'group-4' }
}
}
}
}
}
const horizontalLayout: TabGroupLayoutNode = {
type: 'split',
direction: 'horizontal',
ratio: 0.5,
first: { type: 'leaf', groupId: 'group-1' },
second: { type: 'leaf', groupId: 'group-2' }
}
const twoGroupLayout = {
'wt-1': [
{ id: 'group-1', worktreeId: 'wt-1', activeTabId: 'tab-1', tabOrder: ['tab-1'] },
{ id: 'group-2', worktreeId: 'wt-1', activeTabId: 'tab-2', tabOrder: ['tab-2'] }
]
}
const layoutByWorktree = { 'wt-1': horizontalLayout }
describe('resolvePanelEdgePaneColumnSplit', () => {
const panelRect = {
left: 500,
top: 0,
width: 400,
height: 600,
right: 900,
bottom: 600
} as DOMRect
const bodyRect = {
left: 500,
top: TAB_GROUP_TAB_STRIP_HEIGHT_PX,
width: 400,
height: 600 - TAB_GROUP_TAB_STRIP_HEIGHT_PX,
right: 900,
bottom: 600
} as DOMRect
beforeEach(() => {
mockTabGroupRects(panelRect, bodyRect)
})
afterEach(() => {
vi.unstubAllGlobals()
})
it('returns a right-edge split when the pointer is on the outer band', () => {
expect(
resolvePanelEdgePaneColumnSplit({
activeDrag: makeDragData({ groupId: 'group-1' }),
targetGroupId: 'group-2',
worktreeId: 'wt-1',
pointer: { x: 880, y: 300 },
groupsByWorktree: twoGroupLayout,
layoutByWorktree
})
).toEqual({ groupId: 'group-2', zone: 'right' })
})
it('returns null in the center band so cross-group tab insertion can win', () => {
expect(
resolvePanelEdgePaneColumnSplit({
activeDrag: makeDragData({ groupId: 'group-1' }),
targetGroupId: 'group-2',
worktreeId: 'wt-1',
pointer: { x: 700, y: 300 },
groupsByWorktree: twoGroupLayout,
layoutByWorktree
})
).toBeNull()
})
it('does not treat the tab strip as a top split edge', () => {
expect(
resolvePanelEdgePaneColumnSplit({
activeDrag: makeDragData({ groupId: 'group-2' }),
targetGroupId: 'group-1',
worktreeId: 'wt-1',
pointer: { x: 650, y: 16 },
groupsByWorktree: twoGroupLayout,
layoutByWorktree
})
).toBeNull()
})
it('suppresses adjacent sibling split drops that would collapse back to the current layout', () => {
const group1Panel = {
left: 0,
top: 0,
width: 400,
height: 600,
right: 400,
bottom: 600
} as DOMRect
expect(
resolvePanelEdgePaneColumnSplit({
activeDrag: makeDragData({ groupId: 'group-2', unifiedTabId: 'tab-2' }),
targetGroupId: 'group-1',
worktreeId: 'wt-1',
pointer: { x: 360, y: 300 },
groupsByWorktree: twoGroupLayout,
layoutByWorktree,
panelRect: group1Panel
})
).toBeNull()
})
it('rejects stale panel targets when the pointer has left the panel bounds', () => {
expect(
resolvePanelEdgePaneColumnSplit({
activeDrag: makeDragData({ groupId: 'group-1' }),
targetGroupId: 'group-2',
worktreeId: 'wt-1',
pointer: { x: 499, y: 300 },
groupsByWorktree: {
'wt-1': [
{ ...twoGroupLayout['wt-1'][0], tabOrder: ['tab-1', 'tab-3'] },
twoGroupLayout['wt-1'][1]
]
},
layoutByWorktree,
panelRect
})
).toBeNull()
})
})
describe('resolveActivePaneColumnSplitTarget', () => {
const panelRect = {
left: 500,
top: 0,
width: 400,
height: 600,
right: 900,
bottom: 600
} as DOMRect
const bodyRect = {
left: 500,
top: TAB_GROUP_TAB_STRIP_HEIGHT_PX,
width: 400,
height: 600 - TAB_GROUP_TAB_STRIP_HEIGHT_PX,
right: 900,
bottom: 600
} as DOMRect
beforeEach(() => {
mockTabGroupRects(panelRect, bodyRect)
})
afterEach(() => {
vi.unstubAllGlobals()
})
it('resolves cross-group panel-edge splits without an dnd-kit over target', () => {
expect(findTabGroupPanelUnderPointer('wt-1', { x: 880, y: 300 })?.groupId).toBe('group-2')
expect(
resolveActivePaneColumnSplitTarget({
event: makeEvent({
activeData: makeDragData({ groupId: 'group-1' }),
overData: null,
pointer: { x: 880, y: 300 }
}),
groupsByWorktree: twoGroupLayout,
layoutByWorktree,
worktreeId: 'wt-1',
getDragPointer: () => ({ x: 880, y: 300 })
})
).toEqual(expect.objectContaining({ groupId: 'group-2', zone: 'right' }))
})
it('skips pane-edge splits for cross-group tab-strip hovers', () => {
expect(
resolveActivePaneColumnSplitTarget({
event: makeEvent({
activeData: makeDragData({ groupId: 'group-2', unifiedTabId: 'tab-2' }),
overData: makeDragData({
groupId: 'group-1',
unifiedTabId: 'tab-1',
visibleTabId: 'tab-1'
}),
pointer: { x: 650, y: 16 }
}),
groupsByWorktree: twoGroupLayout,
layoutByWorktree,
worktreeId: 'wt-1',
getDragPointer: () => ({ x: 650, y: 16 })
})
).toBeNull()
})
it('resolves body-edge splits when a stale cross-group tab hover remains active', () => {
expect(
resolveActivePaneColumnSplitTarget({
event: makeEvent({
activeData: makeDragData({ groupId: 'group-1' }),
overData: makeDragData({
groupId: 'group-2',
unifiedTabId: 'tab-2',
visibleTabId: 'tab-2'
}),
pointer: { x: 880, y: 300 }
}),
groupsByWorktree: twoGroupLayout,
layoutByWorktree,
worktreeId: 'wt-1',
getDragPointer: () => ({ x: 880, y: 300 })
})
).toEqual(expect.objectContaining({ groupId: 'group-2', zone: 'right' }))
})
it('ignores stale over targets after the pointer leaves the cached panel', () => {
const geometry = {
entries: [
{
groupId: 'group-2',
panelRect,
bodyRect
}
],
byGroupId: new Map([['group-2', { groupId: 'group-2', panelRect, bodyRect }]])
}
expect(
resolveActivePaneColumnSplitTarget({
event: makeEvent({
activeData: makeDragData({ groupId: 'group-1' }),
overData: makeDragData({
groupId: 'group-2',
unifiedTabId: 'tab-2',
visibleTabId: 'tab-2'
}),
pointer: { x: 499, y: 300 }
}),
groupsByWorktree: twoGroupLayout,
layoutByWorktree,
worktreeId: 'wt-1',
getDragPointer: () => ({ x: 499, y: 300 }),
geometry
})
).toBeNull()
})
it('does not create a split target while hovering over another tab in the same strip', () => {
const panelRect = rect({ left: 500, top: 0, width: 400, height: 600 })
mockTabGroupGeometry([
{
groupId: 'group-1',
panelRect,
bodyRect: rect({
left: 500,
top: TAB_GROUP_TAB_STRIP_HEIGHT_PX,
width: 400,
height: 600 - TAB_GROUP_TAB_STRIP_HEIGHT_PX
})
}
])
const geometry = captureTabGroupPanelGeometrySnapshot('wt-1')
expect(
resolveActivePaneColumnSplitTarget({
event: makeEvent({
activeData: makeDragData({ groupId: 'group-1', unifiedTabId: 'tab-1' }),
overData: makeDragData({
groupId: 'group-1',
unifiedTabId: 'tab-2',
visibleTabId: 'tab-2'
}),
pointer: { x: 560, y: 16 }
}),
groupsByWorktree: {
'wt-1': [
{
id: 'group-1',
worktreeId: 'wt-1',
activeTabId: 'tab-1',
tabOrder: ['tab-1', 'tab-2']
}
]
},
layoutByWorktree: {
'wt-1': { type: 'leaf', groupId: 'group-1' }
},
worktreeId: 'wt-1',
getDragPointer: () => ({ x: 560, y: 16 }),
geometry
})
).toBeNull()
})
it('ignores same-group tab hovers after the pointer leaves the panel', () => {
const panelRect = rect({ left: 500, top: 0, width: 400, height: 600 })
mockTabGroupGeometry([
{
groupId: 'group-1',
panelRect,
bodyRect: rect({
left: 500,
top: TAB_GROUP_TAB_STRIP_HEIGHT_PX,
width: 400,
height: 600 - TAB_GROUP_TAB_STRIP_HEIGHT_PX
})
}
])
const geometry = captureTabGroupPanelGeometrySnapshot('wt-1')
expect(
resolveActivePaneColumnSplitTarget({
event: makeEvent({
activeData: makeDragData({ groupId: 'group-1', unifiedTabId: 'tab-1' }),
overData: makeDragData({
groupId: 'group-1',
unifiedTabId: 'tab-2',
visibleTabId: 'tab-2'
}),
pointer: { x: 560, y: 601 }
}),
groupsByWorktree: {
'wt-1': [
{
id: 'group-1',
worktreeId: 'wt-1',
activeTabId: 'tab-1',
tabOrder: ['tab-1', 'tab-2']
}
]
},
layoutByWorktree: {
'wt-1': { type: 'leaf', groupId: 'group-1' }
},
worktreeId: 'wt-1',
getDragPointer: () => ({ x: 560, y: 601 }),
geometry
})
).toBeNull()
})
it('reuses one geometry snapshot across drag frames instead of measuring layout per move', () => {
const {
counts,
groupsByWorktree,
layoutByWorktree: fourGroupLayoutByWorktree,
queryAll
} = fourGroupFixture()
const geometry = captureTabGroupPanelGeometrySnapshot('wt-1')
const points = [
{ x: 295, y: 300 },
{ x: 360, y: 300 },
{ x: 595, y: 300 },
{ x: 664, y: 300 },
{ x: 899, y: 300 },
{ x: 968, y: 300 },
{ x: 1204, y: 300 }
]
for (const pointer of points) {
resolveActivePaneColumnSplitTarget({
event: makeEvent({
activeData: makeDragData({ groupId: 'group-1' }),
overData: null,
pointer
}),
groupsByWorktree,
layoutByWorktree: fourGroupLayoutByWorktree,
worktreeId: 'wt-1',
getDragPointer: () => pointer,
geometry
})
}
expect(queryAll).toHaveBeenCalledTimes(1)
expect(counts.reduce((sum, entry) => sum + entry.panelReads + entry.bodyReads, 0)).toBe(8)
})
})
@@ -1,245 +0,0 @@
import { describe, expect, it } from 'vitest'
import {
resolveTabIndicatorEdges,
resolveTabInsertion,
type HoveredTabInsertion
} from './tab-insertion'
import type { TabDragItemData } from './useTabDragSplit'
function makeDragData(overrides: Partial<TabDragItemData> = {}): TabDragItemData {
return {
kind: 'tab',
worktreeId: 'wt-1',
groupId: 'group-1',
unifiedTabId: 'tab-active',
visibleTabId: 'tab-active',
tabType: 'editor',
label: 'file.ts',
...overrides
}
}
function isTabDragData(value: unknown): value is TabDragItemData {
return Boolean(value) && typeof value === 'object' && (value as TabDragItemData).kind === 'tab'
}
function makeDragEvent({
activeData,
overData,
overRect
}: {
activeData: TabDragItemData | null
overData: TabDragItemData | null
overRect?: { left: number; width: number }
}) {
return {
active: { data: { current: activeData } },
over: overData
? {
data: { current: overData },
rect: { left: overRect?.left ?? 0, width: overRect?.width ?? 100 }
}
: null,
delta: { x: 0, y: 0 }
} as unknown as Parameters<typeof resolveTabInsertion>[0]
}
describe('resolveTabInsertion', () => {
it('returns null when there is no "over" element', () => {
const event = makeDragEvent({ activeData: makeDragData(), overData: null })
expect(resolveTabInsertion(event, isTabDragData, () => ({ x: 50, y: 10 }))).toBeNull()
})
it('returns null when active data is not tab drag data', () => {
const event = makeDragEvent({
activeData: null,
overData: makeDragData({ unifiedTabId: 'tab-over', visibleTabId: 'tab-over' })
})
expect(resolveTabInsertion(event, isTabDragData, () => ({ x: 50, y: 10 }))).toBeNull()
})
it('returns null when over data is not tab drag data', () => {
const event = {
active: { data: { current: makeDragData() } },
over: { data: { current: { kind: 'pane-body' } }, rect: { left: 0, width: 100 } },
delta: { x: 0, y: 0 }
} as unknown as Parameters<typeof resolveTabInsertion>[0]
expect(resolveTabInsertion(event, isTabDragData, () => ({ x: 50, y: 10 }))).toBeNull()
})
it('returns null when dragging a tab onto itself', () => {
const data = makeDragData({ unifiedTabId: 'same-tab', visibleTabId: 'same-tab' })
const event = makeDragEvent({ activeData: data, overData: data })
expect(resolveTabInsertion(event, isTabDragData, () => ({ x: 50, y: 10 }))).toBeNull()
})
it('returns null when getDragCenter returns null', () => {
const event = makeDragEvent({
activeData: makeDragData({ unifiedTabId: 'tab-a' }),
overData: makeDragData({ unifiedTabId: 'tab-b', visibleTabId: 'tab-b', groupId: 'group-2' })
})
expect(resolveTabInsertion(event, isTabDragData, () => null)).toBeNull()
})
it('returns side "left" when cursor is in the left reorder edge', () => {
const overData = makeDragData({
unifiedTabId: 'tab-over',
visibleTabId: 'tab-over',
groupId: 'group-2'
})
const event = makeDragEvent({
activeData: makeDragData({ unifiedTabId: 'tab-active' }),
overData,
overRect: { left: 100, width: 100 }
})
const result = resolveTabInsertion(event, isTabDragData, () => ({ x: 120, y: 10 }))
expect(result).toEqual({
groupId: 'group-2',
visibleTabId: 'tab-over',
side: 'left'
})
})
it('returns side "right" when cursor is in the right reorder edge', () => {
const overData = makeDragData({
unifiedTabId: 'tab-over',
visibleTabId: 'tab-over',
groupId: 'group-2'
})
const event = makeDragEvent({
activeData: makeDragData({ unifiedTabId: 'tab-active' }),
overData,
overRect: { left: 100, width: 100 }
})
const result = resolveTabInsertion(event, isTabDragData, () => ({ x: 180, y: 10 }))
expect(result).toEqual({
groupId: 'group-2',
visibleTabId: 'tab-over',
side: 'right'
})
})
it('uses midpoint insertion when reordering within the same pane', () => {
const overData = makeDragData({
unifiedTabId: 'tab-over',
visibleTabId: 'tab-over',
groupId: 'group-1'
})
const event = makeDragEvent({
activeData: makeDragData({ unifiedTabId: 'tab-active', groupId: 'group-1' }),
overData,
overRect: { left: 0, width: 200 }
})
expect(resolveTabInsertion(event, isTabDragData, () => ({ x: 100, y: 10 }))).toEqual({
groupId: 'group-1',
visibleTabId: 'tab-over',
side: 'right'
})
})
it('uses midpoint insertion when dragging across split panes', () => {
const overData = makeDragData({
unifiedTabId: 'tab-over',
visibleTabId: 'tab-over',
groupId: 'group-2'
})
const event = makeDragEvent({
activeData: makeDragData({ unifiedTabId: 'tab-active', groupId: 'group-1' }),
overData,
overRect: { left: 0, width: 200 }
})
expect(resolveTabInsertion(event, isTabDragData, () => ({ x: 80, y: 10 }))).toEqual({
groupId: 'group-2',
visibleTabId: 'tab-over',
side: 'left'
})
})
})
describe('resolveTabIndicatorEdges', () => {
it('marks one edge for a left-edge insertion slot', () => {
const hovered: HoveredTabInsertion = {
groupId: 'group-1',
visibleTabId: 'tab-2',
side: 'left'
}
expect(resolveTabIndicatorEdges(['tab-1', 'tab-2', 'tab-3'], hovered)).toEqual([
{ visibleTabId: 'tab-2', side: 'left' }
])
})
it('marks one edge for a right-edge insertion slot', () => {
const hovered: HoveredTabInsertion = {
groupId: 'group-1',
visibleTabId: 'tab-2',
side: 'right'
}
expect(resolveTabIndicatorEdges(['tab-1', 'tab-2', 'tab-3'], hovered)).toEqual([
{ visibleTabId: 'tab-3', side: 'left' }
])
})
it('keeps a single edge marker at the strip boundaries', () => {
expect(
resolveTabIndicatorEdges(['tab-1', 'tab-2'], {
groupId: 'group-1',
visibleTabId: 'tab-1',
side: 'left'
})
).toEqual([{ visibleTabId: 'tab-1', side: 'left' }])
expect(
resolveTabIndicatorEdges(['tab-1', 'tab-2'], {
groupId: 'group-1',
visibleTabId: 'tab-2',
side: 'right'
})
).toEqual([{ visibleTabId: 'tab-2', side: 'right' }])
})
it('returns empty array when hoveredTabInsertion is null', () => {
expect(resolveTabIndicatorEdges(['tab-1', 'tab-2'], null)).toEqual([])
})
it('returns empty array when orderedVisibleTabIds is empty', () => {
expect(
resolveTabIndicatorEdges([], {
groupId: 'group-1',
visibleTabId: 'tab-1',
side: 'left'
})
).toEqual([])
})
it('returns empty array when visibleTabId is not in the list', () => {
expect(
resolveTabIndicatorEdges(['tab-1', 'tab-2'], {
groupId: 'group-1',
visibleTabId: 'tab-unknown',
side: 'left'
})
).toEqual([])
})
it('handles a single-tab strip with left insertion', () => {
expect(
resolveTabIndicatorEdges(['only-tab'], {
groupId: 'group-1',
visibleTabId: 'only-tab',
side: 'left'
})
).toEqual([{ visibleTabId: 'only-tab', side: 'left' }])
})
it('handles a single-tab strip with right insertion', () => {
expect(
resolveTabIndicatorEdges(['only-tab'], {
groupId: 'group-1',
visibleTabId: 'only-tab',
side: 'right'
})
).toEqual([{ visibleTabId: 'only-tab', side: 'right' }])
})
})
@@ -12,7 +12,6 @@ import { loadMainLayoutModel } from '@/runtime/main-loaded-layout-frames.test-fi
import { endLayoutFramePositions } from '@/runtime/layout-frame-position'
import { openWorkspaceLayoutFrameSync } from '@/runtime/workspace-layout-frame-sync'
import { dropTabThroughLayoutOwner } from './tab-group-layout-owner-commands'
import { moveTabToNewPaneColumn } from '../tab-bar/tab-move-to-pane-column'
vi.mock('../../../../shared/layout-owner-switch', () => ({ ORCA_LAYOUT_OWNER_IS_MAIN: true }))
// Simulate main’s layout-command answers and frames.
@@ -152,54 +151,4 @@ describe('a tab move drawn from main’s frame', () => {
expect(useAppStore.getState().activeGroupIdByWorktree[GIT_KEY]).toBe('group-b')
expect(groupOf('group-a')!.activeTabId).toBe(selected.find(([id]) => id === 'group-a')![1])
})
it('splits the tab into the group main made beside its target, and selects it there', async () => {
const groupsBefore = useAppStore.getState().groupsByWorktree[GIT_KEY]!.map((group) => group.id)
dropTabThroughLayoutOwner(GIT_KEY, {
kind: 'split',
tabId: 'tab-unbound',
groupId: 'group-a',
direction: 'right'
})
await settle()
const mainGroup = mainGroupOf('tab-unbound')
expect(groupsBefore).not.toContain(mainGroup.id)
expect(tabOf('tab-unbound').groupId).toBe(mainGroup.id)
expect(groupOf(mainGroup.id)!.tabOrder).toEqual(['tab-unbound'])
expect(useAppStore.getState().layoutByWorktree[GIT_KEY]).toEqual(
layoutApplyTestTransport.mainModel!.workspaces[GIT_KEY]!.groupLayout
)
expect(useAppStore.getState().activeGroupIdByWorktree[GIT_KEY]).toBe(mainGroup.id)
expect(windowTabWrites()).toBe(0)
})
it('moves a tab to a split from its menu where main placed it', async () => {
expect(
moveTabToNewPaneColumn({ unifiedTabId: 'tab-shell', groupId: 'group-a', direction: 'down' })
).toBe(true)
await settle()
expect(layoutApplyTestTransport.sentOfType('splitGroup')).toEqual([
expect.objectContaining({ tabId: 'tab-shell', besideGroupId: 'group-a', direction: 'down' })
])
const mainGroup = mainGroupOf('tab-shell')
expect(mainGroup.id).not.toBe('group-a')
expect(tabOf('tab-shell').groupId).toBe(mainGroup.id)
expect(useAppStore.getState().activeGroupIdByWorktree[GIT_KEY]).toBe(mainGroup.id)
expect(windowTabWrites()).toBe(0)
})
it('reorders the tab within its group as main ordered it', async () => {
dropTabThroughLayoutOwner(GIT_KEY, {
kind: 'reorder',
tabId: 'tab-shell',
groupId: 'group-a',
tabOrder: ['tab-agent', 'tab-shell', 'editor:src/app.ts']
})
await settle()
expect(groupOf('group-a')!.tabOrder).toEqual(['tab-agent', 'tab-shell', 'editor:src/app.ts'])
expect(windowTabWrites()).toBe(0)
})
})
@@ -1,469 +0,0 @@
/**
* @vitest-environment happy-dom
*/
import { act, createElement } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { Tab, TabGroup, TabGroupLayoutNode } from '../../../../shared/tab-types'
import { useAppStore } from '../../store'
import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../../shared/layout-owner-switch'
import type { TabDragItemData } from './useTabDragSplit'
import { shouldActivateTabDragFromDistanceSample } from './tab-drag-pointer-sensor'
import {
canDropTabForPaneColumnSplit,
canDropTabIntoPaneBody,
getTabDragActivationDistance,
TAB_DRAG_ACTIVATION_DISTANCE_PX,
useTabDragSplit
} from './useTabDragSplit'
globalThis.IS_REACT_ACT_ENVIRONMENT = true
vi.mock('../browser-pane/host-guest/webview-registry', () => ({
acquireWebviewsDragPassthrough: vi.fn(() => vi.fn())
}))
vi.mock('../../runtime/web-runtime-session-environment', () => ({
isWebRuntimeSessionActive: vi.fn(() => false)
}))
vi.mock('../../runtime/web-runtime-session-tab-move', () => ({ moveWebRuntimeSessionTab: vi.fn() }))
// A layout-owner build sends the drop to main, whose frame draws it (tab-moves-drawn-from-frames).
const layoutOwner = vi.hoisted(() => ({ drop: vi.fn(() => true) }))
vi.mock('./tab-group-layout-owner-commands', async (importOriginal) => ({
...(await importOriginal<object>()),
dropTabThroughLayoutOwner: layoutOwner.drop
}))
const WT = 'wt-1'
/** The drop split tab-1 off beside group-2: through main in a layout-owner build, else here. */
function expectSplitBesideGroup2(dropUnifiedTab: ReturnType<typeof vi.fn>): void {
if (ORCA_LAYOUT_OWNER_IS_MAIN) {
expect(layoutOwner.drop).toHaveBeenCalledWith(WT, {
kind: 'split',
tabId: 'tab-1',
groupId: 'group-2',
direction: 'right'
})
expect(dropUnifiedTab).not.toHaveBeenCalled()
return
}
expect(dropUnifiedTab).toHaveBeenCalledWith('tab-1', {
groupId: 'group-2',
splitDirection: 'right'
})
}
const mounted: { container: HTMLDivElement; root: Root }[] = []
function makeGroup(id: string, tabOrder: string[]): TabGroup {
return {
id,
worktreeId: WT,
activeTabId: tabOrder[0] ?? null,
tabOrder
}
}
function makeDragData(groupId: string, unifiedTabId = 'tab-1'): TabDragItemData {
return {
kind: 'tab',
worktreeId: WT,
groupId,
unifiedTabId,
visibleTabId: unifiedTabId,
tabType: 'editor',
label: unifiedTabId
}
}
function rect({
left,
top,
width,
height
}: {
left: number
top: number
width: number
height: number
}): DOMRect {
return {
left,
top,
width,
height,
right: left + width,
bottom: top + height
} as DOMRect
}
function addPanelGeometry(groupId: string, panelRect: DOMRect, bodyRect: DOMRect): HTMLElement {
const panel = document.createElement('div')
const body = document.createElement('div')
body.dataset.tabGroupBodyId = groupId
body.dataset.worktreeId = WT
panel.getBoundingClientRect = () => panelRect
body.getBoundingClientRect = () => bodyRect
panel.appendChild(body)
document.body.appendChild(panel)
return panel
}
function makeDragEvent(activeData: TabDragItemData, pointer: { x: number; y: number }) {
return {
active: {
data: { current: activeData },
rect: { current: { initial: null } }
},
over: null,
delta: { x: 0, y: 0 },
activatorEvent: { clientX: pointer.x, clientY: pointer.y }
}
}
function renderDragHook(
onRender?: (drag: ReturnType<typeof useTabDragSplit>) => void
): ReturnType<typeof useTabDragSplit> {
let result: ReturnType<typeof useTabDragSplit> | null = null
function Probe(): null {
result = useTabDragSplit({ worktreeId: WT })
onRender?.(result)
return null
}
const container = document.createElement('div')
document.body.appendChild(container)
const root = createRoot(container)
act(() => root.render(createElement(Probe)))
mounted.push({ container, root })
if (!result) {
throw new Error('useTabDragSplit did not render')
}
return result
}
beforeEach(() => {
layoutOwner.drop.mockClear()
useAppStore.setState({
activeWorktreeId: WT,
activeGroupIdByWorktree: { [WT]: 'group-1' },
groupsByWorktree: {
[WT]: [makeGroup('group-1', ['tab-1', 'tab-3']), makeGroup('group-2', ['tab-2'])]
},
unifiedTabsByWorktree: {
[WT]: [
{
id: 'tab-1',
groupId: 'group-1',
worktreeId: WT,
contentType: 'terminal',
entityId: 'term-1',
label: 'one',
customLabel: null,
color: null,
sortOrder: 0,
createdAt: 0
} satisfies Tab,
{
id: 'tab-2',
groupId: 'group-2',
worktreeId: WT,
contentType: 'terminal',
entityId: 'term-2',
label: 'two',
customLabel: null,
color: null,
sortOrder: 1,
createdAt: 1
} satisfies Tab,
{
id: 'tab-3',
groupId: 'group-1',
worktreeId: WT,
contentType: 'terminal',
entityId: 'term-3',
label: 'three',
customLabel: null,
color: null,
sortOrder: 2,
createdAt: 2
} satisfies Tab
]
},
layoutByWorktree: {
[WT]: {
type: 'split',
direction: 'horizontal',
ratio: 0.5,
first: { type: 'leaf', groupId: 'group-1' },
second: { type: 'leaf', groupId: 'group-2' }
} satisfies TabGroupLayoutNode
}
})
})
afterEach(() => {
for (const { container, root } of mounted.splice(0)) {
act(() => root.unmount())
container.remove()
}
document.body.replaceChildren()
vi.clearAllMocks()
})
describe('tab drag activation distance', () => {
it('uses the named threshold for enabled tab drags', () => {
expect(getTabDragActivationDistance(true)).toBe(TAB_DRAG_ACTIVATION_DISTANCE_PX)
})
it('keeps enabled tab drags above the old overly-sensitive distance', () => {
expect(TAB_DRAG_ACTIVATION_DISTANCE_PX).toBeGreaterThan(5)
})
it('uses an impossible activation distance when tab dragging is disabled', () => {
expect(getTabDragActivationDistance(false)).toBe(Number.MAX_SAFE_INTEGER)
})
it('requires confirmation for an immediate over-threshold distance sample', () => {
expect(
shouldActivateTabDragFromDistanceSample({
elapsedMs: 10,
overThresholdSampleCount: 1
})
).toBe(false)
expect(
shouldActivateTabDragFromDistanceSample({
elapsedMs: 10,
overThresholdSampleCount: 2
})
).toBe(true)
expect(
shouldActivateTabDragFromDistanceSample({
elapsedMs: 60,
overThresholdSampleCount: 1
})
).toBe(true)
})
})
describe('canDropTabIntoPaneBody', () => {
it('rejects pane-body drops that would split a single tab onto itself', () => {
expect(
canDropTabIntoPaneBody({
activeDrag: makeDragData('group-1'),
groupsByWorktree: { [WT]: [makeGroup('group-1', ['tab-1'])] },
overGroupId: 'group-1',
worktreeId: WT
})
).toBe(false)
})
it('allows same-group pane-body drops when the group still has other tabs', () => {
expect(
canDropTabIntoPaneBody({
activeDrag: makeDragData('group-1'),
groupsByWorktree: { [WT]: [makeGroup('group-1', ['tab-1', 'tab-2'])] },
overGroupId: 'group-1',
worktreeId: WT
})
).toBe(true)
})
it('allows pane-body drops into a different group', () => {
expect(
canDropTabIntoPaneBody({
activeDrag: makeDragData('group-1'),
groupsByWorktree: {
[WT]: [makeGroup('group-1', ['tab-1']), makeGroup('group-2', ['tab-2'])]
},
overGroupId: 'group-2',
worktreeId: WT
})
).toBe(true)
})
it('rejects tab-on-tab split drops across groups', () => {
expect(
canDropTabForPaneColumnSplit({
activeDrag: makeDragData('group-1'),
groupsByWorktree: {
[WT]: [makeGroup('group-1', ['tab-1']), makeGroup('group-2', ['tab-2'])]
},
targetGroupId: 'group-2',
worktreeId: WT
})
).toBe(false)
})
})
describe('useTabDragSplit', () => {
it('keeps the drag sensors across re-renders until enablement changes', () => {
const sensorsByRender: ReturnType<typeof useTabDragSplit>['sensors'][] = []
function Probe({ enabled }: { enabled: boolean }): null {
sensorsByRender.push(useTabDragSplit({ worktreeId: WT, enabled }).sensors)
return null
}
const container = document.createElement('div')
document.body.appendChild(container)
const root = createRoot(container)
mounted.push({ container, root })
act(() => root.render(createElement(Probe, { enabled: true })))
act(() => root.render(createElement(Probe, { enabled: true })))
// Why: new sensors rebuild every tab's drag listeners, which re-renders every tab.
expect(sensorsByRender.at(-1)).toBe(sensorsByRender.at(-2))
act(() => root.render(createElement(Probe, { enabled: false })))
expect(sensorsByRender.at(-1)).not.toBe(sensorsByRender.at(-2))
})
it.each(['split', 'insertion'])(
'does not restore a %s preview after blur cleared the drag',
async (preview) => {
if (preview === 'split') {
addPanelGeometry(
'group-2',
rect({ left: 500, top: 0, width: 400, height: 600 }),
rect({ left: 500, top: 32, width: 400, height: 568 })
)
}
const onRender = vi.fn()
const drag = renderDragHook(onRender)
const event = {
...makeDragEvent(makeDragData('group-1'), { x: 880, y: 300 }),
...(preview === 'insertion'
? {
over: {
data: { current: makeDragData('group-2', 'tab-2') },
rect: rect({ left: 500, top: 0, width: 400, height: 32 })
}
}
: {})
}
const previewKey = preview === 'split' ? 'hoveredDropTarget' : 'hoveredTabInsertion'
act(() => drag.onDragStart(event as unknown as Parameters<typeof drag.onDragStart>[0]))
act(() => drag.onDragMove(event as unknown as Parameters<typeof drag.onDragMove>[0]))
expect(onRender.mock.lastCall?.[0][previewKey]).not.toBeNull()
await act(async () => {
window.dispatchEvent(new Event('blur'))
await new Promise((resolve) => window.setTimeout(resolve, 0))
})
expect(onRender.mock.lastCall?.[0][previewKey]).toBeNull()
act(() => drag.onDragMove(event as unknown as Parameters<typeof drag.onDragMove>[0]))
expect(onRender.mock.lastCall?.[0][previewKey]).toBeNull()
expect(drag.isTabDragActiveRef.current).toBe(false)
}
)
it('does not commit a drop after blur cleared the drag', async () => {
addPanelGeometry(
'group-2',
rect({ left: 500, top: 0, width: 400, height: 600 }),
rect({ left: 500, top: 32, width: 400, height: 568 })
)
const dropUnifiedTab = vi.fn(() => true)
const reorderUnifiedTabs = vi.fn()
useAppStore.setState({ dropUnifiedTab, reorderUnifiedTabs })
const drag = renderDragHook()
const event = makeDragEvent(makeDragData('group-1'), { x: 880, y: 300 })
act(() => drag.onDragStart(event as unknown as Parameters<typeof drag.onDragStart>[0]))
await act(async () => {
window.dispatchEvent(new Event('blur'))
await new Promise((resolve) => window.setTimeout(resolve, 0))
})
act(() => drag.onDragEnd(event as unknown as Parameters<typeof drag.onDragEnd>[0]))
expect(dropUnifiedTab).not.toHaveBeenCalled()
expect(reorderUnifiedTabs).not.toHaveBeenCalled()
})
it.each(['pointerup', 'pointercancel', 'blur', 'focus'])(
'clears a stuck active drag when %s arrives without a dnd end event',
async (eventName) => {
const activeData = makeDragData('group-1')
const drag = renderDragHook()
act(() => {
drag.onDragStart(
makeDragEvent(activeData, { x: 120, y: 20 }) as unknown as Parameters<
typeof drag.onDragStart
>[0]
)
// Dispatch before passive effects to verify missed-end listeners are installed synchronously.
window.dispatchEvent(new MouseEvent(eventName, { bubbles: true }))
})
expect(drag.isTabDragActiveRef.current).toBe(true)
await act(async () => {
await new Promise((resolve) => window.setTimeout(resolve, 0))
})
expect(drag.isTabDragActiveRef.current).toBe(false)
}
)
it('does not cancel a legitimate drag end when the fallback timer is pending', async () => {
addPanelGeometry(
'group-2',
rect({ left: 500, top: 0, width: 400, height: 600 }),
rect({ left: 500, top: 32, width: 400, height: 568 })
)
const activeData = makeDragData('group-1')
const dropUnifiedTab = vi.fn(() => true)
useAppStore.setState({ dropUnifiedTab } as Partial<ReturnType<typeof useAppStore.getState>>)
const drag = renderDragHook()
act(() => {
drag.onDragStart(
makeDragEvent(activeData, { x: 120, y: 20 }) as unknown as Parameters<
typeof drag.onDragStart
>[0]
)
window.dispatchEvent(new MouseEvent('pointerup', { bubbles: true }))
drag.onDragEnd(
makeDragEvent(activeData, { x: 880, y: 300 }) as unknown as Parameters<
typeof drag.onDragEnd
>[0]
)
})
await act(async () => {
await new Promise((resolve) => window.setTimeout(resolve, 0))
})
expect(drag.isTabDragActiveRef.current).toBe(false)
expectSplitBesideGroup2(dropUnifiedTab)
})
it('commits a geometry-only pane split when drag end has no over target', () => {
addPanelGeometry(
'group-2',
rect({ left: 500, top: 0, width: 400, height: 600 }),
rect({ left: 500, top: 32, width: 400, height: 568 })
)
const activeData = makeDragData('group-1')
const dropUnifiedTab = vi.fn(() => true)
useAppStore.setState({ dropUnifiedTab } as Partial<ReturnType<typeof useAppStore.getState>>)
const drag = renderDragHook()
act(() => {
drag.onDragStart(
makeDragEvent(activeData, { x: 880, y: 300 }) as unknown as Parameters<
typeof drag.onDragStart
>[0]
)
})
act(() => {
drag.onDragEnd(
makeDragEvent(activeData, { x: 880, y: 300 }) as unknown as Parameters<
typeof drag.onDragEnd
>[0]
)
})
expectSplitBesideGroup2(dropUnifiedTab)
})
})
@@ -1,74 +0,0 @@
// @vitest-environment happy-dom
import { renderHook } from '@testing-library/react'
import { expect, it } from 'vitest'
import type { Tab } from '../../../../shared/tab-types'
import type { TerminalTab } from '../../../../shared/workspace-layout/terminal-tab-types'
import { tabItemLabel } from '../tab-bar/tab-bar-item-label'
import { useTabGroupItemProjections } from './useTabGroupItemProjections'
const aiVaultTitle = { agent: 'claude' as const, sessionId: 'session-1', title: 'fix-tab-title' }
const unifiedTab: Tab = {
id: 'unified-1',
entityId: 'terminal-1',
groupId: 'group-1',
worktreeId: 'wt-1',
contentType: 'terminal',
label: 'fix-tab-title',
generatedLabel: 'First prompt',
aiVaultTitle,
customLabel: null,
color: null,
sortOrder: 0,
createdAt: 0
}
const terminalTab: TerminalTab = {
id: 'terminal-1',
ptyId: null,
worktreeId: 'wt-1',
title: 'fix-tab-title',
generatedTitle: 'First prompt',
aiVaultTitle,
customTitle: null,
color: null,
sortOrder: 0,
createdAt: 0
}
it('labels a terminal tab with its session title over the first-prompt title', () => {
const { result } = renderHook(() =>
useTabGroupItemProjections({
groupId: 'group-1',
worktreeId: 'wt-1',
worktreeState: {
groups: [
{ id: 'group-1', worktreeId: 'wt-1', activeTabId: 'unified-1', tabOrder: ['unified-1'] }
],
unifiedTabs: [unifiedTab],
terminalTabs: [terminalTab],
openFiles: [],
browserTabs: [],
expandedPaneByTabId: {},
terminalLayoutsByTabId: {},
generatedTabTitlesEnabled: true,
mobileEmulatorEnabled: false
}
})
)
const [projected] = result.current.terminalTabs
expect(
tabItemLabel(
{
type: 'terminal',
id: projected.id,
unifiedTabId: projected.unifiedTabId,
isPinned: false,
data: projected
},
true,
null
)
).toBe('fix-tab-title')
})
@@ -1,6 +1,4 @@
import { createCompatibleRuntimeStatusResponse } from '@/runtime/rpc/testing/runtime-compatibility-test-fixture'
import { useAppStore } from '@/store'
import { StrictMode, Suspense, startTransition, useState } from 'react'
import { Suspense, startTransition, useState } from 'react'
// @vitest-environment happy-dom
import { act, cleanup, render, renderHook, screen } from '@testing-library/react'
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
@@ -84,28 +82,6 @@ afterEach(() => {
setLocalRuntimeCapabilitiesForTests(null)
})
it.each([100, 1000])(
'bounds automatic exact hydration at N=%i and coalesces demand while failures terminate',
async (n) => {
const items = Array.from({ length: n }, (_, i) => item(i + 1))
const input = model(items)
lookup.mockResolvedValue(null)
const view = renderHook(() => useTaskPageGitHubListProjection(input))
await act(async () => {})
expect(lookup).toHaveBeenCalledTimes(20)
act(() => {
view.result.current.ensurePRChecksLoaded(items[20])
view.result.current.ensurePRChecksLoaded(items[20])
})
await act(async () => {})
expect(lookup).toHaveBeenCalledTimes(21)
view.rerender()
await act(async () => {})
expect(lookup).toHaveBeenCalledTimes(21)
expect(input.fetchPRChecks).not.toHaveBeenCalled()
}
)
it('publishes exact-owner counts, rejects a newer owner replacement, and retires source changes', async () => {
const row = item(42)
let current = row
@@ -157,104 +133,6 @@ it('publishes exact-owner counts, rejects a newer owner replacement, and retires
expect(patch.mock.calls.length).toBe(previousCalls)
})
it('does not rerender or rearm failed demand on unrelated host writes or capacity pressure; explicit refresh retries', async () => {
const input = model(Array.from({ length: 200 }, (_, i) => item(i + 1)))
lookup.mockResolvedValue(null)
let renders = 0
const view = renderHook(() => {
renders++
return useTaskPageGitHubListProjection(input)
})
await act(async () => {})
act(() => {
for (const row of input.pages[0] ?? []) {
view.result.current.ensurePRChecksLoaded(row)
}
})
await act(async () => {})
const calls = lookup.mock.calls.length
const before = renders
act(() => useAppStore.setState({ runtimeStatusByEnvironmentId: new Map() }))
await act(async () => {})
expect(renders).toBe(before)
view.rerender()
await act(async () => {})
expect(lookup).toHaveBeenCalledTimes(calls)
input.taskRefreshNonce++
view.rerender()
await act(async () => {})
expect(lookup).toHaveBeenCalledTimes(calls + 20)
})
it('ignores an unselected remote source change and hydrates again when the selected source changes', async () => {
const selectedRepo = { ...repo, executionHostId: 'runtime:host-a' as const }
const unselectedRepo = { ...repo, id: 'repo-b', executionHostId: 'runtime:host-b' as const }
const setStatus = (host: string, runtimeId: string) => {
const response = createCompatibleRuntimeStatusResponse(runtimeId)
if (!response.ok) {
throw new Error('Expected a runtime status')
}
const statuses = new Map(useAppStore.getState().runtimeStatusByEnvironmentId)
statuses.set(host, { status: { ...response.result, capabilities: [capability] }, checkedAt: 1 })
useAppStore.setState({ runtimeStatusByEnvironmentId: statuses })
}
setStatus('host-a', 'a1')
setStatus('host-b', 'b1')
const input = model([item(42)])
input.repos = [selectedRepo, unselectedRepo]
input.selectedRepos = [selectedRepo]
lookup.mockResolvedValue(null)
let renders = 0
renderHook(() => {
renders++
return useTaskPageGitHubListProjection(input)
})
await act(async () => {})
expect(lookup).toHaveBeenCalledTimes(1)
const before = renders
act(() => setStatus('host-b', 'b2'))
await act(async () => {})
expect(renders).toBe(before)
expect(lookup).toHaveBeenCalledTimes(1)
act(() => setLocalRuntimeCapabilitiesForTests([]))
await act(async () => {})
expect(renders).toBe(before)
act(() => setStatus('host-a', 'a2'))
await act(async () => {})
expect(renders).toBeGreaterThan(before)
expect(lookup).toHaveBeenCalledTimes(2)
})
it('publishes one committed demand read through StrictMode effect replay', async () => {
const row = item(42)
let current = row
const patch = vi.fn((_key, values, accepts: (item: GitHubWorkItem) => boolean) => {
if (accepts(current)) {
current = { ...current, ...values }
}
})
const pending = Promise.withResolvers<GitHubWorkItem>()
const summary = {
state: 'failure' as const,
total: 1,
passed: 0,
failed: 1,
cancelled: 0,
pending: 0,
neutral: 0
}
lookup.mockReturnValue(pending.promise)
const input = model([row], patch)
renderHook(() => useTaskPageGitHubListProjection(input), { wrapper: StrictMode })
await act(async () => {})
expect(lookup).toHaveBeenCalledOnce()
await act(async () => {
pending.resolve({ ...row, checksSummary: summary })
await pending.promise
})
expect(current.checksSummary).toEqual(summary)
})
it.each([true, false])(
'only a committed query replacement retires a pending read (suspend=%s)',
async (suspend) => {
@@ -1,254 +0,0 @@
import { afterEach, describe, expect, it } from 'vitest'
import type { GitHubWorkItem } from '../../../../../shared/github/work-item-types'
import { getTaskSourceCacheScope } from '../../../../../shared/task-source-context'
import { assertTaskPageGitHubDialogStateAuthority } from './task-page-github-dialog-state-authority'
import { adoptQuietSearchFieldsForItem } from './task-page-github-work-item-quiet-adopt'
import {
applyPendingTaskPageGitHubMutationsToItems,
materializeTaskPageItemList,
reapplyPendingTaskPageGitHubMutationsToCache
} from './task-page-github-work-item-mutation-pages'
import {
deleteLastConfirmedClientValue,
getLastConfirmedClientValue
} from './task-page-github-confirmed-client-values'
import { getOrCreateQuietRevalidateState } from './task-page-github-work-item-quiet-state'
import {
resetTaskPageGitHubMutationRegistryForTests,
setLastConfirmedClientValue,
setTaskPageGitHubMutationQueryKey
} from './task-page-github-work-item-mutation-registry'
import {
taskPageGitHubFamilyDirtyKey,
taskPageGitHubItemKey
} from './task-page-github-work-item-mutation-keys'
function item(overrides: Partial<GitHubWorkItem> = {}): GitHubWorkItem {
return {
id: 'issue:1',
type: 'issue',
number: 1,
title: 't',
state: 'open',
url: 'https://github.com/o/r/issues/1',
labels: [],
updatedAt: '2026-01-01T00:00:00Z',
author: 'author',
repoId: 'repo-1',
...overrides
}
}
const sourceContext = {
kind: 'task-source' as const,
provider: 'github' as const,
hostId: 'local' as const,
projectId: 'proj',
projectHostSetupId: 'setup',
repoId: 'repo-1'
}
afterEach(() => resetTaskPageGitHubMutationRegistryForTests())
describe('dialog state authority (STA-3343)', () => {
it('without authority a stale search refetch reverts a cache-only patch (external edits adopt)', () => {
// Without registry authority, a lagging search response overwrites direct cache patches.
const materialized = materializeTaskPageItemList({
networkItems: [item({ state: 'open' })],
previousItems: [item({ state: 'closed' })],
queryKey: 'q'
})
expect(materialized[0]?.state).toBe('open')
})
it('holds a dialog-confirmed close over a stale search refetch', () => {
setTaskPageGitHubMutationQueryKey('q')
assertTaskPageGitHubDialogStateAuthority({
repoId: 'repo-1',
itemId: 'issue:1',
state: 'closed'
})
const materialized = materializeTaskPageItemList({
networkItems: [item({ state: 'open' })],
previousItems: [item({ state: 'closed' })],
queryKey: 'q'
})
expect(materialized[0]?.state).toBe('closed')
})
it('holds the close through the cache re-apply path too', () => {
setTaskPageGitHubMutationQueryKey('q')
assertTaskPageGitHubDialogStateAuthority({
repoId: 'repo-1',
itemId: 'issue:1',
state: 'closed'
})
const store = new Map<string, GitHubWorkItem>([['issue:1', item({ state: 'open' })]])
reapplyPendingTaskPageGitHubMutationsToCache({
items: [item({ state: 'open' })],
patchWorkItem: (id, patch) => {
store.set(id, { ...(store.get(id) ?? item()), ...patch })
}
})
expect(store.get('issue:1')?.state).toBe('closed')
})
it('records authority under the mutation source scope so other scopes are untouched', () => {
setTaskPageGitHubMutationQueryKey('q')
assertTaskPageGitHubDialogStateAuthority({
repoId: 'repo-1',
itemId: 'issue:1',
state: 'closed',
sourceContext
})
const scope = getTaskSourceCacheScope(sourceContext)
expect(getLastConfirmedClientValue(scope, 'repo-1', 'issue:1', 'state')).toBe('closed')
expect(getLastConfirmedClientValue(null, 'repo-1', 'issue:1', 'state')).toBeUndefined()
const overlaid = applyPendingTaskPageGitHubMutationsToItems([item({ state: 'open' })])
expect(overlaid[0]?.state).toBe('closed')
})
it('marks the state family dirty so quiet revalidation trails the stale row', () => {
setTaskPageGitHubMutationQueryKey('q')
const before = getOrCreateQuietRevalidateState('q').dirtyGeneration
assertTaskPageGitHubDialogStateAuthority({
repoId: 'repo-1',
itemId: 'issue:1',
state: 'closed'
})
const quiet = getOrCreateQuietRevalidateState('q')
expect(quiet.dirtyGeneration).toBe(before + 1)
expect(
quiet.familyDirtyAt.get(
taskPageGitHubFamilyDirtyKey(taskPageGitHubItemKey('repo-1', 'issue:1'), 'state')
)
).toBe(quiet.dirtyGeneration)
})
it('releases authority once search reports the confirmed state, so later external edits win', () => {
setTaskPageGitHubMutationQueryKey('q')
assertTaskPageGitHubDialogStateAuthority({
repoId: 'repo-1',
itemId: 'issue:1',
state: 'closed'
})
const patched: Partial<GitHubWorkItem>[] = []
adoptQuietSearchFieldsForItem({
item: item({ state: 'closed' }),
serverItem: item({ state: 'closed' }),
sourceScope: null,
queryKey: 'q',
fetchStartedAtGeneration: getOrCreateQuietRevalidateState('q').dirtyGeneration,
patchWorkItem: (_id, patch) => patched.push(patch)
})
expect(getLastConfirmedClientValue(null, 'repo-1', 'issue:1', 'state')).toBeUndefined()
const overlaid = applyPendingTaskPageGitHubMutationsToItems([item({ state: 'open' })])
expect(overlaid[0]?.state).toBe('open')
})
it('revert drops fresh authority and restores a pre-existing value', () => {
setTaskPageGitHubMutationQueryKey('q')
const fresh = assertTaskPageGitHubDialogStateAuthority({
repoId: 'repo-1',
itemId: 'issue:1',
state: 'closed'
})
expect(fresh.revert()).toBe(true)
expect(getLastConfirmedClientValue(null, 'repo-1', 'issue:1', 'state')).toBeUndefined()
setLastConfirmedClientValue(null, 'repo-1', 'issue:1', 'state', 'open')
const layered = assertTaskPageGitHubDialogStateAuthority({
repoId: 'repo-1',
itemId: 'issue:1',
state: 'closed'
})
expect(layered.revert()).toBe(true)
expect(getLastConfirmedClientValue(null, 'repo-1', 'issue:1', 'state')).toBe('open')
})
it('does not roll back authority released by search or superseded by a newer state', () => {
setTaskPageGitHubMutationQueryKey('q')
const released = assertTaskPageGitHubDialogStateAuthority({
repoId: 'repo-1',
itemId: 'issue:1',
state: 'closed'
})
deleteLastConfirmedClientValue(null, 'repo-1', 'issue:1', 'state')
expect(released.revert()).toBe(false)
expect(getLastConfirmedClientValue(null, 'repo-1', 'issue:1', 'state')).toBeUndefined()
const superseded = assertTaskPageGitHubDialogStateAuthority({
repoId: 'repo-1',
itemId: 'issue:1',
state: 'closed'
})
setLastConfirmedClientValue(null, 'repo-1', 'issue:1', 'state', 'merged')
expect(superseded.revert()).toBe(false)
expect(getLastConfirmedClientValue(null, 'repo-1', 'issue:1', 'state')).toBe('merged')
})
it('holds qualified state only on its canonical repository and releases it on matching search', () => {
const ownerRepo = { owner: 'o', repo: 'r', host: 'github.com' }
setTaskPageGitHubMutationQueryKey('q')
assertTaskPageGitHubDialogStateAuthority({
repoId: 'repo-1',
itemId: 'issue:1',
state: 'closed',
ownerRepo
})
const other = item({ url: 'https://github.com/upstream/r/issues/1' })
const enterprise = item({ url: 'https://ghe.example/o/r/issues/1' })
expect(
applyPendingTaskPageGitHubMutationsToItems([item(), other, enterprise]).map(
(row) => row.state
)
).toEqual(['closed', 'open', 'open'])
adoptQuietSearchFieldsForItem({
item: other,
serverItem: { ...other, state: 'closed' },
sourceScope: null,
queryKey: 'q',
fetchStartedAtGeneration: getOrCreateQuietRevalidateState('q').dirtyGeneration,
patchWorkItem: () => {}
})
expect(getLastConfirmedClientValue(null, 'repo-1', 'issue:1', 'state', ownerRepo)).toBe(
'closed'
)
adoptQuietSearchFieldsForItem({
item: item(),
serverItem: item({ state: 'closed' }),
sourceScope: null,
queryKey: 'q',
fetchStartedAtGeneration: getOrCreateQuietRevalidateState('q').dirtyGeneration,
patchWorkItem: () => {}
})
expect(
getLastConfirmedClientValue(null, 'repo-1', 'issue:1', 'state', ownerRepo)
).toBeUndefined()
expect(applyPendingTaskPageGitHubMutationsToItems([item()])[0]?.state).toBe('open')
})
it('keeps another repository confirmation when an older qualified edit rolls back', () => {
const forkRepo = { owner: 'o', repo: 'r', host: 'github.com' }
const upstreamRepo = { ...forkRepo, owner: 'upstream' }
const forkAuthority = assertTaskPageGitHubDialogStateAuthority({
repoId: 'repo-1',
itemId: 'issue:1',
state: 'closed',
ownerRepo: forkRepo
})
assertTaskPageGitHubDialogStateAuthority({
repoId: 'repo-1',
itemId: 'issue:1',
state: 'closed',
ownerRepo: upstreamRepo
})
expect(forkAuthority.revert()).toBe(false)
expect(getLastConfirmedClientValue(null, 'repo-1', 'issue:1', 'state', upstreamRepo)).toBe(
'closed'
)
expect(
getLastConfirmedClientValue(null, 'repo-1', 'issue:1', 'state', forkRepo)
).toBeUndefined()
})
})
@@ -82,162 +82,7 @@ describe('GitHub task list scroll restore', () => {
vi.unstubAllGlobals()
})
it('lands on the remembered offset once the rows finally paint', () => {
const list = createScrollList(0)
const pendingRestoreRef = ref<number | null>(360)
const restoreWriteRef = ref<GitHubListRestoreWrite | null>(null)
const applied: number[] = []
startGitHubListScrollRestore({
target: 360,
scrollElementRef: ref<HTMLElement | null>(list.element),
pendingRestoreRef,
restoreWriteRef,
onScrollTopApplied: (scrollTop) => applied.push(scrollTop)
})
expect(list.element.scrollTop).toBe(0)
list.setMaxScrollTop(900)
resize(list.rows)
expect(list.element.scrollTop).toBe(360)
expect(pendingRestoreRef.current).toBeNull()
expect(applied.at(-1)).toBe(360)
})
it('retries when the rows mount after the restore starts', async () => {
const list = createScrollList(0)
list.element.removeChild(list.rows)
const pendingRestoreRef = ref<number | null>(360)
const restoreWriteRef = ref<GitHubListRestoreWrite | null>(null)
startGitHubListScrollRestore({
target: 360,
scrollElementRef: ref<HTMLElement | null>(list.element),
pendingRestoreRef,
restoreWriteRef,
onScrollTopApplied: () => {}
})
list.setMaxScrollTop(900)
list.element.append(list.rows)
await Promise.resolve()
expect(list.element.scrollTop).toBe(360)
expect(pendingRestoreRef.current).toBeNull()
})
it('keeps the remembered offset armed when the list never becomes tall enough', () => {
const list = createScrollList(0)
const pendingRestoreRef = ref<number | null>(360)
const restoreWriteRef = ref<GitHubListRestoreWrite | null>(null)
const applied: number[] = []
startGitHubListScrollRestore({
target: 360,
scrollElementRef: ref<HTMLElement | null>(list.element),
pendingRestoreRef,
restoreWriteRef,
onScrollTopApplied: (scrollTop) => applied.push(scrollTop)
})
vi.advanceTimersByTime(30_000)
// Slow paint must not overwrite the remembered target with a clamped zero.
expect(applied).not.toContain(0)
expect(pendingRestoreRef.current).toBe(360)
list.setMaxScrollTop(900)
resize(list.rows)
expect(list.element.scrollTop).toBe(360)
expect(pendingRestoreRef.current).toBeNull()
})
it('retries when only the scroll container itself resizes', () => {
const list = createScrollList(0)
const pendingRestoreRef = ref<number | null>(360)
const restoreWriteRef = ref<GitHubListRestoreWrite | null>(null)
startGitHubListScrollRestore({
target: 360,
scrollElementRef: ref<HTMLElement | null>(list.element),
pendingRestoreRef,
restoreWriteRef,
onScrollTopApplied: () => {}
})
// Container-only resize can make the target reachable without changing row height.
list.setMaxScrollTop(900)
resize(list.element)
expect(list.element.scrollTop).toBe(360)
expect(pendingRestoreRef.current).toBeNull()
})
it('stops observing once the cleanup runs', () => {
const list = createScrollList(0)
const pendingRestoreRef = ref<number | null>(360)
const restoreWriteRef = ref<GitHubListRestoreWrite | null>(null)
const stop = startGitHubListScrollRestore({
target: 360,
scrollElementRef: ref<HTMLElement | null>(list.element),
pendingRestoreRef,
restoreWriteRef,
onScrollTopApplied: () => {}
})
stop()
list.setMaxScrollTop(900)
resize(list.rows)
expect(list.element.scrollTop).toBe(0)
})
it('leaves the pending restore alone when the list is not mounted', () => {
const pendingRestoreRef = ref<number | null>(360)
const stop = startGitHubListScrollRestore({
target: 360,
scrollElementRef: ref<HTMLElement | null>(null),
pendingRestoreRef,
restoreWriteRef: ref<GitHubListRestoreWrite | null>(null),
onScrollTopApplied: () => {
throw new Error('nothing to apply without a list')
}
})
stop()
expect(pendingRestoreRef.current).toBe(360)
})
describe('user takeover', () => {
it('ignores the scroll event the restore itself produced', () => {
// A half-painted list clamps 360 to 200 and echoes that committed position as a scroll event.
const list = createScrollList(200)
const pendingRestoreRef = ref<number | null>(360)
const restoreWriteRef = ref<GitHubListRestoreWrite | null>(null)
startGitHubListScrollRestore({
target: 360,
scrollElementRef: ref<HTMLElement | null>(list.element),
pendingRestoreRef,
restoreWriteRef,
onScrollTopApplied: () => {}
})
expect(
supersedeGitHubListScrollRestore({
scrollTop: list.element.scrollTop,
pendingRestoreRef,
restoreWriteRef
})
).toBe(false)
expect(pendingRestoreRef.current).toBe(360)
})
it('ends a restore the list can never satisfy when the user scrolls', () => {
const list = createScrollList(0)
const pendingRestoreRef = ref<number | null>(360)
@@ -263,24 +108,5 @@ describe('GitHub task list scroll restore', () => {
resize(list.rows)
expect(list.element.scrollTop).toBe(120)
})
it('does not read a write left over from an earlier target as an echo', () => {
const pendingRestoreRef = ref<number | null>(120)
const restoreWriteRef = ref<GitHubListRestoreWrite | null>({ target: 360, committed: 200 })
expect(
supersedeGitHubListScrollRestore({ scrollTop: 200, pendingRestoreRef, restoreWriteRef })
).toBe(true)
expect(pendingRestoreRef.current).toBeNull()
})
it('records the offset once no restore is pending', () => {
const pendingRestoreRef = ref<number | null>(null)
const restoreWriteRef = ref<GitHubListRestoreWrite | null>({ target: 360, committed: 200 })
expect(
supersedeGitHubListScrollRestore({ scrollTop: 200, pendingRestoreRef, restoreWriteRef })
).toBe(true)
})
})
})
@@ -1,90 +0,0 @@
import { describe, expect, it } from 'vitest'
import {
buildTaskPageGitHubResumeContextKey,
createTaskPageResumePageCache,
TASK_PAGE_GITHUB_RESUME_CACHE_LIMIT
} from './task-page-github-resume-cache'
describe('task page GitHub resume cache', () => {
it('isolates entries by full list context', () => {
const cache = createTaskPageResumePageCache<number>()
const first = buildTaskPageGitHubResumeContextKey({
selectedReposKey: 'local:repo-a',
query: 'is:pr',
pageSize: 30
})
const second = buildTaskPageGitHubResumeContextKey({
selectedReposKey: 'ssh:host-a:repo-a',
query: 'is:pr',
pageSize: 30
})
const differentQuery = buildTaskPageGitHubResumeContextKey({
selectedReposKey: 'local:repo-a',
query: 'is:issue',
pageSize: 30
})
const differentPageSize = buildTaskPageGitHubResumeContextKey({
selectedReposKey: 'local:repo-a',
query: 'is:pr',
pageSize: 60
})
cache.write(first, 2, [1])
cache.write(second, 2, [2])
cache.write(differentQuery, 2, [3])
cache.write(differentPageSize, 2, [4])
expect(cache.read(first, 2)?.items).toEqual([1])
expect(cache.read(second, 2)?.items).toEqual([2])
expect(cache.read(differentQuery, 2)?.items).toEqual([3])
expect(cache.read(differentPageSize, 2)?.items).toEqual([4])
})
it('evicts the least recently used page at the global cap', () => {
const cache = createTaskPageResumePageCache<number>()
for (let page = 0; page < TASK_PAGE_GITHUB_RESUME_CACHE_LIMIT; page += 1) {
cache.write('scope', page, [page], page)
}
expect(cache.read('scope', 0, 10)?.items).toEqual([0])
cache.write('scope', TASK_PAGE_GITHUB_RESUME_CACHE_LIMIT, [5], 11)
expect(cache.size()).toBe(TASK_PAGE_GITHUB_RESUME_CACHE_LIMIT)
expect(cache.read('scope', 1, 12)).toBeNull()
expect(cache.read('scope', 0, 12)?.items).toEqual([0])
})
it('retains only five payloads after all 28 pages are visited', () => {
const cache = createTaskPageResumePageCache<number>()
for (let page = 0; page < 28; page += 1) {
cache.write('scope', page, [page], page)
}
expect(cache.size()).toBe(5)
expect(cache.read('scope', 22, 28)).toBeNull()
expect(cache.read('scope', 23, 28)?.items).toEqual([23])
expect(cache.read('scope', 27, 28)?.items).toEqual([27])
})
it('expires entries after the inactivity window', () => {
const cache = createTaskPageResumePageCache<number>({ ttlMs: 100 })
cache.write('scope', 4, [4], 0)
expect(cache.read('scope', 4, 99)?.items).toEqual([4])
expect(cache.read('scope', 4, 198)?.items).toEqual([4])
expect(cache.read('scope', 4, 298)).toBeNull()
})
it('copies page arrays at the cache boundary', () => {
const cache = createTaskPageResumePageCache<number>()
const source = [1, 2]
cache.write('scope', 0, source)
source.push(3)
const firstRead = cache.read('scope', 0)
firstRead?.items.push(4)
expect(cache.read('scope', 0)?.items).toEqual([1, 2])
})
})
@@ -1,80 +0,0 @@
import { describe, expect, it } from 'vitest'
import {
buildRequestedReviewUsers,
mergeReviewerSuggestions,
resolveTaskPullRequestRepo,
sameOptionalGitHubOwnerRepo
} from './task-page-github-review-model'
describe('sameOptionalGitHubOwnerRepo', () => {
it('treats two missing values as equal', () => {
expect(sameOptionalGitHubOwnerRepo(null, undefined)).toBe(true)
})
it('treats a missing value as unequal to a repo', () => {
expect(
sameOptionalGitHubOwnerRepo(null, { owner: 'acme', repo: 'orca', host: 'github.com' })
).toBe(false)
})
it('separates the same owner/repo on different hosts', () => {
expect(
sameOptionalGitHubOwnerRepo(
{ owner: 'acme', repo: 'orca', host: 'github.com' },
{ owner: 'acme', repo: 'orca', host: 'github.example.com' }
)
).toBe(false)
})
})
describe('resolveTaskPullRequestRepo', () => {
it('keeps prRepo host when present', () => {
expect(
resolveTaskPullRequestRepo({
prRepo: { owner: 'acme', repo: 'orca', host: 'github.example.com' },
url: 'https://github.com/other/repo/pull/1'
})
).toEqual({ owner: 'acme', repo: 'orca', host: 'github.example.com' })
})
it('falls back to the URL slug and pins github.com', () => {
expect(
resolveTaskPullRequestRepo({
prRepo: undefined,
url: 'https://github.com/acme/orca/pull/12'
})
).toEqual({ owner: 'acme', repo: 'orca', host: 'github.com' })
expect(
resolveTaskPullRequestRepo({
prRepo: undefined,
url: 'https://github.example.com:8443/acme/orca/pull/12'
})
).toEqual({ owner: 'acme', repo: 'orca', host: 'github.example.com:8443' })
})
})
describe('mergeReviewerSuggestions', () => {
it('dedupes by login and prefers a later avatar', () => {
expect(
mergeReviewerSuggestions(
[{ login: 'Ada', name: 'Ada', avatarUrl: 'https://example/ada.png' }],
[{ login: 'ada', name: null, avatarUrl: '' }]
)
).toEqual([{ login: 'ada', name: 'Ada', avatarUrl: 'https://example/ada.png' }])
})
})
describe('buildRequestedReviewUsers', () => {
it('keeps existing requests and fills missing logins from candidates', () => {
expect(
buildRequestedReviewUsers(
['octocat', 'hubot'],
[{ login: 'hubot', name: 'Hubot', avatarUrl: 'https://example/hubot.png' }],
[{ login: 'octocat', name: 'Octo', avatarUrl: '' }]
)
).toEqual([
{ login: 'octocat', name: 'Octo', avatarUrl: '' },
{ login: 'hubot', name: 'Hubot', avatarUrl: 'https://example/hubot.png' }
])
})
})
@@ -1,88 +0,0 @@
import { describe, expect, it } from 'vitest'
import {
buildTaskPageGitHubCloseUpdate,
getTaskPageGitHubDuplicateCandidates,
getTaskPageGitHubDuplicateTargetErrorMessage,
validateTaskPageGitHubDuplicateTarget
} from './task-page-github-status-actions'
import type { GitHubWorkItem } from '../../../../../shared/github/work-item-types'
describe('TaskPage GitHub status actions', () => {
it('builds completed and not planned close updates', () => {
expect(buildTaskPageGitHubCloseUpdate({ stateReason: 'completed' })).toEqual({
state: 'closed',
stateReason: 'completed'
})
expect(buildTaskPageGitHubCloseUpdate({ stateReason: 'not_planned' })).toEqual({
state: 'closed',
stateReason: 'not_planned'
})
})
it('builds duplicate close updates with a target issue number', () => {
expect(buildTaskPageGitHubCloseUpdate({ stateReason: 'duplicate', duplicateOf: 42 })).toEqual({
state: 'closed',
stateReason: 'duplicate',
duplicateOf: 42
})
})
it('validates duplicate targets before dispatch', () => {
expect(validateTaskPageGitHubDuplicateTarget('', 12).ok).toBe(false)
expect(validateTaskPageGitHubDuplicateTarget('12', 12).ok).toBe(false)
expect(validateTaskPageGitHubDuplicateTarget('12.5', 12).ok).toBe(false)
expect(validateTaskPageGitHubDuplicateTarget('-1', 12).ok).toBe(false)
expect(validateTaskPageGitHubDuplicateTarget('0', 12).ok).toBe(false)
expect(validateTaskPageGitHubDuplicateTarget(' 34 ', 12)).toEqual({
ok: true,
duplicateOf: 34
})
})
it('maps duplicate validation failures to localized messages', () => {
const t = (_key: string, fallback: string): string => fallback
const validation = validateTaskPageGitHubDuplicateTarget('same', 12)
expect(validation.ok).toBe(false)
if (!validation.ok) {
expect(getTaskPageGitHubDuplicateTargetErrorMessage(validation, t)).toBe(
'Use a whole issue number.'
)
}
})
it('filters duplicate candidates to other matching issues', () => {
const items = [
buildWorkItem({ number: 12, title: 'Current issue', type: 'issue' }),
buildWorkItem({ number: 34, title: 'Copy SSH file support', type: 'issue' }),
buildWorkItem({ number: 56, title: 'Unrelated pull request', type: 'pr' }),
buildWorkItem({ number: 78, title: 'Windows path copy bug', type: 'issue' })
]
expect(getTaskPageGitHubDuplicateCandidates(items, 12, '').map((item) => item.number)).toEqual([
34, 78
])
expect(
getTaskPageGitHubDuplicateCandidates(items, 12, 'copy').map((item) => item.number)
).toEqual([34, 78])
expect(
getTaskPageGitHubDuplicateCandidates(items, 12, '34').map((item) => item.number)
).toEqual([34])
})
})
function buildWorkItem(overrides: Partial<GitHubWorkItem>): GitHubWorkItem {
return {
id: `item-${overrides.number ?? 1}`,
type: 'issue',
number: 1,
title: 'Issue',
state: 'open',
url: 'https://github.com/stablyai/orca/issues/1',
labels: [],
updatedAt: '2026-06-22T00:00:00.000Z',
author: null,
repoId: 'repo-1',
...overrides
}
}
@@ -1,55 +0,0 @@
import { describe, expect, it } from 'vitest'
import {
createTaskPageGitHubStatusStateDraft,
resolveTaskPageGitHubStatusStateDraft,
updateTaskPageGitHubStatusLocalState
} from './task-page-github-status-state'
describe('TaskPage GitHub status state draft', () => {
it('keeps optimistic local state while the backing item state is unchanged', () => {
const item = { id: 'issue-1', state: 'open' as const }
const current = updateTaskPageGitHubStatusLocalState(
createTaskPageGitHubStatusStateDraft(item),
item,
'closed'
)
expect(resolveTaskPageGitHubStatusStateDraft(current, item)).toEqual({
sourceItemId: 'issue-1',
sourceState: 'open',
localState: 'closed'
})
})
it('reconciles to the backing state when the item state changes', () => {
const current = updateTaskPageGitHubStatusLocalState(
createTaskPageGitHubStatusStateDraft({ id: 'issue-1', state: 'open' }),
{ id: 'issue-1', state: 'open' },
'closed'
)
expect(
resolveTaskPageGitHubStatusStateDraft(current, { id: 'issue-1', state: 'closed' })
).toEqual({
sourceItemId: 'issue-1',
sourceState: 'closed',
localState: 'closed'
})
})
it('drops stale optimistic state when the table row switches items', () => {
const current = updateTaskPageGitHubStatusLocalState(
createTaskPageGitHubStatusStateDraft({ id: 'issue-1', state: 'open' }),
{ id: 'issue-1', state: 'open' },
'closed'
)
expect(
resolveTaskPageGitHubStatusStateDraft(current, { id: 'issue-2', state: 'open' })
).toEqual({
sourceItemId: 'issue-2',
sourceState: 'open',
localState: 'open'
})
})
})
@@ -1,24 +0,0 @@
import { describe, expect, it } from 'vitest'
import { getGitHubTaskKind, scopeGitHubTaskSearch } from '../../../../../shared/task-github-search'
describe('getGitHubTaskKind', () => {
it('lets an explicit issue query override a PR-focused preset', () => {
for (const preset of ['prs', 'my-prs', 'review'] as const) {
expect(getGitHubTaskKind(preset, 'is:issue is:open')).toBe('issues')
expect(getGitHubTaskKind(preset, 'is:"issue"')).toBe('issues')
expect(getGitHubTaskKind(preset, 'label:is:issue')).toBe('prs')
}
})
})
describe('scopeGitHubTaskSearch', () => {
it('lets a parsed pr scope override the caller kind', () => {
expect(scopeGitHubTaskSearch('is:draft', 'issues')).toBe('is:pr is:draft')
})
it('lets a parsed issue scope override the caller kind', () => {
// Quoting hides scope from the literal regex, but parsed scope still overrides the caller kind.
expect(scopeGitHubTaskSearch('is:"issue"', 'prs')).toBe('is:"issue"')
})
})
@@ -1,170 +0,0 @@
import { describe, expect, it } from 'vitest'
import type { ParsedTaskQuery } from '../../../../../shared/task-query'
import {
recomputeTaskPageGitHubItemSoftHide,
shouldSoftHideTaskPageGitHubWorkItem
} from './task-page-github-work-item-filter-membership'
const baseQuery = (overrides: Partial<ParsedTaskQuery> = {}): ParsedTaskQuery => ({
scope: 'all',
state: null,
draft: false,
assignee: null,
author: null,
reviewRequested: null,
reviewedBy: null,
labels: [],
freeText: '',
...overrides
})
describe('shouldSoftHideTaskPageGitHubWorkItem', () => {
it('hides closed or merged under is:open', () => {
expect(
shouldSoftHideTaskPageGitHubWorkItem({
item: { state: 'closed', assignees: [], reviewRequests: [] },
query: baseQuery({ state: 'open' }),
viewerLogin: 'me',
skipMeQualifiers: false
})
).toBe(true)
expect(
shouldSoftHideTaskPageGitHubWorkItem({
item: { state: 'merged', assignees: [], reviewRequests: [] },
query: baseQuery({ state: 'open' }),
viewerLogin: 'me',
skipMeQualifiers: false
})
).toBe(true)
})
it('does not state-soft-hide when state is null', () => {
expect(
shouldSoftHideTaskPageGitHubWorkItem({
item: { state: 'closed', assignees: [], reviewRequests: [] },
query: baseQuery({ state: null }),
viewerLogin: 'me',
skipMeQualifiers: false
})
).toBe(false)
})
it('AND: hides on close or unassign under is:open assignee:@me', () => {
const query = baseQuery({ state: 'open', assignee: '@me' })
expect(
shouldSoftHideTaskPageGitHubWorkItem({
item: {
state: 'closed',
assignees: [{ login: 'me', name: null, avatarUrl: '' }],
reviewRequests: []
},
query,
viewerLogin: 'me',
skipMeQualifiers: false
})
).toBe(true)
expect(
shouldSoftHideTaskPageGitHubWorkItem({
item: { state: 'open', assignees: [], reviewRequests: [] },
query,
viewerLogin: 'me',
skipMeQualifiers: false
})
).toBe(true)
})
it('skips @me when skipMeQualifiers or viewerLogin null', () => {
const query = baseQuery({ assignee: '@me' })
expect(
shouldSoftHideTaskPageGitHubWorkItem({
item: { state: 'open', assignees: [], reviewRequests: [] },
query,
viewerLogin: null,
skipMeQualifiers: false
})
).toBe(false)
expect(
shouldSoftHideTaskPageGitHubWorkItem({
item: { state: 'open', assignees: [], reviewRequests: [] },
query,
viewerLogin: 'me',
skipMeQualifiers: true
})
).toBe(false)
})
it('hides when concrete assignee login missing', () => {
expect(
shouldSoftHideTaskPageGitHubWorkItem({
item: {
state: 'open',
assignees: [{ login: 'bob', name: null, avatarUrl: '' }],
reviewRequests: []
},
query: baseQuery({ assignee: 'alice' }),
viewerLogin: 'me',
skipMeQualifiers: false
})
).toBe(true)
})
it('hides non-draft items under is:draft', () => {
const query = baseQuery({ scope: 'pr', state: 'open', draft: true })
expect(
shouldSoftHideTaskPageGitHubWorkItem({
item: { state: 'open', assignees: [], reviewRequests: [] },
query,
viewerLogin: 'me',
skipMeQualifiers: false
})
).toBe(true)
expect(
shouldSoftHideTaskPageGitHubWorkItem({
item: { state: 'draft', assignees: [], reviewRequests: [] },
query,
viewerLogin: 'me',
skipMeQualifiers: false
})
).toBe(false)
})
it('hides when review-requested @me is missing', () => {
expect(
shouldSoftHideTaskPageGitHubWorkItem({
item: { state: 'open', assignees: [], reviewRequests: [] },
query: baseQuery({ reviewRequested: '@me' }),
viewerLogin: 'me',
skipMeQualifiers: false
})
).toBe(true)
})
})
describe('recomputeTaskPageGitHubItemSoftHide', () => {
it('includes sticky hide for matching queryKey', () => {
const itemKey = 'repo\0item'
const options = {
item: {
state: 'open',
assignees: [{ login: 'me', name: null, avatarUrl: '' }],
reviewRequests: []
},
query: baseQuery({ state: 'open' }),
viewerLogin: 'me',
skipMeQualifiers: false,
queryKey: 'q1',
sticky: new Map([
[
itemKey,
{ itemKey, sourceScope: null, queryKey: 'q1', reason: 'filter_membership' as const }
]
]),
itemKey
} satisfies Parameters<typeof recomputeTaskPageGitHubItemSoftHide>[0]
expect(recomputeTaskPageGitHubItemSoftHide(options)).toEqual({ hide: true, sticky: true })
expect(recomputeTaskPageGitHubItemSoftHide({ ...options, queryKey: 'q2' })).toEqual({
hide: false,
sticky: false
})
})
})
@@ -1,121 +0,0 @@
import { describe, expect, it } from 'vitest'
import type { GitHubWorkItem } from '../../../../../shared/github/work-item-types'
import {
applyTaskPageGitHubListOps,
buildTaskPageGitHubWorkItemMutationPatch,
loginSetOfUsers,
loginSetsEqual
} from './task-page-github-work-item-mutation-patches'
function baseItem(overrides: Partial<GitHubWorkItem> = {}): GitHubWorkItem {
return {
id: 'issue:1',
type: 'issue',
number: 1,
title: 't',
state: 'open',
url: 'https://github.com/o/r/issues/1',
labels: [],
updatedAt: '2026-01-01T00:00:00Z',
author: 'alice',
repoId: 'repo-1',
assignees: [],
reviewRequests: [],
...overrides
}
}
describe('applyTaskPageGitHubListOps', () => {
it('adds and removes logins', () => {
const snapshot = [{ login: 'alice', name: null, avatarUrl: '' }]
const afterAdd = applyTaskPageGitHubListOps(snapshot, [
{
family: 'assignees',
kind: 'add',
logins: ['bob'],
users: [{ login: 'bob', name: 'Bob', avatarUrl: 'x' }]
}
])
expect(afterAdd.map((u) => u.login)).toEqual(['alice', 'bob'])
const afterRemove = applyTaskPageGitHubListOps(afterAdd, [
{ family: 'assignees', kind: 'remove', logins: ['alice'] }
])
expect(afterRemove.map((u) => u.login)).toEqual(['bob'])
})
it('does not mutate input snapshot arrays', () => {
const snapshot = [{ login: 'alice', name: null, avatarUrl: '' }]
const frozen = [...snapshot]
applyTaskPageGitHubListOps(snapshot, [{ family: 'assignees', kind: 'add', logins: ['bob'] }])
expect(snapshot).toEqual(frozen)
})
})
describe('buildTaskPageGitHubWorkItemMutationPatch', () => {
it('builds merge patch with state merged and autoMerge cleared', () => {
const item = baseItem({
type: 'pr',
state: 'open',
autoMergeEnabled: true
})
const patch = buildTaskPageGitHubWorkItemMutationPatch(item, { type: 'merge' })
expect(patch.kind).toBe('whole')
if (patch.kind !== 'whole') {
return
}
expect(patch.next).toEqual({ state: 'merged', autoMergeEnabled: false })
expect(patch.previous).toEqual({ state: 'open', autoMergeEnabled: true })
})
it('builds autoMerge patch', () => {
const patch = buildTaskPageGitHubWorkItemMutationPatch(baseItem({ type: 'pr' }), {
type: 'setAutoMerge',
enabled: true
})
expect(patch.next).toEqual({ autoMergeEnabled: true })
})
it('builds per-login assignee toggle', () => {
const patch = buildTaskPageGitHubWorkItemMutationPatch(baseItem(), {
type: 'toggleAssignee',
user: { login: 'Alice', name: 'A', avatarUrl: '' }
})
expect(patch.kind).toBe('list')
if (patch.kind !== 'list') {
return
}
expect(patch.opKey).toBe('assignees:alice')
expect(patch.listOp.kind).toBe('add')
expect(patch.next.assignees?.map((u) => u.login.toLowerCase())).toEqual(['alice'])
})
it('builds multi-login reviewer batch opKey', () => {
const patch = buildTaskPageGitHubWorkItemMutationPatch(baseItem({ type: 'pr' }), {
type: 'addReviewers',
logins: ['bob', 'carol'],
candidates: [
{ login: 'bob', name: null, avatarUrl: '' },
{ login: 'carol', name: null, avatarUrl: '' }
]
})
expect(patch.kind).toBe('list')
if (patch.kind !== 'list') {
return
}
expect(patch.opKey).toBe('reviewRequests:batch:bob,carol')
expect(patch.listOp.logins).toEqual(['bob', 'carol'])
})
})
describe('loginSetsEqual', () => {
it('compares case-insensitive login sets', () => {
expect(
loginSetsEqual(
loginSetOfUsers([{ login: 'Alice', name: null, avatarUrl: '' }]),
loginSetOfUsers([{ login: 'alice', name: null, avatarUrl: '' }])
)
).toBe(true)
expect(loginSetsEqual(new Set(['alice']), new Set(['bob']))).toBe(false)
expect(loginSetsEqual(new Set(['alice']), new Set(['alice', 'bob']))).toBe(false)
})
})
@@ -1,66 +0,0 @@
import { describe, expect, it, vi } from 'vitest'
import {
getTaskPageGitHubPRIconTone,
getTaskPageGitHubWorkItemStateLabel,
getTaskPageGitHubWorkItemStateTone,
isTaskPageGitHubDraftPR
} from './task-page-github-work-item-status'
vi.mock('@/i18n/i18n', () => ({
translate: (_key: string, fallback: string) => fallback
}))
describe('task-page-github-work-item-status', () => {
it('maps PR states to labels', () => {
expect(getTaskPageGitHubWorkItemStateLabel({ type: 'pr', state: 'draft' })).toBe('Draft')
expect(getTaskPageGitHubWorkItemStateLabel({ type: 'pr', state: 'open' })).toBe('Open')
expect(getTaskPageGitHubWorkItemStateLabel({ type: 'pr', state: 'closed' })).toBe('Closed')
expect(getTaskPageGitHubWorkItemStateLabel({ type: 'pr', state: 'merged' })).toBe('Merged')
})
it('uses GitHub-like neutral draft tones and muted draft icons', () => {
const draftTone = getTaskPageGitHubWorkItemStateTone({ type: 'pr', state: 'draft' })
expect(draftTone).toContain('muted')
expect(draftTone).not.toContain('amber')
expect(getTaskPageGitHubPRIconTone({ type: 'pr', state: 'draft' })).toContain('muted')
expect(getTaskPageGitHubPRIconTone({ type: 'pr', state: 'open' })).toContain('emerald')
expect(isTaskPageGitHubDraftPR({ type: 'pr', state: 'draft' })).toBe(true)
expect(isTaskPageGitHubDraftPR({ type: 'pr', state: 'open' })).toBe(false)
})
it('uses distinct muted tones for merged and open PRs', () => {
const mergedTone = getTaskPageGitHubWorkItemStateTone({ type: 'pr', state: 'merged' })
expect(mergedTone).toContain('purple')
expect(mergedTone).toContain('text-purple-700')
expect(mergedTone).not.toContain('bg-purple-600')
const openTone = getTaskPageGitHubWorkItemStateTone({ type: 'pr', state: 'open' })
expect(openTone).toContain('emerald')
expect(openTone).toContain('text-emerald-700')
expect(openTone).not.toContain('bg-emerald-600')
})
it('handles edge cases gracefully', () => {
expect(getTaskPageGitHubWorkItemStateLabel({ type: 'issue', state: 'open' })).toBe('Open')
expect(getTaskPageGitHubWorkItemStateLabel({ type: 'issue', state: 'closed' })).toBe('Closed')
// Unknown state should fallback to 'Open' for PRs
expect(getTaskPageGitHubWorkItemStateLabel({ type: 'pr', state: 'unknown' as 'open' })).toBe(
'Open'
)
expect(getTaskPageGitHubPRIconTone({ type: 'issue', state: 'open' })).toContain(
'muted-foreground'
)
expect(getTaskPageGitHubPRIconTone({ type: 'issue', state: 'closed' })).toContain(
'muted-foreground'
)
expect(isTaskPageGitHubDraftPR({ type: 'pr', state: 'draft' })).toBe(true)
expect(isTaskPageGitHubDraftPR({ type: 'pr', state: 'open' })).toBe(false)
expect(isTaskPageGitHubDraftPR({ type: 'pr', state: 'merged' })).toBe(false)
expect(isTaskPageGitHubDraftPR({ type: 'pr', state: 'closed' })).toBe(false)
expect(isTaskPageGitHubDraftPR({ type: 'issue', state: 'open' })).toBe(false)
expect(isTaskPageGitHubDraftPR({ type: 'issue', state: 'closed' })).toBe(false)
})
})
@@ -1,13 +1,11 @@
// @vitest-environment happy-dom
import { cleanup, render, renderHook, screen, waitFor } from '@testing-library/react'
import { useState } from 'react'
import type { TaskPageComposerActionsModel } from '../use-task-page-composer-actions'
import { cleanup, renderHook } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { Repo } from '../../../../../shared/repo-types'
import type { ExecutionHostId } from '../../../../../shared/execution-host'
import type { GitLabWorkItem } from '../../../../../shared/gitlab-types'
import { i18n } from '@/i18n/i18n'
vi.mock('@/store', () => ({ useAppStore: { getState: vi.fn() } }))
@@ -20,13 +18,7 @@ vi.mock('@/runtime/rpc/compatibility/runtime-capability-proof', () => ({
runtimeEnvironmentSupportsCapability: vi.fn()
}))
import {
GITLAB_TASK_FETCH_CONCURRENCY,
gitLabListQueryKey,
useTaskPageGitLabLoading
} from './use-task-page-gitlab-loading'
import { TaskPageGitLabItemList } from './ItemList'
import { gitLabListQueryKey, useTaskPageGitLabLoading } from './use-task-page-gitlab-loading'
const serverRepo: Repo = {
id: 'server-repo',
@@ -55,181 +47,6 @@ beforeEach(() => {
afterEach(cleanup)
describe('useTaskPageGitLabLoading', () => {
function GitLabListHarness({
view,
executionHostId = 'local'
}: {
view: 'issues' | 'mrs'
executionHostId?: ExecutionHostId
}) {
const [gitlabItems, setGitlabItems] = useState<GitLabWorkItem[]>([])
const [gitlabError, setGitlabError] = useState<string | null>(null)
const [gitlabLoading, setGitlabLoading] = useState(false)
const repo: Repo = { ...serverRepo, executionHostId }
useTaskPageGitLabLoading({
selectedRepos: [repo],
selectedReposKey: executionHostId,
primaryRepo: repo,
taskSource: 'gitlab',
setGitlabItems,
setGitlabLoading,
setGitlabError,
gitlabRefreshNonce: 0,
gitlabView: view,
setGitlabTodos: vi.fn(),
setGitlabTodosLoading: vi.fn(),
activeGitlabFilter: 'opened',
gitlabMRInvolvement: null,
...LIST_STATE
})
const model = {
gitlabItems,
gitlabError,
gitlabLoading,
gitlabEmptyState: { title: 'No items', description: '' },
displayedGitLabItems: gitlabItems,
openGitLabDetailPage: vi.fn(),
handleUseGitLabItem: vi.fn()
} satisfies Partial<TaskPageComposerActionsModel>
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: ItemList reads only the fields supplied above.
return <TaskPageGitLabItemList model={model as unknown as TaskPageComposerActionsModel} />
}
it.each(['issues', 'mrs'] as const)(
'shows a plain message for a wrapped %s rejection',
async (view) => {
const channel = view === 'issues' ? 'gitlab:listIssues' : 'gitlab:listMRs'
const rejectList = vi
.fn()
.mockRejectedValue(
new Error(
`Error invoking remote method '${channel}': Error: Access denied: unknown repository path`
)
)
Object.assign(window.api.gl, { listIssues: rejectList, listMRs: rejectList })
const { container } = render(<GitLabListHarness view={view} />)
expect(await screen.findByText('Access denied: unknown repository path')).toBeTruthy()
expect(container.textContent).not.toContain('Error invoking remote method')
}
)
it.each([new Error("Error invoking remote method 'gitlab:listIssues': Error: "), null, {}])(
'shows a readable fallback for rejection %s',
async (reason) => {
glListIssues.mockRejectedValue(reason)
render(<GitLabListHarness view="issues" />)
expect(
await screen.findByText('Could not load GitLab work items. Refresh to try again.')
).toBeTruthy()
}
)
it.each([
{
failure: 'unknown owner with multiline recovery',
executionHostId: 'runtime:unresolved-owner',
expected:
'No se conoce el equipo de este repositorio.\nVuelve a conectarlo e inténtalo de nuevo.'
},
{
failure: 'unreadable rejection with translated fallback',
executionHostId: 'local',
expected: 'No se pudieron cargar los elementos de GitLab. Actualiza para volver a intentarlo.'
}
] as const)('renders $failure', async ({ executionHostId, expected }) => {
const language = i18n.language
const resources = i18n.getResourceBundle('es', 'translation')
try {
i18n.removeResourceBundle('es', 'translation')
i18n.addResourceBundle('es', 'translation', {
auto: {
runtime: {
forgeCredentialTarget: {
unknownHost:
'No se conoce el equipo de este repositorio.\nVuelve a conectarlo e inténtalo de nuevo.'
}
},
components: {
TaskPage: {
gitlabLoadFailed:
'No se pudieron cargar los elementos de GitLab. Actualiza para volver a intentarlo.'
}
}
}
})
await i18n.changeLanguage('es')
glListIssues.mockRejectedValue(null)
render(<GitLabListHarness view="issues" executionHostId={executionHostId} />)
const banner = await screen.findByText(expected.replace(/\s+/g, ' '))
expect(banner.textContent).toBe(expected)
if (executionHostId === 'runtime:unresolved-owner') {
expect(glListIssues).not.toHaveBeenCalled()
expect(rpc.call).not.toHaveBeenCalled()
}
} finally {
cleanup()
i18n.removeResourceBundle('es', 'translation')
if (resources) {
i18n.addResourceBundle('es', 'translation', resources)
}
await i18n.changeLanguage(language)
}
})
it('suppresses a rejected project error when another project returns rows', async () => {
rpc.call.mockRejectedValueOnce(
new Error(
"Error invoking remote method 'gitlab:listIssues': Error: Access denied: unknown repository path"
)
)
const setGitlabItems = vi.fn()
const setGitlabError = vi.fn()
renderIssuesHook(manyServerRepos(2), setGitlabItems, setGitlabError)
await waitFor(() => expect(setGitlabItems).toHaveBeenCalled())
expect(setGitlabItems.mock.lastCall?.[0]).toHaveLength(1)
expect(setGitlabError).toHaveBeenCalledExactlyOnceWith(null)
})
it('lists a server-owned repo on its server (#14603)', async () => {
const setGitlabItems = vi.fn()
const setGitlabError = vi.fn()
renderHook(() =>
useTaskPageGitLabLoading({
selectedRepos: [serverRepo],
selectedReposKey: 'server-repo',
primaryRepo: serverRepo,
taskSource: 'gitlab',
setGitlabItems,
setGitlabLoading: vi.fn(),
setGitlabError,
gitlabRefreshNonce: 0,
gitlabView: 'issues',
setGitlabTodos: vi.fn(),
setGitlabTodosLoading: vi.fn(),
activeGitlabFilter: 'opened',
gitlabMRInvolvement: null,
...LIST_STATE
})
)
await waitFor(() => expect(setGitlabItems).toHaveBeenCalled())
expect(glListIssues).not.toHaveBeenCalled()
expect(rpc.call).toHaveBeenCalledWith(
{ kind: 'environment', environmentId: 'build-box' },
'gitlab.listIssues',
expect.objectContaining({ repo: 'id:server-repo', state: 'opened' }),
expect.anything()
)
expect(setGitlabItems).toHaveBeenLastCalledWith([
expect.objectContaining({ id: 'i1', repoId: 'server-repo' })
])
expect(setGitlabError).not.toHaveBeenCalledWith(expect.stringContaining('Access denied'))
})
it("never shows the previous filter's rows while a changed filter loads", () => {
rpc.call.mockReturnValue(new Promise(() => {}))
const row: GitLabWorkItem = {
@@ -280,174 +97,4 @@ describe('useTaskPageGitLabLoading', () => {
expect(result.current.displayedGitLabItems).toEqual([])
expect(result.current.gitlabLoading).toBe(true)
})
it('shows MR rows and refresh errors without fetching the advertised probe page', async () => {
const items: GitLabWorkItem[] = Array.from({ length: 50 }, (_, index) => ({
id: `mr-${index + 1}`,
type: 'mr',
number: index + 1,
title: `Merge request ${index + 1}`,
state: 'opened',
url: `https://gitlab.com/group/app/-/merge_requests/${index + 1}`,
labels: [],
updatedAt: '2026-10-01T00:00:00Z',
author: null,
repoId: 'group/app'
}))
rpc.call.mockResolvedValueOnce({ items, page: 1, perPage: 50, totalCount: 0, totalPages: 2 })
const setGitlabItems = vi.fn()
const setGitlabError = vi.fn()
const setGitlabLoading = vi.fn()
const { rerender } = renderHook(
({ refreshNonce }) =>
useTaskPageGitLabLoading({
selectedRepos: [serverRepo],
selectedReposKey: 'server-repo',
primaryRepo: serverRepo,
taskSource: 'gitlab',
setGitlabItems,
setGitlabLoading,
setGitlabError,
gitlabRefreshNonce: refreshNonce,
gitlabView: 'mrs',
setGitlabTodos: vi.fn(),
setGitlabTodosLoading: vi.fn(),
activeGitlabFilter: 'opened',
gitlabMRInvolvement: null,
...LIST_STATE
}),
{ initialProps: { refreshNonce: 0 } }
)
await waitFor(() => expect(setGitlabLoading).toHaveBeenLastCalledWith(false))
expect(setGitlabItems).toHaveBeenLastCalledWith(
items.map((item) => ({ ...item, repoId: serverRepo.id, repoHostId: 'runtime:build-box' }))
)
expect(setGitlabError).toHaveBeenLastCalledWith(null)
expect(rpc.call).toHaveBeenCalledTimes(1)
expect(rpc.call).toHaveBeenCalledWith(
{ kind: 'environment', environmentId: 'build-box' },
'gitlab.listMRs',
expect.objectContaining({ repo: 'id:server-repo', state: 'opened', page: 1, perPage: 50 }),
expect.anything()
)
rpc.call.mockResolvedValueOnce({
items: [],
page: 1,
perPage: 50,
totalCount: 0,
totalPages: 0,
error: { type: 'permission_denied', message: 'GitLab access denied' }
})
rerender({ refreshNonce: 1 })
await waitFor(() => expect(setGitlabError).toHaveBeenLastCalledWith('GitLab access denied'))
expect(setGitlabItems).toHaveBeenLastCalledWith([])
expect(setGitlabLoading).toHaveBeenLastCalledWith(false)
expect(rpc.call).toHaveBeenCalledTimes(2)
})
function manyServerRepos(count: number): Repo[] {
return Array.from({ length: count }, (_, i) => ({
...serverRepo,
id: `server-repo-${i}`,
path: `/srv/app-${i}`,
displayName: `app-${i}`
}))
}
function renderIssuesHook(repos: Repo[], setGitlabItems = vi.fn(), setGitlabError = vi.fn()) {
return renderHook(() =>
useTaskPageGitLabLoading({
selectedRepos: repos,
selectedReposKey: repos.map((repo) => repo.id).join(','),
primaryRepo: repos[0],
taskSource: 'gitlab',
setGitlabItems,
setGitlabLoading: vi.fn(),
setGitlabError,
gitlabRefreshNonce: 0,
gitlabView: 'issues',
setGitlabTodos: vi.fn(),
setGitlabTodosLoading: vi.fn(),
activeGitlabFilter: 'opened',
gitlabMRInvolvement: null,
...LIST_STATE
})
)
}
it('bounds concurrent per-repo GitLab calls for large all-projects selections (#30133)', async () => {
const repos = manyServerRepos(20)
let inFlight = 0
let maxInFlight = 0
rpc.call.mockImplementation(async (_target, _method, params: { repo: string }) => {
inFlight += 1
maxInFlight = Math.max(maxInFlight, inFlight)
await new Promise((resolve) => setTimeout(resolve, 0))
inFlight -= 1
return { items: [{ id: `item-${params.repo}`, updatedAt: '2026-10-01' }] }
})
const setGitlabItems = vi.fn()
renderIssuesHook(repos, setGitlabItems)
await waitFor(() => expect(setGitlabItems).toHaveBeenCalled())
expect(rpc.call).toHaveBeenCalledTimes(repos.length)
expect(maxInFlight).toBe(GITLAB_TASK_FETCH_CONCURRENCY)
expect(setGitlabItems.mock.lastCall?.[0]).toHaveLength(repos.length)
expect(setGitlabItems).toHaveBeenLastCalledWith(
expect.arrayContaining(repos.map((repo) => expect.objectContaining({ repoId: repo.id })))
)
})
it("stamps each twin row's items with the host that returned them", async () => {
const twinOnOtherServer: Repo = { ...serverRepo, executionHostId: 'runtime:other-box' }
rpc.call.mockImplementation(async (target: { environmentId: string }) => ({
items: [{ id: `issue-on-${target.environmentId}`, updatedAt: '2026-10-01' }]
}))
const setGitlabItems = vi.fn()
renderIssuesHook([serverRepo, twinOnOtherServer], setGitlabItems)
await waitFor(() => expect(setGitlabItems).toHaveBeenCalled())
expect(setGitlabItems.mock.lastCall?.[0]).toEqual(
expect.arrayContaining([
expect.objectContaining({
id: 'issue-on-build-box',
repoId: 'server-repo',
repoHostId: 'runtime:build-box'
}),
expect.objectContaining({
id: 'issue-on-other-box',
repoId: 'server-repo',
repoHostId: 'runtime:other-box'
})
])
)
})
it('stops starting GitLab calls once the selection goes stale (#30133)', async () => {
const repos = manyServerRepos(20)
const pending: (() => void)[] = []
rpc.call.mockImplementation(
() =>
new Promise((resolve) => {
pending.push(() => resolve({ items: [] }))
})
)
const setGitlabItems = vi.fn()
const { unmount } = renderIssuesHook(repos, setGitlabItems)
await waitFor(() => expect(rpc.call).toHaveBeenCalledTimes(GITLAB_TASK_FETCH_CONCURRENCY))
unmount()
for (const resolve of pending.splice(0)) {
resolve()
}
await new Promise((resolve) => setTimeout(resolve, 0))
expect(rpc.call).toHaveBeenCalledTimes(GITLAB_TASK_FETCH_CONCURRENCY)
expect(setGitlabItems).not.toHaveBeenCalled()
})
})
@@ -1,162 +0,0 @@
// @vitest-environment happy-dom
import '@testing-library/jest-dom/vitest'
import { useState } from 'react'
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { TooltipProvider } from '@/components/ui/tooltip'
import { useAppStore } from '@/store'
import type { TaskPageComposerActionsModel } from '../use-task-page-composer-actions'
import type { JiraProjectReference } from '../../../../../shared/jira/jira-project-reference'
import TaskPage from '../TaskPage'
import { TaskPageJiraFilters } from './Filters'
const fixture = vi.hoisted(() => ({ loading: false }))
vi.mock('../Surface', () => ({
TaskPageSurface: ({ model }: { model: TaskPageComposerActionsModel }) => (
<FiltersFixture baseModel={model} loading={fixture.loading} />
)
}))
const project = { id: '10', key: 'ALP', name: 'Alpha', siteId: 'site-a' }
const setJiraProjectFilter = vi.fn()
const initialStore = useAppStore.getState()
const initialApiDescriptor = Object.getOwnPropertyDescriptor(window, 'api')
function FiltersFixture({
baseModel,
loading
}: {
baseModel: TaskPageComposerActionsModel
loading: boolean
}) {
const [jiraHasWorkspace, setJiraHasWorkspace] = useState(false)
const [jiraProjectFilter, setProject] = useState<JiraProjectReference | null>(project)
const model = {
...baseModel,
jiraHasWorkspace,
setJiraHasWorkspace,
jiraProjectFilter,
setJiraProjectFilter: (value: JiraProjectReference | null) => {
setJiraProjectFilter(value)
setProject(value)
},
setTaskResumeState: vi.fn(),
jiraPresets: [{ id: 'all', label: 'All' }],
jiraLoading: false,
jiraSearchInput: '',
setJiraSearchInput: vi.fn(),
setAppliedJiraSearch: vi.fn(),
activeJiraPreset: 'all',
setActiveJiraPreset: vi.fn(),
setJiraRefreshNonce: vi.fn(),
jiraSavedFilters: [],
jiraActiveSavedFilterId: null,
jiraSavedFiltersReady: true,
jiraSavedFiltersLoading: false,
jiraSavedFiltersPending: false,
jiraSavedFiltersError: null,
retryJiraSavedFilters: vi.fn(),
applyJiraSavedFilter: vi.fn().mockResolvedValue(true),
createJiraSavedFilter: vi.fn().mockResolvedValue(undefined),
updateJiraSavedFilter: vi.fn().mockResolvedValue(undefined),
deleteJiraSavedFilter: vi.fn().mockResolvedValue(undefined),
clearJiraSavedFilterSelection: vi.fn().mockResolvedValue(undefined),
selectedJiraStatuses: [],
selectJiraStatuses: vi.fn().mockResolvedValue(undefined),
jiraQuickFiltersReady: true,
jiraQuickFiltersLoading: false,
jiraQuickFiltersPending: false,
jiraQuickFiltersError: null,
retryJiraQuickFilters: vi.fn(),
jiraStatusCatalog: null,
jiraTaskSourceScopeKey: 'fixture',
selectedJiraSiteId: 'all',
jiraProjectsLoading: loading,
setNewJiraIssueOpen: vi.fn(),
setNewJiraIssueTitle: vi.fn(),
setNewJiraIssueBody: vi.fn(),
setNewJiraIssueProjectId: vi.fn(),
setNewJiraIssueProjectQuery: vi.fn(),
setNewJiraIssueProjectCommandValue: vi.fn(),
setNewJiraIssueTypeId: vi.fn(),
sortedAvailableJiraProjects: [project]
} satisfies TaskPageComposerActionsModel
return (
<TooltipProvider>
<TaskPageJiraFilters model={model} />
</TooltipProvider>
)
}
beforeEach(() => {
fixture.loading = false
useAppStore.setState(
{
...initialStore,
settings: null,
persistedUIReady: false,
repos: [],
taskPageData: {},
checkLinearConnection: vi
.fn<typeof initialStore.checkLinearConnection>()
.mockResolvedValue(undefined),
checkJiraConnection: vi
.fn<typeof initialStore.checkJiraConnection>()
.mockResolvedValue(undefined),
refreshPreflightStatus: vi
.fn<typeof initialStore.refreshPreflightStatus>()
.mockResolvedValue(undefined)
},
true
)
Object.defineProperty(window, 'api', {
configurable: true,
value: {
gh: { viewer: vi.fn<typeof window.api.gh.viewer>().mockResolvedValue(null) }
}
})
})
afterEach(() => {
cleanup()
useAppStore.setState(initialStore, true)
if (initialApiDescriptor) {
Object.defineProperty(window, 'api', initialApiDescriptor)
} else {
Reflect.deleteProperty(window, 'api')
}
vi.clearAllMocks()
})
describe('Jira project filters in Has Workspace mode', () => {
it('disables project changes while linked and restores the existing choice on a preset', async () => {
render(<TaskPage />)
const picker = screen.getByRole('combobox', { name: 'Jira project' })
expect(picker).toBeEnabled()
expect(picker).toHaveTextContent('ALP · Alpha')
fireEvent.click(screen.getByRole('button', { name: 'Has Workspace' }))
expect(picker).toBeDisabled()
fireEvent.click(picker)
expect(screen.queryByRole('option')).not.toBeInTheDocument()
expect(setJiraProjectFilter).not.toHaveBeenCalled()
expect(picker).toHaveTextContent('ALP · Alpha')
fireEvent.click(screen.getByRole('button', { name: 'All' }))
expect(picker).toBeEnabled()
expect(picker).toHaveTextContent('ALP · Alpha')
fireEvent.click(picker)
fireEvent.click(await screen.findByRole('option', { name: 'All Jira projects' }))
expect(picker).toHaveTextContent('All Jira projects')
fireEvent.click(screen.getByRole('button', { name: 'Has Workspace' }))
expect(picker).toBeDisabled()
expect(picker).toHaveTextContent('All Jira projects')
fireEvent.click(screen.getByRole('button', { name: 'All' }))
expect(picker).toBeEnabled()
expect(picker).toHaveTextContent('All Jira projects')
expect(setJiraProjectFilter).toHaveBeenCalledExactlyOnceWith(null)
})
})
@@ -1,47 +0,0 @@
// @vitest-environment happy-dom
import { afterEach, expect, it, vi } from 'vitest'
import { cleanup, render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { TaskPageJiraSavedFilters } from './TaskPageJiraSavedFilters'
afterEach(cleanup)
const mine = { id: 'mine', name: 'My work', jql: 'assignee = currentUser()' }
function setup(overrides: Partial<React.ComponentProps<typeof TaskPageJiraSavedFilters>> = {}) {
const props = {
filters: [mine],
activeFilterId: mine.id,
currentJql: mine.jql,
onApply: vi.fn(async () => {}),
onCreate: vi.fn(async () => {}),
onUpdate: vi.fn(async () => {}),
onDelete: vi.fn(async () => {}),
...overrides
}
render(<TaskPageJiraSavedFilters {...props} />)
return { user: userEvent.setup(), props }
}
it('saves another named filter and requires explicit updates to existing queries', async () => {
const { user, props } = setup({ currentJql: 'project = APP' })
await user.click(screen.getByRole('button', { name: 'Saved filters' }))
await user.click(screen.getByRole('button', { name: 'Save current filter' }))
await user.type(screen.getByRole('textbox', { name: 'Name' }), 'App bugs')
await user.click(screen.getByRole('button', { name: 'Save filter' }))
await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull())
expect(props.onCreate).toHaveBeenCalledWith({ name: 'App bugs', jql: 'project = APP' })
expect(props.onUpdate).not.toHaveBeenCalled()
})
it('renames, explicitly updates, and deletes a saved filter', async () => {
const { user, props } = setup()
await user.click(screen.getByRole('button', { name: 'Saved filters' }))
await user.click(screen.getByRole('button', { name: 'Edit My work' }))
await user.clear(screen.getByRole('textbox', { name: 'Name' }))
await user.type(screen.getByRole('textbox', { name: 'Name' }), 'Open work')
await user.clear(screen.getByRole('textbox', { name: 'JQL' }))
await user.type(screen.getByRole('textbox', { name: 'JQL' }), 'status = Open')
await user.click(screen.getByRole('button', { name: 'Update filter' }))
expect(props.onUpdate).toHaveBeenCalledWith(mine.id, { name: 'Open work', jql: 'status = Open' })
await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull())
await user.click(screen.getByRole('button', { name: 'Saved filters' }))
await user.click(screen.getByRole('button', { name: 'Edit My work' }))
await user.click(screen.getByRole('button', { name: 'Delete filter' }))
expect(props.onDelete).toHaveBeenCalledWith(mine.id)
})
@@ -1,5 +1,5 @@
// @vitest-environment happy-dom
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { cleanup, render, screen } from '@testing-library/react'
import { afterEach, expect, it, vi } from 'vitest'
import { TaskPageJiraStatusFilters } from './TaskPageJiraStatusFilters'
import { getJiraStatusCatalogScopeKey } from './task-page-jira-status-catalog'
@@ -29,73 +29,6 @@ function model(): TaskPageComposerActionsModel {
jiraSavedFiltersPending: false
} as unknown as TaskPageComposerActionsModel
}
it('keeps a selected status and reachable Clear when the loaded catalog is empty', () => {
const state = model()
render(<TaskPageJiraStatusFilters model={state} />)
expect(screen.getByRole('button', { name: 'Developed' }).getAttribute('aria-pressed')).toBe(
'true'
)
fireEvent.click(screen.getByRole('button', { name: 'Clear statuses' }))
expect(state.selectJiraStatuses).toHaveBeenCalledWith([])
})
it('multi-selects actual statuses and hands the composed query to manual editing', async () => {
const state = model()
state.selectedJiraStatuses = ['In Progress']
state.jiraStatusCatalog = {
scopeKey: 'jira-owner::site::assigned',
items: [
{ name: 'Blocked', categoryKey: 'indeterminate' },
{ name: 'In Progress', categoryKey: 'indeterminate' }
]
}
render(<TaskPageJiraStatusFilters model={state} />)
fireEvent.click(screen.getByRole('button', { name: 'Blocked' }))
expect(state.selectJiraStatuses).toHaveBeenCalledWith(['In Progress', 'Blocked'])
fireEvent.click(screen.getByRole('button', { name: 'Edit as JQL' }))
await waitFor(() =>
expect(state.setJiraSearchInput).toHaveBeenCalledWith(
'assignee = currentUser() AND statusCategory != Done AND status in ("In Progress") ORDER BY updated DESC'
)
)
expect(state.setAppliedJiraSearch).toHaveBeenCalledWith(
'assignee = currentUser() AND statusCategory != Done AND status in ("In Progress") ORDER BY updated DESC'
)
})
it.each([true, false])(
'keeps current-sprint selection %s in the preview and Edit as JQL query',
async (currentSprint) => {
const state = model()
state.activeJiraPreset = 'all'
state.selectedJiraStatuses = ['Blocked']
state.jiraCurrentSprint = currentSprint
const sprintClause = currentSprint ? ' AND sprint in openSprints()' : ''
const expected = `statusCategory != Done AND status in ("Blocked")${sprintClause} ORDER BY updated DESC`
render(<TaskPageJiraStatusFilters model={state} />)
expect(screen.getByRole('textbox', { name: 'Status filter JQL' }).getAttribute('value')).toBe(
expected
)
fireEvent.click(screen.getByRole('button', { name: 'Edit as JQL' }))
await waitFor(() => expect(state.setJiraSearchInput).toHaveBeenCalledWith(expected))
expect(state.setAppliedJiraSearch).toHaveBeenCalledWith(expected)
}
)
it.each([false, true])(
'includes the mapped project and sprint selection %s in manual editing',
async (currentSprint) => {
const state = model()
state.jiraCurrentSprint = currentSprint
state.jiraProjectFilter = { id: '10', key: 'ALP', name: 'Alpha', siteId: 'site-a' }
render(<TaskPageJiraStatusFilters model={state} />)
const sprintClause = currentSprint ? ' AND sprint in openSprints()' : ''
const query = `project = "10" AND (assignee = currentUser() AND statusCategory != Done AND status in ("Developed")${sprintClause}) ORDER BY updated DESC`
expect(screen.getByRole('textbox', { name: 'Status filter JQL' }).getAttribute('value')).toBe(
query
)
fireEvent.click(screen.getByRole('button', { name: 'Edit as JQL' }))
await waitFor(() => expect(state.setAppliedJiraSearch).toHaveBeenCalledWith(query))
}
)
it('drops prior-project discovered statuses while retaining the selected status on project change', () => {
const state = model()
@@ -4,7 +4,6 @@ import { useCallback, useState, type ComponentProps, type ReactNode } from 'reac
import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type {
JiraComment,
JiraIssue,
JiraMutationResult,
JiraProjectStatusOrder,
@@ -301,110 +300,4 @@ describe('Jira board and detail mutation integration', () => {
expect(runtime.jiraGetIssueForTarget).toHaveBeenLastCalledWith(owner, issue.key, issue.siteId)
}
)
it.each([
{ kind: 'explicit', sourceContext: source, settlement: 'success' },
{ kind: 'explicit', sourceContext: source, settlement: 'rejection' },
{ kind: 'default', sourceContext: null, settlement: 'success' },
{ kind: 'default', sourceContext: null, settlement: 'rejection' }
])(
'refreshes the $kind-source detail timeline only after a board comment save $settlement',
async ({ kind, sourceContext, settlement }) => {
const boardSave = Promise.withResolvers<JiraMutationResult>()
let serverIssue = issue
let serverComments: JiraComment[] = [
{ id: 'existing', body: 'Existing note', createdAt: '2026-10-14T12:00:00Z' }
]
finishOutstandingWrites = () => boardSave.resolve({ ok: true })
runtime.jiraGetIssueForTarget.mockImplementation(async () => serverIssue)
runtime.jiraIssueCommentsForTarget.mockImplementation(async () => serverComments)
runtime.jiraUpdateIssueForTarget.mockReturnValue(boardSave.promise)
runtime.jiraListTransitionsForTarget.mockResolvedValue([
{
...done,
fields: [
{
key: 'comment',
name: 'Comment',
required: true,
schema: { system: 'comment', type: 'string' }
}
]
}
])
render(
<StoreBackedBoardAndDetail
sourceContext={sourceContext}
scopeKey={`board-comment-integration:${kind}:${settlement}`}
/>
)
await screen.findByText('Existing note')
expect(runtime.jiraIssueCommentsForTarget).toHaveBeenCalledExactlyOnceWith(
owner,
issue.key,
issue.siteId
)
fireEvent.keyDown(screen.getByRole('button', { name: `Move ${issue.key}` }), {
key: 'ArrowDown'
})
fireEvent.click(await screen.findByRole('menuitem', { name: 'Done' }))
const commentInput = await screen.findByRole('textbox', { name: 'Comment' })
expect(runtime.jiraUpdateIssueForTarget).not.toHaveBeenCalled()
fireEvent.change(commentInput, { target: { value: 'Verified from the board' } })
const form = commentInput.closest('form')
if (!form) {
throw new Error('Expected the board transition form')
}
fireEvent.submit(form)
await waitFor(() => expect(runtime.jiraUpdateIssueForTarget).toHaveBeenCalledTimes(1))
expect(runtime.jiraUpdateIssueForTarget).toHaveBeenCalledWith(
owner,
issue.key,
{ transitionId: done.id, transitionComment: 'Verified from the board' },
issue.siteId
)
expect(runtime.jiraIssueCommentsForTarget).toHaveBeenCalledTimes(1)
expect(screen.getByTestId('detail-comments').textContent).not.toContain(
'Verified from the board'
)
if (settlement === 'success') {
serverIssue = { ...issue, status: done.to }
serverComments = [
...serverComments,
{
id: 'board-comment',
body: 'Verified from the board',
createdAt: '2026-10-14T12:01:00Z'
}
]
await act(async () => boardSave.resolve({ ok: true }))
await screen.findByText('Verified from the board')
expect(runtime.jiraIssueCommentsForTarget).toHaveBeenCalledTimes(2)
expect(runtime.refresh).toHaveBeenCalledTimes(1)
} else {
await act(async () => boardSave.resolve({ ok: false, error: 'Board transition rejected' }))
expect((await screen.findByRole('alert')).textContent).toBe('Board transition rejected')
expect(runtime.jiraIssueCommentsForTarget).toHaveBeenCalledTimes(1)
expect(screen.getByTestId('detail-comments').textContent).not.toContain(
'Verified from the board'
)
expect(runtime.refresh).not.toHaveBeenCalled()
expect(screen.getByDisplayValue('Verified from the board')).toBeDefined()
expect(screen.getByRole('button', { name: 'Change status' }).hasAttribute('disabled')).toBe(
false
)
}
expect(screen.getByText('Existing note')).toBeDefined()
expect(screen.getByTestId('detail-comments-loading').textContent).toBe('false')
expect(runtime.jiraIssueCommentsForTarget.mock.calls).toEqual(
Array.from({ length: settlement === 'success' ? 2 : 1 }, () => [
owner,
issue.key,
issue.siteId
])
)
expect(runtime.jiraAddIssueCommentForTarget).not.toHaveBeenCalled()
}
)
})
@@ -1,13 +1,9 @@
// @vitest-environment happy-dom
import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { JiraIssue, JiraTransition } from '../../../../../shared/jira/jira-types'
import type { TaskSourceContext } from '../../../../../shared/task-source-context'
import type { RuntimeClientTarget } from '../../../../../shared/runtime-client-target'
import {
bumpProviderRuntimeSessionGeneration,
getEnvironmentRuntimeContextKey
} from '@/lib/provider-runtime-context'
const mocks = vi.hoisted(() => ({
transitions: vi.fn(),
@@ -27,7 +23,6 @@ vi.mock('./task-page-jira-board-move', () => ({
import { TaskPageJiraBoardView } from './task-page-jira-board-view'
import { TooltipProvider } from '@/components/ui/tooltip'
import { enqueueJiraIssueMutation, jiraIssueRequestScope } from '../../jira-issue-mutation-queue'
const source: TaskSourceContext = {
kind: 'task-source',
@@ -99,154 +94,6 @@ describe('Jira board moves', () => {
mocks.apply.mockResolvedValue({ result: { ok: true }, latest: null })
})
it('moves from the keyboard menu and refreshes the execution host', async () => {
mount()
await move()
await waitFor(() =>
expect(mocks.apply).toHaveBeenCalledWith(target, issue, { transitionId: 'done' })
)
expect(mocks.refresh).toHaveBeenCalledTimes(1)
expect(mocks.patch).toHaveBeenCalledWith(
issue.key,
{ status: transition.to },
{
sourceContext: source,
siteId: 'site-a'
}
)
expect(screen.queryByRole('dialog')).toBeNull()
})
it('queues a board move behind a detail save without stale rollback', async () => {
const previousSave = Promise.withResolvers<{ ok: false; error: string }>()
const scope = jiraIssueRequestScope(issue, source, {
activeRuntimeEnvironmentId: 'board-owner'
})
if (!scope) {
throw new Error('Expected source-owned mutation scope')
}
const detailSave = enqueueJiraIssueMutation({
scope,
previous: issue,
optimistic: { status: { ...issue.status, id: 'review', name: 'Review' } },
update: () => previousSave.promise,
refresh: async () => null,
patchIssue: (patch) =>
mocks.patch(issue.key, patch, { sourceContext: source, siteId: issue.siteId })
}).catch(() => undefined)
mount()
await move()
await waitFor(() => expect(mocks.patch).toHaveBeenCalledTimes(2))
expect(mocks.apply).not.toHaveBeenCalled()
await act(async () => {
previousSave.resolve({ ok: false, error: 'Earlier detail save rejected' })
await detailSave
})
await waitFor(() => expect(mocks.apply).toHaveBeenCalledTimes(1))
expect(mocks.patch.mock.calls.slice(2).map((call) => call[1])).toEqual([
{ status: transition.to }
])
expect(mocks.refresh).toHaveBeenCalledTimes(1)
})
it('collects a required comment before changing the status', async () => {
mocks.transitions.mockResolvedValue([
{
...transition,
fields: [
{
key: 'comment',
name: 'Comment',
required: true,
schema: { system: 'comment', type: 'string' }
}
]
}
])
mount()
await move()
const comment = await screen.findByRole('textbox', { name: 'Comment' })
expect(mocks.apply).not.toHaveBeenCalled()
fireEvent.change(comment, { target: { value: 'Release checked.' } })
const form = comment.closest('form')
if (!form) {
throw new Error('Expected the transition form')
}
fireEvent.submit(form)
await waitFor(() =>
expect(mocks.apply).toHaveBeenCalledWith(target, issue, {
transitionId: 'done',
transitionComment: 'Release checked.'
})
)
})
it('asks which workflow action to use when a column has several actions', async () => {
mocks.transitions.mockResolvedValue([
transition,
{ ...transition, id: 'cancel', name: 'Cancel work' }
])
mount()
await move()
await screen.findByRole('button', { name: 'Cancel work' })
expect(mocks.apply).not.toHaveBeenCalled()
fireEvent.click(screen.getByRole('button', { name: 'Complete' }))
await waitFor(() =>
expect(mocks.apply).toHaveBeenCalledWith(target, issue, { transitionId: 'done' })
)
})
it('leaves unsupported required fields in Jira', async () => {
mocks.transitions.mockResolvedValue([
{
...transition,
fields: [
{
key: 'customfield_42',
name: 'Release approval',
required: true,
schema: { type: 'array' }
}
]
}
])
mount()
await move()
await screen.findByText('Jira requires fields Orca cannot collect yet: Release approval.')
expect(screen.getByRole('button', { name: 'Open in Jira' })).toBeTruthy()
expect(mocks.apply).not.toHaveBeenCalled()
expect(mocks.patch).not.toHaveBeenCalled()
})
it('rolls back a rejected mutation and keeps its error available for Retry', async () => {
mocks.apply.mockResolvedValueOnce({
result: { ok: false, error: 'Workflow rejected the move' },
latest: null
})
mount()
await move()
await screen.findByRole('alert')
expect(mocks.patch.mock.calls.map((call) => call[1])).toEqual([
{ status: transition.to },
{ status: issue.status }
])
expect(screen.getByRole('alert').textContent).toBe('Workflow rejected the move')
fireEvent.click(screen.getByRole('button', { name: 'Retry' }))
await waitFor(() => expect(mocks.apply).toHaveBeenCalledTimes(2))
})
it('retries metadata failures without a mutation or optimistic status change', async () => {
mocks.transitions.mockRejectedValueOnce(new Error('Jira is offline'))
mount()
await move()
expect((await screen.findByRole('alert')).textContent).toBe('Jira is offline')
expect(mocks.apply).not.toHaveBeenCalled()
expect(mocks.patch).not.toHaveBeenCalled()
fireEvent.click(screen.getByRole('button', { name: 'Retry' }))
await waitFor(() => expect(mocks.transitions).toHaveBeenCalledTimes(2))
expect(mocks.transitions.mock.calls[1][4]).toBe(true)
})
it('does not reopen or mutate after the user cancels a pending metadata request', async () => {
let resolve: (value: JiraTransition[]) => void = () => {}
mocks.transitions.mockReturnValue(
@@ -265,77 +112,4 @@ describe('Jira board moves', () => {
expect(screen.queryByRole('dialog')).toBeNull()
expect(mocks.apply).not.toHaveBeenCalled()
})
it('does not apply a late transition after its owning board is removed', async () => {
let resolve: (value: JiraTransition[]) => void = () => {}
mocks.transitions.mockReturnValue(
new Promise<JiraTransition[]>((done) => {
resolve = done
})
)
const view = mount()
await move()
view.unmount()
await act(async () => {
resolve([transition])
await Promise.resolve()
})
expect(mocks.apply).not.toHaveBeenCalled()
})
it('disables moves while the issue collection is being replaced', () => {
mount(true)
expect(screen.getByRole('button', { name: 'Move ABC-1' }).hasAttribute('disabled')).toBe(true)
expect(mocks.transitions).not.toHaveBeenCalled()
})
it('disables moves when the task page owner cannot be resolved', () => {
mount(false, null)
expect(screen.getByRole('button', { name: 'Move ABC-1' }).hasAttribute('disabled')).toBe(true)
expect(mocks.transitions).not.toHaveBeenCalled()
expect(mocks.apply).not.toHaveBeenCalled()
})
it('loads workflow actions from the captured owner', async () => {
mount()
await move()
expect(mocks.transitions.mock.calls[0][0]).toEqual(target)
})
it.each([
{ settlement: 'success', sourceContext: null, kind: 'default' },
{ settlement: 'failure', sourceContext: null, kind: 'default' },
{ settlement: 'success', sourceContext: source, kind: 'explicit' },
{ settlement: 'failure', sourceContext: source, kind: 'explicit' }
])(
'settles a late $settlement in the $kind source after the board is removed',
async ({ settlement, sourceContext }) => {
const contextKey = getEnvironmentRuntimeContextKey('board-owner')
let settle: () => void = () => {}
mocks.apply.mockReturnValue(
new Promise((resolve, reject) => {
settle = () =>
settlement === 'success'
? resolve({ result: { ok: true }, latest: { ...issue, status: transition.to } })
: reject(new Error('Old owner rejected the move'))
})
)
const view = mount(false, target, sourceContext)
await move()
await waitFor(() => expect(mocks.apply).toHaveBeenCalledTimes(1))
expect(mocks.patch).toHaveBeenCalledTimes(1)
view.unmount()
bumpProviderRuntimeSessionGeneration()
await act(async () => settle())
expect(mocks.patch).toHaveBeenCalledTimes(2)
expect(mocks.patch).toHaveBeenLastCalledWith(
issue.key,
settlement === 'success' ? { ...issue, status: transition.to } : { status: issue.status },
{
sourceContext,
siteId: issue.siteId,
...(!sourceContext ? { contextKey } : {})
}
)
expect(mocks.refresh).not.toHaveBeenCalled()
}
)
})
@@ -65,30 +65,4 @@ describe('findTaskPageJiraIssue', () => {
expect(found?.title).toBe('Remote issue')
})
it('filters same-key Jira issues by site id', () => {
const source = jiraSourceContext('remote-runtime')
const scope = getTaskSourceCacheScope(source)
const found = findTaskPageJiraIssue(
{},
{
[`${scope}::site-1::list::assigned::30`]: {
data: [jiraIssue('ALP-1', 'Site one issue', 'site-1')],
fetchedAt: Date.now()
},
[`${scope}::site-2::list::assigned::30`]: {
data: [jiraIssue('ALP-1', 'Site two issue', 'site-2')],
fetchedAt: Date.now()
}
},
'ALP-1',
{
sourceContext: source,
siteId: 'site-2'
}
)
expect(found?.title).toBe('Site two issue')
})
})
@@ -1,270 +0,0 @@
import { describe, expect, it } from 'vitest'
import {
buildJiraCreateCustomFields,
buildJiraCreateFieldValue,
buildJiraCreateSubmission,
findJiraCreateAllowedValue,
getJiraCreateAllowedValueLabel,
getJiraCreateOptionPayload,
getJiraUserCreateFieldKeys,
hasJiraAssigneeCreateField,
isJiraScalarUserCreateField,
isJiraUserCreateField,
isVisibleJiraCreateField
} from './task-page-jira-create-fields'
import type { JiraCreateField } from '../../../../../shared/jira/jira-types'
function field(overrides: Partial<JiraCreateField> = {}): JiraCreateField {
return { key: 'customfield_1', name: 'Custom', required: true, ...overrides }
}
describe('isVisibleJiraCreateField', () => {
const cases = [
{ key: 'customfield_1', required: true, expected: true },
{ key: 'customfield_1', required: false, expected: false },
{ key: 'project', required: true, expected: false },
{ key: 'issuetype', required: true, expected: false },
{ key: 'summary', required: true, expected: false },
{ key: 'description', required: true, expected: false },
{ key: 'assignee', required: true, expected: false },
{ key: 'Summary', required: true, expected: true }
]
for (const { key, required, expected } of cases) {
it(`returns ${expected} for ${key} (required: ${required})`, () => {
expect(isVisibleJiraCreateField(field({ key, required }))).toBe(expected)
})
}
})
describe('isJiraUserCreateField', () => {
it('matches user pickers, which carry no allowedValues to detect them by', () => {
expect(isJiraUserCreateField(field({ key: 'reporter', schema: { type: 'user' } }))).toBe(true)
expect(isJiraUserCreateField(field({ schema: { type: 'array', items: 'user' } }))).toBe(true)
})
it('ignores non-user fields', () => {
expect(isJiraUserCreateField(field({ schema: { type: 'string' } }))).toBe(false)
expect(isJiraUserCreateField(field({ schema: { type: 'array', items: 'option' } }))).toBe(false)
expect(isJiraUserCreateField(field())).toBe(false)
})
})
describe('isJiraScalarUserCreateField', () => {
it('matches single-user fields the picker can hold', () => {
expect(isJiraScalarUserCreateField(field({ key: 'reporter', schema: { type: 'user' } }))).toBe(
true
)
})
it('rejects array-of-user, which the single-value picker would collapse to one member', () => {
expect(isJiraScalarUserCreateField(field({ schema: { type: 'array', items: 'user' } }))).toBe(
false
)
})
it('ignores non-user fields', () => {
expect(isJiraScalarUserCreateField(field({ schema: { type: 'string' } }))).toBe(false)
expect(isJiraScalarUserCreateField(field())).toBe(false)
})
})
describe('getJiraUserCreateFieldKeys', () => {
it('collects only the user-typed keys for the host to shape', () => {
expect(
getJiraUserCreateFieldKeys([
field({ key: 'reporter', schema: { type: 'user' } }),
field({ key: 'customfield_1', schema: { type: 'string' } }),
field({ key: 'customfield_2', schema: { type: 'array', items: 'user' } })
])
).toEqual(['reporter', 'customfield_2'])
})
it('returns an empty list when nothing is user-typed', () => {
expect(getJiraUserCreateFieldKeys([field()])).toEqual([])
})
})
describe('getJiraCreateAllowedValueLabel', () => {
it('prefers name, then value, then id', () => {
expect(getJiraCreateAllowedValueLabel({ id: 'i', value: 'v', name: 'n' })).toBe('n')
expect(getJiraCreateAllowedValueLabel({ id: 'i', value: 'v' })).toBe('v')
expect(getJiraCreateAllowedValueLabel({ id: 'i' })).toBe('i')
})
it('falls back to Option when nothing is set', () => {
expect(getJiraCreateAllowedValueLabel({})).toBe('Option')
})
})
describe('findJiraCreateAllowedValue', () => {
const withValues = field({
allowedValues: [
{ id: 'id-1', value: 'val-1', name: 'Name 1' },
{ id: 'id-2', value: 'val-2', name: 'Name 2' }
]
})
it('matches on id, value, or name', () => {
expect(findJiraCreateAllowedValue(withValues, 'id-2')?.id).toBe('id-2')
expect(findJiraCreateAllowedValue(withValues, 'val-1')?.id).toBe('id-1')
expect(findJiraCreateAllowedValue(withValues, 'Name 2')?.id).toBe('id-2')
})
it('returns undefined when there is no match or no allowed values', () => {
expect(findJiraCreateAllowedValue(withValues, 'nope')).toBeUndefined()
expect(findJiraCreateAllowedValue(field(), 'id-1')).toBeUndefined()
})
})
describe('getJiraCreateOptionPayload', () => {
it('prefers id, then value, then name', () => {
expect(getJiraCreateOptionPayload({ id: 'i', value: 'v', name: 'n' }, 'fb')).toEqual({
id: 'i'
})
expect(getJiraCreateOptionPayload({ value: 'v', name: 'n' }, 'fb')).toEqual({ value: 'v' })
expect(getJiraCreateOptionPayload({ name: 'n' }, 'fb')).toEqual({ name: 'n' })
})
it('returns the raw fallback string when the option is absent or empty', () => {
expect(getJiraCreateOptionPayload(undefined, 'fb')).toBe('fb')
expect(getJiraCreateOptionPayload({}, 'fb')).toBe('fb')
})
it('skips empty-string members', () => {
expect(getJiraCreateOptionPayload({ id: '', value: 'v' }, 'fb')).toEqual({ value: 'v' })
})
})
describe('buildJiraCreateFieldValue', () => {
it('returns undefined for blank drafts', () => {
expect(buildJiraCreateFieldValue(field(), '')).toBeUndefined()
expect(buildJiraCreateFieldValue(field(), ' ')).toBeUndefined()
})
it('trims plain string values', () => {
expect(buildJiraCreateFieldValue(field(), ' hello ')).toBe('hello')
})
it('splits array fields on commas and drops blanks', () => {
const arrayField = field({ schema: { type: 'array' } })
expect(buildJiraCreateFieldValue(arrayField, 'a, b ,, c')).toEqual(['a', 'b', 'c'])
})
it('maps array parts through allowed values', () => {
const arrayField = field({
schema: { type: 'array' },
allowedValues: [{ id: 'id-1', name: 'Name 1' }]
})
expect(buildJiraCreateFieldValue(arrayField, 'Name 1, other')).toEqual([
{ id: 'id-1' },
'other'
])
})
it('maps scalar option fields through allowed values', () => {
const optionField = field({ allowedValues: [{ id: 'id-1', name: 'Name 1' }] })
expect(buildJiraCreateFieldValue(optionField, 'Name 1')).toEqual({ id: 'id-1' })
expect(buildJiraCreateFieldValue(optionField, 'Unknown')).toBe('Unknown')
})
it('coerces finite numbers and keeps unparseable text', () => {
const numberField = field({ schema: { type: 'number' } })
expect(buildJiraCreateFieldValue(numberField, ' 42 ')).toBe(42)
expect(buildJiraCreateFieldValue(numberField, 'abc')).toBe('abc')
expect(buildJiraCreateFieldValue(numberField, 'Infinity')).toBe('Infinity')
})
it('builds an ADF document for textarea fields', () => {
const custom = field({ schema: { custom: 'com.atlassian.jira:textarea' } })
expect(buildJiraCreateFieldValue(custom, 'line one\nline two')).toEqual({
type: 'doc',
version: 1,
content: [
{ type: 'paragraph', content: [{ type: 'text', text: 'line one' }] },
{ type: 'paragraph', content: [{ type: 'text', text: 'line two' }] }
]
})
expect(buildJiraCreateFieldValue(field({ schema: { type: 'textarea' } }), 'x')).toEqual({
type: 'doc',
version: 1,
content: [{ type: 'paragraph', content: [{ type: 'text', text: 'x' }] }]
})
})
it('prefers allowed values over the number branch', () => {
const numeric = field({ schema: { type: 'number' }, allowedValues: [{ id: 'id-1' }] })
expect(buildJiraCreateFieldValue(numeric, '7')).toBe('7')
})
})
describe('buildJiraCreateCustomFields', () => {
it('returns undefined when every field resolves to undefined', () => {
expect(buildJiraCreateCustomFields([field({ key: 'a' })], {})).toBeUndefined()
expect(buildJiraCreateCustomFields([], { a: 'x' })).toBeUndefined()
})
it('collects only the fields with values, keyed by field key', () => {
const fields = [field({ key: 'a' }), field({ key: 'b' }), field({ key: 'c' })]
expect(buildJiraCreateCustomFields(fields, { a: 'one', b: ' ', c: 'three' })).toEqual({
a: 'one',
c: 'three'
})
})
it('treats a missing draft entry as blank', () => {
expect(buildJiraCreateCustomFields([field({ key: 'a' })], { other: 'x' })).toBeUndefined()
})
})
describe('hasJiraAssigneeCreateField', () => {
it('detects whether the create screen accepts an assignee', () => {
expect(hasJiraAssigneeCreateField([field({ key: 'assignee', required: false })])).toBe(true)
expect(hasJiraAssigneeCreateField([field({ key: 'reporter' })])).toBe(false)
expect(hasJiraAssigneeCreateField([])).toBe(false)
})
})
describe('buildJiraCreateSubmission', () => {
const userField = field({ key: 'customfield_9', schema: { type: 'user' } })
it('omits assignee and userFieldKeys for the Automatic default', () => {
expect(buildJiraCreateSubmission([field({ key: 'a' })], { a: 'one' }, null)).toEqual({
customFields: { a: 'one' },
userFieldKeys: undefined
})
expect(buildJiraCreateSubmission([], {}, undefined)).toEqual({
customFields: undefined,
userFieldKeys: undefined
})
})
it('adds the picked assignee to customFields and names it as a user field', () => {
expect(buildJiraCreateSubmission([], {}, 'acc-1')).toEqual({
customFields: { assignee: 'acc-1' },
userFieldKeys: ['assignee']
})
})
it('names only the user-typed fields that carry a value', () => {
expect(
buildJiraCreateSubmission(
[userField, field({ key: 'customfield_8', schema: { type: 'user' } })],
{ customfield_9: 'acc-9' },
'acc-1'
)
).toEqual({
customFields: { customfield_9: 'acc-9', assignee: 'acc-1' },
userFieldKeys: ['customfield_9', 'assignee']
})
})
it('keeps userFieldKeys absent when user fields exist but are blank', () => {
// Even empty user-field key lists must avoid requiring capabilities absent from older hosts.
expect(buildJiraCreateSubmission([userField], {}, null)).toEqual({
customFields: undefined,
userFieldKeys: undefined
})
})
})
@@ -1,7 +1,7 @@
// @vitest-environment happy-dom
import { act, cleanup, renderHook, waitFor } from '@testing-library/react'
import { afterEach, beforeEach, expect, it } from 'vitest'
import { UNRESOLVED_OWNER_HOST_ID } from '../../../../../shared/execution-host'
import {
favouriteTransport as transport,
favourite,
@@ -26,87 +26,6 @@ async function ready(result: { current: ReturnType<typeof useFavouriteTaskPage>
expect(result.current.jiraLoading).toBe(false)
})
}
async function select(result: { current: ReturnType<typeof useFavouriteTaskPage> }) {
await act(async () =>
expect(
await result.current.applyJiraFavouriteFilter(result.current.jiraFavouriteFilters[0])
).toBe(true)
)
await waitFor(() => expect(result.current.jiraLoading).toBe(false))
}
it('runs an exact favourite on its captured owner/site without saving its JQL', async () => {
const { result } = renderHook(() => useFavouriteTaskPage())
await ready(result)
expect(transport.catalogue).toHaveBeenCalledExactlyOnceWith(
{ kind: 'environment', environmentId: 'owner-a' },
'site-a'
)
await select(result)
expect(transport.search).toHaveBeenCalledExactlyOnceWith(favourite.jql, 50, {
sourceContext: result.current.jiraTaskSourceContext,
siteId: 'site-a',
force: false
})
expect(result.current.jiraSearchInput).toBe('')
expect(result.current.appliedJiraSearch).toBe('')
expect(JSON.stringify(transport.resume.mock.calls)).not.toContain('12345')
expect(transport.mutateSaved).not.toHaveBeenCalled()
act(() => result.current.setJiraRefreshNonce((value) => value + 1))
await waitFor(() => expect(transport.search).toHaveBeenCalledTimes(2))
expect(transport.search.mock.lastCall?.[2].force).toBe(true)
})
it('composes same-site project and sprint while preserving exact JQL and ordering', async () => {
const { result } = renderHook(() => useFavouriteTaskPage())
await ready(result)
act(() => {
result.current.setJiraProjectFilter({ id: '77', key: 'APP', name: 'App', siteId: 'site-a' })
result.current.setJiraCurrentSprint(true)
})
transport.search.mockClear()
await select(result)
expect(transport.search).toHaveBeenLastCalledWith(
'project = "77" AND ((filter = 12345) AND sprint in openSprints()) ORDER BY updated DESC',
50,
{ sourceContext: result.current.jiraTaskSourceContext, siteId: 'site-a', force: false }
)
})
it('rejects a different-site project without sending favourite JQL anywhere', async () => {
const { result } = renderHook(() => useFavouriteTaskPage())
await ready(result)
await select(result)
transport.search.mockClear()
act(() =>
result.current.setJiraProjectFilter({ id: '77', key: 'OTHER', name: 'Other', siteId: 'site-b' })
)
await waitFor(() => expect(result.current.jiraError).not.toBeNull())
expect(transport.search).not.toHaveBeenCalled()
})
it('does not reinterpret a rejected favourite as text', async () => {
const { result } = renderHook(() => useFavouriteTaskPage())
await ready(result)
transport.search.mockRejectedValue(new Error('Error 400: Invalid favourite query'))
await select(result)
expect(transport.search).toHaveBeenCalledTimes(1)
expect(result.current.jiraError?.details).toContain('Invalid favourite query')
})
it('clears the transient selection immediately when manual search changes, and never resumes it', async () => {
const { result, unmount } = renderHook(() => useFavouriteTaskPage())
await ready(result)
await select(result)
act(() => result.current.setJiraSearchInput('ordinary search'))
expect(result.current.jiraFavouriteFilter).toBeNull()
await waitFor(() => expect(result.current.appliedJiraSearch).toBe('ordinary search'))
expect(JSON.stringify(transport.resume.mock.calls)).not.toContain('12345')
unmount()
const next = renderHook(() => useFavouriteTaskPage())
await ready(next.result)
expect(next.result.current.jiraFavouriteFilter).toBeNull()
})
it('waits for confirmed named deselection before changing query or linked mode', async () => {
transport.readSaved.mockResolvedValue({ ...emptySaved, activeFilterId: named.id })
@@ -135,68 +54,6 @@ it('waits for confirmed named deselection before changing query or linked mode',
)
})
it.each(['retained', 'rejected'] as const)(
'keeps the named query when deselection is %s',
async (failure) => {
const selected = { ...emptySaved, activeFilterId: named.id }
transport.readSaved.mockResolvedValue(selected)
const { result } = renderHook(() => useFavouriteTaskPage())
await ready(result)
if (failure === 'retained') {
transport.mutateSaved.mockResolvedValueOnce(selected)
} else {
transport.mutateSaved.mockRejectedValueOnce(new Error('Host unavailable'))
}
await act(async () =>
expect(
await result.current.applyJiraFavouriteFilter(result.current.jiraFavouriteFilters[0])
).toBe(false)
)
expect(result.current.jiraFavouriteFilter).toBeNull()
expect(result.current.jiraSearchInput).toBe(named.jql)
expect(result.current.jiraFavouriteFiltersError).not.toBeNull()
}
)
it('prevents favourite application while an existing named apply or refresh is pending', async () => {
const { result } = renderHook(() => useFavouriteTaskPage())
await ready(result)
const deferred = Promise.withResolvers<typeof emptySaved>()
transport.mutateSaved.mockReturnValueOnce(deferred.promise)
let applying = Promise.resolve(false)
act(() => {
applying = result.current.applyJiraSavedFilter(named)
})
await act(async () =>
expect(
await result.current.applyJiraFavouriteFilter(result.current.jiraFavouriteFilters[0])
).toBe(false)
)
await act(async () => {
deferred.resolve({ ...emptySaved, activeFilterId: named.id })
await applying
})
expect(result.current.jiraFavouriteFilter).toBeNull()
const read = Promise.withResolvers<typeof emptySaved>()
transport.readSaved.mockReturnValueOnce(read.promise)
act(() => result.current.retryJiraSavedFilters())
await act(async () =>
expect(
await result.current.applyJiraFavouriteFilter(result.current.jiraFavouriteFilters[0])
).toBe(false)
)
await act(async () => read.resolve(emptySaved))
})
it('a later named selection clears the favourite and restores named exact JQL', async () => {
const { result } = renderHook(() => useFavouriteTaskPage())
await ready(result)
await select(result)
await act(async () => expect(await result.current.applyJiraSavedFilter(named)).toBe(true))
expect(result.current.jiraFavouriteFilter).toBeNull()
expect(result.current.appliedJiraSearch).toBe(named.jql)
})
it.each(['site', 'owner', 'disabled'] as const)(
'fences a delayed handoff across %s A→B→A',
async (kind) => {
@@ -230,15 +87,3 @@ it.each(['site', 'owner', 'disabled'] as const)(
expect(result.current.jiraSearchInput).not.toContain('12345')
}
)
it.each(['all', 'unresolved'] as const)('does no catalogue read for %s', async (kind) => {
const { result } = renderHook(() =>
useFavouriteTaskPage({
siteId: kind === 'all' ? 'all' : 'site-a',
hostId: kind === 'unresolved' ? UNRESOLVED_OWNER_HOST_ID : 'runtime:owner-a'
})
)
await waitFor(() => expect(result.current.jiraSavedFiltersLoading).toBe(false))
expect(transport.catalogue).not.toHaveBeenCalled()
expect(result.current.jiraFavouriteFiltersEnabled).toBe(false)
})
@@ -1,226 +0,0 @@
// @vitest-environment happy-dom
import { useCallback, useRef, useState } from 'react'
import { act, cleanup, renderHook, waitFor } from '@testing-library/react'
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
import type { JiraIssue } from '../../../../../shared/jira/jira-types'
import type { JiraSavedFilterState } from '../../../../../shared/jira/jira-saved-filters'
import type { TaskPageLinearCollectionEffectsModel } from '../linear/use-task-page-linear-collection-effects'
import { createTestStore } from '@/store/testing/app-store-test-fixture'
import { useTaskPageJiraSavedFilters } from './use-task-page-jira-saved-filters'
import { useTaskPageJiraListEffects } from './use-task-page-jira-list-effects'
import type { JiraStatusCatalogItem } from './task-page-jira-status-catalog'
const runtime = vi.hoisted(() => ({
capability: vi.fn(),
rpc: vi.fn(),
list: vi.fn(),
search: vi.fn(),
statusOrder: vi.fn()
}))
vi.mock('@/runtime/rpc/compatibility/runtime-capability-proof', () => ({
assertRuntimeEnvironmentCapability: runtime.capability
}))
vi.mock('@/runtime/rpc/calls/runtime-rpc-call', () => ({
callRuntimeRpc: runtime.rpc
}))
vi.mock('@/runtime/jira/runtime-jira-client', () => ({
jiraListIssuesForTarget: runtime.list,
jiraSearchIssues: runtime.search,
jiraGetProjectStatusOrder: runtime.statusOrder
}))
const issue: JiraIssue = {
id: 'load-failure-1',
key: 'APP-1',
siteId: 'site-1',
title: 'Jira still loads when saved filters are unavailable',
url: 'https://jira.invalid/browse/APP-1',
project: { id: 'project-1', key: 'APP', name: 'App' },
issueType: { id: 'task', name: 'Task' },
status: { id: 'open', name: 'Open', categoryKey: 'new', categoryName: 'To Do' },
labels: [],
createdAt: '2026-10-14',
updatedAt: '2026-10-14'
}
const issues = [issue]
const selectedStatuses: string[] = []
const noop = () => {}
const suspendedRead = new Promise<JiraIssue[]>(() => {})
function useTaskPageFixture(store: ReturnType<typeof createTestStore>, remote: boolean) {
const [jiraIssues, setJiraIssues] = useState<JiraIssue[]>([])
const [jiraLoading, setJiraLoading] = useState(false)
const [jiraSearchInput, setJiraSearchInput] = useState('')
const [appliedJiraSearch, setAppliedJiraSearch] = useState('')
const [jiraRefreshNonce, setJiraRefreshNonce] = useState(0)
const [, setJiraStatusCatalog] = useState<{
scopeKey: string
items: JiraStatusCatalogItem[]
} | null>(null)
const setQuery = useCallback((query: string) => {
setJiraSearchInput(query)
setAppliedJiraSearch(query)
}, [])
const owner = {
taskSource: 'jira',
taskResumeApplied: true,
accountBackedTaskSourceHostId: remote ? 'runtime:unavailable-filter-owner' : 'local',
jiraTaskSourceContext: null
} as const
const savedFilters = useTaskPageJiraSavedFilters(owner, setQuery)
const model = {
...owner,
...savedFilters,
jiraConnected: true,
selectedJiraSiteId: 'site-1',
jiraHasWorkspace: false,
linkedJiraIssueRefs: [],
linkedJiraIssueRefsSignature: '[]',
jiraTaskSourceScopeKey: 'load-failure',
jiraSearchPersistReadyRef: useRef(true),
selectedJiraIssueKey: null,
selectedJiraIssueFallback: null,
setSelectedJiraIssueKey: noop,
setSelectedJiraIssueFallback: noop,
setTaskResumeState: noop,
setJiraError: noop,
setJiraErrorDetailsOpen: noop,
setJiraJqlRejection: noop,
setJiraStatusCatalog,
setJiraProjectStatusOrder: noop,
setJiraIssues,
setJiraLoading,
jiraSearchInput,
appliedJiraSearch,
setAppliedJiraSearch,
jiraRefreshNonce,
activeJiraPreset: 'assigned',
jiraCurrentSprint: false,
jiraProjectFilter: null,
selectedJiraStatuses: selectedStatuses,
jiraQuickFiltersLoading: false,
displayedJiraIssues: jiraIssues,
listJiraIssues: store.getState().listJiraIssues,
searchJiraIssues: store.getState().searchJiraIssues
}
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: All fields read by the real Jira effects are supplied; unrelated provider pipeline stages are omitted.
useTaskPageJiraListEffects(model as unknown as TaskPageLinearCollectionEffectsModel)
return {
...savedFilters,
jiraIssues,
jiraLoading,
appliedJiraSearch,
setJiraSearchInput,
refresh: () => setJiraRefreshNonce((value) => value + 1)
}
}
beforeEach(() => {
vi.resetAllMocks()
runtime.capability.mockResolvedValue(undefined)
runtime.rpc.mockRejectedValue(new Error('Jira host disconnected'))
runtime.list.mockResolvedValue(issues)
runtime.search.mockResolvedValue(issues)
runtime.statusOrder.mockResolvedValue({ statusIdsByColumn: [], columns: [] })
vi.stubGlobal('api', { ui: {} })
})
afterEach(() => {
cleanup()
vi.unstubAllGlobals()
})
it.each([
{ failure: 'plain missing local bridge', remote: false, cache: 'fresh' },
{ failure: 'plain missing local bridge', remote: false, cache: 'stale' },
{ failure: 'rejected remote read', remote: true, cache: 'fresh' },
{ failure: 'rejected remote read', remote: true, cache: 'stale' }
])(
'settles $cache Jira results after $failure and keeps query/refresh working',
async (testCase) => {
const store = createTestStore()
store.setState({ jiraStatus: { connected: true, viewer: null, selectedSiteId: 'site-1' } })
await store.getState().listJiraIssues('assigned', 50)
if (testCase.cache === 'stale') {
store.setState((state) => ({
jiraSearchCache: Object.fromEntries(
Object.entries(state.jiraSearchCache).map(([key, entry]) => [
key,
{ ...entry, fetchedAt: Date.now() - 120_000 }
])
)
}))
}
runtime.list.mockClear()
const originalList = store.getState().listJiraIssues
const originalSearch = store.getState().searchJiraIssues
const list = vi.fn<typeof originalList>((...args) =>
// Stop a regression from hanging the runner while preserving its excess-call evidence.
list.mock.calls.length > 8 ? suspendedRead : originalList(...args)
)
const search = vi.fn<typeof originalSearch>((...args) =>
search.mock.calls.length > 8 ? suspendedRead : originalSearch(...args)
)
store.setState({ listJiraIssues: list, searchJiraIssues: search })
const { result, rerender } = renderHook(() => useTaskPageFixture(store, testCase.remote))
await waitFor(() => expect(result.current.jiraSavedFiltersLoading).toBe(false))
await act(async () => {})
expect(result.current.jiraSavedFiltersError).toMatch(/Update the Jira host|disconnected/)
expect(result.current.jiraSavedFiltersReady).toBe(false)
expect(result.current.jiraLoading).toBe(false)
expect(result.current.jiraIssues).toEqual(issues)
expect(list).toHaveBeenCalledTimes(1)
expect(runtime.list).toHaveBeenCalledTimes(testCase.cache === 'stale' ? 1 : 0)
await act(async () => rerender())
expect(list).toHaveBeenCalledTimes(1)
act(() => result.current.setJiraSearchInput('login bug'))
await waitFor(() => expect(result.current.appliedJiraSearch).toBe('login bug'))
await waitFor(() => expect(result.current.jiraLoading).toBe(false))
expect(search).toHaveBeenCalledExactlyOnceWith('text ~ "login bug*"', 50, {
sourceContext: null,
force: false
})
act(() => result.current.refresh())
await waitFor(() => expect(search).toHaveBeenCalledTimes(2))
await waitFor(() => expect(result.current.jiraLoading).toBe(false))
expect(search).toHaveBeenLastCalledWith('text ~ "login bug*"', 50, {
sourceContext: null,
force: true
})
expect(runtime.search).toHaveBeenCalledTimes(2)
expect(list).toHaveBeenCalledTimes(1)
}
)
it('recovers from a failed owner read and restores the active exact JQL on retry', async () => {
const store = createTestStore()
store.setState({ jiraStatus: { connected: true, viewer: null, selectedSiteId: 'site-1' } })
const savedState: JiraSavedFilterState = {
filters: [{ id: 'restored', name: 'Restored', jql: 'filter = 12345' }],
activeFilterId: 'restored'
}
const originalList = store.getState().listJiraIssues
const list = vi.fn<typeof originalList>((...args) =>
list.mock.calls.length > 8 ? suspendedRead : originalList(...args)
)
const search = vi.fn(store.getState().searchJiraIssues)
store.setState({ listJiraIssues: list, searchJiraIssues: search })
const { result } = renderHook(() => useTaskPageFixture(store, true))
await waitFor(() => expect(result.current.jiraSavedFiltersLoading).toBe(false))
await act(async () => {})
expect(list).toHaveBeenCalledTimes(1)
runtime.rpc.mockResolvedValueOnce(savedState)
act(() => result.current.retryJiraSavedFilters())
await waitFor(() => expect(result.current.jiraSavedFiltersReady).toBe(true))
await waitFor(() => expect(result.current.jiraLoading).toBe(false))
expect(result.current.jiraSavedFiltersError).toBeNull()
expect(result.current.appliedJiraSearch).toBe('filter = 12345')
expect(search).toHaveBeenCalledExactlyOnceWith('filter = 12345', 50, {
sourceContext: null,
force: false
})
expect(list).toHaveBeenCalledTimes(1)
expect(runtime.rpc).toHaveBeenCalledTimes(2)
})
@@ -1,336 +0,0 @@
import { describe, expect, it } from 'vitest'
import type { JiraIssue, JiraPriority } from '../../../../../shared/jira/jira-types'
import { getJiraPriorityWeight, sortJiraIssues } from '../../jira-issue-sorter'
describe('TaskPage Jira sorting functionality', () => {
function jiraIssue(
key: string,
title: string,
statusName: string,
priorityName?: string,
priorityId?: string,
assigneeDisplayName?: string,
updatedAt = '2026-01-01T00:00:00.000Z',
siteId = 'site-1',
categoryKey = 'new'
): JiraIssue {
return {
id: `${siteId}:${key}`,
key,
title,
url: `https://example.atlassian.net/browse/${key}`,
siteId,
siteName: 'Example Jira',
project: { id: '10000', key: 'ALP', name: 'Alpha', siteId },
issueType: { id: '10001', name: 'Bug' },
status: { id: '1', name: statusName, categoryKey, categoryName: statusName },
priority: priorityName ? { id: priorityId ?? '1', name: priorityName } : undefined,
assignee: assigneeDisplayName
? { accountId: 'user-1', displayName: assigneeDisplayName }
: undefined,
labels: [],
createdAt: '2026-01-01T00:00:00.000Z',
updatedAt
}
}
function jiraPriority(id: string, name: string): JiraPriority {
return { id, name }
}
it('sorts optional planning values with missing values distinct from zero', () => {
const empty = jiraIssue('ALP-1', 'Empty', 'To Do')
const small = {
...jiraIssue('ALP-2', 'Small', 'To Do'),
storyPoints: 0,
originalEstimateSeconds: 0,
remainingEstimateSeconds: 0,
sprint: 'Sprint 2',
parent: { key: 'ALP-9', title: 'Epic 2' }
}
const large = {
...jiraIssue('ALP-3', 'Large', 'To Do'),
storyPoints: 5,
originalEstimateSeconds: 7200,
remainingEstimateSeconds: 3600,
sprint: 'Sprint 10',
parent: { key: 'ALP-10', title: 'Epic 10' }
}
for (const column of [
'storyPoints',
'originalEstimate',
'remainingEstimate',
'sprint',
'parent'
] as const) {
expect(
sortJiraIssues([large, small, empty], column, 'asc').map((issue) => issue.key)
).toEqual(['ALP-1', 'ALP-2', 'ALP-3'])
expect(
sortJiraIssues([small, large, empty], column, 'desc').map((issue) => issue.key)
).toEqual(['ALP-3', 'ALP-2', 'ALP-1'])
}
})
describe('getJiraPriorityWeight', () => {
it('returns default weight for missing priority', () => {
expect(getJiraPriorityWeight(undefined, undefined, [])).toBe(0)
})
it('uses priority index from Jira priorities list when available', () => {
const jiraPriorities: JiraPriority[] = [
jiraPriority('1', 'Highest'),
jiraPriority('2', 'High'),
jiraPriority('3', 'Medium'),
jiraPriority('4', 'Low')
]
expect(getJiraPriorityWeight('High', '2', jiraPriorities)).toBeCloseTo(66.33, 2)
expect(getJiraPriorityWeight('Medium', '3', jiraPriorities)).toBeCloseTo(33.67, 2)
})
it('keeps missing priority below the lowest configured priority', () => {
const jiraPriorities = [jiraPriority('1', 'High'), jiraPriority('2', 'Low')]
expect(getJiraPriorityWeight(undefined, undefined, jiraPriorities)).toBe(0)
expect(getJiraPriorityWeight('Low', '2', jiraPriorities)).toBe(1)
})
it('falls back to priority name mapping when not in priorities list', () => {
expect(getJiraPriorityWeight('Blocker', '1', [])).toBe(99)
expect(getJiraPriorityWeight('High', '2', [])).toBe(75)
expect(getJiraPriorityWeight('Medium', '3', [])).toBe(50)
expect(getJiraPriorityWeight('Low', '4', [])).toBe(25)
expect(getJiraPriorityWeight('Lowest', '5', [])).toBe(1)
})
it('does not treat opaque custom priority IDs as ordering ranks', () => {
expect(getJiraPriorityWeight('Custom Priority', '10', [])).toBe(50)
expect(getJiraPriorityWeight('Another', '5', [])).toBe(50)
expect(getJiraPriorityWeight('Unknown Priority', 'invalid', [])).toBe(50)
})
})
describe('issue sorting', () => {
it('sorts by key in ascending order', () => {
const issues = [
jiraIssue('ALP-10', 'Issue 10', 'To Do'),
jiraIssue('ALP-2', 'Issue 2', 'To Do'),
jiraIssue('ALP-1', 'Issue 1', 'To Do')
]
const sorted = sortJiraIssues(issues, 'key', 'asc')
expect(sorted[0].key).toBe('ALP-1')
expect(sorted[1].key).toBe('ALP-2')
expect(sorted[2].key).toBe('ALP-10')
})
it('sorts by key in descending order', () => {
const issues = [
jiraIssue('ALP-1', 'Issue 1', 'To Do'),
jiraIssue('ALP-2', 'Issue 2', 'To Do'),
jiraIssue('ALP-10', 'Issue 10', 'To Do')
]
const sorted = sortJiraIssues(issues, 'key', 'desc')
expect(sorted[0].key).toBe('ALP-10')
expect(sorted[1].key).toBe('ALP-2')
expect(sorted[2].key).toBe('ALP-1')
})
it('sorts by title alphabetically', () => {
const issues = [
jiraIssue('ALP-1', 'Zebra Issue', 'To Do'),
jiraIssue('ALP-2', 'Apple Issue', 'To Do'),
jiraIssue('ALP-3', 'Banana Issue', 'To Do')
]
const sorted = sortJiraIssues(issues, 'title', 'asc')
expect(sorted[0].title).toBe('Apple Issue')
expect(sorted[1].title).toBe('Banana Issue')
expect(sorted[2].title).toBe('Zebra Issue')
})
it('sorts by priority weight (lowest priority first)', () => {
const issues = [
jiraIssue('ALP-1', 'Issue 1', 'To Do', 'Low', '4'),
jiraIssue('ALP-2', 'Issue 2', 'To Do', 'High', '2'),
jiraIssue('ALP-3', 'Issue 3', 'To Do', 'Medium', '3')
]
const sorted = sortJiraIssues(issues, 'priority', 'asc')
expect(sorted[0].priority?.name).toBe('Low')
expect(sorted[1].priority?.name).toBe('Medium')
expect(sorted[2].priority?.name).toBe('High')
})
it('uses each Jira site priority order when sorting an all-sites list', () => {
const issues = [
jiraIssue(
'BRV-1',
'Site B low by name',
'To Do',
'Low',
'2',
undefined,
undefined,
'site-b'
),
jiraIssue(
'BRV-2',
'Site B high by name',
'To Do',
'High',
'1',
undefined,
undefined,
'site-b'
)
]
const prioritiesBySite = new Map<string, JiraPriority[]>([
['site-a', [jiraPriority('1', 'High'), jiraPriority('2', 'Low')]],
['site-b', [jiraPriority('2', 'Low'), jiraPriority('1', 'High')]]
])
const sorted = sortJiraIssues(issues, 'priority', 'asc', prioritiesBySite)
expect(sorted.map((issue) => issue.key)).toEqual(['BRV-2', 'BRV-1'])
})
it('normalizes priority ranks across sites with different tier counts', () => {
const issues = [
jiraIssue(
'ALP-1',
'Site A highest',
'To Do',
'Highest',
'1',
undefined,
undefined,
'site-a'
),
jiraIssue('BRV-1', 'Site B medium', 'To Do', 'Medium', '3', undefined, undefined, 'site-b')
]
const prioritiesBySite = new Map<string, JiraPriority[]>([
['site-a', [jiraPriority('1', 'Highest'), jiraPriority('2', 'Lowest')]],
[
'site-b',
[
jiraPriority('1', 'Highest'),
jiraPriority('2', 'High'),
jiraPriority('3', 'Medium'),
jiraPriority('4', 'Low'),
jiraPriority('5', 'Lowest')
]
]
])
const sorted = sortJiraIssues(issues, 'priority', 'asc', prioritiesBySite)
expect(sorted.map((issue) => issue.key)).toEqual(['BRV-1', 'ALP-1'])
})
it('sorts by assignee alphabetically', () => {
const issues = [
jiraIssue('ALP-1', 'Issue 1', 'To Do', undefined, undefined, 'Zoe'),
jiraIssue('ALP-2', 'Issue 2', 'To Do', undefined, undefined, 'Alice'),
jiraIssue('ALP-3', 'Issue 3', 'To Do', undefined, undefined, 'Bob')
]
const sorted = sortJiraIssues(issues, 'assignee', 'asc')
expect(sorted[0].assignee?.displayName).toBe('Alice')
expect(sorted[1].assignee?.displayName).toBe('Bob')
expect(sorted[2].assignee?.displayName).toBe('Zoe')
})
it('sorts by updated date (newest first)', () => {
const issues = [
jiraIssue(
'ALP-1',
'Issue 1',
'To Do',
undefined,
undefined,
undefined,
'2026-01-01T00:00:00.000Z'
),
jiraIssue(
'ALP-2',
'Issue 2',
'To Do',
undefined,
undefined,
undefined,
'2026-01-03T00:00:00.000Z'
),
jiraIssue(
'ALP-3',
'Issue 3',
'To Do',
undefined,
undefined,
undefined,
'2026-01-02T00:00:00.000Z'
)
]
const sorted = sortJiraIssues(issues, 'updated', 'desc')
expect(sorted[0].key).toBe('ALP-2')
expect(sorted[1].key).toBe('ALP-3')
expect(sorted[2].key).toBe('ALP-1')
})
it('handles unassigned assignees in sorting', () => {
const issues = [
jiraIssue('ALP-1', 'Issue 1', 'To Do', undefined, undefined, 'Alice'),
jiraIssue('ALP-2', 'Issue 2', 'To Do', undefined, undefined, undefined),
jiraIssue('ALP-3', 'Issue 3', 'To Do', undefined, undefined, 'Bob')
]
const sorted = sortJiraIssues(issues, 'assignee', 'asc')
expect(sorted[0].assignee?.displayName).toBeUndefined()
expect(sorted[1].assignee?.displayName).toBe('Alice')
expect(sorted[2].assignee?.displayName).toBe('Bob')
})
})
it('computes expensive numeric sort keys once per issue', () => {
let priorityReads = 0
let dateReads = 0
const issues = Array.from({ length: 2000 }, (_, i) => ({
...jiraIssue(`ALP-${i}`, `Issue ${i}`, 'Open'),
get priority() {
priorityReads++
return { id: String(i % 3), name: ['High', 'Low', 'Medium'][i % 3] }
},
get updatedAt() {
dateReads++
return new Date(1700000000000 + ((i * 173) % 1999) * 1000).toISOString()
}
}))
const expected = [...issues].sort(
(a, b) =>
getJiraPriorityWeight(a.priority.name, a.priority.id) -
getJiraPriorityWeight(b.priority.name, b.priority.id)
)
expect(priorityReads).toBeGreaterThan(20_000)
priorityReads = 0
const actual = sortJiraIssues(issues, 'priority', 'asc')
expect(priorityReads).toBe(4000)
actual.forEach((issue, i) => expect(issue).toBe(expected[i]))
const byDate = [...issues].sort(
(a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()
)
expect(dateReads).toBeGreaterThan(20_000)
dateReads = 0
const sorted = sortJiraIssues(issues, 'updated', 'desc')
expect(dateReads).toBe(2000)
sorted.forEach((issue, i) => expect(issue).toBe(byDate[i]))
})
})
@@ -1,25 +0,0 @@
// @vitest-environment happy-dom
import { afterEach, describe, expect, it } from 'vitest'
import { cleanup, render, screen } from '@testing-library/react'
import { TaskPageJiraTextFallbackNotice } from './task-page-jira-text-fallback-notice'
afterEach(cleanup)
const REASON = "Field 'login' does not exist or you do not have permission to view it."
const PROSE = { reason: REASON, likelyTypo: false }
describe('TaskPageJiraTextFallbackNotice', () => {
it('updates one mounted live region instead of inserting a new one', () => {
const { rerender } = render(<TaskPageJiraTextFallbackNotice rejection={null} />)
const region = screen.getByRole('status')
expect(region.textContent).toBe('')
rerender(<TaskPageJiraTextFallbackNotice rejection={PROSE} />)
expect(screen.getByRole('status')).toBe(region)
expect(region.textContent).toContain('Showing text matches')
rerender(<TaskPageJiraTextFallbackNotice rejection={null} />)
expect(screen.getByRole('status')).toBe(region)
expect(region.textContent).toBe('')
})
})
@@ -1,655 +0,0 @@
// @vitest-environment happy-dom
import { act, cleanup, renderHook, waitFor } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { TaskPageLinearCollectionEffectsModel } from '../linear/use-task-page-linear-collection-effects'
import { useTaskPageJiraListEffects } from './use-task-page-jira-list-effects'
import { restrictJiraQueryToProject } from '../../../../../shared/jira/jira-project-reference'
import { buildJiraPresetJql } from '../../../../../shared/jira/jira-preset-jql'
import { buildJiraTextMatchJql } from '../../../../../shared/jira/jira-search-input-jql'
const listJiraIssues = vi.fn().mockResolvedValue([])
const searchJiraIssues = vi.fn().mockResolvedValue([])
function createModel(): TaskPageLinearCollectionEffectsModel {
const model = {
settings: null,
setTaskResumeState: vi.fn(),
searchJiraIssues,
listJiraIssues,
jiraConnected: true,
selectedJiraSiteId: 'site-1',
taskSource: 'jira',
jiraTaskSourceContext: null,
jiraHasWorkspace: false,
linkedJiraIssueRefs: [],
linkedJiraIssueRefsSignature: '[]',
jiraTaskSourceScopeKey: 'fixture',
jiraSearchPersistReadyRef: { current: true },
taskResumeApplied: true,
selectedJiraIssueKey: null,
setSelectedJiraIssueKey: vi.fn(),
selectedJiraIssueFallback: null,
setSelectedJiraIssueFallback: vi.fn(),
setJiraIssues: vi.fn(),
setJiraLoading: vi.fn(),
setJiraError: vi.fn(),
setJiraErrorDetailsOpen: vi.fn(),
setJiraJqlRejection: vi.fn(),
jiraSearchInput: '',
appliedJiraSearch: '',
setAppliedJiraSearch: vi.fn(),
activeJiraPreset: 'assigned',
jiraCurrentSprint: false,
jiraProjectFilter: null,
jiraRefreshNonce: 0,
jiraActiveSavedFilterId: null,
jiraSavedFilters: [],
jiraSavedFiltersLoading: false,
selectedJiraStatuses: [],
jiraQuickFiltersLoading: false,
setJiraStatusCatalog: vi.fn(),
setJiraProjectStatusOrder: vi.fn(),
displayedJiraIssues: []
}
// SAFETY: the hook only reads the Jira fields above; the preceding pipeline model is intentionally not constructed in this isolated wiring test.
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the partial fixture intentionally models only fields read by this hook.
return model as unknown as TaskPageLinearCollectionEffectsModel
}
describe('useTaskPageJiraListEffects refresh wiring', () => {
afterEach(cleanup)
beforeEach(() => {
vi.clearAllMocks()
listJiraIssues.mockResolvedValue([])
searchJiraIssues.mockResolvedValue([])
})
it('keeps sprint presets on the host list path and composes text queries after classification', async () => {
const model = createModel()
model.activeJiraPreset = 'all'
model.jiraCurrentSprint = true
model.selectedJiraStatuses = ['Blocked']
const { rerender } = renderHook(() => useTaskPageJiraListEffects(model))
await waitFor(() =>
expect(listJiraIssues).toHaveBeenCalledWith('all', 50, {
sourceContext: null,
force: false,
filters: { statuses: ['Blocked'], currentSprint: true }
})
)
expect(searchJiraIssues).not.toHaveBeenCalled()
await act(async () => {
model.appliedJiraSearch = 'fix login'
rerender()
await Promise.resolve()
})
await waitFor(() =>
expect(searchJiraIssues).toHaveBeenCalledWith(
'(text ~ "fix login*") AND sprint in openSprints()',
50,
{ sourceContext: null, force: false }
)
)
})
it('adds sprint filtering to exact saved JQL without retrying invalid syntax as text', async () => {
const model = createModel()
model.appliedJiraSearch = 'saved-query ORDER BY updated DESC'
model.jiraActiveSavedFilterId = 'saved'
model.jiraSavedFilters = [{ id: 'saved', name: 'Saved', jql: model.appliedJiraSearch }]
model.jiraCurrentSprint = true
searchJiraIssues.mockRejectedValue(new Error('Error 400: Invalid saved query'))
renderHook(() => useTaskPageJiraListEffects(model))
await waitFor(() =>
expect(model.setJiraError).toHaveBeenCalledWith(
expect.objectContaining({ details: 'Invalid saved query' })
)
)
expect(searchJiraIssues).toHaveBeenCalledExactlyOnceWith(
'(saved-query) AND sprint in openSprints() ORDER BY updated DESC',
50,
{ sourceContext: null, force: false }
)
expect(listJiraIssues).not.toHaveBeenCalled()
})
it('keeps mapped saved JQL exact with sprint filtering and ignores persisted statuses', async () => {
const model = createModel()
model.appliedJiraSearch = 'filter = 12345 ORDER BY created ASC'
model.jiraActiveSavedFilterId = 'saved'
model.jiraSavedFilters = [{ id: 'saved', name: 'Saved', jql: model.appliedJiraSearch }]
model.jiraCurrentSprint = true
model.selectedJiraStatuses = ['Blocked']
model.jiraProjectFilter = { id: '10', key: 'ALP', name: 'Alpha', siteId: 'site-a' }
searchJiraIssues.mockRejectedValue(new Error('Error 400: Invalid saved query'))
renderHook(() => useTaskPageJiraListEffects(model))
await waitFor(() =>
expect(model.setJiraError).toHaveBeenCalledWith(
expect.objectContaining({ details: 'Invalid saved query' })
)
)
expect(searchJiraIssues).toHaveBeenCalledExactlyOnceWith(
'project = "10" AND ((filter = 12345) AND sprint in openSprints()) ORDER BY created ASC',
50,
{ sourceContext: null, siteId: 'site-a', force: false }
)
expect(listJiraIssues).not.toHaveBeenCalled()
})
it('classifies mapped text before adding sprint filtering without persisted statuses', async () => {
const model = createModel()
model.appliedJiraSearch = 'fix login'
model.jiraCurrentSprint = true
model.selectedJiraStatuses = ['Blocked']
model.jiraProjectFilter = { id: '10', key: 'ALP', name: 'Alpha', siteId: 'site-a' }
renderHook(() => useTaskPageJiraListEffects(model))
await waitFor(() =>
expect(searchJiraIssues).toHaveBeenCalledExactlyOnceWith(
'project = "10" AND ((text ~ "fix login*") AND sprint in openSprints())',
50,
{ sourceContext: null, siteId: 'site-a', force: false }
)
)
expect(listJiraIssues).not.toHaveBeenCalled()
})
it('reports a saved query that exceeds the request limit after sprint composition', async () => {
const model = createModel()
model.appliedJiraSearch = `key in (${Array.from({ length: 1030 }, (_, index) => `APP-${index + 1}`).join(',')})`
model.jiraActiveSavedFilterId = 'large'
model.jiraSavedFilters = [{ id: 'large', name: 'Large', jql: model.appliedJiraSearch }]
model.jiraCurrentSprint = true
renderHook(() => useTaskPageJiraListEffects(model))
await waitFor(() =>
expect(model.setJiraError).toHaveBeenCalledWith(
expect.objectContaining({ title: expect.stringContaining('too long') })
)
)
expect(searchJiraIssues).not.toHaveBeenCalled()
expect(listJiraIssues).not.toHaveBeenCalled()
})
it('checks the saved query limit after both sprint and project restrictions', async () => {
const model = createModel()
model.appliedJiraSearch = `summary ~ "${'x'.repeat(8140)}"`
model.jiraActiveSavedFilterId = 'large'
model.jiraSavedFilters = [{ id: 'large', name: 'Large', jql: model.appliedJiraSearch }]
model.jiraCurrentSprint = true
model.jiraProjectFilter = { id: '10', key: 'ALP', name: 'Alpha', siteId: 'site-a' }
renderHook(() => useTaskPageJiraListEffects(model))
await waitFor(() =>
expect(model.setJiraError).toHaveBeenCalledWith(
expect.objectContaining({ title: expect.stringContaining('too long') })
)
)
expect(searchJiraIssues).not.toHaveBeenCalled()
expect(listJiraIssues).not.toHaveBeenCalled()
})
it.each([
{
name: 'saved JQL',
query: `summary ~ "${'x'.repeat(8160)}"`,
validQuery: `summary ~ "${'x'.repeat(8159)}"`,
saved: true
},
{
name: 'manual JQL',
query: `summary ~ "${'x'.repeat(8160)}"`,
validQuery: `summary ~ "${'x'.repeat(8159)}"`,
saved: false
},
{ name: 'plain text', query: 'x'.repeat(8162), validQuery: 'x'.repeat(8161), saved: false },
{
name: 'UTF-8 JQL',
query: `summary ~ "${'界'.repeat(2720)}"`,
validQuery: `summary ~ "${'界'.repeat(2719)}xx"`,
saved: false
}
])(
'rejects project-only overflow for $name and recovers at 8192 bytes',
async ({ query, validQuery, saved }) => {
const model = createModel()
const project = { id: '10', key: 'ALP', name: 'Alpha', siteId: 'site-a' }
model.appliedJiraSearch = query
model.jiraProjectFilter = project
if (saved) {
model.jiraActiveSavedFilterId = 'large'
model.jiraSavedFilters = [{ id: 'large', name: 'Large', jql: query }]
}
const jql = query.includes('~') ? query : buildJiraTextMatchJql(query)
expect(new TextEncoder().encode(query).length).toBeLessThan(8192)
expect(new TextEncoder().encode(restrictJiraQueryToProject(jql, project)).length).toBe(8193)
const { rerender } = renderHook(() => useTaskPageJiraListEffects(model))
await waitFor(() =>
expect(model.setJiraError).toHaveBeenLastCalledWith({
title: 'The Jira query is too long. Shorten the query or clear filters.',
details: null
})
)
expect(model.setJiraLoading).toHaveBeenLastCalledWith(false)
expect(model.setJiraIssues).toHaveBeenLastCalledWith([])
expect(model.setJiraJqlRejection).toHaveBeenCalledExactlyOnceWith(null)
expect(searchJiraIssues).not.toHaveBeenCalled()
expect(listJiraIssues).not.toHaveBeenCalled()
const validJql = query.includes('~') ? validQuery : buildJiraTextMatchJql(validQuery)
const scopedValidJql = restrictJiraQueryToProject(validJql, project)
expect(new TextEncoder().encode(scopedValidJql).length).toBe(8192)
model.appliedJiraSearch = validQuery
if (saved) {
model.jiraSavedFilters = [{ id: 'large', name: 'Large', jql: validQuery }]
}
rerender()
await waitFor(() => expect(model.setJiraLoading).toHaveBeenLastCalledWith(false))
expect(searchJiraIssues).toHaveBeenCalledExactlyOnceWith(scopedValidJql, 50, {
sourceContext: null,
siteId: 'site-a',
force: false
})
expect(model.setJiraError).toHaveBeenLastCalledWith(null)
}
)
it.each([
{
name: 'escaped',
statuses: Array.from({ length: 50 }, (_, index) => `${index}${'"'.repeat(124)}`)
},
{
name: 'UTF-8',
statuses: Array.from({ length: 50 }, (_, index) => `${index}${'界'.repeat(126)}`)
}
])(
'rejects an oversized mapped preset with $name status names and recovers',
async ({ statuses }) => {
const model = createModel()
const project = { id: '10', key: 'ALP', name: 'Alpha', siteId: 'site-a' }
model.activeJiraPreset = 'all'
model.jiraProjectFilter = project
model.selectedJiraStatuses = statuses
expect(statuses.every((status) => status.length <= 128)).toBe(true)
const query = restrictJiraQueryToProject(buildJiraPresetJql('all', { statuses }), project)
expect(new TextEncoder().encode(query).length).toBeGreaterThan(8192)
const { rerender } = renderHook(() => useTaskPageJiraListEffects(model))
await waitFor(() =>
expect(model.setJiraError).toHaveBeenLastCalledWith({
title: 'The Jira query is too long. Shorten the query or clear filters.',
details: null
})
)
expect(model.setJiraLoading).toHaveBeenLastCalledWith(false)
expect(searchJiraIssues).not.toHaveBeenCalled()
expect(listJiraIssues).not.toHaveBeenCalled()
model.selectedJiraStatuses = ['Blocked']
rerender()
await waitFor(() => expect(searchJiraIssues).toHaveBeenCalledTimes(1))
expect(searchJiraIssues).toHaveBeenLastCalledWith(
'project = "10" AND (statusCategory != Done AND status in ("Blocked")) ORDER BY updated DESC',
50,
{ sourceContext: null, siteId: 'site-a', force: false }
)
await waitFor(() => expect(model.setJiraError).toHaveBeenLastCalledWith(null))
expect(model.setJiraLoading).toHaveBeenLastCalledWith(false)
}
)
it('reports an oversized manual query without project or sprint filters', async () => {
const model = createModel()
model.appliedJiraSearch = `summary ~ "${'x'.repeat(8181)}"`
renderHook(() => useTaskPageJiraListEffects(model))
await waitFor(() =>
expect(model.setJiraError).toHaveBeenLastCalledWith({
title: 'The Jira query is too long. Shorten the query or clear filters.',
details: null
})
)
expect(searchJiraIssues).not.toHaveBeenCalled()
expect(listJiraIssues).not.toHaveBeenCalled()
expect(model.setJiraLoading).toHaveBeenLastCalledWith(false)
})
it('ignores a mapped preset limit rejection after changing the source', async () => {
const model = createModel()
model.jiraProjectFilter = { id: '10', key: 'ALP', name: 'Alpha', siteId: 'site-a' }
model.selectedJiraStatuses = Array.from(
{ length: 50 },
(_, index) => `${index}${'"'.repeat(124)}`
)
const { rerender } = renderHook(() => useTaskPageJiraListEffects(model))
model.taskSource = 'linear'
rerender()
await act(async () => {
await Promise.resolve()
})
expect(model.setJiraError).toHaveBeenCalledExactlyOnceWith(null)
expect(model.setJiraIssues).not.toHaveBeenCalled()
expect(searchJiraIssues).not.toHaveBeenCalled()
expect(listJiraIssues).not.toHaveBeenCalled()
})
it('loads linked keys directly and does not reread them for local search text', async () => {
const model = createModel()
model.jiraHasWorkspace = true
model.linkedJiraIssueRefs = [{ key: 'APP-1', siteId: 'site-1' }]
model.linkedJiraIssueRefsSignature = JSON.stringify(model.linkedJiraIssueRefs)
const { rerender } = renderHook(() => useTaskPageJiraListEffects(model))
await waitFor(() => expect(searchJiraIssues).toHaveBeenCalledTimes(1))
expect(listJiraIssues).not.toHaveBeenCalled()
expect(searchJiraIssues).toHaveBeenCalledWith('key = APP-1', 1, {
sourceContext: null,
siteId: 'site-1',
force: false
})
model.appliedJiraSearch = 'login'
rerender()
await waitFor(() => expect(model.setJiraLoading).toHaveBeenLastCalledWith(false))
expect(searchJiraIssues).toHaveBeenCalledTimes(1)
model.jiraRefreshNonce = 1
rerender()
await waitFor(() => expect(searchJiraIssues).toHaveBeenCalledTimes(2))
expect(searchJiraIssues).toHaveBeenLastCalledWith('key = APP-1', 1, {
sourceContext: null,
siteId: 'site-1',
force: true
})
})
it('ignores a linked read that completes after the source changes', async () => {
const model = createModel()
model.jiraHasWorkspace = true
model.linkedJiraIssueRefs = [{ key: 'APP-1', siteId: 'site-1' }]
model.linkedJiraIssueRefsSignature = JSON.stringify(model.linkedJiraIssueRefs)
let complete: (value: []) => void = () => {}
searchJiraIssues.mockImplementationOnce(
() =>
new Promise<[]>((resolve) => {
complete = resolve
})
)
const { rerender } = renderHook(() => useTaskPageJiraListEffects(model))
await waitFor(() => expect(searchJiraIssues).toHaveBeenCalledTimes(1))
model.taskSource = 'linear'
rerender()
await act(async () => {
complete([])
await Promise.resolve()
})
expect(model.setJiraIssues).not.toHaveBeenCalled()
})
it('passes force only for a refresh nonce change', async () => {
const model = createModel()
const { rerender } = renderHook(() => useTaskPageJiraListEffects(model))
await waitFor(() => expect(listJiraIssues).toHaveBeenCalledTimes(1))
expect(listJiraIssues).toHaveBeenNthCalledWith(1, 'assigned', 50, {
sourceContext: null,
force: false
})
await act(async () => {
model.jiraRefreshNonce = 1
rerender()
await Promise.resolve()
})
await waitFor(() => expect(listJiraIssues).toHaveBeenCalledTimes(2))
expect(listJiraIssues).toHaveBeenNthCalledWith(2, 'assigned', 50, {
sourceContext: null,
force: true
})
await act(async () => {
model.activeJiraPreset = 'reported'
rerender()
await Promise.resolve()
})
await waitFor(() => expect(listJiraIssues).toHaveBeenCalledTimes(3))
expect(listJiraIssues).toHaveBeenNthCalledWith(3, 'reported', 50, {
sourceContext: null,
force: false
})
})
it('uses the preset list path for status filters and restores it after clearing', async () => {
const model = createModel()
model.selectedJiraStatuses = ['In Progress', 'Blocked']
const { rerender } = renderHook(() => useTaskPageJiraListEffects(model))
await waitFor(() => expect(listJiraIssues).toHaveBeenCalledTimes(1))
expect(listJiraIssues).toHaveBeenLastCalledWith('assigned', 50, {
sourceContext: null,
force: false,
filters: { statuses: ['In Progress', 'Blocked'] }
})
expect(searchJiraIssues).not.toHaveBeenCalled()
await act(async () => {
model.selectedJiraStatuses = []
rerender()
})
expect(listJiraIssues).toHaveBeenLastCalledWith('assigned', 50, {
sourceContext: null,
force: false
})
})
it('keeps manual JQL independent of a persisted status selection', async () => {
const model = createModel()
model.selectedJiraStatuses = ['Blocked']
model.appliedJiraSearch = 'project = APP ORDER BY created ASC'
renderHook(() => useTaskPageJiraListEffects(model))
await waitFor(() => expect(searchJiraIssues).toHaveBeenCalledTimes(1))
expect(searchJiraIssues).toHaveBeenCalledWith('project = APP ORDER BY created ASC', 50, {
sourceContext: null,
force: false
})
expect(listJiraIssues).not.toHaveBeenCalled()
})
it('passes force through the text-search wrapper on refresh', async () => {
const model = createModel()
model.appliedJiraSearch = 'fix login'
const { rerender } = renderHook(() => useTaskPageJiraListEffects(model))
await waitFor(() => expect(searchJiraIssues).toHaveBeenCalledTimes(1))
await act(async () => {
model.jiraRefreshNonce = 1
rerender()
await Promise.resolve()
})
await waitFor(() => expect(searchJiraIssues).toHaveBeenCalledTimes(2))
expect(searchJiraIssues).toHaveBeenNthCalledWith(2, 'text ~ "fix login*"', 50, {
sourceContext: null,
force: true
})
})
it.each([
{
statuses: [],
currentSprint: false,
query: 'project = "10" AND (statusCategory != Done) ORDER BY updated DESC'
},
{
statuses: ['In Progress', 'Blocked'],
currentSprint: false,
query:
'project = "10" AND (statusCategory != Done AND status in ("In Progress", "Blocked")) ORDER BY updated DESC'
},
{
statuses: [],
currentSprint: true,
query:
'project = "10" AND (statusCategory != Done AND sprint in openSprints()) ORDER BY updated DESC'
},
{
statuses: ['Blocked'],
currentSprint: true,
query:
'project = "10" AND (statusCategory != Done AND status in ("Blocked") AND sprint in openSprints()) ORDER BY updated DESC'
}
])(
'scopes statuses $statuses and sprint $currentSprint to the mapped project and owner',
async ({ statuses, currentSprint, query }) => {
const model = createModel()
model.jiraTaskSourceContext = {
kind: 'task-source',
provider: 'jira',
projectId: 'source-project',
hostId: 'runtime:owner'
}
model.activeJiraPreset = 'all'
model.selectedJiraStatuses = statuses
model.jiraCurrentSprint = currentSprint
model.jiraProjectFilter = { id: '10', key: 'ALP', name: 'Alpha', siteId: 'site-a' }
renderHook(() => useTaskPageJiraListEffects(model))
await waitFor(() =>
expect(searchJiraIssues).toHaveBeenCalledWith(query, 50, {
sourceContext: model.jiraTaskSourceContext,
siteId: 'site-a',
force: false
})
)
expect(listJiraIssues).not.toHaveBeenCalled()
await waitFor(() => expect(model.setJiraLoading).toHaveBeenLastCalledWith(false))
if (statuses.length === 0) {
expect(model.setJiraStatusCatalog).toHaveBeenCalledWith({
scopeKey: 'fixture::site-1::all::site-a:10',
items: []
})
} else {
expect(model.setJiraStatusCatalog).not.toHaveBeenCalled()
}
}
)
it('ignores the project mapping for linked issues and restores it when leaving linked mode', async () => {
const model = createModel()
model.activeJiraPreset = 'all'
model.jiraProjectFilter = { id: '10', key: 'ALP', name: 'Alpha', siteId: 'site-a' }
model.selectedJiraStatuses = ['Blocked']
model.jiraCurrentSprint = true
model.jiraHasWorkspace = true
model.linkedJiraIssueRefs = [{ key: 'APP-1', siteId: 'site-1' }]
model.linkedJiraIssueRefsSignature = JSON.stringify(model.linkedJiraIssueRefs)
const { rerender } = renderHook(() => useTaskPageJiraListEffects(model))
await waitFor(() => expect(searchJiraIssues).toHaveBeenCalledTimes(1))
expect(searchJiraIssues).toHaveBeenLastCalledWith('key = APP-1', 1, {
sourceContext: null,
siteId: 'site-1',
force: false
})
expect(listJiraIssues).not.toHaveBeenCalled()
expect(model.setJiraStatusCatalog).not.toHaveBeenCalled()
expect(model.selectedJiraStatuses).toEqual(['Blocked'])
expect(model.jiraCurrentSprint).toBe(true)
model.jiraHasWorkspace = false
rerender()
await waitFor(() => expect(searchJiraIssues).toHaveBeenCalledTimes(2))
expect(searchJiraIssues).toHaveBeenLastCalledWith(
'project = "10" AND (statusCategory != Done AND status in ("Blocked") AND sprint in openSprints()) ORDER BY updated DESC',
50,
{ sourceContext: null, siteId: 'site-a', force: false }
)
expect(model.setJiraError).toHaveBeenLastCalledWith(null)
expect(model.setJiraStatusCatalog).not.toHaveBeenCalled()
})
it.each([
['release candidate', 'project = "10" AND (release candidate)'],
[
'filter = 12345 ORDER BY created ASC',
'project = "10" AND (filter = 12345) ORDER BY created ASC'
]
])(
'keeps saved JQL exact in the mapped project and restores its preset after exit: %s',
async (query, scopedQuery) => {
const model = createModel()
const filter = { id: 'saved', name: 'Saved work', jql: query }
model.jiraTaskSourceContext = {
kind: 'task-source',
provider: 'jira',
projectId: 'source-project',
hostId: 'runtime:owner'
}
model.activeJiraPreset = 'all'
model.jiraProjectFilter = { id: '10', key: 'ALP', name: 'Alpha', siteId: 'site-a' }
model.selectedJiraStatuses = ['Blocked']
model.jiraSavedFilters = [filter]
model.jiraActiveSavedFilterId = filter.id
model.appliedJiraSearch = query
searchJiraIssues.mockRejectedValueOnce(new Error('Error 400: JQL syntax invalid'))
const { rerender } = renderHook(() => useTaskPageJiraListEffects(model))
await waitFor(() =>
expect(model.setJiraError).toHaveBeenLastCalledWith(
expect.objectContaining({ details: 'JQL syntax invalid' })
)
)
expect(searchJiraIssues).toHaveBeenCalledExactlyOnceWith(scopedQuery, 50, {
sourceContext: model.jiraTaskSourceContext,
siteId: 'site-a',
force: false
})
expect(model.setJiraJqlRejection).toHaveBeenCalledExactlyOnceWith(null)
model.jiraActiveSavedFilterId = null
model.appliedJiraSearch = ''
rerender()
await waitFor(() => expect(searchJiraIssues).toHaveBeenCalledTimes(2))
expect(searchJiraIssues).toHaveBeenLastCalledWith(
'project = "10" AND (statusCategory != Done AND status in ("Blocked")) ORDER BY updated DESC',
50,
{ sourceContext: model.jiraTaskSourceContext, siteId: 'site-a', force: false }
)
expect(listJiraIssues).not.toHaveBeenCalled()
expect(model.setJiraError).toHaveBeenLastCalledWith(null)
}
)
it('shows the JQL rejection with its text matches and clears it for the next search', async () => {
const model = createModel()
model.appliedJiraSearch = 'project = NOPE'
searchJiraIssues
.mockRejectedValueOnce(new Error("Error 400: The value 'NOPE' does not exist."))
.mockResolvedValueOnce([])
const { rerender } = renderHook(() => useTaskPageJiraListEffects(model))
const rejection = { reason: "The value 'NOPE' does not exist.", likelyTypo: true }
await waitFor(() => expect(model.setJiraJqlRejection).toHaveBeenLastCalledWith(rejection))
// The next search never settles, so only the reset at its start can clear the notice.
searchJiraIssues.mockReturnValueOnce(new Promise(() => {}))
await act(async () => {
model.appliedJiraSearch = 'fix login'
rerender()
await Promise.resolve()
})
expect(model.setJiraJqlRejection).toHaveBeenLastCalledWith(null)
})
it('drops the rejection of a search that was replaced before it finished', async () => {
const model = createModel()
model.appliedJiraSearch = 'project = NOPE'
let rejectJql: (error: Error) => void = () => {}
searchJiraIssues
.mockReturnValueOnce(new Promise((_, reject) => (rejectJql = reject)))
.mockResolvedValue([])
const { rerender } = renderHook(() => useTaskPageJiraListEffects(model))
await waitFor(() => expect(searchJiraIssues).toHaveBeenCalledTimes(1))
await act(async () => {
model.appliedJiraSearch = 'fix login'
rerender()
await Promise.resolve()
})
await act(async () => {
rejectJql(new Error("Error 400: The value 'NOPE' does not exist."))
await Promise.resolve()
})
await waitFor(() => expect(searchJiraIssues).toHaveBeenCalledTimes(3))
await act(async () => {
await Promise.resolve()
})
expect(model.setJiraJqlRejection).not.toHaveBeenCalledWith(
expect.objectContaining({ likelyTypo: true })
)
})
})
@@ -1,23 +1,11 @@
// @vitest-environment happy-dom
import { act, cleanup, renderHook, waitFor } from '@testing-library/react'
import { act, cleanup, renderHook } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { useAppStore } from '@/store/index'
import { getProviderRuntimeContextKey } from '@/lib/provider-runtime-context'
import type { ExecutionHostId } from '../../../../../shared/execution-host'
import type { Repo } from '../../../../../shared/repo-types'
import { useTaskPageJiraProjectScope } from './use-task-page-jira-project-scope'
import { useTaskPageJiraListState } from './use-task-page-jira-list-state'
import { useTaskPageStoreBindings } from '../use-task-page-store-bindings'
import { useTaskPageRepoSelection } from '../use-task-page-repo-selection'
import { useTaskPageRuntimeHosts } from '../use-task-page-runtime-hosts'
import { useTaskPageSourceAvailability } from '../use-task-page-source-availability'
import { useTaskPageProviderState } from '../use-task-page-provider-state'
import { useTaskPageGitHubListState } from '../github/use-task-page-github-list-state'
import { useTaskPageGitHubDetail } from '../github/use-task-page-github-detail'
import { useTaskPageGitHubCacheReconciliation } from '../github/use-task-page-github-cache-reconciliation'
import { useTaskPageGitHubIssueDraft } from '../github/use-task-page-github-issue-draft'
import { useTaskPageDetailRouting } from '../use-task-page-detail-routing'
import { useTaskPageLinearViewState } from '../linear/use-task-page-linear-view-state'
const { readSavedFilters, mutateSavedFilters, readQuickFilters, writeQuickFilters } = vi.hoisted(
() => ({
readSavedFilters: vi.fn(),
@@ -54,20 +42,6 @@ const sshRepo: Repo = {
defaultJiraProject: sshProject
}
function useComposedJiraListState() {
const store = useTaskPageStoreBindings()
const selection = useTaskPageRepoSelection(store)
const hosts = useTaskPageRuntimeHosts(selection)
const availability = useTaskPageSourceAvailability(hosts)
const provider = useTaskPageProviderState(availability)
const githubList = useTaskPageGitHubListState(provider)
const githubDetail = useTaskPageGitHubDetail(githubList)
const reconciled = useTaskPageGitHubCacheReconciliation(githubDetail)
const draft = useTaskPageGitHubIssueDraft(reconciled)
const routing = useTaskPageDetailRouting(draft)
const linear = useTaskPageLinearViewState(routing)
return useTaskPageJiraListState(linear)
}
afterEach(() => {
cleanup()
useAppStore.setState(originalState, true)
@@ -81,70 +55,6 @@ beforeEach(() => {
writeQuickFilters.mockResolvedValue({ statuses: [] })
})
describe('repository-scoped Jira Tasks', () => {
it('uses an explicit folder repository default and allows the All-project override', () => {
const { result } = renderHook(() =>
useTaskPageJiraProjectScope([repo], [], 'repo', 'local', 'all')
)
expect(result.current.jiraProjectFilter).toEqual(project)
act(() => result.current.setJiraProjectFilter(null))
expect(result.current.jiraProjectFilter).toBeNull()
expect(repo.defaultJiraProject).toEqual(project)
})
it('does not apply another site or execution host’s project mapping', () => {
const initialProps: { host: ExecutionHostId; site: string } = { host: 'local', site: 'site-b' }
const { result, rerender } = renderHook(
({ host, site }) => useTaskPageJiraProjectScope([repo], [repo], 'repo', host, site),
{ initialProps }
)
expect(result.current.jiraProjectFilter).toBeNull()
rerender({ host: 'runtime:remote', site: 'site-a' })
expect(result.current.jiraProjectFilter).toBeNull()
rerender({ host: 'local', site: 'site-a' })
expect(result.current.jiraProjectFilter).toEqual(project)
})
it('uses the explicitly selected SSH owner before a duplicate preselected repository ID', () => {
const { result } = renderHook(() =>
useTaskPageJiraProjectScope([repo, sshRepo], [sshRepo], repo.id, 'local', project.siteId)
)
expect(result.current.jiraProjectFilter).toEqual(sshProject)
})
it('keeps a unique preselection ahead of a different previously selected repository', () => {
const previouslySelected: Repo = { ...repo, id: 'previous', defaultJiraProject: sshProject }
const { result } = renderHook(() =>
useTaskPageJiraProjectScope(
[repo, previouslySelected],
[previouslySelected],
repo.id,
'local',
project.siteId
)
)
expect(result.current.jiraProjectFilter).toEqual(project)
})
it('falls back to the single selected repository when the preselected ID is missing', () => {
const { result } = renderHook(() =>
useTaskPageJiraProjectScope([repo], [repo], 'missing', 'local', project.siteId)
)
expect(result.current.jiraProjectFilter).toEqual(project)
})
it('falls back when a unique different-ID preselection belongs to another Jira account', () => {
const runtimeRepo: Repo = { ...repo, id: 'remote', executionHostId: 'runtime:remote' }
const { result } = renderHook(() =>
useTaskPageJiraProjectScope(
[repo, runtimeRepo],
[repo],
runtimeRepo.id,
'local',
project.siteId
)
)
expect(result.current.jiraProjectFilter).toEqual(project)
})
it('does not borrow a compatible default when the explicit matching owner uses another account', () => {
const runtimeRepo: Repo = { ...repo, executionHostId: 'runtime:remote' }
const { result } = renderHook(() =>
@@ -159,20 +69,6 @@ describe('repository-scoped Jira Tasks', () => {
expect(result.current.jiraProjectFilter).toBeNull()
})
it('does not use a different selected repository to resolve ambiguous preselection', () => {
const previouslySelected: Repo = { ...repo, id: 'previous' }
const { result } = renderHook(() =>
useTaskPageJiraProjectScope(
[repo, sshRepo, previouslySelected],
[previouslySelected],
repo.id,
'local',
project.siteId
)
)
expect(result.current.jiraProjectFilter).toBeNull()
})
it('resets the All-project override when the same repository ID changes execution owner', () => {
const initialProps: { selected: readonly Repo[] } = { selected: [repo] }
const { result, rerender } = renderHook(
@@ -186,73 +82,4 @@ describe('repository-scoped Jira Tasks', () => {
rerender({ selected: [sshRepo] })
expect(result.current.jiraProjectFilter).toEqual(sshProject)
})
it.each([{ selected: [] }, { selected: [repo, sshRepo] }])(
'refuses an ambiguous preselected repository ID without a single selected owner (%#)',
({ selected }) => {
const { result } = renderHook(() =>
useTaskPageJiraProjectScope([repo, sshRepo], selected, repo.id, 'local', project.siteId)
)
expect(result.current.jiraProjectFilter).toBeNull()
}
)
it('does not use the Jira account owner to choose between local and runtime repositories', () => {
const runtimeRepo: Repo = { ...repo, executionHostId: 'runtime:remote' }
const { result } = renderHook(() =>
useTaskPageJiraProjectScope([repo, runtimeRepo], [], repo.id, 'local', project.siteId)
)
expect(result.current.jiraProjectFilter).toBeNull()
})
it('keeps duplicate execution owners ambiguous through the actual Tasks list-state pipeline', () => {
const localGitRepo: Repo = { ...repo, kind: 'git' }
const sshGitRepo: Repo = { ...sshRepo, kind: 'git' }
useAppStore.setState({
settings: null,
repos: [localGitRepo, sshGitRepo],
taskPageData: { taskSource: 'jira', preselectedRepoId: repo.id },
jiraStatus: { connected: true, viewer: null, selectedSiteId: 'all' },
jiraStatusChecked: true,
jiraStatusContextKey: getProviderRuntimeContextKey(null)
})
const { result } = renderHook(useComposedJiraListState)
expect(result.current.selectedRepos).toEqual([localGitRepo, sshGitRepo])
expect(result.current.accountBackedTaskSourceHostId).toBe('local')
expect(result.current.jiraProjectFilter).toBeNull()
})
it('keeps linked mode, saved filters, and the project override in the composed list state', async () => {
localStorage.removeItem('orca.jira.has-workspace.v1')
useAppStore.setState({
settings: null,
repos: [repo],
taskPageData: { taskSource: 'jira', preselectedRepoId: repo.id },
jiraStatus: { connected: true, viewer: null, selectedSiteId: 'all' },
jiraStatusChecked: true,
jiraStatusContextKey: getProviderRuntimeContextKey(null)
})
const { result } = renderHook(useComposedJiraListState)
act(() => result.current.setTaskResumeApplied(true))
await waitFor(() => expect(result.current.jiraSavedFiltersReady).toBe(true))
await waitFor(() => expect(result.current.jiraQuickFiltersReady).toBe(true))
expect(readSavedFilters).toHaveBeenCalledWith({ kind: 'local' })
expect(result.current.jiraActiveSavedFilterId).toBe(filter.id)
expect(result.current.appliedJiraSearch).toBe(filter.jql)
expect(result.current.selectedJiraStatuses).toEqual(['Blocked'])
expect(result.current.jiraProjectFilter).toEqual(project)
act(() => result.current.setJiraProjectFilter(null))
act(() => result.current.setJiraHasWorkspace(true))
expect(result.current.jiraHasWorkspace).toBe(true)
expect(result.current.jiraProjectFilter).toBeNull()
await act(async () => result.current.clearJiraSavedFilterSelection())
expect(mutateSavedFilters).toHaveBeenCalledWith({ kind: 'local' }, { kind: 'select', id: null })
expect(result.current.jiraActiveSavedFilterId).toBeNull()
expect(result.current.jiraProjectFilter).toBeNull()
act(() => result.current.setJiraHasWorkspace(false))
expect(result.current.jiraHasWorkspace).toBe(false)
expect(result.current.jiraProjectFilter).toBeNull()
expect(repo.defaultJiraProject).toEqual(project)
expect(result.current.selectedJiraStatuses).toEqual(['Blocked'])
})
})
@@ -3,10 +3,7 @@ import { act, cleanup, renderHook, waitFor } from '@testing-library/react'
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
import { useTaskPageJiraQuickFilters } from './use-task-page-jira-quick-filters'
import type { JiraQuickFilterState } from '../../../../../shared/jira/jira-quick-filters'
import {
UNRESOLVED_OWNER_HOST_ID,
toRuntimeExecutionHostId
} from '../../../../../shared/execution-host'
import { toRuntimeExecutionHostId } from '../../../../../shared/execution-host'
const { read, write } = vi.hoisted(() => ({ read: vi.fn(), write: vi.fn() }))
vi.mock('@/runtime/jira/runtime-jira-quick-filter-state', () => ({
readJiraQuickFilterState: read,
@@ -34,75 +31,6 @@ function model(
: null
}
}
it('uses the captured account owner without a source context and keeps source ownership first', async () => {
read.mockResolvedValue({ statuses: ['Blocked'] })
write.mockResolvedValue({ statuses: ['Open'] })
const { result, rerender } = renderHook(
({ sourceHost }) => useTaskPageJiraQuickFilters(model(sourceHost)),
{ initialProps: { sourceHost: '' } }
)
await waitFor(() => expect(result.current.jiraQuickFiltersLoading).toBe(false))
expect(read).toHaveBeenLastCalledWith({ kind: 'environment', environmentId: 'account-owner' })
await act(async () => result.current.selectJiraStatuses(['Open']))
expect(write).toHaveBeenLastCalledWith(
{ kind: 'environment', environmentId: 'account-owner' },
{ statuses: ['Open'] }
)
rerender({ sourceHost: 'source-owner' })
await waitFor(() => expect(result.current.jiraQuickFiltersLoading).toBe(false))
expect(read).toHaveBeenLastCalledWith({ kind: 'environment', environmentId: 'source-owner' })
})
it('does not load or write status filters for an unresolved owner', async () => {
const { result } = renderHook(() =>
useTaskPageJiraQuickFilters({
...model(null),
accountBackedTaskSourceHostId: UNRESOLVED_OWNER_HOST_ID
})
)
await waitFor(() => expect(result.current.jiraQuickFiltersLoading).toBe(false))
await act(async () => result.current.selectJiraStatuses(['Open']))
expect(result.current.jiraQuickFiltersReady).toBe(false)
expect(read).not.toHaveBeenCalled()
expect(write).not.toHaveBeenCalled()
})
it('restores selection after remount and waits for writes to reach the host', async () => {
read
.mockResolvedValueOnce({ statuses: ['Blocked'] })
.mockResolvedValueOnce({ statuses: ['In Progress'] })
let finish: (value: JiraQuickFilterState) => void = () => {}
write.mockReturnValue(
new Promise((resolve) => {
finish = resolve
})
)
const first = renderHook(() => useTaskPageJiraQuickFilters(model('a')))
await waitFor(() => expect(first.result.current.jiraQuickFiltersLoading).toBe(false))
let pending: Promise<void> = Promise.resolve()
act(() => {
pending = first.result.current.selectJiraStatuses(['In Progress'])
})
expect(first.result.current.selectedJiraStatuses).toEqual(['Blocked'])
expect(first.result.current.jiraQuickFiltersPending).toBe(true)
await act(async () => {
finish({ statuses: ['In Progress'] })
await pending
})
expect(first.result.current.selectedJiraStatuses).toEqual(['In Progress'])
first.unmount()
const restored = renderHook(() => useTaskPageJiraQuickFilters(model('a')))
await waitFor(() => expect(restored.result.current.selectedJiraStatuses).toEqual(['In Progress']))
})
it('retains selection after a failed write', async () => {
read.mockResolvedValue({ statuses: ['Blocked'] })
write.mockRejectedValue(new Error('Host unreachable'))
const { result } = renderHook(() => useTaskPageJiraQuickFilters(model('a')))
await waitFor(() => expect(result.current.jiraQuickFiltersLoading).toBe(false))
await act(async () => {
await expect(result.current.selectJiraStatuses([])).rejects.toThrow('Host unreachable')
})
expect(result.current.selectedJiraStatuses).toEqual(['Blocked'])
expect(result.current.jiraQuickFiltersError).toBe('Host unreachable')
})
it('ignores a late mutation reply after changing Jira execution hosts', async () => {
read
.mockResolvedValueOnce({ statuses: ['Blocked'] })
@@ -129,43 +57,6 @@ it('ignores a late mutation reply after changing Jira execution hosts', async ()
})
expect(result.current.selectedJiraStatuses).toEqual(['Open'])
})
it('keeps unsupported-host empty selection stable across renders', async () => {
read.mockRejectedValue(new Error('Update host'))
const { result, rerender } = renderHook(() => useTaskPageJiraQuickFilters(model('a')))
await waitFor(() => expect(result.current.jiraQuickFiltersLoading).toBe(false))
const first = result.current.selectedJiraStatuses
rerender()
expect(result.current.selectedJiraStatuses).toBe(first)
expect(result.current.jiraQuickFiltersReady).toBe(false)
})
it('does not send a status filter action retained from a previous account owner', async () => {
read.mockResolvedValue({ statuses: ['Blocked'] })
const { result, rerender } = renderHook(
({ accountHost }) => useTaskPageJiraQuickFilters(model(null, accountHost)),
{ initialProps: { accountHost: 'host-a' } }
)
await waitFor(() => expect(result.current.jiraQuickFiltersLoading).toBe(false))
const oldAction = result.current.selectJiraStatuses
rerender({ accountHost: 'host-b' })
await waitFor(() => expect(result.current.jiraQuickFiltersLoading).toBe(false))
await act(async () => oldAction(['Open']))
expect(write).not.toHaveBeenCalled()
})
it('rejects a retained status action after returning to its earlier owner', async () => {
read.mockResolvedValue({ statuses: ['Open'] })
write.mockResolvedValue({ statuses: ['Closed'] })
const { result, rerender } = renderHook(({ host }) => useTaskPageJiraQuickFilters(model(host)), {
initialProps: { host: 'a' }
})
await waitFor(() => expect(result.current.jiraQuickFiltersLoading).toBe(false))
const oldAction = result.current.selectJiraStatuses
rerender({ host: 'b' })
await waitFor(() => expect(result.current.jiraQuickFiltersLoading).toBe(false))
rerender({ host: 'a' })
await waitFor(() => expect(result.current.jiraQuickFiltersLoading).toBe(false))
await act(async () => oldAction(['Closed']))
expect(write).not.toHaveBeenCalled()
})
it.each(['success', 'failure'] as const)(
'keeps a new owner visit pending after an old %s settlement',
async (outcome) => {
@@ -3,11 +3,7 @@ import { afterEach, beforeEach, expect, it, vi } from 'vitest'
import { act, cleanup, renderHook, waitFor } from '@testing-library/react'
import { startTransition, Suspense } from 'react'
import { useTaskPageJiraSavedFilters } from './use-task-page-jira-saved-filters'
import {
LOCAL_EXECUTION_HOST_ID,
UNRESOLVED_OWNER_HOST_ID,
toRuntimeExecutionHostId
} from '../../../../../shared/execution-host'
import { toRuntimeExecutionHostId } from '../../../../../shared/execution-host'
const { read, mutate } = vi.hoisted(() => ({ read: vi.fn(), mutate: vi.fn() }))
vi.mock('@/runtime/jira/runtime-jira-saved-filter-state', () => ({
readJiraSavedFilterState: read,
@@ -37,70 +33,6 @@ function model(
: null
}
}
it('uses the captured account owner without a source context and keeps source ownership first', async () => {
read.mockResolvedValue(initial)
mutate.mockResolvedValue(initial)
const setQuery = vi.fn()
const { result, rerender } = renderHook(
({ sourceHost }) => useTaskPageJiraSavedFilters(model(sourceHost), setQuery),
{ initialProps: { sourceHost: '' } }
)
await waitFor(() => expect(result.current.jiraSavedFiltersReady).toBe(true))
expect(read).toHaveBeenLastCalledWith({ kind: 'environment', environmentId: 'account-owner' })
await act(async () => result.current.applyJiraSavedFilter(filter))
expect(mutate).toHaveBeenLastCalledWith(
{ kind: 'environment', environmentId: 'account-owner' },
{ kind: 'select', id: filter.id }
)
rerender({ sourceHost: 'source-owner' })
await waitFor(() => expect(result.current.jiraSavedFiltersReady).toBe(true))
expect(read).toHaveBeenLastCalledWith({ kind: 'environment', environmentId: 'source-owner' })
})
it('does not load or write filters for an unresolved owner', async () => {
const { result } = renderHook(() =>
useTaskPageJiraSavedFilters(
{ ...model(null), accountBackedTaskSourceHostId: UNRESOLVED_OWNER_HOST_ID },
vi.fn()
)
)
await waitFor(() => expect(result.current.jiraSavedFiltersLoading).toBe(false))
await act(async () => result.current.applyJiraSavedFilter(filter))
expect(result.current.jiraSavedFiltersReady).toBe(false)
expect(read).not.toHaveBeenCalled()
expect(mutate).not.toHaveBeenCalled()
})
it('separates local ownership from a runtime environment named local', async () => {
read.mockResolvedValue(initial)
const setQuery = vi.fn()
const { result, rerender } = renderHook(
({ remote }) =>
useTaskPageJiraSavedFilters(
{
...model(null),
accountBackedTaskSourceHostId: remote ? 'runtime:local' : LOCAL_EXECUTION_HOST_ID
},
setQuery
),
{ initialProps: { remote: false } }
)
await waitFor(() => expect(result.current.jiraSavedFiltersReady).toBe(true))
expect(read).toHaveBeenLastCalledWith({ kind: 'local' })
rerender({ remote: true })
await waitFor(() =>
expect(read).toHaveBeenLastCalledWith({ kind: 'environment', environmentId: 'local' })
)
})
it('restores the host selection and reruns its exact JQL after remount', async () => {
read.mockResolvedValue(initial)
const setQuery = vi.fn()
const first = renderHook(() => useTaskPageJiraSavedFilters(model('host-a'), setQuery))
await waitFor(() => expect(first.result.current.jiraSavedFiltersReady).toBe(true))
expect(setQuery).toHaveBeenCalledWith(filter.jql)
first.unmount()
setQuery.mockClear()
renderHook(() => useTaskPageJiraSavedFilters(model('host-a'), setQuery))
await waitFor(() => expect(setQuery).toHaveBeenCalledWith(filter.jql))
})
it('ignores an old host read that completes after switching execution hosts', async () => {
let resolveOld: (value: typeof initial) => void = () => {}
read
@@ -124,90 +56,6 @@ it('ignores an old host read that completes after switching execution hosts', as
expect(result.current.jiraSavedFilters).toEqual([])
expect(setQuery).not.toHaveBeenCalledWith(filter.jql)
})
it('ignores a late write response from an owner that was left', async () => {
read.mockResolvedValueOnce(initial).mockResolvedValueOnce({ filters: [], activeFilterId: null })
let resolveWrite: (value: typeof initial) => void = () => {}
mutate.mockImplementationOnce(
() =>
new Promise((resolve) => {
resolveWrite = resolve
})
)
const setQuery = vi.fn()
const { result, rerender } = renderHook(
({ host }) => useTaskPageJiraSavedFilters(model(host), setQuery),
{ initialProps: { host: 'host-a' } }
)
await waitFor(() => expect(result.current.jiraSavedFiltersReady).toBe(true))
let pending = Promise.resolve(false)
act(() => {
pending = result.current.applyJiraSavedFilter(filter)
})
rerender({ host: 'host-b' })
await waitFor(() => expect(result.current.jiraSavedFiltersPending).toBe(false))
setQuery.mockClear()
await act(async () => {
resolveWrite(initial)
await pending
})
expect(result.current.jiraSavedFilters).toEqual([])
expect(setQuery).not.toHaveBeenCalled()
})
it('does not send a saved filter action retained from a previous account owner', async () => {
read.mockResolvedValue(initial)
const setQuery = vi.fn()
const { result, rerender } = renderHook(
({ accountHost }) => useTaskPageJiraSavedFilters(model(null, accountHost), setQuery),
{ initialProps: { accountHost: 'host-a' } }
)
await waitFor(() => expect(result.current.jiraSavedFiltersReady).toBe(true))
const oldAction = result.current.applyJiraSavedFilter
rerender({ accountHost: 'host-b' })
await waitFor(() => expect(result.current.jiraSavedFiltersReady).toBe(true))
await act(async () => oldAction(filter))
expect(mutate).not.toHaveBeenCalled()
})
it('retains stored JQL after failed writes and clears only selection for manual editing', async () => {
read.mockResolvedValue(initial)
mutate.mockRejectedValueOnce(new Error('Host disconnected'))
const setQuery = vi.fn()
const { result } = renderHook(() => useTaskPageJiraSavedFilters(model('host-a'), setQuery))
await waitFor(() => expect(result.current.jiraSavedFiltersReady).toBe(true))
await act(async () => {
await expect(
result.current.updateJiraSavedFilter(filter.id, { name: 'New name', jql: 'invalid' })
).rejects.toThrow('Host disconnected')
})
expect(result.current.jiraSavedFilters).toEqual([filter])
mutate.mockResolvedValueOnce({ filters: [filter], activeFilterId: null })
setQuery.mockClear()
await act(async () => {
await result.current.clearJiraSavedFilterSelection()
})
expect(mutate).toHaveBeenLastCalledWith(
{ kind: 'environment', environmentId: 'host-a' },
{ kind: 'select', id: null }
)
expect(setQuery).not.toHaveBeenCalled()
})
it('rejects a saved-filter action retained from a previous visit to the same owner', async () => {
read.mockResolvedValue(initial)
mutate.mockResolvedValue(initial)
const setQuery = vi.fn()
const { result, rerender } = renderHook(
({ host }) => useTaskPageJiraSavedFilters(model(host), setQuery),
{ initialProps: { host: 'host-a' } }
)
await waitFor(() => expect(result.current.jiraSavedFiltersReady).toBe(true))
const oldAction = result.current.applyJiraSavedFilter
rerender({ host: 'host-b' })
await waitFor(() => expect(result.current.jiraSavedFiltersLoading).toBe(false))
rerender({ host: 'host-a' })
await waitFor(() => expect(read).toHaveBeenCalledTimes(3))
await waitFor(() => expect(result.current.jiraSavedFiltersLoading).toBe(false))
await act(async () => oldAction(filter))
expect(mutate).not.toHaveBeenCalled()
})
it.each(['success', 'failure'] as const)(
'keeps a new visit to the owner pending after an old write settles with %s',
async (settlement) => {
@@ -299,52 +147,3 @@ it('applies only the committed owner while another owner render is suspended', a
)
expect(read).toHaveBeenCalledTimes(1)
})
it('reports false for skipped, stale, or unapplied selections and true only after applying JQL', async () => {
read.mockResolvedValue(initial)
const setQuery = vi.fn()
const { result, unmount } = renderHook(() =>
useTaskPageJiraSavedFilters(model('host-a'), setQuery)
)
await waitFor(() => expect(result.current.jiraSavedFiltersReady).toBe(true))
setQuery.mockClear()
mutate.mockResolvedValueOnce({ filters: [filter], activeFilterId: null })
await act(async () => {
expect(await result.current.applyJiraSavedFilter(filter)).toBe(false)
})
expect(setQuery).not.toHaveBeenCalled()
let resolveApply: (value: typeof initial) => void = () => {}
mutate.mockImplementationOnce(
() =>
new Promise((resolve) => {
resolveApply = resolve
})
)
let applying = Promise.resolve(false)
act(() => {
applying = result.current.applyJiraSavedFilter(filter)
})
await act(async () => {
expect(await result.current.applyJiraSavedFilter(filter)).toBe(false)
})
unmount()
resolveApply(initial)
expect(await applying).toBe(false)
expect(setQuery).not.toHaveBeenCalled()
expect(mutate).toHaveBeenCalledTimes(2)
})
it('validates a stale clear callback against the latest confirmed selection', async () => {
read.mockResolvedValue({ filters: [filter], activeFilterId: null })
const setQuery = vi.fn()
const { result } = renderHook(() => useTaskPageJiraSavedFilters(model('host-a'), setQuery))
await waitFor(() => expect(result.current.jiraSavedFiltersReady).toBe(true))
const clear = result.current.clearJiraSavedFilterSelection
mutate
.mockResolvedValueOnce(initial)
.mockResolvedValueOnce({ filters: [filter], activeFilterId: null })
await act(async () => expect(await result.current.applyJiraSavedFilter(filter)).toBe(true))
await act(async () => expect(await clear()).toBe(true))
expect(mutate).toHaveBeenLastCalledWith(
{ kind: 'environment', environmentId: 'host-a' },
{ kind: 'select', id: null }
)
})
@@ -94,57 +94,6 @@ afterEach(() => {
})
describe('Jira title generation lifecycle', () => {
it('keeps the form usable without desktop generation in the web client', async () => {
vi.stubGlobal('__ORCA_WEB_CLIENT__', true)
Object.defineProperty(window, 'api', { configurable: true, value: {} })
const { result } = setup()
expect(result.current.newJiraIssueSummaryAvailable).toBe(false)
await act(async () => result.current.handleGenerateNewJiraIssueSummary())
act(() => result.current.setNewJiraIssueTitle('Manually written title'))
expect(result.current.newJiraIssueTitle).toBe('Manually written title')
expect(mocks.generate).not.toHaveBeenCalled()
})
it.each([
{ ...initialProps, body: 'A different description' },
{ ...initialProps, sourceHost: 'remote-2' },
{ ...initialProps, accountLabel: 'Account two' },
{ ...initialProps, providerSite: 'site-2' }
])('keeps update guidance inline and clears it for another draft: %j', async (changed) => {
mocks.generate.mockResolvedValue({ success: false, error: 'Update the Jira host.' })
const { result, rerender } = setup()
await act(async () => result.current.handleGenerateNewJiraIssueSummary())
expect(result.current.newJiraIssueSummaryError).toBe('Update the Jira host.')
rerender(changed)
expect(result.current.newJiraIssueSummaryError).toBeNull()
})
it('uses the Jira source host and account', async () => {
mocks.generate.mockResolvedValue({ success: true, summary: 'Remote title' })
const { result, rerender } = setup()
rerender({ ...initialProps, context: 'remote-1', sourceHost: 'remote-1' })
await act(async () => result.current.handleGenerateNewJiraIssueSummary())
expect(mocks.generate).toHaveBeenCalledWith(
{ kind: 'environment', environmentId: 'remote-1' },
expect.objectContaining({ siteId: 'site-1', description: initialProps.body }),
expect.any(AbortSignal)
)
expect(result.current.newJiraIssueTitle).toBe('Remote title')
})
it('uses the captured account host when no source context is available', async () => {
mocks.generate.mockResolvedValue({ success: true, summary: 'Owned title' })
const { result, rerender } = setup()
rerender({ ...initialProps, hasSourceContext: false, accountHost: 'account-owner' })
await act(async () => result.current.handleGenerateNewJiraIssueSummary())
expect(mocks.generate).toHaveBeenCalledWith(
{ kind: 'environment', environmentId: 'account-owner' },
expect.objectContaining({ siteId: 'site-1' }),
expect.any(AbortSignal)
)
expect(result.current.newJiraIssueTitle).toBe('Owned title')
})
it('cancels on the captured account owner and ignores its reply after changing owners', async () => {
const pending = deferred()
mocks.generate.mockReturnValue(pending.promise)
@@ -166,33 +115,6 @@ describe('Jira title generation lifecycle', () => {
)
})
it.each([
{ ...initialProps, sourceHost: 'remote-2' },
{ ...initialProps, accountLabel: 'Account two' },
{ ...initialProps, providerSite: 'site-2' }
])(
'discards output when the Jira source changes with a stable global host key: %j',
async (changed) => {
const pending = deferred()
mocks.generate.mockReturnValue(pending.promise)
const { result, rerender } = setup()
let request!: Promise<void>
act(() => {
request = result.current.handleGenerateNewJiraIssueSummary()
})
rerender(changed)
await act(async () => {
pending.resolve({ success: true, summary: 'Wrong source title' })
await request
})
expect(result.current.newJiraIssueTitle).toBe('')
expect(mocks.cancel).toHaveBeenCalledWith(
{ kind: 'local' },
expect.objectContaining({ siteId: 'site-1' })
)
}
)
it('ignores output from an earlier visit to the same Jira owner', async () => {
const old = deferred()
const newer = deferred()
@@ -240,73 +162,6 @@ describe('Jira title generation lifecycle', () => {
expect(result.current.newJiraIssueTitle).toBe('')
})
it('preserves a title typed while generation is running', async () => {
const pending = deferred()
mocks.generate.mockReturnValue(pending.promise)
const { result } = setup()
let request!: Promise<void>
act(() => {
request = result.current.handleGenerateNewJiraIssueSummary()
})
act(() => result.current.handleNewJiraIssueTitleChange('My title'))
await act(async () => {
pending.resolve({ success: true, summary: 'AI title' })
await request
})
expect(result.current.newJiraIssueTitle).toBe('My title')
})
it.each([
{ ...initialProps, body: 'A different issue' },
{ ...initialProps, context: 'ssh:other' },
{ ...initialProps, project: 'OTHER' },
{ ...initialProps, type: 'Task' },
{ ...initialProps, open: false }
])('discards generation after the draft changes: %j', async (changed) => {
const pending = deferred()
mocks.generate.mockReturnValue(pending.promise)
const { result, rerender } = setup()
let request!: Promise<void>
act(() => {
request = result.current.handleGenerateNewJiraIssueSummary()
})
await act(async () => rerender(changed))
expect(mocks.cancel).toHaveBeenCalledOnce()
expect(result.current.newJiraIssueSummaryGenerating).toBe(false)
await act(async () => {
pending.resolve({ success: true, summary: 'Stale title' })
await request
})
expect(result.current.newJiraIssueTitle).toBe('')
})
it('allows a new request after close/reopen before the old request finishes', async () => {
const old = deferred()
const newer = deferred()
mocks.generate.mockReturnValueOnce(old.promise).mockReturnValueOnce(newer.promise)
const { result, rerender } = setup()
let first!: Promise<void>
let second!: Promise<void>
act(() => {
first = result.current.handleGenerateNewJiraIssueSummary()
})
await act(async () => rerender({ ...initialProps, open: false }))
rerender(initialProps)
act(() => {
second = result.current.handleGenerateNewJiraIssueSummary()
})
await act(async () => {
old.resolve({ success: true, summary: 'Old' })
await first
})
expect(result.current.newJiraIssueSummaryGenerating).toBe(true)
await act(async () => {
newer.resolve({ success: true, summary: 'New' })
await second
})
expect(result.current.newJiraIssueTitle).toBe('New')
})
it('invalidates the result even when cancel IPC rejects', async () => {
const pending = deferred()
mocks.generate.mockReturnValue(pending.promise)
@@ -1,6 +1,6 @@
// @vitest-environment happy-dom
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import { cleanup, render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { afterEach, expect, it, vi } from 'vitest'
import type { LinearIssue } from '../../../../../shared/linear/issue-types'
@@ -81,109 +81,3 @@ it('tracks the group key through refreshed rows and leaves ungrouped issues visi
render(table())
expect(screen.getByRole('button', { name: 'Todo 2', expanded: true })).toBeTruthy()
})
it('bounds section-click work linearly at N and 10N remembered groups and current rows', () => {
const samples = [1, 10].map((scale) => {
const history = 100 * scale
const groups = 3 * scale
const operations = { copiedKeys: 0, membershipChecks: 0, rowVisits: 0 }
function page(start: number, count: number): LinearIssueListRow[] {
return Array.from({ length: count }, (_, offset) => {
const id = start + offset
return [
{
get type() {
operations.rowVisits += 1
return 'section' as const
},
key: `assignee:perf-${id}`,
label: `Person ${id}`,
count: 1
},
{
get type() {
operations.rowVisits += 1
return 'issue' as const
},
issue: issue(`perf-${id}`)
}
]
}).flat()
}
const view = render(table([]))
// Each page stays within the shipped 36-issue limit; absent keys remain in this mount.
for (let start = 0; start < history; start += 36) {
view.rerender(table(page(start, Math.min(36, history - start))))
for (const button of view.container.querySelectorAll('button[aria-expanded]')) {
fireEvent.click(button)
expect(button.getAttribute('aria-expanded')).toBe('false')
}
}
view.rerender(table(page(history, groups)))
const button = screen.getByRole('button', { name: `Person ${history} 1` })
const iterate = Set.prototype[Symbol.iterator]
const has = Set.prototype.has
const copied = vi.spyOn(Set.prototype, Symbol.iterator).mockImplementation(function (
this: Set<unknown>
) {
const iterator = iterate.call(this)
const next = iterator.next.bind(iterator)
iterator.next = () => {
const result = next()
if (
!result.done &&
typeof result.value === 'string' &&
result.value.startsWith('assignee:perf-')
) {
operations.copiedKeys += 1
}
return result
}
return iterator
})
const membership = vi.spyOn(Set.prototype, 'has').mockImplementation(function (
this: Set<unknown>,
value: unknown
) {
if (typeof value === 'string' && value.startsWith('assignee:perf-')) {
operations.membershipChecks += 1
}
return has.call(this, value)
})
const clicks: (typeof operations)[] = []
try {
for (const expanded of [false, true]) {
Object.assign(operations, { copiedKeys: 0, membershipChecks: 0, rowVisits: 0 })
fireEvent.click(button)
expect(button.getAttribute('aria-expanded')).toBe(String(expanded))
expect(operations.copiedKeys).toBeGreaterThanOrEqual(history)
expect(operations.copiedKeys).toBeLessThanOrEqual(2 * (history + 1))
expect(operations.membershipChecks).toBeGreaterThanOrEqual(groups)
expect(operations.membershipChecks).toBeLessThanOrEqual(2 * groups)
expect(operations.rowVisits).toBeGreaterThanOrEqual(2 * groups)
expect(operations.rowVisits).toBeLessThanOrEqual(4 * groups)
clicks.push({ ...operations })
}
} finally {
copied.mockRestore()
membership.mockRestore()
}
view.rerender(table(page(history, groups).map((row) => ({ ...row }))))
const milliseconds = Array.from({ length: 20 }, () => {
const start = performance.now()
fireEvent.click(button)
return performance.now() - start
}).sort((a, b) => a - b)
console.info(
'Linear section-click benchmark',
JSON.stringify({ history, groups, clicks, milliseconds })
)
view.unmount()
return clicks.map(
({ copiedKeys, membershipChecks, rowVisits }) => copiedKeys + membershipChecks + rowVisits
)
})
for (let click = 0; click < 2; click += 1) {
expect(samples[1][click]).toBeLessThanOrEqual(12 * samples[0][click])
}
})
@@ -4,7 +4,6 @@ import {
task,
store,
source,
deferredIssueSetters,
useRoutingFixture
} from './task-page-linear-refresh-test-fixtures'
import { reads as issueReadRegistry } from '@/store/slices/linear/linear-issue-read-lifecycle'
@@ -303,52 +302,6 @@ it('committed hydration survives actual skipped-lane replay, force loading and t
expect(reads.detail).toHaveBeenCalledTimes(4)
})
it('acknowledges only captured field t, leaving newer u delivered before layout acknowledgement', async () => {
const s = store(),
context = defaultScopeSource(null)
const t = deferred<{ ok: boolean }>(),
u = deferred<{ ok: boolean }>()
let savingT!: Promise<{ ok: boolean }>, savingU!: Promise<{ ok: boolean }>
let current!: ReturnType<typeof useLinearIssuePublication>
let publishU = false
function Consumer() {
useLayoutEffect(() => {
if (publishU && publication.issue?.state.name === 'H') {
publishU = false
deferredIssueSetters.enabled = true
savingU = writeLinearIssue(task(), { priority: 2 }, context, () => u.promise)
deferredIssueSetters.enabled = false
}
})
const publication = useLinearIssuePublication(task(), context)
useLayoutEffect(() => {
current = publication
})
return <output data-testid="ack">{publication.issue?.priority}</output>
}
const mounted = render(<Consumer />)
act(() => {
savingT = writeLinearIssue(task(), { priority: 4 }, context, () => t.promise)
})
reads.detail.mockResolvedValue({ ...task('H'), priority: 1 })
const lease = s.getState().fetchLinearIssue(task().id, 'w', { force: true }).acquire()
const view = await lease.waitForCurrentTerminal()
publishU = true
act(() => current.read(lease, view))
expect(screen.getByTestId('ack').textContent).toBe('4')
const delayedU = deferredIssueSetters.updates.splice(0)
expect(delayedU.length).toBeGreaterThan(0)
act(() => delayedU.forEach((deliver) => deliver()))
expect(screen.getByTestId('ack').textContent).toBe('2')
mounted.unmount()
lease.release()
u.resolve({ ok: false })
t.resolve({ ok: false })
await Promise.all([savingT, savingU])
expect(pendingLinearIssueWrites.size).toBe(0)
expect(issueReadRegistry.size).toBe(0)
})
it('committed field t reconstructs while newer u is in a genuinely suspended lane', async () => {
store()
const t = deferred<{ ok: boolean }>(),
+1 -13
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest'
import { compareCodePoints, compareSidebarText, createSidebarTextComparator } from './text-order'
import { compareCodePoints, compareSidebarText } from './text-order'
// Built from code points so the formatter cannot fold the escapes into look-alike literals.
const COMBINING_ACUTE = String.fromCodePoint(0x301)
@@ -79,15 +79,3 @@ describe('compareCodePoints', () => {
expect(compareCodePoints('🎉', REPLACEMENT_CHAR)).toBeGreaterThan(0)
})
})
describe('createSidebarTextComparator', () => {
it('orders exactly like compareSidebarText', () => {
const corpus = ['w2', 'W10', 'w02', 'Éclair', 'éclair', 'zeta', 'ALPHA', 'alpha', '', 'a1b']
const compare = createSidebarTextComparator()
for (const a of corpus) {
for (const b of corpus) {
expect(Math.sign(compare(a, b))).toBe(Math.sign(compareSidebarText(a, b)))
}
}
})
})
@@ -39,13 +39,6 @@ describe('readWorkspaceReviewFact', () => {
})
})
it('lists only rows with a PR, under the key the workspaces domain lists them at', () => {
const other = { ...row, id: 'repo-1::/r/b', branch: 'b' }
expect(buildWorkspaceReviewFacts([row, other], [repo], { 'repo-1::a': pr('open') })).toEqual({
[`|${row.id}`]: { provider: 'github', reviewState: 'open' }
})
})
it('reads each host’s copy of a same-id workspace with its own repo, under its own key', () => {
const sshRepo = { ...repo, connectionId: 'box' }
const local = { ...row, hostId: 'local' as const }

Some files were not shown because too many files have changed in this diff Show More