mirror of
https://github.com/stablyai/orca.git
synced 2026-11-13 17:37:40 +00:00
test: remove 997 low-value test declarations (#38058)
Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
+184
-474
File diff suppressed because one or more lines are too long
@@ -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 = {
|
||||
|
||||
@@ -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('', 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)
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
+1
-136
@@ -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])
|
||||
})
|
||||
})
|
||||
|
||||
-214
@@ -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']])
|
||||
})
|
||||
})
|
||||
-203
@@ -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')
|
||||
})
|
||||
})
|
||||
})
|
||||
-208
@@ -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()
|
||||
})
|
||||
})
|
||||
-153
@@ -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
|
||||
})
|
||||
})
|
||||
})
|
||||
-132
@@ -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 })
|
||||
}
|
||||
}
|
||||
)
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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
Reference in New Issue
Block a user