diff --git a/.github/workflows/zcode-transcript-contract.yml b/.github/workflows/zcode-transcript-contract.yml index c8272bc1e1e..4d1e1904254 100644 --- a/.github/workflows/zcode-transcript-contract.yml +++ b/.github/workflows/zcode-transcript-contract.yml @@ -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 diff --git a/config/flaky-test-pattern-baseline.txt b/config/flaky-test-pattern-baseline.txt index 8e3b40b08c7..679566940c7 100644 --- a/config/flaky-test-pattern-baseline.txt +++ b/config/flaky-test-pattern-baseline.txt @@ -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 diff --git a/config/reliability-gates.jsonc b/config/reliability-gates.jsonc index bcde09970a2..d63c1a4c8b3 100644 --- a/config/reliability-gates.jsonc +++ b/config/reliability-gates.jsonc @@ -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." }, diff --git a/config/scripts/ci/sharding/ci-shard-timings.json b/config/scripts/ci/sharding/ci-shard-timings.json index 40418ce7305..4b57135bca4 100644 --- a/config/scripts/ci/sharding/ci-shard-timings.json +++ b/config/scripts/ci/sharding/ci-shard-timings.json @@ -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, diff --git a/package.json b/package.json index 0ce125ee5eb..80b1001f62d 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/cli/codex-command-classification.test.ts b/src/cli/codex-command-classification.test.ts index ec178efa55e..1d9a540f78d 100644 --- a/src/cli/codex-command-classification.test.ts +++ b/src/cli/codex-command-classification.test.ts @@ -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) - }) }) diff --git a/src/main/managed-server/activation/orcad-activation-record.test.ts b/src/main/managed-server/activation/orcad-activation-record.test.ts deleted file mode 100644 index 674b736c659..00000000000 --- a/src/main/managed-server/activation/orcad-activation-record.test.ts +++ /dev/null @@ -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) - }) -}) diff --git a/src/main/managed-server/host-state/orcad-state-mutation-exec.test.ts b/src/main/managed-server/host-state/orcad-state-mutation-exec.test.ts deleted file mode 100644 index d133d39406d..00000000000 --- a/src/main/managed-server/host-state/orcad-state-mutation-exec.test.ts +++ /dev/null @@ -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) - }) -}) diff --git a/src/main/managed-server/host-state/orcad-state-mutation-fence-heartbeat.test.ts b/src/main/managed-server/host-state/orcad-state-mutation-fence-heartbeat.test.ts index 79e7e43ac4c..a42dee9d08e 100644 --- a/src/main/managed-server/host-state/orcad-state-mutation-fence-heartbeat.test.ts +++ b/src/main/managed-server/host-state/orcad-state-mutation-fence-heartbeat.test.ts @@ -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 { return (await runProcess({ program: '/bin/sh', args: ['-c', command] })).stdout.trim() } -const pause = (ms: number): Promise => 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 => sh(`touch -m -t 200001010000 '${fence}'`) - const steal = (): Promise => 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) } ) diff --git a/src/main/managed-server/host-state/orcad-state-snapshot-space.test.ts b/src/main/managed-server/host-state/orcad-state-snapshot-space.test.ts index d127c079ca7..0c70421bb0b 100644 --- a/src/main/managed-server/host-state/orcad-state-snapshot-space.test.ts +++ b/src/main/managed-server/host-state/orcad-state-snapshot-space.test.ts @@ -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() - }) }) diff --git a/src/main/managed-server/host-state/orcad-windows-host-fence-ops.test.ts b/src/main/managed-server/host-state/orcad-windows-host-fence-ops.test.ts index d5752431106..b24edcc4111 100644 --- a/src/main/managed-server/host-state/orcad-windows-host-fence-ops.test.ts +++ b/src/main/managed-server/host-state/orcad-windows-host-fence-ops.test.ts @@ -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) diff --git a/src/main/managed-server/install/orcad-remote-build-hash.integration.test.ts b/src/main/managed-server/install/orcad-remote-build-hash.integration.test.ts deleted file mode 100644 index aaefc51b115..00000000000 --- a/src/main/managed-server/install/orcad-remote-build-hash.integration.test.ts +++ /dev/null @@ -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() - } -) diff --git a/src/main/managed-server/install/orcad-remote-install-cached-probe.test.ts b/src/main/managed-server/install/orcad-remote-install-cached-probe.test.ts deleted file mode 100644 index 4aa25a822e1..00000000000 --- a/src/main/managed-server/install/orcad-remote-install-cached-probe.test.ts +++ /dev/null @@ -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()), - execCommand: vi.fn() -})) -vi.mock('./orcad-remote-node-runtime', async (importOriginal) => ({ - ...(await importOriginal>()), - ensureRemoteOrcadNodeRuntime: vi.fn(async () => ({ executable: '/n', transfer: 'cached' })) -})) -vi.mock('./remote-versioned-install', async (importOriginal) => ({ - ...(await importOriginal>()), - 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() - }) -}) diff --git a/src/main/managed-server/install/orcad-remote-launch-setsid.test.ts b/src/main/managed-server/install/orcad-remote-launch-setsid.test.ts index 5f9ae94cdbe..07442416329 100644 --- a/src/main/managed-server/install/orcad-remote-launch-setsid.test.ts +++ b/src/main/managed-server/install/orcad-remote-launch-setsid.test.ts @@ -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', () => { diff --git a/src/main/managed-server/install/orcad-remote-lifecycle-windows.test.ts b/src/main/managed-server/install/orcad-remote-lifecycle-windows.test.ts deleted file mode 100644 index 1f67e1a372d..00000000000 --- a/src/main/managed-server/install/orcad-remote-lifecycle-windows.test.ts +++ /dev/null @@ -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()), - 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()), - 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()), - 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>()), - 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) - } - }) -}) diff --git a/src/main/managed-server/install/orcad-remote-log-tail.test.ts b/src/main/managed-server/install/orcad-remote-log-tail.test.ts deleted file mode 100644 index f6c35bf7cff..00000000000 --- a/src/main/managed-server/install/orcad-remote-log-tail.test.ts +++ /dev/null @@ -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()), - 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() - }) -}) diff --git a/src/main/managed-server/install/orcad-remote-node-runtime-host-fallback.test.ts b/src/main/managed-server/install/orcad-remote-node-runtime-host-fallback.test.ts deleted file mode 100644 index a07c4c4cf54..00000000000 --- a/src/main/managed-server/install/orcad-remote-node-runtime-host-fallback.test.ts +++ /dev/null @@ -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()), - 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, 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) - }) -}) diff --git a/src/main/managed-server/install/orcad-remote-node-runtime-noexec.test.ts b/src/main/managed-server/install/orcad-remote-node-runtime-noexec.test.ts deleted file mode 100644 index 9974f2eb9d5..00000000000 --- a/src/main/managed-server/install/orcad-remote-node-runtime-noexec.test.ts +++ /dev/null @@ -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()), - 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') -} diff --git a/src/main/managed-server/install/orcad-remote-node-runtime-recorded-probe.test.ts b/src/main/managed-server/install/orcad-remote-node-runtime-recorded-probe.test.ts deleted file mode 100644 index f58aa9f809b..00000000000 --- a/src/main/managed-server/install/orcad-remote-node-runtime-recorded-probe.test.ts +++ /dev/null @@ -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()), - 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 { - 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 { - 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) - }) -}) diff --git a/src/main/managed-server/install/orcad-remote-node-runtime-slow-hash.test.ts b/src/main/managed-server/install/orcad-remote-node-runtime-slow-hash.test.ts deleted file mode 100644 index 1fd85ad15d4..00000000000 --- a/src/main/managed-server/install/orcad-remote-node-runtime-slow-hash.test.ts +++ /dev/null @@ -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()), - 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 { - 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 | 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) -}) diff --git a/src/main/managed-server/install/orcad-remote-runtime-control.test.ts b/src/main/managed-server/install/orcad-remote-runtime-control.test.ts deleted file mode 100644 index c444c9bfedf..00000000000 --- a/src/main/managed-server/install/orcad-remote-runtime-control.test.ts +++ /dev/null @@ -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()), - 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) - }) -}) diff --git a/src/main/managed-server/install/remote-install-gc-termination.test.ts b/src/main/managed-server/install/remote-install-gc-termination.test.ts deleted file mode 100644 index c4af6031aec..00000000000 --- a/src/main/managed-server/install/remote-install-gc-termination.test.ts +++ /dev/null @@ -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()), - 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 { - 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) - }) -}) diff --git a/src/main/managed-server/install/remote-install-lock-exited-owner.test.ts b/src/main/managed-server/install/remote-install-lock-exited-owner.test.ts index 024121a6f7e..0d67de04bc8 100644 --- a/src/main/managed-server/install/remote-install-lock-exited-owner.test.ts +++ b/src/main/managed-server/install/remote-install-lock-exited-owner.test.ts @@ -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))) diff --git a/src/main/managed-server/install/remote-install-lock.test.ts b/src/main/managed-server/install/remote-install-lock.test.ts deleted file mode 100644 index 78cc26fd2b6..00000000000 --- a/src/main/managed-server/install/remote-install-lock.test.ts +++ /dev/null @@ -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()), - 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) - }) -}) diff --git a/src/main/managed-server/install/remote-install-out-of-space.test.ts b/src/main/managed-server/install/remote-install-out-of-space.test.ts deleted file mode 100644 index ee95117dc14..00000000000 --- a/src/main/managed-server/install/remote-install-out-of-space.test.ts +++ /dev/null @@ -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)) - } - ) -}) diff --git a/src/main/managed-server/install/remote-install-transfers-exec-fallback.test.ts b/src/main/managed-server/install/remote-install-transfers-exec-fallback.test.ts deleted file mode 100644 index 573f4b33a56..00000000000 --- a/src/main/managed-server/install/remote-install-transfers-exec-fallback.test.ts +++ /dev/null @@ -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 unknown>> = {} -): SshConnection & { - uploadDirectory: ReturnType - writeFile: ReturnType -} { - 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 - writeFile: ReturnType - } -} - -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() - }) -}) diff --git a/src/main/managed-server/install/remote-node-runtime-store-lock-host-release.test.ts b/src/main/managed-server/install/remote-node-runtime-store-lock-host-release.test.ts index 33b3533fe66..2d24708a9d9 100644 --- a/src/main/managed-server/install/remote-node-runtime-store-lock-host-release.test.ts +++ b/src/main/managed-server/install/remote-node-runtime-store-lock-host-release.test.ts @@ -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 { 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 { - 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 | 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')) diff --git a/src/main/managed-server/install/remote-node-runtime-store-windows.test.ts b/src/main/managed-server/install/remote-node-runtime-store-windows.test.ts index ec9a65065c2..c51fb77d026 100644 --- a/src/main/managed-server/install/remote-node-runtime-store-windows.test.ts +++ b/src/main/managed-server/install/remote-node-runtime-store-windows.test.ts @@ -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()), - 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']) - }) }) diff --git a/src/main/managed-server/lifecycle/orcad-managed-tunnel-transport.test.ts b/src/main/managed-server/lifecycle/orcad-managed-tunnel-transport.test.ts deleted file mode 100644 index a64632c410c..00000000000 --- a/src/main/managed-server/lifecycle/orcad-managed-tunnel-transport.test.ts +++ /dev/null @@ -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) - }) -}) diff --git a/src/main/managed-server/lifecycle/orcad-managed-update-decision.test.ts b/src/main/managed-server/lifecycle/orcad-managed-update-decision.test.ts deleted file mode 100644 index d5c4b2b1e81..00000000000 --- a/src/main/managed-server/lifecycle/orcad-managed-update-decision.test.ts +++ /dev/null @@ -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() - }) -}) diff --git a/src/main/managed-server/lifecycle/orcad-managed-update-on-restore.test.ts b/src/main/managed-server/lifecycle/orcad-managed-update-on-restore.test.ts deleted file mode 100644 index 808028c57a1..00000000000 --- a/src/main/managed-server/lifecycle/orcad-managed-update-on-restore.test.ts +++ /dev/null @@ -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 { - 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) - }) -}) diff --git a/src/main/managed-server/lifecycle/orcad-managed-version-gc.test.ts b/src/main/managed-server/lifecycle/orcad-managed-version-gc.test.ts deleted file mode 100644 index 967f741702d..00000000000 --- a/src/main/managed-server/lifecycle/orcad-managed-version-gc.test.ts +++ /dev/null @@ -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) - }) -}) diff --git a/src/main/managed-server/lifecycle/orcad-stdio-bridge-provider.test.ts b/src/main/managed-server/lifecycle/orcad-stdio-bridge-provider.test.ts index dc24ae0bcb1..7c85ca3aa2c 100644 --- a/src/main/managed-server/lifecycle/orcad-stdio-bridge-provider.test.ts +++ b/src/main/managed-server/lifecycle/orcad-stdio-bridge-provider.test.ts @@ -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>() - .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) diff --git a/src/main/media/media-range-response.test.ts b/src/main/media/media-range-response.test.ts deleted file mode 100644 index 24698217756..00000000000 --- a/src/main/media/media-range-response.test.ts +++ /dev/null @@ -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) - }) -}) diff --git a/src/main/media/preview-raster-header-check.test.ts b/src/main/media/preview-raster-header-check.test.ts deleted file mode 100644 index 84e972ca9d5..00000000000 --- a/src/main/media/preview-raster-header-check.test.ts +++ /dev/null @@ -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] - ]) - }) -}) diff --git a/src/main/memory/collector-windows-sweep.test.ts b/src/main/memory/collector-windows-sweep.test.ts deleted file mode 100644 index 39f05713b4a..00000000000 --- a/src/main/memory/collector-windows-sweep.test.ts +++ /dev/null @@ -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) - 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) - }) -}) diff --git a/src/main/memory/host-malloc-tunables.test.ts b/src/main/memory/host-malloc-tunables.test.ts deleted file mode 100644 index aebd6b7a4b4..00000000000 --- a/src/main/memory/host-malloc-tunables.test.ts +++ /dev/null @@ -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') - }) -}) diff --git a/src/main/mimo/hook-service.test.ts b/src/main/mimo/hook-service.test.ts index 82003455e1f..904263d6f3a 100644 --- a/src/main/mimo/hook-service.test.ts +++ b/src/main/mimo/hook-service.test.ts @@ -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) diff --git a/src/main/minimax/minimax-cookie-store.test.ts b/src/main/minimax/minimax-cookie-store.test.ts deleted file mode 100644 index 0ba6b91a5f9..00000000000 --- a/src/main/minimax/minimax-cookie-store.test.ts +++ /dev/null @@ -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 { - 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() - }) - }) -}) diff --git a/src/main/muse/hook-config-json.test.ts b/src/main/muse/hook-config-json.test.ts deleted file mode 100644 index 3a5c7e985dd..00000000000 --- a/src/main/muse/hook-config-json.test.ts +++ /dev/null @@ -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() - }) -}) diff --git a/src/main/muse/hook-service.test.ts b/src/main/muse/hook-service.test.ts index 75e9db2795e..280dfc40525 100644 --- a/src/main/muse/hook-service.test.ts +++ b/src/main/muse/hook-service.test.ts @@ -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) - }) }) diff --git a/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-earlier-turn-end.test.ts b/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-earlier-turn-end.test.ts index 0e79ec50d04..004f6e5f435 100644 --- a/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-earlier-turn-end.test.ts +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-earlier-turn-end.test.ts @@ -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 }) diff --git a/src/main/native-chat/host-readable-transcript-path-fs-gate.test.ts b/src/main/native-chat/host-readable-transcript-path-fs-gate.test.ts deleted file mode 100644 index 73567a4be5a..00000000000 --- a/src/main/native-chat/host-readable-transcript-path-fs-gate.test.ts +++ /dev/null @@ -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>() -})) - -vi.mock('node:fs/promises', async (importOriginal) => ({ - ...(await importOriginal()), - 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 -} { - 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((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((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((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(() => {}) : 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() - } - }) -}) diff --git a/src/main/native-chat/native-chat-visual-file-read.test.ts b/src/main/native-chat/native-chat-visual-file-read.test.ts index 746ca77193c..948d8213c6c 100644 --- a/src/main/native-chat/native-chat-visual-file-read.test.ts +++ b/src/main/native-chat/native-chat-visual-file-read.test.ts @@ -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'), '

a

') - 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'), '

b

') - const changed = await readNativeChatVisualFile(folder, 'chart.html', first.revision) - expect(changed).toMatchObject({ ok: true, html: '

b

' }) - }) - - 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'), '

a

