test: remove 997 low-value test declarations (#38058)

Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>
This commit is contained in:
OrcaWin
2026-11-03 23:59:28 -08:00
committed by GitHub
co-authored by OrcaWin
parent 6216e2ee89
commit 74df8bf42e
227 changed files with 226 additions and 27546 deletions
+3 -3
View File
@@ -5,7 +5,7 @@
"version": "3.0.3",
"advisory": "GHSA-vfj7-8cjw-p6xm",
"owner": "Orca maintainers",
"reason": "Parser and AST walkers reject excessive nesting via the patch merged in #31681, covered by dependency-security-patches.test.mjs. Remove when a fixed upstream release is available.",
"reason": "Parser and AST walkers reject excessive nesting via the patch merged in #31681. Remove when a fixed upstream release is available.",
"expires": "2026-11-20",
"patch": {
"path": "patches/braces@3.0.3.patch",
@@ -18,7 +18,7 @@
"version": "3.0.3",
"advisory": "GHSA-vfj7-8cjw-p6xm",
"owner": "Orca maintainers",
"reason": "Parser and AST walkers reject excessive nesting via the patch merged in #31681, covered by dependency-security-patches.test.mjs. Remove when a fixed upstream release is available.",
"reason": "Parser and AST walkers reject excessive nesting via the patch merged in #31681. Remove when a fixed upstream release is available.",
"expires": "2026-11-20",
"patch": {
"path": "../../config/patches/braces@3.0.3.patch",
@@ -31,7 +31,7 @@
"version": "3.0.3",
"advisory": "GHSA-vfj7-8cjw-p6xm",
"owner": "Orca maintainers",
"reason": "Parser and AST walkers reject excessive nesting via the patch merged in #31681, covered by dependency-security-patches.test.mjs. Remove when a fixed upstream release is available.",
"reason": "Parser and AST walkers reject excessive nesting via the patch merged in #31681. Remove when a fixed upstream release is available.",
"expires": "2026-11-20",
"patch": {
"path": "../patches/braces@3.0.3.patch",
+1 -4
View File
@@ -64,7 +64,6 @@
1 test-no-polling-loop:src/main/daemon/daemon-idle-shutdown.test.ts
1 test-no-polling-loop:src/main/daemon/daemon-self-retirement-respawn.test.ts
1 test-no-polling-loop:src/main/daemon/daemon-server.test.ts
1 test-no-polling-loop:src/main/daemon/daemon-stream-backpressure-socket.test.ts
1 test-no-polling-loop:src/main/daemon/headless-emulator-snapshot-cost.bench.test.ts
1 test-no-polling-loop:src/main/daemon/node-pty-conout-worker-errors.test.ts
1 test-no-polling-loop:src/main/daemon/repro-12101-mouse-tracking-survives-agent-death.test.ts
@@ -182,7 +181,6 @@
1 test-no-real-time-wait:src/main/claude-usage/scanner-incremental-reader.test.ts
1 test-no-real-time-wait:src/main/claude/claude-agent-sdk-exit-proof.test.ts
1 test-no-real-time-wait:src/main/claude/claude-agent-sdk-user-message-queue.test.ts
1 test-no-real-time-wait:src/main/claude/claude-api-retry-idle-sweep.test.ts
1 test-no-real-time-wait:src/main/claude/claude-stop-ack-race.test.ts
1 test-no-real-time-wait:src/main/claude/claude-subagent-permission-request.test.ts
1 test-no-real-time-wait:src/main/claude/claude-supervised-stop-real-cli.test.ts
@@ -203,7 +201,6 @@
1 test-no-real-time-wait:src/main/daemon/daemon-self-retirement-respawn.test.ts
1 test-no-real-time-wait:src/main/daemon/daemon-server.test.ts
1 test-no-real-time-wait:src/main/daemon/daemon-shutdown-final-checkpoint-caller-deadline.test.ts
1 test-no-real-time-wait:src/main/daemon/daemon-stream-backpressure-socket.test.ts
1 test-no-real-time-wait:src/main/daemon/daemon-tcc-attribution.test.ts
1 test-no-real-time-wait:src/main/daemon/headless-emulator-snapshot-cost.bench.test.ts
1 test-no-real-time-wait:src/main/daemon/node-pty-windows-output-error.win32.test.ts
@@ -1226,7 +1223,7 @@
3 test-no-real-time-wait:src/main/startup/run-electron-vite-dev-web.test.ts
3 test-no-real-time-wait:src/main/startup/run-electron-vite-dev.test.ts
3 test-no-real-time-wait:src/main/windows/windows-host-job.win32.test.ts
3 test-no-real-time-wait:src/main/wsl/execution/wsl-command-loop.test.ts
2 test-no-real-time-wait:src/main/wsl/execution/wsl-command-loop.test.ts
3 test-no-real-time-wait:src/main/wsl/orcad/wsl-orcad-watch.test.ts
3 test-no-real-time-wait:src/renderer/src/components/terminal-pane/pty-connection-hidden-backlog-snapshot.test.ts
3 test-no-real-time-wait:src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-subscribe-ack-input.test.ts
+3 -3
View File
@@ -24,9 +24,9 @@ The formatting patch applies to desktop and mobile; mobile's older sprintf-js
Mobile patch files stay inside its independent workspace for standalone builds.
`config/scripts/dependency-security-patches.test.mjs` exercises excessive numeric
precision in both installed desktop formatting entries, each desktop minimatch copy's `**/..`
limit, and glob abort. It uses the packages installed from the workspace lockfile
without running installs or accessing the registry. The mobile dependency-only
precision in both installed desktop formatting entries. The minimatch pattern-count
and glob read-count examples were retired without replacement. The remaining test
uses packages installed from the workspace lockfile without installs or registry access. The mobile dependency-only
regression suite was retired; those package examples have no replacement here. Normal patterns and formatting
retain their previous behavior. Excessive brace patterns now fail explicitly;
out-of-range numeric precision is clamped instead of throwing a RangeError.
File diff suppressed because one or more lines are too long
+1 -37
View File
@@ -1,20 +1,7 @@
import { mkdtempSync, readFileSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join, resolve } from 'node:path'
import { afterEach, describe, expect, it, vi } from 'vitest'
import RuntimeSequencer from './vitest-runtime-sequencer.mjs'
import { describe, expect, it } from 'vitest'
import { balanceFiles } from './ci-shard-assignment.mjs'
import { discoverE2eFiles, planE2e, verifyE2eSelection } from './ci-e2e-shard-plan.mjs'
import { parseTimingLog } from './ci-shard-timing-import.mjs'
import TimingSequencer from './ci-unit-sequencer.mjs'
const directories = []
afterEach(() => {
vi.unstubAllEnvs()
for (const directory of directories.splice(0)) {
rmSync(directory, { recursive: true, force: true })
}
})
describe('timing-weighted shard selection', () => {
it('distributes long files, includes unknowns exactly once, and ignores discovery order', () => {
@@ -37,29 +24,6 @@ describe('timing-weighted shard selection', () => {
expect(() => balanceFiles(['a'], 0, {})).toThrow('count')
})
it('uses the post-filter Vitest discovery unchanged across eight shards', async () => {
const directory = mkdtempSync(join(tmpdir(), 'orca-unit-shards-'))
directories.push(directory)
vi.stubEnv('ORCA_SHARD_MANIFEST', join(directory, 'assignment.json'))
const specs = Array.from({ length: 37 }, (_, i) => ({
moduleId: resolve(`src/fixture-${i}.test.ts`)
}))
const selected = []
for (let index = 1; index <= 8; index++) {
const sequencer = new TimingSequencer({
config: { root: process.cwd(), shard: { index, count: 8 } }
})
expect(sequencer.sort).toBe(RuntimeSequencer.prototype.sort)
selected.push(...(await sequencer.shard(specs)))
const manifest = JSON.parse(readFileSync(join(directory, 'assignment.json'), 'utf8'))
expect(manifest.selectedShard).toBe(index)
expect(manifest.baselineSha256).toMatch(/^[a-f0-9]{64}$/)
}
expect(new Set(selected).size).toBe(specs.length)
expect(selected).toHaveLength(specs.length)
expect(new Set(selected)).toEqual(new Set(specs))
})
it('keeps nested/serial E2E files atomic and fails closed on discovery errors', () => {
const spec = (id, file) => ({ id, file, tests: [{ projectName: 'electron-headless' }] })
const report = {
-113
View File
@@ -793,7 +793,6 @@
"src/main/agent-hooks/grok-replay-guard.test.ts": 533,
"src/main/agent-hooks/hook-process-capture.test.ts": 118,
"src/main/agent-hooks/hook-script-outside-orca.test.ts": 171,
"src/main/agent-hooks/hook-status-session-tabs-republish.test.ts": 645,
"src/main/agent-hooks/installer-utils-remote.test.ts": 75,
"src/main/agent-hooks/installer-utils.test.ts": 264,
"src/main/agent-hooks/local-agent-cli-presence.test.ts": 153,
@@ -1005,7 +1004,6 @@
"src/main/ai-vault-search/session-search-store-is-memory.test.ts": 805,
"src/main/ai-vault-search/session-search-store-scope-catalog.test.ts": 268,
"src/main/ai-vault-search/session-search-synthetic-corpus.test.ts": 372,
"src/main/ai-vault-search/session-search-transcript-dependencies.test.ts": 505,
"src/main/ai-vault-search/session-search-typo-policy.test.ts": 158,
"src/main/ai-vault-search/session-search-typo-scope.test.ts": 195,
"src/main/ai-vault-search/session-search-write-buffer.test.ts": 197,
@@ -1078,7 +1076,6 @@
"src/main/ai-vault/scanners/devin/session-scanner-devin-parser.test.ts": 283,
"src/main/ai-vault/session-scanner-directory-reader.test.ts": 190,
"src/main/ai-vault/session-scanner-discovery-pipeline.test.ts": 244,
"src/main/ai-vault/session-scanner-discovery-wsl-gate.test.ts": 180,
"src/main/ai-vault/scanners/droid/session-scanner-droid-parser.test.ts": 166,
"src/main/ai-vault/session-scanner-first-user-prompt.test.ts": 60,
"src/main/ai-vault/scanners/session-scanner-graph-parsers.test.ts": 176,
@@ -1157,7 +1154,6 @@
"src/main/ai-vault/structured-chat-conversation-copy.test.ts": 917,
"src/main/ai-vault/structured-session-ownership-acp-and-pi.test.ts": 2020,
"src/main/ai-vault/structured-session-ownership-client-audience.test.ts": 1092,
"src/main/ai-vault/structured-session-ownership-shell-commands.test.ts": 930,
"src/main/ai-vault/structured-session-ownership.test.ts": 1191,
"src/main/amp/hook-service.test.ts": 68,
"src/main/antigravity/antigravity-acp-runtime-probe.test.ts": 470,
@@ -1235,7 +1231,6 @@
"src/main/bitbucket/client.test.ts": 496,
"src/main/bitbucket/credential-connection.test.ts": 156,
"src/main/bitbucket/credential-store.test.ts": 245,
"src/main/bitbucket/pull-request-creation.test.ts": 417,
"src/main/bitbucket/pull-request-mappers.test.ts": 44,
"src/main/bitbucket/repository-ref.test.ts": 426,
"src/main/bitbucket/status-no-decrypt.test.ts": 256,
@@ -1290,7 +1285,6 @@
"src/main/browser/client-hosts/file-transfers/browser-client-upload-staging.test.ts": 140,
"src/main/browser/client-hosts/file-transfers/browser-client-upload-transfer.test.ts": 93,
"src/main/browser/cdp/input/browser-element-functions.test.ts": 228,
"src/main/browser/guest-lifecycle/prompts/browser-external-app-launch.test.ts": 96,
"src/main/browser/guest-lifecycle/find/browser-find-frame-traversal.electron.test.ts": 1807,
"src/main/browser/cookies/browser-google-cookie-clear-target.test.ts": 6235,
"src/main/browser/element-grab/browser-grab-payload.test.ts": 55,
@@ -1325,8 +1319,6 @@
"src/main/browser/guest-lifecycle/manager/browser-manager-tab-identity.test.ts": 619,
"src/main/browser/guest-lifecycle/manager/browser-manager-viewport-override.test.ts": 597,
"src/main/browser/guest-lifecycle/manager/browser-manager-viewport-ownership.test.ts": 550,
"src/main/browser/guest-lifecycle/manager/browser-manager-viewport-partial-failure.test.ts": 547,
"src/main/browser/guest-lifecycle/manager/browser-manager-worker-request-user-agent.test.ts": 498,
"src/main/browser/network/execution-routes/browser-network-deferred-socket.test.ts": 101,
"src/main/browser/network/execution-routes/browser-network-execution-route.test.ts": 272,
"src/main/browser/network/tunnels/browser-network-tunnel-client.test.ts": 127,
@@ -1382,7 +1374,6 @@
"src/main/browser/webauthn/browser-webauthn-profile-delete.test.ts": 358,
"src/main/browser/cdp/commands/cdp-bridge-integration.test.ts": 28361,
"src/main/browser/cdp/transport/cdp-client-response-writer.test.ts": 279,
"src/main/browser/cdp/input/cdp-keyboard-us-layout.test.ts": 84,
"src/main/browser/cdp/commands/cdp-print-to-pdf.test.ts": 68,
"src/main/browser/cdp/commands/cdp-screenshot.test.ts": 415,
"src/main/browser/cdp/transport/cdp-ws-proxy-first-paint.test.ts": 366,
@@ -1583,7 +1574,6 @@
"src/main/claude/claude-agent-sdk-process-spawn.test.ts": 182,
"src/main/claude/claude-agent-sdk-root-kill-fallback.test.ts": 154,
"src/main/claude/claude-agent-sdk-user-message-queue.test.ts": 143,
"src/main/claude/claude-api-retry-idle-sweep.test.ts": 2411,
"src/main/claude/claude-api-retry-row.test.ts": 901,
"src/main/claude/claude-background-run-isolation-real-cli.test.ts": 147,
"src/main/claude/claude-background-stop-records.test.ts": 1249,
@@ -1613,7 +1603,6 @@
"src/main/claude/claude-descendant-escalation-boundary.test.ts": 593,
"src/main/claude/claude-diff-panel-auto-open.test.ts": 117,
"src/main/claude/claude-fold-steer-receipt.test.ts": 1019,
"src/main/claude/claude-folder-trust-file-wsl-guest.test.ts": 72,
"src/main/claude/claude-folder-trust-file.test.ts": 762,
"src/main/claude/claude-hook-decision-script.test.ts": 336,
"src/main/claude/claude-hook-event-install.test.ts": 198,
@@ -1711,7 +1700,6 @@
"src/main/claude/structured/launch/claude-structured-options.test.ts": 420,
"src/main/claude/structured/launch/claude-structured-provider-fallback.test.ts": 670,
"src/main/claude/structured/launch/claude-structured-real-cli-options.test.ts": 902,
"src/main/claude/structured/launch/claude-structured-spawn-options.test.ts": 344,
"src/main/claude/windows-hook-files.test.ts": 356,
"src/main/claude/windows-native-hook-registration.test.ts": 246,
"src/main/cli/appimage-copy-pre-extraction.test.ts": 328,
@@ -1823,7 +1811,6 @@
"src/main/codex-usage/codex-rollout-file-lifetime.test.ts": 173,
"src/main/codex-usage/codex-rollout-resume-state.test.ts": 67,
"src/main/codex-usage/codex-usage-long-context-billing.test.ts": 615,
"src/main/codex-usage/scanner-attribution-memo.test.ts": 396,
"src/main/codex-usage/scanner-checkpoint-races.test.ts": 523,
"src/main/codex-usage/scanner-file-generation.test.ts": 480,
"src/main/codex-usage/scanner-file-identity.test.ts": 389,
@@ -1841,7 +1828,6 @@
"src/main/codex/app-server/codex-app-server-connection-exit-order.test.ts": 264,
"src/main/codex/app-server/codex-app-server-connection-tree-unproven.test.ts": 634,
"src/main/codex/app-server/codex-app-server-direct-open.test.ts": 280,
"src/main/codex/app-server/codex-app-server-session-stop.test.ts": 174,
"src/main/codex/app-server/codex-app-server-session-supervision.test.ts": 14357,
"src/main/codex/app-server/codex-app-server-session.test.ts": 343,
"src/main/codex/app-server/codex-app-server-teardown.integration.test.ts": 16506,
@@ -2209,7 +2195,6 @@
"src/main/daemon/daemon-pty-adapter-wsl-distro-loss-relaunch.test.ts": 1128,
"src/main/daemon/daemon-pty-adapter-wsl-distro-loss.test.ts": 1098,
"src/main/daemon/daemon-pty-adapter-wsl-passive-restore-hold.test.ts": 892,
"src/main/daemon/daemon-pty-adapter-wsl-quit-cwd.test.ts": 955,
"src/main/daemon/daemon-pty-adapter-wsl-sleep-cwd-relaunch.test.ts": 1248,
"src/main/daemon/daemon-pty-adapter-wsl-sleep-cwd.test.ts": 1166,
"src/main/daemon/daemon-pty-adapter.test.ts": 2102,
@@ -2223,7 +2208,6 @@
"src/main/daemon/daemon-pty-router-unanswering-legacy.test.ts": 5191,
"src/main/daemon/daemon-pty-router.test.ts": 914,
"src/main/daemon/daemon-pty-spawn-preparations.test.ts": 48,
"src/main/daemon/daemon-pty-startup-delivery.test.ts": 1156,
"src/main/daemon/daemon-pty-upgrade-adoption.test.ts": 939,
"src/main/daemon/daemon-pty-write-settlement-recovery.test.ts": 552,
"src/main/daemon/daemon-ready-identity.test.ts": 53,
@@ -2248,7 +2232,6 @@
"src/main/daemon/daemon-shutdown-final-checkpoint-caller-deadline.test.ts": 2543,
"src/main/daemon/daemon-socket-endpoint-path.test.ts": 529,
"src/main/daemon/daemon-spawner.test.ts": 708,
"src/main/daemon/daemon-stream-backpressure-socket.test.ts": 293,
"src/main/daemon/daemon-stream-backpressure.test.ts": 63,
"src/main/daemon/daemon-stream-binary-framing.test.ts": 87,
"src/main/daemon/daemon-stream-binary-negotiation.test.ts": 847,
@@ -2599,9 +2582,7 @@
"src/main/git/worktrees/creation/worktree-add-local-base-refresh.test.ts": 362,
"src/main/git/worktrees/creation/worktree-add-pending-removal.test.ts": 346,
"src/main/git/worktrees/creation/worktree-add-remote-branch-upstream-real-git.test.ts": 786,
"src/main/git/worktrees/creation/worktree-add-timeout-override.test.ts": 334,
"src/main/git/worktrees/creation/worktree-base-divergence-real-git.test.ts": 707,
"src/main/git/worktrees/creation/worktree-base-divergence.test.ts": 102,
"src/main/git/worktrees/creation/worktree-base-refresh-contention.test.ts": 269,
"src/main/git/worktrees/creation/worktree-base-refresh-lock-real-git.test.ts": 1588,
"src/main/git/worktrees/creation/worktree-checkout-progress-real-git.test.ts": 769,
@@ -2626,7 +2607,6 @@
"src/main/git/worktrees/movement/worktree-mutation-route-invalidation.test.ts": 287,
"src/main/git/worktrees/catalog/worktree-path-deduplication.test.ts": 220,
"src/main/git/worktrees/catalog/worktree-porcelain-parsing.test.ts": 357,
"src/main/git/worktrees/removal/worktree-posix-path-case-sensitivity.test.ts": 279,
"src/main/git/worktrees/creation/worktree-preparation-add.test.ts": 233,
"src/main/git/worktrees/creation/worktree-preparation-base-oid.test.ts": 322,
"src/main/git/worktrees/creation/worktree-preparation-cancel-latency.bench.test.ts": 361,
@@ -2650,7 +2630,6 @@
"src/main/git/wsl-direct-git-read-commands.test.ts": 74,
"src/main/git/command-runner/wsl-linked-worktree-git-route-invalidation.test.ts": 48,
"src/main/git/command-runner/wsl-linked-worktree-git-routing.test.ts": 93,
"src/main/git/wsl-process-group-termination.test.ts": 109,
"src/main/git/wsl-windows-checkout-git-config.test.ts": 64,
"src/main/git/wsl-windows-checkout-git-probe.test.ts": 273,
"src/main/git/wsl-windows-checkout-sample-script.test.ts": 165,
@@ -2763,7 +2742,6 @@
"src/main/github/work-item-search-freshness.test.ts": 1425,
"src/main/github/work-item-search-isolation.test.ts": 701,
"src/main/github/work-item-search-pagination.test.ts": 618,
"src/main/github/work-item-search-semantics.test.ts": 645,
"src/main/gitlab/client-mr-auth-rate-limit.test.ts": 475,
"src/main/gitlab/client-mr-branch-lookup.test.ts": 563,
"src/main/gitlab/client-mr-listing.test.ts": 558,
@@ -3003,7 +2981,6 @@
"src/main/ipc/notification-options-localization.test.ts": 313,
"src/main/ipc/notifications-custom-sound.test.ts": 289,
"src/main/ipc/notifications-delivery-gating.test.ts": 325,
"src/main/ipc/notifications-macos-removal.test.ts": 378,
"src/main/ipc/notifications-message-formatting.test.ts": 229,
"src/main/ipc/notifications-mobile-fanout.test.ts": 361,
"src/main/ipc/notifications-permission-onboarding.test.ts": 354,
@@ -3044,7 +3021,6 @@
"src/main/ipc/pty/delivery/pty-delivery-health-heal.test.ts": 4433,
"src/main/ipc/pty/delivery/pty-global-renderer-credit.test.ts": 4242,
"src/main/ipc/pty/delivery/pty-hidden-delivery-gate.test.ts": 59,
"src/main/ipc/pty/delivery/pty-ipc-gap-escape-state.test.ts": 3446,
"src/main/ipc/pty/delivery/pty-ipc-hidden-delivery-gate.test.ts": 4081,
"src/main/ipc/pty/delivery/pty-output-batching-drain.test.ts": 5239,
"src/main/ipc/pty/delivery/pty-pending-data-drain-queue-differential.test.ts": 1566,
@@ -3058,7 +3034,6 @@
"src/main/ipc/pty/host-env/opencode-hook-installation.test.ts": 668,
"src/main/ipc/pty/host-env/spawn-cwd-hung-folder.test.ts": 650,
"src/main/ipc/pty/host-env/spawn-cwd-wsl-move.test.ts": 817,
"src/main/ipc/pty/host-env/spawn-cwd-wsl-stopped-folder.test.ts": 676,
"src/main/ipc/pty/ipc/claude-old-terminal.test.ts": 47,
"src/main/ipc/pty/ipc/closed-pane-spawn-refusal.test.ts": 242,
"src/main/ipc/pty/ipc/codex-shared-server.test.ts": 89,
@@ -3123,7 +3098,6 @@
"src/main/ipc/pty/pty-spawn-env-terminal-basics.test.ts": 4943,
"src/main/ipc/pty/pty-spawn-runtime-handle-binding.test.ts": 5282,
"src/main/ipc/pty/pty-spawn-timing.test.ts": 45,
"src/main/ipc/pty/pty-spawn-wsl-passive-restore-hold.test.ts": 3817,
"src/main/ipc/pty/pty-ssh-stop-verdict.test.ts": 3615,
"src/main/ipc/pty/pty-startup-barrier-and-listing.test.ts": 4011,
"src/main/ipc/pty/pty-startup-swap-window-presence.test.ts": 5899,
@@ -3139,7 +3113,6 @@
"src/main/ipc/pty/runtime/spawn-preflight-requested-shell.test.ts": 1924,
"src/main/ipc/pty/runtime/spawn-preflight-saved-resume.test.ts": 1949,
"src/main/ipc/pty/runtime/spawn-preflight-wsl-background-hold.test.ts": 1568,
"src/main/ipc/pty/spawn-host-env-wsl-passive-restore.test.ts": 1263,
"src/main/ipc/pty/spawn-options-agent-workspace-trust.test.ts": 1131,
"src/main/ipc/qoder-desktop-execution-host.test.ts": 4373,
"src/main/ipc/qoder-independent-renderer-spawn-review.test.ts": 3873,
@@ -3165,12 +3138,10 @@
"src/main/ipc/repos-remote-base-ref-queries.test.ts": 1448,
"src/main/ipc/repos-remote-client-events.test.ts": 1914,
"src/main/ipc/repos-sparse-presets.test.ts": 1310,
"src/main/ipc/repos/base-ref-default-wsl-gate.test.ts": 107,
"src/main/ipc/repos/folder-workspace-attachment-handlers.test.ts": 1932,
"src/main/ipc/repos/local-repo-registration.test.ts": 469,
"src/main/ipc/repos/project-host-setup-wsl-move-fence.test.ts": 2529,
"src/main/ipc/repos/repo-ipc-wsl-move-fence.test.ts": 2655,
"src/main/ipc/runtime-environments/orcad-runtime-maintenance-handlers.test.ts": 802,
"src/main/ipc/runtime-environments/runtime-environment-browser-client-host-handler.test.ts": 827,
"src/main/ipc/runtime-environments/runtime-environment-capability-evidence.test.ts": 46,
"src/main/ipc/runtime-environments/runtime-environment-cli-removal-routing.test.ts": 891,
@@ -3221,8 +3192,6 @@
"src/main/ipc/settings.test.ts": 1544,
"src/main/ipc/filesystem/watcher-process/shallow-watch-delivery-probe-lifetime.test.ts": 86,
"src/main/ipc/shell-paths-exist-wsl-stopped-distro.test.ts": 244,
"src/main/ipc/shell-server-ssh-editor.test.ts": 342,
"src/main/ipc/shell-wsl-owner.test.ts": 330,
"src/main/ipc/shell.test.ts": 357,
"src/main/ipc/skill-cloud-install-ipc-schemas.test.ts": 116,
"src/main/ipc/skill-cloud-ipc-handlers.test.ts": 255,
@@ -3351,7 +3320,6 @@
"src/main/ipc/wsl/wsl-move-handlers-setting-off.test.ts": 724,
"src/main/ipc/wsl/wsl-move-handlers.test.ts": 1263,
"src/main/ipc/wsl/wsl-move-host-connect.test.ts": 984,
"src/main/ipc/wsl/wsl-move-orchestrator-host-connect.test.ts": 1141,
"src/main/ipc/wsl/wsl-move-orchestrator-wiring.test.ts": 1283,
"src/main/ipc/wsl/wsl-move-partial-undo.test.ts": 939,
"src/main/ipc/wsl/wsl-move-release-here-host-asleep.test.ts": 344,
@@ -3513,7 +3481,6 @@
"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,
"src/main/managed-server/install/remote-versioned-install.test.ts": 272,
"src/main/managed-server/lifecycle/orcad-decommission-released-peer-recovery.test.ts": 156,
"src/main/managed-server/lifecycle/orcad-decommission-transaction.test.ts": 157,
"src/main/managed-server/lifecycle/orcad-managed-auto-update.test.ts": 332,
"src/main/managed-server/lifecycle/orcad-managed-bound-port.test.ts": 313,
@@ -3547,7 +3514,6 @@
"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-darwin-footprint.test.ts": 405,
"src/main/memory/collector-windows-sweep.test.ts": 871,
"src/main/memory/collector.test.ts": 925,
"src/main/memory/host-malloc-tunables.test.ts": 41,
@@ -3780,7 +3746,6 @@
"src/main/native-chat/agent-session-wire/structured-agent-session-queued-quit.test.ts": 2500,
"src/main/native-chat/agent-session-wire/structured-agent-session-queued-refused-card.test.ts": 2478,
"src/main/native-chat/agent-session-wire/structured-agent-session-queued-repeat-press.test.ts": 2765,
"src/main/native-chat/agent-session-wire/structured-agent-session-queued-stop-row.test.ts": 1871,
"src/main/native-chat/agent-session-wire/structured-agent-session-queued-withdrawn-draft.test.ts": 1916,
"src/main/native-chat/agent-session-wire/structured-agent-session-ready-operation-order.test.ts": 2646,
"src/main/native-chat/agent-session-wire/structured-agent-session-reasoning-sweep.test.ts": 747,
@@ -3794,7 +3759,6 @@
"src/main/native-chat/agent-session-wire/structured-agent-session-recovery-resolution.test.ts": 4345,
"src/main/native-chat/agent-session-wire/structured-agent-session-refusal-details.test.ts": 380,
"src/main/native-chat/agent-session-wire/structured-agent-session-registry.test.ts": 48,
"src/main/native-chat/agent-session-wire/structured-agent-session-repeated-stop.test.ts": 2059,
"src/main/native-chat/agent-session-wire/structured-agent-session-replaced-server-turn.test.ts": 2308,
"src/main/native-chat/agent-session-wire/structured-agent-session-resend-answer.test.ts": 3851,
"src/main/native-chat/agent-session-wire/structured-agent-session-resend-ledger-refusal.test.ts": 2302,
@@ -3845,7 +3809,6 @@
"src/main/native-chat/agent-session-wire/structured-agent-session-startup-attempt-host.test.ts": 10511,
"src/main/native-chat/agent-session-wire/structured-agent-session-startup-attempt-registrations.test.ts": 2980,
"src/main/native-chat/agent-session-wire/structured-agent-session-startup-attempt-reports.test.ts": 1857,
"src/main/native-chat/agent-session-wire/structured-agent-session-startup-failure-exit.test.ts": 393,
"src/main/native-chat/agent-session-wire/structured-agent-session-startup-provider-errors.test.ts": 8128,
"src/main/native-chat/agent-session-wire/structured-agent-session-startup-reconcile-failure.test.ts": 3220,
"src/main/native-chat/agent-session-wire/structured-agent-session-status-feed-children.test.ts": 2318,
@@ -3862,8 +3825,6 @@
"src/main/native-chat/agent-session-wire/structured-agent-session-stop-note-opened-turn.test.ts": 1446,
"src/main/native-chat/agent-session-wire/structured-agent-session-stop-note-withdrawn-send.test.ts": 1566,
"src/main/native-chat/agent-session-wire/structured-agent-session-stop-receipts.test.ts": 2862,
"src/main/native-chat/agent-session-wire/structured-agent-session-stopped-history.test.ts": 2263,
"src/main/native-chat/agent-session-wire/structured-agent-session-stopping-journal.test.ts": 772,
"src/main/native-chat/agent-session-wire/structured-agent-session-subagent-recency.test.ts": 1511,
"src/main/native-chat/agent-session-wire/structured-agent-session-subagent-rows.test.ts": 918,
"src/main/native-chat/agent-session-wire/structured-agent-session-subscriber-background-tasks.test.ts": 829,
@@ -3877,7 +3838,6 @@
"src/main/native-chat/agent-session-wire/structured-agent-session-thread-goal.test.ts": 3029,
"src/main/native-chat/agent-session-wire/structured-agent-session-transition-rollback.test.ts": 919,
"src/main/native-chat/agent-session-wire/structured-agent-session-transition.test.ts": 1068,
"src/main/native-chat/agent-session-wire/structured-agent-session-turn-completion-child-work.test.ts": 240,
"src/main/native-chat/agent-session-wire/structured-agent-session-turn-completion-feed.test.ts": 276,
"src/main/native-chat/agent-session-wire/structured-agent-session-unexpected-exit.test.ts": 430,
"src/main/native-chat/agent-session-wire/structured-agent-session-unnamed-stop-races.test.ts": 2959,
@@ -4122,7 +4082,6 @@
"src/main/orcad/orcad-idle-exit-monitor.test.ts": 106,
"src/main/orcad/orcad-idle-stop-record.test.ts": 199,
"src/main/orcad/orcad-instance-lock.test.ts": 315,
"src/main/orcad/orcad-launch-contract.test.ts": 564,
"src/main/orcad/orcad-launcher-isolation.integration.test.ts": 617,
"src/main/orcad/orcad-lifecycle-host.test.ts": 407,
"src/main/orcad/orcad-local-serve-selection.test.ts": 533,
@@ -4432,7 +4391,6 @@
"src/main/persistence/terminal-topology/persistence-cross-host-pane-identity.test.ts": 1744,
"src/main/persistence/terminal-topology/persistence-empty-root-layout-load.test.ts": 1841,
"src/main/persistence/terminal-topology/persistence-floating-terminal-trust.test.ts": 2121,
"src/main/persistence/terminal-topology/persistence-host-partitioned-ssh-pty-bindings.test.ts": 1813,
"src/main/persistence/terminal-topology/persistence-layout-binding-recovery.test.ts": 1835,
"src/main/persistence/terminal-topology/persistence-pane-alias-selective-saves.test.ts": 1961,
"src/main/persistence/terminal-topology/persistence-pane-identity-migration.test.ts": 5772,
@@ -4440,7 +4398,6 @@
"src/main/persistence/terminal-topology/persistence-pty-binding-reconciliation.test.ts": 1898,
"src/main/persistence/terminal-topology/persistence-runtime-admitted-terminal-membership.test.ts": 2143,
"src/main/persistence/terminal-topology/persistence-split-pane-incarnation.test.ts": 1790,
"src/main/persistence/terminal-topology/persistence-ssh-lease-tombstone-retention.test.ts": 1927,
"src/main/persistence/terminal-topology/persistence-ssh-pending-pty-kill.test.ts": 1776,
"src/main/persistence/terminal-topology/persistence-ssh-readoption-automation-migration.test.ts": 2463,
"src/main/persistence/terminal-topology/persistence-ssh-remote-cli-control-migration.test.ts": 1738,
@@ -4563,7 +4520,6 @@
"src/main/plugins/contributions/plugin-theme-registries.test.ts": 250,
"src/main/plugins/hooks/plugin-agent-hook-sites.test.ts": 279,
"src/main/plugins/hooks/plugin-hook-participants.test.ts": 341,
"src/main/plugins/hooks/plugin-hook-sites.test.ts": 195,
"src/main/plugins/hooks/plugin-hook-warming.test.ts": 472,
"src/main/plugins/installation/plugin-atomic-file-write.test.ts": 2051,
"src/main/plugins/installation/plugin-catalog-install-guard.test.ts": 475,
@@ -4707,7 +4663,6 @@
"src/main/providers/agent-foreground-process-batch.test.ts": 141,
"src/main/providers/agent-foreground-process-git-bash-launcher.test.ts": 144,
"src/main/providers/agent-foreground-process-git-bash.win32.test.ts": 361,
"src/main/providers/agent-foreground-process-ps-scan-volume.test.ts": 244,
"src/main/providers/agent-foreground-process-real-rows.test.ts": 152,
"src/main/providers/agent-foreground-process-remote-evidence.test.ts": 154,
"src/main/providers/agent-foreground-process-shell-path.test.ts": 143,
@@ -4721,7 +4676,6 @@
"src/main/providers/local-pty/local-pty-default-shell.test.ts": 272,
"src/main/providers/local-pty/local-pty-executable-spawn.test.ts": 96,
"src/main/providers/local-pty/local-pty-foreground-inspection-cheap-tier.test.ts": 266,
"src/main/providers/local-pty/local-pty-pending-native-spawn.test.ts": 376,
"src/main/providers/local-pty/local-pty-provider-foreground-process.test.ts": 520,
"src/main/providers/local-pty/local-pty-provider-io-events.test.ts": 493,
"src/main/providers/local-pty/local-pty-provider-shell-readiness.test.ts": 551,
@@ -4819,12 +4773,10 @@
"src/main/rate-limits/cursor-session-token.test.ts": 96,
"src/main/rate-limits/cursor-usage-mapping.test.ts": 141,
"src/main/rate-limits/gemini-oauth-sources.test.ts": 97,
"src/main/rate-limits/gemini-usage-fetcher.fallback.test.ts": 107,
"src/main/rate-limits/gemini-usage-fetcher.test.ts": 104,
"src/main/rate-limits/grok-auth.test.ts": 105,
"src/main/rate-limits/grok-fetcher.test.ts": 121,
"src/main/rate-limits/grok-weekly-zero.test.ts": 163,
"src/main/rate-limits/initial-account-rate-limit-target.test.ts": 278,
"src/main/rate-limits/kimi-fetcher-wsl-home.test.ts": 187,
"src/main/rate-limits/kimi-fetcher.test.ts": 197,
"src/main/rate-limits/minimax/minimax-fetcher.test.ts": 138,
@@ -4892,7 +4844,6 @@
"src/main/runtime/agent-session/agent-session-conversation-clear-commit.test.ts": 957,
"src/main/runtime/agent-session/agent-session-death-evidence-persistence.test.ts": 1113,
"src/main/runtime/agent-session/agent-session-launch-directory.test.ts": 926,
"src/main/runtime/agent-session/agent-session-runtime-attribution.test.ts": 1015,
"src/main/runtime/agent-session/agent-session-tab-layout.test.ts": 275,
"src/main/runtime/agent-session/agent-session-unsaved-creation-supersession.test.ts": 726,
"src/main/runtime/agent-session/ownership/agent-session-claim-identity.test.ts": 55,
@@ -4960,7 +4911,6 @@
"src/main/runtime/agent-state-rules/agent-state-rules-live-update.test.ts": 379,
"src/main/runtime/agent-state-rules/agent-state-text-anchors.test.ts": 171,
"src/main/runtime/agent-status-store-attach-titles.test.ts": 4361,
"src/main/runtime/agent-terminal-launch-host.test.ts": 3981,
"src/main/runtime/agent-terminal-startup-prompt.test.ts": 4264,
"src/main/runtime/antigravity-readiness-transcripts.test.ts": 5396,
"src/main/runtime/antigravity-terminal-readiness.test.ts": 167,
@@ -5000,7 +4950,6 @@
"src/main/runtime/browser-tab-create-publication.test.ts": 588,
"src/main/runtime/browser/browser-profiles-client-placement.test.ts": 278,
"src/main/runtime/browser/browser-screencasts.test.ts": 632,
"src/main/runtime/browser/browser-tab-listing.test.ts": 50,
"src/main/runtime/claude-agent-teams-service.test.ts": 115,
"src/main/runtime/claude-agent-teams-shim-env.test.ts": 84,
"src/main/runtime/claude-dialog-interactive-wait.test.ts": 3975,
@@ -5048,7 +4997,6 @@
"src/main/runtime/device-registry-own-quarantine.test.ts": 123,
"src/main/runtime/device-registry-startup-offer.test.ts": 102,
"src/main/runtime/draft-paste-ready-scanner-chunking.test.ts": 1475,
"src/main/runtime/dsb-terminal-runtime.test.ts": 4103,
"src/main/runtime/dsh-launch-prompt-resubmit.test.ts": 6527,
"src/main/runtime/dsh-readiness-transcript.test.ts": 71,
"src/main/runtime/e2ee-keypair.test.ts": 77,
@@ -5173,9 +5121,7 @@
"src/main/runtime/omp-user-bubble-launch-authority.test.ts": 4274,
"src/main/runtime/opencode-agent-row-structure-transcript.test.ts": 277,
"src/main/runtime/opencode-finished-session-authority.test.ts": 4172,
"src/main/runtime/opencode-run-lifetime-runtime.test.ts": 4824,
"src/main/runtime/opencode-run-lifetime-status.test.ts": 141,
"src/main/runtime/opencode-wsl-folder-launch-host.test.ts": 4495,
"src/main/runtime/orca-runtime-agent-session-account-env.test.ts": 4192,
"src/main/runtime/orca-runtime-agent-session-operation.test.ts": 4039,
"src/main/runtime/orca-runtime-agent-skill-share.test.ts": 4096,
@@ -5228,7 +5174,6 @@
"src/main/runtime/orca-runtime-serve-browser-restore.test.ts": 4469,
"src/main/runtime/orca-runtime-session-tabs-path-alias.test.ts": 4827,
"src/main/runtime/orca-runtime-skill-recovery.test.ts": 4007,
"src/main/runtime/orca-runtime-startup-draft-launch-parity.test.ts": 4247,
"src/main/runtime/orca-runtime-structured-agent-registration-seam.test.ts": 4596,
"src/main/runtime/orca-runtime-structured-agent-session-create-intent.test.ts": 4338,
"src/main/runtime/orca-runtime-structured-session-restore.test.ts": 4836,
@@ -5242,7 +5187,6 @@
"src/main/runtime/orca-runtime-terminal-create-replay-after-close.test.ts": 4654,
"src/main/runtime/orca-runtime-terminal-cwd.test.ts": 4038,
"src/main/runtime/orca-runtime-terminal-handle-incarnation.test.ts": 4449,
"src/main/runtime/orca-runtime-terminal-handle-leaf-removal.test.ts": 3545,
"src/main/runtime/orca-runtime-terminal-rename-retention.test.ts": 5757,
"src/main/runtime/orca-runtime-terminal-retirement.test.ts": 3714,
"src/main/runtime/orca-runtime-terminal-saved-name-respawn.test.ts": 4349,
@@ -5678,7 +5622,6 @@
"src/main/runtime/rpc/methods/structured-agent-session-legacy-command-wait.test.ts": 2712,
"src/main/runtime/rpc/methods/structured-agent-session-options-read.test.ts": 1676,
"src/main/runtime/rpc/methods/structured-agent-session-options-workspace-scope.test.ts": 6537,
"src/main/runtime/rpc/methods/structured-agent-session-policy.test.ts": 1011,
"src/main/runtime/rpc/methods/structured-agent-session-precommit-refusal.test.ts": 1595,
"src/main/runtime/rpc/methods/structured-agent-session-rename.test.ts": 4670,
"src/main/runtime/rpc/methods/structured-agent-session-restart-dismiss-fence.test.ts": 2207,
@@ -5711,7 +5654,6 @@
"src/main/runtime/rpc/methods/workspace-layout.test.ts": 3391,
"src/main/runtime/rpc/methods/worktree-catalog-snapshot-method.test.ts": 1590,
"src/main/runtime/rpc/methods/worktree-create-foreign-automation-provenance.test.ts": 1528,
"src/main/runtime/rpc/methods/worktree-create-navigation.test.ts": 1652,
"src/main/runtime/rpc/methods/worktree-runtime-routing.test.ts": 1735,
"src/main/runtime/rpc/methods/worktree-schemas.test.ts": 260,
"src/main/runtime/rpc/methods/worktree.test.ts": 1645,
@@ -5864,7 +5806,6 @@
"src/main/runtime/runtime-interrupted-local-worktree-removal.test.ts": 7328,
"src/main/runtime/runtime-layout-apply.test.ts": 169,
"src/main/runtime/runtime-legacy-worker-missing-workspace-recovery.test.ts": 91,
"src/main/runtime/runtime-legacy-worker-recovery-lifetime.test.ts": 178,
"src/main/runtime/runtime-legacy-worker-terminal-recovery-persistence.test.ts": 479,
"src/main/runtime/runtime-legacy-worker-terminal-recovery-retry.test.ts": 102,
"src/main/runtime/runtime-linear-comment-edit.test.ts": 458,
@@ -5878,7 +5819,6 @@
"src/main/runtime/runtime-machine-name.test.ts": 65,
"src/main/runtime/runtime-managed-worktree-metadata-sweep.test.ts": 1812,
"src/main/runtime/runtime-managed-worktree-metadata-wsl-move.test.ts": 1812,
"src/main/runtime/runtime-managed-worktree-metadata.test.ts": 494,
"src/main/runtime/runtime-managed-worktree-queries.test.ts": 1074,
"src/main/runtime/runtime-metadata-ownership-watch.test.ts": 100,
"src/main/runtime/runtime-metadata.test.ts": 224,
@@ -5887,7 +5827,6 @@
"src/main/runtime/runtime-mobile-dictation-controller.test.ts": 243,
"src/main/runtime/runtime-mobile-last-assistant-message.test.ts": 253,
"src/main/runtime/runtime-mobile-session-incarnation-projection.test.ts": 254,
"src/main/runtime/runtime-mobile-structured-tab-status.test.ts": 805,
"src/main/runtime/runtime-native-chat-follow-up-events.test.ts": 4069,
"src/main/runtime/runtime-nested-repo-import-host-twin.test.ts": 281,
"src/main/runtime/runtime-orchestration-crew-names.test.ts": 256,
@@ -5900,7 +5839,6 @@
"src/main/runtime/runtime-project-host-setup-git-remote-link.test.ts": 839,
"src/main/runtime/runtime-provisioned-root-adoption.test.ts": 537,
"src/main/runtime/runtime-reference-agents.test.ts": 207,
"src/main/runtime/runtime-reference-catalog.test.ts": 291,
"src/main/runtime/runtime-reference-find.test.ts": 245,
"src/main/runtime/runtime-reference-integration.test.ts": 4136,
"src/main/runtime/runtime-remote-fetch-ref-maintenance.test.ts": 419,
@@ -5909,7 +5847,6 @@
"src/main/runtime/runtime-repository-hooks-commands.test.ts": 402,
"src/main/runtime/runtime-repository-issue-command.test.ts": 557,
"src/main/runtime/runtime-repository-registration-controller.test.ts": 742,
"src/main/runtime/runtime-repository-registration-host-dedupe.test.ts": 693,
"src/main/runtime/runtime-resolved-worktree-cache.test.ts": 46,
"src/main/runtime/runtime-rpc-browser-host-admission.test.ts": 4402,
"src/main/runtime/runtime-rpc-client-activity.test.ts": 4799,
@@ -5976,7 +5913,6 @@
"src/main/runtime/runtime-worktree-terminal-provisioning.test.ts": 84,
"src/main/runtime/runtime-wsl-background-read-gate.test.ts": 1247,
"src/main/runtime/saved-structured-agent-session-restoration.test.ts": 40,
"src/main/runtime/selected-review-branch.test.ts": 56,
"src/main/runtime/serve-browser-tab-layout.test.ts": 384,
"src/main/runtime/serve-browser-tabs-layout-owner.test.ts": 321,
"src/main/runtime/session-lifecycle/close/close-by-split-handle-layout-owner.test.ts": 5010,
@@ -6121,7 +6057,6 @@
"src/main/runtime/terminal-subscribe-exit-waiter-leak.test.ts": 4118,
"src/main/runtime/terminal-subscriber-driven-daemon-attach.test.ts": 5399,
"src/main/runtime/terminal-surface-close.test.ts": 120,
"src/main/runtime/terminal-tail-buffer.test.ts": 2483,
"src/main/runtime/terminal-tail-crlf.test.ts": 4235,
"src/main/runtime/terminal-tail-redraw-row.equivalence.test.ts": 1478,
"src/main/runtime/terminal-tail-redraw-scaling.test.ts": 352,
@@ -6151,12 +6086,10 @@
"src/main/runtime/terminal/launch/window-tab-start-switch-off.test.ts": 4475,
"src/main/runtime/terminal/layout/agent-generated-tab-titles.test.ts": 5572,
"src/main/runtime/terminal/layout/pane-grid-recording.test.ts": 237,
"src/main/runtime/terminal/lifecycle/bind-first-registration.test.ts": 4390,
"src/main/runtime/terminal/lifecycle/main-pane-liveness.test.ts": 4871,
"src/main/runtime/terminal/lifecycle/ssh-exit-code-pane-outcome.test.ts": 4334,
"src/main/runtime/terminal/query/terminal-identity-read-context.test.ts": 5310,
"src/main/runtime/terminal/recovery/replaced-pane-handle-recovery.test.ts": 6080,
"src/main/runtime/terminal/recovery/restored-pane-start.test.ts": 62,
"src/main/runtime/terminal/stop/exact-terminal-stop-liveness.test.ts": 4321,
"src/main/runtime/terminal/stop/terminal-update-admission.test.ts": 48,
"src/main/runtime/terminal/surface/closed-pane-exit-cleanup-main.test.ts": 4281,
@@ -6199,7 +6132,6 @@
"src/main/runtime/workspaces/persisted-surfaces/sweep-terminal-placement.test.ts": 12352,
"src/main/runtime/workspaces/remote-workspace-terminal-sleep.test.ts": 46,
"src/main/runtime/workspaces/workspace-host-notifications.test.ts": 4332,
"src/main/runtime/workspaces/workspace-lifecycle-commands.test.ts": 89,
"src/main/runtime/workspaces/workspace-lifecycle-queue.test.ts": 116,
"src/main/runtime/workspaces/workspace-query-prototype.test.ts": 4444,
"src/main/runtime/workspaces/workspace-sleep-execution.test.ts": 110,
@@ -6273,7 +6205,6 @@
"src/main/skills/skill-discovery-target.test.ts": 124,
"src/main/skills/skill-discovery-wsl-alias-sharing.test.ts": 165,
"src/main/skills/skill-discovery-wsl-bash-filter.test.ts": 2105,
"src/main/skills/skill-discovery-wsl-plugins.test.ts": 99,
"src/main/skills/skill-discovery-wsl-script-roundtrip.test.ts": 136,
"src/main/skills/skill-discovery-wsl.test.ts": 104,
"src/main/skills/skill-install-destinations.test.ts": 58,
@@ -6481,7 +6412,6 @@
"src/main/ssh/remote-exec/remote-exec-input.test.ts": 71,
"src/main/ssh/remote-exec/remote-frame-protocol.test.ts": 126,
"src/main/ssh/remote-exec/remote-host-failure-producers.test.ts": 356,
"src/main/ssh/runtime-ssh-access-verification.test.ts": 222,
"src/main/ssh/runtime-ssh-access.test.ts": 739,
"src/main/ssh/sftp-namespace-resolution.test.ts": 84,
"src/main/ssh/sftp-stream-late-error.test.ts": 209,
@@ -6577,7 +6507,6 @@
"src/main/startup/gpu-fallback-marker.test.ts": 61,
"src/main/startup/gpu-lifecycle-install-dir-acl-guard.test.ts": 641,
"src/main/startup/headless-serve-x11-reexec.test.ts": 76,
"src/main/startup/http1-compatibility-profile-state.test.ts": 81,
"src/main/startup/hydrate-shell-path.test.ts": 462,
"src/main/startup/hydrate-shell-path.windows.test.ts": 426,
"src/main/startup/legacy-worker-renderer-recovery.test.ts": 47,
@@ -6607,7 +6536,6 @@
"src/main/startup/serve-desktop-activation.test.ts": 42,
"src/main/startup/serve-mode-argv.test.ts": 85,
"src/main/startup/serve-native-quit-guard.test.ts": 61,
"src/main/startup/serve-options.test.ts": 72,
"src/main/startup/serve-signal-handlers.test.ts": 48,
"src/main/startup/serve-user-data-path-guard.test.ts": 198,
"src/main/startup/single-instance-lock-exit.electron.test.ts": 551,
@@ -6636,7 +6564,6 @@
"src/main/telemetry/burst-cap.test.ts": 177,
"src/main/telemetry/chat-ui-launch-tip-cohort.test.ts": 235,
"src/main/telemetry/classify-error.test.ts": 43,
"src/main/telemetry/client-agent-dashboard-active.test.ts": 240,
"src/main/telemetry/client-agent-dashboard-snapshot.test.ts": 201,
"src/main/telemetry/client-deferred-start.test.ts": 8659,
"src/main/telemetry/client-lifecycle.test.ts": 206,
@@ -6647,7 +6574,6 @@
"src/main/telemetry/posthog-transport-main-http-client.test.ts": 123,
"src/main/telemetry/validator-warn-cache.test.ts": 187,
"src/main/telemetry/validator.test.ts": 170,
"src/main/telemetry/wsl-move-telemetry.test.ts": 184,
"src/main/telemetry/wsl-usage-reporter.test.ts": 407,
"src/main/telemetry/wsl-usage-snapshot.test.ts": 208,
"src/main/pty/history/terminal-history-async-delete.test.ts": 374,
@@ -6864,7 +6790,6 @@
"src/main/wsl/history/wsl-fish-history-cleanup.test.ts": 158,
"src/main/wsl/execution/wsl-interop-spawn-directory.test.ts": 52,
"src/main/wsl/discovery/wsl-last-seen-running-age.test.ts": 84,
"src/main/wsl/discovery/wsl-lost-list-output.test.ts": 135,
"src/main/wsl/execution/wsl-managed-cli-path-restore.test.ts": 68,
"src/main/persistence/moving-wsl-projects/wsl-move-incidental-writes.test.ts": 1170,
"src/main/persistence/moving-wsl-projects/wsl-move-scan-pruning.test.ts": 1824,
@@ -6872,7 +6797,6 @@
"src/main/wsl/discovery/wsl-running-distros.test.ts": 201,
"src/main/wsl/filesystem/wsl-running-path-filter.test.ts": 57,
"src/main/wsl/filesystem/wsl-shell-cwd-report.test.ts": 111,
"src/main/wsl/filesystem/wsl-unc-delete.test.ts": 102,
"src/main/wsl/filesystem/wsl-unc-delete.wsl.test.ts": 93,
"src/main/wsl/discovery/distro-catalog.test.ts": 148,
"src/main/wsl/filesystem/wsl-automount-root.test.ts": 108,
@@ -6883,7 +6807,6 @@
"src/main/wsl/discovery/wsl-distro-holder-script.test.ts": 28870,
"src/main/wsl/discovery/wsl-distro-holder.test.ts": 328,
"src/main/wsl/discovery/wsl-distro-registry-id.test.ts": 77,
"src/main/wsl/filesystem/wsl-drive-project-new-default.test.ts": 212,
"src/main/wsl/filesystem/wsl-drive-project-windows-git.test.ts": 249,
"src/main/wsl/filesystem/wsl-drive-unc-conversion-renderer.test.ts": 123,
"src/main/wsl/filesystem/wsl-drive-unc-conversion-run.test.ts": 621,
@@ -7438,7 +7361,6 @@
"src/renderer/src/components/editor/EditorPanel.diff-rich-owner.test.tsx": 2823,
"src/renderer/src/components/editor/EditorPanelHeader.test.tsx": 2230,
"src/renderer/src/components/editor/EditorPanelHeaderPath.test.tsx": 2664,
"src/renderer/src/components/editor/EditorPanelShell.header.test.tsx": 301,
"src/renderer/src/components/editor/external-changes/ExternalFileChangeBanner.test.tsx": 2142,
"src/renderer/src/components/editor/external-changes/ExternalFileChangeCompareDialog.test.tsx": 775,
"src/renderer/src/components/editor/ImageViewer.test.tsx": 1872,
@@ -7471,7 +7393,6 @@
"src/renderer/src/components/editor/rich-markdown/instance/RichMarkdownErrorBoundary.lazy-chunk.test.tsx": 367,
"src/renderer/src/components/editor/rich-markdown/links/RichMarkdownLinkBubble.render.test.tsx": 439,
"src/renderer/src/components/editor/rich-markdown/search/RichMarkdownSearchBar.ime-enter.test.tsx": 358,
"src/renderer/src/components/editor/rich-markdown/commands/RichMarkdownSlashMenu.test.tsx": 676,
"src/renderer/src/components/editor/rich-markdown/tables/RichMarkdownTableControls.test.tsx": 2354,
"src/renderer/src/components/editor/ai-vault-session-log-content.test.tsx": 1899,
"src/renderer/src/components/editor/check-details/actions/check-annotation-path.test.ts": 53,
@@ -7564,7 +7485,6 @@
"src/renderer/src/components/editor/editor-panel-render-model.test.ts": 2795,
"src/renderer/src/components/editor/editor-path-display.test.ts": 45,
"src/renderer/src/components/editor/editor-path-move-inflight.test.ts": 50,
"src/renderer/src/components/editor/save/flush/editor-pending-flush.test.ts": 62,
"src/renderer/src/components/editor/external-changes/editor-restored-tab-conflict-scan.recheck.test.ts": 1796,
"src/renderer/src/components/editor/external-changes/editor-restored-tab-conflict-scan.test.ts": 1809,
"src/renderer/src/components/editor/save/queue/editor-save-disk-precondition.races.test.ts": 1290,
@@ -7773,7 +7693,6 @@
"src/renderer/src/components/editor/save/commands/useEditorCmdSaveRequest.test.tsx": 732,
"src/renderer/src/components/editor/useEditorPanelActiveTabContentLoad.overview.test.tsx": 2064,
"src/renderer/src/components/editor/useEditorPanelContentState.test.tsx": 3214,
"src/renderer/src/components/editor/external-changes/useEditorPanelExternalContentEvents.cross-workspace.test.tsx": 1095,
"src/renderer/src/components/editor/external-changes/useEditorPanelExternalContentEvents.test.tsx": 2018,
"src/renderer/src/components/editor/useEditorPanelFileLoadRetry.host-connection.test.tsx": 2075,
"src/renderer/src/components/editor/useEditorPanelFileLoadRetry.test.tsx": 1953,
@@ -7870,7 +7789,6 @@
"src/renderer/src/components/github/use-image-input.test.ts": 2619,
"src/renderer/src/components/gitlab-item-dialog/use-gitlab-primary-actions.test.ts": 2141,
"src/renderer/src/components/host-picker/bounded-host-list.test.tsx": 667,
"src/renderer/src/components/host-picker/host-list-menus.large-config.test.tsx": 2394,
"src/renderer/src/components/host-picker/host-list-surfaces.large-config.test.tsx": 2963,
"src/renderer/src/components/jira-attachment-image.test.tsx": 2117,
"src/renderer/src/components/jira-connect-scoped-token.test.tsx": 1047,
@@ -8076,7 +7994,6 @@
"src/renderer/src/components/native-chat/native-chat-image-references.test.tsx": 2485,
"src/renderer/src/components/native-chat/native-chat-incremental-assembler.test.ts": 234,
"src/renderer/src/components/native-chat/native-chat-interactive-prompt.test.ts": 273,
"src/renderer/src/components/native-chat/native-chat-launch-default-adoption.test.ts": 234,
"src/renderer/src/components/native-chat/native-chat-leaf-routing.test.ts": 49,
"src/renderer/src/components/native-chat/native-chat-live-message-preparation.test.ts": 252,
"src/renderer/src/components/native-chat/native-chat-message-grouping.test.ts": 199,
@@ -8103,7 +8020,6 @@
"src/renderer/src/components/native-chat/native-chat-reply-reveals.test.tsx": 294,
"src/renderer/src/components/native-chat/native-chat-resolved-path-ownership.test.ts": 1749,
"src/renderer/src/components/native-chat/native-chat-row-height-estimate.test.ts": 293,
"src/renderer/src/components/native-chat/native-chat-runtime-owner-selector.test.ts": 341,
"src/renderer/src/components/native-chat/native-chat-runtime-owner.test.ts": 338,
"src/renderer/src/components/native-chat/native-chat-runtime-send-launch-draft.test.ts": 1358,
"src/renderer/src/components/native-chat/native-chat-runtime-send.test.ts": 284,
@@ -8134,7 +8050,6 @@
"src/renderer/src/components/native-chat/native-chat-visual-markdown-syntax.test.tsx": 2351,
"src/renderer/src/components/native-chat/native-chat-visual-read-client.test.ts": 163,
"src/renderer/src/components/native-chat/native-chat-web-link-actions.test.ts": 401,
"src/renderer/src/components/native-chat/native-chat-work-run-pairing.test.ts": 368,
"src/renderer/src/components/native-chat/native-chat-working-status-shared-clock.test.tsx": 412,
"src/renderer/src/components/native-chat/omp-model-switch-capability.test.ts": 211,
"src/renderer/src/components/native-chat/serialized-screen-background-rows.test.ts": 131,
@@ -8173,7 +8088,6 @@
"src/renderer/src/components/native-chat/use-native-chat-composer-recall.test.tsx": 405,
"src/renderer/src/components/native-chat/use-native-chat-composer-submit.test.tsx": 763,
"src/renderer/src/components/native-chat/use-native-chat-context-clear-floor.test.tsx": 271,
"src/renderer/src/components/native-chat/use-native-chat-context-menu.find.test.tsx": 2619,
"src/renderer/src/components/native-chat/use-native-chat-context-menu.targets.test.tsx": 3071,
"src/renderer/src/components/native-chat/use-native-chat-context-menu.test.tsx": 3668,
"src/renderer/src/components/native-chat/use-native-chat-draft.test.tsx": 590,
@@ -8588,7 +8502,6 @@
"src/renderer/src/components/settings/accounts/AccountsPane.section-lifetime.test.tsx": 2893,
"src/renderer/src/components/settings/accounts/AccountsPane.test.tsx": 2701,
"src/renderer/src/components/settings/advanced/network/AdvancedNetworkSettingsSection.interaction.test.tsx": 2206,
"src/renderer/src/components/settings/advanced/network/AdvancedNetworkSettingsSection.test.ts": 1969,
"src/renderer/src/components/settings/agents/surfaces/AgentDefaultSurfaceSection.test.tsx": 3070,
"src/renderer/src/components/agent/launch-settings/AgentLaunchChoiceEditor.test.tsx": 1565,
"src/renderer/src/components/agent/launch-settings/AgentLaunchDefaultsEditor.test.tsx": 1320,
@@ -8614,13 +8527,11 @@
"src/renderer/src/components/settings/chat/ChatSettingsSection.navigation.test.tsx": 2540,
"src/renderer/src/components/settings/chat/ChatSettingsSection.test.tsx": 2718,
"src/renderer/src/components/settings/cli/install/CliSection.install-failure.test.tsx": 2250,
"src/renderer/src/components/settings/cli/install/CliSection.test.tsx": 2135,
"src/renderer/src/lib/skill-runtime-command.test.ts": 1670,
"src/renderer/src/components/settings/source-control/CommitMessageAiPane.test.tsx": 3099,
"src/renderer/src/components/settings/virtual-machines/runtimes/EphemeralVmRuntimesSection.test.tsx": 2270,
"src/renderer/src/components/settings/virtual-machines/recipes/EphemeralVmsPane.test.tsx": 1025,
"src/renderer/src/components/settings/general/GeneralPane.section-lifetime.test.tsx": 1546,
"src/renderer/src/components/settings/general/GeneralWorkspaceSettingsSection.test.tsx": 1989,
"src/renderer/src/components/settings/accounts/providers/GrokAccountsSection.test.tsx": 623,
"src/renderer/src/components/settings/accounts/sign-in/HostAccountSignInPrompt.test.tsx": 309,
"src/renderer/src/components/settings/agents/launch/HostAgentLaunchSettings.test.tsx": 7498,
@@ -8632,7 +8543,6 @@
"src/renderer/src/components/settings/mobile/pairing/MobilePairingSetupSection.test.tsx": 1414,
"src/renderer/src/components/settings/mobile/MobilePane.test.tsx": 1581,
"src/renderer/src/components/settings/advanced/network/NodeDistMirrorDialog.test.tsx": 2412,
"src/renderer/src/components/settings/notifications/NotificationHostToggles.test.tsx": 646,
"src/renderer/src/components/settings/orchestration/OrchestrationPane.test.tsx": 2437,
"src/renderer/src/components/settings/orchestration/OrchestrationSkillAgentCoverage.test.tsx": 320,
"src/renderer/src/components/plugins/list/PluginsSettingsSection.lifecycle.test.tsx": 5402,
@@ -8705,7 +8615,6 @@
"src/renderer/src/components/settings/runtime-environments/pairing/runtime-share-computer-button.test.tsx": 2092,
"src/renderer/src/components/settings/form-controls/settings-form-option-filter.test.ts": 57,
"src/renderer/src/components/settings/navigation/settings-project-list.test.ts": 99,
"src/renderer/src/components/settings/search/settings-search.test.ts": 53,
"src/renderer/src/components/skills/setup/skill-setup-blocked-plan.test.tsx": 28488,
"src/renderer/src/components/settings/source-control/source-control-action-card-layout.test.tsx": 4551,
"src/renderer/src/components/settings/source-control/source-control-action-permission-draft.test.tsx": 6938,
@@ -8849,7 +8758,6 @@
"src/renderer/src/components/sidebar/workspace-creation/folder/folder-workspace-source-selection.test.ts": 230,
"src/renderer/src/components/sidebar/workspace-creation/folder/folder-workspace-composer-path-status.test.tsx": 2113,
"src/renderer/src/components/sidebar/workspace-creation/folder/folder-workspace-composer-submit-host-platform.test.ts": 2264,
"src/renderer/src/components/sidebar/workspace-creation/folder/folder-workspace-composer-submit-surface.test.ts": 2453,
"src/renderer/src/components/sidebar/workspace-creation/folder/folder-workspace-composer-submit.test.ts": 2665,
"src/renderer/src/components/sidebar/folder-workspace-delete.test.ts": 788,
"src/renderer/src/components/sidebar/workspace-creation/folder/folder-workspace-linked-startup-plan.test.ts": 1652,
@@ -9131,7 +9039,6 @@
"src/renderer/src/components/tab-bar/SortableTab.update-depth-probe.test.tsx": 1257,
"src/renderer/src/components/tab-bar/SortableTabContextMenu.test.tsx": 977,
"src/renderer/src/components/tab-bar/TabBar.context-menu.test.ts": 8259,
"src/renderer/src/components/tab-bar/TabBar.os-file-drop.test.tsx": 1297,
"src/renderer/src/components/tab-bar/TabBar.windows-shell-launch.test.ts": 3772,
"src/renderer/src/components/tab-bar/TabBar.windows-shell-remote-runtime.test.ts": 3732,
"src/renderer/src/components/tab-bar/TabBar.windows-shell-ssh-host.test.ts": 2829,
@@ -9900,7 +9807,6 @@
"src/renderer/src/components/wsl-move/wsl-move-orchestrator-status.test.tsx": 567,
"src/renderer/src/components/wsl-move/wsl-move-scope-rows.test.ts": 156,
"src/renderer/src/hooks/agent-hook-completion-background-turn-notifications.test.ts": 2279,
"src/renderer/src/hooks/agent-hook-completion-fresh-working.test.ts": 375,
"src/renderer/src/hooks/agent-hook-completion-grok-notifications.test.ts": 679,
"src/renderer/src/hooks/agent-hook-completion-notifications.test.ts": 1131,
"src/renderer/src/hooks/agent-hook-completion-store-sync.test.ts": 52,
@@ -9920,7 +9826,6 @@
"src/renderer/src/hooks/composer-state/composer-target-state.jira-mapping.test.tsx": 2792,
"src/renderer/src/hooks/composer-state/imported-project-group-selection.test.tsx": 1929,
"src/renderer/src/hooks/composer-state/jira-mapped-owner-flow.integration.test.tsx": 4506,
"src/renderer/src/hooks/composer-state/multiple-create-reset.test.ts": 1959,
"src/renderer/src/hooks/composer-state/provider-runtime-sync.test.ts": 1988,
"src/renderer/src/hooks/composer-state/quick-creation-execution.test.ts": 2283,
"src/renderer/src/hooks/composer-state/server-ssh-repo-connect.test.ts": 873,
@@ -9937,9 +9842,7 @@
"src/renderer/src/hooks/ipc-events/agent-status-pane-routing-index.test.ts": 1892,
"src/renderer/src/hooks/ipc-events/agent-status-pending-retry-gate.test.ts": 466,
"src/renderer/src/hooks/ipc-events/agent-status-program-status-only.test.ts": 398,
"src/renderer/src/hooks/ipc-events/agent-status-reconnect-replay-notification.test.ts": 352,
"src/renderer/src/hooks/ipc-events/agent-status-terminal-title-tab-write.test.ts": 2247,
"src/renderer/src/hooks/ipc-events/browser-request-ipc-bridge.fallback.test.ts": 776,
"src/renderer/src/hooks/ipc-events/browser-request-ipc-bridge.owner.test.ts": 623,
"src/renderer/src/hooks/ipc-events/browser-request-ipc-bridge.profile-switch.test.ts": 570,
"src/renderer/src/hooks/ipc-events/browser-request-ipc-bridge.split.test.ts": 699,
@@ -9967,7 +9870,6 @@
"src/renderer/src/hooks/ipc-events/worktree-event-lineage-refresh.test.ts": 2309,
"src/renderer/src/hooks/ipc-events/worktree-event-runtime-activation.test.ts": 1934,
"src/renderer/src/hooks/ipc-events/wsl-host-ipc-bridge-capability.test.ts": 1917,
"src/renderer/src/hooks/ipc-events/wsl-host-ipc-bridge-focus.test.ts": 1690,
"src/renderer/src/hooks/ipc-tab-switch-group-order-hydration.test.ts": 797,
"src/renderer/src/hooks/ipc-tab-switch-owner-selection.test.ts": 386,
"src/renderer/src/hooks/ipc-tab-switch-structured-tab-cycle.test.ts": 760,
@@ -10010,7 +9912,6 @@
"src/renderer/src/hooks/useEditorExternalWatch.test.ts": 2099,
"src/renderer/src/hooks/useEphemeralVmRecipeOptions.test.tsx": 415,
"src/renderer/src/hooks/useGitHubSlugMetadata.assignee-reuse.test.tsx": 233,
"src/renderer/src/hooks/useGitHubSlugMetadata.test.tsx": 223,
"src/renderer/src/hooks/useImmediateMutation.test.ts": 618,
"src/renderer/src/hooks/useInstalledAgentSkills.react.test.tsx": 2093,
"src/renderer/src/hooks/useIpcEvents-agent-status-connection-attribution.test.ts": 5510,
@@ -10062,7 +9963,6 @@
"src/renderer/src/i18n/language-catalog-timeout.test.tsx": 2025,
"src/renderer/src/i18n/language-detection-reconciliation.test.tsx": 2927,
"src/renderer/src/i18n/language-readiness.test.ts": 199,
"src/renderer/src/i18n/localized-catalog.test.ts": 284,
"src/renderer/src/i18n/plugin-pack-plurals.test.ts": 191,
"src/renderer/src/i18n/relative-time-format.test.ts": 53,
"src/renderer/src/i18n/ui-intl-formatters.test.ts": 208,
@@ -10119,12 +10019,10 @@
"src/renderer/src/lib/agent-session/structured-agent-session-ordinary-draft.test.tsx": 9907,
"src/renderer/src/lib/agent-session/structured-agent-session-provisional-tab.test.ts": 3672,
"src/renderer/src/lib/agent-skill-cli-prerequisite.test.ts": 219,
"src/renderer/src/lib/agent-startup-creation-continuation.test.ts": 1727,
"src/renderer/src/lib/agent-startup-delayed-delivery-perf.test.ts": 1708,
"src/renderer/src/lib/agent-status-evidence-clock.test.ts": 88,
"src/renderer/src/lib/agent-status.test.ts": 172,
"src/renderer/src/lib/ai-vault-antigravity-reference-host.test.ts": 678,
"src/renderer/src/lib/ai-vault-omp-cold-resume.test.ts": 1909,
"src/renderer/src/lib/ai-vault-resume-command.drop-repin.test.ts": 541,
"src/renderer/src/lib/ai-vault-resume-command.test.ts": 665,
"src/renderer/src/lib/ai-vault-resume-platform.test.ts": 617,
@@ -10149,7 +10047,6 @@
"src/renderer/src/lib/browser-link-profile-isolation.test.ts": 2511,
"src/renderer/src/lib/browser-page-palette-activation.test.ts": 1983,
"src/renderer/src/lib/browser-palette-page-entries.test.ts": 480,
"src/renderer/src/lib/browser-palette-search.test.ts": 467,
"src/renderer/src/lib/browser-workspace-tab-activation.test.ts": 1925,
"src/renderer/src/lib/bundled-skill-target.test.ts": 54,
"src/renderer/src/lib/check-run-details-log-normalization-performance.test.ts": 846,
@@ -10213,7 +10110,6 @@
"src/renderer/src/lib/host-mirror-handle-gap-resume.test.ts": 1811,
"src/renderer/src/lib/host-mirror-handle-gap-snapshot-lifetime.test.ts": 10232,
"src/renderer/src/lib/host-mirror-handle-gap-subscription-lifetime.test.ts": 4060,
"src/renderer/src/lib/host-mirror-handle-gap-teardown.test.ts": 1779,
"src/renderer/src/lib/host-mirror-handle-gap-verdict-union.test.ts": 2256,
"src/renderer/src/lib/host-mirrored-pane-resume-replay.test.ts": 1814,
"src/renderer/src/lib/http-link-completion.test.ts": 1760,
@@ -10238,7 +10134,6 @@
"src/renderer/src/lib/launch-agent-session-continuation-toast.test.tsx": 2347,
"src/renderer/src/lib/launch-agent-structured-chat-guard.test.ts": 2498,
"src/renderer/src/lib/launch-cancel-tombstones-compat/structured-agent-session-launch-cancellation.test.ts": 2266,
"src/renderer/src/lib/launch-structured-agent-session.test.ts": 2628,
"src/renderer/src/lib/launch-work-item-direct-accepted.test.tsx": 2754,
"src/renderer/src/lib/launch-work-item-direct-defaults.test.ts": 1594,
"src/renderer/src/lib/launch-work-item-direct-setup.test.tsx": 3435,
@@ -10290,7 +10185,6 @@
"src/renderer/src/lib/pane-manager/pane-divider.test.ts": 63,
"src/renderer/src/lib/pane-manager/pane-drag-reorder.test.ts": 123,
"src/renderer/src/lib/pane-manager/pane-drag-window-blur.test.ts": 618,
"src/renderer/src/lib/pane-manager/pane-drop-no-op.test.ts": 276,
"src/renderer/src/lib/pane-manager/pane-fit-resize-observer.test.ts": 94,
"src/renderer/src/lib/pane-manager/pane-fit.test.ts": 166,
"src/renderer/src/lib/pane-manager/pane-inline-image-recovery.test.ts": 61,
@@ -10389,7 +10283,6 @@
"src/renderer/src/lib/resume-sleeping-agent-session-live-ssh-dedupe.test.ts": 2059,
"src/renderer/src/lib/resume-sleeping-agent-session-provider-claim.test.ts": 1789,
"src/renderer/src/lib/resume-sleeping-agent-session-replay.test.ts": 1946,
"src/renderer/src/lib/resume-sleeping-agent-session-retired-binding.test.ts": 2642,
"src/renderer/src/lib/resume-sleeping-agent-session-slept-pane-recovery.test.ts": 1855,
"src/renderer/src/lib/resume-sleeping-agent-session-stranded-sleep.test.ts": 1962,
"src/renderer/src/lib/resume-sleeping-agent-session-suppress-navigation.test.ts": 1919,
@@ -10448,7 +10341,6 @@
"src/renderer/src/lib/unread-badge-count-selector.test.ts": 1930,
"src/renderer/src/lib/use-tab-agent-observed-signal-dispatch.test.tsx": 1971,
"src/renderer/src/lib/use-tab-agent-opencode-native-title.test.ts": 2093,
"src/renderer/src/lib/use-tab-agent-process-signals.test.ts": 1930,
"src/renderer/src/lib/use-tab-agent.test.ts": 1961,
"src/renderer/src/lib/user-opened-local-path.test.ts": 325,
"src/renderer/src/lib/wake-sleeping-agents-in-background.test.ts": 511,
@@ -10550,7 +10442,6 @@
"src/renderer/src/runtime/runtime-client-events.test.ts": 64,
"src/renderer/src/runtime/runtime-directory-page-client.test.ts": 268,
"src/renderer/src/runtime/runtime-environment-pairing-refresh.test.ts": 109,
"src/renderer/src/runtime/runtime-environment-ssh-state-read-concurrency.test.ts": 1887,
"src/renderer/src/runtime/runtime-environment-ssh-state.test.ts": 1810,
"src/renderer/src/runtime/runtime-file-client-mutation-ownership.test.ts": 536,
"src/renderer/src/runtime/runtime-file-client-search-listing.test.ts": 577,
@@ -10570,12 +10461,10 @@
"src/renderer/src/runtime/runtime-path-existence-queue-pairing.test.ts": 507,
"src/renderer/src/runtime/runtime-provider-accounts-client.test.ts": 343,
"src/renderer/src/runtime/runtime-quick-open-version-compatibility.test.ts": 451,
"src/renderer/src/runtime/runtime-repo-base-ref-default-wsl-retry.test.ts": 498,
"src/renderer/src/runtime/runtime-rpc-client.test.ts": 342,
"src/renderer/src/runtime/runtime-terminal-artifact-write.test.ts": 567,
"src/renderer/src/runtime/runtime-terminal-inspection.test.ts": 2033,
"src/renderer/src/runtime/runtime-terminal-stream.test.ts": 449,
"src/renderer/src/runtime/server-browser-profile-reuse.test.ts": 2024,
"src/renderer/src/runtime/server-held-panes.test.tsx": 2254,
"src/renderer/src/runtime/server-layout-apply.test.ts": 2222,
"src/renderer/src/runtime/server-layout-editor-handover.test.ts": 5075,
@@ -10624,7 +10513,6 @@
"src/renderer/src/runtime/web-session-tabs-removed-frame-retires-live-publisher.test.ts": 854,
"src/renderer/src/runtime/web-session-tabs-retired-epoch-census-repair.test.tsx": 2349,
"src/renderer/src/runtime/web-session-tabs-stream-end-resubscribe.test.tsx": 2282,
"src/renderer/src/runtime/web-session-tabs-sync-agent-handoff.test.ts": 1070,
"src/renderer/src/runtime/web-session-tabs-sync-agent-status.test.ts": 987,
"src/renderer/src/runtime/web-session-tabs-sync-browser-tabs.test.ts": 925,
"src/renderer/src/runtime/web-session-tabs-sync-client-owned-page-content.test.ts": 990,
@@ -10638,7 +10526,6 @@
"src/renderer/src/runtime/web-session-tabs-sync-mirror-identity.test.ts": 2524,
"src/renderer/src/runtime/web-session-tabs-sync-mirrored-draft-close.test.ts": 1833,
"src/renderer/src/runtime/web-session-tabs-sync-reconnect-resubscribe.test.tsx": 2360,
"src/renderer/src/runtime/web-session-tabs-sync-remote-main-agent-verdict.test.ts": 1771,
"src/renderer/src/runtime/web-session-tabs-sync-remote-status-title-flap.test.ts": 1715,
"src/renderer/src/runtime/web-session-tabs-sync-sleep-activity.test.ts": 1010,
"src/renderer/src/runtime/web-session-tabs-sync-snapshot-batch.test.ts": 1064,
@@ -1,9 +1,5 @@
import * as fs from 'node:fs'
import { mkdtempSync, rmSync } from 'node:fs'
import { createRequire } from 'node:module'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import { describe, expect, it } from 'vitest'
function dependencyRequire(packages) {
let require = createRequire(import.meta.url)
@@ -21,21 +17,6 @@ const sprintfRequire = dependencyRequire([
'global-agent',
'roarr'
])
const globRequire = dependencyRequire(['i18next-cli'])
const minimatchCopies = [
['9.0.9', dependencyRequire(['electron-builder', 'app-builder-lib', '@electron/universal'])],
['10.2.5', dependencyRequire(['i18next-cli', 'glob'])],
['10.2.6', dependencyRequire(['electron-builder', 'app-builder-lib'])]
]
const tempDirs = []
function makeTree(dir, depth) {
fs.writeFileSync(join(dir, 'f'), '')
for (const child of depth ? ['a', 'b'] : []) {
fs.mkdirSync(join(dir, child))
makeTree(join(dir, child), depth - 1)
}
}
describe('installed dependency security patches', () => {
it.each(['src/sprintf.js', 'dist/sprintf.min.js'])('bounds numeric precision in %s', (entry) => {
@@ -51,57 +32,4 @@ describe('installed dependency security patches', () => {
expect(sprintf('%.0f', 1.7)).toBe('2')
expect(sprintf('%.0e', 1.7)).toBe('2e+0')
})
it.each(minimatchCopies)('bounds **/.. pattern splitting in minimatch %s', (version, require) => {
const { Minimatch } = require('minimatch')
expect(require('minimatch/package.json').version).toBe(version)
const amplified = Array(10).fill('**/../a/b').join('/')
expect(new Minimatch(amplified, { optimizationLevel: 2, braceExpandMax: 64 }).set).toHaveLength(
64
)
const mm = new Minimatch('**/../a/b/**/../c/d', { optimizationLevel: 2 })
expect(mm.set).toHaveLength(4)
expect(mm.match('x/a/b/y/c/d')).toBe(true)
})
afterEach(() => {
for (const dir of tempDirs.splice(0)) {
rmSync(dir, { recursive: true, force: true })
}
})
it('stops glob 13 directory reads after an abort', async () => {
const { glob } = globRequire('glob')
expect(globRequire('glob/package.json').version).toBe('13.0.6')
const cwd = mkdtempSync(join(tmpdir(), 'orca-glob-abort-'))
tempDirs.push(cwd)
makeTree(cwd, 5)
const controller = new AbortController()
let reads = 0
// Settles after the latest read's callback has run, so follow-up reads are already counted.
let lastRead = Promise.resolve()
const countedFs = {
...fs,
readdir: (...args) => {
if (++reads === 1) {
controller.abort(new Error('stop'))
}
const callback = args.pop()
let settle = () => {}
lastRead = new Promise((resolve) => {
settle = resolve
})
fs.readdir(...args, (...result) => {
callback(...result)
settle()
})
}
}
await expect(glob('**', { cwd, signal: controller.signal, fs: countedFs })).rejects.toThrow(
'stop'
)
await lastRead
expect(reads).toBe(1)
expect(await glob('**/f', { cwd })).toHaveLength(63)
})
})
@@ -44,7 +44,6 @@ const NODE_RUNTIME_INCLUDE = [
'src/main/ai-vault/transcripts/session-document-consumer-replacement.test.ts',
'src/main/ai-vault-search/session-search-dependent-transcripts.test.ts',
'src/main/ai-vault-search/session-search-list-reparse.test.ts',
'src/main/ai-vault-search/session-search-transcript-dependencies.test.ts',
'src/main/native-chat/structured-agent-cli-conversation-adoption.test.ts',
'src/main/runtime/orchestration-cli-subprocess.test.ts',
'src/main/runtime/orchestration-mailbox-delivery.test.ts',
@@ -55,58 +55,6 @@ describe('the editor document content', () => {
expect(editor.posted).toEqual([{ type: 'change', markdown, generation: 1 }])
})
it('shows an image an edit brings back without loading it again', async () => {
const loadImageSource = vi.fn(async () => 'data:image/png;base64,AAAA')
const editor = editorDocument(loadImageSource)
editor.handle.setMarkdown('![Diagram](images/diagram.png)', 1)
const pasted = document.createElement('img')
pasted.setAttribute('data-workspace-path', 'images/diagram.png')
editor.editor.append(pasted)
editor.type()
await vi.waitFor(() => expect(pasted.getAttribute('src')).toBe('data:image/png;base64,AAAA'))
expect(loadImageSource).toHaveBeenCalledOnce()
})
it('carries the generation it was set under into every change', () => {
const editor = editorDocument()
editor.handle.setMarkdown('first', 4)
editor.type()
editor.handle.setMarkdown('second', 5)
editor.type()
expect(editor.posted).toEqual([
{ type: 'change', markdown: 'first', generation: 4 },
{ type: 'change', markdown: 'second', generation: 5 }
])
})
it('reads a generation that is not a number as zero', () => {
const editor = editorDocument()
editor.handle.setMarkdown('body', Number.NaN)
editor.type()
expect(editor.posted).toEqual([{ type: 'change', markdown: 'body', generation: 0 }])
})
it('posts the change on the spot, with no timer between the edit and the host', () => {
const editor = editorDocument()
vi.useFakeTimers()
try {
editor.handle.setMarkdown('body', 1)
editor.type()
expect(editor.posted).toHaveLength(1)
expect(vi.getTimerCount()).toBe(0)
} finally {
vi.useRealTimers()
}
})
it('says nothing while it is rewriting itself', () => {
// setMarkdown’s DOM rewrite must not echo host’s content as new user change.
const editor = editorDocument()
editor.handle.setMarkdown('# Title', 2)
expect(editor.posted).toEqual([])
expect(editor.editor.innerHTML).toBe('<h1>Title</h1>')
})
it('stops reporting, and disables every checkbox, once it is read-only', () => {
const editor = editorDocument()
editor.handle.setMarkdown('- [ ] Open\n- [x] Done', 1)
@@ -83,62 +83,6 @@ describe('useNewWorkspaceRuntimeContext', () => {
return { runtimeSettings, trustedOrcaHooks, availableProviders }
}
it('publishes other source providers while the optional Jira probe is pending', async () => {
const client = new FakeSession('connected')
let answerJira: (response: RpcResponse) => void = () => {}
const pending = new Promise<RpcResponse>((resolve) => {
answerJira = resolve
})
client.sendRequest.mockImplementation((method: string) => {
if (method === 'jira.status') {
return pending
}
if (method === 'settings.get') {
return Promise.resolve(reply({ settings: SETTINGS }))
}
if (method === 'ui.get') {
return Promise.resolve(reply(UI_WITH_TRUST))
}
if (method === 'preflight.check') {
return Promise.resolve(reply({ glab: { installed: true } }))
}
return Promise.resolve(reply({ connected: true }))
})
await act(async () => {
renderer = create(createElement(Harness, { client }))
})
expect(currentContext().availableProviders).toEqual(['github', 'linear'])
await act(async () => answerJira(reply({ connected: true })))
expect(currentContext().jiraConnection.connected).toBe(true)
})
// A null settings result must still reach the provider commit.
it.each([
['null', null],
['absent', undefined],
['without a settings member', {}]
])('degrades a %s settings result to absent settings', async (_label, settingsResult) => {
// Jira has no availability gate here, so absent settings still offer it.
expect(await answer(settingsResult, UI_WITH_TRUST)).toEqual({
runtimeSettings: null,
trustedOrcaHooks: TRUSTED_HOOKS,
availableProviders: ['github', 'jira']
})
})
// A null UI result must not skip the provider commit either.
it.each([
['null', null],
['absent', undefined],
['without a ui member', {}]
])('degrades a %s ui result to untrusted hooks', async (_label, uiResult) => {
expect(await answer({ settings: SETTINGS }, uiResult)).toEqual({
runtimeSettings: SETTINGS,
trustedOrcaHooks: {},
availableProviders: ['github']
})
})
// Stale trusted hooks would skip the setup-hook approval prompt; assert they are cleared.
it('blanks the trust an earlier host published when the next ui result is null', async () => {
expect((await answer({ settings: SETTINGS }, UI_WITH_TRUST)).trustedOrcaHooks).toEqual(
@@ -161,14 +105,6 @@ describe('useNewWorkspaceRuntimeContext', () => {
expect(currentContext().availableProviders).toEqual([])
})
// Any server's relay change rebuilds every scoped client, so the client object is not the host.
it('keeps the selected host values when its client is rebuilt', async () => {
const published = await answer({ settings: SETTINGS }, UI_WITH_TRUST, 'runtime:a')
await swapToUnansweredClient('runtime:a')
expect(currentContext().runtimeSettings).toEqual(published.runtimeSettings)
expect(currentContext().trustedOrcaHooks).toEqual(TRUSTED_HOOKS)
})
// A -> B -> A: the first A's Save callbacks must stay retired when A is picked again.
it('ignores publishers from an earlier selection, including of the same host', async () => {
let ui: unknown = UI_WITH_TRUST
@@ -2,11 +2,7 @@ import { Buffer } from 'buffer/index.js'
import { Buffer as NodeBuffer } from 'node:buffer'
import { describe, expect, it, vi } from 'vitest'
import type { RpcResponse } from '../transport/types'
import {
downloadMobileFileMedia,
MOBILE_MEDIA_CHUNK_BYTES,
MOBILE_MEDIA_MAX_BYTES
} from './mobile-file-media-download'
import { downloadMobileFileMedia, MOBILE_MEDIA_CHUNK_BYTES } from './mobile-file-media-download'
const media = {
worktreeId: 'folder-workspace',
@@ -46,22 +42,6 @@ function rig(replies: RpcResponse[]) {
}
describe('mobile media download', () => {
it.each(['forbidden', 'method_not_found'])(
'explains when an older host refuses media stat: %s',
async (code) => {
const r = rig([
{
id: '1',
ok: false,
error: { code, message: 'Unavailable' },
_meta: { runtimeId: 'host' }
}
])
await expect(r.run()).rejects.toThrow('Update Orca on your desktop')
expect(r.client.sendRequest).toHaveBeenCalledTimes(1)
expect(r.sink.dispose).toHaveBeenCalledOnce()
}
)
it('stages bounded chunks byte-for-byte and verifies the file after downloading', async () => {
const bytes = new Uint8Array(MOBILE_MEDIA_CHUNK_BYTES + 3).fill(123)
const r = rig([
@@ -89,26 +69,6 @@ describe('mobile media download', () => {
expect(r.progress).toHaveBeenLastCalledWith(bytes.length, bytes.length)
expect(r.sink.dispose).not.toHaveBeenCalled()
})
it('refuses oversized files before requesting any bytes', async () => {
const r = rig([stat(MOBILE_MEDIA_MAX_BYTES + 1)])
await expect(r.run()).rejects.toThrow('256 MB')
expect(r.client.sendRequest).toHaveBeenCalledTimes(1)
expect(r.sink.append).not.toHaveBeenCalled()
expect(r.sink.dispose).toHaveBeenCalledOnce()
})
it.each([
{ contentBase64: 'AA==', bytesRead: 3, eof: true },
{ contentBase64: '', bytesRead: 0, eof: false },
{ contentBase64: 'AA==', bytesRead: 1, eof: true },
{ contentBase64: 'AAAAAA==', bytesRead: 4, eof: true },
{ contentBase64: '$invalid', bytesRead: 3, eof: true }
])('discards a malformed or truncated transfer: %j', async (invalid) => {
const r = rig([stat(3), ok(invalid)])
await expect(r.run()).rejects.toThrow()
expect(r.sink.append).not.toHaveBeenCalled()
expect(r.sink.finish).not.toHaveBeenCalled()
expect(r.sink.dispose).toHaveBeenCalledOnce()
})
it('refuses a file modified while it was downloaded', async () => {
const r = rig([stat(3), chunk(new Uint8Array(3), true), stat(3, 2)])
await expect(r.run()).rejects.toThrow('File changed')
@@ -1,195 +0,0 @@
// The microphone already owns the screen when desktop startup runs; failed current starts must release both, while stale starts must leave their replacement alone.
import { describe, expect, it, vi } from 'vitest'
import { startMobileDictationDesktopSession } from './mobile-dictation-desktop-start'
import type { RpcClient } from '../transport/rpc-client'
const OK_RESPONSE = { ok: true, result: {} } as const
type StartHarnessOptions = {
sendRequest?: (method: string) => Promise<unknown>
commitRecordingStart?: () => boolean
}
function createStartHarness(options: StartHarnessOptions = {}) {
let generation = 1
let enabled = true
let activeId: string | null = 'dictation-a'
const setIdle = vi.fn()
const commitRecordingStart = vi.fn(options.commitRecordingStart ?? (() => true))
const rollbackRecordingStart = vi.fn()
const sendRequest = vi.fn(
options.sendRequest ?? (async () => OK_RESPONSE)
) as unknown as RpcClient['sendRequest']
const client = { sendRequest } as RpcClient
return {
options: {
client,
dictationId: 'dictation-a',
generation: 1,
getCurrentGeneration: () => generation,
getEnabled: () => enabled,
getActiveId: () => activeId,
clearActiveId: (dictationId: string) => {
if (activeId === dictationId) {
activeId = null
}
},
setIdle,
commitRecordingStart,
rollbackRecordingStart
},
setNewerStart: () => {
generation = 2
activeId = 'dictation-b'
},
setDisabled: () => {
enabled = false
},
getActiveId: () => activeId,
setIdle,
sendRequest,
commitRecordingStart,
rollbackRecordingStart
}
}
describe('startMobileDictationDesktopSession', () => {
it('cancels a start a newer one superseded while the desktop session opened', async () => {
let setNewerStart: () => void = () => {}
const harness = createStartHarness({
sendRequest: async (method) => {
if (method === 'speech.dictation.start') {
setNewerStart()
}
return OK_RESPONSE
}
})
setNewerStart = harness.setNewerStart
await expect(startMobileDictationDesktopSession(harness.options)).resolves.toBe(false)
expect(harness.setIdle).not.toHaveBeenCalled()
expect(harness.getActiveId()).toBe('dictation-b')
expect(harness.commitRecordingStart).not.toHaveBeenCalled()
expect(harness.sendRequest).toHaveBeenCalledWith(
'speech.dictation.cancel',
{ dictationId: 'dictation-a' },
{ failWhenDisconnected: true }
)
})
it('returns to idle when a disable makes the start stale', async () => {
let setDisabled: () => void = () => {}
const harness = createStartHarness({
sendRequest: async (method) => {
if (method === 'speech.dictation.start') {
setDisabled()
}
return OK_RESPONSE
}
})
setDisabled = harness.setDisabled
await expect(startMobileDictationDesktopSession(harness.options)).resolves.toBe(false)
expect(harness.setIdle).toHaveBeenCalledOnce()
expect(harness.getActiveId()).toBeNull()
expect(harness.commitRecordingStart).not.toHaveBeenCalled()
})
it('closes the capture the hook opened when the desktop start fails', async () => {
const harness = createStartHarness({
sendRequest: async (method) => {
if (method === 'speech.dictation.start') {
throw new Error('Desktop start failed')
}
return OK_RESPONSE
}
})
await expect(startMobileDictationDesktopSession(harness.options)).rejects.toThrow(
'Desktop start failed'
)
// The hook opened capture before requesting a session; refusal must release it and the screen because no outer start catch does so.
expect(harness.rollbackRecordingStart).toHaveBeenCalledOnce()
expect(harness.sendRequest).toHaveBeenCalledWith(
'speech.dictation.cancel',
{ dictationId: 'dictation-a' },
{ failWhenDisconnected: true }
)
})
it('leaves the capture alone when the failure is no longer the current start', async () => {
let setNewerStart: () => void = () => {}
const harness = createStartHarness({
sendRequest: async (method) => {
if (method === 'speech.dictation.start') {
setNewerStart()
throw new Error('Desktop start failed')
}
return OK_RESPONSE
}
})
setNewerStart = harness.setNewerStart
await expect(startMobileDictationDesktopSession(harness.options)).resolves.toBe(false)
// Capture has no start identity; rolling back stale A would stop B’s microphone and release B’s screen.
expect(harness.rollbackRecordingStart).not.toHaveBeenCalled()
// A still owns its desktop session and must cancel that alone.
expect(harness.sendRequest).toHaveBeenCalledWith(
'speech.dictation.cancel',
{ dictationId: 'dictation-a' },
{ failWhenDisconnected: true }
)
})
it('commits recording before returning a current start to the hook', async () => {
const harness = createStartHarness()
await expect(startMobileDictationDesktopSession(harness.options)).resolves.toBe(true)
expect(harness.commitRecordingStart).toHaveBeenCalledOnce()
expect(harness.rollbackRecordingStart).not.toHaveBeenCalled()
})
it('cleans up the desktop session when native recording throws', async () => {
const harness = createStartHarness({
commitRecordingStart: () => {
throw new Error('Audio focus request failed')
}
})
await expect(startMobileDictationDesktopSession(harness.options)).rejects.toThrow(
'Audio focus request failed'
)
expect(harness.sendRequest).toHaveBeenCalledWith(
'speech.dictation.cancel',
{ dictationId: 'dictation-a' },
{ failWhenDisconnected: true }
)
expect(harness.getActiveId()).toBeNull()
expect(harness.setIdle).toHaveBeenCalledOnce()
expect(harness.rollbackRecordingStart).toHaveBeenCalledOnce()
})
it('rejects and cleans up when the native recorder does not start', async () => {
const harness = createStartHarness({ commitRecordingStart: () => false })
await expect(startMobileDictationDesktopSession(harness.options)).rejects.toThrow(
'Failed to start microphone recording'
)
expect(harness.sendRequest).toHaveBeenCalledWith(
'speech.dictation.cancel',
{ dictationId: 'dictation-a' },
{ failWhenDisconnected: true }
)
expect(harness.getActiveId()).toBeNull()
expect(harness.setIdle).toHaveBeenCalledOnce()
expect(harness.rollbackRecordingStart).toHaveBeenCalledOnce()
})
})
@@ -1,181 +0,0 @@
import { createElement, useState } from 'react'
import {
act,
create as createRenderer,
type ReactTestInstance,
type ReactTestRenderer
} from 'react-test-renderer'
import { afterEach, describe, expect, it, vi } from 'vitest'
// Banner and chevron motion run on Reanimated and Animated, which these mocks leave out.
vi.mock('../components/AutoHeight', () => ({
Presence: ({ children }: { children: unknown }) => children || null,
AutoHeight: ({ children }: { children: unknown }) => children
}))
vi.mock('../components/DisclosureChevron', () => ({
DisclosureChevron: ({ children }: { children: unknown }) => children
}))
vi.mock('react-native', () => ({
Platform: { OS: 'ios' },
Pressable: 'Pressable',
RefreshControl: 'RefreshControl',
Animated: { SectionList: 'SectionList' },
StyleSheet: { create: (styles: unknown) => styles },
Text: 'Text',
View: 'View'
}))
vi.mock('lucide-react-native', () => ({
ChevronDown: 'ChevronDown',
ChevronRight: 'ChevronRight',
Pin: 'Pin',
X: 'X'
}))
vi.mock('../components/AuthFailedBanner', () => ({ AuthFailedBanner: 'AuthFailedBanner' }))
vi.mock('../components/HostDiagnosticsLink', () => ({ HostDiagnosticsLink: 'HostDiagnosticsLink' }))
vi.mock('../components/MobileRepoIcon', () => ({ MobileRepoIcon: 'MobileRepoIcon' }))
vi.mock('../components/WorktreeListRow', () => ({ WorktreeListRow: 'WorktreeListRow' }))
vi.mock('../worktree/host-workspace-list-states', () => ({
HostWorkspaceListStates: 'HostWorkspaceListStates'
}))
import { colors } from '../theme/mobile-theme'
import { HostWorkspaceList } from './host-workspace-list'
import { INITIAL_MOBILE_VIEW } from '../worktree/workspace-view-settings'
const renderers = new Set<ReturnType<typeof createRenderer>>()
function create(...args: Parameters<typeof createRenderer>): ReturnType<typeof createRenderer> {
const renderer = createRenderer(...args)
renderers.add(renderer)
return renderer
}
function unmountRenderers(): void {
act(() => {
for (const renderer of renderers) {
renderer.unmount()
}
})
renderers.clear()
}
// Render the real list banner: the screen suite mocks it away and cannot catch missing error copy or dismissal.
function listWith(fields: { actionError: string; setActionError: (value: string) => void }) {
const rows = [{ worktreeId: 'wt-1', repo: 'orca' }]
const sections = [{ key: 'all', title: '', data: rows }]
const controller = {
actions: { openWorktreeSession: () => {}, openNewWorktreeModal: () => {} },
activeWorktreeScroll: { sectionListRef: { current: null }, onScrollToIndexFailed: () => {} },
catalog: { refreshing: false, onRefresh: () => {} },
connState: 'connected',
contentMaxWidth: 600,
displayWorktrees: rows,
// Use the embedded path to avoid unrelated floating-button dependencies.
embedded: true,
forceReconnectHost: () => {},
hostId: 'host-1',
insets: { bottom: 0 },
isReadOnly: false,
isWideLayout: false,
noticeParam: undefined,
now: 0,
reconnectAttempts: 0,
relayRecovery: { pairingRejected: false },
routeNotice: '',
router: { push: () => {} },
sectionsResult: { rawSections: sections, sections, repoById: new Map() },
setDismissedNotice: () => {},
settings: { activeFilterCount: 0, pendingViewChanges: { pending: [], stale: false } },
state: {
actionError: fields.actionError,
setActionError: fields.setActionError,
catalogError: null,
collapsedGroups: new Set(),
view: { ...INITIAL_MOBILE_VIEW, groupBy: 'none' },
search: '',
worktreesLoaded: true
}
}
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: every child below the banner is mocked to a host string here, so what the list reads off the controller is exactly the fields named above.
return { controller, bottomBarHeight: 0 } as unknown as Parameters<typeof HostWorkspaceList>[0]
}
function render(fields: {
actionError: string
setActionError: (value: string) => void
}): ReactTestRenderer {
function List(): ReturnType<typeof createElement> {
const [actionError, setActionError] = useState(fields.actionError)
return createElement(
HostWorkspaceList,
listWith({
actionError,
setActionError: (value) => {
fields.setActionError(value)
setActionError(value)
}
})
)
}
const rendered: { tree: ReactTestRenderer | null } = { tree: null }
act(() => {
rendered.tree = create(createElement(List))
})
if (rendered.tree === null) {
throw new Error('the list did not render')
}
return rendered.tree
}
function texts(tree: ReactTestRenderer): unknown[] {
return tree.root
.findAll((node) => String(node.type) === 'Text')
.map((node) => node.props.children)
}
function dismiss(tree: ReactTestRenderer): ReactTestInstance {
const [only, ...rest] = tree.root.findAll(
(node) =>
String(node.type) === 'Pressable' && node.props.accessibilityLabel === 'Dismiss notice'
)
if (only === undefined || rest.length > 0) {
throw new Error(`expected one dismiss, found ${rest.length + Number(only !== undefined)}`)
}
return only
}
describe('an action that did not happen', () => {
it('is on screen above the list, in the tone that says it failed', () => {
const tree = render({ actionError: 'Could not delete workspace.', setActionError: () => {} })
expect(texts(tree)).toEqual(['Could not delete workspace.'])
const banner = tree.root.findAll(
(node) => String(node.type) === 'View' && Array.isArray(node.props.style)
)[0]
expect(banner?.props.style).toContainEqual({ borderBottomColor: colors.statusRed })
const list = tree.root.find((node) => String(node.type) === 'SectionList')
expect(list.props.sections[0].data).toEqual([{ worktreeId: 'wt-1', repo: 'orca' }])
expect(tree.root.findAll((node) => node === banner || node === list)).toEqual([banner, list])
})
it('is dismissible, because the list and the confirm it re-opens have to stay reachable', () => {
const cleared: string[] = []
const tree = render({
actionError: 'Could not delete workspace.',
setActionError: (value) => cleared.push(value)
})
act(() => {
dismiss(tree).props.onPress()
})
expect(cleared).toEqual([''])
expect(texts(tree)).not.toContain('Could not delete workspace.')
expect(
tree.root.find((node) => String(node.type) === 'SectionList').props.sections[0].data
).toEqual([{ worktreeId: 'wt-1', repo: 'orca' }])
})
it('paints nothing at all while there is none', () => {
const tree = render({ actionError: '', setActionError: () => {} })
expect(texts(tree)).toEqual([])
})
})
afterEach(unmountRenderers)
@@ -1,205 +0,0 @@
import { createElement, Fragment, type ReactNode } from 'react'
import { act, create as createRenderer, type ReactTestRenderer } from 'react-test-renderer'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { Section, WorkspaceListItem, Worktree } from '../worktree/workspace-list-types'
const { rowRenders, agentRenders } = vi.hoisted(() => ({
rowRenders: vi.fn(),
agentRenders: vi.fn()
}))
// Banner and chevron motion run on Reanimated and Animated, which these mocks leave out.
vi.mock('../components/AutoHeight', () => ({
Presence: ({ children }: { children: unknown }) => children || null,
AutoHeight: ({ children }: { children: unknown }) => children
}))
vi.mock('../components/DisclosureChevron', () => ({
DisclosureChevron: ({ children }: { children: unknown }) => children
}))
vi.mock('react-native', () => ({
Platform: { OS: 'ios' },
Pressable: 'Pressable',
RefreshControl: 'RefreshControl',
Animated: {
SectionList: ({
sections,
keyExtractor,
renderItem
}: {
sections: Section[]
keyExtractor: (item: WorkspaceListItem) => string
renderItem: (info: { item: WorkspaceListItem }) => ReactNode
}) =>
sections.flatMap((section) =>
section.data.map((item) =>
createElement(Fragment, { key: keyExtractor(item) }, renderItem({ item }))
)
)
},
StyleSheet: { create: <T,>(styles: T) => styles },
Text: 'Text',
View: 'View'
}))
vi.mock('lucide-react-native', () => ({}))
vi.mock('../platform/haptics', () => ({ triggerMediumImpact: vi.fn() }))
vi.mock('../components/AgentSpinner', () => ({ AgentSpinner: () => null }))
vi.mock('../components/MobileRepoIcon', () => ({ MobileRepoIcon: () => null }))
vi.mock('../components/WorktreeAgentList', () => ({
WorktreeAgentList: (props: unknown) => {
agentRenders(props)
return null
}
}))
// Each row always renders glyphs, so glyph calls count row renders.
vi.mock('../components/WorktreeMetaGlyphs', () => ({
prStateColor: () => '#000000',
WorktreeMetaGlyphs: () => {
rowRenders()
return null
}
}))
import { buildSections } from '../worktree/workspace-list-sections'
import { NO_WORKSPACE_ROW_FILTERS } from '../../../src/shared/workspace-view/workspace-row-filters'
import { HostWorkspaceList } from './host-workspace-list'
import { INITIAL_MOBILE_VIEW } from '../worktree/workspace-view-settings'
const renderers = new Set<ReturnType<typeof createRenderer>>()
function create(...args: Parameters<typeof createRenderer>): ReturnType<typeof createRenderer> {
const renderer = createRenderer(...args)
renderers.add(renderer)
return renderer
}
function unmountRenderers(): void {
act(() => {
for (const renderer of renderers) {
renderer.unmount()
}
})
renderers.clear()
}
function worktree(id: string, overrides: Partial<Worktree> = {}): Worktree {
return {
worktreeId: `repo-1::${id}`,
repoId: 'repo-1',
repo: 'orca',
branch: `feature/${id}`,
displayName: id,
path: id,
liveTerminalCount: 1,
hasAttachedPty: true,
unread: false,
isPinned: false,
linkedPR: null,
status: 'active',
...overrides
}
}
const noop = () => {}
const stable = { ref: { current: null }, repoById: new Map() }
function listProps(worktrees: Worktree[]) {
// Reparse like every poll, producing fresh row objects even for unchanged data.
const polled: Worktree[] = JSON.parse(JSON.stringify(worktrees))
const sections = buildSections(polled, 'name', NO_WORKSPACE_ROW_FILTERS, '', 'none', new Set())
const controller = {
actions: { openWorktreeSession: noop, openNewWorktreeModal: noop },
activeWorktreeScroll: { sectionListRef: stable.ref, onScrollToIndexFailed: noop },
catalog: { refreshing: false, onRefresh: noop },
connectionVerdict: { kind: 'normal', label: 'Connected' },
connState: 'connected',
displayWorktrees: polled,
embedded: true,
insets: { bottom: 0 },
isReadOnly: false,
now: 1_000,
reconnectAttempts: 0,
relayRecovery: { pairingRejected: false },
sectionsResult: { rawSections: sections, sections, repoById: stable.repoById },
settings: {
activeFilterCount: 0,
toggleWorktreeLineage: noop,
pendingViewChanges: { pending: [], stale: false }
},
state: {
actionError: '',
setActionTarget: noop,
view: { ...INITIAL_MOBILE_VIEW, groupBy: 'none' },
search: '',
worktreesLoaded: true
}
}
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the list reads only the controller fields above on this connected, embedded, error-free path.
return { controller, bottomBarHeight: 0 } as unknown as Parameters<typeof HostWorkspaceList>[0]
}
describe('host list rows across polls', () => {
it('re-renders only the row whose worktree changed', () => {
const rows = [worktree('a'), worktree('b'), worktree('c')]
let tree: ReactTestRenderer | null = null
act(() => {
tree = create(createElement(HostWorkspaceList, listProps(rows)))
})
rowRenders.mockClear()
const changed = [rows[0]!, { ...rows[1]!, branch: 'feature/b-renamed' }, rows[2]!]
act(() => tree!.update(createElement(HostWorkspaceList, listProps(changed))))
expect(rowRenders).toHaveBeenCalledTimes(1)
expect(
tree!.root
.findAll((node) => String(node.type) === 'Text')
.flatMap((node) => node.props.children)
).toContain('feature/b-renamed')
act(() => tree!.unmount())
})
it('re-renders only the workspace whose child roster changed', () => {
const row = worktree('a', {
agents: [
{
paneKey: 'parent',
parentPaneKey: null,
state: 'working',
agentType: 'codex',
prompt: '',
taskTitle: null,
displayName: null,
lastAssistantMessage: null,
toolName: null,
toolInput: null,
interrupted: false,
stateStartedAt: 1,
updatedAt: 1,
subagents: [{ id: 'child', state: 'working', startedAt: 1 }]
}
]
})
let tree: ReactTestRenderer | null = null
act(() => {
tree = create(createElement(HostWorkspaceList, listProps([row, worktree('b')])))
})
rowRenders.mockClear()
agentRenders.mockClear()
const changed = {
...row,
agents: [
{
...row.agents![0]!,
subagents: [{ id: 'child', state: 'waiting' as const, startedAt: 1 }]
}
]
}
act(() => tree!.update(createElement(HostWorkspaceList, listProps([changed, worktree('b')]))))
expect(rowRenders).toHaveBeenCalledTimes(1)
expect(agentRenders).toHaveBeenCalledTimes(1)
expect(agentRenders.mock.calls[0]?.[0]).toMatchObject({
agents: [{ paneKey: 'parent', subagents: [{ id: 'child', state: 'waiting', startedAt: 1 }] }]
})
act(() => tree!.unmount())
})
})
afterEach(unmountRenderers)
@@ -1,195 +0,0 @@
import { createElement } from 'react'
import { act, create as createRenderer } from 'react-test-renderer'
import { afterEach, describe, expect, it, vi } from 'vitest'
import {
structuredAgentSessionPaneKey,
structuredAgentSessionTabId
} from '../../../src/shared/agent-session/view/structured-agent-session-projection'
import { MOBILE_DESKTOP_RELAY_RUNTIME_CAPABILITY } from '../../../src/shared/mobile-desktop-relay-contract'
import type { Worktree } from '../worktree/workspace-list-types'
import { useHostScreenState } from './use-host-screen-state'
import { useHostWorktreeActions } from './use-host-worktree-actions'
import { useSessionLeaveGuard } from '../session/session-leave-guard'
import { stubRpcClient } from '../transport/rpc-client-test-stub'
import type { RpcResponse } from '../transport/types'
const renderers = new Set<ReturnType<typeof createRenderer>>()
function create(...args: Parameters<typeof createRenderer>): ReturnType<typeof createRenderer> {
const renderer = createRenderer(...args)
renderers.add(renderer)
return renderer
}
function unmountRenderers(): void {
act(() => {
for (const renderer of renderers) {
renderer.unmount()
}
})
renderers.clear()
}
vi.mock('expo-router', () => ({}))
vi.mock('react-native', () => ({ Platform: { OS: 'ios' } }))
vi.mock('../storage/preferences', () => ({ savePinnedIds: async () => {} }))
vi.mock('../transport/host-removal-lifecycle', () => ({ removeHostAndCloseClient: async () => {} }))
const leaveHostRoute = vi.hoisted(() => vi.fn())
vi.mock('../host-route-exit', () => ({ leaveHostRoute }))
const item: Worktree = {
worktreeId: 'folder-one',
workspaceKind: 'folder-workspace',
repoId: 'repo',
repo: 'app',
branch: '',
displayName: 'Folder',
path: '/workspace',
liveTerminalCount: 1,
hasAttachedPty: true,
unread: false,
isPinned: false,
linkedPR: null
}
const sessionId = '10000000-0000-4000-8000-000000000000'
const nativeTabId = structuredAgentSessionTabId(sessionId)
const terminalKey = `terminal:${sessionId}`
const nativeKey = structuredAgentSessionPaneKey(nativeTabId, sessionId)
describe('a workspace child opens its parent pane', () => {
it.each([
[terminalKey, undefined],
[nativeKey, undefined],
[terminalKey, 'runtime:server'],
[nativeKey, 'runtime:server']
] as const)(
'selects parent %s on %s through the workspace action',
async (paneKey, executionHost) => {
const push = vi.fn()
const replace = vi.fn()
const sendRequest = vi.fn(
async (_method: string, _params: unknown, _options?: unknown): Promise<RpcResponse> => ({
id: 'activate',
ok: true,
result: {}
})
)
const client = stubRpcClient({ sendRequest })
const row = { ...item, ...(executionHost ? { hostId: executionHost } : {}) }
const held: { open?: (item: Worktree, paneKey?: string) => void } = {}
function Probe() {
const state = useHostScreenState('host')
held.open = useHostWorktreeActions({
client,
hostCapabilities: [MOBILE_DESKTOP_RELAY_RUNTIME_CAPABILITY],
connState: 'connected',
embedded: true,
fetchWorktrees: async () => {},
forgetHostClient: () => {},
hostId: 'host',
pathname: '/h/host/session/folder-one',
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: navigation reaches only push/replace; this double records both router methods.
router: { push, replace } as unknown as Parameters<
typeof useHostWorktreeActions
>[0]['router'],
state
}).openWorktreeSession
return null
}
let tree: ReturnType<typeof create> | undefined
await act(async () => {
tree = create(createElement(Probe))
})
await act(async () => {
held.open?.(row, paneKey)
})
const target = replace.mock.calls[0]?.[0]
expect(target).toBe(
`/h/host/session/folder-one?name=Folder${executionHost ? `&executionHost=${encodeURIComponent(executionHost)}` : ''}&paneKey=${encodeURIComponent(paneKey)}`
)
expect(sendRequest.mock.calls[0]?.slice(0, 2)).toEqual([
'worktree.activate',
{
worktree: 'id:folder-one',
notifyClients: false,
navigation: 'caller'
}
])
expect(sendRequest.mock.calls[0]?.[2]).toEqual(
executionHost ? { envelope: { executionHost } } : undefined
)
expect(push).not.toHaveBeenCalled()
await act(async () => {
tree!.unmount()
})
}
)
})
async function tapWhileRecording(
pathname: string,
tap: (actions: ReturnType<typeof useHostWorktreeActions>) => void
) {
const replace = vi.fn()
const sendRequest = vi.fn(async (): Promise<RpcResponse> => ({
id: 'activate',
ok: true,
result: {}
}))
// The recording session publishes a guard that holds the leave.
const guard = vi.fn(() => true)
const held: { actions?: ReturnType<typeof useHostWorktreeActions> } = {}
function Probe() {
useSessionLeaveGuard(guard)
held.actions = useHostWorktreeActions({
client: stubRpcClient({ sendRequest }),
hostCapabilities: [MOBILE_DESKTOP_RELAY_RUNTIME_CAPABILITY],
connState: 'connected',
embedded: true,
fetchWorktrees: async () => {},
forgetHostClient: () => {},
hostId: 'host',
pathname,
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: navigation reaches only push/replace; this double records both router methods.
router: { push: vi.fn(), replace } as unknown as Parameters<
typeof useHostWorktreeActions
>[0]['router'],
state: useHostScreenState('host')
})
return null
}
await act(async () => {
create(createElement(Probe))
})
await act(async () => {
if (held.actions) {
tap(held.actions)
}
})
return { replace, sendRequest, guard }
}
describe('a sidebar tap while the open session is recording', () => {
it('stays without touching the other workspace', async () => {
const tap = await tapWhileRecording('/h/host/session/other', (actions) =>
actions.openWorktreeSession(item)
)
expect(tap.guard).toHaveBeenCalledTimes(1)
expect(tap.replace).not.toHaveBeenCalled()
expect(tap.sendRequest).not.toHaveBeenCalled()
})
it('keeps recording when the tap is on the open session itself', async () => {
const tap = await tapWhileRecording('/h/host/session/folder-one', (actions) =>
actions.openWorktreeSession(item)
)
expect(tap.guard).not.toHaveBeenCalled()
expect(tap.replace).not.toHaveBeenCalled()
})
it('stays on Back to hosts too', async () => {
const tap = await tapWhileRecording('/h/host/session/other', (actions) => actions.leaveHost())
expect(tap.guard).toHaveBeenCalledTimes(1)
expect(leaveHostRoute).not.toHaveBeenCalled()
})
})
afterEach(unmountRenderers)
@@ -1,223 +0,0 @@
import { createElement } from 'react'
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
import { afterEach, describe, expect, it, vi } from 'vitest'
vi.mock('react-native', () => ({
ActivityIndicator: 'ActivityIndicator',
Platform: { OS: 'android' },
Pressable: 'Pressable',
StyleSheet: { create: <T,>(styles: T) => styles, hairlineWidth: 1 },
Switch: 'Switch',
Text: 'Text',
View: 'View'
}))
vi.mock('lucide-react-native', () => ({
Check: 'Check',
ChevronDown: 'ChevronDown',
ChevronLeft: 'ChevronLeft',
ChevronRight: 'ChevronRight',
X: 'X'
}))
vi.mock('../navigation/use-back-claim', () => ({ useBackClaim: () => {} }))
vi.mock('../platform/use-reduced-motion', () => ({ useReducedMotionEnabled: () => false }))
// The slide settles at once, so a back press unmounts the page in the same act.
vi.mock('react-native-reanimated', async () =>
(await import('../test-support/reanimated-pager-stub')).reanimatedPagerStub()
)
// Stub the drawer's sheet machinery, not BottomDrawer: the option pager under test lives in that module.
vi.mock('../components/bottom-drawer/keyed-bottom-drawer', () => ({
KeyedBottomDrawer: ({ children }: { children: () => unknown }) => children()
}))
vi.mock('../components/MobileSearchField', () => ({ MobileSearchField: 'MobileSearchField' }))
vi.mock('../components/MobileSwitch', () => ({ MobileSwitch: 'Switch' }))
import { NO_WORKSPACE_ROW_FILTERS } from '../../../src/shared/workspace-view/workspace-row-filters'
import { INITIAL_MOBILE_VIEW } from '../worktree/workspace-view-settings'
import { WorkspaceOptionsSheet } from './workspace-options-sheet'
let renderer: ReactTestRenderer | null = null
afterEach(() => {
act(() => renderer?.unmount())
renderer = null
})
type SheetOptions = {
newCardStyle: boolean
hideSleeping: boolean
hasFolderRow?: boolean
activeFilterCount?: number
}
function buildController(options: SheetOptions) {
const settings = {
clearFilters: vi.fn(),
handleGroupChange: vi.fn(),
handleSortChange: vi.fn(),
setAgentActivityDisplayMode: vi.fn(),
setCardLayout: vi.fn(),
setFilter: vi.fn(),
setProjectOrderBy: vi.fn(),
toggleCardProperty: vi.fn(),
toggleRepoFilter: vi.fn(),
pendingViewChanges: { pending: [], stale: false }
}
const controller = {
activeFilterCount: options.activeFilterCount ?? 0,
projectOrderBy: 'manual',
displayWorktrees: [],
sectionsResult: {
uniqueRepos: [
{ key: 'r1', id: 'r1', name: 'orca', color: '#fff' },
{ key: 'r2', id: 'r2', name: 'docs', color: '#000' }
]
},
settings,
state: {
view: {
...INITIAL_MOBILE_VIEW,
filters: { ...NO_WORKSPACE_ROW_FILTERS, hideSleepingWorkspaces: options.hideSleeping },
groupBy: 'repo',
sortBy: 'smart',
sharesOtherClientFilter: true,
newCardStyle: options.newCardStyle
},
hostLabelById: new Map(),
hostPlatform: null,
repoHostIdByRepoId: new Map([['r2', 'ssh:box']]),
serverWorkspaces: { hosts: [{ hostId: 'runtime:vm', label: 'VM' }], worktrees: [] },
setShowWorkspaceOptions: vi.fn(),
showWorkspaceOptions: true,
worktrees: options.hasFolderRow ? [{ workspaceKind: 'folder-workspace' }] : []
}
}
return { controller, settings }
}
function render(options: SheetOptions) {
const { controller, settings } = buildController(options)
act(() => {
renderer = create(
createElement(WorkspaceOptionsSheet, {
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the sheet reads only these members.
controller: controller as unknown as Parameters<
typeof WorkspaceOptionsSheet
>[0]['controller']
})
)
})
return { settings, state: controller.state }
}
const texts = () =>
renderer!.root
.findAll((node) => String(node.type) === 'Text')
.map((node) => node.props.children)
.filter((child): child is string => typeof child === 'string')
const pressable = (label: string) =>
renderer!.root.find(
(node) => String(node.type) === 'Pressable' && node.props.accessibilityLabel === label
)
const press = (label: string) => {
act(() => {
pressable(label).props.onPress()
})
}
const back = () => press('Back to Workspace options')
const flip = (label: string, value: boolean) => {
act(() => {
renderer!.root
.find((node) => String(node.type) === 'Switch' && node.props.accessibilityLabel === label)
.props.onValueChange(value)
})
}
describe('the phone Workspace options sheet', () => {
it('swaps in Card display for the new card style and drops the exemption when nothing sleeps', () => {
render({ newCardStyle: true, hideSleeping: false })
const shown = texts()
expect(shown).toContain('Card display')
expect(shown).not.toContain('Card layout')
expect(shown).not.toContain('Show properties')
expect(shown).not.toContain('Except default branch')
press('Card display')
expect(texts()).toContain('Orca CLI')
expect(texts()).toContain('Branch name')
expect(texts()).not.toContain('Ports')
})
it('names the branch choice for folder paths too once the desktop has folder workspaces', () => {
render({ newCardStyle: true, hideSleeping: false, hasFolderRow: true })
press('Card display')
expect(texts()).toContain('Branch / folder path')
})
it('opens an option page from its row, stays there after a pick, and goes back', () => {
const { settings } = render({ newCardStyle: false, hideSleeping: false })
expect(texts()).toContain('Card layout')
expect(texts()).toContain('Show properties')
expect(texts()).not.toContain('Card display')
press('Sort by, Agent Activity')
expect(texts()).toContain('Agents that need attention, then most recent activity.')
press('Recent')
expect(settings.handleSortChange).toHaveBeenCalledWith('recent')
expect(texts()).toContain('Name')
back()
expect(texts()).toContain('Filters')
})
it('writes each control to the shared value it names', () => {
const { settings } = render({ newCardStyle: false, hideSleeping: true })
expect(texts()).toContain('Except default branch')
flip('Hide CLI-created', true)
expect(settings.setFilter).toHaveBeenCalledWith('hideCliCreatedWorkspaces', true)
press('PR')
expect(settings.handleGroupChange).toHaveBeenCalledWith('pr-status')
press('Card layout, Detailed')
press('Compact')
expect(settings.setCardLayout).toHaveBeenCalledWith('compact')
back()
press('Project order, Manual')
press('Recent')
expect(settings.setProjectOrderBy).toHaveBeenCalledWith('recent')
back()
press('Hosts, All hosts')
press('VM')
expect(settings.setFilter).toHaveBeenCalledWith('visibleHostIds', ['runtime:vm'])
back()
press('Projects, All projects')
press('docs')
expect(settings.toggleRepoFilter).toHaveBeenCalledWith('r2')
back()
press('Show properties, 8')
expect(texts()).not.toContain('Ports')
press('Automation')
expect(settings.toggleCardProperty).toHaveBeenCalledWith('automation')
expect(settings.toggleCardProperty).not.toHaveBeenCalledWith('ports')
press('Full list')
expect(settings.setAgentActivityDisplayMode).toHaveBeenCalledWith('full')
})
it('resets filters only when some are active, and Done closes from the main sheet or a page', () => {
const { settings, state } = render({
newCardStyle: false,
hideSleeping: false,
activeFilterCount: 2
})
expect(texts()).toContain('Synced across your devices')
press('Reset')
expect(settings.clearFilters).toHaveBeenCalledTimes(1)
press('Sort by, Agent Activity')
const pageDone = renderer!.root.findAll(
(node) => String(node.type) === 'Pressable' && node.props.accessibilityLabel === 'Done'
)
act(() => pageDone.at(-1)?.props.onPress())
expect(state.setShowWorkspaceOptions).toHaveBeenCalledWith(false)
act(() => renderer?.unmount())
render({ newCardStyle: false, hideSleeping: false })
expect(pressable('Reset').props.disabled).toBe(true)
})
})
-194
View File
@@ -1,194 +0,0 @@
import { act, create as createRenderer, type ReactTestRenderer } from 'react-test-renderer'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { ReactNode } from 'react'
import { getFoldSplit, type FoldSplit } from './fold-division'
import type { ReservedRegions } from '../../modules/orca-reserved-regions/src'
import type { EdgeInsets, Rect } from 'react-native-safe-area-context'
const renderers = new Set<ReturnType<typeof createRenderer>>()
function create(...args: Parameters<typeof createRenderer>): ReturnType<typeof createRenderer> {
const renderer = createRenderer(...args)
renderers.add(renderer)
return renderer
}
function unmountRenderers(): void {
act(() => {
for (const renderer of renderers) {
renderer.unmount()
}
})
renderers.clear()
}
const geometry = vi.hoisted((): { regions: ReservedRegions; platform: { OS: string } } => ({
regions: { divisions: [], occlusions: [] },
platform: { OS: 'ios' }
}))
vi.mock('./reserved-regions', () => ({ useReservedRegions: () => geometry.regions }))
vi.mock('react-native', () => ({
Platform: geometry.platform,
View: 'View',
StyleSheet: { create: <T,>(styles: T) => styles }
}))
vi.mock('react-native-safe-area-context', async () => {
const { createContext, useContext } = await import('react')
const SafeAreaInsetsContext = createContext<EdgeInsets | null>(null)
const SafeAreaFrameContext = createContext<Rect | null>(null)
return {
SafeAreaInsetsContext,
SafeAreaFrameContext,
SafeAreaProvider: ({ children }: { children: ReactNode }) => children,
useSafeAreaInsets: () => useContext(SafeAreaInsetsContext),
useSafeAreaFrame: () => useContext(SafeAreaFrameContext)
}
})
const { SafeAreaFrameContext, SafeAreaInsetsContext, useSafeAreaFrame, useSafeAreaInsets } =
await import('react-native-safe-area-context')
const { SideSafeArea, useWindowSafeAreaInsets } = await import('./side-safe-area')
// Unequal sides, so a swapped edge shows.
const WINDOW = { top: 0, right: 48, bottom: 21, left: 59 }
const FRAME = { x: 0, y: 0, width: 852, height: 393 }
function renderInLandscape(window = WINDOW, frame = FRAME, nested = false) {
const read: { screen?: EdgeInsets; frame?: Rect; modal?: EdgeInsets; fold?: FoldSplit | null } =
{}
function Probe() {
read.screen = useSafeAreaInsets()
read.frame = useSafeAreaFrame()
read.fold = getFoldSplit(geometry.regions.divisions, read.frame)
read.modal = useWindowSafeAreaInsets()
return null
}
let tree: ReactTestRenderer | undefined
act(() => {
tree = create(
<SafeAreaFrameContext.Provider value={frame}>
<SafeAreaInsetsContext.Provider value={window}>
<SideSafeArea>
{nested ? (
<SideSafeArea>
<Probe />
</SideSafeArea>
) : (
<Probe />
)}
</SideSafeArea>
</SafeAreaInsetsContext.Provider>
</SafeAreaFrameContext.Provider>
)
})
return { tree: tree!, read }
}
describe('the app laid out clear of the side insets', () => {
it('pads the sides once, and screens below see the padded area', () => {
const { tree, read } = renderInLandscape()
const area = tree.root.find((node) => String(node.type) === 'View')
expect(Object.assign({}, ...[area.props.style].flat())).toMatchObject({
paddingLeft: 59,
paddingRight: 48
})
expect(read.screen).toEqual({ top: 0, right: 0, bottom: 21, left: 0 })
expect(read.frame).toEqual({ x: 59, y: 0, width: 745, height: 393 })
})
it('gives a Modal, which opens a window of its own, the window insets', () => {
expect(renderInLandscape().read.modal).toEqual(WINDOW)
})
})
afterEach(() => {
unmountRenderers()
geometry.regions = { divisions: [], occlusions: [] }
geometry.platform.OS = 'ios'
})
const DUO_FRAME = { x: 0, y: 0, width: 951, height: 669 }
const DUO_INSETS = { top: 0, right: 84, bottom: 34, left: 0 }
function duoGeometry() {
geometry.regions = {
divisions: [],
occlusions: [{ x: 867, y: 0, width: 84, height: 120 }],
windowSafeArea: { bounds: DUO_FRAME, insets: DUO_INSETS }
}
}
function topPadding(tree: ReactTestRenderer) {
return tree.root
.findAll((node) => String(node.type) === 'View')
.map((node) => Object.assign({}, ...[node.props.style].flat()).paddingTop ?? 0)
}
it('keeps the full Duo height without adding a top band across nested areas', () => {
duoGeometry()
const { tree, read } = renderInLandscape(DUO_INSETS, DUO_FRAME, true)
expect(topPadding(tree)).toEqual([0, 0])
expect(read.frame).toEqual({ x: 0, y: 0, width: 867, height: 669 })
expect(read.screen).toEqual({ top: 0, right: 0, bottom: 34, left: 0 })
})
it('adds no Duo edge spacing when the keyboard changes or a banner offsets the frame', () => {
duoGeometry()
geometry.regions.keyboardInset = 600
expect(topPadding(renderInLandscape(DUO_INSETS, DUO_FRAME).tree)).toEqual([0])
expect(topPadding(renderInLandscape(DUO_INSETS, { ...DUO_FRAME, y: 12 }).tree)).toEqual([0])
expect(topPadding(renderInLandscape(DUO_INSETS, { ...DUO_FRAME, y: 30 }).tree)).toEqual([0])
})
it.each(['web', 'android'])('does not apply the native Duo workaround on %s', (platform) => {
duoGeometry()
geometry.platform.OS = platform
expect(topPadding(renderInLandscape(DUO_INSETS, DUO_FRAME).tree)).toEqual([0])
})
it('leaves portrait top-safe-area donation and ordinary iPad/phone geometry unchanged', () => {
geometry.regions = {
divisions: [],
occlusions: [{ x: 470, y: 0, width: 199, height: 84 }],
windowSafeArea: {
bounds: { x: 0, y: 0, width: 669, height: 951 },
insets: { top: 84, right: 0, bottom: 34, left: 0 }
}
}
expect(
topPadding(
renderInLandscape(
{ top: 84, right: 0, bottom: 34, left: 0 },
{ x: 0, y: 0, width: 669, height: 951 }
).tree
)
).toEqual([0])
geometry.regions = { divisions: [], occlusions: [] }
expect(topPadding(renderInLandscape().tree)).toEqual([0])
})
it.each([
{
division: { x: 0, y: 320, width: 951, height: 20 },
expected: { axis: 'horizontal', start: 320, end: 340, size: 669 }
},
{
division: { x: 430, y: 0, width: 10, height: 669 },
expected: { axis: 'vertical', start: 430, end: 440, size: 867 }
}
])(
'keeps the window hinge aligned without a synthetic top offset: $expected.axis',
({ division, expected }) => {
duoGeometry()
geometry.regions.divisions = [division]
const { read, tree } = renderInLandscape(DUO_INSETS, DUO_FRAME, true)
expect(topPadding(tree)).toEqual([0, 0])
expect(read.frame).toEqual({ x: 0, y: 0, width: 867, height: 669 })
expect(read.fold).toEqual(expected)
}
)
it('preserves genuine top insets for the screen and modal without a synthetic guard', () => {
duoGeometry()
const { tree, read } = renderInLandscape({ ...DUO_INSETS, top: 18 }, DUO_FRAME)
expect(topPadding(tree)).toEqual([0])
expect(read.screen?.top).toBe(18)
expect(read.modal?.top).toBe(18)
})
@@ -1,10 +1,7 @@
import { Buffer } from 'node:buffer'
import { describe, expect, it, vi } from 'vitest'
import * as byteCounter from '../../../src/shared/terminal/stream/terminal-stream-json-byte-length'
import {
BridgeTerminalOutputBacklog,
terminalStreamMaxPayloadBytes
} from './bridge-terminal-output-backlog'
import { describe, expect, it } from 'vitest'
import { BridgeTerminalOutputBacklog } from './bridge-terminal-output-backlog'
type Output = { type: 'data'; streamId: number; chunk: string }
type Metadata = { type: 'resized'; streamId: number; cols: number; rows: number }
@@ -81,24 +78,6 @@ function serializedFrames(
}
describe('terminal backlog merge framing', () => {
it.each([0, 1, 123_456, 1e21])(
'matches the serializer for stable finite stream id %s',
(streamId) => {
const payloads = [
output('first', streamId),
output('second', streamId),
output('third', streamId)
]
for (const cap of [0, wireBytes(payloads[0]), 54, 80, 120]) {
for (const windowEmpty of [false, true]) {
expect(drain(payloads, cap, windowEmpty)).toEqual(
serializedFrames(payloads, cap, windowEmpty)
)
}
}
}
)
it('merges split surrogate pairs across empty chunks at the exact byte cap', () => {
const payloads = [output('prefix\ud83d'), output(''), output(''), output('\ude00suffix')]
const combined = output('prefix😀suffix')
@@ -106,114 +85,4 @@ describe('terminal backlog merge framing', () => {
expect(drain(payloads, cap, true)).toEqual([combined])
expect(drain(payloads, cap - 1, true)).toEqual(serializedFrames(payloads, cap - 1, true))
})
it('preserves escaped controls, quotes, backslashes, Unicode and lone surrogates', () => {
const payloads = [
output('\u001b[31m\b\t\n\f\r\u0000'),
output('"\\café漢字😀'),
output('\ud800'),
output(''),
output('x\udc00'),
output('\ud800\ud800'),
output('\udc00\udc00')
]
for (let cap = 0; cap < 180; cap++) {
expect(drain(payloads, cap, true)).toEqual(serializedFrames(payloads, cap, true))
}
})
it('keeps metadata barriers and oversized-head behavior in either window state', () => {
const payloads: Payload[] = [
output('before'),
{ type: 'resized', streamId: 1, cols: 80, rows: 24 },
output('x'.repeat(1000)),
output(''),
output('after')
]
for (const windowEmpty of [false, true]) {
expect(drain(payloads, 100, windowEmpty)).toEqual(
serializedFrames(payloads, 100, windowEmpty)
)
}
})
it('matches serialized frame boundaries on fragmented mixed output', () => {
const cells = ['plain', '\u001b[31m', '\n', '"\\', '漢字', '\ud83d', '', '\ude00', '\ud800']
const payloads: Payload[] = []
for (let index = 0; index < 300; index++) {
payloads.push(output(cells[index % cells.length], 123_456))
if (index % 37 === 0) {
payloads.push({ type: 'resized', streamId: 123_456, cols: 80, rows: 24 })
}
}
for (const cap of [38, 50, 80, 640, 4096]) {
for (const windowEmpty of [false, true]) {
expect(drain(payloads, cap, windowEmpty)).toEqual(
serializedFrames(payloads, cap, windowEmpty)
)
}
}
})
it('counts fragmented output once instead of rescanning growing temporary strings', () => {
const chunks = Array.from({ length: 256 }, (_, index) => String(index).padEnd(1024, 'x'))
const measured = vi.spyOn(byteCounter, 'terminalStreamJsonByteLength')
try {
const frames = drain(
chunks.map((chunk) => output(chunk)),
terminalStreamMaxPayloadBytes('id'),
true
)
expect(frames).toEqual([output(chunks.join(''))])
const measuredUnits = measured.mock.calls.reduce((sum, [chunk]) => sum + chunk.length, 0)
expect(measuredUnits).toBeLessThanOrEqual(chunks.join('').length * 2)
} finally {
measured.mockRestore()
}
})
it('preserves output when partial drains compact the queue before more input arrives', () => {
const payloads: Payload[] = Array.from({ length: 10_000 }, (_, index) =>
index % 37 === 0
? { type: 'resized', streamId: 1, cols: index + 1, rows: 24 }
: output(`${index}\u001b[0m😀\n`)
)
const backlog = createBacklog()
const frames: unknown[] = []
try {
for (const payload of payloads.slice(0, 5_000)) {
expect(backlog.hold(payload)).toBe(true)
}
while (backlog.pendingBytes > 50_000) {
frames.push(backlog.next(640, true))
}
const remainingBytes = backlog.pendingBytes
const added = payloads.slice(5_000)
for (const payload of added) {
expect(backlog.hold(payload)).toBe(true)
}
expect(backlog.pendingBytes).toBe(
remainingBytes + added.reduce((total, payload) => total + wireBytes(payload), 0)
)
while (backlog.held) {
frames.push(backlog.next(640, true))
}
expect(frames).toEqual(serializedFrames(payloads, 640, true))
expect(backlog.pendingBytes).toBe(0)
expect(backlog.next(640, true)).toBeNull()
backlog.hold(output('after drain'))
expect(backlog.next(640, true)).toEqual(output('after drain'))
backlog.hold(output('a'))
backlog.hold({ type: 'resized', streamId: 1, cols: 2, rows: 24 })
backlog.hold(output('b'))
expect(backlog.next(640, true)).toEqual(output('a'))
backlog.dispose()
backlog.hold(output('after reset'))
expect(backlog.held).toBe(true)
expect(backlog.next(640, true)).toEqual(output('after reset'))
} finally {
backlog.dispose()
}
})
})
@@ -3,59 +3,12 @@ import { MOBILE_WEB_SHELL_GRANTS } from '../page-route-policy'
import { createFakeBridgePortPair } from './bridge-port-pair-test-harness'
import { BRIDGE_PROTOCOL_VERSION } from './bridge-envelope'
import {
BridgeTerminalKeyIdSchema,
BridgeTerminalToolbarSchema,
BRIDGE_TERMINAL_KEY_IDS,
BRIDGE_TERMINAL_MODIFIER_IDS,
BRIDGE_TERMINAL_TOOLBAR_KEY_IDS,
BRIDGE_TERMINAL_KEY_RAIL_CLAIM_NOTIFY,
BRIDGE_TERMINAL_KEY_RAIL_GRANT,
type BridgeTerminalKeyId,
type BridgeTerminalKeyRail
type BridgeTerminalKeyId
} from './bridge-terminal-key'
const ALL_RAIL_KEYS: BridgeTerminalKeyRail = [
...BRIDGE_TERMINAL_TOOLBAR_KEY_IDS,
...BRIDGE_TERMINAL_KEY_IDS,
...BRIDGE_TERMINAL_MODIFIER_IDS,
'char:/',
'char:@'
]
describe('the iPhone Duo key rail over the bridge', () => {
it('publishes and delivers every rail key through both bridge ends', async () => {
const pair = createFakeBridgePortPair({ terminalKeyRail: ALL_RAIL_KEYS })
await pair.flush()
expect(pair.client.getShellSession()?.terminalKeyRail).toEqual(ALL_RAIL_KEYS)
const pressed: BridgeTerminalKeyId[] = []
const release = pair.client.claimTerminalKeyRail((key) => pressed.push(key), 'all-keys')
await pair.flush()
for (const key of ALL_RAIL_KEYS) {
expect(pair.host.sendTerminalKey(key, 'all-keys')).toBe(true)
}
await pair.flush()
expect(pressed).toEqual(ALL_RAIL_KEYS)
release()
await pair.flush()
expect(pair.host.sendTerminalKey('ctrlC', 'all-keys')).toBe(false)
})
it('tells the page which keys the rail holds, and moves that live as the window turns', async () => {
const pair = createFakeBridgePortPair({ terminalKeyRail: ['escape', 'tab', 'enter'] })
await pair.flush()
expect(pair.client.getShellSession()?.terminalKeyRail).toEqual(['escape', 'tab', 'enter'])
const seen: BridgeTerminalKeyRail[] = []
pair.client.onTerminalKeyRailUpdate((keys) => seen.push(keys))
pair.host.publishTerminalKeyRail(['escape'])
await pair.flush()
pair.host.publishTerminalKeyRail(['escape'])
await pair.flush()
pair.host.publishTerminalKeyRail([])
await pair.flush()
expect(pair.client.getShellSession()?.terminalKeyRail).toEqual([])
expect(seen).toEqual([['escape'], []])
})
it('hears the page claim the rail, and drops the claim when a document starts or ends', async () => {
const pair = createFakeBridgePortPair({ terminalKeyRail: ['escape', 'arrowUp'] })
await pair.flush()
@@ -170,25 +123,3 @@ it('carries toolbar state across both bridge ends and delivers toolbar actions o
await pair.flush()
expect(pair.toShell).toHaveLength(releaseEnd)
})
it('accepts only single printable characters and known latches', () => {
expect(BridgeTerminalKeyIdSchema.safeParse('char:~').success).toBe(true)
expect(BridgeTerminalKeyIdSchema.safeParse('char:ab').success).toBe(false)
expect(BridgeTerminalKeyIdSchema.safeParse('char: ').success).toBe(false)
const toolbar = {
canSend: true,
canCompose: true,
keyboardVisible: false,
liveInputEnabled: true,
phoneDisplayMode: false,
runsAgent: true,
modifiers: { ctrl: 'off', alt: 'off', shift: 'off' }
}
expect(BridgeTerminalToolbarSchema.parse(toolbar)).toEqual(toolbar)
expect(
BridgeTerminalToolbarSchema.safeParse({
...toolbar,
modifiers: { ...toolbar.modifiers, ctrl: 'on' }
}).success
).toBe(false)
})
@@ -1,170 +0,0 @@
// Session exit and missing-worktree bounces must land on one host route instead of stacking another host-area session.
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const harness = await vi.hoisted(async () => await import('./mobile-web-shell-screen-test-harness'))
const dependencies = vi.hoisted(() => harness.createScreenDependencies())
const { screenModuleMocks } = await vi.hoisted(
async () => await import('./mobile-web-shell-screen-test-mocks')
)
const mocks = vi.hoisted(() => screenModuleMocks(dependencies))
const device = vi.hoisted(() => ({ width: 390, height: 844, dismissTo: vi.fn() }))
vi.mock('react-native', () => ({
...mocks['react-native'](),
useWindowDimensions: () => ({ width: device.width, height: device.height })
}))
vi.mock('expo-router', () => {
const base = mocks['expo-router']()
return {
...base,
useRouter: () => ({ ...base.useRouter(), dismissTo: device.dismissTo })
}
})
vi.mock('expo-clipboard', mocks['expo-clipboard'])
vi.mock('expo-haptics', mocks['expo-haptics'])
vi.mock('expo-document-picker', mocks['expo-document-picker'])
vi.mock('@orca/expo-two-way-audio', mocks['@orca/expo-two-way-audio'])
vi.mock('expo-keep-awake', mocks['expo-keep-awake'])
vi.mock('expo-image-picker', mocks['expo-image-picker'])
vi.mock('expo-file-system', mocks['expo-file-system'])
vi.mock('lucide-react-native', mocks['lucide-react-native'])
vi.mock('react-native-safe-area-context', mocks['react-native-safe-area-context'])
vi.mock('../../modules/orca-mobile-web-shell/src', mocks['../../modules/orca-mobile-web-shell/src'])
vi.mock('../app-update/use-wall-app-update', mocks['../app-update/use-wall-app-update'])
vi.mock('../transport/host-client-hooks', mocks['../transport/host-client-hooks'])
vi.mock('./use-page-host-snapshot', mocks['./use-page-host-snapshot'])
vi.mock('./use-mobile-web-shell-session', mocks['./use-mobile-web-shell-session'])
import { createElement } from 'react'
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
import { clientFrame, createFakeRpcClient } from './bridge-host-test-fakes'
import { MobileWebShellScreen } from './MobileWebShellScreen'
import type { NativeStackState } from './native-stack-return'
const HOST = '/h/host-1'
const DETAIL = '/h/host-1/session/wt-1'
const BOUNCE = '/h/host-1?notice=worktree-missing'
const IPAD = { width: 1180, height: 820 }
const PHONE = { width: 390, height: 844 }
type Entry = string
function rootStateOf(stack: readonly Entry[]): NativeStackState {
const routes = stack.map((entry, index) => {
const [path = '', search = ''] = entry.split('?')
const [, , hostId = '', area, worktreeId] = path.split('/')
const query = Object.fromEntries(new URLSearchParams(search))
return area === undefined
? { key: `entry-${index}`, name: '[hostId]/index', params: { hostId, ...query } }
: {
key: `entry-${index}`,
name: `[hostId]/${area}/[worktreeId]`,
params: { hostId, worktreeId: worktreeId ?? '', ...query }
}
})
const hostStack = { key: 'host-stack', index: routes.length - 1, routes }
return { key: 'root-stack', index: 0, routes: [{ key: 'h', name: 'h', state: hostStack }] }
}
// Model Expo Router 55: dismissTo pops to the nearest matching route with new params, or replaces the current entry when no match exists.
function applied(stack: readonly Entry[]): Entry[] {
const next = [...stack]
for (const [href] of dependencies.push.mock.calls) {
next.push(String(href))
}
for (const [action] of dependencies.dispatch.mock.calls) {
next.splice(next.length - action.payload.count)
}
for (const [href] of device.dismissTo.mock.calls) {
const target = String(href)
const route = target.split('?')[0]
const found = next.findLastIndex((entry) => entry.split('?')[0] === route)
if (found === -1) {
next.splice(-1, 1, target)
} else {
next.splice(found, next.length - found, target)
}
}
return next
}
async function hopFromSession(
viewport: { width: number; height: number },
stack: readonly Entry[],
href: string
) {
device.width = viewport.width
device.height = viewport.height
dependencies.rootState = rootStateOf(stack)
dependencies.client = createFakeRpcClient()
dependencies.state = harness.readyState('session-one')
dependencies.pageRoutes = ['/h/[hostId]', '/h/[hostId]/session/[worktreeId]']
const mounted: { tree: ReactTestRenderer | null } = { tree: null }
await act(async () => {
mounted.tree = create(
createElement(MobileWebShellScreen, {
hostId: 'host-1',
route: { pathname: DETAIL }
})
)
})
const view = mounted.tree?.root.findAll((node) => String(node.type) === 'ShellViewProbe')[0]
await act(async () => {
view?.props.onBridgeMessage({ nativeEvent: { json: clientFrame({ type: 'ready' }) } })
view?.props.onBridgeMessage({
nativeEvent: { json: clientFrame({ type: 'notify', name: 'navigate', href }) }
})
})
act(() => mounted.tree?.unmount())
}
beforeEach(() => {
harness.resetScreenDependencies(dependencies)
device.dismissTo.mockReset()
})
afterEach(() => {
vi.clearAllMocks()
})
describe('leaving a wide detail session for its host route', () => {
it('returns to the host-area session below it, so Back does not reopen the session', async () => {
await hopFromSession(IPAD, [HOST, DETAIL], HOST)
expect(applied([HOST, DETAIL])).toEqual([HOST])
})
it('carries the bounce notice to that host route, and still stacks nothing', async () => {
await hopFromSession(IPAD, [HOST, DETAIL], BOUNCE)
expect(applied([HOST, DETAIL])).toEqual([BOUNCE])
})
it('lands on one host route from a cold deep link, with nothing below the session', async () => {
await hopFromSession(IPAD, [DETAIL], BOUNCE)
expect(applied([DETAIL])).toEqual([BOUNCE])
})
})
describe('the same hop on a phone', () => {
it.each([HOST, BOUNCE])('replaces a cold detail session with %s', async (href) => {
await hopFromSession(PHONE, [DETAIL], href)
expect(applied([DETAIL])).toEqual([href])
expect(dependencies.push).not.toHaveBeenCalled()
})
it('updates the existing host notice without retaining the exited detail session', async () => {
await hopFromSession(PHONE, [HOST, DETAIL], BOUNCE)
expect(applied([HOST, DETAIL])).toEqual([BOUNCE])
})
it('returns to the host route below rather than stacking a second one', async () => {
await hopFromSession(PHONE, [HOST, DETAIL], HOST)
expect(device.dismissTo).not.toHaveBeenCalled()
expect(applied([HOST, DETAIL])).toEqual([HOST])
})
it('pushes a route the stack does not hold, such as another worktree', async () => {
const other = '/h/host-1/session/wt-2'
await hopFromSession(PHONE, [HOST, DETAIL], other)
expect(applied([HOST, DETAIL])).toEqual([HOST, DETAIL, other])
})
})
@@ -174,28 +174,6 @@ describe('an offline cold start after a grant-only manifest edit', () => {
doubles.manifest = null
})
it('opens a same-build hit online without writing to the generation directory', async () => {
// Do not rewrite manifest.json while the native view may read it during page open.
const fileSystem = createFakeGenerationFileSystem()
await cacheGeneration(fileSystem, manifestGranting(GRANTS_A))
const generationPaths = () =>
fileSystem.paths().filter((path) => path.startsWith(`${HOST_KEY}/generations/`))
const paths = generationPaths()
const settled = fileSystem.writes.length
doubles.manifest = M2
const online = await mountRoute(fileSystem)
expect(online.state()).toMatchObject({ kind: 'ready', buildId: BUILD_ID })
expect(online.routeGrants()).toEqual(GRANTS_A_PLUS_B)
const generationWrites = fileSystem.writes
.slice(settled)
.filter((path) => path.startsWith(`${HOST_KEY}/generations/`))
expect(generationWrites).toEqual([])
expect(generationPaths()).toEqual(paths)
await online.unmount()
})
it('reads the grants the assets arrived with, whatever a later online read accepted', async () => {
const fileSystem = createFakeGenerationFileSystem()
await cacheGeneration(fileSystem, manifestGranting(GRANTS_A))
@@ -2,14 +2,10 @@ import { describe, expect, it } from 'vitest'
import { computeMobileWebBundleId } from '../../../src/shared/mobile-web-bundle/manifest-contract'
import type { MobileWebBundleFetchResult } from '../transport/web-bundle/mobile-web-bundle-fetch'
import { createFakeGenerationFileSystem } from './generation-file-system-fake'
import { createGenerationStore, MAX_CACHED_HOSTS } from './generation-store'
import { createGenerationStore } from './generation-store'
import { deriveHostCacheKey } from './host-cache-key'
import type { MobileWebShellUpdateFailure } from './mobile-web-shell-update-failure'
import {
MAX_UPDATE_FAILURES,
MAX_UPDATE_FAILURES_PER_HOST,
appendUpdateFailure
} from './mobile-web-shell-update-failure-log'
import { appendUpdateFailure } from './mobile-web-shell-update-failure-log'
const LOG = 'update-failures.json'
@@ -26,45 +22,7 @@ function failure(hostId: string, at: number): MobileWebShellUpdateFailure {
}
}
function appendAll(entries: readonly MobileWebShellUpdateFailure[]): MobileWebShellUpdateFailure[] {
return entries.reduce<MobileWebShellUpdateFailure[]>(appendUpdateFailure, [])
}
describe('appendUpdateFailure', () => {
it('keeps five per host and evicts that host oldest-first', () => {
const kept = appendAll(Array.from({ length: 7 }, (_, at) => failure('host-1', at)))
expect(kept.map((entry) => entry.at)).toEqual([2, 3, 4, 5, 6])
expect(kept).toHaveLength(MAX_UPDATE_FAILURES_PER_HOST)
})
it("never evicts one host's record for another host's failures below the ceiling", () => {
const kept = appendAll([
failure('host-1', 0),
...Array.from({ length: 9 }, (_, at) => failure('host-2', at + 1))
])
expect(kept.filter((entry) => entry.hostId === 'host-1')).toHaveLength(1)
expect(kept.filter((entry) => entry.hostId === 'host-2')).toHaveLength(5)
})
it('holds a ceiling across hosts, evicting the oldest of all', () => {
const kept = appendAll(
Array.from({ length: MAX_UPDATE_FAILURES + 10 }, (_, at) => failure(`host-${at}`, at))
)
expect(kept).toHaveLength(MAX_UPDATE_FAILURES)
expect(kept[0]?.at).toBe(10)
})
it('keeps a full per-host history for every cached host', () => {
const entries = Array.from({ length: MAX_CACHED_HOSTS }, (_, host) =>
Array.from({ length: MAX_UPDATE_FAILURES_PER_HOST }, (_, offset) =>
failure(`host-${host}`, host * MAX_UPDATE_FAILURES_PER_HOST + offset)
)
).flat()
expect(appendAll(entries)).toEqual(entries)
const extra = failure('host-0', entries.length)
expect(appendAll([...entries, extra])).toEqual([...entries.slice(1), extra])
})
it('drops a build id that is not a digest rather than keep host text', () => {
const [entry] = appendUpdateFailure([], {
...failure('host-1', 0),
@@ -142,20 +100,4 @@ describe('the generation store keeps the log', () => {
)
expect(await store.readUpdateFailures()).toEqual([failure('host-1', 1)])
})
it('never rejects: a disk that refuses the write costs the record, not the shell', async () => {
const fileSystem = createFakeGenerationFileSystem()
fileSystem.failWritesAt(LOG)
const store = createGenerationStore({ fileSystem })
await expect(store.recordUpdateFailure(failure('host-1', 1))).resolves.toBeUndefined()
expect(await store.readUpdateFailures()).toEqual([])
})
it('is not mistaken for a host directory', async () => {
const fileSystem = createFakeGenerationFileSystem()
const store = createGenerationStore({ fileSystem })
await store.recordUpdateFailure(failure('host-1', 1))
await store.sweepStagedGenerations()
expect(fileSystem.text(LOG)).not.toBeNull()
})
})
@@ -119,33 +119,6 @@ function mount(
}
}
describe('the device handler the shell hands its host', () => {
it('sends a clipboard verb to the clipboard half', async () => {
const { serve, unmount } = mount('session-a')
await expect(serve('native.clipboard.read', { mime: 'text' })).resolves.toEqual({
value: 'on the pasteboard'
})
unmount()
})
it('sends a media verb to the media half, picker and all', async () => {
const { serve, unmount } = mount('session-a')
await expect(
serve('native.media.pick', { source: 'library', multiple: false })
).resolves.toEqual({ items: [] })
expect(device.picker.launchImageLibraryAsync).toHaveBeenCalled()
unmount()
})
it('answers a release for a handle no pick ever minted, rather than refusing', async () => {
const { serve, unmount } = mount('session-a')
await expect(serve('native.media.release', { handle: 'media-9' })).resolves.toEqual({
released: false
})
unmount()
})
})
describe('a picker completing after its page session ends', () => {
beforeEach(() => {
device.files.clear()
@@ -222,17 +195,4 @@ describe('the screen a page session leaves behind', () => {
await new Promise((resolve) => setTimeout(resolve, 0))
expect(device.wakeTags).toEqual(['+orca-microphone', '-orca-microphone'])
})
it('leaves the next session nothing of the last one to give back', async () => {
const first = mount('session-a')
await first.serve('native.audio.start', { sampleRate: 16_000 })
first.unmount()
await new Promise((resolve) => setTimeout(resolve, 0))
device.wakeTags.length = 0
const second = mount('session-b')
second.unmount()
await new Promise((resolve) => setTimeout(resolve, 0))
// A session with no capture must not deactivate an unheld wake tag.
expect(device.wakeTags).toEqual([])
})
})
@@ -1,194 +0,0 @@
import { createElement, type ReactNode } from 'react'
import { act, create } from 'react-test-renderer'
import { expect, it, vi } from 'vitest'
import { mountFixture } from '../test-support/rpc-recording/recorder-fixture-shape'
const hosts = vi.hoisted(() => {
const component = (name: string) => {
const Host = (props: { children?: ReactNode }) => props.children ?? null
Host.displayName = name
return Host
}
return {
View: component('View'),
Text: component('Text'),
Pressable: component('Pressable'),
TerminalWebView: component('TerminalWebView')
}
})
vi.mock('react-native', () => ({
AppState: { currentState: 'active', addEventListener: () => ({ remove: () => {} }) },
View: hosts.View,
Text: hosts.Text,
Pressable: hosts.Pressable,
ActivityIndicator: hosts.View,
Animated: { View: hosts.View },
StyleSheet: {
create: <T,>(value: T) => value,
absoluteFillObject: { position: 'absolute', top: 0, bottom: 0, left: 0, right: 0 }
}
}))
vi.mock('../terminal/TerminalWebView', () => ({ TerminalWebView: hosts.TerminalWebView }))
vi.mock('../storage/preferences', () => ({ saveTerminalTextScale: () => Promise.resolve() }))
vi.mock('../browser/MobileBrowserPane', () => ({ MobileBrowserPane: () => null }))
vi.mock('./native-chat/MobileNativeChatOverlay', () => ({ MobileNativeChatOverlay: () => null }))
vi.mock('./MobileSessionFileReader', () => ({ FileReader: () => null }))
vi.mock('./MobileSessionMarkdownReader', () => ({ MarkdownReader: () => null }))
vi.mock('./mobile-session-styles', () => ({ styles: {} }))
vi.mock('../transport/use-host-profile', () => ({ useHostProfile: () => null }))
vi.mock('expo-router', async () => {
const { useEffect } = await import('react')
return { useFocusEffect: (effect: () => () => void) => useEffect(effect, [effect]) }
})
import { TerminalPaneView } from './TerminalPaneView'
import { MobileSessionActiveContent } from './MobileSessionActiveContent'
import { terminalPaneViewport } from './mobile-terminal-pane-layout'
type Props = Parameters<typeof TerminalPaneView>[0]
it.each([0, 1])(
'keeps stacked pane %i visible above a tall keyboard and preserves its measured terminal frame',
(index) => {
const onFrame = vi.fn()
const base = mountFixture<Props>({
handle: `term-${index}`,
active: true,
visible: true,
splitIndex: index,
splitDirection: 'column',
splitFrame: { width: 390, height: 500 },
keyboardInset: 0,
keyboardLift: 0,
textScale: 1,
onFrame,
onRef: vi.fn()
})
let renderer!: ReturnType<typeof create>
try {
act(() => {
renderer = create(createElement(TerminalPaneView, base))
})
const views = () => renderer.root.findAllByType(hosts.View)
expect(views()[2].props.style.height).toBe(222)
act(() => {
renderer.update(
createElement(TerminalPaneView, { ...base, keyboardInset: 300, keyboardLift: 150 })
)
})
const [outer, clip, measured] = views()
const outerStyle = Object.assign({}, ...outer.props.style.filter(Boolean))
expect(outerStyle.top).toBe(index * 100)
expect(outerStyle.height).toBe(100)
expect(clip.props.style.overflow).toBe('hidden')
expect(measured.props.style.height).toBe(222)
act(() => {
measured.props.onLayout({ nativeEvent: { layout: { width: 390, height: 222 } } })
})
expect(onFrame).toHaveBeenLastCalledWith(`term-${index}`, { width: 390, height: 222 })
const terminal = renderer.root.findByType(hosts.TerminalWebView)
const terminalStyle = Object.assign({}, ...terminal.props.style.filter(Boolean))
const bottom =
outerStyle.top + 28 + measured.props.style.height + terminalStyle.transform[0].translateY
expect(bottom).toBe(index * 100 + 100)
expect(bottom).toBeGreaterThan(outerStyle.top + 28)
expect(bottom).toBeLessThanOrEqual(200)
} finally {
act(() => renderer?.unmount())
}
}
)
it.each(['stacked', 'side-by-side'] as const)(
'keeps visible %s terminal content above the keyboard independently of input focus',
(layout) => {
const frame = { width: 900, height: 500 }
const terminalHeight = layout === 'stacked' ? 222 : 472
const visibleHeight = layout === 'stacked' ? 72 : 172
const expectedLift = layout === 'stacked' ? 150 : 300
const geometry = new Map()
for (const handle of ['a', 'b', 'c']) {
terminalPaneViewport(geometry, handle).terminalFrameRef.current = {
width: layout === 'stacked' ? 900 : 450,
height: terminalHeight
}
}
const controller = mountFixture<Parameters<typeof MobileSessionActiveContent>[0]['controller']>(
{
sessionTabs: [],
terminals: ['a', 'b', 'c'].map((handle) => ({ handle, title: handle })),
activeHandle: 'a',
terminalSplitActive: true,
terminalSplitLayout: layout,
visibleTerminalHandles: ['a', 'b'],
terminalKeyboardMetrics: new Map([
['a', { rows: 10, rowPitch: 5, cursorY: 2, contentBottomRow: 2, altScreen: false }],
[
'b',
{
rows: 40,
rowPitch: terminalHeight / 40,
cursorY: 39,
contentBottomRow: 39,
altScreen: true
}
]
]),
getTerminalPaneViewport: (handle) => terminalPaneViewport(geometry, handle),
keyboardLift: 0,
activeTerminalKeyboardLift: 0,
notifyTerminalFrame: vi.fn(),
dictation: { isRecording: false },
nativeChatSendError: { message: null, clear: vi.fn() }
}
)
let renderer!: ReturnType<typeof create>
const render = (activeHandle: string, keyboardLift: number) => {
const element = createElement(MobileSessionActiveContent, {
controller: { ...controller, activeHandle, keyboardLift }
})
act(() => {
if (renderer) {
renderer.update(element)
} else {
renderer = create(element)
}
})
}
const terminalStyle = (handle: string) => {
const pane = renderer.root
.findAllByType(TerminalPaneView)
.find((item) => item.props.handle === handle)!
const terminal = pane.findByType(hosts.TerminalWebView)
return Object.assign({}, ...terminal.props.style.filter(Boolean))
}
try {
render('a', 0)
act(() => {
renderer.root
.findAllByType(hosts.View)[0]
.props.onLayout({ nativeEvent: { layout: frame } })
})
for (const focused of ['a', 'b', 'a']) {
render(focused, 300)
expect(terminalStyle('a').transform).toBeUndefined()
expect(terminalStyle('b').transform).toEqual([{ translateY: -expectedLift }])
// A pane never shown has not mounted its terminal yet.
expect(renderer.root.findAllByType(hosts.TerminalWebView)).toHaveLength(2)
for (const pane of renderer.root.findAllByType(TerminalPaneView).slice(0, 2)) {
const [outer, clip, measured] = pane.findAllByType(hosts.View)
const outerStyle = Object.assign({}, ...outer.props.style.filter(Boolean))
expect(outerStyle.height - 28).toBe(visibleHeight)
expect(clip.props.style.overflow).toBe('hidden')
expect(measured.props.style.height).toBe(terminalHeight)
}
expect(terminalHeight + terminalStyle('b').transform[0].translateY).toBe(visibleHeight)
}
render('a', 0)
expect(terminalStyle('a').transform).toBeUndefined()
expect(terminalStyle('b').transform).toBeUndefined()
expect(renderer.root.findAllByType(hosts.TerminalWebView)).toHaveLength(2)
} finally {
act(() => renderer?.unmount())
}
}
)
@@ -1,224 +0,0 @@
import { describe, expect, it, vi } from 'vitest'
import type { AiVaultSession } from '../../../src/shared/ai-vault/contracts/ai-vault-types'
import {
buildMobileAiVaultResumeLaunch,
resumeAiVaultSessionInTerminal
} from './ai-vault-resume-launch'
import {
prepareMobileAiVaultSessionResume,
RESUME_RPC_TIMEOUT_MS
} from './ai-vault-resume-preparation'
const LEGACY_CODEX_HOME = '/Users/ada/Library/Application Support/orca/codex-runtime-home/home'
const PER_ACCOUNT_HOME = '/Users/ada/Library/Application Support/orca/codex-accounts/a/home'
function legacySession(overrides: Partial<AiVaultSession> = {}): AiVaultSession {
return {
id: 'codex:legacy-1',
executionHostId: 'local',
agent: 'codex',
sessionId: 'legacy-1',
title: 'Resume me',
cwd: '/Users/ada/repo',
branch: 'main',
model: null,
filePath: `${LEGACY_CODEX_HOME}/sessions/2026/07/20/rollout-a.jsonl`,
codexHome: LEGACY_CODEX_HOME,
createdAt: null,
updatedAt: null,
modifiedAt: '2026-07-20T00:00:00.000Z',
messageCount: 2,
totalTokens: 10,
previewMessages: [],
queuedMessageCount: 0,
subagentTranscriptCount: 0,
resumeCommand: '',
subagent: null,
...overrides
}
}
describe('prepareMobileAiVaultSessionResume', () => {
it.each([
{
code: 'forbidden',
message: "Method 'aiVault.prepareSessionResume' is not available to mobile clients"
},
{
code: 'method_not_found',
message: 'Unknown method: aiVault.prepareSessionResume'
}
])('uses the legacy command and environment on old-host $code', async (error) => {
const legacy = legacySession()
const sendRequest = vi
.fn()
.mockResolvedValueOnce({ ok: false, error })
.mockResolvedValueOnce({
ok: true,
result: { tab: { type: 'terminal', id: 'tab-1', terminal: 'pty-1', title: 'Terminal' } }
})
.mockResolvedValueOnce({ ok: true, result: { send: { accepted: true } } })
const prepared = await prepareMobileAiVaultSessionResume({ sendRequest }, legacy)
const launch = buildMobileAiVaultResumeLaunch({
session: prepared,
workspacePlatform: 'darwin'
})
await resumeAiVaultSessionInTerminal({ sendRequest }, 'worktree-1', launch)
expect(prepared).toBe(legacy)
expect(sendRequest.mock.calls[1]?.[1]).not.toHaveProperty('envToDelete')
expect(sendRequest).toHaveBeenNthCalledWith(
3,
'terminal.send',
{
terminal: 'pty-1',
text: `cd '/Users/ada/repo' && CODEX_HOME='${LEGACY_CODEX_HOME}' codex '-c' 'disable_paste_burst=true' '-c' 'tui.resume_cwd=current' 'resume' 'legacy-1'`,
enter: true
},
{ timeoutMs: RESUME_RPC_TIMEOUT_MS }
)
})
it('uses the real Codex home when a supported desktop requests it', async () => {
const legacy = legacySession()
const sendRequest = vi.fn().mockResolvedValue({
ok: true,
result: { useRealCodexHome: true }
})
const prepared = await prepareMobileAiVaultSessionResume({ sendRequest }, legacy)
const launch = buildMobileAiVaultResumeLaunch({
session: prepared,
workspacePlatform: 'darwin'
})
expect(sendRequest).toHaveBeenCalledWith(
'aiVault.prepareSessionResume',
{
agent: 'codex',
filePath: legacy.filePath,
codexHome: LEGACY_CODEX_HOME,
executionHostId: 'local'
},
{ timeoutMs: RESUME_RPC_TIMEOUT_MS }
)
expect(prepared).toEqual({ ...legacy, codexHome: null })
expect(launch.command).toBe(
"cd '/Users/ada/repo' && codex '-c' 'disable_paste_burst=true' '-c' 'tui.resume_cwd=current' 'resume' 'legacy-1'"
)
expect(launch.envToDelete).toEqual(['CODEX_HOME', 'ORCA_CODEX_HOME'])
})
it('preserves the legacy resume path when a supported desktop declines real-home use', async () => {
const legacy = legacySession()
const sendRequest = vi.fn().mockResolvedValue({
ok: true,
result: { useRealCodexHome: false }
})
const prepared = await prepareMobileAiVaultSessionResume({ sendRequest }, legacy)
const launch = buildMobileAiVaultResumeLaunch({
session: prepared,
workspacePlatform: 'darwin'
})
expect(prepared).toBe(legacy)
expect(launch.command).toContain(`CODEX_HOME='${LEGACY_CODEX_HOME}'`)
expect(launch.envToDelete).toBeUndefined()
})
it.each([
{ agent: 'claude' as const, codexHome: null },
{ agent: 'codex' as const, codexHome: '/Users/ada/.config/codex' },
{
agent: 'codex' as const,
codexHome: PER_ACCOUNT_HOME,
executionHostId: 'ssh:server-1' as AiVaultSession['executionHostId']
},
{ agent: 'codex' as const, codexHome: '\\\\wsl.localhost\\Ubuntu\\home\\ada\\.codex' }
])('does not prepare unrepinnable session $agent at $codexHome', async (overrides) => {
const current = legacySession(overrides)
const sendRequest = vi.fn()
await expect(prepareMobileAiVaultSessionResume({ sendRequest }, current)).resolves.toBe(current)
expect(sendRequest).not.toHaveBeenCalled()
})
it('repins a per-account session to the home the host substitutes', async () => {
const substituteHome = '/Users/ada/Library/Application Support/orca/codex-accounts/b/home'
const current = legacySession({ codexHome: PER_ACCOUNT_HOME })
const sendRequest = vi.fn().mockResolvedValue({
ok: true,
result: { useRealCodexHome: false, substituteCodexHome: substituteHome }
})
const prepared = await prepareMobileAiVaultSessionResume({ sendRequest }, current)
const launch = buildMobileAiVaultResumeLaunch({
session: prepared,
workspacePlatform: 'darwin'
})
expect(sendRequest).toHaveBeenCalledWith(
'aiVault.prepareSessionResume',
{
agent: 'codex',
filePath: current.filePath,
codexHome: PER_ACCOUNT_HOME,
executionHostId: 'local'
},
{ timeoutMs: RESUME_RPC_TIMEOUT_MS }
)
expect(prepared.codexHome).toBe(substituteHome)
expect(launch.command).toContain(`CODEX_HOME='${substituteHome}'`)
})
it('keeps a per-account session home when an older host sends no repin', async () => {
const current = legacySession({ codexHome: PER_ACCOUNT_HOME })
const sendRequest = vi.fn().mockResolvedValue({
ok: true,
result: { useRealCodexHome: false }
})
await expect(prepareMobileAiVaultSessionResume({ sendRequest }, current)).resolves.toBe(current)
})
it('keeps a per-account session usable when the host cannot prepare at all', async () => {
const current = legacySession({ codexHome: PER_ACCOUNT_HOME })
const sendRequest = vi.fn().mockResolvedValue({
ok: false,
error: { code: 'method_not_found', message: 'Unknown method' }
})
await expect(prepareMobileAiVaultSessionResume({ sendRequest }, current)).resolves.toBe(current)
})
it.each([
{
code: 'internal_error',
message: 'Retry resume after checking session folder permissions.'
},
{
code: 'forbidden',
message: 'Session resume is forbidden'
}
])('keeps genuine server error visible: $code', async (error) => {
const sendRequest = vi.fn().mockResolvedValue({ ok: false, error })
await expect(
prepareMobileAiVaultSessionResume({ sendRequest }, legacySession())
).rejects.toThrow(error.message)
})
it.each(['Request timed out: aiVault.prepareSessionResume', 'Connection interrupted'])(
'keeps transport failure visible: %s',
async (message) => {
const failure = new Error(message)
const sendRequest = vi.fn().mockRejectedValue(failure)
await expect(
prepareMobileAiVaultSessionResume({ sendRequest }, legacySession())
).rejects.toBe(failure)
}
)
})
@@ -1,5 +1,4 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { CLIPBOARD_IMAGE_MAX_SOURCE_BYTES } from '../../../src/shared/clipboard-image'
vi.mock('expo-image-picker', () => ({
UIImagePickerPreferredAssetRepresentationMode: { Compatible: 'compatible' },
@@ -14,7 +13,6 @@ vi.mock('expo-file-system', () => ({
}))
import {
ImageLibraryPermissionError,
pickMobileImage,
pickMobileImages,
type PickedMobileImage
@@ -23,7 +21,6 @@ import {
const granted = { granted: true } as Awaited<
ReturnType<typeof import('expo-image-picker').requestMediaLibraryPermissionsAsync>
>
const denied = { granted: false } as typeof granted
async function collectImages(
images: AsyncIterable<PickedMobileImage>
@@ -59,33 +56,6 @@ function fileFactory(
describe('pickMobileImage', () => {
afterEach(() => vi.restoreAllMocks())
it('reads a photo URI without relying on React Native fetch', async () => {
const bytes = new Uint8Array([0, 1, 2, 3])
const file = fileFactory(bytes)
const launchLibrary = vi.fn().mockResolvedValue({
canceled: false,
assets: [{ uri: 'file:///x.jpg', fileSize: bytes.length }]
})
const fetchSpy = vi
.spyOn(globalThis, 'fetch')
.mockRejectedValue(new Error('Network request failed'))
const result = await pickMobileImage('library', {
requestLibraryPermission: vi.fn().mockResolvedValue(granted),
launchLibrary,
createFile: file.createFile
})
expect(result).toEqual({
base64: Buffer.from(bytes).toString('base64'),
uri: 'file:///x.jpg'
})
expect(launchLibrary).toHaveBeenCalledWith(
expect.objectContaining({ base64: false, preferredAssetRepresentationMode: 'compatible' })
)
expect(fetchSpy).not.toHaveBeenCalled()
expect(file.close).toHaveBeenCalledTimes(1)
})
it('returns every selected library photo in order', async () => {
const bytesByUri = new Map([
['file:///a.jpg', new Uint8Array([1])],
@@ -137,76 +107,6 @@ describe('pickMobileImage', () => {
)
})
it('throws when photo library permission is denied', async () => {
await expect(
pickMobileImage('library', {
requestLibraryPermission: vi.fn().mockResolvedValue(denied),
launchLibrary: vi.fn()
})
).rejects.toBeInstanceOf(ImageLibraryPermissionError)
})
it('returns null when the library picker is cancelled', async () => {
const result = await pickMobileImage('library', {
requestLibraryPermission: vi.fn().mockResolvedValue(granted),
launchLibrary: vi.fn().mockResolvedValue({ canceled: true, assets: null })
})
expect(result).toBeNull()
})
it('reads any picked file into base64 and keeps its name for the files source', async () => {
const bytes = new Uint8Array([1, 2, 3, 4])
const file = fileFactory(bytes)
const launchFiles = vi.fn().mockResolvedValue({
canceled: false,
assets: [
{
uri: 'file:///book.txt',
name: '柴静-看见.txt',
mimeType: 'text/plain',
size: bytes.length
}
]
})
const result = await pickMobileImage('files', {
launchFiles,
createFile: file.createFile
})
expect(launchFiles).toHaveBeenCalledWith(expect.objectContaining({ type: '*/*' }))
expect(result).toEqual({
base64: Buffer.from(bytes).toString('base64'),
uri: 'file:///book.txt',
fileName: '柴静-看见.txt'
})
expect(file.close).toHaveBeenCalledTimes(1)
})
it('returns null when the files picker is cancelled', async () => {
const result = await pickMobileImage('files', {
launchFiles: vi.fn().mockResolvedValue({ canceled: true, assets: null })
})
expect(result).toBeNull()
})
it('rejects an oversized asset before opening it', async () => {
const file = fileFactory(new Uint8Array([1]))
await expect(
pickMobileImage('files', {
launchFiles: vi.fn().mockResolvedValue({
canceled: false,
assets: [{ uri: 'file:///huge.png', size: CLIPBOARD_IMAGE_MAX_SOURCE_BYTES + 1 }]
}),
createFile: file.createFile
})
).rejects.toThrow('Clipboard image is too large')
expect(file.createFile).not.toHaveBeenCalled()
expect(file.open).not.toHaveBeenCalled()
})
it('closes the file handle when reading fails', async () => {
const file = fileFactory(new Uint8Array(), { fileSize: 4, readError: new Error('read failed') })
await expect(
@@ -1,222 +0,0 @@
import { describe, expect, it, vi } from 'vitest'
import { hookMount, performHookAction } from '../test-support/rpc-recording/hook-mount'
import { mountFixture } from '../test-support/rpc-recording/recorder-fixture-shape'
import type { RpcResponse } from '../transport/types'
import type { MarkdownDocState, MobileSessionTab } from './mobile-session-route-types'
import { useMobileSessionDocumentReaders } from './use-mobile-session-document-readers'
import { useMobileSessionCloseActions } from './use-mobile-session-close-actions'
type ReaderScope = Parameters<typeof useMobileSessionDocumentReaders>[0]
type CloseScope = Parameters<typeof useMobileSessionCloseActions>[0]
function success(content: string): RpcResponse {
return {
id: 'read',
ok: true,
result: { content, version: 'version', isDirty: false, editable: false },
_meta: { runtimeId: 'host' }
}
}
function mountDocuments(readReply: () => Promise<RpcResponse>) {
let docs = new Map<string, MarkdownDocState>()
const tabs: { current: MobileSessionTab[] } = { current: [] }
const setDocs: ReaderScope['setMarkdownDocs'] = (update) => {
docs = typeof update === 'function' ? update(docs) : update
}
const closeReply = vi.fn(async (): Promise<RpcResponse> => success(''))
const client = {
sendRequest: vi.fn(async (method: string): Promise<RpcResponse> =>
method === 'markdown.readTab' || method === 'files.read' ? readReply() : closeReply()
)
}
let readers: ReturnType<typeof useMobileSessionDocumentReaders> | undefined
let close: ReturnType<typeof useMobileSessionCloseActions> | undefined
const hook = hookMount(() => {
readers = useMobileSessionDocumentReaders(
mountFixture<ReaderScope>({
worktreeId: 'folder-workspace',
markdownDocsRef: {
get current() {
return docs
}
},
client,
setMarkdownDocs: setDocs,
setFileDocs: () => {}
})
)
close = useMobileSessionCloseActions(
mountFixture<CloseScope>({
worktreeId: 'folder-workspace',
client,
sessionTabsRef: tabs,
setSessionTabs: () => {},
setFileDocs: () => {},
setMarkdownDocs: setDocs,
closedTabTombstonesRef: { current: new Map() },
activeSessionTabIdRef: { current: null },
selectedSessionTabIdRef: { current: null },
pendingSelectionRef: { current: null },
activeSessionTabTypeRef: { current: null },
activeHandleRef: { current: null },
setActiveSessionTabId: () => {},
setActiveHandle: () => {}
})
)
})
hook.mount()
const tab = (id: string) =>
mountFixture<Extract<MobileSessionTab, { type: 'markdown' }>>({
type: 'markdown',
id,
relativePath: id + '.md',
isDirty: false
})
return {
get docs() {
return docs
},
closeReply,
unmount: hook.unmount,
read(id: string) {
const target = tab(id)
tabs.current = [...tabs.current.filter((current) => current.id !== id), target]
return performHookAction(() => readers?.readMarkdownTab(target))
},
close(id: string) {
return performHookAction(() => close?.handleCloseSessionTab(tab(id)))
},
dirty(id: string) {
const doc = docs.get(id)
if (doc?.status !== 'ready') {
throw new Error('Missing ready document')
}
docs.set(id, { ...doc, localContent: 'phone draft', isDirty: true })
}
}
}
function delayedReply() {
let resolve: (reply: RpcResponse) => void = () => {}
const promise = new Promise<RpcResponse>((done) => {
resolve = done
})
return { promise, resolve }
}
describe('mobile Markdown document lifetime', () => {
it('releases only the closed clean document and preserves other cached documents', async () => {
const readReply = vi
.fn()
.mockResolvedValueOnce(success('# a'))
.mockResolvedValueOnce(success('# b'))
.mockResolvedValueOnce(success('# c'))
const h = mountDocuments(readReply)
try {
await h.read('a')
await h.read('b')
await h.read('c')
expect(h.docs.get('a')).toMatchObject({ status: 'ready', content: '# a' })
expect(h.docs.get('b')).toMatchObject({ status: 'ready', content: '# b' })
expect(h.docs.get('c')).toMatchObject({ status: 'ready', content: '# c' })
const a = h.docs.get('a')
const c = h.docs.get('c')
await h.close('b')
expect([...h.docs.keys()]).toEqual(['a', 'c'])
expect(h.docs.get('a')).toBe(a)
expect(h.docs.get('c')).toBe(c)
await h.close('a')
expect([...h.docs.keys()]).toEqual(['c'])
expect(h.docs.get('c')).toBe(c)
await h.close('c')
expect(h.docs.size).toBe(0)
} finally {
h.unmount()
}
})
it.each(['success', 'failure', 'disk fallback'])(
'keeps a closed read released after a late %s',
async (kind) => {
const delayed = delayedReply()
const readReply = vi
.fn()
.mockReturnValueOnce(delayed.promise)
.mockResolvedValue(success('disk'))
const h = mountDocuments(readReply)
try {
const reading = h.read('closed')
await h.close('closed')
delayed.resolve(
kind === 'success'
? success('late')
: {
id: 'read',
ok: false,
error: {
code: kind === 'disk fallback' ? 'renderer_unavailable' : 'tab_not_found',
message: 'Unavailable'
},
_meta: { runtimeId: 'host' }
}
)
await reading
expect(h.docs.size).toBe(0)
if (kind === 'disk fallback') {
expect(readReply).toHaveBeenCalledTimes(2)
}
} finally {
h.unmount()
}
}
)
it('retains the document when the host refuses close', async () => {
const h = mountDocuments(async () => success('kept'))
try {
await h.read('kept')
const doc = h.docs.get('kept')
h.closeReply.mockResolvedValue({
id: 'close',
ok: false,
error: { code: 'refused', message: 'Refused' },
_meta: { runtimeId: 'host' }
})
await h.close('kept')
expect(h.docs.get('kept')).toBe(doc)
} finally {
h.unmount()
}
})
it('preserves unsaved phone drafts after close', async () => {
const h = mountDocuments(async () => success('original'))
try {
await h.read('draft')
h.dirty('draft')
const draft = h.docs.get('draft')
await h.close('draft')
expect(h.docs.get('draft')).toBe(draft)
} finally {
h.unmount()
}
})
it('keeps a newer read when an older request finishes later', async () => {
const old = delayedReply()
const h = mountDocuments(
vi.fn().mockReturnValueOnce(old.promise).mockResolvedValue(success('new'))
)
try {
const first = h.read('same')
await h.read('same')
const current = h.docs.get('same')
old.resolve(success('old'))
await first
expect(h.docs.get('same')).toBe(current)
} finally {
h.unmount()
}
})
})
@@ -57,32 +57,6 @@ describe("the phone's status stream", () => {
}
}
it('opens one stream per client however many chats read it', () => {
const feed = mobileStructuredSessionStatusFeed(client)
feed.subscribe(() => {})
mobileStructuredSessionStatusFeed(client).subscribe(() => {})
expect(client.subscribe).toHaveBeenCalledOnce()
expect(client.subscribe).toHaveBeenCalledWith(
'agentSession.subscribeStatus',
{},
expect.any(Function)
)
})
it("follows the host's Stopping from the snapshot through each status frame", () => {
const listener = vi.fn()
mobileStructuredSessionStatusFeed(client).subscribe(listener)
frames[0]?.({ type: 'snapshot', sessions: [summary(true)] })
expect(read()?.stopping).toBe(true)
frames[0]?.({ type: 'status', session: summary(false) })
expect(read()).toMatchObject({ status: 'working' })
expect(read()).not.toHaveProperty('stopping')
expect(listener).toHaveBeenCalledTimes(2)
})
it('keeps a retrying host owned and starting until readiness, then revokes ownership on disconnect', () => {
mobileStructuredSessionStatusFeed(client).subscribe(() => {})
const starting = { ...summary(false), hostExecutionPhase: 'starting' as const }
@@ -95,43 +69,6 @@ describe("the phone's status stream", () => {
expect(read()?.hostExecutionOwned).toBeUndefined()
expect(read()?.hostExecutionPhase).toBeUndefined()
})
it('drops Stopping when the host ends the stream, and opens it again on the next connection', () => {
mobileStructuredSessionStatusFeed(client).subscribe(() => {})
frames[0]?.({ type: 'snapshot', sessions: [summary(true)] })
frames[0]?.({ type: 'end' })
expect(read()).not.toHaveProperty('stopping')
connection('connected')
expect(client.subscribe).toHaveBeenCalledTimes(2)
})
it('drops Stopping, keeping the rest, once the phone loses contact', () => {
mobileStructuredSessionStatusFeed(client).subscribe(() => {})
frames[0]?.({ type: 'snapshot', sessions: [summary(true)] })
connection('reconnecting')
expect(read()).toMatchObject({ sessionId: 'session-1', status: 'working' })
expect(read()).not.toHaveProperty('stopping')
expect(read()).not.toHaveProperty('hostExecutionOwned')
})
it('reads a host that refuses the method to phones as one without it, asking once per connection', () => {
mobileStructuredSessionStatusFeed(client).subscribe(() => {})
frames[0]?.(FORBIDDEN)
connection('connected')
mobileStructuredSessionStatusFeed(client).subscribe(() => {})
expect(client.subscribe).toHaveBeenCalledOnce()
expect(read()).toBeUndefined()
// A new connection may reach a host updated meanwhile.
connection('reconnecting')
connection('connected')
expect(client.subscribe).toHaveBeenCalledTimes(2)
})
})
// Match physical registry ordering: drop ended streams before delivering their last frame.
@@ -1,224 +0,0 @@
import { act } from 'react-test-renderer'
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'
import {
installChatViewHooks,
assistantTurn,
hostType,
list,
render,
root,
scrollEvent,
update
} from './MobileNativeChatView.harness.test-fixture'
import {
pageScroller,
platform,
scrollToEnd,
scrollToOffset
} from './MobileNativeChatView.render-stubs.test-fixture'
vi.mock('react-native', async () => {
const { reactNativeStub } = await import('./MobileNativeChatView.render-stubs.test-fixture')
return reactNativeStub()
})
vi.mock('react-native-safe-area-context', () => ({
useSafeAreaInsets: () => ({ top: 0, bottom: 0, left: 0, right: 0 })
}))
vi.mock('react-native-gesture-handler', async () => {
const { gestureHandlerStub } = await import('./MobileNativeChatView.render-stubs.test-fixture')
return gestureHandlerStub()
})
vi.mock('lucide-react-native', () => ({
ArrowDown: 'ArrowDown',
ChevronsDownUp: 'ChevronsDownUp',
ChevronsUpDown: 'ChevronsUpDown',
Square: 'Square'
}))
vi.mock('./transcript/MobileNativeChatMessage', () => ({
MobileNativeChatMessage: 'ChatMessage'
}))
vi.mock('./transcript/MobileNativeChatLiveLine', () => ({
MobileNativeChatLiveLine: 'LiveStatus'
}))
vi.mock('./input/MobileNativeChatAsk', () => ({ MobileNativeChatAsk: 'ChatAsk' }))
vi.mock('./input/MobileNativeChatPermission', () => ({
MobileNativeChatPermission: 'ChatPermission'
}))
vi.mock('./input/MobileNativeChatQuestion', () => ({
MobileNativeChatQuestion: 'ChatQuestion'
}))
vi.mock('../../components/ActionSheetModal', () => ({ ActionSheetModal: 'ActionSheetModal' }))
vi.mock('./composer/MobileNativeChatComposer', async () => {
const { composerStub } = await import('./MobileNativeChatView.render-stubs.test-fixture')
return composerStub()
})
describe('MobileNativeChatView: history paging and anchoring', () => {
installChatViewHooks()
it('stops tail-follow before loading earlier history can resize the list', async () => {
const folded = [assistantTurn('a1', 'History')]
const onLoadEarlier = vi.fn()
await render({ folded, hasMore: true, onLoadEarlier })
scrollToEnd.mockClear()
act(() => {
list().props.onScrollBeginDrag?.({})
list().props.onScroll({
nativeEvent: {
contentOffset: { y: 40 },
contentSize: { height: 1_200 },
layoutMeasurement: { height: 500 }
}
})
list().props.onContentSizeChange(320, 950)
})
expect(onLoadEarlier).toHaveBeenCalledOnce()
expect(scrollToEnd).not.toHaveBeenCalled()
expect(scrollToOffset).not.toHaveBeenCalled()
})
it('resumes following when the drag that paged in history ends at the tail', async () => {
vi.useFakeTimers()
try {
const shortChat = {
contentOffset: { y: 0 },
contentSize: { height: 400 },
layoutMeasurement: { height: 500 }
}
const folded = [assistantTurn('a1', 'Short history')]
const onLoadEarlier = vi.fn()
await render({ folded, hasMore: true, onLoadEarlier })
act(() => {
list().props.onScrollBeginDrag?.({})
list().props.onScroll({ nativeEvent: shortChat })
list().props.onScrollEndDrag?.({ nativeEvent: shortChat })
})
await act(async () => {
vi.advanceTimersByTime(200)
})
act(() => list().props.onContentSizeChange(320, 950))
expect(onLoadEarlier).toHaveBeenCalledOnce()
expect(scrollToOffset).toHaveBeenLastCalledWith({ animated: false, offset: 950 })
expect(
root().findAll((node) => node.props.accessibilityLabel === 'Scroll to latest')
).toHaveLength(0)
} finally {
vi.useRealTimers()
}
})
it('pages in history only from a scroll the reader made', async () => {
const onLoadEarlier = vi.fn()
await render({ folded: [assistantTurn('a1', 'Latest')], hasMore: true, onLoadEarlier })
const nearTop = {
nativeEvent: {
contentOffset: { y: 40 },
contentSize: { height: 520 },
layoutMeasurement: { height: 500 }
}
}
act(() => list().props.onScroll(nearTop))
expect(onLoadEarlier).not.toHaveBeenCalled()
act(() => {
list().props.onScrollBeginDrag?.({})
list().props.onScroll(nearTop)
})
expect(onLoadEarlier).toHaveBeenCalledOnce()
})
it('anchors the first visible row, which the header above it is not', async () => {
const folded = [assistantTurn('a1', 'History')]
await render({ folded, hasMore: true })
expect(list().props.maintainVisibleContentPosition).toEqual({ minIndexForVisible: 1 })
await update({ folded, hasMore: false })
expect(list().props.maintainVisibleContentPosition).toEqual({ minIndexForVisible: 0 })
})
describe('on the page, whose list is anchored at its end', () => {
const page = () => root().find((node) => hostType(node) === 'ScrollView')
const jumpControl = () =>
root().findAll((node) => node.props.accessibilityLabel === 'Scroll to latest')
beforeEach(() => {
platform.OS = 'web'
Object.assign(pageScroller, { scrollTop: 0, scrollHeight: 20_000, clientHeight: 500 })
})
afterEach(() => {
platform.OS = 'ios'
})
function readerScrollsTo(y: number) {
act(() => {
pageScroller.scrollTop = y
page().props.onScroll(scrollEvent(y, pageScroller.scrollHeight))
})
}
it('stops following once the reader scrolls off the end, and follows again back at it', async () => {
await render({ folded: [assistantTurn('a1', 'Latest')] })
readerScrollsTo(-600)
expect(jumpControl()).toHaveLength(1)
readerScrollsTo(0)
expect(jumpControl()).toHaveLength(0)
readerScrollsTo(-600)
act(() => jumpControl()[0]!.props.onPress())
expect(pageScroller.scrollTop).toBe(0)
act(() => page().props.onScroll(scrollEvent(0, pageScroller.scrollHeight)))
expect(jumpControl()).toHaveLength(0)
})
it('opens another chat at its newest message, not where the reader left the last one', async () => {
await render({ folded: [assistantTurn('a1', 'Chat A')] })
readerScrollsTo(-600)
await update({ folded: [assistantTurn('b1', 'Chat B')], sendSurfaceId: 'tab-b' })
expect(pageScroller.scrollTop).toBe(0)
expect(jumpControl()).toHaveLength(0)
})
it('asks for older history ahead of the top, and after a failure only the retry asks', async () => {
const onLoadEarlier = vi.fn()
const folded = [assistantTurn('a1', 'Latest')]
await render({ folded, hasMore: true, onLoadEarlier })
expect(onLoadEarlier).not.toHaveBeenCalled()
readerScrollsTo(-15_000)
expect(onLoadEarlier).toHaveBeenCalled()
onLoadEarlier.mockClear()
await update({ folded, hasMore: true, olderPages: 'loading', onLoadEarlier })
readerScrollsTo(-19_500)
await update({ folded, hasMore: true, olderPages: 'failed', onLoadEarlier })
readerScrollsTo(-19_500)
expect(onLoadEarlier).not.toHaveBeenCalled()
const retry = root().find(
(node) => hostType(node) === 'Pressable' && node.props.accessibilityRole === 'button'
)
act(() => retry.props.onPress())
expect(onLoadEarlier).toHaveBeenCalledOnce()
})
it('fills a chat that opens shorter than its reach without a scroll', async () => {
const onLoadEarlier = vi.fn()
pageScroller.scrollHeight = 600
await render({ folded: [assistantTurn('a1', 'Latest')], hasMore: true, onLoadEarlier })
expect(onLoadEarlier).toHaveBeenCalled()
})
})
})
vi.mock('../MobileQueuedMessageComposer', () => ({
MobileQueuedMessageComposer: 'QueuedMessageComposer'
}))
@@ -153,63 +153,3 @@ it.each(['files', 'images'] as const)(
}
}
)
it('retains unique image identity across a real hook remount and removes only the chosen photo', async () => {
const client: RpcClient = {
sendRequest: vi.fn(async (method: string): Promise<RpcResponse> => ({
id: 'r',
ok: true,
result:
method === 'clipboard.startImageUpload'
? { uploadId: 'upload' }
: method === 'clipboard.commitImageUpload'
? '/stored/photo.png'
: null,
_meta: { runtimeId: 'r' }
})),
subscribe: () => () => {},
updateTerminalSubscriptionViewport: () => {},
getState: () => 'connected',
getReconnectAttempt: () => 0,
getLastConnectedAt: () => null,
onStateChange: () => () => {},
notifyForeground: () => {},
close: () => {}
}
picker.pickImages.mockResolvedValue([{ base64: 'YQ==', uri: 'file:///photo.png' }])
let hook!: ReturnType<typeof useMobileNativeChatImageAttachments>
function Probe() {
hook = useMobileNativeChatImageAttachments({
client,
agent: 'codex',
activeHandleRef: { current: null },
deviceTokenRef: { current: null },
getActiveWorktreeConnectionId: async () => null,
connState: 'connected',
scopeKey: 'retained',
enabled: true,
structuredNativeChat: true,
showToast: vi.fn(),
onSendError: vi.fn(),
baseSend: vi.fn().mockResolvedValue('accepted'),
readSeededLaunchDraft: () => null
})
return null
}
let mounted!: ReturnType<typeof create>
await act(async () => {
mounted = create(createElement(Probe))
})
await act(async () => hook.attachImage('library'))
const first = hook.attachments[0]!.id
act(() => mounted.unmount())
await act(async () => {
mounted = create(createElement(Probe))
})
await act(async () => hook.attachImage('library'))
const second = hook.attachments[1]!.id
expect(second).not.toBe(first)
act(() => hook.removeAttachment(second))
expect(hook.attachments.map(({ id }) => id)).toEqual([first])
act(() => mounted.unmount())
})
@@ -1,207 +0,0 @@
import { prepareQueuedMessageEditText } from '../../../../../src/shared/queued-message-edit-command'
import { describe, expect, it } from 'vitest'
import { deriveComposerAutocomplete } from '../../../../../src/shared/native-chat/input/native-chat-composer-autocomplete'
import type { NativeChatSkillDiscoverySnapshot } from '../../../../../src/shared/native-chat/input/native-chat-picker-items'
import {
boundedMenuItems,
draftAfterSkillEdit,
narrowedMenuItems,
leadingSkillToken,
mobileComposerCatalog,
mobileComposerSkillTokens,
type MobileComposerStructuredCatalog
} from './mobile-native-chat-composer-menu'
function mobileComposerSlashItems(input: {
agent: string
editing?: boolean
structured: MobileComposerStructuredCatalog | undefined
query: string
discovery?: NativeChatSkillDiscoverySnapshot
}) {
const catalog = mobileComposerCatalog(input)
const draft = `/${input.query}`
const result = deriveComposerAutocomplete(
draft,
draft.length,
catalog.commands,
catalog.discovery.skills,
catalog.profile,
catalog.discovery,
null,
catalog.sessionSkills,
input.editing
? (name) => prepareQueuedMessageEditText(input.agent, input.structured?.reported)(`/${name}`)
: undefined
)
return result.mode === 'slash' ? boundedMenuItems(result.items) : []
}
const reported = [
{ name: 'compact', kind: 'command' as const, description: 'Compact the chat' },
{ name: 'review-pr', kind: 'skill' as const, description: 'Review a pull request' },
{ name: 'research', kind: 'skill' as const }
]
describe('mobileComposerSlashItems', () => {
it("offers the host's commands, then the session's own and its skills with their descriptions", () => {
const items = mobileComposerSlashItems({
agent: 'claude',
structured: { reported, conversationCommands: ['clear', 'compact'] },
query: ''
})
expect(items.map((item) => [item.kind, item.token, item.description ?? null])).toEqual([
['command', '/model', 'Choose the model'],
['command', '/effort', 'Choose reasoning effort'],
['command', '/clear', 'Clear conversation context'],
['command', '/compact', 'Compact conversation context'],
['skill', '/research', null],
['skill', '/review-pr', 'Review a pull request']
])
})
it('names skills the way the agent invokes them', () => {
const items = mobileComposerSlashItems({
agent: 'codex',
structured: { reported, conversationCommands: [] },
query: 'review'
})
expect(items.map((item) => item.token)).toEqual(['$review-pr'])
})
it('falls back to the host commands, with no skills, before the session reports', () => {
const items = mobileComposerSlashItems({
agent: 'claude',
structured: { reported: undefined, conversationCommands: ['compact'] },
query: ''
})
expect(items.some((item) => item.token === '/compact')).toBe(true)
expect(items.some((item) => item.kind === 'skill')).toBe(false)
})
it("lists the host's scanned skills until the session reports its own", () => {
const discovery: NativeChatSkillDiscoverySnapshot = {
status: 'ready',
skills: [
{
id: 'triage',
name: 'triage',
description: 'Triage an issue',
providers: ['agent-skills'],
sourceKind: 'repo',
sourceLabel: 'Repo',
rootPath: '/repo/.agents/skills',
directoryPath: '/repo/.agents/skills/triage',
skillFilePath: '/repo/.agents/skills/triage/SKILL.md',
installed: true,
updatedAt: null
}
]
}
const tokens = (structured: MobileComposerStructuredCatalog | undefined) =>
mobileComposerSlashItems({ agent: 'claude', structured, query: '', discovery })
.filter((item) => item.kind === 'skill')
.map((item) => item.token)
expect(tokens(undefined)).toEqual(['/triage'])
expect(tokens({ reported: undefined, conversationCommands: [] })).toEqual(['/triage'])
expect(tokens({ reported, conversationCommands: [] })).toEqual(['/research', '/review-pr'])
})
it('keeps a terminal-backed chat on its curated commands', () => {
const items = mobileComposerSlashItems({ agent: 'claude', structured: undefined, query: '' })
expect(items.length).toBeGreaterThan(0)
expect(items.every((item) => item.kind === 'command')).toBe(true)
})
})
describe('mobileComposerSkillTokens', () => {
it('knows every skill the session loaded, though the menu draws only the first rows', () => {
const many = Array.from({ length: 60 }, (_, index) => ({
name: `skill-${String(index).padStart(2, '0')}`,
kind: 'skill' as const
}))
const structured = { reported: many, conversationCommands: [] }
const drawn = mobileComposerSlashItems({ agent: 'claude', structured, query: '' })
expect(drawn.filter((item) => item.kind === 'skill')).toHaveLength(10)
const tokens = mobileComposerSkillTokens(mobileComposerCatalog({ agent: 'claude', structured }))
expect(tokens.size).toBe(60)
expect(tokens.has('/skill-59')).toBe(true)
})
})
describe('leadingSkillToken', () => {
const skills = new Set(['/review-pr', '$review-pr'])
it('reads a known skill followed by a space at the start of the draft', () => {
expect(leadingSkillToken('/review-pr focus on auth', skills)).toBe('/review-pr')
expect(leadingSkillToken('/review-pr ', skills)).toBe('/review-pr')
})
it('reads nothing until the token is closed, or for a command or prose', () => {
expect(leadingSkillToken('/review-pr', skills)).toBeNull()
expect(leadingSkillToken('/compact now', skills)).toBeNull()
expect(leadingSkillToken(' /review-pr x', skills)).toBeNull()
})
})
describe('draftAfterSkillEdit', () => {
it('takes the whole skill out when a delete reaches into it', () => {
expect(draftAfterSkillEdit('/review-pr ', '/review-pr', '/review-pr')).toBe('')
expect(draftAfterSkillEdit('/review-pr focus', '/review-prfocus', '/review-pr')).toBe('focus')
expect(draftAfterSkillEdit('/review-pr focus', '/review-p focus', '/review-pr')).toBe('focus')
})
it('keeps every other edit as typed', () => {
expect(draftAfterSkillEdit('/review-pr focus', '/review-pr focus!', '/review-pr')).toBe(
'/review-pr focus!'
)
expect(draftAfterSkillEdit('/review-pr focus', '/review-pr focu', '/review-pr')).toBe(
'/review-pr focu'
)
expect(draftAfterSkillEdit('/review-pr x', '/review-pXr x', '/review-pr')).toBe('/review-pXr x')
})
})
describe('narrowedMenuItems', () => {
const items = mobileComposerSlashItems({
agent: 'claude',
structured: { reported, conversationCommands: [] },
query: ''
})
it('keeps one kind when the + sheet asks for it', () => {
expect(narrowedMenuItems(items, 'skill').map((item) => [item.kind, item.token])).toEqual([
['skill', '/research'],
['skill', '/review-pr']
])
})
it('shows everything when that kind is empty, as for a chat at rest', () => {
const commandsOnly = items.filter((item) => item.kind === 'command')
expect(narrowedMenuItems(commandsOnly, 'skill')).toEqual(commandsOnly)
})
})
it('keeps edit-valid provider commands and namesake skills without changing the ordinary catalog', () => {
const input = {
agent: 'claude',
structured: {
reported: [
{ name: 'model', kind: 'skill' as const, path: '/model/SKILL.md' },
{ name: 'review', kind: 'command' as const },
{ name: 'goal', kind: 'command' as const }
],
conversationCommands: ['clear' as const, 'compact' as const]
},
query: ''
}
const ordinary = mobileComposerSlashItems(input)
const editing = mobileComposerSlashItems({ ...input, editing: true })
expect(editing.map(({ kind, name }) => `${kind}:${name}`)).toEqual([
'command:review',
'command:goal',
'skill:model'
])
expect(ordinary.map(({ name }) => name)).toContain('compact')
expect(ordinary.map(({ kind, name }) => `${kind}:${name}`)).toContain('command:model')
})
@@ -2,10 +2,7 @@ import { describe, expect, it } from 'vitest'
import type { NativeChatMessage } from '../../../../../src/shared/native-chat/native-chat-types'
import {
findLandedImagePreviewEchoes,
findLandedUnconfirmedSends,
migrateImagePreviewMessageIds,
type PendingImagePreviewEcho,
type UnconfirmedSend
type PendingImagePreviewEcho
} from './mobile-native-chat-draft-reconcile'
function userText(id: string, text: string): NativeChatMessage {
@@ -27,56 +24,6 @@ function pending(
}
describe('mobile native chat image preview reconciliation', () => {
it('reconciles a trailing-marker echo and hands its preview to that echo', () => {
const messages = [
userText('source', '[Image: source: /tmp/a.png]'),
userText('prompt', 'look at this[Image #1]')
]
const preview = {
...pending('pending', ['file:///a.jpg']),
text: 'look at this'
}
const unconfirmed: UnconfirmedSend = {
draftKey: 'draft',
pendingKey: 'pending-key',
text: 'look at this',
normalizedText: 'look at this',
baselineTailMessageId: null,
deadline: null
}
expect(
findLandedImagePreviewEchoes(messages, [{ ...preview, afterMessageId: 'prompt' }])
).toEqual([])
expect(findLandedUnconfirmedSends(messages, [unconfirmed])).toEqual([unconfirmed])
expect(findLandedImagePreviewEchoes(messages, [preview])).toEqual([
{ pendingId: 'pending', messageId: 'prompt', images: ['file:///a.jpg'] }
])
})
it('binds an image echo to the row it was glued into with a following send', () => {
// Adjacent TUI sends can land as concatenated text; image-preview retirement must still recognize that authoritative echo.
const messages = [
userText('source', '[Image: source: /tmp/a.png]'),
userText('prompt', 'look at this[Image #1] is it still working?')
]
const preview = { ...pending('pending', ['file:///a.jpg']), text: 'look at this' }
expect(findLandedImagePreviewEchoes(messages, [preview])).toEqual([
{ pendingId: 'pending', messageId: 'prompt', images: ['file:///a.jpg'] }
])
})
it('does not bind an image echo to a row that merely shares a word', () => {
const messages = [
userText('source', '[Image: source: /tmp/a.png]'),
userText('prompt', 'totally different[Image #1]')
]
const preview = { ...pending('pending', ['file:///a.jpg']), text: 'look at this' }
expect(findLandedImagePreviewEchoes(messages, [preview])).toEqual([])
})
it('does not bind a glued image echo to an ordinary row with the same prefix', () => {
const messages = [
userText('ordinary', 'look at this later'),
@@ -90,43 +37,6 @@ describe('mobile native chat image preview reconciliation', () => {
])
})
it('reconciles a middle-marker echo without changing its rendered whitespace', () => {
const messages = [
userText('source', '[Image: source: /tmp/a.png]'),
userText('prompt', 'look [Image #1] here')
]
const preview = { ...pending('pending', ['file:///a.jpg']), text: 'look here' }
const unconfirmed: UnconfirmedSend = {
draftKey: 'draft',
pendingKey: 'pending-key',
text: 'look here',
normalizedText: 'look here',
baselineTailMessageId: null,
deadline: null
}
expect(findLandedUnconfirmedSends(messages, [unconfirmed])).toEqual([unconfirmed])
expect(findLandedImagePreviewEchoes(messages, [preview])).toEqual([
{ pendingId: 'pending', messageId: 'prompt', images: ['file:///a.jpg'] }
])
})
it('reconciles multiple transcript text blocks with desktop separators', () => {
const prompt: NativeChatMessage = {
...userText('prompt', 'unused'),
blocks: [
{ type: 'text', text: 'look' },
{ type: 'image-ref', path: '/tmp/a.png' },
{ type: 'text', text: '[Image #1] here' }
]
}
const preview = { ...pending('pending', ['file:///a.jpg']), text: 'look here' }
expect(findLandedImagePreviewEchoes([prompt], [preview])).toEqual([
{ pendingId: 'pending', messageId: 'prompt', images: ['file:///a.jpg'] }
])
})
it('keeps separate adjacent image-only sends independently reconcilable', () => {
const landed = findLandedImagePreviewEchoes(
[
@@ -180,49 +90,4 @@ describe('mobile native chat image preview reconciliation', () => {
{ pendingId: 'pending', messageId: 'new-prompt', images: ['file:///new.jpg'] }
])
})
it('moves an early standalone preview to the later folded prompt id', () => {
const sessionKey = 'host\0worktree\0tab\0session'
const previous = { [sessionKey]: { source: ['file:///a.jpg'] } }
const messages = [
userText('source', '[Image: source: /tmp/a.png]'),
userText('prompt', '[Image #1]')
]
expect(migrateImagePreviewMessageIds(previous, sessionKey, messages)).toEqual({
[sessionKey]: { prompt: ['file:///a.jpg'] }
})
})
it('moves an early standalone preview to a trailing-marker prompt id', () => {
const sessionKey = 'host\0worktree\0tab\0session'
const previous = { [sessionKey]: { source: ['file:///a.jpg'] } }
const messages = [
userText('source', '[Image: source: /tmp/a.png]'),
userText('prompt', 'look[Image #1]')
]
expect(migrateImagePreviewMessageIds(previous, sessionKey, messages)).toEqual({
[sessionKey]: { prompt: ['file:///a.jpg'] }
})
})
it('moves a preview when the prompt marker is in a later text block', () => {
const sessionKey = 'host\0worktree\0tab\0session'
const previous = { [sessionKey]: { source: ['file:///a.jpg'] } }
const prompt: NativeChatMessage = {
...userText('prompt', 'unused'),
blocks: [
{ type: 'text', text: 'look' },
{ type: 'text', text: '[Image #1] here' }
]
}
expect(
migrateImagePreviewMessageIds(previous, sessionKey, [
userText('source', '[Image: source: /tmp/a.png]'),
prompt
])
).toEqual({ [sessionKey]: { prompt: ['file:///a.jpg'] } })
})
})
@@ -1,25 +1,13 @@
import { createElement } from 'react'
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
import { afterEach, describe, expect, it } from 'vitest'
import type { NativeChatMessage } from '../../../../../src/shared/native-chat/native-chat-types'
import { useMobileNativeChatDrafts } from './use-mobile-native-chat-drafts'
import {
clearMobileNativeChatDraftForSend,
readMobileNativeChatDraftEditGeneration,
readMobileNativeChatPickedSkills,
editMobileNativeChatDraft,
readMobileNativeChatSkillOrigin,
recordMobileNativeChatSkillPick,
restoreMobileNativeChatPickedSkills,
resetMobileNativeChatDraftStoreForTests,
setMobileNativeChatDraftText,
useMobileNativeChatDraft
} from './mobile-native-chat-draft-store'
import {
readMobileNativeChatDraftInputs,
updateMobileNativeChatDraftInputs,
resetMobileNativeChatDraftInputsForTests
} from './mobile-native-chat-draft-inputs'
import { resetMobileNativeChatDraftInputsForTests } from './mobile-native-chat-draft-inputs'
type DraftState = ReturnType<typeof useMobileNativeChatDrafts> & { composerText: string }
@@ -85,48 +73,6 @@ describe('mobile native-chat draft store', () => {
expect(currentState().composerText).toBe('ping')
})
it('keeps main picked refs across Send-clear but revokes them on a real token edit', () => {
const scope = 'sent-skills'
setMobileNativeChatDraftText(scope, '$review draft')
updateMobileNativeChatDraftInputs(scope, () => ({
images: [],
files: [],
skills: [{ name: 'review', path: '/remote/SKILL.md' }]
}))
const generation = readMobileNativeChatDraftEditGeneration(scope)
clearMobileNativeChatDraftForSend(scope, generation - 1, '$review draft')
expect(readMobileNativeChatDraftInputs(scope).skills).toHaveLength(1)
clearMobileNativeChatDraftForSend(scope, generation, '$review draft')
expect(readMobileNativeChatPickedSkills(scope)).toEqual([
{ name: 'review', path: '/remote/SKILL.md' }
])
editMobileNativeChatDraft(scope, 'different text')
expect(readMobileNativeChatPickedSkills(scope)).toEqual([])
})
it('keeps a typed draft after the chat screen unmounts and remounts', async () => {
await mount('a')
act(() => currentState().setComposerText('half-written prompt'))
act(() => renderer?.unmount())
await mount('a')
expect(currentState().composerText).toBe('half-written prompt')
})
it('keeps each tab its own draft across a remount', async () => {
await mount('a')
act(() => currentState().setComposerText('for a'))
act(() => renderer?.unmount())
await mount('b')
expect(currentState().composerText).toBe('')
act(() => currentState().setComposerText('for b'))
act(() => renderer?.unmount())
await mount('a')
expect(currentState().composerText).toBe('for a')
})
it('lets an old send clear its unchanged scope after another tab was edited', async () => {
await mount('a')
act(() => currentState().setComposerText('for a'))
@@ -143,74 +89,4 @@ describe('mobile native-chat draft store', () => {
await mount('a')
expect(currentState().composerText).toBe('')
})
it('redraws only the draft subscriber per keystroke, not the screen that owns the drafts', async () => {
let screenRenders = 0
let shown = ''
let owner: ReturnType<typeof useMobileNativeChatDrafts> | null = null
function Composer({ draftKey }: { draftKey: string | null }): null {
shown = useMobileNativeChatDraft(draftKey)
return null
}
const messages: NativeChatMessage[] = []
function Screen(): ReturnType<typeof createElement> {
screenRenders += 1
owner = useMobileNativeChatDrafts({
hostId: 'host',
worktreeId: 'worktree',
tabId: 'a',
sessionId: 'session-a',
messages,
transcriptSettled: true
})
return createElement(Composer, { draftKey: owner.draftKey })
}
await act(async () => {
renderer = create(createElement(Screen))
})
const rendersBeforeTyping = screenRenders
act(() => owner?.setComposerText('typed'))
expect(shown).toBe('typed')
expect(screenRenders).toBe(rendersBeforeTyping)
})
})
describe('picked skills', () => {
afterEach(() => resetMobileNativeChatDraftStoreForTests())
it('forgets a pick once an edit deletes its token, even if the name is typed again', () => {
const review = { name: 'review', path: '/a/review/SKILL.md' }
setMobileNativeChatDraftText('draft', 'fix $review then ')
recordMobileNativeChatSkillPick('draft', { token: '$review', path: review.path })
editMobileNativeChatDraft('draft', 'fix it then ')
editMobileNativeChatDraft('draft', 'fix it then $review')
expect(readMobileNativeChatPickedSkills('draft')).toEqual([])
})
it('keeps a picked skill as the send origin through a send clear, until an edit stops it leading', () => {
editMobileNativeChatDraft('draft', '/triage ')
recordMobileNativeChatSkillPick('draft', { token: '/triage' })
editMobileNativeChatDraft('draft', '/triage the flaky test')
setMobileNativeChatDraftText('draft', '')
setMobileNativeChatDraftText('draft', '/triage the flaky test')
expect(readMobileNativeChatSkillOrigin('draft')).toBe('/triage')
editMobileNativeChatDraft('draft', 'the flaky test')
expect(readMobileNativeChatSkillOrigin('draft')).toBeNull()
})
it('replaces the file a re-picked token named, and gives a rejected draft its files back', () => {
const first = { name: 'review', path: '/a/review/SKILL.md' }
const second = { name: 'review', path: '/b/review/SKILL.md' }
editMobileNativeChatDraft('draft', '$review ')
recordMobileNativeChatSkillPick('draft', { token: '$review', path: first.path })
recordMobileNativeChatSkillPick('draft', { token: '$review', path: second.path })
expect(readMobileNativeChatPickedSkills('draft')).toEqual([second])
editMobileNativeChatDraft('draft', 'something else')
setMobileNativeChatDraftText('draft', '$review ')
restoreMobileNativeChatPickedSkills('draft', [second])
expect(readMobileNativeChatPickedSkills('draft')).toEqual([second])
})
})
@@ -1,214 +0,0 @@
// Keep pending actions per conversation and preserve child-work disclosure across temporarily empty rosters.
import { createElement } from 'react'
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { AgentChildWorkView } from '../../../../../src/shared/agent-status/child-work/agent-status-child-work-view'
import type { AgentSessionBackgroundTaskState } from '../../../../../src/shared/agent-session/agent-session-background-task-wire'
import { agentChildRowContextForSessionStream } from '../../../../../src/shared/agent-status/presentation/agent-child-row-stream-context'
import { structuredSessionBackgroundTasksView } from '../../../../../src/shared/structured-session-background-tasks-view'
import { useMobileNativeChatComposerTray } from './use-mobile-native-chat-composer-tray'
import type { MobileStructuredBackgroundTasks } from '../../use-mobile-structured-background-tasks'
vi.mock('../../../components/DragReorderList', () => ({ DragReorderList: 'DragReorderList' }))
vi.mock('react-native-reanimated', async () => {
const { useRef } = await import('react')
return {
default: { ScrollView: 'ScrollView' },
useAnimatedRef: () => useRef(null),
useSharedValue: (value: unknown) => useRef({ value }).current
}
})
vi.mock('react-native', () => ({
Dimensions: { get: () => ({ width: 430, height: 900 }) },
useWindowDimensions: () => ({ fontScale: 1 }),
Pressable: 'Pressable',
ScrollView: 'ScrollView',
StyleSheet: { create: (styles: unknown) => styles, hairlineWidth: 1 },
Text: 'Text',
View: 'View'
}))
vi.mock('lucide-react-native', () => ({
Activity: 'Activity',
AlertCircle: 'AlertCircle',
Bot: 'Bot',
ChevronDown: 'ChevronDown',
CircleHelp: 'CircleHelp',
SquareTerminal: 'SquareTerminal',
Workflow: 'Workflow',
CornerDownRight: 'CornerDownRight',
ListEnd: 'ListEnd',
MoreHorizontal: 'MoreHorizontal',
Pause: 'Pause',
Pencil: 'Pencil',
Play: 'Play',
Send: 'Send',
Trash2: 'Trash2'
}))
vi.mock('../../../components/ActionSheetModal', () => ({ ActionSheetModal: 'ActionSheetModal' }))
vi.mock('../../../components/AgentStateDot', () => ({
AGENT_WORKING_COLOR: '#eab308',
AgentStateDot: 'AgentStateDot'
}))
vi.mock('../../../hooks/use-now', () => ({ useNow: () => 100_000 }))
const CARD = {
messageId: 'waiting-1',
position: 1,
text: 'next',
line: 'next',
state: 'waiting' as const,
hold: 'turn' as const,
needsAttention: false,
caption: null,
attribution: null
}
const CHILD: AgentChildWorkView = {
id: 'a',
providerId: 'task-a',
kind: 'agent',
description: 'review a',
state: 'working',
membership: 'live',
firstObservedAt: 90_000,
observedAt: 99_000,
stoppable: true,
invocation: { invocationId: 'spawn-a', generation: 1 }
}
const RUNNING: AgentSessionBackgroundTaskState = { state: 'monitoring', children: [CHILD] }
function tasks(
sessionKey: string,
state: AgentSessionBackgroundTaskState | null
): MobileStructuredBackgroundTasks {
return {
sessionKey,
view: structuredSessionBackgroundTasksView(state, null),
rowContext: agentChildRowContextForSessionStream(true, 0),
stop: vi.fn(async () => ({ status: 'rejected' as const }))
}
}
const NEVER_RESUMES = (): Promise<boolean> => new Promise<boolean>(() => undefined)
function Slot({
sessionKey,
onResume = NEVER_RESUMES,
backgroundTasks = null
}: {
sessionKey: string
onResume?: () => Promise<boolean>
backgroundTasks?: MobileStructuredBackgroundTasks | null
}): React.JSX.Element {
const tray = useMobileNativeChatComposerTray({
queued: { cards: [CARD], pause: { reason: 'stopped' }, onResume, sessionKey },
backgroundTasks
})
return createElement('View', null, tray.content)
}
describe('useMobileNativeChatComposerTray', () => {
let renderer: ReactTestRenderer | null = null
afterEach(() => {
act(() => renderer?.unmount())
renderer = null
})
it("never lets one conversation's pending Resume disable another's", async () => {
const pending = vi.fn(() => new Promise<boolean>(() => undefined))
const mounted = create(createElement('View'))
renderer = mounted
const resume = () =>
mounted.root.findByProps({ accessibilityLabel: 'Resume sending the queued messages' })
await act(async () => {
mounted.update(createElement(Slot, { sessionKey: 'session-a', onResume: pending }))
})
await act(async () => resume().props.onPress())
expect(resume().props.disabled).toBe(true)
await act(async () => {
mounted.update(createElement(Slot, { sessionKey: 'session-b', onResume: pending }))
})
expect(resume().props.disabled).toBe(false)
})
function stripRows(mounted: ReactTestRenderer) {
return mounted.root.findAll((node) => node.props.testID === 'background-task-row')
}
function openStrip(mounted: ReactTestRenderer): void {
const header = mounted.root
.findByProps({ testID: 'background-tasks-strip' })
.find(
(node) =>
String(node.type) === 'Pressable' && node.props.accessibilityState?.expanded !== undefined
)
act(() => header.props.onPress())
}
it('keeps the strip open across a moment with nothing running', async () => {
const mounted = create(createElement('View'))
renderer = mounted
await act(async () => {
mounted.update(
createElement(Slot, { sessionKey: 's-a', backgroundTasks: tasks('s-a', RUNNING) })
)
})
openStrip(mounted)
expect(stripRows(mounted)).toHaveLength(1)
await act(async () => {
mounted.update(
createElement(Slot, { sessionKey: 's-a', backgroundTasks: tasks('s-a', null) })
)
})
expect(stripRows(mounted)).toHaveLength(0)
await act(async () => {
mounted.update(
createElement(Slot, { sessionKey: 's-a', backgroundTasks: tasks('s-a', RUNNING) })
)
})
expect(stripRows(mounted)).toHaveLength(1)
})
it("starts another conversation's strip closed", async () => {
const mounted = create(createElement('View'))
renderer = mounted
await act(async () => {
mounted.update(
createElement(Slot, { sessionKey: 's-a', backgroundTasks: tasks('s-a', RUNNING) })
)
})
openStrip(mounted)
expect(stripRows(mounted)).toHaveLength(1)
await act(async () => {
mounted.update(
createElement(Slot, { sessionKey: 's-b', backgroundTasks: tasks('s-b', RUNNING) })
)
})
expect(stripRows(mounted)).toHaveLength(0)
})
it('gives the strip and the cards of one conversation their own keys', async () => {
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => undefined)
const mounted = create(createElement('View'))
renderer = mounted
await act(async () => {
mounted.update(
createElement(Slot, { sessionKey: 's-a', backgroundTasks: tasks('s-a', RUNNING) })
)
})
const duplicateKeys = consoleError.mock.calls.filter((call) =>
String(call[0]).includes('same key')
)
consoleError.mockRestore()
expect(duplicateKeys).toEqual([])
})
})
vi.mock('../../MobileQueuedMessageComposer', () => ({
MobileQueuedMessageComposer: 'QueuedMessageComposer'
}))
@@ -1,216 +0,0 @@
import { createElement } from 'react'
import { AlertTriangle } from 'lucide-react-native'
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { agentJournalSubmissionKey } from '../../../../src/shared/agent-session/journal/agent-session-journal-item-key'
import type {
AgentJournalItemBody,
AgentJournalRenderItem,
AgentJournalSubmission
} from '../../../../src/shared/agent-session/journal/agent-session-journal-types'
import type { AgentJournalTurnScope } from '../../../../src/shared/agent-session/journal/agent-session-journal-turn-scope'
import type { NativeChatMessage } from '../../../../src/shared/native-chat/native-chat-types'
import { agentSessionFailureFact } from '../../../../src/shared/agent-session/failures/agent-session-failure'
import { agentSessionFailureWords } from '../../../../src/shared/agent-session/failures/agent-session-failure-words'
import { AGENT_SESSION_GOAL_CONTROL_COPY } from '../../../../src/shared/agent-session/view/agent-session-goal-control-copy'
import { DISPATCH_REJECTED_CANCELLED } from '../../../../src/shared/agent-session/requests/structured-agent-session-dispatch-rejection'
import { projectStructuredAgentSessionMessages } from '../../../../src/shared/agent-session/view/structured-agent-session-message-projection'
import { selectStructuredAgentSettledTurns } from '../../../../src/shared/agent-session/view/structured-agent-session-turn-timing'
vi.mock('react-native', async () => {
const React = await import('react')
const Text = ({ children, ...props }: { children?: React.ReactNode }): React.ReactNode =>
React.createElement('Text', props, children)
return {
ActivityIndicator: 'ActivityIndicator',
Animated: {
Text,
Value: class {
setValue(): void {}
},
loop: (animation: unknown) => animation,
sequence: () => ({ start: vi.fn(), stop: vi.fn() }),
timing: () => ({ start: vi.fn(), stop: vi.fn() })
},
Image: 'Image',
Platform: { OS: 'ios' },
Pressable: 'Pressable',
Text,
View: ({ children, ...props }: { children?: React.ReactNode }) =>
React.createElement('View', props, children),
StyleSheet: { create: (styles: unknown) => styles, hairlineWidth: 1 }
}
})
vi.mock('expo-clipboard', () => ({ setStringAsync: vi.fn() }))
vi.mock('lucide-react-native', () => ({
AlertTriangle: 'AlertTriangle',
ArrowUp: 'ArrowUp',
Bot: 'Bot',
Brain: 'Brain',
Check: 'Check',
ChevronDown: 'ChevronDown',
ChevronRight: 'ChevronRight',
Copy: 'Copy',
Eye: 'Eye',
FileMinus2: 'FileMinus2',
FilePen: 'FilePen',
FilePlus2: 'FilePlus2',
Folder: 'Folder',
Globe: 'Globe',
ListChecks: 'ListChecks',
MessageSquareMore: 'MessageSquareMore',
Pencil: 'Pencil',
Plug: 'Plug',
Search: 'Search',
SquareChevronRight: 'SquareChevronRight',
SquareTerminal: 'SquareTerminal',
Wrench: 'Wrench'
}))
vi.mock('../../components/MobileMarkdown', () => ({ MobileMarkdown: () => null }))
vi.mock('./transcript/MobileNativeChatMessageActionsSheet', () => ({
MobileNativeChatMessageActionsSheet: () => null
}))
import { MobileMarkdown } from '../../components/MobileMarkdown'
import { MobileNativeChatMessage } from './transcript/MobileNativeChatMessage'
import { useMobileNativeChatTurnDisclosure } from './transcript/use-mobile-native-chat-turn-disclosure'
const THREAD: AgentJournalTurnScope = { kind: 'thread' }
let sequence = 0
function item(
itemId: string,
body: AgentJournalItemBody,
turnScope: AgentJournalTurnScope = THREAD
): AgentJournalRenderItem {
sequence += 1
return { itemId, revision: 0, sequence, observedAt: sequence, body, turnScope }
}
const sent = (id: string, text: string) =>
item(agentJournalSubmissionKey(id), {
kind: 'message',
role: 'user',
blocks: [{ type: 'text', text }]
})
function submission(
clientMessageId: string,
overrides: Partial<AgentJournalSubmission> = {}
): AgentJournalSubmission {
return {
clientMessageId,
fence: 1,
payloadFingerprint: clientMessageId,
dispatchState: 'accepted',
providerItemId: null,
reason: null,
submittedAt: 1,
resolvedAt: 2,
...overrides
}
}
const items = [
sent('warm-up', 'warm up'),
item('t1', {
kind: 'turn',
turnId: 't1',
state: 'completed',
outcome: 'success',
userItemId: agentJournalSubmissionKey('warm-up'),
startedAt: 1_000,
completedAt: 4_000
}),
item(
't1-answer',
{ kind: 'message', role: 'assistant', blocks: [{ type: 'text', text: 'Warmed up.' }] },
{ kind: 'turn', turnItemId: 't1' }
),
sent('never-ran', 'look around')
]
const submissions = [
submission('warm-up'),
submission('never-ran', { dispatchState: 'rejected', reason: DISPATCH_REJECTED_CANCELLED })
]
type Disclosure = ReturnType<typeof useMobileNativeChatTurnDisclosure>
let disclosure: Disclosure | null = null
function Harness({ messages }: { messages: readonly NativeChatMessage[] }): null {
disclosure = useMobileNativeChatTurnDisclosure({
messages,
enabled: true,
isWorking: false,
settledTurns: selectStructuredAgentSettledTurns(items, submissions),
turnJournal: { items, submissions },
workingStartedAt: null,
scopeKey: 'host\0worktree\0tab-a'
})
return null
}
describe('a send a Stop took back before the agent started it, on the phone', () => {
let renderer: ReactTestRenderer | null = null
afterEach(() => {
act(() => renderer?.unmount())
renderer = null
})
it.each(['pauseUnconfirmed', 'stopUnconfirmed'] as const)(
'renders the host %s warning once after projection',
(stopNotice) => {
const messages = projectStructuredAgentSessionMessages(
[
item('stop-outcome', {
kind: 'status',
tone: 'warning',
...agentSessionFailureWords(agentSessionFailureFact('cancelUnconfirmed'), {
surface: 'row',
stopNotice
})
})
],
[],
[]
)
expect(messages).toHaveLength(1)
act(() => {
renderer = create(createElement(MobileNativeChatMessage, { message: messages[0]! }))
})
expect(
renderer!.root.findAll((node) => String(node.type) === 'Text').map((node) => node.children)
).toEqual([[AGENT_SESSION_GOAL_CONTROL_COPY[stopNotice]]])
expect(renderer!.root.findAllByType(AlertTriangle)).toHaveLength(1)
}
)
it('stays where it was sent, with the stop row after it and no turn status of its own', () => {
const messages = projectStructuredAgentSessionMessages(items, [], submissions)
expect(messages.map((message) => message.id)).toEqual([
agentJournalSubmissionKey('warm-up'),
't1-answer',
agentJournalSubmissionKey('never-ran'),
`stopped-before-start:${agentJournalSubmissionKey('never-ran')}`
])
act(() => {
renderer = create(createElement(Harness, { messages }))
})
const statuses = messages.map(
(message, index) => disclosure?.resolveRow(index, message).turnStatus ?? null
)
expect(statuses[0]?.workedSeconds).toBe(3)
expect(statuses.slice(2)).toEqual([null, null])
act(() => renderer?.unmount())
act(() => {
renderer = create(createElement(MobileNativeChatMessage, { message: messages[3]! }))
})
expect(renderer!.root.findAllByType(MobileMarkdown)).toEqual([])
expect(
renderer!.root.findAll((node) => String(node.type) === 'Text').map((node) => node.children)
).toEqual([['Stopped manually']])
})
})
@@ -1,203 +0,0 @@
import { createElement } from 'react'
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { NativeChatMessage } from '../../../../../src/shared/native-chat/native-chat-types'
import {
createNativeChatReplyRevealTracker,
trackNativeChatReplyRows
} from '../../../../../src/shared/native-chat/transcript/native-chat-reply-reveals'
import { NATIVE_CHAT_TEXT_REVEAL_DELAY_MS } from '../../../../../src/shared/native-chat/transcript/native-chat-text-reveal'
import {
MobileNativeChatReplyRevealsContext,
type MobileNativeChatReplyReveals
} from './mobile-native-chat-reply-reveals'
import {
createMobileNativeChatGrowthHold,
MobileNativeChatGrowthHoldContext
} from '../../mobile-native-chat-growth-hold'
vi.mock('react-native', () => ({
Image: 'Image',
Platform: { OS: 'ios' },
Pressable: 'Pressable',
Text: 'Text',
View: 'View',
StyleSheet: { create: (value: unknown) => value, hairlineWidth: 1 }
}))
vi.mock('lucide-react-native', () => ({ Copy: 'Copy', Check: 'Check' }))
vi.mock('../../../components/MobileMarkdown', () => ({ MobileMarkdown: 'MobileMarkdown' }))
vi.mock('../../../platform/clipboard', () => ({
useClipboardWriter: () => ({ writeText: vi.fn() })
}))
vi.mock('./MobileNativeChatMessageActionsSheet', () => ({
MobileNativeChatMessageActionsSheet: 'MessageActionsSheet'
}))
vi.mock('./MobileNativeChatReasoningRow', () => ({ MobileNativeChatReasoningRow: 'Reasoning' }))
vi.mock('./MobileNativeChatTurnStatus', () => ({ MobileNativeChatTurnStatus: 'TurnStatus' }))
vi.mock('./MobileNativeChatToolRun', () => ({ ToolRun: 'ToolRun' }))
import { MobileNativeChatMessage } from './MobileNativeChatMessage'
const DELAY = NATIVE_CHAT_TEXT_REVEAL_DELAY_MS
const first = 'word '.repeat(40)
const second = first + 'more '.repeat(40)
describe('a growing reply on the phone', () => {
let renderer: ReactTestRenderer | undefined
let chat: MobileNativeChatReplyReveals
let growth = createMobileNativeChatGrowthHold()
function render(text: string | string[], working = true, runningTool = false): void {
const message: NativeChatMessage = {
id: 'a1',
role: 'assistant',
source: 'transcript',
timestamp: null,
blocks: (typeof text === 'string' ? [text] : text).map((part) => ({
type: 'text',
text: part
}))
}
if (runningTool) {
message.blocks.push({ type: 'tool-call', name: 'Read', input: {}, state: 'running' })
}
const row = createElement(MobileNativeChatMessage, {
message,
mayStillGrow: working,
activeTurnIsWorking: true
})
const revealing = createElement(
MobileNativeChatReplyRevealsContext.Provider,
{ value: chat },
row
)
const tree = createElement(
MobileNativeChatGrowthHoldContext.Provider,
{ value: growth },
revealing
)
act(() => {
if (renderer) {
renderer.update(tree)
} else {
renderer = create(tree)
}
})
}
function contents(): string[] {
return renderer!.root
.findAll((node) => String(node.type) === 'MobileMarkdown')
.map((node) => String(node.props.content))
}
function drawn(): number {
return contents().join('').length
}
function advance(ms: number): void {
act(() => {
vi.advanceTimersByTime(ms)
})
}
function remount(text: string | string[]): void {
act(() => renderer?.unmount())
renderer = undefined
render(text)
}
beforeEach(() => {
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout', 'Date'] })
vi.spyOn(performance, 'now').mockImplementation(() => Date.now())
vi.stubGlobal('requestAnimationFrame', (step: () => void) => setTimeout(step, 16))
vi.stubGlobal('cancelAnimationFrame', (frame: number) => clearTimeout(frame))
const tracker = createNativeChatReplyRevealTracker()
trackNativeChatReplyRows(tracker, ['u1'], new Set(['u1']))
const reveals = trackNativeChatReplyRows(tracker, ['u1', 'a1'], new Set(['u1', 'a1']))
chat = { ...reveals, reducedMotion: false }
})
afterEach(() => {
act(() => renderer?.unmount())
renderer = undefined
growth = createMobileNativeChatGrowthHold()
vi.unstubAllGlobals()
vi.restoreAllMocks()
vi.useRealTimers()
})
it('reveals each chunk over time, and a remounted row resumes instead of replaying', () => {
render(first)
expect(drawn()).toBe('word'.length)
advance(DELAY / 2)
expect(drawn()).toBeGreaterThan('word'.length)
expect(drawn()).toBeLessThan(first.length)
advance(DELAY)
expect(drawn()).toBe(first.length)
render(second)
advance(DELAY / 2)
const before = drawn()
expect(before).toBeGreaterThan(first.length)
expect(before).toBeLessThan(second.length)
remount(second)
expect(drawn()).toBeGreaterThanOrEqual(before)
expect(drawn()).toBeLessThan(second.length)
advance(DELAY)
expect(drawn()).toBe(second.length)
})
it('waits while the page holds growth, then catches up in one step', () => {
growth.set(true)
render(first)
const shown = drawn()
advance(DELAY * 2)
expect(drawn()).toBe(shown)
act(() => growth.set(false))
advance(16)
expect(drawn()).toBe(first.length)
})
it.each([false, true])('paces a second prose block after the first caught up: %s', (caughtUp) => {
render(first)
advance(caughtUp ? DELAY : DELAY / 2)
render([first, second])
if (!caughtUp) {
expect(contents()[0].length).toBeLessThan(first.length)
}
expect(contents()[1].length).toBeLessThan(second.length)
advance(DELAY / 2)
const before = drawn()
expect(before).toBeGreaterThan(first.length)
expect(before).toBeLessThan(first.length + second.length)
remount([first, second])
expect(drawn()).toBeGreaterThanOrEqual(before)
expect(drawn()).toBeLessThan(first.length + second.length)
advance(DELAY)
expect(contents()).toEqual([first, second])
})
it('does not replay read text when a question ends while the row is unmounted', () => {
render(first)
advance(DELAY)
render(first, false)
expect(chat.drawn.has('a1')).toBe(false)
act(() => renderer?.unmount())
renderer = undefined
render(first)
expect(drawn()).toBe(first.length)
render(second)
expect(drawn()).toBeLessThan(second.length)
advance(DELAY)
expect(drawn()).toBe(second.length)
})
it('draws completed prose in full on first mount beside a running tool', () => {
render(first, true, true)
expect(contents()).toEqual([first])
const tool = renderer!.root.find((node) => String(node.type) === 'ToolRun')
expect(tool.props.blocks[0].state).toBe('running')
})
it('draws the text as it arrives with reduced motion', () => {
chat = { ...chat, reducedMotion: true }
render(second)
expect(drawn()).toBe(second.length)
})
})
@@ -1,186 +0,0 @@
import { createElement, type ReactNode } from 'react'
import { act, create, type ReactTestInstance, type ReactTestRenderer } from 'react-test-renderer'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { NativeChatMessage } from '../../../../../src/shared/native-chat/native-chat-types'
import type { NativeChatResolvedPrompt } from '../../../../../src/shared/native-chat/native-chat-resolution-receipt'
vi.mock('react-native', async () => {
const React = await import('react')
const Text = ({ children, ...props }: { children?: ReactNode }): unknown =>
React.createElement('Text', props, children)
return {
AppState: { currentState: 'active', addEventListener: () => ({ remove: () => {} }) },
ActivityIndicator: 'ActivityIndicator',
Animated: {
Text,
Value: class {
setValue(): void {}
},
loop: (animation: unknown) => animation,
sequence: () => ({ start: vi.fn(), stop: vi.fn() }),
timing: () => ({ start: vi.fn(), stop: vi.fn() })
},
Image: 'Image',
Platform: { OS: 'ios' },
Pressable: 'Pressable',
ScrollView: ({ children, ...props }: { children?: ReactNode }) =>
React.createElement('ScrollView', props, children),
Text,
View: ({ children, ...props }: { children?: ReactNode }) =>
React.createElement('View', props, children),
StyleSheet: { create: (styles: unknown) => styles, hairlineWidth: 1 },
useWindowDimensions: () => ({ width: 400, height: 800 })
}
})
vi.mock('expo-clipboard', () => ({ setStringAsync: vi.fn() }))
vi.mock('lucide-react-native', () => ({
AlertCircle: 'AlertCircle',
AlertTriangle: 'AlertTriangle',
ArrowUp: 'ArrowUp',
Bot: 'Bot',
Brain: 'Brain',
Check: 'Check',
ChevronDown: 'ChevronDown',
ChevronRight: 'ChevronRight',
Copy: 'Copy',
Eye: 'Eye',
FileMinus2: 'FileMinus2',
FilePen: 'FilePen',
FilePlus2: 'FilePlus2',
Folder: 'Folder',
Globe: 'Globe',
ListChecks: 'ListChecks',
MessageSquareMore: 'MessageSquareMore',
Pencil: 'Pencil',
Plug: 'Plug',
Search: 'Search',
SquareChevronRight: 'SquareChevronRight',
SquareTerminal: 'SquareTerminal',
Wrench: 'Wrench'
}))
vi.mock('../../../components/MobileMarkdown', () => ({ MobileMarkdown: 'MobileMarkdown' }))
vi.mock('./MobileNativeChatMessageActionsSheet', () => ({
MobileNativeChatMessageActionsSheet: 'MessageActionsSheet'
}))
import { MobileNativeChatMessage } from './MobileNativeChatMessage'
import { findAllByHostType } from '../../../test-support/react-test-renderer-host-nodes'
describe('MobileNativeChatMessage', () => {
let renderer: ReactTestRenderer | null = null
afterEach(() => {
act(() => renderer?.unmount())
renderer = null
})
function render(
message: NativeChatMessage,
props: {
fontScale?: number
structuredActivityUi?: boolean
activeTurnIsWorking?: boolean
trailingRun?: boolean
foldedAway?: boolean
turnExpanded?: boolean
turnStatus?: {
startedAt: number | null
thinking: boolean
workedSeconds: number | null
} | null
onToggleTurn?: () => void
reasoningIsLive?: boolean
unsentNotice?: string
receipt?: NativeChatResolvedPrompt
userMessageOpen?: boolean
onToggleUserMessage?: (messageId: string) => void
awaiting?: boolean
} = {}
): ReactTestRenderer {
act(() => {
renderer = create(createElement(MobileNativeChatMessage, { message, ...props }))
})
return renderer!
}
const textIn = (node: ReactTestInstance): string[] =>
findAllByHostType(node, 'Text').map((text) => String(text.children.join('')))
describe('a reasoning row', () => {
const reasoning = (fields: Partial<NativeChatMessage> = {}): NativeChatMessage => ({
id: 'r1',
role: 'reasoning',
blocks: [{ type: 'text', text: 'Weighing two approaches' }],
timestamp: 1_000,
source: 'transcript',
state: 'completed',
completedAt: 4_000,
...fields
})
const toggleOf = (tree: ReactTestRenderer): ReactTestInstance =>
tree.root.find(
(node) => String(node.type) === 'Pressable' && node.props.accessibilityRole === 'button'
)
const markdownIn = (tree: ReactTestRenderer): ReactTestInstance[] =>
tree.root.findAll((node) => String(node.type) === 'MobileMarkdown')
it('starts collapsed to its headline, with its text unmounted', () => {
const tree = render(reasoning())
expect(textIn(tree.root)).toContain('Thought for 3s')
expect(toggleOf(tree).props.accessibilityState).toEqual({ expanded: false })
expect(toggleOf(tree).props.accessibilityLabel).toBe('Reasoning: Thought for 3s')
expect(toggleOf(tree).props.hitSlop).toBe(6)
expect(markdownIn(tree)).toHaveLength(0)
})
it('leads its headline with the brain, as desktop does', () => {
const [first] = toggleOf(render(reasoning())).children
expect(typeof first === 'string' ? first : first?.type).toBe('Brain')
})
it('mounts its text once opened', () => {
const tree = render(reasoning())
act(() => toggleOf(tree).props.onPress())
expect(toggleOf(tree).props.accessibilityState).toEqual({ expanded: true })
expect(markdownIn(tree).map((node) => node.props.content)).toEqual([
'Weighing two approaches'
])
})
it('draws nothing while the live line discloses it, or when blank', () => {
expect(
render(reasoning({ state: 'running' }), {
activeTurnIsWorking: true,
reasoningIsLive: true
}).toJSON()
).toBeNull()
expect(render(reasoning({ blocks: [{ type: 'text', text: ' \n ' }] })).toJSON()).toBeNull()
})
it("still draws its turn's bar while the live line discloses it", () => {
const tree = render(reasoning({ state: 'running' }), {
activeTurnIsWorking: true,
reasoningIsLive: true,
turnStatus: { startedAt: 1_000, thinking: true, workedSeconds: null }
})
expect(tree.root.findAll((node) => String(node.type) === 'Pressable')).toHaveLength(0)
expect(textIn(tree.root).some((text) => text.startsWith('Working for'))).toBe(true)
})
it('draws any other open block in its working turn as Reasoning', () => {
const child = render(reasoning({ state: 'running', agentId: 'sub-1' }), {
activeTurnIsWorking: true
})
expect(textIn(child.root)).toContain('Reasoning')
expect(toggleOf(child).props.accessibilityLabel).toBe('Reasoning')
})
it('says only what the host saw', () => {
expect(textIn(render(reasoning({ state: 'running' })).root)).toContain('Thought')
const unknown = render(reasoning({ state: undefined, completedAt: undefined }))
expect(textIn(unknown.root)).toContain('Reasoning')
expect(toggleOf(unknown).props.accessibilityLabel).toBe('Reasoning')
expect(textIn(render(reasoning({ completedAt: 1_300 })).root)).toContain('Thought for 1s')
})
})
})
@@ -1,208 +0,0 @@
import { createElement, type ReactNode } from 'react'
import { act, create, type ReactTestInstance, type ReactTestRenderer } from 'react-test-renderer'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type {
NativeChatMessage,
NativeChatSubagentEntry
} from '../../../../../src/shared/native-chat/native-chat-types'
import { Harness, Result, userMessage } from './use-mobile-native-chat-turn-disclosure.test-fixture'
vi.mock('react-native', async () => {
const React = await import('react')
const Text = ({ children, ...props }: { children?: ReactNode }): ReactNode =>
React.createElement('Text', props, children)
return {
Image: 'Image',
Platform: { OS: 'ios' },
Pressable: 'Pressable',
ScrollView: 'ScrollView',
Text,
View: ({ children, ...props }: { children?: ReactNode }) =>
React.createElement('View', props, children),
StyleSheet: { create: (styles: unknown) => styles, hairlineWidth: 1 },
Animated: { Value: class {}, timing: () => ({ start: vi.fn() }) }
}
})
vi.mock('expo-clipboard', () => ({ setStringAsync: vi.fn() }))
vi.mock('lucide-react-native', () => ({
Bot: 'Bot',
Brain: 'Brain',
Check: 'Check',
ChevronDown: 'ChevronDown',
ChevronRight: 'ChevronRight',
Eye: 'Eye',
FileMinus2: 'FileMinus2',
FilePen: 'FilePen',
FilePlus2: 'FilePlus2',
Folder: 'Folder',
Globe: 'Globe',
ListChecks: 'ListChecks',
MessageSquareMore: 'MessageSquareMore',
Pencil: 'Pencil',
Plug: 'Plug',
Search: 'Search',
SquareChevronRight: 'SquareChevronRight',
SquareTerminal: 'SquareTerminal',
Wrench: 'Wrench'
}))
vi.mock('../../../components/MobileMarkdown', () => ({ MobileMarkdown: 'MobileMarkdown' }))
vi.mock('./MobileNativeChatMessageActionsSheet', () => ({
MobileNativeChatMessageActionsSheet: 'MessageActionsSheet'
}))
vi.mock('../../../hooks/use-now', () => ({ useNow: () => 70_000 }))
import { MobileNativeChatMessage } from './MobileNativeChatMessage'
function roster(
agents: NativeChatSubagentEntry[],
sentence: string | null = 'Kicked off 2 subagents'
): NativeChatMessage {
return {
id: 'spawn',
role: 'system',
blocks: [
...(sentence === null ? [] : [{ type: 'text' as const, text: sentence }]),
{ type: 'subagent-group', groupId: 'group-1', agents }
],
timestamp: null,
source: 'transcript'
}
}
const WORKING: NativeChatSubagentEntry[] = [
{ id: 'a', label: 'review', state: 'working', startedAt: 10_000 },
{ id: 'b', label: 'tests', state: 'working', startedAt: 10_000 }
]
const FINISHED: NativeChatSubagentEntry[] = [
{ id: 'a', label: 'review', state: 'completed', startedAt: 0, settledAt: 60_000, tokens: 9_000 },
{ id: 'b', label: 'tests', state: 'failed', startedAt: 0, settledAt: 62_000, tokens: 3_000 }
]
// The View mock is a component around a host node; match the host alone so nothing counts twice.
function byTestId(node: ReactTestInstance, testID: string): boolean {
return typeof node.type === 'string' && node.props.testID === testID
}
function textOf(node: ReactTestInstance): string {
return node.children.map((child) => (typeof child === 'string' ? child : textOf(child))).join('')
}
describe('mobile transcript roster row', () => {
let renderer: ReactTestRenderer | null = null
afterEach(() => {
act(() => renderer?.unmount())
renderer = null
})
function render(
message: NativeChatMessage,
props: Record<string, unknown> = {}
): ReactTestRenderer {
act(() => {
renderer = create(createElement(MobileNativeChatMessage, { message, ...props }))
})
return renderer!
}
const markdown = (tree: ReactTestRenderer): unknown[] =>
tree.root.findAll((node) => String(node.type) === 'MobileMarkdown').map((n) => n.props.content)
const aside = (tree: ReactTestRenderer): string[] =>
tree.root.findAll((node) => String(node.type) === 'Text').map(textOf)
const group = (tree: ReactTestRenderer): ReactTestInstance[] =>
tree.root.findAll((node) => byTestId(node, 'subagent-group'))
it("draws the host's live group in place of its frozen sentence", () => {
const tree = render(roster(WORKING))
expect(markdown(tree)).toEqual([])
expect(group(tree)).toHaveLength(1)
expect(textOf(group(tree)[0]!)).toBe('Kicked off 2 subagents2 working · 1m 0s')
})
it('reads the settled verdict, run length and tokens', () => {
const tree = render(roster(FINISHED, 'Ran 2 subagents (1 failed)'))
expect(markdown(tree)).toEqual([])
expect(textOf(group(tree)[0]!)).toBe('Ran 2 subagents1 failed · 1m 2s · 12k tokens')
})
it('speaks the header whole, its clock only once the group stops counting', () => {
const label = (tree: ReactTestRenderer): unknown =>
group(tree)[0]!.findAll((node) => String(node.type) === 'Pressable')[0]!.props
.accessibilityLabel
expect(label(render(roster(WORKING)))).toBe('Kicked off 2 subagents · 2 working')
const alerted: NativeChatSubagentEntry[] = [
...WORKING,
{ id: 'c', label: 'lint', state: 'failed', startedAt: 0, settledAt: 5_000 }
]
expect(label(render(roster(alerted)))).toBe('Kicked off 3 subagents · 2 working +1 failed')
expect(label(render(roster(FINISHED)))).toBe('Ran 2 subagents · 1 failed · 1m 2s · 12k tokens')
})
it('keeps the sentence when there is no group it can draw', () => {
const textOnly: NativeChatMessage = {
id: 'spawn',
role: 'system',
blocks: [{ type: 'text', text: 'Ran 2 subagents' }],
timestamp: null,
source: 'transcript'
}
expect(aside(render(textOnly))).toEqual(['Ran 2 subagents'])
const childless = render(roster([], 'Ran 2 subagents'))
expect(aside(childless)).toEqual(['Ran 2 subagents'])
expect(group(childless)).toEqual([])
})
it('keeps real text beside a group', () => {
const tree = render(roster(WORKING, 'Delegating the review.'))
expect(aside(tree)[0]).toBe('Delegating the review.')
expect(group(tree)).toHaveLength(1)
})
it('stays visible once its turn settles, outside the tool-run collapse', () => {
const tree = render(roster(FINISHED, null), {
structuredActivityUi: true,
activeTurnIsWorking: false,
turnExpanded: false
})
expect(group(tree)).toHaveLength(1)
})
it('lists each child when the transcript holds it open, and asks to toggle by group id', () => {
const onToggleSubagentGroup = vi.fn()
const closed = render(roster(FINISHED), { onToggleSubagentGroup })
expect(closed.root.findAll((node) => byTestId(node, 'subagent-group-entry'))).toEqual([])
const header = group(closed)[0]!.findAll((node) => String(node.type) === 'Pressable')[0]!
act(() => header.props.onPress())
expect(onToggleSubagentGroup).toHaveBeenCalledWith('group-1')
const open = render(roster(FINISHED), {
subagentGroupsOpen: new Set(['group-1']),
onToggleSubagentGroup
})
expect(open.root.findAll((node) => byTestId(node, 'subagent-group-entry')).map(textOf)).toEqual(
['reviewcompleted · 9k', 'testsfailed · 3k']
)
})
})
describe('transcript-held roster disclosure', () => {
let renderer: ReactTestRenderer | null = null
afterEach(() => {
act(() => renderer?.unmount())
renderer = null
})
it('opens a group by id and hands the open set to roster rows only', () => {
const messages = [userMessage('ask'), roster(WORKING)]
act(() => {
renderer = create(createElement(Harness, { messages, enabled: true, isWorking: false }))
})
const disclosure = () => renderer!.root.findByType(Result).props.disclosure
const rowFor = (index: number) => disclosure().resolveRow(index, messages[index]!)
expect(rowFor(0).subagentGroupsOpen).toBeUndefined()
expect(rowFor(1).subagentGroupsOpen?.has('group-1')).toBe(false)
act(() => rowFor(1).onToggleSubagentGroup('group-1'))
expect(rowFor(1).subagentGroupsOpen?.has('group-1')).toBe(true)
})
})
@@ -1,210 +0,0 @@
// @vitest-environment happy-dom
import { MOBILE_WEB_APP_NATIVE_PARITY_STYLE } from '../../../../../config/scripts/build-mobile-web-app-bundle.mjs'
import { act } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
import { MobileNativeChatMessage } from './MobileNativeChatMessage'
import { MobileNativeChatRowOverlaysContext } from './mobile-native-chat-row-overlays'
import type * as ReactNative from 'react-native'
import type * as ReactNativeSvg from 'react-native-svg'
import type { NativeChatMessage } from '../../../../../src/shared/native-chat/native-chat-types'
vi.mock('react-native', () => vi.importActual<typeof ReactNative>('react-native-web'))
// Use the same SVG web host selected by the page bundler, overriding the native setup stand-in.
vi.mock('react-native-svg', async () => {
const { createRequire } = await import('node:module')
const { resolve } = await import('node:path')
// Anchor the mobile-only dependency here, even when the mocker runs from root dependencies.
const fromMobile = createRequire(resolve('package.json'))
return vi.importActual<typeof ReactNativeSvg>(
fromMobile.resolve('react-native-svg/lib/module/elements.web.js')
)
})
vi.mock('../../../components/MobileMarkdown', () => ({ MobileMarkdown: () => null }))
vi.mock('../../../components/MobileSourceCopyButton', () => ({
MobileSourceCopyButton: () => null
}))
const cases = [
[
'codex',
'notification:guardianWarning',
'Codex reported a safety review warning. View details.'
],
['claude', 'message:system:permission_denied', 'Claude denied permission. View details.']
] as const
let root: Root
let container: HTMLDivElement
let policy: HTMLDivElement
const openActions = vi.fn()
const overlays = { openActions, openImage: () => {} }
const text = `${'安全 '.repeat(1500)}EXPLANATION_SENTINEL`
const head = `{"partial":"${'é'.repeat(4500)}PAYLOAD_SENTINEL`
beforeEach(() => {
policy = document.createElement('div')
policy.innerHTML = MOBILE_WEB_APP_NATIVE_PARITY_STYLE
document.head.append(policy)
container = document.createElement('div')
document.body.append(container)
root = createRoot(container)
openActions.mockClear()
})
afterEach(() => {
act(() => root.unmount())
container.remove()
policy.remove()
window.getSelection()?.removeAllRanges()
})
function render(provider: string, kind: string) {
const notice: NativeChatMessage = {
id: 'notice',
role: 'system',
source: 'transcript',
timestamp: null,
blocks: [
{
type: 'text',
tone: provider === 'claude' ? 'error' : 'warning',
text,
providerFrame: {
provider,
kind,
payload: { head, byteLength: 99999, digest: 'synthetic', truncated: true }
}
}
]
}
act(() =>
root.render(
<MobileNativeChatRowOverlaysContext.Provider value={overlays}>
<MobileNativeChatMessage message={notice} />
<MobileNativeChatMessage
message={{
...notice,
id: 'control',
blocks: [{ type: 'text', tone: 'warning', text: 'Ordinary warning control' }]
}}
/>
</MobileNativeChatRowOverlaysContext.Provider>
)
)
const button = container.querySelector('[role="button"]')
if (!(button instanceof HTMLElement)) {
throw new Error('Missing notice disclosure')
}
return button
}
function touch(node: Element, type: 'touchstart' | 'touchend') {
const finger = { identifier: 1, target: node, pageX: 10, pageY: 10, clientX: 10, clientY: 10 }
node.dispatchEvent(
Object.assign(new Event(type, { bubbles: true, cancelable: true }), {
touches: type === 'touchstart' ? [finger] : [],
changedTouches: [finger]
})
)
}
function select(node: Element) {
const range = document.createRange()
const walker = document.createTreeWalker(node, NodeFilter.SHOW_TEXT)
const first = walker.nextNode()!
let last = first
while (walker.nextNode()) {
last = walker.currentNode
}
range.setStart(first, 0)
range.setEnd(last, last.textContent!.length)
window.getSelection()!.removeAllRanges()
window.getSelection()!.addRange(range)
document.dispatchEvent(new Event('selectionchange'))
}
function tap(node: HTMLElement) {
act(() => {
touch(node, 'touchstart')
touch(node, 'touchend')
node.click()
})
}
it.each(cases)(
'keeps %s summary selection separate from activation in the web page',
(provider, kind, heading) => {
const button = render(provider, kind)
const summary = Array.from(button.querySelectorAll('div')).find(
(node) => node.textContent === heading
)!
const control = Array.from(container.querySelectorAll('div')).find(
(node) => node.childElementCount === 0 && node.textContent === 'Ordinary warning control'
)!
expect(getComputedStyle(document.body).userSelect).toBe('none')
expect(getComputedStyle(control).userSelect).toBe('text')
expect(getComputedStyle(summary).userSelect).toBe('text')
expect(container.querySelectorAll('[role="button"]')).toHaveLength(1)
expect(button.getAttribute('aria-label')).toBe(heading)
expect(button.tabIndex).toBe(0)
expect(button.getAttribute('aria-expanded')).toBe('false')
expect(container.textContent!.includes('EXPLANATION_SENTINEL')).toBe(false)
act(() => {
touch(summary, 'touchstart')
select(summary)
touch(summary, 'touchend')
summary.click()
})
expect(window.getSelection()!.toString()).toBe(heading)
expect(button.getAttribute('aria-expanded')).toBe('false')
expect(container.textContent!.includes('EXPLANATION_SENTINEL')).toBe(false)
expect(openActions).not.toHaveBeenCalled()
window.getSelection()!.removeAllRanges()
tap(button)
expect(button.getAttribute('aria-expanded')).toBe('true')
expect(container.textContent!.includes('EXPLANATION_SENTINEL')).toBe(true)
const detail = Array.from(container.querySelectorAll('div')).find(
(node) => node.childElementCount === 0 && node.textContent?.startsWith(text)
)!
expect(detail.textContent).toContain(text)
expect(detail.textContent).toContain(head)
expect(detail.textContent).toContain(
'Provider response truncated (original size: 99999 bytes).'
)
expect(getComputedStyle(detail).userSelect).toBe('text')
act(() => select(detail))
expect(window.getSelection()!.toString()).toContain('PAYLOAD_SENTINEL')
expect(button.getAttribute('aria-expanded')).toBe('true')
expect(container.textContent!.includes('EXPLANATION_SENTINEL')).toBe(true)
window.getSelection()!.removeAllRanges()
tap(button)
expect(button.getAttribute('aria-expanded')).toBe('false')
expect(container.textContent!.includes('EXPLANATION_SENTINEL')).toBe(false)
act(() => {
expect(button.tagName).toBe('BUTTON')
// happy-dom does not synthesize a native button's keyboard-origin click.
button.dispatchEvent(new MouseEvent('click', { bubbles: true, detail: 0 }))
})
expect(button.getAttribute('aria-expanded')).toBe('true')
expect(container.textContent!.includes('EXPLANATION_SENTINEL')).toBe(true)
tap(button)
expect(button.getAttribute('aria-expanded')).toBe('false')
expect(container.textContent!.includes('EXPLANATION_SENTINEL')).toBe(false)
}
)
it.each(cases)(
'rotates the %s caret wrapper without transforming SVG or paths',
(provider, kind) => {
const button = render(provider, kind)
const svg = button.querySelector('svg')!
const wrapper = svg.parentElement!
expect(svg.getAttribute('width')).toBe('16')
expect(svg.getAttribute('height')).toBe('16')
expect(svg.querySelector('path')?.getAttribute('d')).toBeTruthy()
expect(getComputedStyle(wrapper).transform).toMatch(/^(none)?$/)
tap(button)
expect(getComputedStyle(wrapper).transform).toBe('rotate(90deg)')
for (const node of [svg, ...svg.querySelectorAll('path')]) {
expect(getComputedStyle(node).transform).toMatch(/^(none)?$/)
expect(node.hasAttribute('transform')).toBe(false)
}
tap(button)
expect(getComputedStyle(wrapper).transform).toMatch(/^(none)?$/)
}
)
@@ -39,17 +39,11 @@ vi.mock('react-native', async () => {
vi.mock('lucide-react-native', () => ({ ChevronRight: 'ChevronRight' }))
import { MobileNativeChatTurnStatus } from './MobileNativeChatTurnStatus'
import {
findAllByHostType,
findByHostType
} from '../../../test-support/react-test-renderer-host-nodes'
import { findAllByHostType } from '../../../test-support/react-test-renderer-host-nodes'
const labels = (node: ReactTestInstance): string[] =>
findAllByHostType(node, 'Text').map((text) => String(text.children.join('')))
const spinners = (node: ReactTestInstance): ReactTestInstance[] =>
findAllByHostType(node, 'ActivityIndicator')
let renderer: ReactTestRenderer | null = null
beforeEach(() => {
@@ -88,56 +82,6 @@ describe('MobileNativeChatTurnStatus', () => {
return renderer!
}
it('counts up from the first second while the turn runs, with no spinner', () => {
const tree = render({ startedAt: Date.now() })
expect(labels(tree.root)).toEqual(['Working for 0s'])
act(() => {
vi.advanceTimersByTime(12_000)
})
expect(labels(tree.root)).toEqual(['Working for 12s'])
expect(spinners(tree.root)).toHaveLength(0)
})
it('settles to a tappable "Worked for" row that toggles the turn', () => {
const onToggleExpanded = vi.fn()
const tree = render({ startedAt: Date.now(), workedSeconds: 184, onToggleExpanded })
expect(labels(tree.root)).toEqual(['Worked for 3m 4s'])
const button = findByHostType(tree.root, 'Pressable')
expect(button.props.accessibilityLabel).toBe('Toggle turn details')
expect(button.props.accessibilityState).toEqual({ expanded: false })
act(() => button.props.onPress())
expect(onToggleExpanded).toHaveBeenCalledOnce()
})
it('heads a turn a crash cut off as worked, and a turn the user stopped as interrupted', () => {
const crashed = render({
startedAt: Date.now(),
workedSeconds: 12,
verdict: 'interruption',
onToggleExpanded: vi.fn()
})
expect(labels(crashed.root)).toEqual(['Worked for 12s'])
act(() => crashed.unmount())
const stopped = render({
startedAt: Date.now(),
workedSeconds: 12,
verdict: 'cancellation',
onToggleExpanded: vi.fn()
})
expect(labels(stopped.root)).toEqual(['Interrupted after 12s'])
})
it('stays a plain row when the settled turn has nothing to disclose', () => {
const tree = render({ startedAt: Date.now(), workedSeconds: 5 })
expect(findAllByHostType(tree.root, 'Pressable')).toHaveLength(0)
expect(labels(tree.root)).toEqual(['Worked for 5s'])
})
it('holds no interval once the turn has settled', () => {
render({ startedAt: Date.now(), workedSeconds: 5 })
expect(vi.getTimerCount()).toBe(0)
})
it('stops clock callbacks in the background and catches up immediately on resume', () => {
const tree = render({ startedAt: Date.now() })
act(() => {
@@ -1,213 +0,0 @@
import { describe, expect, it, vi } from 'vitest'
import type { RpcResponse } from '../../../transport/types'
import type { UnvalidatedRpcRequestPort } from '../../../transport/unvalidated-rpc-request-port'
import { openMobileNativeChatFileTap } from './mobile-native-chat-open-file'
function ok(result: unknown): RpcResponse {
return { id: 'rpc', ok: true, result, _meta: { runtimeId: 'runtime-1' } }
}
// Drains the resolve round trip without counting its internal awaits.
const settle = (): Promise<void> => new Promise((resolve) => setTimeout(resolve, 0))
function activationState(activated: boolean) {
return {
activated,
activationSeq: 1,
latestActivationSeq: 1,
sourceTerminalHandle: 'terminal-1',
activeTerminalHandle: 'terminal-1',
activeTabType: 'terminal'
}
}
function baseOptions(client: UnvalidatedRpcRequestPort) {
return {
client,
hostId: 'host-1',
worktreeId: 'wt-1',
pushPreviewRoute: vi.fn(),
openBrowser: vi.fn(),
triggerOpenFeedback: vi.fn(),
fetchSessionTabs: vi.fn(),
getSessionTabs: () => [],
getActiveSessionTabId: () => null,
getActivationState: activationState,
switchSessionTab: vi.fn(),
scheduleDelayedAction: vi.fn(),
onOpenFailed: vi.fn()
}
}
function worktreeFileResolution(relativePath: string) {
return ok({
worktree: 'wt-1',
relativePath,
absolutePath: `/repo/${relativePath}`,
exists: true,
isDirectory: false,
openTarget: {
kind: 'worktree-file',
provider: 'local',
relativePath,
absolutePath: `/repo/${relativePath}`
}
})
}
describe('openMobileNativeChatFileTap', () => {
it('resolves against the worktree root: no terminal handle and no cwd', async () => {
const sendRequest = vi.fn(async () => worktreeFileResolution('src/app.ts'))
const options = baseOptions({ sendRequest })
openMobileNativeChatFileTap({ ...options, pathText: 'src/app.ts' })
await settle()
expect(sendRequest).toHaveBeenCalledWith(
'files.resolveTerminalPath',
{ worktree: 'id:wt-1', pathText: 'src/app.ts', crossWorkspace: true },
{ timeoutMs: 10_000 }
)
})
it('carries the native-chat session identity into resolution and grant refresh', async () => {
const sendRequest = vi.fn(async () =>
ok({
worktree: 'wt-1',
relativePath: null,
absolutePath: '/Users/ada/orca-plans/result.html',
exists: true,
isDirectory: false,
openTarget: {
kind: 'absolute-file',
provider: 'local',
absolutePath: '/Users/ada/orca-plans/result.html',
grantId: 'grant-1',
readOnly: true
}
})
)
const options = baseOptions({ sendRequest })
openMobileNativeChatFileTap({
...options,
pathText: '~/orca-plans/result.html',
nativeChatContext: { tabId: 'tab-1', sessionId: 'session-1' }
})
await settle()
expect(sendRequest).toHaveBeenCalledWith(
'files.resolveTerminalPath',
{
worktree: 'id:wt-1',
pathText: '~/orca-plans/result.html',
crossWorkspace: true,
nativeChatContext: { tabId: 'tab-1', sessionId: 'session-1' }
},
{ timeoutMs: 10_000 }
)
expect(options.pushPreviewRoute).toHaveBeenCalledWith({
pathname: '/h/[hostId]/files/preview/[worktreeId]',
params: expect.objectContaining({
source: 'terminalArtifact',
absolutePath: '/Users/ada/orca-plans/result.html',
grantId: 'grant-1',
nativeChatTab: 'tab-1',
nativeChatSession: 'session-1'
})
})
})
it('parses a :line:col citation and opens the mobile preview route', async () => {
const sendRequest = vi.fn(async () => worktreeFileResolution('src/app.ts'))
const options = baseOptions({ sendRequest })
openMobileNativeChatFileTap({ ...options, pathText: 'src/app.ts:120:7' })
await settle()
expect(sendRequest).toHaveBeenCalledWith(
'files.resolveTerminalPath',
{ worktree: 'id:wt-1', pathText: 'src/app.ts', crossWorkspace: true },
{ timeoutMs: 10_000 }
)
expect(options.triggerOpenFeedback).toHaveBeenCalledTimes(1)
expect(options.pushPreviewRoute).toHaveBeenCalledWith({
pathname: '/h/[hostId]/files/preview/[worktreeId]',
params: expect.objectContaining({
source: 'worktree',
relativePath: 'src/app.ts',
line: '120',
column: '7'
})
})
expect(options.onOpenFailed).not.toHaveBeenCalled()
})
it('surfaces a resolve miss instead of a silent no-op', async () => {
const sendRequest = vi.fn(async () =>
ok({
worktree: 'wt-1',
relativePath: null,
absolutePath: null,
exists: false,
isDirectory: false
})
)
const options = baseOptions({ sendRequest })
openMobileNativeChatFileTap({ ...options, pathText: 'gone/missing.ts' })
await settle()
expect(options.onOpenFailed).toHaveBeenCalledTimes(1)
expect(options.pushPreviewRoute).not.toHaveBeenCalled()
expect(options.triggerOpenFeedback).not.toHaveBeenCalled()
})
it('surfaces a rejected resolve request', async () => {
const sendRequest = vi.fn(async () => {
throw new Error('Request timed out')
})
const options = baseOptions({ sendRequest })
openMobileNativeChatFileTap({ ...options, pathText: 'src/app.ts' })
await settle()
expect(options.onOpenFailed).toHaveBeenCalledTimes(1)
})
it('opens a plain path through files.open with tab activation', async () => {
const responses = [worktreeFileResolution('src/app.ts'), ok({ opened: true })]
const sendRequest = vi.fn(async (method: string) => {
const response = responses.shift()
if (!response) {
throw new Error(`no scripted response for ${method}`)
}
return response
})
const openedTab = { id: 'tab-2', relativePath: 'src/app.ts' }
const switchSessionTab = vi.fn()
const options = {
...baseOptions({ sendRequest }),
getSessionTabs: () => [openedTab],
getActiveSessionTabId: () => 'terminal-tab',
switchSessionTab,
scheduleDelayedAction: vi.fn((callback: () => void) => callback())
}
vi.useFakeTimers()
try {
openMobileNativeChatFileTap({ ...options, pathText: 'src/app.ts' })
await vi.runAllTimersAsync()
expect(sendRequest).toHaveBeenCalledWith(
'files.open',
{ worktree: 'id:wt-1', relativePath: 'src/app.ts' },
{ timeoutMs: 15_000 }
)
expect(switchSessionTab).toHaveBeenCalledWith(openedTab)
expect(options.onOpenFailed).not.toHaveBeenCalled()
} finally {
vi.useRealTimers()
}
})
})
@@ -1,210 +0,0 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { RpcClient } from '../../../transport/rpc-client'
import type { RpcResponse } from '../../../transport/types'
import {
cachedMobileNativeChatVisual,
readMobileNativeChatVisual,
resetMobileNativeChatVisualCacheForTest,
type MobileNativeChatVisualSource
} from './mobile-native-chat-visual-read'
const REVISION_A = 'a'.repeat(32)
const REVISION_B = 'b'.repeat(32)
function success(result: unknown): RpcResponse {
return { id: '1', ok: true, result }
}
function failure(code: string): RpcResponse {
return { id: '1', ok: false, error: { code, message: code } }
}
function sourceWith(sendRequest: ReturnType<typeof vi.fn>): MobileNativeChatVisualSource {
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the reader calls only sendRequest; the rest of RpcClient is connection lifecycle it never touches.
const client = { sendRequest } as unknown as RpcClient
return { client, sessionId: 'session-1' }
}
afterEach(() => {
resetMobileNativeChatVisualCacheForTest()
})
describe('readMobileNativeChatVisual', () => {
it('reads the visual by session and bare file name, then caches it', async () => {
const sendRequest = vi
.fn()
.mockResolvedValue(success({ ok: true, revision: REVISION_A, sizeBytes: 3, html: '<p>' }))
const source = sourceWith(sendRequest)
await expect(readMobileNativeChatVisual(source, 'chart.html')).resolves.toEqual({
kind: 'ready',
html: '<p>',
revision: REVISION_A
})
expect(sendRequest).toHaveBeenCalledWith(
'agentSession.readVisual',
{ sessionId: 'session-1', file: 'chart.html' },
expect.objectContaining({ timeoutMs: expect.any(Number) })
)
expect(cachedMobileNativeChatVisual(source, 'chart.html')).toEqual({
html: '<p>',
revision: REVISION_A
})
})
it('revalidates with the held revision and keeps the cached bytes on unchanged', async () => {
const sendRequest = vi
.fn()
.mockResolvedValueOnce(success({ ok: true, revision: REVISION_A, sizeBytes: 3, html: '<p>' }))
.mockResolvedValueOnce(
success({ ok: true, revision: REVISION_A, sizeBytes: 3, unchanged: true })
)
const source = sourceWith(sendRequest)
await readMobileNativeChatVisual(source, 'chart.html')
await expect(readMobileNativeChatVisual(source, 'chart.html')).resolves.toEqual({
kind: 'ready',
html: '<p>',
revision: REVISION_A
})
expect(sendRequest.mock.calls[1]?.[1]).toEqual({
sessionId: 'session-1',
file: 'chart.html',
knownRevision: REVISION_A
})
})
it('replaces the cached bytes when the host has a new revision', async () => {
const sendRequest = vi
.fn()
.mockResolvedValueOnce(success({ ok: true, revision: REVISION_A, sizeBytes: 3, html: '<p>' }))
.mockResolvedValueOnce(
success({ ok: true, revision: REVISION_B, sizeBytes: 4, html: '<hr>' })
)
const source = sourceWith(sendRequest)
await readMobileNativeChatVisual(source, 'chart.html')
await expect(readMobileNativeChatVisual(source, 'chart.html')).resolves.toEqual({
kind: 'ready',
html: '<hr>',
revision: REVISION_B
})
expect(cachedMobileNativeChatVisual(source, 'chart.html')).toEqual({
html: '<hr>',
revision: REVISION_B
})
})
it('drops the cached bytes when the host refuses a visual it served before', async () => {
const source = sourceWith(
vi
.fn()
.mockResolvedValueOnce(
success({ ok: true, revision: REVISION_A, sizeBytes: 3, html: '<p>' })
)
.mockResolvedValueOnce(success({ ok: false, error: 'not_found' }))
)
await readMobileNativeChatVisual(source, 'chart.html')
await expect(readMobileNativeChatVisual(source, 'chart.html')).resolves.toEqual({
kind: 'refused'
})
expect(cachedMobileNativeChatVisual(source, 'chart.html')).toBeNull()
})
it('reads a host verdict and an older host as refused', async () => {
for (const response of [
success({ ok: false, error: 'not_found' }),
success({ ok: false, error: 'some_future_error' }),
failure('method_not_found'),
failure('forbidden')
]) {
resetMobileNativeChatVisualCacheForTest()
const source = sourceWith(vi.fn().mockResolvedValue(response))
await expect(readMobileNativeChatVisual(source, 'chart.html')).resolves.toEqual({
kind: 'refused'
})
}
})
it('reads an error reply and a malformed reply as no verdict, keeping the cache', async () => {
for (const response of [
failure('runtime_error'),
success({ ok: true, revision: 'not-hex', html: '' }),
success('<html>')
]) {
resetMobileNativeChatVisualCacheForTest()
const sendRequest = vi
.fn()
.mockResolvedValueOnce(
success({ ok: true, revision: REVISION_A, sizeBytes: 3, html: '<p>' })
)
.mockResolvedValueOnce(response)
const source = sourceWith(sendRequest)
await readMobileNativeChatVisual(source, 'chart.html')
await expect(readMobileNativeChatVisual(source, 'chart.html')).resolves.toEqual({
kind: 'unreachable'
})
expect(cachedMobileNativeChatVisual(source, 'chart.html')).not.toBeNull()
}
})
it('reads a transport failure as unreachable, never as refused', async () => {
const source = sourceWith(vi.fn().mockRejectedValue(new Error('socket closed')))
await expect(readMobileNativeChatVisual(source, 'chart.html')).resolves.toEqual({
kind: 'unreachable'
})
})
it('treats an unchanged answer for a revision it never sent as unreachable and drops the entry', async () => {
const source = sourceWith(
vi.fn().mockResolvedValue(success({ ok: true, revision: REVISION_A, unchanged: true }))
)
await expect(readMobileNativeChatVisual(source, 'chart.html')).resolves.toEqual({
kind: 'unreachable'
})
expect(cachedMobileNativeChatVisual(source, 'chart.html')).toBeNull()
})
it('shares one request between concurrent reads of the same visual', async () => {
let resolve: (response: RpcResponse) => void = () => {}
const sendRequest = vi.fn(
() =>
new Promise<RpcResponse>((settle) => {
resolve = settle
})
)
const source = sourceWith(sendRequest)
const first = readMobileNativeChatVisual(source, 'chart.html')
const second = readMobileNativeChatVisual(source, 'chart.html')
resolve(success({ ok: true, revision: REVISION_A, sizeBytes: 3, html: '<p>' }))
await expect(Promise.all([first, second])).resolves.toEqual([
{ kind: 'ready', html: '<p>', revision: REVISION_A },
{ kind: 'ready', html: '<p>', revision: REVISION_A }
])
expect(sendRequest).toHaveBeenCalledTimes(1)
})
it('keys the cache by client and session, so another host or chat never sees this one', async () => {
const source = sourceWith(
vi
.fn()
.mockResolvedValue(success({ ok: true, revision: REVISION_A, sizeBytes: 3, html: '<p>' }))
)
await readMobileNativeChatVisual(source, 'chart.html')
expect(cachedMobileNativeChatVisual({ ...source, sessionId: 'other' }, 'chart.html')).toBeNull()
const otherHost = sourceWith(vi.fn()).client
expect(cachedMobileNativeChatVisual({ ...source, client: otherHost }, 'chart.html')).toBeNull()
})
it('evicts the oldest visuals past the entry bound', async () => {
const source = sourceWith(
vi
.fn()
.mockResolvedValue(success({ ok: true, revision: REVISION_A, sizeBytes: 3, html: '<p>' }))
)
for (let index = 0; index < 17; index += 1) {
await readMobileNativeChatVisual(source, `v${index}.html`)
}
expect(cachedMobileNativeChatVisual(source, 'v0.html')).toBeNull()
expect(cachedMobileNativeChatVisual(source, 'v16.html')).not.toBeNull()
})
})
@@ -110,24 +110,6 @@ function mount() {
return { Page, frames, deliver, owner, terminal, onKey }
}
it('keeps the native scroll view mounted across toolbar-only updates without releasing ownership', () => {
const { Page, frames, deliver, owner } = mount()
const scroll = native!.root.findByType(ScrollView)
for (const update of [
{ keyboardVisible: true },
{ liveInputEnabled: false },
{ phoneDisplayMode: true },
{ canSend: false },
{ modifiers: { ctrl: 'once', alt: 'off', shift: 'off' } as const }
]) {
const toolbar = { ...initialToolbar, ...update }
act(() => page!.update(createElement(Page, { toolbar })))
expect(frames).toEqual([[true, owner, toolbar]])
deliver()
expect(native!.root.findByType(ScrollView)).toBe(scroll)
}
})
it('updates pane ownership without releasing the rail and still releases when covered', () => {
const { Page, frames, deliver, owner, terminal, onKey } = mount()
act(() => page!.update(createElement(Page, { toolbar: initialToolbar, focusedTerminal: 'two' })))
@@ -1,198 +0,0 @@
import { AGENT_TUI_CLEAR_INPUT_MAX } from '../../../src/shared/agent-launch/startup/agent-tui-input-clear'
import { createElement } from 'react'
import { resetMobileNativeChatDraftInputsForTests } from './native-chat/composer/mobile-native-chat-draft-inputs'
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { FakeSession } from '../transport/mobile-endpoint-supervisor-test-fakes'
import { resetMobileNativeChatStaleInputForTests } from './native-chat/mobile-native-chat-stale-input'
import { resetMobileNativeChatTerminalWritesForTests } from './native-chat/mobile-native-chat-terminal-write-lock'
import { useMobileSessionImageAttachments } from './use-mobile-session-image-attachments'
vi.mock('../platform/media-picker', () => ({
useMediaPicker: () => ({
pickImages: async function* () {
yield { base64: 'AAAA', uri: 'file:///a.jpg' }
}
})
}))
type HookArgs = Parameters<typeof useMobileSessionImageAttachments>[0]
type Hook = ReturnType<typeof useMobileSessionImageAttachments>
function baseArgs(overrides: Partial<Omit<HookArgs, 'client'>> = {}) {
const client = new FakeSession('connected')
client.sendRequest.mockImplementation(async (method) => {
if (method === 'clipboard.startImageUpload') {
return { id: 'start', ok: false, error: { code: 'method_not_found', message: 'no' } }
}
if (method === 'clipboard.saveImageAsTempFile') {
return { id: 'save', ok: true, result: '/tmp/a.png' }
}
if (method === 'terminal.send') {
return { id: 'send', ok: true, result: { send: { accepted: true } } }
}
throw new Error(`unexpected request: ${method}`)
})
return {
client,
agent: 'claude',
activeHandle: 'term-1',
activeHandleRef: { current: 'term-1' },
canSend: true,
connState: 'connected',
deviceTokenRef: { current: null },
nativeChatScopeKey: 'scope-1',
nativeChatInputLeaseReady: false,
nativeChatAgentConfirmed: true,
getActiveWorktreeConnectionId: async () => 'conn-1',
beforeTerminalSend: async () => true,
nativeChatBaseSend: vi.fn().mockResolvedValue('accepted'),
structuredNativeChat: true,
nativeChatSessionId: 'session-1',
readSeededLaunchDraft: () => null,
showToast: vi.fn(),
onNativeChatSendError: vi.fn(),
onSuccess: vi.fn(),
onError: vi.fn(),
...overrides
} satisfies HookArgs
}
describe('useMobileSessionImageAttachments', () => {
let renderer: ReactTestRenderer | null = null
let hook: Hook
function Harness({ args }: { args: HookArgs }): null {
hook = useMobileSessionImageAttachments(args)
return null
}
beforeEach(() => {
resetMobileNativeChatDraftInputsForTests()
vi.useFakeTimers()
resetMobileNativeChatStaleInputForTests()
resetMobileNativeChatTerminalWritesForTests()
})
afterEach(() => {
act(() => renderer?.unmount())
renderer = null
vi.useRealTimers()
})
async function renderWithImage(args: HookArgs): Promise<void> {
act(() => {
renderer = create(createElement(Harness, { args }))
})
await act(async () => {
await hook.nativeChatImages.attachImage('library')
})
expect(hook.nativeChatImages.attachments).toEqual([
{ id: expect.any(String), path: '/tmp/a.png', previewUri: 'file:///a.jpg' }
])
}
async function send(): Promise<boolean> {
let accepted = false
await act(async () => {
const pending = hook.nativeChatImages.sendNativeChat('look')
await vi.advanceTimersByTimeAsync(300)
accepted = await pending
})
return accepted
}
it('sends structured session images without a terminal input lease', async () => {
const args = baseArgs({ activeHandle: null, activeHandleRef: { current: null } })
await renderWithImage(args)
const image = hook.nativeChatImages.attachments[0]
expect(await send()).toBe(true)
expect(args.nativeChatBaseSend).toHaveBeenCalledWith(
'look',
['file:///a.jpg'],
expect.any(Number),
[image]
)
expect(args.client.sendRequest.mock.calls.map(([method]) => method)).toEqual([
'clipboard.startImageUpload',
'clipboard.saveImageAsTempFile'
])
expect(hook.nativeChatImages.attachments).toEqual([])
})
it.each([false, true])('gates terminal image delivery on input lease %s', async (leaseReady) => {
const args = baseArgs({
nativeChatInputLeaseReady: leaseReady,
structuredNativeChat: false
})
await renderWithImage(args)
expect(await send()).toBe(leaseReady)
const sends = args.client.sendRequest.mock.calls.filter(
([method]) => method === 'terminal.send'
)
if (leaseReady) {
expect(sends).toEqual([
[
'terminal.send',
{ terminal: 'term-1', text: AGENT_TUI_CLEAR_INPUT_MAX, enter: false },
expect.any(Object)
],
[
'terminal.send',
{ terminal: 'term-1', text: '\x1b[200~/tmp/a.png\x1b[201~ ', enter: false },
expect.any(Object)
]
])
expect(args.nativeChatBaseSend).toHaveBeenCalledWith(
'look',
['file:///a.jpg'],
expect.any(Number),
undefined,
expect.any(Number)
)
expect(hook.nativeChatImages.attachments).toEqual([])
} else {
expect(sends).toEqual([])
expect(args.nativeChatBaseSend).not.toHaveBeenCalled()
expect(args.onNativeChatSendError).toHaveBeenCalledWith('Message not sent (disconnected)')
expect(hook.nativeChatImages.attachments).toHaveLength(1)
}
})
it('keeps a structured session image pending when the connection drops', async () => {
const args = baseArgs()
await renderWithImage(args)
act(() => {
renderer?.update(createElement(Harness, { args: { ...args, connState: 'connecting' } }))
})
expect(await send()).toBe(false)
expect(args.nativeChatBaseSend).not.toHaveBeenCalled()
expect(args.onNativeChatSendError).toHaveBeenCalledWith('Message not sent (disconnected)')
expect(hook.nativeChatImages.attachments).toHaveLength(1)
expect(args.client.sendRequest.mock.calls.map(([method]) => method)).toEqual([
'clipboard.startImageUpload',
'clipboard.saveImageAsTempFile'
])
})
it('keeps a terminal image pending until the live agent is confirmed', async () => {
const args = baseArgs({
nativeChatInputLeaseReady: true,
nativeChatAgentConfirmed: false,
structuredNativeChat: false
})
await renderWithImage(args)
expect(await send()).toBe(false)
expect(args.nativeChatBaseSend).not.toHaveBeenCalled()
expect(args.onNativeChatSendError).toHaveBeenCalledWith('Message not sent (disconnected)')
expect(hook.nativeChatImages.attachments).toHaveLength(1)
expect(args.client.sendRequest.mock.calls.map(([method]) => method)).toEqual([
'clipboard.startImageUpload',
'clipboard.saveImageAsTempFile'
])
})
})
@@ -1,200 +0,0 @@
import type * as OpenMobileStructuredSessionModule from './open-mobile-structured-session'
import { createElement } from 'react'
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
import { afterEach, expect, it, vi } from 'vitest'
import type {
StructuredSessionOpenSource,
StructuredSessionOpenResult
} from '../../../src/shared/open-structured-agent-session'
import { createFakeRpcClient } from '../mobile-web-shell/bridge-host-test-fakes'
import { useMobileStructuredSessionOpening } from './use-mobile-structured-session-opening'
import {
activateMobileUiLocale,
mobileI18n,
setMobilePseudoLocalization
} from '../i18n/mobile-i18n'
import { pseudoLocalizeString } from '../../../src/shared/pseudo-localization'
const mocks = vi.hoisted(() => ({
executionHost: 'local',
open: vi.fn(),
push: vi.fn(),
replace: vi.fn()
}))
vi.mock('../navigation/workspace-execution-host', () => ({
useWorkspaceExecutionHost: () => mocks.executionHost
}))
vi.mock('../navigation/workspace-route-handoff', () => ({
useWorkspaceRouteHandoff: () => ({ push: mocks.push, replace: mocks.replace })
}))
vi.mock('./open-mobile-structured-session', async (original) => ({
...(await original<typeof OpenMobileStructuredSessionModule>()),
openMobileStructuredSession: mocks.open
}))
const source: StructuredSessionOpenSource = {
kind: 'notification',
sessionId: 'root',
worktreeId: 'workspace'
}
const opened: StructuredSessionOpenResult = {
kind: 'opened',
target: { sessionId: 'live', worktreeId: 'workspace' },
tab: { id: 'actual', sessionId: 'live', agent: 'codex' }
}
afterEach(async () => {
vi.clearAllMocks()
setMobilePseudoLocalization(false)
mobileI18n.removeResourceBundle('fr', 'translation')
await activateMobileUiLocale('en')
})
it.each(
(['stable', 'newer-pick', 'execution-host', 'unmount'] as const).flatMap((mode) =>
(['opened', 'failed'] as const).map((outcome) => ({ mode, outcome }))
)
)('only the owning phone view applies $outcome completion: $mode', async ({ mode, outcome }) => {
await activateMobileUiLocale('en')
mocks.executionHost = 'local'
const deferred = Promise.withResolvers<StructuredSessionOpenResult>()
mocks.open.mockReturnValue(deferred.promise)
const selected = { current: 'source' }
const pending = { current: null }
const switchSessionTab = vi.fn()
const scope: Parameters<typeof useMobileStructuredSessionOpening>[0] = {
hostId: 'host',
worktreeId: 'workspace',
client: createFakeRpcClient(),
connState: 'connected',
showToast: vi.fn(),
switchSessionTab,
activeSessionTabIdRef: { current: 'source' },
selectedSessionTabIdRef: selected,
pendingSelectionRef: pending,
sessionTabsRef: {
current: [
{
type: 'terminal',
id: 'source',
title: 'Source',
terminal: 'terminal-source',
isActive: true
}
]
}
}
let open: ((source: StructuredSessionOpenSource) => Promise<void>) | undefined
function Probe() {
open = useMobileStructuredSessionOpening(scope)
return null
}
let tree: ReactTestRenderer | undefined
await act(async () => {
tree = create(createElement(Probe))
})
try {
let completion: Promise<void> | undefined
await act(async () => {
completion = open?.(
outcome === 'opened'
? source
: {
kind: 'sender',
party: { address: 'orca_session_id:root', terminalHandle: null, orcaSessionId: null },
messageIds: ['message'],
fallback: { sessionId: 'root', worktreeId: 'workspace', agent: 'codex' },
displayName: 'Worker custom'
}
)
})
if (mode === 'newer-pick') {
selected.current = 'newer-user-pick'
}
if (mode === 'execution-host') {
mocks.executionHost = 'ssh:another'
await act(async () => tree?.update(createElement(Probe)))
}
if (mode === 'unmount') {
await act(async () => tree?.unmount())
}
mobileI18n.addResourceBundle('fr', 'translation', {
'session.structuredOpen.unreachable': 'Conversation de {{agent}} : réessayez.'
})
await activateMobileUiLocale('fr')
setMobilePseudoLocalization(true)
await act(async () => {
deferred.resolve(
outcome === 'opened'
? opened
: {
kind: 'failed',
failure: { reason: 'unreachable', beforeEffects: false }
}
)
await completion
})
expect(switchSessionTab).toHaveBeenCalledTimes(
mode === 'stable' && outcome === 'opened' ? 1 : 0
)
expect(scope.showToast).toHaveBeenCalledTimes(mode === 'stable' && outcome === 'failed' ? 1 : 0)
expect(mocks.push).not.toHaveBeenCalled()
if (mode === 'stable' && outcome === 'failed') {
expect(scope.showToast).toHaveBeenCalledWith(
pseudoLocalizeString('Conversation de Worker custom : réessayez.', { expand: true }),
3_000
)
expect(mocks.open).toHaveBeenCalledWith(
expect.objectContaining({
source: expect.objectContaining({
fallback: { sessionId: 'root', worktreeId: 'workspace', agent: 'codex' }
})
})
)
}
if (mode === 'stable' && outcome === 'opened') {
expect(switchSessionTab).toHaveBeenCalledWith(
expect.objectContaining({ id: 'actual', sessionId: 'live' }),
{ activate: false }
)
}
} finally {
await act(async () => tree?.unmount())
}
})
it('routes a removed workspace notification to the existing host notice without a tab or duplicate toast', async () => {
mocks.executionHost = 'local'
mocks.open.mockResolvedValue({
kind: 'failed',
failure: { reason: 'workspace-missing', beforeEffects: true }
})
const scope: Parameters<typeof useMobileStructuredSessionOpening>[0] = {
hostId: 'host',
worktreeId: 'workspace',
client: createFakeRpcClient(),
connState: 'connected',
showToast: vi.fn(),
switchSessionTab: vi.fn(),
activeSessionTabIdRef: { current: null },
selectedSessionTabIdRef: { current: null },
pendingSelectionRef: { current: null },
sessionTabsRef: { current: [] }
}
let open: ((value: StructuredSessionOpenSource) => Promise<void>) | undefined
function Probe() {
open = useMobileStructuredSessionOpening(scope)
return null
}
let tree: ReactTestRenderer | undefined
try {
await act(async () => {
tree = create(createElement(Probe))
})
await act(async () => {
await open?.(source)
})
expect(mocks.replace).toHaveBeenCalledExactlyOnceWith('/h/host?notice=worktree-missing')
expect(mocks.push).not.toHaveBeenCalled()
expect(scope.switchSessionTab).not.toHaveBeenCalled()
expect(scope.showToast).not.toHaveBeenCalled()
} finally {
await act(async () => tree?.unmount())
}
})
@@ -1,7 +1,7 @@
import { terminalInputSend } from '../terminal/mobile-terminal-operations'
import { describe, expect, it, vi } from 'vitest'
import type { RpcClient } from '../transport/rpc-client'
import type { ConnectionState } from '../transport/types'
import { useMobileTerminalPaste } from './use-mobile-terminal-paste'
// Call memo factories directly: this hook-only harness has no mounted render to cache across.
@@ -21,121 +21,6 @@ vi.mock('./mobile-clipboard-image', async (importOriginal) => ({
saveMobileClipboardImageAsTempFile: async () => '/tmp/shot.png'
}))
describe('mobile clipboard image followed by typing', () => {
it.each(['omp', 'claude', 'codex', null, 'unknown'])(
'separates a pasted image from subsequent %s input',
async (agent) => {
const sendRequest = vi
.fn()
.mockResolvedValue({ ok: true, result: { send: { accepted: true } } })
const client: RpcClient = {
sendRequest,
subscribe: () => () => {},
updateTerminalSubscriptionViewport: vi.fn(),
getState: () => 'connected',
getReconnectAttempt: () => 0,
getLastConnectedAt: () => null,
onStateChange: () => () => {},
notifyForeground: vi.fn(),
close: vi.fn()
}
const paste = useMobileTerminalPaste({
sendTerminalUserInput: terminalInputSend.request,
agent,
activeHandle: 'term-1',
activeHandleRef: { current: 'term-1' },
activeSessionTabTypeRef: { current: 'terminal' },
canSend: true,
client,
clientRef: { current: client },
connState: 'connected',
connStateRef: { current: 'connected' },
deviceTokenRef: { current: null },
flushPendingLiveInputBeforeExternalSend: async () => true,
getActiveWorktreeConnectionId: async () => null,
onError: vi.fn(),
onSuccess: vi.fn(),
ptyModesRef: { current: new Map() },
refreshCanPaste: vi.fn(),
showToast: vi.fn()
})
await paste()
expect(sendRequest).toHaveBeenCalledWith('terminal.send', {
terminal: 'term-1',
text: `\x1b[200~${agent === 'omp' ? '@' : ''}/tmp/shot.png\x1b[201~ `,
enter: false
})
}
)
it.each(['terminal switch', 'non-terminal view', 'disconnect'])(
'drops a completed image paste after a pending input flush observes %s',
async (change) => {
const sendRequest = vi.fn()
const client: RpcClient = {
sendRequest,
subscribe: () => () => {},
updateTerminalSubscriptionViewport: vi.fn(),
getState: () => 'connected',
getReconnectAttempt: () => 0,
getLastConnectedAt: () => null,
onStateChange: () => () => {},
notifyForeground: vi.fn(),
close: vi.fn()
}
const activeHandleRef = { current: 'term-1' }
const activeSessionTabTypeRef = { current: 'terminal' }
const connStateRef: { current: ConnectionState } = { current: 'connected' }
let releaseFlush!: (ready: boolean) => void
let flushStarted!: () => void
const flushing = new Promise<void>((resolve) => {
flushStarted = resolve
})
const onSuccess = vi.fn()
const paste = useMobileTerminalPaste({
sendTerminalUserInput: terminalInputSend.request,
agent: null,
activeHandle: 'term-1',
activeHandleRef,
activeSessionTabTypeRef,
canSend: true,
client,
clientRef: { current: client },
connState: 'connected',
connStateRef,
deviceTokenRef: { current: null },
flushPendingLiveInputBeforeExternalSend: () => {
flushStarted()
return new Promise<boolean>((resolve) => {
releaseFlush = resolve
})
},
getActiveWorktreeConnectionId: async () => null,
onError: vi.fn(),
onSuccess,
ptyModesRef: { current: new Map() },
refreshCanPaste: vi.fn(),
showToast: vi.fn()
})
const pending = paste()
await flushing
if (change === 'terminal switch') {
activeHandleRef.current = 'term-2'
}
if (change === 'non-terminal view') {
activeSessionTabTypeRef.current = 'agent-session'
}
if (change === 'disconnect') {
connStateRef.current = 'disconnected'
}
releaseFlush(true)
await pending
expect(sendRequest).not.toHaveBeenCalled()
expect(onSuccess).not.toHaveBeenCalled()
}
)
})
describe('mobile clipboard text paste', () => {
it('frames a multi-line paste in an alt-screen app the way desktop does', async () => {
clipboard.text = 'line 1\nline 2 \x1b[201~'
@@ -1,251 +0,0 @@
import { describe, expect, it, vi } from 'vitest'
import type { HostedReviewCreationBlockedReason } from '../../../src/shared/source-control/hosted-review/hosted-review-contracts'
import type { MobileHostedReviewEligibilityReply } from './hosted-review-reply-schema'
import {
buildMobileCreatePrAction,
type MobileCreatePrEligibilityState
} from './mobile-create-pr-action'
function eligibility(
overrides: Partial<MobileHostedReviewEligibilityReply> = {}
): MobileHostedReviewEligibilityReply {
return {
provider: 'github',
review: null,
canCreate: true,
blockedReason: null,
nextAction: null,
reviewLookupOutcome: 'not_found',
defaultBaseRef: 'main',
title: 'feature',
body: '',
...overrides
}
}
function action(
overrides: {
branch?: string | null
eligibility?: MobileHostedReviewEligibilityReply | null
busyAction?: string | null
} = {}
) {
const onCreatePr = vi.fn()
const descriptor = buildMobileCreatePrAction({
branch: Object.hasOwn(overrides, 'branch') ? overrides.branch : 'feature',
eligibilityState:
overrides.eligibility === null
? { kind: 'error' }
: { kind: 'ready', eligibility: overrides.eligibility ?? eligibility() },
busyAction: overrides.busyAction ?? null,
onCreatePr
})
return { descriptor, onCreatePr }
}
describe('buildMobileCreatePrAction', () => {
it('enables create when eligibility can create', () => {
const { descriptor, onCreatePr } = action()
expect(descriptor).toMatchObject({
visible: true,
label: 'Create Pull Request',
disabled: false,
pushFirst: false
})
descriptor.onPress()
expect(onCreatePr).toHaveBeenCalledWith(false)
})
it('lets a dirty branch in when Create PR can commit it first, like desktop', () => {
const descriptor = buildMobileCreatePrAction({
branch: 'feature',
eligibilityState: {
kind: 'ready',
eligibility: eligibility({ canCreate: false, blockedReason: 'dirty' })
},
busyAction: null,
prepareInputs: {
stagedCount: 1,
hasStageableChanges: false,
hasMessage: true,
hasUnresolvedConflicts: false,
upstreamStatus: { hasUpstream: true, ahead: 0, behind: 0 }
},
onCreatePr: vi.fn()
})
expect(descriptor.disabled).toBe(false)
})
it('uses merge-request copy for GitLab', () => {
const { descriptor } = action({
eligibility: eligibility({ provider: 'gitlab' })
})
expect(descriptor.label).toBe('Create Merge Request')
})
it('enables push-first creation when the branch needs push', () => {
const { descriptor, onCreatePr } = action({
eligibility: eligibility({
canCreate: false,
blockedReason: 'needs_push',
nextAction: 'push'
})
})
expect(descriptor).toMatchObject({ visible: true, disabled: false, pushFirst: true })
descriptor.onPress()
expect(onCreatePr).toHaveBeenCalledWith(true)
})
it.each<HostedReviewCreationBlockedReason>([
'dirty',
'no_upstream',
'needs_sync',
'default_branch',
'auth_required'
])('shows a disabled hinted action for locally actionable %s', (blockedReason) => {
const { descriptor } = action({
eligibility: eligibility({ canCreate: false, blockedReason })
})
expect(descriptor.visible).toBe(true)
expect(descriptor.disabled).toBe(true)
expect(descriptor.hint).toBeTruthy()
})
it('falls back to a disabled hinted action for other reachable blocked reasons', () => {
const { descriptor } = action({
eligibility: eligibility({ canCreate: false, blockedReason: 'fork_head_unsupported' })
})
expect(descriptor).toMatchObject({ visible: true, disabled: true })
expect(descriptor.hint).toContain('fork')
})
it.each<HostedReviewCreationBlockedReason>([
'existing_review',
'unsupported_provider',
'detached_head',
null
])('keeps a disabled status row for %s', (blockedReason) => {
const { descriptor } = action({
eligibility: eligibility({ canCreate: false, blockedReason })
})
expect(descriptor).toMatchObject({ visible: true, disabled: true })
expect(descriptor.hint).toBeTruthy()
})
it('keeps an unavailable row after errors but hides missing branches', () => {
expect(action({ eligibility: null }).descriptor).toMatchObject({
visible: true,
disabled: true,
label: 'Review status unavailable'
})
expect(action({ branch: null }).descriptor.visible).toBe(false)
})
it('shows an in-flight spinner and disables the current action', () => {
const { descriptor } = action({ busyAction: 'create-pr' })
expect(descriptor).toMatchObject({ visible: true, disabled: true, loading: true })
})
it('disables without a spinner during unrelated git work', () => {
const { descriptor, onCreatePr } = action({ busyAction: 'stage-all' })
expect(descriptor).toMatchObject({ visible: true, disabled: true, loading: false })
descriptor.onPress()
expect(onCreatePr).not.toHaveBeenCalled()
})
it('keeps a disabled status row for providers without review creation', () => {
const { descriptor } = action({
eligibility: eligibility({ provider: 'unsupported' })
})
expect(descriptor).toMatchObject({
visible: true,
disabled: true,
label: 'Review creation unavailable for this provider'
})
})
it('keeps a creatable button visible but disabled while a newer eligibility loads', () => {
const onCreatePr = vi.fn()
const descriptor = buildMobileCreatePrAction({
branch: 'feature',
eligibilityState: { kind: 'loading', eligibility: eligibility({ canCreate: true }) },
busyAction: null,
onCreatePr
})
expect(descriptor).toMatchObject({ visible: true, disabled: true, loading: false })
})
})
// Cold eligibility must reserve the Create PR row’s footprint so arriving data cannot shift a list being read.
describe('cold-mount layout stability (issue #8411)', () => {
const coldMount: MobileCreatePrEligibilityState[] = [
{ kind: 'loading', eligibility: null },
{ kind: 'ready', eligibility: eligibility({ canCreate: true }) }
]
it('reserves the button row while the first eligibility request is in flight', () => {
const descriptors = coldMount.map((eligibilityState) =>
buildMobileCreatePrAction({
branch: 'feature',
eligibilityState,
busyAction: null,
onCreatePr: vi.fn()
})
)
const footprint = descriptors.map(({ visible }) => visible)
expect(footprint).toEqual([true, true])
})
it.each([
[
'existing review',
{
kind: 'ready',
eligibility: eligibility({ canCreate: false, blockedReason: 'existing_review' })
} satisfies MobileCreatePrEligibilityState
],
[
'unsupported provider',
{
kind: 'ready',
eligibility: eligibility({ provider: 'unsupported' })
} satisfies MobileCreatePrEligibilityState
],
['eligibility error', { kind: 'error' } satisfies MobileCreatePrEligibilityState]
])('keeps the cold-load footprint when %s resolves', (_label, resolvedState) => {
const footprint = [coldMount[0], resolvedState].map((eligibilityState) => {
const descriptor = buildMobileCreatePrAction({
branch: 'feature',
eligibilityState,
busyAction: null,
onCreatePr: vi.fn()
})
return descriptor.visible
})
expect(footprint).toEqual([true, true])
})
it('does not offer a tappable action against unresolved eligibility', () => {
const descriptor = buildMobileCreatePrAction({
branch: 'feature',
eligibilityState: { kind: 'loading', eligibility: null },
busyAction: null,
onCreatePr: vi.fn()
})
expect(descriptor.disabled).toBe(true)
})
})
@@ -1,221 +0,0 @@
import { describe, expect, it, vi } from 'vitest'
import {
buildMobileBranchRemoteAction,
buildMobileSourceControlPrimaryAction,
type MobileSourceControlPrimaryActionArgs,
type MobileSourceControlPrimaryActionHandlers
} from './mobile-source-control-primary-action'
import type { MobileGitStatusResult } from './mobile-git-status'
function handlers(): MobileSourceControlPrimaryActionHandlers {
return {
commit: vi.fn(async () => true),
stageAll: vi.fn(async () => undefined),
runActionSheetGitSequence: vi.fn(async () => undefined),
runActionSheetGitSync: vi.fn(async () => undefined)
}
}
function status(overrides: Partial<MobileGitStatusResult> = {}): MobileGitStatusResult {
return {
entries: [],
conflictOperation: 'unknown',
branch: 'feature',
head: 'abc123',
upstreamStatus: { hasUpstream: true, ahead: 0, behind: 0 },
...overrides
}
}
function args(overrides: Partial<MobileSourceControlPrimaryActionArgs> = {}) {
return {
status: status(),
hasUnresolvedConflicts: false,
stageablePaths: [],
stagedCount: 0,
unstagedCount: 0,
commitMessage: '',
busyAction: null,
branchCompareResult: null,
handlers: handlers(),
...overrides
}
}
describe('buildMobileSourceControlPrimaryAction', () => {
it('selects Stage all for unstaged work and dispatches the stage runner', () => {
const h = handlers()
const action = buildMobileSourceControlPrimaryAction(
args({
stageablePaths: ['a.ts'],
unstagedCount: 1,
handlers: h
})
)
expect(action.label).toBe('Stage all')
expect(action.disabled).toBe(false)
action.onPress()
expect(h.stageAll).toHaveBeenCalledTimes(1)
})
it('selects Commit for staged work with a message and dispatches commit', () => {
const h = handlers()
const action = buildMobileSourceControlPrimaryAction(
args({
stagedCount: 1,
commitMessage: 'Ship it',
handlers: h
})
)
expect(action.label).toBe('Commit 1 file')
expect(action.disabled).toBe(false)
action.onPress()
expect(h.commit).toHaveBeenCalledTimes(1)
})
it('selects Publish Branch only when a current branch exists', () => {
expect(
buildMobileSourceControlPrimaryAction(
args({ status: status({ upstreamStatus: { hasUpstream: false, ahead: 0, behind: 0 } }) })
).label
).toBe('Publish Branch')
const detached = buildMobileSourceControlPrimaryAction(
args({
status: status({
branch: undefined,
upstreamStatus: { hasUpstream: false, ahead: 0, behind: 0 }
})
})
)
expect(detached.label).toBe('Commit')
expect(detached.disabled).toBe(true)
})
it('does not republish a branch whose review is already merged, like desktop', () => {
const action = buildMobileSourceControlPrimaryAction(
args({
status: status({ upstreamStatus: { hasUpstream: false, ahead: 0, behind: 0 } }),
review: { prState: 'merged', isPRStateLoading: false, reviewNumber: 7, pushTarget: null }
})
)
expect(action.disabled).toBe(true)
expect(action.accessibilityHint).toBe('Nothing to commit. The review is already merged.')
})
it('dispatches force push with lease when the shared decision requires it', () => {
const h = handlers()
const action = buildMobileSourceControlPrimaryAction(
args({
status: status({
upstreamStatus: {
hasUpstream: true,
ahead: 10,
behind: 2,
behindCommitsArePatchEquivalent: true
}
}),
branchCompareResult: {
entries: [],
summary: {
status: 'ready',
errorMessage: undefined,
baseRef: 'main',
baseOid: 'base',
compareRef: 'HEAD',
changedFiles: 0,
commitsAhead: 3,
headOid: 'abc',
mergeBase: 'def'
}
},
handlers: h
})
)
expect(action.label).toBe('Force Push')
expect(action.requiresForceWithLease).toBe(true)
action.onPress()
expect(h.runActionSheetGitSequence).toHaveBeenCalledWith('force-push', [
{ method: 'git.push', params: { forceWithLease: true } }
])
})
it('leaves a force push to desktop when the review has its own push target', () => {
const h = handlers()
const action = buildMobileSourceControlPrimaryAction(
args({
status: status({
upstreamStatus: {
hasUpstream: true,
ahead: 2,
behind: 2,
behindCommitsArePatchEquivalent: true
}
}),
review: {
prState: 'open',
isPRStateLoading: false,
reviewNumber: 7,
pushTarget: { remoteName: 'fork', branchName: 'feature' }
},
handlers: h
})
)
expect(action.requiresForceWithLease).toBe(true)
expect(action.disabled).toBe(true)
action.onPress()
expect(h.runActionSheetGitSequence).not.toHaveBeenCalled()
})
it('disables the button for unresolved entries even during a conflict operation', () => {
const action = buildMobileSourceControlPrimaryAction(
args({
status: status({ conflictOperation: 'merge' }),
hasUnresolvedConflicts: true,
stagedCount: 1,
commitMessage: 'Resolve'
})
)
expect(action.label).toBe('Commit 1 file')
expect(action.disabled).toBe(true)
expect(action.accessibilityHint).toBe('Resolve conflicts before committing.')
})
it('does not block solely because a conflict operation exists without unresolved entries', () => {
const action = buildMobileSourceControlPrimaryAction(
args({
status: status({ conflictOperation: 'merge' }),
hasUnresolvedConflicts: false,
stagedCount: 1,
commitMessage: 'Resolve'
})
)
expect(action.disabled).toBe(false)
})
})
describe('buildMobileBranchRemoteAction', () => {
it('offers the branch remote step past local changes, forcing after a rebase', async () => {
const h = handlers()
const rebased = buildMobileBranchRemoteAction(
args({
status: status({
upstreamStatus: {
hasUpstream: true,
ahead: 3,
behind: 3,
behindCommitsArePatchEquivalent: true
}
}),
stagedCount: 1,
commitMessage: 'wip',
handlers: h
})
)
expect(rebased?.label).toBe('Force Push')
await rebased?.run()
expect(h.runActionSheetGitSequence).toHaveBeenCalledWith('force-push', [
{ method: 'git.push', params: { forceWithLease: true } }
])
expect(buildMobileBranchRemoteAction(args())).toBeNull()
})
})
@@ -1,199 +0,0 @@
import { describe, expect, it } from 'vitest'
import { collectSalvageDrops } from '../../../src/shared/zod-salvage'
import {
gitBranchCompareResultSchema,
gitCommitCompareResultSchema
} from './git-compare-reply-schema'
import { gitHistoryResultSchema } from './git-history-reply-schema'
import {
buildMobileSourceControlSections,
countStagedEntries,
countUnstagedEntries,
isMobileGitStageableEntry
} from './mobile-git-status'
import { gitStatusHostPayloadSchema, gitStatusProjectionSchema } from './git-status-reply-schema'
import { hostedReviewEligibilitySchema } from './hosted-review-reply-schema'
describe('source-control reply schemas', () => {
it('decodes a reply from a host that sends members this client does not declare', () => {
// Strict schemas would reject new host fields or silently drop dirty rows.
const fromNewerHost = {
entries: [
{ path: 'src/app.ts', status: 'modified', area: 'staged', submoduleRoot: 'vendor/x' }
],
conflictOperation: 'unknown',
branch: 'feature',
didHitLimit: false
}
const parsed = gitStatusHostPayloadSchema.safeParse(fromNewerHost)
expect(parsed.success).toBe(true)
expect(parsed.data?.entries).toHaveLength(1)
expect(parsed.data?.entries[0]?.submoduleRoot).toBe('vendor/x')
expect(parsed.data?.didHitLimit).toBe(false)
})
it('keeps a commit whose timestamp the host sent as null', () => {
// Number-or-absent would drop null timestamps.
const parsed = gitHistoryResultSchema.safeParse({
items: [{ id: 'a'.repeat(40), parentIds: [], subject: '', timestamp: null }]
})
expect(parsed.data?.items).toHaveLength(1)
})
it('passes an eligibility token through that the shared union does not list', () => {
const parsed = hostedReviewEligibilitySchema.safeParse({
provider: 'gitlab',
reviewLookupOutcome: 'none'
})
expect(parsed.data?.reviewLookupOutcome).toBe('none')
})
it('writes every projected member out, absent ones included', () => {
// Projection preserves keys as undefined; optional-key omission would change the established shape.
const projected = gitStatusProjectionSchema.parse({
entries: [{ path: 'src/app.ts', status: 'modified', area: 'staged' }]
})
expect(projected && Object.keys(projected).sort()).toEqual([
'branch',
'conflictOperation',
'entries',
'head',
'upstreamStatus'
])
expect(projected?.entries[0] && Object.keys(projected.entries[0]).sort()).toEqual([
'added',
'area',
'conflictKind',
'conflictStatus',
'conflictStatusSource',
'oldPath',
'path',
'removed',
'status',
'submodule',
'submoduleRoot'
])
})
it('reports a dropped entry instead of thinning the list in silence', () => {
const salvaged = collectSalvageDrops(() =>
gitStatusHostPayloadSchema.safeParse({
entries: [{ path: 'src/app.ts', status: 'modified', area: 'staged' }, { path: 42 }]
})
)
expect(salvaged.value.success).toBe(true)
expect(salvaged.droppedCount).toBe(1)
expect(salvaged.droppedPaths).toEqual(['1'])
})
})
// Unknown routing enums must degrade to values readers already handle, preserving renderable replies.
const STATUS_ENTRY = { path: 'src/app.ts', status: 'modified', area: 'staged' }
describe('an enum arm this build does not know', () => {
it('degrades a compare summary status rather than refusing the whole compare', () => {
const parsed = gitBranchCompareResultSchema.safeParse({
summary: { baseRef: 'origin/main', changedFiles: 1, status: 'shallow-base' }
})
expect(parsed.data?.summary.status).toBe('error')
const absent = gitBranchCompareResultSchema.safeParse({
summary: { baseRef: 'origin/main', changedFiles: 1 }
})
expect(absent.success).toBe(false)
const wrongType = gitBranchCompareResultSchema.safeParse({
summary: { baseRef: 'origin/main', changedFiles: 1, status: 7 }
})
expect(wrongType.success).toBe(false)
})
it('degrades a branch entry status rather than dropping the row', () => {
const parsed = gitBranchCompareResultSchema.safeParse({
summary: { baseRef: 'origin/main', changedFiles: 1, status: 'ready' },
entries: [{ path: 'src/app.ts', status: 'typechange' }]
})
expect(parsed.data?.entries?.[0]?.status).toBe('modified')
const absent = gitBranchCompareResultSchema.safeParse({
summary: { baseRef: 'origin/main', changedFiles: 1, status: 'ready' },
entries: [{ path: 'src/app.ts' }]
})
expect(absent.data?.entries).toHaveLength(0)
})
it('degrades an optional changed-file status rather than dropping the row', () => {
const parsed = gitCommitCompareResultSchema.safeParse({
entries: [{ path: 'src/app.ts', status: 'typechange' }]
})
expect(parsed.data?.entries[0]?.status).toBe('modified')
const absent = gitCommitCompareResultSchema.safeParse({ entries: [{ path: 'src/app.ts' }] })
expect(absent.data?.entries[0]?.status).toBeUndefined()
})
it('degrades a working-tree entry status rather than dropping the row', () => {
const parsed = gitStatusHostPayloadSchema.safeParse({
entries: [{ ...STATUS_ENTRY, status: 'typechange' }]
})
expect(parsed.data?.entries[0]?.status).toBe('modified')
const absent = gitStatusHostPayloadSchema.safeParse({
entries: [{ path: 'src/app.ts', area: 'staged' }]
})
expect(absent.data?.entries).toHaveLength(0)
})
it('degrades the same status inside the normalized projection', () => {
const projected = gitStatusProjectionSchema.parse({
entries: [{ ...STATUS_ENTRY, status: 'typechange' }]
})
expect(projected?.entries[0]?.status).toBe('modified')
})
it('keeps a row whose conflict tokens it does not know, reading them as absent', () => {
const parsed = gitStatusHostPayloadSchema.safeParse({
entries: [
{
...STATUS_ENTRY,
conflictKind: 'both_renamed',
conflictStatus: 'resolved_upstream',
conflictStatusSource: 'relay'
}
]
})
expect(parsed.data?.entries).toHaveLength(1)
expect(parsed.data?.entries[0]?.conflictKind).toBeUndefined()
expect(parsed.data?.entries[0]?.conflictStatus).toBeUndefined()
expect(parsed.data?.entries[0]?.conflictStatusSource).toBeUndefined()
})
it('passes a hosted-review provider through instead of degrading it', () => {
// Provider round-trips; enum fallback would rewrite the host’s token.
const parsed = hostedReviewEligibilitySchema.safeParse({ provider: 'codeberg' })
expect(parsed.data?.provider).toBe('codeberg')
expect(hostedReviewEligibilitySchema.safeParse({}).success).toBe(false)
expect(hostedReviewEligibilitySchema.safeParse({ provider: 7 }).success).toBe(false)
})
it('keeps a row whose staging area it does not know, in no section', () => {
// Unknown area withholds actions but keeps the row in conflict gating.
const parsed = gitStatusHostPayloadSchema.safeParse({
entries: [{ ...STATUS_ENTRY, area: 'stashed', conflictStatus: 'unresolved' }]
})
const entries = parsed.data?.entries ?? []
expect(entries).toHaveLength(1)
expect(entries[0]?.area).toBeUndefined()
expect(entries.some((entry) => entry.conflictStatus === 'unresolved')).toBe(true)
expect(buildMobileSourceControlSections(entries, 'changes-first')).toHaveLength(0)
expect(isMobileGitStageableEntry(entries[0] ?? STATUS_ENTRY)).toBe(false)
expect(countStagedEntries(entries)).toBe(0)
expect(countUnstagedEntries(entries)).toBe(0)
})
it('reads an explicit null entries list as an empty compare', () => {
// Explicit null entries must reach the consumer’s empty-list fallback.
const parsed = gitBranchCompareResultSchema.safeParse({
summary: { baseRef: 'origin/main', changedFiles: 0, status: 'ready' },
entries: null
})
expect(parsed.success).toBe(true)
expect(parsed.data?.entries ?? []).toEqual([])
})
})
@@ -1,239 +0,0 @@
import { describe, expect, it } from 'vitest'
import {
buildGitHubLinkedWorkItem,
buildGitLabLinkedWorkItem,
buildLinearLinkedWorkItem,
buildSmartNameSelection,
resolveComposerBranchPick,
resolveComposerCreateSelection,
resolveWorkItemAutoName,
shouldApplyAutoName
} from './composer-linked-work-item'
describe('linked work item builders', () => {
it('maps a GitHub PR into a linked work item', () => {
const linked = buildGitHubLinkedWorkItem({
type: 'pr',
number: 42,
title: 'Fix bug',
url: 'https://github.com/o/r/pull/42',
repoId: 'repo-1'
})
expect(linked).toMatchObject({ provider: 'github', type: 'pr', number: 42, repoId: 'repo-1' })
})
it('maps a GitLab MR into a linked work item', () => {
const linked = buildGitLabLinkedWorkItem({
type: 'mr',
number: 7,
title: 'Add feature',
url: 'https://gitlab.com/g/p/-/merge_requests/7',
repoId: 'repo-2'
})
expect(linked).toMatchObject({ provider: 'gitlab', type: 'mr', number: 7, repoId: 'repo-2' })
})
it('maps a Linear issue with identifier, workspace, and org key', () => {
const linked = buildLinearLinkedWorkItem({
identifier: 'ENG-9',
title: 'Ship it',
url: 'https://linear.app/acme/issue/ENG-9',
workspaceId: 'ws-1'
})
expect(linked).toMatchObject({
provider: 'linear',
type: 'issue',
number: 0,
linearIdentifier: 'ENG-9',
linearWorkspaceId: 'ws-1',
linearOrganizationUrlKey: 'acme'
})
})
})
describe('shouldApplyAutoName', () => {
it('applies when the name is empty or the previous auto-name', () => {
expect(shouldApplyAutoName({ currentName: '', lastAutoName: '' })).toBe(true)
expect(shouldApplyAutoName({ currentName: 'fix-bug', lastAutoName: 'fix-bug' })).toBe(true)
})
it('applies when the name is a lookup query (URL / #N)', () => {
expect(shouldApplyAutoName({ currentName: '#42', lastAutoName: 'x' })).toBe(true)
expect(shouldApplyAutoName({ currentName: 'ENG-9', lastAutoName: 'x' })).toBe(false)
})
it('keeps a deliberately typed name', () => {
expect(shouldApplyAutoName({ currentName: 'my custom name', lastAutoName: 'other' })).toBe(
false
)
})
})
describe('resolveWorkItemAutoName', () => {
it('slugifies the title subject', () => {
expect(
resolveWorkItemAutoName({
type: 'issue',
number: 3,
title: 'Fix the Login Bug',
provider: 'github'
})
).toBe('fix-the-login-bug')
})
})
describe('buildSmartNameSelection', () => {
const base = (over: Record<string, unknown>) => ({
provider: 'github' as const,
type: 'pr' as const,
number: 12,
title: 'T',
url: 'u',
...over
})
it('maps GitHub PR / issue kinds and numbers the label', () => {
expect(buildSmartNameSelection({ linkedWorkItem: base({}), baseBranch: undefined })).toEqual({
kind: 'github-pr',
label: '#12 T',
url: 'u'
})
expect(
buildSmartNameSelection({ linkedWorkItem: base({ type: 'issue' }), baseBranch: undefined })
).toMatchObject({ kind: 'github-issue' })
})
it('maps GitLab MR / issue kinds', () => {
expect(
buildSmartNameSelection({
linkedWorkItem: base({ provider: 'gitlab', type: 'mr' }),
baseBranch: undefined
})
).toMatchObject({ kind: 'gitlab-mr' })
expect(
buildSmartNameSelection({
linkedWorkItem: base({ provider: 'gitlab', type: 'issue' }),
baseBranch: undefined
})
).toMatchObject({ kind: 'gitlab-issue' })
})
it('maps Linear with a bare title label', () => {
expect(
buildSmartNameSelection({
linkedWorkItem: base({ provider: 'linear', type: 'issue', number: 0, title: 'ENG-9 Ship' }),
baseBranch: undefined
})
).toEqual({ kind: 'linear', label: 'ENG-9 Ship', url: 'u' })
})
it('falls back to a branch pill', () => {
expect(buildSmartNameSelection({ linkedWorkItem: null, baseBranch: 'main' })).toEqual({
kind: 'branch',
label: 'main'
})
})
it('returns null when nothing is selected', () => {
expect(buildSmartNameSelection({ linkedWorkItem: null, baseBranch: undefined })).toBeNull()
})
})
describe('resolveComposerCreateSelection', () => {
const baseCreateArgs = {
branch: null,
reuseEligibleBranch: null,
reuseSelectedBranch: false,
branchCreateIntent: false,
name: ''
}
it('prefers a linked work item and passes resolved base fields', () => {
const selection = resolveComposerCreateSelection({
...baseCreateArgs,
linkedWorkItem: {
provider: 'github',
type: 'pr',
number: 5,
title: 'T',
url: 'u',
repoId: 'repo-1'
},
base: { baseBranch: 'main', compareBaseRef: 'origin/main', branchNameOverride: 'pr-5' }
})
expect(selection).toMatchObject({
kind: 'work-item',
baseBranch: 'main',
compareBaseRef: 'origin/main',
branchNameOverride: 'pr-5'
})
})
it('marks reuse when the eligible branch is toggled on', () => {
const selection = resolveComposerCreateSelection({
...baseCreateArgs,
linkedWorkItem: null,
base: { baseBranch: 'feature', branchNameOverride: 'feature' },
branch: { refName: 'feature', localBranchName: 'feature' },
reuseEligibleBranch: 'feature',
reuseSelectedBranch: true
})
expect(selection).toEqual({
kind: 'branch',
baseBranch: 'feature',
refName: 'feature',
localBranchName: 'feature',
reuse: true,
branchNameOverride: 'feature'
})
})
it('returns a new-branch selection when create-branch intent is set', () => {
expect(
resolveComposerCreateSelection({
...baseCreateArgs,
linkedWorkItem: null,
base: {},
branchCreateIntent: true,
name: 'feature/login'
})
).toEqual({ kind: 'new-branch', branchName: 'feature/login' })
})
it('returns null with no work item, no branch base, and no intent', () => {
expect(
resolveComposerCreateSelection({ ...baseCreateArgs, linkedWorkItem: null, base: {} })
).toBeNull()
})
})
describe('resolveComposerBranchPick', () => {
it('auto-names and enables reuse for an unused local branch', () => {
const pick = resolveComposerBranchPick({
refName: 'feature',
localBranchName: 'feature',
currentName: '',
lastAutoName: '',
worktreeBranches: []
})
expect(pick.base).toEqual({ baseBranch: 'feature', branchNameOverride: 'feature' })
expect(pick).toMatchObject({
reuseEligibleBranch: 'feature',
reuseSelectedBranch: true,
name: 'feature'
})
})
it('does not reuse a branch already checked out elsewhere', () => {
const pick = resolveComposerBranchPick({
refName: 'feature',
localBranchName: 'feature',
currentName: '',
lastAutoName: '',
worktreeBranches: ['refs/heads/feature']
})
expect(pick.reuseEligibleBranch).toBe('feature')
expect(pick.reuseSelectedBranch).toBe(true)
expect(pick.base.branchNameOverride).toBeUndefined()
})
})
@@ -1,199 +0,0 @@
import { describe, expect, it } from 'vitest'
import {
taskGitHubWorkItemListSchema,
taskGitLabWorkItemListSchema,
taskLinearIssueListSchema,
taskWorkItemLookupSchema
} from './task-source-search-reply-schema'
const githubRow = {
id: 'issue:1',
type: 'issue',
number: 1,
title: 'one',
state: 'open',
url: '',
labels: [],
updatedAt: '2020-01-01T00:00:00.000Z',
author: null
}
const linearRow = {
id: 'issue-1',
identifier: 'ENG-1',
title: 'A Linear issue',
url: '',
state: { name: 'Todo', type: 'unstarted', color: '#000' },
team: { id: 'team-1', key: 'ENG', name: 'Engineering' },
labels: [],
priority: 0,
updatedAt: '2020-01-01T00:00:00.000Z',
workspaceId: 'linear-workspace'
}
describe('the provider lists require items, and a row requires its labels', () => {
it('reads the representative GitHub search row whole', () => {
const parsed = taskGitHubWorkItemListSchema.safeParse({ items: [githubRow] })
expect(parsed.success && parsed.data).toMatchObject({ items: [githubRow] })
})
it('reads the representative GitLab row whole', () => {
const row = { ...githubRow, id: 'issue:2', number: 2, title: 'two', state: 'opened' }
const result = { items: [row], page: 1, perPage: 1, totalCount: 0, totalPages: 2 }
const parsed = taskGitLabWorkItemListSchema.safeParse(result)
expect(parsed.success && parsed.data).toEqual(result)
})
it('names a list with no items, which the task screen mapped unguarded', () => {
expect(taskGitHubWorkItemListSchema.safeParse({}).success).toBe(false)
expect(taskGitLabWorkItemListSchema.safeParse({ error: { type: 'quota' } }).success).toBe(false)
})
it('drops a row with no labels, which both label editors filter unguarded', () => {
const { labels: _labels, ...noLabels } = githubRow
const parsed = taskGitHubWorkItemListSchema.safeParse({ items: [githubRow, noLabels] })
expect(parsed.success && parsed.data).toMatchObject({ items: [githubRow] })
})
it('drops a row whose labels are not an array, rather than handing filter a string', () => {
const parsed = taskGitHubWorkItemListSchema.safeParse({
items: [{ ...githubRow, labels: 'bug' }]
})
expect(parsed.success && parsed.data).toMatchObject({ items: [] })
})
it('drops a non-string label and keeps the row, because every reader compares it as text', () => {
const parsed = taskGitHubWorkItemListSchema.safeParse({
items: [{ ...githubRow, labels: ['bug', 7] }]
})
expect(parsed.success && parsed.data).toMatchObject({ items: [{ labels: ['bug'] }] })
})
it('keeps items alongside an in-band provider error, as the representative reply does', () => {
const parsed = taskGitLabWorkItemListSchema.safeParse({
items: [githubRow],
error: { type: 'not_found', message: 'missing' }
})
expect(parsed.success && parsed.data).toMatchObject({
error: { type: 'not_found', message: 'missing' }
})
})
it('passes the source banner members through without typing them', () => {
const parsed = taskGitHubWorkItemListSchema.safeParse({
items: [],
sources: { issues: 'upstream' },
issueSourceFellBack: true
})
expect(parsed.success && parsed.data).toMatchObject({
sources: { issues: 'upstream' },
issueSourceFellBack: true
})
})
it('drops a row that is not an object rather than failing the page', () => {
const parsed = taskGitHubWorkItemListSchema.safeParse({ items: [githubRow, 'nope'] })
expect(parsed.success && parsed.data).toMatchObject({ items: [githubRow] })
})
})
describe('a work-item row preserves author as a tri-state', () => {
it('keeps an explicit null, which the row renders as "no author"', () => {
const parsed = taskGitHubWorkItemListSchema.safeParse({ items: [githubRow] })
expect(JSON.stringify(parsed)).toContain('"author":null')
})
it('keeps absence absent rather than collapsing it to null', () => {
const { author: _author, ...noAuthor } = githubRow
const parsed = taskGitHubWorkItemListSchema.safeParse({ items: [noAuthor] })
const row = parsed.success ? parsed.data.items[0] : undefined
expect(row && 'author' in row).toBe(false)
})
})
describe('the Linear issue list takes both shapes the picker has always accepted', () => {
it('reads a bare array, which linear.searchIssues answers', () => {
const parsed = taskLinearIssueListSchema.safeParse([linearRow])
expect(parsed.success && parsed.data).toEqual([linearRow])
})
it('reads an items envelope, which linear.listIssues answers', () => {
const parsed = taskLinearIssueListSchema.safeParse({ items: [linearRow] })
expect(parsed.success && parsed.data).toEqual([linearRow])
})
it('drops an issue with no id, which nothing could key a row on', () => {
const { id: _id, ...noId } = linearRow
expect(taskLinearIssueListSchema.safeParse([noId])).toMatchObject({ success: true, data: [] })
})
// Missing unguarded mapping/sort inputs must drop a row instead of crashing or producing NaN.
it('drops an issue with no state, which createLinearTask reads as state.name', () => {
const { state: _state, ...noState } = linearRow
expect(taskLinearIssueListSchema.safeParse([noState])).toMatchObject({ data: [] })
})
it('drops an issue with no team, which createLinearTask reads as team.name', () => {
const { team: _team, ...noTeam } = linearRow
expect(taskLinearIssueListSchema.safeParse([noTeam])).toMatchObject({ data: [] })
})
it('drops an issue whose state carries no name, not just one with no state at all', () => {
const parsed = taskLinearIssueListSchema.safeParse([
{ ...linearRow, state: { type: 'unstarted', color: '#000' } }
])
expect(parsed).toMatchObject({ data: [] })
})
it('drops an issue whose team carries no name', () => {
const parsed = taskLinearIssueListSchema.safeParse([
{ ...linearRow, team: { id: 'team-1', key: 'ENG' } }
])
expect(parsed).toMatchObject({ data: [] })
})
it('drops an issue with no priority, which would make the reviewer comparator NaN', () => {
const { priority: _priority, ...noPriority } = linearRow
expect(taskLinearIssueListSchema.safeParse([noPriority])).toMatchObject({ data: [] })
})
it.each(['identifier', 'title', 'url', 'updatedAt', 'labels'] as const)(
'drops an issue with no %s, which the host type declares non-optional',
(member) => {
const { [member]: _dropped, ...without } = linearRow
expect(taskLinearIssueListSchema.safeParse([without])).toMatchObject({ data: [] })
}
)
it('keeps the rest of the page when one row drops', () => {
const { state: _state, ...noState } = linearRow
const parsed = taskLinearIssueListSchema.safeParse([linearRow, noState])
expect(parsed.success && parsed.data).toEqual([linearRow])
})
it('names a payload that is neither shape, where main threw its own copy', () => {
expect(taskLinearIssueListSchema.safeParse({ issues: [] }).success).toBe(false)
expect(taskLinearIssueListSchema.safeParse('nope').success).toBe(false)
expect(taskLinearIssueListSchema.safeParse(null).success).toBe(false)
})
})
describe('a single work-item lookup', () => {
it('keeps the host null for an item that does not resolve', () => {
expect(taskWorkItemLookupSchema.safeParse(null)).toMatchObject({ success: true, data: null })
})
it('reads the corrected lookup row whole', () => {
expect(taskWorkItemLookupSchema.safeParse(githubRow)).toMatchObject({ success: true })
})
// A malformed single lookup must fail the reply; silently returning it would crash the label editor.
it('names a lookup row with no labels rather than resolving the paste', () => {
const { labels: _labels, ...noLabels } = githubRow
expect(taskWorkItemLookupSchema.safeParse(noLabels).success).toBe(false)
})
it('names a payload that is not an item at all', () => {
expect(taskWorkItemLookupSchema.safeParse('twelve').success).toBe(false)
})
})
@@ -1,192 +0,0 @@
import { createElement, useState } from 'react'
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
import { afterEach, describe, expect, it, vi } from 'vitest'
vi.mock('./mobile-tasks-dependencies', async () => {
const react = await import('react')
return {
useCallback: react.useCallback,
useMemo: react.useMemo,
useState: react.useState,
...(await import('../../../src/shared/work-items'))
}
})
vi.mock('./mobile-tasks-legacy-foundation', () => ({
buildPartialRepositoryNotice: () => 'partial'
}))
vi.mock('./mobile-task-list-operations', () => ({ linearAccountConnect: {} }))
import { useMobileTasksTaskPaginationActions } from './use-mobile-tasks-task-pagination-actions'
import type { TaskListLoadingModel } from './use-mobile-tasks-task-list-loading'
const client = { id: 'client' }
type PageRow = { key: string; updatedAt?: string; source?: { repoId: string } }
const firstPage: PageRow[] = [{ key: 'github:repo-1:issue:40', updatedAt: '2026-10-02T00:00:00Z' }]
const oldSelectionPage = [{ key: 'github:repo-1:issue:4', updatedAt: '2026-10-01T00:00:00Z' }]
function buildModel(
fetchGitHubItemsPage: () => Promise<unknown>,
githubCountedTotalPages: number | null = 2
) {
return {
client,
clientRef: { current: client },
connState: 'connected',
fetchGitHubItemsPage: vi.fn(fetchGitHubItemsPage),
githubCurrentPage: 0,
githubPages: [firstPage],
githubPaginationLoading: false,
githubCountedTotalPages,
loadGenerationRef: { current: 1 },
selectedHostedRepos: [{ id: 'repo-1' }],
tasksSupported: true,
taskUiReady: true,
setError: vi.fn(),
setGithubCurrentPage: vi.fn(),
setGithubCountedTotalPages: vi.fn(),
setGithubLoadingTargetPage: vi.fn(),
setGithubPages: vi.fn(),
setGithubPaginationLoading: vi.fn(),
setItems: vi.fn()
}
}
let renderer: ReactTestRenderer | null = null
let changePage: ((page: number) => Promise<void>) | null = null
let canLoadUncountedNextPage = false
let totalPages = 0
function mount(model: ReturnType<typeof buildModel>): void {
function Harness(): null {
const [countedTotalPages, setCountedTotalPages] = useState(model.githubCountedTotalPages)
model.setGithubCountedTotalPages.mockImplementation(setCountedTotalPages)
const actions = useMobileTasksTaskPaginationActions(
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the fixture carries every member the page handler reads.
{ ...model, githubCountedTotalPages: countedTotalPages } as unknown as TaskListLoadingModel
)
changePage = actions.handleGitHubPageChange
canLoadUncountedNextPage = actions.githubCanLoadUncountedNextPage
totalPages = actions.githubTotalPages
return null
}
act(() => {
renderer = create(createElement(Harness))
})
}
afterEach(() => {
act(() => renderer?.unmount())
renderer = null
changePage = null
})
describe('GitHub task page change', () => {
it('asks for the target page by number and shows it', async () => {
const model = buildModel(async () => ({
items: oldSelectionPage,
failedCount: 0,
errorTypes: []
}))
mount(model)
await act(async () => {
await changePage?.(1)
})
expect(model.fetchGitHubItemsPage).toHaveBeenCalledWith(
client,
model.selectedHostedRepos,
1,
expect.any(Function)
)
expect(model.setGithubPages).toHaveBeenCalledWith([firstPage, oldSelectionPage])
expect(model.setItems).toHaveBeenCalledWith(oldSelectionPage)
})
it('drops a page that lands after the list reloaded for a new selection', async () => {
let land: (page: unknown) => void = () => {}
const model = buildModel(
() =>
new Promise((resolve) => {
land = resolve
})
)
mount(model)
let pending: Promise<void> | undefined
act(() => {
pending = changePage?.(1)
})
model.loadGenerationRef.current += 1
await act(async () => {
land({ items: oldSelectionPage, failedCount: 0, errorTypes: [] })
await pending
})
expect(model.setGithubPages).not.toHaveBeenCalled()
expect(model.setItems).not.toHaveBeenCalled()
expect(model.setGithubPaginationLoading).toHaveBeenLastCalledWith(false)
})
it('explains a clean empty next page and withdraws speculative Next', async () => {
const model = buildModel(async () => ({ items: [], failedCount: 0, errorTypes: [] }), null)
model.githubPages = [
Array.from({ length: 72 }, (_, index) => ({
key: `github:repo-1:issue:${index}`,
source: { repoId: 'repo-1' }
}))
]
mount(model)
expect(canLoadUncountedNextPage).toBe(true)
await act(async () => {
await changePage?.(1)
})
expect(model.setError).toHaveBeenCalledWith('No more results on page 2.')
expect(canLoadUncountedNextPage).toBe(false)
expect(model.setItems).not.toHaveBeenCalled()
})
it('explains an all-repo failure and keeps the page retryable', async () => {
const model = buildModel(async () => ({ items: [], failedCount: 1, errorTypes: [] }))
mount(model)
await act(async () => {
await changePage?.(1)
})
expect(model.setError).toHaveBeenCalledWith('Page 2 could not be loaded from GitHub.')
expect(model.setGithubCountedTotalPages).not.toHaveBeenCalled()
expect(model.setItems).not.toHaveBeenCalled()
})
it('keeps a real count when an empty page is returned', async () => {
const model = buildModel(async () => ({ items: [], failedCount: 0, errorTypes: [] }), 3)
mount(model)
await act(async () => {
await changePage?.(1)
})
expect(model.setError).toHaveBeenCalledWith('No more results on page 2.')
expect(totalPages).toBe(3)
expect(model.setItems).not.toHaveBeenCalled()
})
describe('without a count, as in the default all-repos view', () => {
function rows(repoId: string, count: number): PageRow[] {
return Array.from({ length: count }, (_, index) => ({
key: `github:${repoId}:issue:${index}`,
source: { repoId }
}))
}
function lastPageOffersNext(page: PageRow[]): boolean {
const model = buildModel(async () => ({ items: [], failedCount: 0, errorTypes: [] }), null)
model.githubPages = [page]
model.selectedHostedRepos = [{ id: 'repo-a' }, { id: 'repo-b' }]
mount(model)
return canLoadUncountedNextPage
}
it('offers the next page when one repo filled its share and another did not', () => {
expect(lastPageOffersNext([...rows('repo-a', 36), ...rows('repo-b', 5)])).toBe(true)
})
it('offers no next page when no repo filled its share', () => {
expect(lastPageOffersNext([...rows('repo-a', 20), ...rows('repo-b', 5)])).toBe(false)
})
})
})
@@ -4,9 +4,6 @@ import { Terminal } from '@xterm/xterm'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { createTerminalDocumentScope, type TerminalDocumentScope } from './document-scope'
import { seedWordSelection } from './selection-range'
import { armLongPress, dispatcherShouldBlockSurface } from './tap-dispatch'
import { handleDragMove, stopEdgeScroll } from './selection-overlay'
import { terminalDocumentDouble } from './document-terminal-double.test-support'
const terminals: Terminal[] = []
@@ -79,16 +76,6 @@ describe('word selection uses the rendered xterm cell range', () => {
expect(f.selected()).toBe('🙂')
})
it('keeps ASCII behavior for a terminal seam without cell readers', async () => {
const f = await fixture('hello world')
const line = f.term.buffer.active.getLine(0)!
const getLine = vi.spyOn(f.term.buffer.active, 'getLine').mockReturnValue(line)
vi.spyOn(line, 'getCell').mockReturnValue(undefined)
seedWordSelection(f.scope, 8, 0)
expect(f.select).toHaveBeenLastCalledWith(6, 0, 5)
expect(getLine).toHaveBeenCalledWith(0)
})
it('keeps an absolute scrollback row and tolerates an evicted line', async () => {
const f = await fixture('한글\r\nsecond\r\nthird\r\nfourth')
expect(f.term.buffer.active.viewportY).toBeGreaterThan(0)
@@ -133,45 +120,3 @@ describe('Unicode selection boundaries across every rendered column', () => {
expect(f.selected()).toBe('next')
})
})
it('keeps the Unicode seed while the long-press finger owns a forward or backward drag', async () => {
const f = await fixture('가 한글 next')
const surface = document.createElement('div')
f.scope.term = {
...terminalDocumentDouble().terminal,
cols: f.term.cols,
rows: f.term.rows,
buffer: f.scope.term!.buffer,
select: f.select,
_core: { _renderService: { dimensions: { css: { cell: { width: 8, height: 15 } } } } }
}
Object.assign(f.scope, {
surface,
selectionOverlay: document.createElement('div'),
handleStart: document.createElement('div'),
handleEnd: document.createElement('div'),
selMenu: document.createElement('div'),
viewportRect: () => ({ left: 0, top: 50, width: 480, height: 200 }),
currentScale: 1,
userScale: 1
})
f.scope.touchDispatch.mode = 'surface'
f.scope.touchDispatch.touchId = 7
f.scope.tapCandidate = { x: 36, y: 57, t: Date.now(), identifier: 7 }
vi.useFakeTimers()
armLongPress(f.scope, new Touch({ identifier: 7, target: surface, clientX: 36, clientY: 57 }))
vi.advanceTimersByTime(500)
expect(f.selected()).toBe('한글')
expect(f.scope.sel?.anchor).toEqual({ col: 3, row: 0 })
expect(f.scope.sel?.focus).toEqual({ col: 6, row: 0 })
expect(f.scope.touchDispatch).toMatchObject({ mode: 'select-drag', touchId: 7 })
expect(dispatcherShouldBlockSurface(f.scope)).toBe(true)
expect(f.scope.tapCandidate).toBeNull()
handleDragMove(f.scope, 'end', 11 * 8 + 4, 57)
expect(f.selected()).toBe('한글 next')
handleDragMove(f.scope, 'end', 4, 57)
expect(f.select).toHaveBeenLastCalledWith(0, 0, 4)
expect(f.selected()).toBe('가 한')
stopEdgeScroll(f.scope)
})
@@ -1,24 +1,12 @@
import { describe, expect, it, vi } from 'vitest'
import {
isTerminalLiveInputWithinByteLimit,
TERMINAL_LIVE_INPUT_MAX_BYTES
} from './terminal-live-input'
import { TERMINAL_LIVE_INPUT_MAX_BYTES } from './terminal-live-input'
import {
cancelTerminalLivePendingFlush,
createTerminalLivePendingFlushState,
queueTerminalLiveControlSend,
queueTerminalLiveMirrorSend,
waitForTerminalLivePendingFlush
} from './terminal-live-pending-flush-state'
describe('terminal live pending flush state', () => {
it('Given no in-flight flush When waiting for the barrier Then allows control input', async () => {
const state = createTerminalLivePendingFlushState()
await expect(waitForTerminalLivePendingFlush(state)).resolves.toBe(true)
})
})
describe('terminal live mirror send queue', () => {
it.each(['throw', 'reject', 'refuse', 'too-large'] as const)(
'retires dependent input after a send fails by %s',
@@ -63,100 +51,6 @@ describe('terminal live mirror send queue', () => {
}
)
it.each(['a', '😀'])(
'keeps coalesced %s input within the live sender byte limit',
async (glyph) => {
const state = createTerminalLivePendingFlushState()
const payloads: string[] = []
const received: string[] = []
let release = (_accepted: boolean): void => {}
const sender = async (_handle: string, payload: string): Promise<boolean> => {
payloads.push(payload)
if (payloads.length === 1) {
return new Promise<boolean>((resolve) => {
release = resolve
})
}
const accepted = isTerminalLiveInputWithinByteLimit(payload)
if (accepted) {
received.push(payload)
}
return accepted
}
const part = glyph.repeat(
TERMINAL_LIVE_INPUT_MAX_BYTES / 2 / new TextEncoder().encode(glyph).length
)
const first = queueTerminalLiveMirrorSend(state, 'terminal-1', 'held', sender)
const queued = Array.from({ length: 3 }, () =>
queueTerminalLiveMirrorSend(state, 'terminal-1', part, sender)
)
release(true)
await expect(Promise.all([first, ...queued])).resolves.toEqual([true, true, true, true])
expect(received.join('')).toBe(part.repeat(3))
expect(payloads).toHaveLength(3)
expect(payloads.every((payload) => isTerminalLiveInputWithinByteLimit(payload))).toBe(true)
}
)
it('Given high RTT When more input queues Then pending bytes share one follow-up send', async () => {
const state = createTerminalLivePendingFlushState()
const payloads: string[] = []
let resolveFirstSend: (value: boolean) => void = () => {}
const sender = async (_handle: string, payload: string): Promise<boolean> => {
payloads.push(payload)
if (payloads.length === 1) {
return new Promise<boolean>((resolve) => {
resolveFirstSend = resolve
})
}
return true
}
const first = queueTerminalLiveMirrorSend(state, 'terminal-1', 'a', sender)
const second = queueTerminalLiveMirrorSend(state, 'terminal-1', 'b', sender)
const third = queueTerminalLiveMirrorSend(state, 'terminal-1', 'c', sender)
await Promise.resolve()
expect(payloads).toEqual(['a'])
resolveFirstSend(true)
await expect(Promise.all([first, second, third])).resolves.toEqual([true, true, true])
expect(payloads).toEqual(['a', 'bc'])
})
it('batches repeated controls behind an in-flight send, apart from the text before them', async () => {
const state = createTerminalLivePendingFlushState()
const writes: string[] = []
let release = (): void => {}
const sender = (_handle: string, payload: string): Promise<boolean> => {
writes.push(payload)
return writes.length === 1
? new Promise<boolean>((resolve) => {
release = () => resolve(true)
})
: Promise.resolve(true)
}
const text = queueTerminalLiveMirrorSend(state, 'terminal-1', 'ls', sender)
void queueTerminalLiveMirrorSend(state, 'terminal-1', ' -la', sender)
const controls = ['\x1b[A', '\x1b[A', '\r'].map((bytes) =>
queueTerminalLiveControlSend(state, 'terminal-1', bytes, sender)
)
release()
await expect(text).resolves.toBe(true)
await expect(Promise.all(controls)).resolves.toEqual([true, true, true])
expect(writes).toEqual(['ls', ' -la', '\x1b[A\x1b[A\r'])
})
it('Given a settled mirror send When it was the newest Then the state resets to null', async () => {
const state = createTerminalLivePendingFlushState()
await queueTerminalLiveMirrorSend(state, 'terminal-1', 'payload', async () => true)
await Promise.resolve()
expect(state.current).toBeNull()
})
it('cancels flush waiters before the retired transport acknowledges', async () => {
const state = createTerminalLivePendingFlushState()
let releaseRetired = (_sent: boolean): void => {}
@@ -201,21 +95,4 @@ describe('terminal live mirror send queue', () => {
await control
}
})
it('Given queued input When the queue is cancelled Then unsent input is dropped', async () => {
const state = createTerminalLivePendingFlushState()
let resolveSend: (value: boolean) => void = () => {}
const sender = async (): Promise<boolean> =>
new Promise((resolve) => {
resolveSend = resolve
})
const active = queueTerminalLiveMirrorSend(state, 'terminal-1', 'a', sender)
const pending = queueTerminalLiveMirrorSend(state, 'terminal-1', 'b', sender)
cancelTerminalLivePendingFlush(state)
await expect(Promise.all([active, pending])).resolves.toEqual([false, false])
expect(state.current).toBeNull()
resolveSend(true)
})
})
@@ -2,7 +2,6 @@
import { describe, expect, it, vi } from 'vitest'
import type { TextInput } from 'react-native'
import { hostOs } from '../platform/host-os'
import { bindTerminalTextFieldSubmit } from './terminal-text-field-submit-binding'
import { bindTerminalTextFieldSubmit as bindOnWeb } from './terminal-text-field-submit-binding.web'
vi.mock('../platform/host-os', () => ({ hostOs: vi.fn(() => 'android') }))
@@ -23,19 +22,6 @@ function sendBeforeInput(field: HTMLElement, inputType: string): InputEvent {
return event
}
describe('the native submit binding', () => {
it('binds nothing, because the editor action already fires onSubmitEditing', () => {
const onSubmit = vi.fn()
const field = mountField()
const unbind = bindTerminalTextFieldSubmit(asField(field), onSubmit)
sendBeforeInput(field, 'insertLineBreak')
expect(onSubmit).not.toHaveBeenCalled()
expect(() => unbind()).not.toThrow()
})
})
describe('the web sibling', () => {
it('submits on the line break the browser reports, and cancels it', () => {
const onSubmit = vi.fn()
@@ -48,17 +34,6 @@ describe('the web sibling', () => {
expect(event.defaultPrevented).toBe(true)
})
it('submits a multiline field on its paragraph break', () => {
const onSubmit = vi.fn()
const field = document.createElement('textarea')
document.body.append(field)
bindOnWeb(asField(field), onSubmit)
sendBeforeInput(field, 'insertParagraph')
expect(onSubmit).toHaveBeenCalledTimes(1)
})
// IME candidate acceptance emits insertCompositionText, so it must not submit terminal input.
it('leaves a composition commit alone', () => {
const onSubmit = vi.fn()
@@ -71,16 +46,6 @@ describe('the web sibling', () => {
expect(event.defaultPrevented).toBe(false)
})
it('ignores ordinary typing', () => {
const onSubmit = vi.fn()
const field = mountField()
bindOnWeb(asField(field), onSubmit)
sendBeforeInput(field, 'insertText')
expect(onSubmit).not.toHaveBeenCalled()
})
it('stops listening once the field is unbound', () => {
const onSubmit = vi.fn()
const field = mountField()
@@ -90,21 +55,6 @@ describe('the web sibling', () => {
expect(onSubmit).not.toHaveBeenCalled()
})
it('tolerates a field that never mounted', () => {
expect(() => bindOnWeb(asField(null), vi.fn())()).not.toThrow()
})
it('leaves a node that is not a field alone', () => {
const onSubmit = vi.fn()
const div = document.createElement('div')
document.body.append(div)
bindOnWeb(asField(div), onSubmit)
sendBeforeInput(div, 'insertLineBreak')
expect(onSubmit).not.toHaveBeenCalled()
})
})
describe.each(['android', 'ios'] as const)('live terminal keyboard chords on %s', (os) => {
@@ -198,13 +148,6 @@ describe.each(['android', 'ios'] as const)('live terminal keyboard chords on %s'
expect(onChord).not.toHaveBeenCalled()
})
it('leaves standalone web keyboard handling unchanged', () => {
const { key, onChord } = setup(true, 'web')
expect(key('Tab').defaultPrevented).toBe(false)
expect(key('c', { ctrlKey: true }).defaultPrevented).toBe(false)
expect(onChord).not.toHaveBeenCalled()
})
it('does not swallow keys when the current terminal cannot send', () => {
const { key } = setup(false)
expect(key('Tab').defaultPrevented).toBe(false)
@@ -1,248 +0,0 @@
import { createElement, type RefObject } from 'react'
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type {
TerminalLiveInputFocusTarget,
TerminalLiveInputFocusTimerRef
} from './terminal-live-input'
import { useTerminalLiveInputFocus } from './use-terminal-live-input-focus'
type HarnessProps = {
readonly activeHandleRef: RefObject<string | null>
readonly canSend: boolean
readonly inputRef: RefObject<TerminalLiveInputFocusTarget | null>
readonly keyboardHeight?: number
readonly lifecycleIdentity: object | null
readonly lifecycleKey: string
readonly reopenFocusedInputWhenKeyboardHidden: boolean
readonly timerRef: TerminalLiveInputFocusTimerRef
}
type FocusHandlers = ReturnType<typeof useTerminalLiveInputFocus>
function createFocusTarget(initiallyFocused = false): TerminalLiveInputFocusTarget & {
readonly blur: ReturnType<typeof vi.fn>
readonly focus: ReturnType<typeof vi.fn>
} {
let focused = initiallyFocused
return {
blur: vi.fn(() => {
focused = false
}),
focus: vi.fn(() => {
focused = true
}),
isFocused: () => focused
}
}
function createTimerRef(): TerminalLiveInputFocusTimerRef {
return { current: null }
}
function createHarness(initialProps: HarnessProps): {
readonly handlers: () => FocusHandlers
readonly render: (props: HarnessProps) => void
readonly unmount: () => void
} {
let handlers: FocusHandlers | null = null
let renderer: ReactTestRenderer | null = null
function Harness(props: HarnessProps): null {
handlers = useTerminalLiveInputFocus({
...props,
keyboardHeight: props.keyboardHeight ?? 0
})
return null
}
act(() => {
renderer = create(createElement(Harness, initialProps))
})
if (!handlers || !renderer) {
throw new Error('terminal live input focus harness did not render')
}
return {
handlers: () => {
if (!handlers) {
throw new Error('terminal live input focus harness is not mounted')
}
return handlers
},
render: (props) => {
act(() => renderer?.update(createElement(Harness, props)))
},
unmount: () => {
act(() => renderer?.unmount())
handlers = null
}
}
}
function connectedProps(
inputRef: RefObject<TerminalLiveInputFocusTarget | null>,
timerRef = createTimerRef(),
activeHandleRef: RefObject<string | null> = { current: 'terminal-a' }
): HarnessProps {
return {
activeHandleRef,
canSend: true,
inputRef,
lifecycleIdentity: null,
lifecycleKey: 'host-a:worktree-a:connected',
reopenFocusedInputWhenKeyboardHidden: true,
timerRef
}
}
describe('terminal live input focus hook', () => {
afterEach(() => {
vi.useRealTimers()
})
it('defers initial terminal surface focus until the WebView touch has completed', () => {
vi.useFakeTimers()
const input = createFocusTarget()
const harness = createHarness(connectedProps({ current: input }))
harness.handlers().handleTerminalTap('terminal-a')
expect(input.focus).not.toHaveBeenCalled()
vi.runOnlyPendingTimers()
expect(input.focus).toHaveBeenCalledTimes(1)
harness.unmount()
})
it('reacquires focus after successive terminal tap notifications', () => {
vi.useFakeTimers()
const input = createFocusTarget()
const harness = createHarness(connectedProps({ current: input }))
harness.handlers().handleTerminalTap('terminal-a')
vi.runOnlyPendingTimers()
harness.handlers().handleTerminalTap('terminal-a')
vi.runAllTimers()
expect(input.blur).toHaveBeenCalledTimes(1)
expect(input.focus).toHaveBeenCalledTimes(2)
harness.unmount()
})
it('preserves the focused iPad responder when a hardware keyboard keeps keyboard height at zero', () => {
vi.useFakeTimers()
const input = createFocusTarget(true)
const inputRef = { current: input }
const harness = createHarness({
...connectedProps(inputRef),
reopenFocusedInputWhenKeyboardHidden: false
})
harness.handlers().handleTerminalTap('terminal-a')
vi.runAllTimers()
expect(input.blur).not.toHaveBeenCalled()
expect(input.focus).toHaveBeenCalledTimes(1)
harness.unmount()
})
it('cancels focus when navigation reuses the mounted route', () => {
vi.useFakeTimers()
const oldInput = createFocusTarget()
const inputRef: RefObject<TerminalLiveInputFocusTarget | null> = { current: oldInput }
const timerRef = createTimerRef()
const harness = createHarness(connectedProps(inputRef, timerRef))
harness.handlers().handleTerminalTap('terminal-a')
const newInput = createFocusTarget()
inputRef.current = newInput
harness.render({
...connectedProps(inputRef, timerRef),
lifecycleKey: 'host-a:worktree-b:connected'
})
vi.runOnlyPendingTimers()
expect(oldInput.focus).not.toHaveBeenCalled()
expect(newInput.focus).not.toHaveBeenCalled()
harness.handlers().handleTerminalTap('terminal-a')
vi.runOnlyPendingTimers()
expect(newInput.focus).toHaveBeenCalledTimes(1)
harness.unmount()
})
it('drops pending focus when reconnect reuses the mounted route', () => {
vi.useFakeTimers()
const oldInput = createFocusTarget()
const oldInputRef = { current: oldInput }
const timerRef = createTimerRef()
const harness = createHarness(connectedProps(oldInputRef, timerRef))
harness.handlers().handleTerminalTap('terminal-a')
harness.render({
...connectedProps(oldInputRef, timerRef),
canSend: false,
lifecycleIdentity: {},
lifecycleKey: 'host-a:worktree-a:disconnected'
})
vi.runOnlyPendingTimers()
expect(oldInput.focus).not.toHaveBeenCalled()
const replacementInput = createFocusTarget()
oldInputRef.current = replacementInput
harness.render({
...connectedProps(oldInputRef, timerRef),
lifecycleIdentity: {},
lifecycleKey: 'host-a:worktree-a:connected'
})
harness.handlers().handleTerminalTap('terminal-a')
vi.runOnlyPendingTimers()
expect(replacementInput.focus).toHaveBeenCalledTimes(1)
harness.unmount()
})
it('cancels focus when a retained screen blurs', () => {
vi.useFakeTimers()
const input = createFocusTarget()
const timerRef = createTimerRef()
const harness = createHarness(connectedProps({ current: input }, timerRef))
harness.handlers().handleTerminalTap('terminal-a')
harness.handlers().resetLiveInputFocus()
vi.runOnlyPendingTimers()
expect(timerRef.current).toBeNull()
expect(input.blur).toHaveBeenCalledTimes(1)
expect(input.focus).not.toHaveBeenCalled()
harness.unmount()
})
it('does not let stale handle state focus a replacement terminal', () => {
vi.useFakeTimers()
const firstInput = createFocusTarget()
const inputRef: RefObject<TerminalLiveInputFocusTarget | null> = { current: firstInput }
const timerRef = createTimerRef()
const activeHandleRef = { current: 'terminal-a' as string | null }
const harness = createHarness(connectedProps(inputRef, timerRef, activeHandleRef))
harness.handlers().handleTerminalTap('terminal-a')
const replacementInput = createFocusTarget()
inputRef.current = replacementInput
activeHandleRef.current = 'terminal-b'
harness.render(connectedProps(inputRef, timerRef, activeHandleRef))
vi.runOnlyPendingTimers()
expect(firstInput.focus).not.toHaveBeenCalled()
expect(replacementInput.focus).not.toHaveBeenCalled()
harness.handlers().handleTerminalTap('terminal-b')
vi.runOnlyPendingTimers()
expect(replacementInput.focus).toHaveBeenCalledTimes(1)
harness.unmount()
})
it('keeps the native focus target immediate outside the WebView tap path', () => {
const input = createFocusTarget()
const harness = createHarness(connectedProps({ current: input }))
harness.handlers().focusLiveInput()
expect(input.focus).toHaveBeenCalledTimes(1)
harness.unmount()
})
})
@@ -1,9 +1,8 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { RelayOuterError } from './mobile-relay-e2ee-link'
import {
bundle,
dependencies,
FakeLogicalClient,
FakeRelaySession,
FakeSession,
host,
@@ -54,50 +53,6 @@ describe('mobile endpoint supervisor nudges', () => {
expect(vi.getTimerCount()).toBe(0)
})
it('restarts a disconnected Relay immediately on an app-resume retry', async () => {
const logical = new FakeLogicalClient('disconnected', 'relay')
const openRelay = vi
.fn()
.mockReturnValueOnce(new FakeRelaySession('disconnected', new RelayOuterError(4408)))
.mockReturnValueOnce(new FakeRelaySession('connected'))
const deps = dependencies({
openRelay,
openDirect: vi.fn(() => new FakeSession('disconnected')),
randomBytes: () => new Uint8Array([128, 0])
})
const supervisor = new MobileEndpointSupervisor(logical, host.id, relay, deps)
await supervisor.start()
await vi.advanceTimersByTimeAsync(0)
expect(openRelay).toHaveBeenCalledOnce()
supervisor.nudge('app-resume')
await vi.advanceTimersByTimeAsync(0)
expect(openRelay).toHaveBeenCalledTimes(2)
expect(logical.getState()).toBe('connected')
expect(logical.getActivePath()).toBe('relay')
supervisor.stop()
})
it('leaves physical relay probing to the session watchdog', async () => {
const logical = new FakeLogicalClient('connected', 'relay')
const deps = dependencies()
const supervisor = new MobileEndpointSupervisor(logical, host.id, relay, deps)
await supervisor.start()
supervisor.nudge('focus')
await vi.advanceTimersByTimeAsync(0)
expect(logical.sendRequest).not.toHaveBeenCalled()
expect(logical.suspendActiveSession).not.toHaveBeenCalled()
expect(deps.openRelay).not.toHaveBeenCalled()
supervisor.nudge('focus')
await vi.advanceTimersByTimeAsync(0)
expect(logical.sendRequest).not.toHaveBeenCalled()
supervisor.stop()
})
it('withdraws the racing relay dial when direct authenticated while it was in flight', async () => {
// Withdraw if direct wins before relay authentication; never close the winning session.
const directSession = new FakeSession('connecting')
@@ -1,200 +0,0 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import {
dependencies,
FakeLogicalClient,
FakeRelaySession,
FakeSession,
host,
relay
} from './mobile-endpoint-supervisor-test-fakes'
import { MobileEndpointSupervisor } from './mobile-endpoint-supervisor'
vi.mock('react-native', () => ({ Platform: { OS: 'ios' } }))
vi.mock('expo-secure-store', () => ({ WHEN_UNLOCKED_THIS_DEVICE_ONLY: 'when-unlocked' }))
vi.mock('expo-crypto', () => ({ getRandomBytes: (length: number) => new Uint8Array(length) }))
describe('mobile Relay background lifecycle', () => {
beforeEach(() => {
vi.useFakeTimers()
vi.setSystemTime(new Date('2026-07-13T12:00:00Z'))
})
afterEach(() => vi.useRealTimers())
it('retains a relay session across a quick background and foreground', async () => {
const logical = new FakeLogicalClient('connected', 'relay')
const deps = dependencies()
const supervisor = new MobileEndpointSupervisor(logical, host.id, relay, deps)
await supervisor.start()
supervisor.setForeground(false)
await vi.advanceTimersByTimeAsync(29_999)
expect(logical.suspendActiveSession).not.toHaveBeenCalled()
expect(logical.getState()).toBe('connected')
supervisor.setForeground(true)
await vi.advanceTimersByTimeAsync(1)
expect(logical.suspendActiveSession).not.toHaveBeenCalled()
expect(deps.openRelay).not.toHaveBeenCalled()
expect(logical.getActivePath()).toBe('relay')
supervisor.stop()
})
it('nudges retained relay availability with the tolerant focus policy', async () => {
const logical = new FakeLogicalClient('disconnected', 'lan')
const session = new FakeRelaySession('connected')
const deps = dependencies({
openDirect: vi.fn(() => new FakeSession('disconnected')),
openRelay: vi.fn(() => session)
})
const supervisor = new MobileEndpointSupervisor(logical, host.id, relay, deps)
await supervisor.start()
expect(logical.getActivePath()).toBe('relay')
supervisor.nudge('focus')
expect(session.notifyForeground).not.toHaveBeenCalled()
supervisor.setForeground(false)
await vi.advanceTimersByTimeAsync(5_000)
supervisor.setForeground(true)
expect(session.notifyForeground).toHaveBeenCalledExactlyOnceWith('focus')
// Past the grace the relay is already released, so there is nothing to probe.
supervisor.setForeground(false)
await vi.advanceTimersByTimeAsync(30_000)
supervisor.setForeground(true)
expect(session.notifyForeground).toHaveBeenCalledExactlyOnceWith('focus')
supervisor.stop()
})
it('releases a retained relay after 30 seconds and recovers only on foreground', async () => {
const logical = new FakeLogicalClient('connected', 'relay')
const deps = dependencies()
const supervisor = new MobileEndpointSupervisor(logical, host.id, relay, deps)
await supervisor.start()
supervisor.setForeground(false)
await vi.advanceTimersByTimeAsync(29_999)
expect(logical.suspendActiveSession).not.toHaveBeenCalled()
await vi.advanceTimersByTimeAsync(1)
expect(logical.suspendActiveSession).toHaveBeenCalledOnce()
expect(logical.getState()).toBe('disconnected')
expect(deps.openRelay).not.toHaveBeenCalled()
supervisor.setForeground(true)
await vi.waitFor(() => expect(logical.migrateTo).toHaveBeenCalledOnce())
expect(logical.getActivePath()).toBe('relay')
supervisor.stop()
})
it('enforces an overdue grace on foreground when the background timer was suspended', async () => {
const logical = new FakeLogicalClient('connected', 'relay')
const deps = dependencies()
const supervisor = new MobileEndpointSupervisor(logical, host.id, relay, deps)
await supervisor.start()
supervisor.setForeground(false)
vi.setSystemTime(Date.now() + 30_000)
expect(logical.suspendActiveSession).not.toHaveBeenCalled()
supervisor.setForeground(true)
expect(logical.suspendActiveSession).toHaveBeenCalledOnce()
await vi.waitFor(() => expect(deps.openRelay).toHaveBeenCalledOnce())
expect(logical.getState()).toBe('connected')
supervisor.stop()
})
it('recovers after a retained relay fails while backgrounded', async () => {
const logical = new FakeLogicalClient('connected', 'relay')
const deps = dependencies()
const supervisor = new MobileEndpointSupervisor(logical, host.id, relay, deps)
await supervisor.start()
supervisor.setForeground(false)
await vi.advanceTimersByTimeAsync(5_000)
logical.publishState('disconnected')
expect(logical.suspendActiveSession).toHaveBeenCalledOnce()
expect(vi.getTimerCount()).toBe(0)
expect(deps.openRelay).not.toHaveBeenCalled()
supervisor.setForeground(true)
await vi.waitFor(() => expect(deps.openRelay).toHaveBeenCalledOnce())
expect(logical.getState()).toBe('connected')
supervisor.stop()
})
it('does not retain or suspend a healthy direct session in the background', async () => {
const logical = new FakeLogicalClient('connected', 'tailscale')
const deps = dependencies()
const supervisor = new MobileEndpointSupervisor(logical, host.id, relay, deps)
await supervisor.start()
supervisor.setForeground(false)
await vi.advanceTimersByTimeAsync(30_000)
expect(logical.suspendActiveSession).not.toHaveBeenCalled()
expect(logical.getState()).toBe('connected')
expect(deps.openRelay).not.toHaveBeenCalled()
supervisor.stop()
})
it('resumes a lease rotation that came due during the background grace', async () => {
const logical = new FakeLogicalClient('disconnected', 'lan')
const openRelay = vi.fn(() => new FakeRelaySession('connected', null, Date.now() + 90_000))
const deps = dependencies({
openDirect: vi.fn(() => new FakeSession('disconnected')),
openRelay
})
const supervisor = new MobileEndpointSupervisor(logical, host.id, relay, deps)
await supervisor.start()
await vi.advanceTimersByTimeAsync(40_000)
supervisor.setForeground(false)
await vi.advanceTimersByTimeAsync(20_000)
expect(openRelay).toHaveBeenCalledOnce()
expect(logical.getState()).toBe('connected')
supervisor.setForeground(true)
await vi.waitFor(() => expect(deps.writeBundle).toHaveBeenCalledTimes(2))
expect(logical.migrateTo).toHaveBeenCalledTimes(2)
expect(openRelay).toHaveBeenCalledTimes(2)
expect(logical.getActivePath()).toBe('relay')
expect(logical.getState()).toBe('connected')
supervisor.stop()
})
it('arms lease rotation when confirmation persistence finishes during the grace', async () => {
let finishWrite: (() => void) | undefined
const writeStarted = new Promise<void>((resolve) => {
finishWrite = resolve
})
const logical = new FakeLogicalClient('disconnected', 'lan')
const openRelay = vi.fn(() => new FakeRelaySession('connected', null, Date.now() + 90_000))
const deps = dependencies({
openDirect: vi.fn(() => new FakeSession('disconnected')),
openRelay,
writeBundle: vi.fn(() => writeStarted)
})
const supervisor = new MobileEndpointSupervisor(logical, host.id, relay, deps)
const starting = supervisor.start()
await vi.waitFor(() => expect(deps.writeBundle).toHaveBeenCalledOnce())
supervisor.setForeground(false)
finishWrite?.()
await starting
supervisor.setForeground(true)
await vi.advanceTimersByTimeAsync(59_999)
expect(openRelay).toHaveBeenCalledOnce()
await vi.advanceTimersByTimeAsync(1)
await vi.waitFor(() => expect(deps.writeBundle).toHaveBeenCalledTimes(2))
expect(logical.migrateTo).toHaveBeenCalledTimes(2)
expect(openRelay).toHaveBeenCalledTimes(2)
expect(logical.getActivePath()).toBe('relay')
expect(logical.getState()).toBe('connected')
supervisor.stop()
})
})
@@ -1,225 +0,0 @@
import { describe, expect, it, vi } from 'vitest'
vi.mock('./mobile-e2ee-v2-client-session', () => ({
MobileE2EEV2ClientSession: { create: () => ({}) }
}))
vi.mock('./mobile-e2ee-v2-physical-channel', () => ({
MobileE2EEAuthenticationError: class extends Error {},
MobileE2EEV2PhysicalChannel: class {
start = vi.fn()
handleMessage = vi.fn(async () => {})
sendText = vi.fn(() => true)
sendBinary = vi.fn(() => true)
dispose = vi.fn()
}
}))
import { MOBILE_RELAY_CLOSE_CODE } from '../../../src/shared/remote-runtime/relay/mobile-relay-close-codes'
import { classifyConnection } from './connection-health'
import { RelayOuterError } from './mobile-relay-e2ee-link'
import { RelayReconnectController } from './mobile-relay-reconnect-controller'
import { RelayDirectorHttpError } from './mobile-relay-resume-director'
import { relayHostReachabilityForFailure } from './relay-host-reachability-latch'
import type { RelayHostReachability } from './relay-host-reachability'
// Mirrors LogicalClientConnectionPath.update, the one consumer: it publishes
// only on change, so the latch itself need not dedupe.
function changesReportedTo(reported: RelayHostReachability[]) {
let last: RelayHostReachability | null = null
return (value: RelayHostReachability) => {
if (value !== last) {
last = value
reported.push(value)
}
}
}
function controllerReportingTo(report: (reachability: RelayHostReachability) => void) {
const controller = new RelayReconnectController(
{
now: () => 0,
randomBytes: () => new Uint8Array([0, 0]),
setTimer: () => 0,
clearTimer: () => {}
},
() => {}
)
controller.reportRecoveryTo({
setRecoveryAttempt: () => {},
setPairingRejected: () => {},
setRelayHostReachability: report
})
return controller
}
const hostOffline = () => new RelayOuterError(MOBILE_RELAY_CLOSE_CODE.HOST_OFFLINE)
describe('relayHostReachabilityForFailure', () => {
it('reads the cell close code off a failed dial', () => {
expect(relayHostReachabilityForFailure(hostOffline())).toBe('host-offline')
expect(
relayHostReachabilityForFailure(
new RelayOuterError(MOBILE_RELAY_CLOSE_CODE.BAD_OUTER_CREDENTIAL)
)
).toBe('credential-refused')
expect(relayHostReachabilityForFailure(new RelayOuterError(1006))).toBe('unreachable')
})
it('treats a director 401 as the same refusal as a cell 4401', () => {
expect(relayHostReachabilityForFailure(new RelayDirectorHttpError(401, null))).toBe(
'credential-refused'
)
expect(relayHostReachabilityForFailure(new RelayDirectorHttpError(503, 30_000))).toBe(
'connecting'
)
})
it('has no verdict for errors that carry no relay code', () => {
expect(relayHostReachabilityForFailure(new TypeError('Network request failed'))).toBe(
'connecting'
)
expect(relayHostReachabilityForFailure(null)).toBe('connecting')
})
})
describe('RelayReconnectController relay host reachability', () => {
// Two matching offline closes must explain the outage before hours of generic relay retry copy accumulate.
it('reports host-offline on the second consecutive 4404, not the first', () => {
const reported: RelayHostReachability[] = []
const controller = controllerReportingTo(changesReportedTo(reported))
controller.registerFailure(hostOffline(), false)
expect(reported).toEqual(['connecting'])
controller.registerFailure(hostOffline(), false)
expect(reported).toEqual(['connecting', 'host-offline'])
})
// Bank verdicts before the credential gate returns early.
it('still latches credential-refused while the credential gate holds', () => {
const reported: RelayHostReachability[] = []
const controller = controllerReportingTo(changesReportedTo(reported))
const refused = () => new RelayOuterError(MOBILE_RELAY_CLOSE_CODE.BAD_OUTER_CREDENTIAL)
controller.registerFailure(refused(), false)
controller.registerFailure(refused(), false)
expect(reported.at(-1)).toBe('credential-refused')
})
it('ignores a failed replacement dial while an authenticated relay session is live', () => {
const reported: RelayHostReachability[] = []
const controller = controllerReportingTo(changesReportedTo(reported))
controller.registerFailure(hostOffline(), false)
controller.setActiveSession({ getFailure: () => null, notifyForeground: () => {} })
controller.registerFailure(hostOffline(), false)
controller.registerFailure(hostOffline(), false)
expect(reported).toEqual(['connecting'])
})
it('clears the verdict when a relay session authenticates', () => {
const reported: RelayHostReachability[] = []
const controller = controllerReportingTo(changesReportedTo(reported))
controller.registerFailure(hostOffline(), false)
controller.registerFailure(hostOffline(), false)
controller.setActiveSession({ getFailure: () => null, notifyForeground: () => {} })
expect(reported).toEqual(['connecting', 'host-offline', 'connecting'])
})
it('clears the verdict when a direct session proves the desktop is up', () => {
const reported: RelayHostReachability[] = []
const controller = controllerReportingTo(changesReportedTo(reported))
controller.registerFailure(new RelayOuterError(1006), false)
controller.registerFailure(new RelayOuterError(1006), false)
controller.resetForDirectConnection()
expect(reported).toEqual(['connecting', 'unreachable', 'connecting'])
})
})
describe('classifyConnection with a relay host verdict', () => {
const base = {
state: 'connecting' as const,
reconnectAttempts: 1,
lastConnectedAt: null,
pendingPath: 'relay' as const,
hostName: 'Host 1'
}
it('names the offline desktop instead of "Connecting via Relay…"', () => {
expect(classifyConnection({ ...base, relayHostReachability: 'host-offline' })).toEqual({
kind: 'unreachable',
label: 'Host 1 is offline',
reason: 'never-connected',
detail: "Check it's awake, Orca is running, and you're signed in"
})
})
it('names the refused credential and its real remedy, not a desktop sign-in', () => {
expect(classifyConnection({ ...base, relayHostReachability: 'credential-refused' })).toEqual({
kind: 'unreachable',
label: 'Relay access expired for Host 1',
reason: 'never-connected',
detail: 'Re-pair with your desktop'
})
})
it('blames the connection, softly, on a transport close', () => {
expect(classifyConnection({ ...base, relayHostReachability: 'unreachable' })).toEqual({
kind: 'warning',
label: "Can't reach Relay",
detail: 'Check your connection'
})
})
it('keeps narrating Relay while the verdict is still connecting', () => {
expect(classifyConnection({ ...base, relayHostReachability: 'connecting' }).label).toBe(
'Connecting via Relay…'
)
})
it('does not depend on the relay path still being pending', () => {
expect(
classifyConnection({
...base,
state: 'reconnecting',
pendingPath: null,
relayHostReachability: 'credential-refused'
}).label
).toBe('Relay access expired for Host 1')
})
it('reads as stale once this session had been connected', () => {
expect(
classifyConnection({
...base,
lastConnectedAt: 1,
nowMs: 2,
relayHostReachability: 'host-offline'
})
).toMatchObject({ reason: 'stale' })
})
it('falls back to a generic host name', () => {
expect(
classifyConnection({ ...base, hostName: undefined, relayHostReachability: 'host-offline' })
.label
).toBe('Host is offline')
})
it('never appends the Tailscale hint', () => {
expect(
classifyConnection({ ...base, endpoint: '100.64.0.1', relayHostReachability: 'host-offline' })
).not.toHaveProperty('hint')
})
it('yields to a revoked pairing and a live connection', () => {
expect(
classifyConnection({ ...base, pairingRejected: true, relayHostReachability: 'host-offline' })
.kind
).toBe('auth-failed')
expect(
classifyConnection({ ...base, state: 'connected', relayHostReachability: 'host-offline' })
.label
).toBe('Connected')
})
})
@@ -1,234 +0,0 @@
import { describe, expect, it, vi } from 'vitest'
vi.mock('./mobile-e2ee-v2-client-session', () => ({
MobileE2EEV2ClientSession: { create: () => ({}) }
}))
vi.mock('./mobile-e2ee-v2-physical-channel', () => ({
MobileE2EEAuthenticationError: class extends Error {},
MobileE2EEV2PhysicalChannel: class {
start = vi.fn()
handleMessage = vi.fn(async () => {})
sendText = vi.fn(() => true)
sendBinary = vi.fn(() => true)
dispose = vi.fn()
}
}))
import { RELAY_HOST_CLOSE_REASON } from '../../../src/shared/relay-host-close-reason'
import { MOBILE_RELAY_CLOSE_CODE } from '../../../src/shared/remote-runtime/relay/mobile-relay-close-codes'
import { classifyConnection, verdictDisplayLabel } from './connection-health'
import { MobileRelayE2eeLink, RelayOuterError } from './mobile-relay-e2ee-link'
import { LogicalClientConnectionPath } from './logical-client-connection-path'
import { RelayReconnectController } from './mobile-relay-reconnect-controller'
import type { RelayHostReachability } from './relay-host-reachability'
const SIGNED_OUT_LABEL = 'Sign-in required on Host 1'
const SIGNED_OUT_DETAIL = 'Sign in to Orca on your desktop to reconnect'
class FakeSocket {
static readonly OPEN = 1
readonly OPEN = FakeSocket.OPEN
readyState = FakeSocket.OPEN
bufferedAmount = 0
onopen: (() => void) | null = null
onmessage: ((event: { data: unknown }) => void) | null = null
onerror: (() => void) | null = null
onclose: ((event: { code: number; reason: string }) => void) | null = null
send = vi.fn()
close = vi.fn()
}
function linkOver(
socket: FakeSocket,
onHostCloseReason: (reason: string) => void,
onError: (error: Error) => void
): MobileRelayE2eeLink {
return new MobileRelayE2eeLink({
endpoint: { cellUrl: 'https://relay-c1.onorca.dev', relayHostId: 'AbCdEf0123_-xyZ9' },
credential: 'credential',
expectedCredentialKind: 'resume',
deviceToken: 'device-token',
desktopPublicKeyB64: 'desktop-key',
onAuthenticated: vi.fn(),
onText: vi.fn(),
onBinary: vi.fn(),
onHostCloseReason,
onError,
createSocket: () => socket as unknown as WebSocket
})
}
describe('relay close reason on the phone', () => {
it('reports the cell close reason and still fails with 4404', () => {
const socket = new FakeSocket()
const onHostCloseReason = vi.fn()
const onError = vi.fn()
linkOver(socket, onHostCloseReason, onError)
socket.onclose?.({
code: MOBILE_RELAY_CLOSE_CODE.HOST_OFFLINE,
reason: RELAY_HOST_CLOSE_REASON.SIGNED_OUT
})
expect(onHostCloseReason).toHaveBeenCalledWith(RELAY_HOST_CLOSE_REASON.SIGNED_OUT)
expect(onError).toHaveBeenCalledOnce()
expect(onError).toHaveBeenCalledWith(
new RelayOuterError(
MOBILE_RELAY_CLOSE_CODE.HOST_OFFLINE,
false,
RELAY_HOST_CLOSE_REASON.SIGNED_OUT
)
)
})
// An old cell sends its constant, and every other close sends nothing.
it('reports nothing for a reason it does not know', () => {
const socket = new FakeSocket()
const onHostCloseReason = vi.fn()
linkOver(socket, onHostCloseReason, vi.fn())
socket.onclose?.({
code: MOBILE_RELAY_CLOSE_CODE.HOST_OFFLINE,
reason: 'relay connection rejected'
})
expect(onHostCloseReason).not.toHaveBeenCalled()
})
// Retain close reason whichever arrives first: hello refusal or close.
it('still reports the reason when the hello refusal lands first', async () => {
const socket = new FakeSocket()
const onHostCloseReason = vi.fn()
linkOver(socket, onHostCloseReason, vi.fn())
socket.onmessage?.({
data: JSON.stringify({
type: 'relay-hello',
ok: false,
code: MOBILE_RELAY_CLOSE_CODE.HOST_OFFLINE
})
})
await Promise.resolve()
await Promise.resolve()
socket.onclose?.({
code: MOBILE_RELAY_CLOSE_CODE.HOST_OFFLINE,
reason: RELAY_HOST_CLOSE_REASON.SIGNED_OUT
})
expect(onHostCloseReason).toHaveBeenCalledWith(RELAY_HOST_CLOSE_REASON.SIGNED_OUT)
})
})
describe('the signed-out signal on the logical client', () => {
it('publishes on change and retires when any path reaches connected', () => {
const path = new LogicalClientConnectionPath(() => false)
const changes = vi.fn()
path.subscribe(changes)
path.setRelayHostReachability('signed-out')
path.setRelayHostReachability('signed-out')
expect(path.getRelayHostReachability()).toBe('signed-out')
expect(changes).toHaveBeenCalledTimes(1)
path.clearAfterConnected()
expect(path.getRelayHostReachability()).toBe('connecting')
})
})
describe('the sign-out the cell reported outranks a later plain 4404', () => {
function controllerReporting(reported: RelayHostReachability[]) {
const controller = new RelayReconnectController(
{
now: () => 0,
randomBytes: () => new Uint8Array([0, 0]),
setTimer: () => 0,
clearTimer: () => {}
},
vi.fn()
)
controller.reportRecoveryTo({
setRecoveryAttempt: () => {},
setPairingRejected: () => {},
setRelayHostReachability: (value) => reported.push(value)
})
return controller
}
// A 4404 with no reason is the cell forgetting why, not the desktop signing in.
it('keeps the sign-out through later unlabelled host-offline closes', () => {
const reported: RelayHostReachability[] = []
const controller = controllerReporting(reported)
controller.assertHostReachability('signed-out')
expect(reported).toEqual(['connecting', 'signed-out'])
controller.registerFailure(new RelayOuterError(MOBILE_RELAY_CLOSE_CODE.HOST_OFFLINE), false)
controller.registerFailure(new RelayOuterError(MOBILE_RELAY_CLOSE_CODE.HOST_OFFLINE), false)
expect(reported).toEqual(['connecting', 'signed-out'])
})
it('retires on a connection like every other verdict', () => {
const reported: RelayHostReachability[] = []
const controller = controllerReporting(reported)
controller.assertHostReachability('signed-out')
controller.setActiveSession({ getFailure: () => null, notifyForeground: () => {} })
expect(reported).toEqual(['connecting', 'signed-out', 'connecting'])
})
})
describe('RelayReconnectController cadence', () => {
// Signed-out reason changes copy only; preserve the host-offline retry cadence.
it('keeps the host-offline retry delay for a 4404', () => {
const delays: number[] = []
const controller = new RelayReconnectController(
{
now: () => 0,
randomBytes: () => new Uint8Array([0, 0]),
setTimer: (callback, delay) => {
delays.push(delay)
return 1 as unknown as ReturnType<typeof setTimeout>
},
clearTimer: () => {}
},
vi.fn()
)
controller.registerFailure(new RelayOuterError(MOBILE_RELAY_CLOSE_CODE.HOST_OFFLINE))
expect(delays.at(-1)).toBe(5_000)
})
})
describe('classifyConnection with a signed-out desktop', () => {
const base = {
reconnectAttempts: 0,
lastConnectedAt: null,
relayHostReachability: 'signed-out' as const,
hostName: 'Host 1'
}
it('says so from the first failed dial instead of "Connecting via Relay…"', () => {
const verdict = classifyConnection({
...base,
state: 'connecting',
pendingPath: 'relay'
})
expect(verdict).toEqual({
kind: 'unreachable',
label: SIGNED_OUT_LABEL,
reason: 'never-connected',
detail: SIGNED_OUT_DETAIL
})
expect(verdictDisplayLabel(verdict)).toBe(SIGNED_OUT_LABEL)
})
it('replaces "Can\'t reach desktop" on the direct path too', () => {
expect(
classifyConnection({ ...base, state: 'reconnecting', reconnectAttempts: 20 }).label
).toBe(SIGNED_OUT_LABEL)
})
})
@@ -1,213 +0,0 @@
import { describe, expect, it } from 'vitest'
import { MOBILE_WEB_BUNDLE_CAPABILITY } from '../../../../src/shared/mobile-web-bundle/mobile-web-bundle-capability'
import {
evaluateMobileWebBundleCompat,
type MobileWebBundleCompatManifest,
type MobileWebBundleCompatVerdict,
type MobileWebBundleHostStatus
} from './mobile-web-bundle-compat'
const CAPABLE: readonly string[] = ['browser.screencast.v1', MOBILE_WEB_BUNDLE_CAPABILITY]
// Reply members are present but possibly undefined, so model a host answering neither with both keys.
const ANSWERED_NEITHER: MobileWebBundleHostStatus = {
protocolVersion: undefined,
minCompatibleMobileVersion: undefined
}
function manifest(
overrides: Partial<MobileWebBundleCompatManifest> = {}
): MobileWebBundleCompatManifest {
return {
schemaVersion: 1,
runtimeProtocolVersion: 3,
minCompatibleRuntimeProtocolVersion: 2,
pageVersion: 1,
...overrides
}
}
function evaluate(input: {
hostCapabilities?: readonly string[]
hostStatus?: MobileWebBundleHostStatus
manifest?: MobileWebBundleCompatManifest | null
}): MobileWebBundleCompatVerdict {
return evaluateMobileWebBundleCompat({
hostCapabilities: input.hostCapabilities ?? CAPABLE,
hostStatus: input.hostStatus ?? { protocolVersion: 3, minCompatibleMobileVersion: 2 },
manifest: input.manifest === undefined ? manifest() : input.manifest
})
}
describe('evaluateMobileWebBundleCompat', () => {
it('opens a bundle whose window contains the host', () => {
expect(evaluate({})).toEqual({ kind: 'ok', manifestChecked: true })
})
it('answers the capability question before a manifest exists', () => {
expect(evaluate({ manifest: null })).toEqual({ kind: 'ok', manifestChecked: false })
expect(evaluate({ hostCapabilities: [], manifest: null })).toEqual({
kind: 'blocked',
reason: 'bundle-unavailable'
})
})
it('blocks a host that ships no bundle', () => {
expect(evaluate({ hostCapabilities: ['browser.screencast.v1'] })).toEqual({
kind: 'blocked',
reason: 'bundle-unavailable'
})
})
it('blocks a manifest schema this shell does not know', () => {
expect(evaluate({ manifest: manifest({ schemaVersion: 2 }) })).toMatchObject({
kind: 'blocked',
reason: 'bundle-shell-too-old',
schemaVersion: 2
})
expect(evaluate({ manifest: manifest({ schemaVersion: 0 }) })).toMatchObject({
reason: 'bundle-shell-too-old',
schemaVersion: 0
})
})
it('blocks a host older than the bundle it serves', () => {
expect(
evaluate({
hostStatus: { protocolVersion: 1, minCompatibleMobileVersion: 0 },
manifest: manifest({ minCompatibleRuntimeProtocolVersion: 2 })
})
).toEqual({
kind: 'blocked',
reason: 'bundle-incompatible',
side: 'desktop',
hostProtocolVersion: 1,
requiredHostProtocolVersion: 2
})
})
it('blocks a bundle older than the host expects', () => {
expect(
evaluate({
hostStatus: { protocolVersion: 9, minCompatibleMobileVersion: 4 },
manifest: manifest({ runtimeProtocolVersion: 3, minCompatibleRuntimeProtocolVersion: 0 })
})
).toEqual({
kind: 'blocked',
reason: 'bundle-incompatible',
side: 'mobile',
bundleRuntimeProtocolVersion: 3,
requiredBundleRuntimeProtocolVersion: 4
})
})
it('reports the missing capability first when the host also fails every later check', () => {
expect(
evaluate({
hostCapabilities: [],
hostStatus: { protocolVersion: 0, minCompatibleMobileVersion: 99 },
manifest: manifest({ schemaVersion: 7, minCompatibleRuntimeProtocolVersion: 5 })
})
).toEqual({ kind: 'blocked', reason: 'bundle-unavailable' })
})
it('reports an unknown schema before reading the protocol window inside it', () => {
expect(
evaluate({
hostStatus: { protocolVersion: 0, minCompatibleMobileVersion: 99 },
manifest: manifest({ schemaVersion: 2, minCompatibleRuntimeProtocolVersion: 5 })
})
).toMatchObject({ reason: 'bundle-shell-too-old' })
})
it('reports the desktop side before the mobile side when both windows miss', () => {
expect(
evaluate({
hostStatus: { protocolVersion: 1, minCompatibleMobileVersion: 99 },
manifest: manifest({ runtimeProtocolVersion: 3, minCompatibleRuntimeProtocolVersion: 5 })
})
).toMatchObject({ reason: 'bundle-incompatible', side: 'desktop' })
})
it('treats an omitted host protocolVersion as the oldest host that could have answered', () => {
expect(
evaluate({
hostStatus: ANSWERED_NEITHER,
manifest: manifest({ minCompatibleRuntimeProtocolVersion: 1 })
})
).toEqual({
kind: 'blocked',
reason: 'bundle-incompatible',
side: 'desktop',
hostProtocolVersion: 0,
requiredHostProtocolVersion: 1
})
})
it('treats an omitted host minCompatibleMobileVersion as no floor at all', () => {
expect(
evaluate({
hostStatus: ANSWERED_NEITHER,
manifest: manifest({ runtimeProtocolVersion: 0, minCompatibleRuntimeProtocolVersion: 0 })
})
).toEqual({ kind: 'ok', manifestChecked: true })
})
it('opens at the boundary of both windows, so equality is not a block', () => {
expect(
evaluate({
hostStatus: { protocolVersion: 2, minCompatibleMobileVersion: 3 },
manifest: manifest({ runtimeProtocolVersion: 3, minCompatibleRuntimeProtocolVersion: 2 })
})
).toEqual({ kind: 'ok', manifestChecked: true })
expect(
evaluate({
hostStatus: { protocolVersion: 1, minCompatibleMobileVersion: 3 },
manifest: manifest({ runtimeProtocolVersion: 3, minCompatibleRuntimeProtocolVersion: 2 })
})
).toMatchObject({ reason: 'bundle-incompatible', side: 'desktop' })
expect(
evaluate({
hostStatus: { protocolVersion: 2, minCompatibleMobileVersion: 4 },
manifest: manifest({ runtimeProtocolVersion: 3, minCompatibleRuntimeProtocolVersion: 2 })
})
).toMatchObject({ reason: 'bundle-incompatible', side: 'mobile' })
})
})
// Pin the page-version floor independently of manifest-schema support.
describe('the page floor', () => {
const tooOld = (pageVersion: number) => ({
kind: 'blocked',
reason: 'bundle-incompatible',
side: 'desktop',
pageVersion,
requiredPageVersion: 1
})
it('walls a desktop whose page is older than this shell, as a desktop to update', () => {
expect(evaluate({ manifest: manifest({ pageVersion: 0 }) })).toEqual(tooOld(0))
})
it('reads a manifest with no page version as older than the floor', () => {
const { schemaVersion, runtimeProtocolVersion, minCompatibleRuntimeProtocolVersion } =
manifest()
const unversioned = {
schemaVersion,
runtimeProtocolVersion,
minCompatibleRuntimeProtocolVersion
}
expect(evaluate({ manifest: unversioned })).toEqual(tooOld(0))
})
it('opens a page at the floor and one above it', () => {
expect(evaluate({ manifest: manifest({ pageVersion: 1 }) })).toEqual({
kind: 'ok',
manifestChecked: true
})
expect(evaluate({ manifest: manifest({ pageVersion: 7 }) })).toEqual({
kind: 'ok',
manifestChecked: true
})
})
})
@@ -20,8 +20,6 @@ vi.mock('expo-crypto', async () => {
// Use a small grid for cheap payload work; model slow-link cost with time rather than bytes.
const RANGE_GRID = 1024
const ONE_WAY_LATENCY_MS = 150
/** One 384 KiB window sealed at ~40 KB/s, the slow relayed path #29697 reported. */
const WINDOW_TRANSFER_MS = 17_000
function toHex(bytes: Uint8Array): string {
return Array.from(bytes, (byte) => byte.toString(16).padStart(2, '0')).join('')
@@ -159,20 +157,6 @@ describe('fetchMobileWebBundle over a slow link', () => {
vi.useRealTimers()
})
it('finishes when every reply lands within the stall budget but four queued ones do not', async () => {
// Each window lands 17s after the one before it, so the fourth read in flight waits ~68s.
const files = bundleFiles()
const host = slowLinkHost(files, WINDOW_TRANSFER_MS)
const outcome = await settle(fetchMobileWebBundle({ client: host.client }))
expect(outcome.error).toBeUndefined()
expect(outcome.value?.assets.size).toBe(4)
for (const [path, content] of Object.entries(files)) {
expect(outcome.value?.assets.get(path)).toEqual(content)
}
})
it('fails as a lost link once no window has landed for the stall budget', async () => {
const host = slowLinkHost(bundleFiles(), null)
const startedAt = Date.now()
@@ -1,177 +0,0 @@
import { describe, expect, it } from 'vitest'
import type { RuntimeWorktreeAgentRow } from '../../../src/shared/runtime-types'
import type { AGENT_TURN_OUTCOMES } from '../../../src/shared/agent-status/lifecycle/agent-turn-outcome'
import { isAgentStatusFresh } from '../../../src/shared/agent-status/contracts/agent-status-freshness'
import {
agentDisplayLabel,
agentStateLabel,
agentDotState,
agentRowTimeAt,
formatTimeAgo
} from './agent-row-display'
type Outcome = (typeof AGENT_TURN_OUTCOMES)[number]
const mainAgentDone = (outcome: Outcome, stateStartedAt = 0) => ({
mainAgent: { state: 'done' as const, outcome, stateStartedAt }
})
function row(overrides: Partial<RuntimeWorktreeAgentRow> = {}): RuntimeWorktreeAgentRow {
return {
paneKey: 'p',
parentPaneKey: null,
state: 'working',
agentType: 'claude',
prompt: '',
taskTitle: null,
displayName: null,
lastAssistantMessage: null,
toolName: null,
toolInput: null,
interrupted: false,
stateStartedAt: 0,
updatedAt: 0,
...overrides
}
}
describe('agentDotState', () => {
it('maps known states through and unknown to idle', () => {
expect(agentDotState(row({ state: 'working', updatedAt: 0 }), 0)).toBe('working')
expect(
agentDotState(row({ state: 'working', workingMode: 'monitoring', updatedAt: 0 }), 0)
).toBe('monitoring')
expect(agentDotState(row({ state: 'blocked', updatedAt: 0 }), 0)).toBe('blocked')
expect(agentDotState(row({ state: 'waiting', updatedAt: 0 }), 0)).toBe('waiting')
expect(agentDotState(row({ state: 'done', updatedAt: 0 }), 0)).toBe('done')
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: unparsed rows can carry a future host state.
expect(agentDotState(row({ state: 'unknown-state' as never }), 0)).toBe('idle')
})
it('lets the shared verdict mark win over the row state, label included', () => {
expect(agentDotState(row({ state: 'working', ...mainAgentDone('failure') }), 0)).toBe('failed')
expect(agentDisplayLabel(row({ state: 'done', ...mainAgentDone('unconfirmed') }), 0)).toBe(
'Couldn’t confirm'
)
for (const [outcome, label] of [
['cancellation', 'Interrupted'],
['interruption', 'Failed'],
['failure', 'Failed']
] as const) {
expect(
agentStateLabel(agentDotState(row({ state: 'done', ...mainAgentDone(outcome) }), 0))
).toBe(label)
}
})
it('decays a stale active state to idle, matching desktop', () => {
const stale = 1_800_001
expect(agentDotState(row({ state: 'working', updatedAt: 0, hasLivePty: true }), stale)).toBe(
'unverifiable'
)
expect(agentDotState(row({ state: 'working', updatedAt: 0 }), stale)).toBe('idle')
expect(agentDotState(row({ state: 'blocked', updatedAt: 0 }), stale)).toBe('idle')
expect(agentDotState(row({ state: 'waiting', updatedAt: 0 }), stale)).toBe('idle')
expect(agentDotState(row({ state: 'working', updatedAt: 0 }), 1_800_000)).toBe('working')
expect(agentDotState(row({ state: 'done', updatedAt: 0 }), stale)).toBe('done')
expect(
agentDotState(row({ state: 'done', updatedAt: 0, ...mainAgentDone('interruption') }), stale)
).toBe('failed')
})
// Title renewal updates the row timestamp without renewing status evidence.
it('ages a status by its evidence, not by a later title renewal, as desktop does', () => {
const now = 100 * 60 * 1000
const renewed = {
state: 'blocked' as const,
updatedAt: now,
evidenceObservedAt: now - 31 * 60 * 1000
}
expect(isAgentStatusFresh(renewed, now)).toBe(false)
expect(agentDotState(renewed, now)).toBe('idle')
})
})
describe('agentRowTimeAt', () => {
it('dates a main agent that failed while its subagents run by its own failure', () => {
expect(
agentRowTimeAt(
row({ state: 'working', stateStartedAt: 100, ...mainAgentDone('failure', 900) })
)
).toBe(900)
})
it('dates every other row by when its state began', () => {
expect(
agentRowTimeAt(
row({ state: 'working', stateStartedAt: 100, ...mainAgentDone('success', 900) })
)
).toBe(100)
expect(
agentRowTimeAt(row({ state: 'done', stateStartedAt: 100, ...mainAgentDone('failure', 900) }))
).toBe(100)
expect(agentRowTimeAt(row({ state: 'working', stateStartedAt: 100 }))).toBe(100)
})
})
describe('agentDisplayLabel', () => {
it('prefers last message, then prompt, then state label', () => {
expect(agentDisplayLabel(row({ lastAssistantMessage: 'hello there' }), 0)).toBe('hello there')
expect(agentDisplayLabel(row({ lastAssistantMessage: ' ', prompt: 'do the thing' }), 0)).toBe(
'do the thing'
)
expect(agentDisplayLabel(row({ state: 'working', prompt: '', updatedAt: 0 }), 0)).toBe(
'Working'
)
expect(
agentDisplayLabel(row({ state: 'working', prompt: '{ "tool": "x"', updatedAt: 0 }), 0)
).toBe('Working')
expect(
agentDisplayLabel(
row({ state: 'working', workingMode: 'monitoring', prompt: '', updatedAt: 0 }),
0
)
).toBe('Monitoring background tasks')
})
it("says Stopping while the host says a person's Stop is ending the turn", () => {
const stopping = row({
lastAssistantMessage: 'hello there',
mainAgent: { state: 'working', stopping: true, stateStartedAt: 0 }
})
expect(agentDisplayLabel(stopping, 0)).toBe('Stopping…')
expect(agentDotState(stopping, 0)).toBe('working')
expect(agentDisplayLabel({ ...stopping, conversationName: 'Orca PR rebase' }, 0)).toBe(
'Stopping… · Orca PR rebase'
)
})
it('leads with the host-sent conversation name, like the desktop row', () => {
const named = row({ state: 'done', conversationName: 'Orca PR rebase', prompt: 'rebase it' })
expect(agentDisplayLabel(named, 0)).toBe('Orca PR rebase')
expect(agentDisplayLabel({ ...named, lastAssistantMessage: 'PR is closed' }, 0)).toBe(
'Orca PR rebase · PR is closed'
)
})
it('falls back to the decayed state label when stale', () => {
expect(agentDisplayLabel(row({ state: 'working', prompt: '', updatedAt: 0 }), 1_800_001)).toBe(
'Idle'
)
})
it('keeps the task text on a silent row and names the silence when there is none', () => {
const silent = row({ state: 'working', updatedAt: 0, hasLivePty: true })
expect(agentDisplayLabel({ ...silent, prompt: 'build' }, 40 * 60_000)).toBe('build')
expect(agentDisplayLabel(silent, 40 * 60_000)).toBe('No recent update')
})
})
describe('formatTimeAgo', () => {
const now = 10_000_000
it('formats across thresholds', () => {
expect(formatTimeAgo(now - 30_000, now)).toBe('just now')
expect(formatTimeAgo(now - 5 * 60_000, now)).toBe('5m')
expect(formatTimeAgo(now - 3 * 3_600_000, now)).toBe('3h')
expect(formatTimeAgo(now - 2 * 86_400_000, now)).toBe('2d')
})
})
@@ -9,28 +9,6 @@ import { AcpAgentError, AcpRequestTimeoutError } from './acp-errors'
import { AcpScriptedAgent } from './acp-scripted-agent.test-support'
import { agentSessionFailureFact } from '../../shared/agent-session/failures/agent-session-failure'
import { agentSessionFailureWords } from '../../shared/agent-session/failures/agent-session-failure-words'
import { acpReopenTakeover } from './acp-session-reopen-failure'
it.each(['ServerUnavailable', 'CatalogNotReady'])(
'retains the saved session on a temporary %s load answer',
(errorName) => {
const error = new AcpAgentError(-32603, 'not ready', { errorName })
const warn = vi.fn()
expect(() =>
acpReopenTakeover(
error,
{
key: 'saved-key',
sessionId: 'saved-id',
mayBeUnsaved: () => false,
unannouncedLosses: () => []
},
{ over: false, now: 1, warn }
)
).toThrow(error)
expect(warn).not.toHaveBeenCalled()
}
)
afterEach(async () => {
vi.useRealTimers()
@@ -1,139 +0,0 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { AgentHookServer } from './server'
import { installHookStatusSessionTabsRepublish } from './hook-status-session-tabs-republish'
import { AGENT_STATUS_STALE_AFTER_MS } from '../../shared/agent-status/contracts/agent-status-freshness'
import {
createMobileSessionTabsAgentStatusHeartbeat,
SESSION_TABS_AGENT_STATUS_HEARTBEAT_INTERVAL_MS
} from '../runtime/mobile-session-tabs-agent-status-heartbeat'
const PANE = 'tab-provider:11111111-1111-4111-8111-111111111111'
function providerOnly(server: AgentHookServer, transcriptPath: string): void {
server.ingestRemote(
{
paneKey: PANE,
tabId: 'tab-provider',
worktreeId: 'repo::/worktree',
providerSession: { key: 'session_id', id: 'pi-session', transcriptPath },
providerSessionOnly: true,
payload: { state: 'done', prompt: '', agentType: 'pi' }
},
null
)
}
describe('hook status session-tabs republish', () => {
afterEach(() => {
vi.useRealTimers()
})
it('delivers provider-only changes and authority retirement from the owner mutation stream', () => {
const server = new AgentHookServer()
const touch = vi.fn()
const uninstall = installHookStatusSessionTabsRepublish(server, () => ({
getTerminalWorktreeIdForHandle: () => null,
getTerminalWorktreeIdForPaneKey: () => null,
scheduleMobileSessionTabsAgentStatusHeartbeatForWorktree: vi.fn(),
touchMobileSessionTabsForWorktree: touch
}))
try {
providerOnly(server, '/sessions/first.jsonl')
expect(touch).toHaveBeenLastCalledWith('repo::/worktree')
touch.mockClear()
providerOnly(server, '/sessions/first.jsonl')
expect(touch).not.toHaveBeenCalled()
providerOnly(server, '/sessions/replaced.jsonl')
expect(touch).toHaveBeenCalledTimes(1)
touch.mockClear()
server.retirePaneAuthority(PANE)
expect(touch).toHaveBeenCalledTimes(1)
expect(touch).toHaveBeenCalledWith('repo::/worktree')
} finally {
uninstall()
}
})
it('deduplicates the old and new ownership of one moved row', () => {
const server = new AgentHookServer()
const touch = vi.fn()
providerOnly(server, '/sessions/first.jsonl')
const uninstall = installHookStatusSessionTabsRepublish(server, () => ({
getTerminalWorktreeIdForHandle: () => null,
getTerminalWorktreeIdForPaneKey: () => null,
scheduleMobileSessionTabsAgentStatusHeartbeatForWorktree: vi.fn(),
touchMobileSessionTabsForWorktree: touch
}))
try {
server.transferPaneAuthority(PANE, 'tab-new:22222222-2222-4222-8222-222222222222')
expect(touch).toHaveBeenCalledTimes(1)
expect(touch).toHaveBeenCalledWith('repo::/worktree')
} finally {
uninstall()
}
})
it('renews mobile freshness across its lease through a bounded heartbeat cadence', () => {
vi.useFakeTimers()
vi.setSystemTime(1_000)
const server = new AgentHookServer()
const publications: number[] = []
const rowMutations = vi.fn()
const enrichedStatuses = vi.fn()
const semanticStatuses = vi.fn()
let heartbeat: ReturnType<typeof createMobileSessionTabsAgentStatusHeartbeat>
const runtime = {
getTerminalWorktreeIdForHandle: () => null,
getTerminalWorktreeIdForPaneKey: () => null,
scheduleMobileSessionTabsAgentStatusHeartbeatForWorktree: (worktreeId: string) =>
heartbeat.scheduleWorktreeHeartbeat(worktreeId),
touchMobileSessionTabsForWorktree: (worktreeId: string) => {
heartbeat.observeWorktreeRefresh(worktreeId)
publications.push(Date.now())
}
}
heartbeat = createMobileSessionTabsAgentStatusHeartbeat(
() => [],
(worktreeId) => runtime.touchMobileSessionTabsForWorktree(worktreeId)
)
const uninstall = installHookStatusSessionTabsRepublish(server, () => runtime)
server.subscribeStatusRowMutations(rowMutations)
server.subscribeEnrichedStatus(enrichedStatuses)
server.subscribeStatusChanges(semanticStatuses)
const observation = {
paneKey: PANE,
tabId: 'tab-provider',
worktreeId: 'repo::/worktree',
payload: { state: 'working' as const, prompt: 'active', agentType: 'codex' as const }
}
try {
server.ingestTerminalStatus(observation)
for (let minute = 1; minute <= 31; minute += 1) {
vi.advanceTimersByTime(60_000)
server.ingestTerminalStatus(observation)
vi.runOnlyPendingTimers()
}
expect(Date.now()).toBeGreaterThan(1_000 + AGENT_STATUS_STALE_AFTER_MS)
const renewed = server.getStatusSnapshot()[0]
expect(renewed?.state).toBe('working')
expect(Date.now() - renewed!.receivedAt).toBeLessThan(AGENT_STATUS_STALE_AFTER_MS)
expect(publications).toEqual([
1_000,
1_000 + SESSION_TABS_AGENT_STATUS_HEARTBEAT_INTERVAL_MS,
1_000 + SESSION_TABS_AGENT_STATUS_HEARTBEAT_INTERVAL_MS * 2
])
expect(rowMutations).toHaveBeenCalledTimes(1)
expect(enrichedStatuses).toHaveBeenCalledTimes(1)
expect(semanticStatuses).toHaveBeenCalledTimes(1)
} finally {
uninstall()
heartbeat.dispose()
server.stop()
}
})
})
@@ -12,7 +12,6 @@ import { fileIdentity } from './session-search-file-cursor'
import type { SessionSearchFileWrite } from './session-search-index-writer'
import type { SessionSearchResumableCursor, SessionSearchStore } from './session-search-store'
// File-read assumptions are checked by session-search-transcript-dependencies.test.ts.
export const SELF_CONTAINED_TRANSCRIPT_AGENTS = {
claude: true,
qoder: true,
@@ -1,130 +0,0 @@
import * as fs from 'node:fs'
import * as fsPromises from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterAll, afterEach, beforeAll, expect, it, vi } from 'vitest'
import { AI_VAULT_AGENTS } from '../../shared/ai-vault/contracts/ai-vault-types'
import * as transcriptFs from '../native-chat/wsl-transcript-fs-access'
import { parseAgentSessionFile } from '../ai-vault/session-scanner-agent-parser'
import { AI_VAULT_AGENT_SOURCES } from '../ai-vault/session-scanner-agent-sources'
import { discoverFiles } from '../ai-vault/session-scanner-discovery'
import {
writeEveryAgentVault,
type EveryAgentVault
} from '../ai-vault/session-scanner-every-agent-fixture'
import { geminiJsonlFixture } from '../ai-vault/session-scanner-incremental-fixtures'
import { parseAgentSessionFileCached } from '../ai-vault/parse-cache/session-scanner-parse-cache'
import { resetSessionParseCacheForTests } from '../ai-vault/parse-cache/session-parse-cache-store'
import {
registerTranscriptConsumer,
resetTranscriptConsumersForTests,
type TranscriptMessage
} from '../ai-vault/transcripts/session-transcript-consumers'
import { SELF_CONTAINED_TRANSCRIPT_AGENTS } from './session-search-index-consumer'
vi.mock('node:fs', async (importOriginal) => {
const original = await importOriginal<typeof fs & { default: typeof fs }>()
// Recursive spies alter native stream prototypes and prevent readers from finishing.
const tracked = {
readFile: vi.fn(original.readFile),
readFileSync: vi.fn(original.readFileSync),
open: vi.fn(original.open),
openSync: vi.fn(original.openSync),
createReadStream: vi.fn(original.createReadStream)
}
return { ...original, ...tracked, default: { ...original.default, ...tracked } }
})
vi.mock('node:fs/promises', { spy: true })
vi.mock('../native-chat/wsl-transcript-fs-access', { spy: true })
let root: string
let vault: EveryAgentVault
beforeAll(async () => {
root = await fsPromises.mkdtemp(join(tmpdir(), 'ss-transcript-dependencies-'))
vault = await writeEveryAgentVault(root)
const gemini = geminiJsonlFixture()
await fsPromises.writeFile(
join(vault.roots.geminiSessionsDir, gemini.fileName),
`${gemini.seedLines.join('\n')}\n`
)
})
afterEach(() => {
vi.clearAllMocks()
resetTranscriptConsumersForTests()
resetSessionParseCacheForTests()
})
afterAll(async () => {
await fsPromises.rm(root, { recursive: true, force: true })
})
function contentReadPaths(): unknown[] {
return [
...vi.mocked(fsPromises.readFile).mock.calls.map(([path]) => path),
...vi.mocked(fsPromises.open).mock.calls.map(([path]) => path),
...vi.mocked(fs.readFile).mock.calls.map(([path]) => path),
...vi.mocked(fs.readFileSync).mock.calls.map(([path]) => path),
...vi.mocked(fs.open).mock.calls.map(([path]) => path),
...vi.mocked(fs.openSync).mock.calls.map(([path]) => path),
...vi.mocked(fs.createReadStream).mock.calls.map(([path]) => path),
...vi.mocked(transcriptFs.wslGatedReadFile).mock.calls.map(([path]) => path),
...vi.mocked(transcriptFs.openTranscriptFile).mock.calls.map(([path]) => path),
...vi.mocked(transcriptFs.openTranscriptReadStream).mock.calls.map(([path]) => path),
...vi.mocked(transcriptFs.readTranscriptSlice).mock.calls.map(([path]) => path)
]
}
it.each(AI_VAULT_AGENTS.filter((agent) => SELF_CONTAINED_TRANSCRIPT_AGENTS[agent]))(
'%s reads indexed content only from its candidate transcript',
async (agent) => {
const source = AI_VAULT_AGENT_SOURCES[agent]
if (!source) {
throw new Error(`${agent} needs a real transcript fixture before enabling file-stat reuse`)
}
expect(source.contentDependencyPath).toBeUndefined()
const discoveries = await Promise.all(
source.rootDirs(vault.roots, []).map((rootDir) =>
discoverFiles({
...source,
rootDir,
agent,
limit: 10,
issues: [],
extensions: [...source.extensions]
})
)
)
const files = discoveries.flatMap((discovery) => discovery.files)
expect(files.length).toBeGreaterThan(0)
for (const file of files) {
const candidate = { agent, file, codexHome: null }
for (const read of ['direct', 'cached'] as const) {
const messages: TranscriptMessage[] = []
const sink = { active: true, push: (message: TranscriptMessage) => messages.push(message) }
resetTranscriptConsumersForTests()
resetSessionParseCacheForTests()
registerTranscriptConsumer({
beginRead: () => ({ message: sink.push, finish: () => undefined })
})
vi.clearAllMocks()
const session = await (read === 'direct'
? parseAgentSessionFile(candidate, process.platform, sink)
: parseAgentSessionFileCached(candidate, process.platform))
expect(session).not.toBeNull()
expect(messages.length).toBeGreaterThan(0)
const paths = contentReadPaths()
expect(paths).toContain(file.path)
// Include failed optional reads: an absent sidecar can appear on a later scan.
expect(
paths.filter((path) => path !== file.path),
`${read}: ${file.path}`
).toEqual([])
}
}
}
)
@@ -1,170 +0,0 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { Dirent } from 'node:fs'
import type * as NodeFsPromisesModule from 'node:fs/promises'
import type { AiVaultScanIssue } from '../../shared/ai-vault/contracts/ai-vault-types'
const fsMocks = vi.hoisted(() => ({ readdir: vi.fn(), stat: vi.fn() }))
vi.mock('node:fs/promises', async (importOriginal) => ({
...(await importOriginal<typeof NodeFsPromisesModule>()),
readdir: fsMocks.readdir,
stat: fsMocks.stat
}))
import {
resetWslTranscriptFsGateForTests,
WSL_TRANSCRIPT_FS_SCAN_TIMEOUT_MS,
WslTranscriptFsError
} from '../native-chat/wsl-transcript-fs-gate'
import { discoverFiles, walkSessionFiles } from './session-scanner-discovery'
const SLOW_MESSAGE =
'WSL transcript files are temporarily unavailable because filesystem access is taking too long. Try again shortly or restart Orca if the issue continues.'
// UNC child serialization reads every Dirent kind flag.
function dirent(name: string): Dirent {
return {
name,
isBlockDevice: () => false,
isCharacterDevice: () => false,
isDirectory: () => false,
isFIFO: () => false,
isFile: () => true,
isSocket: () => false,
isSymbolicLink: () => false
} as Dirent
}
let releaseStall: (() => void) | undefined
function stalls<T>(): Promise<T> {
return new Promise<T>((resolve) => {
releaseStall = () => resolve(undefined as T)
})
}
beforeEach(() => {
resetWslTranscriptFsGateForTests()
fsMocks.readdir.mockReset()
fsMocks.stat.mockReset()
fsMocks.readdir.mockResolvedValue([])
releaseStall = undefined
})
// Settle stalled calls to release permits before the next test.
afterEach(async () => {
if (!vi.isFakeTimers()) {
return
}
releaseStall?.()
await vi.advanceTimersByTimeAsync(0)
vi.useRealTimers()
})
describe('walkSessionFiles WSL gate refusals', () => {
it('rethrows gate refusals instead of reporting an empty tree', async () => {
const refusal = new WslTranscriptFsError('timeout', 'slow share')
await expect(
walkSessionFiles('\\\\wsl.localhost\\Ubuntu\\home\\ada\\.codex\\sessions', 'codex', [], {
extensions: new Set(['.jsonl']),
readDirectory: async () => {
throw refusal
}
})
).rejects.toBe(refusal)
})
it('still treats ordinary readdir failures as an empty tree', async () => {
await expect(
walkSessionFiles('/missing/root', 'codex', [], {
extensions: new Set(['.jsonl']),
readDirectory: async () => {
throw Object.assign(new Error('no such directory'), { code: 'ENOENT' })
}
})
).resolves.toEqual([])
})
})
describe('discoverFiles containment for a stalled WSL root', () => {
const UNC_ROOT = '\\\\wsl.localhost\\Ubuntu\\home\\ada\\.codex\\sessions'
it('contains a stalled readdir to one root so a Promise.all fan-out still resolves', async () => {
vi.useFakeTimers()
{
fsMocks.readdir.mockImplementation((dir: string) =>
dir === UNC_ROOT ? stalls<Dirent[]>() : Promise.resolve([])
)
const issues: AiVaultScanIssue[] = []
const discovery = discoverFiles({
rootDir: UNC_ROOT,
limit: 10,
agent: 'codex',
issues,
extensions: ['.jsonl']
})
const healthy = discoverFiles({
rootDir: '/home/ada/.codex/sessions',
limit: 10,
agent: 'codex',
issues: [],
extensions: ['.jsonl']
})
await vi.advanceTimersByTimeAsync(WSL_TRANSCRIPT_FS_SCAN_TIMEOUT_MS + 1)
await expect(Promise.all([discovery, healthy])).resolves.toEqual([
{ agent: 'codex', rootDir: UNC_ROOT, files: [] },
{ agent: 'codex', rootDir: '/home/ada/.codex/sessions', files: [] }
])
expect(issues).toEqual([
{
agent: 'codex',
path: UNC_ROOT,
message: SLOW_MESSAGE,
messageCode: 'wslTranscriptFsSlow'
}
])
}
})
it('reports every stalled file without spending one deadline per file', async () => {
vi.useFakeTimers()
{
const names = ['a', 'b', 'c', 'd', 'e'].map((name) => dirent(`${name}.jsonl`))
fsMocks.readdir.mockResolvedValue(names)
fsMocks.stat.mockImplementation(stalls)
const issues: AiVaultScanIssue[] = []
const discovery = discoverFiles({
rootDir: UNC_ROOT,
limit: 10,
agent: 'codex',
issues,
extensions: ['.jsonl']
})
// Route quarantine makes later stats fail fast; only the first stalled operations reach deadlines.
await vi.advanceTimersByTimeAsync(WSL_TRANSCRIPT_FS_SCAN_TIMEOUT_MS * 2 + 2)
await expect(discovery).resolves.toEqual({ agent: 'codex', rootDir: UNC_ROOT, files: [] })
expect(issues).toHaveLength(names.length)
expect(issues.every((issue) => issue.path.startsWith(UNC_ROOT))).toBe(true)
}
})
})
it('execution admission preserves native access errors while confirmed missing roots remain absent', async () => {
for (const code of ['EACCES', 'ENOENT']) {
const failure = Object.assign(new Error(code), { code })
const search = walkSessionFiles('/account/sessions', 'codex', [], {
extensions: new Set(['.jsonl']),
strictErrors: true,
readDirectory: async () => {
throw failure
}
})
await (code === 'ENOENT'
? expect(search).resolves.toEqual([])
: expect(search).rejects.toBe(failure))
}
})
@@ -1,170 +0,0 @@
import { afterEach, describe, expect, it } from 'vitest'
import type { AgentSessionProviderHandle } from '../../shared/agent-session/lifecycle/agent-session-provider-handle'
import { setStructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-registry'
import { assertLegacyAiVaultResumeCommandAllowed } from './structured-session-ownership'
import { installOwnership, PROVIDER_SESSION } from './structured-session-ownership.test-support'
const OPENCODE_ID = 'ses_2f9aB3cD4eF5gH6iJ7kL8mN9oP'
const UUID = '019fd622-3c4d-7e5f-a061-7c8d9e0f1a2b'
const OTHER_UUID = '019fd633-4d5e-7f60-b172-8d9e0f1a2b3c'
const PI_FILE = `/Users/me/.pi/agent/sessions/--Users-me-repo--/2026-10-09T10-00-00-000Z_${UUID}.jsonl`
type Agent = {
name: string
own: () => void
/** A resume of the owned conversation, without its program. */
resumeArgs: string
/** `--flag=` forms, each completed by a quoted target. */
inlineFlags: string[]
otherId: string
}
const acp = (agent: 'opencode' | 'grok' | 'omp', nativeId: string): AgentSessionProviderHandle => ({
transport: 'acp',
agent,
nativeId
})
const AGENTS: Agent[] = [
{
name: 'claude',
own: () => installOwnership({ provider: 'claude' }),
resumeArgs: `-r ${PROVIDER_SESSION}`,
inlineFlags: ['--resume=', '-r='],
otherId: OTHER_UUID
},
{
name: 'codex',
own: () => installOwnership(),
resumeArgs: `resume ${PROVIDER_SESSION}`,
inlineFlags: [],
otherId: OTHER_UUID
},
{
name: 'opencode',
own: () => installOwnership({ handle: acp('opencode', OPENCODE_ID) }),
resumeArgs: `-s ${OPENCODE_ID}`,
inlineFlags: ['--session=', '-s='],
otherId: 'ses_other'
},
{
name: 'grok',
own: () => installOwnership({ handle: acp('grok', UUID) }),
resumeArgs: `-r ${UUID}`,
inlineFlags: ['--resume=', '-r=', '--load='],
otherId: OTHER_UUID
},
{
name: 'omp',
own: () => installOwnership({ handle: acp('omp', UUID) }),
resumeArgs: `--resume ${UUID}`,
inlineFlags: ['--resume=', '-r=', '--session='],
otherId: OTHER_UUID
},
{
name: 'pi',
own: () =>
installOwnership({ handle: { transport: 'jsonl-rpc', agent: 'pi', nativeId: PI_FILE } }),
resumeArgs: `--session ${UUID}`,
inlineFlags: ['--session='],
otherId: OTHER_UUID
}
]
const allowed = (command: string) =>
expect(
assertLegacyAiVaultResumeCommandAllowed(command, async () => undefined),
command
).resolves.toBeUndefined()
const refused = (command: string) =>
expect(
assertLegacyAiVaultResumeCommandAllowed(command, async () => undefined),
command
).rejects.toThrow('agent_session_conflict')
function ownedId(agent: Agent): string {
return agent.resumeArgs.split(' ').at(-1)!
}
afterEach(() => setStructuredAgentSessionHost(null))
describe.each(AGENTS)('a $name resume read as a shell reads it', (agent) => {
const resume = `${agent.name} ${agent.resumeArgs}`
it('ends at a separator glued to a word', async () => {
agent.own()
for (const command of [
`${resume}; echo done`,
`${resume};`,
`${resume}|tee x`,
`${resume}&`,
`${resume}&&echo done`,
`cd /x&&${resume}`,
`cd /x||${resume}`,
`cd /x;${resume}`,
`cd /x|${resume}`,
`echo hi 2>&1;${resume} 2>&1`
]) {
await refused(command)
}
})
it('keeps a quoted separator inside its word', async () => {
agent.own()
await refused(`${agent.name} "a;b" 'c|d' ${agent.resumeArgs}`)
await allowed(`echo "x;${resume}" 'y&&${resume}'`)
})
it('removes the quotes around a value after `=`', async () => {
agent.own()
for (const flag of agent.inlineFlags) {
await refused(`${agent.name} ${flag}"${ownedId(agent)}"`)
await refused(`${agent.name} ${flag}'${ownedId(agent)}'`)
await allowed(`${agent.name} ${flag}"${agent.otherId}"`)
}
})
})
// Agent names count only where the shell would run them, including text sent from agent prompts.
describe.each(AGENTS)('a $name resume in command position', (agent) => {
const resume = `${agent.name} ${agent.resumeArgs}`
it('is refused behind assignments, a path or a launcher', async () => {
agent.own()
for (const command of [
`FOO=1 BAR='a b' ${resume}`,
`/usr/local/bin/${resume}`,
`${agent.name[0]!.toUpperCase()}${agent.name.slice(1)} ${agent.resumeArgs}`,
`env -i FOO=1 ${resume}`,
`exec ${resume}`,
`command ${resume}`,
`sudo -E ${resume}`,
`npx ${resume}`,
`bunx ${resume}`,
`pnpm dlx ${resume}`,
`& 'C:\\Program Files\\${agent.name}\\${agent.name}.exe' ${agent.resumeArgs}`,
`cd /d "C:/x" && ${resume}`,
`echo hi\n${resume}`
]) {
await refused(command)
}
})
it('is allowed as another program’s argument or prompt text', async () => {
agent.own()
for (const command of [
`Fix the ${agent.name} ${agent.resumeArgs} handling`,
`echo ${resume}`,
`git commit -m wip ${resume}`,
`rg -n ${resume}`
]) {
await allowed(command)
}
})
})
it('allows a prompt that names a Pi flag while a Pi chat owns a conversation', async () => {
AGENTS.find(({ name }) => name === 'pi')!.own()
await allowed('Fix the Pi --resume flag')
await allowed('The pi -c path is broken.\nPlease fix pi --continue too')
})
@@ -1,153 +0,0 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { createBitbucketPullRequest } from './pull-request-creation'
import { _resetBitbucketRepoRefCache } from './repository-ref'
const { gitExecFileAsyncMock } = vi.hoisted(() => ({
gitExecFileAsyncMock: vi.fn()
}))
vi.mock('../git/command-runner/git-exec-file', async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
gitExecFileAsync: gitExecFileAsyncMock
}))
vi.mock('../source-control/pull-request-template', () => ({
readHostedPullRequestTemplate: vi.fn(async () => 'Template body')
}))
const OLD_ENV = process.env
const OLD_FETCH = globalThis.fetch
const CREATE_INPUT = {
provider: 'bitbucket',
base: 'main',
head: 'feature/login',
title: 'Add login'
} as const
function createdPullRequestResponse(): Response {
return Response.json({
id: 42,
title: 'Add login',
state: 'OPEN',
updated_on: '2026-08-11T00:00:00Z',
links: { html: { href: 'https://bitbucket.org/team/repo/pull-requests/42' } },
source: { branch: { name: 'feature/login' } },
destination: { branch: { name: 'main' } }
})
}
describe('Bitbucket pull request creation', () => {
beforeEach(() => {
process.env = { ...OLD_ENV, ORCA_BITBUCKET_ACCESS_TOKEN: 'bb-token' }
gitExecFileAsyncMock.mockReset()
gitExecFileAsyncMock.mockResolvedValue({
stdout: 'https://bitbucket.org/team/repo.git\n',
stderr: ''
})
_resetBitbucketRepoRefCache()
})
afterEach(() => {
process.env = OLD_ENV
globalThis.fetch = OLD_FETCH
_resetBitbucketRepoRefCache()
})
it('posts the Bitbucket source/destination create body to the repository endpoint', async () => {
const fetchMock = vi.fn(async (input: string | URL | Request, init?: RequestInit) => {
const url = new URL(String(input))
expect(url.origin).toBe('https://api.bitbucket.org')
expect(url.pathname).toBe('/2.0/repositories/team/repo/pullrequests')
const requestInit = init!
expect(requestInit.method).toBe('POST')
expect((requestInit.headers as Record<string, string>).Authorization).toBe('Bearer bb-token')
expect(JSON.parse(String(requestInit.body))).toEqual({
title: 'Add login',
description: '',
source: { branch: { name: 'feature/login' } },
destination: { branch: { name: 'main' } }
})
return createdPullRequestResponse()
})
globalThis.fetch = fetchMock as unknown as typeof fetch
await expect(createBitbucketPullRequest('/repo', CREATE_INPUT, 'local')).resolves.toEqual({
ok: true,
number: 42,
url: 'https://bitbucket.org/team/repo/pull-requests/42'
})
expect(fetchMock).toHaveBeenCalledTimes(1)
})
it('ignores a draft request rather than dead-ending a hidden persisted default', async () => {
const fetchMock = vi.fn(async () => createdPullRequestResponse())
globalThis.fetch = fetchMock as unknown as typeof fetch
await expect(
createBitbucketPullRequest('/repo', { ...CREATE_INPUT, draft: true }, 'local')
).resolves.toMatchObject({ ok: true, number: 42 })
expect(JSON.parse(String((fetchMock.mock.calls[0] as never[])[1]['body']))).not.toHaveProperty(
'draft'
)
})
it('maps a duplicate-branch rejection to already_exists with the existing review', async () => {
let call = 0
const fetchMock = vi.fn(async () => {
call += 1
if (call === 1) {
return Response.json(
{ error: { message: 'There is already a pull request for this branch.' } },
{ status: 400 }
)
}
return Response.json({
values: [
{
id: 7,
title: 'Add login',
state: 'OPEN',
updated_on: '2026-08-11T00:00:00Z',
links: { html: { href: 'https://bitbucket.org/team/repo/pull-requests/7' } },
source: { branch: { name: 'feature/login' } },
destination: { branch: { name: 'main' } }
}
]
})
})
globalThis.fetch = fetchMock as unknown as typeof fetch
expect(await createBitbucketPullRequest('/repo', CREATE_INPUT, 'local')).toMatchObject({
ok: false,
code: 'already_exists',
existingReview: { number: 7, url: 'https://bitbucket.org/team/repo/pull-requests/7' }
})
})
it('maps a 401 to auth_required pointing at both credential paths', async () => {
globalThis.fetch = vi.fn(async () =>
Response.json({ error: { message: 'Unauthorized' } }, { status: 401 })
) as unknown as typeof fetch
const result = await createBitbucketPullRequest('/repo', CREATE_INPUT, 'local')
expect(result).toMatchObject({ ok: false, code: 'auth_required', reason: 'auth_required' })
expect(!result.ok && result.error).toContain('Settings')
})
it('refuses a non-Bitbucket remote', async () => {
gitExecFileAsyncMock.mockResolvedValue({
stdout: 'https://github.com/team/repo.git\n',
stderr: ''
})
globalThis.fetch = vi.fn() as unknown as typeof fetch
await expect(createBitbucketPullRequest('/repo', CREATE_INPUT, 'local')).resolves.toMatchObject(
{
ok: false,
code: 'unsupported_provider'
}
)
})
})
@@ -1,132 +0,0 @@
import { describe, it, expect } from 'vitest'
import { imeFallbackKeyEvent, parseCdpKeyEvent, type CdpKeyEvent } from './cdp-keyboard-us-layout'
describe('parseCdpKeyEvent', () => {
it('maps every printable ASCII character to a key event that types that character', () => {
const broken: string[] = []
for (let charCode = 32; charCode <= 126; charCode++) {
const ch = String.fromCharCode(charCode)
const parsed = parseCdpKeyEvent(ch)
if (!parsed || parsed.text !== ch || parsed.keyCode === 0) {
broken.push(ch)
}
}
expect(broken).toEqual([])
})
it.each([
['#', 51],
['$', 52],
['%', 53],
['&', 55],
["'", 222],
['(', 57],
['.', 190]
])(
'gives %s the US-layout key code %i instead of its own char code',
(ch: string, keyCode: number) => {
// '&' and '.' character codes invoke Up and Delete instead of typing.
expect(parseCdpKeyEvent(ch)).toMatchObject({ keyCode, text: ch })
}
)
it.each([
['Ctrl+A', { keyCode: 65, key: 'a', modifiers: 2, text: null }],
['Control+a', { keyCode: 65, key: 'a', modifiers: 2, text: null }],
['Shift+Home', { keyCode: 36, key: 'Home', modifiers: 8, text: null }],
['Alt+ArrowDown', { keyCode: 40, key: 'ArrowDown', modifiers: 1, text: null }],
['Ctrl+Shift+K', { keyCode: 75, key: 'K', modifiers: 10, text: null }],
['Meta+r', { keyCode: 82, key: 'r', modifiers: 4, text: null }],
['Control+Shift+r', { keyCode: 82, key: 'R', modifiers: 10, text: null }]
])('parses the shortcut %s', (raw: string, expected: Partial<CdpKeyEvent>) => {
expect(parseCdpKeyEvent(raw)).toMatchObject(expected)
})
it('treats a capital letter in a shortcut as the key name, not a shift request', () => {
expect(parseCdpKeyEvent('Ctrl+A')).toMatchObject({ key: 'a', modifiers: 2 })
expect(parseCdpKeyEvent('Ctrl+Shift+A')).toMatchObject({ key: 'A', modifiers: 10 })
})
it('shifts a bare capital letter and reports the shifted character as text', () => {
expect(parseCdpKeyEvent('R')).toMatchObject({ keyCode: 82, key: 'R', modifiers: 8, text: 'R' })
expect(parseCdpKeyEvent('Shift+a')).toMatchObject({ key: 'A', modifiers: 8, text: 'A' })
})
it('maps shifted punctuation onto its base key with shift held', () => {
expect(parseCdpKeyEvent('Shift+1')).toMatchObject({ keyCode: 49, key: '!', text: '!' })
expect(parseCdpKeyEvent('+')).toMatchObject({ keyCode: 187, modifiers: 8, text: '+' })
})
it.each([
['Enter', { keyCode: 13, text: '\r' }],
['Space', { keyCode: 32, key: ' ', text: ' ' }],
['Esc', { keyCode: 27, key: 'Escape', text: null }],
['PgDn', { keyCode: 34, key: 'PageDown', text: null }],
['ContextMenu', { keyCode: 93, text: null }],
['F5', { keyCode: 116, key: 'F5', code: 'F5', text: null }],
['F12', { keyCode: 123, text: null }]
])('parses the named key %s', (raw: string, expected: Partial<CdpKeyEvent>) => {
expect(parseCdpKeyEvent(raw)).toMatchObject(expected)
})
it.each([
['Shift', { keyCode: 16, key: 'Shift', code: 'ShiftLeft', modifiers: 8, selfModifier: 8 }],
['Ctrl', { keyCode: 17, key: 'Control', code: 'ControlLeft', modifiers: 2, selfModifier: 2 }],
['Alt', { keyCode: 18, key: 'Alt', code: 'AltLeft', modifiers: 1, selfModifier: 1 }],
['Meta', { keyCode: 91, key: 'Meta', code: 'MetaLeft', modifiers: 4, selfModifier: 4 }]
])(
'reports the own modifier bit and left-side location for a bare %s press',
(raw: string, expected: Partial<CdpKeyEvent>) => {
expect(parseCdpKeyEvent(raw)).toMatchObject({ ...expected, location: 1, text: null })
}
)
it('adds the self bit on top of held modifiers for a modifier-only chord', () => {
expect(parseCdpKeyEvent('Ctrl+Shift')).toMatchObject({
keyCode: 16,
modifiers: 10,
selfModifier: 8
})
})
it('reports no self bit or location for non-modifier keys', () => {
expect(parseCdpKeyEvent('Enter')).toMatchObject({ location: 0, selfModifier: 0 })
expect(parseCdpKeyEvent('a')).toMatchObject({ location: 0, selfModifier: 0 })
expect(parseCdpKeyEvent('Ctrl+A')).toMatchObject({ location: 0, selfModifier: 0 })
})
it.each([['MediaPlayPause'], ['F25'], [''], ['NoSuchKey']])(
'returns null for %s so the caller can fall back',
(raw: string) => {
expect(parseCdpKeyEvent(raw)).toBeNull()
}
)
})
describe('imeFallbackKeyEvent', () => {
it.each([['é'], ['ß'], ['ñ'], ['ü'], ['漢'], ['한']])(
'gives %s the IME key event form with keyCode 229 and its text',
(ch: string) => {
expect(imeFallbackKeyEvent(ch)).toEqual({
keyCode: 229,
key: ch,
code: '',
modifiers: 0,
location: 0,
selfModifier: 0,
text: ch
})
}
)
it.each([
['a table-covered ASCII character', 'a'],
['a surrogate-pair emoji', '👍'],
['a combining sequence', 'e\u0301'],
['a multi-character name', 'MediaPlayPause'],
['a chord with a non-US character', 'Ctrl+é'],
['an empty string', '']
])('returns null for %s so the helper keeps its behavior', (_name: string, raw: string) => {
expect(imeFallbackKeyEvent(raw)).toBeNull()
})
})
@@ -1,153 +0,0 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const mocks = vi.hoisted(() => ({
appGetPathMock: vi.fn(() => '/downloads'),
shellOpenExternalMock: vi.fn(),
browserWindowFromWebContentsMock: vi.fn(),
menuBuildFromTemplateMock: vi.fn(),
guestOffMock: vi.fn(),
guestOnMock: vi.fn(),
guestSetBackgroundThrottlingMock: vi.fn(),
guestSetWindowOpenHandlerMock: vi.fn(),
guestOpenDevToolsMock: vi.fn(),
webContentsFromIdMock: vi.fn(),
screenGetCursorScreenPointMock: vi.fn(() => ({ x: 0, y: 0 })),
openPopupWithOriginBarMock: vi.fn()
}))
vi.mock('electron', () => ({
app: { getPath: mocks.appGetPathMock },
BrowserWindow: { fromWebContents: mocks.browserWindowFromWebContentsMock },
clipboard: { writeText: vi.fn() },
shell: { openExternal: mocks.shellOpenExternalMock },
Menu: { buildFromTemplate: mocks.menuBuildFromTemplateMock },
screen: { getCursorScreenPoint: mocks.screenGetCursorScreenPointMock },
webContents: { fromId: mocks.webContentsFromIdMock }
}))
vi.mock('../popups/popup-origin-bar-window', () => ({
openPopupWithOriginBar: mocks.openPopupWithOriginBarMock
}))
import { browserManager } from '../../browser-manager'
import {
rendererWebContentsId,
resetBrowserManagerMocks,
resetBrowserManagerState
} from './browser-manager-test-harness'
import { createViewportGuestFactory } from './browser-manager-viewport-test-fixtures'
const makeGuest = createViewportGuestFactory(mocks)
const OVERRIDE = { width: 375, height: 667, deviceScaleFactor: 2, mobile: true } as const
describe('browserManager viewport partial failure', () => {
beforeEach(() => {
resetBrowserManagerMocks(mocks)
resetBrowserManagerState()
})
it('keeps wheel routing active when follow-up setup fails after metrics apply', async () => {
const { guest, debuggerSendCommand } = makeGuest(42421)
debuggerSendCommand.mockImplementation((method: string) =>
method === 'Emulation.setTouchEmulationEnabled'
? Promise.reject(new Error('touch setup failed'))
: Promise.resolve(undefined)
)
mocks.webContentsFromIdMock.mockReturnValue(guest)
browserManager.attachGuestPolicies(guest as never)
browserManager.registerGuest({
browserPageId: 'tab-partial-apply',
webContentsId: guest.id as number,
rendererWebContentsId
})
const renderer = { isDestroyed: vi.fn(() => false), send: vi.fn() }
mocks.webContentsFromIdMock.mockImplementation((id: number) =>
id === rendererWebContentsId ? renderer : guest
)
await expect(browserManager.setViewportOverride('tab-partial-apply', OVERRIDE)).resolves.toBe(
false
)
browserManager.setViewportScrollState('tab-partial-apply', rendererWebContentsId, {
scrollLeft: 0,
scrollTop: 0,
maxScrollLeft: 400,
maxScrollTop: 300
})
const beforeMouseEvent = mocks.guestOnMock.mock.calls.findLast(
([event]) => event === 'before-mouse-event'
)?.[1] as ((event: Electron.Event, mouse: Electron.MouseInputEvent) => void) | undefined
expect(beforeMouseEvent).toBeDefined()
const preventDefault = vi.fn()
beforeMouseEvent?.(
{ preventDefault } as unknown as Electron.Event,
{
type: 'mouseWheel',
x: 0,
y: 0,
modifiers: [],
deltaX: 0,
deltaY: 120
} as Electron.MouseWheelInputEvent
)
expect(preventDefault).toHaveBeenCalledTimes(1)
expect(renderer.send).toHaveBeenCalledWith('ui:scrollBrowserPage', {
browserPageId: 'tab-partial-apply',
deltaX: 0,
deltaY: 120
})
})
it('keeps host panning available when metrics setup fails', async () => {
const { guest, debuggerSendCommand } = makeGuest(42422)
debuggerSendCommand.mockImplementation((method: string) =>
method === 'Emulation.setDeviceMetricsOverride'
? Promise.reject(new Error('metrics setup failed'))
: Promise.resolve(undefined)
)
mocks.webContentsFromIdMock.mockReturnValue(guest)
browserManager.attachGuestPolicies(guest as never)
browserManager.registerGuest({
browserPageId: 'tab-metrics-failed',
webContentsId: guest.id as number,
rendererWebContentsId
})
const renderer = { isDestroyed: vi.fn(() => false), send: vi.fn() }
mocks.webContentsFromIdMock.mockImplementation((id: number) =>
id === rendererWebContentsId ? renderer : guest
)
await expect(browserManager.setViewportOverride('tab-metrics-failed', OVERRIDE)).resolves.toBe(
false
)
browserManager.setViewportScrollState('tab-metrics-failed', rendererWebContentsId, {
scrollLeft: 0,
scrollTop: 0,
maxScrollLeft: 400,
maxScrollTop: 300
})
const beforeMouseEvent = mocks.guestOnMock.mock.calls.findLast(
([event]) => event === 'before-mouse-event'
)?.[1] as ((event: Electron.Event, mouse: Electron.MouseInputEvent) => void) | undefined
expect(beforeMouseEvent).toBeDefined()
const preventDefault = vi.fn()
beforeMouseEvent?.(
{ preventDefault } as unknown as Electron.Event,
{
type: 'mouseWheel',
x: 0,
y: 0,
modifiers: [],
deltaX: 0,
deltaY: 120
} as Electron.MouseWheelInputEvent
)
expect(preventDefault).toHaveBeenCalledTimes(1)
expect(renderer.send).toHaveBeenCalledWith('ui:scrollBrowserPage', {
browserPageId: 'tab-metrics-failed',
deltaX: 0,
deltaY: 120
})
})
})
@@ -1,132 +0,0 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const browserMocks = vi.hoisted(() => ({
appGetPathMock: vi.fn(() => '/downloads'),
shellOpenExternalMock: vi.fn(),
browserWindowFromWebContentsMock: vi.fn(),
menuBuildFromTemplateMock: vi.fn(),
guestOffMock: vi.fn(),
guestOnMock: vi.fn(),
guestSetBackgroundThrottlingMock: vi.fn(),
guestSetWindowOpenHandlerMock: vi.fn(),
guestOpenDevToolsMock: vi.fn(),
webContentsFromIdMock: vi.fn(),
screenGetCursorScreenPointMock: vi.fn(() => ({ x: 0, y: 0 })),
openPopupWithOriginBarMock: vi.fn(),
processUserAgentMode: 'clean',
processUserAgent:
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.0.0 Safari/537.36'
}))
vi.mock('electron', () => ({
app: { getPath: browserMocks.appGetPathMock },
BrowserWindow: { fromWebContents: browserMocks.browserWindowFromWebContentsMock },
clipboard: { writeText: vi.fn() },
shell: { openExternal: browserMocks.shellOpenExternalMock },
Menu: { buildFromTemplate: browserMocks.menuBuildFromTemplateMock },
screen: { getCursorScreenPoint: browserMocks.screenGetCursorScreenPointMock },
webContents: { fromId: browserMocks.webContentsFromIdMock }
}))
vi.mock('../popups/popup-origin-bar-window', () => ({
openPopupWithOriginBar: browserMocks.openPopupWithOriginBarMock
}))
vi.mock('../../identity/browser-process-user-agent', () => ({
getBrowserProcessUserAgentIdentity: () => ({
mode: browserMocks.processUserAgentMode,
userAgent: browserMocks.processUserAgent,
electronUserAgent:
browserMocks.processUserAgentMode === 'native'
? browserMocks.processUserAgent
: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) orca/1.0.0 Chrome/134.0.0.0 Electron/30.0.0 Safari/537.36'
})
}))
import { browserManager } from '../../browser-manager'
import {
rendererWebContentsId,
resetBrowserManagerMocks,
resetBrowserManagerState
} from './browser-manager-test-harness'
import {
createViewportGuestFactory,
GUEST_CLEAN_UA
} from './browser-manager-viewport-test-fixtures'
const { webContentsFromIdMock } = browserMocks
const makeGuest = createViewportGuestFactory(browserMocks)
const MOBILE_VIEWPORT_OVERRIDE = { width: 375, height: 667, deviceScaleFactor: 2, mobile: true }
const MOBILE_UA_PATTERN = /CriOS\//
function registerGuest(browserPageId: string, webContentsId: number): Electron.Session {
const { guest } = makeGuest(webContentsId)
webContentsFromIdMock.mockReturnValue(guest)
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the shared viewport fixture builds an untyped guest stub; the manager only reads members that stub defines.
browserManager.attachGuestPolicies(guest as never)
browserManager.registerGuest({ browserPageId, webContentsId, rendererWebContentsId })
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: resolveBrowserGuestRequestUserAgent reads no Session member, so the stub only has to be the object the guest carries.
return guest.session as Electron.Session
}
function resolve(session: Electron.Session, webContentsId?: number): string {
return browserManager.resolveBrowserGuestRequestUserAgent({
session,
url: 'https://example.com/asset.js',
webContentsId
}).userAgent
}
// Shared/service worker requests lack a guest ID and must retain their process identity.
describe('worker request identity under viewport emulation', () => {
beforeEach(() => {
resetBrowserManagerMocks(browserMocks)
resetBrowserManagerState()
browserMocks.processUserAgentMode = 'clean'
browserMocks.processUserAgent = GUEST_CLEAN_UA
})
it('keeps a worker request desktop-clean while a tab in the same session is emulated mobile', async () => {
const session = registerGuest('tab-mobile', 4242)
expect(await browserManager.setViewportOverride('tab-mobile', MOBILE_VIEWPORT_OVERRIDE)).toBe(
true
)
expect(resolve(session)).toBe(GUEST_CLEAN_UA)
})
it('still resolves the mobile identity for the emulated tab itself', async () => {
const session = registerGuest('tab-mobile', 4242)
expect(await browserManager.setViewportOverride('tab-mobile', MOBILE_VIEWPORT_OVERRIDE)).toBe(
true
)
expect(resolve(session, 4242)).toMatch(MOBILE_UA_PATTERN)
})
it('leaves a desktop tab desktop-clean while a peer tab in its session is emulated mobile', async () => {
const session = registerGuest('tab-mobile', 4242)
registerGuest('tab-desktop', 4243)
expect(await browserManager.setViewportOverride('tab-mobile', MOBILE_VIEWPORT_OVERRIDE)).toBe(
true
)
expect(resolve(session, 4243)).toBe(GUEST_CLEAN_UA)
})
it('keeps worker requests desktop-clean when no tab is emulated at all', () => {
const session = registerGuest('tab-plain', 4244)
expect(resolve(session)).toBe(GUEST_CLEAN_UA)
})
// Unmapped guests must not inherit another tab's mobile override.
it('keeps an unmapped webContents desktop-clean beside an emulated tab', async () => {
const session = registerGuest('tab-mobile', 4242)
expect(await browserManager.setViewportOverride('tab-mobile', MOBILE_VIEWPORT_OVERRIDE)).toBe(
true
)
expect(resolve(session, 9999)).toBe(GUEST_CLEAN_UA)
})
})
@@ -1,155 +0,0 @@
import { EventEmitter } from 'node:events'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const mocks = vi.hoisted(() => ({
getApplicationNameForProtocol: vi.fn((_url: string) => 'Okta Verify'),
openExternal: vi.fn((_url: string) => Promise.resolve())
}))
vi.mock('electron', () => ({
app: { getApplicationNameForProtocol: mocks.getApplicationNameForProtocol },
shell: { openExternal: mocks.openExternal }
}))
import type { BrowserPagePromptRequest } from '../../../../shared/browser-page-prompt'
import { requestBrowserExternalAppLaunch } from './browser-external-app-launch'
import {
BROWSER_PAGE_PROMPT_TIMEOUT_MS,
respondToBrowserPagePrompt,
type BrowserPagePromptTarget
} from './browser-page-prompts'
const OKTA_URL = 'com-okta-authenticator:/deviceChallenge?challengeRequest=abc'
function mockRenderer(id: number): Electron.WebContents {
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: prompts only read id, isDestroyed, send and subscribe to the window's lifecycle events.
return {
id,
isDestroyed: () => false,
send: vi.fn(),
on: vi.fn(),
off: vi.fn()
} as unknown as Electron.WebContents
}
const requesters: (Electron.WebContents & EventEmitter)[] = []
function mockRequester(): Electron.WebContents & EventEmitter {
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: prompts only read id, isDestroyed and the input, navigation and destroyed events.
const contents = new EventEmitter() as Electron.WebContents & EventEmitter
Object.assign(contents, { id: 1000 + requesters.length, isDestroyed: () => false })
requesters.push(contents)
return contents
}
function ask(rawUrl = OKTA_URL, requestingUrl: string | null = 'https://acme.okta.com/signin') {
return requestBrowserExternalAppLaunch({
requester: mockRequester(),
target: resolveTarget(),
rawUrl,
requestingUrl: () => requestingUrl
})
}
function sentRequests(renderer: Electron.WebContents): BrowserPagePromptRequest[] {
return vi
.mocked(renderer.send)
.mock.calls.filter(([channel]) => channel === 'browser:page-prompt-requested')
.map(([, request]) => request)
}
let resolveTarget: () => BrowserPagePromptTarget | null = () => null
describe('browser external app launch', () => {
let renderer: Electron.WebContents
beforeEach(() => {
vi.useFakeTimers()
mocks.openExternal.mockClear()
renderer = mockRenderer(10)
// Give each request its own page to avoid prompt sharing.
let page = 0
resolveTarget = () => ({ browserPageId: `page-${++page}`, renderer })
})
afterEach(() => {
vi.advanceTimersByTime(BROWSER_PAGE_PROMPT_TIMEOUT_MS)
for (const requester of requesters.splice(0)) {
requester.emit('destroyed')
}
vi.useRealTimers()
})
it('names the app and the asking site, and opens the app only after a yes', async () => {
expect(ask()).toBe(true)
const [request] = sentRequests(renderer)
expect(request).toEqual({
requestId: expect.any(String),
kind: 'external-app',
details: {
appName: process.platform === 'linux' ? null : 'Okta Verify',
scheme: 'com-okta-authenticator',
origin: 'https://acme.okta.com'
}
})
expect(mocks.openExternal).not.toHaveBeenCalled()
await expect(
respondToBrowserPagePrompt(renderer, { requestId: request.requestId, answer: true })
).resolves.toBe('answered')
expect(mocks.openExternal).toHaveBeenCalledWith(OKTA_URL)
ask('zoommtg://zoom.us/join?confno=1')
await respondToBrowserPagePrompt(renderer, {
requestId: sentRequests(renderer)[1].requestId,
answer: null
})
expect(mocks.openExternal).toHaveBeenCalledTimes(1)
})
it.each([
'https://example.com/',
'http://localhost:3000/',
'file:///etc/passwd',
'javascript:alert(1)',
'data:text/html,x',
'blob:https://example.com/id',
'about:blank',
'orca-preview://doc/index.html',
'chrome-extension://nngceckbapebfimnlniiiahkandclblb/popup/index.html',
'orca-extension-api://api/call',
'shell:startup',
'vbscript:msgbox(1)',
'hcp://system/sysinfo',
'ms-help://topic',
'afp://share.example/volume',
'not a url'
])('refuses %s without asking', (rawUrl) => {
expect(ask(rawUrl)).toBe(false)
expect(renderer.send).not.toHaveBeenCalled()
})
it('refuses a page with no window on this computer', () => {
resolveTarget = () => null
expect(ask()).toBe(false)
})
it('keeps blocking the link while its page already has a prompt open', () => {
resolveTarget = () => ({ browserPageId: 'page-1', renderer })
ask()
expect(ask()).toBe(true)
expect(sentRequests(renderer)).toHaveLength(1)
})
it('reports a launch the OS could not start, so the window can say so', async () => {
mocks.openExternal.mockRejectedValueOnce(new Error('no application for scheme'))
ask('zoommtg://zoom.us/join?confno=1')
await expect(
respondToBrowserPagePrompt(renderer, {
requestId: sentRequests(renderer)[0].requestId,
answer: true
})
).resolves.toBe('failed')
})
})
@@ -1,138 +0,0 @@
// Each retry frame must publish activity even when all attempts revise one row.
import { mkdtemp, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest'
import { openTestAgentSessionRecordStore } from '../runtime/agent-session/records/agent-session-record-store-test-harness'
import type { StructuredAgentSessionAdapter } from '../native-chat/agent-session-wire/structured-agent-session-adapter'
import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink'
import { StructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-host'
import { STRUCTURED_AGENT_SESSION_IDLE_MS } from '../native-chat/agent-session-wire/structured-agent-session-idle-sweep'
import {
HOST_TEST_NOW as NOW,
HOST_TEST_SESSION as SESSION,
HOST_TEST_THREAD as THREAD,
hostTestAttachParams,
resetHostTestOperationIds
} from '../native-chat/agent-session-wire/structured-agent-session-host-test-data'
import { createClaudeJournalTranslator } from './structured/journal/claude-structured-journal-translation'
import {
closeTestJournalHostDatabases,
openTestJournalHostDatabase
} from '../native-chat/agent-session-journal/journal-host-database-test-support'
import { createStructuredAgentSessionLogger } from '../native-chat/agent-session-wire/structured-agent-session-logger'
import { codexProviderHandle } from '../../shared/agent-session/lifecycle/agent-session-provider-handle-encoding'
import { NO_STRUCTURED_AGENTS } from '../native-chat/agent-session-wire/structured-agent-session-adapter-router-test-support'
const SWEEP_MS = 5
const RETRY_GAP_MS = 10 * 60_000
let root: string
let host: StructuredAgentSessionHost
let sink: StructuredAgentSessionEventSink | null
let closeSession: Mock<NonNullable<StructuredAgentSessionAdapter['closeSession']>>
let clock: number
function apiRetry(attempt: number): Record<string, unknown> {
return {
type: 'system',
subtype: 'api_retry',
attempt,
max_retries: 10,
retry_delay_ms: 600_000,
error_status: 429,
error: 'rate_limit',
session_id: 'provider-1',
uuid: `9b2f6a1e-0c4d-4e7a-8f3b-00000000000${attempt}`
}
}
function waitOutSeveralSweeps(): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, SWEEP_MS * 20))
}
beforeEach(async () => {
root = await mkdtemp(join(tmpdir(), 'orca-claude-retry-sweep-'))
resetHostTestOperationIds()
sink = null
clock = NOW
closeSession = vi.fn(async () => true)
const store = await openTestAgentSessionRecordStore(root)
const adapter: StructuredAgentSessionAdapter = {
open: async () => ({
reportedOptions: { model: '' }
}),
acquire: async ({ fence, spawnToken, events }) => {
sink = events ?? null
return {
process: { hostId: 'local', pid: 4242, processStartTimeMs: NOW - 1_000, spawnToken },
acquisitionGeneration: 'generation-1',
link: {
linkId: `link-${fence}`,
handle: codexProviderHandle(THREAD),
origin: 'created',
mintedAtFence: fence,
observedAt: NOW
}
}
},
closeSession,
releaseAcquisition: async () => true,
dispatch: async () => ({ state: 'admitted' }),
cancelTurn: async () => ({ cancelled: false }),
answerPrompt: async () => undefined,
setOption: async () => undefined
}
host = new StructuredAgentSessionHost({
agents: NO_STRUCTURED_AGENTS,
logger: createStructuredAgentSessionLogger(),
store,
adapter,
journalDatabase: openTestJournalHostDatabase(root),
claimKeyId: 'key-1',
mintSpawnToken: () => 'spawn-1',
idleSweep: { intervalMs: SWEEP_MS, idleMs: STRUCTURED_AGENT_SESSION_IDLE_MS },
now: () => clock
})
})
afterEach(async () => {
await host.flushAllStreamedEvents()
await closeTestJournalHostDatabases()
await rm(root, { recursive: true, force: true })
})
describe('a Claude retrying a refused request', () => {
it('keeps the conversation open past the idle window while retry frames arrive', async () => {
expect(await host.attach({ callerKey: 'client-1' }, hostTestAttachParams(null))).toMatchObject({
ok: true
})
if (!sink) {
throw new Error('the host never handed the provider its event sink')
}
const translator = createClaudeJournalTranslator({ sink, fallbackIdPrefix: '1' })
for (let attempt = 1; attempt <= 5; attempt += 1) {
clock += RETRY_GAP_MS
translator.handle({ type: 'message', sessionId: SESSION, message: apiRetry(attempt) })
await waitOutSeveralSweeps()
expect(closeSession).not.toHaveBeenCalled()
expect(host.hasSession(SESSION)).toBe(true)
}
const items = host['sessions'].get(SESSION)?.journal.snapshot().items ?? []
const retryRows = items.filter(
(item) => item.body.kind === 'status' && item.body.failure?.kind === 'providerRetrying'
)
expect(retryRows).toHaveLength(1)
expect(retryRows[0]?.body).toMatchObject({
failure: { detail: { text: expect.stringContaining('"attempt":5') } }
})
clock += STRUCTURED_AGENT_SESSION_IDLE_MS
await vi.waitFor(() => {
expect(closeSession).toHaveBeenCalledWith(SESSION, undefined)
expect(host.hasSession(SESSION)).toBe(false)
})
})
})
@@ -1,131 +0,0 @@
import type * as NodeFs from 'node:fs'
import {
chmodSync,
mkdirSync,
mkdtempSync,
readdirSync,
readFileSync,
realpathSync,
rmSync,
statSync,
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 { WslResult, WslSpec } from '../wsl/execution/wsl-runner'
import { grantClaudeFolderTrust } from './claude-folder-trust-file'
const { DISTRO, GUEST_PREFIX, guest } = vi.hoisted(() => ({
DISTRO: 'Ubuntu-24.04',
GUEST_PREFIX: '//wsl.localhost/Ubuntu-24.04',
guest: { root: '' }
}))
function toGuestDisk(path: string): string {
return path.startsWith(GUEST_PREFIX) ? `${guest.root}${path.slice(GUEST_PREFIX.length)}` : path
}
// Model UNC's synthetic 0666 metadata, ignored chmod and guest-default 0644 creation.
vi.mock('node:fs', async (importOriginal) => {
const actual = await importOriginal<typeof NodeFs>()
const onGuest = (path: unknown): path is string =>
typeof path === 'string' && path.startsWith(GUEST_PREFIX)
const disk = (path: string): string => `${guest.root}${path.slice(GUEST_PREFIX.length)}`
const windowsView = (stats: NodeFs.Stats): NodeFs.Stats =>
Object.assign(stats, { mode: (stats.mode & ~0o777) | 0o666 })
return {
...actual,
existsSync: (path: string) => actual.existsSync(onGuest(path) ? disk(path) : path),
lstatSync: (path: string) =>
onGuest(path) ? windowsView(actual.lstatSync(disk(path))) : actual.lstatSync(path),
statSync: (path: string) =>
onGuest(path) ? windowsView(actual.statSync(disk(path))) : actual.statSync(path),
realpathSync: (path: string) =>
onGuest(path)
? `${GUEST_PREFIX}${actual.realpathSync(disk(path)).slice(guest.root.length)}`
: actual.realpathSync(path),
readFileSync: (path: string, options: BufferEncoding) =>
actual.readFileSync(onGuest(path) ? disk(path) : path, options),
writeFileSync: (path: string, data: string, options?: NodeFs.WriteFileOptions) => {
if (!onGuest(path)) {
return actual.writeFileSync(path, data, options)
}
const created = !actual.existsSync(disk(path))
const flag = typeof options === 'object' && options ? options.flag : undefined
actual.writeFileSync(disk(path), data, { flag })
if (created) {
actual.chmodSync(disk(path), 0o644)
}
},
chmodSync: (path: string, mode: number) =>
onGuest(path) ? undefined : actual.chmodSync(path, mode),
renameSync: (from: string, to: string) =>
actual.renameSync(onGuest(from) ? disk(from) : from, onGuest(to) ? disk(to) : to),
rmSync: (path: string, options?: NodeFs.RmOptions) =>
actual.rmSync(onGuest(path) ? disk(path) : path, options)
}
})
vi.mock('proper-lockfile', () => ({
lock: vi.fn(async () => async () => {})
}))
const runWslProcess = vi.hoisted(() => vi.fn<(spec: WslSpec) => Promise<WslResult>>())
vi.mock('../wsl/execution/wsl-runner', () => ({ runWslProcess }))
async function guestChmod(spec: WslSpec): Promise<WslResult> {
const [reference, separator, target] = spec.args ?? []
expect(spec).toMatchObject({ distro: DISTRO, loginPath: 'none', program: 'chmod' })
expect(separator).toBe('--')
const from = `${guest.root}${reference.replace(/^--reference=/, '')}`
chmodSync(`${guest.root}${target}`, statSync(from).mode & 0o7777)
return { environmentResolved: true, code: 0, stdout: '', stderr: '', timedOut: false }
}
const originalPlatform = process.platform
describe.skipIf(originalPlatform === 'win32')('grantClaudeFolderTrust on a WSL guest file', () => {
const guestFile = `${GUEST_PREFIX}/home/u/.claude.json`
beforeEach(() => {
guest.root = realpathSync(mkdtempSync(join(tmpdir(), 'orca-claude-trust-guest-')))
mkdirSync(join(guest.root, 'home/u'), { recursive: true })
writeFileSync(toGuestDisk(guestFile), JSON.stringify({ theme: 'dark' }))
chmodSync(toGuestDisk(guestFile), 0o600)
runWslProcess.mockReset().mockImplementation(guestChmod)
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
})
afterEach(() => {
Object.defineProperty(process, 'platform', { configurable: true, value: originalPlatform })
rmSync(guest.root, { recursive: true, force: true })
})
it("keeps the guest file's owner-only mode through the rewrite", async () => {
await expect(
grantClaudeFolderTrust({ configFile: guestFile, folderKeys: ['/home/u/wt'] })
).resolves.toBe('granted')
expect(statSync(toGuestDisk(guestFile)).mode & 0o777).toBe(0o600)
expect(JSON.parse(readFileSync(toGuestDisk(guestFile), 'utf-8'))).toEqual({
theme: 'dark',
projects: { '/home/u/wt': { hasTrustDialogAccepted: true } }
})
})
it('leaves the file untouched when the guest cannot copy its mode', async () => {
runWslProcess.mockResolvedValue({
environmentResolved: true,
code: 1,
stdout: '',
stderr: "chmod: unrecognized option '--reference'",
timedOut: false
})
await expect(
grantClaudeFolderTrust({ configFile: guestFile, folderKeys: ['/home/u/wt'] })
).rejects.toThrow(/guest mode/)
expect(readFileSync(toGuestDisk(guestFile), 'utf-8')).toBe(JSON.stringify({ theme: 'dark' }))
expect(statSync(toGuestDisk(guestFile)).mode & 0o777).toBe(0o600)
expect(readdirSync(join(guest.root, 'home/u'))).toEqual(['.claude.json'])
})
})
@@ -1,13 +1,8 @@
import { describe, expect, it, vi } from 'vitest'
import { setClaudeStructuredOption } from './launch/claude-structured-options'
import { readClaudeSettingsEffort } from './launch/claude-structured-session-options'
import { describe, expect, it } from 'vitest'
import { ClaudeControlRequestTimeoutError } from '../claude-agent-sdk-control-requests'
import recordedUltracodeSettings from '../__fixtures__/ultracode-settings.json'
import type { ClaudeSession, ClaudeStructuredSessionEvent } from './claude-structured-session-state'
import {
USER_MESSAGE,
acquired,
adapterFor,
claudeFrame,
fakeClaude,
@@ -15,98 +10,7 @@ import {
recordingJournalSink
} from './claude-structured-session-test-support'
/** Verbatim from Claude Code 2.1.258's get_settings response. */
const REAL_SETTINGS = {
applied: { model: 'claude-opus-5[1m]', effort: 'high', advisor: null, ultracode: false },
effective: { model: 'claude-opus-5[1m]', effortLevel: 'high', env: {} },
sources: {}
}
function sessionWith(
reported: string | null,
calls: string[] = [],
listed?: { model: string; catalog: readonly Record<string, unknown>[] }
) {
return {
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the fixture supplies every session member the option paths under test read.
session: {
options: new Map<string, string>(listed ? [['model', listed.model]] : []),
reportedOptions: {} as { model?: string; effort?: string },
optionMutationSequence: 0,
reportedModelMutation: 0,
confirmedOptions: new Set<string>(),
appliedInputOptions: new Map(),
connection: {
supportedModels: async () => {
calls.push('list_models')
return [...(listed?.catalog ?? [])]
},
setModel: async (model: string) => {
calls.push(`set_model:${model}`)
},
applyFlagSettings: async (settings: { effortLevel?: string }) => {
// The CLI accepts and ignores unknown effort values.
calls.push(`apply:${settings.effortLevel}`)
},
getSettings: async () => {
calls.push('get_settings')
return reported === null
? { applied: {}, effective: {}, sources: {} }
: { applied: { effort: reported }, effective: { effortLevel: reported }, sources: {} }
}
},
startup: { state: 'proven' }
} as unknown as ClaudeSession,
calls
}
}
describe('Claude effort reporting', () => {
it('reads the effort get_settings reports', () => {
expect(readClaudeSettingsEffort(REAL_SETTINGS)).toBe('high')
})
it.each([
[
'the provider stops reporting it',
{ applied: { effort: 'high' }, effective: {}, sources: {} }
],
['the payload carries no effective block', { applied: { effort: 'high' } }],
['the request failed outright', null]
])('reports no effort when %s', (_case, settings) => {
expect(readClaudeSettingsEffort(settings)).toBeNull()
})
it('publishes the effort from get_settings, which system/init never carries', async () => {
const claude = fakeClaude({ settings: REAL_SETTINGS })
const adapter = await acquired(claude)
await expect(adapter.readOptions({ sessionId: 'session-1', fence: 7 })).resolves.toMatchObject({
current: { effort: 'high' }
})
})
it('keeps ordinary effort visible when a configured workflow uses another effort', async () => {
const settings = {
...REAL_SETTINGS,
applied: { ...REAL_SETTINGS.applied, ultracode: true }
}
const adapter = await acquired(fakeClaude({ settings }))
expect((await adapter.readOptions({ sessionId: 'session-1', fence: 7 })).current.effort).toBe(
'high'
)
await expect(
adapter.dispatch({
sessionId: 'session-1',
clientMessageId: 'configured-workflow',
body: USER_MESSAGE,
executionOptions: {},
fence: 7
})
).resolves.toMatchObject({ state: 'admitted', ranOptions: { effort: 'high' } })
await adapter.closeAll()
})
it('keeps a configured workflow at launch without confirming saved ordinary XHigh', async () => {
const claude = fakeClaude({
settings: { applied: { effort: 'xhigh', ultracode: true }, effective: {} }
@@ -199,66 +103,4 @@ describe('Claude effort reporting', () => {
expect(connection.sent).toHaveLength(1)
await adapter.closeAll()
})
it('leaves the effort unreported when the session never learns one', async () => {
const model = 'claude-opus-5[1m]'
const claude = fakeClaude({
settings: { applied: { model }, effective: { model }, sources: {} }
})
const adapter = await acquired(claude)
const options = await adapter.readOptions({ sessionId: 'session-1', fence: 7 })
expect(options.current.effort).toBeUndefined()
expect(options.current.model).toBe(model)
})
it('keeps the init fixture free of an effort the real frame never sends', async () => {
const events: ClaudeStructuredSessionEvent[] = []
const adapter = await acquired(fakeClaude(), {}, events)
// Live: init arrives when the first command starts a cycle, not at startup.
await adapter.dispatch({
sessionId: 'session-1',
clientMessageId: 'seed-cycle',
body: USER_MESSAGE,
fence: 7
})
const init = events.flatMap((event) =>
event.type === 'message' && event.message.subtype === 'init' ? [event.message] : []
)
expect(init).toHaveLength(1)
expect(init[0]).toHaveProperty('model')
expect(Object.keys(init[0])).not.toContain('effortLevel')
})
})
describe('Claude effort readback', () => {
it('records an effort the child did not adopt without vouching for it', async () => {
const { session, calls } = sessionWith('high')
// The native enum is valid; a different readback cannot veto a successful control.
await expect(
setClaudeStructuredOption(session, { key: 'effort', value: 'low' }, undefined)
).resolves.toEqual({ effort: 'low' })
expect(session.confirmedOptions.has('effort')).toBe(false)
// The child's own answer is kept rather than discarded with the refusal.
await vi.waitFor(() => expect(session.reportedOptions.effort).toBe('high'))
expect(calls).toEqual(['apply:low', 'get_settings'])
})
it('records an effort the child confirms', async () => {
const { session } = sessionWith('low')
await expect(
setClaudeStructuredOption(session, { key: 'effort', value: 'low' }, undefined)
).resolves.toEqual({ effort: 'low' })
})
it('records the request when the readback is unavailable', async () => {
const { session } = sessionWith(null)
await expect(
setClaudeStructuredOption(session, { key: 'effort', value: 'low' }, undefined)
).resolves.toEqual({ effort: 'low' })
})
})
@@ -1,165 +0,0 @@
import { describe, expect, it } from 'vitest'
import {
CLAUDE_STRUCTURED_BASE_OPTIONS,
claudeStructuredPermissionOptions,
type ClaudeStructuredSdkOptions
} from './claude-structured-launch-resolution'
import { claudeStructuredSpawnOptions } from './claude-structured-spawn-options'
import type { ClaudeCliFlagFact } from '../../claude-cli-flag-support'
function launched(
saved: Record<string, string>,
options: {
resumesTranscript?: boolean
base?: ClaudeStructuredSdkOptions
effortFlag?: ClaudeCliFlagFact
flagSettingsControl?: ClaudeCliFlagFact
} = {}
) {
return claudeStructuredSpawnOptions({
launch: {
options: options.base ?? CLAUDE_STRUCTURED_BASE_OPTIONS,
resumesTranscript: options.resumesTranscript ?? true
},
saved,
effortFlag: options.effortFlag ?? 'supported',
flagSettingsControl: options.flagSettingsControl ?? 'supported'
})
}
const FULL_ACCESS_LAUNCH: ClaudeStructuredSdkOptions = {
...CLAUDE_STRUCTURED_BASE_OPTIONS,
extraArgs: {
...CLAUDE_STRUCTURED_BASE_OPTIONS.extraArgs,
...claudeStructuredPermissionOptions('full-access').extraArgs
}
}
describe('a Claude chat launched with its saved options', () => {
it('passes the saved model, effort and Fast as launch options', () => {
const spawn = launched({ model: 'opus', effort: 'high', fastMode: 'true' })
expect(spawn.sdkOptions).toMatchObject({
model: 'opus',
effort: 'high',
settings: { fastMode: true },
includePartialMessages: true,
extraArgs: { 'replay-user-messages': null }
})
expect(Object.fromEntries(spawn.options)).toEqual({
model: 'opus',
effort: 'high',
fastMode: 'true'
})
})
it.each(['claude-sonnet-5', 'claude-next-9', 'claude-retired-1'])(
'passes the saved model %s as it was picked',
(model) => {
const spawn = launched({ model, effort: 'xhigh' })
expect(spawn.sdkOptions).toMatchObject({ model, effort: 'xhigh' })
}
)
it.each(['unknown', 'unsupported'] as const)(
'keeps saved effort off the command line when --effort is %s',
(flag) => {
const spawn = launched({ model: 'opus', effort: 'high' }, { effortFlag: flag })
expect(spawn.sdkOptions).toMatchObject({ model: 'opus' })
expect(spawn.sdkOptions).not.toHaveProperty('effort')
expect(Object.fromEntries(spawn.options)).toEqual({ model: 'opus' })
}
)
it('leaves out an effort no Claude can parse, and Fast it cannot decode', () => {
const spawn = launched({ effort: 'ludicrous', fastMode: 'yes' })
expect(spawn.sdkOptions).not.toHaveProperty('effort')
expect(spawn.sdkOptions).not.toHaveProperty('settings')
})
it('keeps a saved Fast on for a new conversation, for its start to apply', () => {
const spawn = launched({ fastMode: 'true' }, { resumesTranscript: false })
expect(spawn.sdkOptions).not.toHaveProperty('settings')
expect(spawn.options.get('fastMode')).toBe('true')
expect(spawn.fastModeAtStart).toBe(true)
// A CLI that never answers the control cannot apply it at start; never claim it is on.
const old = launched(
{ fastMode: 'true' },
{ resumesTranscript: false, flagSettingsControl: 'unsupported' }
)
expect(old.options.has('fastMode')).toBe(false)
expect(old.fastModeAtStart).toBe(false)
})
it('passes a saved Fast off to a new conversation', () => {
const spawn = launched({ fastMode: 'false' }, { resumesTranscript: false })
expect(spawn.sdkOptions.settings).toEqual({ fastMode: false })
expect(spawn.fastModeAtStart).toBe(false)
})
// SDK extraArgs follow its own flags, so configured flags would override saved choices.
it("replaces the agent Arguments' model and effort with the chat's saved ones", () => {
const base = {
...CLAUDE_STRUCTURED_BASE_OPTIONS,
extraArgs: { ...CLAUDE_STRUCTURED_BASE_OPTIONS.extraArgs, model: 'opus', effort: 'max' }
}
expect(launched({ model: 'sonnet', effort: 'low' }, { base }).sdkOptions).toMatchObject({
model: 'sonnet',
effort: 'low',
extraArgs: { 'replay-user-messages': null }
})
expect(launched({ model: 'sonnet', effort: 'low' }, { base }).sdkOptions.extraArgs).toEqual({
'replay-user-messages': null
})
expect(launched({}, { base }).sdkOptions.extraArgs).toEqual(base.extraArgs)
})
it.each(['true', 'false'])(
"keeps the agent Arguments' settings and leaves a saved Fast %s to the start",
(fastMode) => {
const base = {
...CLAUDE_STRUCTURED_BASE_OPTIONS,
extraArgs: { ...CLAUDE_STRUCTURED_BASE_OPTIONS.extraArgs, settings: '/repo/claude.json' }
}
const spawn = launched({ fastMode }, { base })
expect(spawn.sdkOptions).not.toHaveProperty('settings')
expect(spawn.sdkOptions.extraArgs).toEqual(base.extraArgs)
expect(spawn.options.get('fastMode')).toBe(fastMode)
expect(spawn.fastModeAtStart).toBe(true)
}
)
it.each(['full-access', 'plan', 'bypassPermissions'])(
'leaves the launch permission alone with a saved permission mode %s',
(permissionMode) => {
const spawn = launched({ permissionMode }, { base: FULL_ACCESS_LAUNCH })
expect(spawn.sdkOptions).toEqual(FULL_ACCESS_LAUNCH)
expect(spawn.options.size).toBe(0)
}
)
})
it.each(['/repo/claude.json', '{"customInstructions":"keep","fastMode":true}'])(
'compiles saved Ultracode without replacing configured settings %s',
(settings) => {
const spawn = launched(
{ effort: 'ultracode', fastMode: 'true' },
{
base: { ...CLAUDE_STRUCTURED_BASE_OPTIONS, extraArgs: { settings } }
}
)
expect(spawn.sdkOptions.effort).toBe('xhigh')
expect(spawn.sdkOptions.extraArgs?.settings).toBe(settings)
expect(spawn.sdkOptions.settings).toBeUndefined()
expect(spawn.options.get('effort')).toBe('ultracode')
expect(spawn.fastModeAtStart).toBe(true)
}
)
@@ -1,161 +0,0 @@
import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import type * as NodeOs from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
import type * as WorktreePathComparison from '../git/worktrees/catalog/worktree-path-comparison'
const { getPathMock, homedirMock, worktreePathComparisons } = vi.hoisted(() => ({
getPathMock: vi.fn<(name: string) => string>(),
homedirMock: vi.fn<() => string>(),
worktreePathComparisons: { count: 0 }
}))
vi.mock('electron', () => ({
app: {
getPath: getPathMock
}
}))
vi.mock('node:os', async () => {
const actual = await vi.importActual<typeof NodeOs>('node:os')
return {
...actual,
homedir: homedirMock
}
})
vi.mock('../git/worktrees/catalog/worktree-path-comparison', async (importOriginal) => {
const actual = await importOriginal<typeof WorktreePathComparison>()
return {
...actual,
areWorktreePathsEqual: (left: string, right: string) => {
worktreePathComparisons.count += 1
return actual.areWorktreePathsEqual(left, right)
}
}
})
import { scanCodexUsageFiles } from './scanner'
const WORKTREE_COUNT = 4
const EVENTS_PER_FILE = 4
let fakeHomeDir: string
let userDataDir: string
let previousUserDataPath: string | undefined
const originalCodexHome = process.env.CODEX_HOME
function usageRecord(timestamp: string, totalInputTokens: number): string {
return `${JSON.stringify({
timestamp,
type: 'event_msg',
payload: {
type: 'token_count',
info: {
model: 'gpt-5-codex',
last_token_usage: {
input_tokens: 1,
cached_input_tokens: 0,
output_tokens: 0,
reasoning_output_tokens: 0,
total_tokens: 1
},
total_token_usage: {
input_tokens: totalInputTokens,
cached_input_tokens: 0,
output_tokens: 0,
reasoning_output_tokens: 0,
total_tokens: totalInputTokens
}
}
}
})}\n`
}
function writeSessionFile(
sessionsDir: string,
sessionId: string,
cwd: string,
tokenOffset: number
): void {
// Vary usage records so cross-file deduplication preserves all three fixture sessions.
const records = [
`${JSON.stringify({ type: 'session_meta', payload: { id: sessionId, cwd } })}\n`,
...Array.from({ length: EVENTS_PER_FILE }, (_, index) =>
usageRecord(
`2026-07-21T12:${String(index).padStart(2, '0')}:00.000Z`,
tokenOffset + index + 1
)
)
]
writeFileSync(join(sessionsDir, `${sessionId}.jsonl`), records.join(''), 'utf-8')
}
beforeEach(() => {
delete process.env.CODEX_HOME
worktreePathComparisons.count = 0
// Match canonical worktree paths on macOS, where /var resolves to /private/var.
fakeHomeDir = realpathSync(mkdtempSync(join(tmpdir(), 'orca-codex-memo-home-')))
userDataDir = mkdtempSync(join(tmpdir(), 'orca-codex-memo-user-data-'))
previousUserDataPath = process.env.ORCA_USER_DATA_PATH
process.env.ORCA_USER_DATA_PATH = userDataDir
homedirMock.mockReturnValue(fakeHomeDir)
getPathMock.mockImplementation((name: string) => {
if (name === 'userData') {
return userDataDir
}
throw new Error(`unexpected app.getPath(${name})`)
})
})
afterEach(() => {
rmSync(fakeHomeDir, { recursive: true, force: true })
rmSync(userDataDir, { recursive: true, force: true })
if (originalCodexHome === undefined) {
delete process.env.CODEX_HOME
} else {
process.env.CODEX_HOME = originalCodexHome
}
if (previousUserDataPath === undefined) {
delete process.env.ORCA_USER_DATA_PATH
} else {
process.env.ORCA_USER_DATA_PATH = previousUserDataPath
}
vi.clearAllMocks()
})
it('resolves each distinct cwd once per scan, not once per event', async () => {
const sessionsDir = join(fakeHomeDir, '.codex', 'sessions')
mkdirSync(sessionsDir, { recursive: true })
const matchedCwd = join(fakeHomeDir, 'worktrees', 'repo-003', 'packages', 'app')
const unmatchedCwd = join(fakeHomeDir, 'elsewhere', 'project')
// Shared cwd exercises memoization across files.
writeSessionFile(sessionsDir, 'session-a', matchedCwd, 0)
writeSessionFile(sessionsDir, 'session-b', matchedCwd, 1_000)
writeSessionFile(sessionsDir, 'session-c', unmatchedCwd, 2_000)
const worktrees = Array.from({ length: WORKTREE_COUNT }, (_, index) => {
const worktreePath = join(fakeHomeDir, 'worktrees', `repo-${String(index).padStart(3, '0')}`)
mkdirSync(worktreePath, { recursive: true })
return {
repoId: `repo-${index}`,
worktreeId: `repo-${index}::${worktreePath}`,
path: worktreePath,
displayName: `Repo ${index}`
}
})
const result = await scanCodexUsageFiles(worktrees, [])
expect(result.sessions).toHaveLength(3)
const attributedWorktreeIds = new Set(
result.sessions.flatMap((session) =>
session.locationBreakdown.map((location) => location.worktreeId)
)
)
expect(attributedWorktreeIds).toEqual(
new Set([`repo-3::${join(fakeHomeDir, 'worktrees', 'repo-003')}`, null])
)
expect(worktreePathComparisons.count).toBeLessThanOrEqual(2 * WORKTREE_COUNT)
expect(worktreePathComparisons.count).toBeLessThan(EVENTS_PER_FILE * WORKTREE_COUNT)
})
@@ -1,133 +0,0 @@
import { EventEmitter } from 'node:events'
import { PassThrough } from 'node:stream'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { PROVIDER_SUPERVISOR_MAX_STOP_MS } from '../../provider-process/provider-process-supervisor'
import { terminateProviderProcessTree } from '../../provider-process/provider-process-teardown'
import type * as ProcessTreeKill from './codex-app-server-process-tree-kill'
import {
killCodexAppServerProcessTree,
type CodexAppServerSpawn
} from './codex-app-server-process-tree-kill'
import { CodexAppServerTimeoutError, runCodexAppServerSession } from './codex-app-server-session'
vi.mock('../../provider-process/provider-process-teardown', () => ({
terminateProviderProcessTree: vi.fn(async () => 'exited' as const)
}))
vi.mock('./codex-app-server-process-tree-kill', async (importOriginal) => ({
...(await importOriginal<typeof ProcessTreeKill>()),
killCodexAppServerProcessTree: vi.fn()
}))
type FakeChild = EventEmitter & {
pid: number
stdin: PassThrough
stdout: PassThrough
stderr: PassThrough
kill: ReturnType<typeof vi.fn<(signal?: NodeJS.Signals) => boolean>>
}
/** A child that answers initialize and then nothing; it exits only on `exitsOn`. */
function fakeChild(exitsOn: NodeJS.Signals | null): FakeChild {
const child: FakeChild = Object.assign(new EventEmitter(), {
pid: 4242,
stdin: new PassThrough(),
stdout: new PassThrough(),
stderr: new PassThrough(),
kill: vi.fn((signal?: NodeJS.Signals) => {
if (signal === exitsOn) {
child.emit('exit', null, signal)
child.emit('close', null, signal)
}
return true
})
})
child.stdin.on('data', (chunk: Buffer) => {
const message = JSON.parse(chunk.toString())
if (message.method === 'initialize') {
child.stdout.write(`${JSON.stringify({ id: message.id, result: {} })}\n`)
}
})
return child
}
const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform')
afterEach(() => {
vi.useRealTimers()
vi.clearAllMocks()
if (originalPlatform) {
Object.defineProperty(process, 'platform', originalPlatform)
}
})
function startWedgedSession(child: FakeChild, platform: NodeJS.Platform): Promise<unknown> {
Object.defineProperty(process, 'platform', { configurable: true, value: platform })
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the session reads only pid, stdio, kill and the exit/close/error events, which the fake implements.
const spawnImpl: CodexAppServerSpawn = () => child as never
return runCodexAppServerSession(
{ command: '/bin/codex', cliPath: null, args: ['app-server'], timeoutMs: 1_000 },
() => new Promise<never>(() => {}),
spawnImpl
).catch((error: unknown) => error)
}
describe('runCodexAppServerSession stop', () => {
it('SIGTERMs a supervised session at its deadline instead of waiting out its stdin grace', async () => {
vi.useFakeTimers()
const child = fakeChild('SIGTERM')
let stdinEnded = false
child.stdin.on('finish', () => (stdinEnded = true))
const outcome = startWedgedSession(child, 'darwin')
await vi.advanceTimersByTimeAsync(1_000)
expect(child.kill.mock.calls).toEqual([['SIGTERM']])
expect(stdinEnded).toBe(true)
expect(await outcome).toBeInstanceOf(CodexAppServerTimeoutError)
expect(terminateProviderProcessTree).not.toHaveBeenCalled()
expect(killCodexAppServerProcessTree).not.toHaveBeenCalled()
})
it('tears a supervised session down only once the supervisor has had its full stop time', async () => {
vi.useFakeTimers()
const child = fakeChild(null)
const outcome = startWedgedSession(child, 'linux')
await vi.advanceTimersByTimeAsync(1_000 + PROVIDER_SUPERVISOR_MAX_STOP_MS - 1)
expect(terminateProviderProcessTree).not.toHaveBeenCalled()
await vi.advanceTimersByTimeAsync(1)
expect(terminateProviderProcessTree).toHaveBeenCalledWith(child, {
site: 'codex-app-server-session',
platform: 'linux',
supervised: true
})
await vi.advanceTimersByTimeAsync(1_000)
expect(await outcome).toBeInstanceOf(CodexAppServerTimeoutError)
expect(child.kill.mock.calls).toEqual([['SIGTERM']])
expect(killCodexAppServerProcessTree).not.toHaveBeenCalled()
})
it('keeps the Windows deadline kill and its 1.5 s close wait', async () => {
vi.useFakeTimers()
const child = fakeChild(null)
const outcome = startWedgedSession(child, 'win32')
await vi.advanceTimersByTimeAsync(1_000)
expect(killCodexAppServerProcessTree).toHaveBeenCalledTimes(1)
await vi.advanceTimersByTimeAsync(1_499)
expect(terminateProviderProcessTree).not.toHaveBeenCalled()
await vi.advanceTimersByTimeAsync(1)
expect(terminateProviderProcessTree).toHaveBeenCalledWith(child, {
site: 'codex-app-server-session',
platform: 'win32',
supervised: false
})
await vi.advanceTimersByTimeAsync(1_000)
expect(await outcome).toBeInstanceOf(CodexAppServerTimeoutError)
// Windows SIGTERM is TerminateProcess; the mocked teardowns own termination.
expect(child.kill).not.toHaveBeenCalled()
expect(killCodexAppServerProcessTree).toHaveBeenCalledTimes(1)
})
})
@@ -1,140 +0,0 @@
// Bridge bash reports no folder; a distro stopped while Orca was quit left the pane at its spawn folder.
import './mock-descendant-sweep'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { join } from 'node:path'
import { readFileSync, rmSync } from 'node:fs'
import { DaemonPtyAdapter } from './daemon-pty-adapter'
import type { DaemonServer } from './daemon-server'
import { getHistorySessionDirName } from './history-paths'
import { createMockSubprocess, startDaemonAdapterHarness } from './daemon-pty-adapter-test-harness'
import type { WslGuestProcessRow } from '../wsl/execution/wsl-guest-pane-processes'
import type * as GuestProcessesModule from '../wsl/execution/wsl-guest-pane-processes'
import * as startupCwdProbe from '../ipc/pty/host-env/startup-cwd-directory-exists'
const readRows = vi.hoisted(() =>
vi.fn(
async (
_distro: string,
_timeoutMs: number,
_opts?: { running?: boolean }
): Promise<WslGuestProcessRow[] | null> => null
)
)
vi.mock('../wsl/execution/wsl-guest-pane-processes', async (importOriginal) => ({
...(await importOriginal<typeof GuestProcessesModule>()),
readWslGuestPaneProcesses: readRows
}))
const PANE_KEY = '11111111-1111-4111-8111-111111111111:22222222-2222-4222-8222-222222222222'
const WSL_CWD = '\\\\wsl.localhost\\Ubuntu\\home\\jin\\repo'
const DEEP = `${WSL_CWD}\\deep`
/** Shortens quit's folder budget so an unanswering distro is not a real-time wait. */
class QuickQuitDaemonPtyAdapter extends DaemonPtyAdapter {
protected quitWslCwdBudgetMs = 5
}
describe('a bridge WSL pane keeps its shell folder across quit', () => {
let dir: string
let socketPath: string
let tokenPath: string
let server: DaemonServer
let harnessAdapter: DaemonPtyAdapter
let adapters: DaemonPtyAdapter[] = []
const platform = Object.getOwnPropertyDescriptor(process, 'platform')
const spawnCwds: (string | undefined)[] = []
let lastSubprocess: ReturnType<typeof createMockSubprocess>
beforeEach(async () => {
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
readRows.mockReset()
readRows.mockResolvedValue([
{ pid: 10, ppid: 1, comm: 'bash', paneKey: PANE_KEY, cwd: '/home/jin/repo/deep' }
])
spawnCwds.length = 0
const harness = await startDaemonAdapterHarness((opts) => {
spawnCwds.push(opts.cwd)
lastSubprocess = createMockSubprocess()
return lastSubprocess
})
;({ dir, socketPath, tokenPath, server } = harness)
harnessAdapter = harness.adapter
})
afterEach(async () => {
vi.restoreAllMocks()
if (platform) {
Object.defineProperty(process, 'platform', platform)
}
for (const adapter of adapters) {
adapter.dispose()
}
adapters = []
harnessAdapter.dispose()
await server.shutdown()
rmSync(dir, { recursive: true, force: true })
})
const historyDir = () => join(dir, 'history')
const launch = (Adapter: typeof DaemonPtyAdapter = DaemonPtyAdapter) => {
const adapter = new Adapter({ socketPath, tokenPath, historyPath: historyDir() })
adapters.push(adapter)
return adapter
}
const checkpointCwd = (id: string) =>
JSON.parse(
readFileSync(join(historyDir(), getHistorySessionDirName(id), 'checkpoint.json'), 'utf-8')
).cwd
const openBridgePane = (adapter: DaemonPtyAdapter, sessionId: string, cwd = WSL_CWD) =>
adapter.spawn({ cols: 80, rows: 24, cwd, env: { ORCA_PANE_KEY: PANE_KEY }, sessionId })
it("saves the shell's folder in quit's final checkpoint, never booting the distro", async () => {
const app = launch()
const { id } = await openBridgePane(app, 'repo::/repo@@quit')
lastSubprocess._simulateData('$ cd deep\r\n')
await app.disconnectOnly()
// `running` lets the read skip its check only when the running-distro cache confirms it.
expect(readRows).toHaveBeenCalledWith('Ubuntu', expect.any(Number), { running: true })
expect(checkpointCwd(id)).toBe(DEEP)
})
it('reopens in that folder when the distro stopped while Orca was quit', async () => {
vi.spyOn(startupCwdProbe, 'probeRecoveredStartupCwdDirectory').mockResolvedValue(true)
const app = launch()
const { id } = await openBridgePane(app, 'repo::/repo@@terminated')
lastSubprocess._simulateData('$ cd deep\r\n')
await app.disconnectOnly()
// `wsl --terminate` ends the bridge's wsl.exe; the daemon reaps the session.
lastSubprocess._simulateExit(1)
const relaunched = launch()
await relaunched.reconcileOnStartup(new Set(['repo::/repo']))
const restored = await openBridgePane(relaunched, id)
expect(restored.coldRestore).toBeTruthy()
expect(spawnCwds.at(-1)).toBe(DEEP)
})
it('keeps the spawn folder when the distro cannot be read', async () => {
readRows.mockResolvedValue(null)
const app = launch()
const { id } = await openBridgePane(app, 'repo::/repo@@unreadable')
await app.disconnectOnly()
expect(checkpointCwd(id)).toBe(WSL_CWD)
})
it('quits on its budget when the distro does not answer', async () => {
readRows.mockReturnValue(new Promise(() => {}))
const app = launch(QuickQuitDaemonPtyAdapter)
const { id } = await openBridgePane(app, 'repo::/repo@@silent')
await app.disconnectOnly()
expect(readRows.mock.calls[0]?.[1]).toBe(5)
expect(checkpointCwd(id)).toBe(WSL_CWD)
})
})
@@ -1,145 +0,0 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { mkdirSync, readdirSync, rmSync } from 'node:fs'
import { basename, join } from 'node:path'
import * as localPtyUtils from '../providers/local-pty/local-pty-executable-spawn'
import {
createMockSubprocess,
startDaemonAdapterHarness,
waitFor,
type DaemonAdapterHarness,
type SpawnSubprocess
} from './daemon-pty-adapter-test-harness'
const itOnPosix = process.platform === 'win32' ? it.skip : it
describe('DaemonPtyAdapter startup delivery', () => {
let harness: DaemonAdapterHarness
let adapter: DaemonAdapterHarness['adapter']
let dir: string
let lastSubprocess: ReturnType<typeof createMockSubprocess>
let lastSpawnOpts: Parameters<SpawnSubprocess>[0] | null
let nextShellPath: string | undefined
beforeEach(async () => {
lastSpawnOpts = null
nextShellPath = undefined
harness = await startDaemonAdapterHarness((opts) => {
lastSpawnOpts = opts
lastSubprocess = Object.assign(
createMockSubprocess(),
nextShellPath ? { shellPath: nextShellPath } : {}
)
return lastSubprocess
})
adapter = harness.adapter
dir = harness.dir
})
afterEach(async () => {
adapter.dispose()
await harness.server.shutdown()
rmSync(dir, { recursive: true, force: true })
})
itOnPosix('uses the normal shell-ready timeout for fish', async () => {
// The mock subprocess represents installed fish even on hosts without it.
const resolveShell = vi
.spyOn(localPtyUtils, 'resolveUnixShellPath')
.mockReturnValue('/usr/bin/fish')
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] })
try {
await adapter.spawn({
cols: 80,
rows: 24,
command: 'codex',
env: { SHELL: '/usr/bin/fish' }
})
await vi.advanceTimersByTimeAsync(14_999)
expect(lastSubprocess.write).not.toHaveBeenCalled()
await vi.advanceTimersByTimeAsync(1)
expect(lastSubprocess.write).toHaveBeenCalledExactlyOnceWith('codex\r')
expect(lastSpawnOpts).not.toEqual(
expect.objectContaining({ startupCommandDelivery: 'shell-ready' })
)
} finally {
vi.useRealTimers()
resolveShell.mockRestore()
}
})
itOnPosix.for(['environment', 'override'] as const)(
'waits for the fallback shell when the %s shell is missing',
async (source, context) => {
const missingShell = join(dir, 'missing-fish')
const fallbackName = basename(localPtyUtils.resolveUnixShellPath(missingShell))
context.skip(!['bash', 'zsh'].includes(fallbackName), 'Requires a Bash/zsh fallback')
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] })
try {
await adapter.spawn({
cols: 80,
rows: 24,
command: 'codex',
env: { SHELL: source === 'environment' ? missingShell : '/bin/sh' },
...(source === 'override' ? { shellOverride: missingShell } : {})
})
await vi.advanceTimersByTimeAsync(350)
} finally {
vi.useRealTimers()
}
expect(lastSubprocess.write).not.toHaveBeenCalled()
expect(lastSpawnOpts).toEqual(
expect.objectContaining({ startupCommandDelivery: 'shell-ready' })
)
lastSubprocess._simulateData('\x1b]777;orca-shell-ready\x07\r\nuser@host $ ')
await waitFor(() => vi.mocked(lastSubprocess.write).mock.calls.length > 0)
expect(lastSubprocess.write).toHaveBeenCalledExactlyOnceWith('codex\r')
}
)
itOnPosix.each([
{ command: 'codex' },
{ command: 'codex', startupCommandDelivery: 'fast' as const },
{ command: "codex 'linked issue context'", startupCommandDelivery: 'shell-ready' as const }
])('waits past 300ms and submits once after readiness: %j', async (startup) => {
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] })
try {
await adapter.spawn({ cols: 80, rows: 24, ...startup, env: { SHELL: '/bin/zsh' } })
await vi.advanceTimersByTimeAsync(350)
} finally {
vi.useRealTimers()
}
expect(lastSubprocess.write).not.toHaveBeenCalled()
expect(lastSpawnOpts).toEqual(
expect.objectContaining({ startupCommandDelivery: 'shell-ready' })
)
lastSubprocess._simulateData('\x1b]777;orca-shell-ready\x07')
lastSubprocess._simulateData('\r\nuser@host $ ')
await waitFor(() => vi.mocked(lastSubprocess.write).mock.calls.length > 0)
expect(lastSubprocess.write).toHaveBeenCalledExactlyOnceWith(`${startup.command}\r`)
})
itOnPosix('types only the short line that sources a staged launch line', async () => {
const stagingDir = join(dir, 'tmp')
mkdirSync(stagingDir)
vi.stubEnv('TMPDIR', stagingDir)
nextShellPath = '/bin/zsh'
try {
const command = `claude '${'x'.repeat(600)}'`
await adapter.spawn({
cols: 80,
rows: 24,
command,
env: { SHELL: '/bin/zsh' }
})
lastSubprocess._simulateData('\x1b]777;orca-shell-ready\x07\r\nuser@host $ ')
await waitFor(() => vi.mocked(lastSubprocess.write).mock.calls.length > 0)
const [script] = readdirSync(stagingDir)
expect(lastSubprocess.write).toHaveBeenCalledExactlyOnceWith(
`. '${join(stagingDir, script)}'\r`
)
} finally {
vi.unstubAllEnvs()
}
})
})
@@ -1,149 +0,0 @@
import { once } from 'node:events'
import { mkdtempSync, rmSync } from 'node:fs'
import { createServer, Socket } from 'node:net'
import { tmpdir } from 'node:os'
import { basename, join } from 'node:path'
import { setTimeout as delay } from 'node:timers/promises'
import { describe, expect, it } from 'vitest'
import { DaemonStreamDataBatcher } from './daemon-stream-data-batcher'
import { createNdjsonParser } from './ndjson'
import { SessionProducerPause } from './session-producer-pause'
const MiB = 1024 * 1024
describe('daemon stream backpressure with a stalled Socket reader', () => {
it.each([
{ chunkSize: 64 * 1024, hidden: false },
{ chunkSize: 1024, hidden: false },
{ chunkSize: 64 * 1024, hidden: true },
{ chunkSize: 1024, hidden: true }
])(
'bounds $chunkSize-character writes (hidden=$hidden) and resumes',
async ({ chunkSize, hidden }) => {
// A fixed POSIX root keeps the full Unix socket path below the kernel's byte limit.
const dir = mkdtempSync(join(process.platform === 'win32' ? tmpdir() : '/tmp', 'orca-dsbp-'))
const socketPath =
process.platform === 'win32'
? `\\\\.\\pipe\\${basename(dir)}.sock`
: join(dir, 'stream.sock')
const server = createServer()
const writer = new Socket()
let reader: Socket | undefined
let paused = false
let produced = 0
let dropped = 0
const received = new Map<string, number>()
const producer = new SessionProducerPause({
pause: () => {
paused = true
},
resume: () => {
paused = false
}
})
const batcher = new DaemonStreamDataBatcher(() => ({ streamSocket: writer }), {
isSessionDroppable: (sessionId) => hidden && sessionId === 'flood',
onProducerBackpressureChanged: (sessionId, value) => {
expect(sessionId).toBe('flood')
producer.setStreamBackpressured(value)
}
})
writer.on('drain', () => batcher.flush('client'))
try {
const accepted = once(server, 'connection')
server.listen(socketPath)
await once(server, 'listening')
writer.connect(socketPath)
await once(writer, 'connect')
const [acceptedSocket] = await accepted
if (!(acceptedSocket instanceof Socket)) {
throw new Error('Expected accepted Socket')
}
reader = acceptedSocket
const parser = createNdjsonParser(
(event) => {
if (
!event ||
typeof event !== 'object' ||
!('sessionId' in event) ||
typeof event.sessionId !== 'string' ||
!('payload' in event)
) {
return
}
const payload = event.payload
if (
payload &&
typeof payload === 'object' &&
'droppedChars' in payload &&
typeof payload.droppedChars === 'number'
) {
dropped += payload.droppedChars
}
if (
!payload ||
typeof payload !== 'object' ||
!('data' in payload) ||
typeof payload.data !== 'string'
) {
return
}
received.set(
event.sessionId,
(received.get(event.sessionId) ?? 0) + payload.data.length
)
},
() => {
throw new Error('Invalid stream frame')
}
)
reader.on('data', (data) => parser.feed(data.toString('utf8')))
reader.pause()
const chunk = 'x'.repeat(chunkSize)
// Keep write callbacks pending until the pause assertion, independent of kernel buffer capacity.
while (!paused && produced < 8 * MiB) {
batcher.enqueue('client', 'flood', chunk, {
flushImmediately: chunkSize <= 1024,
flushMaxChars: 1024
})
batcher.flush('client')
produced += chunkSize
}
expect(paused).toBe(!hidden)
expect(writer.writableLength + 2 * batcher.queuedCharsForClient('client')).toBeLessThan(
5 * MiB
)
if (!hidden) {
producer.pause()
producer.resumeClient()
expect(paused).toBe(true)
}
batcher.enqueue('client', 'typing', 'echo', { flushImmediately: true, flushMaxChars: 1024 })
reader.resume()
const startedAt = performance.now()
while (paused || writer.writableLength || batcher.queuedCharsForClient('client')) {
if (performance.now() - startedAt > 5_000) {
throw new Error('Stream failed to drain')
}
await delay(5)
}
const ended = once(reader, 'end')
writer.end()
await ended
expect((received.get('flood') ?? 0) + dropped).toBe(produced)
expect(dropped > 0).toBe(hidden)
expect(received.get('typing')).toBe(4)
expect(paused).toBe(false)
} finally {
batcher.clear()
producer.release({ resume: false })
writer.destroy()
reader?.destroy()
await new Promise<void>((resolve) => server.close(() => resolve()))
rmSync(dir, { recursive: true, force: true })
}
}
)
})
-74
View File
@@ -2,7 +2,6 @@ import { describe, expect, it } from 'vitest'
import {
applyManagedDshPatch,
findManagedDshPatchRegion,
readManagedDshHooksConfigPath,
removeManagedDshPatch
} from './dsh-home-patch'
@@ -16,65 +15,9 @@ function applyOrFail(text: string, hooksPath = HOOKS_PATH): string {
return next
}
const PRISTINE = [
'# Your patch layer for this dsh profile, applied after every bundle layer:',
'# a top-level YAML array of loader patch entries (id-targeted config',
'# overrides, disables, and insert lists; `!!js` expressions allowed).',
'[]',
''
].join('\n')
const USER_ROWS = ['- id: llm-deepseek', ' config:', " apiKeyEnv: 'MY_KEY'", ''].join('\n')
describe('applyManagedDshPatch', () => {
it('creates the managed block in an empty file', () => {
const text = applyOrFail('')
expect(readManagedDshHooksConfigPath(text)).toBe(HOOKS_PATH)
expect(text).toContain("name: '@deepseek-ai/dsh-hooks-claude-code'")
expect(text.endsWith('\n')).toBe(true)
})
it('replaces the empty flow sequence DSH ships, keeping its comments', () => {
const text = applyOrFail(PRISTINE)
expect(text).not.toMatch(/^\[]$/m)
expect(text).toContain('# Your patch layer for this dsh profile')
expect(readManagedDshHooksConfigPath(text)).toBe(HOOKS_PATH)
})
it('appends after user rows without touching them', () => {
const text = applyOrFail(USER_ROWS)
expect(text.startsWith(USER_ROWS.trimEnd())).toBe(true)
expect(readManagedDshHooksConfigPath(text)).toBe(HOOKS_PATH)
})
it('rewrites its own block in place rather than stacking copies', () => {
const once = applyOrFail(USER_ROWS, '/old/path.json')
const twice = applyOrFail(once)
expect(twice.match(/orca-managed-dsh-hooks \(managed by Orca/g)).toHaveLength(1)
expect(readManagedDshHooksConfigPath(twice)).toBe(HOOKS_PATH)
expect(twice).not.toContain('/old/path.json')
})
it('is idempotent', () => {
const once = applyOrFail(PRISTINE)
expect(applyOrFail(once)).toBe(once)
})
it('treats `[] # comment` as the empty document it is, keeping the comment', () => {
const text = '[] # keep empty\n'
const next = applyOrFail(text)
expect(next).not.toMatch(/^\s*\[]/m)
expect(next).toContain('# keep empty')
expect(readManagedDshHooksConfigPath(next)).toBe(HOOKS_PATH)
})
it.each(['- id: llm-deepseek\n config: {}\n', '\n', '# only a comment\n'])(
'still edits %j',
(text) => {
expect(applyManagedDshPatch(text, HOOKS_PATH)).not.toBeNull()
}
)
it.each(['[{ id: llm-deepseek }]\n', '[\n { id: a },\n { id: b }\n]\n'])(
'refuses to append after the non-empty flow sequence %j',
(text) => {
@@ -82,31 +25,14 @@ describe('applyManagedDshPatch', () => {
expect(applyManagedDshPatch(text, HOOKS_PATH)).toBeNull()
}
)
it('quotes a path containing a single quote', () => {
const awkward = "/home/o'brien/.orca/agent-hooks/dsh-hooks.json"
expect(readManagedDshHooksConfigPath(applyOrFail('', awkward))).toBe(awkward)
})
})
describe('removeManagedDshPatch', () => {
it('restores the empty flow sequence when nothing else remains', () => {
const installed = applyOrFail(PRISTINE)
const { text, changed } = removeManagedDshPatch(installed)
expect(changed).toBe(true)
expect(text.trimEnd().endsWith('[]')).toBe(true)
expect(text).toContain('# Your patch layer for this dsh profile')
})
it('leaves user rows alone and adds no [] when they remain', () => {
const installed = applyOrFail(USER_ROWS)
const { text } = removeManagedDshPatch(installed)
expect(text.trimEnd()).toBe(USER_ROWS.trimEnd())
})
it('reports no change when the file carries no managed block', () => {
expect(removeManagedDshPatch(USER_ROWS)).toEqual({ text: USER_ROWS, changed: false })
})
})
describe('findManagedDshPatchRegion', () => {
@@ -59,32 +59,6 @@ describe('project formatter execution', () => {
)
})
it('does not spawn for unconfigured projects, other hosts, or excluded files', async () => {
expect(
await formatEditorText(store, { ...target(), path: join(root, 'a.png') }, 'text')
).toEqual({ kind: 'skipped' })
expect(
await formatEditorText(store, { ...target(), executionHostId: 'ssh:other' }, 'text')
).toEqual({ kind: 'skipped' })
delete repo.editorFormatter
expect(await formatEditorText(store, target(), 'text')).toEqual({ kind: 'skipped' })
expect(runProcess).not.toHaveBeenCalled()
})
it('checks host support only after a configured formatter applies', async () => {
const unsupported = vi.fn(() => {
throw new Error('unsupported host')
})
expect(
await formatEditorText(store, { ...target(), path: join(root, 'a.png') }, 'text', unsupported)
).toEqual({ kind: 'skipped' })
expect(unsupported).not.toHaveBeenCalled()
await expect(formatEditorText(store, target(), 'text', unsupported)).rejects.toThrow(
'unsupported host'
)
expect(runProcess).not.toHaveBeenCalled()
})
it('reports the formatter failure and never changes the saved file', async () => {
repo.editorFormatter!.command = command('process.stderr.write("syntax error");process.exit(2)')
await expect(formatEditorText(store, target(), 'input')).rejects.toThrow('syntax error')
@@ -124,19 +98,4 @@ describe('project formatter execution', () => {
}
}
)
it.each([
[{ timedOut: true }, 'timed out'],
[{ outputTruncated: true }, 'output exceeded']
])('refuses incomplete formatter output (%j)', async (result, message) => {
vi.mocked(runProcess).mockResolvedValueOnce({
code: 0,
signal: null,
timedOut: false,
stdout: 'partial',
stderr: '',
...result
})
await expect(formatEditorText(store, target(), 'input')).rejects.toThrow(message)
})
})
@@ -1,142 +0,0 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const { gitExecFileAsyncMock, gitExecFileSyncMock, translateWslOutputPathsMock } = vi.hoisted(
() => ({
gitExecFileAsyncMock: vi.fn(),
gitExecFileSyncMock: vi.fn(),
translateWslOutputPathsMock: vi.fn((output: string) => output)
})
)
vi.mock('../../command-runner/git-exec-file', async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
gitExecFileAsync: gitExecFileAsyncMock
}))
vi.mock('../../command-runner/git-exec-file-sync', async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
gitExecFileSync: gitExecFileSyncMock
}))
vi.mock('../../command-runner/wsl-path-translation', async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
translateWslOutputPaths: translateWslOutputPathsMock
}))
import { GitCommandTimeoutError } from '../../../../shared/git/commands/git-command-timeout'
import { addWorktree } from './worktree-add'
import {
resolveWorktreeAddTimeoutMs,
WORKTREE_ADD_TIMEOUT_MAX_MS,
WORKTREE_ADD_TIMEOUT_MS,
worktreeAddTimeoutMessage
} from '../worktree-operation-options'
import { registerWorktreeSuiteHooks } from '../testing/worktree-test-harness'
registerWorktreeSuiteHooks()
describe('resolveWorktreeAddTimeoutMs', () => {
let warnSpy: ReturnType<typeof vi.spyOn>
beforeEach(() => {
warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
})
afterEach(() => {
warnSpy.mockRestore()
})
it('falls back to the default when the override is unset, blank, or unparseable', () => {
expect(resolveWorktreeAddTimeoutMs({})).toBe(WORKTREE_ADD_TIMEOUT_MS)
expect(resolveWorktreeAddTimeoutMs({ ORCA_WORKTREE_ADD_TIMEOUT_MS: ' ' })).toBe(
WORKTREE_ADD_TIMEOUT_MS
)
expect(resolveWorktreeAddTimeoutMs({ ORCA_WORKTREE_ADD_TIMEOUT_MS: 'nope' })).toBe(
WORKTREE_ADD_TIMEOUT_MS
)
})
it('raises the timeout up to the closed maximum', () => {
expect(resolveWorktreeAddTimeoutMs({ ORCA_WORKTREE_ADD_TIMEOUT_MS: '300000' })).toBe(300_000)
expect(resolveWorktreeAddTimeoutMs({ ORCA_WORKTREE_ADD_TIMEOUT_MS: '300000.9' })).toBe(300_000)
expect(resolveWorktreeAddTimeoutMs({ ORCA_WORKTREE_ADD_TIMEOUT_MS: '999999999' })).toBe(
1_800_000
)
})
it('treats an infinite override as the maximum, not the default', () => {
expect(resolveWorktreeAddTimeoutMs({ ORCA_WORKTREE_ADD_TIMEOUT_MS: 'Infinity' })).toBe(
1_800_000
)
expect(resolveWorktreeAddTimeoutMs({ ORCA_WORKTREE_ADD_TIMEOUT_MS: '1e400' })).toBe(1_800_000)
expect(resolveWorktreeAddTimeoutMs({ ORCA_WORKTREE_ADD_TIMEOUT_MS: '-Infinity' })).toBe(
WORKTREE_ADD_TIMEOUT_MS
)
expect(warnSpy).toHaveBeenCalledWith(
expect.stringContaining('"Infinity" is outside [180000, 1800000]ms; using 1800000ms')
)
})
it('stays quiet when the value is unset, blank, or lands in range', () => {
resolveWorktreeAddTimeoutMs({})
resolveWorktreeAddTimeoutMs({ ORCA_WORKTREE_ADD_TIMEOUT_MS: ' ' })
resolveWorktreeAddTimeoutMs({ ORCA_WORKTREE_ADD_TIMEOUT_MS: '600000' })
resolveWorktreeAddTimeoutMs({ ORCA_WORKTREE_ADD_TIMEOUT_MS: '300000.9' })
expect(warnSpy).not.toHaveBeenCalled()
})
// A seconds-versus-milliseconds mistake should warn when clamped.
it('warns with the range when it clamps an out-of-range value', () => {
resolveWorktreeAddTimeoutMs({ ORCA_WORKTREE_ADD_TIMEOUT_MS: '300' })
expect(warnSpy).toHaveBeenCalledTimes(1)
expect(warnSpy).toHaveBeenCalledWith(
'[git/worktree] ORCA_WORKTREE_ADD_TIMEOUT_MS="300" is outside [180000, 1800000]ms; using 180000ms'
)
})
it('warns that an unparseable value is not a number rather than out of range', () => {
resolveWorktreeAddTimeoutMs({ ORCA_WORKTREE_ADD_TIMEOUT_MS: '600_000' })
expect(warnSpy).toHaveBeenCalledTimes(1)
expect(warnSpy).toHaveBeenCalledWith(
expect.stringContaining('"600_000" is not a number; using 180000ms')
)
})
it('never lowers the timeout below the stall-guard default', () => {
expect(resolveWorktreeAddTimeoutMs({ ORCA_WORKTREE_ADD_TIMEOUT_MS: '300' })).toBe(
WORKTREE_ADD_TIMEOUT_MS
)
expect(resolveWorktreeAddTimeoutMs({ ORCA_WORKTREE_ADD_TIMEOUT_MS: '0' })).toBe(
WORKTREE_ADD_TIMEOUT_MS
)
expect(resolveWorktreeAddTimeoutMs({ ORCA_WORKTREE_ADD_TIMEOUT_MS: '-1' })).toBe(
WORKTREE_ADD_TIMEOUT_MS
)
})
})
describe('addWorktree timeout', () => {
it('names the limit and the override when git worktree add times out', async () => {
gitExecFileAsyncMock.mockImplementation(async (args: string[]) => {
if (args.includes('worktree')) {
throw new GitCommandTimeoutError(WORKTREE_ADD_TIMEOUT_MS)
}
return { stdout: '', stderr: '' }
})
const error = await addWorktree('/repo', '/repo-feature', 'feature').catch((e: unknown) => e)
expect(error).toBeInstanceOf(GitCommandTimeoutError)
expect(String(error)).toContain('180 seconds')
expect(String(error)).toContain('ORCA_WORKTREE_ADD_TIMEOUT_MS')
})
it('drops the override advice once the limit is already at the maximum', () => {
expect(worktreeAddTimeoutMessage(WORKTREE_ADD_TIMEOUT_MAX_MS)).not.toContain(
'ORCA_WORKTREE_ADD_TIMEOUT_MS'
)
})
})
@@ -1,160 +0,0 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const mocks = vi.hoisted(() => ({ gitExecFileAsync: vi.fn() }))
vi.mock('../../command-runner/git-exec-file', async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
gitExecFileAsync: mocks.gitExecFileAsync
}))
import { GitAdmissionScheduler } from '../../../../shared/git/admission/git-admission-scheduler'
import type { GitAdmissionTier } from '../../../../shared/rpc-contract/git-admission-tier-params'
import { measureRetargetDivergence } from './worktree-base-divergence'
type ExecOptions = {
cwd: string
timeout?: number
wslDistro?: string
signal?: AbortSignal
admissionTier?: GitAdmissionTier
}
function subcommands(): string[] {
return mocks.gitExecFileAsync.mock.calls.map((call) => (call[0] as string[])[0]!)
}
function answerProbes(count: string, mergeBase = 'abc123\n') {
mocks.gitExecFileAsync.mockImplementation(async (args: string[]) =>
args[0] === 'merge-base' ? { stdout: mergeBase } : { stdout: count }
)
}
function exitCodeError(code: number): Error & { code: number } {
return Object.assign(new Error('git exited'), { code })
}
beforeEach(() => {
mocks.gitExecFileAsync.mockReset()
})
describe('measureRetargetDivergence deadlines', () => {
it('finishes through interactive headroom while general capacity is occupied', async () => {
const scheduler = new GitAdmissionScheduler({ generalCap: 1, generalHeadroom: 1 })
const blocker = await scheduler.acquire({ args: ['status'], cwd: '/repo' })
mocks.gitExecFileAsync.mockImplementation(async (args: string[], options: ExecOptions) => {
const grant = await scheduler.acquire({
args,
cwd: options.cwd,
signal: options.signal,
tier: options.admissionTier
})
try {
return { stdout: args[0] === 'merge-base' ? 'abc123\n' : '1\n' }
} finally {
grant.release()
}
})
try {
await expect(
measureRetargetDivergence('/repo', 'refs/heads/main', 'refs/remotes/origin/main', {
admissionTier: 'interactive',
budgetMsForTest: 200
})
).resolves.toBe('within')
expect(subcommands()).toEqual(['rev-list', 'rev-list', 'merge-base'])
} finally {
blocker.release()
}
})
it('really aborts the in-flight probes when the shared budget expires', async () => {
mocks.gitExecFileAsync.mockImplementation(
(_args: string[], options: ExecOptions) =>
new Promise((_resolve, reject) => {
options.signal?.addEventListener(
'abort',
() =>
reject(
Object.assign(new Error('The operation was aborted.'), { name: 'AbortError' })
),
{ once: true }
)
})
)
await expect(
measureRetargetDivergence('/repo', 'refs/heads/main', 'refs/remotes/origin/main', {
budgetMsForTest: 25
})
).resolves.toBe('unknown')
})
it('stops the probes when the create itself is cancelled, without waiting for the budget', async () => {
mocks.gitExecFileAsync.mockImplementation(
(_args: string[], options: ExecOptions) =>
new Promise((_resolve, reject) => {
options.signal?.addEventListener(
'abort',
() =>
reject(
Object.assign(new Error('The operation was aborted.'), { name: 'AbortError' })
),
{ once: true }
)
})
)
const controller = new AbortController()
const pending = measureRetargetDivergence(
'/repo',
'refs/heads/main',
'refs/remotes/origin/main',
// A budget long enough that only the caller's cancellation can end this in time.
{ signal: controller.signal, budgetMsForTest: 60_000 }
)
controller.abort()
await expect(pending).resolves.toBe('unknown')
})
it('reports a blown deadline as unverifiable rather than as excess drift', async () => {
mocks.gitExecFileAsync.mockRejectedValue(new Error('git timed out.'))
await expect(
measureRetargetDivergence('/repo', 'refs/heads/main', 'refs/remotes/origin/main')
).resolves.toBe('unknown')
expect(subcommands()).not.toContain('merge-base')
})
it('separates merge-base saying no from merge-base failing', async () => {
mocks.gitExecFileAsync.mockImplementation(async (args: string[]) => {
if (args[0] === 'merge-base') {
throw exitCodeError(1)
}
return { stdout: '2\n' }
})
await expect(
measureRetargetDivergence('/repo', 'refs/heads/main', 'refs/remotes/origin/main')
).resolves.toBe('exceeded')
mocks.gitExecFileAsync.mockReset()
mocks.gitExecFileAsync.mockImplementation(async (args: string[]) => {
if (args[0] === 'merge-base') {
throw new Error('git timed out.')
}
return { stdout: '2\n' }
})
await expect(
measureRetargetDivergence('/repo', 'refs/heads/main', 'refs/remotes/origin/main')
).resolves.toBe('unknown')
})
it('reports drift past the cap without spending a merge-base walk', async () => {
answerProbes('101\n')
await expect(
measureRetargetDivergence('/repo', 'refs/heads/main', 'refs/remotes/origin/main')
).resolves.toBe('exceeded')
expect(subcommands()).not.toContain('merge-base')
})
})
@@ -1,152 +0,0 @@
// Exercise the real Linux-to-UNC translator: an identity mock would hide ext4-tail case folding.
import type * as FsPromises from 'node:fs/promises'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const { gitExecFileAsyncMock, gitExecFileSyncMock, statMock, readFileMock, resolveGitDirMock } =
vi.hoisted(() => ({
gitExecFileAsyncMock: vi.fn(),
gitExecFileSyncMock: vi.fn(),
statMock: vi.fn(),
readFileMock: vi.fn(),
resolveGitDirMock: vi.fn()
}))
vi.mock('../../command-runner/git-exec-file', async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
gitExecFileAsync: gitExecFileAsyncMock
}))
vi.mock('../../command-runner/git-exec-file-sync', async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
gitExecFileSync: gitExecFileSyncMock
}))
vi.mock('../../source-control/resolve-git-dir', () => ({
resolveGitDir: resolveGitDirMock
}))
vi.mock('../../source-control/git-read-cache-invalidation', () => ({
runWithGitReadCacheInvalidation: <T>(run: () => Promise<T>) => run()
}))
vi.mock('fs/promises', async () => {
const actual = await vi.importActual<typeof FsPromises>('fs/promises')
return { ...actual, stat: statMock, readFile: readFileMock }
})
import {
createGitCallReader,
createGitCommandMocker,
resetWorktreeRemovalState
} from './remove-worktree-test-harness'
import { areWorktreePathsEqual } from '../catalog/worktree-path-comparison'
import { removeWorktree } from './worktree-removal'
const mockGitCommands = createGitCommandMocker(gitExecFileAsyncMock)
const getGitCalls = createGitCallReader(gitExecFileAsyncMock)
const UNC = '\\\\wsl.localhost\\Ubuntu\\home\\alice\\ws'
describe('worktree path comparison across path syntaxes', () => {
it('keeps two POSIX worktrees that differ only in case distinct on a Windows desktop', () => {
expect(areWorktreePathsEqual('/home/alice/ws/Feature', '/home/alice/ws/feature', 'win32')).toBe(
false
)
})
it('still folds the share alias, the distro name and the slash style, which Windows folds', () => {
expect(
areWorktreePathsEqual(
'\\\\wsl.localhost\\Ubuntu\\home\\alice\\wt',
'//WSL$/ubuntu/home/alice/wt',
'win32'
)
).toBe(true)
})
it('still folds a drvfs tail, which really is a Windows volume', () => {
expect(
areWorktreePathsEqual(
'\\\\wsl$\\Ubuntu\\mnt\\C\\Users\\Jin',
'\\\\wsl.localhost\\Ubuntu\\mnt\\c\\users\\jin',
'win32'
)
).toBe(true)
})
it('does not fold a distro directory that merely looks like the drvfs mount', () => {
expect(
areWorktreePathsEqual(
'\\\\wsl$\\Ubuntu\\MNT\\c\\Repo',
'\\\\wsl$\\Ubuntu\\MNT\\c\\repo',
'win32'
)
).toBe(false)
})
it('collapses dot segments in both case-sensitive syntaxes', () => {
expect(areWorktreePathsEqual(`${UNC}\\.\\feature`, `${UNC}\\x\\..\\feature`, 'win32')).toBe(
true
)
expect(
areWorktreePathsEqual('/home/alice/ws/./feature', '/home/alice/ws/x/../feature', 'win32')
).toBe(true)
})
it('still folds Windows drive paths by case and slash style', () => {
expect(areWorktreePathsEqual('C:/Users/Bob/wt', 'c:\\Users\\bob\\wt', 'win32')).toBe(true)
expect(areWorktreePathsEqual('C:/Users/Bob/wt', 'c:\\Users\\bob\\wt', 'darwin')).toBe(true)
})
it('never equates paths written in different syntaxes', () => {
expect(areWorktreePathsEqual('/home/alice/wt', `${UNC}\\..\\wt`, 'win32')).toBe(false)
expect(areWorktreePathsEqual('/Users/bob/wt', 'C:\\Users\\bob\\wt', 'win32')).toBe(false)
expect(areWorktreePathsEqual(`${UNC}\\wt`, 'C:\\ws\\wt', 'win32')).toBe(false)
})
})
describe('removeWorktree branch selection on a Windows desktop', () => {
const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform')!
beforeEach(() => {
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
gitExecFileAsyncMock.mockReset()
gitExecFileSyncMock.mockReset()
statMock.mockReset()
statMock.mockRejectedValue(Object.assign(new Error('ENOENT'), { code: 'ENOENT' }))
readFileMock.mockReset()
readFileMock.mockRejectedValue(Object.assign(new Error('ENOENT'), { code: 'ENOENT' }))
resolveGitDirMock.mockReset()
resetWorktreeRemovalState()
})
afterEach(() => {
Object.defineProperty(process, 'platform', originalPlatform)
})
it('deletes the branch of the requested WSL worktree, not its case twin', async () => {
const listing = `worktree /home/alice/repo
HEAD aaa111
branch refs/heads/main
worktree /home/alice/ws/Feature
HEAD bbb222
branch refs/heads/Feature
worktree /home/alice/ws/feature
HEAD ccc333
branch refs/heads/feature
`
mockGitCommands({
'git worktree list --porcelain -z': { stdout: listing },
'git worktree list --porcelain': { stdout: listing }
})
await removeWorktree('\\\\wsl.localhost\\Ubuntu\\home\\alice\\repo', `${UNC}\\feature`, true, {
wslDistro: 'Ubuntu'
})
const calls = getGitCalls()
expect(calls).toContain('git branch -d -- feature')
expect(calls).not.toContain('git branch -d -- Feature')
})
})
@@ -1,113 +0,0 @@
import type { ChildProcess } from 'node:child_process'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type * as BackgroundDistroGate from '../wsl/discovery/wsl-background-distro-gate'
const { runWslProcessMock, readWslDistroRunningStateMock } = vi.hoisted(() => ({
runWslProcessMock: vi.fn(),
readWslDistroRunningStateMock: vi.fn()
}))
vi.mock('../wsl/execution/wsl-runner', () => ({ runWslProcess: runWslProcessMock }))
vi.mock('../wsl/discovery/wsl-background-distro-gate', async (importOriginal) => ({
...(await importOriginal<typeof BackgroundDistroGate>()),
readWslDistroRunningState: readWslDistroRunningStateMock
}))
import { createWslProcessGroupTermination } from './wsl-process-group-termination'
describe('WSL process-group termination', () => {
beforeEach(() => {
runWslProcessMock.mockReset()
runWslProcessMock.mockResolvedValue({ code: 0, timedOut: false })
// Windows CI runs the real gate; these cases kill in a running distro.
readWslDistroRunningStateMock.mockReset().mockResolvedValue('running')
})
it('forces and verifies the reported guest process group', async () => {
const termination = createWslProcessGroupTermination('Ubuntu')
const wrapped = termination.wrapGuestArgs(['git', 'fetch']).join(' ')
const marker = wrapped.match(/(__ORCA_WSL_PROCESS_GROUP_[0-9a-f-]+__=)/)?.[1]
termination.observeStderr?.(Buffer.from(`${marker}43`))
// The marker line is still truncated; committing 43 would target a stranger.
await expect(termination.signal({} as ChildProcess)).resolves.toBe(false)
termination.observeStderr?.(Buffer.from('21\n'))
await expect(termination.force({} as ChildProcess)).resolves.toBe(true)
const spec = runWslProcessMock.mock.calls[0]?.[0]
expect(spec.script).toContain('kill -KILL')
expect(spec.args).toEqual(['4321'])
})
it('does not claim termination before the guest reports its identity', async () => {
const termination = createWslProcessGroupTermination('Ubuntu')
await expect(termination.signal({} as ChildProcess)).resolves.toBe(false)
expect(runWslProcessMock).not.toHaveBeenCalled()
})
it('reports failure when the guest kill times out', async () => {
runWslProcessMock.mockResolvedValue({ code: null, timedOut: true })
const termination = createWslProcessGroupTermination('Ubuntu')
const wrapped = termination.wrapGuestArgs(['git', 'fetch']).join(' ')
const marker = wrapped.match(/(__ORCA_WSL_PROCESS_GROUP_[0-9a-f-]+__=)/)?.[1]
termination.observeStderr?.(Buffer.from(`${marker}4321\n`))
await expect(termination.force({} as ChildProcess)).resolves.toBe(false)
})
it('retains the guest identity from a large coalesced stderr chunk', async () => {
const termination = createWslProcessGroupTermination('Ubuntu')
const wrapped = termination.wrapGuestArgs(['git', 'fetch']).join(' ')
const marker = wrapped.match(/(__ORCA_WSL_PROCESS_GROUP_[0-9a-f-]+__=)/)?.[1]
termination.observeStderr?.(Buffer.from(`${marker}4321\n${'x'.repeat(1_024)}`))
await expect(termination.signal({} as ChildProcess)).resolves.toBe(true)
expect(runWslProcessMock.mock.calls[0]?.[0]?.args).toEqual(['4321'])
})
describe('on Windows (E9)', () => {
const originalPlatform = process.platform
beforeEach(() => {
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
})
afterEach(() => {
Object.defineProperty(process, 'platform', { configurable: true, value: originalPlatform })
})
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the guest barrier never reads the host child.
const child = {} as ChildProcess
function reportedGroup(): ReturnType<typeof createWslProcessGroupTermination> {
const termination = createWslProcessGroupTermination('Ubuntu')
const wrapped = termination.wrapGuestArgs(['git', 'fetch']).join(' ')
const marker = wrapped.match(/(__ORCA_WSL_PROCESS_GROUP_[0-9a-f-]+__=)/)?.[1]
termination.observeStderr?.(Buffer.from(`${marker}4321\n`))
return termination
}
it('treats a group in a stopped distro as gone instead of booting it to kill', async () => {
readWslDistroRunningStateMock.mockResolvedValue('stopped')
await expect(reportedGroup().force(child)).resolves.toBe(true)
expect(runWslProcessMock).not.toHaveBeenCalled()
})
it('claims nothing when WSL cannot say whether the distro runs', async () => {
readWslDistroRunningStateMock.mockResolvedValue('unverifiable')
await expect(reportedGroup().signal(child)).resolves.toBe(false)
expect(runWslProcessMock).not.toHaveBeenCalled()
})
it('trusts a stop only from a probe started after the guest proved it ran', async () => {
readWslDistroRunningStateMock.mockResolvedValue('stopped')
const before = performance.now()
const termination = reportedGroup()
await termination.force(child)
const [distro, options] = readWslDistroRunningStateMock.mock.lastCall ?? []
expect(distro).toBe('Ubuntu')
expect(options?.startedAfter).toBeGreaterThanOrEqual(before)
})
})
})
@@ -1,129 +0,0 @@
import { expect, it, vi } from 'vitest'
import { api } from './work-item-search-test-harness'
import { listWorkItems } from './client/list/list-work-items'
import { countWorkItems } from './client/list/count-work-items'
import { mapIssueWorkItem } from './client/map/work-item'
import metadata from './__fixtures__/work-item-search-metadata.json'
it('matches the saved REST projection for users, bots, avatars, assignees and labels', async () => {
api.specialNodes = metadata.map((pair) => pair.graphql)
api.reportedCount = 2748
const result = await listWorkItems('fixture/repo', 5, 'is:issue')
expect(result.items).toEqual(metadata.map((pair) => mapIssueWorkItem(pair.rest)))
expect(await countWorkItems('fixture/repo', 'is:issue')).toBe(2748)
expect(api.restSearches).toBe(0)
})
it('preserves deleted authors and hydrates associations beyond GraphQL connection limits', async () => {
const row = structuredClone(metadata[0].graphql)
api.specialNodes = [
{ ...row, author: null, labels: { ...row.labels, pageInfo: { hasNextPage: true } } }
]
const result = await listWorkItems('fixture/repo', 24, 'is:issue')
expect(result.items).toHaveLength(1)
expect(result.items[0].author).toBeNull()
expect(result.items[0].labels).toEqual(
Array.from({ length: 125 }, (_, index) => `label-${index}`)
)
expect(api.restDetails).toBe(1)
expect(api.restSearches).toBe(0)
})
it('passes every issue predicate to the server for both results and full counts', async () => {
api.expectedSearch =
'repo:fixture/repo is:issue is:closed assignee:"some user" author:"some author" label:"needs review" label:bug in:"title,body" "needle phrase"'
api.specialNodes = [
{ ...metadata[0].graphql, number: 7, state: 'CLOSED', title: 'old needle phrase' }
]
const query =
'is:issue is:closed assignee:"some user" author:"some author" label:"needs review" label:bug in:"title,body" "needle phrase"'
expect((await listWorkItems('fixture/repo', 24, query)).items).toMatchObject([
{ number: 7, state: 'closed', title: 'old needle phrase' }
])
expect(await countWorkItems('fixture/repo', query)).toBe(1)
expect(api.restSearches).toBe(0)
})
it.each([
['is:issue state:all', 'repo:fixture/repo is:issue'],
['is:issue is:open label:bug', 'repo:fixture/repo is:issue is:open label:bug']
])('retains state/scope semantics for %s', async (query, expected) => {
api.expectedSearch = expected
expect((await listWorkItems('fixture/repo', 24, query)).items).toHaveLength(24)
expect(await countWorkItems('fixture/repo', query)).toBe(120)
expect(api.restSearches).toBe(0)
})
it.each([
[
'is:draft',
'is:open draft:true sort:created-desc',
'repo:fixture/repo is:pull-request is:open draft:true'
],
[
'is:pr is:closed',
'is:closed -is:merged sort:created-desc',
'repo:fixture/repo is:pull-request is:closed -is:merged'
],
['is:merged', 'is:merged sort:created-desc', 'repo:fixture/repo is:pull-request is:merged'],
[
'review-requested:"some user" reviewed-by:someone',
'review-requested:"some user" reviewed-by:someone sort:created-desc',
'repo:fixture/repo is:pull-request review-requested:"some user" reviewed-by:someone'
],
[
'is:pr title:foo OR title:bar',
'title:foo OR title:bar sort:created-desc',
'repo:fixture/repo is:pull-request title:foo OR title:bar'
]
])('keeps rich PR lists and full count predicates for %s', async (query, prSearch, countSearch) => {
api.expectedSearch = `( ${prSearch} ) repo:fixture/repo type:pr`
api.specialNodes = []
api.reportedCount = 120
expect((await listWorkItems('fixture/repo', 24, query)).items).toEqual([])
expect(api.graphqlCalls).toBe(1)
expect(api.restSearches).toBe(0)
const request = api.calls.find(({ args }) => args.includes('graphql'))
expect(request?.args.join(' ')).toContain(
JSON.stringify(`( ${prSearch} ) repo:fixture/repo type:pr sort:created-desc`)
)
expect(request?.args.join(' ')).toContain('reviewRequests(first: 100)')
expect(await countWorkItems('fixture/repo', query)).toBe(120)
expect(api.graphqlCalls).toBe(1)
vi.stubEnv('GH_TOKEN', 'fixture-rest-fallback')
api.graphqlAvailable = false
api.expectedSearch = countSearch
expect(await countWorkItems('fixture/repo', query)).toBe(120)
expect(api.restSearches).toBe(1)
})
it('falls back with the original numbered query when GraphQL is unavailable', async () => {
api.graphqlAvailable = false
api.expectedSearch = 'repo:fixture/repo is:issue is:closed label:"needs review" "exact phrase"'
const result = await listWorkItems(
'fixture/repo',
24,
'is:issue is:closed label:"needs review" "exact phrase"',
3,
undefined,
undefined,
true
)
expect(result.items[0].number).toBe(9952)
const call = api.calls.find((call) => call.args.some((arg) => arg.startsWith('search/issues?')))
expect(call?.args).toEqual([
'api',
'--hostname',
'github.com',
`search/issues?q=${encodeURIComponent(api.expectedSearch)}&sort=created&order=desc&per_page=24&page=3`,
'--jq',
'.items'
])
})
it('falls back for malformed GraphQL rows instead of presenting a truncated result', async () => {
api.specialNodes = [{ ...metadata[0].graphql, __typename: 'PullRequest' }]
const result = await listWorkItems('fixture/repo', 24, 'is:issue')
expect(result.items).toHaveLength(1)
expect(api.restSearches).toBe(1)
})
@@ -1,174 +0,0 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { StructuredAttentionOrigin } from '../../shared/agent-session/agent-session-attention'
import {
getDismissHandler,
getDispatchHandler,
notificationCtorMock,
notificationRemoveGroupMock,
notificationRemoveMock,
resetNotificationDispatchMocks
} from './notifications-test-harness'
vi.mock('electron', async () =>
(await import('./notifications-test-harness')).createElectronModuleMock()
)
vi.mock('./notification-authorization-status', async () =>
(await import('./notifications-test-harness')).createNotificationAuthorizationModuleMock()
)
vi.mock('./ui', async () =>
(await import('./notifications-test-harness')).createTrustedUIRendererModuleMock()
)
vi.mock('../tray/system-tray', async () =>
(await import('./notifications-test-harness')).createSystemTrayModuleMock()
)
import { registerNotificationHandlers } from './notifications'
const PANE = 'tab-1:11111111-1111-4111-8111-111111111111'
const originalPlatform = process.platform
const STRUCTURED_ORIGIN: StructuredAttentionOrigin = {
scope: {
executionHostId: 'runtime:remote-host',
wslDistro: null,
workspaceId: 'remote-folder',
workspaceKind: 'folder'
},
sessionId: 'remote-session',
cause: { kind: 'prompt', promptId: 'A' },
journalCursor: { epoch: 'remote-journal', sequence: 1 }
}
function setPlatform(platform: NodeJS.Platform): void {
Object.defineProperty(process, 'platform', { value: platform, configurable: true })
}
function register(): void {
registerNotificationHandlers(
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: these paths read only getSettings; Store is a class, so a structural double needs the cast.
{
getSettings: () => ({
notifications: {
enabled: true,
agentTaskComplete: true,
agentNeedsInput: true,
terminalBell: true,
suppressWhenFocused: false
}
})
} as never
)
}
function dispatchStructured(): unknown {
return getDispatchHandler()(
{},
{
source: 'agent-task-complete',
surface: 'agent-session',
worktreeId: 'repo::remote',
paneKey: PANE,
notificationId: 'agent-attention:A',
attentionKey: 'agent-attention:A',
structuredOrigin: STRUCTURED_ORIGIN
}
)
}
function lastNotificationOptions(): unknown {
return notificationCtorMock.mock.lastCall?.[0]
}
describe('macOS Notification Center removal', () => {
beforeEach(() => {
vi.useFakeTimers()
vi.setSystemTime(new Date('2026-03-28T16:00:00Z'))
resetNotificationDispatchMocks()
setPlatform('darwin')
})
afterEach(() => {
setPlatform(originalPlatform)
vi.useRealTimers()
})
it.each([
{ source: 'agent-task-complete', notificationId: 'agent:turn-1' },
{ source: 'terminal-bell' }
])('posts a $source notification in its pane thread with no id', async (request) => {
register()
await getDispatchHandler()({}, { ...request, worktreeId: 'repo::wt1', paneKey: PANE })
expect(notificationCtorMock).toHaveBeenCalledOnce()
expect(lastNotificationOptions()).toMatchObject({ groupId: `pane:${PANE}` })
expect(lastNotificationOptions()).not.toHaveProperty('id')
})
it('posts a notification with no pane outside any thread', async () => {
register()
await getDispatchHandler()({}, { source: 'test' })
expect(notificationCtorMock).toHaveBeenCalledOnce()
expect(lastNotificationOptions()).not.toHaveProperty('id')
expect(lastNotificationOptions()).not.toHaveProperty('groupId')
})
it('removes a read pane’s thread with no notification held, as after a restart', () => {
register()
getDismissHandler()({}, ['agent:rebuilt'], [PANE])
expect(notificationRemoveGroupMock).toHaveBeenCalledExactlyOnceWith(`pane:${PANE}`)
expect(notificationRemoveMock).not.toHaveBeenCalled()
})
it('removes a structured alert by id only once a read covers it', async () => {
register()
await dispatchStructured()
expect(lastNotificationOptions()).toMatchObject({ id: 'agent-attention:A' })
expect(lastNotificationOptions()).not.toHaveProperty('groupId')
getDismissHandler()({}, [], [PANE])
expect(notificationRemoveMock).not.toHaveBeenCalled()
getDismissHandler()(
{},
[],
[PANE],
[
{
paneKey: PANE,
sessionId: 'remote-session',
observedCursor: { epoch: 'remote-journal', sequence: 1 }
}
]
)
expect(notificationRemoveMock).toHaveBeenCalledExactlyOnceWith(['agent-attention:A'])
})
it.each(['win32', 'linux'] as const)('leaves %s on the close() path', async (platform) => {
setPlatform(platform)
register()
await getDispatchHandler()(
{},
{
source: 'agent-task-complete',
worktreeId: 'repo::wt1',
paneKey: PANE,
notificationId: 'agent:turn-1'
}
)
expect(notificationCtorMock).toHaveBeenCalledOnce()
expect(lastNotificationOptions()).not.toHaveProperty('id')
expect(lastNotificationOptions()).not.toHaveProperty('groupId')
getDismissHandler()({}, ['agent:turn-1'], [PANE])
expect(notificationRemoveMock).not.toHaveBeenCalled()
expect(notificationRemoveGroupMock).not.toHaveBeenCalled()
})
})
@@ -1,124 +0,0 @@
import { describe, expect, it, vi } from 'vitest'
import { setupPtyIpcSuite } from '../pty-ipc-test-harness'
import { registerPtyHandlers } from '../register-handlers'
vi.mock('electron', () => import('../pty-ipc-mock-registry').then((m) => m.electronModuleMock()))
vi.mock('fs', () => import('../pty-ipc-mock-registry').then((m) => m.fsModuleMock()))
vi.mock('node-pty', () => import('../pty-ipc-mock-registry').then((m) => m.nodePtyModuleMock()))
vi.mock('node:child_process', async (importOriginal) =>
(await import('../pty-ipc-mock-registry')).childProcessModuleMock(await importOriginal())
)
vi.mock('../../../opencode/hook-service', () =>
import('../pty-ipc-mock-registry').then((m) => m.openCodeHookServiceModuleMock())
)
vi.mock('../../../mimo/hook-service', () =>
import('../pty-ipc-mock-registry').then((m) => m.mimoHookServiceModuleMock())
)
vi.mock('../../../agent-hooks/server', () =>
import('../pty-ipc-mock-registry').then((m) => m.agentHookServerModuleMock())
)
vi.mock('../../../pi/titlebar-extension-service', () =>
import('../pty-ipc-mock-registry').then((m) => m.piTitlebarExtensionModuleMock())
)
vi.mock('../../../pty/shell-startup/pwsh', () =>
import('../pty-ipc-mock-registry').then((m) => m.pwshModuleMock())
)
vi.mock('../../../wsl/filesystem/unc-path-access', async (importOriginal) =>
(await import('../pty-ipc-mock-registry')).wslModuleMock(await importOriginal())
)
vi.mock('../../../telemetry/client', () =>
import('../pty-ipc-mock-registry').then((m) => m.telemetryClientModuleMock())
)
vi.mock('../../../telemetry/classify-error', () =>
import('../pty-ipc-mock-registry').then((m) => m.classifyErrorModuleMock())
)
vi.mock('../../../cli/linux-terminal-orca-cli-shim', () =>
import('../pty-ipc-mock-registry').then((m) => m.linuxCliShimModuleMock())
)
vi.mock('../../../memory/pty-registry', () =>
import('../pty-ipc-mock-registry').then((m) => m.ptyRegistryModuleMock())
)
vi.mock('../../../agent-hooks/migration-unsupported-pty-state', () =>
import('../pty-ipc-mock-registry').then((m) => m.migrationUnsupportedPtyModuleMock())
)
vi.mock('../../../codex/configuration/homes/codex-pane-account-registry', () =>
import('../pty-ipc-mock-registry').then((m) => m.codexPaneAccountRegistryModuleMock())
)
vi.mock('../../../codex/history/index/codex-state-db-backfill-recovery', () =>
import('../pty-ipc-mock-registry').then((m) => m.codexBackfillRecoveryModuleMock())
)
// The observable daemon provider adopts the spawn's sessionId as the PTY id.
const PTY_ID = 'daemon-session'
describe('the first delivery after a hidden drop (#33766)', () => {
const {
handlers,
mainWindow,
installObservableDaemonTestProvider,
getPtySetHiddenRendererPtyListener
} = setupPtyIpcSuite()
async function hideDropAndResume(dropped: string, resumed: string) {
vi.useFakeTimers()
let seq = 0
const runtime = {
setPtyController: vi.fn(),
registerPty: vi.fn(),
noteTerminalSpawnCommand: vi.fn(),
onPtySpawned: vi.fn(),
onPtyExit: vi.fn(),
onPtyData: vi.fn((_id: string, _data: string, _at: number, chars: number) => (seq += chars)),
getPtyOutputSequence: vi.fn(() => seq),
hasRemoteTerminalViewSubscriber: vi.fn(() => false),
createPreAllocatedTerminalHandle: vi.fn(() => null),
registerPreAllocatedHandleForPty: vi.fn()
}
const daemon = installObservableDaemonTestProvider()
try {
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the suite's window and runtime stubs cover every member registerPtyHandlers reads.
registerPtyHandlers(mainWindow as never, runtime as never)
await handlers.get('pty:spawn')!(null, { cols: 80, rows: 24, sessionId: PTY_ID })
const id = PTY_ID
const setHidden = getPtySetHiddenRendererPtyListener()
setHidden(null, { id, hidden: true })
daemon.emitData(id, dropped)
vi.advanceTimersByTime(50)
setHidden(null, { id, hidden: false })
mainWindow.webContents.send.mockClear()
daemon.emitData(id, resumed)
vi.advanceTimersByTime(50)
const delivered = mainWindow.webContents.send.mock.calls
.filter(([channel]) => channel === 'pty:data')
.map(([, payload]): Record<string, unknown> => payload)
return { delivered, seq }
} finally {
vi.useRealTimers()
}
}
it.each([
[
'an OSC title',
'done\x1b]0;Sequent',
'ial refactoring PRs | cleanup-dumb-exports\x07> prompt',
'\x1b]'
],
['an SGR sequence', 'diff \x1b[38;5', ';242m+ line', '\x1b['],
['a CSI whose ESC alone was dropped', 'diff \x1b', '[38;5;242m+ line', '\x1b']
])(
'labels the first delivery after a drop that split %s, without trimming it',
async (_name, dropped, resumed, gapEscapeState) => {
const { delivered, seq } = await hideDropAndResume(dropped, resumed)
// A restore whose snapshot carries the open escape still needs every continuation byte.
expect(delivered).toEqual([
{ id: expect.any(String), data: resumed, seq, rawLength: resumed.length, gapEscapeState }
])
}
)
it('leaves a delivery after a clean drop boundary unlabeled, including a bell after text', async () => {
const { delivered } = await hideDropAndResume('hidden\x1b[0m', 'build done\x07')
expect(delivered.map((payload) => payload.data).join('')).toBe('build done\x07')
expect(delivered.some((payload) => 'gapEscapeState' in payload)).toBe(false)
})
})
@@ -1,135 +0,0 @@
import type * as FsPromises from 'node:fs/promises'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type * as BackgroundDistroGateModule from '../../../wsl/discovery/wsl-background-distro-gate'
import type * as WslMoveScopeGuardModule from '../../../persistence/moving-wsl-projects/wsl-move-scope-guard'
import type { Store } from '../../../persistence/loading-store/store'
import type { FolderWorkspace } from '../../../../shared/folder-workspaces/folder-workspace-types'
import { folderWorkspaceKey } from '../../../../shared/workspace-scope'
import { wslDistroHoldMessage } from '../../../../shared/wsl-distro-stopped-hold'
import { isWslUserActFor } from '../../../wsl/discovery/wsl-user-act-scope'
const mocks = vi.hoisted(() => ({
stat: vi.fn<(path: string) => Promise<{ isDirectory(): boolean }>>(),
readWslDistroRunningState: vi.fn<() => Promise<'running' | 'stopped' | 'unverifiable'>>()
}))
vi.mock('node:fs/promises', async (importOriginal) => ({
...(await importOriginal<typeof FsPromises>()),
stat: mocks.stat
}))
vi.mock('../../../wsl/discovery/wsl-background-distro-gate', async (importOriginal) => ({
...(await importOriginal<typeof BackgroundDistroGateModule>()),
readWslDistroRunningState: mocks.readWslDistroRunningState
}))
vi.mock(
'../../../persistence/moving-wsl-projects/wsl-move-scope-guard',
async (importOriginal) => ({
...(await importOriginal<typeof WslMoveScopeGuardModule>()),
isWslMoveCwdFenced: () => false,
isWslMoveWorkspaceBlocked: () => false
})
)
import { assertWorkspaceAcceptsNewPty } from './spawn-cwd'
import { getFolderWorkspacePathStatus } from '../../../project-groups/folder-workspace-path-status'
const FOLDER = '\\\\wsl.localhost\\Ubuntu\\home\\me\\notes'
const WORKTREE_ID = folderWorkspaceKey('folder-1')
const originalPlatform = process.platform
function makeStore(twinOrder?: 'remote-first' | 'local-first'): Store {
const workspace: FolderWorkspace = {
id: 'folder-1',
projectGroupId: 'group-1',
name: 'notes',
folderPath: FOLDER,
linkedTask: null,
comment: '',
isArchived: false,
isUnread: false,
isPinned: false,
sortOrder: 0,
lastActivityAt: 1,
createdAt: 1,
updatedAt: 1
}
const remote = { ...workspace, connectionId: 'remote', folderPath: '/remote/notes' }
const folders = twinOrder
? twinOrder === 'remote-first'
? [remote, workspace]
: [workspace, remote]
: [workspace]
const store = {
getRepos: () => [],
getProjectGroups: () => [],
getFolderWorkspaces: () => folders
}
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the folder check reads only these three store methods.
return store as unknown as Store
}
beforeEach(() => {
Object.defineProperty(process, 'platform', { value: 'win32' })
mocks.stat.mockReset()
mocks.stat.mockImplementation(async () => {
expect(isWslUserActFor('Ubuntu')).toBe(true)
return { isDirectory: () => true }
})
mocks.readWslDistroRunningState.mockReset()
mocks.readWslDistroRunningState.mockResolvedValue('stopped')
})
afterEach(() => {
Object.defineProperty(process, 'platform', { value: originalPlatform })
})
describe('a folder workspace on a stopped WSL distro', () => {
it.each(['remote-first', 'local-first'] as const)(
'keeps the selected host through the passive WSL check (%s)',
async (order) => {
const store = makeStore(order)
await expect(
assertWorkspaceAcceptsNewPty(store, WORKTREE_ID, 'session-1', undefined, true, null)
).rejects.toThrow(wslDistroHoldMessage('Ubuntu', 'stopped'))
expect(mocks.stat).not.toHaveBeenCalled()
mocks.readWslDistroRunningState.mockClear()
await expect(
assertWorkspaceAcceptsNewPty(store, WORKTREE_ID, 'session-1', undefined, true, 'remote')
).rejects.toThrow('folder_workspace_path_unavailable:/remote/notes')
expect(mocks.stat).not.toHaveBeenCalled()
expect(mocks.readWslDistroRunningState).not.toHaveBeenCalled()
}
)
it('reports the folder unavailable to a background status read without touching it', async () => {
await expect(
getFolderWorkspacePathStatus(makeStore(), {
scope: 'folder-workspace',
folderWorkspaceId: 'folder-1'
})
).resolves.toEqual({ path: FOLDER, exists: false, reason: 'unavailable' })
expect(mocks.stat).not.toHaveBeenCalled()
})
it('holds a passive restore with Start instead of checking the folder', async () => {
await expect(
assertWorkspaceAcceptsNewPty(makeStore(), WORKTREE_ID, 'session-1', undefined, true)
).rejects.toThrow(wslDistroHoldMessage('Ubuntu', 'stopped'))
expect(mocks.stat).not.toHaveBeenCalled()
})
it('checks the folder as the user act for a spawn the user asked for', async () => {
await expect(
assertWorkspaceAcceptsNewPty(makeStore(), WORKTREE_ID, undefined, undefined, false)
).resolves.toBeUndefined()
expect(mocks.stat).toHaveBeenCalledExactlyOnceWith(FOLDER)
})
it('lets a passive restore check the folder once the distro runs', async () => {
mocks.readWslDistroRunningState.mockResolvedValue('running')
mocks.stat.mockResolvedValue({ isDirectory: () => true })
await expect(
assertWorkspaceAcceptsNewPty(makeStore(), WORKTREE_ID, 'session-1', undefined, true)
).resolves.toBeUndefined()
expect(mocks.stat).toHaveBeenCalledExactlyOnceWith(FOLDER)
})
})
@@ -116,19 +116,4 @@ describe('triggerPtySpawnPushTargetMaterialization (real git fixture)', () => {
).trim()
expect(fetchedHead).toBe(forkHead)
})
it('is a no-op once the remote was already created (repeat pty:spawn, e.g. reattach)', async () => {
const target = { ...forkTarget(), remoteCreated: true }
const { deps } = depsFor(target)
await git(['remote', 'add', FORK_REMOTE, forkPath], repoPath)
triggerPtySpawnPushTargetMaterialization(deps, { cols: 80, rows: 24, worktreeId })
// Let the fire-and-forget hook settle before asserting the remoteCreated no-op.
await new Promise((resolve) => setImmediate(resolve))
const upstream = await git(['rev-parse', '--abbrev-ref', `${mainBranch}@{u}`], repoPath).catch(
() => ''
)
expect(upstream.trim()).toBe('')
})
})
@@ -1,125 +0,0 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { statSyncMock, spawnMock, wslUncDirectoryExistsAsyncMock } from './pty-ipc-mock-registry'
import { setupPtyIpcSuite } from './pty-ipc-test-harness'
import { registerPtyHandlers } from './register-handlers'
vi.mock('electron', () => import('./pty-ipc-mock-registry').then((m) => m.electronModuleMock()))
vi.mock('fs', () => import('./pty-ipc-mock-registry').then((m) => m.fsModuleMock()))
vi.mock('node-pty', () => import('./pty-ipc-mock-registry').then((m) => m.nodePtyModuleMock()))
vi.mock('node:child_process', async (importOriginal) =>
(await import('./pty-ipc-mock-registry')).childProcessModuleMock(await importOriginal())
)
vi.mock('../../opencode/hook-service', () =>
import('./pty-ipc-mock-registry').then((m) => m.openCodeHookServiceModuleMock())
)
vi.mock('../../mimo/hook-service', () =>
import('./pty-ipc-mock-registry').then((m) => m.mimoHookServiceModuleMock())
)
vi.mock('../../agent-hooks/server', () =>
import('./pty-ipc-mock-registry').then((m) => m.agentHookServerModuleMock())
)
vi.mock('../../pi/titlebar-extension-service', () =>
import('./pty-ipc-mock-registry').then((m) => m.piTitlebarExtensionModuleMock())
)
vi.mock('../../pty/shell-startup/pwsh', () =>
import('./pty-ipc-mock-registry').then((m) => m.pwshModuleMock())
)
vi.mock('../../wsl/filesystem/unc-path-access', async (importOriginal) =>
(await import('./pty-ipc-mock-registry')).wslModuleMock(await importOriginal())
)
vi.mock('node:fs/promises', async (importOriginal) =>
(await import('./pty-ipc-mock-registry')).fsPromisesModuleMock(await importOriginal())
)
vi.mock('../../telemetry/client', () =>
import('./pty-ipc-mock-registry').then((m) => m.telemetryClientModuleMock())
)
vi.mock('../../telemetry/classify-error', () =>
import('./pty-ipc-mock-registry').then((m) => m.classifyErrorModuleMock())
)
vi.mock('../../cli/linux-terminal-orca-cli-shim', () =>
import('./pty-ipc-mock-registry').then((m) => m.linuxCliShimModuleMock())
)
vi.mock('../../memory/pty-registry', () =>
import('./pty-ipc-mock-registry').then((m) => m.ptyRegistryModuleMock())
)
vi.mock('../../agent-hooks/migration-unsupported-pty-state', () =>
import('./pty-ipc-mock-registry').then((m) => m.migrationUnsupportedPtyModuleMock())
)
vi.mock('../../codex/configuration/homes/codex-pane-account-registry', () =>
import('./pty-ipc-mock-registry').then((m) => m.codexPaneAccountRegistryModuleMock())
)
vi.mock('../../codex/history/index/codex-state-db-backfill-recovery', () =>
import('./pty-ipc-mock-registry').then((m) => m.codexBackfillRecoveryModuleMock())
)
const runningStateMock = vi.hoisted(() =>
vi.fn(async (_distro: string, _options?: { startedAfter?: number }) => 'stopped')
)
vi.mock('../../wsl/discovery/wsl-background-distro-gate', async (importOriginal) => ({
...(await importOriginal<object>()),
readWslDistroRunningState: runningStateMock
}))
const WSL_RUNTIME = {
status: 'resolved',
runtime: {
kind: 'wsl',
hostPlatform: 'wsl',
projectId: 'repo-1',
distro: 'Ubuntu',
reason: 'project-override',
cacheKey: 'repo-1:wsl'
}
} as const
// E9: a restore the user did not ask for must not start a stopped distro, whatever provider runs.
describe('a passive WSL restore with a stopped distro', () => {
const { handlers, mainWindow } = setupPtyIpcSuite()
const originalPlatform = process.platform
beforeEach(() => {
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
runningStateMock.mockReset().mockResolvedValue('stopped')
wslUncDirectoryExistsAsyncMock.mockReset().mockResolvedValue(true)
statSyncMock.mockImplementation(() => ({ isDirectory: () => true, mode: 0o755 }))
})
afterEach(() => {
Object.defineProperty(process, 'platform', { configurable: true, value: originalPlatform })
})
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the suite's window fake implements what the handlers read.
const register = (): void => registerPtyHandlers(mainWindow as never)
const restore = (overrides: Record<string, unknown> = {}) =>
handlers.get('pty:spawn')!(null, {
cols: 80,
rows: 24,
cwd: '/home/me/repo',
worktreeId: 'repo-1::C:\\Users\\me\\repo',
sessionId: 'repo-1::C:\\Users\\me\\repo@@restored',
projectRuntime: WSL_RUNTIME,
wslPassiveRestore: true,
...overrides
})
it('holds with the local provider (daemon off), before the cwd probe or any spawn', async () => {
register()
await expect(restore()).rejects.toThrow()
expect(runningStateMock).toHaveBeenCalledWith('Ubuntu', expect.anything())
expect(wslUncDirectoryExistsAsyncMock).not.toHaveBeenCalled()
expect(spawnMock).not.toHaveBeenCalled()
})
it('restores once the distro runs, and never holds a restore the user asked for', async () => {
register()
runningStateMock.mockResolvedValue('running')
await restore()
expect(spawnMock).toHaveBeenCalledTimes(1)
runningStateMock.mockResolvedValue('stopped')
runningStateMock.mockClear()
await restore({ wslPassiveRestore: undefined, sessionId: 'repo-1::C:\\Users\\me\\repo@@other' })
expect(runningStateMock).not.toHaveBeenCalled()
expect(spawnMock).toHaveBeenCalledTimes(2)
})
})
@@ -1,137 +0,0 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const mocks = vi.hoisted(() => ({
ensureBackfill: vi.fn(async () => {}),
blockedPath: vi.fn(async (): Promise<{ distro: string; state: string } | null> => null),
regQuery: vi.fn()
}))
const LXSS = 'HKEY_CURRENT_USER\\Software\\Microsoft\\Windows\\CurrentVersion\\Lxss'
const UBUNTU_ID = '0e3a7f2c-1b4d-4c5e-9f60-7a8b9c0d1e2f'
// The common single-distro setup: Lxss names Ubuntu as the default.
const LXSS_UBUNTU_DEFAULT = [
LXSS,
` DefaultDistribution REG_SZ {${UBUNTU_ID}}`,
'',
`${LXSS}\\{${UBUNTU_ID}}`,
' DistributionName REG_SZ Ubuntu',
''
].join('\r\n')
vi.mock('@orca/process-host', async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
runProcess: mocks.regQuery
}))
// wsl.exe's own list is unavailable, so the default comes from the registry alone.
vi.mock('../../wsl/discovery/distro-catalog', async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
listWslDistrosAsync: async () => []
}))
vi.mock('../../codex/history/index/codex-state-db-backfill-recovery', () => ({
ensureCodexStateDbBackfillRecoveryStarted: mocks.ensureBackfill
}))
vi.mock('../../wsl/filesystem/wsl-unc-access-gate', async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
blockedWslDistroForPath: mocks.blockedPath
}))
import { resolvePtySpawnHostEnv } from './spawn-host-env'
import { noCodexResumeLaunch, stripSequencedStartupResumeArgv } from './host-env/codex-resume'
import { isWslUserActFor } from '../../wsl/discovery/wsl-user-act-scope'
import { resetWslDefaultDistroForTests } from '../../wsl/discovery/wsl-default-distro'
import type { IPtyProvider } from '../../providers/pty-provider-contract'
import type { CodexAccountSelectionTarget } from '../../codex-accounts/runtime-selection'
const HOME = '\\\\wsl.localhost\\Ubuntu\\home\\alice\\.codex'
const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform')
function resolve(
wslPassiveRestore: boolean,
seen: boolean[],
launch: { target?: CodexAccountSelectionTarget; expectedWslDistro?: string | null } = {}
) {
return resolvePtySpawnHostEnv({
launchAgent: 'codex',
command: 'codex',
resumeProviderSession: undefined,
env: {},
cwd: '\\\\wsl.localhost\\Ubuntu\\home\\alice\\repo',
shellOverride: undefined,
expectedWslDistro: launch.expectedWslDistro === undefined ? 'Ubuntu' : launch.expectedWslDistro,
codexSelectionTarget: launch.target ?? { runtime: 'wsl', wslDistro: 'Ubuntu' },
preAdopted: false,
isDaemonHostSpawn: false,
sessionId: 'session-1',
callerNamedSession: true,
mintedSessionId: false,
resumeOnSelectedAccount: false,
emptySessionIdSkipsHostEnv: false,
wslPassiveRestore,
requestedAgentTeamsPath: undefined,
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: a non-daemon spawn returns before any provider call.
provider: {} as IPtyProvider,
deps: {
prepareCodexResumeHome: () => null,
resolveCodexResumeLaunch: async () => noCodexResumeLaunch('codex'),
noCodexResumeLaunch,
reconcileSharedRuntimeResumeHome: async (home) => home.codexHomePath,
stripSequencedStartupResumeArgv,
getSelectedCodexHomePath: async () => {
seen.push(isWslUserActFor('Ubuntu'))
return HOME
}
}
})
}
beforeEach(() => {
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
mocks.ensureBackfill.mockClear()
mocks.blockedPath.mockReset().mockResolvedValue(null)
mocks.regQuery.mockReset().mockResolvedValue({ code: 0, stdout: LXSS_UBUNTU_DEFAULT })
resetWslDefaultDistroForTests()
})
afterEach(() => {
if (originalPlatform) {
Object.defineProperty(process, 'platform', originalPlatform)
}
})
// Spawn env prep runs before #35813's hold: only a launch someone asked for may start the distro (E9).
describe('WSL spawn host env', () => {
it('never turns a passive restore with no named distro into a user act on the default one', async () => {
const seen: boolean[] = []
await resolve(true, seen, {
target: { runtime: 'wsl', wslDistro: null },
expectedWslDistro: null
})
expect(seen).toEqual([false])
})
it('a WSL launch someone asked for, with no named distro, acts on the default one', async () => {
const seen: boolean[] = []
await resolve(false, seen, {
target: { runtime: 'wsl', wslDistro: null },
expectedWslDistro: null
})
expect(seen).toEqual([true])
})
it('a launch whose default WSL cannot name never acts on a guessed distro', async () => {
mocks.regQuery.mockResolvedValue({ code: 1, stdout: '' })
const seen: boolean[] = []
await resolve(false, seen, {
target: { runtime: 'wsl', wslDistro: null },
expectedWslDistro: null
})
expect(seen).toEqual([false])
})
it('skips the state-DB recovery (a UNC read) when the distro is stopped', async () => {
mocks.blockedPath.mockResolvedValue({ distro: 'Ubuntu', state: 'stopped' })
await resolve(true, [])
expect(mocks.blockedPath).toHaveBeenCalledWith(HOME)
expect(mocks.ensureBackfill).not.toHaveBeenCalled()
})
})
@@ -1,142 +0,0 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type * as DistroGateModule from '../../wsl/discovery/wsl-background-distro-gate'
import type { Repo } from '../../../shared/repo-types'
const mocks = vi.hoisted(() => {
const state: {
distroState: 'running' | 'stopped' | 'unverifiable'
gitOptions: { wslDistro?: string }
} = { distroState: 'stopped', gitOptions: {} }
return {
state,
handlers: new Map<string, (event: unknown, args: unknown) => Promise<unknown>>(),
send: vi.fn(),
getBaseRefDefault: vi.fn(async (_path: string, _options?: unknown) => 'origin/main'),
getRemoteCount: vi.fn(async (_path: string, _options?: unknown) => 1)
}
})
vi.mock('electron', () => ({
BrowserWindow: {
getAllWindows: () => [{ isDestroyed: () => false, webContents: { send: mocks.send } }]
},
ipcMain: {
handle: (channel: string, handler: (event: unknown, args: unknown) => Promise<unknown>) =>
mocks.handlers.set(channel, handler)
}
}))
vi.mock('../../git/repo', () => ({
getBaseRefDefault: mocks.getBaseRefDefault,
getRemoteCount: mocks.getRemoteCount,
searchBaseRefDetails: vi.fn(async () => [])
}))
vi.mock('../../workspaces/execution-host/project-runtime-git-options', () => ({
getProjectRuntimeReadGitOptions: () => mocks.state.gitOptions
}))
vi.mock('../../wsl/discovery/wsl-background-distro-gate', async (importOriginal) => ({
...(await importOriginal<typeof DistroGateModule>()),
readWslDistroRunningState: vi.fn(async () => mocks.state.distroState)
}))
import { registerBaseRefQueryHandlers } from './base-ref-query-handlers'
import {
_resetWslBackgroundDistroGateForTests,
noteWslDistrosRunning
} from '../../wsl/discovery/wsl-background-distro-gate'
import { RuntimeRepositoryRefQueries } from '../../runtime/runtime-repository-ref-queries'
const WSL_REPO = '\\\\wsl.localhost\\Ubuntu\\home\\me\\repo'
const UNKNOWN = { defaultBaseRef: null, remoteCount: 0 }
const REFUSED = { ...UNKNOWN, refusedWslDistro: 'Ubuntu' }
const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform')
function makeRepo(overrides: Partial<Repo> = {}): Repo {
return {
id: 'repo-1',
path: WSL_REPO,
displayName: 'repo',
badgeColor: '#000',
addedAt: 1,
kind: 'git',
...overrides
}
}
function registerWith(repo: Repo): (args: Record<string, unknown>) => Promise<unknown> {
const store = { getRepo: () => repo, getRepos: () => [repo] }
registerBaseRefQueryHandlers(
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the handler reads only getRepo/getRepos, both provided.
store as unknown as Parameters<typeof registerBaseRefQueryHandlers>[0]
)
const handler = mocks.handlers.get('repos:getBaseRefDefault')
if (!handler) {
throw new Error('repos:getBaseRefDefault was not registered')
}
return (args) => handler(null, { repoId: repo.id, ...args })
}
describe('repos:getBaseRefDefault for a stopped WSL distro (E9)', () => {
beforeEach(() => {
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
mocks.handlers.clear()
mocks.send.mockClear()
_resetWslBackgroundDistroGateForTests()
mocks.state.distroState = 'stopped'
mocks.state.gitOptions = {}
mocks.getBaseRefDefault.mockClear()
mocks.getRemoteCount.mockClear()
})
afterEach(() => {
if (originalPlatform) {
Object.defineProperty(process, 'platform', originalPlatform)
}
})
it('returns unknown without running Git when the Settings pane opens', async () => {
const getDefault = registerWith(makeRepo())
await expect(getDefault({})).resolves.toEqual(REFUSED)
expect(mocks.getBaseRefDefault).not.toHaveBeenCalled()
expect(mocks.getRemoteCount).not.toHaveBeenCalled()
})
it('tells renderers to re-ask once the refusing distro runs', async () => {
const getDefault = registerWith(makeRepo())
await getDefault({})
expect(mocks.send).not.toHaveBeenCalled()
noteWslDistrosRunning(['Ubuntu'])
expect(mocks.send).toHaveBeenCalledWith('preflight:wslDistroRunning', 'Ubuntu')
})
it('refuses a host path whose Git runs through wsl.exe in a stopped distro', async () => {
mocks.state.gitOptions = { wslDistro: 'Ubuntu' }
const getDefault = registerWith(makeRepo({ path: 'C:\\src\\repo' }))
await expect(getDefault({})).resolves.toEqual(REFUSED)
expect(mocks.getBaseRefDefault).not.toHaveBeenCalled()
})
})
describe('runtime repo.baseRefDefault for a stopped WSL distro (E9)', () => {
beforeEach(() => {
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
mocks.state.distroState = 'stopped'
mocks.getBaseRefDefault.mockClear()
})
afterEach(() => {
if (originalPlatform) {
Object.defineProperty(process, 'platform', originalPlatform)
}
})
it('returns unknown, naming the refusing distro, without touching its UNC path', async () => {
const queries = new RuntimeRepositoryRefQueries({ resolveRepo: async () => makeRepo() })
await expect(queries.getDefault('id:repo-1')).resolves.toEqual(REFUSED)
expect(mocks.getBaseRefDefault).not.toHaveBeenCalled()
})
})
@@ -1,137 +0,0 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const mocks = vi.hoisted(() => ({
handle: vi.fn(),
update: vi.fn(),
rollback: vi.fn(),
recover: vi.fn(),
stop: vi.fn(),
cancel: vi.fn(),
retire: vi.fn()
}))
vi.mock('electron', () => ({ ipcMain: { handle: mocks.handle } }))
vi.mock('../../ssh/orcad-runtime-maintenance', () => ({
updateManagedOrcadEnvironment: mocks.update,
rollbackManagedOrcadEnvironment: mocks.rollback,
recoverManagedOrcadEnvironment: mocks.recover
}))
vi.mock('../../managed-server/lifecycle/orcad-runtime-decommission', () => ({
stopManagedOrcadEnvironment: mocks.stop,
cancelManagedOrcadStop: mocks.cancel
}))
vi.mock('./runtime-environment-removal-cleanup', () => ({
retireRemovedRuntimeEnvironment: mocks.retire
}))
const { registerOrcadRuntimeMaintenanceHandlers } =
await import('./orcad-runtime-maintenance-handlers')
const invalidateTransport = vi.fn()
const clearHostServerStatus = vi.fn()
const clearHostServerNotes = vi.fn()
const store = {
removeWorkspaceSessionHost: vi.fn(),
getRemoteLayoutSnapshot: vi.fn(),
setRemoteLayoutSnapshot: vi.fn(),
setHostRepoCatalogSnapshot: vi.fn()
}
function handler(channel: string): (_event: unknown, args: unknown) => Promise<unknown> {
const registration = mocks.handle.mock.calls.find(([name]) => name === channel)
if (!registration) {
throw new Error(`${channel} handler was not registered`)
}
return registration[1]
}
describe('managed orcad maintenance IPC', () => {
beforeEach(() => {
vi.clearAllMocks()
registerOrcadRuntimeMaintenanceHandlers({
getUserDataPath: () => '/profile',
getActiveEnvironmentId: () => 'active-environment',
invalidateTransport,
clearHostServerStatus,
clearHostServerNotes,
store
})
})
it('reconnects after an update restarts orcad, but not after a deferral', async () => {
mocks.update.mockResolvedValueOnce({ outcome: 'deferred', code: 'busy' })
await handler('runtimeEnvironments:updateOrcad')(null, { selector: 'Managed', force: 'yes' })
expect(mocks.update).toHaveBeenCalledWith('/profile', { selector: 'Managed', force: false })
expect(invalidateTransport).not.toHaveBeenCalled()
expect(clearHostServerNotes).not.toHaveBeenCalled()
mocks.update.mockResolvedValueOnce({ outcome: 'updated', environment: { id: 'e-1' } })
await handler('runtimeEnvironments:updateOrcad')(null, { selector: 'Managed', force: true })
expect(invalidateTransport).toHaveBeenCalledWith('e-1')
})
it.each(['updated', 'already-current'])(
'clears the host notes after a verified %s update',
async (outcome) => {
mocks.update.mockResolvedValueOnce({
outcome,
environment: { id: 'e-1', orcadDeployment: { sshTargetId: 'ssh-1' } },
activeVersion: 'candidate'
})
await handler('runtimeEnvironments:updateOrcad')(null, { selector: 'Managed', force: true })
expect(clearHostServerNotes).toHaveBeenCalledExactlyOnceWith('ssh-1', 'e-1')
expect(invalidateTransport).toHaveBeenCalledTimes(outcome === 'updated' ? 1 : 0)
}
)
it('clears the notes only after the old transport has been retired', async () => {
let finishRetirement: (() => void) | undefined
invalidateTransport.mockReturnValueOnce(
new Promise<void>((resolve) => {
finishRetirement = resolve
})
)
mocks.update.mockResolvedValueOnce({
outcome: 'updated',
environment: { id: 'e-1', orcadDeployment: { sshTargetId: 'ssh-1' } }
})
const updating = handler('runtimeEnvironments:updateOrcad')(null, { selector: 'Managed' })
await vi.waitFor(() => expect(invalidateTransport).toHaveBeenCalledOnce())
expect(clearHostServerNotes).not.toHaveBeenCalled()
finishRetirement?.()
await updating
expect(clearHostServerNotes).toHaveBeenCalledOnce()
})
it('keeps the previous diagnostic when an update rejects', async () => {
mocks.update.mockRejectedValueOnce(new Error('SSH contact lost'))
await expect(
handler('runtimeEnvironments:updateOrcad')(null, { selector: 'Managed' })
).rejects.toThrow('SSH contact lost')
expect(clearHostServerNotes).not.toHaveBeenCalled()
expect(invalidateTransport).not.toHaveBeenCalled()
})
it('stops with the Active Server guard and the shared removal cleanup', async () => {
mocks.stop.mockResolvedValueOnce({ outcome: 'unlinked', sshTargetId: 'ssh-1' })
await handler('runtimeEnvironments:stopOrcad')(null, { selector: ' Managed ' })
const [, args, policy] = mocks.stop.mock.calls[0] ?? []
expect(args).toEqual({ selector: 'Managed' })
expect(policy.isActiveEnvironment('active-environment')).toBe(true)
expect(policy.isActiveEnvironment('e-1')).toBe(false)
policy.retireLocalState('e-1')
expect(mocks.retire).toHaveBeenCalledWith('e-1', invalidateTransport, store, '/profile')
// The SSH host stops naming the unlinked server without waiting for a reconnect.
expect(clearHostServerStatus).toHaveBeenCalledWith('ssh-1')
})
it('keeps the SSH host’s managed state when the stop is refused', async () => {
mocks.stop.mockResolvedValueOnce({
outcome: 'refused',
verdict: 'live',
code: 'c',
reason: 'r'
})
await handler('runtimeEnvironments:stopOrcad')(null, { selector: 'Managed' })
expect(clearHostServerStatus).not.toHaveBeenCalled()
})
})
@@ -1,144 +0,0 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { SshTarget } from '../../shared/ssh-types'
const { handleMock, resolveCliCommandMock, spawnMock } = vi.hoisted(() => ({
handleMock: vi.fn(),
resolveCliCommandMock: vi.fn(),
spawnMock: vi.fn()
}))
vi.mock('electron', () => ({
ipcMain: { handle: handleMock },
shell: { showItemInFolder: vi.fn(), openExternal: vi.fn(), openPath: vi.fn() },
dialog: { showOpenDialog: vi.fn() }
}))
vi.mock('node:child_process', () => ({ spawn: spawnMock }))
vi.mock('../../shared/node-cli-command-resolution', () => ({
resolveCliCommand: resolveCliCommandMock
}))
import { registerShellHandlers } from './shell'
import { fingerprintRuntimeSshTarget } from '../ssh/runtime-ssh-target-fingerprint'
import type { ListRuntimeEnvironments } from './shell-editor-ssh-host'
const link = { sshTargetId: 'ssh-1', sshTargetGeneration: 1, localPort: 41000, remotePort: 6768 }
const managedServer = { id: 'env-1', orcadDeployment: link }
function sshTarget(id: string, configHost: string, overrides: Partial<SshTarget> = {}): SshTarget {
return {
id,
label: configHost,
host: `${configHost}.example.com`,
port: 22,
username: 'ada',
source: 'ssh-config',
configHost,
generation: 1,
orcadFence: { environmentId: 'env-1' },
...overrides
}
}
function spawnedChild(): { once: unknown; off: unknown; unref: unknown } {
const child = {
once: vi.fn((eventName: string, callback: () => void) => {
if (eventName === 'spawn') {
queueMicrotask(callback)
}
return child
}),
off: vi.fn(() => child),
unref: vi.fn()
}
return child
}
describe('Open in VS Code for a workspace on a server Orca reaches through an SSH host', () => {
const sshAccessServer = {
id: 'env-1',
sshAccess: {
...link,
endpointId: 'tunnel',
previousPreferredEndpointId: 'lan',
targetFingerprint: fingerprintRuntimeSshTarget(sshTarget('ssh-1', 'builder'))
}
}
const targets = new Map<string, SshTarget>()
beforeEach(() => {
targets.clear()
targets.set('ssh-1', sshTarget('ssh-1', 'builder'))
targets.set('ssh-2', sshTarget('ssh-2', 'other'))
handleMock.mockReset()
spawnMock.mockReset()
spawnMock.mockImplementation(spawnedChild)
resolveCliCommandMock.mockReset()
resolveCliCommandMock.mockReturnValue('/usr/local/bin/code')
})
function openInEditor(
listRuntimeEnvironments: ListRuntimeEnvironments,
request: { connectionId: string; ownerHostId: string }
): Promise<unknown> {
registerShellHandlers({ getSshTarget: (id) => targets.get(id) }, listRuntimeEnvironments)
const call = handleMock.mock.calls.find((entry) => entry[0] === 'shell:openInExternalEditor')
return call?.[1]({}, { path: '/home/ada/project', command: 'code', ...request })
}
it.each([
['managed server', managedServer],
['paired server with SSH access', sshAccessServer]
])('opens a %s workspace through the host alias', async (_label, environment) => {
await expect(
openInEditor(() => [environment], { connectionId: 'ssh-1', ownerHostId: 'runtime:env-1' })
).resolves.toEqual({ ok: true })
expect(spawnMock).toHaveBeenCalledWith(
'/usr/local/bin/code',
['--remote', 'ssh-remote+builder', '/home/ada/project'],
expect.objectContaining({ detached: true, stdio: 'ignore', windowsHide: true, shell: false })
)
})
it.each([
['a host the server does not run on', { connectionId: 'ssh-2', ownerHostId: 'runtime:env-1' }],
['an unknown server', { connectionId: 'ssh-1', ownerHostId: 'runtime:env-unknown' }],
['a local owner', { connectionId: 'ssh-1', ownerHostId: 'local' }]
])('refuses %s', async (_label, request) => {
await expect(openInEditor(() => [managedServer], request)).resolves.toEqual({
ok: false,
reason: 'remote-runtime-unsupported'
})
expect(spawnMock).not.toHaveBeenCalled()
})
it.each([
['was re-created since the server linked it', { generation: 2 }],
['is fenced to another managed server', { orcadFence: { environmentId: 'env-2' } }]
])('refuses a host whose SSH registration %s', async (_label, overrides) => {
targets.set('ssh-1', sshTarget('ssh-1', 'builder', overrides))
await expect(
openInEditor(() => [managedServer], { connectionId: 'ssh-1', ownerHostId: 'runtime:env-1' })
).resolves.toEqual({ ok: false, reason: 'remote-runtime-unsupported' })
expect(spawnMock).not.toHaveBeenCalled()
})
it('refuses SSH access verified against connection settings the host no longer has', async () => {
targets.set('ssh-1', sshTarget('ssh-1', 'builder', { host: 'elsewhere.example.com' }))
await expect(
openInEditor(() => [sshAccessServer], { connectionId: 'ssh-1', ownerHostId: 'runtime:env-1' })
).resolves.toEqual({ ok: false, reason: 'remote-runtime-unsupported' })
expect(spawnMock).not.toHaveBeenCalled()
})
it('refuses when the server list cannot be read', async () => {
const unreadable: ListRuntimeEnvironments = () => {
throw new Error('unreadable store')
}
await expect(
openInEditor(unreadable, { connectionId: 'ssh-1', ownerHostId: 'runtime:env-1' })
).resolves.toEqual({ ok: false, reason: 'remote-runtime-unsupported' })
expect(spawnMock).not.toHaveBeenCalled()
})
})
-143
View File
@@ -1,143 +0,0 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type * as WindowsPathAccessHost from '../windows/windows-path-access-host'
import type * as ExternalEditorLaunch from '../external-editors/external-editor-launch'
const { handleMock, launchMock, showItemInFolderMock, statMock, recordForEnvironmentMock } =
vi.hoisted(() => ({
handleMock: vi.fn<(channel: string, handler: (...args: unknown[]) => unknown) => void>(),
launchMock: vi.fn(),
showItemInFolderMock: vi.fn(),
statMock: vi.fn(),
recordForEnvironmentMock: vi.fn()
}))
vi.mock('electron', () => ({
ipcMain: { handle: handleMock },
shell: { showItemInFolder: showItemInFolderMock, openExternal: vi.fn(), openPath: vi.fn() },
dialog: { showOpenDialog: vi.fn() }
}))
vi.mock('node:fs/promises', () => ({
constants: { COPYFILE_EXCL: 1 },
copyFile: vi.fn(),
readFile: vi.fn(),
stat: statMock
}))
vi.mock('../../shared/node-cli-command-resolution', () => ({
resolveCliCommand: (command: string) => command
}))
vi.mock('../wsl/host-lifecycle/wsl-host-service-instance', () => ({
getWslHostService: () => ({ recordForEnvironment: recordForEnvironmentMock })
}))
// WSL paths require Windows resolution even when this test runs on another platform.
vi.mock('../windows/windows-path-access-host', async (importOriginal) => {
const actual = await importOriginal<typeof WindowsPathAccessHost>()
return {
resolveWindowsPathAccess: (owner: unknown) => actual.resolveWindowsPathAccess(owner, 'win32')
}
})
vi.mock('../external-editors/external-editor-launch', async (importOriginal) => {
const actual = await importOriginal<typeof ExternalEditorLaunch>()
return {
...actual,
launchExternalEditor: launchMock,
resolveExternalEditorLaunchSpec: (command: string | undefined, path: string) =>
actual.resolveExternalEditorLaunchSpec(command, path, {
platform: 'win32',
fileExists: () => false
})
}
})
import { registerShellHandlers } from './shell'
import { toRuntimeExecutionHostId } from '../../shared/execution-host'
const WSL_OWNER = toRuntimeExecutionHostId('wsl:Ubuntu')
const UNC = '\\\\wsl.localhost\\Ubuntu\\home\\me\\my repo'
function getHandler(channel: string): (...args: unknown[]) => Promise<unknown> {
registerShellHandlers({ getSshTarget: () => undefined })
const handler = handleMock.mock.calls.find(([name]) => name === channel)?.[1]
if (!handler) {
throw new Error(`${channel} handler not registered`)
}
return async (...args) => handler(...args)
}
describe('shell handlers for a WSL-owned path', () => {
beforeEach(() => {
handleMock.mockReset()
launchMock.mockReset().mockResolvedValue(undefined)
showItemInFolderMock.mockReset()
statMock.mockReset().mockResolvedValue({ isDirectory: () => true })
recordForEnvironmentMock
.mockReset()
.mockImplementation((id: string) => (id === 'wsl:Ubuntu' ? { distro: 'Ubuntu' } : null))
})
it('reveals the \\\\wsl.localhost spelling in Explorer', async () => {
await expect(
getHandler('shell:openInFileManager')({}, '/home/me/my repo', WSL_OWNER)
).resolves.toEqual({ ok: true })
expect(statMock).toHaveBeenCalledWith(UNC)
expect(showItemInFolderMock).toHaveBeenCalledWith(UNC)
})
it('opens VS Code and Cursor with --remote wsl+<distro> and the Linux path', async () => {
const handler = getHandler('shell:openInExternalEditor')
await expect(
handler({}, { path: '/home/me/my repo', command: 'code', ownerHostId: WSL_OWNER })
).resolves.toEqual({ ok: true })
expect(launchMock.mock.calls[0][0].spawnArgs).toEqual([
'--remote',
'wsl+Ubuntu',
'/home/me/my repo'
])
await handler({}, { path: '/home/me/my repo', command: 'cursor', ownerHostId: WSL_OWNER })
expect(launchMock.mock.calls[1][0].spawnArgs).toEqual([
'--new-window',
'--remote',
'wsl+Ubuntu',
'/home/me/my repo'
])
})
it('hands other editors the \\\\wsl.localhost path', async () => {
await getHandler('shell:openInExternalEditor')(
{},
{ path: '/home/me/my repo', command: 'zed', ownerHostId: WSL_OWNER }
)
expect(launchMock.mock.calls[0][0].spawnArgs).toEqual([UNC])
})
it('refuses a relative path and a missing one', async () => {
const reveal = getHandler('shell:openInFileManager')
await expect(reveal({}, 'home/me', WSL_OWNER)).resolves.toEqual({
ok: false,
reason: 'not-absolute'
})
statMock.mockRejectedValueOnce(new Error('ENOENT'))
await expect(reveal({}, '/home/gone', WSL_OWNER)).resolves.toEqual({
ok: false,
reason: 'not-found'
})
expect(showItemInFolderMock).not.toHaveBeenCalled()
})
it('still refuses a paired server that is not a WSL host', async () => {
const owner = toRuntimeExecutionHostId('paired-server')
await expect(getHandler('shell:openInFileManager')({}, '/srv', owner)).resolves.toEqual({
ok: false,
reason: 'remote-runtime-unsupported'
})
await expect(
getHandler('shell:openInExternalEditor')({}, { path: '/srv', ownerHostId: owner })
).resolves.toEqual({ ok: false, reason: 'remote-runtime-unsupported' })
expect(statMock).not.toHaveBeenCalled()
expect(launchMock).not.toHaveBeenCalled()
})
})

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