mirror of
https://github.com/stablyai/orca.git
synced 2026-11-13 17:37:40 +00:00
test: remove 468 low-value tests (#39614)
Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>
This commit is contained in:
@@ -268,7 +268,6 @@
|
||||
1 test-no-real-time-wait:src/main/runtime/terminal/wait/launched-agent-crash-guard.live-shell.test.ts
|
||||
1 test-no-real-time-wait:src/main/runtime/terminal/wait/launched-agent-shell-handoff-readiness.test.ts
|
||||
2 test-no-real-time-wait:src/main/runtime/terminal/wait/tui-idle-name-only-real-pty.integration.test.ts
|
||||
1 test-no-real-time-wait:src/main/runtime/workspaces/removal/structured-session-worktree-teardown.test.ts
|
||||
4 test-no-real-time-wait:src/main/runtime/workspaces/removal/worktree-teardown-unstopped-pty.test.ts
|
||||
3 test-no-real-time-wait:src/main/skills/skill-bundle-process-termination.integration.test.ts
|
||||
1 test-no-real-time-wait:src/main/skills/skill-cloud-direct-upload.test.ts
|
||||
@@ -654,16 +653,15 @@
|
||||
1 test-no-wall-clock:src/main/runtime/structured-child-records-switch.test.ts
|
||||
1 test-no-wall-clock:src/main/runtime/structured-codex-child-work-runtime.test.ts
|
||||
1 test-no-wall-clock:src/main/runtime/terminal-pane-recovery-liveness-gate.test.ts
|
||||
46 test-no-wall-clock:src/main/runtime/terminal-query-responder.test.ts
|
||||
24 test-no-wall-clock:src/main/runtime/terminal-query-responder.test.ts
|
||||
1 test-no-wall-clock:src/main/runtime/terminal-subscriber-driven-daemon-attach.test.ts
|
||||
2 test-no-wall-clock:src/main/runtime/terminal/wait/tui-idle-claude-task-wakeup.test.ts
|
||||
16 test-no-wall-clock:src/main/runtime/terminal/wait/tui-idle-hook-lane-runtime.test.ts
|
||||
4 test-no-wall-clock:src/main/runtime/terminal/wait/tui-idle-name-only-real-pty.integration.test.ts
|
||||
4 test-no-wall-clock:src/main/runtime/worker-terminal-archive-release.test.ts
|
||||
9 test-no-wall-clock:src/main/runtime/worker-terminal-archive-screen.test.ts
|
||||
7 test-no-wall-clock:src/main/runtime/worker-terminal-wrap-provenance.test.ts
|
||||
3 test-no-wall-clock:src/main/runtime/worker-terminal-archive-release.test.ts
|
||||
4 test-no-wall-clock:src/main/runtime/worker-terminal-archive-screen.test.ts
|
||||
5 test-no-wall-clock:src/main/runtime/worker-terminal-wrap-provenance.test.ts
|
||||
2 test-no-wall-clock:src/main/runtime/workspace-layout-live-titles.test.ts
|
||||
3 test-no-wall-clock:src/main/runtime/workspaces/process-listing/runtime-worktree-structured-agent-rows-liveness.test.ts
|
||||
1 test-no-wall-clock:src/main/runtime/workspaces/process-listing/runtime-worktree-structured-agent-rows-liveness.test.ts
|
||||
6 test-no-wall-clock:src/main/skills/skill-bundle-process-termination.integration.test.ts
|
||||
7 test-no-wall-clock:src/main/skills/skill-install-lock.test.ts
|
||||
1 test-no-wall-clock:src/main/skills/skill-install-recovery.test.ts
|
||||
|
||||
+108
-315
File diff suppressed because one or more lines are too long
@@ -3408,7 +3408,6 @@
|
||||
"src/main/runtime/headless-move-leaf-to-new-tab.test.ts": 3596,
|
||||
"src/main/runtime/terminal/output/headless-terminal-dispose-write-ordering.test.ts": 4309,
|
||||
"src/main/runtime/terminal/output/headless-terminal-memory-bound.test.ts": 5615,
|
||||
"src/main/runtime/terminal/layout/headless-terminal-split-layout.test.ts": 55,
|
||||
"src/main/runtime/held-pane-reopen-layout-owner.test.ts": 5093,
|
||||
"src/main/runtime/hermes-slash-readiness-transcript.test.ts": 4931,
|
||||
"src/main/runtime/hook-status-resume-sleep-ordering.test.ts": 9614,
|
||||
@@ -3462,8 +3461,6 @@
|
||||
"src/main/runtime/mobile-session-editor-projection.test.ts": 57,
|
||||
"src/main/runtime/session-lifecycle/publication/mobile-session-tabs-agent-status-heartbeat.test.ts": 65,
|
||||
"src/main/runtime/terminal/surface/mobile-session-terminal-persistence-retirement.test.ts": 278,
|
||||
"src/main/runtime/terminal/surface/mobile-session-terminal-retirement-proof.test.ts": 48,
|
||||
"src/main/runtime/terminal/surface/mobile-session-terminal-retirement.test.ts": 236,
|
||||
"src/main/runtime/mobile-session-window-tab-order.test.ts": 4194,
|
||||
"src/main/runtime/mobile-store-orphaned-temp-cleanup.test.ts": 320,
|
||||
"src/main/runtime/mobile-subscribe-idle-pane-hydrate.test.ts": 4782,
|
||||
@@ -3482,7 +3479,6 @@
|
||||
"src/main/runtime/orca-runtime-codex-idle-title-chat-address.test.ts": 5781,
|
||||
"src/main/runtime/orca-runtime-cold-restore-terminal-membership-layout-owner.test.ts": 4130,
|
||||
"src/main/runtime/orca-runtime-cold-restore-terminal-membership.test.ts": 3716,
|
||||
"src/main/runtime/workspaces/creation/orca-runtime-create-base-prefetch.test.ts": 4048,
|
||||
"src/main/runtime/orca-runtime-exact-worker-provider-session.test.ts": 3617,
|
||||
"src/main/runtime/orca-runtime-exited-split-pane-close.test.ts": 4235,
|
||||
"src/main/runtime/orca-runtime-failed-start-pane-layout-owner.test.ts": 4204,
|
||||
@@ -3516,12 +3512,10 @@
|
||||
"src/main/runtime/terminal/identity/orca-runtime-terminal-handle-incarnation.test.ts": 4449,
|
||||
"src/main/runtime/terminal/title/orca-runtime-terminal-rename-retention.test.ts": 5757,
|
||||
"src/main/runtime/terminal/surface/orca-runtime-terminal-retirement.test.ts": 3714,
|
||||
"src/main/runtime/terminal/title/orca-runtime-terminal-saved-name-respawn.test.ts": 4349,
|
||||
"src/main/runtime/terminal/layout/orca-runtime-terminal-split-authority.test.ts": 4420,
|
||||
"src/main/runtime/terminal/surface/orca-runtime-terminal-surface-close.test.ts": 5648,
|
||||
"src/main/runtime/orca-runtime-windowless-terminal-activity.test.ts": 3277,
|
||||
"src/main/runtime/orca-runtime-workspace-ports-host.test.ts": 3642,
|
||||
"src/main/runtime/workspaces/catalog/orca-runtime-worktree-change-invalidation.test.ts": 4110,
|
||||
"src/main/runtime/workspaces/creation/orca-runtime-worktree-provisioning-availability.test.ts": 4411,
|
||||
"src/main/runtime/orca-runtime.test.ts": 43162,
|
||||
"src/main/runtime/orchestration-cli-restart-handoff.test.ts": 20192,
|
||||
@@ -3968,11 +3962,9 @@
|
||||
"src/main/runtime/browser/client-pages/runtime-browser-page-registry.test.ts": 52,
|
||||
"src/main/runtime/client/settings/runtime-client-agent-launch-rollback.test.ts": 1577,
|
||||
"src/main/runtime/client/settings/runtime-client-agent-launch-settings-durability.test.ts": 3333,
|
||||
"src/main/runtime/terminal/launch/runtime-created-worktree-terminal-target.test.ts": 5528,
|
||||
"src/main/runtime/runtime-desktop-pane-title-forwarding.test.ts": 6114,
|
||||
"src/main/runtime/workspaces/removal/runtime-failed-local-worktree-removal.test.ts": 1333,
|
||||
"src/main/runtime/runtime-file-target-execution-host.test.ts": 4187,
|
||||
"src/main/runtime/workspaces/removal/runtime-folder-workspace-delete-lifecycle.test.ts": 4190,
|
||||
"src/main/runtime/workspaces/startup/runtime-folder-workspace-wsl-failed-start-pane.test.ts": 4472,
|
||||
"src/main/runtime/workspaces/creation/runtime-folder-workspace-wsl-launch-scope.test.ts": 3705,
|
||||
"src/main/runtime/integrations/git/runtime-git-generation-admission.test.ts": 595,
|
||||
@@ -3987,7 +3979,6 @@
|
||||
"src/main/runtime/integrations/linear/runtime-linear-comment-edit.test.ts": 458,
|
||||
"src/main/runtime/integrations/linear/runtime-linear-write-lifetime.test.ts": 512,
|
||||
"src/main/runtime/workspaces/creation/runtime-local-worktree-create-pr-conflict.test.ts": 462,
|
||||
"src/main/runtime/workspaces/creation/runtime-local-worktree-create-pr-conflicts.test.ts": 490,
|
||||
"src/main/runtime/workspaces/creation/runtime-local-worktree-create.test.ts": 693,
|
||||
"src/main/runtime/workspaces/startup/runtime-local-worktree-terminal-startup-layout-owner.test.ts": 76,
|
||||
"src/main/runtime/workspaces/startup/runtime-local-worktree-terminal-startup.test.ts": 79,
|
||||
@@ -4011,7 +4002,6 @@
|
||||
"src/main/runtime/repositories/runtime-repository-clone-host-dedupe.test.ts": 874,
|
||||
"src/main/runtime/repositories/runtime-repository-issue-command.test.ts": 557,
|
||||
"src/main/runtime/repositories/runtime-repository-registration-controller.test.ts": 742,
|
||||
"src/main/runtime/workspaces/catalog/runtime-resolved-worktree-cache.test.ts": 46,
|
||||
"src/main/runtime/rpc/server/runtime-rpc-browser-host-admission.test.ts": 4402,
|
||||
"src/main/runtime/rpc/server/runtime-rpc-delegated-device-sync.test.ts": 4986,
|
||||
"src/main/runtime/rpc/server/runtime-rpc-device-key-dispatch.test.ts": 3597,
|
||||
@@ -4037,14 +4027,10 @@
|
||||
"src/main/runtime/terminal/identity/runtime-terminal-handle-contracts.test.ts": 3983,
|
||||
"src/main/runtime/terminal/wait/runtime-terminal-idle-polls.test.ts": 240,
|
||||
"src/main/runtime/terminal/query/runtime-terminal-inventory-contracts.test.ts": 4427,
|
||||
"src/main/runtime/terminal/adoption/runtime-terminal-orphan-adoption-duplicate-pty.test.ts": 422,
|
||||
"src/main/runtime/terminal/adoption/runtime-terminal-orphan-adoption-rollback.test.ts": 1832,
|
||||
"src/main/runtime/terminal/adoption/runtime-terminal-orphan-session-adoption.test.ts": 309,
|
||||
"src/main/runtime/terminal/adoption/runtime-terminal-orphan-topology-validation.test.ts": 59,
|
||||
"src/main/runtime/terminal/surface/runtime-terminal-retirement-contracts.test.ts": 4505,
|
||||
"src/main/runtime/terminal/title/runtime-terminal-set-pane-title.test.ts": 5216,
|
||||
"src/main/runtime/terminal/input/runtime-terminal-writer.test.ts": 150,
|
||||
"src/main/runtime/workspaces/catalog/runtime-visible-catalog.test.ts": 56,
|
||||
"src/main/runtime/runtime-workspace-launch-contracts.test.ts": 4059,
|
||||
"src/main/runtime/workspaces/session-hosts/runtime-workspace-session-controller-twin-repo.test.ts": 298,
|
||||
"src/main/runtime/workspaces/process-listing/runtime-worktree-agent-rows-verdict.test.ts": 1458,
|
||||
@@ -4052,9 +4038,6 @@
|
||||
"src/main/runtime/workspaces/creation/runtime-worktree-create-git.test.ts": 600,
|
||||
"src/main/runtime/workspaces/creation/runtime-worktree-create-repo-host.test.ts": 4484,
|
||||
"src/main/runtime/workspaces/lineage/runtime-worktree-lineage-controller.test.ts": 1027,
|
||||
"src/main/runtime/workspaces/process-listing/runtime-worktree-ps-summaries.test.ts": 191,
|
||||
"src/main/runtime/workspaces/process-listing/runtime-worktree-ps-unverifiable-terminals.test.ts": 45,
|
||||
"src/main/runtime/workspaces/catalog/runtime-worktree-selection.test.ts": 52,
|
||||
"src/main/runtime/workspaces/startup/runtime-worktree-startup-readiness.test.ts": 289,
|
||||
"src/main/runtime/workspaces/process-listing/runtime-worktree-structured-agent-rows-liveness.test.ts": 1412,
|
||||
"src/main/runtime/workspaces/startup/runtime-worktree-terminal-provisioning.test.ts": 84,
|
||||
@@ -4133,14 +4116,12 @@
|
||||
"src/main/runtime/terminal-agent-interrupt.test.ts": 54,
|
||||
"src/main/runtime/terminal-agent-phone-push.test.ts": 172,
|
||||
"src/main/runtime/terminal-agent-prompt-answer.test.ts": 100,
|
||||
"src/main/runtime/terminal-chat-owner-persistence.test.ts": 4139,
|
||||
"src/main/runtime/terminal-close-observed-exit.test.ts": 5914,
|
||||
"src/main/runtime/terminal-close-slow-agent-exit.test.ts": 15751,
|
||||
"src/main/runtime/terminal-create-closed-tab-durability.test.ts": 5767,
|
||||
"src/main/runtime/terminal-focus-navigation-coalescer.test.ts": 55,
|
||||
"src/main/runtime/terminal-foreground-capture.test.ts": 296,
|
||||
"src/main/runtime/terminal-identity-probe.test.ts": 46,
|
||||
"src/main/runtime/terminal/launch/terminal-image-viewer-env.test.ts": 163,
|
||||
"src/main/runtime/terminal-intentional-stop-exit.test.ts": 9894,
|
||||
"src/main/runtime/terminal-intentional-stops.test.ts": 48,
|
||||
"src/main/runtime/terminal-interactive-wait-visibility.test.ts": 4552,
|
||||
@@ -4148,12 +4129,10 @@
|
||||
"src/main/runtime/terminal-kitty-file-wsl-root.test.ts": 118,
|
||||
"src/main/runtime/terminal-list-payload-size.test.ts": 4185,
|
||||
"src/main/runtime/terminal-list-pending-pty-registration.test.ts": 4780,
|
||||
"src/main/runtime/terminal/adoption/terminal-orphan-topology.test.ts": 46,
|
||||
"src/main/runtime/terminal-pane-recovery-liveness-gate.test.ts": 4411,
|
||||
"src/main/runtime/terminal-query-responder.test.ts": 27968,
|
||||
"src/main/runtime/terminal-restore-record-seed.test.ts": 4073,
|
||||
"src/main/runtime/terminal-retirement-async-durability.test.ts": 5903,
|
||||
"src/main/runtime/terminal-retirement-proof-emitted-frame.test.ts": 3961,
|
||||
"src/main/runtime/terminal-retirement-proof-publication.test.ts": 4976,
|
||||
"src/main/runtime/terminal-run-facts-input.test.ts": 4370,
|
||||
"src/main/runtime/terminal-run-facts.test.ts": 68,
|
||||
@@ -4162,9 +4141,7 @@
|
||||
"src/main/runtime/terminal-start-failure-readiness.test.ts": 3416,
|
||||
"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/terminal-surface-close.test.ts": 120,
|
||||
"src/main/runtime/terminal/output/tail/terminal-tail-crlf.test.ts": 4235,
|
||||
"src/main/runtime/terminal/output/tail/terminal-tail-redraw-scaling.test.ts": 352,
|
||||
"src/main/runtime/terminal/output/tail/terminal-tail-redraw-spans.test.ts": 166,
|
||||
"src/main/runtime/terminal-tail-redraw-wall-time.test.ts": 43418,
|
||||
"src/main/runtime/terminal/output/tail/terminal-tail-sentinel-index.test.ts": 3649,
|
||||
@@ -4183,7 +4160,6 @@
|
||||
"src/main/runtime/terminal/launch/mobile-terminal-chat-fork.test.ts": 3900,
|
||||
"src/main/runtime/terminal/launch/terminal-split-layout-owner.test.ts": 4503,
|
||||
"src/main/runtime/terminal/launch/terminal-split-retry.test.ts": 4038,
|
||||
"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/lifecycle/main-pane-liveness.test.ts": 4871,
|
||||
"src/main/runtime/terminal/lifecycle/ssh-exit-code-pane-outcome.test.ts": 4334,
|
||||
@@ -4193,17 +4169,14 @@
|
||||
"src/main/runtime/terminal/stop/terminal-update-admission.test.ts": 48,
|
||||
"src/main/runtime/terminal/surface/closed-pane-exit-cleanup-main.test.ts": 4281,
|
||||
"src/main/runtime/terminal/surface/main-terminal-acquisition.test.ts": 4330,
|
||||
"src/main/runtime/terminal/surface/pty-pane-copies-untouched-main.test.ts": 4585,
|
||||
"src/main/runtime/terminal/surface/terminal-surface-replies-main.test.ts": 4324,
|
||||
"src/main/runtime/terminal/surface/terminal-surface-replies-window.test.ts": 4440,
|
||||
"src/main/runtime/trust-dialog-status-observer.test.ts": 4458,
|
||||
"src/main/runtime/terminal/wait/tui-idle-claude-task-wakeup.test.ts": 185,
|
||||
"src/main/runtime/terminal/wait/tui-idle-codex-plan-menu.test.ts": 4441,
|
||||
"src/main/runtime/terminal/wait/tui-idle-command-paint.test.ts": 4148,
|
||||
"src/main/runtime/terminal/wait/tui-idle-hook-lane-runtime.test.ts": 7702,
|
||||
"src/main/runtime/terminal/wait/tui-idle-name-only-real-pty.integration.test.ts": 39042,
|
||||
"src/main/runtime/unreadable-secret-store-preservation.win32.test.ts": 51,
|
||||
"src/main/runtime/wait-blocked-check-state.test.ts": 107,
|
||||
"src/main/runtime/windowless-main-layout-commands-layout-owner.test.ts": 4237,
|
||||
"src/main/runtime/windows-codex-held-prompt-resubmit.test.ts": 4446,
|
||||
"src/main/runtime/windows-default-route-interfaces.test.ts": 45,
|
||||
@@ -4216,15 +4189,12 @@
|
||||
"src/main/runtime/workspace-layout-operation-frames.test.ts": 2136,
|
||||
"src/main/runtime/workspace-layout-projection.test.ts": 99,
|
||||
"src/main/runtime/workspace-layout-stream.test.ts": 427,
|
||||
"src/main/runtime/workspace-notification-mute.test.ts": 99,
|
||||
"src/main/runtime/workspace-session-membership-scaling.test.ts": 72,
|
||||
"src/main/runtime/terminal/identity/workspace-session-worktree-id.test.ts": 211,
|
||||
"src/main/runtime/workspaces/persisted-surfaces/live-terminal-placement-before-initial-tab.test.ts": 6111,
|
||||
"src/main/runtime/workspaces/persisted-surfaces/sweep-terminal-placement.test.ts": 12352,
|
||||
"src/main/runtime/workspaces/sleep/remote-workspace-terminal-sleep.test.ts": 46,
|
||||
"src/main/runtime/workspaces/workspace-host-notifications.test.ts": 4332,
|
||||
"src/main/runtime/workspaces/lifecycle/workspace-lifecycle-queue.test.ts": 116,
|
||||
"src/main/runtime/workspaces/catalog/workspace-query-prototype.test.ts": 4444,
|
||||
"src/main/runtime/workspaces/sleep/workspace-sleep-execution.test.ts": 110,
|
||||
"src/main/runtime/workspaces/startup/worktree-agent-first-surface.test.ts": 5816,
|
||||
"src/main/runtime/workspaces/catalog/worktree-list-host-scope.test.ts": 1051,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { HostStatusReply } from '../transport/host-status-reply-schema'
|
||||
import { bridgeId, clientFrame, flushBridge } from './bridge-host-test-fakes'
|
||||
import { bridgeId, clientFrame } from './bridge-host-test-fakes'
|
||||
import { harness, subscribeFrame } from './bridge-host-test-harness'
|
||||
|
||||
const PROVEN: HostStatusReply = { protocolVersion: 10, minCompatibleMobileVersion: 1 }
|
||||
@@ -13,66 +13,6 @@ describe('the connection verdict hold', () => {
|
||||
return { bridge, verdict }
|
||||
}
|
||||
|
||||
it('reports handshaking and keeps requests and streams off the desktop until the verdict', async () => {
|
||||
const { bridge } = heldBridge()
|
||||
const init = bridge.last()
|
||||
expect(init).toMatchObject({ type: 'init', connection: { state: 'handshaking' } })
|
||||
|
||||
bridge.host.receive(
|
||||
clientFrame({ type: 'request', id: bridgeId(1), method: 'worktree.ps', params: {} })
|
||||
)
|
||||
bridge.host.receive(subscribeFrame(bridgeId(2)))
|
||||
// The PDF viewer downloads the file from the desktop, so it waits too.
|
||||
bridge.host.receive(
|
||||
clientFrame({ type: 'request', id: bridgeId(3), method: 'native.pdf.view', params: {} })
|
||||
)
|
||||
// status.get is how the page learns the verdict, so it alone goes through.
|
||||
bridge.host.receive(clientFrame({ type: 'request', id: bridgeId(4), method: 'status.get' }))
|
||||
bridge.client.pushState('connected')
|
||||
await flushBridge()
|
||||
|
||||
expect(bridge.client.requests.map((request) => request.method)).toEqual(['status.get'])
|
||||
expect(bridge.client.streams).toEqual([])
|
||||
expect(bridge.nativeCalls).toEqual([])
|
||||
expect(bridge.last()).toMatchObject({ type: 'state', connection: { state: 'handshaking' } })
|
||||
})
|
||||
|
||||
it("serves the phone's own store before the verdict, so drafts typed offline are saved", async () => {
|
||||
const { bridge } = heldBridge()
|
||||
bridge.host.receive(
|
||||
clientFrame({
|
||||
type: 'request',
|
||||
id: bridgeId(1),
|
||||
method: 'native.store.rows',
|
||||
params: { action: 'read', table: 'draft', keys: ['draft:chat:wt:'] }
|
||||
})
|
||||
)
|
||||
await flushBridge()
|
||||
|
||||
expect(bridge.nativeCalls.map((call) => call.verb)).toEqual(['native.store.rows'])
|
||||
})
|
||||
|
||||
it('re-sends init as connected once the verdict opens, then sends what waited', () => {
|
||||
const { bridge, verdict } = heldBridge()
|
||||
bridge.host.receive(
|
||||
clientFrame({ type: 'request', id: bridgeId(1), method: 'worktree.ps', params: {} })
|
||||
)
|
||||
bridge.host.receive(subscribeFrame(bridgeId(2)))
|
||||
bridge.host.receive(
|
||||
clientFrame({ type: 'request', id: bridgeId(3), method: 'stats.summary', params: {} })
|
||||
)
|
||||
bridge.host.receive(clientFrame({ type: 'cancel', id: bridgeId(3), target: 'request' }))
|
||||
|
||||
verdict.status = PROVEN
|
||||
bridge.host.publishHostStatus()
|
||||
|
||||
expect(
|
||||
bridge.frames().find((frame) => frame.type === 'init' && frame.hostStatus)
|
||||
).toMatchObject({ connection: { state: 'connected' }, hostStatus: PROVEN })
|
||||
expect(bridge.client.requests.map((request) => request.method)).toEqual(['worktree.ps'])
|
||||
expect(bridge.client.streams.map((stream) => stream.method)).toEqual(['terminal.subscribe'])
|
||||
})
|
||||
|
||||
it('keeps page order for a frame that lands after the status opens but before the flush', () => {
|
||||
const { bridge, verdict } = heldBridge()
|
||||
bridge.host.receive(
|
||||
|
||||
@@ -1,75 +0,0 @@
|
||||
import { onTestFinished, describe, expect, it, vi } from 'vitest'
|
||||
import { scopeRpcClientToExecutionHost } from '../../transport/execution-host-scoped-rpc-client'
|
||||
import type { RpcClient } from '../../transport/rpc-client'
|
||||
import type { RpcResponse } from '../../transport/types'
|
||||
import { createBridgePortPair } from './bridge-port-pair-test-harness'
|
||||
import { createPageClient, INIT } from './bridge-page-client-test-harness'
|
||||
|
||||
const SERVER = 'runtime:env-1' as const
|
||||
|
||||
function recordingShellClient() {
|
||||
const sendRequest = vi.fn((..._args: unknown[]) => new Promise<RpcResponse>(() => {}))
|
||||
const subscribe = vi.fn(
|
||||
(..._args: unknown[]) =>
|
||||
() =>
|
||||
undefined
|
||||
)
|
||||
const client: RpcClient = {
|
||||
sendRequest,
|
||||
subscribe,
|
||||
updateTerminalSubscriptionViewport: () => {},
|
||||
getState: () => 'connected',
|
||||
getReconnectAttempt: () => 0,
|
||||
getLastConnectedAt: () => 0,
|
||||
onStateChange: () => () => undefined,
|
||||
notifyForeground: () => {},
|
||||
close: () => {}
|
||||
}
|
||||
return { client, sendRequest, subscribe }
|
||||
}
|
||||
|
||||
describe('a server workspace call across the shell bridge', () => {
|
||||
it('reaches the shell client with its executionHost on the envelope', async () => {
|
||||
const shell = recordingShellClient()
|
||||
const pair = createBridgePortPair({ rpc: shell.client })
|
||||
onTestFinished(() => {
|
||||
pair.client.close()
|
||||
pair.host.dispose()
|
||||
})
|
||||
await pair.flush()
|
||||
|
||||
const server = scopeRpcClientToExecutionHost(pair.client, SERVER)
|
||||
void server.sendRequest('worktree.ps', { limit: 1 }).catch(() => undefined)
|
||||
server.subscribe('terminal.subscribe', { terminal: 't1' }, () => {})
|
||||
await pair.flush()
|
||||
|
||||
expect(shell.sendRequest.mock.calls).toEqual([
|
||||
['worktree.ps', { limit: 1 }, { envelope: { executionHost: SERVER } }]
|
||||
])
|
||||
expect(shell.subscribe.mock.calls).toEqual([
|
||||
[
|
||||
'terminal.subscribe',
|
||||
{ terminal: 't1' },
|
||||
expect.any(Function),
|
||||
{ envelope: { executionHost: SERVER }, holdReplay: expect.any(Function) }
|
||||
]
|
||||
])
|
||||
pair.client.close()
|
||||
pair.host.dispose()
|
||||
})
|
||||
|
||||
it('refuses it under a shell without the envelope, instead of running it on the desktop', async () => {
|
||||
const page = createPageClient()
|
||||
onTestFinished(() => page.client.close())
|
||||
page.deliver({ ...INIT, grants: { ...INIT.grants, native: [] } })
|
||||
expect(page.client.carriesRpcEnvelope?.()).toBe(false)
|
||||
|
||||
const server = scopeRpcClientToExecutionHost(page.client, SERVER)
|
||||
await expect(server.sendRequest('worktree.ps', {})).rejects.toThrow(/update the Orca app/)
|
||||
expect(() => server.subscribe('terminal.subscribe', { terminal: 't1' }, () => {})).toThrow(
|
||||
/update the Orca app/
|
||||
)
|
||||
expect(page.frames().filter((frame) => frame.type !== 'ready')).toEqual([])
|
||||
page.client.close()
|
||||
})
|
||||
})
|
||||
@@ -1,78 +0,0 @@
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { createFakeBridgePortPair, disposeBridgePortPair } from './bridge-port-pair-test-harness'
|
||||
import { storeParamsSchema } from './bridge-store-verb'
|
||||
import { createPageDeviceStore } from './page-device-store'
|
||||
|
||||
let pair: ReturnType<typeof createFakeBridgePortPair> | null = null
|
||||
|
||||
afterEach(async () => {
|
||||
if (pair) {
|
||||
await disposeBridgePortPair(pair)
|
||||
}
|
||||
pair = null
|
||||
})
|
||||
|
||||
// A shell whose store answers asynchronously, like the real one, so calls overlap in flight.
|
||||
async function pageOverShellStore() {
|
||||
const rows = new Map<string, string>()
|
||||
pair = createFakeBridgePortPair({
|
||||
serveNativeVerb: async (_verb, params) => {
|
||||
await new Promise<void>((resolve) => queueMicrotask(resolve))
|
||||
const call = storeParamsSchema.parse(params)
|
||||
if (call.action === 'read') {
|
||||
return { values: call.keys.map((key) => rows.get(key) ?? null), deferred: [] }
|
||||
}
|
||||
for (const op of call.ops) {
|
||||
if (op.value === null) {
|
||||
rows.delete(op.key)
|
||||
} else {
|
||||
rows.set(op.key, op.value)
|
||||
}
|
||||
}
|
||||
return { written: true }
|
||||
}
|
||||
})
|
||||
await pair.flush()
|
||||
return { store: createPageDeviceStore(pair.client), rows }
|
||||
}
|
||||
|
||||
describe('the page device store', () => {
|
||||
it('writes a call of more batches than the bridge has request slots', async () => {
|
||||
const { store, rows } = await pageOverShellStore()
|
||||
// 64 ops a batch: 65 batches.
|
||||
const ops = Array.from({ length: 65 * 64 }, (_op, at) => ({ key: `k-${at}`, value: 'eA==' }))
|
||||
|
||||
expect(await store.write('cache', ops)).toEqual({ written: true })
|
||||
expect(rows.size).toBe(4160)
|
||||
})
|
||||
|
||||
it('refuses a value over the store cap before sending any batch', async () => {
|
||||
const { store, rows } = await pageOverShellStore()
|
||||
|
||||
expect(
|
||||
await store.write('cache', [
|
||||
{ key: 'small', value: 'eA==' },
|
||||
{ key: 'big', value: 'x'.repeat(540_000) }
|
||||
])
|
||||
).toEqual({ written: false, refusal: 'too-large' })
|
||||
expect([...rows.keys()]).toEqual([])
|
||||
})
|
||||
|
||||
it('reads a key the store cannot hold as a miss, and refuses to write one', async () => {
|
||||
const { store } = await pageOverShellStore()
|
||||
const longKey = 'k'.repeat(513)
|
||||
await store.write('draft', [{ key: 'kept', value: 'eA==' }])
|
||||
|
||||
expect(await store.read('draft', ['kept', longKey, ''])).toEqual({
|
||||
status: 'ok',
|
||||
values: ['eA==', null, null]
|
||||
})
|
||||
expect(
|
||||
await store.write('draft', [
|
||||
{ key: 'kept', value: null },
|
||||
{ key: longKey, value: 'eA==' }
|
||||
])
|
||||
).toEqual({ written: false, refusal: 'bad-key' })
|
||||
expect(await store.read('draft', ['kept'])).toEqual({ status: 'ok', values: ['eA=='] })
|
||||
})
|
||||
})
|
||||
@@ -89,50 +89,6 @@ describe('RpcClientStreamRegistry', () => {
|
||||
])
|
||||
})
|
||||
|
||||
it.each([
|
||||
['a streamed end', (id: string) => streamingResponse(id, { type: 'end' })],
|
||||
[
|
||||
'a final end reply',
|
||||
(id: string): RpcResponse => ({
|
||||
id,
|
||||
ok: true,
|
||||
result: { type: 'end' },
|
||||
_meta: { runtimeId: 'runtime-1' }
|
||||
})
|
||||
],
|
||||
[
|
||||
'an error reply',
|
||||
(id: string): RpcResponse => ({
|
||||
id,
|
||||
ok: false,
|
||||
error: { code: 'runtime_error', message: 'failed' },
|
||||
_meta: { runtimeId: 'runtime-1' }
|
||||
})
|
||||
]
|
||||
])('sends no terminal unsubscribe when the listener disposes on %s', (_label, ending) => {
|
||||
const { registry, sent } = createRegistry()
|
||||
const events: unknown[] = []
|
||||
let dispose = (): void => {}
|
||||
dispose = registry.subscribe(
|
||||
'terminal.subscribe',
|
||||
{ terminal: 'term-1', client: { id: 'phone', type: 'mobile' } },
|
||||
(event) => {
|
||||
events.push(event)
|
||||
// The session hook disposes synchronously on end/error; a slot-named unsubscribe here
|
||||
// would retire a newer same-slot stream on the host.
|
||||
dispose()
|
||||
}
|
||||
)
|
||||
const request = sent[0]!
|
||||
|
||||
registry.handleResponse(ending(request.id))
|
||||
dispose()
|
||||
|
||||
expect(events).toHaveLength(1)
|
||||
expect(sent).toEqual([request])
|
||||
expect(registry.size()).toBe(0)
|
||||
})
|
||||
|
||||
it('re-sends a stream its owner holds only when the owner resends it, and a cancel before then sends nothing', () => {
|
||||
const { registry, sent } = createRegistry()
|
||||
const held: (() => void)[] = []
|
||||
@@ -153,47 +109,6 @@ describe('RpcClientStreamRegistry', () => {
|
||||
])
|
||||
})
|
||||
|
||||
it('does not replay a terminal stream the host already ended', () => {
|
||||
const { registry, sent, setState } = createRegistry()
|
||||
registry.subscribe(
|
||||
'terminal.subscribe',
|
||||
{ terminal: 'term-1', client: { id: 'phone' } },
|
||||
() => {}
|
||||
)
|
||||
const request = sent[0]!
|
||||
registry.handleResponse(streamingResponse(request.id, { type: 'subscribed', streamId: 7 }))
|
||||
registry.handleResponse(streamingResponse(request.id, { type: 'end' }))
|
||||
|
||||
setState('connecting')
|
||||
registry.markForReplay()
|
||||
setState('connected')
|
||||
registry.replayAfterAuthentication()
|
||||
|
||||
expect(sent).toEqual([request])
|
||||
})
|
||||
|
||||
it('unsubscribes a session tabs stream by its own request id', () => {
|
||||
const { registry, sent } = createRegistry()
|
||||
const disposeOlder = registry.subscribe(
|
||||
'session.tabs.subscribe',
|
||||
{ worktree: 'wt-1' },
|
||||
() => {}
|
||||
)
|
||||
registry.subscribe('session.tabs.subscribe', { worktree: 'wt-1' }, () => {})
|
||||
const [older, newer] = sent
|
||||
registry.handleResponse(streamingResponse(older!.id, { type: 'snapshot', tabs: [] }))
|
||||
|
||||
disposeOlder()
|
||||
|
||||
// Without the request id the host sweeps every stream for the worktree, including the newer one.
|
||||
expect(sent[2]).toMatchObject({
|
||||
method: 'session.tabs.unsubscribe',
|
||||
params: { worktree: 'wt-1', subscriptionId: older!.id }
|
||||
})
|
||||
expect(newer!.id).not.toBe(older!.id)
|
||||
expect(sent).toHaveLength(3)
|
||||
})
|
||||
|
||||
it('holds a session tabs unsubscribe until the host registers the stream', () => {
|
||||
const { registry, sent } = createRegistry()
|
||||
const events: unknown[] = []
|
||||
@@ -219,119 +134,6 @@ describe('RpcClientStreamRegistry', () => {
|
||||
expect(registry.size()).toBe(0)
|
||||
})
|
||||
|
||||
describe.each([
|
||||
['browser.screencast', 'browser.screencast.unsubscribe', { page: 'page-1' }],
|
||||
['runtime.clientEvents.subscribe', 'runtime.clientEvents.unsubscribe', null],
|
||||
['notifications.subscribe', 'notifications.unsubscribe', null],
|
||||
['accounts.subscribe', 'accounts.unsubscribe', null],
|
||||
['session.tabs.subscribe', 'session.tabs.unsubscribe', { worktree: 'wt-1' }]
|
||||
])('canceled %s delivery', (method, cleanup, params) => {
|
||||
it('ignores late scrollback and terminal registration while waiting for host cleanup', () => {
|
||||
const { registry, sent } = createRegistry()
|
||||
const events: unknown[] = []
|
||||
registry.subscribe(method, params, (event) => events.push(event))()
|
||||
const request = sent[0]!
|
||||
|
||||
expect(
|
||||
registry.handleResponse({
|
||||
id: request.id,
|
||||
ok: true,
|
||||
result: { type: 'scrollback', serialized: 'late' }
|
||||
})
|
||||
).toBe(true)
|
||||
registry.handleResponse(streamingResponse(request.id, { type: 'subscribed', streamId: 41 }))
|
||||
registry.handleBinary(terminalOutput(41, 'late'))
|
||||
expect(events).toEqual([])
|
||||
expect(registry.size()).toBe(1)
|
||||
expect(sent).toHaveLength(1)
|
||||
|
||||
registry.handleResponse(
|
||||
streamingResponse(
|
||||
request.id,
|
||||
method === 'session.tabs.subscribe'
|
||||
? { type: 'snapshot', tabs: [] }
|
||||
: { type: 'ready', subscriptionId: 'late-host-id' }
|
||||
)
|
||||
)
|
||||
expect(sent[1]).toMatchObject({
|
||||
method: cleanup,
|
||||
params:
|
||||
method === 'session.tabs.subscribe'
|
||||
? { worktree: 'wt-1', subscriptionId: request.id }
|
||||
: { subscriptionId: 'late-host-id' }
|
||||
})
|
||||
expect(registry.size()).toBe(0)
|
||||
expect(events).toEqual([])
|
||||
})
|
||||
|
||||
it('does not replay a canceled opener queued before connection', () => {
|
||||
const { registry, sent, setState } = createRegistry('connecting')
|
||||
const events: unknown[] = []
|
||||
const dispose = registry.subscribe(method, params, (event) => events.push(event))
|
||||
|
||||
dispose()
|
||||
dispose()
|
||||
registry.markForReplay()
|
||||
setState('connected')
|
||||
registry.replayAfterAuthentication()
|
||||
|
||||
expect(sent).toEqual([])
|
||||
expect(events).toEqual([])
|
||||
expect(registry.size()).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
it('holds a session tabs unsubscribe again after a reconnect replays the stream', () => {
|
||||
const { registry, sent } = createRegistry()
|
||||
const dispose = registry.subscribe('session.tabs.subscribe', { worktree: 'wt-1' }, () => {})
|
||||
const subscribe = sent[0]!
|
||||
registry.handleResponse(streamingResponse(subscribe.id, { type: 'snapshot', tabs: [] }))
|
||||
|
||||
registry.markForReplay()
|
||||
registry.replayAfterAuthentication()
|
||||
dispose()
|
||||
|
||||
expect(sent.map((request) => request.method)).toEqual([
|
||||
'session.tabs.subscribe',
|
||||
'session.tabs.subscribe'
|
||||
])
|
||||
registry.handleResponse(streamingResponse(subscribe.id, { type: 'snapshot', tabs: [] }))
|
||||
expect(sent[2]).toMatchObject({
|
||||
method: 'session.tabs.unsubscribe',
|
||||
params: { worktree: 'wt-1', subscriptionId: subscribe.id }
|
||||
})
|
||||
})
|
||||
|
||||
it('drops a held session tabs unsubscribe when the subscribe fails or reconnects first', () => {
|
||||
const { registry, sent } = createRegistry()
|
||||
const events: unknown[] = []
|
||||
const disposeFailed = registry.subscribe(
|
||||
'session.tabs.subscribe',
|
||||
{ worktree: 'wt-1' },
|
||||
(event) => events.push(event)
|
||||
)
|
||||
const disposeReplayed = registry.subscribe(
|
||||
'session.tabs.subscribe',
|
||||
{ worktree: 'wt-2' },
|
||||
() => {}
|
||||
)
|
||||
const failed = sent[0]!
|
||||
disposeFailed()
|
||||
disposeReplayed()
|
||||
|
||||
registry.handleResponse({
|
||||
id: failed.id,
|
||||
ok: false,
|
||||
error: { code: 'worktree_not_found', message: 'Worktree not found' }
|
||||
})
|
||||
registry.markForReplay()
|
||||
registry.replayAfterAuthentication()
|
||||
|
||||
expect(sent).toHaveLength(2)
|
||||
expect(events).toEqual([])
|
||||
expect(registry.size()).toBe(0)
|
||||
})
|
||||
|
||||
it('ends one transcript stream on dispose and leaves a sibling on the same socket (U-03)', () => {
|
||||
const { registry, sent } = createRegistry()
|
||||
const disposeFirst = registry.subscribe('agentSession.subscribe', { sessionId: 's1' }, () => {})
|
||||
@@ -352,27 +154,6 @@ describe('RpcClientStreamRegistry', () => {
|
||||
expect(registry.size()).toBe(1)
|
||||
})
|
||||
|
||||
it('names the terminal request it sent when unsubscribing, and keeps the slot for older hosts', () => {
|
||||
const { registry, sent } = createRegistry()
|
||||
const dispose = registry.subscribe(
|
||||
'terminal.subscribe',
|
||||
{ terminal: 'term-1', client: { id: 'phone-1', type: 'mobile' } },
|
||||
() => {}
|
||||
)
|
||||
const subscribe = sent[0]!
|
||||
|
||||
dispose()
|
||||
|
||||
expect(sent[1]).toMatchObject({
|
||||
method: 'terminal.unsubscribe',
|
||||
params: {
|
||||
subscriptionId: 'term-1:phone-1',
|
||||
client: { id: 'phone-1' },
|
||||
requestId: subscribe.id
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it('releases a replayed browser stream replaced by a new one before its ready', () => {
|
||||
const { registry, sent } = createRegistry()
|
||||
registry.subscribe('browser.screencast', { page: 'page-1' }, () => {})
|
||||
@@ -399,65 +180,4 @@ describe('RpcClientStreamRegistry', () => {
|
||||
).toEqual([{ subscriptionId: 'page-1-new-connection' }])
|
||||
expect(registry.size()).toBe(1)
|
||||
})
|
||||
|
||||
describe.each([
|
||||
['runtime.clientEvents.subscribe', 'runtime.clientEvents.unsubscribe', null],
|
||||
['notifications.subscribe', 'notifications.unsubscribe', null],
|
||||
['accounts.subscribe', 'accounts.unsubscribe', null],
|
||||
['browser.screencast', 'browser.screencast.unsubscribe', { page: 'page-1' }]
|
||||
])('%s ready id across a replay', (method, unsubscribeMethod, params) => {
|
||||
function unsubscribes(sent: SentRequest[]): unknown[] {
|
||||
return sent.filter((request) => request.method === unsubscribeMethod).map((r) => r.params)
|
||||
}
|
||||
|
||||
function subscribeReady() {
|
||||
const harness = createRegistry()
|
||||
const dispose = harness.registry.subscribe(method, params, () => {})
|
||||
const requestId = harness.sent[0]!.id
|
||||
harness.registry.handleResponse(
|
||||
streamingResponse(requestId, { type: 'ready', subscriptionId: 'old-connection-id' })
|
||||
)
|
||||
return { ...harness, dispose, requestId }
|
||||
}
|
||||
|
||||
it('releases the replayed registration when disposed before its new ready', () => {
|
||||
const { registry, sent, dispose, requestId } = subscribeReady()
|
||||
|
||||
registry.markForReplay()
|
||||
registry.replayAfterAuthentication()
|
||||
dispose()
|
||||
registry.handleResponse(
|
||||
streamingResponse(requestId, { type: 'ready', subscriptionId: 'new-connection-id' })
|
||||
)
|
||||
|
||||
expect(unsubscribes(sent)).toEqual([{ subscriptionId: 'new-connection-id' }])
|
||||
})
|
||||
|
||||
it('forgets the previous connection id when marked for replay', () => {
|
||||
const { registry, sent, dispose } = subscribeReady()
|
||||
|
||||
registry.markForReplay()
|
||||
dispose()
|
||||
|
||||
// A disposal while disconnected has nothing to name on the next connection.
|
||||
expect(unsubscribes(sent)).toEqual([])
|
||||
expect(registry.size()).toBe(0)
|
||||
})
|
||||
|
||||
it('sends one unsubscribe however often the stream is disposed', () => {
|
||||
const { registry, sent, dispose, requestId } = subscribeReady()
|
||||
|
||||
registry.markForReplay()
|
||||
registry.replayAfterAuthentication()
|
||||
dispose()
|
||||
dispose()
|
||||
registry.handleResponse(
|
||||
streamingResponse(requestId, { type: 'ready', subscriptionId: 'new-connection-id' })
|
||||
)
|
||||
dispose()
|
||||
|
||||
expect(unsubscribes(sent)).toEqual([{ subscriptionId: 'new-connection-id' }])
|
||||
expect(registry.size()).toBe(0)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,14 +1,13 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { RelayDialStageTracker, type RelayDialStage } from './relay-dial-stage'
|
||||
import './relay-dial-stage'
|
||||
import type { ConnectionState, RpcResponse } from './types'
|
||||
import type { RpcClient } from './rpc-client'
|
||||
import { isRpcDeliveryUnknown, markRpcDeliveryUnknown } from './rpc-delivery-ambiguity'
|
||||
import './rpc-delivery-ambiguity'
|
||||
import {
|
||||
createStableLogicalRpcClient,
|
||||
LogicalClientCutoverError,
|
||||
type MobileConnectionPath
|
||||
LogicalClientCutoverError
|
||||
} from './stable-logical-rpc-client'
|
||||
import { LogicalClientSuspendedError } from './logical-client-suspended-error'
|
||||
import './logical-client-suspended-error'
|
||||
|
||||
class FakeSession implements RpcClient {
|
||||
readonly sendRequest =
|
||||
@@ -107,127 +106,6 @@ describe('stable logical RPC client', () => {
|
||||
pending.resolve(success('late'))
|
||||
})
|
||||
|
||||
it('attaches a subscription its owner holds at cutover only when the owner resumes it', async () => {
|
||||
const oldSession = new FakeSession('connected')
|
||||
const nextSession = new FakeSession('connected')
|
||||
const client = createStableLogicalRpcClient(oldSession, 'lan')
|
||||
const held: (() => void)[] = []
|
||||
client.subscribe('terminal.subscribe', { terminal: 'term-1' }, vi.fn(), {
|
||||
holdReplay: (resend) => held.push(resend) > 0
|
||||
})
|
||||
oldSession.subscribe.mockClear()
|
||||
|
||||
await client.migrateTo(nextSession, 'relay')
|
||||
expect(nextSession.subscribe).not.toHaveBeenCalled()
|
||||
|
||||
held[0]!()
|
||||
expect(nextSession.subscribe).toHaveBeenCalledOnce()
|
||||
expect(oldSession.subscribe).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps replies that commit before cutover and carries viewport state into replay', async () => {
|
||||
const oldSession = new FakeSession('connected')
|
||||
const nextSession = new FakeSession('connected')
|
||||
oldSession.sendRequest.mockResolvedValue(success('old'))
|
||||
const client = createStableLogicalRpcClient(oldSession, 'lan')
|
||||
client.subscribe(
|
||||
'terminal.subscribe',
|
||||
{ terminal: 'term-1', viewport: { cols: 80, rows: 24 } },
|
||||
vi.fn()
|
||||
)
|
||||
client.updateTerminalSubscriptionViewport('term-1', { cols: 120, rows: 40 })
|
||||
|
||||
await expect(client.sendRequest('status.get')).resolves.toEqual(success('old'))
|
||||
await client.migrateTo(nextSession, 'relay')
|
||||
expect(nextSession.subscribe).toHaveBeenCalledWith(
|
||||
'terminal.subscribe',
|
||||
{ terminal: 'term-1', viewport: { cols: 120, rows: 40 } },
|
||||
expect.any(Function),
|
||||
undefined
|
||||
)
|
||||
})
|
||||
|
||||
it('suspends one physical session and replays subscriptions on foreground replacement', async () => {
|
||||
const oldSession = new FakeSession('connected')
|
||||
const nextSession = new FakeSession('connected')
|
||||
nextSession.sendRequest.mockResolvedValue(success('next'))
|
||||
const client = createStableLogicalRpcClient(oldSession, 'relay')
|
||||
client.subscribe('session.tabs.subscribe', { worktree: 'id:wt-1' }, vi.fn())
|
||||
|
||||
client.suspendActiveSession()
|
||||
|
||||
expect(oldSession.close).toHaveBeenCalledOnce()
|
||||
expect(client.getState()).toBe('disconnected')
|
||||
await expect(client.sendRequest('status.get')).rejects.toThrow('Client suspended')
|
||||
|
||||
await client.migrateTo(nextSession, 'relay')
|
||||
|
||||
expect(nextSession.subscribe).toHaveBeenCalledWith(
|
||||
'session.tabs.subscribe',
|
||||
{ worktree: 'id:wt-1' },
|
||||
expect.any(Function),
|
||||
undefined
|
||||
)
|
||||
await expect(client.sendRequest('status.get')).resolves.toEqual(success('next'))
|
||||
})
|
||||
|
||||
it('reports requests the suspended session never sent as suspended, and written ones as ambiguous', async () => {
|
||||
const session = new FakeSession('connected')
|
||||
const unsent = deferred<RpcResponse>()
|
||||
const written = deferred<RpcResponse>()
|
||||
session.sendRequest.mockReturnValueOnce(unsent.promise).mockReturnValueOnce(written.promise)
|
||||
const client = createStableLogicalRpcClient(session, 'relay')
|
||||
const waiting = client.sendRequest('speech.dictation.chunk').catch((error: unknown) => error)
|
||||
const inFlight = client.sendRequest('speech.dictation.chunk').catch((error: unknown) => error)
|
||||
|
||||
client.suspendActiveSession()
|
||||
unsent.reject(new Error('relay session closed'))
|
||||
written.reject(markRpcDeliveryUnknown(new Error('Client closed')))
|
||||
|
||||
expect(await waiting).toBeInstanceOf(LogicalClientSuspendedError)
|
||||
expect(isRpcDeliveryUnknown(await inFlight)).toBe(true)
|
||||
})
|
||||
|
||||
it('preserves delivery ambiguity without replaying a mutation after relay replacement', async () => {
|
||||
const session = new FakeSession('connected')
|
||||
const replacement = new FakeSession('connected')
|
||||
const inFlight = deferred<RpcResponse>()
|
||||
session.sendRequest.mockReturnValue(inFlight.promise)
|
||||
// Mirror the real physical contract: close() rejects post-write pendings
|
||||
// with a delivery-unknown-marked error.
|
||||
const closeError = markRpcDeliveryUnknown(new Error('Client closed'))
|
||||
session.close.mockImplementation(() => inFlight.reject(closeError))
|
||||
const client = createStableLogicalRpcClient(session, 'relay')
|
||||
const request = client.sendRequest('terminal.send', { terminal: 'term', text: 'hi' })
|
||||
|
||||
client.suspendActiveSession()
|
||||
|
||||
await expect(request).rejects.toBe(closeError)
|
||||
await expect(request.catch((error: unknown) => isRpcDeliveryUnknown(error))).resolves.toBe(true)
|
||||
await client.migrateTo(replacement, 'relay')
|
||||
expect(replacement.sendRequest).not.toHaveBeenCalled()
|
||||
replacement.sendRequest.mockResolvedValue(success('next'))
|
||||
await expect(client.sendRequest('status.get')).resolves.toEqual(success('next'))
|
||||
})
|
||||
|
||||
it('lets the physical close settle in-flight requests on close, keeping pre-write failures definite', async () => {
|
||||
const session = new FakeSession('connected')
|
||||
const inFlight = deferred<RpcResponse>()
|
||||
session.sendRequest.mockReturnValue(inFlight.promise)
|
||||
// A request still waiting for connect wrote no frame and must remain unmarked on logical close.
|
||||
const preWriteError = new Error('Connection closed')
|
||||
session.close.mockImplementation(() => inFlight.reject(preWriteError))
|
||||
const client = createStableLogicalRpcClient(session, 'lan')
|
||||
const request = client.sendRequest('terminal.send', { terminal: 'term', text: 'hi' })
|
||||
|
||||
client.close()
|
||||
|
||||
await expect(request).rejects.toBe(preWriteError)
|
||||
await expect(request.catch((error: unknown) => isRpcDeliveryUnknown(error))).resolves.toBe(
|
||||
false
|
||||
)
|
||||
})
|
||||
|
||||
it('preserves a committed response when logical close wins the callback race', async () => {
|
||||
const session = new FakeSession('connected')
|
||||
const inFlight = deferred<RpcResponse>()
|
||||
@@ -240,204 +118,6 @@ describe('stable logical RPC client', () => {
|
||||
|
||||
await expect(request).resolves.toEqual(success('accepted'))
|
||||
})
|
||||
|
||||
it('publishes the replacement dial phases while the client is suspended', async () => {
|
||||
const oldSession = new FakeSession('connected')
|
||||
const replacement = new FakeSession('connecting')
|
||||
const client = createStableLogicalRpcClient(oldSession, 'relay')
|
||||
const states: ConnectionState[] = []
|
||||
client.onStateChange((next) => states.push(next))
|
||||
|
||||
client.suspendActiveSession()
|
||||
expect(client.getPendingPath()).toBeNull()
|
||||
|
||||
const migrating = client.migrateTo(replacement, 'relay')
|
||||
// The replacement never announces its initial connecting state; the client must still show it.
|
||||
expect(client.getState()).toBe('connecting')
|
||||
expect(client.getPendingPath()).toBe('relay')
|
||||
replacement.setState('handshaking')
|
||||
expect(client.getState()).toBe('handshaking')
|
||||
replacement.setState('connected')
|
||||
await migrating
|
||||
|
||||
expect(states).toEqual(['disconnected', 'connecting', 'handshaking', 'connected'])
|
||||
expect(client.getPendingPath()).toBeNull()
|
||||
expect(client.getActivePath()).toBe('relay')
|
||||
})
|
||||
|
||||
// Forward replacement reconnecting phases so the suspended client shows progress during the dial.
|
||||
it('forwards a reconnecting phase published by the dialing session itself', async () => {
|
||||
const oldSession = new FakeSession('connected')
|
||||
const replacement = new FakeSession('connecting')
|
||||
const client = createStableLogicalRpcClient(oldSession, 'relay')
|
||||
client.suspendActiveSession()
|
||||
const states: ConnectionState[] = []
|
||||
client.onStateChange((next) => states.push(next))
|
||||
|
||||
const migrating = client.migrateTo(replacement, 'relay')
|
||||
replacement.setState('connecting')
|
||||
replacement.setState('reconnecting')
|
||||
expect(client.getState()).toBe('reconnecting')
|
||||
replacement.setState('handshaking')
|
||||
replacement.setState('connected')
|
||||
await migrating
|
||||
|
||||
expect(states).toEqual(['connecting', 'reconnecting', 'handshaking', 'connected'])
|
||||
})
|
||||
|
||||
// A direct session already reconnecting needs no phase forwarding, but must still name the pending relay path.
|
||||
it('names a relay dial started from reconnecting without disturbing the state', async () => {
|
||||
const direct = new FakeSession('connected')
|
||||
const replacement = new FakeSession('connecting')
|
||||
const client = createStableLogicalRpcClient(direct, 'lan')
|
||||
direct.setState('reconnecting')
|
||||
const states: ConnectionState[] = []
|
||||
const paths: (MobileConnectionPath | null)[] = []
|
||||
client.onStateChange((next) => states.push(next))
|
||||
client.onConnectionPathChange(() => paths.push(client.getPendingPath()))
|
||||
|
||||
const migrating = client.migrateTo(replacement, 'relay')
|
||||
expect(client.getPendingPath()).toBe('relay')
|
||||
expect(paths).toEqual(['relay'])
|
||||
replacement.setState('connecting')
|
||||
replacement.setState('handshaking')
|
||||
|
||||
expect(client.getState()).toBe('reconnecting')
|
||||
expect(states).toEqual([])
|
||||
|
||||
replacement.setState('connected')
|
||||
await migrating
|
||||
|
||||
expect(states).toEqual(['connected'])
|
||||
expect(client.getPendingPath()).toBeNull()
|
||||
expect(client.getActivePath()).toBe('relay')
|
||||
})
|
||||
|
||||
it('publishes recovery-path changes and keeps Relay pending between failed dials', async () => {
|
||||
const direct = new FakeSession('reconnecting')
|
||||
const replacement = new FakeSession('connecting')
|
||||
const client = createStableLogicalRpcClient(direct, 'tailscale')
|
||||
const paths: (MobileConnectionPath | null)[] = []
|
||||
client.onConnectionPathChange(() => paths.push(client.getPendingPath()))
|
||||
|
||||
client.setRecoveryPath('relay')
|
||||
const migrating = client.migrateTo(replacement, 'relay')
|
||||
replacement.setState('disconnected')
|
||||
await expect(migrating).rejects.toThrow(/disconnected/)
|
||||
|
||||
expect(client.getPendingPath()).toBe('relay')
|
||||
expect(paths).toEqual(['relay'])
|
||||
|
||||
client.setRecoveryPath(null)
|
||||
expect(client.getPendingPath()).toBeNull()
|
||||
expect(paths).toEqual(['relay', null])
|
||||
})
|
||||
|
||||
it('does not revive a stale recovery path after a connection later drops', () => {
|
||||
const direct = new FakeSession('reconnecting')
|
||||
const client = createStableLogicalRpcClient(direct, 'tailscale')
|
||||
|
||||
client.setRecoveryPath('relay')
|
||||
direct.setState('connected')
|
||||
direct.setState('reconnecting')
|
||||
|
||||
expect(client.getPendingPath()).toBeNull()
|
||||
})
|
||||
|
||||
it('drops the pending path when the previous session recovers mid-dial', async () => {
|
||||
const direct = new FakeSession('connected')
|
||||
const replacement = new FakeSession('connecting')
|
||||
const client = createStableLogicalRpcClient(direct, 'lan')
|
||||
direct.setState('reconnecting')
|
||||
|
||||
const migrating = client.migrateTo(replacement, 'relay')
|
||||
expect(client.getPendingPath()).toBe('relay')
|
||||
|
||||
direct.setState('connected')
|
||||
expect(client.getPendingPath()).toBeNull()
|
||||
|
||||
replacement.setState('connected')
|
||||
await migrating
|
||||
})
|
||||
|
||||
it('never downgrades a live session while a make-before-break replacement dials', async () => {
|
||||
const oldSession = new FakeSession('connected')
|
||||
const replacement = new FakeSession('connecting')
|
||||
const client = createStableLogicalRpcClient(oldSession, 'relay')
|
||||
const states: ConnectionState[] = []
|
||||
client.onStateChange((next) => states.push(next))
|
||||
|
||||
const migrating = client.migrateTo(replacement, 'relay')
|
||||
replacement.setState('connecting')
|
||||
replacement.setState('handshaking')
|
||||
|
||||
expect(client.getState()).toBe('connected')
|
||||
expect(client.getPendingPath()).toBeNull()
|
||||
|
||||
replacement.setState('connected')
|
||||
await migrating
|
||||
|
||||
expect(states).toEqual(['connected'])
|
||||
})
|
||||
|
||||
it('restores disconnected when a dial it was narrating fails', async () => {
|
||||
const session = new FakeSession('connected')
|
||||
const replacement = new FakeSession('connecting')
|
||||
const client = createStableLogicalRpcClient(session, 'relay')
|
||||
client.suspendActiveSession()
|
||||
const states: ConnectionState[] = []
|
||||
client.onStateChange((next) => states.push(next))
|
||||
|
||||
const migrating = client.migrateTo(replacement, 'relay')
|
||||
replacement.setState('handshaking')
|
||||
replacement.setState('disconnected')
|
||||
|
||||
await expect(migrating).rejects.toThrow(/disconnected/)
|
||||
expect(states).toEqual(['connecting', 'handshaking', 'disconnected'])
|
||||
expect(client.getState()).toBe('disconnected')
|
||||
expect(client.getPendingPath()).toBeNull()
|
||||
})
|
||||
|
||||
it('outlives the flat bound when the relay cell holds the dial', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const oldSession = new FakeSession('connected')
|
||||
const replacement = Object.assign(new FakeSession('connecting'), {
|
||||
dialStage: new RelayDialStageTracker(),
|
||||
getDialStage(): RelayDialStage {
|
||||
return this.dialStage.getDialStage()
|
||||
},
|
||||
onDialStageChange(listener: (stage: RelayDialStage) => void) {
|
||||
return this.dialStage.onDialStageChange(listener)
|
||||
}
|
||||
})
|
||||
const client = createStableLogicalRpcClient(oldSession, 'lan')
|
||||
const migrating = client.migrateTo(replacement, 'relay', 12_000)
|
||||
await vi.advanceTimersByTimeAsync(1_000)
|
||||
replacement.dialStage.advance('awaiting-hello')
|
||||
await vi.advanceTimersByTimeAsync(20_000)
|
||||
expect(replacement.close).not.toHaveBeenCalled()
|
||||
replacement.setState('connected')
|
||||
await migrating
|
||||
expect(client.getActivePath()).toBe('relay')
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('closes a replacement that fails authentication and preserves the active session', async () => {
|
||||
const oldSession = new FakeSession('connected')
|
||||
const replacement = new FakeSession('connecting')
|
||||
const client = createStableLogicalRpcClient(oldSession, 'lan')
|
||||
const migrating = client.migrateTo(replacement, 'relay')
|
||||
replacement.setState('auth-failed')
|
||||
|
||||
await expect(migrating).rejects.toThrow(/auth-failed/)
|
||||
expect(replacement.close).toHaveBeenCalledOnce()
|
||||
expect(oldSession.close).not.toHaveBeenCalled()
|
||||
expect(client.getActivePath()).toBe('lan')
|
||||
expect(client.getGeneration()).toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('stable logical RPC client subscription fencing', () => {
|
||||
|
||||
@@ -1,80 +0,0 @@
|
||||
/** Natural PTY exit/disconnect must evict leader teams too; explicit close was once the only cleanup. */
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { OrcaRuntimeService } from '../orca-runtime'
|
||||
import type { ClaudeAgentTeamsService } from '../claude-agent-teams-service'
|
||||
|
||||
export function registerClaudeAgentTeamsPtyExitLeakTests(): void {
|
||||
type RuntimeInternals = {
|
||||
claudeAgentTeams: ClaudeAgentTeamsService
|
||||
handleByPtyId: Map<string, string>
|
||||
dropDisconnectedPtyRecord: (ptyId: string) => void
|
||||
}
|
||||
|
||||
function internals(runtime: OrcaRuntimeService): RuntimeInternals {
|
||||
return runtime as unknown as RuntimeInternals
|
||||
}
|
||||
|
||||
function registerTeam(runtime: OrcaRuntimeService, ptyId: string, leaderHandle: string): void {
|
||||
const { claudeAgentTeams, handleByPtyId } = internals(runtime)
|
||||
claudeAgentTeams.createLaunchEnv({
|
||||
leaderHandle,
|
||||
baseEnv: {},
|
||||
shimDir: '/tmp/orca-shim',
|
||||
shimBin: 'orca'
|
||||
})
|
||||
handleByPtyId.set(ptyId, leaderHandle)
|
||||
}
|
||||
|
||||
describe('ClaudeAgentTeams eviction on natural PTY exit (leak regression)', () => {
|
||||
it('evicts the team when its leader PTY exits naturally (onPtyExit)', () => {
|
||||
const runtime = new OrcaRuntimeService()
|
||||
registerTeam(runtime, 'pty-leader', 'handle-leader')
|
||||
expect(internals(runtime).claudeAgentTeams.getActiveTeamCount()).toBe(1)
|
||||
|
||||
runtime.onPtyExit('pty-leader', 0)
|
||||
|
||||
expect(internals(runtime).claudeAgentTeams.getActiveTeamCount()).toBe(0)
|
||||
})
|
||||
|
||||
it('evicts the team when the disconnected PTY record is pruned', () => {
|
||||
const runtime = new OrcaRuntimeService()
|
||||
registerTeam(runtime, 'pty-leader', 'handle-leader')
|
||||
expect(internals(runtime).claudeAgentTeams.getActiveTeamCount()).toBe(1)
|
||||
|
||||
internals(runtime).dropDisconnectedPtyRecord('pty-leader')
|
||||
|
||||
expect(internals(runtime).claudeAgentTeams.getActiveTeamCount()).toBe(0)
|
||||
})
|
||||
|
||||
it('does not accumulate teams across many natural leader exits', () => {
|
||||
const runtime = new OrcaRuntimeService()
|
||||
for (let i = 0; i < 100; i++) {
|
||||
registerTeam(runtime, `pty-${i}`, `handle-${i}`)
|
||||
runtime.onPtyExit(`pty-${i}`, 0)
|
||||
}
|
||||
expect(internals(runtime).claudeAgentTeams.getActiveTeamCount()).toBe(0)
|
||||
})
|
||||
|
||||
it('leaves a concurrently-launched team intact when only one leader exits', () => {
|
||||
const runtime = new OrcaRuntimeService()
|
||||
registerTeam(runtime, 'pty-a', 'handle-a')
|
||||
registerTeam(runtime, 'pty-b', 'handle-b')
|
||||
expect(internals(runtime).claudeAgentTeams.getActiveTeamCount()).toBe(2)
|
||||
|
||||
runtime.onPtyExit('pty-a', 0)
|
||||
|
||||
expect(internals(runtime).claudeAgentTeams.getActiveTeamCount()).toBe(1)
|
||||
})
|
||||
|
||||
it('is a no-op for a PTY that never launched a team', () => {
|
||||
const runtime = new OrcaRuntimeService()
|
||||
const { handleByPtyId } = internals(runtime)
|
||||
handleByPtyId.set('pty-plain', 'handle-plain')
|
||||
expect(internals(runtime).claudeAgentTeams.getActiveTeamCount()).toBe(0)
|
||||
|
||||
runtime.onPtyExit('pty-plain', 0)
|
||||
|
||||
expect(internals(runtime).claudeAgentTeams.getActiveTeamCount()).toBe(0)
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -1,74 +0,0 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { RuntimeMobileSessionTabsResult } from '../../../shared/runtime-session-contracts'
|
||||
import { OrcaRuntimeService } from '../orca-runtime'
|
||||
|
||||
export function registerSessionTabsEmptyWorktreePublicationTests(): void {
|
||||
const WORKTREE = 'repo::/never-opened'
|
||||
|
||||
type WorktreeAnswerInternals = {
|
||||
getMobileSessionTabsForWorktree: (
|
||||
worktreeId: string,
|
||||
clientNavigationId?: string
|
||||
) => RuntimeMobileSessionTabsResult
|
||||
}
|
||||
|
||||
function createHeadedRuntime(): OrcaRuntimeService {
|
||||
const runtime = new OrcaRuntimeService()
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: these tests only reach listProcesses.
|
||||
runtime.setPtyController({ listProcesses: vi.fn(async () => []) } as never)
|
||||
runtime.attachWindow(1)
|
||||
return runtime
|
||||
}
|
||||
|
||||
function answerFor(runtime: OrcaRuntimeService, clientNavigationId?: string) {
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: reads the runtime's own per-worktree answer.
|
||||
return (runtime as unknown as WorktreeAnswerInternals).getMobileSessionTabsForWorktree(
|
||||
WORKTREE,
|
||||
clientNavigationId
|
||||
)
|
||||
}
|
||||
|
||||
function recordAnswers(runtime: OrcaRuntimeService): RuntimeMobileSessionTabsResult[] {
|
||||
const answers: RuntimeMobileSessionTabsResult[] = []
|
||||
runtime.onMobileSessionTabsChanged((snapshot) => {
|
||||
if (snapshot.worktree === WORKTREE) {
|
||||
answers.push(snapshot)
|
||||
}
|
||||
}, 'device-1')
|
||||
return answers
|
||||
}
|
||||
|
||||
function publishGraphWithoutWorktree(runtime: OrcaRuntimeService): void {
|
||||
runtime.syncWindowGraph(1, { tabs: [], leaves: [], mobileSessionTabs: [] })
|
||||
}
|
||||
|
||||
describe('answering for a worktree the host has no tabs for', () => {
|
||||
it('answers "ask me later" only until the renderer graph publishes', () => {
|
||||
const runtime = createHeadedRuntime()
|
||||
expect(answerFor(runtime)).toMatchObject({ publicationEpoch: 'none', tabs: [] })
|
||||
expect(answerFor(runtime, 'device-1')).toMatchObject({
|
||||
publicationEpoch: 'none:client-navigation',
|
||||
tabs: []
|
||||
})
|
||||
|
||||
publishGraphWithoutWorktree(runtime)
|
||||
|
||||
const published = answerFor(runtime, 'device-1')
|
||||
expect(published.publicationEpoch).not.toMatch(/^none/)
|
||||
expect(published.tabs).toEqual([])
|
||||
})
|
||||
|
||||
it('tells a client that asked during startup once the graph publishes', () => {
|
||||
const runtime = createHeadedRuntime()
|
||||
const answers = recordAnswers(runtime)
|
||||
answerFor(runtime, 'device-1')
|
||||
answerFor(runtime, 'device-1')
|
||||
|
||||
publishGraphWithoutWorktree(runtime)
|
||||
|
||||
expect(answers).toHaveLength(1)
|
||||
expect(answers[0].publicationEpoch).not.toMatch(/^none/)
|
||||
expect(answers[0].tabs).toEqual([])
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -6,11 +6,8 @@ import {
|
||||
type AgentTransition
|
||||
} from '../../shared/agent-status/lifecycle/agent-transitions'
|
||||
import type { NotificationSettings } from '../../shared/notification-settings-types'
|
||||
import type {
|
||||
MobileNotificationDispatchEvent,
|
||||
MobileNotificationEvent
|
||||
} from './push/notifications/runtime-mobile-notification-controller'
|
||||
import { createNotificationDeliveryService } from '../notifications/notification-delivery-service'
|
||||
import type { MobileNotificationDispatchEvent } from './push/notifications/runtime-mobile-notification-controller'
|
||||
import '../notifications/notification-delivery-service'
|
||||
import { TerminalAgentPhonePush } from './terminal-agent-phone-push'
|
||||
|
||||
const WT = 'repo-1::/tmp/wt'
|
||||
@@ -149,26 +146,6 @@ describe('TerminalAgentPhonePush', () => {
|
||||
])
|
||||
})
|
||||
|
||||
it.each([null, 'host-a'])(
|
||||
'pushes windowless news with its execution owner: %s',
|
||||
(connectionId) => {
|
||||
const { sent, finish } = harness()
|
||||
finish({ connectionId })
|
||||
expect(sent).toHaveLength(1)
|
||||
expect(sent[0]?.workspaceExecutionHostId ?? 'local').toBe(
|
||||
connectionId ? `ssh:${connectionId}` : 'local'
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
it('waits out the quiet window for a Pi agent even with no live work seen', () => {
|
||||
const { sent, advance, finish } = harness()
|
||||
finish({ agentType: 'pi' })
|
||||
expect(sent).toEqual([])
|
||||
advance(AGENT_DONE_QUIET_MS)
|
||||
expect(sent).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('announces no milestone a goal agent resumes from within the quiet window', () => {
|
||||
const { push, sent, advance, working, finish } = harness()
|
||||
working()
|
||||
@@ -180,26 +157,6 @@ describe('TerminalAgentPhonePush', () => {
|
||||
expect(sent).toEqual([])
|
||||
})
|
||||
|
||||
it('pushes an agent that needs input at once, and a failed turn as finished', () => {
|
||||
const { push, sent, state, advance } = harness({
|
||||
hookRow: row({
|
||||
state: 'waiting',
|
||||
lastAssistantMessage: undefined,
|
||||
toolName: 'Bash',
|
||||
toolInput: 'rm -rf build'
|
||||
})
|
||||
})
|
||||
push.observe(moved('needs-input', 'needs-you'))
|
||||
expect(sent).toHaveLength(1)
|
||||
advance(5_000)
|
||||
state.hookRow = row({ mainAgent: { state: 'done', outcome: 'failure', stateStartedAt: 5_000 } })
|
||||
push.observe(moved('errored'))
|
||||
expect(sent.map((event) => [event.agentState, event.title, event.body])).toEqual([
|
||||
['waiting', 'orca / feature - Claude needs input', 'Using Bash: rm -rf build'],
|
||||
['done', 'orca / feature - Claude failed', 'Fixed it.']
|
||||
])
|
||||
})
|
||||
|
||||
it('stays silent while a window owns the host, including one that arrives mid quiet window', () => {
|
||||
const { sent, state, advance, working, finish } = harness()
|
||||
state.hasWindow = true
|
||||
@@ -214,66 +171,6 @@ describe('TerminalAgentPhonePush', () => {
|
||||
expect(sent).toEqual([])
|
||||
})
|
||||
|
||||
it('collapses a burst in one workspace as the window’s fan-out does', () => {
|
||||
const { push, sent, finish } = harness()
|
||||
finish()
|
||||
push.observe(moved('prompt-submitted', 'working'))
|
||||
finish({ stateStartedAt: 6_000 })
|
||||
expect(sent).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('pushes nothing for a prompt, a resume, a rest, a session boundary, or a structured chat’s row', () => {
|
||||
const { push, sent, state, advance } = harness()
|
||||
push.observe(moved('prompt-submitted', 'working'))
|
||||
push.observe(moved(null, 'working'))
|
||||
push.observe(moved(null, 'rest'))
|
||||
// The detector names no news for a boundary's done.
|
||||
push.observe(moved(null, 'done'))
|
||||
push.observe({
|
||||
kind: 'seeded',
|
||||
paneKey: PANE,
|
||||
workspace: { worktreeId: WT, sshHostId: null },
|
||||
phase: 'done',
|
||||
at: 1
|
||||
})
|
||||
state.hookRow = row({ structuredHost: 'owned' })
|
||||
push.observe(moved('turn-finished'))
|
||||
advance(AGENT_DONE_QUIET_MS)
|
||||
expect(sent).toEqual([])
|
||||
})
|
||||
|
||||
it('pushes nothing for a muted workspace', () => {
|
||||
const { sent, finish } = harness({ muted: true })
|
||||
finish()
|
||||
expect(sent).toEqual([])
|
||||
})
|
||||
|
||||
it('leaves a dispatched worker’s finish to its coordinator, and still pushes its prompt', () => {
|
||||
const { push, sent, state, advance, finish } = harness({ dispatched: true })
|
||||
finish()
|
||||
advance(AGENT_DONE_QUIET_MS)
|
||||
expect(sent).toEqual([])
|
||||
state.hookRow = row({ state: 'waiting', stateStartedAt: 6_000 })
|
||||
push.observe(moved('needs-input', 'needs-you'))
|
||||
expect(sent.map((event) => event.agentState)).toEqual(['waiting'])
|
||||
})
|
||||
|
||||
it('pushes a dispatched worker’s finish whose title still waits for approval, as the window does', () => {
|
||||
const { sent, advance, finish } = harness({
|
||||
dispatched: true,
|
||||
title: 'Codex - waiting for approval'
|
||||
})
|
||||
finish()
|
||||
advance(AGENT_DONE_QUIET_MS)
|
||||
expect(sent).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('marks the push not desktop-allowed when agent notifications are off', () => {
|
||||
const { sent, finish } = harness({ settings: { agentTaskComplete: false } })
|
||||
finish()
|
||||
expect(sent[0]?.desktopAllowed).toBe(false)
|
||||
})
|
||||
|
||||
it('announces a lead agent’s finish while background work runs, and not its later all-clear', () => {
|
||||
const { sent, advance, working, finish } = harness()
|
||||
working()
|
||||
@@ -289,28 +186,6 @@ describe('TerminalAgentPhonePush', () => {
|
||||
expect(sent).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('waits out the quiet window for a pane restored mid-work, as the window seeds it', () => {
|
||||
const { push, sent, advance, finish } = harness({ hookRow: row({ state: 'working' }) })
|
||||
const workspace = { worktreeId: WT, sshHostId: null }
|
||||
push.observe({ kind: 'seeded', paneKey: PANE, workspace, phase: 'working', at: 1 })
|
||||
finish()
|
||||
expect(sent).toEqual([])
|
||||
advance(AGENT_DONE_QUIET_MS)
|
||||
expect(sent).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('counts a pane restored mid background work as work seen, as the window seeds it', () => {
|
||||
const { push, sent, advance, finish } = harness({
|
||||
hookRow: row({ state: 'working', turnCompletedAt: 7_000 })
|
||||
})
|
||||
const workspace = { worktreeId: WT, sshHostId: null }
|
||||
push.observe({ kind: 'seeded', paneKey: PANE, workspace, phase: 'working', at: 1 })
|
||||
finish()
|
||||
expect(sent).toEqual([])
|
||||
advance(AGENT_DONE_QUIET_MS)
|
||||
expect(sent).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('announces no finish restored with the pane', () => {
|
||||
const { push, sent, advance, working, finish } = harness({
|
||||
hookRow: row({ state: 'working', turnCompletedAt: 7_000 })
|
||||
@@ -322,48 +197,4 @@ describe('TerminalAgentPhonePush', () => {
|
||||
advance(AGENT_DONE_QUIET_MS)
|
||||
expect(sent).toEqual([])
|
||||
})
|
||||
|
||||
it('sends phones, old ones included, the alert the window’s own request would have', () => {
|
||||
const { sent, finish } = harness()
|
||||
finish()
|
||||
const fromWindow: MobileNotificationEvent[] = []
|
||||
const service = createNotificationDeliveryService({
|
||||
readNotificationSettings: () => ({
|
||||
enabled: false,
|
||||
agentTaskComplete: true,
|
||||
agentNeedsInput: true,
|
||||
terminalBell: true,
|
||||
suppressWhenFocused: true,
|
||||
customSoundId: 'system',
|
||||
customSoundPath: null,
|
||||
customSoundVolume: 1,
|
||||
mutedNotificationSourceIds: [],
|
||||
notifyHiddenWorkspaces: true
|
||||
}),
|
||||
findActiveWindow: () => null,
|
||||
isWindowVisible: () => false,
|
||||
setTrayAttention: () => {},
|
||||
isNotificationSupported: () => false,
|
||||
dispatchMobileNotification: (event) => fromWindow.push(event),
|
||||
readAuthorizationStatus: async () => null,
|
||||
recordDeliveryOutcome: () => {},
|
||||
deliverNative: () => ({ delivered: false }),
|
||||
platform: 'linux',
|
||||
now: () => 10_000
|
||||
})
|
||||
void service.dispatch({
|
||||
source: 'agent-task-complete',
|
||||
notificationId: sent[0]?.notificationId,
|
||||
worktreeId: WT,
|
||||
paneKey: PANE,
|
||||
repoLabel: 'orca',
|
||||
worktreeLabel: 'feature',
|
||||
agentType: 'claude',
|
||||
agentState: 'done',
|
||||
agentPrompt: 'fix the bug',
|
||||
agentLastAssistantMessage: 'Fixed it.'
|
||||
})
|
||||
// The window's request was made with desktop banners off; only that flag differs.
|
||||
expect(fromWindow).toEqual([{ ...sent[0], desktopAllowed: false }])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -68,18 +68,6 @@ function harness() {
|
||||
}
|
||||
|
||||
describe('TerminalAgentPromptAnswers', () => {
|
||||
it('refuses a second answer to the same prompt before the agent reports moving on', async () => {
|
||||
const h = harness()
|
||||
const { promptId } = h.answers.pending(h.runtime, target)!
|
||||
await h.answer(promptId)
|
||||
expect(h.answers.pending(h.runtime, target)).toBeNull()
|
||||
await expect(h.answer(promptId)).resolves.toEqual({
|
||||
outcome: 'refused',
|
||||
reason: 'prompt_not_pending'
|
||||
})
|
||||
expect(h.keys).toEqual(['1'])
|
||||
})
|
||||
|
||||
it('allows only one concurrent answer to claim the prompt', async () => {
|
||||
const h = harness()
|
||||
const { promptId } = h.answers.pending(h.runtime, target)!
|
||||
@@ -118,16 +106,6 @@ describe('TerminalAgentPromptAnswers', () => {
|
||||
expect(h.answers.pending(h.runtime, target)).toBeNull()
|
||||
})
|
||||
|
||||
it('refuses a prompt the agent has moved on from, or a later one', async () => {
|
||||
const h = harness()
|
||||
const { promptId } = h.answers.pending(h.runtime, target)!
|
||||
h.state.row = approvalRow(20)
|
||||
await expect(h.answer(promptId)).resolves.toMatchObject({ reason: 'prompt_not_pending' })
|
||||
h.state.row = { ...approvalRow(20), state: 'working' }
|
||||
await expect(h.answer(promptId)).resolves.toMatchObject({ reason: 'prompt_not_pending' })
|
||||
expect(h.keys).toEqual([])
|
||||
})
|
||||
|
||||
it('refuses when the prompt or the process changes between the read and the write', async () => {
|
||||
const h = harness()
|
||||
const first = h.answers.pending(h.runtime, target)!.promptId
|
||||
@@ -144,25 +122,6 @@ describe('TerminalAgentPromptAnswers', () => {
|
||||
expect(h.keys).toEqual([])
|
||||
})
|
||||
|
||||
it('refuses an option the prompt does not offer, and a prompt only Orca can answer', async () => {
|
||||
const h = harness()
|
||||
const { promptId } = h.answers.pending(h.runtime, target)!
|
||||
await expect(h.answer(promptId, 'allow_always')).resolves.toMatchObject({
|
||||
reason: 'option_not_offered'
|
||||
})
|
||||
const hookless = { ...target, answerable: false }
|
||||
const unsupported = h.answers.pending(h.runtime, hookless)!
|
||||
expect(unsupported.kind).toBe('unsupported')
|
||||
await expect(
|
||||
h.answers.answer(h.runtime, hookless, {
|
||||
promptId: unsupported.promptId,
|
||||
optionId: 'allow_once',
|
||||
signal: new AbortController().signal
|
||||
})
|
||||
).resolves.toMatchObject({ reason: 'prompt_unsupported' })
|
||||
expect(h.keys).toEqual([])
|
||||
})
|
||||
|
||||
it('keeps a write the transport refused answerable, and an unprovable one answered', async () => {
|
||||
const h = harness()
|
||||
const { promptId } = h.answers.pending(h.runtime, target)!
|
||||
@@ -191,20 +150,4 @@ describe('TerminalAgentPromptAnswers', () => {
|
||||
})
|
||||
expect(h.answers.pending(h.runtime, target)?.promptId).toBe(promptId)
|
||||
})
|
||||
|
||||
it('refuses before any key when the caller’s own check fails', async () => {
|
||||
const h = harness()
|
||||
const { promptId } = h.answers.pending(h.runtime, target)!
|
||||
await expect(
|
||||
h.answers.answer(h.runtime, target, {
|
||||
promptId,
|
||||
optionId: 'allow_once',
|
||||
signal: new AbortController().signal,
|
||||
beforeWrite: async () => {
|
||||
throw new Error('agent_not_in_foreground')
|
||||
}
|
||||
})
|
||||
).resolves.toEqual({ outcome: 'refused', reason: 'agent_not_reachable' })
|
||||
expect(h.keys).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,52 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import './orca-runtime-tests/runtime-test-module-registration.spec'
|
||||
import { OrcaRuntimeService } from './orca-runtime'
|
||||
import {
|
||||
HEADLESS_LEAF_ID,
|
||||
TEST_WORKTREE_ID,
|
||||
makeRuntimeStoreWithWorkspaceSession,
|
||||
makeWorkspaceSessionWithHeadlessTerminal
|
||||
} from './orca-runtime-tests/orca-runtime-test-fixtures.spec'
|
||||
import { UpdatePaneLayout } from '../../shared/rpc-contract/session-tabs-schemas-params'
|
||||
|
||||
describe('remote terminal chat ownership', () => {
|
||||
it('preserves, changes, and explicitly clears the owner across the RPC and restart boundary', async () => {
|
||||
const { runtimeStore, getSession } = makeRuntimeStoreWithWorkspaceSession(
|
||||
makeWorkspaceSessionWithHeadlessTerminal()
|
||||
)
|
||||
const runtime = new OrcaRuntimeService(runtimeStore)
|
||||
const update = (owner: string | null | undefined) => {
|
||||
const params = UpdatePaneLayout.parse({
|
||||
worktree: `id:${TEST_WORKTREE_ID}`,
|
||||
tabId: 'host-tab',
|
||||
root: getSession().terminalLayoutsByTabId['host-tab']!.root,
|
||||
expandedLeafId: null,
|
||||
...(owner !== undefined ? { chatLeafId: owner } : {})
|
||||
})
|
||||
return runtime.updateMobileSessionPaneLayout(params.worktree, {
|
||||
...params,
|
||||
expandedLeafId: params.expandedLeafId ?? null
|
||||
})
|
||||
}
|
||||
await update(HEADLESS_LEAF_ID)
|
||||
expect(getSession().terminalLayoutsByTabId['host-tab']?.chatLeafId).toBe(HEADLESS_LEAF_ID)
|
||||
// An older client changing geometry must not clear the owner.
|
||||
await update(undefined)
|
||||
expect(getSession().terminalLayoutsByTabId['host-tab']?.chatLeafId).toBe(HEADLESS_LEAF_ID)
|
||||
runtime['mobileSessionTabsByWorktree'].delete(TEST_WORKTREE_ID)
|
||||
runtime['hydrateHeadlessMobileSessionTabsFromWorkspaceSession'](TEST_WORKTREE_ID)
|
||||
const rehydrated = await runtime.listMobileSessionTabs(`id:${TEST_WORKTREE_ID}`)
|
||||
const surface = rehydrated.tabs.find(
|
||||
(tab) => tab.type === 'terminal' && tab.parentTabId === 'host-tab'
|
||||
)
|
||||
expect(surface?.type === 'terminal' && surface.parentLayout?.chatLeafId).toBe(HEADLESS_LEAF_ID)
|
||||
await update(null)
|
||||
expect(getSession().terminalLayoutsByTabId['host-tab']?.chatLeafId).toBeUndefined()
|
||||
const cleared = await runtime.listMobileSessionTabs(`id:${TEST_WORKTREE_ID}`)
|
||||
expect(
|
||||
cleared.tabs
|
||||
.filter((tab) => tab.type === 'terminal')
|
||||
.every((tab) => !tab.parentLayout?.chatLeafId)
|
||||
).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -7,7 +7,7 @@ import { makePaneKey } from '../../shared/workspace-layout/stable-pane-id'
|
||||
import { Store } from '../persistence/loading-store/store'
|
||||
import { ProfileStateSqliteAuthority } from '../persistence/profile-state/profile-state-sqlite-authority'
|
||||
import { wirePtyIpcSession } from '../ipc/pty/delivery/wire-session'
|
||||
import { SYNTHETIC_KILL_EXIT_DUPLICATE_WINDOW_MS } from '../ipc/pty/delivery/visibility-state'
|
||||
import '../ipc/pty/delivery/visibility-state'
|
||||
import { bindProviderListeners } from '../ipc/pty/provider/bind-listeners'
|
||||
import {
|
||||
stopRendererOwnedPty,
|
||||
@@ -149,24 +149,6 @@ describe('intentional stops keep the pane through the exit', () => {
|
||||
])
|
||||
})
|
||||
|
||||
it('keeps the tab and its wake binding when the renderer hibernates the pane', async () => {
|
||||
const harness = createHarness()
|
||||
|
||||
await stopRendererOwnedPty(harness.deps, { id: PTY_ID, keepHistory: true })
|
||||
|
||||
expect(harness.tabIds()).toEqual([TAB_ID])
|
||||
expect(harness.boundPtyId()).toBe(PTY_ID)
|
||||
expect(harness.rendererExits()).toEqual([
|
||||
{
|
||||
id: PTY_ID,
|
||||
code: 0,
|
||||
incarnationId: INCARNATION_ID,
|
||||
hostId: 'local',
|
||||
preserveRendererBinding: true
|
||||
}
|
||||
])
|
||||
})
|
||||
|
||||
it('keeps a typed pane that a restart replaces, and binds the replacement', async () => {
|
||||
const harness = createHarness()
|
||||
harness.runtime.terminalRunFacts.recordSpawnCommit({
|
||||
@@ -204,48 +186,6 @@ describe('intentional stops keep the pane through the exit', () => {
|
||||
])
|
||||
})
|
||||
|
||||
it('labels the exit for both a sleep and a restart that stop the same process', async () => {
|
||||
const harness = createHarness()
|
||||
const settleSleep = harness.runtime.intentionalPtyStops.mark(
|
||||
PTY_ID,
|
||||
'reversible',
|
||||
INCARNATION_ID
|
||||
)
|
||||
|
||||
await stopReplacedPanePty(harness.deps, PTY_ID)
|
||||
settleSleep(true)
|
||||
|
||||
expect(harness.boundPtyId()).toBe(PTY_ID)
|
||||
expect(harness.rendererExits()).toEqual([
|
||||
{
|
||||
id: PTY_ID,
|
||||
code: 0,
|
||||
incarnationId: INCARNATION_ID,
|
||||
hostId: 'local',
|
||||
preserveRendererBinding: true,
|
||||
replacedByRestart: true
|
||||
}
|
||||
])
|
||||
})
|
||||
|
||||
it('keeps the pane when main restarts the daemon under it', () => {
|
||||
const harness = createHarness()
|
||||
|
||||
harness.emitDaemonRestartExit(true)
|
||||
|
||||
expect(harness.tabIds()).toEqual([TAB_ID])
|
||||
expect(harness.boundPtyId()).toBe(PTY_ID)
|
||||
expect(harness.rendererExits()).toEqual([
|
||||
{
|
||||
id: PTY_ID,
|
||||
code: -1,
|
||||
incarnationId: INCARNATION_ID,
|
||||
hostId: 'local',
|
||||
replacedByRestart: true
|
||||
}
|
||||
])
|
||||
})
|
||||
|
||||
it('retires the pane on a daemon-wide exit that no restart claims', () => {
|
||||
const harness = createHarness()
|
||||
|
||||
@@ -308,20 +248,6 @@ describe('intentional stops keep the pane through the exit', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('forgets the stop once the duplicate-exit window after it closes', async () => {
|
||||
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] })
|
||||
const harness = createHarness({ lateProviderExit: true })
|
||||
await stopRendererOwnedPty(harness.deps, { id: PTY_ID, keepHistory: true })
|
||||
|
||||
vi.advanceTimersByTime(SYNTHETIC_KILL_EXIT_DUPLICATE_WINDOW_MS - 1)
|
||||
expect(harness.runtime.intentionalPtyStops.claimExit(PTY_ID, INCARNATION_ID)).toEqual([
|
||||
'reversible'
|
||||
])
|
||||
vi.advanceTimersByTime(1)
|
||||
|
||||
expect(harness.runtime.intentionalPtyStops.claimExit(PTY_ID, INCARNATION_ID)).toEqual([])
|
||||
})
|
||||
|
||||
it('keeps the tab and its wake binding when the runtime puts the worktree to sleep', async () => {
|
||||
const harness = createHarness({ folder: true })
|
||||
const inventories = [[{ id: PTY_ID, worktreeId: WORKTREE_ID, cwd: WORKTREE_PATH, title: 'a' }]]
|
||||
|
||||
@@ -1,15 +1,10 @@
|
||||
import type { TerminalWaiter } from './terminal/wait/terminal-waiter'
|
||||
import type {
|
||||
RuntimeTerminalWait,
|
||||
RuntimeTerminalAgentStatus
|
||||
} from '../../shared/runtime-terminal-contracts'
|
||||
import { RuntimeMachineName } from './hosts/platform-probe/runtime-machine-name'
|
||||
import { OrcaRuntimeService } from './orca-runtime'
|
||||
import { makeTuiIdlePty } from './terminal/wait/tui-idle-wait-test-harness'
|
||||
import './hosts/platform-probe/runtime-machine-name'
|
||||
import './orca-runtime'
|
||||
import './terminal/wait/tui-idle-wait-test-harness'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { makeAgentStatusStoreWiring } from './agent-status-store-wiring.test-fixture'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { TERMINAL_INTERACTIVE_WAIT_PROBE_TIMEOUT_MS } from './terminal/wait/terminal-interactive-probe-budget'
|
||||
import {
|
||||
createTranscriptPane,
|
||||
@@ -36,8 +31,6 @@ describe('Interactive wait visibility and terminal admission', () => {
|
||||
return readFileSync(join(__dirname, '__fixtures__', `${name}.txt`), 'utf8')
|
||||
}
|
||||
const CURSOR_APPROVAL = fixture('cursor-agent-approval-prompt')
|
||||
const CURSOR_LONG_TOOL_CALL = fixture('cursor-agent-long-tool-call')
|
||||
const CURSOR_IDLE = fixture('cursor-agent-idle-after-approval')
|
||||
|
||||
// Claude Code 2.1.234's own trust screen, which the runtime already matched by shape.
|
||||
const CLAUDE_TRUST = [
|
||||
@@ -67,23 +60,6 @@ describe('Interactive wait visibility and terminal admission', () => {
|
||||
|
||||
describe('terminal interactive-wait visibility (STA-4513, STA-3714)', () => {
|
||||
describe('cursor-agent approval menu, the case no hook reports', () => {
|
||||
it('names the pending approval on the pane a coordinator inspects', async () => {
|
||||
const { runtime, handle } = await createPane({
|
||||
paneTitle: CURSOR_TITLE,
|
||||
foregroundProcess: 'cursor-agent',
|
||||
data: CURSOR_APPROVAL
|
||||
})
|
||||
|
||||
await expect(runtime.showTerminal(handle)).resolves.toMatchObject({
|
||||
agentWait: { source: 'prompt-text', reason: 'agent-approval-prompt' }
|
||||
})
|
||||
await expect(runtime.getTerminalInteractiveWait(handle)).resolves.toMatchObject({
|
||||
source: 'prompt-text',
|
||||
reason: 'agent-approval-prompt',
|
||||
since: expect.any(Number)
|
||||
})
|
||||
})
|
||||
|
||||
it('refuses a guarded send and a dispatch preamble while the approval is up', async () => {
|
||||
// Refuse both injection paths so coordinator preambles cannot land in approval dialogs (STA-2631).
|
||||
const { runtime, handle } = await createPane({
|
||||
@@ -104,18 +80,6 @@ describe('Interactive wait visibility and terminal admission', () => {
|
||||
).rejects.toThrow('agent_prompt_blocked')
|
||||
})
|
||||
|
||||
it('lets a dispatch preamble through once the same lane is working', async () => {
|
||||
const { runtime, handle } = await createPane({
|
||||
paneTitle: CURSOR_TITLE,
|
||||
foregroundProcess: 'cursor-agent',
|
||||
data: CURSOR_LONG_TOOL_CALL
|
||||
})
|
||||
|
||||
await expect(
|
||||
assertTerminalAgentSendable({ runtime, handle, assertWritable: () => {} })
|
||||
).resolves.toBeUndefined()
|
||||
})
|
||||
|
||||
it('refuses to call the lane idle while the approval is unanswered', async () => {
|
||||
const { runtime, handle } = await createPane({
|
||||
paneTitle: CURSOR_TITLE,
|
||||
@@ -128,39 +92,6 @@ describe('Interactive wait visibility and terminal admission', () => {
|
||||
).resolves.toMatchObject({ satisfied: false, blockedReason: 'agent-approval-prompt' })
|
||||
})
|
||||
|
||||
it('reports no wait for the same agent inside a long tool call', async () => {
|
||||
// The control: identical vendor, identical spinner title, a real `sleep 60` running.
|
||||
const { runtime, handle } = await createPane({
|
||||
paneTitle: CURSOR_TITLE,
|
||||
foregroundProcess: 'cursor-agent',
|
||||
data: CURSOR_LONG_TOOL_CALL
|
||||
})
|
||||
|
||||
await expect(runtime.getTerminalInteractiveWait(handle)).resolves.toBeNull()
|
||||
await expect(runtime.showTerminal(handle)).resolves.toMatchObject({ agentWait: null })
|
||||
})
|
||||
|
||||
it('reports no wait once the agent is idle again', async () => {
|
||||
const { runtime, handle } = await createPane({
|
||||
paneTitle: CURSOR_TITLE,
|
||||
foregroundProcess: 'cursor-agent',
|
||||
data: CURSOR_IDLE
|
||||
})
|
||||
|
||||
await expect(runtime.getTerminalInteractiveWait(handle)).resolves.toBeNull()
|
||||
})
|
||||
|
||||
it('treats an answered menu still in scrollback as scrollback', async () => {
|
||||
// A live dialog must still own the screen bottom; stale menu text can remain in scrollback.
|
||||
const { runtime, handle } = await createPane({
|
||||
paneTitle: CURSOR_TITLE,
|
||||
foregroundProcess: 'cursor-agent',
|
||||
data: `${CURSOR_APPROVAL}\n${CURSOR_IDLE}`
|
||||
})
|
||||
|
||||
await expect(runtime.getTerminalInteractiveWait(handle)).resolves.toBeNull()
|
||||
})
|
||||
|
||||
it('treats an answered menu followed by any later output as scrollback', async () => {
|
||||
// Any output after a menu can disprove it; matching only Cursor's follow-up text would block healthy prompt injection.
|
||||
const { runtime, handle } = await createPane({
|
||||
@@ -178,80 +109,9 @@ describe('Interactive wait visibility and terminal admission', () => {
|
||||
|
||||
await expect(runtime.getTerminalInteractiveWait(handle)).resolves.toBeNull()
|
||||
})
|
||||
|
||||
it('is not anchored by the agent narrating a choice after the menu', async () => {
|
||||
// Independent marker matches can let later prose revive an answered menu at screen bottom.
|
||||
const { runtime, handle } = await createPane({
|
||||
paneTitle: CURSOR_TITLE,
|
||||
foregroundProcess: 'cursor-agent',
|
||||
data: `${CURSOR_APPROVAL}\nApproved. Next time I will suggest Run Everything instead.\n`
|
||||
})
|
||||
|
||||
await expect(runtime.getTerminalInteractiveWait(handle)).resolves.toBeNull()
|
||||
})
|
||||
|
||||
it('is not anchored by the agent narrating a choice before the menu', async () => {
|
||||
const { runtime, handle } = await createPane({
|
||||
paneTitle: CURSOR_TITLE,
|
||||
foregroundProcess: 'cursor-agent',
|
||||
data: `You can pick Run Everything or Run (once) if you prefer.\nStill thinking.\n`
|
||||
})
|
||||
|
||||
await expect(runtime.getTerminalInteractiveWait(handle)).resolves.toBeNull()
|
||||
})
|
||||
|
||||
it('requires the dialog to be the last thing on the screen', async () => {
|
||||
// One line of slack revives answered menus; live captures end exactly at the dialog.
|
||||
const { runtime, handle } = await createPane({
|
||||
paneTitle: CURSOR_TITLE,
|
||||
foregroundProcess: 'cursor-agent',
|
||||
data: `${CURSOR_APPROVAL}\n Auto · 6.1%\n`
|
||||
})
|
||||
|
||||
await expect(runtime.getTerminalInteractiveWait(handle)).resolves.toBeNull()
|
||||
})
|
||||
|
||||
it('reports the same wait for a pane whose PTY is not local', async () => {
|
||||
// The verdict is derived from retained tail and title state, so an SSH or WSL pane
|
||||
// reaches it the same way a local one does.
|
||||
const { runtime, handle } = await createPane({
|
||||
paneTitle: CURSOR_TITLE,
|
||||
foregroundProcess: 'cursor-agent',
|
||||
data: CURSOR_APPROVAL,
|
||||
connectionId: 'ssh:build-host'
|
||||
})
|
||||
|
||||
await expect(runtime.getTerminalInteractiveWait(handle)).resolves.toMatchObject({
|
||||
source: 'prompt-text',
|
||||
reason: 'agent-approval-prompt'
|
||||
})
|
||||
})
|
||||
|
||||
it('ignores a partial menu that names no decision keys', async () => {
|
||||
const { runtime, handle } = await createPane({
|
||||
paneTitle: CURSOR_TITLE,
|
||||
foregroundProcess: 'cursor-agent',
|
||||
data: 'The agent asked: Run this command? I said yes and it worked.\n'
|
||||
})
|
||||
|
||||
await expect(runtime.getTerminalInteractiveWait(handle)).resolves.toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('prompts the runtime already matched but never surfaced', () => {
|
||||
it('surfaces a startup trust screen on the pane, not only on terminal wait', async () => {
|
||||
// A pane on its trust screen still wears Orca's tab title; the agent has set none.
|
||||
const { runtime, handle } = await createPane({
|
||||
paneTitle: 'sta4513-claude',
|
||||
foregroundProcess: 'claude',
|
||||
data: CLAUDE_TRUST
|
||||
})
|
||||
|
||||
await expect(runtime.showTerminal(handle)).resolves.toMatchObject({
|
||||
agentWait: { source: 'prompt-text', reason: 'agent-trust-workspace' }
|
||||
})
|
||||
})
|
||||
|
||||
it('still lets a live working title clear a stale startup prompt', async () => {
|
||||
// Startup prompt text lacks dismissal markers; a live non-permission title proves staleness for both status and send guards.
|
||||
const { runtime, handle } = await createPane({
|
||||
@@ -281,16 +141,6 @@ describe('Interactive wait visibility and terminal admission', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('reports no wait for a hook-reported working turn', async () => {
|
||||
const { runtime, handle } = await createPane({
|
||||
paneTitle: '✻ Claude Code',
|
||||
foregroundProcess: 'claude',
|
||||
data: agentStatusOsc('working')
|
||||
})
|
||||
|
||||
await expect(runtime.getTerminalInteractiveWait(handle)).resolves.toBeNull()
|
||||
})
|
||||
|
||||
it('drops a retained permission row once the agent stops owning the pane', async () => {
|
||||
// Shell titles may match no rule; a fresh hook row could otherwise report a dead agent waiting for 30 minutes.
|
||||
const { runtime, handle } = await createPane({
|
||||
@@ -301,16 +151,6 @@ describe('Interactive wait visibility and terminal admission', () => {
|
||||
|
||||
await expect(runtime.getTerminalInteractiveWait(handle)).resolves.toBeNull()
|
||||
})
|
||||
|
||||
it('drops a retained permission row once a shell owns the pane', async () => {
|
||||
const { runtime, handle } = await createPane({
|
||||
paneTitle: 'zsh',
|
||||
foregroundProcess: 'zsh',
|
||||
data: agentStatusOsc('blocked')
|
||||
})
|
||||
|
||||
await expect(runtime.getTerminalInteractiveWait(handle)).resolves.toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
it('still reports a live dialog restored from terminal history', async () => {
|
||||
@@ -384,32 +224,6 @@ describe('Interactive wait visibility and terminal admission', () => {
|
||||
}
|
||||
}, 20_000)
|
||||
|
||||
it('accepts a menu whose keys are rendered as glyphs', async () => {
|
||||
const { runtime, handle } = await createPane({
|
||||
paneTitle: CURSOR_TITLE,
|
||||
foregroundProcess: 'cursor-agent',
|
||||
data: [
|
||||
'Run this command?\n',
|
||||
' → Run (once) (\u21b5)\n',
|
||||
' Run Everything (\u21e7\u21b9)\n'
|
||||
].join('')
|
||||
})
|
||||
|
||||
await expect(runtime.getTerminalInteractiveWait(handle)).resolves.toMatchObject({
|
||||
reason: 'agent-approval-prompt'
|
||||
})
|
||||
})
|
||||
|
||||
it('still rejects prose that merely ends in parentheses', async () => {
|
||||
const { runtime, handle } = await createPane({
|
||||
paneTitle: CURSOR_TITLE,
|
||||
foregroundProcess: 'cursor-agent',
|
||||
data: `${CURSOR_APPROVAL}\nNext time I will suggest Run Everything (as before)\n`
|
||||
})
|
||||
|
||||
await expect(runtime.getTerminalInteractiveWait(handle)).resolves.toBeNull()
|
||||
})
|
||||
|
||||
it('leaves the wait unevaluated when the foreground probe wedges', async () => {
|
||||
// Bound remote PTY probes; a wedged host must leave wait unknown without stalling showTerminal.
|
||||
const { runtime, handle } = await createPane({
|
||||
@@ -434,18 +248,6 @@ describe('Interactive wait visibility and terminal admission', () => {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
}, 15_000)
|
||||
|
||||
it('answers undefined rather than "not waiting" for a pane it cannot read', async () => {
|
||||
const { runtime } = await createPane({
|
||||
paneTitle: CURSOR_TITLE,
|
||||
foregroundProcess: 'cursor-agent',
|
||||
data: CURSOR_APPROVAL
|
||||
})
|
||||
|
||||
await expect(
|
||||
runtime.getTerminalInteractiveWait('term_does_not_exist')
|
||||
).resolves.toBeUndefined()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -472,224 +274,3 @@ describe('Screen rule readiness scope', () => {
|
||||
}, 20_000)
|
||||
})
|
||||
})
|
||||
|
||||
/** Stands in for a mailbox with work pending, which these timer mechanics take as given. */
|
||||
function withPendingDelivery(runtime: OrcaRuntimeService): OrcaRuntimeService {
|
||||
runtime['getOrchestrationMailboxTerminal'] = (ptyId) => ({
|
||||
ptyId,
|
||||
handle: 'term_pending',
|
||||
paneKeys: [],
|
||||
lastAgentStatus: 'idle',
|
||||
lastAgentStatusObservedLive: true
|
||||
})
|
||||
vi.spyOn(runtime['orchestrationMailboxNotifications'], 'hasDeliverableMail').mockReturnValue(true)
|
||||
return runtime
|
||||
}
|
||||
|
||||
describe('terminal wait ownership', () => {
|
||||
beforeEach(() => {
|
||||
vi.spyOn(RuntimeMachineName.prototype, 'start').mockImplementation(() => {})
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(10_000)
|
||||
})
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
function pendingStatus() {
|
||||
const { promise, resolve } = Promise.withResolvers<RuntimeTerminalAgentStatus>()
|
||||
return { status: promise, finish: resolve }
|
||||
}
|
||||
|
||||
function interactiveWait(getStatus: (handle: string) => Promise<RuntimeTerminalAgentStatus>) {
|
||||
const runtime = new OrcaRuntimeService()
|
||||
runtime['getTerminalAgentStatusPtyId'] = () => 'pty-1'
|
||||
runtime['getTerminalWaitPermissionInputs'] = () => ({
|
||||
terminal: {
|
||||
waitText: '',
|
||||
waitBlockedAt: null,
|
||||
title: null,
|
||||
titleStatus: null,
|
||||
titleStatusIsLive: false
|
||||
},
|
||||
lifecycle: null
|
||||
})
|
||||
runtime['getFreshExplicitAgentStatusForHandle'] = () => ({
|
||||
status: 'permission',
|
||||
updatedAt: 42,
|
||||
stateStartedAt: 42
|
||||
})
|
||||
runtime['resolveAuthoritativeTerminalWaitPermission'] = () => null
|
||||
runtime.getTerminalAgentStatus = getStatus
|
||||
return runtime
|
||||
}
|
||||
|
||||
it('shares permission probes per PTY and releases the completed probe', async () => {
|
||||
const pending = pendingStatus()
|
||||
const getStatus = vi.fn(() => pending.status)
|
||||
const wait = interactiveWait(getStatus)
|
||||
const first = wait.getTerminalInteractiveWait('handle-1')
|
||||
const second = wait.getTerminalInteractiveWait('handle-2')
|
||||
expect(getStatus).toHaveBeenCalledOnce()
|
||||
pending.finish({ handle: 'handle-1', isRunningAgent: true, status: 'permission' })
|
||||
expect(await first).toEqual({ source: 'hook', since: 42 })
|
||||
expect(await second).toEqual({ source: 'hook', since: 42 })
|
||||
await wait.getTerminalInteractiveWait('handle-3')
|
||||
expect(getStatus).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('keeps a timed-out permission probe in flight until its host responds', async () => {
|
||||
const pending = pendingStatus()
|
||||
const getStatus = vi.fn(() => pending.status)
|
||||
const wait = interactiveWait(getStatus)
|
||||
const first = wait.getTerminalInteractiveWait('handle-1')
|
||||
await vi.advanceTimersByTimeAsync(2000)
|
||||
expect(await first).toBeUndefined()
|
||||
const second = wait.getTerminalInteractiveWait('handle-2')
|
||||
expect(getStatus).toHaveBeenCalledOnce()
|
||||
pending.finish({ handle: 'handle-1', isRunningAgent: true, status: 'permission' })
|
||||
expect(await second).toEqual({ source: 'hook', since: 42 })
|
||||
})
|
||||
|
||||
it('isolates permission probes between runtimes with the same PTY id', async () => {
|
||||
const pending = pendingStatus()
|
||||
const getStatus = vi.fn(() => pending.status)
|
||||
const first = interactiveWait(getStatus).getTerminalInteractiveWait('handle-1')
|
||||
const second = interactiveWait(getStatus).getTerminalInteractiveWait('handle-1')
|
||||
expect(getStatus).toHaveBeenCalledTimes(2)
|
||||
pending.finish({ handle: 'handle-1', isRunningAgent: false, status: null })
|
||||
expect(await first).toBeNull()
|
||||
expect(await second).toBeNull()
|
||||
})
|
||||
|
||||
it('rechecks current terminal evidence before delivery and coalesces repeated refusals', async () => {
|
||||
const pty = makeTuiIdlePty({
|
||||
lastOutputAt: Date.now(),
|
||||
lastAgentStatus: 'idle',
|
||||
lastOscTitle: 'Codex'
|
||||
})
|
||||
const deliver = vi.fn()
|
||||
const runtime = withPendingDelivery(new OrcaRuntimeService())
|
||||
const ownedPty = runtime['terminalIdentity'].createPty(pty)
|
||||
runtime['deliverPendingMessagesForPty'] = deliver
|
||||
expect(runtime['checkDeliverySettledAndArmRecheck'](pty.ptyId)).toBe(false)
|
||||
expect(runtime['checkDeliverySettledAndArmRecheck'](pty.ptyId)).toBe(false)
|
||||
expect(vi.getTimerCount()).toBe(1)
|
||||
ownedPty.lastOscTitle = null
|
||||
await vi.advanceTimersByTimeAsync(3050)
|
||||
expect(deliver).toHaveBeenCalledExactlyOnceWith(pty.ptyId)
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
})
|
||||
|
||||
it('cancels only the delivery recheck belonging to its runtime', async () => {
|
||||
const pty = makeTuiIdlePty({
|
||||
lastOutputAt: Date.now(),
|
||||
lastAgentStatus: 'idle',
|
||||
lastOscTitle: 'Codex'
|
||||
})
|
||||
const first = withPendingDelivery(new OrcaRuntimeService())
|
||||
const second = withPendingDelivery(new OrcaRuntimeService())
|
||||
first['terminalIdentity'].createPty(pty)
|
||||
second['terminalIdentity'].createPty(pty)
|
||||
first['checkDeliverySettledAndArmRecheck'](pty.ptyId)
|
||||
second['checkDeliverySettledAndArmRecheck'](pty.ptyId)
|
||||
expect(vi.getTimerCount()).toBe(2)
|
||||
first['clearDeliveryRecheck'](pty.ptyId)
|
||||
expect(vi.getTimerCount()).toBe(1)
|
||||
second['clearDeliveryRecheck'](pty.ptyId)
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
})
|
||||
|
||||
it('clears a pending blocked scan without cancelling another runtime scan', async () => {
|
||||
const pty = makeTuiIdlePty({ waitBlockedAt: null })
|
||||
const getPty = vi.fn(() => pty)
|
||||
const first = new OrcaRuntimeService()
|
||||
const second = new OrcaRuntimeService()
|
||||
vi.spyOn(first['ptysById'], 'get').mockImplementation(getPty)
|
||||
vi.spyOn(second['ptysById'], 'get').mockImplementation(getPty)
|
||||
first['scheduleWaitBlockedCheck']('pty-1', 'output', 10_000)
|
||||
second['scheduleWaitBlockedCheck']('pty-1', 'output', 10_000)
|
||||
first['scheduleWaitBlockedCheck']('pty-1', 'more', 10_001)
|
||||
second['scheduleWaitBlockedCheck']('pty-1', 'more', 10_001)
|
||||
expect(vi.getTimerCount()).toBe(2)
|
||||
first['clearWaitBlockedCheckState']('pty-1')
|
||||
expect(vi.getTimerCount()).toBe(1)
|
||||
await vi.advanceTimersByTimeAsync(50)
|
||||
expect(getPty).toHaveBeenCalledTimes(3)
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
})
|
||||
|
||||
it('routes settlement through the current runtime verdict and resolver', async () => {
|
||||
const runtime = new OrcaRuntimeService()
|
||||
const pending = Promise.withResolvers<RuntimeTerminalWait>()
|
||||
const waiter: TerminalWaiter = {
|
||||
handle: 'handle-1',
|
||||
ptyId: 'pty-1',
|
||||
condition: 'tui-idle',
|
||||
resolve: pending.resolve,
|
||||
reject: pending.reject,
|
||||
timeout: null,
|
||||
cancelIdlePoll: null,
|
||||
abortCleanup: null
|
||||
}
|
||||
runtime['terminalWaiters'].add(waiter)
|
||||
const verdict = vi.fn<OrcaRuntimeService['evaluateTuiIdleForPty']>(() => ({
|
||||
kind: 'ready-strong'
|
||||
}))
|
||||
runtime['evaluateTuiIdleForPty'] = verdict
|
||||
const original = runtime['resolveWaiter']
|
||||
const resolve = vi.fn<OrcaRuntimeService['resolveWaiter']>((...args) =>
|
||||
original.call(runtime, ...args)
|
||||
)
|
||||
runtime['resolveWaiter'] = resolve
|
||||
const pty = makeTuiIdlePty({ lastAgentStatus: 'working' })
|
||||
runtime['resolvePtyTuiIdleWaiters'](pty, 'pty-1')
|
||||
expect(verdict).toHaveBeenCalledExactlyOnceWith(pty)
|
||||
expect(resolve).toHaveBeenCalledOnce()
|
||||
await expect(pending.promise).resolves.toMatchObject({ satisfied: true, condition: 'tui-idle' })
|
||||
})
|
||||
|
||||
it('keeps an absent launcher target as a rejected promise', async () => {
|
||||
const runtime = new OrcaRuntimeService()
|
||||
const result = runtime.resolveOrchestrationAgentLauncherForTarget('custom', {})
|
||||
expect(result).toBeInstanceOf(Promise)
|
||||
await expect(result).rejects.toThrow()
|
||||
})
|
||||
|
||||
it('reads the launcher resolver before forwarding an absent target', async () => {
|
||||
const runtime = new OrcaRuntimeService()
|
||||
const events: string[] = []
|
||||
const rejection = new Error('original_resolver_rejection')
|
||||
const resolve = vi.fn<OrcaRuntimeService['resolveTerminalWorkspaceLaunchScope']>(
|
||||
async function (this: OrcaRuntimeService) {
|
||||
events.push('original-call')
|
||||
expect(this).toBe(runtime)
|
||||
throw rejection
|
||||
}
|
||||
)
|
||||
const replacement = vi.fn()
|
||||
Object.defineProperty(runtime, 'resolveTerminalWorkspaceLaunchScope', {
|
||||
configurable: true,
|
||||
get() {
|
||||
events.push('callee')
|
||||
return resolve
|
||||
}
|
||||
})
|
||||
const target = {
|
||||
get worktree() {
|
||||
events.push('argument')
|
||||
Object.defineProperty(runtime, 'resolveTerminalWorkspaceLaunchScope', {
|
||||
value: replacement
|
||||
})
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
const result = runtime.resolveOrchestrationAgentLauncherForTarget('custom', target)
|
||||
expect(result).toBeInstanceOf(Promise)
|
||||
await expect(result).rejects.toBe(rejection)
|
||||
expect(events).toEqual(['callee', 'argument', 'original-call'])
|
||||
expect(resolve).toHaveBeenCalledExactlyOnceWith(undefined)
|
||||
expect(replacement).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -59,27 +59,6 @@ describe('terminal.recoverPane liveness gate', () => {
|
||||
expect(createTerminal).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('refuses to recreate a shell for an expired lease the host proved is still live', async () => {
|
||||
// Successful relay reattach followed by binding refusal records live then expired; another spawn would duplicate a proven-live agent.
|
||||
const tabId = 'tab-proved-live'
|
||||
const ptyId = 'ssh:ssh-target@@pty-11'
|
||||
const runtime = createRuntimeWithSshLease(ptyId, tabId)
|
||||
const paneKey = makePaneKey(tabId, HEADLESS_LEAF_ID)
|
||||
registerBoundPty(runtime, ptyId, TEST_WORKTREE_ID, 'ssh-target', {
|
||||
tabId,
|
||||
leafId: HEADLESS_LEAF_ID
|
||||
})
|
||||
const handle = runtime.resolveTerminalPane(paneKey, TEST_WORKTREE_ID).handle
|
||||
runtime.onPtyExit(ptyId, -1)
|
||||
runtime.markPtyLivenessLive(ptyId)
|
||||
const createTerminal = vi.spyOn(runtime, 'createTerminal')
|
||||
|
||||
await expect(runtime.recoverTerminalPane(paneKey, TEST_WORKTREE_ID, handle)).rejects.toThrow(
|
||||
'terminal_not_recoverable'
|
||||
)
|
||||
expect(createTerminal).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('refuses to recreate a shell for an expired lease whose PTY liveness is unverifiable', async () => {
|
||||
// An unconfirmed SSH -1 exit plus expired lease proves no process exit and cannot authorize replacement.
|
||||
const tabId = 'tab-unverifiable-gate'
|
||||
@@ -184,38 +163,6 @@ function replacementTerminal(tabId: string): RuntimeTerminalCreate {
|
||||
describe('terminal pane recovery uses the current Root state', () => {
|
||||
afterEach(() => vi.restoreAllMocks())
|
||||
|
||||
it('reads the replacement pending map before connected-pane and lease checks', async () => {
|
||||
const tabId = 'tab-current-recovery-map'
|
||||
const { runtime, paneKey, handle, recoveryKey } = createRecoveryPane(tabId)
|
||||
const original = runtime.pendingRecoveries()
|
||||
original.set(
|
||||
recoveryKey,
|
||||
Promise.resolve({
|
||||
handle: 'term-old-map',
|
||||
tabId,
|
||||
leafId: HEADLESS_LEAF_ID,
|
||||
ptyId: null
|
||||
})
|
||||
)
|
||||
const pending = Promise.resolve({
|
||||
handle: 'term-current-map',
|
||||
tabId,
|
||||
leafId: HEADLESS_LEAF_ID,
|
||||
ptyId: null
|
||||
})
|
||||
const current = new Map([[recoveryKey, pending]])
|
||||
runtime.usePendingRecoveries(current)
|
||||
const create = vi.spyOn(runtime, 'createTerminal')
|
||||
|
||||
await expect(
|
||||
runtime.recoverTerminalPane(paneKey, TEST_WORKTREE_ID, handle)
|
||||
).resolves.toMatchObject({ handle: 'term-current-map' })
|
||||
expect(runtime.pendingRecoveries()).toBe(current)
|
||||
expect(current.get(recoveryKey)).toBe(pending)
|
||||
expect(original.has(recoveryKey)).toBe(true)
|
||||
expect(create).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('shares a rejected recovery and removes it before a later retry', async () => {
|
||||
const tabId = 'tab-rejected-recovery'
|
||||
const { runtime, paneKey, ptyId, handle, recoveryKey } = createRecoveryPane(tabId)
|
||||
@@ -243,37 +190,4 @@ describe('terminal pane recovery uses the current Root state', () => {
|
||||
).resolves.toMatchObject({ handle: 'term-replacement' })
|
||||
expect(create).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('keeps a newer pending entry when an earlier recovery settles', async () => {
|
||||
const tabId = 'tab-newer-recovery'
|
||||
const { runtime, paneKey, ptyId, handle, recoveryKey } = createRecoveryPane(tabId)
|
||||
runtime.onPtyExit(ptyId, -1, undefined, { hostExitConfirmed: true })
|
||||
const creation = deferred<RuntimeTerminalCreate>()
|
||||
const create = vi.spyOn(runtime, 'createTerminal').mockImplementation(function (
|
||||
this: PaneRecoveryRuntime,
|
||||
selector,
|
||||
options
|
||||
) {
|
||||
expect(this).toBe(runtime)
|
||||
expect(arguments.length).toBe(2)
|
||||
expect(selector).toBe(`id:${TEST_WORKTREE_ID}`)
|
||||
expect(options).toEqual({ tabId, leafId: HEADLESS_LEAF_ID, focus: false, reopensPane: true })
|
||||
return creation.promise
|
||||
})
|
||||
const first = runtime.recoverTerminalPane(paneKey, TEST_WORKTREE_ID, handle)
|
||||
const newer = Promise.resolve({
|
||||
handle: 'term-newer',
|
||||
tabId,
|
||||
leafId: HEADLESS_LEAF_ID,
|
||||
ptyId: null
|
||||
})
|
||||
const current = new Map([[recoveryKey, newer]])
|
||||
runtime.usePendingRecoveries(current)
|
||||
creation.resolve(replacementTerminal(tabId))
|
||||
|
||||
await expect(first).resolves.toMatchObject({ handle: 'term-replacement' })
|
||||
expect(create).toHaveBeenCalledOnce()
|
||||
expect(runtime.pendingRecoveries()).toBe(current)
|
||||
expect(current.get(recoveryKey)).toBe(newer)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -5,7 +5,6 @@ import { HeadlessEmulator } from '../daemon/emulation/headless-emulator'
|
||||
import {
|
||||
_resetHiddenRendererPtyDeliveryGateForTest,
|
||||
markHiddenRendererPty,
|
||||
setRendererPtyDeliveryInterest,
|
||||
unmarkHiddenRendererPty
|
||||
} from '../ipc/pty/delivery/pty-hidden-delivery-gate'
|
||||
import {
|
||||
@@ -116,116 +115,7 @@ afterEach(() => {
|
||||
settingsState.terminalModelQueryAuthority = true
|
||||
})
|
||||
|
||||
describe('reply parity for hidden-dropped chunks', () => {
|
||||
// Pin design replies against bundled xterm/headless, which shares renderer core.
|
||||
it.each([
|
||||
['DA1 CSI c', '\x1b[c', ['\x1b[?1;2c']],
|
||||
['DA1 CSI 0 c variant', '\x1b[0c', ['\x1b[?1;2c']],
|
||||
['DA2', '\x1b[>c', ['\x1b[>0;276;0c']],
|
||||
['DSR 5n operating status', '\x1b[5n', ['\x1b[0n']],
|
||||
['CPR 6n at origin', '\x1b[6n', ['\x1b[1;1R']],
|
||||
['CPR 6n reports the model cursor position', 'hello\r\nworld\x1b[6n', ['\x1b[2;6R']],
|
||||
['DECXCPR ?6n', '\x1b[?6n', ['\x1b[?1;1R']],
|
||||
['DECRPM ?1 DECCKM default', '\x1b[?1$p', ['\x1b[?1;2$y']],
|
||||
['DECRPM ?6 DECOM default', '\x1b[?6$p', ['\x1b[?6;2$y']],
|
||||
['DECRPM ?7 DECAWM default', '\x1b[?7$p', ['\x1b[?7;1$y']],
|
||||
['DECRPM ?25 DECTCEM default', '\x1b[?25$p', ['\x1b[?25;1$y']],
|
||||
['DECRPM ?1004 focus events default', '\x1b[?1004$p', ['\x1b[?1004;2$y']],
|
||||
['DECRPM ?1006 SGR mouse default', '\x1b[?1006$p', ['\x1b[?1006;2$y']],
|
||||
['DECRPM ?1016 SGR pixels default', '\x1b[?1016$p', ['\x1b[?1016;2$y']],
|
||||
['DECRPM ?1049 alt screen default', '\x1b[?1049$p', ['\x1b[?1049;2$y']],
|
||||
['DECRPM ?2004 bracketed paste default', '\x1b[?2004$p', ['\x1b[?2004;2$y']],
|
||||
['DECRPM ?2026 synchronized output default', '\x1b[?2026$p', ['\x1b[?2026;2$y']],
|
||||
['DECRPM reports a set mode as enabled', '\x1b[?2004h\x1b[?2004$p', ['\x1b[?2004;1$y']],
|
||||
['DECRPM unknown mode reports 0', '\x1b[?12345$p', ['\x1b[?12345;0$y']],
|
||||
['DECRQM ANSI insert mode', '\x1b[4$p', ['\x1b[4;2$y']],
|
||||
['DECRQSS DECSTBM default margins', '\x1bP$qr\x1b\\', ['\x1bP1$r1;24r\x1b\\']],
|
||||
['DECRQSS DECSTBM after margin set', '\x1b[5;20r\x1bP$qr\x1b\\', ['\x1bP1$r5;20r\x1b\\']],
|
||||
['DECRQSS DECSCUSR default cursor', '\x1bP$q q\x1b\\', ['\x1bP1$r2 q\x1b\\']],
|
||||
['DECRQSS DECSCA', '\x1bP$q"q\x1b\\', ['\x1bP1$r0"q\x1b\\']],
|
||||
['DECRQSS SGR', '\x1bP$qm\x1b\\', ['\x1bP1$r0m\x1b\\']],
|
||||
['XTVERSION', '\x1b[>0q', ['\x1bP>|xterm.js(6.0.0)\x1b\\']],
|
||||
['kitty CSI ? u default flags', '\x1b[?u', ['\x1b[?0u']],
|
||||
['kitty CSI ? u reports pushed flags', '\x1b[=5;1u\x1b[?u', ['\x1b[?5u']]
|
||||
])('%s', async (_label, chunk, expectedReplies) => {
|
||||
const { runtime, replies, inputKinds } = createResponderRuntime()
|
||||
markHiddenRendererPty('pty-q')
|
||||
|
||||
runtime.onPtyData('pty-q', chunk, Date.now())
|
||||
await settle(runtime, 'pty-q')
|
||||
|
||||
expect(replies.map((reply) => reply.data)).toEqual(expectedReplies)
|
||||
expect(replies.every((reply) => reply.ptyId === 'pty-q')).toBe(true)
|
||||
// Why: a reply written as driving input would read the untouched run as typed.
|
||||
expect(inputKinds).toEqual(expectedReplies.map(() => 'query-reply'))
|
||||
})
|
||||
|
||||
it.each([
|
||||
['XTWINOPS', '\x1b[14t'],
|
||||
['XTGETTCAP', '\x1bP+q544e\x1b\\'],
|
||||
['DSR ?15n printer status', '\x1b[?15n'],
|
||||
['DSR ?25n UDK status', '\x1b[?25n'],
|
||||
['DSR ?26n keyboard status', '\x1b[?26n'],
|
||||
['DSR ?53n locator status', '\x1b[?53n'],
|
||||
// No renderer attributes means silence; fabricated colors would revive the default-black OSC-11 bug.
|
||||
['OSC 10 foreground query', '\x1b]10;?\x07'],
|
||||
['OSC 11 background query', '\x1b]11;?\x07'],
|
||||
['OSC 12 cursor-color query', '\x1b]12;?\x1b\\'],
|
||||
['OSC 4 palette query', '\x1b]4;1;?\x07'],
|
||||
['DSR ?996n color-scheme query', '\x1b[?996n']
|
||||
])('stays silent for %s', async (_label, chunk) => {
|
||||
const { runtime, replies } = createResponderRuntime()
|
||||
markHiddenRendererPty('pty-q')
|
||||
|
||||
runtime.onPtyData('pty-q', chunk, Date.now())
|
||||
await settle(runtime, 'pty-q')
|
||||
|
||||
expect(replies).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('reply ownership matrix', () => {
|
||||
it('never answers delivered (unmarked) chunks — the visible xterm owns them', async () => {
|
||||
const { runtime, replies } = createResponderRuntime()
|
||||
setTerminalViewAttributes(viewAttributes())
|
||||
|
||||
runtime.onPtyData('pty-v', QUERY_BATCH, Date.now())
|
||||
await settle(runtime, 'pty-v')
|
||||
|
||||
expect(replies).toEqual([])
|
||||
})
|
||||
|
||||
it('never answers while renderer delivery interest holds the chunk delivered', async () => {
|
||||
const { runtime, replies } = createResponderRuntime()
|
||||
setTerminalViewAttributes(viewAttributes())
|
||||
markHiddenRendererPty('pty-i')
|
||||
setRendererPtyDeliveryInterest('pty-i', true)
|
||||
|
||||
runtime.onPtyData('pty-i', QUERY_BATCH, Date.now())
|
||||
await settle(runtime, 'pty-i')
|
||||
|
||||
expect(replies).toEqual([])
|
||||
})
|
||||
|
||||
it.each([
|
||||
['terminalModelQueryAuthority', () => (settingsState.terminalModelQueryAuthority = false)],
|
||||
['terminalHiddenDeliveryGate', () => (settingsState.terminalHiddenDeliveryGate = false)],
|
||||
[
|
||||
'terminalMainSideEffectAuthority',
|
||||
() => (settingsState.terminalMainSideEffectAuthority = false)
|
||||
]
|
||||
])('never answers with kill switch %s off', async (_label, flip) => {
|
||||
flip()
|
||||
const { runtime, replies } = createResponderRuntime()
|
||||
setTerminalViewAttributes(viewAttributes())
|
||||
markHiddenRendererPty('pty-k')
|
||||
|
||||
runtime.onPtyData('pty-k', QUERY_BATCH, Date.now())
|
||||
await settle(runtime, 'pty-k')
|
||||
|
||||
expect(replies).toEqual([])
|
||||
})
|
||||
|
||||
it('yields until every remote view subscriber releases, including repeated release', async () => {
|
||||
const { runtime, replies } = createResponderRuntime()
|
||||
setTerminalViewAttributes(viewAttributes())
|
||||
@@ -286,38 +176,6 @@ describe('reply ownership matrix', () => {
|
||||
expect(replies).toHaveLength(2)
|
||||
})
|
||||
|
||||
// #32944: a paused view's discarded queries fall back to main only when that view alone owned them.
|
||||
it('reports sole remote query ownership and answers yielded stateless queries', async () => {
|
||||
const { runtime, replies } = createResponderRuntime()
|
||||
markHiddenRendererPty('pty-yield')
|
||||
const releaseA = runtime.registerRemoteTerminalViewSubscriber('pty-yield', () => false)
|
||||
expect(runtime.isSoleRemoteTerminalQueryOwner('pty-yield')).toBe(true)
|
||||
const releaseB = runtime.registerRemoteTerminalViewSubscriber('pty-yield', () => false)
|
||||
expect(runtime.isSoleRemoteTerminalQueryOwner('pty-yield')).toBe(false)
|
||||
releaseB()
|
||||
releaseA()
|
||||
|
||||
runtime.onPtyData('pty-yield', 'prompt', 0)
|
||||
runtime.answerYieldedTerminalQueries('pty-yield', '\x1b[c')
|
||||
await settle(runtime, 'pty-yield')
|
||||
expect(replies.map((reply) => reply.data)).toEqual(['\x1b[?1;2c'])
|
||||
})
|
||||
|
||||
it('keeps raw preview presence separate from terminal query authority', async () => {
|
||||
const { runtime, replies } = createResponderRuntime()
|
||||
markHiddenRendererPty('pty-preview')
|
||||
const release = runtime.registerRawTerminalViewSubscriber('pty-preview')
|
||||
|
||||
expect(runtime.hasRawTerminalViewSubscriber('pty-preview')).toBe(true)
|
||||
expect(runtime.hasRemoteTerminalViewSubscriber('pty-preview')).toBe(false)
|
||||
runtime.onPtyData('pty-preview', '\x1b[c', Date.now())
|
||||
await settle(runtime, 'pty-preview')
|
||||
expect(replies.map((reply) => reply.data)).toEqual(['\x1b[?1;2c'])
|
||||
|
||||
release()
|
||||
expect(runtime.hasRawTerminalViewSubscriber('pty-preview')).toBe(false)
|
||||
})
|
||||
|
||||
it('yields to a phone-fitted mobile view but answers for a passive desktop-size one', async () => {
|
||||
const { runtime, replies } = createResponderRuntime()
|
||||
markHiddenRendererPty('pty-fit')
|
||||
@@ -355,20 +213,6 @@ describe('main-side replay guard', () => {
|
||||
'\x1b[?997;1n'
|
||||
])
|
||||
})
|
||||
|
||||
it('never answers queries replayed by renderer-buffer hydration', async () => {
|
||||
setTerminalViewAttributes(viewAttributes())
|
||||
const { runtime, replies } = createResponderRuntime({
|
||||
rendererBuffer: { data: `restored screen${QUERY_BATCH}`, cols: 80, rows: 24 }
|
||||
})
|
||||
markHiddenRendererPty('pty-hyd')
|
||||
|
||||
// Hydrated snapshots must not answer embedded terminal queries.
|
||||
runtime.onPtyData('pty-hyd', 'live output', Date.now())
|
||||
await settle(runtime, 'pty-hyd')
|
||||
|
||||
expect(replies).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('kitty flag re-seed parity (terminal-query-authority.md §kitty)', () => {
|
||||
@@ -387,17 +231,6 @@ describe('kitty flag re-seed parity (terminal-query-authority.md §kitty)', () =
|
||||
await settle(runtime, 'pty-kitty')
|
||||
expect(replies.map((reply) => reply.data)).toEqual(['\x1b[?5u'])
|
||||
})
|
||||
|
||||
it('answers ?0u when the snapshot carried no flags (fresh-shell paths)', async () => {
|
||||
const { runtime, replies } = createResponderRuntime()
|
||||
markHiddenRendererPty('pty-kitty0')
|
||||
|
||||
runtime.seedHeadlessTerminal('pty-kitty0', 'restored prompt')
|
||||
runtime.onPtyData('pty-kitty0', '\x1b[?u', Date.now())
|
||||
await settle(runtime, 'pty-kitty0')
|
||||
|
||||
expect(replies.map((reply) => reply.data)).toEqual(['\x1b[?0u'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('ingestion-time ownership capture', () => {
|
||||
@@ -414,33 +247,11 @@ describe('ingestion-time ownership capture', () => {
|
||||
|
||||
expect(replies.map((reply) => reply.data)).toEqual(['\x1b[?1;2c'])
|
||||
})
|
||||
|
||||
it('stays silent when the hidden mark lands after ingestion', async () => {
|
||||
const { runtime, replies } = createResponderRuntime()
|
||||
|
||||
runtime.onPtyData('pty-race2', DA1, Date.now())
|
||||
markHiddenRendererPty('pty-race2')
|
||||
await settle(runtime, 'pty-race2')
|
||||
|
||||
expect(replies).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('stale writeChain links after dispose', () => {
|
||||
const DA1 = '\x1b[c'
|
||||
|
||||
it('never forwards a queued reply once the PTY state is disposed', async () => {
|
||||
const { runtime, replies } = createResponderRuntime()
|
||||
markHiddenRendererPty('pty-stale')
|
||||
|
||||
// Queue a forward-flagged chain link, then dispose before it runs.
|
||||
runtime.onPtyData('pty-stale', DA1, Date.now())
|
||||
runtime.onPtyExit('pty-stale', 0)
|
||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||
|
||||
expect(replies).toEqual([])
|
||||
})
|
||||
|
||||
it('never injects a stale reply into a successor PTY reusing the session id', async () => {
|
||||
const { runtime, replies } = createResponderRuntime()
|
||||
markHiddenRendererPty('pty-reuse')
|
||||
@@ -471,60 +282,9 @@ describe('ConPTY DA1 override', () => {
|
||||
|
||||
expect(replies.map((reply) => reply.data)).toEqual(['\x1b[?61;4c'])
|
||||
})
|
||||
|
||||
it('answers once at creation and after a repeated native-Windows mark', async () => {
|
||||
const { runtime, replies } = createResponderRuntime()
|
||||
markNativeWindowsConptyPty('pty-win-idem')
|
||||
markHiddenRendererPty('pty-win-idem')
|
||||
|
||||
runtime.onPtyData('pty-win-idem', '\x1b[c', Date.now())
|
||||
await settle(runtime, 'pty-win-idem')
|
||||
expect(replies.map((reply) => reply.data)).toEqual(['\x1b[?61;4c'])
|
||||
|
||||
markNativeWindowsConptyPty('pty-win-idem')
|
||||
runtime.onPtyData('pty-win-idem', '\x1b[c', Date.now())
|
||||
await settle(runtime, 'pty-win-idem')
|
||||
expect(replies.map((reply) => reply.data)).toEqual(['\x1b[?61;4c', '\x1b[?61;4c'])
|
||||
})
|
||||
|
||||
it('lets non-primary device-attribute queries fall through to the core', async () => {
|
||||
const { runtime, replies } = createResponderRuntime()
|
||||
markNativeWindowsConptyPty('pty-win2')
|
||||
markHiddenRendererPty('pty-win2')
|
||||
|
||||
runtime.onPtyData('pty-win2', '\x1b[>c', Date.now())
|
||||
await settle(runtime, 'pty-win2')
|
||||
|
||||
expect(replies.map((reply) => reply.data)).toEqual(['\x1b[>0;276;0c'])
|
||||
})
|
||||
|
||||
it('keeps the override silent for delivered chunks', async () => {
|
||||
const { runtime, replies } = createResponderRuntime()
|
||||
markNativeWindowsConptyPty('pty-win3')
|
||||
|
||||
runtime.onPtyData('pty-win3', '\x1b[c', Date.now())
|
||||
await settle(runtime, 'pty-win3')
|
||||
|
||||
expect(replies).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('HeadlessEmulator forwarding window', () => {
|
||||
it('forwards replies only for writes flagged forwardQueryReplies', async () => {
|
||||
const onQueryReply = vi.fn()
|
||||
const emulator = new HeadlessEmulator({ cols: 80, rows: 24, onQueryReply })
|
||||
try {
|
||||
await emulator.write('\x1b[c')
|
||||
expect(onQueryReply).not.toHaveBeenCalled()
|
||||
|
||||
await emulator.write('\x1b[c', { forwardQueryReplies: true })
|
||||
expect(onQueryReply).toHaveBeenCalledTimes(1)
|
||||
expect(onQueryReply).toHaveBeenCalledWith('\x1b[?1;2c')
|
||||
} finally {
|
||||
emulator.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
it('scopes the async-fallback forwarding window to the flagged chunk parse', async () => {
|
||||
const onQueryReply = vi.fn()
|
||||
const emulator = new HeadlessEmulator({ cols: 80, rows: 24, onQueryReply })
|
||||
@@ -594,48 +354,6 @@ describe('view-attribute bridge replies (after renderer push)', () => {
|
||||
expect(replies.map((reply) => reply.data)).toEqual(expectedReplies)
|
||||
})
|
||||
|
||||
it('answers ?996n from palette luminance, not the pushed app mode (dark palette, light app mode)', async () => {
|
||||
const { runtime, replies } = createResponderRuntime()
|
||||
markHiddenRendererPty('pty-lum-dark')
|
||||
// Separate-light-theme off can leave dark terminal colors in light app mode; match xterm luminance-based ?996n.
|
||||
setTerminalViewAttributes(viewAttributes({ colorSchemeMode: 'light' }))
|
||||
|
||||
runtime.onPtyData('pty-lum-dark', '\x1b[?996n', Date.now())
|
||||
await settle(runtime, 'pty-lum-dark')
|
||||
|
||||
expect(replies.map((reply) => reply.data)).toEqual(['\x1b[?997;1n'])
|
||||
})
|
||||
|
||||
it('answers ?996n light for a light palette regardless of the pushed app mode', async () => {
|
||||
const { runtime, replies } = createResponderRuntime()
|
||||
markHiddenRendererPty('pty-lum-light')
|
||||
setTerminalViewAttributes(
|
||||
viewAttributes({
|
||||
foreground: [0x33, 0x33, 0x33],
|
||||
background: [0xfa, 0xfa, 0xfa],
|
||||
colorSchemeMode: 'dark'
|
||||
})
|
||||
)
|
||||
|
||||
runtime.onPtyData('pty-lum-light', '\x1b[?996n', Date.now())
|
||||
await settle(runtime, 'pty-lum-light')
|
||||
|
||||
expect(replies.map((reply) => reply.data)).toEqual(['\x1b[?997;2n'])
|
||||
})
|
||||
|
||||
it('answers ?996n from OSC-SET-mutated colors like a visible xterm', async () => {
|
||||
// _reportColorScheme reads the CURRENT theme-service colors, which include
|
||||
// OSC 10/11 SET mutations — the per-PTY overlays layer the same way.
|
||||
const { runtime, replies } = createResponderRuntime()
|
||||
markHiddenRendererPty('pty-lum-set')
|
||||
setTerminalViewAttributes(viewAttributes())
|
||||
|
||||
runtime.onPtyData('pty-lum-set', '\x1b]11;#ffffff\x07\x1b]10;#101010\x07\x1b[?996n', Date.now())
|
||||
await settle(runtime, 'pty-lum-set')
|
||||
|
||||
expect(replies.map((reply) => reply.data)).toEqual(['\x1b[?997;2n'])
|
||||
})
|
||||
|
||||
it('stays silent before the first push, then answers the same query after it', async () => {
|
||||
const { runtime, replies } = createResponderRuntime()
|
||||
markHiddenRendererPty('pty-first')
|
||||
@@ -650,21 +368,6 @@ describe('view-attribute bridge replies (after renderer push)', () => {
|
||||
await settle(runtime, 'pty-first')
|
||||
expect(replies.map((reply) => reply.data)).toEqual(['\x1b]11;rgb:1e1e/1e1e/2e2e\x1b\\'])
|
||||
})
|
||||
|
||||
it('retrofits cursor options onto already-live emulators when the push lands late', async () => {
|
||||
const { runtime, replies } = createResponderRuntime()
|
||||
markHiddenRendererPty('pty-late')
|
||||
|
||||
// Emulator exists before any push: core default DECSCUSR is steady block.
|
||||
runtime.onPtyData('pty-late', '\x1bP$q q\x1b\\', Date.now())
|
||||
await settle(runtime, 'pty-late')
|
||||
expect(replies.map((reply) => reply.data)).toEqual(['\x1bP1$r2 q\x1b\\'])
|
||||
|
||||
setTerminalViewAttributes(viewAttributes({ cursorStyle: 'underline', cursorBlink: false }))
|
||||
runtime.onPtyData('pty-late', '\x1bP$q q\x1b\\', Date.now())
|
||||
await settle(runtime, 'pty-late')
|
||||
expect(replies.map((reply) => reply.data)).toEqual(['\x1bP1$r2 q\x1b\\', '\x1bP1$r4 q\x1b\\'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('per-PTY OSC color SET layering', () => {
|
||||
@@ -685,60 +388,6 @@ describe('per-PTY OSC color SET layering', () => {
|
||||
])
|
||||
})
|
||||
|
||||
it('restores a single indexed color via OSC 104;<idx>', async () => {
|
||||
const { runtime, replies } = createResponderRuntime()
|
||||
markHiddenRendererPty('pty-104')
|
||||
setTerminalViewAttributes(viewAttributes())
|
||||
|
||||
runtime.onPtyData('pty-104', '\x1b]4;1;#00ff00\x07\x1b]104;1\x07\x1b]4;1;?\x07', Date.now())
|
||||
await settle(runtime, 'pty-104')
|
||||
|
||||
expect(replies.map((reply) => reply.data)).toEqual(['\x1b]4;1;rgb:cccc/0000/0000\x1b\\'])
|
||||
})
|
||||
|
||||
it('restores the whole indexed table via bare OSC 104', async () => {
|
||||
const { runtime, replies } = createResponderRuntime()
|
||||
markHiddenRendererPty('pty-104all')
|
||||
setTerminalViewAttributes(viewAttributes())
|
||||
|
||||
runtime.onPtyData(
|
||||
'pty-104all',
|
||||
'\x1b]4;1;#00ff00;196;#0000ff\x07\x1b]104\x07\x1b]4;1;?;196;?\x07',
|
||||
Date.now()
|
||||
)
|
||||
await settle(runtime, 'pty-104all')
|
||||
|
||||
expect(replies.map((reply) => reply.data)).toEqual([
|
||||
'\x1b]4;1;rgb:cccc/0000/0000\x1b\\',
|
||||
'\x1b]4;196;rgb:c4c4/8888/4c4c\x1b\\'
|
||||
])
|
||||
})
|
||||
|
||||
it('layers OSC 10/11/12 SETs and restores them via 110/111/112', async () => {
|
||||
const { runtime, replies } = createResponderRuntime()
|
||||
markHiddenRendererPty('pty-special')
|
||||
setTerminalViewAttributes(viewAttributes())
|
||||
|
||||
runtime.onPtyData(
|
||||
'pty-special',
|
||||
'\x1b]10;#010203\x07\x1b]11;rgb:ff/ff/ff\x07\x1b]12;#0a0b0c\x07' +
|
||||
'\x1b]10;?\x07\x1b]11;?\x07\x1b]12;?\x07' +
|
||||
'\x1b]110\x07\x1b]111\x07\x1b]112\x07' +
|
||||
'\x1b]10;?\x07\x1b]11;?\x07\x1b]12;?\x07',
|
||||
Date.now()
|
||||
)
|
||||
await settle(runtime, 'pty-special')
|
||||
|
||||
expect(replies.map((reply) => reply.data)).toEqual([
|
||||
'\x1b]10;rgb:0101/0202/0303\x1b\\',
|
||||
'\x1b]11;rgb:ffff/ffff/ffff\x1b\\',
|
||||
'\x1b]12;rgb:0a0a/0b0b/0c0c\x1b\\',
|
||||
'\x1b]10;rgb:d0d0/d0d0/d0d0\x1b\\',
|
||||
'\x1b]11;rgb:1e1e/1e1e/2e2e\x1b\\',
|
||||
'\x1b]12;rgb:ffff/9999/0000\x1b\\'
|
||||
])
|
||||
})
|
||||
|
||||
it('tracks SET mutations parsed from a seed without replying, like renderer replay', async () => {
|
||||
// Restored OSC SETs update theme state like visible xterm, while replay remains silent.
|
||||
const { runtime, replies } = createResponderRuntime()
|
||||
|
||||
@@ -1,17 +1,10 @@
|
||||
import { describe, expect, it, vi, afterEach } from 'vitest'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { OrcaRuntimeService } from './orca-runtime'
|
||||
import { getDefaultWorkspaceSession } from '../../shared/constants'
|
||||
import type { WorkspaceSessionState } from '../../shared/workspace-session/workspace-session-state-types'
|
||||
import { makePaneKey } from '../../shared/workspace-layout/stable-pane-id'
|
||||
import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../shared/layout-owner-switch'
|
||||
import { bindPaneFirst } from './orca-runtime-tests/bind-first-pane.test-fixture'
|
||||
import type {
|
||||
RuntimePtyTabCloseAuthority,
|
||||
HeadlessSeedMetadata,
|
||||
RuntimeTerminalBufferSnapshot
|
||||
} from './terminal/records/runtime-terminal-state-records'
|
||||
import type { RuntimeMobileSessionCreateTerminalResult } from '../../shared/runtime-session-contracts'
|
||||
import type { PtyProviderBufferSnapshot } from '../providers/pty-provider-contract'
|
||||
import '../../shared/workspace-layout/stable-pane-id'
|
||||
import '../../shared/layout-owner-switch'
|
||||
import './orca-runtime-tests/bind-first-pane.test-fixture'
|
||||
|
||||
// Reattach restore arrives as RPC data, not onPtyData; seed preview/tail/title without inventing recency or side-effect facts.
|
||||
|
||||
@@ -152,40 +145,6 @@ describe('seedTerminalRestoreTail', () => {
|
||||
expect(terminals[0]!.lastOutputAt).toBe(42)
|
||||
})
|
||||
|
||||
it('keeps a live-tracked title over the payload title', async () => {
|
||||
const runtime = makeRuntimeWithLeaf()
|
||||
|
||||
runtime.onPtyData(PTY_ID, '\x1b]0;live-title\x07', 42)
|
||||
runtime.seedTerminalRestoreTail(PTY_ID, { lastTitle: 'stale-persisted-title' })
|
||||
|
||||
const internals = runtime as unknown as RuntimeRecordInternals
|
||||
expect(internals.ptysById.get(PTY_ID)!.lastOscTitle).toBe('live-title')
|
||||
})
|
||||
|
||||
it('seeds title-only payloads (no restore text)', async () => {
|
||||
const runtime = makeRuntimeWithLeaf()
|
||||
|
||||
runtime.seedTerminalRestoreTail(PTY_ID, { lastTitle: 'title-only-restore' })
|
||||
|
||||
const { terminals } = await runtime.listTerminals(`id:${WORKTREE_ID}`)
|
||||
expect(terminals[0]!.title).toBe('title-only-restore')
|
||||
expect(terminals[0]!.preview).toBe('')
|
||||
expect(terminals[0]!.lastOutputAt).toBeNull()
|
||||
})
|
||||
|
||||
it('republishes session tabs when a seeded title lands', () => {
|
||||
const runtime = makeRuntimeWithLeaf()
|
||||
const touch = vi.spyOn(
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: OrcaRuntimeService defines this protected method; the spy only observes it.
|
||||
runtime as unknown as { touchMobileSessionSnapshotsForPty: (ptyId: string) => void },
|
||||
'touchMobileSessionSnapshotsForPty'
|
||||
)
|
||||
|
||||
runtime.seedTerminalRestoreTail(PTY_ID, { lastTitle: 'restored-agent-title' })
|
||||
|
||||
expect(touch).toHaveBeenCalledWith(PTY_ID)
|
||||
})
|
||||
|
||||
it('caps the parsed suffix of an oversized payload at a line boundary', async () => {
|
||||
const runtime = makeRuntimeWithLeaf()
|
||||
|
||||
@@ -221,420 +180,4 @@ describe('seedTerminalRestoreTail', () => {
|
||||
runtime.onPtyData(PTY_ID, 'Do you trust this workspace?\r\nPress t to trust\r\n', 2_000_000)
|
||||
expect(pty.waitBlockedAt).toBe(2_000_000)
|
||||
})
|
||||
|
||||
it('seeds the pty record for a daemon-scoped id with no synced leaf', async () => {
|
||||
const runtime = makeRuntimeWithLeaf()
|
||||
const orphanPtyId = `${WORKTREE_ID}@@session-orphan`
|
||||
|
||||
runtime.seedTerminalRestoreTail(orphanPtyId, { text: 'orphan restored\r\n' })
|
||||
|
||||
const internals = runtime as unknown as RuntimeRecordInternals
|
||||
const pty = internals.ptysById.get(orphanPtyId)!
|
||||
expect(pty.preview).toBe('orphan restored')
|
||||
expect(pty.lastOutputAt).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('terminal query dispatch characterization', () => {
|
||||
class QueryDispatchRuntime extends OrcaRuntimeService {
|
||||
prepareMobileCreate() {
|
||||
this.registerPty('pty-query', 'workspace')
|
||||
const pty = this.ptysById.get('pty-query')
|
||||
if (!pty) {
|
||||
throw new Error('missing_test_pty')
|
||||
}
|
||||
this.terminalIdentity.setRecordedSurface(
|
||||
pty,
|
||||
'tab',
|
||||
makePaneKey('tab', '00000000-0000-4000-8000-000000000001'),
|
||||
undefined
|
||||
)
|
||||
if (ORCA_LAYOUT_OWNER_IS_MAIN) {
|
||||
// Main's layout is the only answer to which pane holds the terminal; it needs a session.
|
||||
Reflect.set(this, 'store', {})
|
||||
bindPaneFirst(this, {
|
||||
worktreeId: 'workspace',
|
||||
tabId: 'tab',
|
||||
leafId: '00000000-0000-4000-8000-000000000001',
|
||||
ptyId: 'pty-query'
|
||||
})
|
||||
}
|
||||
this.pendingMobileTerminalCreatesByKey.set('workspace::tab', {
|
||||
activate: false,
|
||||
paired: false,
|
||||
selectIfNoActiveTab: false
|
||||
})
|
||||
return pty
|
||||
}
|
||||
|
||||
ensureSurface() {
|
||||
return this.ensurePtyBackedMobileSurfaceForRendererTab('workspace', 'tab')
|
||||
}
|
||||
|
||||
resolveAuthority(authority: RuntimePtyTabCloseAuthority) {
|
||||
return this.resolvePtyTabCloseSurfaceAuthority(authority)
|
||||
}
|
||||
}
|
||||
|
||||
afterEach(() => vi.restoreAllMocks())
|
||||
|
||||
describe('terminal query dispatch', () => {
|
||||
it('preserves the two arguments in both protected mobile surface lookups', () => {
|
||||
const runtime = new QueryDispatchRuntime(null)
|
||||
const pty = runtime.prepareMobileCreate()
|
||||
const find = vi.fn(function (this: typeof runtime) {
|
||||
expect(this).toBe(runtime)
|
||||
expect(arguments.length).toBe(2)
|
||||
return null
|
||||
})
|
||||
Object.defineProperty(runtime, 'findMobileTerminalSurface', { value: find })
|
||||
Object.defineProperty(runtime, 'findLiveRegisteredPtyForRendererTab', { value: () => pty })
|
||||
Object.defineProperty(runtime, 'publishPtyBackedMobileSessionTerminal', { value: () => {} })
|
||||
|
||||
try {
|
||||
expect(runtime.ensureSurface()).toBeNull()
|
||||
expect(find).toHaveBeenCalledTimes(2)
|
||||
expect(pty.runtimeSessionOwned).toBe(true)
|
||||
} finally {
|
||||
runtime.onPtyExit('pty-query', 0)
|
||||
}
|
||||
})
|
||||
|
||||
it('captures the protected live PTY lookup before reading authority.handle', () => {
|
||||
const runtime = new QueryDispatchRuntime(null)
|
||||
const original = vi.fn(function (this: typeof runtime) {
|
||||
expect(this).toBe(runtime)
|
||||
expect(arguments.length).toBe(1)
|
||||
return null
|
||||
})
|
||||
const replacement = vi.fn(() => null)
|
||||
Object.defineProperty(runtime, 'getLivePtyForHandle', {
|
||||
configurable: true,
|
||||
get: () => original
|
||||
})
|
||||
const authority: RuntimePtyTabCloseAuthority = {
|
||||
get handle() {
|
||||
Object.defineProperty(runtime, 'getLivePtyForHandle', { value: replacement })
|
||||
return 'handle'
|
||||
},
|
||||
ptyId: 'pty-query',
|
||||
incarnationId: null,
|
||||
worktreeId: 'workspace'
|
||||
}
|
||||
|
||||
expect(runtime.resolveAuthority(authority)).toBeNull()
|
||||
expect(original).toHaveBeenCalledExactlyOnceWith('handle')
|
||||
expect(replacement).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it.each([
|
||||
['seedHeadlessTerminal', 4],
|
||||
['resizeHeadlessTerminal', 3],
|
||||
['trackHeadlessTerminalData', 3]
|
||||
] as const)('captures recovery %s before its argument getters', (method, argc) => {
|
||||
const runtime = new QueryDispatchRuntime(null)
|
||||
const original = vi.fn(function (this: typeof runtime) {
|
||||
expect(this).toBe(runtime)
|
||||
expect(arguments.length).toBe(argc)
|
||||
})
|
||||
const replacement = vi.fn()
|
||||
Object.defineProperty(runtime, 'disposeHeadlessTerminal', { value: () => {} })
|
||||
Object.defineProperty(runtime, 'seedHeadlessTerminal', {
|
||||
configurable: true,
|
||||
value: () => {}
|
||||
})
|
||||
Object.defineProperty(runtime, 'resizeHeadlessTerminal', {
|
||||
configurable: true,
|
||||
value: () => {}
|
||||
})
|
||||
Object.defineProperty(runtime, 'trackHeadlessTerminalData', {
|
||||
configurable: true,
|
||||
value: () => {}
|
||||
})
|
||||
Object.defineProperty(runtime, method, { get: () => original })
|
||||
const replace = () => Object.defineProperty(runtime, method, { value: replacement })
|
||||
let reads = 0
|
||||
Object.defineProperty(runtime, 'getTerminalSize', {
|
||||
value: () => ({
|
||||
get cols() {
|
||||
if (method === 'resizeHeadlessTerminal') {
|
||||
replace()
|
||||
}
|
||||
return 100
|
||||
},
|
||||
rows: 40
|
||||
})
|
||||
})
|
||||
|
||||
runtime.replaceHeadlessTerminalFromRendererSnapshotForRecovery(
|
||||
'pty-query',
|
||||
{
|
||||
get data() {
|
||||
if (++reads === 2 && method === 'seedHeadlessTerminal') {
|
||||
replace()
|
||||
}
|
||||
return 'snapshot'
|
||||
},
|
||||
cols: 80,
|
||||
rows: 24
|
||||
},
|
||||
[
|
||||
{
|
||||
get data() {
|
||||
if (method === 'trackHeadlessTerminalData') {
|
||||
replace()
|
||||
}
|
||||
return 'tail'
|
||||
},
|
||||
seq: 7
|
||||
}
|
||||
]
|
||||
)
|
||||
|
||||
expect(original).toHaveBeenCalledOnce()
|
||||
expect(replacement).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it.each(['resizeHeadlessTerminal', 'trackHeadlessTerminalData'] as const)(
|
||||
'preserves invoke-time Root lookup for recovery %s',
|
||||
(method) => {
|
||||
const runtime = new QueryDispatchRuntime(null)
|
||||
const original = vi.fn()
|
||||
const replacement = vi.fn(function (this: typeof runtime) {
|
||||
expect(this).toBe(runtime)
|
||||
expect(arguments.length).toBe(3)
|
||||
})
|
||||
Object.defineProperty(runtime, method, { configurable: true, value: original })
|
||||
const argument = {
|
||||
get value() {
|
||||
Object.defineProperty(runtime, method, { value: replacement })
|
||||
return 'pty-query'
|
||||
}
|
||||
}
|
||||
|
||||
Object.defineProperty(runtime, 'disposeHeadlessTerminal', { value: () => {} })
|
||||
Object.defineProperty(runtime, 'seedHeadlessTerminal', { value: () => {} })
|
||||
Object.defineProperty(runtime, 'getTerminalSize', {
|
||||
value: () => (method === 'resizeHeadlessTerminal' ? { cols: 80, rows: 24 } : null)
|
||||
})
|
||||
runtime.replaceHeadlessTerminalFromRendererSnapshotForRecovery(
|
||||
argument.value,
|
||||
{ data: 'snapshot', cols: 80, rows: 24 },
|
||||
method === 'trackHeadlessTerminalData' ? [{ data: 'tail', seq: 7 }] : []
|
||||
)
|
||||
|
||||
expect(original).not.toHaveBeenCalled()
|
||||
expect(replacement).toHaveBeenCalledOnce()
|
||||
expect(replacement).toHaveBeenCalledWith(
|
||||
'pty-query',
|
||||
method === 'resizeHeadlessTerminal' ? 80 : 'tail',
|
||||
method === 'resizeHeadlessTerminal' ? 24 : 7
|
||||
)
|
||||
}
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('terminal query reentry characterization', () => {
|
||||
class QueryRuntime extends OrcaRuntimeService {
|
||||
readonly calls: string[] = []
|
||||
surfaceReads = 0
|
||||
|
||||
override getRendererTerminalSerializerGeneration(ptyId: string): number {
|
||||
this.calls.push(`generation:${ptyId}`)
|
||||
return 27
|
||||
}
|
||||
|
||||
override getTerminalSize(ptyId: string): { cols: number; rows: number } {
|
||||
this.calls.push(`grid:${ptyId}`)
|
||||
return { cols: 100, rows: 40 }
|
||||
}
|
||||
|
||||
protected override disposeHeadlessTerminal(ptyId: string): void {
|
||||
this.calls.push(`dispose:${ptyId}`)
|
||||
}
|
||||
|
||||
override seedHeadlessTerminal(
|
||||
ptyId: string,
|
||||
data: string,
|
||||
size?: { cols: number; rows: number },
|
||||
metadata: HeadlessSeedMetadata = {}
|
||||
): void {
|
||||
this.calls.push(`seed:${ptyId}:${data}:${size?.cols}:${metadata.cwd}`)
|
||||
}
|
||||
|
||||
protected override resizeHeadlessTerminal(ptyId: string, cols: number, rows: number): void {
|
||||
this.calls.push(`resize:${ptyId}:${cols}:${rows}`)
|
||||
}
|
||||
|
||||
protected override trackHeadlessTerminalData(ptyId: string, data: string, seq?: number): void {
|
||||
this.calls.push(`data:${ptyId}:${data}:${seq}`)
|
||||
}
|
||||
|
||||
protected override findMobileTerminalSurface(
|
||||
_worktreeId: string,
|
||||
_parentTabId: string,
|
||||
_options: { requireReady?: boolean } = {}
|
||||
): RuntimeMobileSessionCreateTerminalResult | null {
|
||||
this.surfaceReads += 1
|
||||
return null
|
||||
}
|
||||
|
||||
ptyHandle(ptyId: string): string {
|
||||
this.registerPty(ptyId, 'repo::/tmp/query-workspace')
|
||||
const pty = this.ptysById.get(ptyId)
|
||||
if (!pty) {
|
||||
throw new Error('missing_test_pty')
|
||||
}
|
||||
return this.issuePtyHandle(pty)
|
||||
}
|
||||
|
||||
recoveryState(ptyId: string): { preferred: boolean; hydration: string | undefined } {
|
||||
return {
|
||||
preferred: this.providerSnapshotPreferredPtys.has(ptyId),
|
||||
hydration: this.headlessHydrationState.get(ptyId)
|
||||
}
|
||||
}
|
||||
|
||||
waitForSurface(signal: AbortSignal): Promise<RuntimeMobileSessionCreateTerminalResult> {
|
||||
return this.waitForMobileTerminalSurface('repo::/tmp/query-workspace', 'tab-query', {
|
||||
signal
|
||||
})
|
||||
}
|
||||
|
||||
get pendingGraphChecks(): number {
|
||||
return this.graphSyncCallbacks.length
|
||||
}
|
||||
}
|
||||
|
||||
describe('terminal query runtime re-entry', () => {
|
||||
it('uses the public renderer generation override for a handle', () => {
|
||||
const runtime = new QueryRuntime(null)
|
||||
const handle = runtime.ptyHandle('pty-query')
|
||||
expect(runtime.getRendererTerminalSerializerGenerationForHandle(handle)).toBe(27)
|
||||
expect(runtime.calls).toEqual(['generation:pty-query'])
|
||||
runtime.onPtyExit('pty-query', 0)
|
||||
})
|
||||
|
||||
it('recovers through the current runtime overrides in the original output order', () => {
|
||||
const runtime = new QueryRuntime(null)
|
||||
runtime.replaceHeadlessTerminalFromRendererSnapshotForRecovery(
|
||||
'pty-recovery',
|
||||
{ data: 'snapshot', cols: 80, rows: 24, cwd: '/tmp/query' },
|
||||
[{ data: 'tail', seq: 7 }]
|
||||
)
|
||||
expect(runtime.calls).toEqual([
|
||||
'dispose:pty-recovery',
|
||||
'seed:pty-recovery:snapshot:80:/tmp/query',
|
||||
'grid:pty-recovery',
|
||||
'resize:pty-recovery:100:40',
|
||||
'data:pty-recovery:tail:7'
|
||||
])
|
||||
expect(runtime.recoveryState('pty-recovery')).toEqual({ preferred: true, hydration: 'done' })
|
||||
runtime.onPtyExit('pty-recovery', 0)
|
||||
})
|
||||
|
||||
it('leaves output ownership untouched when the recovery snapshot is empty', () => {
|
||||
const runtime = new QueryRuntime(null)
|
||||
runtime.replaceHeadlessTerminalFromRendererSnapshotForRecovery('pty-empty', {
|
||||
data: '',
|
||||
cols: 80,
|
||||
rows: 24
|
||||
})
|
||||
expect(runtime.calls).toEqual([])
|
||||
expect(runtime.recoveryState('pty-empty')).toEqual({ preferred: false, hydration: undefined })
|
||||
})
|
||||
|
||||
it('uses the protected surface override and removes its graph subscription on abort', async () => {
|
||||
const runtime = new QueryRuntime(null)
|
||||
const client = new AbortController()
|
||||
const waiting = runtime.waitForSurface(client.signal)
|
||||
expect(runtime.surfaceReads).toBe(2)
|
||||
expect(runtime.pendingGraphChecks).toBe(1)
|
||||
client.abort()
|
||||
await expect(waiting).rejects.toThrow('client_disconnected')
|
||||
expect(runtime.pendingGraphChecks).toBe(0)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('terminal view promise characterization', () => {
|
||||
const snapshot: RuntimeTerminalBufferSnapshot = { data: 'screen', cols: 80, rows: 24 }
|
||||
it('returns the current available-state promise with the runtime receiver and original arity', () => {
|
||||
const runtime = new OrcaRuntimeService()
|
||||
const expected = Promise.resolve(snapshot)
|
||||
const receivers: OrcaRuntimeService[] = []
|
||||
const arities: number[] = []
|
||||
runtime['serializeTerminalBufferFromAvailableState'] = function (
|
||||
this: OrcaRuntimeService,
|
||||
...args
|
||||
) {
|
||||
receivers.push(this)
|
||||
arities.push(args.length)
|
||||
expect(args).toEqual(['view-pty', {}])
|
||||
return expected
|
||||
}
|
||||
expect(runtime.serializeTerminalBuffer('view-pty')).toBe(expected)
|
||||
expect(receivers).toEqual([runtime])
|
||||
expect(arities).toEqual([2])
|
||||
})
|
||||
it('reads the current fallback after provider completion and keeps provider-first options', async () => {
|
||||
const runtime = new OrcaRuntimeService()
|
||||
const provider = Promise.withResolvers<PtyProviderBufferSnapshot | null>()
|
||||
const receivers: OrcaRuntimeService[] = []
|
||||
const opts = { scrollbackRows: 12 }
|
||||
runtime['serializeProviderTerminalBuffer'] = function (this: OrcaRuntimeService, ...args) {
|
||||
receivers.push(this)
|
||||
expect(args).toEqual(['view-pty', opts, { timeoutMs: 8000, retireOnTimeout: true }])
|
||||
return provider.promise
|
||||
}
|
||||
const firstFallback = vi.fn(async () => null)
|
||||
runtime['serializeTerminalBufferFromAvailableState'] = firstFallback
|
||||
const pending = runtime.serializeAuthoritativeTerminalBuffer('view-pty', opts)
|
||||
runtime['serializeTerminalBufferFromAvailableState'] = function (
|
||||
this: OrcaRuntimeService,
|
||||
...args
|
||||
) {
|
||||
receivers.push(this)
|
||||
expect(args).toEqual(['view-pty', opts])
|
||||
return Promise.resolve(snapshot)
|
||||
}
|
||||
provider.resolve(null)
|
||||
await expect(pending).resolves.toBe(snapshot)
|
||||
expect(firstFallback).not.toHaveBeenCalled()
|
||||
expect(receivers).toEqual([runtime, runtime])
|
||||
})
|
||||
it('skips driver reads for empty input and rechecks the current driver before a write', async () => {
|
||||
const runtime = new OrcaRuntimeService()
|
||||
const initialDriver = vi.fn(() => ({ kind: 'desktop' as const }))
|
||||
runtime.getDriver = initialDriver
|
||||
await expect(runtime.writeTerminalPreviewInput('view-pty', '')).resolves.toBe(false)
|
||||
expect(initialDriver).not.toHaveBeenCalled()
|
||||
const receivers: OrcaRuntimeService[] = []
|
||||
const writeChunks = runtime['writeTerminalInputChunks']
|
||||
const currentDriver = vi.fn(function (this: OrcaRuntimeService, _ptyId: string) {
|
||||
receivers.push(this)
|
||||
return { kind: 'mobile' as const, clientId: 'phone' }
|
||||
})
|
||||
runtime['writeTerminalInputChunks'] = vi.fn(function (
|
||||
this: OrcaRuntimeService,
|
||||
...args: Parameters<typeof writeChunks>
|
||||
) {
|
||||
receivers.push(this)
|
||||
runtime.getDriver = currentDriver
|
||||
return writeChunks.apply(this, args)
|
||||
})
|
||||
await expect(runtime.writeTerminalPreviewInput('view-pty', 'paste')).resolves.toBe(false)
|
||||
expect(runtime['writeTerminalInputChunks']).toHaveBeenCalledExactlyOnceWith(
|
||||
'view-pty',
|
||||
'paste',
|
||||
{
|
||||
inputKind: 'driving',
|
||||
inputSource: 'desktop'
|
||||
}
|
||||
)
|
||||
expect(initialDriver).not.toHaveBeenCalled()
|
||||
expect(currentDriver).toHaveBeenCalledExactlyOnceWith('view-pty')
|
||||
expect(receivers).toEqual([runtime, runtime])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,92 +0,0 @@
|
||||
import { expect, it, vi } from 'vitest'
|
||||
import type {
|
||||
RuntimeMobileSessionTabsResult,
|
||||
RuntimeMobileSessionTabsSnapshot
|
||||
} from '../../shared/runtime-types'
|
||||
|
||||
await import('./orca-runtime-tests/runtime-test-module-registration.spec')
|
||||
const { OrcaRuntimeService } = await import('./orca-runtime')
|
||||
await import('./orca-runtime-tests/orca-runtime-test-lifecycle.spec')
|
||||
const { store, TEST_WORKTREE_ID } =
|
||||
await import('./orca-runtime-tests/orca-runtime-test-fixtures.spec')
|
||||
const { committingSpawn } = await import('./orca-runtime-tests/bind-first-pane.test-fixture')
|
||||
|
||||
const retired = {
|
||||
parentTabId: 'tab',
|
||||
leafId: 'leaf',
|
||||
ptyId: 'pty',
|
||||
terminal: 'term_old',
|
||||
incarnationId: 'inc'
|
||||
}
|
||||
|
||||
type RuntimeInternals = {
|
||||
mobileSessionTabsByWorktree: Map<string, RuntimeMobileSessionTabsSnapshot>
|
||||
storeMobileSessionSnapshot: (
|
||||
worktreeId: string,
|
||||
snapshot: RuntimeMobileSessionTabsSnapshot
|
||||
) => RuntimeMobileSessionTabsSnapshot
|
||||
}
|
||||
|
||||
function seedRuntimeWithStoredProof(): {
|
||||
runtime: InstanceType<typeof OrcaRuntimeService>
|
||||
internals: RuntimeInternals
|
||||
} {
|
||||
const runtime = new OrcaRuntimeService(store)
|
||||
runtime.setPtyController({
|
||||
// As the PTY host's spawn commit: the new pane is bound before the terminal registers.
|
||||
spawn: vi.fn(committingSpawn(runtime, async () => ({ id: 'pty-runtime-fallback' }))),
|
||||
write: () => true,
|
||||
kill: () => true,
|
||||
getForegroundProcess: async () => null
|
||||
})
|
||||
runtime.syncWindowGraph(0, {
|
||||
tabs: [],
|
||||
leaves: [],
|
||||
mobileSessionTabs: [
|
||||
{
|
||||
worktree: TEST_WORKTREE_ID,
|
||||
publicationEpoch: 'headless:active-generation',
|
||||
snapshotVersion: 7,
|
||||
activeGroupId: null,
|
||||
activeTabId: null,
|
||||
activeTabType: null,
|
||||
tabs: []
|
||||
}
|
||||
]
|
||||
})
|
||||
const internals = runtime as unknown as RuntimeInternals
|
||||
const stored = internals.mobileSessionTabsByWorktree.get(TEST_WORKTREE_ID)!
|
||||
internals.storeMobileSessionSnapshot(TEST_WORKTREE_ID, {
|
||||
...stored,
|
||||
snapshotVersion: stored.snapshotVersion + 1,
|
||||
retiredTerminalSurfaces: [retired]
|
||||
})
|
||||
return { runtime, internals }
|
||||
}
|
||||
|
||||
// Subscribers dedupe epoch/version; emitting a pre-store frame at the stored version would hide proofs until another bump.
|
||||
it('emits the stored retirement proofs on the frame a runtime-owned create publishes', async () => {
|
||||
const { runtime, internals } = seedRuntimeWithStoredProof()
|
||||
const events: RuntimeMobileSessionTabsResult[] = []
|
||||
const unsubscribe = runtime.onMobileSessionTabsChanged(
|
||||
(frame) => events.push(frame),
|
||||
'paired-client'
|
||||
)
|
||||
|
||||
try {
|
||||
await runtime.createMobileSessionTerminal(`id:${TEST_WORKTREE_ID}`, {
|
||||
activate: false,
|
||||
select: false,
|
||||
navigation: 'caller',
|
||||
clientNavigationId: 'paired-client'
|
||||
})
|
||||
|
||||
const storedAfter = internals.mobileSessionTabsByWorktree.get(TEST_WORKTREE_ID)!
|
||||
const emitted = events.at(-1)!
|
||||
expect(storedAfter.retiredTerminalSurfaces).toEqual([retired])
|
||||
expect(emitted.snapshotVersion).toBe(storedAfter.snapshotVersion)
|
||||
expect(emitted.retiredTerminalSurfaces).toEqual([retired])
|
||||
} finally {
|
||||
unsubscribe()
|
||||
}
|
||||
})
|
||||
@@ -133,33 +133,6 @@ describe('run facts: the controller write funnel', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('records stream input from a client', async () => {
|
||||
const run = await createFreshRun()
|
||||
|
||||
await sendTerminalStreamInput(run.runtime, {
|
||||
terminal: run.handle,
|
||||
text: 'l',
|
||||
client: undefined,
|
||||
isMobile: false
|
||||
})
|
||||
|
||||
expect(run.firstUserInputAt()).not.toBeNull()
|
||||
})
|
||||
|
||||
it('records a dispatched agent prompt before its first write', async () => {
|
||||
vi.useFakeTimers()
|
||||
const run = await createFreshRun()
|
||||
|
||||
const submission = run.runtime.sendTerminalAgentPrompt(run.handle, 'review this', {
|
||||
inputKind: 'driving'
|
||||
})
|
||||
await vi.runAllTimersAsync()
|
||||
await submission.catch(() => undefined)
|
||||
|
||||
expect(run.firstUserInputAt()).not.toBeNull()
|
||||
expect(run.writes[0]?.inputRecorded).toBe(true)
|
||||
})
|
||||
|
||||
it('reads a run launched with a prompt as untyped until someone drives it', async () => {
|
||||
vi.useFakeTimers()
|
||||
const run = await createFreshRun()
|
||||
@@ -191,14 +164,6 @@ describe('run facts: the controller write funnel', () => {
|
||||
expect(run.runtime.terminalRunFacts.readLastDrivingInputAt(PTY_ID)).toBeNull()
|
||||
})
|
||||
|
||||
it('records nothing for a client query reply', async () => {
|
||||
const run = await createFreshRun()
|
||||
|
||||
await run.runtime.sendTerminal(run.handle, { text: 'y' }, { inputKind: 'query-reply' })
|
||||
|
||||
expect(run.firstUserInputAt()).toBeNull()
|
||||
})
|
||||
|
||||
it.each([
|
||||
['a terminal reply', '\x1b[3;4R'],
|
||||
['focus reports', '\x1b[I\x1b[O'],
|
||||
@@ -217,14 +182,6 @@ describe('run facts: the controller write funnel', () => {
|
||||
expect(run.firstUserInputAt()).toBeNull()
|
||||
})
|
||||
|
||||
it('records a reply mixed with a keystroke', async () => {
|
||||
const run = await createFreshRun()
|
||||
|
||||
await run.runtime.sendTerminal(run.handle, { text: '\x1b[3;4Rx' }, { inputKind: 'driving' })
|
||||
|
||||
expect(run.firstUserInputAt()).not.toBeNull()
|
||||
})
|
||||
|
||||
it('records dashboard preview typing before the write that could end the process', async () => {
|
||||
const run = await createFreshRun()
|
||||
|
||||
@@ -232,16 +189,6 @@ describe('run facts: the controller write funnel', () => {
|
||||
|
||||
expect(run.writes.map((write) => write.inputRecorded)).toEqual([true])
|
||||
})
|
||||
|
||||
it('records nothing for dashboard preview bytes that are only a reply or focus reports', async () => {
|
||||
const run = await createFreshRun()
|
||||
|
||||
await run.runtime.writeTerminalPreviewInput(PTY_ID, '\x1b[3;4R')
|
||||
await run.runtime.writeTerminalPreviewInput(PTY_ID, '\x1b[O\x1b[I')
|
||||
|
||||
expect(run.writes).toHaveLength(2)
|
||||
expect(run.firstUserInputAt()).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('run facts: a created worktree’s startup writes', () => {
|
||||
|
||||
@@ -100,17 +100,6 @@ describe('sendTerminal absence gate for leaf-branch writes', () => {
|
||||
expect(write).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('gates agent prompt sends behind the same proven-absence check', async () => {
|
||||
const probe = vi.fn(async () => false)
|
||||
const { runtime, handle, write } = await makeRuntimeWithLeafHandle({ probePtyLiveness: probe })
|
||||
|
||||
await expect(
|
||||
runtime.sendTerminalAgentPrompt(handle, 'do the thing', { inputKind: 'driving' })
|
||||
).rejects.toThrow('terminal_not_writable')
|
||||
|
||||
expect(write).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('proceeds when the probe answers unknown (null) — unknown is not absence', async () => {
|
||||
const { runtime, handle, write } = await makeRuntimeWithLeafHandle({
|
||||
probePtyLiveness: async () => null
|
||||
@@ -126,79 +115,6 @@ describe('sendTerminal absence gate for leaf-branch writes', () => {
|
||||
expect(write).toHaveBeenCalledWith(STALE_PTY_ID, 'ping', 'driving')
|
||||
})
|
||||
|
||||
it('treats a throwing probe as unknown and proceeds', async () => {
|
||||
const { runtime, handle, write } = await makeRuntimeWithLeafHandle({
|
||||
probePtyLiveness: async () => {
|
||||
throw new Error('probe transport down')
|
||||
}
|
||||
})
|
||||
|
||||
await expect(
|
||||
runtime.sendTerminal(handle, { text: 'ping' }, { inputKind: 'driving' })
|
||||
).resolves.toMatchObject({
|
||||
accepted: true
|
||||
})
|
||||
|
||||
expect(write).toHaveBeenCalledWith(STALE_PTY_ID, 'ping', 'driving')
|
||||
})
|
||||
|
||||
it('proceeds when the probe answers live (restored session before its pane remounts)', async () => {
|
||||
const { runtime, handle, write } = await makeRuntimeWithLeafHandle({
|
||||
probePtyLiveness: async () => true
|
||||
})
|
||||
|
||||
await expect(
|
||||
runtime.sendTerminal(handle, { text: 'ping' }, { inputKind: 'driving' })
|
||||
).resolves.toMatchObject({
|
||||
accepted: true
|
||||
})
|
||||
|
||||
expect(write).toHaveBeenCalledWith(STALE_PTY_ID, 'ping', 'driving')
|
||||
})
|
||||
|
||||
it('proceeds unchanged when the controller exposes no probe', async () => {
|
||||
const { runtime, handle, write } = await makeRuntimeWithLeafHandle({})
|
||||
|
||||
await expect(
|
||||
runtime.sendTerminal(handle, { text: 'ping' }, { inputKind: 'driving' })
|
||||
).resolves.toMatchObject({
|
||||
accepted: true
|
||||
})
|
||||
|
||||
expect(write).toHaveBeenCalledWith(STALE_PTY_ID, 'ping', 'driving')
|
||||
})
|
||||
|
||||
it('never probes when the provider synchronously knows the id (live pty)', async () => {
|
||||
const probe = vi.fn(async () => false)
|
||||
const { runtime, handle, write } = await makeRuntimeWithLeafHandle({
|
||||
probePtyLiveness: probe,
|
||||
hasPty: (ptyId) => ptyId === STALE_PTY_ID
|
||||
})
|
||||
|
||||
await expect(
|
||||
runtime.sendTerminal(handle, { text: 'ping' }, { inputKind: 'driving' })
|
||||
).resolves.toMatchObject({
|
||||
accepted: true
|
||||
})
|
||||
|
||||
expect(probe).not.toHaveBeenCalled()
|
||||
expect(write).toHaveBeenCalledWith(STALE_PTY_ID, 'ping', 'driving')
|
||||
})
|
||||
|
||||
it('reuses a proven-absent verdict across repeated sends instead of re-probing', async () => {
|
||||
const probe = vi.fn(async () => false)
|
||||
const { runtime, handle } = await makeRuntimeWithLeafHandle({ probePtyLiveness: probe })
|
||||
|
||||
await expect(
|
||||
runtime.sendTerminal(handle, { text: 'a' }, { inputKind: 'driving' })
|
||||
).rejects.toThrow('terminal_not_writable')
|
||||
await expect(
|
||||
runtime.sendTerminal(handle, { text: 'b' }, { inputKind: 'driving' })
|
||||
).rejects.toThrow('terminal_not_writable')
|
||||
|
||||
expect(probe).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('drops the cached absent verdict once the provider re-learns the id', async () => {
|
||||
const probe = vi.fn(async () => false)
|
||||
const livePtyIds = new Set<string>()
|
||||
@@ -615,37 +531,6 @@ describe('push-on-idle orchestration delivery absence gate', () => {
|
||||
expect(payloads.some((data) => data.includes('late completion'))).toBe(false)
|
||||
})
|
||||
|
||||
it('keeps messages queued instead of marking a proven-absent pty delivered', async () => {
|
||||
const { runtime, handle, write, stub } = await makeIdleLeafWithoutPtyRecord({
|
||||
probePtyLiveness: async () => false
|
||||
})
|
||||
stub.insert('lost forever?')
|
||||
|
||||
runtime.deliverPendingMessagesForHandle(handle)
|
||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||
|
||||
expect(write).not.toHaveBeenCalled()
|
||||
expect(stub.stageMailboxPointerEnter).not.toHaveBeenCalled()
|
||||
expect(stub.rows[0].delivered_at).toBeNull()
|
||||
})
|
||||
|
||||
it('still delivers on unknown liveness after the probe resolves', async () => {
|
||||
const { runtime, handle, write, stub } = await makeIdleLeafWithoutPtyRecord({
|
||||
probePtyLiveness: async () => null
|
||||
})
|
||||
stub.insert('hello')
|
||||
|
||||
runtime.deliverPendingMessagesForHandle(handle)
|
||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||
// Why twice: the probe continuation yields a turn before delivering.
|
||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||
|
||||
expect(write).toHaveBeenCalledWith(
|
||||
STALE_PTY_ID,
|
||||
expect.stringContaining('You have 1 orchestration message')
|
||||
)
|
||||
})
|
||||
|
||||
// Single-flight must span pointer through Enter; park new triggers until the sequence watermark advances.
|
||||
it('delivers once across concurrent probe triggers and an in-window re-trigger, then flushes parked rows', async () => {
|
||||
vi.useFakeTimers()
|
||||
@@ -703,45 +588,6 @@ describe('push-on-idle orchestration delivery absence gate', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('sync-path double-trigger inside the Enter window delivers the first batch once and parks the rest', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const probe = vi.fn(async () => null)
|
||||
const { runtime, handle, write, stub } = await makeIdleLeafWithoutPtyRecord({
|
||||
probePtyLiveness: probe,
|
||||
// Provider knows the id: delivery takes the pure synchronous path.
|
||||
hasPty: (ptyId) => ptyId === STALE_PTY_ID
|
||||
})
|
||||
stub.insert('first')
|
||||
|
||||
runtime.deliverPendingMessagesForHandle(handle)
|
||||
stub.insert('second')
|
||||
runtime.deliverPendingMessagesForHandle(handle)
|
||||
|
||||
const pointerWrites = () =>
|
||||
write.mock.calls.filter(
|
||||
([, data]) => typeof data === 'string' && data.includes('orchestration check')
|
||||
)
|
||||
expect(pointerWrites()).toHaveLength(1)
|
||||
expect(pointerWrites()[0]?.[1]).toContain('You have 1 orchestration message')
|
||||
expect(probe).not.toHaveBeenCalled()
|
||||
|
||||
// Settle flushes the parked trigger; both still-pending rows are counted,
|
||||
// while the newer sequence authorizes exactly one fresh pointer.
|
||||
await vi.advanceTimersByTimeAsync(500)
|
||||
expect(pointerWrites()).toHaveLength(2)
|
||||
expect(pointerWrites()[1]?.[1]).toContain('You have 1 orchestration message')
|
||||
|
||||
await vi.advanceTimersByTimeAsync(500)
|
||||
expect(stub.stageMailboxPointerEnter).toHaveBeenCalledTimes(2)
|
||||
expect(stub.rows.map((row) => row.delivered_at)).toEqual(
|
||||
stub.rows.map(() => expect.any(String))
|
||||
)
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
// Cold restore reuses the id; an Enter authorized by the dead incarnation must not reach its replacement.
|
||||
it('retires an armed Enter when the pty exits and respawns under the same id inside the window', async () => {
|
||||
vi.useFakeTimers()
|
||||
@@ -786,33 +632,6 @@ describe('push-on-idle orchestration delivery absence gate', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('cleans all delivery state on exit without id reuse — no leak, no stray settle effects', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const { runtime, handle, write, stub } = await makeIdleLeafWithoutPtyRecord({
|
||||
probePtyLiveness: async () => null,
|
||||
hasPty: (ptyId) => ptyId === STALE_PTY_ID
|
||||
})
|
||||
stub.insert('first')
|
||||
runtime.deliverPendingMessagesForHandle(handle)
|
||||
stub.insert('second')
|
||||
runtime.deliverPendingMessagesForHandle(handle)
|
||||
await Promise.resolve()
|
||||
|
||||
runtime.onPtyExit(STALE_PTY_ID, 0)
|
||||
|
||||
await vi.advanceTimersByTimeAsync(500)
|
||||
expect(write.mock.calls.filter(([, data]) => data === '\r')).toHaveLength(0)
|
||||
expect(stub.stageMailboxPointerEnter).toHaveBeenCalledOnce()
|
||||
expect(stub.releaseMailboxPointerEnter).toHaveBeenCalledOnce()
|
||||
// No stray settle flushed the parked trigger into the dead pty.
|
||||
expect(write).toHaveBeenCalledTimes(1)
|
||||
expect(stub.rows.every((row) => row.delivered_at === null)).toBe(true)
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
// Graph resync replaces leaf objects; read current writable state rather than a stale closure before Enter.
|
||||
it('does not fire a stale Enter through an orphaned leaf snapshot after resync and exit', async () => {
|
||||
vi.useFakeTimers()
|
||||
|
||||
@@ -364,62 +364,6 @@ describe('subscriber-driven daemon attach (never-activated tab)', () => {
|
||||
expect(model.attachCalls).toEqual([PTY_ID])
|
||||
})
|
||||
|
||||
it('never attaches SSH-scoped sessions or sessions absent from the daemon inventory', async () => {
|
||||
const { runtime, model } = setupNeverAttachedDaemonSession({ snapshotCapable: false })
|
||||
const sshPtyId = 'ssh:conn-9@@relay-pty-4'
|
||||
const sshRecord = internals(runtime).recordPtyWorktree(sshPtyId, WORKTREE_ID, {
|
||||
connected: true
|
||||
})
|
||||
const sshHandle = internals(runtime).issuePtyHandle(sshRecord)
|
||||
const absentPtyId = `${WORKTREE_ID}@@99999999`
|
||||
const absentRecord = internals(runtime).recordPtyWorktree(absentPtyId, WORKTREE_ID, {
|
||||
connected: true
|
||||
})
|
||||
const absentHandle = internals(runtime).issuePtyHandle(absentRecord)
|
||||
|
||||
const harness = startMultiplex(runtime)
|
||||
await vi.waitFor(() => expect(harness.handlers.has(0)).toBe(true))
|
||||
|
||||
sendSubscribe(harness, 1, sshHandle, 'client-ssh')
|
||||
await waitForSubscribed(harness, 1)
|
||||
expect(model.attachCalls).toEqual([])
|
||||
|
||||
// Absent session: subscribing must not create it or surface a new error.
|
||||
sendSubscribe(harness, 2, absentHandle, 'client-absent')
|
||||
await waitForSubscribed(harness, 2)
|
||||
await Promise.resolve()
|
||||
expect(model.sessions.has(absentPtyId)).toBe(false)
|
||||
expect(model.emitData(absentPtyId, 'ghost')).toBe(false)
|
||||
expect(harness.messages.some((message) => message.result?.type === 'error')).toBe(false)
|
||||
|
||||
// Unrelated live sessions are untouched by another pty's subscribers.
|
||||
expect(model.sessions.get(PTY_ID)?.attached).toBe(false)
|
||||
expect(model.attachCalls).not.toContain(PTY_ID)
|
||||
expect(model.resizeCalls).toEqual([])
|
||||
})
|
||||
|
||||
it('retries a refused attach for a later subscriber once the daemon learns the session', async () => {
|
||||
const { runtime, model, handle } = setupNeverAttachedDaemonSession({ snapshotCapable: false })
|
||||
// Degraded-daemon shape: main knows the record, the daemon does not own the
|
||||
// id yet, and the controller answers false rather than a no-op success.
|
||||
model.sessions.delete(PTY_ID)
|
||||
|
||||
const harness = startMultiplex(runtime)
|
||||
await vi.waitFor(() => expect(harness.handlers.has(0)).toBe(true))
|
||||
sendSubscribe(harness, 1, handle, 'client-a')
|
||||
await waitForSubscribed(harness, 1)
|
||||
await vi.waitFor(() => expect(model.attachCalls).toEqual([PTY_ID]))
|
||||
// A refused attach must not pin sticky success while the stream is blank.
|
||||
expect(model.emitData(PTY_ID, 'ghost')).toBe(false)
|
||||
|
||||
model.sessions.set(PTY_ID, { cols: 100, rows: 30, attached: false, screen: '' })
|
||||
sendSubscribe(harness, 2, handle, 'client-b')
|
||||
await waitForSubscribed(harness, 2)
|
||||
await vi.waitFor(() => expect(model.attachCalls).toEqual([PTY_ID, PTY_ID]))
|
||||
expect(model.emitData(PTY_ID, 'late daemon hello\r\n')).toBe(true)
|
||||
await vi.waitFor(() => expect(outputText(harness)).toContain('late daemon hello'))
|
||||
})
|
||||
|
||||
it('retries an existing subscriber when provider inventory becomes ready', async () => {
|
||||
const { runtime, model, handle } = setupNeverAttachedDaemonSession({
|
||||
snapshotCapable: false
|
||||
@@ -446,17 +390,6 @@ describe('subscriber-driven daemon attach (never-activated tab)', () => {
|
||||
await vi.waitFor(() => expect(outputText(harness)).toContain('after inventory'))
|
||||
})
|
||||
|
||||
it('attaches a surviving session at inventory adoption with no view subscribed', async () => {
|
||||
const { runtime, model, handle } = setupNeverAttachedDaemonSession({ snapshotCapable: false })
|
||||
|
||||
await internals(runtime).refreshPtyWorktreeRecordsWithControllerInventory([], null)
|
||||
|
||||
await vi.waitFor(() => expect(model.attachCalls).toEqual([PTY_ID]))
|
||||
expect(model.emitData(PTY_ID, 'worker still printing\r\n')).toBe(true)
|
||||
const read = await runtime.readTerminal(handle)
|
||||
expect(read.tail.join('\n')).toContain('worker still printing')
|
||||
})
|
||||
|
||||
it('streams a survivor a terminal.create retry adopts after a restart', async () => {
|
||||
const runtime = new OrcaRuntimeService()
|
||||
const model = createDaemonProviderModel({ snapshotCapable: false })
|
||||
@@ -519,25 +452,6 @@ describe('subscriber-driven daemon attach (never-activated tab)', () => {
|
||||
expect(read.tail).toEqual([])
|
||||
})
|
||||
|
||||
it('terminal read falls back to the provider tail for a never-attached live pty', async () => {
|
||||
const { runtime, handle } = setupNeverAttachedDaemonSession({
|
||||
snapshotCapable: true,
|
||||
screen: 'retained daemon line\r\n'
|
||||
})
|
||||
|
||||
const read = await runtime.readTerminal(handle)
|
||||
|
||||
expect(read.tail.join('\n')).toContain('retained daemon line')
|
||||
})
|
||||
|
||||
it('terminal read stays empty (not an error) when provider state is unprovable', async () => {
|
||||
const { runtime, handle } = setupNeverAttachedDaemonSession({ snapshotCapable: false })
|
||||
|
||||
const read = await runtime.readTerminal(handle)
|
||||
|
||||
expect(read.tail).toEqual([])
|
||||
})
|
||||
|
||||
it('terminal read keeps preferring the ingested model over provider state once attached', async () => {
|
||||
const { runtime, model, handle } = setupNeverAttachedDaemonSession({
|
||||
snapshotCapable: true,
|
||||
@@ -577,19 +491,6 @@ describe('re-attach after a daemon reconnect detached a spawned session', () =>
|
||||
internals(runtime).notePtyEndpointLoss?.(PTY_ID)
|
||||
}
|
||||
|
||||
it('takes back the stream of a session no view shows', async () => {
|
||||
const { runtime, model, handle } = setupSpawnedAttachedSession()
|
||||
|
||||
loseEndpoint(runtime, model)
|
||||
|
||||
await vi.waitFor(() => expect(model.attachCalls).toEqual([PTY_ID]))
|
||||
expect(model.emitData(PTY_ID, 'background agent output\r\n')).toBe(true)
|
||||
await expect(
|
||||
runtime.sendTerminal(handle, { text: 'typed' }, { inputKind: 'driving' })
|
||||
).resolves.toMatchObject({ accepted: true })
|
||||
expect(model.writes).toEqual(['typed'])
|
||||
})
|
||||
|
||||
it('holds input sent while the re-attach is in flight instead of refusing it', async () => {
|
||||
const { runtime, model, handle } = setupSpawnedAttachedSession()
|
||||
const releaseAttach = model.deferNextAttach(true)
|
||||
@@ -646,41 +547,6 @@ describe('re-attach after a daemon reconnect detached a spawned session', () =>
|
||||
expect(model.writes).toEqual([])
|
||||
})
|
||||
|
||||
it('leaves a failed re-attach to the next view or inventory pass, not to each keystroke', async () => {
|
||||
const { runtime, model, handle } = setupSpawnedAttachedSession()
|
||||
const releaseAttach = model.deferNextAttach(false)
|
||||
loseEndpoint(runtime, model)
|
||||
releaseAttach()
|
||||
await vi.waitFor(() => expect(model.attachCalls).toEqual([PTY_ID]))
|
||||
|
||||
await runtime.sendTerminal(handle, { text: 'a' }, { inputKind: 'driving' }).catch(() => {})
|
||||
await runtime.sendTerminal(handle, { text: 'b' }, { inputKind: 'driving' }).catch(() => {})
|
||||
expect(model.attachCalls).toEqual([PTY_ID])
|
||||
|
||||
const harness = startMultiplex(runtime)
|
||||
await vi.waitFor(() => expect(harness.handlers.has(0)).toBe(true))
|
||||
sendSubscribe(harness, 1, handle)
|
||||
await waitForSubscribed(harness, 1)
|
||||
await vi.waitFor(() => expect(model.attachCalls).toEqual([PTY_ID, PTY_ID]))
|
||||
expect(model.emitData(PTY_ID, 'live again\r\n')).toBe(true)
|
||||
})
|
||||
|
||||
it('redoes an earlier subscriber-driven attach that the loss undid', async () => {
|
||||
// Never-attached session: the first remote subscribe attaches it (the restored-tab path).
|
||||
const { runtime, model, handle } = setupNeverAttachedDaemonSession({ snapshotCapable: false })
|
||||
const harness = startMultiplex(runtime)
|
||||
await vi.waitFor(() => expect(harness.handlers.has(0)).toBe(true))
|
||||
sendSubscribe(harness, 1, handle)
|
||||
await waitForSubscribed(harness, 1)
|
||||
await vi.waitFor(() => expect(model.attachCalls).toEqual([PTY_ID]))
|
||||
|
||||
loseEndpoint(runtime, model)
|
||||
|
||||
await vi.waitFor(() => expect(model.attachCalls).toEqual([PTY_ID, PTY_ID]))
|
||||
expect(model.emitData(PTY_ID, 'second life\r\n')).toBe(true)
|
||||
await vi.waitFor(() => expect(outputText(harness)).toContain('second life'))
|
||||
})
|
||||
|
||||
it('attaches again when another loss lands while the re-attach is in flight', async () => {
|
||||
const { runtime, model } = setupSpawnedAttachedSession()
|
||||
const releaseFirst = model.deferNextAttach(true)
|
||||
|
||||
@@ -47,23 +47,6 @@ afterEach(() => {
|
||||
})
|
||||
|
||||
describe("an update's last terminal census", () => {
|
||||
it('refuses every new terminal until the stop, with a retryable message', async () => {
|
||||
collect.mockResolvedValue(idle)
|
||||
const runtime = new OrcaRuntimeService(store)
|
||||
await expect(census(runtime, updateRecount)).resolves.toMatchObject({
|
||||
liveSessions: 0,
|
||||
admissionClosedUntil: expect.any(Number)
|
||||
})
|
||||
|
||||
await expect(runtime.acquireWorktreeTerminalSpawn(target)).rejects.toThrow(
|
||||
TERMINAL_UPDATE_ADMISSION_CLOSED_MESSAGE
|
||||
)
|
||||
// A spawn outside any worktree dies with the stop all the same.
|
||||
await expect(runtime.acquireWorktreeTerminalSpawn(undefined)).rejects.toThrow(
|
||||
TERMINAL_UPDATE_ADMISSION_CLOSED_MESSAGE
|
||||
)
|
||||
})
|
||||
|
||||
it('counts only after a spawn admitted before it has finished starting', async () => {
|
||||
collect.mockResolvedValue({ ...idle, liveSessions: 1 })
|
||||
const runtime = new OrcaRuntimeService(store)
|
||||
@@ -96,19 +79,4 @@ describe("an update's last terminal census", () => {
|
||||
runtime.acquireWorktreeTerminalSpawn(target, { reattachSessionId: 'pty-live' })
|
||||
).rejects.toThrow(TERMINAL_UPDATE_ADMISSION_CLOSED_MESSAGE)
|
||||
})
|
||||
|
||||
it('reopens admission when a deferred update asks with 0, and a plain census never closes it', async () => {
|
||||
collect.mockResolvedValue(idle)
|
||||
const runtime = new OrcaRuntimeService(store)
|
||||
await expect(census(runtime, { releaseFinishedAutomationTerminals: true })).resolves.toEqual(
|
||||
idle
|
||||
)
|
||||
;(await runtime.acquireWorktreeTerminalSpawn(target))()
|
||||
|
||||
await census(runtime, updateRecount)
|
||||
await expect(census(runtime, { closeAdmissionForMs: 0 })).resolves.toMatchObject({
|
||||
admissionClosedUntil: 0
|
||||
})
|
||||
;(await runtime.acquireWorktreeTerminalSpawn(target))()
|
||||
})
|
||||
})
|
||||
|
||||
-105
@@ -1,105 +0,0 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { LOCAL_EXECUTION_HOST_ID } from '../../../../shared/execution-host'
|
||||
import type { RuntimeMobileSessionTabsResult } from '../../../../shared/runtime-session-contracts'
|
||||
import type { RuntimeTerminalOrphanAdoptionRequest } from '../../../../shared/runtime-terminal-contracts'
|
||||
import type { WorkspaceSessionState } from '../../../../shared/workspace-session/workspace-session-state-types'
|
||||
import {
|
||||
GIT_KEY,
|
||||
addWorkspace,
|
||||
emptySession,
|
||||
leaf
|
||||
} from '../../../../shared/workspace-layout/workspace-layout-session.test-fixture'
|
||||
import { adoptRuntimeTerminalOrphansFromInventory } from './runtime-terminal-orphan-adoption'
|
||||
import type { RuntimePtyWorktreeRecord } from '../records/runtime-terminal-state-records'
|
||||
import { paneOfPty } from '../surface/pty-pane-query'
|
||||
|
||||
const TAB = 'tab-1'
|
||||
const KEPT = leaf(1)
|
||||
const DUPLICATE = leaf(2)
|
||||
|
||||
function duplicateSession(): WorkspaceSessionState {
|
||||
const session = addWorkspace(emptySession(), GIT_KEY, [
|
||||
{
|
||||
id: 'group-1',
|
||||
tabs: [
|
||||
{
|
||||
id: TAB,
|
||||
leaves: [
|
||||
[KEPT, 'pty-1'],
|
||||
[DUPLICATE, 'pty-1']
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
])
|
||||
return { ...session, terminalPtyIncarnationsByPaneKey: { [`${TAB}:${KEPT}`]: 'inc-1' } }
|
||||
}
|
||||
|
||||
function adoptKeptPane(session: WorkspaceSessionState) {
|
||||
const request: RuntimeTerminalOrphanAdoptionRequest = {
|
||||
worktree: GIT_KEY,
|
||||
expectedTopologyRevision: 0,
|
||||
claims: [
|
||||
{ terminal: 'term-1', ptyId: 'pty-1', incarnationId: 'inc-1', tabId: TAB, leafId: KEPT }
|
||||
]
|
||||
}
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: adoption reads only these PTY fields before the persisted-binding check.
|
||||
const pty = {
|
||||
ptyId: 'pty-1',
|
||||
incarnationId: 'inc-1',
|
||||
worktreeId: GIT_KEY,
|
||||
connectionId: null,
|
||||
connected: true,
|
||||
tabId: null,
|
||||
paneKey: null
|
||||
} as RuntimePtyWorktreeRecord
|
||||
const replayPersistedSurface = vi.fn()
|
||||
const adopted = adoptRuntimeTerminalOrphansFromInventory({
|
||||
hostId: LOCAL_EXECUTION_HOST_ID,
|
||||
request,
|
||||
workspace: {
|
||||
id: GIT_KEY,
|
||||
path: '/Users/dev/orca',
|
||||
connectionId: null,
|
||||
repo: null,
|
||||
folderWorkspace: null
|
||||
},
|
||||
inventory: {
|
||||
livePtyIds: new Set(['pty-1']),
|
||||
allLivePtyIds: new Set(['pty-1']),
|
||||
terminalIdentityByPtyId: new Map([
|
||||
['pty-1', { handle: 'term-1', incarnationId: 'inc-1', wslDistro: null }]
|
||||
]),
|
||||
queriedHostIds: new Set([LOCAL_EXECUTION_HOST_ID])
|
||||
},
|
||||
repoId: 'repo-1',
|
||||
worktreeWslDistro: null,
|
||||
ports: {
|
||||
readSession: () => ({ session, sessionWorktreeId: GIT_KEY, currentRevision: 0 }),
|
||||
getPty: () => pty,
|
||||
paneOf: (record) => paneOfPty(record, () => null),
|
||||
getLeaves: () => [],
|
||||
getLeaf: () => undefined,
|
||||
getDisplayTitle: () => null,
|
||||
replayPersistedSurface,
|
||||
recordAdoptedSurface: vi.fn(),
|
||||
getMobileSnapshots: () => [],
|
||||
getSession: () => session,
|
||||
setSession: vi.fn(),
|
||||
flushSession: async () => {},
|
||||
hydrateSession: vi.fn(),
|
||||
notifySessionChanged: vi.fn(),
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the replay path returns this value untouched.
|
||||
getSnapshot: () => ({}) as RuntimeMobileSessionTabsResult
|
||||
}
|
||||
})
|
||||
return { adopted, replayPersistedSurface }
|
||||
}
|
||||
|
||||
describe('orphan adoption of a terminal main repaired at load', () => {
|
||||
it('refuses the stored duplicate as a competing owner (the reader that skips the Loader)', async () => {
|
||||
await expect(adoptKeptPane(duplicateSession()).adopted).rejects.toThrow(
|
||||
'terminal_orphan_competing_owner'
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -1,73 +0,0 @@
|
||||
import { expect, it } from 'vitest'
|
||||
import { LOCAL_EXECUTION_HOST_ID } from '../../../../shared/execution-host'
|
||||
import { makePaneKey } from '../../../../shared/workspace-layout/stable-pane-id'
|
||||
import type { RuntimeTerminalOrphanAdoptionRequest } from '../../../../shared/runtime-terminal-contracts'
|
||||
import { collectLayoutLeafIdsInOrder } from '../../../../shared/workspace-layout/terminal-pane-tree'
|
||||
import {
|
||||
GIT_KEY,
|
||||
addWorkspace,
|
||||
emptySession,
|
||||
leaf
|
||||
} from '../../../../shared/workspace-layout/workspace-layout-session.test-fixture'
|
||||
import { buildRuntimeTerminalOrphanSession } from './runtime-terminal-orphan-session-adoption'
|
||||
import type { RuntimePtyWorktreeRecord } from '../records/runtime-terminal-state-records'
|
||||
|
||||
const TAB = 'tab-1'
|
||||
const HEALTHY = leaf(1)
|
||||
const ORPHAN = leaf(2)
|
||||
|
||||
function adopt(existingLeaves: [string, string?][]) {
|
||||
const session = addWorkspace(emptySession(), GIT_KEY, [
|
||||
{ id: 'group-1', tabs: [{ id: TAB, leaves: existingLeaves }] }
|
||||
])
|
||||
const claim = {
|
||||
terminal: 'terminal-orphan',
|
||||
ptyId: 'pty-orphan',
|
||||
incarnationId: 'incarnation-orphan',
|
||||
tabId: TAB,
|
||||
leafId: ORPHAN
|
||||
}
|
||||
const topology: NonNullable<RuntimeTerminalOrphanAdoptionRequest['topology']> = {
|
||||
tabs: [
|
||||
{
|
||||
tabId: TAB,
|
||||
root: { type: 'leaf', leafId: ORPHAN },
|
||||
activeLeafId: ORPHAN,
|
||||
expandedLeafId: null
|
||||
}
|
||||
],
|
||||
groups: [{ id: 'group-1', activeTabId: TAB, tabOrder: [TAB] }]
|
||||
}
|
||||
const request: RuntimeTerminalOrphanAdoptionRequest = {
|
||||
worktree: GIT_KEY,
|
||||
expectedTopologyRevision: 0,
|
||||
claims: [claim],
|
||||
topology
|
||||
}
|
||||
return buildRuntimeTerminalOrphanSession({
|
||||
hostId: LOCAL_EXECUTION_HOST_ID,
|
||||
session,
|
||||
sessionWorktreeId: GIT_KEY,
|
||||
worktreeId: GIT_KEY,
|
||||
request,
|
||||
validated: [
|
||||
{
|
||||
claim,
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the tab already exists, so the builder never reads the PTY record.
|
||||
pty: {} as RuntimePtyWorktreeRecord,
|
||||
paneKey: makePaneKey(TAB, ORPHAN)
|
||||
}
|
||||
],
|
||||
topologyTabsById: new Map(topology.tabs.map((tab) => [tab.tabId, tab])),
|
||||
topologyGroups: topology.groups,
|
||||
getDisplayTitle: () => null
|
||||
})
|
||||
}
|
||||
|
||||
it('keeps the healthy sibling pane and its PTY when one split pane is recovered', () => {
|
||||
const layout = adopt([[HEALTHY, 'pty-healthy']]).terminalLayoutsByTabId[TAB]
|
||||
|
||||
expect(collectLayoutLeafIdsInOrder(layout?.root)).toEqual([HEALTHY, ORPHAN])
|
||||
expect(layout?.ptyIdsByLeafId).toEqual({ [HEALTHY]: 'pty-healthy', [ORPHAN]: 'pty-orphan' })
|
||||
expect(layout?.activeLeafId).toBe(ORPHAN)
|
||||
})
|
||||
-136
@@ -1,136 +0,0 @@
|
||||
import { expect, it } from 'vitest'
|
||||
import type { RuntimeTerminalOrphanAdoptionRequest } from '../../../../shared/runtime-terminal-contracts'
|
||||
import { validateRuntimeTerminalOrphanTopology } from './runtime-terminal-orphan-topology-validation'
|
||||
|
||||
function fixture(count: number): RuntimeTerminalOrphanAdoptionRequest {
|
||||
const ids = Array.from({ length: count }, (_, index) => `tab-${index}`)
|
||||
return {
|
||||
worktree: 'folder-workspace',
|
||||
expectedTopologyRevision: 1,
|
||||
claims: ids.map((tabId) => ({
|
||||
tabId,
|
||||
leafId: tabId,
|
||||
terminal: tabId,
|
||||
ptyId: tabId,
|
||||
incarnationId: tabId
|
||||
})) as RuntimeTerminalOrphanAdoptionRequest['claims'],
|
||||
topology: {
|
||||
tabs: ids.map((tabId) => ({
|
||||
tabId,
|
||||
root: { type: 'leaf', leafId: tabId },
|
||||
activeLeafId: tabId,
|
||||
expandedLeafId: null
|
||||
})),
|
||||
groups: [{ id: 'g', activeTabId: ids[0], tabOrder: ids, recentTabIds: ids.toReversed() }]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
it('validates large restored MRU lists with linear tab-order reads', () => {
|
||||
const request = fixture(1000)
|
||||
let reads = 0
|
||||
const group = request.topology!.groups[0]
|
||||
group.tabOrder = new Proxy(group.tabOrder, {
|
||||
get(target, key, receiver) {
|
||||
if (typeof key === 'string' && /^\d+$/.test(key)) {
|
||||
reads += 1
|
||||
}
|
||||
// oxlint-disable-next-line anti-slop/no-reflect-get -- Proxy get trap default forward.
|
||||
return Reflect.get(target, key, receiver)
|
||||
}
|
||||
})
|
||||
expect(
|
||||
validateRuntimeTerminalOrphanTopology(
|
||||
request,
|
||||
request.claims.map((claim) => ({ claim }))
|
||||
).topologyTabsById.size
|
||||
).toBe(1000)
|
||||
expect(reads).toBeLessThanOrEqual(3000)
|
||||
})
|
||||
|
||||
it.each(['duplicate', 'foreign-recent', 'foreign-active'])(
|
||||
'rejects %s group membership',
|
||||
(kind) => {
|
||||
const request = fixture(2)
|
||||
const group = request.topology!.groups[0]
|
||||
if (kind === 'duplicate') {
|
||||
group.tabOrder.push(group.tabOrder[0])
|
||||
}
|
||||
if (kind === 'foreign-recent') {
|
||||
group.recentTabIds = ['foreign']
|
||||
}
|
||||
if (kind === 'foreign-active') {
|
||||
group.activeTabId = 'foreign'
|
||||
}
|
||||
expect(() =>
|
||||
validateRuntimeTerminalOrphanTopology(
|
||||
request,
|
||||
request.claims.map((claim) => ({ claim }))
|
||||
)
|
||||
).toThrow('terminal_orphan_topology_invalid')
|
||||
}
|
||||
)
|
||||
|
||||
function validate(request: RuntimeTerminalOrphanAdoptionRequest) {
|
||||
return validateRuntimeTerminalOrphanTopology(
|
||||
request,
|
||||
request.claims.map((claim) => ({ claim }))
|
||||
)
|
||||
}
|
||||
|
||||
type Groups = NonNullable<RuntimeTerminalOrphanAdoptionRequest['topology']>['groups']
|
||||
|
||||
function withGroups(count: number, groups: Groups): RuntimeTerminalOrphanAdoptionRequest {
|
||||
const request = fixture(count)
|
||||
request.topology!.groups = groups
|
||||
return request
|
||||
}
|
||||
|
||||
// Tab uniqueness is global across groups; per-group membership alone permits cross-wired duplicate adoption.
|
||||
it('rejects a tab claimed by two different groups', () => {
|
||||
expect(() =>
|
||||
validate(
|
||||
withGroups(2, [
|
||||
{ id: 'g1', activeTabId: 'tab-0', tabOrder: ['tab-0', 'tab-1'], recentTabIds: [] },
|
||||
{ id: 'g2', activeTabId: 'tab-1', tabOrder: ['tab-1'], recentTabIds: [] }
|
||||
])
|
||||
)
|
||||
).toThrow('terminal_orphan_topology_invalid')
|
||||
})
|
||||
|
||||
it('rejects a duplicated group id', () => {
|
||||
expect(() =>
|
||||
validate(
|
||||
withGroups(2, [
|
||||
{ id: 'g', activeTabId: 'tab-0', tabOrder: ['tab-0'], recentTabIds: [] },
|
||||
{ id: 'g', activeTabId: 'tab-1', tabOrder: ['tab-1'], recentTabIds: [] }
|
||||
])
|
||||
)
|
||||
).toThrow('terminal_orphan_topology_invalid')
|
||||
})
|
||||
|
||||
// Empty tab order cannot hold its active tab; fail closed instead of selecting an arbitrary pane.
|
||||
it('rejects an empty tab order', () => {
|
||||
expect(() =>
|
||||
validate(withGroups(2, [{ id: 'g', activeTabId: 'tab-0', tabOrder: [], recentTabIds: [] }]))
|
||||
).toThrow('terminal_orphan_topology_invalid')
|
||||
})
|
||||
|
||||
it('rejects a claimed tab that no group lists', () => {
|
||||
expect(() =>
|
||||
validate(
|
||||
withGroups(2, [{ id: 'g', activeTabId: 'tab-0', tabOrder: ['tab-0'], recentTabIds: [] }])
|
||||
)
|
||||
).toThrow('terminal_orphan_topology_invalid')
|
||||
})
|
||||
|
||||
it.each([
|
||||
['omitted', undefined],
|
||||
['empty', [] as string[]]
|
||||
])('accepts %s recentTabIds', (_label, recentTabIds) => {
|
||||
expect(
|
||||
validate(
|
||||
withGroups(2, [{ id: 'g', activeTabId: 'tab-0', tabOrder: ['tab-0', 'tab-1'], recentTabIds }])
|
||||
).topologyTabsById.size
|
||||
).toBe(2)
|
||||
})
|
||||
@@ -1,107 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { mergeTerminalOrphanGroupLayout } from './terminal-orphan-topology'
|
||||
|
||||
describe('terminal orphan topology', () => {
|
||||
it('keeps current host layout while preserving an unrelated proposed subtree', () => {
|
||||
expect(
|
||||
mergeTerminalOrphanGroupLayout({
|
||||
existingLayout: { type: 'leaf', groupId: 'group-live' },
|
||||
existingGroupIds: ['group-live'],
|
||||
proposedLayout: {
|
||||
type: 'split',
|
||||
direction: 'vertical',
|
||||
ratio: 0.6,
|
||||
first: { type: 'leaf', groupId: 'group-old-left' },
|
||||
second: { type: 'leaf', groupId: 'group-old-right' }
|
||||
},
|
||||
proposedGroupIds: ['group-old-left', 'group-old-right'],
|
||||
mergedGroupIds: ['group-live', 'group-old-left', 'group-old-right']
|
||||
})
|
||||
).toEqual({
|
||||
type: 'split',
|
||||
direction: 'vertical',
|
||||
ratio: 0.6,
|
||||
first: { type: 'leaf', groupId: 'group-live' },
|
||||
second: {
|
||||
type: 'split',
|
||||
direction: 'vertical',
|
||||
ratio: 0.6,
|
||||
first: { type: 'leaf', groupId: 'group-old-left' },
|
||||
second: { type: 'leaf', groupId: 'group-old-right' }
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it('grafts proposed groups beside their one current anchor without duplicating it', () => {
|
||||
expect(
|
||||
mergeTerminalOrphanGroupLayout({
|
||||
existingLayout: {
|
||||
type: 'split',
|
||||
direction: 'vertical',
|
||||
first: { type: 'leaf', groupId: 'group-live' },
|
||||
second: { type: 'leaf', groupId: 'group-other' }
|
||||
},
|
||||
existingGroupIds: ['group-live', 'group-other'],
|
||||
proposedLayout: {
|
||||
type: 'split',
|
||||
direction: 'horizontal',
|
||||
ratio: 0.7,
|
||||
first: { type: 'leaf', groupId: 'group-live' },
|
||||
second: { type: 'leaf', groupId: 'group-recovered' }
|
||||
},
|
||||
proposedGroupIds: ['group-live', 'group-recovered'],
|
||||
mergedGroupIds: ['group-live', 'group-other', 'group-recovered']
|
||||
})
|
||||
).toEqual({
|
||||
type: 'split',
|
||||
direction: 'vertical',
|
||||
first: {
|
||||
type: 'split',
|
||||
direction: 'horizontal',
|
||||
ratio: 0.7,
|
||||
first: { type: 'leaf', groupId: 'group-live' },
|
||||
second: { type: 'leaf', groupId: 'group-recovered' }
|
||||
},
|
||||
second: { type: 'leaf', groupId: 'group-other' }
|
||||
})
|
||||
})
|
||||
|
||||
it('appends new groups when multiple shared anchors make placement ambiguous', () => {
|
||||
expect(
|
||||
mergeTerminalOrphanGroupLayout({
|
||||
existingLayout: {
|
||||
type: 'split',
|
||||
direction: 'horizontal',
|
||||
first: { type: 'leaf', groupId: 'group-a' },
|
||||
second: { type: 'leaf', groupId: 'group-b' }
|
||||
},
|
||||
existingGroupIds: ['group-a', 'group-b'],
|
||||
proposedLayout: {
|
||||
type: 'split',
|
||||
direction: 'vertical',
|
||||
ratio: 0.7,
|
||||
first: {
|
||||
type: 'split',
|
||||
direction: 'horizontal',
|
||||
first: { type: 'leaf', groupId: 'group-a' },
|
||||
second: { type: 'leaf', groupId: 'group-b' }
|
||||
},
|
||||
second: { type: 'leaf', groupId: 'group-recovered' }
|
||||
},
|
||||
proposedGroupIds: ['group-a', 'group-b', 'group-recovered'],
|
||||
mergedGroupIds: ['group-a', 'group-b', 'group-recovered']
|
||||
})
|
||||
).toEqual({
|
||||
type: 'split',
|
||||
direction: 'vertical',
|
||||
ratio: 0.7,
|
||||
first: {
|
||||
type: 'split',
|
||||
direction: 'horizontal',
|
||||
first: { type: 'leaf', groupId: 'group-a' },
|
||||
second: { type: 'leaf', groupId: 'group-b' }
|
||||
},
|
||||
second: { type: 'leaf', groupId: 'group-recovered' }
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -55,16 +55,6 @@ async function hostTabRows(runtime: ReturnType<typeof restartedHost>) {
|
||||
}
|
||||
|
||||
describe('a terminal record arriving after main composed its panes (switch on)', () => {
|
||||
it('lists the restored pane once, with the terminal its saved layout binds', async () => {
|
||||
const runtime = restartedHost('inc-now', () => [
|
||||
{ id: 'persisted-pty', incarnationId: 'inc-now', connected: true }
|
||||
])
|
||||
|
||||
expect(await hostTabRows(runtime)).toEqual([
|
||||
{ ptyId: 'persisted-pty', incarnationId: 'inc-now' }
|
||||
])
|
||||
})
|
||||
|
||||
it('re-composes when the record learns the incarnation the pane is stamped with', async () => {
|
||||
let incarnationId = 'inc-before'
|
||||
const runtime = restartedHost('inc-now', () => [
|
||||
@@ -78,39 +68,4 @@ describe('a terminal record arriving after main composed its panes (switch on)',
|
||||
{ ptyId: 'persisted-pty', incarnationId: 'inc-now' }
|
||||
])
|
||||
})
|
||||
|
||||
it('publishes nothing when an unchanged inventory refresh records the same terminal', async () => {
|
||||
const runtime = restartedHost('inc-now', () => [
|
||||
{ id: 'persisted-pty', incarnationId: 'inc-now', connected: true }
|
||||
])
|
||||
await hostTabRows(runtime)
|
||||
await new Promise((resolve) => setImmediate(resolve))
|
||||
const published = vi.spyOn(runtime, 'syncWindowGraph')
|
||||
|
||||
await hostTabRows(runtime)
|
||||
await new Promise((resolve) => setImmediate(resolve))
|
||||
|
||||
expect(published).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('re-composes when the record moves to another workspace', async () => {
|
||||
const runtime = restartedHost('inc-now', () => [
|
||||
{ id: 'persisted-pty', incarnationId: 'inc-now', connected: true }
|
||||
])
|
||||
// A window publishes, so main republishes its panes when a record changes.
|
||||
runtime.attachWindow(1)
|
||||
runtime.syncWindowGraph(1, { tabs: [], leaves: [], mountedLeaves: [] })
|
||||
expect(await hostTabRows(runtime)).toEqual([
|
||||
{ ptyId: 'persisted-pty', incarnationId: 'inc-now' }
|
||||
])
|
||||
|
||||
await new Promise((resolve) => setImmediate(resolve))
|
||||
const published = vi.spyOn(runtime, 'syncWindowGraph')
|
||||
// Simulate a worktree-identity migration.
|
||||
runtime['recordPtyWorktree']('persisted-pty', 'repo-1::/tmp/worktree-b')
|
||||
await new Promise((resolve) => setImmediate(resolve))
|
||||
|
||||
expect(runtime['paneOfPty'](runtime['ptysById'].get('persisted-pty')).tabId).toBeNull()
|
||||
expect(published).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
/** Absent tab rows mean never initialized; explicit empty rows are closed-last-tab tombstones. Re-keying must preserve that distinction. */
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { getDefaultWorkspaceSession } from '../../../../shared/constants'
|
||||
import type { WorkspaceSessionState } from '../../../../shared/workspace-session/workspace-session-state-types'
|
||||
import { canonicalizeTerminalSessionWorktreeId } from './workspace-session-worktree-id'
|
||||
|
||||
const SOURCE = 'repo-1::/old/path'
|
||||
const TARGET = 'repo-1::/new/path'
|
||||
|
||||
function session(overrides: Partial<WorkspaceSessionState> = {}): WorkspaceSessionState {
|
||||
return { ...getDefaultWorkspaceSession(), ...overrides }
|
||||
}
|
||||
|
||||
describe('canonicalizeTerminalSessionWorktreeId', () => {
|
||||
it('does not fabricate a tombstone for a workspace that had no tabs row', () => {
|
||||
const next = session({ tabsByWorktree: {} })
|
||||
|
||||
canonicalizeTerminalSessionWorktreeId(next, SOURCE, TARGET)
|
||||
|
||||
expect(Object.hasOwn(next.tabsByWorktree, TARGET)).toBe(false)
|
||||
})
|
||||
|
||||
it('carries a real closed-last-terminal tombstone across the re-key', () => {
|
||||
// Explicit empty rows preserve the user's closed-last-terminal intent.
|
||||
const next = session({ tabsByWorktree: { [SOURCE]: [] } })
|
||||
|
||||
canonicalizeTerminalSessionWorktreeId(next, SOURCE, TARGET)
|
||||
|
||||
expect(Object.hasOwn(next.tabsByWorktree, TARGET)).toBe(true)
|
||||
expect(next.tabsByWorktree[TARGET]).toEqual([])
|
||||
expect(Object.hasOwn(next.tabsByWorktree, SOURCE)).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -1,9 +1,9 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { expect, it, vi } from 'vitest'
|
||||
import type { RuntimeNotifier } from '../../runtime-notifier-contract'
|
||||
import { buildMobileTerminalChatFork } from './mobile-terminal-chat-fork'
|
||||
import './mobile-terminal-chat-fork'
|
||||
import { prepareStructuredChatCliFork } from '../../../ai-vault/structured-chat-cli-fork'
|
||||
import { OrchestrationError } from '../../orchestration/orchestration-error'
|
||||
import { PROJECT_RUNTIME_REPAIR_REQUIRED_CODE } from '../../../../shared/project-runtime-repair-message'
|
||||
import '../../orchestration/orchestration-error'
|
||||
import '../../../../shared/project-runtime-repair-message'
|
||||
import '../../orca-runtime-tests/orca-runtime-test-lifecycle.spec'
|
||||
import { committingSpawn } from '../../orca-runtime-tests/bind-first-pane.test-fixture'
|
||||
import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../../shared/layout-owner-switch'
|
||||
@@ -14,65 +14,6 @@ vi.mock('../../../ai-vault/structured-chat-cli-fork', () => ({
|
||||
}))
|
||||
const cwd = process.platform === 'win32' ? 'C:\\repo' : '/tmp/repo'
|
||||
const filePath = process.platform === 'win32' ? 'C:\\sessions\\chat.jsonl' : '/tmp/chat.jsonl'
|
||||
const runtime = {
|
||||
platform: process.platform,
|
||||
pty: { shellOverride: undefined, terminalWindowsWslDistro: null }
|
||||
}
|
||||
|
||||
describe('native chat fork launch recipe', () => {
|
||||
it('keeps Manual empty args, custom command and environment in an independent fork', () => {
|
||||
const startup = buildMobileTerminalChatFork({
|
||||
prepared: {
|
||||
useRealCodexHome: false,
|
||||
cliForkSource: {
|
||||
agent: 'claude',
|
||||
sourceAgent: 'claude',
|
||||
sessionId: 'original-chat',
|
||||
filePath,
|
||||
cwd,
|
||||
codexHome: null,
|
||||
executionHostPlatform: process.platform
|
||||
}
|
||||
},
|
||||
settings: {
|
||||
agentCmdOverrides: { claude: 'my-claude' },
|
||||
agentDefaultArgs: { claude: '' },
|
||||
agentDefaultEnv: { claude: { MY_SETTING: 'present' } }
|
||||
},
|
||||
runtime
|
||||
})
|
||||
expect(startup.command).toContain('my-claude')
|
||||
expect(startup.command).toContain('original-chat')
|
||||
expect(startup.command).toContain('fork-session')
|
||||
expect(startup.env).toMatchObject({ MY_SETTING: 'present' })
|
||||
expect(startup.cwd).toBe(cwd)
|
||||
expect(startup.launchRuntime).toBe(runtime)
|
||||
expect(startup).not.toHaveProperty('resumeProviderSession')
|
||||
})
|
||||
|
||||
it('opens the host copy without reusing the chat identity', () => {
|
||||
const startup = buildMobileTerminalChatFork({
|
||||
prepared: {
|
||||
useRealCodexHome: false,
|
||||
forkedSession: { agent: 'opencode', sessionId: 'copy', filePath },
|
||||
cliForkSource: {
|
||||
agent: 'opencode',
|
||||
sourceAgent: 'opencode',
|
||||
sessionId: 'copy',
|
||||
filePath,
|
||||
cwd,
|
||||
codexHome: null,
|
||||
executionHostPlatform: process.platform
|
||||
}
|
||||
},
|
||||
settings: { agentDefaultArgs: { opencode: '' } },
|
||||
runtime
|
||||
})
|
||||
expect(startup.command).toContain('copy')
|
||||
expect(startup.command).not.toContain('original-chat')
|
||||
expect(startup.resumeProviderSession?.id).toBe('copy')
|
||||
})
|
||||
})
|
||||
|
||||
it.each([true, false])(
|
||||
'forks a source-selected chat with saved placement available: %s',
|
||||
@@ -223,53 +164,3 @@ it.each([true, false])(
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
it('keeps preparation and runtime failures from starting a terminal', async () => {
|
||||
await import('../../orca-runtime-tests/runtime-test-module-registration.spec')
|
||||
const { OrcaRuntimeService } = await import('../../orca-runtime')
|
||||
const { store, TEST_WORKTREE_ID } =
|
||||
await import('../../orca-runtime-tests/orca-runtime-test-fixtures.spec')
|
||||
const runtime = new OrcaRuntimeService(store)
|
||||
const spawn = vi.fn()
|
||||
runtime.setPtyController({
|
||||
spawn,
|
||||
write: () => true,
|
||||
kill: () => true,
|
||||
getForegroundProcess: async () => null
|
||||
})
|
||||
const ensure = vi.spyOn(runtime, 'ensureStructuredAgentSessionHost')
|
||||
for (const [failure, expected] of [
|
||||
[
|
||||
new Error(
|
||||
'Could not confirm this conversation was saved. Try again after the agent responds.'
|
||||
),
|
||||
{ message: 'chat_fork_source_unavailable' }
|
||||
],
|
||||
[
|
||||
new Error('This chat has no saved conversation to copy.'),
|
||||
{ message: 'chat_fork_source_unavailable' }
|
||||
],
|
||||
[
|
||||
new OrchestrationError(PROJECT_RUNTIME_REPAIR_REQUIRED_CODE, 'Repair project settings.'),
|
||||
{ code: PROJECT_RUNTIME_REPAIR_REQUIRED_CODE }
|
||||
]
|
||||
] as const) {
|
||||
ensure.mockResolvedValueOnce(undefined)
|
||||
vi.mocked(prepareStructuredChatCliFork).mockRejectedValueOnce(failure)
|
||||
await expect(
|
||||
runtime['createMobileChatForkTerminal']({
|
||||
workspace: {
|
||||
id: TEST_WORKTREE_ID,
|
||||
path: cwd,
|
||||
connectionId: null,
|
||||
repo: null,
|
||||
folderWorkspace: null
|
||||
},
|
||||
worktreeId: TEST_WORKTREE_ID,
|
||||
opts: { sourceChatFork: { sessionId: 'unsaved-chat' }, clientMutationId: 'next-create' },
|
||||
graphEpoch: 0
|
||||
})
|
||||
).rejects.toMatchObject(expected)
|
||||
}
|
||||
expect(spawn).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
@@ -1,183 +0,0 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { ExecutionHostId } from '../../../../shared/execution-host'
|
||||
import type { Repo } from '../../../../shared/repo-types'
|
||||
import type { WorktreeMeta } from '../../../../shared/worktree/meta-types'
|
||||
import type { Worktree } from '../../../../shared/worktree/types'
|
||||
import type { WorktreeLineage } from '../../../../shared/worktree/lineage-types'
|
||||
import type { RuntimeStore } from '../../runtime-store-contract'
|
||||
import type { ResolvedWorktree } from '../../workspaces/catalog/runtime-worktree-path-identity'
|
||||
import { mergeWorktree } from '../../../ipc/worktree-metadata-merge'
|
||||
import { resolveCreatedWorktreeTerminalTarget } from './runtime-created-worktree-terminal-target'
|
||||
import { committingSpawn } from '../../orca-runtime-tests/bind-first-pane.test-fixture'
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
BrowserWindow: { fromId: vi.fn(() => null) },
|
||||
webContents: { fromId: vi.fn(() => null) },
|
||||
ipcMain: { on: vi.fn(), removeListener: vi.fn() },
|
||||
app: { getPath: vi.fn(() => '/tmp'), isPackaged: false }
|
||||
}))
|
||||
|
||||
import { OrcaRuntimeService } from '../../orca-runtime'
|
||||
|
||||
class StartupRuntime extends OrcaRuntimeService {
|
||||
readonly lookup = vi.fn<(selector: string) => Promise<ResolvedWorktree>>()
|
||||
readonly repoLookup = vi.fn<(selector: string) => Promise<Repo>>()
|
||||
|
||||
protected override resolveRepoSelector(selector: string): Promise<Repo> {
|
||||
return this.repoLookup(selector)
|
||||
}
|
||||
|
||||
protected override resolveGitWorktreeSelector(selector: string): Promise<ResolvedWorktree> {
|
||||
return this.lookup(selector)
|
||||
}
|
||||
}
|
||||
|
||||
function owner(hostId: ExecutionHostId = 'local'): Repo {
|
||||
return {
|
||||
id: 'repo-1',
|
||||
path: '/repos/app',
|
||||
displayName: 'app',
|
||||
badgeColor: 'blue',
|
||||
addedAt: 1,
|
||||
executionHostId: hostId
|
||||
}
|
||||
}
|
||||
|
||||
function makeFixture(repos: Repo[] = [owner()], hostId: ExecutionHostId = 'local') {
|
||||
const meta: WorktreeMeta = {
|
||||
instanceId: 'new-instance',
|
||||
hostId,
|
||||
displayName: '',
|
||||
comment: '',
|
||||
linkedIssue: null,
|
||||
linkedPR: null,
|
||||
linkedLinearIssue: null,
|
||||
isArchived: false,
|
||||
isUnread: false,
|
||||
isPinned: false,
|
||||
sortOrder: 0,
|
||||
lastActivityAt: 0
|
||||
}
|
||||
const worktree = mergeWorktree(
|
||||
'repo-1',
|
||||
{
|
||||
path: '/workspaces/new',
|
||||
head: 'abc123',
|
||||
branch: 'refs/heads/new',
|
||||
isBare: false,
|
||||
isMainWorktree: false
|
||||
},
|
||||
meta
|
||||
)
|
||||
const metadata: Record<string, WorktreeMeta> = { [worktree.id]: meta }
|
||||
const lineageById: Record<string, WorktreeLineage> = {}
|
||||
const store: RuntimeStore = {
|
||||
getRepos: () => repos,
|
||||
getRepo: (id) => repos.find((repo) => repo.id === id),
|
||||
addRepo: vi.fn<RuntimeStore['addRepo']>(),
|
||||
updateRepo: vi.fn<RuntimeStore['updateRepo']>(),
|
||||
getAllWorktreeMeta: () => metadata,
|
||||
getWorktreeMeta: (id) => metadata[id],
|
||||
getAllWorktreeLineage: () => lineageById,
|
||||
setWorktreeMeta: (target, patch) =>
|
||||
(metadata[target.id] = { ...meta, ...metadata[target.id], ...patch }),
|
||||
removeWorktreeMeta: (target) => {
|
||||
delete metadata[target.id]
|
||||
},
|
||||
getGitHubCache: vi.fn<RuntimeStore['getGitHubCache']>(),
|
||||
getSettings: () => ({
|
||||
workspaceDir: '/workspaces',
|
||||
nestWorkspaces: false,
|
||||
refreshLocalBaseRefOnWorktreeCreate: false,
|
||||
branchPrefix: 'none',
|
||||
branchPrefixCustom: ''
|
||||
})
|
||||
}
|
||||
const runtime = new StartupRuntime(store)
|
||||
const fallback: ResolvedWorktree = {
|
||||
...worktree,
|
||||
git: worktree,
|
||||
parentWorktreeId: null,
|
||||
childWorktreeIds: [],
|
||||
lineage: null
|
||||
}
|
||||
runtime.lookup.mockResolvedValue(fallback)
|
||||
let ptySequence = 0
|
||||
const spawn = vi.fn(async (_args: { worktreeId?: string; tabId?: string; leafId?: string }) => ({
|
||||
id: `new-pty-${++ptySequence}`
|
||||
}))
|
||||
runtime.setPtyController({
|
||||
spawn: committingSpawn(runtime, spawn),
|
||||
write: () => true,
|
||||
kill: () => true,
|
||||
getForegroundProcess: async () => null
|
||||
})
|
||||
return { runtime, store, worktree, meta, metadata, lineageById, spawn }
|
||||
}
|
||||
|
||||
describe('created worktree terminal evidence', () => {
|
||||
it('does not reuse creation evidence for a different selector or a replaced instance', async () => {
|
||||
const { store, worktree, metadata } = makeFixture()
|
||||
expect(resolveCreatedWorktreeTerminalTarget(store, 'branch:new', worktree)).toBeNull()
|
||||
expect(
|
||||
resolveCreatedWorktreeTerminalTarget(store, 'id:repo-1::/elsewhere', worktree)
|
||||
).toBeNull()
|
||||
metadata[worktree.id] = { ...metadata[worktree.id]!, instanceId: 'replacement' }
|
||||
expect(resolveCreatedWorktreeTerminalTarget(store, `id:${worktree.id}`, worktree)).toBeNull()
|
||||
})
|
||||
|
||||
it('rejects another host metadata and ambiguous legacy ownership', async () => {
|
||||
const { store, worktree, metadata } = makeFixture([owner(), owner('ssh:builder')])
|
||||
metadata[worktree.id] = { ...metadata[worktree.id]!, hostId: 'ssh:builder' }
|
||||
expect(resolveCreatedWorktreeTerminalTarget(store, `id:${worktree.id}`, worktree)).toBeNull()
|
||||
metadata[worktree.id] = { ...metadata[worktree.id]!, hostId: undefined }
|
||||
const unstamped: Worktree = { ...worktree, hostId: undefined }
|
||||
expect(resolveCreatedWorktreeTerminalTarget(store, `id:${worktree.id}`, unstamped)).toBeNull()
|
||||
expect(resolveCreatedWorktreeTerminalTarget(store, `id:${worktree.id}`, worktree)).toBeNull()
|
||||
})
|
||||
|
||||
it('keeps folder workspaces on their existing launch lookup', () => {
|
||||
const { store, worktree } = makeFixture([{ ...owner(), kind: 'folder' }])
|
||||
expect(resolveCreatedWorktreeTerminalTarget(store, `id:${worktree.id}`, worktree)).toBeNull()
|
||||
})
|
||||
|
||||
it('preserves stored lineage while rejecting stale parents and cycles', () => {
|
||||
const { store, worktree, meta, metadata, lineageById } = makeFixture()
|
||||
const parentId = 'repo-1::/workspaces/parent'
|
||||
const childId = 'repo-1::/workspaces/child'
|
||||
metadata[parentId] = { ...meta, instanceId: 'parent-instance' }
|
||||
metadata[childId] = { ...meta, instanceId: 'child-instance' }
|
||||
const lineage: WorktreeLineage = {
|
||||
worktreeId: worktree.id,
|
||||
worktreeInstanceId: 'new-instance',
|
||||
parentWorktreeId: parentId,
|
||||
parentWorktreeInstanceId: 'parent-instance',
|
||||
origin: 'cli',
|
||||
capture: { source: 'explicit-cli-flag', confidence: 'explicit' },
|
||||
createdAt: 1
|
||||
}
|
||||
lineageById[worktree.id] = lineage
|
||||
lineageById[childId] = {
|
||||
...lineage,
|
||||
worktreeId: childId,
|
||||
worktreeInstanceId: 'child-instance',
|
||||
parentWorktreeId: worktree.id,
|
||||
parentWorktreeInstanceId: 'new-instance'
|
||||
}
|
||||
const resolve = () => resolveCreatedWorktreeTerminalTarget(store, `id:${worktree.id}`, worktree)
|
||||
expect(resolve()).toEqual(
|
||||
expect.objectContaining({ parentWorktreeId: parentId, childWorktreeIds: [childId], lineage })
|
||||
)
|
||||
metadata[parentId] = { ...meta, instanceId: 'replacement' }
|
||||
expect(resolve()).toEqual(expect.objectContaining({ parentWorktreeId: null, lineage: null }))
|
||||
metadata[parentId] = { ...meta, instanceId: 'parent-instance' }
|
||||
lineageById[parentId] = {
|
||||
...lineage,
|
||||
worktreeId: parentId,
|
||||
worktreeInstanceId: 'parent-instance',
|
||||
parentWorktreeId: worktree.id,
|
||||
parentWorktreeInstanceId: 'new-instance'
|
||||
}
|
||||
expect(resolve()).toEqual(expect.objectContaining({ parentWorktreeId: null, lineage: null }))
|
||||
})
|
||||
})
|
||||
@@ -1,67 +0,0 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { ELECTRON_REMOTE_RUNTIME_CLIENT_CAPABILITIES } from '../../../../shared/electron-remote-runtime-client-capabilities'
|
||||
import { NATIVE_REMOTE_RUNTIME_CLIENT_CAPABILITIES } from '../../../../shared/protocol-version'
|
||||
import { TERMINAL_IMAGE_VIEWER_CAPABILITY } from '../../../../shared/terminal/images/terminal-image-viewer-capability'
|
||||
import {
|
||||
applyClaudeTerminalImageViewerEnv,
|
||||
terminalImageViewerSupported
|
||||
} from './terminal-image-viewer-env'
|
||||
|
||||
const paired = { caller: { kind: 'paired-device', deviceId: 'phone' } as const }
|
||||
afterEach(() => vi.unstubAllEnvs())
|
||||
describe('Claude images follow the launching viewer', () => {
|
||||
it.each([undefined, [], NATIVE_REMOTE_RUNTIME_CLIENT_CAPABILITIES])(
|
||||
'keeps older and native clients on old output %#',
|
||||
(clientCapabilities) => {
|
||||
expect(
|
||||
terminalImageViewerSupported({ ...paired, clientKind: 'runtime', clientCapabilities })
|
||||
).toBe(false)
|
||||
}
|
||||
)
|
||||
it('enables a capable paired Electron viewer', () => {
|
||||
expect(
|
||||
terminalImageViewerSupported({
|
||||
...paired,
|
||||
clientKind: 'runtime',
|
||||
clientCapabilities: ELECTRON_REMOTE_RUNTIME_CLIENT_CAPABILITIES
|
||||
})
|
||||
).toBe(true)
|
||||
})
|
||||
it('does not treat a mobile viewer as an image renderer', () => {
|
||||
expect(
|
||||
terminalImageViewerSupported({
|
||||
...paired,
|
||||
clientKind: 'mobile',
|
||||
clientCapabilities: [TERMINAL_IMAGE_VIEWER_CAPABILITY]
|
||||
})
|
||||
).toBe(false)
|
||||
})
|
||||
it.each([
|
||||
{},
|
||||
{ ...paired },
|
||||
{ ...paired, clientCapabilities: [TERMINAL_IMAGE_VIEWER_CAPABILITY] },
|
||||
{ clientKind: 'runtime' as const, clientCapabilities: [TERMINAL_IMAGE_VIEWER_CAPABILITY] },
|
||||
{ caller: { kind: 'local-cli' as const }, connectionId: 'remote' },
|
||||
{ caller: { kind: 'local-cli' as const }, pairedDeviceId: 'remote' },
|
||||
{ caller: { kind: 'local-cli' as const }, clientId: 'remote' }
|
||||
])('fails closed for an unclassified or remote context %#', (context) => {
|
||||
expect(terminalImageViewerSupported(context)).toBe(false)
|
||||
})
|
||||
it.each([
|
||||
{ caller: { kind: 'desktop' as const }, clientKind: 'runtime' as const },
|
||||
{ caller: { kind: 'local-cli' as const } }
|
||||
])('keeps same-build local launches on their existing policy %#', (context) => {
|
||||
expect(terminalImageViewerSupported(context)).toBeUndefined()
|
||||
})
|
||||
it('disables automatic image forcing only for unsupported Claude', () => {
|
||||
vi.stubEnv('CLAUDE_CODE_FORCE_TERMINAL_IMAGES', undefined)
|
||||
const launch = { launchAgent: 'claude' as const, env: { KEEP: 'yes' } }
|
||||
expect(applyClaudeTerminalImageViewerEnv(launch, false).env).toEqual({
|
||||
KEEP: 'yes',
|
||||
CLAUDE_CODE_FORCE_TERMINAL_IMAGES: '0'
|
||||
})
|
||||
expect(launch.env).toEqual({ KEEP: 'yes' })
|
||||
const codex = { launchAgent: 'codex' as const }
|
||||
expect(applyClaudeTerminalImageViewerEnv(codex, false)).toBe(codex)
|
||||
})
|
||||
})
|
||||
@@ -70,74 +70,6 @@ export function registerTerminalPtyExitWaiterTests(): void {
|
||||
})
|
||||
|
||||
describe('fresh composer lifetime', () => {
|
||||
it.each(['exit', 'replacement', 'prune', 'graph-retirement', 'abort', 'timeout'] as const)(
|
||||
'retires its scanner, listeners and deadline on %s',
|
||||
async (end) => {
|
||||
const { runtime, handle } = await createTranscriptPane({
|
||||
paneTitle: 'shell',
|
||||
foregroundProcess: 'bash',
|
||||
data: ''
|
||||
})
|
||||
const subscribe = vi.spyOn(runtime, 'subscribeToTerminalData')
|
||||
const stop = new AbortController()
|
||||
const removeAbort = vi.spyOn(stop.signal, 'removeEventListener')
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const deadlines = vi.spyOn(globalThis, 'setTimeout')
|
||||
const clearDeadline = vi.spyOn(globalThis, 'clearTimeout')
|
||||
const pending = runtime.waitForFreshWorkerComposer(handle, 'claude', 60_000, {
|
||||
signal: stop.signal
|
||||
})
|
||||
const outcome = pending.catch((error: unknown) => error)
|
||||
const settled = vi.fn()
|
||||
outcome.then(settled)
|
||||
expect(runtime['terminalWaiters'].forPty(TRANSCRIPT_PANE_PTY_ID)).toHaveLength(1)
|
||||
expect(subscribe).toHaveBeenCalledOnce()
|
||||
const deadline =
|
||||
deadlines.mock.results[deadlines.mock.calls.findIndex(([, ms]) => ms === 60_000)].value
|
||||
if (end === 'exit') {
|
||||
runtime.onPtyExit(TRANSCRIPT_PANE_PTY_ID, 137, 'inc-1', { providerExitObserved: true })
|
||||
} else if (end === 'replacement') {
|
||||
runtime.onPtySpawned(TRANSCRIPT_PANE_PTY_ID, 'inc-2')
|
||||
runtime['recordPtyWorktree'](TRANSCRIPT_PANE_PTY_ID, 'wt-1', {
|
||||
connected: true,
|
||||
incarnationId: 'inc-2'
|
||||
})
|
||||
runtime.onPtyExit(TRANSCRIPT_PANE_PTY_ID, 137, 'inc-1', { providerExitObserved: true })
|
||||
} else if (end === 'prune') {
|
||||
runtime['dropDisconnectedPtyRecord'](TRANSCRIPT_PANE_PTY_ID)
|
||||
} else if (end === 'graph-retirement') {
|
||||
runtime['restoreHeadlessGraphAuthority']()
|
||||
} else if (end === 'abort') {
|
||||
stop.abort()
|
||||
} else {
|
||||
await vi.advanceTimersByTimeAsync(60_000)
|
||||
}
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
expect(settled).toHaveBeenCalledOnce()
|
||||
expect(await outcome).toMatchObject({
|
||||
message:
|
||||
end === 'exit'
|
||||
? 'terminal_exited'
|
||||
: end === 'abort'
|
||||
? 'request_aborted'
|
||||
: end === 'timeout'
|
||||
? 'timeout'
|
||||
: 'terminal_handle_stale'
|
||||
})
|
||||
expect(runtime['terminalWaiters'].forPty(TRANSCRIPT_PANE_PTY_ID)).toHaveLength(0)
|
||||
expect(
|
||||
runtime['terminalStreamConsumers']['dataListeners'].get(TRANSCRIPT_PANE_PTY_ID)
|
||||
).toBeUndefined()
|
||||
expect(removeAbort).toHaveBeenCalledWith('abort', expect.any(Function))
|
||||
expect(clearDeadline).toHaveBeenCalledWith(deadline)
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
vi.restoreAllMocks()
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
it.each(['accept-exit', 'exit', 'replacement'] as const)(
|
||||
'ignores a late acceptance/foreground success after %s',
|
||||
async (end) => {
|
||||
|
||||
@@ -125,22 +125,6 @@ describe('a split of a pane the window draws, in a layout-owner build', () => {
|
||||
expect(split.tabId).toBe(TAB_ID)
|
||||
})
|
||||
|
||||
it('lists the new pane to phones and session.tabs with no window report', async () => {
|
||||
const { runtime, handle } = setup()
|
||||
// A phone already lists the tab, so the new pane must be published, not just hydrated.
|
||||
await runtime.listMobileSessionTabs(`id:${TEST_WORKTREE_ID}`)
|
||||
|
||||
const split = await runtime.splitTerminal(handle, { direction: 'vertical' })
|
||||
|
||||
const listed = await runtime.listMobileSessionTabs(`id:${TEST_WORKTREE_ID}`)
|
||||
expect(
|
||||
listed.tabs.some(
|
||||
(tab) =>
|
||||
tab.type === 'terminal' && tab.parentTabId === TAB_ID && tab.leafId === split.leafId
|
||||
)
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('a split no one asked to see leaves the phones’ active tab alone; one asked for takes it', async () => {
|
||||
const { runtime, handle } = setup()
|
||||
const listed = () => runtime.listMobileSessionTabs(`id:${TEST_WORKTREE_ID}`)
|
||||
@@ -153,30 +137,6 @@ describe('a split of a pane the window draws, in a layout-owner build', () => {
|
||||
expect((await listed()).activeTabId).toBe(`${TAB_ID}::${shown.leafId}`)
|
||||
})
|
||||
|
||||
it('the window’s own split keeps its proposed pane id, folder and size', async () => {
|
||||
const { runtime, leafIds, spawn } = setup()
|
||||
|
||||
const split = await runtime.splitTerminalPane(
|
||||
{ worktreeId: TEST_WORKTREE_ID, tabId: TAB_ID, leafId: HEADLESS_LEAF_ID },
|
||||
{
|
||||
direction: 'horizontal',
|
||||
newLeafId: NEW_LEAF_ID,
|
||||
cwd: TEST_WORKTREE_PATH,
|
||||
initialSize: { cols: 90, rows: 20 },
|
||||
keepPaneOnStartFailure: true
|
||||
}
|
||||
)
|
||||
|
||||
expect(split.leafId).toBe(NEW_LEAF_ID)
|
||||
expect(leafIds()).toEqual([HEADLESS_LEAF_ID, NEW_LEAF_ID])
|
||||
expect(spawn.mock.calls[0]![0]).toMatchObject({
|
||||
leafId: NEW_LEAF_ID,
|
||||
cwd: TEST_WORKTREE_PATH,
|
||||
cols: 90,
|
||||
rows: 20
|
||||
})
|
||||
})
|
||||
|
||||
it('a window split whose terminal fails to start keeps its pane, showing why', async () => {
|
||||
const { runtime, leafIds, spawn } = setup()
|
||||
spawn.mockRejectedValueOnce(new Error('shell not found'))
|
||||
@@ -206,126 +166,4 @@ describe('a split of a pane the window draws, in a layout-owner build', () => {
|
||||
expect(leafIds()).toEqual([HEADLESS_LEAF_ID])
|
||||
expect(notifier.closeTerminalPane).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('starts nothing when main cannot write the pane', async () => {
|
||||
const { runtime, runtimeStore, handle, splitTerminal, leafIds, spawn } = setup()
|
||||
Reflect.set(
|
||||
runtimeStore,
|
||||
'admitTerminalPane',
|
||||
vi.fn(async () => 'pane_not_found')
|
||||
)
|
||||
|
||||
await expect(runtime.splitTerminal(handle, { direction: 'vertical' })).rejects.toThrow(
|
||||
'terminal_split_source_not_found'
|
||||
)
|
||||
expect(spawn).not.toHaveBeenCalled()
|
||||
expect(splitTerminal).not.toHaveBeenCalled()
|
||||
expect(leafIds()).toEqual([HEADLESS_LEAF_ID])
|
||||
})
|
||||
|
||||
it('starts nothing for a pane-named split of a tab main does not hold', async () => {
|
||||
const { runtime, spawn } = setup()
|
||||
|
||||
await expect(
|
||||
runtime.splitTerminalPane(
|
||||
{ worktreeId: TEST_WORKTREE_ID, tabId: 'tab-elsewhere', leafId: HEADLESS_LEAF_ID },
|
||||
{ direction: 'vertical' }
|
||||
)
|
||||
).rejects.toThrow('terminal_split_source_not_found')
|
||||
expect(spawn).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
// The real spawn commit refuses a pane that left the layout while it started (spawn-commit.ts).
|
||||
function closeWhileStarting(setupResult: ReturnType<typeof setup>, close: () => Promise<void>) {
|
||||
const { spawn, getSession } = setupResult
|
||||
spawn.mockImplementationOnce(async (args: { tabId?: string; leafId?: string }) => {
|
||||
await close()
|
||||
const root = getSession().terminalLayoutsByTabId[TAB_ID]?.root
|
||||
if (!root || !collectLayoutLeafIdsInOrder(root).includes(args.leafId!)) {
|
||||
throw new Error('terminal_split_source_not_found')
|
||||
}
|
||||
return { id: 'pty-late' }
|
||||
})
|
||||
}
|
||||
|
||||
it.each([
|
||||
['its pane', { kind: 'pane', tabId: TAB_ID, leafId: NEW_LEAF_ID }],
|
||||
['its tab', { kind: 'tab', tabId: TAB_ID }]
|
||||
] as const)(
|
||||
'answers a split once, and leaves nothing, when the user closes %s while it starts',
|
||||
async (_, target) => {
|
||||
const context = setup()
|
||||
const { runtime, getSession } = context
|
||||
closeWhileStarting(context, () =>
|
||||
runtime.closeTerminalSurfaceFromRenderer({
|
||||
worktreeId: TEST_WORKTREE_ID,
|
||||
target,
|
||||
reason: 'user'
|
||||
})
|
||||
)
|
||||
|
||||
await expect(
|
||||
runtime.splitTerminalPane(
|
||||
{ worktreeId: TEST_WORKTREE_ID, tabId: TAB_ID, leafId: HEADLESS_LEAF_ID },
|
||||
{ direction: 'vertical', newLeafId: NEW_LEAF_ID, keepPaneOnStartFailure: true }
|
||||
)
|
||||
).rejects.toThrow('terminal_split_source_not_found')
|
||||
|
||||
const root = getSession().terminalLayoutsByTabId[TAB_ID]?.root
|
||||
expect(root ? collectLayoutLeafIdsInOrder(root) : []).not.toContain(NEW_LEAF_ID)
|
||||
expect(runtime.startStatusOf(makePaneKey(TAB_ID, NEW_LEAF_ID))).toBeNull()
|
||||
}
|
||||
)
|
||||
|
||||
it('refuses a named pane id another pane already holds, starting nothing', async () => {
|
||||
const { runtime, spawn } = setup()
|
||||
|
||||
await expect(
|
||||
runtime.splitTerminalPane(
|
||||
{ worktreeId: TEST_WORKTREE_ID, tabId: TAB_ID, leafId: HEADLESS_LEAF_ID },
|
||||
{ direction: 'vertical', newLeafId: HEADLESS_LEAF_ID }
|
||||
)
|
||||
).rejects.toThrow('pane_exists')
|
||||
expect(spawn).not.toHaveBeenCalled()
|
||||
expect(runtime.startStatusOf(makePaneKey(TAB_ID, HEADLESS_LEAF_ID))).toBeNull()
|
||||
})
|
||||
|
||||
it('a window split main cannot start at all still shows its pane, failed, with why', async () => {
|
||||
const { runtime, leafIds } = setup()
|
||||
runtime.setPtyController({
|
||||
write: () => true,
|
||||
kill: () => true,
|
||||
getForegroundProcess: async () => null
|
||||
})
|
||||
|
||||
await expect(
|
||||
runtime.splitTerminalPane(
|
||||
{ worktreeId: TEST_WORKTREE_ID, tabId: TAB_ID, leafId: HEADLESS_LEAF_ID },
|
||||
{ direction: 'vertical', newLeafId: NEW_LEAF_ID, keepPaneOnStartFailure: true }
|
||||
)
|
||||
).rejects.toThrow('runtime_unavailable')
|
||||
|
||||
expect(leafIds()).toEqual([HEADLESS_LEAF_ID, NEW_LEAF_ID])
|
||||
expect(runtime.startStatusOf(makePaneKey(TAB_ID, NEW_LEAF_ID))).toEqual({
|
||||
status: 'failed',
|
||||
reason: 'runtime_unavailable'
|
||||
})
|
||||
})
|
||||
|
||||
it('refuses a taken pane id before anything else, even when main cannot start', async () => {
|
||||
const { runtime } = setup()
|
||||
runtime.setPtyController({
|
||||
write: () => true,
|
||||
kill: () => true,
|
||||
getForegroundProcess: async () => null
|
||||
})
|
||||
|
||||
await expect(
|
||||
runtime.splitTerminalPane(
|
||||
{ worktreeId: TEST_WORKTREE_ID, tabId: TAB_ID, leafId: HEADLESS_LEAF_ID },
|
||||
{ direction: 'vertical', newLeafId: HEADLESS_LEAF_ID, keepPaneOnStartFailure: true }
|
||||
)
|
||||
).rejects.toThrow('pane_exists')
|
||||
expect(runtime.startStatusOf(makePaneKey(TAB_ID, HEADLESS_LEAF_ID))).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
setupLayoutOwnerRuntime,
|
||||
WORKTREE_ID
|
||||
} from '../../orca-runtime-tests/orca-runtime-layout-owner-start-fixture'
|
||||
|
||||
// Why: pinned off, so a run with ORCA_LAYOUT_OWNER=main still checks the switch-off refusal.
|
||||
vi.mock('../../../../shared/layout-owner-switch', () => ({ ORCA_LAYOUT_OWNER_IS_MAIN: false }))
|
||||
|
||||
describe("the window's new-tab start with the layout-owner build off", () => {
|
||||
it('is refused, starting nothing, so the window starts its tab as today', async () => {
|
||||
const { runtime, spawn } = setupLayoutOwnerRuntime()
|
||||
|
||||
await expect(
|
||||
runtime.startWindowTabTerminal(WORKTREE_ID, { tabId: 'tab', leafId: 'leaf' }, {})
|
||||
).rejects.toThrow('host_not_owned')
|
||||
expect(spawn).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -79,15 +79,6 @@ Your task ID is: ${taskId}
|
||||
${task}`
|
||||
|
||||
describe('main titles agent tabs from its own hook status', () => {
|
||||
it('titles a headless host’s tab, which no window ever opened', async () => {
|
||||
const { runtime, titleOf } = await hostWith([GIT])
|
||||
|
||||
runtime.recordHookStatusTitle(status(GIT))
|
||||
await runtime.hookStatusTitlesSettled()
|
||||
|
||||
expect(titleOf(GIT)).toBe('Refactor the auth middleware to use JWT')
|
||||
})
|
||||
|
||||
it('gives a batch’s titles of several workspaces in one rename and one frame', async () => {
|
||||
const { runtime, apply, titleOf } = await hostWith([GIT, FOLDER])
|
||||
const frames: WorkspaceLayoutStreamEvent[] = []
|
||||
@@ -106,36 +97,6 @@ describe('main titles agent tabs from its own hook status', () => {
|
||||
])
|
||||
})
|
||||
|
||||
it('gives nothing with the setting off', async () => {
|
||||
const { runtime, apply, titleOf } = await hostWith([GIT], {
|
||||
titles: false
|
||||
})
|
||||
|
||||
runtime.recordHookStatusTitle(status(GIT))
|
||||
await runtime.hookStatusTitlesSettled()
|
||||
|
||||
expect(apply).not.toHaveBeenCalled()
|
||||
expect(titleOf(GIT)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('leaves a renamed tab and a quick command’s tab untitled', async () => {
|
||||
const { runtime, apply, titleOf } = await hostWith([GIT, FOLDER], {
|
||||
edit: (session) => {
|
||||
const [git] = session.session.tabsByWorktree[GIT]!
|
||||
const [folder] = session.session.tabsByWorktree[FOLDER]!
|
||||
session.session.tabsByWorktree[GIT] = [{ ...git!, customTitle: 'Status tests' }]
|
||||
session.session.tabsByWorktree[FOLDER] = [{ ...folder!, quickCommandLabel: 'Run tests' }]
|
||||
}
|
||||
})
|
||||
|
||||
runtime.recordHookStatusTitle(status(GIT))
|
||||
runtime.recordHookStatusTitle(status(FOLDER))
|
||||
await runtime.hookStatusTitlesSettled()
|
||||
|
||||
expect(apply).not.toHaveBeenCalled()
|
||||
expect([titleOf(GIT), titleOf(FOLDER)]).toEqual([undefined, undefined])
|
||||
})
|
||||
|
||||
it('has the titles in the first snapshot a window that starts after them reads', async () => {
|
||||
const { runtime } = await hostWith([GIT])
|
||||
|
||||
@@ -181,25 +142,6 @@ describe('main keeps the window’s generated-title rules', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps the first prompt’s title', async () => {
|
||||
const { titleOf } = await titled([
|
||||
[status(GIT)],
|
||||
[status(GIT, { prompt: 'Replace this with a later task name' })]
|
||||
])
|
||||
expect(titleOf(GIT)).toBe('Refactor the auth middleware to use JWT')
|
||||
})
|
||||
|
||||
it('titles a dispatch from its task, not its preamble', async () => {
|
||||
const { titleOf } = await titled([
|
||||
[
|
||||
status(GIT, {
|
||||
prompt: dispatch('task-1', 'Implement the detailed worker instructions')
|
||||
})
|
||||
]
|
||||
])
|
||||
expect(titleOf(GIT)).toBe('Implement the detailed worker')
|
||||
})
|
||||
|
||||
it('replaces a dispatch’s preamble title once main has its labels', async () => {
|
||||
const prompt = dispatch('task-1', 'Implement the detailed worker instructions')
|
||||
const host = await titled(
|
||||
@@ -213,39 +155,6 @@ describe('main keeps the window’s generated-title rules', () => {
|
||||
expect(host.titleOf(GIT)).toBe('Better worker label')
|
||||
})
|
||||
|
||||
it('keeps a dispatch label when a later non-dispatch prompt comes', async () => {
|
||||
const { titleOf } = await titled(
|
||||
[
|
||||
[
|
||||
status(GIT, {
|
||||
prompt: dispatch('task-1', 'Implement the worker instructions')
|
||||
})
|
||||
],
|
||||
[
|
||||
status(GIT, {
|
||||
prompt: 'Refactor the auth middleware to use JWT tokens'
|
||||
})
|
||||
]
|
||||
],
|
||||
labelsOf('task-1', 'Better worker label')
|
||||
)
|
||||
expect(titleOf(GIT)).toBe('Better worker label')
|
||||
})
|
||||
|
||||
it('titles a later non-dispatch prompt while sticky labels remain but no title exists', async () => {
|
||||
const { titleOf } = await titled(
|
||||
[
|
||||
[
|
||||
status(GIT, {
|
||||
orchestration: labelsOf('task-1', 'Stale worker label')[paneOf(GIT)]
|
||||
})
|
||||
]
|
||||
],
|
||||
labelsOf('task-1', 'Stale worker label')
|
||||
)
|
||||
expect(titleOf(GIT)).toBe('Refactor the auth middleware to use JWT')
|
||||
})
|
||||
|
||||
it('does not re-pin task A’s label onto a later dispatch of task B', async () => {
|
||||
const { titleOf } = await titled(
|
||||
[
|
||||
@@ -264,17 +173,4 @@ describe('main keeps the window’s generated-title rules', () => {
|
||||
)
|
||||
expect(titleOf(GIT)).toBe('Implement task B worker instructions')
|
||||
})
|
||||
|
||||
it('titles a paste-sized prompt from its start', async () => {
|
||||
const prompt = `Fix the flaky status tests ${'large pasted text '.repeat(5000)}`
|
||||
const { titleOf } = await titled([[status(GIT, { prompt })]])
|
||||
expect(titleOf(GIT)).toBe('Fix the flaky status tests large pasted')
|
||||
})
|
||||
|
||||
it('keeps a title given in the same batch, as the window’s batch does', async () => {
|
||||
const { titleOf } = await titled([
|
||||
[status(GIT), status(GIT, { prompt: 'Replace this with a later task name' })]
|
||||
])
|
||||
expect(titleOf(GIT)).toBe('Refactor the auth middleware to use JWT')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,76 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
buildHeadlessTerminalSplitLayout,
|
||||
countTerminalLayoutLeaves
|
||||
} from './headless-terminal-split-layout'
|
||||
|
||||
describe('buildHeadlessTerminalSplitLayout (headless split persistence)', () => {
|
||||
it('replaces the provisional admission leaf with the requested split direction', () => {
|
||||
const next = buildHeadlessTerminalSplitLayout(
|
||||
{
|
||||
root: {
|
||||
type: 'split',
|
||||
direction: 'vertical',
|
||||
first: { type: 'leaf', leafId: 'leaf-a' },
|
||||
second: { type: 'leaf', leafId: 'leaf-c' }
|
||||
},
|
||||
activeLeafId: 'leaf-c',
|
||||
expandedLeafId: null,
|
||||
ptyIdsByLeafId: { 'leaf-a': 'pty-a', 'leaf-c': 'provisional-pty' }
|
||||
},
|
||||
{ leafId: 'leaf-c', ptyId: 'pty-c', splitFromLeafId: 'leaf-a', direction: 'horizontal' }
|
||||
)
|
||||
|
||||
expect(next.root).toEqual({
|
||||
type: 'split',
|
||||
direction: 'horizontal',
|
||||
first: { type: 'leaf', leafId: 'leaf-a' },
|
||||
second: { type: 'leaf', leafId: 'leaf-c' }
|
||||
})
|
||||
expect(next.ptyIdsByLeafId).toEqual({ 'leaf-a': 'pty-a', 'leaf-c': 'pty-c' })
|
||||
})
|
||||
|
||||
it('preserves a sibling committed by a concurrent split', () => {
|
||||
const next = buildHeadlessTerminalSplitLayout(
|
||||
{
|
||||
root: {
|
||||
type: 'split',
|
||||
direction: 'vertical',
|
||||
first: {
|
||||
type: 'split',
|
||||
direction: 'horizontal',
|
||||
first: { type: 'leaf', leafId: 'leaf-a' },
|
||||
second: { type: 'leaf', leafId: 'leaf-b' }
|
||||
},
|
||||
second: { type: 'leaf', leafId: 'leaf-c' }
|
||||
},
|
||||
activeLeafId: 'leaf-c',
|
||||
expandedLeafId: null,
|
||||
ptyIdsByLeafId: {
|
||||
'leaf-a': 'pty-a',
|
||||
'leaf-b': 'pty-b',
|
||||
'leaf-c': 'provisional-pty'
|
||||
}
|
||||
},
|
||||
{ leafId: 'leaf-c', ptyId: 'pty-c', splitFromLeafId: 'leaf-a', direction: 'vertical' }
|
||||
)
|
||||
|
||||
expect(next.root).toEqual({
|
||||
type: 'split',
|
||||
direction: 'horizontal',
|
||||
first: {
|
||||
type: 'split',
|
||||
direction: 'vertical',
|
||||
first: { type: 'leaf', leafId: 'leaf-a' },
|
||||
second: { type: 'leaf', leafId: 'leaf-c' }
|
||||
},
|
||||
second: { type: 'leaf', leafId: 'leaf-b' }
|
||||
})
|
||||
expect(countTerminalLayoutLeaves(next.root)).toBe(3)
|
||||
expect(next.ptyIdsByLeafId).toEqual({
|
||||
'leaf-a': 'pty-a',
|
||||
'leaf-b': 'pty-b',
|
||||
'leaf-c': 'pty-c'
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -18,10 +18,6 @@ const {
|
||||
HEADLESS_SECOND_LEAF_ID,
|
||||
TEST_WORKTREE_ID,
|
||||
TEST_WORKTREE_PATH,
|
||||
makeFolderProjectGroup,
|
||||
makeFolderWorkspace,
|
||||
TEST_FOLDER_WORKSPACE_KEY,
|
||||
TEST_FOLDER_WORKSPACE_PATH,
|
||||
makeHeadlessTerminalLayout,
|
||||
makeRuntimeStoreWithWorkspaceSession,
|
||||
makeWorkspaceSessionWithHeadlessTerminal
|
||||
@@ -135,16 +131,6 @@ afterEach(() => {
|
||||
})
|
||||
|
||||
describe('a pane main owns does not vouch for its terminal (switch on)', () => {
|
||||
it('reads a headless pane disconnected once the host stops listing its shell', async () => {
|
||||
const { runtime, host } = savedPaneHost('saved-pty')
|
||||
expect(await rowStatus(runtime, TEST_WORKTREE_ID)).toBe('ready')
|
||||
|
||||
host.live.clear()
|
||||
|
||||
expect(await rowStatus(runtime, TEST_WORKTREE_ID)).toBe('pending-handle')
|
||||
expect(record(runtime, 'saved-pty')?.connected).toBe(false)
|
||||
})
|
||||
|
||||
it('reads a windowed pane disconnected, and a window republish does not reconnect it', async () => {
|
||||
const { runtime, host, publishWindowGraph } = savedPaneHost('saved-pty', { windowed: true })
|
||||
expect(await rowStatus(runtime, TEST_WORKTREE_ID)).toBe('ready')
|
||||
@@ -158,24 +144,6 @@ describe('a pane main owns does not vouch for its terminal (switch on)', () => {
|
||||
expect(await rowStatus(runtime, TEST_WORKTREE_ID)).toBe('pending-handle')
|
||||
})
|
||||
|
||||
it('never shows a restored pane not ready while its shell is listed', async () => {
|
||||
const { runtime } = savedPaneHost('saved-pty', { windowed: true })
|
||||
const statuses: string[] = []
|
||||
const unsubscribe = runtime.onMobileSessionTabsChanged((snapshot) => {
|
||||
for (const tab of snapshot.tabs) {
|
||||
if (tab.type === 'terminal') {
|
||||
statuses.push(tab.status)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
expect(await rowStatus(runtime, TEST_WORKTREE_ID)).toBe('ready')
|
||||
await new Promise((resolve) => setImmediate(resolve))
|
||||
unsubscribe()
|
||||
|
||||
expect(statuses.filter((status) => status !== 'ready')).toEqual([])
|
||||
})
|
||||
|
||||
it('leaves an SSH pane to the relay: a local sweep is not absence, the relay omitting it is', async () => {
|
||||
const { runtime, host } = savedPaneHost(SSH_PTY)
|
||||
runtime.registerPty(SSH_PTY, TEST_WORKTREE_ID, 'ssh-target', {
|
||||
@@ -195,63 +163,6 @@ describe('a pane main owns does not vouch for its terminal (switch on)', () => {
|
||||
expect(record(runtime, SSH_PTY)?.connected).toBe(false)
|
||||
expect(runtime.getPtyLivenessVerdict(SSH_PTY)?.status).not.toBe('exited')
|
||||
})
|
||||
|
||||
it('applies the same rule to a folder workspace', async () => {
|
||||
const host: Host = { live: new Set(['folder-pty']) }
|
||||
const session = makeWorkspaceSessionWithHeadlessTerminal({
|
||||
activeWorktreeId: TEST_FOLDER_WORKSPACE_KEY,
|
||||
tabsByWorktree: {
|
||||
[TEST_FOLDER_WORKSPACE_KEY]: [
|
||||
{
|
||||
id: 'folder-tab',
|
||||
ptyId: 'folder-pty',
|
||||
worktreeId: TEST_FOLDER_WORKSPACE_KEY,
|
||||
title: 'Folder',
|
||||
customTitle: null,
|
||||
color: null,
|
||||
sortOrder: 0,
|
||||
createdAt: 1
|
||||
}
|
||||
]
|
||||
},
|
||||
terminalLayoutsByTabId: {
|
||||
'folder-tab': makeHeadlessTerminalLayout({ [HEADLESS_LEAF_ID]: 'folder-pty' })
|
||||
}
|
||||
})
|
||||
const { runtimeStore } = makeRuntimeStoreWithWorkspaceSession(session)
|
||||
const runtime = new OrcaRuntimeService({
|
||||
...runtimeStore,
|
||||
getFolderWorkspaces: () => [makeFolderWorkspace()],
|
||||
getProjectGroups: () => [makeFolderProjectGroup()]
|
||||
})
|
||||
runtime.setPtyController(
|
||||
hostController(host, TEST_FOLDER_WORKSPACE_PATH, TEST_FOLDER_WORKSPACE_KEY)
|
||||
)
|
||||
runtime.syncWindowGraph(0, { tabs: [], leaves: [] })
|
||||
expect(await rowStatus(runtime, TEST_FOLDER_WORKSPACE_KEY)).toBe('ready')
|
||||
|
||||
host.live.clear()
|
||||
|
||||
expect(await rowStatus(runtime, TEST_FOLDER_WORKSPACE_KEY)).toBe('pending-handle')
|
||||
})
|
||||
})
|
||||
|
||||
describe('a launch into a pane through a listing gap (switch on)', () => {
|
||||
it('treats a disconnected terminal main still binds as running until its exit is certified', async () => {
|
||||
const { runtime, host } = savedPaneHost('saved-pty', { windowed: true })
|
||||
const paneKey = `host-tab:${HEADLESS_LEAF_ID}`
|
||||
await rowStatus(runtime, TEST_WORKTREE_ID)
|
||||
expect(runtime.hasLiveTerminalForPaneKey(paneKey)).toBe(true)
|
||||
|
||||
// The host omits the shell once: an agent launch must attach or refuse, never start a second.
|
||||
host.live.clear()
|
||||
await rowStatus(runtime, TEST_WORKTREE_ID)
|
||||
expect(record(runtime, 'saved-pty')?.connected).toBe(false)
|
||||
expect(runtime.hasLiveTerminalForPaneKey(paneKey)).toBe(true)
|
||||
|
||||
await runtime.onPtyExit('saved-pty', 0)
|
||||
expect(runtime.hasLiveTerminalForPaneKey(paneKey)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('stopping a workspace through a listing gap (switch on)', () => {
|
||||
@@ -426,16 +337,3 @@ describe('a terminal registered while a listing is in flight (switch on)', () =>
|
||||
expect(record(runtime, 'split-pty')?.connected).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('switch off: a window leaf still vouches for its terminal', () => {
|
||||
it('keeps the window record but withholds a ready mobile handle when the host loses its PTY', async () => {
|
||||
layoutOwner.isMain = false
|
||||
const { runtime, host } = savedPaneHost('saved-pty', { windowed: true })
|
||||
expect(await rowStatus(runtime, TEST_WORKTREE_ID)).toBe('ready')
|
||||
|
||||
host.live.clear()
|
||||
|
||||
expect(await rowStatus(runtime, TEST_WORKTREE_ID)).toBe('pending-handle')
|
||||
expect(record(runtime, 'saved-pty')?.connected).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { appendNormalizedToTailBuffer } from './terminal-tail-buffer'
|
||||
|
||||
// #11315: an SSH TUI (Pi ask_user) repainting full-width rows pinned Electron main at 100% CPU
|
||||
// because every character written behind the row end rebuilt the whole row string.
|
||||
|
||||
const ESC = '\x1b'
|
||||
|
||||
// One frame: climb to the top of a three-row panel and overwrite every row in place.
|
||||
function overwriteFrame(width: number): string {
|
||||
return [
|
||||
`${ESC}[3A\r${'a'.repeat(width)}\n`,
|
||||
`\r${ESC}[5G${'b'.repeat(width)}\n`,
|
||||
`\r${'c'.repeat(width)}\n`
|
||||
].join('')
|
||||
}
|
||||
|
||||
describe('terminal tail redraw cost', () => {
|
||||
it('keeps the overwritten rows exact', () => {
|
||||
const width = 40
|
||||
const seed = appendNormalizedToTailBuffer([], '', `${'x'.repeat(width)}\n`.repeat(3), null)
|
||||
const next = appendNormalizedToTailBuffer(
|
||||
seed.lines,
|
||||
seed.partialLine,
|
||||
overwriteFrame(width).repeat(2),
|
||||
seed.redrawCursor
|
||||
)
|
||||
expect(next.lines.slice(-3)).toEqual([
|
||||
'a'.repeat(width),
|
||||
` ${'b'.repeat(width)}`,
|
||||
'c'.repeat(width)
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -1,7 +1,5 @@
|
||||
import { registerTerminalListExecutionHostScopeTests } from './terminal-list-execution-host-scope.spec'
|
||||
import { registerPtyInventoryLivenessVerdictTests } from '../../orca-runtime-tests/pty-inventory-liveness-verdict.spec'
|
||||
import { registerSessionTabsEmptyWorktreePublicationTests } from '../../orca-runtime-tests/session-tabs-empty-worktree-publication.spec'
|
||||
|
||||
registerTerminalListExecutionHostScopeTests()
|
||||
registerPtyInventoryLivenessVerdictTests()
|
||||
registerSessionTabsEmptyWorktreePublicationTests()
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { identityFixture as fixture, LEAF, PANE } from './terminal-identity.test-fixture'
|
||||
import { identityFixture as fixture, PANE } from './terminal-identity.test-fixture'
|
||||
import { OrchestrationDb } from '../../orchestration/db/orchestration-db'
|
||||
import '../../orchestration/db/contract-constants'
|
||||
import '../../orchestration/db/messages/mailbox-routing-page'
|
||||
@@ -9,11 +9,10 @@ import { readCrewRuntimeInputs } from '../../orchestration/crew/crew-runtime-wat
|
||||
import { expect, it, vi } from 'vitest'
|
||||
import '../../orca-runtime-tests/runtime-test-module-registration.spec'
|
||||
import { TEST_WORKTREE_ID } from '../../orca-runtime-tests/orca-runtime-test-fixtures.spec'
|
||||
import { createBoundRun } from '../../rpc/methods/orchestration/runs/runs'
|
||||
import { resolveOrchestrationCaller } from '../../rpc/methods/orchestration/runs/run-scope'
|
||||
import { formatOrcaSessionAddress } from '../../../../shared/orca-session-address'
|
||||
import { testOrcaSessionId } from '../../../../shared/orca-session-address-test-fixture'
|
||||
import type { RuntimeSyncWindowGraph } from '../../../../shared/runtime-types'
|
||||
import '../../rpc/methods/orchestration/runs/runs'
|
||||
import '../../rpc/methods/orchestration/runs/run-scope'
|
||||
import '../../../../shared/orca-session-address'
|
||||
import '../../../../shared/orca-session-address-test-fixture'
|
||||
|
||||
function countIterations<K, V>(
|
||||
map: ReadonlyMap<K, V>,
|
||||
@@ -51,59 +50,6 @@ function countIterations<K, V>(
|
||||
}
|
||||
}
|
||||
|
||||
it('charges actual native map visits at N/10N for missing, moved and cold identities and leaves fast hits lazy', () => {
|
||||
const measure = (population: number, indexed: boolean, fast: boolean) => {
|
||||
const f = fixture()
|
||||
for (let i = 0; i < population; i++) {
|
||||
const pane = `tab-${i}:aaaaaaaa-aaaa-4aaa-8aaa-${String(i).padStart(12, '0')}`
|
||||
f.add(`pty-${i}`, `term_${i}`, pane)
|
||||
f.stamp(`term_${i}`, `pty-${i}`)
|
||||
}
|
||||
const counts = { entries: 0, get: 0, set: 0, delete: 0 }
|
||||
countIterations(f.owner.handles, counts)
|
||||
countIterations(f.owner.handleByPtyId, counts)
|
||||
countIterations(f.owner.leaves, counts)
|
||||
countIterations(f.owner.ptysById, counts)
|
||||
const context = indexed ? f.runtime.createTerminalIdentityReadContext() : undefined
|
||||
for (let i = 0; i < population; i++) {
|
||||
f.owner.getLivePtyForHandle(fast ? `term_${i}` : `term_missing_${i}`, context)
|
||||
if (!fast) {
|
||||
expect(f.owner.getPtyRecordForPaneKey(`missing-${i}:${LEAF}`, context)).toBeNull()
|
||||
}
|
||||
}
|
||||
return counts
|
||||
}
|
||||
const baseline = [measure(3, false, false), measure(30, false, false)]
|
||||
const candidate = [measure(3, true, false), measure(30, true, false)]
|
||||
expect(baseline[1].entries).toBe(baseline[0].entries * 100)
|
||||
expect(candidate[1].entries).toBe(candidate[0].entries * 10)
|
||||
expect(measure(30, true, true).entries).toBe(0)
|
||||
const cold = (population: number, indexed: boolean) => {
|
||||
const f = fixture()
|
||||
for (let i = 0; i < population; i++) {
|
||||
f.add(`cold-${i}`, `term_cold_${i}`, `cold-pane-${i}`)
|
||||
f.stamp(`term_cold_${i}`, `cold-${i}`)
|
||||
f.owner.source.unbindPty(`cold-${i}`)
|
||||
}
|
||||
const counts = { entries: 0, get: 0, set: 0, delete: 0 }
|
||||
countIterations(f.owner.handles, counts)
|
||||
countIterations(f.owner.ptysById, counts)
|
||||
countIterations(f.owner.handleByPtyId, counts)
|
||||
const context = indexed ? f.runtime.createTerminalIdentityReadContext() : undefined
|
||||
for (let i = 0; i < population; i++) {
|
||||
expect(f.runtime.getTerminalHandleForPaneKey(`cold-pane-${i}`, context)).toBe(
|
||||
`term_cold_${i}`
|
||||
)
|
||||
expect(f.owner.getLivePtyForHandle(`term_cold_${i}`, context)?.pty.ptyId).toBe(`cold-${i}`)
|
||||
}
|
||||
return counts
|
||||
}
|
||||
const coldBaseline = [cold(3, false), cold(30, false)]
|
||||
const coldCandidate = [cold(3, true), cold(30, true)]
|
||||
expect(coldBaseline[1].entries).toBeGreaterThan(coldBaseline[0].entries * 50)
|
||||
expect(coldCandidate[1].entries).toBe(coldCandidate[0].entries * 10)
|
||||
})
|
||||
|
||||
it('invalidates actual SSH-held facts on register and finally-release with equal and conflicting claims', async () => {
|
||||
const f = fixture()
|
||||
const wake = vi.fn()
|
||||
@@ -287,112 +233,3 @@ it('reuses raw inputs for new historical parties through the production Crew bou
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('charges retained incarnation materialization through actual pending-pane Run producers and the complete Crew read', async () => {
|
||||
vi.useFakeTimers({ now: new Date('2026-10-28T20:00:00Z') })
|
||||
const measure = async (peers: number, inventory: number) => {
|
||||
const f = fixture()
|
||||
const db = new OrchestrationDb(':memory:')
|
||||
f.runtime.setOrchestrationDb(db)
|
||||
const graph: RuntimeSyncWindowGraph = {
|
||||
tabs: [
|
||||
{
|
||||
tabId: 'tab-cost',
|
||||
worktreeId: TEST_WORKTREE_ID,
|
||||
title: 'Cost',
|
||||
activeLeafId: null,
|
||||
layout: null
|
||||
}
|
||||
],
|
||||
leaves: []
|
||||
}
|
||||
for (let i = 0; i < peers + inventory; i++) {
|
||||
const leafId = `aaaaaaaa-aaaa-4aaa-8aaa-${String(i).padStart(12, '0')}`
|
||||
const ptyId = i < peers ? null : `inventory-${i}`
|
||||
graph.leaves.push({
|
||||
tabId: 'tab-cost',
|
||||
worktreeId: TEST_WORKTREE_ID,
|
||||
leafId,
|
||||
paneRuntimeId: i + 1,
|
||||
ptyId
|
||||
})
|
||||
if (ptyId) {
|
||||
f.runtime.registerPty(ptyId, TEST_WORKTREE_ID, null, {
|
||||
incarnationId: `process-${i}`,
|
||||
tabId: 'tab-cost',
|
||||
leafId
|
||||
})
|
||||
}
|
||||
}
|
||||
f.runtime.attachWindow(1)
|
||||
f.runtime.syncWindowGraph(1, graph)
|
||||
for (const leaf of graph.leaves.slice(peers)) {
|
||||
expect(f.runtime.getTerminalHandleForPaneKey(`tab-cost:${leaf.leafId}`)).toMatch(/^term_/)
|
||||
}
|
||||
const self = formatOrcaSessionAddress(testOrcaSessionId('eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee'))
|
||||
const pendingHandles: string[] = []
|
||||
for (const leaf of graph.leaves.slice(0, peers)) {
|
||||
graph.tabs[0].activeLeafId = leaf.leafId
|
||||
f.runtime.syncWindowGraph(1, graph)
|
||||
const handle = await f.runtime.resolveActiveTerminal()
|
||||
const caller = resolveOrchestrationCaller(f.runtime, {
|
||||
callerTerminalHandle: handle,
|
||||
callerSession: undefined,
|
||||
requireStablePane: true
|
||||
})
|
||||
expect(caller.paneKey).toBe(`tab-cost:${leaf.leafId}`)
|
||||
expect(f.owner.handles.get(handle)?.ptyId).toBeNull()
|
||||
const run = createBoundRun(f.runtime, caller, handle, 'Pending coordinator')
|
||||
db.insertMessage({
|
||||
from: `run:${run.id}`,
|
||||
to: self,
|
||||
subject: 'Retained Run',
|
||||
body: `peer-${pendingHandles.length}`
|
||||
})
|
||||
pendingHandles.push(handle)
|
||||
}
|
||||
expect(f.owner.handleByPtyIncarnation.size).toBe(inventory)
|
||||
const counts = { invocations: 0, yielded: 0 }
|
||||
const incarnations = f.owner.handleByPtyIncarnation
|
||||
const iterate = incarnations[Symbol.iterator].bind(incarnations)
|
||||
Object.defineProperty(incarnations, Symbol.iterator, {
|
||||
configurable: true,
|
||||
value: function* () {
|
||||
counts.invocations++
|
||||
for (const entry of iterate()) {
|
||||
counts.yielded++
|
||||
yield entry
|
||||
}
|
||||
}
|
||||
})
|
||||
const process = vi.spyOn(f.runtime, 'getTerminalProcessIncarnation')
|
||||
const model = new CrewReadModel(
|
||||
(previous) => readCrewRuntimeInputs(f.owner.crewReaders, db, true, 'cost-host', previous),
|
||||
f.owner.crewChanges.subscribe
|
||||
)
|
||||
const stop = model.subscribe(() => {})
|
||||
try {
|
||||
const output = model.read(self)
|
||||
expect(output.status).toBe('ok')
|
||||
expect(output.sections.talkingWith).toHaveLength(peers)
|
||||
for (const handle of pendingHandles) {
|
||||
expect(process.mock.calls.some(([current]) => current === handle)).toBe(true)
|
||||
}
|
||||
expect(counts.yielded).toBeLessThanOrEqual(4 * inventory + 4 * peers)
|
||||
} finally {
|
||||
stop()
|
||||
process.mockRestore()
|
||||
db.close()
|
||||
}
|
||||
}
|
||||
try {
|
||||
for (const [peers, inventory] of [
|
||||
[3, 3],
|
||||
[30, 30]
|
||||
]) {
|
||||
await measure(peers, inventory)
|
||||
}
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
@@ -2,7 +2,7 @@ import { describe, expect, it, vi } from 'vitest'
|
||||
import { OrcaRuntimeService } from '../../orca-runtime'
|
||||
import { getDefaultWorkspaceSession } from '../../../../shared/constants'
|
||||
import type { WorkspaceSessionState } from '../../../../shared/workspace-session/workspace-session-state-types'
|
||||
import { folderWorkspaceKey } from '../../../../shared/workspace-scope'
|
||||
import '../../../../shared/workspace-scope'
|
||||
import type { FolderWorkspace } from '../../../../shared/folder-workspaces/folder-workspace-types'
|
||||
import { type ExecutionHostId, LOCAL_EXECUTION_HOST_ID } from '../../../../shared/execution-host'
|
||||
import type { PtyProcessInfo } from '../../../providers/pty-process-info'
|
||||
@@ -49,35 +49,6 @@ export function registerTerminalListExecutionHostScopeTests(): void {
|
||||
}
|
||||
}
|
||||
|
||||
function makeSshFolderWorkspace() {
|
||||
return {
|
||||
id: 'folder-ssh',
|
||||
projectGroupId: 'group-1',
|
||||
name: 'SSH folder',
|
||||
folderPath: '/remote/folder',
|
||||
connectionId: 'box-2',
|
||||
linkedTask: null,
|
||||
comment: '',
|
||||
isArchived: false,
|
||||
isUnread: false,
|
||||
isPinned: false,
|
||||
sortOrder: 0,
|
||||
lastActivityAt: 0,
|
||||
createdAt: 0,
|
||||
updatedAt: 0
|
||||
}
|
||||
}
|
||||
|
||||
function makeRuntimeFolderWorkspace() {
|
||||
return {
|
||||
...makeSshFolderWorkspace(),
|
||||
id: 'folder-runtime',
|
||||
name: 'Runtime folder',
|
||||
connectionId: 'stale-box',
|
||||
executionHostId: 'runtime:env-9' as const
|
||||
}
|
||||
}
|
||||
|
||||
type GraphLeaf = { worktreeId: string; leafId: string; ptyId: string }
|
||||
|
||||
function makeRuntime(leaves: GraphLeaf[], store = makeStore()): OrcaRuntimeService {
|
||||
@@ -111,67 +82,6 @@ export function registerTerminalListExecutionHostScopeTests(): void {
|
||||
}
|
||||
|
||||
describe('listTerminals scope declaration', () => {
|
||||
it('covers a queried SSH host used only by a folder workspace', async () => {
|
||||
const baseStore = makeStore()
|
||||
const folderWorkspace = makeSshFolderWorkspace()
|
||||
const runtime = makeRuntime([], {
|
||||
...baseStore,
|
||||
getRepos: vi.fn(() => [REPOS[0]!]),
|
||||
getRepo: vi.fn((id: string) => (id === REPOS[0]!.id ? REPOS[0] : undefined)),
|
||||
getWorkspaceSessionHostIds: vi.fn(() => ['local']),
|
||||
getFolderWorkspaces: vi.fn(() => [folderWorkspace])
|
||||
})
|
||||
runtime.setPtyController({
|
||||
spawn: vi.fn(async () => ({ id: 'never' })),
|
||||
write: () => true,
|
||||
kill: () => true,
|
||||
listProcesses: vi.fn(async () => []),
|
||||
listProcessesWithHostScope: vi.fn(async () => ({
|
||||
processes: [],
|
||||
hostIds: ['local', 'ssh:box-2']
|
||||
}))
|
||||
} as never)
|
||||
|
||||
const result = await runtime.listTerminals(`id:${folderWorkspaceKey(folderWorkspace.id)}`)
|
||||
|
||||
expect(result.hostScope?.hostIds).toEqual(['ssh:box-2'])
|
||||
expect(result.hostScope?.omittedHostIds).toEqual(['local'])
|
||||
})
|
||||
|
||||
it('does not claim local coverage for a paired-runtime folder workspace', async () => {
|
||||
const baseStore = makeStore()
|
||||
const folderWorkspace = makeRuntimeFolderWorkspace()
|
||||
const runtime = makeRuntime([], {
|
||||
...baseStore,
|
||||
getRepos: vi.fn(() => [REPOS[0]!]),
|
||||
getRepo: vi.fn((id: string) => (id === REPOS[0]!.id ? REPOS[0] : undefined)),
|
||||
getWorkspaceSessionHostIds: vi.fn(() => ['local']),
|
||||
getFolderWorkspaces: vi.fn(() => [folderWorkspace])
|
||||
})
|
||||
|
||||
const result = await runtime.listTerminals(`id:${folderWorkspaceKey(folderWorkspace.id)}`)
|
||||
|
||||
expect(result.hostScope?.hostIds).toEqual([])
|
||||
expect(result.hostScope?.omittedHostIds).toEqual(['local', 'runtime:env-9'])
|
||||
})
|
||||
|
||||
it('marks every known host omitted when process inventory is unverifiable', async () => {
|
||||
const runtime = makeRuntime([])
|
||||
runtime.setPtyController({
|
||||
spawn: vi.fn(async () => ({ id: 'never' })),
|
||||
write: () => true,
|
||||
kill: () => true,
|
||||
listProcesses: vi.fn(async () => {
|
||||
throw new Error('relay unavailable')
|
||||
})
|
||||
} as never)
|
||||
|
||||
const result = await runtime.listTerminals()
|
||||
|
||||
expect(result.hostScope?.hostIds).toEqual([])
|
||||
expect(result.hostScope?.omittedHostIds).toEqual(['local', 'ssh:box-1'])
|
||||
})
|
||||
|
||||
// A targeted listing a newer census overtook joins that census instead of answering for no
|
||||
// host; re-listing would overtake the newer one in turn (two workspaces sleeping at once).
|
||||
it('answers from a newer concurrent inventory refresh instead of an empty scope', async () => {
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import { registerTerminalPtyExitWaiterTests } from '../launch/terminal-pty-exit-waiter.spec'
|
||||
import { registerClaudeAgentTeamsPtyExitLeakTests } from '../../orca-runtime-tests/claude-agent-teams-pty-exit-leak.spec'
|
||||
|
||||
registerTerminalPtyExitWaiterTests()
|
||||
registerClaudeAgentTeamsPtyExitLeakTests()
|
||||
|
||||
@@ -1,45 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { preserveTerminalRetirementProofs } from './mobile-session-terminal-retirement-proof'
|
||||
import type { RuntimeMobileSessionTabsSnapshot } from '../../../../shared/runtime-session-contracts'
|
||||
|
||||
const retired = {
|
||||
parentTabId: 'tab',
|
||||
leafId: 'leaf',
|
||||
ptyId: 'pty',
|
||||
terminal: 'term',
|
||||
incarnationId: 'inc'
|
||||
}
|
||||
function snapshot(
|
||||
overrides: Partial<RuntimeMobileSessionTabsSnapshot> = {}
|
||||
): RuntimeMobileSessionTabsSnapshot {
|
||||
return {
|
||||
worktree: 'worktree',
|
||||
worktreeInstanceId: 'instance',
|
||||
publicationEpoch: 'epoch',
|
||||
snapshotVersion: 1,
|
||||
activeGroupId: null,
|
||||
activeTabId: null,
|
||||
activeTabType: null,
|
||||
tabs: [],
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
describe('mobile session terminal retirement proofs', () => {
|
||||
// Identity-less host writes must inherit the occupant, preventing renderer(A) -> host -> renderer(B) proof laundering.
|
||||
it('does not launder proofs across occupants through an identity-less host write', () => {
|
||||
const occupantA = snapshot({
|
||||
worktreeInstanceId: 'instance-a',
|
||||
retiredTerminalSurfaces: [retired]
|
||||
})
|
||||
const hostWrite = preserveTerminalRetirementProofs(
|
||||
snapshot({ worktreeInstanceId: undefined, snapshotVersion: 2 }),
|
||||
occupantA
|
||||
)
|
||||
expect(hostWrite.worktreeInstanceId).toBe('instance-a')
|
||||
expect(hostWrite.retiredTerminalSurfaces).toEqual([retired])
|
||||
|
||||
const occupantB = snapshot({ worktreeInstanceId: 'instance-b', snapshotVersion: 3 })
|
||||
expect(preserveTerminalRetirementProofs(occupantB, hostWrite)).toBe(occupantB)
|
||||
})
|
||||
})
|
||||
@@ -1,196 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { getDefaultWorkspaceSession } from '../../../../shared/constants'
|
||||
import type { RuntimeMobileSessionTabsSnapshot } from '../../../../shared/runtime-session-contracts'
|
||||
import type { RuntimeMobileSessionTerminalTab } from '../../../../shared/runtime-mobile-session-tab-contracts'
|
||||
import { retireTerminalSurfacesFromSnapshot } from './mobile-session-terminal-retirement'
|
||||
import { retireTerminalSurfaceFromPersistence } from './mobile-session-terminal-persistence-retirement'
|
||||
|
||||
const WORKTREE_ID = 'repo::/worktree'
|
||||
|
||||
function splitSnapshot(): RuntimeMobileSessionTabsSnapshot {
|
||||
const parentLayout = {
|
||||
root: {
|
||||
type: 'split' as const,
|
||||
direction: 'vertical' as const,
|
||||
first: { type: 'leaf' as const, leafId: 'left' },
|
||||
second: { type: 'leaf' as const, leafId: 'right' }
|
||||
},
|
||||
activeLeafId: 'left',
|
||||
expandedLeafId: 'left',
|
||||
ptyIdsByLeafId: { left: 'pty-left', right: 'pty-right' },
|
||||
buffersByLeafId: { left: 'left buffer', right: 'right buffer' },
|
||||
titlesByLeafId: { left: 'Left', right: 'Right' }
|
||||
}
|
||||
return {
|
||||
worktree: WORKTREE_ID,
|
||||
publicationEpoch: 'host',
|
||||
snapshotVersion: 4,
|
||||
activeGroupId: 'terminals',
|
||||
activeTabId: 'terminal::left',
|
||||
activeTabType: 'terminal',
|
||||
tabGroups: [
|
||||
{
|
||||
id: 'terminals',
|
||||
activeTabId: 'terminal',
|
||||
tabOrder: ['terminal', 'notes'],
|
||||
recentTabIds: ['notes', 'terminal']
|
||||
},
|
||||
{ id: 'secondary', activeTabId: 'other', tabOrder: ['other'] }
|
||||
],
|
||||
tabGroupLayout: {
|
||||
type: 'split',
|
||||
direction: 'horizontal',
|
||||
first: { type: 'leaf', groupId: 'terminals' },
|
||||
second: { type: 'leaf', groupId: 'secondary' }
|
||||
},
|
||||
tabs: [
|
||||
{
|
||||
type: 'terminal',
|
||||
id: 'terminal::left',
|
||||
parentTabId: 'terminal',
|
||||
leafId: 'left',
|
||||
ptyId: 'pty-left',
|
||||
title: 'Left',
|
||||
parentLayout,
|
||||
isActive: true
|
||||
},
|
||||
{
|
||||
type: 'terminal',
|
||||
id: 'terminal::right',
|
||||
parentTabId: 'terminal',
|
||||
leafId: 'right',
|
||||
ptyId: 'pty-right',
|
||||
title: 'Right',
|
||||
parentLayout,
|
||||
isActive: false
|
||||
},
|
||||
{
|
||||
type: 'markdown',
|
||||
id: 'notes',
|
||||
title: 'Notes',
|
||||
filePath: '/worktree/notes.md',
|
||||
relativePath: 'notes.md',
|
||||
language: 'markdown',
|
||||
mode: 'edit',
|
||||
isDirty: false,
|
||||
sourceFileId: 'notes.md',
|
||||
sourceFilePath: '/worktree/notes.md',
|
||||
sourceRelativePath: 'notes.md',
|
||||
documentVersion: '1',
|
||||
isActive: false
|
||||
},
|
||||
{
|
||||
type: 'file',
|
||||
id: 'other',
|
||||
title: 'Other',
|
||||
filePath: '/worktree/other.ts',
|
||||
relativePath: 'other.ts',
|
||||
language: 'typescript',
|
||||
isDirty: false,
|
||||
isActive: false
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
describe('mobile session terminal retirement', () => {
|
||||
it('retires one split leaf and rewrites every surviving sibling layout', () => {
|
||||
const result = retireTerminalSurfacesFromSnapshot({
|
||||
snapshot: splitSnapshot(),
|
||||
ptyId: 'pty-left'
|
||||
})
|
||||
|
||||
expect(result?.snapshot).toMatchObject({
|
||||
snapshotVersion: 5,
|
||||
activeGroupId: 'terminals',
|
||||
activeTabId: 'terminal::right',
|
||||
activeTabType: 'terminal'
|
||||
})
|
||||
expect(result?.snapshot.tabs).toHaveLength(3)
|
||||
expect(result?.snapshot.tabs[0]).toMatchObject({
|
||||
id: 'terminal::right',
|
||||
isActive: true,
|
||||
parentLayout: {
|
||||
root: { type: 'leaf', leafId: 'right' },
|
||||
activeLeafId: 'right',
|
||||
expandedLeafId: null,
|
||||
ptyIdsByLeafId: { right: 'pty-right' },
|
||||
buffersByLeafId: { right: 'right buffer' },
|
||||
titlesByLeafId: { right: 'Right' }
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it('does not retire an exact surface rebound to a replacement PTY', () => {
|
||||
const snapshot = splitSnapshot()
|
||||
const rebound = {
|
||||
...snapshot,
|
||||
tabs: snapshot.tabs.map((tab) =>
|
||||
tab.type === 'terminal' && tab.leafId === 'left'
|
||||
? {
|
||||
...tab,
|
||||
ptyId: 'pty-replacement',
|
||||
parentLayout: tab.parentLayout
|
||||
? {
|
||||
...tab.parentLayout,
|
||||
ptyIdsByLeafId: {
|
||||
...tab.parentLayout.ptyIdsByLeafId,
|
||||
left: 'pty-replacement'
|
||||
}
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
: tab
|
||||
)
|
||||
}
|
||||
|
||||
expect(
|
||||
retireTerminalSurfacesFromSnapshot({
|
||||
snapshot: rebound,
|
||||
ptyId: 'pty-left',
|
||||
exactSurfaces: [{ parentTabId: 'terminal', leafId: 'left' }]
|
||||
})
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
it('de-persists an exact leaf without removing its live sibling', () => {
|
||||
const session = {
|
||||
...getDefaultWorkspaceSession(),
|
||||
activeTabId: 'terminal',
|
||||
activeTabIdByWorktree: { [WORKTREE_ID]: 'terminal' },
|
||||
tabsByWorktree: {
|
||||
[WORKTREE_ID]: [
|
||||
{
|
||||
id: 'terminal',
|
||||
ptyId: 'pty-left',
|
||||
worktreeId: WORKTREE_ID,
|
||||
title: 'Terminal',
|
||||
customTitle: null,
|
||||
color: null,
|
||||
sortOrder: 0,
|
||||
createdAt: 1
|
||||
}
|
||||
]
|
||||
},
|
||||
terminalLayoutsByTabId: {
|
||||
terminal: (splitSnapshot().tabs[0] as RuntimeMobileSessionTerminalTab).parentLayout!
|
||||
}
|
||||
}
|
||||
|
||||
const result = retireTerminalSurfaceFromPersistence(session, {
|
||||
worktreeId: WORKTREE_ID,
|
||||
parentTabId: 'terminal',
|
||||
leafId: 'left',
|
||||
ptyId: 'pty-left'
|
||||
})
|
||||
|
||||
expect(result.tabsByWorktree[WORKTREE_ID]).toEqual([
|
||||
expect.objectContaining({ id: 'terminal', ptyId: 'pty-right' })
|
||||
])
|
||||
expect(result.terminalLayoutsByTabId.terminal).toMatchObject({
|
||||
root: { type: 'leaf', leafId: 'right' },
|
||||
activeLeafId: 'right',
|
||||
ptyIdsByLeafId: { right: 'pty-right' }
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,75 +0,0 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
HEADLESS_LEAF_ID,
|
||||
TEST_WORKTREE_ID
|
||||
} from '../../orca-runtime-tests/orca-runtime-test-fixtures.spec'
|
||||
|
||||
import type { RuntimePtyWorktreeRecord } from '../records/runtime-terminal-state-records'
|
||||
import { SURFACE_TAB_ID, surfaceRuntime } from './terminal-surface-replies.test-fixture'
|
||||
|
||||
vi.mock('../../../../shared/layout-owner-switch', () => ({ ORCA_LAYOUT_OWNER_IS_MAIN: true }))
|
||||
|
||||
const COPIES = ['tabId', 'paneKey', 'surfaceRecordedAtGraphSequence'] as const
|
||||
|
||||
/** Every touch of a record's pane copies, which a layout-owner build must never make (L80). */
|
||||
function trapPaneCopies(ptysById: Map<string, RuntimePtyWorktreeRecord>): string[] {
|
||||
const touched: string[] = []
|
||||
const trap = (record: RuntimePtyWorktreeRecord): void => {
|
||||
for (const field of COPIES) {
|
||||
expect(Object.hasOwn(record, field)).toBe(false)
|
||||
Object.defineProperty(record, field, {
|
||||
configurable: true,
|
||||
get() {
|
||||
touched.push(`read ${record.ptyId}.${field}`)
|
||||
throw new Error(`pane copy read: ${field}`)
|
||||
},
|
||||
set() {
|
||||
touched.push(`write ${record.ptyId}.${field}`)
|
||||
throw new Error(`pane copy write: ${field}`)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
for (const record of ptysById.values()) {
|
||||
trap(record)
|
||||
}
|
||||
// A record made later (a spawn) is trapped as it is stored.
|
||||
const set = ptysById.set.bind(ptysById)
|
||||
ptysById.set = (ptyId, record) => {
|
||||
const stored = set(ptyId, record)
|
||||
trap(record)
|
||||
return stored
|
||||
}
|
||||
return touched
|
||||
}
|
||||
|
||||
describe('PTY record pane copies (switch on)', () => {
|
||||
it('are not needed with no window: main answers before any graph is installed', async () => {
|
||||
const { runtime } = surfaceRuntime()
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: ptysById is the runtime's PTY registry; the test traps the records it keeps.
|
||||
const { ptysById } = runtime as unknown as { ptysById: Map<string, RuntimePtyWorktreeRecord> }
|
||||
const touched = trapPaneCopies(ptysById)
|
||||
const published = vi.spyOn(runtime, 'syncWindowGraph')
|
||||
|
||||
expect(runtime['paneOfPty'](ptysById.get('pty-a')!)).toMatchObject({
|
||||
tabId: SURFACE_TAB_ID,
|
||||
paneKey: `${SURFACE_TAB_ID}:${HEADLESS_LEAF_ID}`
|
||||
})
|
||||
const listed = await runtime.listTerminals(`id:${TEST_WORKTREE_ID}`)
|
||||
expect(
|
||||
listed.terminals.map(({ tabId, ptyId, orphaned }) => ({ tabId, ptyId, orphaned }))
|
||||
).toEqual([
|
||||
{ tabId: SURFACE_TAB_ID, ptyId: 'pty-a', orphaned: false },
|
||||
{ tabId: SURFACE_TAB_ID, ptyId: 'pty-b', orphaned: false }
|
||||
])
|
||||
for (const terminal of listed.terminals) {
|
||||
await expect(runtime.showTerminal(terminal.handle)).resolves.toMatchObject({
|
||||
tabId: SURFACE_TAB_ID
|
||||
})
|
||||
}
|
||||
runtime.registerPty('pty-c', TEST_WORKTREE_ID)
|
||||
// Answering is not publishing: no graph is installed until a window or the host sends one.
|
||||
expect(published).not.toHaveBeenCalled()
|
||||
expect(touched).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -1,64 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { TerminalLayoutSnapshot } from '../../../../shared/workspace-layout/terminal-tab-types'
|
||||
import {
|
||||
resolveTerminalCloseTarget,
|
||||
type TerminalCloseLayoutCopies
|
||||
} from './terminal-surface-close'
|
||||
|
||||
const PANE = { kind: 'pane', tabId: 'tab-1', leafId: 'leaf-a' } as const
|
||||
|
||||
function layout(...leafIds: string[]): TerminalLayoutSnapshot {
|
||||
const [first, second] = leafIds
|
||||
return {
|
||||
root:
|
||||
first === undefined
|
||||
? null
|
||||
: second === undefined
|
||||
? { type: 'leaf', leafId: first }
|
||||
: {
|
||||
type: 'split',
|
||||
direction: 'horizontal',
|
||||
first: { type: 'leaf', leafId: first },
|
||||
second: { type: 'leaf', leafId: second }
|
||||
},
|
||||
activeLeafId: first ?? null,
|
||||
expandedLeafId: null
|
||||
}
|
||||
}
|
||||
|
||||
function copies(overrides: Partial<TerminalCloseLayoutCopies>): TerminalCloseLayoutCopies {
|
||||
return {
|
||||
rendererListsTab: false,
|
||||
snapshotRows: [],
|
||||
graphLeafIds: [],
|
||||
sessionLayout: undefined,
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
describe('resolveTerminalCloseTarget', () => {
|
||||
it('never widens a pane close when the owner copy records no panes', () => {
|
||||
// A renderer tab registered before its panes, with nothing published for it yet.
|
||||
expect(resolveTerminalCloseTarget(PANE, copies({ rendererListsTab: true }))).toBe('absent')
|
||||
// A main-owned tab whose layout was saved before its pane mounted, and no other copy.
|
||||
expect(resolveTerminalCloseTarget(PANE, copies({ sessionLayout: layout() }))).toBe('absent')
|
||||
})
|
||||
|
||||
it('reads past an empty copy to one that records the split', () => {
|
||||
expect(
|
||||
resolveTerminalCloseTarget(
|
||||
PANE,
|
||||
copies({
|
||||
sessionLayout: layout(),
|
||||
snapshotRows: [{ leafId: 'leaf-a', parentLayout: layout('leaf-a', 'leaf-b') }]
|
||||
})
|
||||
)
|
||||
).toBe('pane')
|
||||
})
|
||||
|
||||
it('treats a pane the owner copy lacks as absent', () => {
|
||||
expect(
|
||||
resolveTerminalCloseTarget(PANE, copies({ rendererListsTab: true, graphLeafIds: ['leaf-b'] }))
|
||||
).toBe('absent')
|
||||
})
|
||||
})
|
||||
@@ -1,4 +1,4 @@
|
||||
import path from 'node:path'
|
||||
import 'node:path'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import {
|
||||
@@ -10,17 +10,11 @@ import {
|
||||
import {
|
||||
SURFACE_PTYS,
|
||||
SURFACE_TAB_ID,
|
||||
captureSurfaceReplies,
|
||||
mountedSurfaceGraph,
|
||||
recordedSurfaceReplies,
|
||||
surfaceRuntime
|
||||
} from './terminal-surface-replies.test-fixture'
|
||||
|
||||
vi.mock('../../../../shared/layout-owner-switch', () => ({ ORCA_LAYOUT_OWNER_IS_MAIN: true }))
|
||||
|
||||
const golden = (name: string): string =>
|
||||
recordedSurfaceReplies(path.join(__dirname, '__fixtures__', name))
|
||||
|
||||
const MOUNTED = Object.keys(SURFACE_PTYS).map((leafId, index) => ({
|
||||
tabId: SURFACE_TAB_ID,
|
||||
leafId,
|
||||
@@ -29,19 +23,6 @@ const MOUNTED = Object.keys(SURFACE_PTYS).map((leafId, index) => ({
|
||||
}))
|
||||
|
||||
describe('terminal replies from main (switch on)', () => {
|
||||
it('match the window-owned build when the window sends only what it mounts', async () => {
|
||||
const { runtime } = surfaceRuntime()
|
||||
runtime.attachWindow(1)
|
||||
runtime.syncWindowGraph(1, { tabs: [], leaves: [], mountedLeaves: MOUNTED })
|
||||
|
||||
// Older readers skip the added verdict; every field they read is unchanged.
|
||||
expect(await captureSurfaceReplies(runtime, { ignore: ['verdict'] })).toBe(
|
||||
golden('terminal-surface-replies-desktop.json')
|
||||
)
|
||||
const listed = await runtime.listTerminals(`id:${TEST_WORKTREE_ID}`)
|
||||
expect(listed.terminals.map((terminal) => terminal.verdict)).toEqual(['live', 'live'])
|
||||
})
|
||||
|
||||
it('keep answering after the window closes', async () => {
|
||||
const { runtime } = surfaceRuntime()
|
||||
runtime.attachWindow(1)
|
||||
@@ -70,49 +51,6 @@ describe('terminal replies from main (switch on)', () => {
|
||||
expect(shown).toMatchObject({ tabId: SURFACE_TAB_ID, ptyId: 'pty-a', paneRuntimeId: -1 })
|
||||
})
|
||||
|
||||
it('list a pane split into the layout before any window shows it', async () => {
|
||||
const { runtime, store, setSession } = surfaceRuntime()
|
||||
runtime.attachWindow(1)
|
||||
runtime.syncWindowGraph(1, { tabs: [], leaves: [], mountedLeaves: MOUNTED })
|
||||
const session = store.getSession()
|
||||
const layout = session.terminalLayoutsByTabId[SURFACE_TAB_ID]!
|
||||
setSession({
|
||||
...session,
|
||||
terminalLayoutsByTabId: {
|
||||
[SURFACE_TAB_ID]: {
|
||||
...layout,
|
||||
root: {
|
||||
type: 'split',
|
||||
direction: 'vertical',
|
||||
first: layout.root!,
|
||||
second: { type: 'leaf', leafId: THIRD_LEAF_ID }
|
||||
},
|
||||
ptyIdsByLeafId: { ...layout.ptyIdsByLeafId, [THIRD_LEAF_ID]: 'pty-c' }
|
||||
}
|
||||
}
|
||||
})
|
||||
runtime.registerPty('pty-c', TEST_WORKTREE_ID)
|
||||
await Promise.resolve()
|
||||
|
||||
const listed = await runtime.listTerminals(`id:${TEST_WORKTREE_ID}`)
|
||||
expect(listed.terminals.map((terminal) => terminal.ptyId)).toEqual(['pty-a', 'pty-b', 'pty-c'])
|
||||
})
|
||||
|
||||
it('close a tab by a pane handle in main, with no session row from the window', async () => {
|
||||
const { runtime, store, killed } = surfaceRuntime()
|
||||
runtime.attachWindow(1)
|
||||
runtime.syncWindowGraph(1, { tabs: [], leaves: [], mountedLeaves: MOUNTED })
|
||||
const listed = await runtime.listTerminals(`id:${TEST_WORKTREE_ID}`)
|
||||
const second = listed.terminals.find((terminal) => terminal.ptyId === 'pty-b')!
|
||||
|
||||
await expect(runtime.closeTerminalTab(second.handle)).resolves.toMatchObject({
|
||||
tabId: SURFACE_TAB_ID,
|
||||
closeMode: 'tab'
|
||||
})
|
||||
expect(store.getSession().tabsByWorktree[TEST_WORKTREE_ID] ?? []).toEqual([])
|
||||
expect(killed.toSorted()).toEqual(['pty-a', 'pty-b'])
|
||||
})
|
||||
|
||||
it("close a tab by an unbound pane's handle, stopping the tab's other terminals", async () => {
|
||||
const { runtime, store, setSession, killed } = surfaceRuntime()
|
||||
runtime.attachWindow(1)
|
||||
@@ -156,32 +94,4 @@ describe('terminal replies from main (switch on)', () => {
|
||||
expect(layout?.root).toEqual({ type: 'leaf', leafId: HEADLESS_LEAF_ID })
|
||||
expect(killed).toEqual(['pty-b'])
|
||||
})
|
||||
|
||||
// Main-owned liveness comes from host inventory, so graph sync must not reconnect disconnected records.
|
||||
it("a graph sync leaves a disconnected terminal of main's pane disconnected, with no reinstall", async () => {
|
||||
const { runtime } = surfaceRuntime()
|
||||
runtime.attachWindow(1)
|
||||
runtime.syncWindowGraph(1, mountedSurfaceGraph())
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: ptysById is the runtime's PTY registry; the test edits the record it keeps.
|
||||
const { ptysById } = runtime as unknown as { ptysById: Map<string, { connected: boolean }> }
|
||||
ptysById.get('pty-a')!.connected = false
|
||||
const installs: { depth: number; connected: boolean | undefined }[] = []
|
||||
let depth = 0
|
||||
const sync = runtime.syncWindowGraph.bind(runtime)
|
||||
vi.spyOn(runtime, 'syncWindowGraph').mockImplementation((windowId, graph) => {
|
||||
installs.push({ depth, connected: ptysById.get('pty-a')?.connected })
|
||||
depth += 1
|
||||
try {
|
||||
return sync(windowId, graph)
|
||||
} finally {
|
||||
depth -= 1
|
||||
}
|
||||
})
|
||||
|
||||
runtime.syncWindowGraph(1, mountedSurfaceGraph())
|
||||
await Promise.resolve()
|
||||
|
||||
expect(installs).toEqual([{ depth: 0, connected: false }])
|
||||
expect(ptysById.get('pty-a')?.connected).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,122 +0,0 @@
|
||||
import '../../orca-runtime-tests/orca-runtime-test-lifecycle.spec'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import '../../orca-runtime-tests/runtime-test-module-registration.spec'
|
||||
import { OrcaRuntimeService } from '../../orca-runtime'
|
||||
import {
|
||||
HEADLESS_LEAF_ID,
|
||||
TEST_WORKTREE_ID,
|
||||
makeRuntimeStoreWithWorkspaceSession,
|
||||
makeWorkspaceSessionWithHeadlessTerminal
|
||||
} from '../../orca-runtime-tests/orca-runtime-test-fixtures.spec'
|
||||
import type { RuntimeNotifier } from '../../runtime-notifier-contract'
|
||||
|
||||
// After a restart, a reconnecting client restarts each saved tab's terminal. The mobile row for
|
||||
// that tab must carry the tab's saved name, not only the new terminal's own title.
|
||||
function notifier(): RuntimeNotifier {
|
||||
return {
|
||||
worktreesChanged() {},
|
||||
reposChanged() {},
|
||||
activateWorktree() {},
|
||||
createTerminal() {},
|
||||
splitTerminal() {},
|
||||
renameTerminal() {},
|
||||
focusTerminal() {},
|
||||
closeTerminal() {},
|
||||
terminalFitOverrideChanged() {},
|
||||
terminalDriverChanged() {}
|
||||
}
|
||||
}
|
||||
|
||||
async function phoneSurface(runtime: OrcaRuntimeService) {
|
||||
const listed = await runtime.listMobileSessionTabs(`id:${TEST_WORKTREE_ID}`)
|
||||
const surface = listed.tabs.find(
|
||||
(tab) => tab.type === 'terminal' && tab.parentTabId === 'host-tab'
|
||||
)
|
||||
return surface?.type === 'terminal' ? surface : undefined
|
||||
}
|
||||
|
||||
function runtimeWithSession(savedName?: string | null): OrcaRuntimeService {
|
||||
const session = makeWorkspaceSessionWithHeadlessTerminal()
|
||||
if (savedName !== undefined) {
|
||||
session.tabsByWorktree[TEST_WORKTREE_ID]![0]!.customTitle = savedName
|
||||
}
|
||||
const { runtimeStore } = makeRuntimeStoreWithWorkspaceSession(session)
|
||||
return new OrcaRuntimeService(runtimeStore)
|
||||
}
|
||||
|
||||
function controller(prefix: string) {
|
||||
let spawned = 0
|
||||
return {
|
||||
spawn: vi.fn(async () => ({ id: `${prefix}-${++spawned}` })),
|
||||
write: () => true,
|
||||
kill: () => true,
|
||||
getForegroundProcess: async () => null
|
||||
}
|
||||
}
|
||||
|
||||
describe('a saved tab name after its terminal is restarted', () => {
|
||||
it.each([
|
||||
['with a notifier', true],
|
||||
['headless', false]
|
||||
])('reaches the phone %s', async (_label, withNotifier) => {
|
||||
const runtime = runtimeWithSession('Saved name')
|
||||
if (withNotifier) {
|
||||
runtime.setNotifier(notifier())
|
||||
}
|
||||
runtime.setPtyController(controller('respawned-pty'))
|
||||
|
||||
await runtime.createTerminal(`id:${TEST_WORKTREE_ID}`, {
|
||||
tabId: 'host-tab',
|
||||
leafId: HEADLESS_LEAF_ID,
|
||||
activate: true
|
||||
})
|
||||
|
||||
expect(await phoneSurface(runtime)).toMatchObject({
|
||||
title: 'Saved name',
|
||||
customTitle: 'Saved name'
|
||||
})
|
||||
})
|
||||
|
||||
// The path a reconnecting client takes after a restart: the host restarts the saved tab's
|
||||
// terminal for the client by the tab's identity.
|
||||
it('reaches the phone when a client restarts the saved tab by its identity', async () => {
|
||||
const runtime = runtimeWithSession('Saved name')
|
||||
runtime.setNotifier(notifier())
|
||||
runtime.setPtyController(controller('client-restarted-pty'))
|
||||
|
||||
await runtime['createRuntimeOwnedMobileSessionTerminal'](TEST_WORKTREE_ID, true, undefined, {
|
||||
identity: { tabId: 'host-tab', leafId: HEADLESS_LEAF_ID }
|
||||
})
|
||||
|
||||
expect(await phoneSurface(runtime)).toMatchObject({
|
||||
title: 'Saved name',
|
||||
customTitle: 'Saved name'
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps a name cleared on the phone row cleared when the terminal restarts', async () => {
|
||||
const runtime = runtimeWithSession('Saved name')
|
||||
runtime.setNotifier(notifier())
|
||||
runtime.setPtyController(controller('client-restarted-pty'))
|
||||
const identity = { tabId: 'host-tab', leafId: HEADLESS_LEAF_ID }
|
||||
await runtime['createRuntimeOwnedMobileSessionTerminal'](TEST_WORKTREE_ID, true, undefined, {
|
||||
identity
|
||||
})
|
||||
// A desktop clear reaches the row before its debounced session write drops the old name.
|
||||
const snapshot = runtime['mobileSessionTabsByWorktree'].get(TEST_WORKTREE_ID)!
|
||||
runtime['mobileSessionTabsByWorktree'].set(TEST_WORKTREE_ID, {
|
||||
...snapshot,
|
||||
tabs: snapshot.tabs.map((tab) =>
|
||||
tab.type === 'terminal' && tab.parentTabId === 'host-tab'
|
||||
? { ...tab, customTitle: null }
|
||||
: tab
|
||||
)
|
||||
})
|
||||
|
||||
await runtime['createRuntimeOwnedMobileSessionTerminal'](TEST_WORKTREE_ID, true, undefined, {
|
||||
identity
|
||||
})
|
||||
|
||||
expect((await phoneSurface(runtime))?.customTitle).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -217,16 +217,6 @@ describe('active workspace context for a focused desktop-catalog workspace (real
|
||||
}
|
||||
})
|
||||
|
||||
it('answers the real branch and display name for the linked worktree', async () => {
|
||||
const id = `${REPO_ID}::${linkedPath}`
|
||||
await expect(runtimeFor(focused(id, 'local')).resolveActiveWorktreeContext()).resolves.toEqual({
|
||||
worktreeId: id,
|
||||
path: linkedPath,
|
||||
branch: 'refs/heads/feature/parity',
|
||||
displayName: 'Parity feature'
|
||||
})
|
||||
})
|
||||
|
||||
it('matches the pre-#38653 answer for a focused SSH worktree whose host is not connected', async () => {
|
||||
const id = 'ssh-repo::/home/me/app-feature'
|
||||
for (const displayName of ['', 'Named on the box']) {
|
||||
@@ -238,17 +228,6 @@ describe('active workspace context for a focused desktop-catalog workspace (real
|
||||
}
|
||||
})
|
||||
|
||||
it('still resolves a workspace whose stored focus host is stale (local) as it did before', async () => {
|
||||
// The focus was stamped local; the repo row now routes to an SSH host (e.g. after a move).
|
||||
const id = 'moved-repo::/home/me/app'
|
||||
const rows = sshRows('moved-repo', id, 'Moved main')
|
||||
const before = await contextBefore(id, rows)
|
||||
expect(before).toMatchObject({ worktreeId: id, displayName: 'Moved main' })
|
||||
await expect(
|
||||
runtimeFor(focused(id, 'local'), rows).resolveActiveWorktreeContext()
|
||||
).resolves.toEqual(before)
|
||||
})
|
||||
|
||||
it("never answers the local twin's context for a paired-server focus with the same id", async () => {
|
||||
const id = `${REPO_ID}::${linkedPath}`
|
||||
const session = focused(id, 'runtime:env-1')
|
||||
|
||||
@@ -25,22 +25,6 @@ function guardHarness(foregrounds: LaunchedAgentForeground[]) {
|
||||
}
|
||||
|
||||
describe('the check before each write of a launch prompt', () => {
|
||||
it.each([
|
||||
['a shell turning bracketed paste on', '$ \x1b[?2004h'],
|
||||
['a shell integration prompt mark', '\x1b]133;D;1\x07\x1b]133;A\x07$ '],
|
||||
['a mark split across two chunks', ['\x1b[?20', '04h']]
|
||||
] as const)('reads again after %s, and refuses a shell', async (_label, output) => {
|
||||
const { runtime, guard, emit } = guardHarness(['agent', 'shell'])
|
||||
|
||||
await guard.beforeWrite('pty-1')
|
||||
for (const chunk of typeof output === 'string' ? [output] : output) {
|
||||
emit(chunk)
|
||||
}
|
||||
|
||||
await expect(guard.beforeWrite('pty-1')).rejects.toThrow('agent_not_in_foreground')
|
||||
expect(runtime.readLaunchedAgentForeground).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('catches a shell that returns while the first read is still running', async () => {
|
||||
const { runtime, guard, emit } = guardHarness([])
|
||||
vi.mocked(runtime.readLaunchedAgentForeground)
|
||||
@@ -54,20 +38,6 @@ describe('the check before each write of a launch prompt', () => {
|
||||
|
||||
await expect(guard.beforeWrite('pty-1')).rejects.toThrow('agent_not_in_foreground')
|
||||
})
|
||||
|
||||
it('stops watching when it refuses and when it is disposed', async () => {
|
||||
const refused = guardHarness(['shell'])
|
||||
await expect(refused.guard.beforeWrite('pty-1')).rejects.toThrow('agent_not_in_foreground')
|
||||
expect(refused.unsubscribe).toHaveBeenCalledTimes(1)
|
||||
|
||||
const cleared = guardHarness(['agent'])
|
||||
await cleared.guard.beforeWrite('pty-1')
|
||||
const scheduled = Promise.resolve().then(() => cleared.guard.beforeWrite('pty-1'))
|
||||
cleared.guard.dispose()
|
||||
await expect(scheduled).rejects.toThrow('request_aborted')
|
||||
expect(cleared.runtime.subscribeToTerminalData).toHaveBeenCalledTimes(1)
|
||||
expect(cleared.unsubscribe).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
it('invalidates reused and pending foreground proof when the terminal session is replaced', async () => {
|
||||
|
||||
@@ -1,116 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { AGENT_STATUS_STALE_AFTER_MS } from '../../../../shared/agent-status/contracts/agent-status-freshness'
|
||||
import type { AgentStatusIpcPayload } from '../../../../shared/agent-status/contracts/agent-status-ipc-payload'
|
||||
import { wslHookRelayConnectionId } from '../../../../shared/wsl-hook-relay-contract'
|
||||
import { evaluateHookTurn, readTuiIdleHookTurn } from './tui-idle-hook-lane'
|
||||
|
||||
const PANE = 'tab:11111111-1111-4111-8111-111111111111'
|
||||
|
||||
function pendingRow(overrides: Partial<AgentStatusIpcPayload> = {}): AgentStatusIpcPayload {
|
||||
const now = Date.now()
|
||||
return {
|
||||
paneKey: PANE,
|
||||
state: 'working',
|
||||
mainAgent: { state: 'done', stateStartedAt: now },
|
||||
prompt: '',
|
||||
agentType: 'claude',
|
||||
connectionId: 'host-a',
|
||||
launchToken: 'launch-a',
|
||||
claudeTaskWakeupPending: 'notification',
|
||||
providerSession: { key: 'session_id', id: 'session-a' },
|
||||
observation: {
|
||||
origin: 'hook',
|
||||
authorityId: 'host-a',
|
||||
incarnation: 1,
|
||||
revision: 1,
|
||||
observedAt: now
|
||||
},
|
||||
receivedAt: now,
|
||||
stateStartedAt: now,
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
function verdict(row: AgentStatusIpcPayload, blocked = false, titleObservedAtEpochMs?: number) {
|
||||
return evaluateHookTurn('claude', () =>
|
||||
readTuiIdleHookTurn({
|
||||
agent: 'claude',
|
||||
handles: [],
|
||||
paneKeys: [PANE],
|
||||
hookRows: [row],
|
||||
connectionId: 'host-a',
|
||||
launchToken: 'launch-a',
|
||||
titleObservedAtEpochMs,
|
||||
hasExplicitIdleTitle: titleObservedAtEpochMs !== undefined,
|
||||
resolveBlockedText: () => (blocked ? 'agent-interactive-prompt' : null)
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
describe('Claude task wake-up readiness authority', () => {
|
||||
it('vetoes rest from its own fresh hook without promoting ordinary Claude done to ready', () => {
|
||||
expect(verdict(pendingRow())).toEqual({ kind: 'working' })
|
||||
expect(verdict(pendingRow({ state: 'done', claudeTaskWakeupPending: undefined }))).toBeNull()
|
||||
expect(verdict(pendingRow({ claudeTaskWakeupPending: undefined }))).toBeNull()
|
||||
})
|
||||
|
||||
it('reports an opaque child permission wait during the finishing turn', () => {
|
||||
expect(verdict(pendingRow({ state: 'waiting' }), true)).toEqual({
|
||||
kind: 'blocked',
|
||||
reason: 'agent-interactive-prompt'
|
||||
})
|
||||
})
|
||||
|
||||
it.each([
|
||||
{ connectionId: 'host-b' },
|
||||
{ launchToken: 'old-launch' },
|
||||
{ agentType: 'codex' },
|
||||
{ providerSession: undefined },
|
||||
{ providerSessionOnly: true },
|
||||
{ restoredUnconfirmed: true },
|
||||
{ observation: undefined },
|
||||
{ paneKey: 'another-pane' }
|
||||
])('does not take authority from an unmatched or unverifiable row: %j', (overrides) => {
|
||||
expect(verdict(pendingRow(overrides))).toBeNull()
|
||||
})
|
||||
|
||||
it('allows a finishing turn’s fresh native rest when its Stop was lost, while owing still holds', () => {
|
||||
const finishing = pendingRow({ claudeTaskWakeupPending: 'finishing-turn', turnStartedAt: 10 })
|
||||
expect(verdict(finishing, false, 9)).toEqual({ kind: 'working' })
|
||||
expect(verdict(finishing, false, 10)).toEqual({ kind: 'working' })
|
||||
expect(verdict(finishing, false, 11)).toBeNull()
|
||||
expect(verdict(pendingRow(), false, 11)).toEqual({ kind: 'working' })
|
||||
const replay = pendingRow({ claudeTaskWakeupPending: 'finishing-turn' })
|
||||
expect(verdict(replay)).toEqual({ kind: 'working' })
|
||||
expect(verdict(replay, false, replay.receivedAt)).toEqual({ kind: 'working' })
|
||||
expect(verdict(replay, false, replay.receivedAt + 1)).toBeNull()
|
||||
})
|
||||
|
||||
it('joins native, SSH and WSL execution hosts without accepting another distro', () => {
|
||||
for (const [connectionId, wslDistro, rowConnectionId, held] of [
|
||||
[null, null, null, true],
|
||||
['ssh-a', 'Ubuntu', 'ssh-a', true],
|
||||
[null, 'Ubuntu', wslHookRelayConnectionId('Ubuntu'), true],
|
||||
[null, 'Ubuntu', wslHookRelayConnectionId('Debian'), false],
|
||||
['ssh-a', 'Ubuntu', wslHookRelayConnectionId('Ubuntu'), false]
|
||||
] as const) {
|
||||
const turn = readTuiIdleHookTurn({
|
||||
agent: 'claude',
|
||||
handles: [],
|
||||
paneKeys: [PANE],
|
||||
hookRows: [pendingRow({ connectionId: rowConnectionId })],
|
||||
connectionId,
|
||||
wslDistro,
|
||||
launchToken: 'launch-a',
|
||||
resolveBlockedText: () => null
|
||||
})
|
||||
expect(evaluateHookTurn('claude', () => turn)).toEqual(held ? { kind: 'working' } : null)
|
||||
}
|
||||
})
|
||||
|
||||
it('uses evidence age rather than a reconnect receipt clock', () => {
|
||||
expect(
|
||||
verdict(pendingRow({ evidenceObservedAt: Date.now() - AGENT_STATUS_STALE_AFTER_MS - 1 }))
|
||||
).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -21,15 +21,6 @@ const PANE_KEY = makePaneKey('tab-1', '11111111-1111-4111-8111-111111111111')
|
||||
const FIXTURE = readRuntimeFixture('codex-0-160-0-plan-implement-menu')
|
||||
const MENU_START = FIXTURE.indexOf('\x1b[30;1H\x1b[2m Worked for')
|
||||
const MENU_END = FIXTURE.indexOf('\x1b[?2026l', FIXTURE.indexOf('esc\x1b[22m', MENU_START))
|
||||
// A plan turn whose output quotes the menu as its last lines, with no menu on screen.
|
||||
const QUOTED_MENU = [
|
||||
'• Proposed Plan',
|
||||
'• The plan menu reads:',
|
||||
' Implement this plan?',
|
||||
' 3. No, stay in Plan mode',
|
||||
' enter select · esc back',
|
||||
''
|
||||
].join('\r\n')
|
||||
const QUIESCENCE_MS = 3000
|
||||
|
||||
function doneRow(receivedAt: number): AgentStatusIpcPayload {
|
||||
@@ -116,15 +107,4 @@ describe('Codex plan menu painted after its Stop hook', () => {
|
||||
|
||||
expect((await waiting).blockedReason).toBe('agent-interactive-prompt')
|
||||
})
|
||||
|
||||
it('settles on a done that follows output quoting the menu', async () => {
|
||||
let rows: AgentStatusIpcPayload[] = []
|
||||
const { runtime, handle } = await codexPane(() => rows)
|
||||
paint(runtime, QUOTED_MENU)
|
||||
await vi.advanceTimersByTimeAsync(100)
|
||||
rows = [doneRow(Date.now())]
|
||||
|
||||
// Held for the turn's last frame, then settled before the next poll sweep.
|
||||
expect(await waitOutcome(runtime, handle, 1000)).toBe('ready')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -202,30 +202,4 @@ describeOnWindows('a secure store that exists but cannot be read', () => {
|
||||
|
||||
expectUntouched(filePath, locked)
|
||||
})
|
||||
|
||||
/** Refresh failures must not delete unreadable account sessions; only found sessions permit that path. */
|
||||
it('does not delete the account session it could not read', async () => {
|
||||
vi.doMock('electron', () => ({
|
||||
safeStorage: {
|
||||
isEncryptionAvailable: () => true,
|
||||
encryptString: (value: string) => Buffer.from(value),
|
||||
decryptString: (buffer: Buffer) => buffer.toString()
|
||||
}
|
||||
}))
|
||||
const { readOrcaCloudSession, getOrcaCloudSessionPath } =
|
||||
await import('./../orca-profiles/profile-cloud-session-store')
|
||||
const dir = join(root, 'profiles')
|
||||
mkdirSync(dir, { recursive: true })
|
||||
const filePath = getOrcaCloudSessionPath('profile-1', dir)
|
||||
mkdirSync(join(filePath, '..'), { recursive: true })
|
||||
const original = JSON.stringify({ version: 1, format: 'dev-plaintext-v1', savedAt: 1 })
|
||||
writeFileSync(filePath, original)
|
||||
const locked = makeUnreadable(filePath)
|
||||
|
||||
// The status the delete path keys off: `unreadable`, never `decrypt-failed`.
|
||||
expect(readOrcaCloudSession('profile-1', dir).status).toBe('unreadable')
|
||||
|
||||
expectUntouched(filePath, locked)
|
||||
vi.doUnmock('electron')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,71 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
appendWaitBlockedCarry,
|
||||
createWaitBlockedAppendedCarry,
|
||||
readWaitBlockedCarry
|
||||
} from './wait-blocked-check-state'
|
||||
|
||||
import { MAX_TAIL_CHARS } from './terminal/output/tail/terminal-tail-limits'
|
||||
|
||||
/** The accumulation this replaced, verbatim, as the equivalence oracle. */
|
||||
function referenceAppend(previous: string, chunk: string): string {
|
||||
return previous.length + chunk.length > MAX_TAIL_CHARS
|
||||
? `${previous}${chunk}`.slice(-MAX_TAIL_CHARS)
|
||||
: `${previous}${chunk}`
|
||||
}
|
||||
|
||||
function mulberry32(seed: number): () => number {
|
||||
let a = seed >>> 0
|
||||
return () => {
|
||||
a = (a + 0x6d2b79f5) | 0
|
||||
let t = Math.imul(a ^ (a >>> 15), 1 | a)
|
||||
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t
|
||||
return ((t ^ (t >>> 14)) >>> 0) / 4294967296
|
||||
}
|
||||
}
|
||||
|
||||
describe('wait-blocked appended carry', () => {
|
||||
it('is byte-identical to the concat+slice carry across a 1MB flood in one 50ms window', () => {
|
||||
// A 50ms TUI flood must retain exactly the reference rolling window's bytes.
|
||||
const carry = createWaitBlockedAppendedCarry()
|
||||
let reference = ''
|
||||
const rng = mulberry32(20260902)
|
||||
let produced = 0
|
||||
let chunkIndex = 0
|
||||
while (produced < 1024 * 1024) {
|
||||
const size = 1 + Math.floor(rng() * 8192)
|
||||
const chunk = `${chunkIndex}:${'█'.repeat(Math.max(0, size - 3))}\n`
|
||||
chunkIndex += 1
|
||||
produced += chunk.length
|
||||
appendWaitBlockedCarry(carry, chunk)
|
||||
reference = referenceAppend(reference, chunk)
|
||||
expect(carry.chars).toBe(reference.length)
|
||||
}
|
||||
expect(readWaitBlockedCarry(carry)).toBe(reference)
|
||||
expect(reference.length).toBe(MAX_TAIL_CHARS)
|
||||
})
|
||||
|
||||
it('matches the reference for boundary shapes: empty, exact-cap, and over-cap single chunks', () => {
|
||||
const cases: string[][] = [
|
||||
[],
|
||||
[''],
|
||||
['abc', '', 'def'],
|
||||
['x'.repeat(MAX_TAIL_CHARS)],
|
||||
['x'.repeat(MAX_TAIL_CHARS), 'y'],
|
||||
['a', 'b'.repeat(MAX_TAIL_CHARS + 5)],
|
||||
['a'.repeat(MAX_TAIL_CHARS - 1), 'bc'],
|
||||
['a'.repeat(10), 'b'.repeat(MAX_TAIL_CHARS - 10)],
|
||||
['a'.repeat(10), 'b'.repeat(MAX_TAIL_CHARS - 10), 'c']
|
||||
]
|
||||
for (const chunks of cases) {
|
||||
const carry = createWaitBlockedAppendedCarry()
|
||||
let reference = ''
|
||||
for (const chunk of chunks) {
|
||||
appendWaitBlockedCarry(carry, chunk)
|
||||
reference = referenceAppend(reference, chunk)
|
||||
}
|
||||
expect(readWaitBlockedCarry(carry)).toBe(reference)
|
||||
expect(carry.chars).toBe(reference.length)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -125,86 +125,6 @@ describe('released worker archives preserve the host screen', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it.each([
|
||||
{ cols: 100, prefix: 89, provider: false },
|
||||
{ cols: 100, prefix: 97, provider: false },
|
||||
{ cols: 10, prefix: 7, provider: false },
|
||||
{ cols: 3, prefix: 0, provider: false },
|
||||
{ cols: 100, prefix: 89, provider: true },
|
||||
{ cols: 10, prefix: 7, provider: true }
|
||||
])(
|
||||
'masks wrapped tokens in stored and post-close output (%j)',
|
||||
async ({ cols, prefix, provider }) => {
|
||||
h.setup()
|
||||
const { dispatchId } = await h.startSettledWorker('failed')
|
||||
const secret = `dcap_${'s'.repeat(24)}`
|
||||
const text = `${'x'.repeat(prefix)} ${secret}`
|
||||
const fixture = await createTerminalArchiveFixture({
|
||||
hydrate: false,
|
||||
cols,
|
||||
provider,
|
||||
screenLines: [text, 'Safe diagnostic']
|
||||
})
|
||||
fixture.runtime.onPtyData(
|
||||
'pty-1',
|
||||
provider ? 'retained history\r\n' : `${text}\r\nSafe diagnostic\r\n`,
|
||||
Date.now()
|
||||
)
|
||||
const screen = await fixture.runtime.readTerminal(fixture.handle, { screen: true })
|
||||
expect(screen.tail.join('')).toContain(secret)
|
||||
expect(screen.tail).not.toContain(secret)
|
||||
vi.mocked(h.runtime.readTerminal).mockImplementation((_handle, opts) =>
|
||||
fixture.runtime.readTerminal(fixture.handle, opts)
|
||||
)
|
||||
vi.mocked(h.runtime.closeTerminal).mockImplementation(async (handle) => {
|
||||
fixture.runtime.onPtyExit('pty-1', 0)
|
||||
return { handle, tabId: 'tab-worker', ptyKilled: true }
|
||||
})
|
||||
|
||||
await expect(
|
||||
h.call('orchestration.workerRelease', { dispatch: dispatchId })
|
||||
).resolves.toMatchObject({ state: 'released' })
|
||||
const stored = h.db.getWorkerTerminalArchive(dispatchId)
|
||||
if (!stored) {
|
||||
throw new Error('No stored archive')
|
||||
}
|
||||
expect(stored.content).not.toContain(secret)
|
||||
const content: unknown = JSON.parse(stored.content)
|
||||
if (
|
||||
typeof content !== 'object' ||
|
||||
content === null ||
|
||||
!('lines' in content) ||
|
||||
!Array.isArray(content.lines)
|
||||
) {
|
||||
throw new Error('No stored terminal lines')
|
||||
}
|
||||
expect(content.lines.join('')).not.toContain(secret)
|
||||
expect(content.lines.join('')).toContain('Safe diagnostic')
|
||||
vi.mocked(h.runtime.readTerminal).mockRejectedValue(new Error('Live output gone'))
|
||||
const read = await h.call('orchestration.workerRead', { dispatch: dispatchId, limit: 2000 })
|
||||
expect(read).toMatchObject({
|
||||
archived: true,
|
||||
terminal: { nextCursor: null },
|
||||
warnings: expect.arrayContaining([
|
||||
'Dispatch capability tokens were redacted from terminal output.'
|
||||
])
|
||||
})
|
||||
if (
|
||||
typeof read !== 'object' ||
|
||||
read === null ||
|
||||
!('terminal' in read) ||
|
||||
typeof read.terminal !== 'object' ||
|
||||
read.terminal === null ||
|
||||
!('tail' in read.terminal) ||
|
||||
!Array.isArray(read.terminal.tail)
|
||||
) {
|
||||
throw new Error('No archived terminal output')
|
||||
}
|
||||
expect(read.terminal.tail.join('')).not.toContain(secret)
|
||||
expect(read.terminal.tail.join('')).toContain('Safe diagnostic')
|
||||
}
|
||||
)
|
||||
|
||||
it.each(['soft wrap', 'multiple lines'] as const)(
|
||||
'masks a real composer draft after release (%s)',
|
||||
async (layout) => {
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
ARCHIVE_SCREEN_LINES,
|
||||
createTerminalArchiveFixture
|
||||
} from './worker-terminal-archive.test-support'
|
||||
import { createTerminalArchiveFixture } from './worker-terminal-archive.test-support'
|
||||
import { syncSinglePty } from './orca-runtime-tests/orca-runtime-test-fixtures.spec'
|
||||
|
||||
afterEach(() => vi.restoreAllMocks())
|
||||
@@ -26,79 +23,6 @@ describe('worker archive through real terminal read routing', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps an ordinary live stream without duplicating an identical screen', async () => {
|
||||
const fixture = await createTerminalArchiveFixture({ hydrate: false })
|
||||
fixture.runtime.onPtyData('pty-1', `${ARCHIVE_SCREEN_LINES.join('\r\n')}\r\n`, Date.now())
|
||||
const archive = await fixture.capture()
|
||||
if (archive.kind !== 'terminal_tail') {
|
||||
throw new Error('Expected terminal archive')
|
||||
}
|
||||
expect(archive.content.lines).toEqual(ARCHIVE_SCREEN_LINES)
|
||||
expect(archive.content.warnings).toEqual([])
|
||||
})
|
||||
|
||||
it.each(['\r\n'.repeat(7), `host % codex\r\n${'\r\n'.repeat(6)}`])(
|
||||
'preserves a populated normal screen alongside a short stream (%j)',
|
||||
async (stream) => {
|
||||
const fixture = await createTerminalArchiveFixture()
|
||||
fixture.runtime.onPtyData('pty-1', stream, Date.now())
|
||||
const preview = await fixture.runtime.readTerminal(fixture.handle)
|
||||
expect(preview.source).toBe('stream')
|
||||
expect(preview.tail).not.toContain(ARCHIVE_SCREEN_LINES[1])
|
||||
|
||||
const archive = await fixture.capture()
|
||||
|
||||
expect(archive.kind).toBe('terminal_tail')
|
||||
expect(archive.status).toBe('captured')
|
||||
if (archive.kind !== 'terminal_tail') {
|
||||
throw new Error('Expected terminal archive')
|
||||
}
|
||||
expect(archive.content.lines).toEqual(expect.arrayContaining(ARCHIVE_SCREEN_LINES))
|
||||
expect(archive.content.warnings.join(' ')).toContain('screen')
|
||||
if (stream.includes('host % codex')) {
|
||||
expect(archive.content.lines).toContain('host % codex')
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
it('preserves retained history beyond the preview and the current screen', async () => {
|
||||
const fixture = await createTerminalArchiveFixture({ provider: true })
|
||||
const history = Array.from({ length: 180 }, (_, index) => `history ${index}`)
|
||||
fixture.runtime.onPtyData('pty-1', `${history.join('\r\n')}\r\n`, Date.now())
|
||||
expect((await fixture.runtime.readTerminal(fixture.handle)).tail).not.toContain('history 0')
|
||||
|
||||
const archive = await fixture.capture()
|
||||
|
||||
if (archive.kind !== 'terminal_tail') {
|
||||
throw new Error('Expected terminal archive')
|
||||
}
|
||||
expect(archive.content.lines).toEqual(
|
||||
expect.arrayContaining([...history, ...ARCHIVE_SCREEN_LINES])
|
||||
)
|
||||
expect(archive.content.warnings.join(' ')).toContain('screen')
|
||||
})
|
||||
|
||||
it('preserves the alternate screen using the same capture path', async () => {
|
||||
const fixture = await createTerminalArchiveFixture({ alternate: true })
|
||||
fixture.runtime.onPtyData('pty-1', '\r\n'.repeat(7), Date.now())
|
||||
const archive = await fixture.capture()
|
||||
if (archive.kind !== 'terminal_tail') {
|
||||
throw new Error('Expected terminal archive')
|
||||
}
|
||||
expect(archive.content.lines).toEqual(expect.arrayContaining(ARCHIVE_SCREEN_LINES))
|
||||
})
|
||||
|
||||
it('allows a genuinely empty terminal with honest unavailable-screen provenance', async () => {
|
||||
const fixture = await createTerminalArchiveFixture({ screenLines: [], hydrate: false })
|
||||
const archive = await fixture.capture()
|
||||
expect(archive.status).toBe('empty')
|
||||
if (archive.kind !== 'terminal_tail') {
|
||||
throw new Error('Expected terminal archive')
|
||||
}
|
||||
expect(archive.content.lines.every((line) => line.trim() === '')).toBe(true)
|
||||
expect(archive.content.warnings.join(' ')).toContain('screen was unavailable')
|
||||
})
|
||||
|
||||
it.each(['unavailable', 'stale'] as const)(
|
||||
'preserves useful stream output when the provider screen is %s',
|
||||
async (authority) => {
|
||||
@@ -130,21 +54,6 @@ describe('worker archive through real terminal read routing', () => {
|
||||
}
|
||||
)
|
||||
|
||||
it('preserves and redacts a composer draft from the authoritative screen', async () => {
|
||||
const fixture = await createTerminalArchiveFixture({ hydrate: false })
|
||||
const secret = `dcap_${'d'.repeat(24)}`
|
||||
fixture.runtime.onPtyData('pty-1', `output\r\n────────\r\n❯ ${secret}`, Date.now())
|
||||
|
||||
const archive = await fixture.capture()
|
||||
|
||||
if (archive.kind !== 'terminal_tail') {
|
||||
throw new Error('Expected terminal archive')
|
||||
}
|
||||
expect(archive.content.draft).toBe('[dispatch capability redacted]')
|
||||
expect(JSON.stringify(archive.content)).not.toContain(secret)
|
||||
expect(archive.content.lines).toContain('output')
|
||||
})
|
||||
|
||||
it('refuses capture when the terminal handle changes during a host screen read', async () => {
|
||||
const fixture = await createTerminalArchiveFixture({ provider: true })
|
||||
fixture.runtime.onPtyData('pty-1', 'retained diagnostic\r\n', Date.now())
|
||||
|
||||
@@ -108,23 +108,6 @@ describe('host wrap provenance in worker output', () => {
|
||||
}
|
||||
)
|
||||
|
||||
it('preserves a whitespace token boundary removed from raw display rows', async () => {
|
||||
const fixture = await createTerminalArchiveFixture({
|
||||
provider: true,
|
||||
cols: 25,
|
||||
screenLines: [`dcap_sssss${' '.repeat(15)}PermissionDenied`, 'Safe diagnostic']
|
||||
})
|
||||
fixture.runtime.onPtyData('pty-1', 'useful history\r\n', Date.now())
|
||||
const raw = await fixture.runtime.readTerminal(fixture.handle, { screen: true })
|
||||
expect(raw.tail).toEqual(['dcap_sssss', 'PermissionDenied', 'Safe diagnostic'])
|
||||
expect(raw.tailRows?.[1].wrapped).toBe(true)
|
||||
expect(raw.tailRows?.[0].trailingSpaces).toBe(15)
|
||||
const archive = await fixture.capture()
|
||||
expect(archive.content).toMatchObject({
|
||||
lines: ['useful history', 'dcap_sssss', 'PermissionDenied', 'Safe diagnostic']
|
||||
})
|
||||
})
|
||||
|
||||
it.each(['screen' as const, undefined])(
|
||||
'retains complete-line semantics and coverage limits without wrap metadata (source=%s)',
|
||||
async (source) => {
|
||||
@@ -160,50 +143,6 @@ describe('host wrap provenance in worker output', () => {
|
||||
}
|
||||
)
|
||||
|
||||
it.each([false, true])(
|
||||
'keeps hard screen diagnostics in live and archived reads (legacy=%s)',
|
||||
async (legacy) => {
|
||||
h.setup()
|
||||
const { dispatchId } = await h.startSettledWorker('failed')
|
||||
const fixture = await createTerminalArchiveFixture({
|
||||
provider: true,
|
||||
screenLines: ['dcap_sssss', 'PermissionDenied', 'Safe diagnostic']
|
||||
})
|
||||
fixture.runtime.onPtyData('pty-1', 'useful history\r\n', Date.now())
|
||||
vi.mocked(h.runtime.readTerminal).mockImplementation(async (_handle, opts) => {
|
||||
const read = await fixture.runtime.readTerminal(fixture.handle, opts)
|
||||
if (!legacy) {
|
||||
return read
|
||||
}
|
||||
// Older responses carry neither source nor physical wrap provenance.
|
||||
const { source: _source, tailRows: _tailRows, ...old } = read
|
||||
return old
|
||||
})
|
||||
const live = await h.call('orchestration.workerRead', {
|
||||
dispatch: dispatchId,
|
||||
source: 'terminal',
|
||||
limit: 2000
|
||||
})
|
||||
expect(readTail(live)).toContain('useful history')
|
||||
vi.mocked(h.runtime.closeTerminal).mockImplementation(async (handle) => {
|
||||
fixture.runtime.onPtyExit('pty-1', 0)
|
||||
return { handle, tabId: 'tab-worker', ptyKilled: true }
|
||||
})
|
||||
await h.call('orchestration.workerRelease', { dispatch: dispatchId })
|
||||
const stored = h.db.getWorkerTerminalArchive(dispatchId)
|
||||
expect(stored?.content).toContain('PermissionDenied')
|
||||
vi.mocked(h.runtime.readTerminal).mockRejectedValue(new Error('Live output gone'))
|
||||
const read = await h.call('orchestration.workerRead', { dispatch: dispatchId, limit: 2000 })
|
||||
expect(read).toMatchObject({ archived: true, terminal: { nextCursor: null } })
|
||||
expect(readTail(read)).toEqual([
|
||||
'useful history',
|
||||
'dcap_sssss',
|
||||
'PermissionDenied',
|
||||
'Safe diagnostic'
|
||||
])
|
||||
}
|
||||
)
|
||||
|
||||
it('keeps hard composer breaks in stored and full archived draft output', async () => {
|
||||
h.setup()
|
||||
const { dispatchId } = await h.startSettledWorker('failed')
|
||||
|
||||
@@ -12,20 +12,13 @@ import type { Store } from '../persistence/loading-store/store'
|
||||
import { WindowSession } from '../persistence/terminal-topology/terminal-topology-window-session-fixture'
|
||||
import {
|
||||
emptyTerminalSessionProfile,
|
||||
FIXTURE_FOLDER_WORKTREE_ID as FOLDER,
|
||||
FIXTURE_GIT_WORKTREE_ID as GIT,
|
||||
openTopologyStore
|
||||
} from '../persistence/terminal-topology/terminal-topology-profile-fixture'
|
||||
import { applyWindowLayoutCommand } from './terminal/layout/runtime-layout-apply'
|
||||
import {
|
||||
layoutFrameModelErrors,
|
||||
logLayoutFrameModelErrors
|
||||
} from './workspace-layout-frame-model-check'
|
||||
import './workspace-layout-frame-model-check'
|
||||
import { installLayoutFrameModelCheck } from './workspace-layout-frame-model-check.test-fixture'
|
||||
import {
|
||||
observeWorkspaceLayoutFrames,
|
||||
type LayoutFrameObservation
|
||||
} from './workspace-layout-frame-observer'
|
||||
import './workspace-layout-frame-observer'
|
||||
import {
|
||||
WorkspaceLayoutStream,
|
||||
type LayoutOperationCommit
|
||||
@@ -113,35 +106,6 @@ afterEach(() => {
|
||||
})
|
||||
|
||||
describe('one records frame per main operation', () => {
|
||||
const SPREAD = { [GIT]: 50, [FOLDER]: 49, [SSH_KEY]: 1 }
|
||||
|
||||
it('publishes 100 generated titles in three workspaces and two partitions as one frame', async () => {
|
||||
const store = await storeWith(SPREAD)
|
||||
const frames: WorkspaceLayoutStreamEvent[] = []
|
||||
const { seq } = streamOver(store).subscribe((frame) => frames.push(frame))
|
||||
|
||||
await expect(renameTabs(store, titles(SPREAD))).resolves.toEqual({ ok: true, result: {} })
|
||||
|
||||
expect(frames).toHaveLength(1)
|
||||
const frame = frames[0]
|
||||
if (frame?.type !== 'records') {
|
||||
throw new Error('expected one records frame')
|
||||
}
|
||||
expect(frame.seq).toBe(seq + 1)
|
||||
expect(
|
||||
frame.workspaces.map(({ key, change, ...records }) => [key, change, Object.keys(records)])
|
||||
).toEqual([
|
||||
[GIT, 'records', ['tabs']],
|
||||
[FOLDER, 'records', ['tabs']],
|
||||
[SSH_KEY, 'records', ['tabs']]
|
||||
])
|
||||
expect(
|
||||
frame.workspaces.flatMap((change) => ('tabs' in change ? change.tabs!.set! : []))
|
||||
).toHaveLength(100)
|
||||
expect(check.failures).toEqual([])
|
||||
expect(check.checked()).toBe(1)
|
||||
})
|
||||
|
||||
it('publishes a pane bind as one binding record, and a same-id respawn as one more', async () => {
|
||||
const store = await storeWith({ [GIT]: 2 })
|
||||
const frames: WorkspaceLayoutStreamEvent[] = []
|
||||
@@ -171,16 +135,6 @@ describe('one records frame per main operation', () => {
|
||||
])
|
||||
expect(check.failures).toEqual([])
|
||||
})
|
||||
|
||||
it('sends nothing for an operation that changes no record', async () => {
|
||||
const store = await storeWith({ [GIT]: 1 })
|
||||
const frames: WorkspaceLayoutStreamEvent[] = []
|
||||
streamOver(store).subscribe((frame) => frames.push(frame))
|
||||
|
||||
await renameTabs(store, [{ workspace: GIT, tabId: 'missing', title: 'Gone' }])
|
||||
|
||||
expect(frames).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('a throwing operation listener', () => {
|
||||
@@ -201,50 +155,6 @@ describe('a throwing operation listener', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe("main's model check on every frame", () => {
|
||||
it('passes the frame main sent and catches a broken copy of it', async () => {
|
||||
check.uninstall()
|
||||
const store = await storeWith({ [GIT]: 3 })
|
||||
const observed: LayoutFrameObservation[] = []
|
||||
observeWorkspaceLayoutFrames((observation) => observed.push(observation))
|
||||
streamOver(store).subscribe(() => {})
|
||||
|
||||
await renameTabs(store, titles({ [GIT]: 3 }))
|
||||
|
||||
expect(observed).toHaveLength(1)
|
||||
const observation = observed[0]!
|
||||
expect(layoutFrameModelErrors(observation)).toEqual([])
|
||||
const broken = structuredClone(observation.frame)
|
||||
const change = broken.workspaces[0]!
|
||||
if (change.change !== 'records') {
|
||||
throw new Error('expected a records change')
|
||||
}
|
||||
change.tabs!.set!.pop()
|
||||
expect(layoutFrameModelErrors({ ...observation, frame: broken })).toEqual([
|
||||
`frame ${broken.seq}: ${GIT} differs from a fresh projection`
|
||||
])
|
||||
observeWorkspaceLayoutFrames(null)
|
||||
})
|
||||
})
|
||||
|
||||
describe("the dev build's model check", () => {
|
||||
it('skips a frame that only changed live titles, and checks a structural one', () => {
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
const fresh = vi.fn(() => new Map())
|
||||
const frame = {
|
||||
type: 'records' as const,
|
||||
epoch: 'epoch-1',
|
||||
seq: 1,
|
||||
workspaces: [{ key: GIT, change: 'removed' as const }]
|
||||
}
|
||||
logLayoutFrameModelErrors({ frame, before: new Map(), fresh, titlesOnly: true })
|
||||
expect(fresh).not.toHaveBeenCalled()
|
||||
logLayoutFrameModelErrors({ frame, before: new Map(), fresh, titlesOnly: false })
|
||||
expect(fresh).toHaveBeenCalledTimes(1)
|
||||
warn.mockRestore()
|
||||
})
|
||||
})
|
||||
|
||||
describe('a headless main', () => {
|
||||
it('hears each operation with no layout subscriber, from one projection it shares', async () => {
|
||||
const store = await storeWith({ [GIT]: 2, [SSH_KEY]: 1 })
|
||||
@@ -271,27 +181,4 @@ describe('a headless main', () => {
|
||||
'Next'
|
||||
)
|
||||
})
|
||||
|
||||
it('sends a window-style and a phone-style subscriber the same frames', async () => {
|
||||
const store = await storeWith({ [GIT]: 2, [FOLDER]: 1 })
|
||||
const stream = streamOver(store)
|
||||
const window: WorkspaceLayoutStreamEvent[] = []
|
||||
const phone: WorkspaceLayoutStreamEvent[] = []
|
||||
const windowSub = stream.subscribe((frame) => window.push(frame))
|
||||
const phoneSub = stream.subscribe((frame) => phone.push(frame))
|
||||
|
||||
await renameTabs(store, titles({ [GIT]: 2, [FOLDER]: 1 }))
|
||||
await store.persistPtyBinding({
|
||||
worktreeId: GIT,
|
||||
tabId: tabIdOf(GIT, 0),
|
||||
leafId: leafOf(0),
|
||||
ptyId: 'pty-0'
|
||||
})
|
||||
|
||||
expect(window).toHaveLength(2)
|
||||
expect(phone).toEqual(window)
|
||||
expect(phoneSub.snapshot).toEqual(windowSub.snapshot)
|
||||
expect(check.failures).toEqual([])
|
||||
expect(check.checked()).toBe(2)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -3,27 +3,21 @@ import { LOCAL_EXECUTION_HOST_ID, type ExecutionHostId } from '../../shared/exec
|
||||
import type { WorkspaceSessionState } from '../../shared/workspace-session/workspace-session-state-types'
|
||||
import {
|
||||
localDesktopSession,
|
||||
relaySshSession,
|
||||
serverRuntimeSession
|
||||
relaySshSession
|
||||
} from '../../shared/workspace-layout/workspace-layout-profile.test-fixture'
|
||||
import {
|
||||
emptySession,
|
||||
FOLDER_KEY,
|
||||
GIT_KEY,
|
||||
SERVER_KEY,
|
||||
SSH_KEY
|
||||
} from '../../shared/workspace-layout/workspace-layout-session.test-fixture'
|
||||
import { loadWorkspaceLayout } from '../../shared/workspace-layout/workspace-layout-load'
|
||||
import { nameBasedLoadContext } from '../../shared/workspace-layout/workspace-layout-minted-ids'
|
||||
import {
|
||||
publishWorkspaceLayout,
|
||||
type PublishedWorkspaceLayout
|
||||
} from '../../shared/workspace-layout/workspace-layout-published'
|
||||
import '../../shared/workspace-layout/workspace-layout-load'
|
||||
import '../../shared/workspace-layout/workspace-layout-minted-ids'
|
||||
import '../../shared/workspace-layout/workspace-layout-published'
|
||||
import { WorkspaceLayoutStream } from './terminal/surface/workspace-layout-stream'
|
||||
import { collectLayoutEvents } from './orca-runtime-tests/workspace-layout-stream-events.test-fixture'
|
||||
|
||||
const SSH_HOST: ExecutionHostId = 'ssh:target-1'
|
||||
const SERVER_HOST: ExecutionHostId = 'runtime:env-1'
|
||||
|
||||
function fakeStore(partitions: Map<ExecutionHostId, WorkspaceSessionState>) {
|
||||
const listeners = new Set<(hostId: ExecutionHostId | null) => void>()
|
||||
@@ -54,23 +48,6 @@ function fakeStore(partitions: Map<ExecutionHostId, WorkspaceSessionState>) {
|
||||
}
|
||||
}
|
||||
|
||||
/** Full uncached projection is the reference for partition-scoped updates. */
|
||||
function fullProjection(
|
||||
partitions: Map<ExecutionHostId, WorkspaceSessionState>,
|
||||
homeHostId: (key: string) => ExecutionHostId | null
|
||||
): [string, PublishedWorkspaceLayout][] {
|
||||
const projected = new Map<string, PublishedWorkspaceLayout>()
|
||||
for (const [hostId, session] of partitions) {
|
||||
const { layout } = loadWorkspaceLayout(hostId, session, nameBasedLoadContext())
|
||||
for (const [key, workspace] of Object.entries(layout.workspaces)) {
|
||||
if (homeHostId(key) === hostId) {
|
||||
projected.set(key, publishWorkspaceLayout(workspace, hostId))
|
||||
}
|
||||
}
|
||||
}
|
||||
return [...projected]
|
||||
}
|
||||
|
||||
function setup() {
|
||||
const partitions = new Map<ExecutionHostId, WorkspaceSessionState>([
|
||||
[LOCAL_EXECUTION_HOST_ID, localDesktopSession()],
|
||||
@@ -102,18 +79,6 @@ function subscribeEvents(stream: WorkspaceLayoutStream) {
|
||||
}
|
||||
|
||||
describe('WorkspaceLayoutStream', () => {
|
||||
it('does nothing until someone subscribes, and stops when the last one leaves', () => {
|
||||
const { stream, store, listeners } = setup()
|
||||
expect(store.onWorkspaceSessionWritten).not.toHaveBeenCalled()
|
||||
const first = stream.subscribe(() => {})
|
||||
const second = stream.subscribe(() => {})
|
||||
expect(listeners.size).toBe(1)
|
||||
first.unsubscribe()
|
||||
expect(listeners.size).toBe(1)
|
||||
second.unsubscribe()
|
||||
expect(listeners.size).toBe(0)
|
||||
})
|
||||
|
||||
it('snapshots each workspace from its owning partition only', () => {
|
||||
const { stream, homes } = setup()
|
||||
const { snapshot } = stream.subscribe(() => {})
|
||||
@@ -133,45 +98,6 @@ describe('WorkspaceLayoutStream', () => {
|
||||
expect(strayless.map((entry) => entry.key)).toEqual([GIT_KEY, FOLDER_KEY])
|
||||
})
|
||||
|
||||
it('publishes a workspace when a write changes it by value, and its removal', async () => {
|
||||
const { stream, write } = setup()
|
||||
const { events } = subscribeEvents(stream)
|
||||
const session = localDesktopSession()
|
||||
write(LOCAL_EXECUTION_HOST_ID, session)
|
||||
await flush()
|
||||
expect(events).toEqual([])
|
||||
|
||||
session.tabGroups![GIT_KEY]![0]!.tabOrder.reverse()
|
||||
write(LOCAL_EXECUTION_HOST_ID, session)
|
||||
write(LOCAL_EXECUTION_HOST_ID, session)
|
||||
await flush()
|
||||
expect(events.map((event) => [event.type, event.key])).toEqual([['workspace', GIT_KEY]])
|
||||
|
||||
const withoutFolder = structuredClone(session)
|
||||
delete withoutFolder.tabsByWorktree[FOLDER_KEY]
|
||||
delete withoutFolder.unifiedTabs![FOLDER_KEY]
|
||||
delete withoutFolder.tabGroups![FOLDER_KEY]
|
||||
delete withoutFolder.tabGroupLayouts![FOLDER_KEY]
|
||||
write(LOCAL_EXECUTION_HOST_ID, withoutFolder)
|
||||
await flush()
|
||||
expect(events.at(-1)).toEqual({ type: 'removed', key: FOLDER_KEY, seq: events[0]!.seq! + 1 })
|
||||
})
|
||||
|
||||
it("publishes nothing for a pane's grid, which is main's own record", async () => {
|
||||
const { stream, write } = setup()
|
||||
const { events } = subscribeEvents(stream)
|
||||
const session = localDesktopSession()
|
||||
const [tabId, layout] = Object.entries(session.terminalLayoutsByTabId).find(
|
||||
([, candidate]) => candidate.root?.type === 'leaf'
|
||||
)!
|
||||
const leafId = layout.root!.type === 'leaf' ? layout.root!.leafId : ''
|
||||
layout.lastGridsByLeafId = { [leafId]: { cols: 91, rows: 33 } }
|
||||
write(LOCAL_EXECUTION_HOST_ID, session)
|
||||
await flush()
|
||||
expect(session.terminalLayoutsByTabId[tabId]?.lastGridsByLeafId).toBeDefined()
|
||||
expect(events).toEqual([])
|
||||
})
|
||||
|
||||
it('numbers each change frame, so a reply can name the frame holding its write', async () => {
|
||||
const { stream, write } = setup()
|
||||
const { events, subscription: first } = subscribeEvents(stream)
|
||||
@@ -196,23 +122,6 @@ describe('WorkspaceLayoutStream', () => {
|
||||
second.unsubscribe()
|
||||
})
|
||||
|
||||
it('logs a failing listener once and never throws into the write', async () => {
|
||||
const { stream, write } = setup()
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
stream.subscribe(() => {
|
||||
throw new Error('listener failed')
|
||||
})
|
||||
const session = localDesktopSession()
|
||||
session.tabGroups![GIT_KEY]![0]!.tabOrder.reverse()
|
||||
expect(() => write(LOCAL_EXECUTION_HOST_ID, session)).not.toThrow()
|
||||
await flush()
|
||||
session.tabGroups![GIT_KEY]![0]!.tabOrder.reverse()
|
||||
write(LOCAL_EXECUTION_HOST_ID, structuredClone(session))
|
||||
await flush()
|
||||
expect(warn).toHaveBeenCalledTimes(1)
|
||||
warn.mockRestore()
|
||||
})
|
||||
|
||||
it('keeps delivering to every listener when one throws', async () => {
|
||||
const { stream, write } = setup()
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
@@ -280,58 +189,6 @@ describe('WorkspaceLayoutStream', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('re-projects only the partition a write names', async () => {
|
||||
const { stream, store, write, writeUnnamed } = setup()
|
||||
stream.subscribe(() => {})
|
||||
store.getWorkspaceSession.mockClear()
|
||||
write(SSH_HOST, relaySshSession('target-1'))
|
||||
await flush()
|
||||
expect(store.getWorkspaceSession.mock.calls).toEqual([[SSH_HOST]])
|
||||
store.getWorkspaceSession.mockClear()
|
||||
writeUnnamed(SSH_HOST, relaySshSession('target-1'))
|
||||
await flush()
|
||||
expect(store.getWorkspaceSession.mock.calls).toEqual([[LOCAL_EXECUTION_HOST_ID], [SSH_HOST]])
|
||||
})
|
||||
|
||||
it('publishes what a full re-projection with the load report would, write after write', async () => {
|
||||
const { stream, write, writeUnnamed, removePartition, homes, partitions } = setup()
|
||||
const homeHostId = (key: string) => homes[key] ?? null
|
||||
const { collector } = subscribeEvents(stream)
|
||||
const expectFullProjection = async (): Promise<void> => {
|
||||
await flush()
|
||||
const expected = fullProjection(partitions, homeHostId)
|
||||
expect(Object.fromEntries(collector.layouts)).toEqual(Object.fromEntries(expected))
|
||||
const late = stream.subscribe(() => {})
|
||||
expect(late.snapshot.map((entry) => [entry.key, entry.layout])).toEqual(expected)
|
||||
late.unsubscribe()
|
||||
}
|
||||
await expectFullProjection()
|
||||
|
||||
const local = localDesktopSession()
|
||||
local.tabGroups![GIT_KEY]![0]!.tabOrder.reverse()
|
||||
write(LOCAL_EXECUTION_HOST_ID, local)
|
||||
await expectFullProjection()
|
||||
|
||||
// A named write and an unnamed one in the same task.
|
||||
const ssh = relaySshSession('target-1')
|
||||
delete ssh.tabsByWorktree[SSH_KEY]
|
||||
delete ssh.unifiedTabs![SSH_KEY]
|
||||
write(SSH_HOST, ssh)
|
||||
writeUnnamed(LOCAL_EXECUTION_HOST_ID, localDesktopSession())
|
||||
await expectFullProjection()
|
||||
|
||||
// A new partition, a home that moves between partitions, then the partition removed.
|
||||
homes[SERVER_KEY] = SERVER_HOST
|
||||
write(SERVER_HOST, serverRuntimeSession('env-1'))
|
||||
await expectFullProjection()
|
||||
homes[GIT_KEY] = SERVER_HOST
|
||||
write(SSH_HOST, relaySshSession('target-1'))
|
||||
await expectFullProjection()
|
||||
homes[GIT_KEY] = LOCAL_EXECUTION_HOST_ID
|
||||
removePartition(SERVER_HOST)
|
||||
await expectFullProjection()
|
||||
})
|
||||
|
||||
it('re-projects every partition after a failed projection', async () => {
|
||||
const { stream, store, write } = setup()
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
@@ -351,89 +208,4 @@ describe('WorkspaceLayoutStream', () => {
|
||||
expect(warn).toHaveBeenCalledTimes(1)
|
||||
warn.mockRestore()
|
||||
})
|
||||
|
||||
describe('live pane titles', () => {
|
||||
const panesOf = (layout: PublishedWorkspaceLayout) =>
|
||||
layout.tabs.flatMap((tab) => tab.terminal?.panes ?? [])
|
||||
const ptyIdsOf = (layout: PublishedWorkspaceLayout) =>
|
||||
panesOf(layout).flatMap((pane) => (pane.ptyId ? [pane.ptyId] : []))
|
||||
|
||||
it('lays each pane terminal title over its pane, and only there', () => {
|
||||
const { stream, liveTitles } = setup()
|
||||
const bare = stream.subscribe(() => {}).snapshot
|
||||
const [first, second] = ptyIdsOf(bare.find((entry) => entry.key === GIT_KEY)!.layout)
|
||||
liveTitles.set(first, 'claude: working')
|
||||
liveTitles.set(second, 'vim')
|
||||
const late = setup()
|
||||
late.liveTitles.set(first, 'claude: working')
|
||||
late.liveTitles.set(second, 'vim')
|
||||
const titled = late.stream.subscribe(() => {}).snapshot
|
||||
const git = titled.find((entry) => entry.key === GIT_KEY)!.layout
|
||||
const byPty = new Map(panesOf(git).map((pane) => [pane.ptyId, pane.liveTitle]))
|
||||
expect(byPty.get(first)).toBe('claude: working')
|
||||
expect(byPty.get(second)).toBe('vim')
|
||||
const stripped = titled.map((entry) => ({
|
||||
key: entry.key,
|
||||
layout: JSON.parse(
|
||||
JSON.stringify(entry.layout, (k, v) => (k === 'liveTitle' ? undefined : v))
|
||||
)
|
||||
}))
|
||||
expect(stripped).toEqual(bare)
|
||||
})
|
||||
|
||||
it('publishes a title change without reading the Store, one frame per burst', async () => {
|
||||
const { stream, store, liveTitles, write } = setup()
|
||||
const {
|
||||
events,
|
||||
subscription: { snapshot }
|
||||
} = subscribeEvents(stream)
|
||||
const [ptyId] = ptyIdsOf(snapshot.find((entry) => entry.key === GIT_KEY)!.layout)
|
||||
store.getWorkspaceSession.mockClear()
|
||||
for (let frame = 0; frame < 50; frame++) {
|
||||
liveTitles.set(ptyId, `build ${frame}`)
|
||||
stream.markLiveTitlesDirty()
|
||||
}
|
||||
await flush()
|
||||
expect(store.getWorkspaceSession).not.toHaveBeenCalled()
|
||||
expect(events).toHaveLength(1)
|
||||
const event = events[0]
|
||||
expect(event.type === 'workspace' && event.key).toBe(GIT_KEY)
|
||||
expect(
|
||||
event.type === 'workspace' && panesOf(event.layout).find((p) => p.ptyId === ptyId)
|
||||
).toMatchObject({ liveTitle: 'build 49' })
|
||||
|
||||
// An unchanged title sends nothing; a structural write keeps the title on.
|
||||
stream.markLiveTitlesDirty()
|
||||
await flush()
|
||||
expect(events).toHaveLength(1)
|
||||
const session = localDesktopSession()
|
||||
session.tabGroups![GIT_KEY]![0]!.tabOrder.reverse()
|
||||
write(LOCAL_EXECUTION_HOST_ID, session)
|
||||
await flush()
|
||||
const rewritten = events.at(-1)!
|
||||
expect(
|
||||
rewritten.type === 'workspace' && panesOf(rewritten.layout).find((p) => p.ptyId === ptyId)
|
||||
).toMatchObject({ liveTitle: 'build 49' })
|
||||
|
||||
liveTitles.delete(ptyId)
|
||||
stream.markLiveTitlesDirty()
|
||||
await flush()
|
||||
const cleared = events.at(-1)!
|
||||
expect(cleared.type === 'workspace' && panesOf(cleared.layout).some((p) => p.liveTitle)).toBe(
|
||||
false
|
||||
)
|
||||
})
|
||||
|
||||
it('reads no title while nobody subscribes', async () => {
|
||||
const { stream, liveTitle } = setup()
|
||||
stream.markLiveTitlesDirty()
|
||||
await flush()
|
||||
expect(liveTitle).not.toHaveBeenCalled()
|
||||
stream.subscribe(() => {}).unsubscribe()
|
||||
liveTitle.mockClear()
|
||||
stream.markLiveTitlesDirty()
|
||||
await flush()
|
||||
expect(liveTitle).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,242 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { NotificationSettings } from '../../shared/notification-settings-types'
|
||||
import type { PersistedState } from '../../shared/persisted-state-types'
|
||||
import type { Worktree } from '../../shared/worktree/types'
|
||||
import type { WorktreeMeta } from '../../shared/worktree/meta-types'
|
||||
import type { WorktreeLineage } from '../../shared/worktree/lineage-types'
|
||||
import type { FolderWorkspace } from '../../shared/folder-workspaces/folder-workspace-types'
|
||||
import { getDefaultNotificationSettings } from '../../shared/notification-settings-defaults'
|
||||
import { normalizeNotificationSettings } from '../persistence/applying-settings/onboarding-normalization'
|
||||
import {
|
||||
isWorkspaceHiddenBySidebarFilters,
|
||||
isWorkspaceNotificationMuted,
|
||||
type WorkspaceNotificationMuteDeps
|
||||
} from './push/notifications/workspace-notification-mute'
|
||||
|
||||
const WORKSPACE_ID = 'repo-1::/work/feature'
|
||||
const SCOPE = WORKSPACE_ID
|
||||
|
||||
function listed(overrides: Partial<Worktree> = {}): Worktree {
|
||||
return {
|
||||
id: WORKSPACE_ID,
|
||||
repoId: 'repo-1',
|
||||
path: '/work/feature',
|
||||
head: 'abc1234',
|
||||
branch: 'refs/heads/feature',
|
||||
isBare: false,
|
||||
isMainWorktree: false,
|
||||
displayName: 'feature',
|
||||
comment: '',
|
||||
linkedIssue: null,
|
||||
linkedPR: null,
|
||||
linkedLinearIssue: null,
|
||||
isArchived: false,
|
||||
isUnread: false,
|
||||
isPinned: false,
|
||||
sortOrder: 0,
|
||||
lastActivityAt: 0,
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
function deps(options: {
|
||||
ui?: Partial<PersistedState['ui']> | null
|
||||
resolved?: Worktree[] | null
|
||||
meta?: Partial<WorktreeMeta>
|
||||
metaById?: Record<string, Partial<WorktreeMeta>>
|
||||
folders?: FolderWorkspace[]
|
||||
lineage?: Record<string, Pick<WorktreeLineage, 'worktreeId' | 'parentWorktreeId'>>
|
||||
notifications?: NotificationSettings
|
||||
}): WorkspaceNotificationMuteDeps {
|
||||
return {
|
||||
store: {
|
||||
getSettings: () => ({ notifications: options.notifications }),
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the check reads only the hide* switches of this partial UI.
|
||||
getUI: () => (options.ui === null ? undefined : options.ui) as PersistedState['ui'],
|
||||
getRepo: () => undefined,
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the check reads only provenance fields of this partial meta.
|
||||
getWorktreeMeta: (id) => (options.metaById?.[id] ?? options.meta) as WorktreeMeta | undefined,
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the check reads only worktreeId and parentWorktreeId.
|
||||
getAllWorktreeLineage: () => (options.lineage ?? {}) as Record<string, WorktreeLineage>,
|
||||
getFolderWorkspaces: () => options.folders ?? []
|
||||
},
|
||||
resolvedWorktrees: options.resolved === undefined ? [listed()] : options.resolved
|
||||
}
|
||||
}
|
||||
|
||||
const CLI_META: Partial<WorktreeMeta> = { cliProvenance: { kind: 'created-by-cli', createdAt: 0 } }
|
||||
|
||||
describe('isWorkspaceHiddenBySidebarFilters', () => {
|
||||
it('hides a CLI-created workspace from its provenance even with no Git listing', () => {
|
||||
expect(
|
||||
isWorkspaceHiddenBySidebarFilters(
|
||||
deps({ ui: { hideCliCreatedWorkspaces: true }, resolved: null, meta: CLI_META }),
|
||||
SCOPE
|
||||
)
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('hides an automation-created, default-branch or detached-HEAD workspace by its switch', () => {
|
||||
const automation: Partial<WorktreeMeta> = {
|
||||
automationProvenance: {
|
||||
kind: 'created-by-automation',
|
||||
automationId: 'a',
|
||||
automationNameSnapshot: 'a',
|
||||
automationRunId: 'r',
|
||||
automationRunTitleSnapshot: 'r',
|
||||
createdAt: 0,
|
||||
executionTargetType: 'local',
|
||||
executionTargetId: 'local',
|
||||
projectId: 'p'
|
||||
}
|
||||
}
|
||||
expect(
|
||||
isWorkspaceHiddenBySidebarFilters(
|
||||
deps({ ui: { hideAutomationGeneratedWorkspaces: true }, meta: automation, resolved: null }),
|
||||
SCOPE
|
||||
)
|
||||
).toBe(true)
|
||||
expect(
|
||||
isWorkspaceHiddenBySidebarFilters(
|
||||
deps({
|
||||
ui: { hideDefaultBranchWorkspace: true },
|
||||
resolved: [listed({ isMainWorktree: true, branch: 'refs/heads/main' })]
|
||||
}),
|
||||
SCOPE
|
||||
)
|
||||
).toBe(true)
|
||||
expect(
|
||||
isWorkspaceHiddenBySidebarFilters(
|
||||
deps({ ui: { hideDetachedHeadWorkspaces: true }, resolved: [listed({ branch: '' })] }),
|
||||
SCOPE
|
||||
)
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('is not hidden when no switch matches or the switch is off', () => {
|
||||
const on = { hideCliCreatedWorkspaces: true }
|
||||
expect(isWorkspaceHiddenBySidebarFilters(deps({ ui: on }), SCOPE)).toBe(false)
|
||||
expect(isWorkspaceHiddenBySidebarFilters(deps({ ui: {}, meta: CLI_META }), SCOPE)).toBe(false)
|
||||
})
|
||||
|
||||
it('fails open without persisted UI, a workspace record, or for a folder or shown lineage parent', () => {
|
||||
const on = { hideCliCreatedWorkspaces: true }
|
||||
expect(isWorkspaceHiddenBySidebarFilters(deps({ ui: null, meta: CLI_META }), SCOPE)).toBe(false)
|
||||
expect(isWorkspaceHiddenBySidebarFilters(deps({ ui: on, resolved: null }), SCOPE)).toBe(false)
|
||||
expect(
|
||||
isWorkspaceHiddenBySidebarFilters(deps({ ui: on, meta: CLI_META }), 'folder:missing')
|
||||
).toBe(false)
|
||||
expect(
|
||||
isWorkspaceHiddenBySidebarFilters(
|
||||
deps({
|
||||
ui: on,
|
||||
resolved: null,
|
||||
meta: CLI_META,
|
||||
metaById: { child: {} },
|
||||
lineage: { child: { worktreeId: 'child', parentWorktreeId: WORKSPACE_ID } }
|
||||
}),
|
||||
SCOPE
|
||||
)
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('hides a lineage parent whose children are all hidden too', () => {
|
||||
expect(
|
||||
isWorkspaceHiddenBySidebarFilters(
|
||||
deps({
|
||||
ui: { hideCliCreatedWorkspaces: true },
|
||||
resolved: null,
|
||||
meta: CLI_META,
|
||||
lineage: { child: { worktreeId: 'child', parentWorktreeId: WORKSPACE_ID } }
|
||||
}),
|
||||
SCOPE
|
||||
)
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('hides another client’s worktree or folder workspace under Hide other-client workspaces', () => {
|
||||
const fromPhone = { kind: 'paired-device', deviceId: 'phone' } as const
|
||||
const hide = { hideWorkspacesFromOtherDevices: true }
|
||||
expect(
|
||||
isWorkspaceHiddenBySidebarFilters(
|
||||
deps({ ui: hide, resolved: [listed({ creatorProvenance: fromPhone })] }),
|
||||
SCOPE
|
||||
)
|
||||
).toBe(true)
|
||||
expect(
|
||||
isWorkspaceHiddenBySidebarFilters(
|
||||
deps({ ui: hide, meta: { creatorProvenance: { kind: 'host' } } }),
|
||||
SCOPE
|
||||
)
|
||||
).toBe(false)
|
||||
const folder: FolderWorkspace = {
|
||||
id: 'folder-1',
|
||||
projectGroupId: 'g1',
|
||||
name: 'notes',
|
||||
folderPath: '/notes',
|
||||
creatorProvenance: fromPhone,
|
||||
linkedTask: null,
|
||||
comment: '',
|
||||
isArchived: false,
|
||||
isUnread: false,
|
||||
isPinned: false,
|
||||
sortOrder: 0,
|
||||
lastActivityAt: 0,
|
||||
createdAt: 0,
|
||||
updatedAt: 0
|
||||
}
|
||||
const folderScope = 'folder:folder-1'
|
||||
expect(
|
||||
isWorkspaceHiddenBySidebarFilters(deps({ ui: hide, folders: [folder] }), folderScope)
|
||||
).toBe(true)
|
||||
expect(
|
||||
isWorkspaceHiddenBySidebarFilters(
|
||||
deps({ ui: { hideCliCreatedWorkspaces: true }, folders: [folder] }),
|
||||
folderScope
|
||||
)
|
||||
).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('isWorkspaceNotificationMuted', () => {
|
||||
const hidden = { ui: { hideCliCreatedWorkspaces: true }, resolved: null, meta: CLI_META }
|
||||
const notify = (notifyHiddenWorkspaces: boolean) => ({
|
||||
...getDefaultNotificationSettings(),
|
||||
notifyHiddenWorkspaces
|
||||
})
|
||||
|
||||
it('mutes a hidden workspace only while "Notify About Hidden Workspaces" is off', () => {
|
||||
expect(
|
||||
isWorkspaceNotificationMuted(deps({ ...hidden, notifications: notify(false) }), SCOPE)
|
||||
).toBe(true)
|
||||
expect(
|
||||
isWorkspaceNotificationMuted(deps({ ...hidden, notifications: notify(true) }), SCOPE)
|
||||
).toBe(false)
|
||||
expect(isWorkspaceNotificationMuted(deps({ notifications: notify(false) }), SCOPE)).toBe(false)
|
||||
})
|
||||
|
||||
it('reads the saved value as loaded from disk, and the default (off) for a file without it', () => {
|
||||
const saved = normalizeNotificationSettings({ notifyHiddenWorkspaces: true })
|
||||
expect(isWorkspaceNotificationMuted(deps({ ...hidden, notifications: saved }), SCOPE)).toBe(
|
||||
false
|
||||
)
|
||||
const older = normalizeNotificationSettings({ enabled: true })
|
||||
expect(older.notifyHiddenWorkspaces).toBe(false)
|
||||
expect(isWorkspaceNotificationMuted(deps({ ...hidden, notifications: older }), SCOPE)).toBe(
|
||||
true
|
||||
)
|
||||
})
|
||||
|
||||
it('reads the persisted default (off), and fails open with no notification settings', () => {
|
||||
expect(getDefaultNotificationSettings().notifyHiddenWorkspaces).toBe(false)
|
||||
expect(
|
||||
isWorkspaceNotificationMuted(
|
||||
deps({ ...hidden, notifications: getDefaultNotificationSettings() }),
|
||||
SCOPE
|
||||
)
|
||||
).toBe(true)
|
||||
expect(isWorkspaceNotificationMuted(deps({ ...hidden, notifications: undefined }), SCOPE)).toBe(
|
||||
false
|
||||
)
|
||||
})
|
||||
})
|
||||
-55
@@ -479,59 +479,4 @@ describe('external worktree discovery for paired clients', () => {
|
||||
runtime.notifyReposChangedForRemoteClients()
|
||||
expect(resultType(await reader.next('collision-events'))).toBe('reposChanged')
|
||||
})
|
||||
|
||||
it('keeps the shared runtime publication valid without a headed renderer', async () => {
|
||||
vi.mocked(listWorktrees).mockResolvedValue([initialWorktree])
|
||||
vi.mocked(listWorktreesStrict).mockResolvedValue([initialWorktree])
|
||||
const runtime = new OrcaRuntimeService(makeStore() as never)
|
||||
const userDataPath = mkdtempSync(join(tmpdir(), 'o-ewd-h-'))
|
||||
tempDirs.push(userDataPath)
|
||||
const server = new OrcaRuntimeRpcServer({
|
||||
runtime,
|
||||
userDataPath,
|
||||
enableWebSocket: true,
|
||||
wsPort: 0
|
||||
})
|
||||
servers.push(server)
|
||||
await server.start()
|
||||
const offer = server.createPairingOffer({
|
||||
address: '127.0.0.1',
|
||||
name: 'headless-client',
|
||||
scope: 'runtime'
|
||||
})
|
||||
if (!offer.available) {
|
||||
throw new Error('pairing_unavailable')
|
||||
}
|
||||
const client = await authenticate(offer.pairingUrl)
|
||||
const reader = createReader(client)
|
||||
sessions.push(client)
|
||||
readers.push(reader)
|
||||
send(client, { id: 'headless-events', method: 'runtime.clientEvents.subscribe' })
|
||||
await reader.next('headless-events', (response) => resultType(response) === 'ready')
|
||||
send(client, {
|
||||
id: 'headless-initial-catalog',
|
||||
method: 'worktree.list',
|
||||
params: { repo: REPO_ID, limit: 100 }
|
||||
})
|
||||
expect(catalogPaths(await reader.next('headless-initial-catalog'))).toEqual([
|
||||
initialWorktreePath
|
||||
])
|
||||
|
||||
vi.mocked(listWorktrees).mockResolvedValue([initialWorktree, externalWorktree])
|
||||
vi.mocked(listWorktreesStrict).mockResolvedValue([initialWorktree, externalWorktree])
|
||||
runtime.notifyWorktreeCatalogChangedForRemoteClients(REPO_ID)
|
||||
|
||||
await expect(
|
||||
reader.next('headless-events', (response) => resultType(response) === 'worktreesChanged')
|
||||
).resolves.toMatchObject({ result: { repoId: REPO_ID } })
|
||||
send(client, {
|
||||
id: 'headless-refreshed-catalog',
|
||||
method: 'worktree.list',
|
||||
params: { repo: REPO_ID, limit: 100 }
|
||||
})
|
||||
expect(catalogPaths(await reader.next('headless-refreshed-catalog'))).toEqual([
|
||||
initialWorktreePath,
|
||||
externalWorktreePath
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
-133
@@ -1,133 +0,0 @@
|
||||
import type * as WorktreeListingModule from '../../../git/worktrees/catalog/worktree-listing'
|
||||
// Headless hosts never load ipc/worktrees; runtime changes must invalidate both scan generation and cache.
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { Repo } from '../../../../shared/repo-types'
|
||||
import type { DetectedWorktreeListResult } from '../../../../shared/worktree/types'
|
||||
|
||||
const electronMocks = vi.hoisted(() => {
|
||||
const ipcMain = {
|
||||
on: vi.fn(() => ipcMain),
|
||||
removeListener: vi.fn(() => ipcMain),
|
||||
emit: vi.fn(() => true)
|
||||
}
|
||||
return {
|
||||
BrowserWindow: { fromId: vi.fn((): unknown => null) },
|
||||
webContents: { fromId: vi.fn((): unknown => null) },
|
||||
ipcMain,
|
||||
app: { getPath: vi.fn(() => '/tmp'), isPackaged: false }
|
||||
}
|
||||
})
|
||||
vi.mock('../../../git/worktrees/catalog/worktree-listing', async (importOriginal) => {
|
||||
return {
|
||||
...(await importOriginal<typeof WorktreeListingModule>()),
|
||||
listWorktreesStrict: listWorktreesStrictMock
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('electron', () => electronMocks)
|
||||
|
||||
const listWorktreesStrictMock = vi.hoisted(() => vi.fn())
|
||||
|
||||
vi.mock('./repo-worktree-admin-fingerprint', () => ({
|
||||
readRepoWorktreeAdminFingerprint: vi.fn(async () => null)
|
||||
}))
|
||||
|
||||
import { OrcaRuntimeService } from '../../orca-runtime'
|
||||
import { runWorktreeChangeInvalidators } from '../../../ipc/worktrees/listing/worktree-change-invalidators'
|
||||
import { getLocalWorktreeScanGeneration } from '../../../workspaces/catalog/local-worktree-scan-generation'
|
||||
import { partialRuntimeStore } from '../../orca-runtime-tests/runtime-store-partial-fixture'
|
||||
|
||||
const REPO_ID = 'repo-local'
|
||||
const REPO_PATH = '/Users/me/dev/app'
|
||||
const CREATED_PATH = '/Users/me/dev/app-created'
|
||||
|
||||
const repo: Repo = {
|
||||
id: REPO_ID,
|
||||
path: REPO_PATH,
|
||||
displayName: 'app',
|
||||
badgeColor: 'blue',
|
||||
addedAt: 1
|
||||
}
|
||||
|
||||
function row(path: string) {
|
||||
return { path, head: 'abc', branch: 'main', isBare: false, isMainWorktree: path === REPO_PATH }
|
||||
}
|
||||
|
||||
function makeStore() {
|
||||
return {
|
||||
getRepo: (id: string) => (id === REPO_ID ? repo : undefined),
|
||||
getRepos: () => [repo],
|
||||
getAllWorktreeMeta: () => ({}),
|
||||
getWorktreeMeta: () => undefined,
|
||||
setWorktreeMeta: vi.fn(),
|
||||
removeWorktreeMeta: () => {},
|
||||
getAllWorktreeLineage: () => ({}),
|
||||
getAllWorkspaceLineage: () => ({}),
|
||||
removeWorktreeLineage: vi.fn(),
|
||||
removeWorkspaceLineage: vi.fn(),
|
||||
getSettings: () => ({
|
||||
workspaceDir: '/tmp/workspaces',
|
||||
nestWorkspaces: false,
|
||||
refreshLocalBaseRefOnWorktreeCreate: false,
|
||||
branchPrefix: 'none',
|
||||
branchPrefixCustom: ''
|
||||
}),
|
||||
getProjects: () => []
|
||||
}
|
||||
}
|
||||
|
||||
function makeRuntimeService(): OrcaRuntimeService {
|
||||
return new OrcaRuntimeService(partialRuntimeStore(makeStore()))
|
||||
}
|
||||
|
||||
function makeRuntime(): () => Promise<DetectedWorktreeListResult> {
|
||||
const runtime = makeRuntimeService()
|
||||
return () => runtime.listDetectedManagedWorktrees(`id:${REPO_ID}`)
|
||||
}
|
||||
|
||||
describe('runtime worktree change invalidation', () => {
|
||||
beforeEach(() => {
|
||||
listWorktreesStrictMock.mockReset()
|
||||
listWorktreesStrictMock.mockResolvedValue([row(REPO_PATH)])
|
||||
})
|
||||
|
||||
it('bumps that generation from the change event the runtime itself sends, with no window notifier attached', () => {
|
||||
const runtime = makeRuntimeService()
|
||||
const before = getLocalWorktreeScanGeneration(REPO_ID)
|
||||
|
||||
runtime.notifyBranchRenamed(REPO_ID)
|
||||
|
||||
expect(getLocalWorktreeScanGeneration(REPO_ID)).not.toBe(before)
|
||||
})
|
||||
|
||||
it('clears the runtime scan cache so the next listing scans again', async () => {
|
||||
const list = makeRuntime()
|
||||
|
||||
await list()
|
||||
await list()
|
||||
expect(listWorktreesStrictMock).toHaveBeenCalledTimes(1)
|
||||
|
||||
runWorktreeChangeInvalidators(REPO_ID)
|
||||
await list()
|
||||
expect(listWorktreesStrictMock).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('re-lists a scan a worktree change overtook and publishes the rows that include the change', async () => {
|
||||
const list = makeRuntime()
|
||||
listWorktreesStrictMock
|
||||
.mockImplementationOnce(async () => {
|
||||
runWorktreeChangeInvalidators(REPO_ID)
|
||||
return [row(REPO_PATH)]
|
||||
})
|
||||
.mockResolvedValueOnce([row(REPO_PATH), row(CREATED_PATH)])
|
||||
|
||||
const result = await list()
|
||||
|
||||
expect(listWorktreesStrictMock).toHaveBeenCalledTimes(2)
|
||||
expect(result).toMatchObject({ authoritative: true, source: 'git' })
|
||||
expect(result.worktrees.map((worktree) => worktree.path).sort()).toEqual([
|
||||
REPO_PATH,
|
||||
CREATED_PATH
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -1,17 +1,16 @@
|
||||
import { joinResolvedWorktreeScans } from './resolved-worktree-scan-join'
|
||||
import { getRepoHostIdentityForParts } from '../../../../shared/repo-host-identity'
|
||||
import { WorkspaceGitIdentity } from '../../sidebar-inputs/workspace-git-identity'
|
||||
import './resolved-worktree-scan-join'
|
||||
import '../../../../shared/repo-host-identity'
|
||||
import '../../sidebar-inputs/workspace-git-identity'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { ExecutionHostId } from '../../../../shared/execution-host'
|
||||
|
||||
import type { Repo } from '../../../../shared/repo-types'
|
||||
import type { WorktreeMeta } from '../../../../shared/worktree/meta-types'
|
||||
import type { WorktreeLineage } from '../../../../shared/worktree/lineage-types'
|
||||
|
||||
import type { GitWorktreeInfo, Worktree } from '../../../../shared/worktree/types'
|
||||
import type { Store } from '../../../persistence/loading-store/store'
|
||||
import { mergeWorktreeMetaForWrite } from '../../../persistence/loading-store/worktree-meta-write-normalization'
|
||||
import { buildDetectedGitWorktrees } from '../../../ipc/worktrees/listing/ssh-worktree-fallback'
|
||||
import {
|
||||
listStoredWorktreeRowsForRepo,
|
||||
resolveRepoWorktreeRows,
|
||||
resolveScopedWorktreeIdRow,
|
||||
type RepoWorktreeRowDeps
|
||||
@@ -72,80 +71,6 @@ function createDeps(repos: Repo[]): RepoWorktreeRowDeps & {
|
||||
}
|
||||
|
||||
describe('host-qualified scoped worktree resolution', () => {
|
||||
it('projects cross-repository lineage by scanning only related repos on the selected host', async () => {
|
||||
const childRepo = repo('child-repo', '/child-repo', { executionHostId: 'local' })
|
||||
const parentRepo = repo('parent-repo', '/parent-repo', { executionHostId: 'local' })
|
||||
const remoteParent = repo('parent-repo', '/remote-parent-repo', {
|
||||
connectionId: 'builder',
|
||||
executionHostId: 'ssh:builder'
|
||||
})
|
||||
const unrelated = repo('unrelated', '/unrelated', { executionHostId: 'local' })
|
||||
const deps = createDeps([childRepo, parentRepo, remoteParent, unrelated])
|
||||
const childId = 'child-repo::/child'
|
||||
const parentId = 'parent-repo::/parent'
|
||||
deps.metaById[childId] = mergeWorktreeMetaForWrite(undefined, {
|
||||
instanceId: 'child-instance',
|
||||
hostId: 'local',
|
||||
projectId: 'child-project'
|
||||
})
|
||||
deps.metaById[parentId] = mergeWorktreeMetaForWrite(undefined, {
|
||||
instanceId: 'parent-instance',
|
||||
hostId: 'local',
|
||||
projectId: 'parent-project'
|
||||
})
|
||||
const lineage: WorktreeLineage = {
|
||||
worktreeId: childId,
|
||||
worktreeInstanceId: 'child-instance',
|
||||
parentWorktreeId: parentId,
|
||||
parentWorktreeInstanceId: 'parent-instance',
|
||||
origin: 'cli',
|
||||
capture: { source: 'explicit-cli-flag', confidence: 'explicit' },
|
||||
createdAt: 1
|
||||
}
|
||||
Object.assign(deps.store, { getAllWorktreeLineage: () => ({ [childId]: lineage }) })
|
||||
deps.scanRepo.mockImplementation(async (owner) => ({
|
||||
ok: true,
|
||||
worktrees: [gitWorktree(owner.id === 'child-repo' ? '/child' : '/parent')]
|
||||
}))
|
||||
|
||||
await expect(resolveScopedWorktreeIdRow(deps, childId, 'local')).resolves.toMatchObject({
|
||||
parentWorktreeId: parentId,
|
||||
lineage
|
||||
})
|
||||
expect(deps.scanRepo.mock.calls.map(([owner]) => owner)).toEqual([childRepo, parentRepo])
|
||||
|
||||
deps.scanRepo.mockClear()
|
||||
await expect(resolveScopedWorktreeIdRow(deps, parentId, 'local')).resolves.toMatchObject({
|
||||
childWorktreeIds: [childId]
|
||||
})
|
||||
expect(deps.scanRepo.mock.calls.map(([owner]) => owner)).toEqual([parentRepo, childRepo])
|
||||
})
|
||||
|
||||
it('scans only the selected local or SSH owner when repo ids and paths collide', async () => {
|
||||
const owners = [
|
||||
repo('shared', '/local/repo', { executionHostId: 'local' }),
|
||||
repo('shared', '/remote/repo', {
|
||||
connectionId: 'builder',
|
||||
executionHostId: 'ssh:builder'
|
||||
}),
|
||||
repo('unrelated', '/unrelated/repo', { connectionId: 'slow-box' })
|
||||
]
|
||||
const deps = createDeps(owners)
|
||||
const worktreeId = 'shared::/same/worktree'
|
||||
|
||||
await expect(
|
||||
resolveScopedWorktreeIdRow(deps, worktreeId, 'ssh:builder')
|
||||
).resolves.toMatchObject({ id: worktreeId, hostId: 'ssh:builder' })
|
||||
expect(deps.scanRepo.mock.calls.map(([owner]) => owner)).toEqual([owners[1]])
|
||||
|
||||
deps.scanRepo.mockClear()
|
||||
await expect(resolveScopedWorktreeIdRow(deps, worktreeId, 'local')).resolves.toMatchObject({
|
||||
id: worktreeId,
|
||||
hostId: 'local'
|
||||
})
|
||||
expect(deps.scanRepo.mock.calls.map(([owner]) => owner)).toEqual([owners[0]])
|
||||
})
|
||||
|
||||
it('does not project another host metadata onto a colliding scoped row', async () => {
|
||||
const deps = createDeps([
|
||||
repo('shared', '/local/repo', { executionHostId: 'local' }),
|
||||
@@ -170,94 +95,6 @@ describe('host-qualified scoped worktree resolution', () => {
|
||||
displayName: 'feature'
|
||||
})
|
||||
})
|
||||
it('uses the host-qualified metadata owner when a legacy row has another host', async () => {
|
||||
const deps = createDeps([
|
||||
repo('shared', '/remote/repo', {
|
||||
connectionId: 'builder',
|
||||
executionHostId: 'ssh:builder'
|
||||
})
|
||||
])
|
||||
const worktreeId = 'shared::/same/worktree'
|
||||
const remoteMeta = {
|
||||
displayName: 'remote workspace',
|
||||
hostId: 'ssh:builder',
|
||||
instanceId: 'remote-instance'
|
||||
} as unknown as WorktreeMeta
|
||||
deps.metaById[worktreeId] = {
|
||||
displayName: 'stale local workspace',
|
||||
hostId: 'local',
|
||||
instanceId: 'local-instance'
|
||||
} as unknown as WorktreeMeta
|
||||
;(
|
||||
deps.store as Store & {
|
||||
getWorktreeMetaForHost: () => WorktreeMeta
|
||||
}
|
||||
).getWorktreeMetaForHost = () => remoteMeta
|
||||
|
||||
const rows = await resolveRepoWorktreeRows(
|
||||
deps,
|
||||
deps.store.getRepos()[0]!,
|
||||
deps.metaById,
|
||||
new Map()
|
||||
)
|
||||
|
||||
expect(rows[0]).toMatchObject({
|
||||
displayName: 'remote workspace',
|
||||
hostId: 'ssh:builder',
|
||||
instanceId: 'remote-instance'
|
||||
})
|
||||
})
|
||||
|
||||
it('restores canonical-only SSH rows without leaking colliding local metadata', () => {
|
||||
const local = repo('shared', '/local/repo', { executionHostId: 'local' })
|
||||
const remote = repo('shared', '/remote/repo', {
|
||||
connectionId: 'builder',
|
||||
executionHostId: 'ssh:builder'
|
||||
})
|
||||
const deps = createDeps([local, remote])
|
||||
deps.metaById['shared::/local/worktree'] = {
|
||||
displayName: 'local workspace',
|
||||
hostId: 'local'
|
||||
} as WorktreeMeta
|
||||
;(
|
||||
deps.store as Store & {
|
||||
getAllWorktreeMetaForHost: () => Record<string, WorktreeMeta>
|
||||
}
|
||||
).getAllWorktreeMetaForHost = () => ({
|
||||
'shared::/remote/worktree': {
|
||||
displayName: 'remote workspace',
|
||||
hostId: 'ssh:builder'
|
||||
} as unknown as WorktreeMeta
|
||||
})
|
||||
|
||||
expect(listStoredWorktreeRowsForRepo(deps.store, remote, 2)).toEqual([
|
||||
expect.objectContaining({ path: '/remote/worktree' })
|
||||
])
|
||||
})
|
||||
|
||||
it.each([
|
||||
['runtime:windows', String.raw`C:\Users\dev\orca worktree`],
|
||||
['local', '/mnt/c/Users/dev/orca worktree']
|
||||
] satisfies [ExecutionHostId, string][])(
|
||||
'keeps %s path resolution scoped',
|
||||
async (hostId, path) => {
|
||||
const target = repo('shared', path, { executionHostId: hostId })
|
||||
const deps = createDeps([
|
||||
target,
|
||||
repo('shared', '/other-host/repo', { executionHostId: 'ssh:unrelated' }),
|
||||
repo('unrelated', '/unrelated/repo')
|
||||
])
|
||||
deps.scanRepo.mockImplementation(async () => ({ ok: true, worktrees: [gitWorktree(path)] }))
|
||||
const worktreeId = `shared::${path}`
|
||||
|
||||
await expect(resolveScopedWorktreeIdRow(deps, worktreeId, hostId)).resolves.toMatchObject({
|
||||
id: worktreeId,
|
||||
hostId,
|
||||
path
|
||||
})
|
||||
expect(deps.scanRepo.mock.calls.map(([owner]) => owner)).toEqual([target])
|
||||
}
|
||||
)
|
||||
|
||||
it('resolves only the selected folder-workspace owner without scanning Git providers', async () => {
|
||||
const local = repo('folders', '/local/folders', {
|
||||
@@ -296,65 +133,10 @@ describe('host-qualified scoped worktree resolution', () => {
|
||||
expect(deps.listFolderWorkspaces).toHaveBeenCalledExactlyOnceWith(remote, 2)
|
||||
expect(deps.scanRepo).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not scan when the requested host does not own the repo id', async () => {
|
||||
const deps = createDeps([
|
||||
repo('shared', '/local/repo', { executionHostId: 'local' }),
|
||||
repo('unrelated', '/unrelated/repo', { executionHostId: 'runtime:other' })
|
||||
])
|
||||
|
||||
await expect(
|
||||
resolveScopedWorktreeIdRow(deps, 'shared::/same/worktree', 'ssh:builder')
|
||||
).resolves.toBeNull()
|
||||
expect(deps.scanRepo).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('reuses fleet owner counts without reloading repos per row', async () => {
|
||||
const owners = Array.from({ length: 100 }, (_, index) =>
|
||||
repo(`repo-${index}`, `/repos/${index}`, { executionHostId: 'local' })
|
||||
)
|
||||
const deps = createDeps(owners)
|
||||
const getRepos = vi.spyOn(deps.store, 'getRepos')
|
||||
|
||||
await Promise.all(
|
||||
owners.map((owner) => resolveRepoWorktreeRows(deps, owner, deps.metaById, new Map(), 1))
|
||||
)
|
||||
|
||||
expect(getRepos).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
/** Normalize id-selector paths like path selectors so UI removal resolves the same host-qualified row as CLI (#16243). */
|
||||
describe('scoped worktree id resolution across path spellings (#16243)', () => {
|
||||
it.each([
|
||||
['a trailing slash', '/same/worktree', 'shared::/same/worktree/'],
|
||||
['a doubled separator', '/same/worktree', 'shared::/same//worktree'],
|
||||
['an NFD name', '/same/café', `shared::${'/same/café'.normalize('NFD')}`]
|
||||
])('resolves the scanned row when the id carries %s', async (_label, scannedPath, worktreeId) => {
|
||||
const owner = repo('shared', '/local/repo', { executionHostId: 'local' })
|
||||
const deps = createDeps([owner])
|
||||
deps.scanRepo.mockImplementation(async () => ({
|
||||
ok: true,
|
||||
worktrees: [gitWorktree(scannedPath)]
|
||||
}))
|
||||
|
||||
await expect(resolveScopedWorktreeIdRow(deps, worktreeId, 'local')).resolves.toMatchObject({
|
||||
id: `shared::${scannedPath}`,
|
||||
path: scannedPath
|
||||
})
|
||||
})
|
||||
|
||||
it('still refuses the same path under a different repo id', async () => {
|
||||
const deps = createDeps([
|
||||
repo('shared', '/local/repo', { executionHostId: 'local' }),
|
||||
repo('unrelated', '/unrelated/repo', { executionHostId: 'local' })
|
||||
])
|
||||
|
||||
await expect(
|
||||
resolveScopedWorktreeIdRow(deps, 'unrelated::/same/worktree/', 'local')
|
||||
).resolves.toBeNull()
|
||||
})
|
||||
|
||||
it('refuses rather than guessing when two rows spell one path', async () => {
|
||||
const owner = repo('shared', '/local/repo', { executionHostId: 'local' })
|
||||
const deps = createDeps([owner])
|
||||
@@ -368,19 +150,6 @@ describe('scoped worktree id resolution across path spellings (#16243)', () => {
|
||||
).resolves.toBeNull()
|
||||
})
|
||||
|
||||
it('prefers the exactly matching row over an equivalent spelling', async () => {
|
||||
const owner = repo('shared', '/local/repo', { executionHostId: 'local' })
|
||||
const deps = createDeps([owner])
|
||||
deps.scanRepo.mockImplementation(async () => ({
|
||||
ok: true,
|
||||
worktrees: [gitWorktree('/same//worktree'), gitWorktree('/same/worktree')]
|
||||
}))
|
||||
|
||||
await expect(
|
||||
resolveScopedWorktreeIdRow(deps, 'shared::/same//worktree', 'local')
|
||||
).resolves.toMatchObject({ id: 'shared::/same//worktree' })
|
||||
})
|
||||
|
||||
// #15598/#15616: the backslash spelling is what a pre-restart Windows registration recorded.
|
||||
it('resolves a Windows backslash id against the forward-slash spelling git reports', async () => {
|
||||
const path = 'D:/Agentic/game2/battle-core'
|
||||
@@ -392,16 +161,6 @@ describe('scoped worktree id resolution across path spellings (#16243)', () => {
|
||||
resolveScopedWorktreeIdRow(deps, 'shared::D:\\Agentic\\game2\\battle-core', 'runtime:windows')
|
||||
).resolves.toMatchObject({ id: `shared::${path}`, path })
|
||||
})
|
||||
|
||||
it.each([
|
||||
['no repo boundary', 'not-an-id'],
|
||||
['an empty path', 'shared::']
|
||||
])('keeps exact matching for a malformed id with %s', async (_label, worktreeId) => {
|
||||
const deps = createDeps([repo('shared', '/local/repo', { executionHostId: 'local' })])
|
||||
|
||||
await expect(resolveScopedWorktreeIdRow(deps, worktreeId, 'local')).resolves.toBeNull()
|
||||
expect(deps.scanRepo).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('folder-to-Git checkout identity', () => {
|
||||
@@ -442,31 +201,3 @@ describe('folder-to-Git checkout identity', () => {
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
it('stamps the scan/sidebar join once and keeps the title across detach', () => {
|
||||
const owner = repo('row-owner', '/repo', { executionHostId: 'local' })
|
||||
const deps = createDeps([owner])
|
||||
const writes = vi.spyOn(deps.store, 'setWorktreeMeta').mockImplementation(({ id }, updates) => {
|
||||
const next = mergeWorktreeMetaForWrite(deps.metaById[id], updates)
|
||||
deps.metaById[id] = next
|
||||
return next
|
||||
})
|
||||
const key = getRepoHostIdentityForParts(owner.id, 'local')
|
||||
const scans = new Map([
|
||||
[key, { ok: true as const, worktrees: [gitWorktree('/repo/work')], gitRun: {} }]
|
||||
])
|
||||
const first = joinResolvedWorktreeScans(deps.store, () => deps, scans)
|
||||
expect(writes).toHaveBeenCalledOnce()
|
||||
const identity = new WorkspaceGitIdentity()
|
||||
const rows = (joined: typeof first) => joined.worktrees.map((row) => ({ ...row, gitRun: {} }))
|
||||
identity.observeScan(rows(first), first.scannedRepoKeys)
|
||||
scans.set(key, {
|
||||
ok: true,
|
||||
worktrees: [{ ...gitWorktree('/repo/work'), branch: '' }],
|
||||
gitRun: {}
|
||||
})
|
||||
const detached = joinResolvedWorktreeScans(deps.store, () => deps, scans)
|
||||
identity.observeScan(rows(detached), detached.scannedRepoKeys)
|
||||
expect(identity.title(detached.worktrees[0]!)).toBe(first.worktrees[0]!.displayName)
|
||||
expect(writes).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
@@ -36,14 +36,12 @@ vi.mock(
|
||||
|
||||
import { RuntimeManagedWorktreeQueries } from './runtime-managed-worktree-queries'
|
||||
import type { RuntimeStore } from '../../runtime-store-contract'
|
||||
import { partialRuntimeStore } from '../../orca-runtime-tests/runtime-store-partial-fixture'
|
||||
import { getRepoHostIdentityForParts } from '../../../../shared/repo-host-identity'
|
||||
import '../../orca-runtime-tests/runtime-store-partial-fixture'
|
||||
import '../../../../shared/repo-host-identity'
|
||||
import {
|
||||
bumpLocalWorktreeScanGeneration,
|
||||
getLocalWorktreeScanGeneration,
|
||||
resetLocalWorktreeScanGenerationsForTests
|
||||
} from '../../../workspaces/catalog/local-worktree-scan-generation'
|
||||
import type { HostWorktreeTarget } from '../../../../shared/worktree/host-qualified-identity'
|
||||
|
||||
const settings: ReturnType<RuntimeStore['getSettings']> = {
|
||||
workspaceDir: '/worktrees',
|
||||
@@ -96,101 +94,7 @@ function queries(
|
||||
})
|
||||
}
|
||||
|
||||
describe('RuntimeManagedWorktreeQueries.buildVisibilityMatchers', () => {
|
||||
it.each([false, true])(
|
||||
'keeps host-qualified matcher inputs and defaults (reversed=%s)',
|
||||
(reversed) => {
|
||||
const local = folderRepo({
|
||||
customWorktreeVisibilitySources: [{ id: 'local-source', rootPath: '/local-source' }]
|
||||
})
|
||||
const remote = folderRepo({
|
||||
connectionId: 'builder',
|
||||
path: '/remote/app',
|
||||
worktreeBasePath: '.claude/worktrees',
|
||||
customWorktreeVisibilitySources: [{ id: 'remote-source', rootPath: '/remote-source' }]
|
||||
})
|
||||
const repos = reversed ? [remote, local] : [local, remote]
|
||||
const worktrees = ['/checkout/first', '/checkout/second'].map((path, index) => ({
|
||||
...mergeWorktree(
|
||||
local.id,
|
||||
{ path, head: 'abc', branch: 'topic', isBare: false, isMainWorktree: false },
|
||||
undefined
|
||||
),
|
||||
hostId: index === 0 ? ('local' as const) : ('ssh:builder' as const)
|
||||
}))
|
||||
const defaults = { customSources: [{ id: 'default-source', rootPath: '/default-source' }] }
|
||||
const catalog = queries(
|
||||
partialRuntimeStore({
|
||||
getRepos: () => [...repos, folderRepo({ id: 'without-checkout' })],
|
||||
getSettings: () => ({ ...settings, worktreeVisibilityDefaults: defaults })
|
||||
})
|
||||
)
|
||||
const matchers = catalog.buildVisibilityMatchers(worktrees)
|
||||
expect([...matchers.keys()]).toEqual([
|
||||
...repos.map((repo) =>
|
||||
getRepoHostIdentityForParts(repo.id, repo.connectionId ? 'ssh:builder' : 'local')
|
||||
),
|
||||
getRepoHostIdentityForParts('without-checkout', 'local')
|
||||
])
|
||||
for (const [index, hostId] of (['local', 'ssh:builder'] as const).entries()) {
|
||||
const key = getRepoHostIdentityForParts(local.id, hostId)
|
||||
const matcher = matchers.get(key)!
|
||||
expect(matcher(`${worktrees[index]!.path}/.claude/worktrees/topic`)).toEqual({
|
||||
kind: 'built-in',
|
||||
id: 'claude'
|
||||
})
|
||||
expect(matcher(`${worktrees[1 - index]!.path}/.claude/worktrees/topic`)).toBeNull()
|
||||
expect(matcher('/local-source/topic')).toEqual(
|
||||
index === 0 ? { kind: 'custom', id: 'local-source' } : null
|
||||
)
|
||||
expect(matcher('/remote-source/topic')).toEqual(
|
||||
index === 1 ? { kind: 'custom', id: 'remote-source' } : null
|
||||
)
|
||||
expect(
|
||||
matcher(`${index === 0 ? local.path : remote.path}/.claude/worktrees/topic`)
|
||||
).toEqual(index === 0 ? { kind: 'built-in', id: 'claude' } : null)
|
||||
expect(matcher('/default-source/topic')).toEqual({ kind: 'custom', id: 'default-source' })
|
||||
expect(
|
||||
catalog.buildVisibilityMatchers(worktrees, false).get(key)!('/default-source/topic')
|
||||
).toBeNull()
|
||||
expect(
|
||||
catalog.buildVisibilityMatchers(worktrees, true, settings).get(key)!(
|
||||
'/default-source/topic'
|
||||
)
|
||||
).toBeNull()
|
||||
}
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
describe('RuntimeManagedWorktreeQueries.listDetected', () => {
|
||||
it('stamps a scanned listing with the generation its scan began at', async () => {
|
||||
resetLocalWorktreeScanGenerationsForTests()
|
||||
const repo = folderRepo({ kind: 'git', path: '/source/git-app' })
|
||||
const store = {
|
||||
getRepos: () => [repo],
|
||||
getRepo: () => repo,
|
||||
getAllWorktreeMeta: () => ({}),
|
||||
getWorktreeMeta: () => undefined,
|
||||
setWorktreeMeta: vi.fn(),
|
||||
getAllWorktreeLineage: () => ({}),
|
||||
getSettings: () => settings
|
||||
} as unknown as RuntimeStore
|
||||
const atScanStart = getLocalWorktreeScanGeneration(repo.id)
|
||||
const scanRepo = vi.fn(async () => {
|
||||
// A mutation lands after the scan settles; the listing still names the catalog it scanned.
|
||||
return { ok: true as const, worktrees: [] }
|
||||
})
|
||||
|
||||
const listing = await queries(store, { scanRepo }).listDetected(repo)
|
||||
bumpLocalWorktreeScanGeneration(repo.id)
|
||||
|
||||
expect(listing.authoritative).toBe(true)
|
||||
expect(listing.catalogVersion?.sequence).toBe(atScanStart)
|
||||
expect(listing.catalogVersion?.epoch).toEqual(expect.any(String))
|
||||
expect(getLocalWorktreeScanGeneration(repo.id)).toBeGreaterThan(atScanStart)
|
||||
})
|
||||
|
||||
it("does not project another host's folder metadata", async () => {
|
||||
const local = folderRepo()
|
||||
const remote = folderRepo({ connectionId: 'build-box', displayName: 'Remote app' })
|
||||
@@ -215,149 +119,9 @@ describe('RuntimeManagedWorktreeQueries.listDetected', () => {
|
||||
displayName: 'Local app'
|
||||
})
|
||||
})
|
||||
|
||||
it('omits host-owned source defaults for clients that do not support them', async () => {
|
||||
const repo = folderRepo({ path: '/source/app' })
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: a partial store mock; the code under test reads only the members it defines.
|
||||
const store = {
|
||||
getRepos: () => [repo],
|
||||
getRepo: () => repo,
|
||||
getAllWorktreeMeta: () => ({}),
|
||||
getWorktreeMeta: () => undefined,
|
||||
setWorktreeMeta: vi.fn((_target: HostWorktreeTarget, updates) => metadata(updates)),
|
||||
getAllWorktreeLineage: () => ({}),
|
||||
getSettings: () => ({
|
||||
...settings,
|
||||
worktreeVisibilityDefaults: {
|
||||
external: 'show' as const,
|
||||
customSources: [{ id: 'host-source', rootPath: '/source' }],
|
||||
sourcePreferences: { custom: { 'host-source': 'show' as const } }
|
||||
}
|
||||
})
|
||||
} as unknown as RuntimeStore
|
||||
|
||||
const current = await queries(store).listDetected(repo, true)
|
||||
const legacy = await queries(store).listDetected(repo, false)
|
||||
|
||||
expect(current.worktrees[0]).toMatchObject({
|
||||
visibilitySource: { kind: 'custom', id: 'host-source' }
|
||||
})
|
||||
expect(legacy.worktrees[0]).not.toHaveProperty('visibilitySource')
|
||||
})
|
||||
})
|
||||
|
||||
describe('RuntimeManagedWorktreeQueries.list host scope', () => {
|
||||
it('uses the resolved legacy host matcher and omits ambiguous legacy rows', async () => {
|
||||
const repo = folderRepo({
|
||||
kind: 'git',
|
||||
connectionId: 'builder',
|
||||
customWorktreeVisibilitySources: [{ id: 'remote-source', rootPath: '/source' }],
|
||||
worktreeVisibilitySourcePreferences: { custom: { 'remote-source': 'hide' } }
|
||||
})
|
||||
const git: GitWorktreeInfo = {
|
||||
path: '/source/topic',
|
||||
head: '',
|
||||
branch: 'topic',
|
||||
isBare: false,
|
||||
isMainWorktree: false
|
||||
}
|
||||
const row: ResolvedWorktree = {
|
||||
...mergeWorktree(repo.id, git, undefined),
|
||||
git,
|
||||
parentWorktreeId: null,
|
||||
childWorktreeIds: [],
|
||||
lineage: null
|
||||
}
|
||||
let repos = [repo]
|
||||
let rows = [row]
|
||||
const listing = queries(
|
||||
partialRuntimeStore({ getRepos: () => repos, getSettings: () => settings }),
|
||||
{
|
||||
listResolved: async () => rows
|
||||
}
|
||||
)
|
||||
expect((await listing.list(undefined, 10)).worktrees).toEqual([])
|
||||
repos = [repo, folderRepo({ kind: 'git' })]
|
||||
expect((await listing.list(undefined, 10)).worktrees).toEqual([])
|
||||
rows = [
|
||||
{ ...row, hostId: 'ssh:builder' },
|
||||
{ ...row, hostId: 'local' }
|
||||
]
|
||||
expect((await listing.list('remote', 10)).worktrees).toEqual([])
|
||||
})
|
||||
|
||||
it.each([5, 50])('bounds listing hydration and ownership work at scale %s', async (size) => {
|
||||
let idReads = 0
|
||||
let hydrated = 0
|
||||
const local = folderRepo({ kind: 'git', externalWorktreeVisibility: 'hide' })
|
||||
const ssh = folderRepo({
|
||||
kind: 'git',
|
||||
connectionId: 'builder',
|
||||
externalWorktreeVisibility: 'show'
|
||||
})
|
||||
const repos = [
|
||||
local,
|
||||
ssh,
|
||||
...Array.from({ length: size }, (_, index) => folderRepo({ id: `unrelated-${index}` }))
|
||||
]
|
||||
const getRepos = vi.fn(() =>
|
||||
repos.map((repo) => {
|
||||
hydrated++
|
||||
return {
|
||||
...repo,
|
||||
get id() {
|
||||
idReads++
|
||||
return repo.id
|
||||
}
|
||||
}
|
||||
})
|
||||
)
|
||||
const rows: ResolvedWorktree[] = [local, ssh].flatMap((repo) =>
|
||||
Array.from({ length: size }, (_, index) => {
|
||||
const git: GitWorktreeInfo = {
|
||||
path: index === 0 ? repo.path : `/outside/work-${index}`,
|
||||
head: 'abc',
|
||||
branch: `branch-${index}`,
|
||||
isBare: false,
|
||||
isMainWorktree: index === 0
|
||||
}
|
||||
return {
|
||||
...mergeWorktree(repo.id, git, undefined),
|
||||
hostId: repo.connectionId ? ('ssh:builder' as const) : ('local' as const),
|
||||
parentWorktreeId: null,
|
||||
childWorktreeIds: [],
|
||||
lineage: null,
|
||||
git
|
||||
}
|
||||
})
|
||||
)
|
||||
const store: RuntimeStore = {
|
||||
getRepo: vi.fn<RuntimeStore['getRepo']>(),
|
||||
addRepo: vi.fn<RuntimeStore['addRepo']>(),
|
||||
updateRepo: vi.fn<RuntimeStore['updateRepo']>(),
|
||||
setWorktreeMeta: vi.fn<RuntimeStore['setWorktreeMeta']>(),
|
||||
removeWorktreeMeta: vi.fn<RuntimeStore['removeWorktreeMeta']>(),
|
||||
getGitHubCache: vi.fn<RuntimeStore['getGitHubCache']>(),
|
||||
getRepos,
|
||||
getAllWorktreeMeta: () => ({}),
|
||||
getWorktreeMeta: () => undefined,
|
||||
getSettings: () => settings
|
||||
}
|
||||
const listing = queries(store, {
|
||||
listResolved: async () => rows,
|
||||
listKnownHostIds: () => ['local', 'ssh:builder']
|
||||
})
|
||||
|
||||
const page = await listing.list(undefined, 2)
|
||||
|
||||
expect(page.totalCount).toBe(size + 1)
|
||||
expect(page.truncated).toBe(true)
|
||||
expect(page.worktrees.map((row) => row.hostId)).toEqual(['local', 'ssh:builder'])
|
||||
expect(getRepos.mock.calls.length).toBeLessThanOrEqual(3)
|
||||
expect(hydrated).toBeLessThanOrEqual(3 * repos.length)
|
||||
expect(idReads).toBeLessThanOrEqual(20 * (repos.length + rows.length))
|
||||
})
|
||||
|
||||
it.each([false, true])(
|
||||
'preserves host twins, expandable coverage and per-host visibility (reversed=%s)',
|
||||
async (reversed) => {
|
||||
@@ -465,82 +229,6 @@ describe('RuntimeManagedWorktreeQueries.list host scope', () => {
|
||||
hiddenHostIds.clear()
|
||||
}
|
||||
})
|
||||
|
||||
it('does not report the scoped host as omitted once it contributes rows', async () => {
|
||||
const store = sshStore()
|
||||
const result = await queries(store, {
|
||||
listResolved: async () =>
|
||||
[
|
||||
{
|
||||
id: 'repo-ssh::/home/dev/app',
|
||||
repoId: 'repo-ssh',
|
||||
path: '/home/dev/app',
|
||||
hostId: 'ssh:conn-1'
|
||||
}
|
||||
] as never
|
||||
}).list('repo-ssh', 50)
|
||||
|
||||
expect(result.hostScope?.hostIds).toEqual(['ssh:conn-1'])
|
||||
expect(result.hostScope?.omittedHostIds).toEqual([])
|
||||
})
|
||||
|
||||
// The caller scoped the listing, so the hosts they excluded must not come back as gaps.
|
||||
it('never names a host the caller scoped out', async () => {
|
||||
const scoped = await queries(sshStore(), {
|
||||
listKnownHostIds: () => ['local', 'ssh:other', 'runtime:elsewhere'] as never
|
||||
}).list('repo-ssh', 50)
|
||||
|
||||
expect(scoped.hostScope?.omittedHostIds).toEqual(['ssh:conn-1'])
|
||||
})
|
||||
|
||||
// Check both host spellings so scoped coverage names the actual execution host.
|
||||
it('names the local host for a scoped local repo', async () => {
|
||||
const repo = folderRepo({ id: 'repo-local', kind: 'git', path: '/workspace/local' })
|
||||
const store = {
|
||||
getRepos: () => [repo],
|
||||
getRepo: () => repo,
|
||||
getAllWorktreeMeta: () => ({}),
|
||||
getWorktreeMeta: () => undefined,
|
||||
setWorktreeMeta: vi.fn(),
|
||||
getAllWorktreeLineage: () => ({}),
|
||||
getSettings: () => settings
|
||||
} as unknown as RuntimeStore
|
||||
|
||||
const result = await queries(store).list('repo-local', 50)
|
||||
|
||||
expect(result.hostScope?.omittedHostIds).toEqual(['local'])
|
||||
})
|
||||
|
||||
it('prefers executionHostId over connectionId for the scoped host', async () => {
|
||||
const repo = folderRepo({
|
||||
id: 'repo-runtime',
|
||||
kind: 'git',
|
||||
connectionId: 'conn-legacy',
|
||||
executionHostId: 'runtime:env-1',
|
||||
path: '/workspace/runtime'
|
||||
})
|
||||
const store = {
|
||||
getRepos: () => [repo],
|
||||
getRepo: () => repo,
|
||||
getAllWorktreeMeta: () => ({}),
|
||||
getWorktreeMeta: () => undefined,
|
||||
setWorktreeMeta: vi.fn(),
|
||||
getAllWorktreeLineage: () => ({}),
|
||||
getSettings: () => settings
|
||||
} as unknown as RuntimeStore
|
||||
|
||||
const result = await queries(store).list('repo-runtime', 50)
|
||||
|
||||
expect(result.hostScope?.omittedHostIds).toEqual(['runtime:env-1'])
|
||||
})
|
||||
|
||||
it('still reports every configured host when the listing is unscoped', async () => {
|
||||
const unscoped = await queries(sshStore(), {
|
||||
listKnownHostIds: () => ['local', 'ssh:conn-1'] as never
|
||||
}).list(undefined, 50)
|
||||
|
||||
expect(unscoped.hostScope?.omittedHostIds).toEqual(['local', 'ssh:conn-1'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('RuntimeManagedWorktreeQueries.listDetected overtaken scan', () => {
|
||||
@@ -601,35 +289,6 @@ describe('RuntimeManagedWorktreeQueries.listDetected overtaken scan', () => {
|
||||
expect(pruneLineageMock.mock.calls[0]?.[2]).toEqual(rows([repo.path, created]))
|
||||
})
|
||||
|
||||
it('prunes metadata against the expectation and generation of the scan it publishes', async () => {
|
||||
const expectations: unknown[] = []
|
||||
const generations: number[] = []
|
||||
const store = gitStore({
|
||||
pruneSessionlessMissingLocalWorktreeMetadataForRepo: vi.fn(() => []),
|
||||
getProfileStorageDirectory: () => '/profile',
|
||||
captureNativeLocalWorktreeMetadataScanExpectation: vi.fn(() => {
|
||||
const expectation = { repo, metadata: [] }
|
||||
expectations.push(expectation)
|
||||
generations.push(getLocalWorktreeScanGeneration(repo.id))
|
||||
return expectation
|
||||
})
|
||||
})
|
||||
const scanRepo = vi
|
||||
.fn()
|
||||
.mockImplementationOnce(overtakenScan([repo.path]))
|
||||
.mockResolvedValueOnce({ ok: true, worktrees: rows([repo.path, created]) })
|
||||
|
||||
await queries(store, { scanRepo }).listDetected(repo)
|
||||
|
||||
expect(expectations).toHaveLength(2)
|
||||
expect(pruneMetadataMock).toHaveBeenCalledTimes(1)
|
||||
expect(pruneMetadataMock.mock.calls[0]?.[0]).toMatchObject({
|
||||
scan: expectations[1],
|
||||
scanGeneration: generations[1],
|
||||
gitWorktrees: rows([repo.path, created])
|
||||
})
|
||||
})
|
||||
|
||||
it('publishes the last scan non-authoritatively once the re-scan bound is spent', async () => {
|
||||
const scanRepo = vi.fn(overtakenScan([repo.path]))
|
||||
|
||||
@@ -644,16 +303,4 @@ describe('RuntimeManagedWorktreeQueries.listDetected overtaken scan', () => {
|
||||
expect(pruneLineageMock).not.toHaveBeenCalled()
|
||||
expect(pruneMetadataMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not re-run a failed scan the change overtook', async () => {
|
||||
const scanRepo = vi.fn(async () => {
|
||||
bumpLocalWorktreeScanGeneration(repo.id)
|
||||
return { ok: false as const, worktrees: [] }
|
||||
})
|
||||
|
||||
const result = await queries(gitStore(), { scanRepo }).listDetected(repo)
|
||||
|
||||
expect(scanRepo).toHaveBeenCalledTimes(1)
|
||||
expect(result).toMatchObject({ authoritative: false, source: 'metadata-fallback' })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,69 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { RuntimeResolvedWorktreeCache } from './runtime-resolved-worktree-cache'
|
||||
import type { ResolvedWorktreeSnapshot } from './runtime-resolved-worktree-cache'
|
||||
import {
|
||||
getLocalWorktreeScanGeneration,
|
||||
getWorktreeScanMutationRevision
|
||||
} from '../../../workspaces/catalog/local-worktree-scan-generation'
|
||||
|
||||
function snapshotOf(ids: string[]): ResolvedWorktreeSnapshot {
|
||||
return {
|
||||
worktrees: ids.map((id) => ({ id }) as ResolvedWorktreeSnapshot['worktrees'][number]),
|
||||
platformByRepoId: new Map()
|
||||
}
|
||||
}
|
||||
|
||||
describe('RuntimeResolvedWorktreeCache', () => {
|
||||
it('recomputes when the repo inventory moved, even well inside the TTL', async () => {
|
||||
// Repo registration invalidates earlier snapshots: absence there cannot prove workspace not found.
|
||||
const cache = new RuntimeResolvedWorktreeCache()
|
||||
const results = [snapshotOf(['repo-1::/a']), snapshotOf(['repo-1::/a', 'repo-2::/b'])]
|
||||
let computes = 0
|
||||
const compute = async (): Promise<ResolvedWorktreeSnapshot> => results[computes++]
|
||||
|
||||
await cache.getSnapshot(compute, 60_000, 7)
|
||||
const afterRegistration = await cache.getSnapshot(compute, 60_000, 8)
|
||||
|
||||
expect(computes).toBe(2)
|
||||
expect(afterRegistration.worktrees.map((worktree) => worktree.id)).toEqual([
|
||||
'repo-1::/a',
|
||||
'repo-2::/b'
|
||||
])
|
||||
})
|
||||
|
||||
it('does not join an in-flight compute that started under a stale inventory', async () => {
|
||||
const cache = new RuntimeResolvedWorktreeCache()
|
||||
const computed: number[] = []
|
||||
const compute = async (): Promise<ResolvedWorktreeSnapshot> => {
|
||||
computed.push(computed.length)
|
||||
return snapshotOf([])
|
||||
}
|
||||
|
||||
const first = cache.getSnapshot(compute, 60_000, 7)
|
||||
const second = cache.getSnapshot(compute, 60_000, 8)
|
||||
await Promise.all([first, second])
|
||||
|
||||
expect(computed).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('keeps a primed snapshot servable when nothing mutated', async () => {
|
||||
// Headless reattach resolves from a primed cache without Git; non-mutation reads must preserve its freshness.
|
||||
const cache = new RuntimeResolvedWorktreeCache()
|
||||
let computes = 0
|
||||
const prime = async (): Promise<ResolvedWorktreeSnapshot> => {
|
||||
computes += 1
|
||||
return snapshotOf(['repo-restore::/tmp/restore-records'])
|
||||
}
|
||||
await cache.getSnapshot(prime, 60_000, getWorktreeScanMutationRevision())
|
||||
|
||||
// A read that mints a scan generation for a repo nothing has scanned yet is not a mutation.
|
||||
getLocalWorktreeScanGeneration(`repo-never-scanned-${Math.random()}`)
|
||||
|
||||
expect(cache.isFresh(getWorktreeScanMutationRevision())).toBe(true)
|
||||
const served = await cache.getSnapshot(prime, 60_000, getWorktreeScanMutationRevision())
|
||||
expect(computes).toBe(1)
|
||||
expect(served.worktrees.map((worktree) => worktree.id)).toEqual([
|
||||
'repo-restore::/tmp/restore-records'
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -1,78 +0,0 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { Repo } from '../../../../shared/repo-types'
|
||||
|
||||
const { visibleRepos } = vi.hoisted(() => ({
|
||||
visibleRepos:
|
||||
vi.fn<(store: { getRepos: () => Repo[]; getSshTargets: () => unknown[] }) => Repo[]>()
|
||||
}))
|
||||
vi.mock('../../../ssh/orcad-retained-source', () => ({
|
||||
visibleRepos,
|
||||
visibleProjectGroups: vi.fn(() => []),
|
||||
visibleFolderWorkspaces: vi.fn(() => [])
|
||||
}))
|
||||
|
||||
const { runtimeHiddenHostIds, runtimeHiddenRepos } = await import('./runtime-visible-catalog')
|
||||
|
||||
const sshTarget = { id: 'target-1', label: 'dev', host: 'dev', port: 22, username: 'dev' }
|
||||
const repo = (id: string, connectionId?: string): Repo => ({
|
||||
id,
|
||||
...(connectionId ? { connectionId } : {}),
|
||||
path: `/src/${id}`,
|
||||
displayName: id,
|
||||
badgeColor: '#000',
|
||||
addedAt: 0
|
||||
})
|
||||
const storeWith = (repos: Repo[], withTargets: boolean) => ({
|
||||
getRepos: () => repos,
|
||||
...(withTargets ? { getSshTargets: () => [sshTarget] } : {})
|
||||
})
|
||||
|
||||
describe('runtime visible catalog', () => {
|
||||
it('hides a moved local row by host while the WSL host row with the same id stays listed', () => {
|
||||
const local = repo('moved')
|
||||
const onHost: Repo = { ...repo('moved'), executionHostId: 'runtime:wsl-ubuntu' }
|
||||
visibleRepos.mockImplementation((store) => store.getRepos().filter((r) => r !== local))
|
||||
const hidden = runtimeHiddenRepos(storeWith([local, onHost], true))
|
||||
expect(hidden.size).toBe(1)
|
||||
expect(hidden.hasRepo(local)).toBe(true)
|
||||
expect(hidden.hasRepo(onHost)).toBe(false)
|
||||
expect(hidden.hasWorktree({ repoId: 'moved', hostId: 'local' })).toBe(true)
|
||||
expect(hidden.hasWorktree({ repoId: 'moved', hostId: 'runtime:wsl-ubuntu' })).toBe(false)
|
||||
})
|
||||
|
||||
it('keeps a host that still has a visible row', () => {
|
||||
visibleRepos.mockImplementation((store) => store.getRepos().filter((r) => r.id !== 'a'))
|
||||
const store = storeWith([repo('a', 'target-2'), repo('b', 'target-2')], true)
|
||||
expect(runtimeHiddenHostIds(store).size).toBe(0)
|
||||
})
|
||||
|
||||
describe('a repo id shared by a visible host and a retained SSH source', () => {
|
||||
const local = repo('same-project')
|
||||
const retained = repo('same-project', 'target-1')
|
||||
const store = storeWith([local, retained], true)
|
||||
// Same id on both rows: only the SSH row is the retired source.
|
||||
const hideRetained = (s: { getRepos: () => Repo[] }): Repo[] =>
|
||||
s.getRepos().filter((r) => r.connectionId !== 'target-1')
|
||||
|
||||
it('still names the retired SSH host as hidden', () => {
|
||||
visibleRepos.mockImplementation(hideRetained)
|
||||
expect([...runtimeHiddenHostIds(store)]).toEqual(['ssh:target-1'])
|
||||
})
|
||||
|
||||
it('hides only the worktrees on the retained host', () => {
|
||||
visibleRepos.mockImplementation(hideRetained)
|
||||
const hidden = runtimeHiddenRepos(store)
|
||||
expect(hidden.hasRepo(retained)).toBe(true)
|
||||
expect(hidden.hasRepo(local)).toBe(false)
|
||||
expect(hidden.hasWorktree({ repoId: 'same-project', hostId: 'ssh:target-1' })).toBe(true)
|
||||
expect(hidden.hasWorktree({ repoId: 'same-project', hostId: 'local' })).toBe(false)
|
||||
// A legacy worktree with no host cannot be placed on either row, and one row is visible.
|
||||
expect(hidden.hasWorktree({ repoId: 'same-project' })).toBe(false)
|
||||
})
|
||||
|
||||
it('hides a hostless worktree whose every row is hidden', () => {
|
||||
visibleRepos.mockImplementation(() => [])
|
||||
expect(runtimeHiddenRepos(store).hasWorktree({ repoId: 'same-project' })).toBe(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,59 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
getRuntimeWorktreeRemovalOptionsKey,
|
||||
runtimeRepoMatchesExecutionHost
|
||||
} from './runtime-worktree-selection'
|
||||
|
||||
describe('getRuntimeWorktreeRemovalOptionsKey', () => {
|
||||
it('separates a waived archive-hook retry from the attempt about to refuse on it (#19334)', () => {
|
||||
const strict = getRuntimeWorktreeRemovalOptionsKey({ runHooks: true })
|
||||
expect(
|
||||
getRuntimeWorktreeRemovalOptionsKey({ runHooks: true, allowFailedArchiveHook: true })
|
||||
).not.toBe(strict)
|
||||
})
|
||||
|
||||
it('keeps every waiver on its own axis, so none of them coalesce', () => {
|
||||
const keys = [
|
||||
{},
|
||||
{ force: true },
|
||||
{ deleteBranch: false },
|
||||
{ deleteBranch: true },
|
||||
{ runHooks: true },
|
||||
{ allowUnverifiedPtyStop: true },
|
||||
{ allowFailedArchiveHook: true }
|
||||
].map(getRuntimeWorktreeRemovalOptionsKey)
|
||||
expect(new Set(keys).size).toBe(keys.length)
|
||||
})
|
||||
})
|
||||
|
||||
describe('runtimeRepoMatchesExecutionHost', () => {
|
||||
it('matches an unstamped SSH repo against its own host (#11163)', () => {
|
||||
// Legacy connectionId must match ssh:<target> requests or repo-add/clone dedupe creates duplicate rows.
|
||||
expect(runtimeRepoMatchesExecutionHost({ connectionId: 'target-1' }, 'ssh:target-1')).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects an unstamped SSH repo against a different SSH host', () => {
|
||||
expect(runtimeRepoMatchesExecutionHost({ connectionId: 'target-1' }, 'ssh:target-2')).toBe(
|
||||
false
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects an unstamped SSH repo against local and runtime hosts', () => {
|
||||
expect(runtimeRepoMatchesExecutionHost({ connectionId: 'target-1' }, 'local')).toBe(false)
|
||||
expect(runtimeRepoMatchesExecutionHost({ connectionId: 'target-1' }, 'runtime:env-1')).toBe(
|
||||
false
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps a stamped repo bound to the host it names', () => {
|
||||
expect(runtimeRepoMatchesExecutionHost({ executionHostId: 'runtime:env-1' }, 'local')).toBe(
|
||||
false
|
||||
)
|
||||
expect(
|
||||
runtimeRepoMatchesExecutionHost(
|
||||
{ executionHostId: 'local', connectionId: 'target-1' },
|
||||
'local'
|
||||
)
|
||||
).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -1,548 +0,0 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import '../../orca-runtime-tests/runtime-test-module-registration.spec'
|
||||
import { OrcaRuntimeService } from '../../orca-runtime'
|
||||
import { store, TEST_WORKTREE_ID } from '../../orca-runtime-tests/orca-runtime-test-fixtures.spec'
|
||||
import type { Repo } from '../../../../shared/repo-types'
|
||||
import type { ExecutionHostId } from '../../../../shared/execution-host'
|
||||
import type { ResolvedWorktree } from './runtime-worktree-path-identity'
|
||||
import type { ResolvedWorktreeSnapshot } from './runtime-resolved-worktree-cache'
|
||||
import type { DetectedWorktreeListResult } from '../../../../shared/worktree/types'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import {
|
||||
FLOATING_TERMINAL_WORKTREE_ID,
|
||||
getDefaultWorkspaceSession
|
||||
} from '../../../../shared/constants'
|
||||
import type { BrowserTabInfo } from '../../../../shared/runtime-browser-contracts'
|
||||
import type { RuntimeMobileSessionTabsSnapshot } from '../../../../shared/runtime-session-contracts'
|
||||
import type { WorkspaceSessionState } from '../../../../shared/workspace-session/workspace-session-state-types'
|
||||
import { firstWorkRenameDeps } from '../../../agent-hooks/first-work-rename-runtime'
|
||||
|
||||
const repo: Repo = {
|
||||
id: 'workspace-query',
|
||||
path: '/tmp/workspace-query',
|
||||
displayName: 'Workspace query',
|
||||
addedAt: 1,
|
||||
badgeColor: 'blue'
|
||||
}
|
||||
const resolved: ResolvedWorktree = {
|
||||
...store.getWorktreeMeta(TEST_WORKTREE_ID),
|
||||
id: TEST_WORKTREE_ID,
|
||||
repoId: 'repo-1',
|
||||
path: '/tmp/worktree-a',
|
||||
branch: 'main',
|
||||
displayName: 'Workspace',
|
||||
linkedIssue: null,
|
||||
comment: '',
|
||||
parentWorktreeId: null,
|
||||
childWorktreeIds: [],
|
||||
lineage: null,
|
||||
git: {
|
||||
path: '/tmp/worktree-a',
|
||||
head: '',
|
||||
branch: 'main',
|
||||
isBare: false,
|
||||
isMainWorktree: false
|
||||
}
|
||||
}
|
||||
const snapshot: ResolvedWorktreeSnapshot = {
|
||||
worktrees: [resolved],
|
||||
platformByRepoId: new Map()
|
||||
}
|
||||
|
||||
describe('workspace query prototype dispatch', () => {
|
||||
it('re-enters the runtime repo selector', async () => {
|
||||
const seen: unknown[][] = []
|
||||
class Probe extends OrcaRuntimeService {
|
||||
protected override selectReposBySelector(selector: string): Repo[] {
|
||||
seen.push([this, selector])
|
||||
return [repo]
|
||||
}
|
||||
run() {
|
||||
return this.resolveRepoSelector('id:workspace-query')
|
||||
}
|
||||
}
|
||||
const runtime = new Probe(store)
|
||||
await expect(runtime.run()).resolves.toBe(repo)
|
||||
expect(seen).toEqual([[runtime, 'id:workspace-query']])
|
||||
})
|
||||
|
||||
it('re-enters the runtime snapshot from async listing', async () => {
|
||||
const seen: OrcaRuntimeService[] = []
|
||||
class Probe extends OrcaRuntimeService {
|
||||
protected override async listResolvedWorktreeSnapshot(): Promise<ResolvedWorktreeSnapshot> {
|
||||
seen.push(this)
|
||||
return snapshot
|
||||
}
|
||||
run() {
|
||||
return this.listResolvedWorktrees()
|
||||
}
|
||||
}
|
||||
const runtime = new Probe(store)
|
||||
await expect(runtime.run()).resolves.toEqual([resolved])
|
||||
expect(seen).toEqual([runtime])
|
||||
})
|
||||
|
||||
it('calls the runtime scoped resolver with the original single argument', async () => {
|
||||
const seen: { receiver: OrcaRuntimeService; args: unknown[] }[] = []
|
||||
class Probe extends OrcaRuntimeService {
|
||||
protected override hasFreshResolvedWorktreeCache(): boolean {
|
||||
return false
|
||||
}
|
||||
protected override async resolveExplicitWorktreeIdScoped(
|
||||
_worktreeId: string,
|
||||
_hostId?: ExecutionHostId
|
||||
): Promise<ResolvedWorktree> {
|
||||
seen.push({ receiver: this, args: Array.from(arguments) })
|
||||
return resolved
|
||||
}
|
||||
run() {
|
||||
return this.resolveGitWorktreeSelector(`id:${TEST_WORKTREE_ID}`)
|
||||
}
|
||||
}
|
||||
const runtime = new Probe(store)
|
||||
await expect(runtime.run()).resolves.toBe(resolved)
|
||||
expect(seen).toEqual([{ receiver: runtime, args: [TEST_WORKTREE_ID] }])
|
||||
})
|
||||
|
||||
it('keeps disconnected SSH stored rows with a failed scan verdict', async () => {
|
||||
class Probe extends OrcaRuntimeService {
|
||||
run(remote: Repo) {
|
||||
return this.listRepoWorktreesForResolutionUncached(remote, undefined)
|
||||
}
|
||||
}
|
||||
const remote: Repo = { ...repo, executionHostId: 'ssh:workspace-query' }
|
||||
const id = `${repo.id}::/remote/stored-worktree`
|
||||
const meta = {
|
||||
...store.getWorktreeMeta(TEST_WORKTREE_ID),
|
||||
hostId: remote.executionHostId,
|
||||
customName: 'Stored',
|
||||
comment: '',
|
||||
issueNumber: null,
|
||||
issueUrl: null,
|
||||
issueTitle: null,
|
||||
mergeStatus: null
|
||||
}
|
||||
const runtime = new Probe({
|
||||
...store,
|
||||
getRepos: () => [remote],
|
||||
getAllWorktreeMeta: () => ({ [id]: meta }),
|
||||
getWorktreeMeta: () => meta
|
||||
})
|
||||
const result = await runtime.run(remote)
|
||||
expect(result.ok).toBe(false)
|
||||
expect(result.worktrees.map((row) => row.path)).toEqual(['/remote/stored-worktree'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('workspace-detected-listing-capture characterization', () => {
|
||||
describe('detected workspace listing during repo resolution', () => {
|
||||
it('captures the listing method before awaiting resolution and keeps its runtime receiver', async () => {
|
||||
const pending = Promise.withResolvers<Repo>()
|
||||
const events: string[] = []
|
||||
const repo: Repo = {
|
||||
id: 'repo',
|
||||
path: '/repo',
|
||||
displayName: 'repo',
|
||||
badgeColor: 'blue',
|
||||
addedAt: 0
|
||||
}
|
||||
const result: DetectedWorktreeListResult = {
|
||||
repoId: repo.id,
|
||||
authoritative: true,
|
||||
source: 'git',
|
||||
worktrees: []
|
||||
}
|
||||
const runtime = new (class extends OrcaRuntimeService {
|
||||
override resolveRepoSelectorForConnection(selector: string, connectionId?: string | null) {
|
||||
events.push('resolve')
|
||||
expect([selector, connectionId]).toEqual(['repo', 'ssh:box'])
|
||||
return pending.promise
|
||||
}
|
||||
})()
|
||||
const original = vi.fn(function (
|
||||
this: OrcaRuntimeService,
|
||||
resolved: Repo,
|
||||
sourceDefaultsSupported?: boolean
|
||||
) {
|
||||
events.push('invoke original')
|
||||
expect(this).toBe(runtime)
|
||||
expect([resolved, sourceDefaultsSupported]).toEqual([repo, false])
|
||||
return Promise.resolve(result)
|
||||
})
|
||||
const replacement = vi.fn(() => Promise.resolve({ ...result, repoId: 'replacement' }))
|
||||
let listing: (
|
||||
repo: Repo,
|
||||
sourceDefaultsSupported?: boolean
|
||||
) => Promise<DetectedWorktreeListResult> = original
|
||||
Object.defineProperty(runtime, 'listDetectedWorktreesForResolvedRepo', {
|
||||
get: () => {
|
||||
events.push('capture listing')
|
||||
return listing
|
||||
}
|
||||
})
|
||||
|
||||
const listingPromise = runtime.listDetectedManagedWorktrees('repo', 'ssh:box', false)
|
||||
listing = replacement
|
||||
pending.resolve(repo)
|
||||
|
||||
await expect(listingPromise).resolves.toBe(result)
|
||||
expect(events).toEqual(['capture listing', 'resolve', 'invoke original'])
|
||||
expect(original).toHaveBeenCalledOnce()
|
||||
expect(replacement).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('workspace methods captured before request property reads', () => {
|
||||
it('captures port probes before reading repoId', async () => {
|
||||
const runtime = new OrcaRuntimeService()
|
||||
const events: string[] = []
|
||||
const original = vi.fn(async () => [])
|
||||
const replacement = vi.fn(async () => [])
|
||||
let probes = original
|
||||
Object.defineProperty(runtime, 'getWorkspacePortProbes', {
|
||||
get: () => {
|
||||
events.push('capture probes')
|
||||
return probes
|
||||
}
|
||||
})
|
||||
await runtime.killWorkspacePort({
|
||||
pid: 4242,
|
||||
port: 3020,
|
||||
get repoId() {
|
||||
events.push('read repoId')
|
||||
probes = replacement
|
||||
return 'repo'
|
||||
}
|
||||
})
|
||||
expect(events).toEqual(['capture probes', 'read repoId'])
|
||||
expect(original).toHaveBeenCalledWith('repo')
|
||||
expect(original.mock.contexts[0]).toBe(runtime)
|
||||
expect(replacement).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('captures file routing before reading the requested workspace', async () => {
|
||||
const runtime = new OrcaRuntimeService()
|
||||
const events: string[] = []
|
||||
const original = vi.fn(async () => ({ executionHostId: 'ssh:box' }))
|
||||
const replacement = vi.fn(async () => ({ executionHostId: 'ssh:box' }))
|
||||
let routing = original
|
||||
Object.defineProperty(runtime, 'resolveRuntimeFileTarget', {
|
||||
get: () => {
|
||||
events.push('capture routing')
|
||||
return routing
|
||||
}
|
||||
})
|
||||
await runtime.killWorkspacePortOnHost({
|
||||
executionHostId: 'ssh:box',
|
||||
pid: 4242,
|
||||
port: 3020,
|
||||
get worktree() {
|
||||
events.push('read worktree')
|
||||
routing = replacement
|
||||
return 'folder:remote'
|
||||
}
|
||||
})
|
||||
expect(events).toEqual(['capture routing', 'read worktree'])
|
||||
expect(original).toHaveBeenCalledWith('folder:remote')
|
||||
expect(original.mock.contexts[0]).toBe(runtime)
|
||||
expect(replacement).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('captures scan invalidation before reading the resolved repo id', async () => {
|
||||
const runtime = new OrcaRuntimeService()
|
||||
const events: string[] = []
|
||||
const original = vi.fn()
|
||||
const replacement = vi.fn()
|
||||
let invalidate = original
|
||||
const repo: Repo = {
|
||||
get id() {
|
||||
events.push('read repo id')
|
||||
invalidate = replacement
|
||||
return 'repo'
|
||||
},
|
||||
path: '/repo',
|
||||
displayName: 'repo',
|
||||
badgeColor: 'blue',
|
||||
addedAt: 0
|
||||
}
|
||||
Object.defineProperty(runtime, 'invalidateWorktreeScanCacheForRepo', {
|
||||
get: () => {
|
||||
events.push('capture invalidation')
|
||||
return invalidate
|
||||
}
|
||||
})
|
||||
Object.defineProperty(runtime, 'resolveRepoSelectorForConnection', {
|
||||
value: async () => repo
|
||||
})
|
||||
const detected: DetectedWorktreeListResult = {
|
||||
repoId: 'repo',
|
||||
authoritative: false,
|
||||
source: 'metadata-fallback',
|
||||
worktrees: []
|
||||
}
|
||||
Object.defineProperty(runtime, 'listDetectedWorktreesForResolvedRepo', {
|
||||
value: async () => detected
|
||||
})
|
||||
|
||||
await expect(runtime.teardownMissingManagedWorktreeTerminals('repo', [])).resolves.toEqual({
|
||||
stoppedWorktreeIds: []
|
||||
})
|
||||
expect(events).toEqual(['capture invalidation', 'read repo id'])
|
||||
expect(original).toHaveBeenCalledWith('repo')
|
||||
expect(original.mock.contexts[0]).toBe(runtime)
|
||||
expect(replacement).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('workspace-file-target-capture characterization', () => {
|
||||
describe('floating workspace file target resolution', () => {
|
||||
it('captures the converter before awaiting the path and keeps its runtime receiver', async () => {
|
||||
const runtime = new OrcaRuntimeService()
|
||||
const pending = Promise.withResolvers<string>()
|
||||
const events: string[] = []
|
||||
const convert = runtime['floatingWorkspaceToResolvedWorktree']
|
||||
const original = vi.fn(function (this: OrcaRuntimeService, path: string) {
|
||||
events.push('invoke original')
|
||||
expect(this).toBe(runtime)
|
||||
return convert.call(this, path)
|
||||
})
|
||||
const replacement = vi.fn((path: string) => convert.call(runtime, path))
|
||||
let converter: typeof convert = original
|
||||
Object.defineProperty(runtime, 'floatingWorkspaceToResolvedWorktree', {
|
||||
get: () => {
|
||||
events.push('capture converter')
|
||||
return converter
|
||||
}
|
||||
})
|
||||
Object.defineProperty(runtime, 'resolveFloatingWorkspacePath', {
|
||||
value: () => {
|
||||
events.push('resolve path')
|
||||
return pending.promise
|
||||
}
|
||||
})
|
||||
const result = runtime['resolveRuntimeFileTarget'](FLOATING_TERMINAL_WORKTREE_ID)
|
||||
converter = replacement
|
||||
const path = join(tmpdir(), 'floating')
|
||||
pending.resolve(path)
|
||||
|
||||
await expect(result).resolves.toMatchObject({
|
||||
worktree: { path },
|
||||
executionHostId: 'local'
|
||||
})
|
||||
expect(events).toEqual(['capture converter', 'resolve path', 'invoke original'])
|
||||
expect(original).toHaveBeenCalledWith(path)
|
||||
expect(replacement).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('workspace-session-read-order characterization', () => {
|
||||
const WORKTREE_ID = 'repo::/workspace'
|
||||
|
||||
describe('workspace session callable lookup order', () => {
|
||||
it('keeps the native refusal for a launcher without a workspace selector', async () => {
|
||||
const runtime = new OrcaRuntimeService(null)
|
||||
await expect(
|
||||
runtime.resolveOrchestrationAgentLauncherForTarget('missing-launcher', {})
|
||||
).rejects.toBeInstanceOf(TypeError)
|
||||
})
|
||||
|
||||
it('captures the browser reader before evaluating the snapshot workspace getter', () => {
|
||||
const runtime = new OrcaRuntimeService(null)
|
||||
const order: string[] = []
|
||||
const replacement = vi.fn(() => new Map<string, BrowserTabInfo>())
|
||||
const original = function (
|
||||
this: InstanceType<typeof OrcaRuntimeService>,
|
||||
worktreeId: string
|
||||
) {
|
||||
expect(this).toBe(runtime)
|
||||
expect(worktreeId).toBe(WORKTREE_ID)
|
||||
order.push('original call')
|
||||
return new Map<string, BrowserTabInfo>()
|
||||
}
|
||||
Object.defineProperty(runtime, 'getListedBrowserTabsByPageId', {
|
||||
configurable: true,
|
||||
get: () => {
|
||||
order.push('reader lookup')
|
||||
return original
|
||||
}
|
||||
})
|
||||
const snapshot: RuntimeMobileSessionTabsSnapshot = {
|
||||
worktree: WORKTREE_ID,
|
||||
publicationEpoch: 'seed',
|
||||
snapshotVersion: 1,
|
||||
tabs: [],
|
||||
activeGroupId: null,
|
||||
activeTabId: null,
|
||||
activeTabType: null
|
||||
}
|
||||
Object.defineProperty(snapshot, 'worktree', {
|
||||
get: () => {
|
||||
order.push('argument lookup')
|
||||
Object.defineProperty(runtime, 'getListedBrowserTabsByPageId', { value: replacement })
|
||||
return WORKTREE_ID
|
||||
}
|
||||
})
|
||||
expect(runtime['collectPublicMobileSessionTabIds'](snapshot)).toEqual(new Set())
|
||||
expect(order).toEqual(['reader lookup', 'argument lookup', 'original call'])
|
||||
expect(replacement).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('captures the title writer before constructing a session with getter-backed fields', () => {
|
||||
const runtime = new OrcaRuntimeService(null)
|
||||
const order: string[] = []
|
||||
const replacement = vi.fn()
|
||||
const original = function (
|
||||
this: InstanceType<typeof OrcaRuntimeService>,
|
||||
worktreeId: string,
|
||||
session: WorkspaceSessionState
|
||||
) {
|
||||
expect(this).toBe(runtime)
|
||||
expect(worktreeId).toBe(WORKTREE_ID)
|
||||
expect(session.tabsByWorktree[WORKTREE_ID]?.[0]?.customTitle).toBe('renamed')
|
||||
order.push('original write')
|
||||
}
|
||||
const session = getDefaultWorkspaceSession()
|
||||
const tabs: WorkspaceSessionState['tabsByWorktree'] = {
|
||||
[WORKTREE_ID]: [
|
||||
{
|
||||
id: 'tab',
|
||||
ptyId: null,
|
||||
worktreeId: WORKTREE_ID,
|
||||
title: 'Terminal',
|
||||
customTitle: null,
|
||||
color: null,
|
||||
sortOrder: 0,
|
||||
createdAt: 1
|
||||
}
|
||||
]
|
||||
}
|
||||
let tabReads = 0
|
||||
Object.defineProperty(session, 'tabsByWorktree', {
|
||||
enumerable: true,
|
||||
get: () => {
|
||||
tabReads += 1
|
||||
if (tabReads === 2) {
|
||||
order.push('session spread')
|
||||
Object.defineProperty(runtime, 'setWorkspaceSessionForWorktree', { value: replacement })
|
||||
}
|
||||
return tabs
|
||||
}
|
||||
})
|
||||
Object.defineProperty(runtime, 'store', { value: { setWorkspaceSession: () => {} } })
|
||||
Object.defineProperty(runtime, 'getWorkspaceSessionForWorktree', { value: () => session })
|
||||
Object.defineProperty(runtime, 'setWorkspaceSessionForWorktree', {
|
||||
configurable: true,
|
||||
get: () => {
|
||||
order.push('writer lookup')
|
||||
return original
|
||||
}
|
||||
})
|
||||
runtime['persistHeadlessTerminalTitle'](WORKTREE_ID, 'tab', 'renamed')
|
||||
expect(order).toEqual(['writer lookup', 'session spread', 'original write'])
|
||||
expect(replacement).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('first-work rename lazy Store capabilities', () => {
|
||||
type RenameStore = Parameters<typeof firstWorkRenameDeps>[0]
|
||||
type RenameDeps = ReturnType<typeof firstWorkRenameDeps>
|
||||
|
||||
function renameStore(): RenameStore {
|
||||
return {
|
||||
getSettings: () => ({
|
||||
workspaceDir: '/workspace',
|
||||
nestWorkspaces: false,
|
||||
refreshLocalBaseRefOnWorktreeCreate: false,
|
||||
branchPrefix: 'none',
|
||||
branchPrefixCustom: ''
|
||||
}),
|
||||
getRepo: () => undefined,
|
||||
getRepos: () => [],
|
||||
getWorktreeMeta: () => undefined,
|
||||
setWorktreeMeta: vi.fn()
|
||||
}
|
||||
}
|
||||
|
||||
const missingCalls: {
|
||||
name: string
|
||||
invoke: (deps: RenameDeps) => unknown
|
||||
provideFolder?: true
|
||||
}[] = [
|
||||
{ name: 'display name', invoke: (deps) => deps.getCurrentDisplayName('folder:sample') },
|
||||
{ name: 'folder path', invoke: (deps) => deps.getFolderWorkspacePath?.('folder:sample') },
|
||||
{
|
||||
name: 'pending rename',
|
||||
invoke: (deps) => deps.isPendingFirstAgentMessageRename?.('folder:sample')
|
||||
},
|
||||
{ name: 'display write', invoke: (deps) => deps.setDisplayName('folder:sample', 'New') },
|
||||
{ name: 'error read', invoke: (deps) => deps.setRenameError('folder:sample', 'Failure') },
|
||||
{
|
||||
name: 'error write',
|
||||
provideFolder: true,
|
||||
invoke: (deps) => deps.setRenameError('folder:sample', 'Failure')
|
||||
},
|
||||
{ name: 'tab owner', invoke: (deps) => deps.resolveWorktreeIdForTab('tab') }
|
||||
]
|
||||
|
||||
it.each(missingCalls)(
|
||||
'preserves the direct missing $name capability failure',
|
||||
({ invoke, provideFolder }) => {
|
||||
const store = renameStore()
|
||||
if (provideFolder) {
|
||||
store.getFolderWorkspace = () => undefined
|
||||
}
|
||||
const runtime = new OrcaRuntimeService(null)
|
||||
let notified = false
|
||||
runtime.notifyFolderWorkspaceChanged = () => {
|
||||
notified = true
|
||||
}
|
||||
const deps = firstWorkRenameDeps(store, runtime)
|
||||
expect(() => invoke(deps)).toThrow(TypeError)
|
||||
expect(notified).toBe(false)
|
||||
expect(deps.renameWorktreeFolder).toBeUndefined()
|
||||
}
|
||||
)
|
||||
|
||||
it('looks up the current writer on each call and retains Store and Root receivers', () => {
|
||||
const store = renameStore()
|
||||
const runtime = new OrcaRuntimeService(null)
|
||||
const order: string[] = []
|
||||
runtime.notifyFolderWorkspaceChanged = function () {
|
||||
expect(this).toBe(runtime)
|
||||
order.push('notify')
|
||||
}
|
||||
const deps = firstWorkRenameDeps(store, runtime)
|
||||
const write: NonNullable<RenameStore['updateFolderWorkspace']> = function (
|
||||
this: RenameStore,
|
||||
id,
|
||||
updates
|
||||
) {
|
||||
expect(this).toBe(store)
|
||||
expect(arguments).toHaveLength(2)
|
||||
expect(id).toBe('sample')
|
||||
expect(updates).toEqual({
|
||||
name: 'New',
|
||||
pendingFirstAgentMessageRename: false,
|
||||
firstAgentMessageRenameError: null
|
||||
})
|
||||
order.push('write')
|
||||
return null
|
||||
}
|
||||
Object.defineProperty(store, 'updateFolderWorkspace', {
|
||||
configurable: true,
|
||||
get() {
|
||||
order.push('lookup')
|
||||
return write
|
||||
}
|
||||
})
|
||||
deps.setDisplayName('folder:sample', 'New')
|
||||
expect(order).toEqual(['lookup', 'write', 'notify'])
|
||||
delete store.updateFolderWorkspace
|
||||
expect(() => deps.setDisplayName('folder:sample', 'Again')).toThrow(TypeError)
|
||||
expect(order).toEqual(['lookup', 'write', 'notify'])
|
||||
})
|
||||
})
|
||||
@@ -93,24 +93,4 @@ describe('worktree.list host coverage under the row cap', () => {
|
||||
expect(remote).toHaveLength(24)
|
||||
expect(result.hostScope).toEqual({ hostIds: ['local', 'ssh:box-1'], omittedHostIds: [] })
|
||||
})
|
||||
|
||||
it('names a configured host that contributed no rows at all', async () => {
|
||||
// Failed repo scans and empty hosts both contribute no rows; scope must expose the uncertainty.
|
||||
const result = await queries(fleet(3, 0), ['local', 'ssh:box-1', 'runtime:paired']).list(
|
||||
undefined,
|
||||
200
|
||||
)
|
||||
|
||||
expect(result.hostScope).toEqual({
|
||||
hostIds: ['local'],
|
||||
omittedHostIds: ['runtime:paired', 'ssh:box-1']
|
||||
})
|
||||
})
|
||||
|
||||
it('does not report configured hosts as omitted from a --repo listing', async () => {
|
||||
// Caller-scoped listings must omit unrelated hosts.
|
||||
const result = await queries(fleet(0, 5)).list('id:repo-ssh', 200)
|
||||
|
||||
expect(result.hostScope).toEqual({ hostIds: ['ssh:box-1'], omittedHostIds: [] })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -31,12 +31,9 @@ vi.mock('./repo-worktree-admin-fingerprint', () => ({
|
||||
}))
|
||||
|
||||
import { OrcaRuntimeService } from '../../orca-runtime'
|
||||
import {
|
||||
WORKTREE_SCAN_ADMIN_FINGERPRINT_TIMEOUT_MS,
|
||||
WORKTREE_SCAN_ADMIN_RECONCILE_INTERVAL_MS
|
||||
} from './worktree-scan-admin-budget'
|
||||
import { RESOLVED_WORKTREE_REPO_TIMEOUT_MS } from './repo-worktree-row-resolution'
|
||||
import { canonicalWorktreeIdentity } from '../../../../shared/worktree/identity'
|
||||
import { WORKTREE_SCAN_ADMIN_FINGERPRINT_TIMEOUT_MS } from './worktree-scan-admin-budget'
|
||||
import './repo-worktree-row-resolution'
|
||||
import '../../../../shared/worktree/identity'
|
||||
import type { HostWorktreeTarget } from '../../../../shared/worktree/host-qualified-identity'
|
||||
|
||||
const REPO_ID = 'repo-local'
|
||||
@@ -45,8 +42,6 @@ const WORKTREE_PATH = '/Users/me/dev/app-feature'
|
||||
const WORKTREE_ID = `${REPO_ID}::${WORKTREE_PATH}`
|
||||
const MAIN_WORKTREE_ID = `${REPO_ID}::${REPO_PATH}`
|
||||
const SCAN_TTL_MS = 30_000
|
||||
// Slow healthy stat work must permit reuse rather than trigger repeating Git scans.
|
||||
const SLOW_BUT_HEALTHY_PROBE_MS = 3_000
|
||||
|
||||
function makeMeta(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
@@ -182,87 +177,6 @@ describe('worktree scan admin-fingerprint gate', () => {
|
||||
readRepoWorktreeAdminFingerprintMock.mockResolvedValue('fp-1')
|
||||
})
|
||||
|
||||
it('skips the Git scan past the TTL while the admin fingerprint is unchanged', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const { list } = makeRuntime()
|
||||
|
||||
await list()
|
||||
expect(scanCount()).toBe(1)
|
||||
|
||||
vi.advanceTimersByTime(SCAN_TTL_MS + 1_000)
|
||||
await list()
|
||||
expect(scanCount()).toBe(1)
|
||||
|
||||
vi.advanceTimersByTime(1_000)
|
||||
await list()
|
||||
expect(scanCount()).toBe(1)
|
||||
expect(readRepoWorktreeAdminFingerprintMock).toHaveBeenCalledTimes(2)
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('rescans at the TTL once the admin fingerprint changes', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const { list } = makeRuntime()
|
||||
|
||||
await list()
|
||||
expect(scanCount()).toBe(1)
|
||||
|
||||
readRepoWorktreeAdminFingerprintMock.mockResolvedValue('fp-2')
|
||||
vi.advanceTimersByTime(SCAN_TTL_MS + 1_000)
|
||||
await list()
|
||||
expect(scanCount()).toBe(2)
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('reconciles with a real scan once the bounded interval elapses', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const { list } = makeRuntime()
|
||||
|
||||
await list()
|
||||
expect(scanCount()).toBe(1)
|
||||
|
||||
// Keep polling past the TTL with an unchanged fingerprint; only the reconcile deadline rescans.
|
||||
for (
|
||||
let elapsed = 31_000;
|
||||
elapsed < WORKTREE_SCAN_ADMIN_RECONCILE_INTERVAL_MS;
|
||||
elapsed += 31_000
|
||||
) {
|
||||
vi.advanceTimersByTime(31_000)
|
||||
await list()
|
||||
}
|
||||
expect(scanCount()).toBe(1)
|
||||
|
||||
vi.advanceTimersByTime(WORKTREE_SCAN_ADMIN_RECONCILE_INTERVAL_MS)
|
||||
await list()
|
||||
expect(scanCount()).toBe(2)
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('still rescans immediately when an event invalidates the repo', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const { runtime, list } = makeRuntime()
|
||||
|
||||
await list()
|
||||
expect(scanCount()).toBe(1)
|
||||
|
||||
runtime.notifyBranchRenamed(REPO_ID)
|
||||
await list()
|
||||
expect(scanCount()).toBe(2)
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('resolves a just-created id after invalidation even within both cache TTLs', async () => {
|
||||
const { runtime, list } = makeRuntime()
|
||||
listWorktreesStrictMock.mockResolvedValueOnce([
|
||||
@@ -279,96 +193,6 @@ describe('worktree scan admin-fingerprint gate', () => {
|
||||
expect(scanCount()).toBe(2)
|
||||
})
|
||||
|
||||
it('scans when the probe cannot describe the repo', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
readRepoWorktreeAdminFingerprintMock.mockResolvedValue(null)
|
||||
const { list } = makeRuntime()
|
||||
|
||||
await list()
|
||||
vi.advanceTimersByTime(SCAN_TTL_MS + 1_000)
|
||||
await list()
|
||||
|
||||
expect(scanCount()).toBe(2)
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('never probes a repo whose scan TTL already reaches the reconcile interval', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const { list } = makeRuntime({ repoPath: '/tmp/.codex-tmp/capsule-a' })
|
||||
|
||||
await list()
|
||||
vi.advanceTimersByTime(WORKTREE_SCAN_ADMIN_RECONCILE_INTERVAL_MS + 1_000)
|
||||
await list()
|
||||
|
||||
expect(scanCount()).toBe(2)
|
||||
expect(readRepoWorktreeAdminFingerprintMock).not.toHaveBeenCalled()
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('never consults the probe for SSH repos', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const { list } = makeRuntime({ connectionId: 'ssh-remote-1' })
|
||||
|
||||
await list()
|
||||
vi.advanceTimersByTime(SCAN_TTL_MS + 1_000)
|
||||
await list()
|
||||
|
||||
expect(listWorktreesStrictMock).not.toHaveBeenCalled()
|
||||
expect(readRepoWorktreeAdminFingerprintMock).not.toHaveBeenCalled()
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('does not extend a failed scan on an unchanged fingerprint', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
listWorktreesStrictMock.mockRejectedValue(new Error('git unavailable'))
|
||||
const { list } = makeRuntime()
|
||||
|
||||
await list()
|
||||
expect(scanCount()).toBe(1)
|
||||
|
||||
vi.advanceTimersByTime(SCAN_TTL_MS + 1_000)
|
||||
await list()
|
||||
expect(scanCount()).toBe(2)
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('bounds Git fan-out under a sustained 1 Hz polling workload', async () => {
|
||||
// Ten idle repos polled faster than snapshot TTL formerly spawned git worktree list every 30s.
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const REPOS = 10
|
||||
const POLL_SECONDS = 30 * 60
|
||||
const { list } = makeRuntime({ repoCount: REPOS })
|
||||
|
||||
for (let second = 0; second < POLL_SECONDS; second += 1) {
|
||||
await list()
|
||||
vi.advanceTimersByTime(1_000)
|
||||
}
|
||||
|
||||
// Reconciliation cuts this half-hour workload from 600 scans to 60.
|
||||
const ttlOnlyScans = (REPOS * POLL_SECONDS * 1_000) / SCAN_TTL_MS
|
||||
const reconcileScans =
|
||||
(REPOS * POLL_SECONDS * 1_000) / WORKTREE_SCAN_ADMIN_RECONCILE_INTERVAL_MS
|
||||
expect(ttlOnlyScans).toBe(600)
|
||||
expect(reconcileScans).toBe(60)
|
||||
expect(scanCount()).toBe(reconcileScans)
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps refreshing when a probe on a wedged filesystem never settles', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
@@ -400,137 +224,6 @@ describe('worktree scan admin-fingerprint gate', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('issues no further probes while one is still outstanding', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
readRepoWorktreeAdminFingerprintMock.mockReturnValue(new Promise<string | null>(() => {}))
|
||||
const { list } = makeRuntime()
|
||||
|
||||
await list()
|
||||
expect(readRepoWorktreeAdminFingerprintMock).toHaveBeenCalledTimes(1)
|
||||
|
||||
const startedAt = Date.now()
|
||||
for (let refresh = 0; refresh < 20; refresh += 1) {
|
||||
vi.advanceTimersByTime(SCAN_TTL_MS + 1_000)
|
||||
await list()
|
||||
}
|
||||
|
||||
// Each abandoned probe can pin a libuv filesystem worker indefinitely.
|
||||
expect(readRepoWorktreeAdminFingerprintMock).toHaveBeenCalledTimes(1)
|
||||
// Only the reconcile ceiling rescans while the probe stays pending, never every refresh.
|
||||
const elapsed = Date.now() - startedAt
|
||||
expect(scanCount()).toBeGreaterThan(1)
|
||||
expect(scanCount()).toBeLessThanOrEqual(
|
||||
1 + Math.ceil(elapsed / WORKTREE_SCAN_ADMIN_RECONCILE_INTERVAL_MS)
|
||||
)
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('resumes gating once the filesystem recovers', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const releaseStalledProbe = stallProbeOnce()
|
||||
const { list } = makeRuntime()
|
||||
|
||||
await list()
|
||||
vi.advanceTimersByTime(SCAN_TTL_MS + 1_000)
|
||||
await list()
|
||||
expect(scanCount()).toBe(1)
|
||||
|
||||
// The mount unwedges; its stale answer must not gate anything, but the repo must probe again.
|
||||
releaseStalledProbe('fp-stale')
|
||||
await drainMicrotasks()
|
||||
vi.advanceTimersByTime(SCAN_TTL_MS + 1_000)
|
||||
await list()
|
||||
expect(scanCount()).toBe(2)
|
||||
|
||||
vi.advanceTimersByTime(SCAN_TTL_MS + 1_000)
|
||||
await list()
|
||||
expect(scanCount()).toBe(2)
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('reuses the scanned rows when the awaited probe outlives its deadline', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const { list } = makeRuntime()
|
||||
|
||||
await list()
|
||||
expect(scanCount()).toBe(1)
|
||||
|
||||
stallProbeOnce()
|
||||
vi.advanceTimersByTime(SCAN_TTL_MS + 1_000)
|
||||
const second = list()
|
||||
const secondSettled = trackSettled(second)
|
||||
await drainMicrotasks()
|
||||
expect(secondSettled()).toBe(false)
|
||||
expect(scanCount()).toBe(1)
|
||||
|
||||
await vi.advanceTimersByTimeAsync(WORKTREE_SCAN_ADMIN_FINGERPRINT_TIMEOUT_MS)
|
||||
await drainMicrotasks()
|
||||
// #19312: a timeout is "unknown", never a mismatch, so it must not start a full scan.
|
||||
expect(scanCount()).toBe(1)
|
||||
expect(heads(await second)).toEqual(['abc', 'def'])
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('still reconciles on schedule while every probe times out', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
readRepoWorktreeAdminFingerprintMock.mockImplementation(() => new Promise(() => {}))
|
||||
const { list } = makeRuntime()
|
||||
const scannedAt = Date.now()
|
||||
await list()
|
||||
expect(scanCount()).toBe(1)
|
||||
|
||||
while (Date.now() - scannedAt < WORKTREE_SCAN_ADMIN_RECONCILE_INTERVAL_MS - SCAN_TTL_MS) {
|
||||
vi.advanceTimersByTime(SCAN_TTL_MS + 1_000)
|
||||
await list()
|
||||
}
|
||||
expect(scanCount()).toBe(1)
|
||||
|
||||
vi.advanceTimersByTime(2 * SCAN_TTL_MS)
|
||||
await list()
|
||||
expect(scanCount()).toBe(2)
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('bounds the awaited probe by the caller per-repo budget', () => {
|
||||
// The wait runs inside that budget, so outlasting it can only strand the caller on persisted rows.
|
||||
expect(WORKTREE_SCAN_ADMIN_FINGERPRINT_TIMEOUT_MS).toBeLessThan(
|
||||
RESOLVED_WORKTREE_REPO_TIMEOUT_MS
|
||||
)
|
||||
})
|
||||
|
||||
it('still reuses the scan when a slow probe answers inside its deadline', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const { list } = makeRuntime()
|
||||
await list()
|
||||
expect(scanCount()).toBe(1)
|
||||
|
||||
const releaseSlowProbe = stallProbeOnce()
|
||||
vi.advanceTimersByTime(SCAN_TTL_MS + 1_000)
|
||||
const second = list()
|
||||
|
||||
// Use an absolute healthy latency so shortening the deadline cannot make a self-referential test pass.
|
||||
await vi.advanceTimersByTimeAsync(SLOW_BUT_HEALTHY_PROBE_MS)
|
||||
releaseSlowProbe('fp-1')
|
||||
await second
|
||||
expect(scanCount()).toBe(1)
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('still returns scanned rows within the per-repo budget when the probe stalls', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
@@ -552,36 +245,6 @@ describe('worktree scan admin-fingerprint gate', () => {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('does not publish an already-expired snapshot after a slow compute', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const { list, store } = makeRuntime()
|
||||
await list()
|
||||
|
||||
stallProbeOnce()
|
||||
vi.advanceTimersByTime(SCAN_TTL_MS + 1_000)
|
||||
const second = list()
|
||||
await vi.advanceTimersByTimeAsync(WORKTREE_SCAN_ADMIN_FINGERPRINT_TIMEOUT_MS)
|
||||
await second
|
||||
|
||||
// Stamp completion time: a probe slower than snapshot TTL otherwise creates an already-expired entry.
|
||||
const getRepos = vi.spyOn(store, 'getRepos')
|
||||
await list()
|
||||
expect(getRepos).not.toHaveBeenCalled()
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('shares one probe and one scan across concurrent callers', async () => {
|
||||
const { list } = makeRuntime()
|
||||
|
||||
await Promise.all([list(), list(), list()])
|
||||
|
||||
expect(scanCount()).toBe(1)
|
||||
expect(readRepoWorktreeAdminFingerprintMock).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('scoped explicit worktree-id resolution', () => {
|
||||
@@ -605,43 +268,6 @@ describe('scoped explicit worktree-id resolution', () => {
|
||||
return listWorktreesStrictMock.mock.calls.map((call) => call[0] as string)
|
||||
}
|
||||
|
||||
it('scans only the owning repo for an id: selector on a cold cache', async () => {
|
||||
const runtime = new OrcaRuntimeService(makeStore({ repoCount: 10 }) as never)
|
||||
const resolve = (selector: string): Promise<{ id: string }> =>
|
||||
runtime['resolveGitWorktreeSelector'](selector)
|
||||
|
||||
const resolved = await resolve(`id:${MAIN_WORKTREE_ID}`)
|
||||
|
||||
expect(resolved.id).toBe(MAIN_WORKTREE_ID)
|
||||
expect(scannedRepoPaths()).toEqual([REPO_PATH])
|
||||
})
|
||||
it('resolves an exact canonical identity without relying on the mutable locator', async () => {
|
||||
const runtime = new OrcaRuntimeService(makeStore({ repoCount: 10 }) as never)
|
||||
const resolve = (selector: string): Promise<{ id: string }> =>
|
||||
runtime['resolveGitWorktreeSelector'](selector)
|
||||
const identityKey = canonicalWorktreeIdentity({
|
||||
worktreeId: MAIN_WORKTREE_ID,
|
||||
executionHostId: 'local',
|
||||
instanceId: '22222222-2222-4222-8222-222222222222'
|
||||
})
|
||||
|
||||
const resolved = await resolve(`identity:${identityKey}`)
|
||||
|
||||
expect(resolved.id).toBe(MAIN_WORKTREE_ID)
|
||||
})
|
||||
|
||||
it('still finds worktrees in other repos through the fleet path', async () => {
|
||||
const runtime = new OrcaRuntimeService(makeStore({ repoCount: 10 }) as never)
|
||||
const resolve = (selector: string): Promise<{ id: string }> =>
|
||||
runtime['resolveGitWorktreeSelector'](selector)
|
||||
|
||||
const otherId = `${REPO_ID}-3::${REPO_PATH}-3`
|
||||
const resolved = await resolve(`id:${otherId}`)
|
||||
|
||||
expect(resolved.id).toBe(otherId)
|
||||
expect(scannedRepoPaths()).toEqual([`${REPO_PATH}-3`])
|
||||
})
|
||||
|
||||
it('keeps cross-repo selectors on the fleet path so ambiguity still throws', async () => {
|
||||
const runtime = new OrcaRuntimeService(makeStore({ repoCount: 10 }) as never)
|
||||
const resolve = (selector: string): Promise<unknown> =>
|
||||
@@ -650,13 +276,4 @@ describe('scoped explicit worktree-id resolution', () => {
|
||||
await expect(resolve('branch:main')).rejects.toThrow('selector_ambiguous')
|
||||
expect(new Set(scannedRepoPaths()).size).toBe(10)
|
||||
})
|
||||
|
||||
it('falls back to the fleet path when the id names no registered repo', async () => {
|
||||
const runtime = new OrcaRuntimeService(makeStore({ repoCount: 10 }) as never)
|
||||
const resolve = (selector: string): Promise<unknown> =>
|
||||
runtime['resolveGitWorktreeSelector'](selector)
|
||||
|
||||
await expect(resolve('id:missing-repo::/nowhere')).rejects.toThrow('selector_not_found')
|
||||
expect(new Set(scannedRepoPaths()).size).toBe(10)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -45,14 +45,6 @@ const MAIN_ROW = {
|
||||
isBare: false,
|
||||
isMainWorktree: true
|
||||
}
|
||||
// Linux paths are case-sensitive: a different spelling is a different checkout.
|
||||
const OTHER_SPELLING_ROW = {
|
||||
path: '/home/me/FileLoc',
|
||||
head: 'def',
|
||||
branch: 'refs/heads/feat',
|
||||
isBare: false,
|
||||
isMainWorktree: false
|
||||
}
|
||||
|
||||
function makeRuntime(options: { connectionId?: string } = {}): OrcaRuntimeService {
|
||||
const metaById: Record<string, WorktreeMeta> = {}
|
||||
@@ -103,33 +95,6 @@ describe('worktree scan with a repeated path', () => {
|
||||
localScanMock.mockReset()
|
||||
})
|
||||
|
||||
describe('rows that agree, as reported', () => {
|
||||
beforeEach(() => {
|
||||
gitLists([MAIN_ROW, { ...MAIN_ROW, isMainWorktree: false }, OTHER_SPELLING_ROW])
|
||||
})
|
||||
|
||||
it('resolves the id the CLI sends for `active` and `current` after a listing', async () => {
|
||||
const runtime = makeRuntime()
|
||||
await runtime.listManagedWorktrees()
|
||||
|
||||
await expect(runtime.showManagedWorktree(`id:${MAIN_WORKTREE_ID}`)).resolves.toMatchObject({
|
||||
id: MAIN_WORKTREE_ID,
|
||||
isMainWorktree: true
|
||||
})
|
||||
})
|
||||
|
||||
it('lists each path once for the CLI and for paired clients', async () => {
|
||||
const runtime = makeRuntime()
|
||||
const expectedIds = [MAIN_WORKTREE_ID, `${REPO_ID}::/home/me/FileLoc`]
|
||||
|
||||
const listed = await runtime.listManagedWorktrees()
|
||||
expect(listed.worktrees.map((worktree) => worktree.id)).toEqual(expectedIds)
|
||||
|
||||
const detected = await runtime.listDetectedManagedWorktrees(`id:${REPO_ID}`)
|
||||
expect(detected.worktrees.map((worktree) => worktree.id)).toEqual(expectedIds)
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps the main checkout over a stale registration on another branch', async () => {
|
||||
gitLists([
|
||||
MAIN_ROW,
|
||||
|
||||
@@ -156,18 +156,4 @@ describe('runtime creation registry', () => {
|
||||
).rejects.toThrow('could not create')
|
||||
expect(creationsOf(runtime)).toEqual({})
|
||||
})
|
||||
|
||||
it('lists a folder workspace create too', async () => {
|
||||
const runtime = makeRuntime({ id: 'folder', path: '/notes', kind: 'folder' })
|
||||
let during: Record<string, unknown> = {}
|
||||
mocks.createFolder.mockReset().mockImplementation(async () => {
|
||||
during = creationsOf(runtime)
|
||||
return { worktree: { id: 'folder::/notes' } }
|
||||
})
|
||||
|
||||
await runtime.createManagedWorktree({ repoSelector: 'folder', name: 'notes' })
|
||||
|
||||
expect(Object.values(during)).toEqual([{ repoId: 'folder' }])
|
||||
expect(creationsOf(runtime)).toEqual({})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -47,11 +47,7 @@ vi.mock('../../../ipc/worktrees/removal/registered-worktree-roots-scoped-invalid
|
||||
import { OrcaRuntimeService } from '../../orca-runtime'
|
||||
import type { WorktreeCreateTimingRecorder } from '../../../workspaces/creation/worktree-create-timing'
|
||||
import type { PreparationRearmHolder } from '../../../workspaces/creation/preparation/worktree-create-preparation'
|
||||
import {
|
||||
_resetWorktreeCreateConcurrencyForTests,
|
||||
beginPreparationWork,
|
||||
type PreparationWork
|
||||
} from '../../../workspaces/creation/worktree-create-concurrency'
|
||||
import { _resetWorktreeCreateConcurrencyForTests } from '../../../workspaces/creation/worktree-create-concurrency'
|
||||
import { partialRuntimeStore } from '../../orca-runtime-tests/runtime-store-partial-fixture'
|
||||
|
||||
const gitRepo = {
|
||||
@@ -163,24 +159,6 @@ describe('runtime create events', () => {
|
||||
expect(events('workspace_created')).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('counts preparation work running during the create but not the re-arm the create starts', async () => {
|
||||
let competing: PreparationWork | undefined
|
||||
mocks.createLocal.mockImplementation(async ({ rearm }: LocalCreateArgs) => {
|
||||
competing = beginPreparationWork()
|
||||
rearm.fire = () => {
|
||||
beginPreparationWork()
|
||||
beginPreparationWork()
|
||||
}
|
||||
return localCreateResult()
|
||||
})
|
||||
|
||||
await makeRuntime().createManagedWorktree({ repoSelector: 'repo-1', name: 'app' })
|
||||
competing?.end()
|
||||
await vi.waitFor(() => expect(events('workspace_created')).toHaveLength(1))
|
||||
|
||||
expect(events('workspace_created')[0]).toMatchObject({ concurrent_preparations: 1 })
|
||||
})
|
||||
|
||||
it('still returns the create when sending its event throws', async () => {
|
||||
vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
mocks.track.mockImplementation(() => {
|
||||
@@ -205,36 +183,4 @@ describe('runtime create events', () => {
|
||||
).rejects.toThrow('could not create work tree')
|
||||
expect(mocks.track).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('refuses an SSH create and still names its host on the failed event', async () => {
|
||||
const runtime = makeRuntime({
|
||||
id: 'repo-remote',
|
||||
path: '/srv/app',
|
||||
kind: 'git',
|
||||
executionHostId: 'ssh:remote-1'
|
||||
})
|
||||
|
||||
await expect(
|
||||
runtime.createManagedWorktree({ repoSelector: 'repo-remote', name: 'app' })
|
||||
).rejects.toThrow('Remote connection dropped')
|
||||
await vi.waitFor(() => expect(events('workspace_create_failed')).toHaveLength(1))
|
||||
|
||||
expect(events('workspace_create_failed')[0]).toMatchObject({
|
||||
create_entry_point: 'runtime',
|
||||
execution_host: 'ssh'
|
||||
})
|
||||
expect(mocks.probe).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('sends nothing when the request is rejected before any create starts', async () => {
|
||||
await expect(
|
||||
makeRuntime().createManagedWorktree({
|
||||
repoSelector: 'repo-1',
|
||||
name: 'app',
|
||||
startupAgent: 'codex'
|
||||
})
|
||||
).rejects.toThrow('Selected agent is disabled')
|
||||
expect(mocks.createLocal).not.toHaveBeenCalled()
|
||||
expect(mocks.track).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,98 +0,0 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type * as WorktreeCreatePreparation from '../../../workspaces/creation/preparation/worktree-create-preparation'
|
||||
import type { Project } from '../../../../shared/project-types'
|
||||
import type { Repo } from '../../../../shared/repo-types'
|
||||
import {
|
||||
_resetWslCachesForTests,
|
||||
_setWslCachesForTests
|
||||
} from '../../../wsl/discovery/distro-catalog'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
prefetchWorktreeCreateBase: vi.fn(),
|
||||
prepareWorktreeCreateForRepo: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('../../../workspaces/creation/worktree-create-base-prefetch', () => ({
|
||||
prefetchWorktreeCreateBase: mocks.prefetchWorktreeCreateBase
|
||||
}))
|
||||
vi.mock(
|
||||
'../../../workspaces/creation/preparation/worktree-create-preparation',
|
||||
async (importOriginal) => ({
|
||||
...(await importOriginal<typeof WorktreeCreatePreparation>()),
|
||||
prepareWorktreeCreateForRepo: mocks.prepareWorktreeCreateForRepo
|
||||
})
|
||||
)
|
||||
|
||||
import { OrcaRuntimeService } from '../../orca-runtime'
|
||||
|
||||
const repo: Repo = {
|
||||
id: 'repo-1',
|
||||
displayName: 'Repo',
|
||||
path: String.raw`C:\workspace\repo`,
|
||||
badgeColor: '#000000',
|
||||
addedAt: 0
|
||||
}
|
||||
|
||||
const project: Project = {
|
||||
id: 'project-1',
|
||||
displayName: 'Project',
|
||||
badgeColor: '#000000',
|
||||
sourceRepoIds: ['repo-1'],
|
||||
createdAt: 0,
|
||||
updatedAt: 0,
|
||||
localWindowsRuntimePreference: { kind: 'wsl', distro: 'Ubuntu' }
|
||||
}
|
||||
|
||||
function makeStore(overrides: Partial<Project> = {}): unknown {
|
||||
return {
|
||||
getRepos: () => [repo],
|
||||
getRepo: (id: string) => (id === repo.id ? repo : undefined),
|
||||
getProjects: () => [{ ...project, ...overrides }],
|
||||
getSettings: () => ({ localWindowsRuntimeDefault: { kind: 'windows-host' } })
|
||||
}
|
||||
}
|
||||
|
||||
function setPlatform(platform: NodeJS.Platform): void {
|
||||
Object.defineProperty(process, 'platform', { configurable: true, value: platform })
|
||||
}
|
||||
|
||||
const hostPlatform = process.platform
|
||||
|
||||
beforeEach(() => {
|
||||
mocks.prefetchWorktreeCreateBase.mockReset().mockResolvedValue(undefined)
|
||||
mocks.prepareWorktreeCreateForRepo.mockReset().mockResolvedValue(undefined)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
setPlatform(hostPlatform)
|
||||
_resetWslCachesForTests()
|
||||
})
|
||||
|
||||
// RPC prefetch must match IPC project routing; construct through the barrel for later selector subclasses.
|
||||
describe('prefetchManagedWorktreeCreateBase (orca-runtime-get-worktree-terminal-provisioning-host)', () => {
|
||||
it('warms up in the distro a WSL-routed project runs in', async () => {
|
||||
_setWslCachesForTests({ available: true, distros: ['Ubuntu'] })
|
||||
setPlatform('win32')
|
||||
const runtime = new OrcaRuntimeService(makeStore() as never)
|
||||
|
||||
await runtime.prefetchManagedWorktreeCreateBase({ repoSelector: 'repo-1' })
|
||||
|
||||
expect(mocks.prefetchWorktreeCreateBase).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ gitOptions: { wslDistro: 'Ubuntu' } })
|
||||
)
|
||||
})
|
||||
|
||||
it('does not surface a repair-required project runtime as a prefetch failure', async () => {
|
||||
_setWslCachesForTests({ available: true, distros: ['Debian'] })
|
||||
setPlatform('win32')
|
||||
const runtime = new OrcaRuntimeService(makeStore() as never)
|
||||
|
||||
await expect(
|
||||
runtime.prefetchManagedWorktreeCreateBase({ repoSelector: 'repo-1' })
|
||||
).resolves.toBeUndefined()
|
||||
|
||||
expect(mocks.prefetchWorktreeCreateBase).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ gitOptions: {} })
|
||||
)
|
||||
})
|
||||
})
|
||||
+1
-104
@@ -13,9 +13,7 @@ import { listWorktrees } from '../../../git/worktrees/catalog/worktree-scan-cach
|
||||
import { shouldRunSetupForCreate } from '../../../workspace-hooks/effective-hook-config'
|
||||
import { store } from '../../orca-runtime-tests/orca-runtime-test-fixtures.spec'
|
||||
import { createMobileCreateTestNotifier } from '../../orca-runtime-tests/orca-runtime-test-scenario-builders.spec'
|
||||
import { tmpdir } from 'node:os'
|
||||
import type { Worktree } from '../../../../shared/worktree/types'
|
||||
import type { RuntimePtyController } from '../../runtime-pty-controller-contract'
|
||||
import 'node:os'
|
||||
|
||||
describe('host worktree creation while its renderer is unavailable', () => {
|
||||
it.each([
|
||||
@@ -96,104 +94,3 @@ describe('host worktree creation while its renderer is unavailable', () => {
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
describe('startup hosts retain current runtime capabilities', () => {
|
||||
it('refuses base prefetch without a runtime store', async () => {
|
||||
const runtime = new OrcaRuntimeService(null)
|
||||
await expect(
|
||||
runtime.prefetchManagedWorktreeCreateBase({ repoSelector: 'id:missing' })
|
||||
).rejects.toThrow('runtime_unavailable')
|
||||
})
|
||||
|
||||
it('reads the current create override with the captured worktree and three arguments', async () => {
|
||||
const runtime = new OrcaRuntimeService(store)
|
||||
onTestFinished(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
const created: Worktree = {
|
||||
id: 'created-worktree',
|
||||
repoId: 'repo-1',
|
||||
displayName: 'Created',
|
||||
comment: '',
|
||||
linkedIssue: null,
|
||||
linkedPR: null,
|
||||
linkedLinearIssue: null,
|
||||
isArchived: false,
|
||||
isUnread: false,
|
||||
isPinned: false,
|
||||
sortOrder: 0,
|
||||
lastActivityAt: 0,
|
||||
path: tmpdir(),
|
||||
head: 'abc',
|
||||
branch: 'created',
|
||||
isBare: false,
|
||||
isMainWorktree: false
|
||||
}
|
||||
const host = runtime['getWorktreeTerminalProvisioningHost'](created)
|
||||
const options = { command: 'echo startup' }
|
||||
const create = vi.spyOn(runtime, 'createTerminal').mockImplementation(async function (
|
||||
this: OrcaRuntimeService,
|
||||
selector,
|
||||
input,
|
||||
evidence
|
||||
) {
|
||||
expect(this).toBe(runtime)
|
||||
expect(arguments.length).toBe(3)
|
||||
expect([selector, input, evidence]).toEqual(['id:created-worktree', options, created])
|
||||
expect(evidence).toBe(created)
|
||||
return { handle: 'created-handle', worktreeId: created.id, title: null }
|
||||
})
|
||||
await host.createTerminal('id:created-worktree', options)
|
||||
expect(create).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('reads replacement providers and preserves readiness failures after removal', async () => {
|
||||
const runtime = new OrcaRuntimeService(store)
|
||||
const host = runtime['getWorktreeStartupReadinessHost']()
|
||||
const hasChildProcesses = host.hasChildProcesses
|
||||
if (!hasChildProcesses) {
|
||||
throw new Error('missing readiness child-process callback')
|
||||
}
|
||||
const provisioning = runtime['getWorktreeTerminalProvisioningHost']()
|
||||
expect(provisioning.canSpawn()).toBe(false)
|
||||
const controller: RuntimePtyController = {
|
||||
write: () => true,
|
||||
kill: () => true,
|
||||
getForegroundProcess: vi.fn(async function (this: RuntimePtyController, ptyId: string) {
|
||||
expect(this).toBe(controller)
|
||||
expect(arguments.length).toBe(1)
|
||||
expect(ptyId).toBe('startup-pty')
|
||||
return 'late provider'
|
||||
})
|
||||
}
|
||||
runtime.setPtyController(controller)
|
||||
await expect(host.getForegroundProcess('startup-pty')).resolves.toBe('late provider')
|
||||
await expect(hasChildProcesses('startup-pty')).resolves.toBe(false)
|
||||
const replacement: RuntimePtyController = {
|
||||
write: () => true,
|
||||
kill: () => true,
|
||||
spawn: async () => ({ id: 'spawned' }),
|
||||
getForegroundProcess: async () => 'replacement provider',
|
||||
hasChildProcesses: async () => true
|
||||
}
|
||||
runtime.setPtyController(replacement)
|
||||
expect(provisioning.canSpawn()).toBe(true)
|
||||
await expect(host.getForegroundProcess('startup-pty')).resolves.toBe('replacement provider')
|
||||
await expect(hasChildProcesses('startup-pty')).resolves.toBe(true)
|
||||
runtime.setPtyController(null)
|
||||
expect(provisioning.canSpawn()).toBe(false)
|
||||
expect(() => host.getForegroundProcess('startup-pty')).toThrow(TypeError)
|
||||
expect(() => hasChildProcesses('startup-pty')).toThrow(TypeError)
|
||||
})
|
||||
|
||||
it('writes the canonical setup-token map when invoked', () => {
|
||||
const runtime = new OrcaRuntimeService(store)
|
||||
const host = runtime['getWorktreeTerminalProvisioningHost']()
|
||||
const original = runtime['setupCompletionTokenByPtyId']
|
||||
const replacement = new Map<string, string>()
|
||||
Object.defineProperty(runtime, 'setupCompletionTokenByPtyId', { value: replacement })
|
||||
host.recordSetupCompletionToken?.('startup-pty', 'late-token')
|
||||
expect(replacement.get('startup-pty')).toBe('late-token')
|
||||
expect(original.has('startup-pty')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
+1
-161
@@ -1,10 +1,8 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { resolve } from 'node:path'
|
||||
import 'node:path'
|
||||
|
||||
const {
|
||||
runtimeCreateMocks: mocks,
|
||||
worktreePath,
|
||||
pickedBranch,
|
||||
createWorktree,
|
||||
resetRuntimeCreateMocks
|
||||
} = await vi.hoisted(async () => await import('./runtime-local-worktree-create-test-harness'))
|
||||
@@ -94,142 +92,6 @@ describe('runtime create PR conflict probe', () => {
|
||||
mocks.branchName.mockImplementation(async (_path, override, name: string) => override ?? name)
|
||||
})
|
||||
|
||||
it.each([1, 10])(
|
||||
'keeps the selected name and source through %i occupied paths, without PR eligibility',
|
||||
async (occupied) => {
|
||||
const options = { wslDistro: 'Ubuntu' }
|
||||
mocks.routing.mockReturnValue(options)
|
||||
mocks.remoteBase.mockResolvedValue(remoteTrackingBase)
|
||||
mocks.githubPr.mockResolvedValue({ number: 42 })
|
||||
let pathChecks = 0
|
||||
mocks.pathExists.mockImplementation(async () => pathChecks++ < occupied)
|
||||
|
||||
await createWorktree({ ...pickedBranch, sparseCheckout: { directories: ['src'] } })
|
||||
|
||||
expect(mocks.remoteBase.mock.calls).toEqual([['/repo', 'origin/main', options]])
|
||||
expect(mocks.branchName).toHaveBeenCalledOnce()
|
||||
expect(mocks.branchConflict.mock.calls).toEqual([
|
||||
['/repo', 'main', remoteTrackingBase.ref, options]
|
||||
])
|
||||
expect(mocks.pathExists).toHaveBeenCalledTimes(occupied + 1)
|
||||
expect(mocks.githubPr).not.toHaveBeenCalled()
|
||||
expect(mocks.consume).not.toHaveBeenCalled()
|
||||
expect(mocks.addSparse).toHaveBeenCalledExactlyOnceWith(
|
||||
'/repo',
|
||||
resolve('/worktrees', `app-${occupied + 1}`),
|
||||
'main',
|
||||
['src'],
|
||||
remoteTrackingBase.base,
|
||||
false,
|
||||
expect.objectContaining({ remoteTrackingBase, trackRemoteBranch: true, ...options })
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
it.each([true, false])(
|
||||
'keeps local eligibility with picker intent (reusable: %s)',
|
||||
async (reusable) => {
|
||||
mocks.branchConflict.mockImplementation(async (_path, branch: string) =>
|
||||
branch === 'main' ? 'local' : null
|
||||
)
|
||||
mocks.canCheckout.mockImplementation(
|
||||
async (_path, branch: string) => reusable && branch === 'main'
|
||||
)
|
||||
mocks.pathExists.mockImplementation(async (path) => path === worktreePath)
|
||||
mocks.consume.mockResolvedValue({ status: 'miss', reason: 'not_prepared' })
|
||||
|
||||
if (!reusable) {
|
||||
await expect(createWorktree(pickedBranch)).rejects.toThrow('was not found locally')
|
||||
expect(mocks.add).not.toHaveBeenCalled()
|
||||
expect(mocks.consume).not.toHaveBeenCalled()
|
||||
return
|
||||
}
|
||||
await createWorktree(pickedBranch)
|
||||
expect(mocks.add).toHaveBeenCalledWith(
|
||||
'/repo',
|
||||
resolve('/worktrees', 'app-2'),
|
||||
'main',
|
||||
'origin/main',
|
||||
false,
|
||||
false,
|
||||
expect.objectContaining({ checkoutExistingBranch: true })
|
||||
)
|
||||
expect(mocks.githubPr).not.toHaveBeenCalled()
|
||||
}
|
||||
)
|
||||
|
||||
it('keeps unflagged legacy, mobile New branch and Advanced collisions on baseline allocation', async () => {
|
||||
mocks.consume.mockResolvedValue({ status: 'miss', reason: 'not_prepared' })
|
||||
mocks.branchName.mockImplementation(async (_path, override, name: string) => override ?? name)
|
||||
mocks.branchConflict.mockImplementation(async (_path, branch: string) =>
|
||||
branch === 'feature/test' ? 'remote' : null
|
||||
)
|
||||
|
||||
await createWorktree({ branchNameOverride: 'feature/test', baseBranch: 'origin/main' })
|
||||
|
||||
expect(mocks.add.mock.calls[0][2]).toBe('feature/test-2')
|
||||
})
|
||||
|
||||
it('runs the first name PR lookup while the base fetch is still in flight', async () => {
|
||||
mocks.remoteBase.mockResolvedValue(remoteTrackingBase)
|
||||
let finishFetch!: () => void
|
||||
mocks.refresh.mockReturnValue(
|
||||
new Promise((resolve) => {
|
||||
finishFetch = () => resolve({ ok: true })
|
||||
})
|
||||
)
|
||||
let fetchPendingAtLookup = false
|
||||
mocks.githubPr.mockImplementation(async () => {
|
||||
fetchPendingAtLookup = mocks.refresh.mock.calls.length === 1
|
||||
finishFetch?.()
|
||||
return null
|
||||
})
|
||||
|
||||
await createWorktree()
|
||||
|
||||
expect(fetchPendingAtLookup).toBe(true)
|
||||
expect(mocks.githubPr).toHaveBeenCalledWith('/repo', 'app', {})
|
||||
expect(mocks.consume).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
branch: 'app',
|
||||
options: expect.objectContaining({ remoteTrackingBase })
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('suffixes past a PR on the first name', async () => {
|
||||
mocks.githubPr.mockResolvedValueOnce({ number: 7, state: 'closed' }).mockResolvedValue(null)
|
||||
|
||||
await createWorktree()
|
||||
|
||||
expect(mocks.githubPr.mock.calls.map(([, branch]) => branch)).toEqual(['app', 'app-2'])
|
||||
expect(mocks.consume).toHaveBeenCalledWith(expect.objectContaining({ branch: 'app-2' }))
|
||||
})
|
||||
|
||||
it.each([undefined, 'Ubuntu'])(
|
||||
'probes the next name for a PR once the first name collides, routed for %s',
|
||||
async (wslDistro) => {
|
||||
const routing = wslDistro ? { wslDistro } : {}
|
||||
mocks.routing.mockReturnValue(routing)
|
||||
mocks.branchConflict.mockResolvedValueOnce('local').mockResolvedValue(null)
|
||||
|
||||
await createWorktree()
|
||||
|
||||
expect(mocks.githubPr).toHaveBeenCalledOnce()
|
||||
expect(mocks.githubPr).toHaveBeenCalledWith('/repo', 'app-2', routing)
|
||||
}
|
||||
)
|
||||
|
||||
it('skips a later name that already has a PR', async () => {
|
||||
mocks.branchConflict.mockResolvedValueOnce('local').mockResolvedValue(null)
|
||||
mocks.githubPr.mockResolvedValueOnce({ number: 7, state: 'closed' }).mockResolvedValue(null)
|
||||
|
||||
await createWorktree()
|
||||
|
||||
expect(mocks.githubPr.mock.calls.map(([, branch]) => branch)).toEqual(['app-2', 'app-3'])
|
||||
expect(mocks.consume).toHaveBeenCalledWith(expect.objectContaining({ branch: 'app-3' }))
|
||||
})
|
||||
|
||||
it('reports the naming failure when the started base fetch also fails', async () => {
|
||||
mocks.remoteBase.mockResolvedValue(remoteTrackingBase)
|
||||
mocks.refresh.mockRejectedValue(new Error('offline'))
|
||||
@@ -247,28 +109,6 @@ describe('runtime create PR conflict probe', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('runs the first name PR lookup while the origin fetch is still in flight', async () => {
|
||||
mocks.hasBase.mockResolvedValue(false)
|
||||
let finishFetch!: () => void
|
||||
mocks.fetch.mockReturnValue(
|
||||
new Promise<void>((resolve) => {
|
||||
finishFetch = resolve
|
||||
})
|
||||
)
|
||||
let fetchPendingAtLookup = false
|
||||
mocks.githubPr.mockImplementation(async () => {
|
||||
fetchPendingAtLookup = mocks.fetch.mock.calls.length === 1
|
||||
finishFetch?.()
|
||||
return null
|
||||
})
|
||||
|
||||
await createWorktree()
|
||||
|
||||
expect(fetchPendingAtLookup).toBe(true)
|
||||
expect(mocks.fetch).toHaveBeenCalledWith('/repo', 'origin', {})
|
||||
expect(mocks.consume).toHaveBeenCalledWith(expect.objectContaining({ branch: 'app' }))
|
||||
})
|
||||
|
||||
it('reports the naming failure when the started origin fetch also fails', async () => {
|
||||
mocks.hasBase.mockResolvedValue(false)
|
||||
mocks.fetch.mockRejectedValue(new Error('offline'))
|
||||
|
||||
-427
@@ -1,427 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { resolve } from 'node:path'
|
||||
import type { Store } from '../../../persistence/loading-store/store'
|
||||
import type { WorktreeMeta } from '../../../../shared/worktree/meta-types'
|
||||
import type { HostWorktreeTarget } from '../../../../shared/worktree/host-qualified-identity'
|
||||
import type { Repo } from '../../../../shared/repo-types'
|
||||
import type { RuntimeManagedWorktreeCreateArgs } from './runtime-managed-worktree-create-types'
|
||||
import { resolveGitAdmissionTier } from '../../../git/command-runner/git-operation-executor'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
git: vi.fn(),
|
||||
rearm: vi.fn(),
|
||||
routing: vi.fn<() => { wslDistro?: string }>(),
|
||||
defaultBase: vi.fn(),
|
||||
hasBase: vi.fn(),
|
||||
branchName: vi.fn(),
|
||||
canCheckout: vi.fn(),
|
||||
pathExists: vi.fn(),
|
||||
branchConflict: vi.fn(),
|
||||
githubPr: vi.fn(),
|
||||
consume: vi.fn(),
|
||||
add: vi.fn(),
|
||||
addSparse: vi.fn(),
|
||||
pushTarget: vi.fn(),
|
||||
listing: vi.fn(),
|
||||
remoteBase: vi.fn(),
|
||||
hasRemoteRef: vi.fn(),
|
||||
refresh: vi.fn(),
|
||||
fetch: vi.fn(),
|
||||
resolveShared: vi.fn<() => Promise<string[]>>(),
|
||||
resolveInclude: vi.fn<() => Promise<string[]>>(),
|
||||
copyPaths: vi.fn<() => Promise<string[]>>(),
|
||||
effectiveHooks: vi.fn(),
|
||||
created: {
|
||||
path: '',
|
||||
head: 'abc123',
|
||||
branch: 'app',
|
||||
isBare: false,
|
||||
isMainWorktree: false
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('../../../text-generation/commit-message-text-generation', () => ({
|
||||
resolveTextGenerationParams: () => ({
|
||||
ok: true,
|
||||
params: { agentId: 'claude', model: 'fixture' }
|
||||
}),
|
||||
generateBranchNameFromContext: async () => ({ success: true, slug: 'fix-auth' })
|
||||
}))
|
||||
vi.mock('../../../text-generation/commit-message-agent-environment', () => ({
|
||||
prepareLocalCommitMessageAgentEnv: async () => ({ ok: true })
|
||||
}))
|
||||
|
||||
vi.mock('../../../git/command-runner/git-exec-file', async (importOriginal) => ({
|
||||
...(await importOriginal<Record<string, unknown>>()),
|
||||
gitExecFileAsync: mocks.git
|
||||
}))
|
||||
vi.mock('../../../workspaces/execution-host/project-runtime-git-options', () => ({
|
||||
getLocalProjectGitExecOptions: () => ({ cwd: '/repo', ...mocks.routing() }),
|
||||
getLocalProjectWorktreeGitOptions: mocks.routing,
|
||||
getWorktreeMirrorDistro: () => undefined
|
||||
}))
|
||||
vi.mock('../../../git/repo-default-base-ref', () => ({
|
||||
getBaseRefDefault: mocks.defaultBase,
|
||||
resolveDefaultBaseRefWithLocalGit: mocks.defaultBase
|
||||
}))
|
||||
vi.mock('../../../git/repo-branch-conflict', () => ({
|
||||
getBranchConflictKind: mocks.branchConflict
|
||||
}))
|
||||
vi.mock('../../../git/git-username', () => ({ resolveLocalGitUsername: async () => '' }))
|
||||
vi.mock('../../../workspace-hooks/hooks', () => ({ getEffectiveHooks: mocks.effectiveHooks }))
|
||||
vi.mock('../../../git/worktrees/creation/worktree-base-ref-probe', () => ({
|
||||
hasLocalWorktreeBaseRef: mocks.hasBase
|
||||
}))
|
||||
vi.mock('./runtime-worktree-create-git', () => ({
|
||||
resolveCreateBranchName: mocks.branchName,
|
||||
canCheckoutExistingLocalBranch: mocks.canCheckout,
|
||||
getLocalGitHubPrForBranch: mocks.githubPr,
|
||||
getSelectedHostedReviewForBranch: vi.fn()
|
||||
}))
|
||||
vi.mock('../catalog/runtime-worktree-filesystem', () => ({ runtimePathExists: mocks.pathExists }))
|
||||
vi.mock('../../../workspaces/creation/preparation/worktree-create-preparation', () => ({
|
||||
consumePreparedWorktreeCreate: mocks.consume
|
||||
}))
|
||||
vi.mock('../../../git/worktrees/creation/worktree-add', () => ({
|
||||
addWorktree: mocks.add
|
||||
}))
|
||||
|
||||
vi.mock('../../../git/worktrees/creation/worktree-sparse-add', () => ({
|
||||
addSparseWorktree: mocks.addSparse
|
||||
}))
|
||||
vi.mock('../../../ipc/worktree-remote', () => ({
|
||||
configureCreatedWorktreePushTarget: mocks.pushTarget
|
||||
}))
|
||||
vi.mock('../../../ipc/created-worktree-reconciliation', () => ({
|
||||
resolveCreatedWorktree: mocks.listing
|
||||
}))
|
||||
vi.mock('../../../workspaces/removal/retirement/worktree-name-retirement', () => ({
|
||||
failedWorktreeCreationNeedsRetirement: vi.fn(),
|
||||
retireGeneratedWorktreeName: vi.fn()
|
||||
}))
|
||||
vi.mock('../../../git/worktrees/content-paths/worktree-shared-directories', () => ({
|
||||
resolveWorktreeSharedDirectories: mocks.resolveShared
|
||||
}))
|
||||
vi.mock('../../../git/worktrees/content-paths/worktree-include-file', () => ({
|
||||
resolveWorktreeIncludePaths: mocks.resolveInclude
|
||||
}))
|
||||
vi.mock('../../../ipc/worktree-symlinks', () => ({
|
||||
createWorktreeCopiedPaths: mocks.copyPaths,
|
||||
createWorktreeLinkedPaths: vi.fn(),
|
||||
createWorktreeSharedPaths: vi.fn()
|
||||
}))
|
||||
|
||||
import { createRuntimeLocalManagedWorktree } from './runtime-local-worktree-create'
|
||||
import type { PreparationRearmHolder } from '../../../workspaces/creation/preparation/worktree-create-preparation'
|
||||
import {
|
||||
createWorktreeCreateTimingRecorder,
|
||||
type WorktreeCreateTimingRecorder
|
||||
} from '../../../workspaces/creation/worktree-create-timing'
|
||||
import type { SparsePreset } from '../../../../shared/worktree/create-types'
|
||||
|
||||
const worktreePath = resolve('/worktrees', 'app')
|
||||
|
||||
const pickedBranch = {
|
||||
baseBranch: 'origin/main',
|
||||
branchNameOverride: 'main',
|
||||
branchIntent: 'open-existing-v2'
|
||||
} as const
|
||||
|
||||
const writtenMeta: Partial<WorktreeMeta>[] = []
|
||||
const WEB_PRESET: SparsePreset = {
|
||||
id: 'preset-web',
|
||||
repoId: 'repo-1',
|
||||
name: 'web',
|
||||
directories: ['apps/web', 'packages/ui'],
|
||||
createdAt: 0,
|
||||
updatedAt: 0
|
||||
}
|
||||
const readSparsePresets = vi.fn<(repoId: string) => SparsePreset[]>()
|
||||
|
||||
function createWorktree(
|
||||
request: Partial<RuntimeManagedWorktreeCreateArgs> = {},
|
||||
rearm: PreparationRearmHolder = { fire: () => {} },
|
||||
timing: WorktreeCreateTimingRecorder = createWorktreeCreateTimingRecorder(),
|
||||
repoOverrides: Partial<Repo> = {},
|
||||
autoRenameBranchFromWork = false
|
||||
) {
|
||||
const store = {
|
||||
getSettings: () => ({
|
||||
workspaceDir: '/worktrees',
|
||||
nestWorkspaces: false,
|
||||
refreshLocalBaseRefOnWorktreeCreate: false,
|
||||
branchPrefix: '',
|
||||
autoRenameBranchFromWork
|
||||
}),
|
||||
setWorktreeMeta: (_target: HostWorktreeTarget, updates: Partial<WorktreeMeta>) => {
|
||||
writtenMeta.push(updates)
|
||||
return updates
|
||||
},
|
||||
getSparsePresets: (repoId: string): SparsePreset[] => readSparsePresets(repoId)
|
||||
}
|
||||
return createRuntimeLocalManagedWorktree({
|
||||
request: { repoSelector: 'repo-1', name: 'app', baseBranch: 'main', ...request },
|
||||
repo: {
|
||||
id: 'repo-1',
|
||||
path: '/repo',
|
||||
displayName: 'Repo',
|
||||
badgeColor: '#000000',
|
||||
addedAt: 0,
|
||||
...repoOverrides
|
||||
},
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: All store methods reached by this isolated create path are supplied above.
|
||||
store: store as Store,
|
||||
createdWithAgent: undefined,
|
||||
resolveRemoteTrackingBase: mocks.remoteBase,
|
||||
hasRemoteTrackingRef: mocks.hasRemoteRef,
|
||||
// vi.fn marks returned promises handled; a fresh promise exposes unawaited fetch rejection.
|
||||
refreshRemoteTrackingBase: async (...refreshArgs) => await mocks.refresh(...refreshArgs),
|
||||
fetchRemote: async (...fetchArgs) => await mocks.fetch(...fetchArgs),
|
||||
onWorktreeMetadataPersisted: () => undefined,
|
||||
rearm,
|
||||
timing
|
||||
})
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks()
|
||||
writtenMeta.length = 0
|
||||
mocks.created.branch = 'app'
|
||||
mocks.git.mockResolvedValue({ stdout: '', stderr: '' })
|
||||
mocks.created.path = worktreePath
|
||||
mocks.routing.mockReturnValue({})
|
||||
mocks.defaultBase.mockImplementation(async () => {
|
||||
expect(resolveGitAdmissionTier()).toBe('interactive')
|
||||
return 'main'
|
||||
})
|
||||
mocks.hasBase.mockResolvedValue(true)
|
||||
mocks.branchName.mockResolvedValue('app')
|
||||
mocks.canCheckout.mockResolvedValue(false)
|
||||
mocks.pathExists.mockResolvedValue(false)
|
||||
mocks.branchConflict.mockResolvedValue(null)
|
||||
mocks.githubPr.mockResolvedValue(null)
|
||||
mocks.consume.mockResolvedValue({ status: 'hit', result: {}, rearm: mocks.rearm })
|
||||
mocks.add.mockResolvedValue({})
|
||||
mocks.addSparse.mockResolvedValue({})
|
||||
mocks.listing.mockImplementation(async () => {
|
||||
expect(resolveGitAdmissionTier()).toBe('interactive')
|
||||
return { created: mocks.created }
|
||||
})
|
||||
mocks.remoteBase.mockImplementation(async () => {
|
||||
expect(resolveGitAdmissionTier()).toBe('interactive')
|
||||
return null
|
||||
})
|
||||
mocks.hasRemoteRef.mockResolvedValue(true)
|
||||
mocks.refresh.mockResolvedValue({ ok: true })
|
||||
mocks.fetch.mockResolvedValue(undefined)
|
||||
mocks.resolveShared.mockResolvedValue([])
|
||||
mocks.resolveInclude.mockResolvedValue(['.env'])
|
||||
mocks.copyPaths.mockResolvedValue([])
|
||||
mocks.effectiveHooks.mockReturnValue(null)
|
||||
readSparsePresets.mockReturnValue([WEB_PRESET])
|
||||
})
|
||||
|
||||
describe('runtime create PR conflict probe', () => {
|
||||
const remoteTrackingBase = {
|
||||
remote: 'origin',
|
||||
branch: 'main',
|
||||
base: 'origin/main',
|
||||
ref: 'refs/remotes/origin/main'
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
mocks.branchName.mockImplementation(async (_path, override, name: string) => override ?? name)
|
||||
})
|
||||
|
||||
it.each([1, 10])(
|
||||
'keeps the selected name and source through %i occupied paths, without PR eligibility',
|
||||
async (occupied) => {
|
||||
const options = { wslDistro: 'Ubuntu' }
|
||||
mocks.routing.mockReturnValue(options)
|
||||
mocks.remoteBase.mockResolvedValue(remoteTrackingBase)
|
||||
mocks.githubPr.mockResolvedValue({ number: 42 })
|
||||
let pathChecks = 0
|
||||
mocks.pathExists.mockImplementation(async () => pathChecks++ < occupied)
|
||||
|
||||
await createWorktree({ ...pickedBranch, sparseCheckout: { directories: ['src'] } })
|
||||
|
||||
expect(mocks.remoteBase.mock.calls).toEqual([['/repo', 'origin/main', options]])
|
||||
expect(mocks.branchName).toHaveBeenCalledOnce()
|
||||
expect(mocks.branchConflict.mock.calls).toEqual([
|
||||
['/repo', 'main', remoteTrackingBase.ref, options]
|
||||
])
|
||||
expect(mocks.pathExists).toHaveBeenCalledTimes(occupied + 1)
|
||||
expect(mocks.githubPr).not.toHaveBeenCalled()
|
||||
expect(mocks.consume).not.toHaveBeenCalled()
|
||||
expect(mocks.addSparse).toHaveBeenCalledExactlyOnceWith(
|
||||
'/repo',
|
||||
resolve('/worktrees', `app-${occupied + 1}`),
|
||||
'main',
|
||||
['src'],
|
||||
remoteTrackingBase.base,
|
||||
false,
|
||||
expect.objectContaining({ remoteTrackingBase, trackRemoteBranch: true, ...options })
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
it.each([true, false])(
|
||||
'keeps local eligibility with picker intent (reusable: %s)',
|
||||
async (reusable) => {
|
||||
mocks.branchConflict.mockImplementation(async (_path, branch: string) =>
|
||||
branch === 'main' ? 'local' : null
|
||||
)
|
||||
mocks.canCheckout.mockImplementation(
|
||||
async (_path, branch: string) => reusable && branch === 'main'
|
||||
)
|
||||
mocks.pathExists.mockImplementation(async (path) => path === worktreePath)
|
||||
mocks.consume.mockResolvedValue({ status: 'miss', reason: 'not_prepared' })
|
||||
|
||||
if (!reusable) {
|
||||
await expect(createWorktree(pickedBranch)).rejects.toThrow('was not found locally')
|
||||
expect(mocks.add).not.toHaveBeenCalled()
|
||||
expect(mocks.consume).not.toHaveBeenCalled()
|
||||
return
|
||||
}
|
||||
await createWorktree(pickedBranch)
|
||||
expect(mocks.add).toHaveBeenCalledWith(
|
||||
'/repo',
|
||||
resolve('/worktrees', 'app-2'),
|
||||
'main',
|
||||
'origin/main',
|
||||
false,
|
||||
false,
|
||||
expect.objectContaining({ checkoutExistingBranch: true })
|
||||
)
|
||||
expect(mocks.githubPr).not.toHaveBeenCalled()
|
||||
}
|
||||
)
|
||||
|
||||
it('keeps unflagged legacy, mobile New branch and Advanced collisions on baseline allocation', async () => {
|
||||
mocks.consume.mockResolvedValue({ status: 'miss', reason: 'not_prepared' })
|
||||
mocks.branchName.mockImplementation(async (_path, override, name: string) => override ?? name)
|
||||
mocks.branchConflict.mockImplementation(async (_path, branch: string) =>
|
||||
branch === 'feature/test' ? 'remote' : null
|
||||
)
|
||||
|
||||
await createWorktree({ branchNameOverride: 'feature/test', baseBranch: 'origin/main' })
|
||||
|
||||
expect(mocks.add.mock.calls[0][2]).toBe('feature/test-2')
|
||||
})
|
||||
|
||||
it('runs the first name PR lookup while the base fetch is still in flight', async () => {
|
||||
mocks.remoteBase.mockResolvedValue(remoteTrackingBase)
|
||||
let finishFetch!: () => void
|
||||
mocks.refresh.mockReturnValue(
|
||||
new Promise((resolve) => {
|
||||
finishFetch = () => resolve({ ok: true })
|
||||
})
|
||||
)
|
||||
let fetchPendingAtLookup = false
|
||||
mocks.githubPr.mockImplementation(async () => {
|
||||
fetchPendingAtLookup = mocks.refresh.mock.calls.length === 1
|
||||
finishFetch?.()
|
||||
return null
|
||||
})
|
||||
|
||||
await createWorktree()
|
||||
|
||||
expect(fetchPendingAtLookup).toBe(true)
|
||||
expect(mocks.githubPr).toHaveBeenCalledWith('/repo', 'app', {})
|
||||
expect(mocks.consume).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
branch: 'app',
|
||||
options: expect.objectContaining({ remoteTrackingBase })
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('suffixes past a PR on the first name', async () => {
|
||||
mocks.githubPr.mockResolvedValueOnce({ number: 7, state: 'closed' }).mockResolvedValue(null)
|
||||
|
||||
await createWorktree()
|
||||
|
||||
expect(mocks.githubPr.mock.calls.map(([, branch]) => branch)).toEqual(['app', 'app-2'])
|
||||
expect(mocks.consume).toHaveBeenCalledWith(expect.objectContaining({ branch: 'app-2' }))
|
||||
})
|
||||
|
||||
it.each([undefined, 'Ubuntu'])(
|
||||
'probes the next name for a PR once the first name collides, routed for %s',
|
||||
async (wslDistro) => {
|
||||
const routing = wslDistro ? { wslDistro } : {}
|
||||
mocks.routing.mockReturnValue(routing)
|
||||
mocks.branchConflict.mockResolvedValueOnce('local').mockResolvedValue(null)
|
||||
|
||||
await createWorktree()
|
||||
|
||||
expect(mocks.githubPr).toHaveBeenCalledOnce()
|
||||
expect(mocks.githubPr).toHaveBeenCalledWith('/repo', 'app-2', routing)
|
||||
}
|
||||
)
|
||||
|
||||
it('skips a later name that already has a PR', async () => {
|
||||
mocks.branchConflict.mockResolvedValueOnce('local').mockResolvedValue(null)
|
||||
mocks.githubPr.mockResolvedValueOnce({ number: 7, state: 'closed' }).mockResolvedValue(null)
|
||||
|
||||
await createWorktree()
|
||||
|
||||
expect(mocks.githubPr.mock.calls.map(([, branch]) => branch)).toEqual(['app-2', 'app-3'])
|
||||
expect(mocks.consume).toHaveBeenCalledWith(expect.objectContaining({ branch: 'app-3' }))
|
||||
})
|
||||
|
||||
it('reports the naming failure when the started base fetch also fails', async () => {
|
||||
mocks.remoteBase.mockResolvedValue(remoteTrackingBase)
|
||||
mocks.refresh.mockRejectedValue(new Error('offline'))
|
||||
mocks.branchConflict.mockResolvedValue('local')
|
||||
const unhandled = vi.fn()
|
||||
process.on('unhandledRejection', unhandled)
|
||||
try {
|
||||
await expect(createWorktree()).rejects.toThrow('Branch "app-')
|
||||
await new Promise((resolve) => setImmediate(resolve))
|
||||
expect(mocks.refresh).toHaveBeenCalledOnce()
|
||||
expect(unhandled).not.toHaveBeenCalled()
|
||||
expect(mocks.consume).not.toHaveBeenCalled()
|
||||
} finally {
|
||||
process.off('unhandledRejection', unhandled)
|
||||
}
|
||||
})
|
||||
|
||||
it('runs the first name PR lookup while the origin fetch is still in flight', async () => {
|
||||
mocks.hasBase.mockResolvedValue(false)
|
||||
let finishFetch!: () => void
|
||||
mocks.fetch.mockReturnValue(
|
||||
new Promise<void>((resolve) => {
|
||||
finishFetch = resolve
|
||||
})
|
||||
)
|
||||
let fetchPendingAtLookup = false
|
||||
mocks.githubPr.mockImplementation(async () => {
|
||||
fetchPendingAtLookup = mocks.fetch.mock.calls.length === 1
|
||||
finishFetch?.()
|
||||
return null
|
||||
})
|
||||
|
||||
await createWorktree()
|
||||
|
||||
expect(fetchPendingAtLookup).toBe(true)
|
||||
expect(mocks.fetch).toHaveBeenCalledWith('/repo', 'origin', {})
|
||||
expect(mocks.consume).toHaveBeenCalledWith(expect.objectContaining({ branch: 'app' }))
|
||||
})
|
||||
|
||||
it('reports the naming failure when the started origin fetch also fails', async () => {
|
||||
mocks.hasBase.mockResolvedValue(false)
|
||||
mocks.fetch.mockRejectedValue(new Error('offline'))
|
||||
mocks.branchConflict.mockResolvedValue('local')
|
||||
const unhandled = vi.fn()
|
||||
process.on('unhandledRejection', unhandled)
|
||||
try {
|
||||
await expect(createWorktree()).rejects.toThrow('Branch "app-')
|
||||
await new Promise((resolve) => setImmediate(resolve))
|
||||
expect(mocks.fetch).toHaveBeenCalledOnce()
|
||||
expect(unhandled).not.toHaveBeenCalled()
|
||||
expect(mocks.consume).not.toHaveBeenCalled()
|
||||
} finally {
|
||||
process.off('unhandledRejection', unhandled)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -167,51 +167,6 @@ describe('runtime concurrent creates on one name', () => {
|
||||
})
|
||||
|
||||
describe('runtime prepared-worktree replenishment', () => {
|
||||
it('keeps the failed claim release available when the fallback also fails', async () => {
|
||||
mocks.consume.mockResolvedValue({
|
||||
status: 'miss',
|
||||
reason: 'prepare_failed',
|
||||
rearm: mocks.rearm
|
||||
})
|
||||
mocks.add.mockRejectedValue(new Error('normal add failed'))
|
||||
const rearm: PreparationRearmHolder = { fire: () => {} }
|
||||
await expect(createWorktree({}, rearm)).rejects.toThrow('normal add failed')
|
||||
|
||||
expect(mocks.rearm).not.toHaveBeenCalled()
|
||||
rearm.fire()
|
||||
expect(mocks.rearm).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('leaves the re-arm holder armed but unfired once probes and include copies finish', async () => {
|
||||
const rearm: PreparationRearmHolder = { fire: () => {} }
|
||||
let finishProbe!: (paths: string[]) => void
|
||||
mocks.resolveShared.mockImplementation(
|
||||
() =>
|
||||
new Promise<string[]>((resolve) => {
|
||||
finishProbe = resolve
|
||||
})
|
||||
)
|
||||
let finishCopy!: (paths: string[]) => void
|
||||
mocks.copyPaths.mockImplementation(
|
||||
() =>
|
||||
new Promise<string[]>((resolve) => {
|
||||
finishCopy = resolve
|
||||
})
|
||||
)
|
||||
const creation = createWorktree({}, rearm)
|
||||
await vi.waitFor(() => expect(mocks.resolveShared).toHaveBeenCalledOnce())
|
||||
expect(mocks.rearm).not.toHaveBeenCalled()
|
||||
finishProbe([])
|
||||
await vi.waitFor(() => expect(mocks.copyPaths).toHaveBeenCalledOnce())
|
||||
expect(mocks.rearm).not.toHaveBeenCalled()
|
||||
finishCopy([])
|
||||
await creation
|
||||
// The caller launches terminals before arming, so create must not fire it itself.
|
||||
expect(mocks.rearm).not.toHaveBeenCalled()
|
||||
rearm.fire()
|
||||
expect(mocks.rearm).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('arms the holder even when materialization fails', async () => {
|
||||
mocks.copyPaths.mockRejectedValue(new Error('copy failed'))
|
||||
const rearm: PreparationRearmHolder = { fire: () => {} }
|
||||
@@ -273,27 +228,6 @@ describe('runtime create base without a tracking ref', () => {
|
||||
mocks.hasBase.mockImplementation(async (_repo: string, ref: string) => ref === 'main')
|
||||
})
|
||||
|
||||
it('names the workspace only after the fetch has settled', async () => {
|
||||
let refreshSettled = false
|
||||
mocks.refresh.mockImplementation(async () => {
|
||||
await Promise.resolve()
|
||||
mocks.hasRemoteRef.mockResolvedValue(true)
|
||||
refreshSettled = true
|
||||
return { ok: true }
|
||||
})
|
||||
const settledAtNaming: boolean[] = []
|
||||
mocks.canCheckout.mockImplementation(async () => {
|
||||
settledAtNaming.push(refreshSettled)
|
||||
return false
|
||||
})
|
||||
|
||||
await createWorktree({ baseBranch: 'origin/main', branchNameOverride: 'app' })
|
||||
|
||||
expect(settledAtNaming).toEqual([true])
|
||||
expect(mocks.canCheckout).toHaveBeenCalledWith('/repo', 'app', 'origin/main', {})
|
||||
expect(mocks.branchConflict).toHaveBeenCalledWith('/repo', 'app', 'origin/main', {}, undefined)
|
||||
})
|
||||
|
||||
it('creates from the local branch and reports it when the fetch fails', async () => {
|
||||
mocks.refresh.mockResolvedValue({ ok: false, errorKind: 'git_error' })
|
||||
|
||||
@@ -309,22 +243,6 @@ describe('runtime create base without a tracking ref', () => {
|
||||
expect(result.addResult).not.toHaveProperty('baseFallback')
|
||||
expect(result.worktree.baseRef).toBe('main')
|
||||
})
|
||||
|
||||
it('decides the base before branch reuse and conflict checks', async () => {
|
||||
mocks.refresh.mockResolvedValue({ ok: false, errorKind: 'git_error' })
|
||||
|
||||
await createWorktree({
|
||||
baseBranch: 'origin/main',
|
||||
branchNameOverride: 'app',
|
||||
allowLocalBaseFallback: true
|
||||
})
|
||||
|
||||
expect(mocks.refresh.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
mocks.canCheckout.mock.invocationCallOrder[0]
|
||||
)
|
||||
expect(mocks.canCheckout).toHaveBeenCalledWith('/repo', 'app', 'main', {})
|
||||
expect(mocks.branchConflict).toHaveBeenCalledWith('/repo', 'app', 'main', {}, undefined)
|
||||
})
|
||||
})
|
||||
|
||||
describe('runtime create setup decision', () => {
|
||||
|
||||
@@ -12,8 +12,6 @@ vi.mock('../../../workspace-hooks/worktree-runner-script', () => ({
|
||||
createSetupRunnerScript: runner.createSetupRunnerScript,
|
||||
resolveSetupRunnerShell: () => undefined
|
||||
}))
|
||||
|
||||
const PRIMARY_YAML = 'scripts:\n setup: pnpm install\n'
|
||||
const PRIMARY_TRUST_CONTENT = 'pnpm install'
|
||||
const sha256 = (text: string): string => createHash('sha256').update(text).digest('hex')
|
||||
|
||||
@@ -50,14 +48,6 @@ afterEach(() => {
|
||||
})
|
||||
|
||||
describe('created worktree setup trust', () => {
|
||||
it('runs setup when the created branch matches the approved content', async () => {
|
||||
writeFileSync(join(worktreePath, 'orca.yaml'), PRIMARY_YAML)
|
||||
const result = await prepare(sha256(PRIMARY_TRUST_CONTENT))
|
||||
expect(runner.createSetupRunnerScript).toHaveBeenCalledOnce()
|
||||
expect(result.setup).toBeDefined()
|
||||
expect(result.setupTrustMismatch).toBeUndefined()
|
||||
})
|
||||
|
||||
it('skips setup and default tab commands and reports the branch content when it differs', async () => {
|
||||
writeFileSync(
|
||||
join(worktreePath, 'orca.yaml'),
|
||||
@@ -86,13 +76,6 @@ describe('created worktree setup trust', () => {
|
||||
expect(result.setupTrustMismatch).toBeUndefined()
|
||||
})
|
||||
|
||||
it('skips setup a branch adds when the primary checkout had none', async () => {
|
||||
writeFileSync(join(worktreePath, 'orca.yaml'), PRIMARY_YAML)
|
||||
const result = await prepare(sha256(''))
|
||||
expect(runner.createSetupRunnerScript).not.toHaveBeenCalled()
|
||||
expect(result.setupTrustMismatch?.scriptContent).toBe(PRIMARY_TRUST_CONTENT)
|
||||
})
|
||||
|
||||
it('runs the created branch setup as before when an older client sends no approval', async () => {
|
||||
writeFileSync(join(worktreePath, 'orca.yaml'), 'scripts:\n setup: make bootstrap\n')
|
||||
const result = await prepare()
|
||||
|
||||
@@ -75,13 +75,6 @@ describe('adoptRuntimeProvisionedRoot', () => {
|
||||
expect(setWorktreeMeta).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('refuses a project whose root is not the recipe root', async () => {
|
||||
const { value } = ports([checkout()])
|
||||
await expect(
|
||||
adoptRuntimeProvisionedRoot('repo-vm', { name: 'fix-login', expectedPath: '/other' }, value)
|
||||
).rejects.toThrow('does not match')
|
||||
})
|
||||
|
||||
it('refuses a project a WSL move is taking before reading its checkout', async () => {
|
||||
const userDataPath = mkdtempSync(join(tmpdir(), 'wsl-move-adopt-'))
|
||||
try {
|
||||
|
||||
@@ -27,32 +27,6 @@ beforeEach(() => {
|
||||
})
|
||||
|
||||
describe('explicit local branch reuse on the execution host', () => {
|
||||
it('routes all probes through the WSL distro and does not compare or reset the remote tip', async () => {
|
||||
git.mockResolvedValueOnce({ stdout: 'local-ahead\n' })
|
||||
git.mockResolvedValueOnce({ stdout: 'refs/heads/feature/test\n' })
|
||||
worktrees.mockResolvedValue([])
|
||||
await expect(
|
||||
canCheckoutExistingLocalBranch(
|
||||
'/repo',
|
||||
'feature/test',
|
||||
'origin/feature/test',
|
||||
{ wslDistro: 'Ubuntu' },
|
||||
true
|
||||
)
|
||||
).resolves.toBe(true)
|
||||
expect(git.mock.calls).toEqual([
|
||||
[
|
||||
['rev-parse', '--verify', '--quiet', 'refs/heads/feature/test^{commit}'],
|
||||
{ cwd: '/repo', wslDistro: 'Ubuntu' }
|
||||
],
|
||||
[
|
||||
['for-each-ref', '--format=%(refname)', 'refs/heads/feature/test'],
|
||||
{ cwd: '/repo', wslDistro: 'Ubuntu' }
|
||||
]
|
||||
])
|
||||
expect(worktrees).toHaveBeenCalledWith('/repo', { wslDistro: 'Ubuntu' })
|
||||
})
|
||||
|
||||
it('rejects a filesystem alias before attempting checkout through WSL', async () => {
|
||||
git.mockResolvedValueOnce({ stdout: 'local-ahead\n' })
|
||||
git.mockResolvedValueOnce({ stdout: '' })
|
||||
|
||||
@@ -5,7 +5,7 @@ import { RpcDispatcher } from '../../rpc/dispatcher'
|
||||
import type { RpcRequest } from '../../rpc/core'
|
||||
import { REPO_METHODS } from '../../rpc/methods/projects/repo'
|
||||
import { WORKTREE_METHODS } from '../../rpc/methods/workspaces/worktree'
|
||||
import { agentLaunchWorkspaceFactory } from '../../rpc/methods/agent-launch/agent-launch-worktree-creation'
|
||||
import '../../rpc/methods/agent-launch/agent-launch-worktree-creation'
|
||||
import { OrcaRuntimeService } from '../../orca-runtime'
|
||||
import { partialRuntimeStore } from '../../orca-runtime-tests/runtime-store-partial-fixture'
|
||||
|
||||
@@ -93,29 +93,4 @@ describe('a repo id set up on two hosts, named with repoHostId', () => {
|
||||
expect(reached).toHaveBeenCalledTimes(1)
|
||||
expect(reached.mock.calls[0]?.[1]).toMatchObject({ path: '/laptop/dup' })
|
||||
})
|
||||
|
||||
it('agent.launch create-worktree keeps the host', async () => {
|
||||
const reached = stopAtResolvedProject()
|
||||
const runtime = createRuntime()
|
||||
const factory = agentLaunchWorkspaceFactory({ runtime }, 'claude')
|
||||
|
||||
await expect(
|
||||
factory.createWorktree({
|
||||
create: { repo: 'id:dup', repoHostId: 'ssh:gpu', name: 'x' },
|
||||
startupAgent: 'claude'
|
||||
})
|
||||
).rejects.toThrow('stop after resolution')
|
||||
|
||||
expect(reached.mock.calls[0]?.[1]).toMatchObject({ path: '/remote/dup' })
|
||||
})
|
||||
|
||||
it('a retried create for one host never coalesces with the same retry for another', async () => {
|
||||
const runtime = createRuntime()
|
||||
const run = vi.fn(async () => 'created')
|
||||
|
||||
await runtime.dedupeWorktreeCreate({ repo: 'id:dup', repoHostId: 'local' }, 'retry-1', run)
|
||||
await runtime.dedupeWorktreeCreate({ repo: 'id:dup', repoHostId: 'ssh:gpu' }, 'retry-1', run)
|
||||
|
||||
expect(run).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -85,13 +85,6 @@ describe('WorkspaceLifecycleQueue', () => {
|
||||
expect(queue.hints()).toEqual({})
|
||||
})
|
||||
|
||||
it('runs a repeated command again rather than merging it into the first', async () => {
|
||||
const queue = new WorkspaceLifecycleQueue()
|
||||
let runs = 0
|
||||
const sleep: LifecycleCommand<number> = { kind: 'sleep', run: async () => ++runs }
|
||||
expect(await Promise.all([queue.submit(KEY, sleep), queue.submit(KEY, sleep)])).toEqual([1, 2])
|
||||
})
|
||||
|
||||
it('keeps separate workspaces apart, including one id on two hosts', async () => {
|
||||
const queue = new WorkspaceLifecycleQueue()
|
||||
const log: string[] = []
|
||||
@@ -193,30 +186,6 @@ describe('WorkspaceLifecycleQueue', () => {
|
||||
expect(queue.hints()).toEqual({})
|
||||
})
|
||||
|
||||
it('re-runs a repeated command once contact is back', async () => {
|
||||
const { queue } = manualRetryQueue()
|
||||
let reachable = false
|
||||
let sleeps = 0
|
||||
const sleep: LifecycleCommand<boolean> = {
|
||||
kind: 'sleep',
|
||||
run: async () => {
|
||||
sleeps += 1
|
||||
return reachable
|
||||
},
|
||||
outcome: (ok) => (ok ? 'settled' : 'unverifiable'),
|
||||
observe: async () => {
|
||||
if (!reachable) {
|
||||
throw new Error('lost')
|
||||
}
|
||||
}
|
||||
}
|
||||
expect(await queue.submit(KEY, sleep)).toBe(false)
|
||||
reachable = true
|
||||
expect(await queue.submit(KEY, sleep)).toBe(true)
|
||||
expect(sleeps).toBe(2)
|
||||
expect(queue.pausedKeys().includes(KEY)).toBe(false)
|
||||
})
|
||||
|
||||
it('pauses on lost contact mid-run, but not on a definite failure', async () => {
|
||||
const queue = new WorkspaceLifecycleQueue()
|
||||
const lost = (message: string): LifecycleCommand<never> => ({
|
||||
@@ -333,62 +302,9 @@ describe('WorkspaceLifecycleQueue', () => {
|
||||
expect(await other).toBe('woken')
|
||||
expect(clock).toBe(500)
|
||||
})
|
||||
|
||||
it('never cancels a command because its requester went away', async () => {
|
||||
const queue = new WorkspaceLifecycleQueue()
|
||||
const log: string[] = []
|
||||
const sleep = gated(log, 'sleep', 'sleep', 'slept')
|
||||
// A disconnected requester must not cancel the running command.
|
||||
void queue.submit(KEY, sleep.command).catch(() => {})
|
||||
await flush()
|
||||
sleep.release()
|
||||
await flush()
|
||||
expect(log).toEqual(['start sleep', 'end sleep'])
|
||||
})
|
||||
|
||||
it('leaves nothing pending in a fresh queue, as after a restart', () => {
|
||||
const queue = new WorkspaceLifecycleQueue()
|
||||
expect(queue.hints()).toEqual({})
|
||||
expect(queue.creationsSnapshot().creations).toEqual({})
|
||||
})
|
||||
})
|
||||
|
||||
describe('WorkspaceLifecycleQueue creates', () => {
|
||||
it('lists a creation once opened, names its workspace, and drops it on close', () => {
|
||||
const queue = new WorkspaceLifecycleQueue()
|
||||
const creation = queue.openCreate({ creationId: 'c1', repoId: 'repo-1' })
|
||||
expect(queue.creationsSnapshot().creations).toEqual({ c1: { repoId: 'repo-1' } })
|
||||
const opened = queue.creationsSnapshot().revision
|
||||
creation.named('repo-1::/wt')
|
||||
expect(queue.creationsSnapshot().creations).toEqual({
|
||||
c1: { repoId: 'repo-1', worktreeId: 'repo-1::/wt' }
|
||||
})
|
||||
expect(queue.creationsSnapshot().revision).toBeGreaterThan(opened)
|
||||
creation.close()
|
||||
expect(queue.creationsSnapshot().creations).toEqual({})
|
||||
})
|
||||
|
||||
it('mints an id for a caller that sends none', () => {
|
||||
const queue = new WorkspaceLifecycleQueue()
|
||||
const creation = queue.openCreate({ repoId: 'repo-1' })
|
||||
const ids = Object.keys(queue.creationsSnapshot().creations)
|
||||
expect(ids).toHaveLength(1)
|
||||
expect(ids[0]).not.toBe('')
|
||||
creation.close()
|
||||
expect(queue.creationsSnapshot().creations).toEqual({})
|
||||
})
|
||||
|
||||
it('leaves a concurrent retry under the same id listed when the first create closes', () => {
|
||||
const queue = new WorkspaceLifecycleQueue()
|
||||
const first = queue.openCreate({ creationId: 'c1', repoId: 'repo-1' })
|
||||
const second = queue.openCreate({ creationId: 'c1', repoId: 'repo-1' })
|
||||
first.named('repo-1::/stale')
|
||||
first.close()
|
||||
expect(queue.creationsSnapshot().creations).toEqual({ c1: { repoId: 'repo-1' } })
|
||||
second.close()
|
||||
expect(queue.creationsSnapshot().creations).toEqual({})
|
||||
})
|
||||
|
||||
it('frees the workspace queue when a create closes after a retry took over its id', async () => {
|
||||
const queue = new WorkspaceLifecycleQueue()
|
||||
const first = queue.openCreate({ creationId: 'c1', repoId: 'repo-1', keyOf: () => KEY })
|
||||
@@ -400,15 +316,6 @@ describe('WorkspaceLifecycleQueue creates', () => {
|
||||
retry.close()
|
||||
})
|
||||
|
||||
it('closes once', () => {
|
||||
const queue = new WorkspaceLifecycleQueue()
|
||||
const creation = queue.openCreate({ creationId: 'c1', repoId: 'repo-1' })
|
||||
creation.close()
|
||||
const closed = queue.creationsSnapshot().revision
|
||||
creation.close()
|
||||
expect(queue.creationsSnapshot().revision).toBe(closed)
|
||||
})
|
||||
|
||||
it('binds to the workspace once named, so commands on the Creating row wait behind it', async () => {
|
||||
const queue = new WorkspaceLifecycleQueue()
|
||||
const log: string[] = []
|
||||
@@ -494,22 +401,4 @@ describe('WorkspaceLifecycleQueue membership', () => {
|
||||
// Publish membership and creation changes together in one revision.
|
||||
expect(bumps).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('drops a failed create at once, with nothing to list', () => {
|
||||
const queue = new WorkspaceLifecycleQueue()
|
||||
const rows = listing()
|
||||
queue.setMembership(rows.membership)
|
||||
queue.openCreate({ creationId: 'c1', repoId: 'repo-1' }).close()
|
||||
expect(queue.creationsSnapshot().creations).toEqual({})
|
||||
})
|
||||
|
||||
it('shows a finished delete until a fresh listing drops the row', async () => {
|
||||
const queue = new WorkspaceLifecycleQueue()
|
||||
const rows = listing()
|
||||
queue.setMembership(rows.membership)
|
||||
expect(await queue.submit(KEY, { kind: 'delete', run: async () => 'deleted' })).toBe('deleted')
|
||||
expect(queue.hints()).toEqual({ [KEY]: { kind: 'delete', queuedBehind: 0 } })
|
||||
rows.applyFreshScan()
|
||||
expect(queue.hints()).toEqual({})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { ALL_TUI_AGENTS } from '../../../../shared/agent-launch/catalog/tui-agent-display-names'
|
||||
import '../../../../shared/agent-launch/catalog/tui-agent-display-names'
|
||||
import type { CliWorktreeBuilder } from '../../rpc/methods/agent-launch/agent-launch-cwd-cli.test-fixture'
|
||||
import { toSshExecutionHostId } from '../../../../shared/execution-host'
|
||||
import '../../../../shared/execution-host'
|
||||
import { WorktreeCreate } from '../../../../shared/rpc-contract/worktree-create-params'
|
||||
import { WorktreeCreate as PreObservationCreate } from '../../../../shared/rpc-contract/worktree-create-params.pre-observation.test-fixture'
|
||||
import { AgentLaunch, AgentLaunchReplay } from '../../../../shared/rpc-contract/agent-launch-params'
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
AgentLaunchReplay as PreObservationReplay
|
||||
} from '../../../../shared/rpc-contract/agent-launch-params.pre-observation.test-fixture'
|
||||
import { RuntimeWorktreeLineageController } from './runtime-worktree-lineage-controller'
|
||||
import type { FolderWorkspace } from '../../../../shared/folder-workspaces/folder-workspace-types'
|
||||
|
||||
import type { ResolvedWorktree } from '../catalog/runtime-worktree-path-identity'
|
||||
import {
|
||||
formatAmbiguousCwdParentMessage,
|
||||
@@ -104,80 +104,6 @@ function parseVersionedCreate(
|
||||
afterEach(() => vi.restoreAllMocks())
|
||||
|
||||
describe('consumed cwd parent at the production authority boundary', () => {
|
||||
it.each(['terminal', 'env', 'task', 'conflict', 'cross-host', 'exact', 'no-parent'] as const)(
|
||||
'preserves %s authority/provenance and warnings when choices are unused',
|
||||
async (kind) => {
|
||||
const { controller, db, showTerminal, resolveWorktree } = fixture()
|
||||
const input: WorktreeLineageInput = { ...unresolved }
|
||||
if (kind === 'env') {
|
||||
input.envParentWorkspace = 'id:outside-cwd'
|
||||
}
|
||||
if (kind === 'task') {
|
||||
input.comment = 'created for task_creator'
|
||||
vi.spyOn(db, 'getDispatchContext').mockReturnValue(undefined)
|
||||
vi.spyOn(db, 'getTask').mockReturnValue(
|
||||
taskLineageRow({ id: 'task_creator', created_by_terminal_handle: 'term_creator' })
|
||||
)
|
||||
showTerminal.mockImplementation(async (handle) => {
|
||||
if (handle !== 'term_creator') {
|
||||
throw new Error('terminal_not_found')
|
||||
}
|
||||
return { worktreeId: root.id }
|
||||
})
|
||||
} else if (kind !== 'terminal' && kind !== 'conflict') {
|
||||
showTerminal.mockRejectedValue(new Error('terminal_not_found'))
|
||||
}
|
||||
if (kind === 'conflict') {
|
||||
input.envParentWorkspace = choices[0]
|
||||
}
|
||||
if (kind === 'cross-host') {
|
||||
resolveWorktree.mockImplementation(async (selector) => ({
|
||||
...worktree(selector.slice(3)),
|
||||
hostId: toSshExecutionHostId('other')
|
||||
}))
|
||||
}
|
||||
if (kind === 'exact') {
|
||||
input.parentWorktree = choices[1]
|
||||
}
|
||||
if (kind === 'no-parent') {
|
||||
input.noParent = true
|
||||
}
|
||||
const withoutChoices = { ...input, cwdParentAmbiguous: undefined }
|
||||
const before = await controller.resolveCreate(withoutChoices, 'local')
|
||||
const after = await controller.resolveCreate(input, 'local')
|
||||
expect(after).toEqual(before)
|
||||
if (kind === 'conflict' || kind === 'cross-host') {
|
||||
expect(after).toMatchObject({
|
||||
kind: 'none',
|
||||
warnings: [{ code: 'LINEAGE_PARENT_CONTEXT_CONFLICT' }]
|
||||
})
|
||||
} else if (kind === 'no-parent') {
|
||||
expect(after).toEqual({ kind: 'none', warnings: [] })
|
||||
} else {
|
||||
const source =
|
||||
kind === 'env'
|
||||
? 'env-workspace'
|
||||
: kind === 'task'
|
||||
? 'orchestration-context'
|
||||
: kind === 'exact'
|
||||
? 'explicit-cli-flag'
|
||||
: 'terminal-context'
|
||||
expect(after).toMatchObject({
|
||||
kind: 'lineage',
|
||||
origin: kind === 'task' ? 'orchestration' : 'cli',
|
||||
capture: { source, confidence: kind === 'exact' ? 'explicit' : 'inferred' },
|
||||
parent: { worktree: { id: kind === 'env' ? 'outside-cwd' : root.id } }
|
||||
})
|
||||
if (kind === 'terminal') {
|
||||
expect(after).toHaveProperty('createdByTerminalHandle', 'term_R')
|
||||
}
|
||||
if (kind === 'task') {
|
||||
expect(after).toHaveProperty('taskId', 'task_creator')
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
it.each(['absent', 'stale-terminal', 'stale-env', 'stale-task', 'terminal-disappears'])(
|
||||
'refuses a retained positive cwd tie (%s), preserving exact RPC recovery',
|
||||
async (kind) => {
|
||||
@@ -225,29 +151,6 @@ describe('consumed cwd parent at the production authority boundary', () => {
|
||||
}
|
||||
)
|
||||
|
||||
it('retains unique/legacy cwd and missing-parent behavior without inventing authority', async () => {
|
||||
const { controller, showTerminal } = fixture()
|
||||
showTerminal.mockRejectedValue(new Error('terminal_not_found'))
|
||||
for (const cwdParentAmbiguous of [undefined]) {
|
||||
expect(
|
||||
await controller.resolveCreate({ ...unresolved, cwdParentAmbiguous }, 'local')
|
||||
).toMatchObject({
|
||||
kind: 'lineage',
|
||||
origin: 'cli',
|
||||
capture: { source: 'cwd-context', confidence: 'inferred' },
|
||||
parent: { worktree: { id: siblingId } }
|
||||
})
|
||||
}
|
||||
expect(
|
||||
await controller.resolveCreate({ ...unresolved, cwdParentWorktree: 'id:stale' }, 'local')
|
||||
).toMatchObject({
|
||||
kind: 'none',
|
||||
warnings: expect.arrayContaining([
|
||||
expect.objectContaining({ code: 'LINEAGE_PARENT_CONTEXT_MISSING' })
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
it.each([false, true])(
|
||||
'executes old/new client and host schema compatibility (newClient=%s)',
|
||||
async (newClient) => {
|
||||
@@ -286,222 +189,4 @@ describe('consumed cwd parent at the production authority boundary', () => {
|
||||
}
|
||||
}
|
||||
)
|
||||
it('retains complete historical transforms, refinements and actual launch embeddings', () => {
|
||||
for (const newHost of [false, true]) {
|
||||
for (const route of ['create', 'launch', 'replay'] as const) {
|
||||
for (const value of [undefined, true, false, null, 'true', 1, [], {}]) {
|
||||
const request = { repo: 'id:repo-a', cwdParentAmbiguous: value, futureField: 1 }
|
||||
if (newHost && value !== undefined && value !== true) {
|
||||
expect(() => parseVersionedCreate(newHost, route, request)).toThrow()
|
||||
} else {
|
||||
const parsed = parseVersionedCreate(newHost, route, request)
|
||||
expect(parsed).not.toHaveProperty('futureField')
|
||||
expect('cwdParentAmbiguous' in parsed ? parsed.cwdParentAmbiguous : undefined).toBe(
|
||||
newHost ? value : undefined
|
||||
)
|
||||
}
|
||||
}
|
||||
for (const invalid of [
|
||||
{ repo: 123 },
|
||||
{ parentWorktree: 'id:p', noParent: true },
|
||||
{ parentWorkspace: 'folder:p', noParent: true },
|
||||
{ parentWorkspace: 'folder:p', parentWorktree: 'id:p' },
|
||||
{ startupPrompt: 'hello' },
|
||||
{ linkedWorkItem: { invalid: true }, linkedTaskSourceContext: { invalid: true } }
|
||||
]) {
|
||||
expect(() =>
|
||||
parseVersionedCreate(newHost, route, { repo: 'id:repo-a', ...invalid })
|
||||
).toThrow()
|
||||
}
|
||||
const linkedWorkItem = {
|
||||
provider: 'github',
|
||||
type: 'issue',
|
||||
number: 1,
|
||||
title: 'Issue',
|
||||
url: 'https://github.com/example/repo/issues/1'
|
||||
}
|
||||
const linkedTaskSourceContext = {
|
||||
provider: 'github',
|
||||
projectId: 'project',
|
||||
hostId: 'local'
|
||||
}
|
||||
expect(
|
||||
parseVersionedCreate(newHost, route, {
|
||||
repo: 'id:repo-a',
|
||||
linkedWorkItem,
|
||||
linkedTaskSourceContext
|
||||
})
|
||||
).toMatchObject({ linkedWorkItem, linkedTaskSourceContext })
|
||||
expect(() =>
|
||||
parseVersionedCreate(newHost, route, {
|
||||
repo: 'id:repo-a',
|
||||
linkedWorkItem,
|
||||
linkedTaskSourceContext: { ...linkedTaskSourceContext, provider: 'gitlab' }
|
||||
})
|
||||
).toThrow('Linked work item and source context identities must match')
|
||||
expect(
|
||||
parseVersionedCreate(newHost, route, {
|
||||
repo: 'id:repo-a',
|
||||
setupDecision: 'unknown',
|
||||
telemetrySource: 'invalid-telemetry-source',
|
||||
createdWithAgent: 'unknown',
|
||||
startupAgent: 'codex',
|
||||
startupPrompt: 'hello'
|
||||
})
|
||||
).toMatchObject({
|
||||
repo: 'id:repo-a',
|
||||
setupDecision: undefined,
|
||||
telemetrySource: undefined,
|
||||
createdWithAgent: undefined,
|
||||
startupAgent: 'codex',
|
||||
startupPrompt: 'hello'
|
||||
})
|
||||
expect(
|
||||
parseVersionedCreate(newHost, route, {
|
||||
repo: 'id:repo-a',
|
||||
telemetrySource: 'unknown'
|
||||
})
|
||||
).toMatchObject({ repo: 'id:repo-a', telemetrySource: 'unknown' })
|
||||
}
|
||||
}
|
||||
for (const schema of [
|
||||
PreObservationLaunch,
|
||||
PreObservationReplay,
|
||||
AgentLaunch,
|
||||
AgentLaunchReplay
|
||||
]) {
|
||||
expect(
|
||||
schema.safeParse({
|
||||
agent: 'codex',
|
||||
operationId: 'malformed',
|
||||
target: { kind: 'existing', worktree: 'id:r' }
|
||||
}).success
|
||||
).toBe(false)
|
||||
expect(() =>
|
||||
schema.parse({
|
||||
agent: 'codex',
|
||||
operationId: `${Date.now()}-${'a'.repeat(32)}`,
|
||||
sessionId: 'claude_session',
|
||||
target: { kind: 'existing', worktree: 'id:r' }
|
||||
})
|
||||
).toThrow('Launch session id must be named for its agent')
|
||||
}
|
||||
expect(
|
||||
PreObservationReplay.safeParse({
|
||||
agent: 'codex',
|
||||
target: { kind: 'existing', worktree: 'id:r' }
|
||||
}).success
|
||||
).toBe(false)
|
||||
})
|
||||
it.each([false, null, 'true', 1, [], {}])(
|
||||
'rejects malformed positive literal %j before runtime reads',
|
||||
(value) => {
|
||||
const { resolveWorktree, showTerminal } = fixture()
|
||||
expect(
|
||||
WorktreeCreate.safeParse({ repo: 'id:repo-a', name: 'child', cwdParentAmbiguous: value })
|
||||
.success
|
||||
).toBe(false)
|
||||
expect(resolveWorktree).not.toHaveBeenCalled()
|
||||
expect(showTerminal).not.toHaveBeenCalled()
|
||||
}
|
||||
)
|
||||
it('does not recheck terminal liveness after its existing validation read', async () => {
|
||||
const { controller, showTerminal } = fixture()
|
||||
showTerminal.mockImplementationOnce(async () => {
|
||||
showTerminal.mockRejectedValue(new Error('terminal_not_found'))
|
||||
return { worktreeId: root.id }
|
||||
})
|
||||
expect(await controller.resolveCreate(unresolved, 'local')).toMatchObject({
|
||||
capture: { source: 'terminal-context' },
|
||||
createdByTerminalHandle: 'term_R'
|
||||
})
|
||||
expect(showTerminal).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
it('keeps the complete original refusal bounded for plain creation and every finite agent', () => {
|
||||
const codex = [
|
||||
'Orca did not create the workspace or start codex because more than one workspace matched the current directory when this request was prepared.',
|
||||
'Run `orca worktree list --limit 10000 --json` with the same server and host selection. Match path and host; if truncated, repeat with a larger --limit using totalCount. Choose id:<id> (or identity:<identity.key> for a shared ID), then add --parent-worktree <selector> to your original invocation, or deliberately use --no-parent. Pass the selector as one argument quoted for your shell.',
|
||||
'To choose a parent, start a fresh request without --retry-launch; retrying the old ID returns this refusal.'
|
||||
].join('\n')
|
||||
expect(formatAmbiguousCwdParentMessage('codex')).toBe(codex)
|
||||
expect(formatAmbiguousCwdParentMessage()).toBe(codex.replace(' or start codex', ''))
|
||||
expect(formatAmbiguousCwdParentMessage()).toHaveLength(622)
|
||||
expect(
|
||||
Math.max(...ALL_TUI_AGENTS.map((agent) => formatAmbiguousCwdParentMessage(agent).length))
|
||||
).toBe(650)
|
||||
for (const agent of ALL_TUI_AGENTS) {
|
||||
expect(formatAmbiguousCwdParentMessage(agent)).toBe(
|
||||
codex.replace('or start codex', `or start ${agent}`)
|
||||
)
|
||||
expect(formatAmbiguousCwdParentMessage(agent).length).toBeLessThan(4224)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('caller parent for a create on another machine', () => {
|
||||
it('skips a stale terminal and keeps the valid environment parent, as a same-machine create does', async () => {
|
||||
const { controller, showTerminal } = fixture()
|
||||
showTerminal.mockRejectedValue(new Error('terminal_not_found'))
|
||||
await expect(
|
||||
controller.resolveCallerParentForAnotherStore(
|
||||
{
|
||||
envParentWorkspace: 'id:repo-a::/tmp/env-parent',
|
||||
callerTerminalHandle: 'term_stale'
|
||||
},
|
||||
'mac-install'
|
||||
)
|
||||
).resolves.toEqual({
|
||||
parent: {
|
||||
installationId: 'mac-install',
|
||||
workspaceKey: 'worktree:repo-a::/tmp/env-parent',
|
||||
instanceId: 'repo-a::/tmp/env-parent'
|
||||
},
|
||||
source: 'env-workspace'
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('a task id in the note of a create on another machine', () => {
|
||||
it('names the folder workspace the task was made in', async () => {
|
||||
const db = makeRuntimeOrchestrationDb()
|
||||
vi.spyOn(db, 'getDispatchContext').mockReturnValue(undefined)
|
||||
vi.spyOn(db, 'getTask').mockReturnValue(
|
||||
taskLineageRow({ id: 'task_folder', created_by_terminal_handle: 'term_folder' })
|
||||
)
|
||||
const controller = new RuntimeWorktreeLineageController({
|
||||
getStore: () => null,
|
||||
getDb: () => db,
|
||||
showTerminal: async () => ({ worktreeId: 'folder:f-1' }),
|
||||
resolveWorktree: async () => {
|
||||
throw new Error('selector_not_found')
|
||||
}
|
||||
})
|
||||
const folderWorkspace: FolderWorkspace = {
|
||||
id: 'f-1',
|
||||
projectGroupId: 'group-1',
|
||||
name: 'Release',
|
||||
folderPath: '/work/multi',
|
||||
linkedTask: null,
|
||||
comment: '',
|
||||
isArchived: false,
|
||||
isUnread: false,
|
||||
isPinned: false,
|
||||
sortOrder: 0,
|
||||
lastActivityAt: 0,
|
||||
createdAt: 0,
|
||||
updatedAt: 0
|
||||
}
|
||||
vi.spyOn(controller, 'resolveParent').mockImplementation(async (selector) => {
|
||||
if (selector !== 'id:folder:f-1') {
|
||||
throw new Error('selector_not_found')
|
||||
}
|
||||
return { type: 'folder', workspaceKey: 'folder:f-1', folderWorkspace, instanceId: null }
|
||||
})
|
||||
await expect(
|
||||
controller.resolveCallerParentForAnotherStore({ comment: 'for task_folder' }, 'desktop-1')
|
||||
).resolves.toEqual({
|
||||
parent: { installationId: 'desktop-1', workspaceKey: 'folder:f-1', instanceId: 'f-1' },
|
||||
source: 'orchestration-context'
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { worktreeWorkspaceKey } from '../../../../shared/workspace-scope'
|
||||
import { resolveRuntimeWorktreeCreateLineage } from './runtime-worktree-lineage-resolution'
|
||||
|
||||
const unused = async (): Promise<never> => {
|
||||
throw new Error('not reached')
|
||||
}
|
||||
|
||||
describe('resolveRuntimeWorktreeCreateLineage', () => {
|
||||
it('records an app-picked parent on another machine as manual', async () => {
|
||||
const parent = {
|
||||
installationId: 'mac-install',
|
||||
workspaceKey: worktreeWorkspaceKey('repo::/src/parent'),
|
||||
instanceId: 'parent-instance'
|
||||
}
|
||||
|
||||
await expect(
|
||||
resolveRuntimeWorktreeCreateLineage(
|
||||
{ crossStoreParent: parent, parentWorkspaceOrigin: 'manual' },
|
||||
{
|
||||
resolveParent: unused,
|
||||
resolveWorktreeParent: unused,
|
||||
resolveTaskCandidate: unused,
|
||||
resolveCaller: unused
|
||||
}
|
||||
)
|
||||
).resolves.toEqual({
|
||||
kind: 'crossStore',
|
||||
parent,
|
||||
origin: 'manual',
|
||||
capture: { source: 'manual-action', confidence: 'explicit' }
|
||||
})
|
||||
})
|
||||
})
|
||||
-7
@@ -100,13 +100,6 @@ describe('updateRuntimeManagedWorktreeMetadata under a WSL move', () => {
|
||||
expect(p.notifyChanged).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('refuses a user edit', async () => {
|
||||
await expect(update(MOVING, { comment: 'mine' })).rejects.toBeInstanceOf(
|
||||
WslMoveProjectFencedError
|
||||
)
|
||||
expect(store.getWorktreeMeta(MOVING)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('refuses a lineage change even alongside passive fields', async () => {
|
||||
await expect(
|
||||
update(MOVING, { isUnread: true, lineage: { noParent: true } })
|
||||
|
||||
@@ -120,18 +120,6 @@ describe('main places the swept workspace’s live terminals, layout-owner build
|
||||
expect(listed?.tabId).not.toMatch(/^pty:/)
|
||||
})
|
||||
|
||||
it('a listing that does not ask places nothing', async () => {
|
||||
const { store, runtime } = await hostWith([session('pty-swept')])
|
||||
|
||||
const context = { runtime }
|
||||
await terminalListHandler()(
|
||||
{ worktree: `id:${SWEPT}`, limit: 200, requireFreshPtyLiveness: true },
|
||||
context
|
||||
)
|
||||
|
||||
expect(boundPtyIds(store, SWEPT)).toEqual([])
|
||||
})
|
||||
|
||||
it('leaves a terminal whose registration is still binding its pane, as a census does', async () => {
|
||||
const { store, runtime } = await hostWith([session('pty-registering')])
|
||||
runtime.beginPtyRegistration('pty-registering', 'pty-registering-inc')
|
||||
|
||||
@@ -1,269 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { buildRuntimeWorktreePsSummaries } from './runtime-worktree-ps-summaries'
|
||||
import type { ResolvedWorktree } from '../catalog/runtime-worktree-path-identity'
|
||||
import type { RuntimeStore } from '../../runtime-store-contract'
|
||||
|
||||
describe('buildRuntimeWorktreePsSummaries', () => {
|
||||
it('preserves persisted host ownership over the resolved row fallback', () => {
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the summary builder reads only these worktree fields.
|
||||
const worktree = {
|
||||
id: 'repo-1::/workspace/app',
|
||||
repoId: 'repo-1',
|
||||
hostId: 'ssh:resolved-host',
|
||||
path: '/workspace/app',
|
||||
branch: 'feature',
|
||||
isArchived: false,
|
||||
isMainWorktree: false,
|
||||
parentWorktreeId: null,
|
||||
childWorktreeIds: [],
|
||||
lineage: null,
|
||||
lastActivityAt: 0
|
||||
} as unknown as ResolvedWorktree
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the summary builder reads only these store methods.
|
||||
const store = {
|
||||
getRepos: () => [],
|
||||
getWorktreeMeta: () => ({
|
||||
hostId: 'ssh:persisted-host',
|
||||
linkedPR: 42,
|
||||
linkedItems: [{ provider: 'github', type: 'pr', number: 57 }]
|
||||
}),
|
||||
getAllWorktreeMeta: () => ({}),
|
||||
getFolderWorkspaces: () => [],
|
||||
getProjectGroups: () => []
|
||||
} as unknown as RuntimeStore
|
||||
|
||||
const summary = buildRuntimeWorktreePsSummaries({
|
||||
store,
|
||||
resolvedWorktrees: [worktree],
|
||||
platformByRepoId: new Map()
|
||||
}).get(worktree.id)
|
||||
|
||||
expect(summary?.hostId).toBe('ssh:persisted-host')
|
||||
expect(summary?.linkedItems).toEqual([
|
||||
{ provider: 'github', type: 'pr', number: 57 },
|
||||
{ provider: 'github', type: 'pr', number: 42 }
|
||||
])
|
||||
})
|
||||
|
||||
it("sends a renamed workspace's older ids so phone history still finds its sessions", () => {
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the builder reads only these fields for a git row.
|
||||
const worktree = {
|
||||
id: 'repo-1::/workspace/app',
|
||||
repoId: 'repo-1',
|
||||
path: '/workspace/app',
|
||||
branch: 'feature',
|
||||
isArchived: false,
|
||||
isMainWorktree: false,
|
||||
parentWorktreeId: null,
|
||||
childWorktreeIds: [],
|
||||
lineage: null,
|
||||
lastActivityAt: 0
|
||||
} as unknown as ResolvedWorktree
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the summary builder reads only these store methods.
|
||||
const store = {
|
||||
getRepos: () => [],
|
||||
getWorktreeMeta: () => ({ priorWorktreeIds: ['repo-1::/workspace/old-app'] }),
|
||||
getAllWorktreeMeta: () => ({}),
|
||||
getFolderWorkspaces: () => [],
|
||||
getProjectGroups: () => []
|
||||
} as unknown as RuntimeStore
|
||||
|
||||
const summary = buildRuntimeWorktreePsSummaries({
|
||||
store,
|
||||
resolvedWorktrees: [worktree],
|
||||
platformByRepoId: new Map()
|
||||
}).get(worktree.id)
|
||||
|
||||
expect(summary?.priorWorktreeIds).toEqual(['repo-1::/workspace/old-app'])
|
||||
})
|
||||
|
||||
it('carries the error of a failed delete the host still lists', () => {
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the builder reads only these fields for a git row.
|
||||
const worktree = {
|
||||
id: 'repo-1::/workspace/app',
|
||||
repoId: 'repo-1',
|
||||
path: '/workspace/app',
|
||||
branch: 'feature',
|
||||
isArchived: false,
|
||||
isMainWorktree: false,
|
||||
parentWorktreeId: null,
|
||||
childWorktreeIds: [],
|
||||
lineage: null,
|
||||
lastActivityAt: 0,
|
||||
removalError: 'Operation not permitted'
|
||||
} as unknown as ResolvedWorktree
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the builder reads only these store members.
|
||||
const store = {
|
||||
getRepos: () => [],
|
||||
getWorktreeMeta: () => undefined,
|
||||
getAllWorktreeMeta: () => ({}),
|
||||
getFolderWorkspaces: () => [],
|
||||
getProjectGroups: () => []
|
||||
} as unknown as RuntimeStore
|
||||
|
||||
const summary = buildRuntimeWorktreePsSummaries({
|
||||
store,
|
||||
resolvedWorktrees: [worktree],
|
||||
platformByRepoId: new Map()
|
||||
}).get(worktree.id)
|
||||
|
||||
expect(summary?.removalError).toBe('Operation not permitted')
|
||||
})
|
||||
|
||||
it('sends the Workspace options row facts the sidebar reads', () => {
|
||||
const row = (id: string, fields: Record<string, unknown>) =>
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the builder reads only these fields for a git row.
|
||||
({
|
||||
id,
|
||||
repoId: 'repo-1',
|
||||
path: `/workspace/${id}`,
|
||||
branch: 'feature',
|
||||
head: 'abc1234',
|
||||
isArchived: false,
|
||||
isMainWorktree: false,
|
||||
parentWorktreeId: null,
|
||||
childWorktreeIds: [],
|
||||
lineage: null,
|
||||
lastActivityAt: 0,
|
||||
...fields
|
||||
}) as unknown as ResolvedWorktree
|
||||
const worktrees = [
|
||||
row('main', { isMainWorktree: true, branch: 'refs/heads/main' }),
|
||||
// The phone's own guess calls both of these default-branch rows; the host says not.
|
||||
row('main-detached', { isMainWorktree: true, branch: '' }),
|
||||
row('provisioned', { isMainWorktree: true, ephemeralVmCheckoutMode: 'provisioned-root' }),
|
||||
row('automation', { automationProvenance: { kind: 'created-by-automation' } }),
|
||||
row('cli', { cliProvenance: { kind: 'created-by-cli', createdAt: 0 } }),
|
||||
row('folder-root', { repoId: 'folder-repo', isMainWorktree: true, branch: '', head: '' })
|
||||
]
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the builder reads only these store members.
|
||||
const store = {
|
||||
getRepos: () => [
|
||||
{ id: 'repo-1', path: '/r', displayName: 'Repo', badgeColor: '#000', addedAt: 0 },
|
||||
{
|
||||
id: 'folder-repo',
|
||||
path: '/f',
|
||||
displayName: 'Folder',
|
||||
badgeColor: '#000',
|
||||
addedAt: 0,
|
||||
kind: 'folder'
|
||||
}
|
||||
],
|
||||
getWorktreeMeta: () => undefined,
|
||||
getAllWorktreeMeta: () => ({}),
|
||||
getFolderWorkspaces: () => [],
|
||||
getProjectGroups: () => []
|
||||
} as unknown as RuntimeStore
|
||||
|
||||
const summaries = buildRuntimeWorktreePsSummaries({
|
||||
store,
|
||||
resolvedWorktrees: worktrees,
|
||||
platformByRepoId: new Map()
|
||||
})
|
||||
const summary = (id: string) => summaries.get(id)
|
||||
|
||||
expect(summary('main')?.defaultBranch).toBe(true)
|
||||
expect(summary('folder-root')?.defaultBranch).toBe(false)
|
||||
expect(summary('main-detached')?.defaultBranch).toBe(false)
|
||||
expect(summary('main-detached')?.detachedHead).toBe(true)
|
||||
expect(summary('provisioned')?.defaultBranch).toBe(false)
|
||||
expect(summary('automation')?.automationCreated).toBe(true)
|
||||
expect(summary('cli')?.cliCreated).toBe(true)
|
||||
expect(summary('main')).not.toHaveProperty('automationCreated')
|
||||
expect(summary('main')).not.toHaveProperty('cliCreated')
|
||||
expect(summary('main')).not.toHaveProperty('detachedHead')
|
||||
expect(summary('folder-root')).not.toHaveProperty('detachedHead')
|
||||
})
|
||||
|
||||
it("names the automation that made a row, and carries the branch PR's title, link and checks", () => {
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the builder reads only these fields for a git row.
|
||||
const worktree = {
|
||||
id: 'repo-1::/workspace/app',
|
||||
repoId: 'repo-1',
|
||||
path: '/workspace/app',
|
||||
branch: 'refs/heads/feature',
|
||||
head: 'abc1234',
|
||||
isArchived: false,
|
||||
isMainWorktree: false,
|
||||
parentWorktreeId: null,
|
||||
childWorktreeIds: [],
|
||||
lineage: null,
|
||||
lastActivityAt: 0,
|
||||
automationProvenance: {
|
||||
kind: 'created-by-automation',
|
||||
automationNameSnapshot: ' Nightly dependency bump '
|
||||
}
|
||||
} as unknown as ResolvedWorktree
|
||||
const pr = {
|
||||
number: 7,
|
||||
title: 'Bump deps',
|
||||
state: 'open',
|
||||
url: 'https://github.com/o/r/pull/7',
|
||||
checksStatus: 'success',
|
||||
updatedAt: '',
|
||||
mergeable: 'MERGEABLE',
|
||||
headSha: 'not-sent'
|
||||
}
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the builder reads only these store members.
|
||||
const store = {
|
||||
getRepos: () => [
|
||||
{ id: 'repo-1', path: '/r', displayName: 'Repo', badgeColor: '#000', addedAt: 0 }
|
||||
],
|
||||
getWorktreeMeta: () => undefined,
|
||||
getAllWorktreeMeta: () => ({}),
|
||||
getFolderWorkspaces: () => [],
|
||||
getProjectGroups: () => [],
|
||||
getGitHubCache: () => ({ pr: { 'repo-1::feature': { data: pr, fetchedAt: 1 } }, issue: {} })
|
||||
} as unknown as RuntimeStore
|
||||
|
||||
const summary = buildRuntimeWorktreePsSummaries({
|
||||
store,
|
||||
resolvedWorktrees: [worktree],
|
||||
platformByRepoId: new Map()
|
||||
}).get(worktree.id)
|
||||
|
||||
expect(summary?.automationName).toBe('Nightly dependency bump')
|
||||
expect(summary?.linkedPR).toEqual({
|
||||
number: 7,
|
||||
state: 'open',
|
||||
title: 'Bump deps',
|
||||
url: 'https://github.com/o/r/pull/7',
|
||||
checksStatus: 'success'
|
||||
})
|
||||
})
|
||||
|
||||
it('leaves the automation name off when the record has none, as older hosts do', () => {
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the builder reads only these fields for a git row.
|
||||
const worktree = {
|
||||
id: 'repo-1::/workspace/app',
|
||||
repoId: 'repo-1',
|
||||
path: '/workspace/app',
|
||||
branch: 'feature',
|
||||
isArchived: false,
|
||||
isMainWorktree: false,
|
||||
parentWorktreeId: null,
|
||||
childWorktreeIds: [],
|
||||
lineage: null,
|
||||
lastActivityAt: 0,
|
||||
// Blank reads as no name.
|
||||
automationProvenance: { kind: 'created-by-automation', automationNameSnapshot: ' ' }
|
||||
} as unknown as ResolvedWorktree
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the builder reads only these store members.
|
||||
const store = {
|
||||
getRepos: () => [],
|
||||
getWorktreeMeta: () => undefined,
|
||||
getAllWorktreeMeta: () => ({}),
|
||||
getFolderWorkspaces: () => [],
|
||||
getProjectGroups: () => []
|
||||
} as unknown as RuntimeStore
|
||||
|
||||
const summary = buildRuntimeWorktreePsSummaries({
|
||||
store,
|
||||
resolvedWorktrees: [worktree],
|
||||
platformByRepoId: new Map()
|
||||
}).get(worktree.id)
|
||||
|
||||
expect(summary?.automationCreated).toBe(true)
|
||||
expect(summary).not.toHaveProperty('automationName')
|
||||
})
|
||||
})
|
||||
-24
@@ -1,24 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { RuntimeWorktreePsSummary } from '../../../../shared/runtime-worktree-contracts'
|
||||
import { applyRuntimeWorktreePsUnverifiableTerminals } from './runtime-worktree-ps-unverifiable-terminals'
|
||||
|
||||
describe('unverifiable terminal attribution', () => {
|
||||
it('follows the host PTY record, not a stale pane, as the live pass does', () => {
|
||||
const summaries = new Map<string, Partial<RuntimeWorktreePsSummary>>([
|
||||
['wt-old', {}],
|
||||
['wt-new', {}]
|
||||
])
|
||||
applyRuntimeWorktreePsUnverifiableTerminals({
|
||||
countedPtyIds: new Set(),
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the pass reads only ptyId and worktreeId.
|
||||
leaves: [{ ptyId: 'pty-1', worktreeId: 'wt-old' } as never],
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the pass reads only ptyId and worktreeId.
|
||||
ptysById: new Map([['pty-1', { ptyId: 'pty-1', worktreeId: 'wt-new' } as never]]),
|
||||
getLivenessVerdict: () => ({ status: 'unverifiable', reason: 'lost contact' }),
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: only the counters this pass writes are read back.
|
||||
getSummary: (id) => (summaries.get(id) as RuntimeWorktreePsSummary | undefined) ?? null
|
||||
})
|
||||
expect(summaries.get('wt-new')?.unverifiableTerminalCount).toBe(1)
|
||||
expect(summaries.get('wt-old')?.unverifiableTerminalCount).toBeUndefined()
|
||||
})
|
||||
})
|
||||
+1
-26
@@ -22,7 +22,7 @@ vi.mock('../../../telemetry/cohort-classifier', () => ({
|
||||
/** Exercise journal to worktree-status liveness: feed summaries never retract, but the store drops closed chats, including unanswered approvals whose retained status stays attention. */
|
||||
const WORKTREE_ID = 'repo-1::/workspace/app'
|
||||
const SESSION = 'a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d'
|
||||
const SUBJECT = makeStructuredAgentStatusSubject(
|
||||
makeStructuredAgentStatusSubject(
|
||||
{
|
||||
executionHostId: 'local',
|
||||
wslDistro: null,
|
||||
@@ -164,29 +164,4 @@ describe('worktree ps and a closed structured chat', () => {
|
||||
|
||||
expect(worktreeFor(store).agents).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('keeps an aged host-held approval state authoritative', async () => {
|
||||
const { store, published } = await awaitingApproval()
|
||||
const aged = {
|
||||
...published.at(-1)!,
|
||||
hostExecutionOwned: true as const,
|
||||
updatedAt: Date.now() - 30 * 60 * 1000 - 1
|
||||
}
|
||||
store.ingestStructuredStatus(aged, SUBJECT)
|
||||
const row = worktreeFor(store)
|
||||
expect(row.agents).toHaveLength(1)
|
||||
expect(row.agents[0]?.state).toBe('blocked')
|
||||
expect(row.status).toBe('permission')
|
||||
expect(row.agents[0]?.updatedAt).toBe(aged.updatedAt)
|
||||
})
|
||||
|
||||
it('lets an aged approval decay once the host no longer owns the child', async () => {
|
||||
const { store, published } = await awaitingApproval()
|
||||
const { hostExecutionOwned: _owned, ...held } = published.at(-1)!
|
||||
store.ingestStructuredStatus({ ...held, updatedAt: Date.now() - 30 * 60 * 1000 - 1 }, SUBJECT)
|
||||
const row = worktreeFor(store)
|
||||
expect(row.agents).toHaveLength(1)
|
||||
expect(row.agents[0]?.state).toBe('blocked')
|
||||
expect(row.status).toBe('inactive')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -313,25 +313,6 @@ describe('successful scan cache on timeout', () => {
|
||||
}
|
||||
)
|
||||
|
||||
it('does not reuse failed cached local rows on timeout', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const { runtime, repo, successfulScan } = cachedScanFixture(null)
|
||||
runtime.scanGit
|
||||
.mockResolvedValueOnce({ ...successfulScan, ok: false })
|
||||
.mockImplementation(neverSettles)
|
||||
await runtime.scanRows(repo)
|
||||
await vi.advanceTimersByTimeAsync(31_000)
|
||||
|
||||
const result = await advancePastRepoScanBudget(runtime.scanRows(repo))
|
||||
|
||||
expect(result.rows[0]).toMatchObject({ branch: '', head: '', displayName: 'app' })
|
||||
expect(result.ok).toBe(false)
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps a failed-removal checkout while the successful Git result awaits its listing overlay', async () => {
|
||||
vi.useFakeTimers()
|
||||
const checkoutExists = vi
|
||||
@@ -374,45 +355,6 @@ describe('worktree.ps on a degraded repo scan', () => {
|
||||
listWorktreesStrictMock.mockResolvedValue([])
|
||||
})
|
||||
|
||||
it('keeps persisted worktrees when a remote scan stalls past the per-repo budget', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const runtime = new OrcaRuntimeService(
|
||||
partialRuntimeStore(makeStore({ connectionId: 'ssh-remote-1' }))
|
||||
)
|
||||
|
||||
const result = await advancePastRepoScanBudget(runtime.getWorktreePs(10_000))
|
||||
|
||||
expect(result.worktrees.map((worktree) => worktree.worktreeId)).toContain(WORKTREE_ID)
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps persisted worktrees when a remote repo is unreachable', async () => {
|
||||
const runtime = new OrcaRuntimeService(
|
||||
partialRuntimeStore(makeStore({ connectionId: 'ssh-remote-1' }))
|
||||
)
|
||||
|
||||
const result = await runtime.getWorktreePs(10_000)
|
||||
|
||||
expect(result.worktrees.map((worktree) => worktree.worktreeId)).toContain(WORKTREE_ID)
|
||||
})
|
||||
|
||||
it('keeps persisted worktrees when a local scan stalls past the per-repo budget', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
listWorktreesStrictMock.mockImplementation(neverSettles)
|
||||
const runtime = new OrcaRuntimeService(partialRuntimeStore(makeStore()))
|
||||
|
||||
const result = await advancePastRepoScanBudget(runtime.getWorktreePs(10_000))
|
||||
|
||||
expect(result.worktrees.map((worktree) => worktree.worktreeId)).toContain(WORKTREE_ID)
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
// withTimeout also falls back on rejection; absorb real scan failures before they can restore stale rows.
|
||||
it('treats a rejected scan as a real answer instead of restoring persisted worktrees', async () => {
|
||||
listWorktreesStrictMock.mockRejectedValue(new Error('git unavailable'))
|
||||
@@ -423,47 +365,6 @@ describe('worktree.ps on a degraded repo scan', () => {
|
||||
expect(result.worktrees.map((worktree) => worktree.worktreeId)).not.toContain(WORKTREE_ID)
|
||||
})
|
||||
|
||||
it('still reports selector_not_found for a local worktree when the scan rejects', async () => {
|
||||
listWorktreesStrictMock.mockRejectedValue(new Error('git unavailable'))
|
||||
const runtime = new OrcaRuntimeService(partialRuntimeStore(makeStore()))
|
||||
|
||||
await expect(runtime.showManagedWorktree(`id:${WORKTREE_ID}`)).rejects.toThrow(
|
||||
'selector_not_found'
|
||||
)
|
||||
})
|
||||
|
||||
it('caches a zero-row local scan instead of rescanning on every poll', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
listWorktreesStrictMock.mockResolvedValue([])
|
||||
const runtime = new OrcaRuntimeService(partialRuntimeStore(makeStore()))
|
||||
|
||||
await runtime.getWorktreePs(10_000)
|
||||
await vi.advanceTimersByTimeAsync(2_000)
|
||||
await runtime.getWorktreePs(10_000)
|
||||
|
||||
expect(listWorktreesStrictMock).toHaveBeenCalledTimes(1)
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('caches a failed local scan instead of respawning Git on every poll', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
listWorktreesStrictMock.mockRejectedValue(new Error('spawn git EAGAIN'))
|
||||
const runtime = new OrcaRuntimeService(partialRuntimeStore(makeStore()))
|
||||
|
||||
await runtime.getWorktreePs(10_000)
|
||||
await vi.advanceTimersByTimeAsync(2_000)
|
||||
await runtime.getWorktreePs(10_000)
|
||||
|
||||
expect(listWorktreesStrictMock).toHaveBeenCalledTimes(1)
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('does not overwrite another execution host’s healthy rows when this host’s scan stalls', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
@@ -529,21 +430,6 @@ describe('worktree.ps on a degraded repo scan', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('does not re-spawn the git scan on every poll while a scan stays stalled', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
listWorktreesStrictMock.mockImplementation(neverSettles)
|
||||
const runtime = new OrcaRuntimeService(partialRuntimeStore(makeStore()))
|
||||
|
||||
await advancePastRepoScanBudget(runtime.getWorktreePs(10_000))
|
||||
await advancePastRepoScanBudget(runtime.getWorktreePs(10_000))
|
||||
|
||||
expect(listWorktreesStrictMock).toHaveBeenCalledTimes(1)
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('drops and prunes a worktree that a healthy scan no longer reports', async () => {
|
||||
const removeWorktreeLineage = vi.fn()
|
||||
listWorktreesStrictMock.mockResolvedValue([
|
||||
@@ -578,24 +464,6 @@ describe('worktree.ps on a degraded repo scan', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('does not re-read persisted worktree metadata on a healthy scan', async () => {
|
||||
listWorktreesStrictMock.mockResolvedValue([
|
||||
{ path: REPO_PATH, head: 'abc', branch: 'main', isBare: false, isMainWorktree: true }
|
||||
])
|
||||
const store = makeStore()
|
||||
const getWorktreeMetaForHost = vi.fn((id: string, hostId: string) =>
|
||||
hostId === 'local' ? store.getWorktreeMeta(id) : undefined
|
||||
)
|
||||
const runtime = new OrcaRuntimeService(
|
||||
partialRuntimeStore({ ...store, getWorktreeMetaForHost })
|
||||
)
|
||||
|
||||
await runtime.getWorktreePs(10_000)
|
||||
|
||||
expect(getWorktreeMetaForHost).toHaveBeenCalledWith(MAIN_WORKTREE_ID, 'local')
|
||||
expect(store.getAllWorktreeMeta).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('lists restored worktrees while a scan is stalled and still hides agent scratch', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
|
||||
@@ -105,15 +105,6 @@ describe('worktree.ps host coverage', () => {
|
||||
])
|
||||
})
|
||||
|
||||
it('names every host the page covers', async () => {
|
||||
const runtime = new OrcaRuntimeService(makeStore() as never)
|
||||
|
||||
const result = await runtime.getWorktreePs(10_000)
|
||||
|
||||
expect(result.hostScope?.hostIds).toEqual(['local', `ssh:${SSH_CONNECTION_ID}`])
|
||||
expect(result.hostScope?.omittedHostIds).toEqual([])
|
||||
})
|
||||
|
||||
it('keeps a remote row in the page when the cap cannot hold every local row', async () => {
|
||||
const runtime = new OrcaRuntimeService(makeStore() as never)
|
||||
|
||||
|
||||
@@ -81,42 +81,6 @@ describe('stopMissingWorktreeTerminals', () => {
|
||||
expect(localProvider.shutdown).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('still stops graph-visible sessions when the owning provider is unavailable', async () => {
|
||||
const deletedId = 'repo-1::/workspace/deleted'
|
||||
const runtime = createRuntime()
|
||||
|
||||
const result = await stopMissingWorktreeTerminals(
|
||||
{ ...localRepo, connectionId: 'ssh-1' },
|
||||
[deletedId],
|
||||
[],
|
||||
{
|
||||
runtime,
|
||||
getLocalProvider: () => null
|
||||
}
|
||||
)
|
||||
|
||||
expect(result).toEqual({ stoppedWorktreeIds: [deletedId] })
|
||||
expect(runtime.stopTerminalsForWorktree).toHaveBeenCalledWith(deletedId, {
|
||||
resolvedWorktreeId: deletedId,
|
||||
resolvedConnectionId: 'ssh-1'
|
||||
})
|
||||
})
|
||||
|
||||
// Per-workspace scans cost O(N) round trips carrying O(N²) rows; share one host inventory.
|
||||
it('enumerates the host once for the whole sweep', async () => {
|
||||
const ids = Array.from({ length: 25 }, (_, index) => `repo-1::/workspace/wt-${index}`)
|
||||
const provider = createProvider(ids.map((id) => `${id}@@session`))
|
||||
|
||||
const result = await stopMissingWorktreeTerminals(localRepo, ids, [], {
|
||||
runtime: createRuntime(),
|
||||
getLocalProvider: () => provider
|
||||
})
|
||||
|
||||
expect(result.stoppedWorktreeIds).toHaveLength(ids.length)
|
||||
expect(provider.listProcesses).toHaveBeenCalledTimes(1)
|
||||
expect(provider.shutdown).toHaveBeenCalledTimes(ids.length)
|
||||
})
|
||||
|
||||
it.each([false, true])(
|
||||
'stops multiple workspaces through scoped daemon listings (unrelated duplicate: %s)',
|
||||
async (duplicate) => {
|
||||
@@ -148,63 +112,6 @@ describe('stopMissingWorktreeTerminals', () => {
|
||||
}
|
||||
)
|
||||
|
||||
it('keeps provider method binding intact while batching', async () => {
|
||||
class PrototypeProvider {
|
||||
listCalls = 0
|
||||
shutdownCalls: string[] = []
|
||||
private readonly sessions: { id: string; cwd: string; title: string }[]
|
||||
constructor(sessionIds: string[]) {
|
||||
this.sessions = sessionIds.map((id) => ({ id, cwd: '/workspace', title: 'shell' }))
|
||||
}
|
||||
async listProcesses(): Promise<{ id: string; cwd: string; title: string }[]> {
|
||||
this.listCalls += 1
|
||||
return this.sessions
|
||||
}
|
||||
async shutdown(sessionId: string): Promise<void> {
|
||||
this.shutdownCalls.push(sessionId)
|
||||
}
|
||||
}
|
||||
const ids = ['repo-1::/workspace/a', 'repo-1::/workspace/b', 'repo-1::/workspace/c']
|
||||
const provider = new PrototypeProvider(ids.map((id) => `${id}@@session`))
|
||||
|
||||
const result = await stopMissingWorktreeTerminals(localRepo, ids, [], {
|
||||
runtime: createRuntime(),
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the sweep calls only listProcesses and shutdown, which this class implements.
|
||||
getLocalProvider: () => provider as unknown as IPtyProvider
|
||||
})
|
||||
|
||||
expect(result.stoppedWorktreeIds).toHaveLength(ids.length)
|
||||
expect(provider.listCalls).toBe(1)
|
||||
expect(provider.shutdownCalls).toHaveLength(ids.length)
|
||||
})
|
||||
|
||||
// Provider-internal this.listProcesses must bypass the sweep cache and read the live host.
|
||||
it('does not serve the shared snapshot to provider-internal listProcesses', async () => {
|
||||
class SelfListingProvider {
|
||||
listCalls = 0
|
||||
constructor(private readonly sessions: { id: string; cwd: string; title: string }[]) {}
|
||||
async listProcesses(): Promise<{ id: string; cwd: string; title: string }[]> {
|
||||
this.listCalls += 1
|
||||
return this.sessions
|
||||
}
|
||||
async shutdown(): Promise<void> {
|
||||
await this.listProcesses()
|
||||
}
|
||||
}
|
||||
const ids = ['repo-1::/workspace/a', 'repo-1::/workspace/b']
|
||||
const provider = new SelfListingProvider(
|
||||
ids.map((id) => ({ id: `${id}@@session`, cwd: '/workspace', title: 'shell' }))
|
||||
)
|
||||
|
||||
await stopMissingWorktreeTerminals(localRepo, ids, [], {
|
||||
runtime: createRuntime(),
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the sweep calls only listProcesses and shutdown, which this class implements.
|
||||
getLocalProvider: () => provider as unknown as IPtyProvider
|
||||
})
|
||||
|
||||
expect(provider.listCalls).toBe(1 + ids.length)
|
||||
})
|
||||
|
||||
it('does not reuse a failed process scan', async () => {
|
||||
const ids = ['repo-1::/workspace/a', 'repo-1::/workspace/b']
|
||||
const listProcesses = vi
|
||||
|
||||
@@ -262,16 +262,6 @@ describe.skipIf(process.platform !== 'darwin')('a worktree delete Git fails part
|
||||
beginTerminalInstall(worktreePath)()
|
||||
})
|
||||
|
||||
it('keeps the leftover listed with Git’s error after an in-session delete fails', async () => {
|
||||
const error = await failInSession()
|
||||
|
||||
expect(String(error)).toMatch(/Operation not permitted/)
|
||||
expect(await isRegistered(worktreePath)).toBe(false)
|
||||
expect(await listedRows()).toEqual([
|
||||
{ path: worktreePath, removalError: expect.stringMatching(/Operation not permitted/) }
|
||||
])
|
||||
})
|
||||
|
||||
it('does not retry it at the next start', async () => {
|
||||
await failStartupFinish()
|
||||
_resetPendingWorktreeRemovalsForTests()
|
||||
@@ -332,25 +322,6 @@ describe.skipIf(process.platform !== 'darwin')('a worktree delete Git fails part
|
||||
])
|
||||
})
|
||||
|
||||
it('deletes the merged branch when the delete fails, and only the folder is left to the user', async () => {
|
||||
await failInSession()
|
||||
|
||||
expect(await git(['branch', '--list', 'feature'])).toBe('')
|
||||
expect(existsSync(lockedFile)).toBe(true)
|
||||
expect(removeHostTree).not.toHaveBeenCalled()
|
||||
expect(await listedRows()).toEqual([
|
||||
{ path: worktreePath, removalError: expect.stringMatching(/Operation not permitted/) }
|
||||
])
|
||||
|
||||
// Once the folder is gone, listing finishes the remaining record.
|
||||
await setImmutable(false)
|
||||
await rm(worktreePath, { recursive: true })
|
||||
expect(await listedRows()).toEqual([])
|
||||
await vi.waitFor(async () => expect(await readWorktreeRemovalRecords(recordsDir)).toEqual([]))
|
||||
expect(await git(['branch', '--list', 'feature'])).toBe('')
|
||||
expect(await git(['worktree', 'list', '--porcelain'])).not.toContain(worktreePath)
|
||||
})
|
||||
|
||||
it('leaves another worktree registered while its folder is missing, as on an unmounted drive', async () => {
|
||||
const parent = join(scratchDir, 'volume')
|
||||
const other = join(parent, 'other')
|
||||
@@ -406,15 +377,6 @@ describe.skipIf(process.platform !== 'darwin')('a worktree delete Git fails part
|
||||
expect(existsSync(lockedFile)).toBe(true)
|
||||
})
|
||||
|
||||
it('removes the push-target remote Orca added when the startup finish fails', async () => {
|
||||
const pushTarget = await addOrcaPushTargetRemote()
|
||||
|
||||
expect(String(await finishAtStartup([], pushTarget))).toMatch(LOCKED_FILE_ERROR)
|
||||
|
||||
expect(await git(['remote'])).not.toContain(pushTarget.remoteName)
|
||||
expect(existsSync(lockedFile)).toBe(true)
|
||||
})
|
||||
|
||||
it('keeps an unmerged branch when the delete fails, as a normal delete does', async () => {
|
||||
await writeFile(join(worktreePath, 'work.txt'), 'work\n')
|
||||
await git(['add', 'work.txt'], worktreePath)
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
// Fragments stay side-effect ordered: mocks, then lifecycle, then fixtures.
|
||||
await import('../../orca-runtime-tests/runtime-test-module-registration.spec')
|
||||
const { OrcaRuntimeService } = await import('../../orca-runtime')
|
||||
await import('../../orca-runtime-tests/orca-runtime-test-lifecycle.spec')
|
||||
const { createFolderWorkspaceRuntimeStore, makeFolderWorkspace } =
|
||||
await import('../../orca-runtime-tests/orca-runtime-test-fixtures.spec')
|
||||
|
||||
describe('deleting a folder workspace', () => {
|
||||
it('announces the removal to worktree lifecycle listeners under its folder key', async () => {
|
||||
const folderWorkspace = makeFolderWorkspace({ folderPath: '/tmp/folder-ws' })
|
||||
const runtime = new OrcaRuntimeService({
|
||||
...createFolderWorkspaceRuntimeStore(folderWorkspace),
|
||||
removeFolderWorkspace: () => true
|
||||
})
|
||||
const listener = vi.fn()
|
||||
runtime.onWorktreeLifecycle(listener)
|
||||
await expect(runtime.deleteFolderWorkspace(folderWorkspace.id)).resolves.toEqual({
|
||||
deleted: true
|
||||
})
|
||||
expect(listener).toHaveBeenCalledWith({
|
||||
kind: 'removed',
|
||||
worktreeId: `folder:${folderWorkspace.id}`,
|
||||
path: '/tmp/folder-ws'
|
||||
})
|
||||
})
|
||||
})
|
||||
-26
@@ -783,17 +783,6 @@ describe('finishing an interrupted worktree removal after a restart', () => {
|
||||
expect(records).toMatchObject([{ failure: { message: expect.any(String) } }])
|
||||
})
|
||||
|
||||
it('deletes the branch when Git finished the checkout but the quit came before the branch', async () => {
|
||||
await git(['worktree', 'remove', worktreePath])
|
||||
|
||||
const { outcome, purged, records } = await finishAfterRestart()
|
||||
|
||||
expect(outcome).toMatchObject({ status: 'removed' })
|
||||
expect(records).toEqual([])
|
||||
expect(await git(['branch', '--list', 'feature'])).toBe('')
|
||||
expect(purged).toEqual([`repo-1::${worktreePath}`])
|
||||
})
|
||||
|
||||
it('keeps an unmerged branch, as a normal removal does', async () => {
|
||||
await writeFile(join(worktreePath, 'work.txt'), 'work\n')
|
||||
await git(['add', '-A'], worktreePath)
|
||||
@@ -818,21 +807,6 @@ describe('finishing an interrupted worktree removal after a restart', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('treats a removal that fully finished as done', async () => {
|
||||
const head = (await git(['rev-parse', 'feature'])).trim()
|
||||
await git(['worktree', 'remove', worktreePath])
|
||||
await git(['branch', '-d', 'feature'])
|
||||
|
||||
const { outcome, purged, records } = await finishAfterRestart({ head })
|
||||
|
||||
expect(records).toEqual([])
|
||||
expect(outcome).toMatchObject({ status: 'removed' })
|
||||
expect(
|
||||
outcome && 'preservedBranch' in outcome ? outcome.preservedBranch : undefined
|
||||
).toBeUndefined()
|
||||
expect(purged).toEqual([`repo-1::${worktreePath}`])
|
||||
})
|
||||
|
||||
it('drops a record whose repo Orca no longer has', async () => {
|
||||
vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
|
||||
|
||||
@@ -17,10 +17,9 @@ const {
|
||||
isProvenLiveStructuredSessionRemovalError,
|
||||
isUnstoppedPtyRemovalError
|
||||
} = await import('../../../../shared/worktree/removal')
|
||||
const { listStructuredSessionsForWorktree } = await import('./structured-session-worktree-teardown')
|
||||
await import('./structured-session-worktree-teardown')
|
||||
|
||||
const WORKTREE = 'repo_1::/tmp/wt-a'
|
||||
const OTHER_WORKTREE = 'repo_1::/tmp/wt-b'
|
||||
|
||||
function record(
|
||||
sessionId: string,
|
||||
@@ -188,39 +187,6 @@ describe('worktree teardown and structured agent sessions', () => {
|
||||
hostRef.current = null
|
||||
})
|
||||
|
||||
it('finds sessions by workspace, and ignores a sibling worktree', () => {
|
||||
installHost({ records: [record('s1', WORKTREE), record('s2', OTHER_WORKTREE)] })
|
||||
expect(listStructuredSessionsForWorktree(WORKTREE, {})).toEqual({
|
||||
members: [{ sessionId: 's1', agent: 'claude' }],
|
||||
live: [{ sessionId: 's1', agent: 'claude' }]
|
||||
})
|
||||
})
|
||||
|
||||
it('counts a chat with no attached child as a member but never as live', () => {
|
||||
// Chats at rest remain workspace members even when no child is attached.
|
||||
installHost({ records: [record('s1', WORKTREE)], detached: new Set(['s1']) })
|
||||
expect(listStructuredSessionsForWorktree(WORKTREE, {})).toEqual({
|
||||
members: [{ sessionId: 's1', agent: 'claude' }],
|
||||
live: []
|
||||
})
|
||||
})
|
||||
|
||||
it('closes a live session on an ordinary removal instead of refusing it', async () => {
|
||||
// Structured sessions appear on none of the three PTY surfaces; ordinary removal must close them separately.
|
||||
const host = installHost({ records: [record('s1', WORKTREE)] })
|
||||
await expect(killAllProcessesForWorktree(WORKTREE, destructiveDeps())).resolves.toMatchObject({
|
||||
structuredStopped: 1
|
||||
})
|
||||
expect(host.closed).toEqual(['s1'])
|
||||
})
|
||||
|
||||
it('refuses only when the close does not settle', async () => {
|
||||
installHost({ records: [record('s1', WORKTREE)], stuck: new Set(['s1']) })
|
||||
await expect(killAllProcessesForWorktree(WORKTREE, destructiveDeps())).rejects.toThrow(
|
||||
/still live: 1 agent session \(claude\)/
|
||||
)
|
||||
})
|
||||
|
||||
it('puts the chat tab back when the removal refuses over the session', async () => {
|
||||
// A refused removal preserves the workspace and must restore its chat tabs.
|
||||
const host = installHost({
|
||||
@@ -247,74 +213,6 @@ describe('worktree teardown and structured agent sessions', () => {
|
||||
warn.mockRestore()
|
||||
})
|
||||
|
||||
it('leaves the chat tab dropped for a folder-workspace removal, which never refuses', async () => {
|
||||
// Folder removal cannot refuse, so do not restore tabs for the forgotten workspace.
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
const host = installHost({
|
||||
records: [record('s1', WORKTREE)],
|
||||
stuck: new Set(['s1']),
|
||||
visible: ['s1']
|
||||
})
|
||||
await killAllProcessesForWorktree(WORKTREE, {
|
||||
localProvider,
|
||||
includeProviderInventory: false as const,
|
||||
includeLocalRegistry: false as const,
|
||||
closeStructuredSessions: true
|
||||
})
|
||||
expect([...host.visible]).toEqual([])
|
||||
warn.mockRestore()
|
||||
})
|
||||
|
||||
it('names the force escape hatch in the refusal, like the unstopped-PTY gate', async () => {
|
||||
installHost({ records: [record('s1', WORKTREE)], stuck: new Set(['s1']) })
|
||||
await expect(killAllProcessesForWorktree(WORKTREE, destructiveDeps())).rejects.toThrow(/force/i)
|
||||
})
|
||||
|
||||
it('classifies for the desktop Force Delete button, not just the CLI', async () => {
|
||||
// Ordinary desktop deletion already passes force for dirty files; the classifier must separately offer the running-session waiver (#11960).
|
||||
installHost({ records: [record('s1', WORKTREE)], stuck: new Set(['s1']) })
|
||||
const error = await killAllProcessesForWorktree(WORKTREE, destructiveDeps()).catch(
|
||||
(thrown: Error) => thrown.message
|
||||
)
|
||||
expect(classifyWorktreeForceDeleteReason(error as string, true)).toBe('running-agent-session')
|
||||
// Once waived, suppress the button just as for unstopped PTYs.
|
||||
expect(classifyWorktreeForceDeleteReason(error as string, true, true)).toBeNull()
|
||||
})
|
||||
|
||||
it('keeps session ids out of a message users and agents read', async () => {
|
||||
// Session ids lead to mailbox-gating pane keys; CLI/toast text must expose only counts and providers.
|
||||
installHost({ records: [record('s1', WORKTREE)], stuck: new Set(['s1']) })
|
||||
const error = await killAllProcessesForWorktree(WORKTREE, destructiveDeps()).catch(
|
||||
(thrown: Error) => thrown.message
|
||||
)
|
||||
expect(error).not.toContain('s1')
|
||||
expect(error).toContain('1 agent session (claude)')
|
||||
})
|
||||
|
||||
it('closes best-effort for a folder-workspace removal, which requires no stop proof', async () => {
|
||||
// Folder paths forget shared roots without stop proof; close best-effort without refusing removal.
|
||||
const host = installHost({ records: [record('s1', WORKTREE)] })
|
||||
await expect(
|
||||
killAllProcessesForWorktree(WORKTREE, {
|
||||
localProvider,
|
||||
includeProviderInventory: false,
|
||||
includeLocalRegistry: false,
|
||||
closeStructuredSessions: true
|
||||
})
|
||||
).resolves.toMatchObject({ structuredStopped: 1 })
|
||||
expect(host.closed).toEqual(['s1'])
|
||||
})
|
||||
|
||||
it('closes them under force instead of orphaning the child', async () => {
|
||||
const host = installHost({ records: [record('s1', WORKTREE), record('s2', WORKTREE)] })
|
||||
const result = await killAllProcessesForWorktree(
|
||||
WORKTREE,
|
||||
destructiveDeps({ allowUnverifiedStop: true })
|
||||
)
|
||||
expect(host.closed).toEqual(['s1', 's2'])
|
||||
expect(result.structuredStopped).toBe(2)
|
||||
})
|
||||
|
||||
it('still removes under force when a close does not settle, and says so', async () => {
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
const retired: string[] = []
|
||||
@@ -358,18 +256,6 @@ describe('worktree teardown and structured agent sessions', () => {
|
||||
expect(retired).toEqual(['s1'])
|
||||
})
|
||||
|
||||
it('leaves the best-effort reconciliation paths alone', async () => {
|
||||
// Those callers repair state and delete nothing, so a refusal there would wedge a repair.
|
||||
installHost({ records: [record('s1', WORKTREE)] })
|
||||
await expect(
|
||||
killAllProcessesForWorktree(WORKTREE, {
|
||||
localProvider,
|
||||
includeProviderInventory: false,
|
||||
includeLocalRegistry: false
|
||||
})
|
||||
).resolves.toMatchObject({ runtimeStopped: 0 })
|
||||
})
|
||||
|
||||
it('leaves a same-id workspace on another execution host alone', async () => {
|
||||
// A workspace id is `repoId::path` with no host component, so the local, SSH and paired-runtime
|
||||
// copies of one id are DIFFERENT workspaces. Unfenced, deleting the local one closed a chat
|
||||
@@ -383,25 +269,6 @@ describe('worktree teardown and structured agent sessions', () => {
|
||||
expect(host.closed).toEqual([])
|
||||
})
|
||||
|
||||
it('reads an explicit local fence the way the PTY sweeps do', () => {
|
||||
// This helper reuses the PTY fence's own type, so the two cannot answer `null` differently:
|
||||
// there it means this machine, and it has to mean this machine here. ABSENT is the one
|
||||
// deliberate difference — no fence at all for the PTY sweeps, narrowed to local here, because
|
||||
// a single-host-id comparison cannot express match-all and closing every host's chats is
|
||||
// destructive. Latent today only because `WorktreeTeardownDeps` cannot yet carry the `null`.
|
||||
installHost({
|
||||
records: [record('s1', WORKTREE, { executionHostId: 'ssh:host-a' }), record('s2', WORKTREE)]
|
||||
})
|
||||
const local = {
|
||||
members: [{ sessionId: 's2', agent: 'claude' }],
|
||||
live: [{ sessionId: 's2', agent: 'claude' }]
|
||||
}
|
||||
expect(listStructuredSessionsForWorktree(WORKTREE, { resolvedConnectionId: null })).toEqual(
|
||||
local
|
||||
)
|
||||
expect(listStructuredSessionsForWorktree(WORKTREE, {})).toEqual(local)
|
||||
})
|
||||
|
||||
it('closes only the session on the host the removal resolved to', async () => {
|
||||
const host = installHost({
|
||||
records: [record('s1', WORKTREE, { executionHostId: 'ssh:host-a' }), record('s2', WORKTREE)]
|
||||
@@ -415,21 +282,6 @@ describe('worktree teardown and structured agent sessions', () => {
|
||||
expect(host.closed).toEqual(['s1'])
|
||||
})
|
||||
|
||||
it('names only the sessions that stayed, and every provider still there', async () => {
|
||||
installHost({
|
||||
records: [
|
||||
record('s1', WORKTREE),
|
||||
record('s2', WORKTREE, { provider: 'codex' }),
|
||||
record('s3', WORKTREE)
|
||||
],
|
||||
stuck: new Set(['s2', 's3'])
|
||||
})
|
||||
const error = await killAllProcessesForWorktree(WORKTREE, destructiveDeps()).catch(
|
||||
(thrown: Error) => thrown.message
|
||||
)
|
||||
expect(error).toContain('still live: 2 agent sessions (claude, codex)')
|
||||
})
|
||||
|
||||
it('names the unconfirmed sessions too, instead of counting only the live ones', async () => {
|
||||
// The PTY sibling may drop everything outside its live list because a fresh inventory PROVED
|
||||
// those exited. Nothing proves that here: an `unverifiable` session is unclosed as well, so
|
||||
@@ -449,46 +301,6 @@ describe('worktree teardown and structured agent sessions', () => {
|
||||
expect(isProvenLiveStructuredSessionRemovalError(error as string)).toBe(true)
|
||||
})
|
||||
|
||||
it('still reports what it closed when a forced removal skips the PTY verdict', async () => {
|
||||
// A sweep that fails outright short-circuits the per-PTY verdict — but not the structured
|
||||
// close that already ran, so the count has to survive that return or the removal log claims
|
||||
// `structured=0` for chats it just ended.
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
const runtime: TeardownRuntime = {
|
||||
...presentationRuntime(),
|
||||
getPtyLivenessVerdict: () => null,
|
||||
stopTerminalsForWorktree: async () => {
|
||||
throw new Error('the terminal sweep died')
|
||||
}
|
||||
}
|
||||
const host = installHost({ records: [record('s1', WORKTREE)] })
|
||||
const result = await killAllProcessesForWorktree(WORKTREE, {
|
||||
...destructiveDeps({ allowUnverifiedStop: true }),
|
||||
runtime
|
||||
})
|
||||
expect(host.closed).toEqual(['s1'])
|
||||
expect(result.structuredStopped).toBe(1)
|
||||
warn.mockRestore()
|
||||
})
|
||||
|
||||
it('separates a close it could not confirm from one it watched stay attached', async () => {
|
||||
// `src/shared/worktree/removal.ts` keeps these two apart on purpose: a user waiving "we could
|
||||
// not confirm" is making a different decision than one discarding a conversation Orca just saw
|
||||
// running. The toast branches on this marker, so flattening them makes one of the two a lie.
|
||||
installHost({ records: [record('s1', WORKTREE)], unverifiable: new Set(['s1']) })
|
||||
const unconfirmed = await killAllProcessesForWorktree(WORKTREE, destructiveDeps()).catch(
|
||||
(thrown: Error) => thrown.message
|
||||
)
|
||||
expect(unconfirmed).toContain('could not confirm these closed: 1 agent session (claude)')
|
||||
expect(isProvenLiveStructuredSessionRemovalError(unconfirmed as string)).toBe(false)
|
||||
|
||||
installHost({ records: [record('s1', WORKTREE)], stuck: new Set(['s1']) })
|
||||
const live = await killAllProcessesForWorktree(WORKTREE, destructiveDeps()).catch(
|
||||
(thrown: Error) => thrown.message
|
||||
)
|
||||
expect(isProvenLiveStructuredSessionRemovalError(live as string)).toBe(true)
|
||||
})
|
||||
|
||||
it('refuses in agent-session wording when the close outlives the sweep budget', async () => {
|
||||
vi.useFakeTimers({ toFake: ['Date', 'setTimeout', 'clearTimeout'] })
|
||||
try {
|
||||
@@ -530,61 +342,6 @@ describe('worktree teardown and structured agent sessions', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('names only the sessions still open when the budget expires mid-close', async () => {
|
||||
vi.useFakeTimers({ toFake: ['Date', 'setTimeout', 'clearTimeout'] })
|
||||
try {
|
||||
// Record serial-loop progress before timeout so already-closed children are not reported as unclosed.
|
||||
installHost({
|
||||
records: [record('s1', WORKTREE), record('s2', WORKTREE, { provider: 'codex' })],
|
||||
closeGates: { s2: new Promise<void>(() => {}) }
|
||||
})
|
||||
const outcome = killAllProcessesForWorktree(
|
||||
WORKTREE,
|
||||
destructiveDeps({ timeoutMs: 40 })
|
||||
).catch((thrown: Error) => thrown.message)
|
||||
await vi.advanceTimersByTimeAsync(40)
|
||||
const error = await outcome
|
||||
expect(error).toContain('could not confirm these closed: 1 agent session (codex)')
|
||||
expect(error).not.toContain('claude')
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('counts the closes that landed before the budget expired', async () => {
|
||||
vi.useFakeTimers({ toFake: ['Date', 'setTimeout', 'clearTimeout'] })
|
||||
// Preserve completed-close counts in timeout logs.
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
const slowClose = new Promise<void>((resolve) => {
|
||||
setTimeout(resolve, 300)
|
||||
})
|
||||
try {
|
||||
installHost({
|
||||
records: [record('s1', WORKTREE), record('s2', WORKTREE, { provider: 'codex' })],
|
||||
closeGates: { s2: slowClose }
|
||||
})
|
||||
const outcome = killAllProcessesForWorktree(
|
||||
WORKTREE,
|
||||
destructiveDeps({ allowUnverifiedStop: true, timeoutMs: 40 })
|
||||
)
|
||||
void outcome.catch(() => undefined)
|
||||
await vi.advanceTimersByTimeAsync(300)
|
||||
const result = await outcome
|
||||
expect(result.structuredStopped).toBe(1)
|
||||
expect(structuredSessionWarning(warn)).toContain(
|
||||
'could not confirm these closed: 1 agent session (codex)'
|
||||
)
|
||||
} finally {
|
||||
try {
|
||||
await vi.advanceTimersByTimeAsync(300)
|
||||
await slowClose
|
||||
} finally {
|
||||
warn.mockRestore()
|
||||
vi.useRealTimers()
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it('stops issuing new closes once the budget is spent', async () => {
|
||||
// Stop issuing closes at deadline; the uncancellable in-flight close and untouched tail both remain unverified.
|
||||
let releaseFirstClose: () => void = () => {}
|
||||
@@ -611,25 +368,6 @@ describe('worktree teardown and structured agent sessions', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('leaves the terminals already stopped when it refuses over a stuck session', async () => {
|
||||
// PTY sweeps run concurrently to share the deadline: structured refusal can follow already-stopped PTYs.
|
||||
// Serializing again would starve terminal stops and let force-delete remove files with open PTY handles.
|
||||
let terminalSweeps = 0
|
||||
const runtime: TeardownRuntime = {
|
||||
...presentationRuntime(),
|
||||
getPtyLivenessVerdict: () => null,
|
||||
stopTerminalsForWorktree: async () => {
|
||||
terminalSweeps += 1
|
||||
return { stopped: 2 }
|
||||
}
|
||||
}
|
||||
installHost({ records: [record('s1', WORKTREE)], stuck: new Set(['s1']) })
|
||||
await expect(
|
||||
killAllProcessesForWorktree(WORKTREE, { ...destructiveDeps(), runtime })
|
||||
).rejects.toThrow(/still live: 1 agent session \(claude\)/)
|
||||
expect(terminalSweeps).toBe(1)
|
||||
})
|
||||
|
||||
it('starts the terminal sweeps while the structured close is still in flight', async () => {
|
||||
// Starting PTY sweeps after serial structured closes would spend their shared budget before they can attempt a stop.
|
||||
let releaseClose: () => void = () => {}
|
||||
@@ -654,20 +392,6 @@ describe('worktree teardown and structured agent sessions', () => {
|
||||
await expect(removal).resolves.toMatchObject({ structuredStopped: 1 })
|
||||
})
|
||||
|
||||
it('retires the chat tab of a chat that had no child to close', async () => {
|
||||
// Chats at rest are absent from the close list; their durable tab index must not resurrect a deleted workspace.
|
||||
const host = installHost({
|
||||
records: [record('s1', WORKTREE)],
|
||||
detached: new Set(['s1']),
|
||||
visible: ['s1']
|
||||
})
|
||||
await expect(killAllProcessesForWorktree(WORKTREE, destructiveDeps())).resolves.toMatchObject({
|
||||
runtimeStopped: 0
|
||||
})
|
||||
expect(host.closed).toEqual([])
|
||||
expect([...host.visible]).toEqual([])
|
||||
})
|
||||
|
||||
it('retires it from the live tab snapshot as well as the durable index', async () => {
|
||||
// Durable index hiding alone leaves the live snapshot tab able to reattach this session.
|
||||
const retired: string[] = []
|
||||
@@ -734,32 +458,6 @@ describe('worktree teardown and structured agent sessions', () => {
|
||||
expect([...host.visible]).toEqual(['s1'])
|
||||
})
|
||||
|
||||
it('retires the chat tab under force, which deletes the workspace anyway', async () => {
|
||||
const host = installHost({
|
||||
records: [record('s1', WORKTREE)],
|
||||
detached: new Set(['s1']),
|
||||
visible: ['s1']
|
||||
})
|
||||
await killAllProcessesForWorktree(WORKTREE, destructiveDeps({ allowUnverifiedStop: true }))
|
||||
expect([...host.visible]).toEqual([])
|
||||
})
|
||||
|
||||
it('retires the chat tab for a folder-workspace removal too', async () => {
|
||||
// Forgetting folder metadata must retire detached tabs even though the shared checkout remains.
|
||||
const host = installHost({
|
||||
records: [record('s1', WORKTREE)],
|
||||
detached: new Set(['s1']),
|
||||
visible: ['s1']
|
||||
})
|
||||
await killAllProcessesForWorktree(WORKTREE, {
|
||||
localProvider,
|
||||
includeProviderInventory: false as const,
|
||||
includeLocalRegistry: false as const,
|
||||
closeStructuredSessions: true
|
||||
})
|
||||
expect([...host.visible]).toEqual([])
|
||||
})
|
||||
|
||||
it('retires a live snapshot tab when folder cleanup cannot close its child', async () => {
|
||||
const retired: string[] = []
|
||||
const host = installHost({
|
||||
@@ -849,11 +547,4 @@ describe('worktree teardown and structured agent sessions', () => {
|
||||
await killAllProcessesForWorktree(WORKTREE, destructiveDeps())
|
||||
expect([...host.visible]).toEqual(['s1'])
|
||||
})
|
||||
|
||||
it('does not block removal when no structured host is installed', async () => {
|
||||
// Reading persisted storage would force-install a host during teardown; an unavailable host proves no child.
|
||||
await expect(killAllProcessesForWorktree(WORKTREE, destructiveDeps())).resolves.toMatchObject({
|
||||
runtimeStopped: 0
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user