') - await link(join(folder, 'a.html'), join(folder, 'b.html')) - expect(await readNativeChatVisualFile(folder, 'b.html')).toMatchObject({ ok: true }) - }) }) diff --git a/src/main/native-chat/ssh-transcript-path.test.ts b/src/main/native-chat/ssh-transcript-path.test.ts deleted file mode 100644 index f0cd2ac1499..00000000000 --- a/src/main/native-chat/ssh-transcript-path.test.ts +++ /dev/null @@ -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() - }) -}) diff --git a/src/main/native-chat/structured-agent-command-guest-path.test.ts b/src/main/native-chat/structured-agent-command-guest-path.test.ts deleted file mode 100644 index 4e903c95475..00000000000 --- a/src/main/native-chat/structured-agent-command-guest-path.test.ts +++ /dev/null @@ -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() - 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) => - 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() - }) -}) diff --git a/src/main/native-chat/structured-agent-command-resolution-wsl-host.test.ts b/src/main/native-chat/structured-agent-command-resolution-wsl-host.test.ts deleted file mode 100644 index 6470bb76668..00000000000 --- a/src/main/native-chat/structured-agent-command-resolution-wsl-host.test.ts +++ /dev/null @@ -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()), - 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() - }) -}) diff --git a/src/main/native-chat/subagent-entry-id-bounds.test.ts b/src/main/native-chat/subagent-entry-id-bounds.test.ts deleted file mode 100644 index 56d0edece66..00000000000 --- a/src/main/native-chat/subagent-entry-id-bounds.test.ts +++ /dev/null @@ -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) - }) -}) diff --git a/src/main/native-chat/transcript-file-version.test.ts b/src/main/native-chat/transcript-file-version.test.ts index 01851b9e36a..71ae18a5223 100644 --- a/src/main/native-chat/transcript-file-version.test.ts +++ b/src/main/native-chat/transcript-file-version.test.ts @@ -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) -}) diff --git a/src/main/native-chat/transcript-line-decoders-claude-pasted-content.test.ts b/src/main/native-chat/transcript-line-decoders-claude-pasted-content.test.ts deleted file mode 100644 index 84dc3c68ed1..00000000000 --- a/src/main/native-chat/transcript-line-decoders-claude-pasted-content.test.ts +++ /dev/null @@ -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\n${prompt}\n\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(`\r\n${prompt}\r\n`).blocks).toEqual([ - { type: 'text', text: prompt } - ]) - }) - it('decodes a paste whose own text quotes differently identified or bare tags', () => { - const quoted = `\nx\n\n` - expect( - decode(`\n${quoted}\n`).blocks - ).toEqual([{ type: 'text', text: quoted }]) - }) - it.each([ - `Explain this:\n${wrapped}`, - `\n\nx\n`, - `\n\nx\n\n`, - wrapped.replace('id="7e64">\n', 'id="other">\n'), - wrapped.replace('', ''), - wrapped.replace('', ''), - `${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 }]) - }) -}) diff --git a/src/main/native-chat/transcript-opencode-read-bounds.test.ts b/src/main/native-chat/transcript-opencode-read-bounds.test.ts deleted file mode 100644 index 5c4e6e0208e..00000000000 --- a/src/main/native-chat/transcript-opencode-read-bounds.test.ts +++ /dev/null @@ -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() - } - ) -}) diff --git a/src/renderer/src/components/settings/repository/execution/repository-runtime-session-summary.test.ts b/src/renderer/src/components/settings/repository/execution/repository-runtime-session-summary.test.ts deleted file mode 100644 index 16a8992768b..00000000000 --- a/src/renderer/src/components/settings/repository/execution/repository-runtime-session-summary.test.ts +++ /dev/null @@ -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) -}) diff --git a/src/renderer/src/components/tab-group/TabGroupSplitLayout.drag.test.tsx b/src/renderer/src/components/tab-group/TabGroupSplitLayout.drag.test.tsx deleted file mode 100644 index 711b24315ed..00000000000 --- a/src/renderer/src/components/tab-group/TabGroupSplitLayout.drag.test.tsx +++ /dev/null @@ -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 => ({ - recordFeatureInteraction: recordFeatureInteractionMock, - setTabGroupSplitRatio: setTabGroupSplitRatioMock, - layoutByWorktree: {} - }) - // The switch-on ratio commit reads the store directly. - const useAppStore = Object.assign( - (selector: (state: Record) => unknown) => selector(state()), - { getState: state } - ) - return { useAppStore } -}) - -vi.mock('./TabGroupPanel', () => ({ - default: ({ groupId }: { groupId: string }) =>
-})) - -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( - - ) - }) - 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() - 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 - 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() - }) -}) diff --git a/src/renderer/src/components/tab-group/TabGroupSplitLayout.test.ts b/src/renderer/src/components/tab-group/TabGroupSplitLayout.test.ts deleted file mode 100644 index 790dceacdfd..00000000000 --- a/src/renderer/src/components/tab-group/TabGroupSplitLayout.test.ts +++ /dev/null @@ -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) => unknown) - props: Record -} - -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) { - // 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) { - 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 - }) - ) - }) -}) diff --git a/src/renderer/src/components/tab-group/TabPaneColumnSplitDragOverlay.test.tsx b/src/renderer/src/components/tab-group/TabPaneColumnSplitDragOverlay.test.tsx deleted file mode 100644 index 72ec9216560..00000000000 --- a/src/renderer/src/components/tab-group/TabPaneColumnSplitDragOverlay.test.tsx +++ /dev/null @@ -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 -} - -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() - }) -}) diff --git a/src/renderer/src/components/tab-group/editor-reveal-flash.test.ts b/src/renderer/src/components/tab-group/editor-reveal-flash.test.ts deleted file mode 100644 index 639011c8142..00000000000 --- a/src/renderer/src/components/tab-group/editor-reveal-flash.test.ts +++ /dev/null @@ -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() - }) -}) diff --git a/src/renderer/src/components/tab-group/tab-drag-pointer.test.ts b/src/renderer/src/components/tab-group/tab-drag-pointer.test.ts deleted file mode 100644 index f2bf11ea8e4..00000000000 --- a/src/renderer/src/components/tab-group/tab-drag-pointer.test.ts +++ /dev/null @@ -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[0]) - ).toEqual({ x: 350, y: 50 }) - }) -}) diff --git a/src/renderer/src/components/tab-group/tab-drag-preview-activation.test.ts b/src/renderer/src/components/tab-group/tab-drag-preview-activation.test.ts index 42754b25879..a3b51a86459 100644 --- a/src/renderer/src/components/tab-group/tab-drag-preview-activation.test.ts +++ b/src/renderer/src/components/tab-group/tab-drag-preview-activation.test.ts @@ -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) diff --git a/src/renderer/src/components/tab-group/tab-drag-retained-guest-passthrough.test.tsx b/src/renderer/src/components/tab-group/tab-drag-retained-guest-passthrough.test.tsx index 3393ca91b1f..3e9e63976b6 100644 --- a/src/renderer/src/components/tab-group/tab-drag-retained-guest-passthrough.test.tsx +++ b/src/renderer/src/components/tab-group/tab-drag-retained-guest-passthrough.test.tsx @@ -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 { let result: ReturnType | 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() diff --git a/src/renderer/src/components/tab-group/tab-drop-zone.test.ts b/src/renderer/src/components/tab-group/tab-drop-zone.test.ts deleted file mode 100644 index 2690d7d97fe..00000000000 --- a/src/renderer/src/components/tab-group/tab-drop-zone.test.ts +++ /dev/null @@ -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') - }) -}) diff --git a/src/renderer/src/components/tab-group/tab-group-body-anchor.test.ts b/src/renderer/src/components/tab-group/tab-group-body-anchor.test.ts deleted file mode 100644 index 621abdc84dc..00000000000 --- a/src/renderer/src/components/tab-group/tab-group-body-anchor.test.ts +++ /dev/null @@ -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-]+$/) - }) -}) diff --git a/src/renderer/src/components/tab-group/tab-group-body-geometry.test.ts b/src/renderer/src/components/tab-group/tab-group-body-geometry.test.ts index 07458d30bee..4406317109c 100644 --- a/src/renderer/src/components/tab-group/tab-group-body-geometry.test.ts +++ b/src/renderer/src/components/tab-group/tab-group-body-geometry.test.ts @@ -22,15 +22,6 @@ function track(cleanup: () => void): () => void { return cleanup } -function resizeListenerDelta( - add: ReturnType, - remove: ReturnType -) { - const count = (spy: ReturnType): 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() diff --git a/src/renderer/src/components/tab-group/tab-group-layout-owner-commands.test.ts b/src/renderer/src/components/tab-group/tab-group-layout-owner-commands.test.ts index 910ee4045d3..a02508a2bee 100644 --- a/src/renderer/src/components/tab-group/tab-group-layout-owner-commands.test.ts +++ b/src/renderer/src/components/tab-group/tab-group-layout-owner-commands.test.ts @@ -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() - }) }) diff --git a/src/renderer/src/components/tab-group/tab-group-panel-split-target.test.ts b/src/renderer/src/components/tab-group/tab-group-panel-split-target.test.ts deleted file mode 100644 index 47f7deac859..00000000000 --- a/src/renderer/src/components/tab-group/tab-group-panel-split-target.test.ts +++ /dev/null @@ -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 { - 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[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 } { - 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 - layoutByWorktree: Record - queryAll: ReturnType -} { - 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) - }) -}) diff --git a/src/renderer/src/components/tab-group/tab-insertion.test.ts b/src/renderer/src/components/tab-group/tab-insertion.test.ts deleted file mode 100644 index c049c35c311..00000000000 --- a/src/renderer/src/components/tab-group/tab-insertion.test.ts +++ /dev/null @@ -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 { - 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[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[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' }]) - }) -}) diff --git a/src/renderer/src/components/tab-group/tab-moves-drawn-from-frames.test.ts b/src/renderer/src/components/tab-group/tab-moves-drawn-from-frames.test.ts index 640e15bdf1b..2cf911d7dcb 100644 --- a/src/renderer/src/components/tab-group/tab-moves-drawn-from-frames.test.ts +++ b/src/renderer/src/components/tab-group/tab-moves-drawn-from-frames.test.ts @@ -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) - }) }) diff --git a/src/renderer/src/components/tab-group/useTabDragSplit.test.ts b/src/renderer/src/components/tab-group/useTabDragSplit.test.ts deleted file mode 100644 index b03c45f3c0c..00000000000 --- a/src/renderer/src/components/tab-group/useTabDragSplit.test.ts +++ /dev/null @@ -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()), - 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): 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) => void -): ReturnType { - let result: ReturnType | 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['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[0])) - act(() => drag.onDragMove(event as unknown as Parameters[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[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[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[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>) - - 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>) - - 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) - }) -}) diff --git a/src/renderer/src/components/tab-group/useTabGroupItemProjections.test.ts b/src/renderer/src/components/tab-group/useTabGroupItemProjections.test.ts deleted file mode 100644 index f1853968203..00000000000 --- a/src/renderer/src/components/tab-group/useTabGroupItemProjections.test.ts +++ /dev/null @@ -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') -}) diff --git a/src/renderer/src/components/task-page/github/pr-check-hydration.test.tsx b/src/renderer/src/components/task-page/github/pr-check-hydration.test.tsx index 593b1e0dd4c..a09d3171298 100644 --- a/src/renderer/src/components/task-page/github/pr-check-hydration.test.tsx +++ b/src/renderer/src/components/task-page/github/pr-check-hydration.test.tsx @@ -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() - 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) => { diff --git a/src/renderer/src/components/task-page/github/task-page-github-dialog-state-authority.test.ts b/src/renderer/src/components/task-page/github/task-page-github-dialog-state-authority.test.ts deleted file mode 100644 index 0ec8a96525b..00000000000 --- a/src/renderer/src/components/task-page/github/task-page-github-dialog-state-authority.test.ts +++ /dev/null @@ -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 { - 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([['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[] = [] - 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() - }) -}) diff --git a/src/renderer/src/components/task-page/github/task-page-github-list-scroll-restore.test.ts b/src/renderer/src/components/task-page/github/task-page-github-list-scroll-restore.test.ts index 959ba896f2f..1985bd0e804 100644 --- a/src/renderer/src/components/task-page/github/task-page-github-list-scroll-restore.test.ts +++ b/src/renderer/src/components/task-page/github/task-page-github-list-scroll-restore.test.ts @@ -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(360) - const restoreWriteRef = ref(null) - const applied: number[] = [] - - startGitHubListScrollRestore({ - target: 360, - scrollElementRef: ref(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(360) - const restoreWriteRef = ref(null) - - startGitHubListScrollRestore({ - target: 360, - scrollElementRef: ref(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(360) - const restoreWriteRef = ref(null) - const applied: number[] = [] - - startGitHubListScrollRestore({ - target: 360, - scrollElementRef: ref(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(360) - const restoreWriteRef = ref(null) - - startGitHubListScrollRestore({ - target: 360, - scrollElementRef: ref(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(360) - const restoreWriteRef = ref(null) - - const stop = startGitHubListScrollRestore({ - target: 360, - scrollElementRef: ref(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(360) - - const stop = startGitHubListScrollRestore({ - target: 360, - scrollElementRef: ref(null), - pendingRestoreRef, - restoreWriteRef: ref(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(360) - const restoreWriteRef = ref(null) - - startGitHubListScrollRestore({ - target: 360, - scrollElementRef: ref(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(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(120) - const restoreWriteRef = ref({ 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(null) - const restoreWriteRef = ref({ target: 360, committed: 200 }) - - expect( - supersedeGitHubListScrollRestore({ scrollTop: 200, pendingRestoreRef, restoreWriteRef }) - ).toBe(true) - }) }) }) diff --git a/src/renderer/src/components/task-page/github/task-page-github-resume-cache.test.ts b/src/renderer/src/components/task-page/github/task-page-github-resume-cache.test.ts deleted file mode 100644 index 7e004f0dd19..00000000000 --- a/src/renderer/src/components/task-page/github/task-page-github-resume-cache.test.ts +++ /dev/null @@ -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() - 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() - 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() - - 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({ 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() - 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]) - }) -}) diff --git a/src/renderer/src/components/task-page/github/task-page-github-reviewer-suggestions.test.ts b/src/renderer/src/components/task-page/github/task-page-github-reviewer-suggestions.test.ts deleted file mode 100644 index 88c7c906360..00000000000 --- a/src/renderer/src/components/task-page/github/task-page-github-reviewer-suggestions.test.ts +++ /dev/null @@ -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' } - ]) - }) -}) diff --git a/src/renderer/src/components/task-page/github/task-page-github-status-actions.test.ts b/src/renderer/src/components/task-page/github/task-page-github-status-actions.test.ts deleted file mode 100644 index 50c14293255..00000000000 --- a/src/renderer/src/components/task-page/github/task-page-github-status-actions.test.ts +++ /dev/null @@ -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 { - 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 - } -} diff --git a/src/renderer/src/components/task-page/github/task-page-github-status-state.test.ts b/src/renderer/src/components/task-page/github/task-page-github-status-state.test.ts deleted file mode 100644 index f2897319cbe..00000000000 --- a/src/renderer/src/components/task-page/github/task-page-github-status-state.test.ts +++ /dev/null @@ -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' - }) - }) -}) diff --git a/src/renderer/src/components/task-page/github/task-page-github-task-kind.test.ts b/src/renderer/src/components/task-page/github/task-page-github-task-kind.test.ts deleted file mode 100644 index 833bfd38754..00000000000 --- a/src/renderer/src/components/task-page/github/task-page-github-task-kind.test.ts +++ /dev/null @@ -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"') - }) -}) diff --git a/src/renderer/src/components/task-page/github/task-page-github-work-item-filter-membership.test.ts b/src/renderer/src/components/task-page/github/task-page-github-work-item-filter-membership.test.ts deleted file mode 100644 index 395d51b1404..00000000000 --- a/src/renderer/src/components/task-page/github/task-page-github-work-item-filter-membership.test.ts +++ /dev/null @@ -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 => ({ - 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[0] - expect(recomputeTaskPageGitHubItemSoftHide(options)).toEqual({ hide: true, sticky: true }) - expect(recomputeTaskPageGitHubItemSoftHide({ ...options, queryKey: 'q2' })).toEqual({ - hide: false, - sticky: false - }) - }) -}) diff --git a/src/renderer/src/components/task-page/github/task-page-github-work-item-mutation-patches.test.ts b/src/renderer/src/components/task-page/github/task-page-github-work-item-mutation-patches.test.ts deleted file mode 100644 index 748627f74f4..00000000000 --- a/src/renderer/src/components/task-page/github/task-page-github-work-item-mutation-patches.test.ts +++ /dev/null @@ -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 { - 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) - }) -}) diff --git a/src/renderer/src/components/task-page/github/task-page-github-work-item-status.test.ts b/src/renderer/src/components/task-page/github/task-page-github-work-item-status.test.ts deleted file mode 100644 index 5549559e19b..00000000000 --- a/src/renderer/src/components/task-page/github/task-page-github-work-item-status.test.ts +++ /dev/null @@ -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) - }) -}) diff --git a/src/renderer/src/components/task-page/gitlab/use-task-page-gitlab-loading.test.tsx b/src/renderer/src/components/task-page/gitlab/use-task-page-gitlab-loading.test.tsx index b68ea7370d0..36b1588261a 100644 --- a/src/renderer/src/components/task-page/gitlab/use-task-page-gitlab-loading.test.tsx +++ b/src/renderer/src/components/task-page/gitlab/use-task-page-gitlab-loading.test.tsx @@ -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([]) - const [gitlabError, setGitlabError] = useState(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 - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: ItemList reads only the fields supplied above. - return - } - - 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() - - 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() - - 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() - - 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() - }) }) diff --git a/src/renderer/src/components/task-page/jira/Filters.linked-mode.test.tsx b/src/renderer/src/components/task-page/jira/Filters.linked-mode.test.tsx deleted file mode 100644 index ed047bb9009..00000000000 --- a/src/renderer/src/components/task-page/jira/Filters.linked-mode.test.tsx +++ /dev/null @@ -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 }) => ( - - ) -})) - -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(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 ( - - - - ) -} - -beforeEach(() => { - fixture.loading = false - useAppStore.setState( - { - ...initialStore, - settings: null, - persistedUIReady: false, - repos: [], - taskPageData: {}, - checkLinearConnection: vi - .fn() - .mockResolvedValue(undefined), - checkJiraConnection: vi - .fn() - .mockResolvedValue(undefined), - refreshPreflightStatus: vi - .fn() - .mockResolvedValue(undefined) - }, - true - ) - Object.defineProperty(window, 'api', { - configurable: true, - value: { - gh: { viewer: vi.fn().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() - 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) - }) -}) diff --git a/src/renderer/src/components/task-page/jira/TaskPageJiraSavedFilters.test.tsx b/src/renderer/src/components/task-page/jira/TaskPageJiraSavedFilters.test.tsx deleted file mode 100644 index e438fcdc077..00000000000 --- a/src/renderer/src/components/task-page/jira/TaskPageJiraSavedFilters.test.tsx +++ /dev/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> = {}) { - 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() - 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) -}) diff --git a/src/renderer/src/components/task-page/jira/TaskPageJiraStatusFilters.test.tsx b/src/renderer/src/components/task-page/jira/TaskPageJiraStatusFilters.test.tsx index e0684be241f..969bc13a13e 100644 --- a/src/renderer/src/components/task-page/jira/TaskPageJiraStatusFilters.test.tsx +++ b/src/renderer/src/components/task-page/jira/TaskPageJiraStatusFilters.test.tsx @@ -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() - 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() - 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() - 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() - 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() diff --git a/src/renderer/src/components/task-page/jira/task-page-jira-board-detail-integration.test.tsx b/src/renderer/src/components/task-page/jira/task-page-jira-board-detail-integration.test.tsx index 0d8747b4e6f..86a90957200 100644 --- a/src/renderer/src/components/task-page/jira/task-page-jira-board-detail-integration.test.tsx +++ b/src/renderer/src/components/task-page/jira/task-page-jira-board-detail-integration.test.tsx @@ -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() - 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( - - ) - 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() - } - ) }) diff --git a/src/renderer/src/components/task-page/jira/task-page-jira-board-view.test.tsx b/src/renderer/src/components/task-page/jira/task-page-jira-board-view.test.tsx index 9087fe65e89..ffc6e25c7c8 100644 --- a/src/renderer/src/components/task-page/jira/task-page-jira-board-view.test.tsx +++ b/src/renderer/src/components/task-page/jira/task-page-jira-board-view.test.tsx @@ -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((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() - } - ) }) diff --git a/src/renderer/src/components/task-page/jira/task-page-jira-cache-selectors.test.ts b/src/renderer/src/components/task-page/jira/task-page-jira-cache-selectors.test.ts index 31944897ec5..0cad8587abf 100644 --- a/src/renderer/src/components/task-page/jira/task-page-jira-cache-selectors.test.ts +++ b/src/renderer/src/components/task-page/jira/task-page-jira-cache-selectors.test.ts @@ -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') - }) }) diff --git a/src/renderer/src/components/task-page/jira/task-page-jira-create-fields.test.ts b/src/renderer/src/components/task-page/jira/task-page-jira-create-fields.test.ts deleted file mode 100644 index 0b520c41069..00000000000 --- a/src/renderer/src/components/task-page/jira/task-page-jira-create-fields.test.ts +++ /dev/null @@ -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 { - 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 - }) - }) -}) diff --git a/src/renderer/src/components/task-page/jira/task-page-jira-favourite-selection.test.tsx b/src/renderer/src/components/task-page/jira/task-page-jira-favourite-selection.test.tsx index 5b4a69bc03c..364a38f2b47 100644 --- a/src/renderer/src/components/task-page/jira/task-page-jira-favourite-selection.test.tsx +++ b/src/renderer/src/components/task-page/jira/task-page-jira-favourite-selection.test.tsx @@ -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 expect(result.current.jiraLoading).toBe(false) }) } -async function select(result: { current: ReturnType }) { - 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() - 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() - 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) -}) diff --git a/src/renderer/src/components/task-page/jira/task-page-jira-saved-filter-load-failure.test.tsx b/src/renderer/src/components/task-page/jira/task-page-jira-saved-filter-load-failure.test.tsx deleted file mode 100644 index 2a8b388adb0..00000000000 --- a/src/renderer/src/components/task-page/jira/task-page-jira-saved-filter-load-failure.test.tsx +++ /dev/null @@ -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(() => {}) - -function useTaskPageFixture(store: ReturnType, remote: boolean) { - const [jiraIssues, setJiraIssues] = useState([]) - 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((...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((...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((...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) -}) diff --git a/src/renderer/src/components/task-page/jira/task-page-jira-sorting.test.ts b/src/renderer/src/components/task-page/jira/task-page-jira-sorting.test.ts deleted file mode 100644 index ec604d73ff9..00000000000 --- a/src/renderer/src/components/task-page/jira/task-page-jira-sorting.test.ts +++ /dev/null @@ -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([ - ['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([ - ['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])) - }) -}) diff --git a/src/renderer/src/components/task-page/jira/task-page-jira-text-fallback-notice.test.tsx b/src/renderer/src/components/task-page/jira/task-page-jira-text-fallback-notice.test.tsx deleted file mode 100644 index 328d949b81c..00000000000 --- a/src/renderer/src/components/task-page/jira/task-page-jira-text-fallback-notice.test.tsx +++ /dev/null @@ -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() - const region = screen.getByRole('status') - expect(region.textContent).toBe('') - - rerender() - expect(screen.getByRole('status')).toBe(region) - expect(region.textContent).toContain('Showing text matches') - - rerender() - expect(screen.getByRole('status')).toBe(region) - expect(region.textContent).toBe('') - }) -}) diff --git a/src/renderer/src/components/task-page/jira/use-task-page-jira-list-effects.test.ts b/src/renderer/src/components/task-page/jira/use-task-page-jira-list-effects.test.ts deleted file mode 100644 index 69789c5abb8..00000000000 --- a/src/renderer/src/components/task-page/jira/use-task-page-jira-list-effects.test.ts +++ /dev/null @@ -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 }) - ) - }) -}) diff --git a/src/renderer/src/components/task-page/jira/use-task-page-jira-project-scope.test.tsx b/src/renderer/src/components/task-page/jira/use-task-page-jira-project-scope.test.tsx index 73207fcc0c2..ad095a2911e 100644 --- a/src/renderer/src/components/task-page/jira/use-task-page-jira-project-scope.test.tsx +++ b/src/renderer/src/components/task-page/jira/use-task-page-jira-project-scope.test.tsx @@ -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']) - }) }) diff --git a/src/renderer/src/components/task-page/jira/use-task-page-jira-quick-filters.test.tsx b/src/renderer/src/components/task-page/jira/use-task-page-jira-quick-filters.test.tsx index f645f730be2..cdcdb449843 100644 --- a/src/renderer/src/components/task-page/jira/use-task-page-jira-quick-filters.test.tsx +++ b/src/renderer/src/components/task-page/jira/use-task-page-jira-quick-filters.test.tsx @@ -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 = 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) => { diff --git a/src/renderer/src/components/task-page/jira/use-task-page-jira-saved-filters.test.tsx b/src/renderer/src/components/task-page/jira/use-task-page-jira-saved-filters.test.tsx index ba386aacb5f..8853cfba223 100644 --- a/src/renderer/src/components/task-page/jira/use-task-page-jira-saved-filters.test.tsx +++ b/src/renderer/src/components/task-page/jira/use-task-page-jira-saved-filters.test.tsx @@ -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 } - ) -}) diff --git a/src/renderer/src/components/task-page/jira/use-task-page-jira-summary-generation.test.tsx b/src/renderer/src/components/task-page/jira/use-task-page-jira-summary-generation.test.tsx index f7990c04ae6..8a78a86819a 100644 --- a/src/renderer/src/components/task-page/jira/use-task-page-jira-summary-generation.test.tsx +++ b/src/renderer/src/components/task-page/jira/use-task-page-jira-summary-generation.test.tsx @@ -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 - 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 - 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 - 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 - let second!: Promise - 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) diff --git a/src/renderer/src/components/task-page/linear/IssueTable.test.tsx b/src/renderer/src/components/task-page/linear/IssueTable.test.tsx index ec991124c75..806f7f723fa 100644 --- a/src/renderer/src/components/task-page/linear/IssueTable.test.tsx +++ b/src/renderer/src/components/task-page/linear/IssueTable.test.tsx @@ -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 - ) { - 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, - 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]) - } -}) diff --git a/src/renderer/src/components/task-page/linear/task-page-linear-committed-publication.test.tsx b/src/renderer/src/components/task-page/linear/task-page-linear-committed-publication.test.tsx index e898cb84628..c481a58e57f 100644 --- a/src/renderer/src/components/task-page/linear/task-page-linear-committed-publication.test.tsx +++ b/src/renderer/src/components/task-page/linear/task-page-linear-committed-publication.test.tsx @@ -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 - 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 {publication.issue?.priority} - } - const mounted = render() - 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 }>(), diff --git a/src/shared/sidebar-list/text-order.test.ts b/src/shared/sidebar-list/text-order.test.ts index 00443191425..98c87d5ee8a 100644 --- a/src/shared/sidebar-list/text-order.test.ts +++ b/src/shared/sidebar-list/text-order.test.ts @@ -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))) - } - } - }) -}) diff --git a/src/shared/sidebar-list/workspace-review-facts.test.ts b/src/shared/sidebar-list/workspace-review-facts.test.ts index 92f510d86aa..cbad9c86d10 100644 --- a/src/shared/sidebar-list/workspace-review-facts.test.ts +++ b/src/shared/sidebar-list/workspace-review-facts.test.ts @@ -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 } diff --git a/src/shared/skill-bundle-install-contract.test.ts b/src/shared/skill-bundle-install-contract.test.ts index 589b3dd3c32..7433503f228 100644 --- a/src/shared/skill-bundle-install-contract.test.ts +++ b/src/shared/skill-bundle-install-contract.test.ts @@ -21,13 +21,6 @@ function request() { } describe('skill bundle install contract', () => { - it('accepts one aggregate request with per-skill conflict decisions', () => { - expect(SkillBundleInstallRequestSchema.parse(request())).toMatchObject({ - selectedSkillIds: ['alpha', 'beta'], - conflictDecisions: [{ skillId: 'alpha', resolution: 'keep-local' }] - }) - }) - it('rejects duplicate selections and decisions for unselected skills', () => { expect(() => SkillBundleInstallRequestSchema.parse({ diff --git a/src/shared/skill-bundle-name.test.ts b/src/shared/skill-bundle-name.test.ts deleted file mode 100644 index 716e233ddf9..00000000000 --- a/src/shared/skill-bundle-name.test.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { normalizeSkillBundleName } from './skill-bundle-name' - -describe('normalizeSkillBundleName', () => { - it('creates a plugin-compatible name from a human-readable label', () => { - expect(normalizeSkillBundleName('Téam Skills -- v2.0')).toBe('team-skills-v2.0') - }) - - it('truncates without leaving a trailing separator', () => { - expect(normalizeSkillBundleName(`${'a'.repeat(63)}.suffix`)).toBe('a'.repeat(63)) - }) - - it('returns an empty name when the label has no supported characters', () => { - expect(normalizeSkillBundleName('🔥')).toBe('') - }) -}) diff --git a/src/shared/skill-delete-contract.test.ts b/src/shared/skill-delete-contract.test.ts deleted file mode 100644 index ee27ed65a56..00000000000 --- a/src/shared/skill-delete-contract.test.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { MAX_SKILL_DELETE_BATCH, SkillDeleteRequestSchema } from './skill-delete-contract' - -const SKILL = { - id: 'skill-id', - directoryPath: '/root/demo', - skillFilePath: '/root/demo/SKILL.md', - name: 'demo', - updatedAt: 1 -} - -describe('SkillDeleteRequestSchema', () => { - it('rejects an unknown top-level field rather than ignoring it', () => { - // Old hosts must reject delete-affecting unknown fields instead of silently dropping them. - expect(() => - SkillDeleteRequestSchema.parse({ operationId: 'op', skills: [SKILL], force: true }) - ).toThrow() - }) - - it('strips unknown keys inside the nested discovery target', () => { - const parsed = SkillDeleteRequestSchema.parse({ - operationId: 'op', - target: { runtime: 'host', somethingNew: 1 }, - skills: [SKILL] - }) - expect(parsed.target).toEqual({ runtime: 'host' }) - }) - - it('keeps a null updatedAt, which the host fails closed on', () => { - const parsed = SkillDeleteRequestSchema.parse({ - operationId: 'op', - skills: [{ ...SKILL, updatedAt: null }] - }) - expect(parsed.skills[0].updatedAt).toBeNull() - }) - - it('refuses an empty or oversized batch', () => { - expect(() => SkillDeleteRequestSchema.parse({ operationId: 'op', skills: [] })).toThrow() - expect(() => - SkillDeleteRequestSchema.parse({ - operationId: 'op', - skills: Array.from({ length: MAX_SKILL_DELETE_BATCH + 1 }, () => SKILL) - }) - ).toThrow() - }) -}) diff --git a/src/shared/skill-install-contract.test.ts b/src/shared/skill-install-contract.test.ts deleted file mode 100644 index a41c24f3f12..00000000000 --- a/src/shared/skill-install-contract.test.ts +++ /dev/null @@ -1,72 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { - SkillInstallRequestSchema, - SkillInstallResultSchema, - SkillPackageIdentitySchema -} from './skill-install-contract' -import { SKILL_PACKAGE_MAX_COMPRESSED_BYTES } from './skill-package-manifest' - -const packageIdentity = { - packageId: 'package_1', - versionId: 'version_1', - packageDigest: 'a'.repeat(64), - archiveSha256: 'b'.repeat(64), - compressedBytes: 128 -} - -describe('skill install wire compatibility', () => { - it('enforces compressed package bytes below, at, and above the V1 limit', () => { - expect( - SkillPackageIdentitySchema.safeParse({ - ...packageIdentity, - compressedBytes: SKILL_PACKAGE_MAX_COMPRESSED_BYTES - 1 - }).success - ).toBe(true) - expect( - SkillPackageIdentitySchema.safeParse({ - ...packageIdentity, - compressedBytes: SKILL_PACKAGE_MAX_COMPRESSED_BYTES - }).success - ).toBe(true) - expect( - SkillPackageIdentitySchema.safeParse({ - ...packageIdentity, - compressedBytes: SKILL_PACKAGE_MAX_COMPRESSED_BYTES + 1 - }).success - ).toBe(false) - }) - - it('accepts an older client request that omits every additive field', () => { - expect( - SkillInstallRequestSchema.parse({ - operationId: 'operation_1', - package: packageIdentity, - ingress: { - kind: 'download-grant', - url: 'https://storage.googleapis.com/package', - expiresAt: '2026-08-11T12:00:00.000Z' - }, - destination: { scope: 'global' } - }) - ).toMatchObject({ destination: { scope: 'global' } }) - }) - - it('accepts an older host result that omits additive failure and placement fields', () => { - expect( - SkillInstallResultSchema.parse({ - operationId: 'operation_1', - status: 'failed', - name: 'private-skill', - packageDigest: packageIdentity.packageDigest, - placements: [ - { - provider: 'claude', - path: '/host-owned/path', - topology: 'provider-alias', - status: 'failed' - } - ] - }) - ).toMatchObject({ status: 'failed', placements: [{ status: 'failed' }] }) - }) -}) diff --git a/src/shared/skill-install-failure.test.ts b/src/shared/skill-install-failure.test.ts deleted file mode 100644 index 552f51112cb..00000000000 --- a/src/shared/skill-install-failure.test.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { - SKILL_INSTALL_BUSY_FAILURE, - SKILL_INSTALL_CANCELLED_FAILURE, - SkillInstallFailureSchema -} from './skill-install-failure' - -describe('skill install failure contract', () => { - it('keeps retryability explicit and rejects unstable codes', () => { - expect(SkillInstallFailureSchema.parse(SKILL_INSTALL_BUSY_FAILURE).retryable).toBe(true) - expect(SkillInstallFailureSchema.parse(SKILL_INSTALL_CANCELLED_FAILURE).category).toBe( - 'cancelled' - ) - expect(() => - SkillInstallFailureSchema.parse({ - category: 'filesystem', - code: 'EBUSY: /private/path', - retryable: true - }) - ).toThrow() - }) -}) diff --git a/src/shared/skill-metadata.test.ts b/src/shared/skill-metadata.test.ts index 5278fbcd1c8..f2a168dc24e 100644 --- a/src/shared/skill-metadata.test.ts +++ b/src/shared/skill-metadata.test.ts @@ -18,18 +18,6 @@ description: >- description: 'Use the orca CLI to drive a running editor; keep worktree comments current.' }) }) - - it('falls back to heading and first paragraph when frontmatter is absent', () => { - const summary = summarizeSkillMarkdown(`# Design Review - -Use when reviewing UI implementation quality. -`) - - expect(summary).toEqual({ - name: 'Design Review', - description: 'Use when reviewing UI implementation quality.' - }) - }) }) it.each(['', '\uFEFF'])('preserves the last CRLF frontmatter field with BOM %j', (bom) => { diff --git a/src/shared/skill-package-manifest.test.ts b/src/shared/skill-package-manifest.test.ts index b793ad96176..a7ceadd6837 100644 --- a/src/shared/skill-package-manifest.test.ts +++ b/src/shared/skill-package-manifest.test.ts @@ -84,20 +84,6 @@ describe('skill package manifest', () => { expect(() => validateSkillPackagePath(path(17))).toThrow('skill-package-path-invalid') }) - it('enforces file count at, below, and above the V1 limit', () => { - const files = (count: number): SkillPackageFile[] => [ - file, - ...Array.from({ length: count - 1 }, (_, index) => - packageFile(`file-${index.toString().padStart(3, '0')}.md`) - ) - ] - expect(() => parseSkillPackageManifest(manifest(files(511)))).not.toThrow() - expect(() => parseSkillPackageManifest(manifest(files(512)))).not.toThrow() - expect(() => parseSkillPackageManifest(manifest(files(513)))).toThrow( - 'skill-package-manifest-invalid' - ) - }) - it('enforces per-file and total extracted bytes around both V1 limits', () => { const fourMiB = 4 * 1024 * 1024 expect(() => @@ -129,18 +115,4 @@ describe('skill package manifest', () => { ) ).toThrow('skill-package-case-collision') }) - - it('fuzzes path normalization without accepting traversal or platform syntax', () => { - let state = 0x5eed - const random = (): number => { - state = (state * 1103515245 + 12345) & 0x7fffffff - return state - } - const hazards = ['..', '.', '', 'C:', '\\server', 'nul', 'x.', 'x ', 'x\u0000y'] - for (let index = 0; index < 1_000; index += 1) { - const hazard = hazards[random() % hazards.length] - const path = `safe/${hazard}/file-${random()}.md` - expect(() => validateSkillPackagePath(path)).toThrow('skill-package-path-invalid') - } - }) }) diff --git a/src/shared/skill-path-containment.test.ts b/src/shared/skill-path-containment.test.ts index b5e2646b7f2..706357a8302 100644 --- a/src/shared/skill-path-containment.test.ts +++ b/src/shared/skill-path-containment.test.ts @@ -1,7 +1,6 @@ import { describe, expect, it } from 'vitest' import { POSIX_SKILL_PATH_SEMANTICS, - skillPathDepthBelow, skillPathInside, skillPathsEqual, WINDOWS_SKILL_PATH_SEMANTICS @@ -31,16 +30,6 @@ describe('skillPathInside', () => { ).toBe(false) }) - it('accepts a child', () => { - expect( - skillPathInside( - '/home/u/.claude/skills', - '/home/u/.claude/skills/foo', - POSIX_SKILL_PATH_SEMANTICS - ) - ).toBe(true) - }) - it('keeps POSIX case-sensitive even when the process is win32', () => { // A WSL runtime is POSIX whatever `process.platform` says. expect( @@ -64,11 +53,3 @@ describe('skillPathInside', () => { expect(skillPathInside('/root', '/root/..cache', POSIX_SKILL_PATH_SEMANTICS)).toBe(true) }) }) - -describe('skillPathDepthBelow', () => { - it('counts segments and returns null outside the root', () => { - expect(skillPathDepthBelow('/r', '/r/a/SKILL.md', POSIX_SKILL_PATH_SEMANTICS)).toBe(2) - expect(skillPathDepthBelow('/r', '/r/a/b/c/d/SKILL.md', POSIX_SKILL_PATH_SEMANTICS)).toBe(5) - expect(skillPathDepthBelow('/r', '/other/SKILL.md', POSIX_SKILL_PATH_SEMANTICS)).toBeNull() - }) -}) diff --git a/src/shared/source-scan/source-tree-scan.test.ts b/src/shared/source-scan/source-tree-scan.test.ts index 50b727ec71d..c79e7d8f838 100644 --- a/src/shared/source-scan/source-tree-scan.test.ts +++ b/src/shared/source-scan/source-tree-scan.test.ts @@ -10,22 +10,6 @@ describe('stripComments', () => { expect(stripComments(source)).toContain('case "$root"/*/home)') expect(stripComments(source)).toContain('spawn(x)') }) - - it('is not derailed by an apostrophe in prose', () => { - // A prose apostrophe once swallowed the rest of the file and disabled later comment stripping. - const source = ["// don't do this", '/* spawn(bad) */', 'spawn(good)'].join('\n') - expect(stripComments(source)).not.toContain('bad') - }) - - it('is not derailed by a quote inside a regex literal', () => { - const source = ['const re = /[\'"]/', '/* spawn(bad) */', 'spawn(good)'].join('\n') - expect(stripComments(source)).not.toContain('bad') - }) - - it('handles an escaped quote inside a string', () => { - const source = ["const s = 'it\\'s'", '/* spawn(bad) */'].join('\n') - expect(stripComments(source)).not.toContain('bad') - }) }) describe('regex literals', () => { diff --git a/src/shared/source-scan/source-tree-walk.test.ts b/src/shared/source-scan/source-tree-walk.test.ts index e56c8a9d34c..18451ae8c64 100644 --- a/src/shared/source-scan/source-tree-walk.test.ts +++ b/src/shared/source-scan/source-tree-walk.test.ts @@ -11,7 +11,7 @@ import type * as Fs from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { directoryEntryNeedsStat, scanSourceTree } from './source-tree-scan' +import { scanSourceTree } from './source-tree-scan' vi.mock('node:fs', async (importOriginal) => { const actual = await importOriginal() @@ -118,16 +118,3 @@ describe('scanSourceTree filesystem traversal', () => { expect(statSync).toHaveBeenCalledExactlyOnceWith(join(root, 'alias')) }) }) - -describe('directoryEntryNeedsStat', () => { - const probe = (kind: 'file' | 'dir' | 'link' | 'unknown') => ({ - isFile: () => kind === 'file', - isDirectory: () => kind === 'dir', - isSymbolicLink: () => kind === 'link' - }) - - // DT_UNKNOWN needs stat or an entire directory subtree can disappear from guards. - it('stats an entry whose type readdir could not report', () => { - expect(directoryEntryNeedsStat(probe('unknown'))).toBe(true) - }) -}) diff --git a/src/shared/ssh-remote-pty-lease-predicates.test.ts b/src/shared/ssh-remote-pty-lease-predicates.test.ts deleted file mode 100644 index 7cff18bd550..00000000000 --- a/src/shared/ssh-remote-pty-lease-predicates.test.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { isLiveSshPtyLease } from './ssh-types' - -describe('isLiveSshPtyLease', () => { - it.each([ - ['attached', true], - ['detached', true], - ['expired', false], - ['terminated', false] - ] as const)('reads a %s lease as live: %s', (state, live) => { - expect(isLiveSshPtyLease({ state })).toBe(live) - }) -}) diff --git a/src/shared/ssh-retained-payload-admission.test.ts b/src/shared/ssh-retained-payload-admission.test.ts index eb42012770e..1c2aeb52a4e 100644 --- a/src/shared/ssh-retained-payload-admission.test.ts +++ b/src/shared/ssh-retained-payload-admission.test.ts @@ -56,28 +56,6 @@ describe('SSH retained payload admission', () => { expect(admit('nope')).toEqual({ kind: 'managed', environmentId: 'env-1' }) }) - it('caps an unverifiable serving detail like its sibling details', () => { - const managedServer = admitSshConnectionState( - { - targetId: 'ssh-a', - status: 'connected', - error: null, - reconnectAttempt: 0, - managedServer: { - kind: 'managed', - environmentId: 'env-1', - serving: { - state: 'unverifiable', - detail: 'x'.repeat(SSH_CONNECTION_ERROR_MAX_UTF8_BYTES * 4) - } - } - }, - 'ssh-a' - )?.managedServer - const detail = managedServer?.kind === 'managed' ? managedServer.serving?.detail : undefined - expect(getUtf8ByteLength(detail ?? '')).toBe(SSH_CONNECTION_ERROR_MAX_UTF8_BYTES) - }) - it('admits the plain SSH mode and drops a malformed one without dropping the state', () => { const state = { targetId: 'ssh-a', status: 'connected', error: null, reconnectAttempt: 0 } const plainSsh = { reason: 'home_noexec', message: 'Home is noexec.' } diff --git a/src/shared/ssh-target-generation.test.ts b/src/shared/ssh-target-generation.test.ts deleted file mode 100644 index 20fb58bb199..00000000000 --- a/src/shared/ssh-target-generation.test.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { - nextSshTargetGeneration, - resolveSshTargetGenerationHighWaterMark -} from './ssh-target-generation' - -describe('resolveSshTargetGenerationHighWaterMark', () => { - it('never reissues a generation an automation already captured', () => { - // A rolled-back counter cannot reissue an automation’s captured generation. - const highWaterMark = resolveSshTargetGenerationHighWaterMark({ - persistedCounter: 2, - targetGenerations: [1, 2], - capturedGenerations: [9] - }) - expect(highWaterMark).toBe(9) - expect(nextSshTargetGeneration(highWaterMark)).toBe(10) - }) - - it('never reissues a generation a stored target already carries', () => { - expect( - resolveSshTargetGenerationHighWaterMark({ - persistedCounter: 0, - targetGenerations: [7], - capturedGenerations: [] - }) - ).toBe(7) - }) - - it('keeps the persisted counter when it leads', () => { - expect( - resolveSshTargetGenerationHighWaterMark({ - persistedCounter: 12, - targetGenerations: [3], - capturedGenerations: [5] - }) - ).toBe(12) - }) - - it('ignores corrupt values on every input', () => { - expect( - resolveSshTargetGenerationHighWaterMark({ - persistedCounter: Number.NaN, - targetGenerations: [undefined, -4, 2.5], - capturedGenerations: [null] - }) - ).toBe(0) - expect(nextSshTargetGeneration(0)).toBe(1) - }) -}) diff --git a/src/shared/ssh-target-source.test.ts b/src/shared/ssh-target-source.test.ts deleted file mode 100644 index f81929c6c47..00000000000 --- a/src/shared/ssh-target-source.test.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { isLegacyConfigImportTarget, isSshConfigSourcedTarget } from './ssh-target-source' - -describe('isSshConfigSourcedTarget', () => { - it('trusts an explicit source', () => { - expect(isSshConfigSourcedTarget({ source: 'ssh-config', label: 'a', host: 'a' })).toBe(true) - expect( - isSshConfigSourcedTarget({ - source: 'manual', - label: 'box', - configHost: 'box', - host: '10.0.0.5' - }) - ).toBe(false) - }) - - it('counts a legacy target as imported only in the old import shape', () => { - expect(isLegacyConfigImportTarget({ label: 'box', configHost: 'box', host: '10.0.0.5' })).toBe( - true - ) - // Alias equals the host: nothing was resolved, so it may be a hand-made target. - expect(isLegacyConfigImportTarget({ label: 'box', configHost: 'box', host: 'box' })).toBe(false) - expect(isSshConfigSourcedTarget({ label: 'My box', host: 'box.example.test' })).toBe(false) - }) -}) diff --git a/src/shared/startup-command-staging.test.ts b/src/shared/startup-command-staging.test.ts index 7e51b91fd71..220ee942487 100644 --- a/src/shared/startup-command-staging.test.ts +++ b/src/shared/startup-command-staging.test.ts @@ -32,27 +32,6 @@ function stage(command: string, shellPath = '/bin/zsh', platform: NodeJS.Platfor } describe('stageStartupCommand', () => { - it('types exactly 512 bytes, stages 513', () => { - expect(stage('a'.repeat(512)).delivery).toBe('typed') - expect(stage('a'.repeat(513)).delivery).toBe('staged') - }) - - it('measures bytes, not characters', () => { - expect(stage('é'.repeat(257)).delivery).toBe('staged') - }) - - it('ignores a trailing submit byte when measuring', () => { - expect(stage(`${'a'.repeat(512)}\n`).delivery).toBe('typed') - expect(stage(`${'a'.repeat(512)}\r\n`).delivery).toBe('typed') - }) - - it.each([['\n'], ['\r'], ['\t'], ['\x1b'], ['\x03'], ['\x7f']])( - 'stages a short line carrying control byte %j', - (byte) => { - expect(stage(`claude 'a${byte}b'`).delivery).toBe('staged') - } - ) - it('writes a 0600 script whose body is exactly the command, and types a sourcing line', () => { const command = `claude '${'x'.repeat(600)}'` const staged = stage(`${command}\n`) @@ -64,100 +43,6 @@ describe('stageStartupCommand', () => { expect(readFileSync(scriptPath, 'utf8')).toBe(`command rm -f -- '${scriptPath}'\n${command}\n`) }) - // Why: ksh runs a sourced file's commands in the shell's own process group, so Ctrl-Z is lost. - it.each([['/bin/ksh'], ['/usr/bin/mksh']])( - 'runs a long Orca-built line through /bin/sh in %s, and types a short one as is', - (shellPath) => { - const staged = stageStartupCommand({ - command: `claude '${'x'.repeat(600)}'`, - shellPath, - orcaBuiltLine: true, - platform: 'darwin', - directory - }) - expect(staged.command).toBe(`/bin/sh '${staged.scriptPath}'`) - const command = `claude 'wow!! great'` - expect( - stageStartupCommand({ - command, - shellPath, - orcaBuiltLine: true, - platform: 'darwin', - directory - }) - ).toEqual({ command, delivery: 'typed' }) - } - ) - - it.each([['/bin/ksh'], ['/usr/bin/mksh']])( - 'types a long command the user wrote as it always was in %s', - (shellPath) => { - const command = 'a'.repeat(600) - expect(stage(command, shellPath)).toEqual({ command, delivery: 'typed' }) - expect(readdirSync(directory)).toEqual([]) - } - ) - - // Measured: tcsh typed `'fix C:'"\\"'dir'` as `fix C:\\dir` and expanded `!!` in `'wow!! great'`. - it.each([['/bin/tcsh'], ['/usr/bin/nu'], [undefined]])( - 'stages even a short Orca-built line for a shell that cannot source it (%s)', - (shellPath) => { - const command = `claude 'wow!! great'` - const staged = stageStartupCommand({ - command, - shellPath, - orcaBuiltLine: true, - platform: 'darwin', - directory - }) - expect(staged.command).toBe(`/bin/sh '${staged.scriptPath}'`) - expect(readFileSync(staged.scriptPath!, 'utf8')).toContain(`\n${command}\n`) - } - ) - - // Why: behind `/bin/sh` the agent is not the shell's own job, and a pasted prompt waits on that. - it.each([['/bin/tcsh'], ['/usr/bin/nu'], [undefined]])( - 'types a short plain Orca-built line as is for a shell that cannot source it (%s)', - (shellPath) => { - const command = `claude '--dangerously-skip-permissions' '--model' 'claude-opus-5-5[1m]'` - expect( - stageStartupCommand({ - command, - shellPath, - orcaBuiltLine: true, - platform: 'darwin', - directory - }) - ).toEqual({ command, delivery: 'typed' }) - expect(readdirSync(directory)).toEqual([]) - } - ) - - it.each([[`claude 'it'\\''s'`], ['claude "x"'], ["claude '$HOME'"], ["claude 'a`b'"]])( - 'stages a short Orca-built line tcsh would not read literally: %s', - (command) => { - const staged = stageStartupCommand({ - command, - shellPath: '/bin/tcsh', - orcaBuiltLine: true, - platform: 'darwin', - directory - }) - expect(staged.command).toBe(`/bin/sh '${staged.scriptPath}'`) - } - ) - - // Why: a quick command or --command written for tcsh or nu is not sh syntax. - it.each([['/bin/tcsh'], ['/usr/bin/nu'], [undefined]])( - 'types a command the user wrote as it always was in a shell that cannot source it (%s)', - (shellPath) => { - const command = `foreach f (*.log)\n echo $f\nend` - const staged = stageStartupCommand({ command, shellPath, platform: 'darwin', directory }) - expect(staged).toEqual({ command, delivery: 'typed' }) - expect(readdirSync(directory)).toEqual([]) - } - ) - it('types the full command and says so when the script cannot be written', () => { const command = 'a'.repeat(600) const staged = stageStartupCommand({ diff --git a/src/shared/startup-command-submission.test.ts b/src/shared/startup-command-submission.test.ts deleted file mode 100644 index e4889e89dc4..00000000000 --- a/src/shared/startup-command-submission.test.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { buildStartupCommandSubmission } from './startup-command-submission' - -describe('buildStartupCommandSubmission', () => { - it('replaces a caller-supplied trailing terminator with CR', () => { - for (const command of ['claude\n', 'claude\r', 'claude\r\n']) { - expect(buildStartupCommandSubmission(command, { bracketedPasteSafe: true })).toBe('claude\r') - } - }) - - it('wraps a multiline command in bracketed paste with a trailing submit byte', () => { - const command = "claude 'first\nsecond'" - expect(buildStartupCommandSubmission(command, { bracketedPasteSafe: true })).toBe( - `\x1b[200~${command}\x1b[201~\r` - ) - }) - - it('strips a trailing submit byte before bracket-wrapping the multiline body', () => { - const body = "claude 'first\nsecond'" - expect(buildStartupCommandSubmission(`${body}\n`, { bracketedPasteSafe: true })).toBe( - `\x1b[200~${body}\x1b[201~\r` - ) - }) - - // Interior LF is remappable Ctrl+J too; every raw line must submit with CR (#23250). - it('submits every line of a raw multiline command with CR', () => { - for (const command of ['echo one\necho two', 'echo one\r\necho two', 'echo one\recho two']) { - expect(buildStartupCommandSubmission(command, { bracketedPasteSafe: false })).toBe( - 'echo one\recho two\r' - ) - } - }) -}) diff --git a/src/shared/status-bar-compact-change-notice.test.ts b/src/shared/status-bar-compact-change-notice.test.ts deleted file mode 100644 index 1e4dfafaeaa..00000000000 --- a/src/shared/status-bar-compact-change-notice.test.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { resolveStatusBarCompactChangeNoticeDismissed } from './status-bar-compact-change-notice' - -describe('Compact change notice audience', () => { - it.each([ - { rawUsageMode: undefined, isExistingProfile: false, dismissed: true }, - { rawUsageMode: undefined, isExistingProfile: true, dismissed: false }, - { rawUsageMode: null, isExistingProfile: true, dismissed: false }, - { rawUsageMode: 'expanded', isExistingProfile: true, dismissed: false }, - { rawUsageMode: 'verbose', isExistingProfile: true, dismissed: true }, - { rawUsageMode: 'compact', isExistingProfile: true, dismissed: true } - ])('decides before filling defaults: %o', ({ rawUsageMode, isExistingProfile, dismissed }) => { - expect( - resolveStatusBarCompactChangeNoticeDismissed({ - rawDismissed: undefined, - rawUsageMode, - isExistingProfile - }) - ).toBe(dismissed) - }) - - it.each([true, false])('preserves a previously decided dismissal of %s', (rawDismissed) => { - expect( - resolveStatusBarCompactChangeNoticeDismissed({ - rawDismissed, - rawUsageMode: 'compact', - isExistingProfile: true - }) - ).toBe(rawDismissed) - }) -}) diff --git a/src/shared/status-bar-usage-mode.test.ts b/src/shared/status-bar-usage-mode.test.ts deleted file mode 100644 index 8d387a4346c..00000000000 --- a/src/shared/status-bar-usage-mode.test.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { normalizeStatusBarUsageMode } from './status-bar-usage-mode' - -describe('normalizeStatusBarUsageMode', () => { - it.each([undefined, null, '', 'expanded', 0, false, {}])( - 'defaults missing or invalid value %j to compact', - (value) => { - expect(normalizeStatusBarUsageMode(value)).toBe('compact') - } - ) - - it('preserves supported modes', () => { - expect(normalizeStatusBarUsageMode('verbose')).toBe('verbose') - expect(normalizeStatusBarUsageMode('compact')).toBe('compact') - }) -}) diff --git a/src/shared/structural-value-equality.test.ts b/src/shared/structural-value-equality.test.ts index f2df7b15b9a..baef04f083f 100644 --- a/src/shared/structural-value-equality.test.ts +++ b/src/shared/structural-value-equality.test.ts @@ -55,11 +55,6 @@ describe('structuralValuesEqual', () => { expect(structuralValuesEqual({ a: 1, b: undefined }, { a: 1, c: undefined })).toBe(false) expect(structuralValuesEqual({ a: 1, b: undefined }, { a: 1, b: undefined })).toBe(true) }) - - it('compares leaves with === so NaN is never equal and -0 matches 0', () => { - expect(structuralValuesEqual({ a: Number.NaN }, { a: Number.NaN })).toBe(false) - expect(structuralValuesEqual({ a: 0 }, { a: -0 })).toBe(true) - }) }) describe('structuralValuesEqualIgnoringUndefined', () => { @@ -72,9 +67,4 @@ describe('structuralValuesEqualIgnoringUndefined', () => { ).toBe(true) expect(structuralValuesEqualIgnoringUndefined({ a: 1 }, { a: 1, b: 2 })).toBe(false) }) - - it('compares leaves with Object.is so NaN is equal and -0 differs from 0', () => { - expect(structuralValuesEqualIgnoringUndefined({ a: Number.NaN }, { a: Number.NaN })).toBe(true) - expect(structuralValuesEqualIgnoringUndefined({ a: 0 }, { a: -0 })).toBe(false) - }) }) diff --git a/src/shared/svg-document.test.ts b/src/shared/svg-document.test.ts index 81c507f527f..56382fd3bfd 100644 --- a/src/shared/svg-document.test.ts +++ b/src/shared/svg-document.test.ts @@ -15,11 +15,6 @@ describe('readSvgIntrinsicSize', () => { ])('sizes %s', (markup, size) => { expect(readSvgIntrinsicSize(markup)).toEqual(size) }) - - it('leaves an SVG with no usable size to the browser', () => { - expect(readSvgIntrinsicSize('')).toBeNull() - expect(readSvgIntrinsicSize('not svg')).toBeNull() - }) }) describe('decodeSvgBase64', () => { diff --git a/src/shared/tab-has-live-pty.test.ts b/src/shared/tab-has-live-pty.test.ts deleted file mode 100644 index 37a057f002b..00000000000 --- a/src/shared/tab-has-live-pty.test.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { tabHasLivePty } from './tab-has-live-pty' - -describe('tabHasLivePty', () => { - it('returns false for a missing tab id', () => { - expect(tabHasLivePty({ 'other-tab': ['pty-1'] }, 'tab-1')).toBe(false) - }) - - it('returns false for a tab whose live-pty array is empty (sleep / pre-spawn)', () => { - // Sleep empties ptyIdsByTabId but retains tab.ptyId solely as a wake/history hint. - expect(tabHasLivePty({ 'tab-1': [] }, 'tab-1')).toBe(false) - }) - - it('returns true for a tab with at least one live pty id', () => { - expect(tabHasLivePty({ 'tab-1': ['pty-1'] }, 'tab-1')).toBe(true) - }) - - it('returns true for a tab with multiple live pty ids (split panes)', () => { - expect(tabHasLivePty({ 'tab-1': ['pty-1', 'pty-2'] }, 'tab-1')).toBe(true) - }) -}) diff --git a/src/shared/tailnet-address.test.ts b/src/shared/tailnet-address.test.ts deleted file mode 100644 index 74d69b1238f..00000000000 --- a/src/shared/tailnet-address.test.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { isTailnetIPv4Address } from './tailnet-address' - -describe('isTailnetIPv4Address', () => { - it('accepts the tailnet IPv4 allocation range', () => { - expect(isTailnetIPv4Address('100.64.0.1')).toBe(true) - expect(isTailnetIPv4Address('100.102.47.57')).toBe(true) - expect(isTailnetIPv4Address('100.127.255.254')).toBe(true) - }) - - it('rejects non-tailnet IPv4 addresses and malformed input', () => { - expect(isTailnetIPv4Address('100.63.255.255')).toBe(false) - expect(isTailnetIPv4Address('100.128.0.1')).toBe(false) - expect(isTailnetIPv4Address('192.168.1.24')).toBe(false) - expect(isTailnetIPv4Address('fd7a:115c:a1e0::ce33:2f3a')).toBe(false) - expect(isTailnetIPv4Address('100.102.47')).toBe(false) - }) -}) diff --git a/src/shared/task-providers.test.ts b/src/shared/task-providers.test.ts deleted file mode 100644 index 337af16987b..00000000000 --- a/src/shared/task-providers.test.ts +++ /dev/null @@ -1,102 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { - filterAvailableTaskProviders, - normalizeTaskProviderSettings, - restoreAvailableDefaultTaskProvider -} from './task-providers' - -describe('task providers', () => { - it('restores a valid saved default when provider settings drifted', () => { - expect( - normalizeTaskProviderSettings({ - visibleTaskProviders: ['linear'], - defaultTaskSource: 'github' - }) - ).toEqual({ - defaultTaskSource: 'github', - visibleTaskProviders: ['github', 'linear'] - }) - }) - - it('normalizes invalid saved defaults to the first visible provider', () => { - expect( - normalizeTaskProviderSettings({ - visibleTaskProviders: ['gitlab'], - defaultTaskSource: 'bitbucket' - }) - ).toEqual({ - defaultTaskSource: 'gitlab', - visibleTaskProviders: ['gitlab'] - }) - }) - - it('filters runtime-unavailable providers without changing preference normalization', () => { - expect( - filterAvailableTaskProviders(['github', 'gitlab', 'linear'], { - gitlabInstalled: false, - linearConnected: true - }) - ).toEqual(['github', 'linear']) - }) - - it('keeps an available saved default visible when provider visibility drifted', () => { - expect( - restoreAvailableDefaultTaskProvider( - ['linear'], - { - gitlabInstalled: false, - linearConnected: true - }, - 'github' - ) - ).toEqual(['github', 'linear']) - }) - - it('preserves intentionally narrowed providers when the saved default matches them', () => { - expect( - restoreAvailableDefaultTaskProvider( - ['linear'], - { - gitlabInstalled: false, - linearConnected: true - }, - 'linear' - ) - ).toEqual(['linear']) - }) - - it('does not restore an unavailable saved default', () => { - expect( - restoreAvailableDefaultTaskProvider( - ['linear'], - { - gitlabInstalled: false, - linearConnected: true - }, - 'gitlab' - ) - ).toEqual(['linear']) - }) - - it('ignores invalid saved defaults while restoring visible providers', () => { - expect( - restoreAvailableDefaultTaskProvider( - ['gitlab'], - { - gitlabInstalled: false, - linearConnected: true - }, - 'bitbucket' - ) - ).toEqual(['github']) - }) - - it('falls back to GitHub when every preferred provider is unavailable', () => { - expect( - filterAvailableTaskProviders(['gitlab', 'linear'], { - gitlabInstalled: false, - linearConnected: false - }) - ).toEqual(['github']) - }) -}) diff --git a/src/shared/task-query.test.ts b/src/shared/task-query.test.ts index 323408ea6ba..3de2bb8a10a 100644 --- a/src/shared/task-query.test.ts +++ b/src/shared/task-query.test.ts @@ -23,11 +23,6 @@ describe('hasExplicitTaskScope', () => { }) describe('parseTaskQuery', () => { - it('widens scope to all when both is:issue and is:pr are present', () => { - const parsed = parseTaskQuery('is:issue is:pr') - expect(parsed.scope).toBe('all') - }) - it('widens scope to all regardless of issue and PR token order', () => { const parsed = parseTaskQuery('is:pr is:issue') expect(parsed.scope).toBe('all') @@ -60,10 +55,6 @@ describe('parseTaskQuery', () => { }) describe('stripRepoQualifiers', () => { - it('removes repo:owner/name tokens', () => { - expect(stripRepoQualifiers('is:open repo:foo/bar assignee:@me')).toBe('is:open assignee:@me') - }) - it('re-quotes a standalone token that contains whitespace', () => { // Re-quote standalone phrases or serialization would split them into separate search tokens. const stripped = stripRepoQualifiers('"needs review" repo:x/y') @@ -95,12 +86,6 @@ describe('withQualifier', () => { expect(parseTaskQuery(cleared).freeText).toBe('hello') }) - it('sets the all state filter', () => { - const result = withQualifier('is:pr is:open', 'state', 'all') - expect(parseTaskQuery(result).state).toBe('all') - expect(result).toContain('state:all') - }) - it('preserves quoted free-text tokens when applying a filter', () => { const result = withQualifier('"exact phrase" milestone:"next release"', 'author', 'alice') expect(result).toContain('"exact phrase"') diff --git a/src/shared/telemetry-agent-dashboard-event-schemas.test.ts b/src/shared/telemetry-agent-dashboard-event-schemas.test.ts deleted file mode 100644 index f12588c8744..00000000000 --- a/src/shared/telemetry-agent-dashboard-event-schemas.test.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { eventSchemas } from './telemetry-events' - -describe('Agent Dashboard telemetry schemas', () => { - it('accepts only the adoption and rendered-surface payloads', () => { - expect(eventSchemas.agent_dashboard_enabled.safeParse({ enabled: true }).success).toBe(true) - expect(eventSchemas.agent_dashboard_opened.safeParse({ surface: 'popout' }).success).toBe(true) - expect(eventSchemas.agent_dashboard_opened.safeParse({ surface: 'in_window' }).success).toBe( - true - ) - expect(eventSchemas.agent_dashboard_active.safeParse({ surface: 'popout' }).success).toBe(true) - expect(eventSchemas.agent_dashboard_active.safeParse({ surface: 'in_window' }).success).toBe( - true - ) - }) - - it('rejects unbounded or ambiguous dashboard telemetry', () => { - expect(eventSchemas.agent_dashboard_enabled.safeParse({}).success).toBe(false) - expect(eventSchemas.agent_dashboard_opened.safeParse({ surface: 'sidebar' }).success).toBe( - false - ) - expect( - eventSchemas.agent_dashboard_opened.safeParse({ surface: 'popout', card: 'task' }).success - ).toBe(false) - expect(eventSchemas.agent_dashboard_active.safeParse({ surface: 'sidebar' }).success).toBe( - false - ) - expect( - eventSchemas.agent_dashboard_active.safeParse({ surface: 'popout', day: 123 }).success - ).toBe(false) - }) -}) diff --git a/src/shared/telemetry-common-props.test.ts b/src/shared/telemetry-common-props.test.ts deleted file mode 100644 index e8eff984141..00000000000 --- a/src/shared/telemetry-common-props.test.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { commonPropsSchema } from './telemetry-events' - -describe('commonPropsSchema', () => { - it('round-trips a realistic payload', () => { - const parsed = commonPropsSchema.safeParse({ - app_version: '1.3.33', - platform: 'darwin', - arch: 'arm64', - os_release: '25.3.0', - install_id: '00000000-0000-4000-8000-000000000000', - session_id: 'ffffffff-ffff-4fff-8fff-ffffffffffff', - orca_channel: 'stable' - }) - expect(parsed.success).toBe(true) - }) - - it('rejects strings past the 64-char cap', () => { - const parsed = commonPropsSchema.safeParse({ - app_version: 'x'.repeat(65), - platform: 'darwin', - arch: 'arm64', - os_release: '25.3.0', - install_id: '00000000-0000-4000-8000-000000000000', - session_id: 'ffffffff-ffff-4fff-8fff-ffffffffffff', - orca_channel: 'stable' - }) - expect(parsed.success).toBe(false) - }) - - // Empty IDs would collapse attribution across installs/process lifetimes. - it('rejects empty analytics identities', () => { - const basePayload = { - app_version: '1.3.33', - platform: 'darwin', - arch: 'arm64', - os_release: '25.3.0', - orca_channel: 'stable' - } - - expect( - commonPropsSchema.safeParse({ - ...basePayload, - install_id: '', - session_id: 'ffffffff-ffff-4fff-8fff-ffffffffffff' - }).success - ).toBe(false) - expect( - commonPropsSchema.safeParse({ - ...basePayload, - install_id: '00000000-0000-4000-8000-000000000000', - session_id: '' - }).success - ).toBe(false) - }) -}) diff --git a/src/shared/telemetry-workspace-create-schemas.test.ts b/src/shared/telemetry-workspace-create-schemas.test.ts deleted file mode 100644 index fcd3dcfdf44..00000000000 --- a/src/shared/telemetry-workspace-create-schemas.test.ts +++ /dev/null @@ -1,141 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { eventSchemas } from './telemetry-event-registry' - -const created = eventSchemas.workspace_created -const failed = eventSchemas.workspace_create_failed - -const fullCreatedPayload = { - source: 'command_palette', - from_existing_branch: false, - nth_repo_added: 3, - total_ms: 8_412, - unattributed_ms: 311, - refresh_base_ref_ms: 2_040, - git_worktree_add_ms: 4_900, - prepared_checkout_claim_ms: 12, - prepared_checkout_wait_ms: 3_100, - prepared_checkout_finalize_ms: 120, - list_created_worktree_ms: 640, - spawn_startup_terminal_ms: 90, - prepared_checkout: 'hit', - prepared_checkout_reset: 'retargeted', - prepared_checkout_origin: 'rearm_then_prefetch', - prepared_checkout_build_ms: 41_000, - prepared_checkout_idle_ms: 0, - create_entry_point: 'runtime', - execution_host: 'wsl', - worktree_count_bucket: '301-1000', - repo_file_count_bucket: '100k-500k', - concurrent_creates: 2, - concurrent_preparations: 1, - post_checkout_hook: 'present' -} - -describe('workspace_created timing fields', () => { - it('still accepts the payload that predates the timing fields', () => { - expect(created.safeParse({ source: 'sidebar', from_existing_branch: true }).success).toBe(true) - }) - - it('accepts a full payload', () => { - expect(created.safeParse(fullCreatedPayload).success).toBe(true) - }) - - it('accepts a miss with its reason', () => { - const parsed = created.safeParse({ - source: 'sidebar', - from_existing_branch: false, - prepared_checkout: 'miss', - prepared_checkout_miss_reason: 'none_armed' - }) - expect(parsed.success).toBe(true) - }) - - it.each([ - ['repo_path', '/Users/alice/secret-repo'], - ['branch', 'alice/feature'], - ['base_ref', 'origin/main'], - ['hook_content', '#!/bin/sh\ngit lfs post-checkout'], - ['fetch_ms', 10] - ])('rejects the unknown key %s via .strict()', (key, value) => { - expect(created.safeParse({ ...fullCreatedPayload, [key]: value }).success).toBe(false) - }) - - it.each([ - ['total_ms', '8412'], - ['total_ms', -1], - ['git_worktree_add_ms', 1.5], - ['concurrent_creates', -1], - ['concurrent_preparations', 0.5], - ['prepared_checkout', 'maybe'], - ['prepared_checkout_reset', 'hard'], - ['prepared_checkout_origin', 'dialog'], - ['create_entry_point', 'cli'], - ['repo_file_count_bucket', '12000'], - ['prepared_checkout_idle_ms', -5], - ['prepared_checkout_miss_reason', 'the branch alice/feature was missing'], - ['execution_host', 'docker'], - ['worktree_count_bucket', '812'], - ['post_checkout_hook', 'lfs'] - ])('rejects %s = %j', (key, value) => { - expect(created.safeParse({ ...fullCreatedPayload, [key]: value }).success).toBe(false) - }) -}) - -describe('workspace_create_failed timing fields', () => { - it('still accepts the payload that predates the timing fields', () => { - expect(failed.safeParse({ source: 'sidebar', error_class: 'git_failed' }).success).toBe(true) - }) - - it('accepts the failing phase and elapsed time', () => { - const parsed = failed.safeParse({ - source: 'sidebar', - error_class: 'git_failed', - failed_phase: 'git_worktree_add', - total_ms: 120_000, - create_entry_point: 'app', - execution_host: 'ssh', - concurrent_creates: 0, - concurrent_preparations: 0 - }) - expect(parsed.success).toBe(true) - }) - - it('accepts the prepared-checkout outcome and wait of the failed create', () => { - const parsed = failed.safeParse({ - source: 'sidebar', - error_class: 'git_failed', - failed_phase: 'git_worktree_add', - prepared_checkout: 'miss', - prepared_checkout_miss_reason: 'finalize_failed', - prepared_checkout_wait_ms: 209_000 - }) - expect(parsed.success).toBe(true) - }) - - it('accepts a failure outside every timed phase', () => { - const parsed = failed.safeParse({ - source: 'sidebar', - error_class: 'unknown', - failed_phase: 'untimed' - }) - expect(parsed.success).toBe(true) - }) - - it('rejects a phase outside the closed vocabulary', () => { - const parsed = failed.safeParse({ - source: 'sidebar', - error_class: 'git_failed', - failed_phase: 'fatal: could not create work tree' - }) - expect(parsed.success).toBe(false) - }) - - it.each([ - ['post_checkout_hook', 'present'], - ['repo_file_count_bucket', '<1k'], - ['git_worktree_add_ms', 10] - ])('rejects the success-only field %s via .strict()', (key, value) => { - const parsed = failed.safeParse({ source: 'sidebar', error_class: 'git_failed', [key]: value }) - expect(parsed.success).toBe(false) - }) -}) diff --git a/src/shared/terminal-themes/terminal-custom-themes.test.ts b/src/shared/terminal-themes/terminal-custom-themes.test.ts deleted file mode 100644 index b4a82f34a53..00000000000 --- a/src/shared/terminal-themes/terminal-custom-themes.test.ts +++ /dev/null @@ -1,78 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { normalizeTerminalCustomThemes } from './terminal-custom-themes' - -describe('terminal custom themes', () => { - it('normalizes colors and drops invalid theme records', () => { - const themes = normalizeTerminalCustomThemes([ - { - id: 'warp:Tokyo Night', - name: 'Tokyo/Night\\Dark', - source: 'warp', - mode: 'dark', - terminal: { - background: '1a1b26', - foreground: '#c0caf5', - black: '#15161e', - red: 'not-a-color' - }, - importedAt: '2026-06-05T00:00:00.000Z', - sourcePath: '/Users/alice/.warp/themes/tokyo.yaml' - }, - { - id: 'bad', - name: 'Bad', - source: 'warp', - terminal: { background: '#000000' } - } - ]) - - expect(themes).toEqual([ - { - id: 'warp:tokyo-night', - name: 'Tokyo Night Dark', - source: 'warp', - mode: 'dark', - terminal: { - background: '#1a1b26', - foreground: '#c0caf5', - black: '#15161e' - }, - importedAt: '2026-06-05T00:00:00.000Z' - } - ]) - }) - - it('deduplicates normalized ids with last write winning', () => { - const themes = normalizeTerminalCustomThemes([ - { - id: 'warp:dupe', - name: 'First', - source: 'warp', - terminal: { background: '#000000', foreground: '#ffffff', black: '#111111' } - }, - { - id: 'warp:dupe', - name: 'Second', - source: 'warp', - terminal: { background: '#000000', foreground: '#ffffff', red: '#ff0000' } - } - ]) - - expect(themes).toHaveLength(1) - expect(themes[0]?.name).toBe('Second') - expect(themes[0]?.terminal.red).toBe('#ff0000') - }) - - it('requires at least one ANSI palette color', () => { - expect( - normalizeTerminalCustomThemes([ - { - id: 'warp:cursor-only', - name: 'Cursor Only', - source: 'warp', - terminal: { background: '#000000', foreground: '#ffffff', cursor: '#ffffff' } - } - ]) - ).toEqual([]) - }) -}) diff --git a/src/shared/terminal-themes/terminal-theme-selection.test.ts b/src/shared/terminal-themes/terminal-theme-selection.test.ts index 601aa325933..3fb95b8a4df 100644 --- a/src/shared/terminal-themes/terminal-theme-selection.test.ts +++ b/src/shared/terminal-themes/terminal-theme-selection.test.ts @@ -5,7 +5,6 @@ import { terminalCustomThemeRemoval, withImportedTerminalColorOverrides } from './terminal-theme-selection' -import { TERMINAL_THEME_CATALOG } from './terminal-theme-catalog' const base = { theme: 'system' as const, @@ -14,42 +13,6 @@ const base = { terminalThemeLight: 'Builtin Tango Light' } -describe('the terminal colours a host configures', () => { - it('uses the light theme for a light appearance, so a light user is not told dark', () => { - expect(resolveConfiguredTerminalColors(base, false)).toEqual({ - foreground: '#2e3434', - background: '#ffffff' - }) - expect(resolveConfiguredTerminalColors({ ...base, theme: 'light' }, true)).toEqual({ - foreground: '#2e3434', - background: '#ffffff' - }) - expect(resolveConfiguredTerminalColors(base, true)).toEqual({ - foreground: '#ffffff', - background: '#282c34' - }) - }) - - it('follows the selected catalog theme and colour overrides', () => { - const name = 'Tokyo Night' - const selected = TERMINAL_THEME_CATALOG[name] - expect(selected).toBeDefined() - expect(resolveConfiguredTerminalColors({ ...base, terminalThemeDark: name }, true)).toEqual({ - foreground: selected?.foreground, - background: selected?.background - }) - expect( - resolveConfiguredTerminalColors( - { ...base, terminalColorOverrides: { background: '#101010' } }, - true - ) - ).toEqual({ foreground: '#ffffff', background: '#101010' }) - expect( - resolveConfiguredTerminalColors({ ...base, terminalThemeDark: 'no such theme' }, true) - ).toEqual({ foreground: '#ffffff', background: '#282c34' }) - }) -}) - describe('terminal colour overrides per theme slot (#10561)', () => { const settings = { ...base, @@ -83,15 +46,6 @@ describe('terminal colour overrides per theme slot (#10561)', () => { withImportedTerminalColorOverrides(settings, { ...imported, theme: 'dark' }, false) ).toEqual({ theme: 'dark', terminalColorOverrides: { background: '#101010', red: '#ff0000' } }) }) - - it('keeps the theme colour for a field cleared in memory', () => { - expect( - resolveConfiguredTerminalColors( - { ...base, terminalColorOverrides: { background: undefined } }, - true - ) - ).toEqual({ foreground: '#ffffff', background: '#282c34' }) - }) }) describe('plugin terminal palettes', () => { diff --git a/src/shared/terminal/appearance/terminal-fonts.test.ts b/src/shared/terminal/appearance/terminal-fonts.test.ts deleted file mode 100644 index 0c5d2132631..00000000000 --- a/src/shared/terminal/appearance/terminal-fonts.test.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { - DEFAULT_TERMINAL_FONT_WEIGHT_BOLD, - resolveTerminalFontWeights, - normalizeTerminalFontWeight, - normalizeTerminalFontWeightBold -} from './terminal-fonts' - -describe('terminal font weights', () => { - it('clamps both weights to the supported xterm range', () => { - expect(normalizeTerminalFontWeight(10)).toBe(100) - expect(normalizeTerminalFontWeight(1200)).toBe(900) - expect(normalizeTerminalFontWeightBold(10)).toBe(100) - expect(normalizeTerminalFontWeightBold(1200)).toBe(900) - }) - - it('does not derive bold from the base weight', () => { - expect(resolveTerminalFontWeights(800, undefined)).toEqual({ - fontWeight: 800, - fontWeightBold: DEFAULT_TERMINAL_FONT_WEIGHT_BOLD - }) - expect(resolveTerminalFontWeights(300, 400)).toEqual({ - fontWeight: 300, - fontWeightBold: 400 - }) - expect(resolveTerminalFontWeights(700, 700)).toEqual({ - fontWeight: 700, - fontWeightBold: 700 - }) - }) -}) diff --git a/src/shared/terminal/input/terminal-input.test.ts b/src/shared/terminal/input/terminal-input.test.ts index 9cfe24dc463..1fcd467569e 100644 --- a/src/shared/terminal/input/terminal-input.test.ts +++ b/src/shared/terminal/input/terminal-input.test.ts @@ -2,7 +2,6 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { TERMINAL_INPUT_TOO_LARGE_ERROR, assertTerminalInputWithinLimit, - iterateTerminalInputChunks, isTerminalInputTooLarge, isTerminalInputTooLargeWithDeferredMeasurement, isTerminalInputTooLargeWithYield, @@ -23,16 +22,6 @@ describe('terminal input bounds', () => { expect(chunks.join('')).toBe('ab😀cd') }) - it('does not scan the full payload before yielding the first chunk', () => { - const codePointAt = vi.spyOn(String.prototype, 'codePointAt') - const text = 'x'.repeat(128) - const chunks = iterateTerminalInputChunks(text, 8) - - expect(chunks.next()).toEqual({ done: false, value: 'x'.repeat(8) }) - - expect(codePointAt.mock.calls.length).toBeLessThan(text.length) - }) - it('keeps a single multibyte terminal character intact when the byte cap is smaller', () => { const chunks = splitTerminalInputChunks('😀a', 1) @@ -55,16 +44,6 @@ describe('terminal input bounds', () => { ) }) - it('rejects terminal input whose string length already exceeds the byte limit without scanning', () => { - const codePointAt = vi.spyOn(String.prototype, 'codePointAt') - - expect(isTerminalInputTooLarge('x'.repeat(6), 5)).toBe(true) - expect(() => assertTerminalInputWithinLimit('x'.repeat(6), 5)).toThrow( - TERMINAL_INPUT_TOO_LARGE_ERROR - ) - expect(codePointAt).not.toHaveBeenCalled() - }) - it('yields while measuring accepted large terminal input', async () => { vi.useFakeTimers() const text = 'é'.repeat(CLIPBOARD_TEXT_MEASURE_YIELD_CODE_UNITS + 1) diff --git a/src/shared/terminal/links/hard-wrapped-terminal-http-links.test.ts b/src/shared/terminal/links/hard-wrapped-terminal-http-links.test.ts index 2de9f80c935..09293c95d0e 100644 --- a/src/shared/terminal/links/hard-wrapped-terminal-http-links.test.ts +++ b/src/shared/terminal/links/hard-wrapped-terminal-http-links.test.ts @@ -1,29 +1,7 @@ import type { IBufferLine } from '@xterm/xterm' -import { describe, expect, it, vi } from 'vitest' +import { describe, expect, it } from 'vitest' import { buildHardWrappedHttpLogicalLineCandidates } from './hard-wrapped-terminal-http-links' -import { - TERMINAL_HTTP_URL_MAX_HARD_WRAPPED_ROWS, - TERMINAL_HTTP_URL_MAX_LENGTH -} from './terminal-http-link-limits' - -function bufferLine(text: string, onTranslateWithColumns: () => void): IBufferLine { - return { - isWrapped: false, - length: text.length, - translateToString: ( - _trimRight?: boolean, - startColumn = 0, - endColumn = text.length, - outColumns?: number[] - ) => { - if (outColumns) { - onTranslateWithColumns() - } - outColumns?.push(...Array.from({ length: text.length + 1 }, (_value, index) => index)) - return text.slice(startColumn, endColumn) - } - } as IBufferLine -} +import { TERMINAL_HTTP_URL_MAX_LENGTH } from './terminal-http-link-limits' function bufferLineWithCellColumns(text: string): IBufferLine { const columns: number[] = [] @@ -49,51 +27,6 @@ function bufferLineWithCellColumns(text: string): IBufferLine { } describe('hard-wrapped terminal HTTP candidate bounds', () => { - it('builds each required column map once and rejects an overlength candidate early', () => { - const onTranslateWithColumns = vi.fn() - const rows = Array.from({ length: TERMINAL_HTTP_URL_MAX_HARD_WRAPPED_ROWS }, (_value, index) => - bufferLine(`| ${index === 0 ? 'http://x' : 'aaaaaaaa'} |`, onTranslateWithColumns) - ) - - expect( - buildHardWrappedHttpLogicalLineCandidates( - { getLine: (y) => rows[y] }, - TERMINAL_HTTP_URL_MAX_HARD_WRAPPED_ROWS - ) - ).toEqual([]) - expect(onTranslateWithColumns).toHaveBeenCalledTimes( - Math.floor(TERMINAL_HTTP_URL_MAX_LENGTH / 'aaaaaaaa'.length) + 1 - ) - }) - - it('does not build column maps while rejecting rows without an HTTP scheme', () => { - const onTranslateWithColumns = vi.fn() - const rows = Array.from({ length: TERMINAL_HTTP_URL_MAX_HARD_WRAPPED_ROWS }, () => - bufferLine(`| ${'a'.repeat(500)} |`, onTranslateWithColumns) - ) - - expect( - buildHardWrappedHttpLogicalLineCandidates( - { getLine: (y) => rows[y] }, - TERMINAL_HTTP_URL_MAX_HARD_WRAPPED_ROWS - ) - ).toEqual([]) - expect(onTranslateWithColumns).not.toHaveBeenCalled() - }) - - it('stops scanning back at a table border instead of walking every framed row', () => { - const rows = Array.from({ length: TERMINAL_HTTP_URL_MAX_HARD_WRAPPED_ROWS }, (_value, index) => - bufferLine( - index % 10 === 0 ? '├──────────────────────┤' : `│ https://example.com/${index} │`, - () => undefined - ) - ) - const getLine = vi.fn((y: number) => rows[y]) - - buildHardWrappedHttpLogicalLineCandidates({ getLine }, TERMINAL_HTTP_URL_MAX_HARD_WRAPPED_ROWS) - expect(new Set(getLine.mock.calls.map(([y]) => y)).size).toBeLessThan(20) - }) - it('reconstructs framed URL rows aligned by cells when a fragment has wide characters', () => { const rowTexts = ['|http://a/ab|', '|a你你你你你|', '|tailzzzzzzz|'] const rows = rowTexts.map(bufferLineWithCellColumns) diff --git a/src/shared/terminal/links/terminal-file-uri-link.test.ts b/src/shared/terminal/links/terminal-file-uri-link.test.ts index c603e6acc97..1d77ec1ee50 100644 --- a/src/shared/terminal/links/terminal-file-uri-link.test.ts +++ b/src/shared/terminal/links/terminal-file-uri-link.test.ts @@ -2,18 +2,6 @@ import { describe, expect, it } from 'vitest' import { detectTerminalFileUriLinks } from './terminal-file-uri-link' describe('detectTerminalFileUriLinks', () => { - it('decodes a localhost-less file URI to an absolute path', () => { - const line = 'Report: file:///Users/dev/orca/report.html' - const [link] = detectTerminalFileUriLinks(line) - expect(link).toMatchObject({ - pathText: '/Users/dev/orca/report.html', - line: null, - column: null - }) - expect(line.slice(link.startIndex, link.endIndex)).toBe('file:///Users/dev/orca/report.html') - expect(link.displayText).toBe('file:///Users/dev/orca/report.html') - }) - it('keeps standard unescaped parentheses and apostrophes inside the path', () => { const uri = "file:///tmp/Brennan's%20Report%20(final)" const [link] = detectTerminalFileUriLinks(`open (${uri})`) diff --git a/src/shared/terminal/links/terminal-links.test.ts b/src/shared/terminal/links/terminal-links.test.ts index 2195c582550..c6ea1e735f2 100644 --- a/src/shared/terminal/links/terminal-links.test.ts +++ b/src/shared/terminal/links/terminal-links.test.ts @@ -152,12 +152,6 @@ describe('terminal path helpers', () => { displayText: 'app/(shop)/products/[productId]/page.tsx:42:7' }) }) - - it('grows a spaced reading over a bounded number of words', () => { - const paths = pathTexts(`/tmp/root ${'part/x '.repeat(20_000)}`) - - expect(paths.filter((path) => path.startsWith('/tmp/root'))).toHaveLength(11) - }) }) it('resolves tilde-prefixed paths against explicit terminal home when cwd is outside home', () => { @@ -210,19 +204,6 @@ describe('terminal path helpers', () => { }) describe('plain-text file:// URIs', () => { - it('extracts a printed file:// URI as a file link resolving to its path', () => { - const line = 'Report: file:///Users/dev/orca/report.html' - const link = extractTerminalFileLinks(line).find( - (candidate) => candidate.displayText === 'file:///Users/dev/orca/report.html' - ) - expect(link).toMatchObject({ pathText: '/Users/dev/orca/report.html' }) - expect(resolveTerminalFileLink(link!, '/Users/dev/orca')).toEqual({ - absolutePath: '/Users/dev/orca/report.html', - line: null, - column: null - }) - }) - it('does not also emit a bare-path link for the URI body', () => { const links = extractTerminalFileLinks('file:///Users/dev/orca/report.html') expect(links.map((link) => link.displayText)).toEqual(['file:///Users/dev/orca/report.html']) diff --git a/src/shared/terminal/links/wrapped-terminal-link-ranges.test.ts b/src/shared/terminal/links/wrapped-terminal-link-ranges.test.ts index e196eb2c18d..b3bf052aa6a 100644 --- a/src/shared/terminal/links/wrapped-terminal-link-ranges.test.ts +++ b/src/shared/terminal/links/wrapped-terminal-link-ranges.test.ts @@ -1,7 +1,6 @@ import { describe, expect, it } from 'vitest' import { buildHardWrappedPathLogicalLineCandidates, - buildWrappedLogicalLine, rangeForParsedFileLink } from './wrapped-terminal-link-ranges' @@ -44,28 +43,6 @@ function makeBufferLine( } } -describe('buildWrappedLogicalLine', () => { - it('caps pathological soft-wrapped lines before scanning the whole run', () => { - const rows = Array.from({ length: 1_000 }, (_value, index) => - makeBufferLine('b'.repeat(80), { isWrapped: index > 0 }) - ) - const observedRows: number[] = [] - - const logicalLine = buildWrappedLogicalLine( - { - getLine: (y) => { - observedRows.push(y) - return rows[y] - } - }, - 1 - ) - - expect(logicalLine).toBeNull() - expect(Math.max(...observedRows)).toBeLessThan(250) - }) -}) - describe('buildHardWrappedPathLogicalLineCandidates', () => { const firstPath = 'validation-screenshots/01-before-white-terminal-scrollbar-gutter.png' const middleStart = 'validation-screenshots/02-after-' @@ -228,25 +205,6 @@ describe('buildHardWrappedPathLogicalLineCandidates', () => { ) }) - it('fingerprints full source rows outside the selected boundary fragments', () => { - const rows = makeThreeLinkRows() - const buffer = { getLine: (y: number) => rows[y] } - const expectedText = middleStart + middleEnd - const before = buildHardWrappedPathLogicalLineCandidates(buffer, 1).find( - (candidate) => candidate.text === expectedText - ) - - rows[1] = makeBufferLine(`${middleEnd} · validation-screenshots/03-after-dark-theme.png`) - const after = buildHardWrappedPathLogicalLineCandidates(buffer, 1).find( - (candidate) => candidate.text === expectedText - ) - - expect(before).toBeDefined() - expect(after).toBeDefined() - expect(after!.rows.map((row) => row.text)).toEqual(before!.rows.map((row) => row.text)) - expect(after!.fingerprint).not.toBe(before!.fingerprint) - }) - it('preserves Windows drive paths and original continuation columns', () => { const firstRow = 'result: C:\\Users\\Alice\\Project\\src\\very-' const firstFragment = 'C:\\Users\\Alice\\Project\\src\\very-' diff --git a/src/shared/terminal/output/terminal-bell-detector.test.ts b/src/shared/terminal/output/terminal-bell-detector.test.ts index 8f1ce7b0bfc..8ae219a64fe 100644 --- a/src/shared/terminal/output/terminal-bell-detector.test.ts +++ b/src/shared/terminal/output/terminal-bell-detector.test.ts @@ -2,13 +2,6 @@ import { describe, expect, it } from 'vitest' import { createBellDetector } from './terminal-bell-detector' describe('createBellDetector', () => { - it('skips ANSI chunks without losing later real bells', () => { - const detector = createBellDetector() - - expect(detector.chunkContainsBell('\x1b[32mbuild\x1b[0m output')).toBe(false) - expect(detector.chunkContainsBell('\x07')).toBe(true) - }) - it('keeps split OSC state so title terminators are not reported as bells', () => { const detector = createBellDetector() diff --git a/src/shared/terminal/output/terminal-selection-gutter.test.ts b/src/shared/terminal/output/terminal-selection-gutter.test.ts deleted file mode 100644 index a2345899229..00000000000 --- a/src/shared/terminal/output/terminal-selection-gutter.test.ts +++ /dev/null @@ -1,66 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { stripTerminalSelectionGutter } from './terminal-selection-gutter' - -// Agent CLIs paint a marker then a fixed gutter; users copy the message body. -const AGENT_MESSAGE_BODY = [ - ' Thanks for flagging this. The retry limit is now 5, and the backoff', - ' starts at 2s instead of 500ms.', - '', - ' Let me know if anything still looks off.' -].join('\n') - -describe('stripTerminalSelectionGutter', () => { - it('drops the gutter agent output is painted behind', () => { - expect(stripTerminalSelectionGutter(AGENT_MESSAGE_BODY)).toBe( - [ - 'Thanks for flagging this. The retry limit is now 5, and the backoff', - 'starts at 2s instead of 500ms.', - '', - 'Let me know if anything still looks off.' - ].join('\n') - ) - }) - - it('keeps relative indentation inside the gutter', () => { - const nested = [' def run():', ' return 1', '', ' run()'].join('\n') - expect(stripTerminalSelectionGutter(nested)).toBe( - ['def run():', ' return 1', '', 'run()'].join('\n') - ) - }) - - it('leaves a selection that starts mid-line untouched', () => { - const midLine = ['answer starts here', ' and continues', ' and ends'].join('\n') - expect(stripTerminalSelectionGutter(midLine)).toBe(midLine) - }) - - it('ignores whitespace-only lines when measuring the gutter', () => { - expect(stripTerminalSelectionGutter([' a', ' ', ' b'].join('\n'))).toBe( - ['a', '', 'b'].join('\n') - ) - }) - - it('leaves an all-whitespace selection untouched', () => { - expect(stripTerminalSelectionGutter(' \n \n')).toBe(' \n \n') - }) - - it('preserves the CRLF joins xterm emits on Windows', () => { - expect(stripTerminalSelectionGutter(' first\r\n second\r\n')).toBe('first\r\nsecond\r\n') - }) - - // A blank Windows CRLF row must not look like zero-indent content and cancel gutter removal. - it('still finds the gutter across a blank CRLF row', () => { - expect(stripTerminalSelectionGutter(' first\r\n\r\n second\r\n')).toBe( - 'first\r\n\r\nsecond\r\n' - ) - }) - - it('does not strip past a shorter line', () => { - const uneven = [' deep', ' shallow'].join('\n') - expect(stripTerminalSelectionGutter(uneven)).toBe([' deep', 'shallow'].join('\n')) - }) - - it('leaves tab-indented text alone (terminal cells never hold tabs)', () => { - const tabbed = ['\tone', '\ttwo'].join('\n') - expect(stripTerminalSelectionGutter(tabbed)).toBe(tabbed) - }) -}) diff --git a/src/shared/terminal/protocol/escape/terminal-partial-escape-tail-ground-scan.test.ts b/src/shared/terminal/protocol/escape/terminal-partial-escape-tail-ground-scan.test.ts index 725c3046317..0174aa18687 100644 --- a/src/shared/terminal/protocol/escape/terminal-partial-escape-tail-ground-scan.test.ts +++ b/src/shared/terminal/protocol/escape/terminal-partial-escape-tail-ground-scan.test.ts @@ -1,28 +1,7 @@ -import { describe, expect, it, vi } from 'vitest' +import { describe, expect, it } from 'vitest' import { advancePartialEscapeTail, extractPartialEscapeTail } from './terminal-partial-escape-tail' describe('partial escape scanning between sequences', () => { - it.each([ - ['ASCII', 'ordinary output '.repeat(8192)], - ['UTF-16', '漢字😀 output '.repeat(8192)] - ])('skips ordinary %s text between completed escapes', (_name, text) => { - const pending = '\x1b[38;5;' - const stream = `\x1b[32m${text}\x1b[0m${text}${pending}` - const charCodeAt = vi.spyOn(String.prototype, 'charCodeAt') - let actual: string - let inspectedCodeUnits: number - try { - actual = extractPartialEscapeTail(stream) - inspectedCodeUnits = charCodeAt.mock.calls.length - } finally { - charCodeAt.mockRestore() - } - - expect(actual).toBe(pending) - expect(inspectedCodeUnits).toBeLessThan(32) - expect(advancePartialEscapeTail(actual, '196mready')).toBe('') - }) - it.each([ ['\x1b[38;5;', '196m'], ['\x1b]2;building', '\x07'], diff --git a/src/shared/terminal/protocol/escape/terminal-partial-escape-tail.test.ts b/src/shared/terminal/protocol/escape/terminal-partial-escape-tail.test.ts index 5ce347d583c..d5c07e98d37 100644 --- a/src/shared/terminal/protocol/escape/terminal-partial-escape-tail.test.ts +++ b/src/shared/terminal/protocol/escape/terminal-partial-escape-tail.test.ts @@ -2,8 +2,7 @@ import { describe, expect, it } from 'vitest' import { advancePartialEscapeTail, advanceTerminalEscapeBoundary, - extractPartialEscapeTail, - MAX_PARTIAL_ESCAPE_TAIL_LENGTH + extractPartialEscapeTail } from './terminal-partial-escape-tail' describe('advanceTerminalEscapeBoundary', () => { @@ -62,14 +61,6 @@ describe('extractPartialEscapeTail', () => { }) }) -describe('advancePartialEscapeTail', () => { - it('abandons tracking (returns empty) past the cap', () => { - // An unterminated OSC longer than the cap degrades to pre-fix behavior. - const huge = `\x1b]0;${'x'.repeat(MAX_PARTIAL_ESCAPE_TAIL_LENGTH + 10)}` - expect(advancePartialEscapeTail('', huge)).toBe('') - }) -}) - describe('advancePartialEscapeTail ESC-free fast path', () => { it('still carries a pending tail through an ESC-free chunk', () => { expect(advancePartialEscapeTail('\x1b]0;my-title', ' still in the OSC')).toBe( diff --git a/src/shared/terminal/protocol/escape/terminal-synchronized-output-scan.test.ts b/src/shared/terminal/protocol/escape/terminal-synchronized-output-scan.test.ts index fc77355f00f..e7d29c445b3 100644 --- a/src/shared/terminal/protocol/escape/terminal-synchronized-output-scan.test.ts +++ b/src/shared/terminal/protocol/escape/terminal-synchronized-output-scan.test.ts @@ -78,9 +78,4 @@ describe('resolveSynchronizedOutputSafeSplit', () => { // The remainder must still contain a complete, parseable close. expect(data.slice(splitAt)).toContain(CLOSE) }) - - it('falls back to the limit when one frame is longer than the window', () => { - const data = `${OPEN}${'x'.repeat(100)}${CLOSE}` - expect(resolveSynchronizedOutputSafeSplit(data, 20)).toBe(20) - }) }) diff --git a/src/shared/terminal/protocol/input-modes/terminal-kitty-keyboard-mode-tracker.test.ts b/src/shared/terminal/protocol/input-modes/terminal-kitty-keyboard-mode-tracker.test.ts index 540fd8b74d7..fb395a24287 100644 --- a/src/shared/terminal/protocol/input-modes/terminal-kitty-keyboard-mode-tracker.test.ts +++ b/src/shared/terminal/protocol/input-modes/terminal-kitty-keyboard-mode-tracker.test.ts @@ -151,15 +151,6 @@ describe('TerminalKittyKeyboardModeTracker', () => { expect(fresh.snapshotFlags).toBeUndefined() }) - it('ignores values that are not non-negative safe integers', () => { - const tracker = new TerminalKittyKeyboardModeTracker() - tracker.resetForSnapshot() - for (const invalid of [-1, 1.5, Number.NaN, Number.MAX_SAFE_INTEGER + 2]) { - tracker.restoreSnapshotFlags(invalid) - expect(tracker.snapshotFlags).toBeUndefined() - } - }) - it('re-proves state on an absolute set but not on a relative update', () => { const relative = new TerminalKittyKeyboardModeTracker() relative.resetForSnapshot() diff --git a/src/shared/terminal/protocol/input-modes/terminal-mode-reset-profiles.test.ts b/src/shared/terminal/protocol/input-modes/terminal-mode-reset-profiles.test.ts index 9c5fab939a8..cc7a31d6a2e 100644 --- a/src/shared/terminal/protocol/input-modes/terminal-mode-reset-profiles.test.ts +++ b/src/shared/terminal/protocol/input-modes/terminal-mode-reset-profiles.test.ts @@ -1,10 +1,6 @@ import { Terminal } from '@xterm/headless' import { describe, expect, it } from 'vitest' -import { - PROCESS_BOUNDARY_GROUND, - buildKittyKeyboardRestore, - buildPostReplayLiveAgentReattachReset -} from './terminal-mode-reset-profiles' +import { PROCESS_BOUNDARY_GROUND, buildKittyKeyboardRestore } from './terminal-mode-reset-profiles' import { TerminalKittyKeyboardModeTracker } from './terminal-kitty-keyboard-mode-tracker' // Consumers import these bytes; literal expectations catch modes silently disappearing. @@ -22,13 +18,6 @@ describe('terminal mode reset profiles', () => { term.dispose() }) - describe('live-agent cursor preservation', () => { - it('omits the cursor-show when the agent left its cursor hidden', () => { - expect(buildPostReplayLiveAgentReattachReset('x\x1b[?25l')).not.toContain('\x1b[?25h') - expect(buildPostReplayLiveAgentReattachReset('x\x1b[?25h')).toContain('\x1b[?25h') - }) - }) - // A replayed push grows xterm’s stack but acts as a mirror set; pop-all prevents the app’s single pop restoring stale flags. it("brings xterm and the mirror to 0 on the app's pop after a redelivered push", async () => { const term = new Terminal({ allowProposedApi: true, vtExtensions: { kittyKeyboard: true } }) diff --git a/src/shared/terminal/protocol/input-modes/terminal-mouse-encoding.test.ts b/src/shared/terminal/protocol/input-modes/terminal-mouse-encoding.test.ts index ef1f323be62..b4f17a757a2 100644 --- a/src/shared/terminal/protocol/input-modes/terminal-mouse-encoding.test.ts +++ b/src/shared/terminal/protocol/input-modes/terminal-mouse-encoding.test.ts @@ -22,9 +22,4 @@ describe('readTerminalMouseEncoding', () => { expect(readTerminalMouseEncoding(terminal)).toBe('default') terminal.dispose() }) - - it('answers default when the private internals are missing', () => { - expect(readTerminalMouseEncoding({ cols: 80, rows: 24 })).toBe('default') - expect(readTerminalMouseEncoding({ cols: 80, rows: 24, _core: {} })).toBe('default') - }) }) diff --git a/src/shared/terminal/protocol/input-modes/terminal-replay-cursor-reset.test.ts b/src/shared/terminal/protocol/input-modes/terminal-replay-cursor-reset.test.ts deleted file mode 100644 index 6ede2cc47a7..00000000000 --- a/src/shared/terminal/protocol/input-modes/terminal-replay-cursor-reset.test.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { - buildPostReplayLiveAgentReattachReset, - POST_REPLAY_LIVE_AGENT_REATTACH_RESET, - replayPayloadEndsWithCursorHidden, - RESET_TERMINAL_CURSOR_STYLE -} from './terminal-mode-reset-profiles' - -describe('replayPayloadEndsWithCursorHidden', () => { - it('is true when the last DECTCEM sequence hides the cursor', () => { - expect(replayPayloadEndsWithCursorHidden('\x1b[?25h frame \x1b[?25l')).toBe(true) - expect(replayPayloadEndsWithCursorHidden('\x1b[?1004h\x1b[?25lparked screen')).toBe(true) - }) - - it('is false when the cursor was re-shown or never touched', () => { - expect(replayPayloadEndsWithCursorHidden('\x1b[?25l frame \x1b[?25h')).toBe(false) - expect(replayPayloadEndsWithCursorHidden('plain shell output')).toBe(false) - expect(replayPayloadEndsWithCursorHidden('')).toBe(false) - }) -}) - -describe('buildPostReplayLiveAgentReattachReset', () => { - it('preserves an intentionally hidden cursor', () => { - expect(buildPostReplayLiveAgentReattachReset('agent frame\x1b[?25l')).toBe( - RESET_TERMINAL_CURSOR_STYLE - ) - }) - - it('re-shows the cursor when the payload left it visible', () => { - expect(buildPostReplayLiveAgentReattachReset('agent frame\x1b[?25l\x1b[?25h')).toBe( - POST_REPLAY_LIVE_AGENT_REATTACH_RESET - ) - expect(buildPostReplayLiveAgentReattachReset('no dectcem at all')).toBe( - POST_REPLAY_LIVE_AGENT_REATTACH_RESET - ) - }) -}) diff --git a/src/shared/terminal/protocol/queries/terminal-color-scheme-protocol.test.ts b/src/shared/terminal/protocol/queries/terminal-color-scheme-protocol.test.ts index 407039de35e..d8c104fda4b 100644 --- a/src/shared/terminal/protocol/queries/terminal-color-scheme-protocol.test.ts +++ b/src/shared/terminal/protocol/queries/terminal-color-scheme-protocol.test.ts @@ -1,13 +1,8 @@ import { describe, expect, it } from 'vitest' -import { mode2031SequenceFor, scanMode2031Sequences } from './terminal-color-scheme-protocol' +import { scanMode2031Sequences } from './terminal-color-scheme-protocol' describe('terminal color scheme protocol', () => { - it('maps mode 2031 replies to CSI 997 status reports', () => { - expect(mode2031SequenceFor('dark')).toBe('\x1b[?997;1n') - expect(mode2031SequenceFor('light')).toBe('\x1b[?997;2n') - }) - it('detects mode 2031 subscribes in compound and split private mode sequences', () => { expect(scanMode2031Sequences('', '\x1b[?25;2031h')).toMatchObject({ subscribe: true, diff --git a/src/shared/terminal/protocol/queries/terminal-osc-color-reply.test.ts b/src/shared/terminal/protocol/queries/terminal-osc-color-reply.test.ts index 065c4a6f551..b62eb2872c7 100644 --- a/src/shared/terminal/protocol/queries/terminal-osc-color-reply.test.ts +++ b/src/shared/terminal/protocol/queries/terminal-osc-color-reply.test.ts @@ -42,10 +42,4 @@ describe('parseTerminalOscColorQuery', () => { expect(parseTerminalOscColorQuery('\x1b]10;?;?;?\x1b\\', 0)).toEqual({ kind: 'none' }) expect(parseTerminalOscColorQuery('\x1b]11;?;?\x1b\\', 0)).toEqual({ kind: 'none' }) }) - - it('rejects unsupported query-shaped bodies without waiting for a terminator', () => { - expect(parseTerminalOscColorQuery(`\x1b]10;?${'x'.repeat(10_000)}`, 0)).toEqual({ - kind: 'none' - }) - }) }) diff --git a/src/shared/terminal/protocol/queries/terminal-view-attributes.test.ts b/src/shared/terminal/protocol/queries/terminal-view-attributes.test.ts index 487cd170566..6e63d436e43 100644 --- a/src/shared/terminal/protocol/queries/terminal-view-attributes.test.ts +++ b/src/shared/terminal/protocol/queries/terminal-view-attributes.test.ts @@ -1,13 +1,6 @@ /** Main’s hidden-PTY color replies must match the bundled renderer xterm’s XParseColor grammar byte-for-byte. */ import { describe, expect, it } from 'vitest' -import { - formatXColorRgbSpec, - parseXColorSpec, - terminalViewAttributesEqual, - validateTerminalViewAttributes, - type TerminalViewAttributes, - type TerminalViewRgb -} from './terminal-view-attributes' +import { formatXColorRgbSpec, parseXColorSpec } from './terminal-view-attributes' describe('parseXColorSpec', () => { // Scaling fixtures mirror XParseColor.parseColor: h|hh|hhh|hhhh channels @@ -45,47 +38,3 @@ describe('formatXColorRgbSpec', () => { expect(formatXColorRgbSpec([0, 8, 255])).toBe('rgb:0000/0808/ffff') }) }) - -describe('validateTerminalViewAttributes', () => { - const valid = (): TerminalViewAttributes => ({ - foreground: [1, 2, 3], - background: [4, 5, 6], - cursor: [7, 8, 9], - ansi: Array.from({ length: 256 }, (_, i) => [i % 256, 0, 0] as TerminalViewRgb), - colorSchemeMode: 'dark', - cursorStyle: 'block', - cursorBlink: true - }) - - it.each([ - ['null payload', null], - ['missing foreground', { ...valid(), foreground: undefined }], - ['short triple', { ...valid(), background: [1, 2] }], - ['out-of-range channel', { ...valid(), cursor: [0, 0, 300] }], - ['non-integer channel', { ...valid(), cursor: [0, 0, 1.5] }], - ['short palette', { ...valid(), ansi: valid().ansi.slice(0, 16) }], - ['bad palette entry', { ...valid(), ansi: [...valid().ansi.slice(0, 255), 'red'] }], - ['bad mode', { ...valid(), colorSchemeMode: 'auto' }], - ['bad cursor style', { ...valid(), cursorStyle: 'beam' }], - ['non-boolean blink', { ...valid(), cursorBlink: 1 }] - ])('rejects %s', (_label, payload) => { - expect(validateTerminalViewAttributes(payload)).toBeNull() - }) -}) - -describe('terminalViewAttributesEqual', () => { - // Equal re-pushes from a fresh renderer must not fan out as another theme apply. - const snapshot = (): TerminalViewAttributes => ({ - foreground: [1, 2, 3], - background: [4, 5, 6], - cursor: [7, 8, 9], - ansi: Array.from({ length: 256 }, (_, i) => [i % 256, 0, 0] as TerminalViewRgb), - colorSchemeMode: 'dark', - cursorStyle: 'block', - cursorBlink: true - }) - - it('treats two independently built identical snapshots as equal', () => { - expect(terminalViewAttributesEqual(snapshot(), snapshot())).toBe(true) - }) -}) diff --git a/src/shared/terminal/pty/diagnostics/pty-delivery-diagnostics.test.ts b/src/shared/terminal/pty/diagnostics/pty-delivery-diagnostics.test.ts deleted file mode 100644 index 11bee90e8ba..00000000000 --- a/src/shared/terminal/pty/diagnostics/pty-delivery-diagnostics.test.ts +++ /dev/null @@ -1,74 +0,0 @@ -// Bound/coalesce breadcrumb floods and redact path-bearing PTY prefixes while retaining report correlation suffixes. -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { - createPtyDeliveryBreadcrumbRing, - redactPtyIdForDiagnostics -} from './pty-delivery-diagnostics' - -describe('pty delivery breadcrumb ring', () => { - beforeEach(() => { - vi.useFakeTimers() - }) - - afterEach(() => { - vi.useRealTimers() - }) - - it('coalesces same-kind events inside the window and separates them outside it', () => { - const ring = createPtyDeliveryBreadcrumbRing(10, 1_000) - ring.record('gate-mark', { id: 'a' }) - vi.advanceTimersByTime(200) - ring.record('gate-mark', { id: 'b' }) - vi.advanceTimersByTime(200) - ring.record('gate-mark') - - let entries = ring.snapshot() - expect(entries).toHaveLength(1) - expect(entries[0].repeats).toBe(3) - // The freshest detail wins so the report shows the latest actor. - expect(entries[0].detail).toEqual({ id: 'b' }) - - vi.advanceTimersByTime(2_000) - ring.record('gate-mark', { id: 'c' }) - entries = ring.snapshot() - expect(entries).toHaveLength(2) - expect(entries[1].repeats).toBeUndefined() - }) - - it('does not coalesce across different kinds and stays bounded at capacity', () => { - const ring = createPtyDeliveryBreadcrumbRing(5, 1_000) - ring.record('gate-mark') - ring.record('gate-unmark') - expect(ring.snapshot()).toHaveLength(2) - - for (let i = 0; i < 20; i++) { - vi.advanceTimersByTime(2_000) - ring.record(`kind-${i}`) - } - const entries = ring.snapshot() - expect(entries).toHaveLength(5) - expect(entries[4].kind).toBe('kind-19') - }) - - it('snapshot returns copies and reset empties the ring', () => { - const ring = createPtyDeliveryBreadcrumbRing(5, 1_000) - ring.record('watchdog-heal', { healCount: 1 }) - const entries = ring.snapshot() - entries[0].kind = 'tampered' - expect(ring.snapshot()[0].kind).toBe('watchdog-heal') - ring.reset() - expect(ring.snapshot()).toHaveLength(0) - }) -}) - -describe('redactPtyIdForDiagnostics', () => { - it('keeps the @@ suffix and drops the path-bearing worktree prefix', () => { - expect(redactPtyIdForDiagnostics('/Users/someone/repo@@ab12cd34')).toBe('…@@ab12cd34') - }) - - it('truncates long ids without a separator and passes short ids through', () => { - expect(redactPtyIdForDiagnostics('pty-1')).toBe('pty-1') - const long = 'x'.repeat(40) - expect(redactPtyIdForDiagnostics(long)).toBe(`…${'x'.repeat(12)}`) - }) -}) diff --git a/src/shared/terminal/pty/ingress/pty-owner-color-query-colors.test.ts b/src/shared/terminal/pty/ingress/pty-owner-color-query-colors.test.ts deleted file mode 100644 index 29bb2e0c255..00000000000 --- a/src/shared/terminal/pty/ingress/pty-owner-color-query-colors.test.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { expect, it } from 'vitest' -import { - ORCA_DEFAULT_COLOR_QUERY_REPLY_COLORS, - normalizeColorQueryReplyColors, - resolvePtyOwnerColorQueryColors -} from './pty-owner-color-query-colors' - -it('prefers host colours, then spawn colours, then the default, skipping unusable pairs', () => { - const host = { foreground: '#000000', background: '#111111' } - const spawn = { foreground: '#222222', background: '#333333' } - const unusable = { foreground: 'red', background: '#333333' } - - expect(resolvePtyOwnerColorQueryColors(host, spawn)).toEqual(host) - expect(resolvePtyOwnerColorQueryColors(null, spawn)).toEqual(spawn) - expect(resolvePtyOwnerColorQueryColors(unusable, spawn)).toEqual(spawn) - expect(resolvePtyOwnerColorQueryColors(null, unusable)).toBe( - ORCA_DEFAULT_COLOR_QUERY_REPLY_COLORS - ) - expect(resolvePtyOwnerColorQueryColors(undefined, {})).toBe(ORCA_DEFAULT_COLOR_QUERY_REPLY_COLORS) -}) - -it('keeps only a wire pair that answers both slots', () => { - expect( - normalizeColorQueryReplyColors({ foreground: '#fff', background: 'rgb(1, 2, 3)' }) - ).toEqual({ foreground: '#fff', background: 'rgb(1, 2, 3)' }) - expect(normalizeColorQueryReplyColors({ foreground: '#fff' })).toBeNull() - expect(normalizeColorQueryReplyColors({ foreground: '#fff', background: 'blue' })).toBeNull() - expect(normalizeColorQueryReplyColors('#fff')).toBeNull() - expect(normalizeColorQueryReplyColors(null)).toBeNull() -}) - -it("puts a launching client's colours ahead of the host's", () => { - const launcher = { foreground: '#444444', background: '#555555' } - const host = { foreground: '#000000', background: '#111111' } - const spawn = { foreground: '#222222', background: '#333333' } - - expect(resolvePtyOwnerColorQueryColors(host, spawn, launcher)).toEqual(launcher) - expect( - resolvePtyOwnerColorQueryColors(host, spawn, { foreground: 'red', background: '#555555' }) - ).toEqual(host) -}) diff --git a/src/shared/terminal/pty/ingress/pty-startup-ingress-kitty.test.ts b/src/shared/terminal/pty/ingress/pty-startup-ingress-kitty.test.ts index f8510338914..7afde09aef1 100644 --- a/src/shared/terminal/pty/ingress/pty-startup-ingress-kitty.test.ts +++ b/src/shared/terminal/pty/ingress/pty-startup-ingress-kitty.test.ts @@ -149,19 +149,3 @@ it.each([undefined, {}, { foreground: '#fff' }, { background: 'invalid' }])( expect(emissions.map((span) => span.data).join('')).toBe('\x1b[c') } ) -it.each([undefined, false, 'true', 1])( - 'rejects colorless intents without explicit Kitty support: %j', - (kittyKeyboardProtocol) => { - expect( - parsePtyStartupIngressIntent({ kittyKeyboardProtocol, deadlineMs: 5000 }) - ).toBeUndefined() - } -) -it.each([-1, 30001, Number.NaN, Infinity, '5000', undefined])( - 'rejects an invalid Kitty-only deadline: %j', - (deadlineMs) => { - expect( - parsePtyStartupIngressIntent({ kittyKeyboardProtocol: true, deadlineMs }) - ).toBeUndefined() - } -) diff --git a/src/shared/terminal/pty/ingress/pty-startup-ingress-launcher-appearance.test.ts b/src/shared/terminal/pty/ingress/pty-startup-ingress-launcher-appearance.test.ts index 8f4d1f56ac1..a845cfe995b 100644 --- a/src/shared/terminal/pty/ingress/pty-startup-ingress-launcher-appearance.test.ts +++ b/src/shared/terminal/pty/ingress/pty-startup-ingress-launcher-appearance.test.ts @@ -175,22 +175,6 @@ describe("a launcher-pinned PTY's owner answers every theme query (#34340)", () }) }) -it('keeps the launcher palette across the wire only beside launcher colours', () => { - const wire = (value: unknown) => parsePtyStartupIngressIntent(JSON.parse(JSON.stringify(value))) - - expect( - wire({ - colors: HOST, - deadlineMs: 0, - launcherColors: LAUNCHER_COLORS, - launcherAttributes: LAUNCHER - })?.launcherAttributes - ).toEqual(LAUNCHER) - expect(wire({ colors: HOST, deadlineMs: 0, launcherAttributes: LAUNCHER })).not.toHaveProperty( - 'launcherAttributes' - ) -}) - it("forwards a mixed OSC's SETs to the viewers when ConPTY's reply write fails", () => { const intent = parsePtyStartupIngressIntent({ colors: HOST, diff --git a/src/shared/terminal/pty/ingress/pty-startup-ingress-live-query-reply.test.ts b/src/shared/terminal/pty/ingress/pty-startup-ingress-live-query-reply.test.ts index 97530028616..47c35443990 100644 --- a/src/shared/terminal/pty/ingress/pty-startup-ingress-live-query-reply.test.ts +++ b/src/shared/terminal/pty/ingress/pty-startup-ingress-live-query-reply.test.ts @@ -3,15 +3,11 @@ import { describe, expect, it } from 'vitest' import { PtyStartupIngress } from './pty-startup-ingress' import type { PtyIngressEmission } from './pty-startup-ingress-contract' import { mode2031SequenceFor } from '../../protocol/queries/terminal-color-scheme-protocol' -import { - extractOnlyCookedEchoSafeQueryReplies, - needsCookedEchoSafeQueryReply -} from '../../protocol/queries/terminal-query-reply' +import { extractOnlyCookedEchoSafeQueryReplies } from '../../protocol/queries/terminal-query-reply' const COLOR_SCHEME_REPLY = mode2031SequenceFor('dark') const OSC_COLOR_REPLY = '\x1b]11;rgb:00/00/00\x07' -const CPR_REPLY = '\x1b[6;1R' -const DA1_REPLY = '\x1b[?1;2c' + // ECHOCTL encodes every C0 including BEL→^G; live PTY transcripts disprove the old ESC-only fixture. const caretEcho = (reply: string): string => [...reply] @@ -41,12 +37,6 @@ const visible = (emissions: readonly PtyIngressEmission[]): string => emissions.map((emission) => emission.data).join('') describe('live query replies', () => { - it('classifies the real mode-2031 reply as cooked-echo-risk', () => { - expect(COLOR_SCHEME_REPLY).toBe('\x1b[?997;1n') - expect(needsCookedEchoSafeQueryReply(COLOR_SCHEME_REPLY)).toBe(true) - expect(needsCookedEchoSafeQueryReply(mode2031SequenceFor('light'))).toBe(true) - }) - it('answers repeated identical replies without collapsing them', () => { const { ingress, writes } = harness() expect(ingress.answerLiveQueryReply(COLOR_SCHEME_REPLY)).toBe(true) @@ -103,16 +93,6 @@ describe('live query replies', () => { ingress.drainAndClose() }) - it('leaves ordinary keystrokes alone', () => { - for (const keystroke of ['y', 'gh auth login\r', '\x1b[A', '\x1b', '\x03']) { - expect(needsCookedEchoSafeQueryReply(keystroke)).toBe(false) - } - const { ingress, writes } = harness() - expect(ingress.answerLiveQueryReply('ls\r')).toBe(false) - expect(writes).toEqual([]) - ingress.drainAndClose() - }) - it('swallows a pre-coalesced repeated ?997 payload', () => { const doubled = COLOR_SCHEME_REPLY + COLOR_SCHEME_REPLY expect(extractOnlyCookedEchoSafeQueryReplies(doubled)).toEqual([ @@ -125,16 +105,6 @@ describe('live query replies', () => { ingress.drainAndClose() }) - it('leaves latency-critical replies on the host path, in call order', () => { - const { ingress, writes } = harness() - // Host writes CPR/DA1 behind daemon post-ready flush; delivery declines them instead of splicing replies into buffered startup commands. - for (const reply of [CPR_REPLY, DA1_REPLY, OSC_COLOR_REPLY + DA1_REPLY]) { - expect(ingress.answerLiveQueryReply(reply)).toBe(false) - } - expect(writes).toEqual([]) - ingress.drainAndClose() - }) - // The seven-byte private-DSR readline needle may remove one exact armed match, never filter ordinary output indefinitely. describe('the short readline needle does not eat ordinary output', () => { const armed = (chunks: readonly string[]): string => { diff --git a/src/shared/terminal/pty/ingress/pty-startup-ingress.test.ts b/src/shared/terminal/pty/ingress/pty-startup-ingress.test.ts index dd3a7f9141b..ed55802f94a 100644 --- a/src/shared/terminal/pty/ingress/pty-startup-ingress.test.ts +++ b/src/shared/terminal/pty/ingress/pty-startup-ingress.test.ts @@ -1,13 +1,12 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { PtyStartupIngress } from './pty-startup-ingress' -import { parsePtyStartupIngressIntent } from './pty-startup-ingress-intent' + import type { PtyIngressEmission } from './pty-startup-ingress-contract' const COLORS = { foreground: '#2e3434', background: '#ffffff' } const FOREGROUND_REPLY = '\x1b]10;rgb:2e2e/3434/3434\x1b\\' const BACKGROUND_REPLY = '\x1b]11;rgb:ffff/ffff/ffff\x1b\\' -// Orca's default dark theme, which answers until a viewer reports colours. -const DEFAULT_FOREGROUND_REPLY = '\x1b]10;rgb:ffff/ffff/ffff\x1b\\' + const DEFAULT_BACKGROUND_REPLY = '\x1b]11;rgb:2828/2c2c/3434\x1b\\' // The two echo shapes a cooked POSIX tty produces for a written reply: ECHOCTL // caret forms, and readline eating `ESC ]` / ST while self-inserting the rest. @@ -47,15 +46,6 @@ function visible(emissions: readonly PtyIngressEmission[]): string { describe('PtyStartupIngress', () => { afterEach(() => vi.useRealTimers()) - it('validates intent colors and deadline bounds', () => { - const intent = { - colors: COLORS, - deadlineMs: 5_000 - } - expect(parsePtyStartupIngressIntent(intent)).toEqual(intent) - expect(parsePtyStartupIngressIntent({ ...intent, deadlineMs: 30_001 })).toBeUndefined() - }) - it('recognizes BEL/ST queries at every split and answers both in order', () => { vi.useFakeTimers() const query = '\x1b]10;?\x07\x1b]11;?\x1b\\' @@ -287,21 +277,6 @@ describe('PtyStartupIngress', () => { expect(writes).toEqual([BACKGROUND_REPLY, '\x1b]11;rgb:1212/3434/5656\x1b\\', BACKGROUND_REPLY]) }) - it('answers a PTY nobody reported colours for from the default theme', () => { - // #22500: a headless host starts the agent before any viewer attaches. - const writes: string[] = [] - const emissions: PtyIngressEmission[] = [] - const ingress = new PtyStartupIngress({ - write: (data) => writes.push(data), - onEmission: (emission) => emissions.push(emission) - }) - - ingress.accept('before\x1b]10;?;?\x1b\\after') - - expect(writes).toEqual([DEFAULT_FOREGROUND_REPLY, DEFAULT_BACKGROUND_REPLY]) - expect(visible(emissions)).toBe('beforeafter') - }) - it('swallows a cooked POSIX echo of its own reply without re-sending it', () => { // Kernel echo leaves the original reply in slave input; resending would become unsolicited stdin after raw mode starts. vi.useFakeTimers() diff --git a/src/shared/terminal/pty/ingress/pty-startup-reply-echo-shapes.test.ts b/src/shared/terminal/pty/ingress/pty-startup-reply-echo-shapes.test.ts index 002ee430f1f..f9f25f3b55e 100644 --- a/src/shared/terminal/pty/ingress/pty-startup-reply-echo-shapes.test.ts +++ b/src/shared/terminal/pty/ingress/pty-startup-reply-echo-shapes.test.ts @@ -39,56 +39,4 @@ describe('replyEchoProjections on a POSIX pty', () => { it.each(LIVE_ECHOES.map((entry) => entry.reply))('matches the verbatim echo of %j', (reply) => { expect(locateEcho(replyEchoProjections(reply, 'posix-pty'), reply).kind).toBe('complete') }) - - // Guard the private-DSR grammar locally: widening a caller must not arm DA1’s shared-prefix BEL needle. - it.each(['\x1b[?1;2c', '\x1b[?0u', '\x1b[?2026;2$y', '\x1b[?12;5R'])( - 'projects no readline needle for %j, which is not a private DSR', - (reply) => { - const needles = replyEchoProjections(reply, 'posix-pty').map( - (projection) => projection.needle - ) - expect(needles.some((needle) => needle.startsWith('\x07'))).toBe(false) - } - ) - - // Peer-supplied empty/short DSR parameters could otherwise arm BEL+n and delete ordinary output. - it.each(['\x1b[?n', '\x1b[?;n', '\x1b[?5n'])( - 'projects no readline needle for %j, which would be too short to be safe', - (reply) => { - const needles = replyEchoProjections(reply, 'posix-pty').map( - (projection) => projection.needle - ) - expect(needles.some((needle) => needle.startsWith('\x07'))).toBe(false) - } - ) - - // Never hold ESC-led partials: a bare ESC could belong to a torn query and would escape unanswered when the hold expires. - it('holds a partial only for needles that do not start with ESC', () => { - for (const { reply } of LIVE_ECHOES) { - for (const projection of replyEchoProjections(reply, 'posix-pty')) { - expect(projection.holdPartial).toBe(!projection.needle.startsWith('\x1b')) - } - } - }) -}) - -describe('replyEchoProjections on other backends', () => { - it('keeps ConPTY on its documented ESC-stripped form', () => { - expect(replyEchoProjections(DSR_997, 'windows-conpty')).toEqual([ - { needle: '[?997;1n', holdPartial: true } - ]) - }) - - // ConPTY BEL echo is unmeasured; preserve current raw-BEL needle rather than guessing. In-tree OSC replies all use ST. - it('leaves a BEL literal in the ConPTY needle, which is unverified', () => { - const [conpty] = replyEchoProjections(OSC11_BEL, 'windows-conpty') - expect(conpty?.needle).toBe(']11;rgb:2e2e/3434/3434\x07') - // The ST reply is the shape #9651 was actually reported against, and it has no BEL. - const [st] = replyEchoProjections(OSC11_ST, 'windows-conpty') - expect(st?.needle).toBe(']11;rgb:2e2e/3434/3434\\') - }) - - it('suppresses nothing when the echo shape is unverified', () => { - expect(replyEchoProjections(DSR_997, 'windows-wsl')).toEqual([]) - }) }) diff --git a/src/shared/terminal/pty/lifecycle/pty-liveness-verdict.test.ts b/src/shared/terminal/pty/lifecycle/pty-liveness-verdict.test.ts deleted file mode 100644 index 48d7afe84c8..00000000000 --- a/src/shared/terminal/pty/lifecycle/pty-liveness-verdict.test.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { describeUnconfirmedAgentStop, describeUnconfirmedStop } from './pty-liveness-verdict' - -describe('unconfirmed-stop sentences', () => { - it('terminates a reason that has no terminator', () => { - expect(describeUnconfirmedStop('its SSH provider is no longer registered')).toBe( - 'The PTY was not confirmed stopped: its SSH provider is no longer registered.' - ) - }) - - it('does not double the terminator on a reason that is already a sentence', () => { - // A relayed lifecycle_conflict message arrives punctuated and printed `...to failed..`. - expect( - describeUnconfirmedAgentStop({ - ptyStopVerdict: 'unverifiable', - ptyStopReason: 'worker w1 cannot transition from stopping to failed.' - }) - ).toBe( - 'The agent terminal was closed but its process could not be confirmed stopped: worker w1 cannot transition from stopping to failed.' - ) - }) - - it('still terminates the live-process wording', () => { - expect(describeUnconfirmedAgentStop({ ptyStopVerdict: 'live' })).toBe( - 'The agent terminal was closed but its process could not be confirmed stopped: it is live.' - ) - }) -}) diff --git a/src/shared/terminal/pty/lifecycle/pty-owner-backend.test.ts b/src/shared/terminal/pty/lifecycle/pty-owner-backend.test.ts deleted file mode 100644 index cda932e69df..00000000000 --- a/src/shared/terminal/pty/lifecycle/pty-owner-backend.test.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { resolvePtyOwnerBackend } from './pty-owner-backend' - -describe('resolvePtyOwnerBackend', () => { - it.each([ - ['win32', 'powershell.exe', null, 'windows-conpty'], - ['win32', 'C:\\Program Files\\Git\\bin\\bash.exe', null, 'windows-conpty'], - ['win32', undefined, null, 'windows-conpty'], - ['win32', 'wsl.exe', null, 'windows-wsl'], - ['win32', 'C:\\Windows\\System32\\wsl.exe', 'Ubuntu', 'windows-wsl'], - ['win32', 'powershell.exe', 'Ubuntu', 'windows-conpty'], - ['win32', undefined, 'Ubuntu', 'windows-wsl'], - ['darwin', '/bin/zsh', null, 'posix-pty'], - ['linux', '/bin/bash', null, 'posix-pty'] - ] as const)('%s %s with %s resolves to %s', (platform, shellPath, wslDistro, expected) => { - expect(resolvePtyOwnerBackend({ platform, shellPath, wslDistro })).toBe(expected) - }) -}) diff --git a/src/shared/terminal/pty/lifecycle/pty-running-work-probe.test.ts b/src/shared/terminal/pty/lifecycle/pty-running-work-probe.test.ts index 983ae3c406c..647103eada4 100644 --- a/src/shared/terminal/pty/lifecycle/pty-running-work-probe.test.ts +++ b/src/shared/terminal/pty/lifecycle/pty-running-work-probe.test.ts @@ -1,52 +1,9 @@ import { afterEach, expect, it, vi } from 'vitest' import { probePtyRunningWorkWithInspection } from './pty-running-work-probe' -import { - clientOnlyUnverifiableInspection, - type TerminalProcessInspection -} from './terminal-process-inspection' +import type { TerminalProcessInspection } from './terminal-process-inspection' afterEach(() => vi.useRealTimers()) -it.each([ - [ - { foregroundProcess: 'zsh', hasChildProcesses: true, childProcessEvidence: 'children' }, - 'live', - undefined - ], - [ - { foregroundProcess: 'zsh', hasChildProcesses: false, childProcessEvidence: 'no-children' }, - 'exited', - undefined - ], - [ - { foregroundProcess: 'zsh', hasChildProcesses: false, childProcessEvidence: 'unverifiable' }, - 'unverifiable', - 'host_child_processes_unobserved' - ], - [clientOnlyUnverifiableInspection('transport_loss'), 'unverifiable', 'transport_loss'], - [{ foregroundProcess: 'zsh', hasChildProcesses: true }, 'live', undefined], - [{ foregroundProcess: 'zsh', hasChildProcesses: false }, 'exited', undefined] -] as const)( - 'keeps the existing owning-host verdict for %j', - async (inspection, verdict, reason) => { - const inspect = vi.fn(async () => inspection) - const [probe] = await probePtyRunningWorkWithInspection( - ['remote:owner:pty'], - { timeoutMs: 1000 }, - inspect - ) - expect(probe).toEqual({ - ptyId: 'remote:owner:pty', - verdict, - ...(reason ? { reason } : {}), - timedOut: false, - remote: true, - ...(verdict === 'unverifiable' ? {} : { foregroundProcess: 'zsh' }) - }) - expect(inspect).toHaveBeenCalledWith('remote:owner:pty') - } -) - it('keeps thrown inspections unverifiable and clears the deadline once all answer', async () => { vi.useFakeTimers() const inspect = vi.fn(async () => { diff --git a/src/shared/terminal/pty/lifecycle/terminal-exit-cause.test.ts b/src/shared/terminal/pty/lifecycle/terminal-exit-cause.test.ts deleted file mode 100644 index 4f26c0d2a07..00000000000 --- a/src/shared/terminal/pty/lifecycle/terminal-exit-cause.test.ts +++ /dev/null @@ -1,59 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { - isProvenProcessExit, - resolveProcessExitCause, - resolveUnreportedExitCause -} from './terminal-exit-cause' - -describe('resolveProcessExitCause', () => { - it('reports a signalled death as a signal, not as the zero node-pty pairs with it', () => { - expect(resolveProcessExitCause({ exitCode: 0, signal: 9 })).toEqual({ - kind: 'signaled', - signal: 9 - }) - }) - - it('refuses to read a status the host cannot report', () => { - // macOS login -flpq returns 0 for both exit 42 and SIGKILL. - expect( - resolveProcessExitCause({ exitCode: 0, signal: 0, hostReportsChildExitStatus: false }) - ).toEqual({ kind: 'unknown', reason: 'host_status_unavailable' }) - expect( - resolveProcessExitCause({ exitCode: 42, signal: 9, hostReportsChildExitStatus: false }) - ).toEqual({ kind: 'unknown', reason: 'host_status_unavailable' }) - }) - - it('treats the stop paths’ negative sentinel as absence of evidence', () => { - expect(resolveProcessExitCause({ exitCode: -1 })).toEqual({ - kind: 'unknown', - reason: 'stop_unverified' - }) - }) -}) - -describe('resolveUnreportedExitCause', () => { - it('refuses to turn a bare zero into a clean finish', () => { - // Older daemons/relays supply no cause; bare zero cannot distinguish a clean exit from a signal. - expect(resolveUnreportedExitCause(0)).toEqual({ kind: 'unknown', reason: 'cause_unreported' }) - }) - - it('keeps a bare nonzero status, which nothing fabricates', () => { - // node-pty pairs signals with 0 and the wrapper returns 0, so nonzero preserves the child’s wait status. - expect(resolveUnreportedExitCause(137)).toEqual({ kind: 'exited', exitCode: 137 }) - }) -}) - -describe('isProvenProcessExit', () => { - it('rejects the synthetic loss sentinel', () => { - // A host shutdown can deliver -1 for every PTY without proving process death. - expect(isProvenProcessExit(-1)).toBe(false) - }) - - it('keeps a shell that a user closed with `exit` tearing down on macOS', () => { - // macOS login hides the cause but waits for the shell; its exit still proves shell death and permits teardown. - expect( - resolveProcessExitCause({ exitCode: 0, signal: 0, hostReportsChildExitStatus: false }).kind - ).toBe('unknown') - expect(isProvenProcessExit(0)).toBe(true) - }) -}) diff --git a/src/shared/terminal/pty/native-process/pty-slave-line-discipline-echo.test.ts b/src/shared/terminal/pty/native-process/pty-slave-line-discipline-echo.test.ts index 6ce2611e287..cf78f5cddd4 100644 --- a/src/shared/terminal/pty/native-process/pty-slave-line-discipline-echo.test.ts +++ b/src/shared/terminal/pty/native-process/pty-slave-line-discipline-echo.test.ts @@ -3,7 +3,7 @@ import { describe, expect, it, vi, beforeEach } from 'vitest' const execFileMock = vi.hoisted(() => vi.fn()) vi.mock('node:child_process', () => ({ execFile: execFileMock })) -import { createPtySlaveLineEditorProbe, readPtySlavePath } from './pty-slave-line-discipline-echo' +import { createPtySlaveLineEditorProbe } from './pty-slave-line-discipline-echo' /** Replies to the next stty call with the given output, or an error when `output` is null. */ function answerStty(output: string | null): void { @@ -20,18 +20,6 @@ beforeEach(() => { execFileMock.mockReset() }) -describe('readPtySlavePath', () => { - it('reads node-pty ptsName and rejects every shape that is not a usable path', () => { - expect(readPtySlavePath({ ptsName: '/dev/ttys048' })).toBe('/dev/ttys048') - // A ConPTY terminal has no ptsName at all, and an empty one names no device. - expect(readPtySlavePath({})).toBeUndefined() - expect(readPtySlavePath({ ptsName: '' })).toBeUndefined() - expect(readPtySlavePath({ ptsName: 12 })).toBeUndefined() - expect(readPtySlavePath(undefined)).toBeUndefined() - expect(readPtySlavePath(null)).toBeUndefined() - }) -}) - describe('createPtySlaveLineEditorProbe', () => { it('requires raw quiet mode with the line editor disabling literal-next', async () => { const probe = createPtySlaveLineEditorProbe('/dev/ttys048', 'darwin') @@ -64,15 +52,6 @@ describe('createPtySlaveLineEditorProbe', () => { await expect(second).resolves.toBe('line-editor') }) - it('passes the device with the flag its own platform understands', async () => { - answerStty(LINE_EDITOR) - await createPtySlaveLineEditorProbe('/dev/ttys048', 'darwin')?.() - expect(execFileMock.mock.calls[0]?.[1]).toEqual(['-a', '-f', '/dev/ttys048']) - answerStty(LINE_EDITOR) - await createPtySlaveLineEditorProbe('/dev/pts/3', 'linux')?.() - expect(execFileMock.mock.calls[1]?.[1]).toEqual(['-a', '-F', '/dev/pts/3']) - }) - it('keeps probing after a transient failure and only latches a permanent one', async () => { const probe = createPtySlaveLineEditorProbe('/dev/ttys048', 'darwin') // Burst restore causes transient EAGAIN/timeouts; those must not disable readiness inspection for the PTY’s lifetime. diff --git a/src/shared/terminal/pty/native-process/shell-process-readiness.test.ts b/src/shared/terminal/pty/native-process/shell-process-readiness.test.ts index 7de11d6fb57..5bfbb01e5b3 100644 --- a/src/shared/terminal/pty/native-process/shell-process-readiness.test.ts +++ b/src/shared/terminal/pty/native-process/shell-process-readiness.test.ts @@ -53,15 +53,6 @@ describe('shell process readiness', () => { } ) - it.skipIf(process.platform === 'win32')( - 'uses the POSIX exec default when PATH is unset', - async () => { - await expect(resolveShellExecutablePath('sh', process.cwd(), undefined)).resolves.toBe( - await resolveShellExecutablePath('/bin/sh', process.cwd(), '') - ) - } - ) - it.skipIf(process.platform === 'win32')( 'resolves relative shell paths against the PTY cwd', async () => { diff --git a/src/shared/terminal/quick-commands/terminal-quick-commands.test.ts b/src/shared/terminal/quick-commands/terminal-quick-commands.test.ts deleted file mode 100644 index 366f922fad7..00000000000 --- a/src/shared/terminal/quick-commands/terminal-quick-commands.test.ts +++ /dev/null @@ -1,208 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { - applyTerminalQuickCommandMutation, - flattenTerminalQuickCommand, - normalizeTerminalQuickCommands, - parseNormalizedTerminalQuickCommands, - terminalQuickCommandMatchesRepo -} from './terminal-quick-commands' - -describe('terminal quick commands', () => { - it('preserves lists larger than forty commands across loading and protocol parsing', () => { - const commands = Array.from({ length: 100 }, (_, index) => ({ - id: `command-${index}`, - label: `Command ${index}`, - action: 'terminal-command' as const, - command: 'echo saved', - appendEnter: true, - scope: { type: 'repo' as const, repoId: `project-${index}` } - })) - - expect(normalizeTerminalQuickCommands(commands)).toEqual(commands) - expect(parseNormalizedTerminalQuickCommands(commands)).toEqual(commands) - }) - - it('normalizes agent prompt commands without storing generated shell text', () => { - expect( - normalizeTerminalQuickCommands([ - { - id: 'agent-review', - label: 'Review', - action: 'agent-prompt', - agent: 'codex', - prompt: ' Review this diff\n', - command: "codex 'old workaround'" - }, - { - id: 'unknown-agent', - label: 'Unknown', - action: 'agent-prompt', - agent: 'not-real', - prompt: 'Do work' - }, - { - id: 'post-start-agent', - label: 'Aider', - action: 'agent-prompt', - agent: 'aider', - prompt: 'Do work' - } - ]) - ).toEqual([ - { - id: 'agent-review', - label: 'Review', - action: 'agent-prompt', - agent: 'codex', - prompt: ' Review this diff', - scope: { type: 'global' } - } - ]) - }) - - it('keeps agent prompts whole while bounding shell commands', () => { - const largePrompt = 'Review this diff.\n'.repeat(320) - const overLimitPrompt = 'x'.repeat(6001) - const overLimitCommand = 'y'.repeat(4001) - - expect( - normalizeTerminalQuickCommands([ - { - id: 'large-review', - label: 'Review', - action: 'agent-prompt', - agent: 'codex', - prompt: largePrompt - }, - { - id: 'over-limit-review', - label: 'Review with cap', - action: 'agent-prompt', - agent: 'codex', - prompt: overLimitPrompt - }, - { - id: 'over-limit-command', - label: 'Run long command', - command: overLimitCommand - } - ]) - ).toEqual([ - { - id: 'large-review', - label: 'Review', - action: 'agent-prompt', - agent: 'codex', - prompt: largePrompt.trimEnd(), - scope: { type: 'global' } - }, - { - id: 'over-limit-review', - label: 'Review with cap', - action: 'agent-prompt', - agent: 'codex', - prompt: overLimitPrompt, - scope: { type: 'global' } - }, - { - id: 'over-limit-command', - label: 'Run long command', - action: 'terminal-command', - command: 'y'.repeat(4000), - appendEnter: true, - scope: { type: 'global' } - } - ]) - }) - - it('accepts only complete canonical command lists at protocol boundaries', () => { - const canonical = normalizeTerminalQuickCommands([ - { id: 'status', label: 'Status', command: 'git status', appendEnter: true } - ]) - - expect(parseNormalizedTerminalQuickCommands(canonical)).toEqual(canonical) - expect(parseNormalizedTerminalQuickCommands([{ ...canonical[0], command: 42 }])).toBeNull() - expect( - parseNormalizedTerminalQuickCommands([...canonical, ...canonical.slice(0, 1)]) - ).toBeNull() - }) - - it('applies targeted mutations without replacing unrelated commands', () => { - const [first, second] = normalizeTerminalQuickCommands([ - { id: 'first', label: 'First', command: 'echo first', appendEnter: true }, - { id: 'second', label: 'Second', command: 'echo second', appendEnter: true } - ]) - const edited = { ...first!, label: 'Edited' } - - expect( - applyTerminalQuickCommandMutation([first!, second!], { - type: 'upsert', - command: edited - }) - ).toEqual([edited, second]) - expect( - applyTerminalQuickCommandMutation([first!, second!], { type: 'delete', id: first!.id }) - ).toEqual([second]) - }) - - it('matches global commands everywhere and repo commands only in their repo', () => { - expect( - terminalQuickCommandMatchesRepo( - { - id: 'global', - label: 'Global', - command: 'date', - appendEnter: true, - scope: { type: 'global' } - }, - null - ) - ).toBe(true) - expect( - terminalQuickCommandMatchesRepo( - { - id: 'repo', - label: 'Repo', - command: 'pnpm dev', - appendEnter: true, - scope: { type: 'repo', repoId: 'repo-1' } - }, - 'repo-1' - ) - ).toBe(true) - expect( - terminalQuickCommandMatchesRepo( - { - id: 'repo', - label: 'Repo', - command: 'pnpm dev', - appendEnter: true, - scope: { type: 'repo', repoId: 'repo-1' } - }, - 'repo-2' - ) - ).toBe(false) - }) -}) - -describe('flattenTerminalQuickCommand', () => { - it('handles Windows-style CRLF endings', () => { - const result = flattenTerminalQuickCommand({ - id: 'test', - label: 'Test', - command: 'echo one\r\necho two', - appendEnter: true - }) - expect(result.command).toBe('echo one; echo two') - }) - - it('drops empty edge lines without leaving dangling separators', () => { - const result = flattenTerminalQuickCommand({ - id: 'test', - label: 'Test', - command: '\n echo one \n\n echo two\n', - appendEnter: true - }) - expect(result.command).toBe('echo one; echo two') - }) -}) diff --git a/src/shared/terminal/replay/terminal-scrollback-policy.test.ts b/src/shared/terminal/replay/terminal-scrollback-policy.test.ts deleted file mode 100644 index 56c27b3934e..00000000000 --- a/src/shared/terminal/replay/terminal-scrollback-policy.test.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { - legacyTerminalScrollbackBytesToRows, - normalizeDesktopTerminalScrollbackRows, - normalizeDesktopTerminalSnapshotRows, - TERMINAL_OUTPUT_BACKLOG_MIN_CAP_CHARS, - terminalOutputBacklogCapChars -} from './terminal-scrollback-policy' - -describe('terminal scrollback policy', () => { - it('normalizes persisted desktop rows without string coercion', () => { - expect(normalizeDesktopTerminalScrollbackRows(undefined)).toBe(5_000) - expect(normalizeDesktopTerminalScrollbackRows('25000')).toBe(5_000) - expect(normalizeDesktopTerminalScrollbackRows(Number.NaN)).toBe(5_000) - expect(normalizeDesktopTerminalScrollbackRows(500.9)).toBe(1_000) - expect(normalizeDesktopTerminalScrollbackRows(25_000.9)).toBe(25_000) - expect(normalizeDesktopTerminalScrollbackRows(100_000)).toBe(50_000) - }) - - it('normalizes snapshot rows while preserving visible-screen-only zero', () => { - expect(normalizeDesktopTerminalSnapshotRows(undefined)).toBeUndefined() - expect(normalizeDesktopTerminalSnapshotRows('0')).toBeUndefined() - expect(normalizeDesktopTerminalSnapshotRows(0)).toBe(0) - expect(normalizeDesktopTerminalSnapshotRows(-1)).toBe(0) - expect(normalizeDesktopTerminalSnapshotRows(25_000.9)).toBe(25_000) - expect(normalizeDesktopTerminalSnapshotRows(100_000)).toBe(50_000) - }) - - it('scales the output backlog cap with scrollback rows above a 2 MB floor', () => { - // Large scrollback raises the cap so backlog drops do not discard history the setting would retain. - expect(terminalOutputBacklogCapChars(undefined)).toBe(TERMINAL_OUTPUT_BACKLOG_MIN_CAP_CHARS) - expect(terminalOutputBacklogCapChars(5_000)).toBe(TERMINAL_OUTPUT_BACKLOG_MIN_CAP_CHARS) - expect(terminalOutputBacklogCapChars('garbage')).toBe(TERMINAL_OUTPUT_BACKLOG_MIN_CAP_CHARS) - expect(terminalOutputBacklogCapChars(25_000)).toBe(3_000_000) - expect(terminalOutputBacklogCapChars(50_000)).toBe(6_000_000) - // Values beyond the settings max clamp like the setting itself does. - expect(terminalOutputBacklogCapChars(1_000_000)).toBe(6_000_000) - }) - - it('migrates legacy decimal MB buckets by intent, not byte-to-row math', () => { - expect(legacyTerminalScrollbackBytesToRows(undefined)).toBe(5_000) - expect(legacyTerminalScrollbackBytesToRows(0)).toBe(5_000) - expect(legacyTerminalScrollbackBytesToRows(1_000_000)).toBe(1_000) - expect(legacyTerminalScrollbackBytesToRows(10_000_000)).toBe(5_000) - expect(legacyTerminalScrollbackBytesToRows(25_000_000)).toBe(10_000) - expect(legacyTerminalScrollbackBytesToRows(50_000_000)).toBe(25_000) - expect(legacyTerminalScrollbackBytesToRows(100_000_000)).toBe(50_000) - expect(legacyTerminalScrollbackBytesToRows(250_000_000)).toBe(50_000) - }) -}) diff --git a/src/shared/terminal/stream/terminal-stream-protocol.test.ts b/src/shared/terminal/stream/terminal-stream-protocol.test.ts index 1b9b733cb65..a557ceec8ab 100644 --- a/src/shared/terminal/stream/terminal-stream-protocol.test.ts +++ b/src/shared/terminal/stream/terminal-stream-protocol.test.ts @@ -1,9 +1,6 @@ import { describe, expect, it, vi } from 'vitest' import { - TerminalStreamOpcode, - decodeTerminalStreamFrame, decodeTerminalStreamJson, - encodeTerminalStreamFrame, TERMINAL_STREAM_JSON_STRUCTURE_LIMITS } from './terminal-stream-protocol' @@ -20,21 +17,4 @@ describe('terminal-stream-protocol', () => { parseSpy.mockRestore() } }) - - it('rejects unknown frame versions and opcodes', () => { - const encoded = encodeTerminalStreamFrame({ - opcode: TerminalStreamOpcode.Output, - streamId: 1, - seq: 1, - payload: new Uint8Array() - }) - - const badVersion = encoded.slice() - badVersion[1] = 99 - expect(decodeTerminalStreamFrame(badVersion)).toBeNull() - - const badOpcode = encoded.slice() - badOpcode[2] = 99 - expect(decodeTerminalStreamFrame(badOpcode)).toBeNull() - }) })