mirror of
https://github.com/stablyai/orca.git
synced 2026-10-20 08:03:28 +00:00
Merge remote-tracking branch 'origin/main' into brennanb2025/fix-10385-false-connected
This commit is contained in:
@@ -64,11 +64,14 @@ jobs:
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
# Why: building once avoids five parallel electron-vite builds inside
|
||||
# Playwright globalSetup, which otherwise contends for CPU/RAM on OSS
|
||||
# runners before the sharded tests even start.
|
||||
# Why: building here avoids parallel builds inside Playwright globalSetup;
|
||||
# paired-browser specs also need the standalone web bundle.
|
||||
- name: Build Electron app for E2E
|
||||
run: npx electron-vite build --mode e2e
|
||||
env:
|
||||
VITE_EXPOSE_STORE: 'true'
|
||||
run: |
|
||||
npx electron-vite build --mode e2e
|
||||
pnpm run build:web-from-renderer
|
||||
|
||||
- name: Upload E2E build output
|
||||
uses: actions/upload-artifact@v7
|
||||
|
||||
+140
-15
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"updatedAt": "2026-07-29",
|
||||
"updatedAt": "2026-07-30",
|
||||
"policy": {
|
||||
"maturityLevels": ["experimental", "soak", "blocking", "accepted-gap", "deprecated"],
|
||||
"blockingPromotion": {
|
||||
@@ -3105,7 +3105,7 @@
|
||||
"ORCA_E2E_WEB_CLIENT=1 SKIP_BUILD=1 pnpm exec playwright test tests/e2e/paired-remote-terminal-probe-gap-recovery.spec.ts --config tests/playwright.config.ts --project electron-headful --workers=1",
|
||||
"ORCA_E2E_WEB_CLIENT=1 SKIP_BUILD=1 pnpm exec playwright test tests/e2e/headless-paired-remote-terminal-stall-recovery.spec.ts --config tests/playwright.config.ts --project electron-headful --workers=1",
|
||||
"ORCA_E2E_WEB_CLIENT=1 SKIP_BUILD=1 pnpm exec playwright test tests/e2e/paired-remote-terminal-retention-memory.spec.ts --config tests/playwright.config.ts --project electron-headful --workers=1",
|
||||
"ORCA_E2E_WEB_CLIENT=1 SKIP_BUILD=1 pnpm exec playwright test tests/e2e/headless-paired-remote-terminal-retention-memory.spec.ts --config tests/playwright.config.ts --project electron-headful --workers=1",
|
||||
"ORCA_E2E_WEB_CLIENT=1 SKIP_BUILD=1 pnpm exec playwright test tests/e2e/headless-paired-remote-terminal-retention-memory.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1",
|
||||
"SKIP_BUILD=1 pnpm exec playwright test tests/e2e/terminal-parked-memory.spec.ts --config tests/playwright.config.ts --project electron-headful --workers=1"
|
||||
],
|
||||
"testFiles": [
|
||||
@@ -3170,6 +3170,7 @@
|
||||
"file": "tests/e2e/headless-paired-remote-terminal-retention-memory.spec.ts",
|
||||
"assertions": [
|
||||
"ordinary-parks paired terminals against an isolated headless Orca host",
|
||||
"cold-activates only visible paired terminals against an isolated headless host",
|
||||
"the headless host shares the zero-hidden-renderer-work and exact-restoration contract"
|
||||
]
|
||||
},
|
||||
@@ -3314,13 +3315,13 @@
|
||||
"summary": "An isolated `orca serve` host and paired web renderer exhausted one terminal stream, recovered it, and preserved the original PTY and tab without exposing readiness or pairing material."
|
||||
},
|
||||
{
|
||||
"date": "2026-07-29",
|
||||
"date": "2026-07-30",
|
||||
"runner": "local",
|
||||
"platform": "macos",
|
||||
"command": "ORCA_E2E_WEB_CLIENT=1 SKIP_BUILD=1 pnpm exec playwright test tests/e2e/headless-paired-remote-terminal-retention-memory.spec.ts --config tests/playwright.config.ts --project electron-headful --workers=1",
|
||||
"command": "ORCA_E2E_WEB_CLIENT=1 SKIP_BUILD=1 pnpm exec playwright test tests/e2e/headless-paired-remote-terminal-retention-memory.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1",
|
||||
"result": "passed",
|
||||
"durationSeconds": 14.2,
|
||||
"summary": "The isolated headless `orca serve` host passed the byte-identical six-PTY ordinary-parking, memory/lag, deep-history, same-PTY, parked-output, and post-reveal I/O oracle."
|
||||
"durationSeconds": 23,
|
||||
"summary": "The isolated headless `orca serve` host passed ordinary parking plus cold activation: deferred tabs stayed unmounted until activation, preserved PTY identity and parked output, and resumed live input."
|
||||
},
|
||||
{
|
||||
"date": "2026-07-29",
|
||||
@@ -3348,15 +3349,6 @@
|
||||
"result": "passed",
|
||||
"durationSeconds": 17.5,
|
||||
"summary": "The headed six-PTY oracle kept all warm-mounted hidden streams at zero renderer scheduler enqueues/drains under sustained output and under 500 ms timer lag, then restored the authoritative flood tail and parked/live markers exactly once."
|
||||
},
|
||||
{
|
||||
"date": "2026-07-30",
|
||||
"runner": "local",
|
||||
"platform": "macos",
|
||||
"command": "ORCA_E2E_WEB_CLIENT=1 SKIP_BUILD=1 pnpm exec playwright test tests/e2e/headless-paired-remote-terminal-retention-memory.spec.ts --config tests/playwright.config.ts --project electron-headful --workers=1",
|
||||
"result": "passed",
|
||||
"durationSeconds": 14.0,
|
||||
"summary": "The isolated headless host passed the same six-stream zero-hidden-renderer-work, bounded-lag, exact authoritative restoration, same-PTY, and continued-I/O oracle."
|
||||
}
|
||||
],
|
||||
"runtimeBudget": {
|
||||
@@ -7887,6 +7879,139 @@
|
||||
"Relay-hosted plugin provisioning, consent persistence, workers, and audit services remain out of scope and the relay registration therefore denies every provisioned identity by default."
|
||||
],
|
||||
"demotionRule": "Keep experimental or demote to protection none if the suite flakes, permits a mismatched terminal send, performs more than one inventory list per call, accepts client-supplied grants, or relay and desktop error codes diverge."
|
||||
},
|
||||
{
|
||||
"id": "ssh-port-forward.renderer-snapshot-continuity",
|
||||
"title": "SSH forwarded-port state survives stale renderer hydration",
|
||||
"maturity": "experimental",
|
||||
"protection": "partial",
|
||||
"owner": "desktop-ssh",
|
||||
"layer": "renderer-ssh-snapshot-reconciliation",
|
||||
"surfaces": [
|
||||
"SSH Ports panel",
|
||||
"forwarded-port renderer state",
|
||||
"persisted forward restoration",
|
||||
"ssh2 port forwarding",
|
||||
"system-SSH port forwarding"
|
||||
],
|
||||
"platforms": ["macos", "linux", "windows"],
|
||||
"providers": ["ssh2", "system-ssh"],
|
||||
"coveredPlatforms": ["macos"],
|
||||
"coveredProviders": ["ssh2", "system-ssh"],
|
||||
"coverageNotes": "A deterministic renderer ordering test covers stale initial snapshots independently by target and stream, including hydration after partial SSH authority reconciliation. Headed macOS Electron tests force the same startup hydration race and use an ephemeral Docker sshd, a real remote Git worktree, real remote Node listeners, real HTTP forwards, in-place relay and full transport reconnect restoration, collision rejection, scan refresh, explicit removal, and an unrelated surviving forward through both transports. The forced-system run requires a recorded OpenSSH -L invocation.",
|
||||
"motivatingLinks": ["user-reported SSH Ports panel disappearance"],
|
||||
"invariant": "For one connected SSH authority, a renderer snapshot may update Forwarded or Detected state only if no newer push for that target and stream arrived after the snapshot began. Active tunnels and persisted intent remain authoritative through scan refresh and reconnect until explicit removal or a real connection-lifecycle transition.",
|
||||
"oracle": "Hold empty initial Forwarded and Detected snapshot promises, publish live events, release the snapshots, and require each target and stream to preserve only its own newer push while applying unaffected snapshots. Reject one target's Detected snapshot and require its Forwarded snapshot plus later targets to hydrate independently. Begin another target with partial connected authority and require its snapshots to hydrate after same-watermark authority reconciliation. In headed Electron, hold an authoritative empty Forwarded snapshot across renderer reload, add a forward through the Ports panel, release and confirm the wrapped main handler resumed, then complete a later renderer-to-main listPortForwards round trip as the ordered hydration-continuation barrier. Before checking the Forwarded row, require main inventory, persisted intent, HTTP, remote process identity, and close warnings to prove the tunnel remained authoritative. Then forward two real Docker listeners, refresh detection, force an in-place relay-channel reconnect, perform a full transport reconnect, reject a bound local-port collision, remove one row, and require all signals to agree.",
|
||||
"commands": [
|
||||
"pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/hooks/useIpcEvents.test.ts src/main/ssh/ssh-port-forward.test.ts src/main/ssh/system-ssh-forward-process.test.ts src/main/ipc/ssh.test.ts src/main/ssh/ssh-relay-session.test.ts tests/e2e/helpers/ssh-port-forward-snapshot-barrier.unit.test.ts --reporter=dot",
|
||||
"pnpm exec electron-vite build --mode e2e",
|
||||
"ORCA_E2E_SSH_DOCKER=1 ORCA_E2E_FORWARD_APP_LOGS=1 SKIP_BUILD=1 pnpm exec playwright test tests/e2e/ssh-port-forward-lifecycle.spec.ts --config tests/playwright.config.ts --project electron-headful --workers=1",
|
||||
"ORCA_E2E_SSH_DOCKER=1 ORCA_SSH_FORCE_SYSTEM_TRANSPORT=1 ORCA_E2E_FORWARD_APP_LOGS=1 SKIP_BUILD=1 pnpm exec playwright test tests/e2e/ssh-port-forward-lifecycle.spec.ts --config tests/playwright.config.ts --project electron-headful --workers=1"
|
||||
],
|
||||
"testFiles": [
|
||||
"src/renderer/src/hooks/useIpcEvents.test.ts",
|
||||
"src/main/ssh/ssh-port-forward.test.ts",
|
||||
"src/main/ssh/system-ssh-forward-process.test.ts",
|
||||
"src/main/ipc/ssh.test.ts",
|
||||
"src/main/ssh/ssh-relay-session.test.ts",
|
||||
"tests/e2e/helpers/ssh-port-forward-snapshot-barrier.unit.test.ts",
|
||||
"tests/e2e/ssh-port-forward-lifecycle.spec.ts"
|
||||
],
|
||||
"assertionRefs": [
|
||||
{
|
||||
"file": "src/renderer/src/hooks/useIpcEvents.test.ts",
|
||||
"assertions": ["does not let initial SSH port snapshots overwrite newer push events"]
|
||||
},
|
||||
{
|
||||
"file": "src/main/ssh/ssh-port-forward.test.ts",
|
||||
"assertions": ["lists forwards filtered by connectionId", "removes a forward by id"]
|
||||
},
|
||||
{
|
||||
"file": "src/main/ssh/system-ssh-forward-process.test.ts",
|
||||
"assertions": [
|
||||
"does not spawn ssh when the requested local forward port is already in use",
|
||||
"sends SIGTERM then SIGKILL when the process does not exit",
|
||||
"does not resolve stop until the process exits"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/main/ipc/ssh.test.ts",
|
||||
"assertions": [
|
||||
"preserves active port forwards and live connections across handler re-registration",
|
||||
"persists desired forwards and broadcasts when an active forward closes unexpectedly"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/main/ssh/ssh-relay-session.test.ts",
|
||||
"assertions": ["cleans up port forwards on reconnect"]
|
||||
},
|
||||
{
|
||||
"file": "tests/e2e/helpers/ssh-port-forward-snapshot-barrier.unit.test.ts",
|
||||
"assertions": ["holds only the first matching request while its snapshot is unresolved"]
|
||||
},
|
||||
{
|
||||
"file": "tests/e2e/ssh-port-forward-lifecycle.spec.ts",
|
||||
"assertions": ["keeps a user-forwarded listener live across scan refresh @headful"]
|
||||
}
|
||||
],
|
||||
"evidenceRuns": [
|
||||
{
|
||||
"date": "2026-07-30",
|
||||
"runner": "local",
|
||||
"platform": "macos",
|
||||
"command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/hooks/useIpcEvents.test.ts src/main/ssh/ssh-port-forward.test.ts src/main/ssh/system-ssh-forward-process.test.ts src/main/ipc/ssh.test.ts src/main/ssh/ssh-relay-session.test.ts tests/e2e/helpers/ssh-port-forward-snapshot-barrier.unit.test.ts --reporter=dot",
|
||||
"result": "passed",
|
||||
"durationSeconds": 6.77,
|
||||
"summary": "Six renderer, main-process, and barrier lifecycle files passed with 215 tests, including deterministic stale-snapshot, rejected-stream, partial-authority, and single-capture isolation plus existing collision, delayed-exit, reconnect, persistence, re-registration, and unrelated-forward contracts."
|
||||
},
|
||||
{
|
||||
"date": "2026-07-30",
|
||||
"runner": "local",
|
||||
"platform": "macos",
|
||||
"command": "ORCA_E2E_SSH_DOCKER=1 ORCA_E2E_FORWARD_APP_LOGS=1 SKIP_BUILD=1 pnpm exec playwright test tests/e2e/ssh-port-forward-lifecycle.spec.ts --config tests/playwright.config.ts --project electron-headful --workers=1",
|
||||
"result": "passed",
|
||||
"durationSeconds": 53.7,
|
||||
"summary": "Headed Electron passed against a real Docker sshd over ssh2 after forcing the stale startup-snapshot race and exact renderer-continuation barrier, with two remote listeners, scan refresh, in-place relay and full transport reconnect restoration, collision rejection, explicit removal, independent state inventories, HTTP responses, process identity, and no live forward-close warning."
|
||||
},
|
||||
{
|
||||
"date": "2026-07-30",
|
||||
"runner": "local",
|
||||
"platform": "macos",
|
||||
"command": "ORCA_E2E_SSH_DOCKER=1 ORCA_SSH_FORCE_SYSTEM_TRANSPORT=1 ORCA_E2E_FORWARD_APP_LOGS=1 SKIP_BUILD=1 pnpm exec playwright test tests/e2e/ssh-port-forward-lifecycle.spec.ts --config tests/playwright.config.ts --project electron-headful --workers=1",
|
||||
"result": "passed",
|
||||
"durationSeconds": 66,
|
||||
"summary": "The identical stale-snapshot and headed lifecycle passed through forced system OpenSSH, with a wrapper marker proving the -L forward process executed; in-place relay and full transport reconnects restored both intents, no unexpected forward close was captured before assertions, and intentional removal completed."
|
||||
}
|
||||
],
|
||||
"runtimeBudget": {
|
||||
"p95Seconds": 180,
|
||||
"scope": "focused renderer/main contracts plus two headed Docker SSH transport runs"
|
||||
},
|
||||
"flakeHistory": {
|
||||
"status": "unknown",
|
||||
"evidence": "The deterministic contract and both real transport runs passed locally; CI and soak history are not yet available."
|
||||
},
|
||||
"redGreenEvidence": {
|
||||
"status": "complete",
|
||||
"evidence": "The identical renderer unit and headed Electron oracles fail on current main because the stale empty hydration reply becomes a second and final Forwarded write, pass with bounded per-target per-stream pending-hydration state, fail again when only that fix is removed, and pass again after restoration. In the tightened headed revert, both transports first proved main inventory, persisted intent, HTTP, remote process identity, no live close warning, and renderer continuation, then failed only at the missing Forwarded-row assertion. Both passed after rebuilding the restored candidate."
|
||||
},
|
||||
"performanceBudget": {
|
||||
"required": true,
|
||||
"evidence": "The fix adds one effect-scoped map containing only unresolved target hydrations, one O(1) boolean write per relevant existing push event, and one O(1) authority/stream check per initial snapshot. Forwarded and Detected hydrate independently so a stalled stream does not block its peer or later targets; entries are removed after both settle. It adds no polling, timers, IPC calls, scans, subprocesses, or renderer subscriptions."
|
||||
},
|
||||
"promotionCriteria": [
|
||||
"Collect 100 consecutive focused CI passes or 14 days of soak history.",
|
||||
"Run the headed topology on Linux and Windows with their native system-SSH clients.",
|
||||
"Re-run the headed snapshot barrier after Electron major-version upgrades.",
|
||||
"Keep the independent Forwarded and Detected freshness assertions and both transport runs green."
|
||||
],
|
||||
"knownGaps": [
|
||||
"Headed live evidence is macOS-only; Linux and Windows system-SSH clients were not exercised.",
|
||||
"The headed race uses Electron's private invoke-handler registry to delay the real listPortForwards handler because Electron exposes no public handler-wrapping API.",
|
||||
"The live run reconnects an existing app session but does not restart the packaged application from disk.",
|
||||
"The live topology uses an SSH Git worktree; folder-workspace behavior is covered by target-scoped renderer reconciliation rather than a second headed topology."
|
||||
],
|
||||
"demotionRule": "Keep experimental or demote if same-authority snapshots can overwrite newer pushes, reconnect silently loses persisted intent, one forward operation disturbs unrelated forwards, live HTTP diverges from renderer/main inventory, or either transport topology flakes without an identified product or harness fault."
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -34,4 +34,14 @@ describe('release E2E dispatch contract', () => {
|
||||
expect(refInput.type).toBe('string')
|
||||
expect(refInput.required).toBe(false)
|
||||
})
|
||||
|
||||
it('includes the paired-runtime web client in the shared E2E build artifact', () => {
|
||||
const buildStep = e2eWorkflow.jobs.build.steps.find(
|
||||
(step) => step.name === 'Build Electron app for E2E'
|
||||
)
|
||||
|
||||
expect(buildStep.run).toContain('electron-vite build --mode e2e')
|
||||
expect(buildStep.env.VITE_EXPOSE_STORE).toBe('true')
|
||||
expect(buildStep.run).toContain('pnpm run build:web-from-renderer')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,258 @@
|
||||
import { getDefaultUserDataPath } from '../../src/cli/runtime/metadata'
|
||||
import type { PairingOffer } from '../../src/shared/pairing'
|
||||
import { RemoteRuntimeSharedControlConnection } from '../../src/shared/remote-runtime-shared-control-connection'
|
||||
import {
|
||||
resolveEnvironment,
|
||||
resolveEnvironmentPairingOffer
|
||||
} from '../../src/shared/runtime-environment-store'
|
||||
import type { MemorySnapshot, RuntimeStatus } from '../../src/shared/types'
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const environmentName = process.env.ORCA_PROBE_ENVIRONMENT_NAME
|
||||
if (!environmentName) {
|
||||
throw new Error('ORCA_PROBE_ENVIRONMENT_NAME is required')
|
||||
}
|
||||
const userDataPath = getDefaultUserDataPath()
|
||||
const environment = resolveEnvironment(userDataPath, environmentName)
|
||||
const pairing = resolveEnvironmentPairingOffer(userDataPath, environment.id)
|
||||
const cycles = readProbeInteger('ORCA_PROBE_CYCLES', 10, 100)
|
||||
const concurrency = readProbeInteger('ORCA_PROBE_CONCURRENCY', 25, 200)
|
||||
const settleMs = readProbeInteger('ORCA_PROBE_SETTLE_MS', 250, 5_000)
|
||||
const cleanupTimeoutMs = readProbeInteger('ORCA_PROBE_CLEANUP_TIMEOUT_MS', 10_000, 30_000)
|
||||
const unknownResponses = new Map<string, number>()
|
||||
const originalWarn = console.warn
|
||||
console.warn = (message?: unknown, details?: unknown): void => {
|
||||
if (
|
||||
message === '[remote-runtime.shared-control] unknown response id' &&
|
||||
typeof details === 'object' &&
|
||||
details !== null
|
||||
) {
|
||||
const responseId = String((details as { responseId?: unknown }).responseId ?? 'unknown')
|
||||
unknownResponses.set(responseId, (unknownResponses.get(responseId) ?? 0) + 1)
|
||||
return
|
||||
}
|
||||
originalWarn(message, details)
|
||||
}
|
||||
try {
|
||||
const startedAt = Date.now()
|
||||
const before = await requestMemorySnapshot(pairing, environment.id)
|
||||
let ok = 0
|
||||
let subscriptionResponses = 0
|
||||
let runtimeStatus: RuntimeStatus | null = null
|
||||
const cleanupDurationsMs: number[] = []
|
||||
for (let cycle = 0; cycle < cycles; cycle += 1) {
|
||||
const result = await runCycle({
|
||||
pairing,
|
||||
environmentId: environment.id,
|
||||
concurrency,
|
||||
settleMs,
|
||||
cleanupTimeoutMs
|
||||
})
|
||||
ok += result.ok
|
||||
subscriptionResponses += result.subscriptionResponses
|
||||
runtimeStatus ??= result.runtimeStatus
|
||||
cleanupDurationsMs.push(result.cleanupDurationMs)
|
||||
}
|
||||
await wait(settleMs)
|
||||
const after = await requestMemorySnapshot(pairing, environment.id)
|
||||
console.log(
|
||||
JSON.stringify({
|
||||
environment: { id: environment.id, name: environment.name },
|
||||
runtime: runtimeStatus
|
||||
? {
|
||||
appVersion: runtimeStatus.appVersion ?? null,
|
||||
capabilities: runtimeStatus.capabilities ?? [],
|
||||
hostPlatform: runtimeStatus.hostPlatform ?? null
|
||||
}
|
||||
: null,
|
||||
cycles,
|
||||
concurrency,
|
||||
requests: cycles * concurrency,
|
||||
ok,
|
||||
subscriptionResponses,
|
||||
cleanupDurationMs: {
|
||||
average: Math.round(
|
||||
cleanupDurationsMs.reduce((total, duration) => total + duration, 0) /
|
||||
cleanupDurationsMs.length
|
||||
),
|
||||
maximum: Math.max(...cleanupDurationsMs)
|
||||
},
|
||||
unknownResponseFrames: Array.from(unknownResponses.values()).reduce(
|
||||
(total, count) => total + count,
|
||||
0
|
||||
),
|
||||
unknownResponseIds: unknownResponses.size,
|
||||
memory: {
|
||||
before: summarizeMemory(before),
|
||||
after: summarizeMemory(after),
|
||||
appDelta: after.app.memory - before.app.memory
|
||||
},
|
||||
elapsedMs: Date.now() - startedAt
|
||||
})
|
||||
)
|
||||
} finally {
|
||||
console.warn = originalWarn
|
||||
}
|
||||
}
|
||||
|
||||
async function runCycle(args: {
|
||||
pairing: PairingOffer
|
||||
environmentId: string
|
||||
concurrency: number
|
||||
settleMs: number
|
||||
cleanupTimeoutMs: number
|
||||
}): Promise<{
|
||||
ok: number
|
||||
subscriptionResponses: number
|
||||
cleanupDurationMs: number
|
||||
runtimeStatus: RuntimeStatus | null
|
||||
}> {
|
||||
const connection = new RemoteRuntimeSharedControlConnection(args.pairing, {
|
||||
environmentId: args.environmentId
|
||||
})
|
||||
try {
|
||||
const responses = await Promise.all(
|
||||
Array.from({ length: args.concurrency }, () =>
|
||||
connection.request<RuntimeStatus>('status.get', undefined, 10_000)
|
||||
)
|
||||
)
|
||||
const runtimeStatus = responses.find((response) => response.ok)
|
||||
let subscriptionResponses = 0
|
||||
const subscriptions = await Promise.all([
|
||||
connection.subscribe('runtime.clientEvents.subscribe', undefined, 10_000, {
|
||||
onResponse: () => {
|
||||
subscriptionResponses += 1
|
||||
},
|
||||
onError: () => {}
|
||||
}),
|
||||
connection.subscribe('session.tabs.subscribeAll', undefined, 10_000, {
|
||||
onResponse: () => {
|
||||
subscriptionResponses += 1
|
||||
},
|
||||
onError: () => {}
|
||||
})
|
||||
])
|
||||
await wait(args.settleMs)
|
||||
for (const subscription of subscriptions) {
|
||||
subscription.close()
|
||||
}
|
||||
const cleanupDurationMs = await waitForConnectionIdle(connection, args.cleanupTimeoutMs)
|
||||
// Let cleanup replies reach the retirement cache before closing the socket.
|
||||
await wait(args.settleMs)
|
||||
return {
|
||||
ok: responses.filter((response) => response.ok).length,
|
||||
subscriptionResponses,
|
||||
cleanupDurationMs,
|
||||
runtimeStatus: runtimeStatus?.ok === true ? runtimeStatus.result : null
|
||||
}
|
||||
} finally {
|
||||
connection.close()
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForConnectionIdle(
|
||||
connection: RemoteRuntimeSharedControlConnection,
|
||||
timeoutMs: number
|
||||
): Promise<number> {
|
||||
const startedAt = Date.now()
|
||||
while (Date.now() - startedAt < timeoutMs) {
|
||||
const diagnostics = connection.getDiagnostics()
|
||||
if (diagnostics.pendingRequestCount === 0 && diagnostics.subscriptionCount === 0) {
|
||||
return Date.now() - startedAt
|
||||
}
|
||||
await wait(25)
|
||||
}
|
||||
throw new Error(`Cycle did not settle: ${JSON.stringify(connection.getDiagnostics())}`)
|
||||
}
|
||||
|
||||
async function requestMemorySnapshot(
|
||||
pairing: PairingOffer,
|
||||
environmentId: string
|
||||
): Promise<MemorySnapshot> {
|
||||
const connection = new RemoteRuntimeSharedControlConnection(pairing, { environmentId })
|
||||
try {
|
||||
const response = await connection.request<MemorySnapshot>(
|
||||
'diagnostics.memory',
|
||||
undefined,
|
||||
20_000
|
||||
)
|
||||
if (!response.ok) {
|
||||
throw new Error(`Memory snapshot failed: ${response.error.message}`)
|
||||
}
|
||||
return response.result
|
||||
} finally {
|
||||
connection.close()
|
||||
}
|
||||
}
|
||||
|
||||
function summarizeMemory(snapshot: MemorySnapshot): {
|
||||
app: MemorySnapshot['app']
|
||||
host: MemorySnapshot['host']
|
||||
processMemoryMetric: MemorySnapshot['processMemoryMetric']
|
||||
totalCpu: number
|
||||
totalMemory: number
|
||||
worktreeCount: number
|
||||
sessionCount: number
|
||||
worktreeMemory: number
|
||||
topWorktrees: {
|
||||
worktreeName: string
|
||||
repoName: string
|
||||
cpu: number
|
||||
memory: number
|
||||
sessionCount: number
|
||||
topSessions: { pid: number; cpu: number; memory: number }[]
|
||||
}[]
|
||||
} {
|
||||
return {
|
||||
app: snapshot.app,
|
||||
host: snapshot.host,
|
||||
processMemoryMetric: snapshot.processMemoryMetric,
|
||||
totalCpu: snapshot.totalCpu,
|
||||
totalMemory: snapshot.totalMemory,
|
||||
worktreeCount: snapshot.worktrees.length,
|
||||
sessionCount: snapshot.worktrees.reduce(
|
||||
(total, worktree) => total + worktree.sessions.length,
|
||||
0
|
||||
),
|
||||
worktreeMemory: snapshot.worktrees.reduce((total, worktree) => total + worktree.memory, 0),
|
||||
topWorktrees: [...snapshot.worktrees]
|
||||
.sort((left, right) => right.memory - left.memory)
|
||||
.slice(0, 10)
|
||||
.map((worktree) => ({
|
||||
worktreeName: worktree.worktreeName,
|
||||
repoName: worktree.repoName,
|
||||
cpu: worktree.cpu,
|
||||
memory: worktree.memory,
|
||||
sessionCount: worktree.sessions.length,
|
||||
topSessions: [...worktree.sessions]
|
||||
.sort((left, right) => right.memory - left.memory)
|
||||
.slice(0, 5)
|
||||
.map((session) => ({
|
||||
pid: session.pid,
|
||||
cpu: session.cpu,
|
||||
memory: session.memory
|
||||
}))
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
function readProbeInteger(name: string, fallback: number, maximum: number): number {
|
||||
const value = process.env[name]
|
||||
if (value === undefined) {
|
||||
return fallback
|
||||
}
|
||||
const parsed = Number(value)
|
||||
if (!Number.isSafeInteger(parsed) || parsed < 1 || parsed > maximum) {
|
||||
throw new Error(`${name} must be an integer from 1 through ${maximum}`)
|
||||
}
|
||||
return parsed
|
||||
}
|
||||
|
||||
function wait(delayMs: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, delayMs))
|
||||
}
|
||||
|
||||
void main().catch((error: unknown) => {
|
||||
console.error(error)
|
||||
process.exitCode = 1
|
||||
})
|
||||
@@ -1894,6 +1894,7 @@ export default function SessionScreen() {
|
||||
setActiveHandle(active.terminal)
|
||||
subscribeToTerminal(active.terminal)
|
||||
} else if (active) {
|
||||
// Why: an empty snapshot can transiently omit a live terminal; explicit close clears it on RPC success.
|
||||
const previous = activeHandleRef.current
|
||||
if (previous) {
|
||||
unsubscribeTerminal(previous)
|
||||
@@ -4133,6 +4134,7 @@ export default function SessionScreen() {
|
||||
reason: 'user'
|
||||
})
|
||||
if (response.ok) {
|
||||
const remainingTabs = sessionTabsRef.current.filter((candidate) => candidate.id !== tab.id)
|
||||
if (tab.type === 'browser' && tab.browserPageId === pendingBrowserFocusPageIdRef.current) {
|
||||
pendingBrowserFocusPageIdRef.current = null
|
||||
}
|
||||
@@ -4143,14 +4145,16 @@ export default function SessionScreen() {
|
||||
initializedHandlesRef.current.delete(terminalHandle)
|
||||
clearTerminalLiveInputDefault(terminalHandle)
|
||||
}
|
||||
setSessionTabs((prev) => prev.filter((candidate) => candidate.id !== tab.id))
|
||||
sessionTabsRef.current = remainingTabs
|
||||
setSessionTabs(remainingTabs)
|
||||
// Why: tombstone the closed tab and rely on the snapshot, not a blind refetch that often re-added the not-yet-closed tab.
|
||||
closedTabTombstonesRef.current.set(tab.id, Date.now() + 10_000)
|
||||
// Why: bulk close re-activates the anchor before awaiting each close;
|
||||
// the render-synced ref sees that switch while this closure would not,
|
||||
// so comparing against the ref keeps the anchor from being nulled out.
|
||||
if (activeSessionTabIdRef.current === tab.id) {
|
||||
if (activeSessionTabIdRef.current === tab.id || remainingTabs.length === 0) {
|
||||
activeSessionTabTypeRef.current = null
|
||||
activeSessionTabIdRef.current = null
|
||||
setActiveSessionTabId(null)
|
||||
activeHandleRef.current = null
|
||||
setActiveHandle(null)
|
||||
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
} from '../src/components/AccountUsage'
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage'
|
||||
import { loadHosts } from '../src/transport/host-store'
|
||||
import { navigateToMobileHostEdit } from '../src/transport/host-edit-navigation'
|
||||
import { removeHostAndCloseClient } from '../src/transport/host-removal-lifecycle'
|
||||
import { pickResumeWorktree } from '../src/worktree/resume-worktree'
|
||||
import type { RpcClient } from '../src/transport/rpc-client'
|
||||
@@ -1010,7 +1011,7 @@ export default function HomeScreen() {
|
||||
closeBeforePress: true,
|
||||
onPress: () => {
|
||||
setActionTarget(null)
|
||||
router.push(`/h/${host.id}/edit`)
|
||||
navigateToMobileHostEdit(router, host.id)
|
||||
}
|
||||
})
|
||||
items.push({
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
const sessionRouteSource = readFileSync(
|
||||
new URL('../../app/h/[hostId]/session/[worktreeId].tsx', import.meta.url),
|
||||
'utf8'
|
||||
)
|
||||
|
||||
describe('mobile session last-tab close', () => {
|
||||
it('preserves terminal identity while an empty snapshot may be transient', () => {
|
||||
const start = sessionRouteSource.indexOf('const applySessionTabs = useCallback')
|
||||
const end = sessionRouteSource.indexOf('const readMarkdownTab', start)
|
||||
const block = sessionRouteSource.slice(start, end)
|
||||
|
||||
expect(block).toContain('} else if (active) {')
|
||||
})
|
||||
|
||||
it('clears stale active identity when closing leaves no tabs', () => {
|
||||
const start = sessionRouteSource.indexOf('async function handleCloseSessionTab')
|
||||
const end = sessionRouteSource.indexOf('const bulkCloseActions', start)
|
||||
const block = sessionRouteSource.slice(start, end)
|
||||
|
||||
expect(block).toContain(
|
||||
'activeSessionTabIdRef.current === tab.id || remainingTabs.length === 0'
|
||||
)
|
||||
expect(block).toContain('activeSessionTabIdRef.current = null')
|
||||
expect(block).toContain('activeHandleRef.current = null')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,29 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { mobileHostEditRoute, navigateToMobileHostEdit } from './host-edit-navigation'
|
||||
|
||||
describe('mobileHostEditRoute', () => {
|
||||
it('keeps the dynamic host segment explicit for a cold host navigator', () => {
|
||||
expect(mobileHostEditRoute('host-1')).toEqual({
|
||||
pathname: '/h/[hostId]/edit',
|
||||
params: { hostId: 'host-1' }
|
||||
})
|
||||
})
|
||||
|
||||
it('mounts a cold host navigator before replacing its index with edit', () => {
|
||||
let nextFrame: FrameRequestCallback | null = null
|
||||
const push = vi.fn()
|
||||
const replace = vi.fn()
|
||||
vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => {
|
||||
nextFrame = callback
|
||||
return 1
|
||||
})
|
||||
|
||||
navigateToMobileHostEdit({ push, replace }, 'host-1')
|
||||
expect(push).toHaveBeenCalledWith('/h/host-1')
|
||||
expect(replace).not.toHaveBeenCalled()
|
||||
|
||||
nextFrame?.(0)
|
||||
expect(replace).toHaveBeenCalledWith(mobileHostEditRoute('host-1'))
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,19 @@
|
||||
type HostEditRouter = {
|
||||
push: (href: `/h/${string}`) => void
|
||||
replace: (href: ReturnType<typeof mobileHostEditRoute>) => void
|
||||
}
|
||||
|
||||
export function mobileHostEditRoute(hostId: string) {
|
||||
return {
|
||||
pathname: '/h/[hostId]/edit' as const,
|
||||
params: { hostId }
|
||||
}
|
||||
}
|
||||
|
||||
export function navigateToMobileHostEdit(router: HostEditRouter, hostId: string): void {
|
||||
// Why: a cold nested host navigator resolves a deep push to its index route.
|
||||
router.push(`/h/${hostId}`)
|
||||
requestAnimationFrame(() => {
|
||||
router.replace(mobileHostEditRoute(hostId))
|
||||
})
|
||||
}
|
||||
@@ -6736,6 +6736,17 @@ describe('registerPtyHandlers', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('settles a stale renderer process inspection as unavailable', async () => {
|
||||
registerPtyHandlers(mainWindow as never)
|
||||
setLocalPtyProvider({ hasPty: vi.fn(() => false) } as never)
|
||||
|
||||
await expect(handlers.get('pty:inspectProcess')!(null, { id: 'gone-pty' })).resolves.toEqual({
|
||||
foregroundProcess: null,
|
||||
hasChildProcesses: false,
|
||||
unavailable: true
|
||||
})
|
||||
})
|
||||
|
||||
// Why: daemon resize is fire-and-forget, so pty:getSize must report the APPLIED size, not the requested one (Claude-Code split-pane desync).
|
||||
describe('pty:getSize reports applied size, not requested size', () => {
|
||||
function setupProviderWithAppliedSize(args: {
|
||||
|
||||
+5
-2
@@ -68,7 +68,10 @@ import { isPwshAvailable } from '../pwsh'
|
||||
import { LocalPtyProvider } from '../providers/local-pty-provider'
|
||||
import type { IPtyProvider, PtySpawnOptions, PtySpawnResult } from '../providers/types'
|
||||
import { isPtyWriteUnavailableError } from '../providers/pty-write-unavailable-error'
|
||||
import { inspectPtyProviderProcess } from '../providers/pty-process-inspection'
|
||||
import {
|
||||
inspectPtyProviderProcess,
|
||||
inspectPtyProviderProcessForRenderer
|
||||
} from '../providers/pty-process-inspection'
|
||||
import {
|
||||
PtyProcessListAdmission,
|
||||
visitPtyProcessListingsInBatches
|
||||
@@ -6178,7 +6181,7 @@ export function registerPtyHandlers(
|
||||
)
|
||||
|
||||
ipcMain.handle('pty:inspectProcess', async (_event, args: { id: string }) =>
|
||||
inspectPtyProviderProcess(getProviderForPty(args.id), args.id)
|
||||
inspectPtyProviderProcessForRenderer(getProviderForPty(args.id), args.id)
|
||||
)
|
||||
|
||||
ipcMain.handle(
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { IPtyProvider } from './types'
|
||||
import { inspectPtyProviderProcess } from './pty-process-inspection'
|
||||
import {
|
||||
inspectPtyProviderProcess,
|
||||
inspectPtyProviderProcessForRenderer
|
||||
} from './pty-process-inspection'
|
||||
|
||||
describe('PTY provider process inspection', () => {
|
||||
it('rejects a missing provider PTY instead of returning idle evidence', async () => {
|
||||
@@ -25,6 +28,27 @@ describe('PTY provider process inspection', () => {
|
||||
expect(inspectProcess).toHaveBeenCalledExactlyOnceWith('pty-1')
|
||||
})
|
||||
|
||||
it('returns unavailable to the renderer when a stale PTY is gone', async () => {
|
||||
const provider = {
|
||||
hasPty: vi.fn(() => false)
|
||||
} as unknown as IPtyProvider
|
||||
|
||||
await expect(inspectPtyProviderProcessForRenderer(provider, 'pty-missing')).resolves.toEqual({
|
||||
foregroundProcess: null,
|
||||
hasChildProcesses: false,
|
||||
unavailable: true
|
||||
})
|
||||
})
|
||||
|
||||
it('preserves non-stale renderer inspection failures', async () => {
|
||||
const failure = new Error('daemon unavailable')
|
||||
const provider = {
|
||||
inspectProcess: vi.fn().mockRejectedValue(failure)
|
||||
} as unknown as IPtyProvider
|
||||
|
||||
await expect(inspectPtyProviderProcessForRenderer(provider, 'pty-1')).rejects.toBe(failure)
|
||||
})
|
||||
|
||||
it('preserves an unavailable inspection result', async () => {
|
||||
const inspection = {
|
||||
foregroundProcess: null,
|
||||
|
||||
@@ -25,3 +25,17 @@ export async function inspectPtyProviderProcess(
|
||||
const hasChildProcesses = await provider.hasChildProcesses(ptyId)
|
||||
return { foregroundProcess, hasChildProcesses }
|
||||
}
|
||||
|
||||
export async function inspectPtyProviderProcessForRenderer(
|
||||
provider: IPtyProvider,
|
||||
ptyId: string
|
||||
): Promise<PtyProcessInspection> {
|
||||
try {
|
||||
return await inspectPtyProviderProcess(provider, ptyId)
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message === 'terminal_gone') {
|
||||
return { foregroundProcess: null, hasChildProcesses: false, unavailable: true }
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22157,6 +22157,128 @@ describe('OrcaRuntimeService', () => {
|
||||
])
|
||||
})
|
||||
|
||||
it.each([
|
||||
{
|
||||
behavior: 'fills a missing renderer session',
|
||||
hookAgentType: 'codex',
|
||||
hookOffset: 0,
|
||||
rendererSessionId: null,
|
||||
expectedSessionId: 'hook-session'
|
||||
},
|
||||
{
|
||||
behavior: 'replaces a stale renderer session at the same event timestamp',
|
||||
hookAgentType: 'codex',
|
||||
hookOffset: 0,
|
||||
rendererSessionId: 'stale-renderer-session',
|
||||
expectedSessionId: 'hook-session'
|
||||
},
|
||||
{
|
||||
behavior: 'preserves a renderer session newer than the hook row',
|
||||
hookAgentType: 'codex',
|
||||
hookOffset: -1,
|
||||
rendererSessionId: 'newer-renderer-session',
|
||||
expectedSessionId: 'newer-renderer-session'
|
||||
},
|
||||
{
|
||||
behavior: 'rejects a hook session owned by another agent',
|
||||
hookAgentType: 'claude',
|
||||
hookOffset: 1,
|
||||
rendererSessionId: null,
|
||||
expectedSessionId: null
|
||||
}
|
||||
] as const)(
|
||||
'$behavior',
|
||||
async ({ hookAgentType, hookOffset, rendererSessionId, expectedSessionId }) => {
|
||||
const leafId = '11111111-1111-4111-8111-111111111111'
|
||||
const paneKey = `codex-tab:${leafId}`
|
||||
const providerSession = {
|
||||
key: 'session_id' as const,
|
||||
id: 'hook-session'
|
||||
}
|
||||
const now = Date.now()
|
||||
const runtime = new OrcaRuntimeService(store, undefined, {
|
||||
getAgentStatusSnapshot: () => [
|
||||
{
|
||||
paneKey,
|
||||
state: 'done',
|
||||
prompt: '',
|
||||
agentType: hookAgentType,
|
||||
connectionId: null,
|
||||
receivedAt: now + hookOffset,
|
||||
stateStartedAt: now + hookOffset,
|
||||
tabId: 'codex-tab',
|
||||
worktreeId: TEST_WORKTREE_ID,
|
||||
providerSession
|
||||
}
|
||||
]
|
||||
})
|
||||
runtime.attachWindow(1)
|
||||
runtime.syncWindowGraph(1, {
|
||||
tabs: [],
|
||||
leaves: [],
|
||||
mobileSessionTabs: [
|
||||
{
|
||||
worktree: TEST_WORKTREE_ID,
|
||||
publicationEpoch: 'epoch-1',
|
||||
snapshotVersion: 1,
|
||||
activeGroupId: null,
|
||||
activeTabId: `codex-tab::${leafId}`,
|
||||
activeTabType: 'terminal',
|
||||
tabs: [
|
||||
{
|
||||
type: 'terminal',
|
||||
id: `codex-tab::${leafId}`,
|
||||
parentTabId: 'codex-tab',
|
||||
leafId,
|
||||
title: 'Codex',
|
||||
launchAgent: 'codex',
|
||||
agentStatus: {
|
||||
state: 'working',
|
||||
prompt: 'Reply with MOBILE QA OK and nothing else.',
|
||||
updatedAt: now,
|
||||
stateStartedAt: now,
|
||||
agentType: 'codex',
|
||||
paneKey,
|
||||
stateHistory: [],
|
||||
...(rendererSessionId
|
||||
? {
|
||||
providerSession: {
|
||||
key: 'session_id' as const,
|
||||
id: rendererSessionId
|
||||
}
|
||||
}
|
||||
: {})
|
||||
},
|
||||
isActive: true
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
const result = await runtime.listMobileSessionTabs(`id:${TEST_WORKTREE_ID}`)
|
||||
|
||||
expect(result.tabs[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
type: 'terminal',
|
||||
agentStatus: expect.objectContaining({
|
||||
state: 'working',
|
||||
prompt: 'Reply with MOBILE QA OK and nothing else.',
|
||||
agentType: 'codex'
|
||||
})
|
||||
})
|
||||
)
|
||||
if (expectedSessionId) {
|
||||
expect(result.tabs[0]).toHaveProperty('agentStatus.providerSession', {
|
||||
key: 'session_id',
|
||||
id: expectedSessionId
|
||||
})
|
||||
} else {
|
||||
expect(result.tabs[0]).not.toHaveProperty('agentStatus.providerSession')
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
it('preserves authoritative OMP identity for Pi-compatible remote terminal snapshots', async () => {
|
||||
const runtime = new OrcaRuntimeService(store)
|
||||
const leafId = '11111111-1111-4111-8111-111111111111'
|
||||
|
||||
@@ -69,7 +69,8 @@ import {
|
||||
import {
|
||||
hasCompatibleAgentTitleIdentity,
|
||||
normalizeCompatibleAgentStatusEntryForOwner,
|
||||
normalizeCompatibleAgentTitleForOwner
|
||||
normalizeCompatibleAgentTitleForOwner,
|
||||
resolveCompatibleAgentTypeForOwner
|
||||
} from '../../shared/agent-title-owner'
|
||||
import { resolvePaneAgentOwner } from '../../shared/pane-agent-owner'
|
||||
import {
|
||||
@@ -28674,6 +28675,9 @@ export class OrcaRuntimeService {
|
||||
const retainedAgentStatus = tab.agentStatus
|
||||
? null
|
||||
: this.getFreshRetainedAgentStatusForMobileTab(paneKey, liveLeafPty ?? mobileStatusPty, tab)
|
||||
const hookAgentStatus = tab.agentStatus
|
||||
? this.getHookAgentRowForPane(getHookRowsForPane(paneKey))
|
||||
: null
|
||||
const leafTitle = leaf
|
||||
? getLatestAgentCandidateTitle(
|
||||
{ title: leaf.paneTitle, updatedAt: leaf.paneTitleUpdatedAt },
|
||||
@@ -28691,7 +28695,11 @@ export class OrcaRuntimeService {
|
||||
const ownerAgent =
|
||||
resolvePaneAgentOwner({
|
||||
launchAgent,
|
||||
hookAgent: tab.agentStatus?.agentType ?? retainedAgentStatus?.payload.agentType ?? null
|
||||
hookAgent:
|
||||
tab.agentStatus?.agentType ??
|
||||
hookAgentStatus?.agentType ??
|
||||
retainedAgentStatus?.payload.agentType ??
|
||||
null
|
||||
}) ??
|
||||
liveLeafPty?.foregroundAgent ??
|
||||
pty?.foregroundAgent ??
|
||||
@@ -28702,8 +28710,32 @@ export class OrcaRuntimeService {
|
||||
)
|
||||
const liveTitleEvidence = leafTitle ?? ptyTitle
|
||||
const liveTitleEvidenceClassification = classifyAgentTitle(liveTitleEvidence)
|
||||
// Why: renderer status can precede hook session identity, leaving native chat with no transcript address.
|
||||
const rendererStatusAgent =
|
||||
resolveCompatibleAgentTypeForOwner(tab.agentStatus?.agentType, ownerAgent) ??
|
||||
ownerAgent ??
|
||||
undefined
|
||||
const hookSessionAgent = resolveCompatibleAgentTypeForOwner(
|
||||
hookAgentStatus?.providerSessionAgentType,
|
||||
ownerAgent
|
||||
)
|
||||
const hookSessionMatchesRenderer =
|
||||
!rendererStatusAgent || !hookSessionAgent || rendererStatusAgent === hookSessionAgent
|
||||
const hookProviderSession =
|
||||
hookAgentStatus?.providerSession &&
|
||||
hookSessionMatchesRenderer &&
|
||||
(!tab.agentStatus?.providerSession ||
|
||||
(hookAgentStatus.providerSessionReceivedAt ?? -1) >= tab.agentStatus.updatedAt)
|
||||
? hookAgentStatus.providerSession
|
||||
: tab.agentStatus?.providerSession
|
||||
const normalizedTabAgentStatus = tab.agentStatus
|
||||
? normalizeCompatibleAgentStatusEntryForOwner(tab.agentStatus, ownerAgent)
|
||||
? normalizeCompatibleAgentStatusEntryForOwner(
|
||||
{
|
||||
...tab.agentStatus,
|
||||
...(hookProviderSession ? { providerSession: hookProviderSession } : {})
|
||||
},
|
||||
ownerAgent
|
||||
)
|
||||
: null
|
||||
// Why: keep rich hook status on a live prompt/tool (authoritative even under a non-agent title), else interactivePrompt is lost.
|
||||
const hasLiveAgentSignal =
|
||||
@@ -28942,6 +28974,8 @@ export class OrcaRuntimeService {
|
||||
* read would keep offering native chat for what is now a plain shell. */
|
||||
private getHookAgentRowForPane(rows: readonly AgentStatusIpcPayload[]): {
|
||||
providerSession: AgentProviderSessionMetadata | null
|
||||
providerSessionAgentType: string | null
|
||||
providerSessionReceivedAt: number | null
|
||||
agentType: string | null
|
||||
} {
|
||||
let session: AgentStatusIpcPayload | null = null
|
||||
@@ -28967,6 +29001,8 @@ export class OrcaRuntimeService {
|
||||
}
|
||||
return {
|
||||
providerSession: session?.providerSession ?? null,
|
||||
providerSessionAgentType: session?.agentType ?? null,
|
||||
providerSessionReceivedAt: session?.receivedAt ?? null,
|
||||
agentType: agent?.agentType ?? null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -328,6 +328,18 @@ describe('renderer startup runtime routing', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('loads Linear agent setup implementation only after the prompt opens it', () => {
|
||||
const source = readFileSync(
|
||||
join(process.cwd(), 'src/renderer/src/components/sidebar/LinearAgentSkillSetupPrompt.tsx'),
|
||||
'utf8'
|
||||
)
|
||||
|
||||
expect(source).toContain("() => import('./LinearAgentSkillSetupDialog')")
|
||||
expect(source).not.toContain("from './LinearAgentSkillSetupDialog'")
|
||||
expect(source).toContain('const setupDialog = setupDialogOpen ? (')
|
||||
expect(source).toContain('<Suspense fallback={null}>')
|
||||
})
|
||||
|
||||
it('does not eagerly import optional status-bar segments on startup', () => {
|
||||
const source = readFileSync(
|
||||
join(process.cwd(), 'src/renderer/src/components/status-bar/StatusBar.tsx'),
|
||||
|
||||
@@ -89,6 +89,7 @@ import { getTerminalWorktreeColdParkRecheckDelayMs } from './terminal-pane/termi
|
||||
import {
|
||||
TERMINAL_WORKTREE_COLD_PARK_DELAY_MS,
|
||||
canParkTerminalWorktreeRenderers,
|
||||
isParkRestorableTerminalPty,
|
||||
selectPairedRuntimeParkingEnvironmentIds,
|
||||
selectColdParkedTerminalWorktrees,
|
||||
type TerminalWorktreeColdParkCandidate
|
||||
@@ -112,7 +113,8 @@ import {
|
||||
disposeAllParkedTerminalWatchers,
|
||||
pruneParkedTerminalWatchers,
|
||||
shouldDeferParkedPtyExitTabClose,
|
||||
syncParkedTerminalTabWatchers
|
||||
syncParkedTerminalTabWatchers,
|
||||
terminalWatcherLiveWorkspaceIds
|
||||
} from './terminal-pane/terminal-parked-tab-watchers'
|
||||
import { isMainTerminalSideEffectAuthorityForPty } from './terminal-pane/terminal-side-effect-facts-handler'
|
||||
import { appendUniqueOpenFileIds } from './terminal/unsaved-close-queue'
|
||||
@@ -1204,8 +1206,15 @@ function Terminal(): React.JSX.Element | null {
|
||||
}
|
||||
}
|
||||
const activationHostSupportsDeferral = canDeferColdActivationTabsForHost({
|
||||
executionHostId: activeWorktreeDeferralHostId
|
||||
executionHostId: activeWorktreeDeferralHostId,
|
||||
pairedRuntimeParkingEnvironmentIds
|
||||
})
|
||||
const isColdActivationPtyEligible = (ptyId: string): boolean =>
|
||||
isRemoteRuntimePtyId(ptyId)
|
||||
? isParkRestorableTerminalPty(ptyId, renderedActiveWorktreeId, {
|
||||
pairedRuntimeParkingEnvironmentIds
|
||||
})
|
||||
: terminalProviderHasAuthoritativeSnapshot(ptyId)
|
||||
if (lastActivationWorktreeIdRef.current !== renderedActiveWorktreeId) {
|
||||
lastActivationWorktreeIdRef.current = renderedActiveWorktreeId
|
||||
const tabById = new Map(worktreeTabs.map((tab) => [tab.id, tab]))
|
||||
@@ -1226,7 +1235,7 @@ function Terminal(): React.JSX.Element | null {
|
||||
canWatcherCoverParkedTerminalTab(
|
||||
renderedActiveWorktreeId,
|
||||
tab,
|
||||
terminalProviderHasAuthoritativeSnapshot
|
||||
isColdActivationPtyEligible
|
||||
)
|
||||
)
|
||||
},
|
||||
@@ -1243,7 +1252,7 @@ function Terminal(): React.JSX.Element | null {
|
||||
!canWatcherCoverParkedTerminalTab(
|
||||
renderedActiveWorktreeId,
|
||||
tab,
|
||||
terminalProviderHasAuthoritativeSnapshot
|
||||
isColdActivationPtyEligible
|
||||
)
|
||||
) {
|
||||
immediateTabIds.add(tab.id)
|
||||
@@ -1286,7 +1295,9 @@ function Terminal(): React.JSX.Element | null {
|
||||
)
|
||||
// Why: legacy (non-split) host owns watcher reconciliation; split mode's overlay layers own theirs, so only dispose worktrees with no overlay layer.
|
||||
useEffect(() => {
|
||||
pruneParkedTerminalWatchers(new Set(workspaceSurfaces.map((workspace) => workspace.id)))
|
||||
pruneParkedTerminalWatchers(
|
||||
terminalWatcherLiveWorkspaceIds(workspaceSurfaces.map((workspace) => workspace.id))
|
||||
)
|
||||
for (const workspace of workspaceSurfaces) {
|
||||
if (
|
||||
anyMountedWorktreeHasLayout &&
|
||||
|
||||
@@ -137,6 +137,7 @@ const mocks = vi.hoisted(() => ({
|
||||
setTabColor: vi.fn(),
|
||||
setTabCustomTitle: vi.fn(),
|
||||
setTabPaneExpanded: vi.fn(),
|
||||
shouldDeferParkedPtyExitTabClose: vi.fn(),
|
||||
useContextualTour: vi.fn()
|
||||
}))
|
||||
|
||||
@@ -144,6 +145,10 @@ const saveDialogBox = vi.hoisted(() => ({
|
||||
fileId: null as string | null
|
||||
}))
|
||||
|
||||
const parkingBox = vi.hoisted(() => ({
|
||||
parkedTabIds: new Set<string>()
|
||||
}))
|
||||
|
||||
vi.mock('react', async () => {
|
||||
const actual = await vi.importActual<typeof import('react')>('react') // eslint-disable-line @typescript-eslint/consistent-type-imports -- vi.importActual requires inline import()
|
||||
return {
|
||||
@@ -205,6 +210,14 @@ vi.mock('@/components/terminal-pane/TerminalPane', () => ({
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/components/terminal-pane/use-terminal-tab-cold-parking', () => ({
|
||||
useTerminalTabColdParking: () => parkingBox.parkedTabIds
|
||||
}))
|
||||
|
||||
vi.mock('@/components/terminal-pane/terminal-parked-tab-watchers', () => ({
|
||||
shouldDeferParkedPtyExitTabClose: mocks.shouldDeferParkedPtyExitTabClose
|
||||
}))
|
||||
|
||||
vi.mock('@/components/terminal-pane/terminal-ime-input-context-refresh', () => ({
|
||||
isTerminalImeInputContextRefreshing: mocks.isTerminalImeInputContextRefreshing
|
||||
}))
|
||||
@@ -574,6 +587,22 @@ function findByTypeName(node: unknown, typeName: string): ReactElementLike {
|
||||
return found
|
||||
}
|
||||
|
||||
function findAllByTypeName(node: unknown, typeName: string): ReactElementLike[] {
|
||||
const found: ReactElementLike[] = []
|
||||
visit(node, (entry) => {
|
||||
const candidate =
|
||||
typeof entry.type === 'function' || typeof entry.type === 'object'
|
||||
? ((entry.type as { displayName?: string; name?: string }).displayName ??
|
||||
(entry.type as { displayName?: string; name?: string }).name ??
|
||||
'')
|
||||
: entry.type
|
||||
if (candidate === typeName) {
|
||||
found.push(entry)
|
||||
}
|
||||
})
|
||||
return found
|
||||
}
|
||||
|
||||
function findByProp(node: unknown, propName: string): ReactElementLike {
|
||||
let found: ReactElementLike | null = null
|
||||
visit(node, (entry) => {
|
||||
@@ -780,6 +809,7 @@ describe('FloatingTerminalPanel close behavior', () => {
|
||||
hookRuntime.index = 0
|
||||
hookRuntime.values = []
|
||||
saveDialogBox.fileId = null
|
||||
parkingBox.parkedTabIds = new Set()
|
||||
resetStore()
|
||||
// Why: the open-maximized intent is a module singleton; drain any leftover
|
||||
// from a prior test so it cannot bleed into an unrelated render.
|
||||
@@ -800,6 +830,7 @@ describe('FloatingTerminalPanel close behavior', () => {
|
||||
mocks.isTerminalImeInputContextRefreshing.mockReturnValue(false)
|
||||
mocks.isWebRuntimeSessionActive.mockReturnValue(false)
|
||||
mocks.pickFloatingMarkdownDocument.mockResolvedValue(null)
|
||||
mocks.shouldDeferParkedPtyExitTabClose.mockReturnValue(false)
|
||||
const localStorage = {
|
||||
clear: vi.fn(),
|
||||
getItem: vi.fn(() => null),
|
||||
@@ -1477,6 +1508,20 @@ describe('FloatingTerminalPanel close behavior', () => {
|
||||
expect(openPane.props.isVisible).toBe(true)
|
||||
})
|
||||
|
||||
it('does not mount floating terminal panes selected for cold parking', async () => {
|
||||
setFloatingTabs([makeTab({ id: 'tab-1' }), makeTab({ id: 'tab-2' })])
|
||||
parkingBox.parkedTabIds = new Set(['tab-2'])
|
||||
|
||||
await renderPanel(true)
|
||||
runEffects()
|
||||
await Promise.resolve()
|
||||
const element = await renderPanel(true)
|
||||
|
||||
expect(findAllByTypeName(element, 'TerminalPane').map((pane) => pane.props.tabId)).toEqual([
|
||||
'tab-1'
|
||||
])
|
||||
})
|
||||
|
||||
it('routes titlebar Cmd+T to the floating workspace', async () => {
|
||||
setFloatingTabs([makeTab({ id: 'tab-1' })])
|
||||
const element = await renderPanel(true)
|
||||
@@ -2483,11 +2528,16 @@ describe('FloatingTerminalPanel close behavior', () => {
|
||||
const element = await renderPanel(true, onOpenChange)
|
||||
const terminalPane = findByTypeName(element, 'TerminalPane')
|
||||
|
||||
;(terminalPane.props.onPtyExit as () => void)()
|
||||
expect(mocks.closeTab).toHaveBeenCalledWith('tab-1', { reason: 'pty-exit' })
|
||||
;(terminalPane.props.onPtyExit as (ptyId: string) => void)('pty-1')
|
||||
expect(mocks.shouldDeferParkedPtyExitTabClose).toHaveBeenCalledWith('tab-1', 'pty-1')
|
||||
expect(mocks.closeTerminalTab).toHaveBeenCalledWith('tab-1', {
|
||||
lifecyclePtyId: 'pty-1',
|
||||
reason: 'pty-exit'
|
||||
})
|
||||
expect(mocks.closeTab).not.toHaveBeenCalled()
|
||||
expect(onOpenChange).not.toHaveBeenCalled()
|
||||
|
||||
mocks.closeTab.mockClear()
|
||||
mocks.closeTerminalTab.mockClear()
|
||||
;(terminalPane.props.onCloseTab as () => void)()
|
||||
// Explicit pane close routes through the confirmed-close authority, not a raw pty-exit prune.
|
||||
expect(mocks.closeTerminalTab).toHaveBeenCalledWith(
|
||||
@@ -2498,6 +2548,23 @@ describe('FloatingTerminalPanel close behavior', () => {
|
||||
expect(onOpenChange).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('preserves split siblings when a parked PTY exits during reveal', async () => {
|
||||
setFloatingTabs([makeTab({ id: 'tab-1' })])
|
||||
mocks.shouldDeferParkedPtyExitTabClose.mockReturnValueOnce(true)
|
||||
|
||||
await renderPanel(true)
|
||||
runEffects()
|
||||
await Promise.resolve()
|
||||
const element = await renderPanel(true)
|
||||
const terminalPane = findByTypeName(element, 'TerminalPane')
|
||||
|
||||
;(terminalPane.props.onPtyExit as (ptyId: string) => void)('split-pty')
|
||||
|
||||
expect(mocks.shouldDeferParkedPtyExitTabClose).toHaveBeenCalledWith('tab-1', 'split-pty')
|
||||
expect(mocks.closeTerminalTab).not.toHaveBeenCalled()
|
||||
expect(mocks.closeTab).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('renders and closes simulator tabs in the floating workspace', async () => {
|
||||
const tab = setFloatingSimulatorTab()
|
||||
|
||||
|
||||
@@ -12,6 +12,8 @@ import { useContextualTour } from '@/components/contextual-tours/use-contextual-
|
||||
import TabBar from '@/components/tab-bar/TabBar'
|
||||
import { resolveGroupTabFromVisibleId } from '@/components/tab-group/tab-group-visible-id'
|
||||
import TerminalPane, { type TerminalPaneHandle } from '@/components/terminal-pane/TerminalPane'
|
||||
import { shouldDeferParkedPtyExitTabClose } from '@/components/terminal-pane/terminal-parked-tab-watchers'
|
||||
import { useTerminalTabColdParking } from '@/components/terminal-pane/use-terminal-tab-cold-parking'
|
||||
import { isTerminalPaneCloseChord } from '@/components/terminal-pane/terminal-shortcut-policy'
|
||||
import { isTerminalImeInputContextRefreshing } from '@/components/terminal-pane/terminal-ime-input-context-refresh'
|
||||
import { Button } from '@/components/ui/button'
|
||||
@@ -109,6 +111,7 @@ import { translate } from '@/i18n/i18n'
|
||||
import { consumeFloatingTerminalOpenMaximizedIntent } from '@/lib/floating-terminal'
|
||||
import { selectFloatingTerminalPanelInputs } from './floating-terminal-panel-inputs'
|
||||
const LOCAL_RUNTIME_SETTINGS = { activeRuntimeEnvironmentId: null } as const
|
||||
const NO_ACTIVITY_TERMINAL_PORTALS = []
|
||||
|
||||
const EditorPanel = lazy(() => import('@/components/editor/EditorPanel'))
|
||||
|
||||
@@ -349,6 +352,27 @@ export function FloatingTerminalPanel({
|
||||
? activeTab.entityId
|
||||
: null
|
||||
const terminalTabById = useMemo(() => new Map(tabs.map((tab) => [tab.id, tab])), [tabs])
|
||||
const terminalAssignments = useMemo(() => {
|
||||
const assignments = new Map<string, { groupId: string; isActiveInGroup: boolean }>()
|
||||
for (const tab of unifiedTabs) {
|
||||
if (tab.contentType === 'terminal') {
|
||||
assignments.set(tab.entityId, {
|
||||
groupId: tab.groupId,
|
||||
isActiveInGroup: tab.entityId === activeTerminalId
|
||||
})
|
||||
}
|
||||
}
|
||||
return assignments
|
||||
}, [activeTerminalId, unifiedTabs])
|
||||
const parkedTerminalTabIds = useTerminalTabColdParking({
|
||||
worktreeId: FLOATING_TERMINAL_WORKTREE_ID,
|
||||
terminalTabs: tabs,
|
||||
assignments: terminalAssignments,
|
||||
isWorktreeActive: open,
|
||||
coldParkTerminalPanes: false,
|
||||
shouldMeasureHiddenWorktree: false,
|
||||
activityTerminalPortals: NO_ACTIVITY_TERMINAL_PORTALS
|
||||
})
|
||||
const terminalItems = useMemo<(TerminalTab & { unifiedTabId: string })[]>(
|
||||
() =>
|
||||
groupTabs
|
||||
@@ -1817,33 +1841,43 @@ export function FloatingTerminalPanel({
|
||||
}
|
||||
>
|
||||
{cwd
|
||||
? tabs.map((tab) => {
|
||||
const isActive = tab.id === activeTerminalId
|
||||
return (
|
||||
<div
|
||||
key={`${tab.id}-${tab.generation ?? 0}`}
|
||||
className={isActive ? 'absolute inset-0' : 'absolute inset-0 hidden'}
|
||||
aria-hidden={!isActive}
|
||||
>
|
||||
<TerminalPane
|
||||
ref={terminalPaneRegistry.getRefCallback(tab.id)}
|
||||
tabId={tab.id}
|
||||
worktreeId={FLOATING_TERMINAL_WORKTREE_ID}
|
||||
cwd={cwd}
|
||||
isActive={isActive}
|
||||
// Why: the closed panel is only CSS-hidden, so gate
|
||||
// visibility on `open` too. This routes the floating
|
||||
// terminal through the standard hidden-terminal
|
||||
// suspend/resume path: no live WebGL context (or glyph
|
||||
// atlas to corrupt) while hidden, and the resume on
|
||||
// reopen rebuilds the renderer from scratch.
|
||||
isVisible={isActive && open}
|
||||
onPtyExit={() => closeTab(tab.id, { reason: 'pty-exit' })}
|
||||
onCloseTab={() => closeFloatingItemConfirmed(tab.id)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
? tabs
|
||||
.filter((tab) => !parkedTerminalTabIds.has(tab.id))
|
||||
.map((tab) => {
|
||||
const isActive = tab.id === activeTerminalId
|
||||
return (
|
||||
<div
|
||||
key={`${tab.id}-${tab.generation ?? 0}`}
|
||||
className={isActive ? 'absolute inset-0' : 'absolute inset-0 hidden'}
|
||||
aria-hidden={!isActive}
|
||||
>
|
||||
<TerminalPane
|
||||
ref={terminalPaneRegistry.getRefCallback(tab.id)}
|
||||
tabId={tab.id}
|
||||
worktreeId={FLOATING_TERMINAL_WORKTREE_ID}
|
||||
cwd={cwd}
|
||||
isActive={isActive}
|
||||
// Why: the closed panel is only CSS-hidden, so gate
|
||||
// visibility on `open` too. This routes the floating
|
||||
// terminal through the standard hidden-terminal
|
||||
// suspend/resume path: no live WebGL context (or glyph
|
||||
// atlas to corrupt) while hidden, and the resume on
|
||||
// reopen rebuilds the renderer from scratch.
|
||||
isVisible={isActive && open}
|
||||
onPtyExit={(ptyId) => {
|
||||
if (shouldDeferParkedPtyExitTabClose(tab.id, ptyId)) {
|
||||
return
|
||||
}
|
||||
closeTerminalTab(tab.id, {
|
||||
reason: 'pty-exit',
|
||||
lifecyclePtyId: ptyId
|
||||
})
|
||||
}}
|
||||
onCloseTab={() => closeFloatingItemConfirmed(tab.id)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
: null}
|
||||
{browserTabs.map((tab) => {
|
||||
const isActive = tab.id === activeBrowserTab?.id
|
||||
|
||||
@@ -191,3 +191,5 @@ export function LinearAgentSkillSetupDialog({
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
export default LinearAgentSkillSetupDialog
|
||||
|
||||
+1
@@ -119,6 +119,7 @@ async function renderPrompt(
|
||||
await act(async () => {
|
||||
root?.render(<LinearAgentSkillSetupPrompt {...props} />)
|
||||
})
|
||||
await import('./LinearAgentSkillSetupDialog')
|
||||
await act(async () => {})
|
||||
}
|
||||
|
||||
|
||||
@@ -147,6 +147,9 @@ async function renderPrompt(
|
||||
await act(async () => {
|
||||
root?.render(<LinearAgentSkillSetupPrompt {...props} />)
|
||||
})
|
||||
if (props.surface === 'modal') {
|
||||
await import('./LinearAgentSkillSetupDialog')
|
||||
}
|
||||
await act(async () => {})
|
||||
return container
|
||||
}
|
||||
@@ -178,6 +181,7 @@ function findBodyButton(label: string): HTMLButtonElement | undefined {
|
||||
}
|
||||
|
||||
async function settleRender(): Promise<void> {
|
||||
await import('./LinearAgentSkillSetupDialog')
|
||||
await act(async () => {})
|
||||
await act(async () => {})
|
||||
}
|
||||
@@ -253,12 +257,7 @@ describe('LinearAgentSkillSetupPrompt', () => {
|
||||
const unlinked = await renderPrompt({ linked: false, remote: false })
|
||||
expect(unlinked.textContent).not.toContain('Set up Linear agent skill')
|
||||
|
||||
await act(async () => {
|
||||
root?.unmount()
|
||||
})
|
||||
root = null
|
||||
unlinked.remove()
|
||||
container = null
|
||||
await unmountPrompt()
|
||||
|
||||
const ready = await renderPrompt({ linked: true, remote: false })
|
||||
expect(ready.textContent).not.toContain('Set up Linear agent skill')
|
||||
@@ -343,6 +342,7 @@ describe('LinearAgentSkillSetupPrompt', () => {
|
||||
await act(async () => {
|
||||
setupButton?.dispatchEvent(new MouseEvent('click', { bubbles: true }))
|
||||
})
|
||||
await settleRender()
|
||||
|
||||
expect(document.body.textContent).toContain("wsl.exe -d 'Fedora' -- bash -lc 'npx skills add")
|
||||
expect(mocks.panelProps.at(-1)).toEqual(
|
||||
@@ -425,7 +425,7 @@ describe('LinearAgentSkillSetupPrompt', () => {
|
||||
expect(mocks.getWslCliStatus).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('opens the terminal setup panel in a dialog only after the user asks to set up', async () => {
|
||||
it('keeps the prompt usable and loads the lazy setup dialog only when requested', async () => {
|
||||
const rendered = await renderPrompt({ linked: true, remote: false })
|
||||
|
||||
expect(document.body.querySelector('[data-testid="linear-skill-inline-panel"]')).toBeNull()
|
||||
@@ -436,6 +436,7 @@ describe('LinearAgentSkillSetupPrompt', () => {
|
||||
await act(async () => {
|
||||
setupButton?.dispatchEvent(new MouseEvent('click', { bubbles: true }))
|
||||
})
|
||||
await settleRender()
|
||||
|
||||
expect(document.body.querySelector('[data-testid="linear-skill-inline-panel"]')).not.toBeNull()
|
||||
expect(document.body.textContent).toContain('orca-linear')
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { Suspense, useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { RefreshCw, TicketCheck, X } from 'lucide-react'
|
||||
import type { CliInstallStatus } from '../../../../shared/cli-install-types'
|
||||
import type { ProjectExecutionRuntimeResolution } from '../../../../shared/project-execution-runtime'
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
ensureOrcaCliAvailableForAgentSkillTerminal,
|
||||
isOrcaCliAvailableOnPath
|
||||
} from '@/lib/agent-skill-cli-prerequisite'
|
||||
import { lazyWithRetry } from '@/lib/lazy-with-retry'
|
||||
import { cn } from '@/lib/utils'
|
||||
import {
|
||||
buildSkillCommandForRuntime,
|
||||
@@ -45,9 +46,12 @@ import {
|
||||
readLocalDismissed,
|
||||
type LinearAgentSkillPromptSettings
|
||||
} from './linear-agent-skill-runtime'
|
||||
import { LinearAgentSkillSetupDialog } from './LinearAgentSkillSetupDialog'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
|
||||
const LinearAgentSkillSetupDialog = lazyWithRetry(() => import('./LinearAgentSkillSetupDialog'), {
|
||||
reloadKey: 'linear-agent-skill-setup-dialog'
|
||||
})
|
||||
|
||||
export const _linearAgentSkillSetupPromptInternalsForTests = {
|
||||
resetSessionReminders(): void {
|
||||
resetLinearAgentSkillSetupReminderToastState()
|
||||
@@ -278,69 +282,71 @@ export function LinearAgentSkillSetupPrompt({
|
||||
return null
|
||||
}
|
||||
|
||||
const setupDialog = (
|
||||
<LinearAgentSkillSetupDialog
|
||||
open={setupDialogOpen}
|
||||
showSuccess={showSuccessModal}
|
||||
successDescription={successDescription}
|
||||
missingLabel={missingLabel}
|
||||
command={command}
|
||||
installedCommand={installedCommand}
|
||||
terminalShellOverride={terminalShellOverride}
|
||||
installed={skill.installed}
|
||||
loading={showCheckingModal || cliLoading || skill.loading}
|
||||
error={skill.error}
|
||||
getPrerequisiteStatus={
|
||||
agentRuntime.runtime === 'wsl'
|
||||
? () => window.api.cli.getWslInstallStatus(getWslCliDistroRequest(agentRuntime))
|
||||
: undefined
|
||||
}
|
||||
onBeforeOpenTerminal={async () => {
|
||||
const requestIdentity = setupCheckIdentity
|
||||
const writeIfCurrent = (write: () => void): void => {
|
||||
writeCliStatusForIdentity(requestIdentity, write)
|
||||
}
|
||||
const nextStatus =
|
||||
const setupDialog = setupDialogOpen ? (
|
||||
<Suspense fallback={null}>
|
||||
<LinearAgentSkillSetupDialog
|
||||
open
|
||||
showSuccess={showSuccessModal}
|
||||
successDescription={successDescription}
|
||||
missingLabel={missingLabel}
|
||||
command={command}
|
||||
installedCommand={installedCommand}
|
||||
terminalShellOverride={terminalShellOverride}
|
||||
installed={skill.installed}
|
||||
loading={showCheckingModal || cliLoading || skill.loading}
|
||||
error={skill.error}
|
||||
getPrerequisiteStatus={
|
||||
agentRuntime.runtime === 'wsl'
|
||||
? await ensureWslCliAvailableForAgentSkillTerminal(agentRuntime)
|
||||
: await ensureOrcaCliAvailableForAgentSkillTerminal({
|
||||
onStatusChange: (nextCliStatus) => {
|
||||
writeIfCurrent(() => setCliStatus(nextCliStatus))
|
||||
}
|
||||
})
|
||||
if (agentRuntime.runtime === 'wsl') {
|
||||
writeIfCurrent(() => setCliStatus(nextStatus))
|
||||
? () => window.api.cli.getWslInstallStatus(getWslCliDistroRequest(agentRuntime))
|
||||
: undefined
|
||||
}
|
||||
}}
|
||||
onRecheck={async () => {
|
||||
if (surface === 'modal') {
|
||||
setActiveSetupCheckIdentity(setupCheckIdentity)
|
||||
setSetupCheckResult('checking')
|
||||
await Promise.all([refreshCliStatus(), skill.refresh()])
|
||||
return
|
||||
}
|
||||
await refreshCliStatus()
|
||||
await skill.refresh()
|
||||
}}
|
||||
onOpenChange={(open) => {
|
||||
if (open) {
|
||||
setSetupDialogOpen(true)
|
||||
return
|
||||
}
|
||||
if (showSuccessModal) {
|
||||
closeSuccessModal()
|
||||
return
|
||||
}
|
||||
if (surface === 'modal') {
|
||||
snoozeForSession()
|
||||
return
|
||||
}
|
||||
setSetupDialogOpen(false)
|
||||
}}
|
||||
onDismissPermanently={dismissPermanently}
|
||||
onDone={closeSuccessModal}
|
||||
/>
|
||||
)
|
||||
onBeforeOpenTerminal={async () => {
|
||||
const requestIdentity = setupCheckIdentity
|
||||
const writeIfCurrent = (write: () => void): void => {
|
||||
writeCliStatusForIdentity(requestIdentity, write)
|
||||
}
|
||||
const nextStatus =
|
||||
agentRuntime.runtime === 'wsl'
|
||||
? await ensureWslCliAvailableForAgentSkillTerminal(agentRuntime)
|
||||
: await ensureOrcaCliAvailableForAgentSkillTerminal({
|
||||
onStatusChange: (nextCliStatus) => {
|
||||
writeIfCurrent(() => setCliStatus(nextCliStatus))
|
||||
}
|
||||
})
|
||||
if (agentRuntime.runtime === 'wsl') {
|
||||
writeIfCurrent(() => setCliStatus(nextStatus))
|
||||
}
|
||||
}}
|
||||
onRecheck={async () => {
|
||||
if (surface === 'modal') {
|
||||
setActiveSetupCheckIdentity(setupCheckIdentity)
|
||||
setSetupCheckResult('checking')
|
||||
await Promise.all([refreshCliStatus(), skill.refresh()])
|
||||
return
|
||||
}
|
||||
await refreshCliStatus()
|
||||
await skill.refresh()
|
||||
}}
|
||||
onOpenChange={(open) => {
|
||||
if (open) {
|
||||
setSetupDialogOpen(true)
|
||||
return
|
||||
}
|
||||
if (showSuccessModal) {
|
||||
closeSuccessModal()
|
||||
return
|
||||
}
|
||||
if (surface === 'modal') {
|
||||
snoozeForSession()
|
||||
return
|
||||
}
|
||||
setSetupDialogOpen(false)
|
||||
}}
|
||||
onDismissPermanently={dismissPermanently}
|
||||
onDone={closeSuccessModal}
|
||||
/>
|
||||
</Suspense>
|
||||
) : null
|
||||
|
||||
if (surface === 'modal') {
|
||||
return setupDialog
|
||||
|
||||
+1
@@ -107,6 +107,7 @@ async function renderPrompt(
|
||||
<LinearAgentSkillSetupPrompt linked={true} remote={false} surface="modal" {...props} />
|
||||
)
|
||||
})
|
||||
await import('./LinearAgentSkillSetupDialog')
|
||||
await act(async () => {})
|
||||
}
|
||||
|
||||
|
||||
@@ -210,6 +210,91 @@ function appendBoard(): { board: HTMLElement; lane: HTMLElement; firstCard: HTML
|
||||
return { board, lane, firstCard }
|
||||
}
|
||||
|
||||
const VIRTUAL_CARD_PITCH = 44
|
||||
const VIRTUAL_CARD_HEIGHT = 36
|
||||
// Scrolled past the head of the lane, so the mounted window starts mid-list.
|
||||
const VIRTUAL_SPACER_TOP = -350
|
||||
|
||||
// A virtualized lane: only `windowRange` of the view is mounted, and the layout
|
||||
// registration carries the whole view — exactly what the virtualizer publishes.
|
||||
function appendVirtualBoard(args: {
|
||||
fullLaneIds: readonly string[]
|
||||
viewIds: readonly string[]
|
||||
windowRange: readonly [number, number]
|
||||
publishFullIds: boolean
|
||||
}): { lane: HTMLElement; unregister: () => void } {
|
||||
const board = document.createElement('div')
|
||||
board.setAttribute('data-workspace-board-selection-surface', '')
|
||||
setRect(board, { left: 0, top: 0, right: 320, bottom: 240, width: 320, height: 240 })
|
||||
|
||||
const lane = document.createElement('section')
|
||||
lane.setAttribute('data-workspace-status-drop-target', '')
|
||||
lane.dataset.workspaceStatus = 'doing'
|
||||
setRect(lane, { left: 0, top: 0, right: 200, bottom: 220, width: 200, height: 220 })
|
||||
if (args.publishFullIds) {
|
||||
lane.dataset.workspaceLaneFullIds = serializeWorkspaceLaneFullIds([...args.fullLaneIds]) ?? ''
|
||||
}
|
||||
|
||||
const laneScroll = document.createElement('div')
|
||||
laneScroll.setAttribute('data-workspace-board-lane-scroll', '')
|
||||
setRect(laneScroll, { left: 0, top: 0, right: 200, bottom: 220, width: 200, height: 220 })
|
||||
|
||||
const totalSize = args.viewIds.length * VIRTUAL_CARD_PITCH
|
||||
const spacer = document.createElement('div')
|
||||
setRect(spacer, {
|
||||
left: 8,
|
||||
top: VIRTUAL_SPACER_TOP,
|
||||
right: 192,
|
||||
bottom: VIRTUAL_SPACER_TOP + totalSize,
|
||||
width: 184,
|
||||
height: totalSize
|
||||
})
|
||||
|
||||
for (let index = args.windowRange[0]; index <= args.windowRange[1]; index++) {
|
||||
const worktreeId = args.viewIds[index]!
|
||||
const card = document.createElement('div')
|
||||
card.setAttribute('data-workspace-board-card-id', worktreeId)
|
||||
card.dataset.workspaceBoardCardId = worktreeId
|
||||
card.dataset.workspaceBoardCardIndex = String(index)
|
||||
const top = VIRTUAL_SPACER_TOP + index * VIRTUAL_CARD_PITCH
|
||||
setRect(card, {
|
||||
left: 8,
|
||||
top,
|
||||
right: 192,
|
||||
bottom: top + VIRTUAL_CARD_HEIGHT,
|
||||
width: 184,
|
||||
height: VIRTUAL_CARD_HEIGHT
|
||||
})
|
||||
setVisible(card)
|
||||
spacer.append(card)
|
||||
}
|
||||
|
||||
laneScroll.append(spacer)
|
||||
lane.append(laneScroll)
|
||||
board.append(lane)
|
||||
document.body.append(board)
|
||||
setElementFromPoint(lane)
|
||||
|
||||
const unregister = registerWorkspaceKanbanVirtualLaneLayout({
|
||||
scrollElement: laneScroll,
|
||||
spacerElement: spacer,
|
||||
getItemIds: () => args.viewIds,
|
||||
getMeasurements: () =>
|
||||
args.viewIds.map((_, index) => ({
|
||||
index,
|
||||
start: index * VIRTUAL_CARD_PITCH,
|
||||
end: index * VIRTUAL_CARD_PITCH + VIRTUAL_CARD_HEIGHT
|
||||
}))
|
||||
})
|
||||
return { lane, unregister }
|
||||
}
|
||||
|
||||
// 40-member lane under a search that matches every other card.
|
||||
function searchedVirtualLaneIds(): { fullLaneIds: string[]; viewIds: string[] } {
|
||||
const fullLaneIds = Array.from({ length: 40 }, (_, index) => `doing-${index}`)
|
||||
return { fullLaneIds, viewIds: fullLaneIds.filter((_, index) => index % 2 === 0) }
|
||||
}
|
||||
|
||||
function worktree(args: {
|
||||
id: string
|
||||
workspaceStatus: string
|
||||
@@ -356,6 +441,74 @@ describe('workspace kanban sidebar drop DOM bridge', () => {
|
||||
expect(resolveWorkspaceKanbanSidebarFullLaneDropIndex('doing', 2)).toBe(4)
|
||||
})
|
||||
|
||||
it('lands a virtualized searched lane drop where the indicator pointed', () => {
|
||||
const { fullLaneIds, viewIds } = searchedVirtualLaneIds()
|
||||
const { unregister } = appendVirtualBoard({
|
||||
fullLaneIds,
|
||||
viewIds,
|
||||
windowRange: [8, 17],
|
||||
publishFullIds: true
|
||||
})
|
||||
|
||||
const target = getWorkspaceKanbanSidebarDropTarget(24, 210)
|
||||
// The indicator sits between the mounted cards for view items 12 and 13,
|
||||
// whose full-lane neighbours are doing-24 and doing-26.
|
||||
expect(target).toMatchObject({ status: 'doing', dropIndex: 13, dropIndicatorY: 218 })
|
||||
expect(resolveWorkspaceKanbanSidebarFullLaneDropIndex('doing', target.dropIndex)).toBe(26)
|
||||
|
||||
unregister()
|
||||
})
|
||||
|
||||
it('translates virtualized searched lane drops above and below the mounted window', () => {
|
||||
const { fullLaneIds, viewIds } = searchedVirtualLaneIds()
|
||||
const { unregister } = appendVirtualBoard({
|
||||
fullLaneIds,
|
||||
viewIds,
|
||||
windowRange: [8, 17],
|
||||
publishFullIds: true
|
||||
})
|
||||
|
||||
expect(resolveWorkspaceKanbanSidebarFullLaneDropIndex('doing', 0)).toBe(0)
|
||||
expect(resolveWorkspaceKanbanSidebarFullLaneDropIndex('doing', 3)).toBe(6)
|
||||
expect(resolveWorkspaceKanbanSidebarFullLaneDropIndex('doing', 19)).toBe(38)
|
||||
expect(resolveWorkspaceKanbanSidebarFullLaneDropIndex('doing', 20)).toBe(39)
|
||||
|
||||
unregister()
|
||||
})
|
||||
|
||||
it('passes a virtualized unfiltered lane drop index through untranslated', () => {
|
||||
const { fullLaneIds } = searchedVirtualLaneIds()
|
||||
const { unregister } = appendVirtualBoard({
|
||||
fullLaneIds,
|
||||
viewIds: fullLaneIds,
|
||||
windowRange: [8, 17],
|
||||
publishFullIds: false
|
||||
})
|
||||
|
||||
expect(getWorkspaceKanbanSidebarDropGroups()).toEqual([
|
||||
{ key: 'doing', worktreeIds: fullLaneIds }
|
||||
])
|
||||
expect(resolveWorkspaceKanbanSidebarFullLaneDropIndex('doing', 13)).toBe(13)
|
||||
|
||||
unregister()
|
||||
})
|
||||
|
||||
it('still counts unlaid-out cards as lane members without a virtual layout', () => {
|
||||
const { lane } = appendBoard()
|
||||
const secondCard = lane.querySelectorAll<HTMLElement>('[data-workspace-board-card-id]')[1]!
|
||||
secondCard.remove()
|
||||
const hiddenCard = document.createElement('div')
|
||||
hiddenCard.setAttribute('data-workspace-board-card-id', 'doing-hidden')
|
||||
hiddenCard.dataset.workspaceBoardCardId = 'doing-hidden'
|
||||
lane.append(hiddenCard, secondCard)
|
||||
setElementFromPoint(lane)
|
||||
|
||||
expect(getWorkspaceKanbanSidebarDropGroups()).toEqual([
|
||||
{ key: 'doing', worktreeIds: ['doing-a', 'doing-hidden', 'doing-b'] }
|
||||
])
|
||||
expect(resolveWorkspaceKanbanSidebarFullLaneDropIndex('doing', 1)).toBe(2)
|
||||
})
|
||||
|
||||
it('passes the drop index through for a lane it cannot find', () => {
|
||||
appendBoard()
|
||||
|
||||
|
||||
@@ -23,9 +23,11 @@ import {
|
||||
updateCardDropIndicator,
|
||||
type WorkspaceKanbanCardDropTarget
|
||||
} from './workspace-kanban-card-pointer-drag-dom'
|
||||
import { getWorkspaceKanbanVirtualLaneItemIds } from './workspace-kanban-virtual-lane-layout'
|
||||
|
||||
const BOARD_SELECTOR = '[data-workspace-board-selection-surface]'
|
||||
const BOARD_SHEET_SELECTOR = '[data-workspace-board-sheet]'
|
||||
const LANE_SCROLL_SELECTOR = '[data-workspace-board-lane-scroll]'
|
||||
const EXTERNAL_DRAG_TARGET_ATTR = 'data-workspace-board-external-drag-target'
|
||||
|
||||
let externalDragTargetElement: HTMLElement | null = null
|
||||
@@ -53,24 +55,33 @@ function getLaneCardIds(lane: HTMLElement): HTMLElement[] {
|
||||
return Array.from(lane.querySelectorAll<HTMLElement>(CARD_SELECTOR))
|
||||
}
|
||||
|
||||
// Mirrors getCardDropTarget's card scan so both sides share one index space.
|
||||
// The offsetParent read forces layout, so callers pass the card list they
|
||||
// already collected rather than re-querying.
|
||||
function toRenderedCardIds(cards: readonly HTMLElement[]): string[] {
|
||||
return cards
|
||||
.filter((card) => card.offsetParent !== null)
|
||||
.flatMap((card) => card.dataset.workspaceBoardCardId ?? [])
|
||||
}
|
||||
|
||||
// Why: board search hides non-matching cards, so the rendered card scan is a
|
||||
// filtered lane. Lanes publish their full membership for exactly this reader.
|
||||
// The fallback is the unfiltered card list — a lane member the browser is not
|
||||
// laying out is still a member for manual-order purposes.
|
||||
function toLaneFullWorktreeIds(lane: HTMLElement, cards: readonly HTMLElement[]): string[] {
|
||||
return (
|
||||
parseWorkspaceLaneFullIds(lane.dataset.workspaceLaneFullIds) ??
|
||||
cards.flatMap((card) => card.dataset.workspaceBoardCardId ?? [])
|
||||
)
|
||||
/**
|
||||
* `viewIds` is the lane in the index space `getCardDropTarget` reports, and
|
||||
* `fullLaneIds` is the lane's whole membership. Board search hides non-matching
|
||||
* cards, so lanes publish their full membership for exactly this reader.
|
||||
*
|
||||
* Why the virtual layout first: lanes virtualize, so the mounted cards are a
|
||||
* window rather than the lane view, and `getCardDropTarget` indexes the same
|
||||
* virtual layout. The DOM scan is only the fallback for a lane the virtualizer
|
||||
* never claimed — there the unfiltered card list is the full lane, since a card
|
||||
* the browser is not laying out is still a member for manual-order purposes.
|
||||
*/
|
||||
function toLaneDropIds(lane: HTMLElement): { fullLaneIds: string[]; viewIds: string[] } {
|
||||
const publishedFullLaneIds = parseWorkspaceLaneFullIds(lane.dataset.workspaceLaneFullIds)
|
||||
const laneScroll = lane.querySelector<HTMLElement>(LANE_SCROLL_SELECTOR)
|
||||
const virtualItemIds = laneScroll ? getWorkspaceKanbanVirtualLaneItemIds(laneScroll) : null
|
||||
if (virtualItemIds) {
|
||||
const viewIds = [...virtualItemIds]
|
||||
return { fullLaneIds: publishedFullLaneIds ?? viewIds, viewIds }
|
||||
}
|
||||
const cards = getLaneCardIds(lane)
|
||||
return {
|
||||
fullLaneIds:
|
||||
publishedFullLaneIds ?? cards.flatMap((card) => card.dataset.workspaceBoardCardId ?? []),
|
||||
viewIds: cards
|
||||
.filter((card) => card.offsetParent !== null)
|
||||
.flatMap((card) => card.dataset.workspaceBoardCardId ?? [])
|
||||
}
|
||||
}
|
||||
|
||||
function getStatusDropTargetElement(
|
||||
@@ -128,7 +139,7 @@ export function getWorkspaceKanbanSidebarDropGroups(): WorktreeDragGroup[] {
|
||||
if (!status) {
|
||||
return []
|
||||
}
|
||||
return [{ key: status, worktreeIds: toLaneFullWorktreeIds(lane, getLaneCardIds(lane)) }]
|
||||
return [{ key: status, worktreeIds: toLaneDropIds(lane).fullLaneIds }]
|
||||
})
|
||||
}
|
||||
|
||||
@@ -144,26 +155,26 @@ export function getWorkspaceKanbanSidebarDropTarget(
|
||||
}
|
||||
|
||||
/**
|
||||
* Translates a tracked drop index — which counts *rendered* cards, matching the
|
||||
* drop indicator — onto the full lane that `getWorkspaceKanbanSidebarDropGroups`
|
||||
* reports. Call this once, at the commit boundary: a tracked target can be
|
||||
* committed after the pointer has left the lane, so translating earlier would
|
||||
* miss that path.
|
||||
* Translates a tracked drop index — which counts the lane's *view* items,
|
||||
* matching the drop indicator — onto the full lane that
|
||||
* `getWorkspaceKanbanSidebarDropGroups` reports. Call this once, at the commit
|
||||
* boundary: a tracked target can be committed after the pointer has left the
|
||||
* lane, so translating earlier would miss that path.
|
||||
*/
|
||||
export function resolveWorkspaceKanbanSidebarFullLaneDropIndex(
|
||||
status: WorkspaceStatus,
|
||||
renderedDropIndex: number
|
||||
viewDropIndex: number
|
||||
): number {
|
||||
const board = getWorkspaceKanbanBoardElement()
|
||||
const lane = board ? getStatusDropTargetElement(board, status) : null
|
||||
if (!lane) {
|
||||
return renderedDropIndex
|
||||
return viewDropIndex
|
||||
}
|
||||
const cards = getLaneCardIds(lane)
|
||||
const { fullLaneIds, viewIds } = toLaneDropIds(lane)
|
||||
return resolveFullLaneDropIndex({
|
||||
fullLaneIds: toLaneFullWorktreeIds(lane, cards),
|
||||
renderedIds: toRenderedCardIds(cards),
|
||||
filteredDropIndex: renderedDropIndex
|
||||
fullLaneIds,
|
||||
renderedIds: viewIds,
|
||||
filteredDropIndex: viewDropIndex
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -44,6 +44,16 @@ export function registerWorkspaceKanbanVirtualLaneLayout(args: {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The lane's item ids in the index space `resolveWorkspaceKanbanVirtualLaneDropIndex`
|
||||
* reports — every item in the lane view, not just the mounted virtual window.
|
||||
*/
|
||||
export function getWorkspaceKanbanVirtualLaneItemIds(
|
||||
scrollElement: HTMLElement
|
||||
): readonly string[] | null {
|
||||
return virtualLaneLayouts.get(scrollElement)?.getItemIds() ?? null
|
||||
}
|
||||
|
||||
export function getWorkspaceKanbanVirtualLaneItemRects(
|
||||
scrollElement: HTMLElement
|
||||
): WorkspaceKanbanVirtualLaneItemRect[] | null {
|
||||
|
||||
@@ -55,10 +55,10 @@ async function drainFakeTimerWork(limit = 20): Promise<void> {
|
||||
|
||||
// Why: a reveal remount reads the still-live park watcher entry at connect time to
|
||||
// tell itself apart from an in-place reattach; the host disposes it a beat later.
|
||||
async function parkTabForReveal(tabId: string, ptyId: string): Promise<void> {
|
||||
async function parkTabForReveal(tabId: string, ptyId: string, worktreeId = 'wt-1'): Promise<void> {
|
||||
const { parkedWatchersByTabId } = await import('./terminal-parked-watcher-registry')
|
||||
parkedWatchersByTabId.set(tabId, {
|
||||
worktreeId: 'wt-1',
|
||||
worktreeId,
|
||||
tabPtyId: ptyId,
|
||||
paneIdByPtyId: new Map([[ptyId, 1]]),
|
||||
disposersByPtyId: new Map([[ptyId, () => {}]])
|
||||
@@ -20243,6 +20243,62 @@ describe('connectPanePty', () => {
|
||||
expect(transport.getPtyId).toHaveReturnedWith(remotePtyId)
|
||||
})
|
||||
|
||||
it('restores a local main-model snapshot after a contentless park reattach', async () => {
|
||||
const { connectPanePty } = await import('./pty-connection')
|
||||
const localPtyId = 'global-floating-terminal@@terminal-1'
|
||||
const transport = createMockTransport(localPtyId)
|
||||
transport.connect.mockImplementation(async ({ sessionId }: { sessionId?: string }) => {
|
||||
transport.getPtyId.mockReturnValue(localPtyId)
|
||||
return sessionId ? { id: localPtyId, isReattach: true } : null
|
||||
})
|
||||
transportFactoryQueue.push(transport)
|
||||
const getMainBufferSnapshot = vi.mocked(window.api.pty.getMainBufferSnapshot)
|
||||
getMainBufferSnapshot.mockResolvedValue({
|
||||
data: 'FLOATING-PARK-RESTORE-OK\r\n',
|
||||
cols: 113,
|
||||
rows: 32,
|
||||
seq: 558,
|
||||
source: 'headless'
|
||||
})
|
||||
await parkTabForReveal('tab-1', localPtyId, 'global-floating-terminal')
|
||||
mockStoreState = {
|
||||
...mockStoreState,
|
||||
tabsByWorktree: {
|
||||
'global-floating-terminal': [{ id: 'tab-1', ptyId: localPtyId }]
|
||||
},
|
||||
ptyIdsByTabId: { 'tab-1': [localPtyId] },
|
||||
terminalLayoutsByTabId: {
|
||||
'tab-1': {
|
||||
root: { type: 'leaf', leafId: LEAF_1 },
|
||||
activeLeafId: LEAF_1,
|
||||
expandedLeafId: null,
|
||||
ptyIdsByLeafId: { [LEAF_1]: localPtyId }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const pane = createPane(1)
|
||||
const { parseCallbacks, writes } = captureCallbackTerminalWrites(pane)
|
||||
const deps = createDeps({
|
||||
worktreeId: 'global-floating-terminal',
|
||||
restoredLeafId: LEAF_1,
|
||||
restoredPtyIdByLeafId: { [LEAF_1]: localPtyId }
|
||||
})
|
||||
connectPanePty(pane as never, createManager(1) as never, deps as never)
|
||||
for (let step = 0; step < 30; step += 1) {
|
||||
parseCallbacks.shift()?.()
|
||||
await flushAsyncTicks(2)
|
||||
}
|
||||
|
||||
expect(getMainBufferSnapshot).toHaveBeenCalledOnce()
|
||||
expect(getMainBufferSnapshot).toHaveBeenCalledWith(localPtyId, { scrollbackRows: 5000 })
|
||||
expect(transport.connect).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ sessionId: localPtyId })
|
||||
)
|
||||
expect(deps.syncPanePtyLayoutBinding).toHaveBeenCalledWith(1, localPtyId)
|
||||
expect(writes.join('')).toContain('FLOATING-PARK-RESTORE-OK')
|
||||
})
|
||||
|
||||
it('falls back to relay replay when the SSH model snapshot stalls', async () => {
|
||||
const { connectPanePty } = await import('./pty-connection')
|
||||
const { SSH_REATTACH_MODEL_SNAPSHOT_TIMEOUT_MS } = await import('./ssh-reattach-model-restore')
|
||||
|
||||
@@ -7822,7 +7822,9 @@ export function connectPanePty(
|
||||
// the subscribe screen without keeping the old xterm mounted.
|
||||
let prefetchedParkModelSnapshot: PtyBufferSnapshot | null = null
|
||||
if (revealFollowsTerminalPark && (!hasStructuralReplay || isRemoteRuntimePtyId(ptyId))) {
|
||||
if (isRemoteRuntimePtyId(ptyId)) {
|
||||
if (parseAppSshPtyId(ptyId)) {
|
||||
prefetchedParkModelSnapshot = await fetchSshMainModelReattachSnapshot()
|
||||
} else {
|
||||
try {
|
||||
prefetchedParkModelSnapshot = await serializeHiddenOutputSnapshot(ptyId, {
|
||||
scrollbackRows: resolveHiddenRestoreScrollbackRows(pane.terminal.options.scrollback)
|
||||
@@ -7830,8 +7832,6 @@ export function connectPanePty(
|
||||
} catch {
|
||||
prefetchedParkModelSnapshot = null
|
||||
}
|
||||
} else {
|
||||
prefetchedParkModelSnapshot = await fetchSshMainModelReattachSnapshot()
|
||||
}
|
||||
if (!isCurrentReattachPayload()) {
|
||||
return false
|
||||
|
||||
@@ -35,7 +35,8 @@ export {
|
||||
disposeParkedTerminalWatchersForPtyIds,
|
||||
disposeParkedTerminalWatchersForWorktree,
|
||||
getParkedTerminalWatcherTabIds,
|
||||
pruneParkedTerminalWatchers
|
||||
pruneParkedTerminalWatchers,
|
||||
terminalWatcherLiveWorkspaceIds
|
||||
} from './terminal-parked-watcher-registry'
|
||||
export type { ParkedTerminalPaneCapture } from './terminal-parked-watcher-registry'
|
||||
export {
|
||||
|
||||
@@ -8,16 +8,24 @@ import {
|
||||
hasPreHandlerPtyExit
|
||||
} from './pty-pre-handler-buffer'
|
||||
import {
|
||||
capturedPanesByTabId,
|
||||
parkedWatchersByTabId,
|
||||
pruneParkedTerminalWatchers
|
||||
pruneParkedTerminalWatchers,
|
||||
terminalWatcherLiveWorkspaceIds
|
||||
} from './terminal-parked-watcher-registry'
|
||||
import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants'
|
||||
|
||||
const TAB_ID = 'removed-parked-tab'
|
||||
const PTY_ID = 'removed-worktree@@parked-pty'
|
||||
const FLOATING_TAB_ID = 'floating-parked-tab'
|
||||
const FLOATING_PTY_ID = `${FLOATING_TERMINAL_WORKTREE_ID}@@parked-pty`
|
||||
|
||||
describe('terminal parked watcher registry removal', () => {
|
||||
afterEach(() => {
|
||||
parkedWatchersByTabId.delete(TAB_ID)
|
||||
parkedWatchersByTabId.delete(FLOATING_TAB_ID)
|
||||
capturedPanesByTabId.delete(TAB_ID)
|
||||
capturedPanesByTabId.delete(FLOATING_TAB_ID)
|
||||
clearPreHandlerPtyState(PTY_ID)
|
||||
})
|
||||
|
||||
@@ -51,4 +59,35 @@ describe('terminal parked watcher registry removal', () => {
|
||||
expect(exit).not.toHaveBeenCalled()
|
||||
expect(delayedData).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps the floating workspace while pruning a removed workspace', () => {
|
||||
const removedDispose = vi.fn()
|
||||
const floatingDispose = vi.fn()
|
||||
parkedWatchersByTabId.set(TAB_ID, {
|
||||
worktreeId: 'removed-worktree',
|
||||
tabPtyId: PTY_ID,
|
||||
paneIdByPtyId: new Map([[PTY_ID, 1]]),
|
||||
disposersByPtyId: new Map([[PTY_ID, removedDispose]])
|
||||
})
|
||||
parkedWatchersByTabId.set(FLOATING_TAB_ID, {
|
||||
worktreeId: FLOATING_TERMINAL_WORKTREE_ID,
|
||||
tabPtyId: FLOATING_PTY_ID,
|
||||
paneIdByPtyId: new Map([[FLOATING_PTY_ID, 1]]),
|
||||
disposersByPtyId: new Map([[FLOATING_PTY_ID, floatingDispose]])
|
||||
})
|
||||
capturedPanesByTabId.set(TAB_ID, { worktreeId: 'removed-worktree', panes: [] })
|
||||
capturedPanesByTabId.set(FLOATING_TAB_ID, {
|
||||
worktreeId: FLOATING_TERMINAL_WORKTREE_ID,
|
||||
panes: []
|
||||
})
|
||||
|
||||
pruneParkedTerminalWatchers(terminalWatcherLiveWorkspaceIds([]))
|
||||
|
||||
expect(parkedWatchersByTabId.has(TAB_ID)).toBe(false)
|
||||
expect(capturedPanesByTabId.has(TAB_ID)).toBe(false)
|
||||
expect(removedDispose).toHaveBeenCalledOnce()
|
||||
expect(parkedWatchersByTabId.has(FLOATING_TAB_ID)).toBe(true)
|
||||
expect(capturedPanesByTabId.has(FLOATING_TAB_ID)).toBe(true)
|
||||
expect(floatingDispose).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
* import cycle-free, mirroring how pty-dispatcher exports its handler maps.
|
||||
*/
|
||||
import { discardPreHandlerPtyState } from './pty-pre-handler-buffer'
|
||||
import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants'
|
||||
|
||||
export type ParkedTerminalPaneCapture = {
|
||||
ptyId: string | null
|
||||
@@ -51,6 +52,11 @@ export function getParkedTerminalWatcherTabIds(): string[] {
|
||||
return Array.from(parkedWatchersByTabId.keys())
|
||||
}
|
||||
|
||||
// Why: the floating workspace is synthetic, so repo/folder surface lists never include it.
|
||||
export function terminalWatcherLiveWorkspaceIds(workspaceIds: Iterable<string>): Set<string> {
|
||||
return new Set([...workspaceIds, FLOATING_TERMINAL_WORKTREE_ID])
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether this tab is parked right now — the reveal remount's own mount effect
|
||||
* runs before the host effect that disposes the watcher (child effects first),
|
||||
|
||||
@@ -6,6 +6,7 @@ import type { TerminalTab } from '../../../../shared/types'
|
||||
const mocks = vi.hoisted(() => ({
|
||||
storeState: {
|
||||
pendingStartupByTabId: {} as Record<string, unknown>,
|
||||
runtimeStatusByEnvironmentId: new Map(),
|
||||
settings: {} as Record<string, unknown>,
|
||||
terminalLayoutsByTabId: {} as Record<string, { ptyIdsByLeafId?: Record<string, string> }>
|
||||
},
|
||||
@@ -75,6 +76,34 @@ describe('useTerminalTabColdParking measure-clock contract', () => {
|
||||
mocks.exemptTabIds = new Set()
|
||||
mocks.exemptSelectCalls = 0
|
||||
mocks.storeState.terminalLayoutsByTabId = {}
|
||||
mocks.storeState.runtimeStatusByEnvironmentId = new Map()
|
||||
})
|
||||
|
||||
it('parks paired-runtime tabs only when their exact host advertises restore', () => {
|
||||
const environmentId = 'paired-env'
|
||||
const remoteArgs = {
|
||||
...hookArgs(false),
|
||||
terminalTabs: [
|
||||
{ ...terminalTab('tab-1'), ptyId: `remote:${environmentId}@@term-1` },
|
||||
{ ...terminalTab('tab-2'), ptyId: `remote:${environmentId}@@term-2` }
|
||||
]
|
||||
}
|
||||
for (const [advertisedEnvironmentId, expected] of [
|
||||
[environmentId, new Set(['tab-2'])],
|
||||
['other-env', new Set()]
|
||||
] as const) {
|
||||
mocks.storeState.runtimeStatusByEnvironmentId = new Map([
|
||||
[advertisedEnvironmentId, { status: { capabilities: ['terminal.paired-parking.v1'] } }]
|
||||
])
|
||||
const { result, unmount } = renderHook(() => useTerminalTabColdParking(remoteArgs))
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(TERMINAL_TAB_HOT_RETAIN_MS + 1)
|
||||
})
|
||||
|
||||
expect(result.current).toEqual(expected)
|
||||
unmount()
|
||||
}
|
||||
})
|
||||
|
||||
// Why: the worktree layer preserves hiddenSince through a background-measure
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
import { getTerminalTabColdParkRecheckDelayMs } from './terminal-cold-park-recheck-deadlines'
|
||||
import {
|
||||
TERMINAL_TAB_COLD_PARK_DELAY_MS,
|
||||
selectPairedRuntimeParkingEnvironmentIds,
|
||||
selectColdParkedTerminalTabs,
|
||||
type TerminalTabColdParkCandidate
|
||||
} from './terminal-hidden-view-parking'
|
||||
@@ -90,6 +91,11 @@ export function useTerminalTabColdParking(args: {
|
||||
const terminalSshParkingEnabled = useAppStore(
|
||||
(state) => state.settings?.terminalSshViewParking !== false
|
||||
)
|
||||
const runtimeStatusByEnvironmentId = useAppStore((state) => state.runtimeStatusByEnvironmentId)
|
||||
const pairedRuntimeParkingEnvironmentIds = useMemo(
|
||||
() => selectPairedRuntimeParkingEnvironmentIds(runtimeStatusByEnvironmentId),
|
||||
[runtimeStatusByEnvironmentId]
|
||||
)
|
||||
const terminalTabHiddenSinceRef = useRef(new Map<string, number>())
|
||||
// Why (shared measure-clock contract with Terminal.tsx): tab hiddenSince
|
||||
// survives a background-measure window so per-tab park deadlines stay in
|
||||
@@ -189,7 +195,10 @@ export function useTerminalTabColdParking(args: {
|
||||
parkingEnabled: terminalParkingEnabled,
|
||||
nowMs,
|
||||
parkCooldownUntilMs: measureParkCooldownUntilRef.current,
|
||||
restorePolicy: { sshParkingEnabled: terminalSshParkingEnabled },
|
||||
restorePolicy: {
|
||||
sshParkingEnabled: terminalSshParkingEnabled,
|
||||
pairedRuntimeParkingEnvironmentIds
|
||||
},
|
||||
...overrides
|
||||
})
|
||||
// Why: a tab the byte watchers cannot cover (no capture, no layout
|
||||
@@ -238,6 +247,7 @@ export function useTerminalTabColdParking(args: {
|
||||
assignments,
|
||||
isWorktreeActive,
|
||||
pendingStartupByTabId,
|
||||
pairedRuntimeParkingEnvironmentIds,
|
||||
shouldMeasureHiddenWorktree,
|
||||
terminalParkingEnabled,
|
||||
terminalSshParkingEnabled,
|
||||
|
||||
@@ -205,10 +205,22 @@ describe('cold activation tab deferral', () => {
|
||||
const tabIds = (count: number): string[] =>
|
||||
Array.from({ length: count }, (_, index) => `tab-${index + 1}`)
|
||||
|
||||
it('enables deferral only for a positively resolved local execution host', () => {
|
||||
it('enables deferral for local and snapshot-capable paired execution hosts', () => {
|
||||
expect(canDeferColdActivationTabsForHost({ executionHostId: 'local' })).toBe(true)
|
||||
expect(canDeferColdActivationTabsForHost({ executionHostId: 'ssh:ssh-1' })).toBe(false)
|
||||
expect(canDeferColdActivationTabsForHost({ executionHostId: 'runtime:runtime-1' })).toBe(false)
|
||||
expect(
|
||||
canDeferColdActivationTabsForHost({
|
||||
executionHostId: 'runtime:runtime-1',
|
||||
pairedRuntimeParkingEnvironmentIds: new Set(['runtime-1'])
|
||||
})
|
||||
).toBe(true)
|
||||
expect(
|
||||
canDeferColdActivationTabsForHost({
|
||||
executionHostId: 'runtime:runtime-1',
|
||||
pairedRuntimeParkingEnvironmentIds: new Set(['runtime-2'])
|
||||
})
|
||||
).toBe(false)
|
||||
expect(canDeferColdActivationTabsForHost({ executionHostId: null })).toBe(false)
|
||||
})
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import {
|
||||
BACKGROUND_MOUNT_TERMINAL_WORKTREE_EVENT,
|
||||
type BackgroundMountTerminalWorktreeDetail
|
||||
} from '@/constants/terminal'
|
||||
import { parseExecutionHostId } from '../../../../shared/execution-host'
|
||||
|
||||
const pendingMounts = new Map<string, BackgroundMountTerminalWorktreeDetail>()
|
||||
const requestListeners = new Set<() => void>()
|
||||
@@ -153,10 +154,18 @@ export function canMountTerminalWorkspaceForStartup(args: {
|
||||
|
||||
export function canDeferColdActivationTabsForHost(args: {
|
||||
executionHostId: string | null
|
||||
pairedRuntimeParkingEnvironmentIds?: ReadonlySet<string>
|
||||
}): boolean {
|
||||
// Why: restored identities can be transient or stale while remote ownership
|
||||
// hydrates. Only positively confirmed local execution has daemon snapshots.
|
||||
return args.executionHostId === 'local'
|
||||
const host = parseExecutionHostId(args.executionHostId)
|
||||
if (host?.kind === 'local') {
|
||||
return true
|
||||
}
|
||||
// Why: remote ownership must match the exact host advertising bounded
|
||||
// snapshots; stale runtime identities stay eager instead of losing output.
|
||||
return (
|
||||
host?.kind === 'runtime' &&
|
||||
args.pairedRuntimeParkingEnvironmentIds?.has(host.environmentId) === true
|
||||
)
|
||||
}
|
||||
|
||||
function replaceActivationDeferredMountTabs(
|
||||
|
||||
@@ -5539,6 +5539,306 @@ describe('useIpcEvents agent status snapshot integration', () => {
|
||||
}
|
||||
)
|
||||
|
||||
it('does not let initial SSH port snapshots overwrite newer push events', async () => {
|
||||
const targetId = 'target-ports'
|
||||
const secondTargetId = 'target-ports-second'
|
||||
const rejectingTargetId = 'target-ports-rejecting'
|
||||
const partialTargetId = 'target-ports-partial'
|
||||
const connectedState = {
|
||||
targetId,
|
||||
status: 'connected' as const,
|
||||
error: null,
|
||||
reconnectAttempt: 0,
|
||||
providerEpoch: 'epoch-ports',
|
||||
connectionGeneration: 3
|
||||
}
|
||||
const secondConnectedState = {
|
||||
...connectedState,
|
||||
targetId: secondTargetId,
|
||||
providerEpoch: 'epoch-ports-second'
|
||||
}
|
||||
const rejectingConnectedState = {
|
||||
...connectedState,
|
||||
targetId: rejectingTargetId,
|
||||
providerEpoch: 'epoch-ports-rejecting'
|
||||
}
|
||||
const partialConnectedState = {
|
||||
...connectedState,
|
||||
targetId: partialTargetId,
|
||||
providerEpoch: null,
|
||||
connectionGeneration: undefined
|
||||
}
|
||||
const reconciledPartialState = {
|
||||
...connectedState,
|
||||
targetId: partialTargetId,
|
||||
providerEpoch: 'epoch-ports-partial',
|
||||
connectionGeneration: 4
|
||||
}
|
||||
const liveForward = {
|
||||
id: 'forward-live',
|
||||
targetId,
|
||||
localPort: 17860,
|
||||
remoteHost: '127.0.0.1',
|
||||
remotePort: 7860,
|
||||
status: 'active' as const
|
||||
}
|
||||
const secondForward = {
|
||||
...liveForward,
|
||||
id: 'forward-second',
|
||||
targetId: secondTargetId,
|
||||
localPort: 17861,
|
||||
remotePort: 7861
|
||||
}
|
||||
const rejectingTargetForward = {
|
||||
...liveForward,
|
||||
id: 'forward-rejecting-target',
|
||||
targetId: rejectingTargetId,
|
||||
localPort: 17862,
|
||||
remotePort: 7862
|
||||
}
|
||||
const partialTargetForward = {
|
||||
...liveForward,
|
||||
id: 'forward-partial-target',
|
||||
targetId: partialTargetId,
|
||||
localPort: 17863,
|
||||
remotePort: 7863
|
||||
}
|
||||
const detectedPort = {
|
||||
port: 7860,
|
||||
pid: 42,
|
||||
processName: 'python',
|
||||
command: 'python -m http.server 7860'
|
||||
}
|
||||
const secondDetectedPort = {
|
||||
...detectedPort,
|
||||
port: 7861,
|
||||
pid: 43,
|
||||
command: 'python -m http.server 7861'
|
||||
}
|
||||
let resolveForwards: (value: []) => void = () => {}
|
||||
let resolveDetected: (value: (typeof detectedPort)[]) => void = () => {}
|
||||
let resolveSecondForwards: (value: (typeof secondForward)[]) => void = () => {}
|
||||
let resolveSecondDetected: (value: []) => void = () => {}
|
||||
const forwardsSnapshot = new Promise<[]>((resolve) => {
|
||||
resolveForwards = resolve
|
||||
})
|
||||
const detectedSnapshot = new Promise<(typeof detectedPort)[]>((resolve) => {
|
||||
resolveDetected = resolve
|
||||
})
|
||||
const secondForwardsSnapshot = new Promise<(typeof secondForward)[]>((resolve) => {
|
||||
resolveSecondForwards = resolve
|
||||
})
|
||||
const secondDetectedSnapshot = new Promise<[]>((resolve) => {
|
||||
resolveSecondDetected = resolve
|
||||
})
|
||||
const listPortForwards = vi.fn(({ targetId: requestedTargetId }: { targetId: string }) => {
|
||||
if (requestedTargetId === rejectingTargetId) {
|
||||
return Promise.resolve([rejectingTargetForward])
|
||||
}
|
||||
if (requestedTargetId === partialTargetId) {
|
||||
return Promise.resolve([partialTargetForward])
|
||||
}
|
||||
return requestedTargetId === targetId ? forwardsSnapshot : secondForwardsSnapshot
|
||||
})
|
||||
const listDetectedPorts = vi.fn(({ targetId: requestedTargetId }: { targetId: string }) => {
|
||||
if (requestedTargetId === rejectingTargetId) {
|
||||
return Promise.reject(new Error('detected snapshot unavailable'))
|
||||
}
|
||||
if (requestedTargetId === partialTargetId) {
|
||||
return Promise.resolve([])
|
||||
}
|
||||
return requestedTargetId === targetId ? detectedSnapshot : secondDetectedSnapshot
|
||||
})
|
||||
let forwardListener:
|
||||
| ((data: { targetId: string; forwards: (typeof liveForward)[] }) => void)
|
||||
| undefined
|
||||
let detectedListener:
|
||||
| ((data: { targetId: string; ports: (typeof detectedPort)[] }) => void)
|
||||
| undefined
|
||||
const setPortForwards = vi.fn()
|
||||
const setDetectedPorts = vi.fn()
|
||||
const sshConnectionStates = new Map<
|
||||
string,
|
||||
| typeof connectedState
|
||||
| typeof secondConnectedState
|
||||
| typeof rejectingConnectedState
|
||||
| typeof partialConnectedState
|
||||
| typeof reconciledPartialState
|
||||
>()
|
||||
const storeState = buildStoreState({
|
||||
sshTargetLabels: new Map([
|
||||
[targetId, 'Ports Target'],
|
||||
[secondTargetId, 'Second Ports Target'],
|
||||
[rejectingTargetId, 'Rejecting Ports Target'],
|
||||
[partialTargetId, 'Partial Ports Target']
|
||||
]),
|
||||
sshConnectionStates,
|
||||
setSshConnectionState: (
|
||||
nextTargetId: string,
|
||||
state:
|
||||
| typeof connectedState
|
||||
| typeof secondConnectedState
|
||||
| typeof rejectingConnectedState
|
||||
| typeof partialConnectedState
|
||||
| typeof reconciledPartialState
|
||||
) => {
|
||||
sshConnectionStates.set(nextTargetId, state)
|
||||
},
|
||||
setPortForwards,
|
||||
setDetectedPorts,
|
||||
setSshTargetsMetadata: vi.fn(),
|
||||
setRemovedSshTargetLabels: vi.fn(),
|
||||
setRemoteWorkspaceSyncStatus: vi.fn(),
|
||||
fetchRuntimeEnvironmentRepos: vi.fn(async () => []),
|
||||
fetchWorktreeLineage: vi.fn(async () => undefined),
|
||||
clearRemoteDetectedAgents: vi.fn(),
|
||||
clearDirectSshTargetPtyBindings: vi.fn(),
|
||||
clearRemovedSshTargetState: vi.fn(),
|
||||
invalidateStaleDirectSshTargetPtyBindings: vi.fn(() => 0),
|
||||
retryDirectSshTargetPanes: vi.fn(() => 0)
|
||||
})
|
||||
const coordinator = {
|
||||
requestReconnect: vi.fn(async () => ({ status: 'complete' })),
|
||||
replaceAuthority: vi.fn(),
|
||||
prepareOnly: vi.fn(async () => ({ token: null })),
|
||||
correctUnboundTerminals: vi.fn(() => 0),
|
||||
finalizeHydratedTerminals: vi.fn(() => 0),
|
||||
invalidate: vi.fn(),
|
||||
stop: vi.fn()
|
||||
}
|
||||
let partialTargetStateCalls = 0
|
||||
|
||||
stubReactSyncEffect()
|
||||
stubAuxiliaryModules()
|
||||
vi.doMock('../store', () => ({
|
||||
useAppStore: {
|
||||
subscribe: vi.fn(() => () => {}),
|
||||
getState: () => storeState
|
||||
}
|
||||
}))
|
||||
vi.doMock('./direct-ssh-reconnect-rollout', () => ({
|
||||
isDirectSshReconnectCoordinatorRoutingEnabled: () => true
|
||||
}))
|
||||
vi.doMock('./direct-ssh-worktree-refresh-scheduler', () => ({
|
||||
createDirectSshWorktreeRefreshScheduler: () => ({
|
||||
stop: vi.fn(),
|
||||
disposeProvider: vi.fn()
|
||||
})
|
||||
}))
|
||||
vi.doMock('./direct-ssh-host-hydration', () => ({
|
||||
createDirectSshHostHydration: () => ({
|
||||
capturePreparationInput: vi.fn(),
|
||||
readHostScopedLineage: vi.fn(),
|
||||
isPreparationTokenCurrent: vi.fn(() => true),
|
||||
stop: vi.fn()
|
||||
})
|
||||
}))
|
||||
vi.doMock('./direct-ssh-reconnect-coordinator', () => ({
|
||||
createDirectSshReconnectCoordinator: () => coordinator
|
||||
}))
|
||||
vi.doMock('@/lib/direct-ssh-reconnect-product-telemetry', () => ({
|
||||
createDirectSshReconnectProductTelemetryAdapter: vi.fn()
|
||||
}))
|
||||
vi.stubGlobal(
|
||||
'window',
|
||||
buildWindowApi({
|
||||
onSet: () => () => {},
|
||||
ssh: {
|
||||
listTargets: () =>
|
||||
Promise.resolve([
|
||||
{ id: rejectingTargetId, label: 'Rejecting Ports Target' },
|
||||
{ id: partialTargetId, label: 'Partial Ports Target' },
|
||||
{ id: targetId, label: 'Ports Target' },
|
||||
{ id: secondTargetId, label: 'Second Ports Target' }
|
||||
]),
|
||||
listRemovedTargetLabels: () => Promise.resolve({}),
|
||||
getState: ({ targetId: requestedTargetId }: { targetId: string }) => {
|
||||
if (requestedTargetId === partialTargetId) {
|
||||
partialTargetStateCalls += 1
|
||||
return Promise.resolve(
|
||||
partialTargetStateCalls === 1 ? partialConnectedState : reconciledPartialState
|
||||
)
|
||||
}
|
||||
return Promise.resolve(
|
||||
requestedTargetId === rejectingTargetId
|
||||
? rejectingConnectedState
|
||||
: requestedTargetId === targetId
|
||||
? connectedState
|
||||
: secondConnectedState
|
||||
)
|
||||
},
|
||||
listPortForwards,
|
||||
listDetectedPorts,
|
||||
onPortForwardsChanged: (
|
||||
listener: (data: { targetId: string; forwards: (typeof liveForward)[] }) => void
|
||||
) => {
|
||||
forwardListener = listener
|
||||
return () => {}
|
||||
},
|
||||
onDetectedPortsChanged: (
|
||||
listener: (data: { targetId: string; ports: (typeof detectedPort)[] }) => void
|
||||
) => {
|
||||
detectedListener = listener
|
||||
return () => {}
|
||||
}
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
const { useIpcEvents } = await import('./useIpcEvents')
|
||||
useIpcEvents()
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(forwardListener).toBeTypeOf('function')
|
||||
expect(detectedListener).toBeTypeOf('function')
|
||||
expect(partialTargetStateCalls).toBe(2)
|
||||
expect(sshConnectionStates.get(partialTargetId)).toEqual(reconciledPartialState)
|
||||
expect(listPortForwards).toHaveBeenCalledTimes(4)
|
||||
expect(listDetectedPorts).toHaveBeenCalledTimes(4)
|
||||
expect(
|
||||
setPortForwards.mock.calls.filter(
|
||||
([requestedTargetId]) => requestedTargetId === rejectingTargetId
|
||||
)
|
||||
).toEqual([[rejectingTargetId, [rejectingTargetForward]]])
|
||||
expect(
|
||||
setPortForwards.mock.calls.filter(
|
||||
([requestedTargetId]) => requestedTargetId === partialTargetId
|
||||
)
|
||||
).toEqual([[partialTargetId, [partialTargetForward]]])
|
||||
})
|
||||
forwardListener?.({ targetId, forwards: [liveForward] })
|
||||
resolveForwards([])
|
||||
resolveDetected([detectedPort])
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(
|
||||
setPortForwards.mock.calls.filter(([requestedTargetId]) => requestedTargetId === targetId)
|
||||
).toEqual([[targetId, [liveForward]]])
|
||||
expect(
|
||||
setDetectedPorts.mock.calls.filter(([requestedTargetId]) => requestedTargetId === targetId)
|
||||
).toEqual([[targetId, [detectedPort]]])
|
||||
expect(listPortForwards).toHaveBeenCalledTimes(4)
|
||||
expect(listDetectedPorts).toHaveBeenCalledTimes(4)
|
||||
})
|
||||
forwardListener?.({ targetId, forwards: [liveForward] })
|
||||
detectedListener?.({ targetId: secondTargetId, ports: [secondDetectedPort] })
|
||||
resolveSecondForwards([secondForward])
|
||||
resolveSecondDetected([])
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(
|
||||
setPortForwards.mock.calls.filter(
|
||||
([requestedTargetId]) => requestedTargetId === secondTargetId
|
||||
)
|
||||
).toEqual([[secondTargetId, [secondForward]]])
|
||||
expect(
|
||||
setDetectedPorts.mock.calls.filter(
|
||||
([requestedTargetId]) => requestedTargetId === secondTargetId
|
||||
)
|
||||
).toEqual([[secondTargetId, [secondDetectedPort]]])
|
||||
})
|
||||
})
|
||||
|
||||
it('caps pending mobile state events while startup hydration is unresolved', async () => {
|
||||
const setFitOverride = vi.fn()
|
||||
const hydrateOverrides = vi.fn()
|
||||
|
||||
@@ -2631,6 +2631,37 @@ export function useIpcEvents(): void {
|
||||
}
|
||||
|
||||
const sshStateWatermarkByTargetId = new Map<string, number>()
|
||||
const pendingPortHydrationByTargetId = new Map<
|
||||
string,
|
||||
{ receivedForwardPush: boolean; receivedDetectedPush: boolean }
|
||||
>()
|
||||
const hydrateSshPorts = (targetId: string, authority: DirectSshAuthority): void => {
|
||||
const pendingPortHydration = {
|
||||
receivedForwardPush: false,
|
||||
receivedDetectedPush: false
|
||||
}
|
||||
pendingPortHydrationByTargetId.set(targetId, pendingPortHydration)
|
||||
const isHydrationAuthorityCurrent = (): boolean =>
|
||||
!directSshEffectStopped &&
|
||||
directSshAuthoritiesEqual(currentDirectSshAuthority(targetId), authority)
|
||||
const forwardHydration = window.api.ssh.listPortForwards({ targetId }).then((forwards) => {
|
||||
// Why: if the session disconnected while awaiting the snapshot, applying it would resurrect a dead session's ports.
|
||||
if (isHydrationAuthorityCurrent() && !pendingPortHydration.receivedForwardPush) {
|
||||
useAppStore.getState().setPortForwards(targetId, forwards)
|
||||
}
|
||||
})
|
||||
const detectedHydration = window.api.ssh.listDetectedPorts({ targetId }).then((detected) => {
|
||||
if (isHydrationAuthorityCurrent() && !pendingPortHydration.receivedDetectedPush) {
|
||||
useAppStore.getState().setDetectedPorts(targetId, detected)
|
||||
}
|
||||
})
|
||||
// Why: one failed or stalled port stream must not block the other stream or later targets.
|
||||
void Promise.allSettled([forwardHydration, detectedHydration]).then(() => {
|
||||
if (pendingPortHydrationByTargetId.get(targetId) === pendingPortHydration) {
|
||||
pendingPortHydrationByTargetId.delete(targetId)
|
||||
}
|
||||
})
|
||||
}
|
||||
let applySshConnectionStateChange!: (
|
||||
targetId: string,
|
||||
state: SshConnectionState,
|
||||
@@ -2668,23 +2699,6 @@ export function useIpcEvents(): void {
|
||||
state as SshConnectionState,
|
||||
'initial-hydration'
|
||||
)
|
||||
// Why: ports arrive only via push events; on reattach to a live session fetch snapshots or the Ports panel shows empty.
|
||||
if ((state as SshConnectionState).status === 'connected') {
|
||||
const authority = currentDirectSshAuthority(target.id)
|
||||
const [forwards, detected] = await Promise.all([
|
||||
window.api.ssh.listPortForwards({ targetId: target.id }),
|
||||
window.api.ssh.listDetectedPorts({ targetId: target.id })
|
||||
])
|
||||
// Why: if the session disconnected while awaiting the snapshot, applying it would resurrect a dead session's ports.
|
||||
if (
|
||||
!directSshEffectStopped &&
|
||||
authority &&
|
||||
directSshAuthoritiesEqual(currentDirectSshAuthority(target.id), authority)
|
||||
) {
|
||||
useAppStore.getState().setPortForwards(target.id, forwards)
|
||||
useAppStore.getState().setDetectedPorts(target.id, detected)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
@@ -2706,12 +2720,20 @@ export function useIpcEvents(): void {
|
||||
|
||||
unsubs.push(
|
||||
window.api.ssh.onPortForwardsChanged(({ targetId, forwards }) => {
|
||||
const pendingPortHydration = pendingPortHydrationByTargetId.get(targetId)
|
||||
if (pendingPortHydration) {
|
||||
pendingPortHydration.receivedForwardPush = true
|
||||
}
|
||||
useAppStore.getState().setPortForwards(targetId, forwards)
|
||||
})
|
||||
)
|
||||
|
||||
unsubs.push(
|
||||
window.api.ssh.onDetectedPortsChanged(({ targetId, ports }) => {
|
||||
const pendingPortHydration = pendingPortHydrationByTargetId.get(targetId)
|
||||
if (pendingPortHydration) {
|
||||
pendingPortHydration.receivedDetectedPush = true
|
||||
}
|
||||
useAppStore.getState().setDetectedPorts(targetId, ports)
|
||||
})
|
||||
)
|
||||
@@ -2736,7 +2758,7 @@ export function useIpcEvents(): void {
|
||||
latest?.targetId !== targetId ||
|
||||
!latest?.providerEpoch ||
|
||||
latest.connectionGeneration === undefined ||
|
||||
sshStateWatermarkByTargetId.get(targetId) !== watermark
|
||||
(sshStateWatermarkByTargetId.get(targetId) ?? 0) !== watermark
|
||||
) {
|
||||
return
|
||||
}
|
||||
@@ -2840,6 +2862,10 @@ export function useIpcEvents(): void {
|
||||
},
|
||||
{ authority, previousAuthority, origin }
|
||||
)
|
||||
// Why: initial connected state can be partial; hydrate only after reconciliation yields a complete authority.
|
||||
if (origin === 'initial-hydration') {
|
||||
hydrateSshPorts(targetId, authority)
|
||||
}
|
||||
}
|
||||
|
||||
let sshTargetStateEventId = 0
|
||||
|
||||
@@ -12,10 +12,12 @@ import {
|
||||
} from './remote-runtime-shared-control-ready'
|
||||
import { SharedControlReconnectScheduler } from './remote-runtime-shared-control-reconnect'
|
||||
import { requestSharedControl } from './remote-runtime-shared-control-requests'
|
||||
import { SharedControlRetiredRequestIds } from './remote-runtime-shared-control-retired-request-ids'
|
||||
import { SharedControlReadyStableResetTimer } from './remote-runtime-shared-control-stability'
|
||||
import * as sharedControlState from './remote-runtime-shared-control-state'
|
||||
import * as sharedControlSend from './remote-runtime-shared-control-send'
|
||||
import { closeSharedControlSocket } from './remote-runtime-shared-control-socket-close'
|
||||
import { closeSharedControlConnectionSubscription } from './remote-runtime-shared-control-subscription-close'
|
||||
import type { RemoteRuntimeSocketLivenessOptions } from './remote-runtime-socket-liveness'
|
||||
import * as sharedControlSubscriptions from './remote-runtime-shared-control-subscriptions'
|
||||
import { startSharedControlSubscription } from './remote-runtime-shared-control-subscription-start'
|
||||
@@ -43,6 +45,7 @@ export class RemoteRuntimeSharedControlConnection {
|
||||
private lastError: string | null = null
|
||||
private readonly pendingRequests = new Map<string, SharedControlPendingRequest<unknown>>()
|
||||
private readonly subscriptions = new Map<string, SharedControlLogicalSubscription<unknown>>()
|
||||
private readonly retiredRequestIds = new SharedControlRetiredRequestIds()
|
||||
private readonly readyWaiters: SharedControlReadyWaiter[] = []
|
||||
private everReady = false
|
||||
private readonly socketGeneration = new SharedControlSocketGeneration()
|
||||
@@ -72,7 +75,8 @@ export class RemoteRuntimeSharedControlConnection {
|
||||
params,
|
||||
timeoutMs,
|
||||
ensureReady: () => this.ensureReadyWithTimeout(timeoutMs),
|
||||
send: (requestId) => this.sendRequest(requestId)
|
||||
send: (requestId) => this.sendRequest(requestId),
|
||||
retireRequestId: (requestId) => this.retiredRequestIds.retire(requestId)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -203,6 +207,7 @@ export class RemoteRuntimeSharedControlConnection {
|
||||
deviceToken: this.pairing.deviceToken,
|
||||
pendingRequests: this.pendingRequests,
|
||||
subscriptions: this.subscriptions,
|
||||
retiredRequestIds: this.retiredRequestIds,
|
||||
readyWaiters: this.readyWaiters,
|
||||
setState: (state) => {
|
||||
this.state = state
|
||||
@@ -256,10 +261,12 @@ export class RemoteRuntimeSharedControlConnection {
|
||||
}
|
||||
|
||||
private closeSubscription(requestId: string): void {
|
||||
sharedControlSubscriptions.closeSharedControlLogicalSubscription({
|
||||
closeSharedControlConnectionSubscription({
|
||||
subscriptions: this.subscriptions,
|
||||
subscription: this.subscriptions.get(requestId),
|
||||
request: (method, params) => this.sendSubscriptionCleanupRequest(method, params)
|
||||
retiredRequestIds: this.retiredRequestIds,
|
||||
requestId,
|
||||
deviceToken: this.pairing.deviceToken,
|
||||
send: (payload) => this.sendEncrypted(payload)
|
||||
})
|
||||
this.reconnect.clearWhenIdle(this.subscriptions.size === 0 && this.state === 'closed')
|
||||
}
|
||||
@@ -273,15 +280,6 @@ export class RemoteRuntimeSharedControlConnection {
|
||||
})
|
||||
}
|
||||
|
||||
private sendSubscriptionCleanupRequest(method: string, params: unknown): void {
|
||||
sharedControlSubscriptions.sendSharedControlCleanupRequest({
|
||||
deviceToken: this.pairing.deviceToken,
|
||||
method,
|
||||
params,
|
||||
send: (payload) => this.sendEncrypted(payload)
|
||||
})
|
||||
}
|
||||
|
||||
private handleSocketClosed(error: RemoteRuntimeClientError, socketGeneration: number): void {
|
||||
if (
|
||||
!this.socketGeneration.acceptClose({
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import type { parseRemoteRuntimeRpcFrame } from './remote-runtime-request-frames'
|
||||
import { logUnknownSharedControlResponse } from './remote-runtime-shared-control-diagnostics-log'
|
||||
import {
|
||||
handleSharedControlLogicalResponse,
|
||||
sendSharedControlCleanupRequest
|
||||
} from './remote-runtime-shared-control-subscriptions'
|
||||
import type { SharedControlRetiredRequestIds } from './remote-runtime-shared-control-retired-request-ids'
|
||||
import { sendRetiredSharedControlCleanupRequest } from './remote-runtime-shared-control-subscription-close'
|
||||
import { handleSharedControlLogicalResponse } from './remote-runtime-shared-control-subscriptions'
|
||||
import {
|
||||
refreshSharedControlPendingRequestTimeouts,
|
||||
resolveSharedControlPendingResponse
|
||||
@@ -20,6 +19,7 @@ export function dispatchSharedControlFrame(args: {
|
||||
frame: SharedControlFrame
|
||||
pendingRequests: Map<string, SharedControlPendingRequest<unknown>>
|
||||
subscriptions: Map<string, SharedControlLogicalSubscription<unknown>>
|
||||
retiredRequestIds: SharedControlRetiredRequestIds
|
||||
deviceToken: string
|
||||
send: (payload: unknown) => boolean
|
||||
}): void {
|
||||
@@ -36,18 +36,27 @@ export function dispatchSharedControlFrame(args: {
|
||||
subscription,
|
||||
response,
|
||||
request: (method, params) =>
|
||||
sendSharedControlCleanupRequest({
|
||||
sendRetiredSharedControlCleanupRequest({
|
||||
retiredRequestIds: args.retiredRequestIds,
|
||||
deviceToken: args.deviceToken,
|
||||
method,
|
||||
params,
|
||||
send: args.send
|
||||
})
|
||||
})
|
||||
if (!args.subscriptions.has(response.id)) {
|
||||
args.retiredRequestIds.retire(response.id)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (args.pendingRequests.has(response.id)) {
|
||||
resolveSharedControlPendingResponse(args.pendingRequests, response.id, response)
|
||||
args.retiredRequestIds.retire(response.id)
|
||||
return
|
||||
}
|
||||
|
||||
if (args.retiredRequestIds.has(response.id)) {
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { RemoteRuntimeClientError } from './remote-runtime-client-error'
|
||||
import { SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY } from './protocol-version'
|
||||
import { dispatchSharedControlFrame } from './remote-runtime-shared-control-frame-dispatch'
|
||||
import { parseSharedControlFrame } from './remote-runtime-shared-control-protocol'
|
||||
import type { SharedControlRetiredRequestIds } from './remote-runtime-shared-control-retired-request-ids'
|
||||
import { resolveSharedControlReadyWaiters } from './remote-runtime-shared-control-state'
|
||||
import type {
|
||||
SharedControlConnectionState,
|
||||
@@ -19,6 +20,7 @@ export function handleSharedControlTextFrame(args: {
|
||||
environmentId?: string
|
||||
pendingRequests: Map<string, SharedControlPendingRequest<unknown>>
|
||||
subscriptions: Map<string, SharedControlLogicalSubscription<unknown>>
|
||||
retiredRequestIds: SharedControlRetiredRequestIds
|
||||
readyWaiters: SharedControlReadyWaiter[]
|
||||
setState: (state: SharedControlConnectionState) => void
|
||||
handleSocketClosed: (error: RemoteRuntimeClientError) => void
|
||||
@@ -65,6 +67,7 @@ export function handleSharedControlTextFrame(args: {
|
||||
frame: parsed.frame,
|
||||
pendingRequests: args.pendingRequests,
|
||||
subscriptions: args.subscriptions,
|
||||
retiredRequestIds: args.retiredRequestIds,
|
||||
deviceToken: args.deviceToken,
|
||||
send: args.sendEncrypted
|
||||
})
|
||||
|
||||
@@ -21,6 +21,7 @@ export function requestSharedControl<TResult>(args: {
|
||||
timeoutMs: number
|
||||
ensureReady: () => Promise<void>
|
||||
send: (requestId: string) => void
|
||||
retireRequestId?: (requestId: string) => void
|
||||
// Why: default off — ordinary short RPCs keep an absolute deadline. Only
|
||||
// long-polls routed through this path opt in so keepalives extend them.
|
||||
refreshTimeoutOnKeepalive?: boolean
|
||||
@@ -48,6 +49,7 @@ export function requestSharedControl<TResult>(args: {
|
||||
}
|
||||
pendingRequests.delete(requestId)
|
||||
releaseRemoteRuntimePreparedRequest(pending)
|
||||
args.retireRequestId?.(requestId)
|
||||
// Why: one stalled method does not prove the shared socket is dead;
|
||||
// socket liveness owns connection-wide teardown so other RPCs survive.
|
||||
pending.reject(remoteRuntimeTimeoutError())
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { SharedControlRetiredRequestIds } from './remote-runtime-shared-control-retired-request-ids'
|
||||
|
||||
describe('SharedControlRetiredRequestIds', () => {
|
||||
it('retains recent ids through repeated late frames and expires them', () => {
|
||||
let now = 1_000
|
||||
const ids = new SharedControlRetiredRequestIds({
|
||||
ttlMs: 100,
|
||||
now: () => now
|
||||
})
|
||||
|
||||
ids.retire('request-1')
|
||||
expect(ids.has('request-1')).toBe(true)
|
||||
expect(ids.has('request-1')).toBe(true)
|
||||
|
||||
now += 101
|
||||
expect(ids.has('request-1')).toBe(false)
|
||||
expect(ids.size).toBe(0)
|
||||
})
|
||||
|
||||
it('evicts the oldest ids at its configured bound', () => {
|
||||
const ids = new SharedControlRetiredRequestIds({ maxIds: 2 })
|
||||
|
||||
ids.retire('request-1')
|
||||
ids.retire('request-2')
|
||||
ids.retire('request-3')
|
||||
|
||||
expect(ids.size).toBe(2)
|
||||
expect(ids.has('request-1')).toBe(false)
|
||||
expect(ids.has('request-2')).toBe(true)
|
||||
expect(ids.has('request-3')).toBe(true)
|
||||
})
|
||||
|
||||
it('refreshes an existing id to the newest eviction rank', () => {
|
||||
const ids = new SharedControlRetiredRequestIds({ maxIds: 2 })
|
||||
|
||||
ids.retire('request-1')
|
||||
ids.retire('request-2')
|
||||
ids.retire('request-1')
|
||||
ids.retire('request-3')
|
||||
|
||||
expect(ids.has('request-1')).toBe(true)
|
||||
expect(ids.has('request-2')).toBe(false)
|
||||
expect(ids.has('request-3')).toBe(true)
|
||||
})
|
||||
|
||||
it('expires ids correctly after the clock moves backward', () => {
|
||||
let now = 1_000
|
||||
const ids = new SharedControlRetiredRequestIds({
|
||||
ttlMs: 100,
|
||||
now: () => now
|
||||
})
|
||||
|
||||
ids.retire('request-1')
|
||||
now = 900
|
||||
ids.retire('request-2')
|
||||
now = 1_001
|
||||
|
||||
expect(ids.has('request-1')).toBe(true)
|
||||
expect(ids.has('request-2')).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,53 @@
|
||||
const DEFAULT_MAX_RETIRED_REQUEST_IDS = 2_048
|
||||
const DEFAULT_RETIRED_REQUEST_ID_TTL_MS = 60_000
|
||||
|
||||
export class SharedControlRetiredRequestIds {
|
||||
private readonly ids = new Map<string, number>()
|
||||
private readonly maxIds: number
|
||||
private readonly ttlMs: number
|
||||
private readonly now: () => number
|
||||
|
||||
constructor(
|
||||
options: {
|
||||
maxIds?: number
|
||||
ttlMs?: number
|
||||
now?: () => number
|
||||
} = {}
|
||||
) {
|
||||
this.maxIds = Math.max(1, options.maxIds ?? DEFAULT_MAX_RETIRED_REQUEST_IDS)
|
||||
this.ttlMs = Math.max(1, options.ttlMs ?? DEFAULT_RETIRED_REQUEST_ID_TTL_MS)
|
||||
this.now = options.now ?? Date.now
|
||||
}
|
||||
|
||||
retire(requestId: string): void {
|
||||
const now = this.now()
|
||||
this.pruneExpired(now)
|
||||
this.ids.delete(requestId)
|
||||
this.ids.set(requestId, now + this.ttlMs)
|
||||
while (this.ids.size > this.maxIds) {
|
||||
const oldestId = this.ids.keys().next().value
|
||||
if (oldestId === undefined) {
|
||||
return
|
||||
}
|
||||
this.ids.delete(oldestId)
|
||||
}
|
||||
}
|
||||
|
||||
has(requestId: string): boolean {
|
||||
this.pruneExpired(this.now())
|
||||
return this.ids.has(requestId)
|
||||
}
|
||||
|
||||
get size(): number {
|
||||
this.pruneExpired(this.now())
|
||||
return this.ids.size
|
||||
}
|
||||
|
||||
private pruneExpired(now: number): void {
|
||||
for (const [requestId, expiresAt] of this.ids) {
|
||||
if (expiresAt <= now) {
|
||||
this.ids.delete(requestId)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { SharedControlRetiredRequestIds } from './remote-runtime-shared-control-retired-request-ids'
|
||||
import {
|
||||
closeSharedControlConnectionSubscription,
|
||||
sendRetiredSharedControlCleanupRequest
|
||||
} from './remote-runtime-shared-control-subscription-close'
|
||||
import { createSharedControlSubscription } from './remote-runtime-shared-control-subscriptions'
|
||||
import type { SharedControlLogicalSubscription } from './remote-runtime-shared-control-types'
|
||||
|
||||
describe('shared-control subscription retirement', () => {
|
||||
it('retires a closed subscription and its cleanup request', () => {
|
||||
const subscriptions = new Map<string, SharedControlLogicalSubscription<unknown>>()
|
||||
const subscription = createSharedControlSubscription({
|
||||
requestId: 'request-1',
|
||||
method: 'runtime.clientEvents.subscribe',
|
||||
params: null,
|
||||
retainedParamsBytes: 0,
|
||||
callbacks: { onResponse: vi.fn(), onError: vi.fn() }
|
||||
})
|
||||
subscription.sent = true
|
||||
subscription.remoteSubscriptionId = 'subscription-1'
|
||||
subscriptions.set(subscription.requestId, subscription)
|
||||
const retiredRequestIds = new SharedControlRetiredRequestIds()
|
||||
let cleanupRequestId = ''
|
||||
|
||||
closeSharedControlConnectionSubscription({
|
||||
subscriptions,
|
||||
retiredRequestIds,
|
||||
requestId: subscription.requestId,
|
||||
deviceToken: 'device-token',
|
||||
send: (payload) => {
|
||||
cleanupRequestId = (payload as { id: string }).id
|
||||
return true
|
||||
}
|
||||
})
|
||||
|
||||
expect(subscriptions.size).toBe(0)
|
||||
expect(retiredRequestIds.has(subscription.requestId)).toBe(true)
|
||||
expect(retiredRequestIds.has(cleanupRequestId)).toBe(true)
|
||||
})
|
||||
|
||||
it('does not retire an unsent cleanup request', () => {
|
||||
const retiredRequestIds = new SharedControlRetiredRequestIds()
|
||||
let cleanupRequestId = ''
|
||||
|
||||
sendRetiredSharedControlCleanupRequest({
|
||||
retiredRequestIds,
|
||||
deviceToken: 'device-token',
|
||||
method: 'runtime.clientEvents.unsubscribe',
|
||||
params: null,
|
||||
send: (payload) => {
|
||||
cleanupRequestId = (payload as { id: string }).id
|
||||
return false
|
||||
}
|
||||
})
|
||||
|
||||
expect(retiredRequestIds.has(cleanupRequestId)).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,44 @@
|
||||
import type { SharedControlRetiredRequestIds } from './remote-runtime-shared-control-retired-request-ids'
|
||||
import {
|
||||
closeSharedControlLogicalSubscription,
|
||||
sendSharedControlCleanupRequest
|
||||
} from './remote-runtime-shared-control-subscriptions'
|
||||
import type { SharedControlLogicalSubscription } from './remote-runtime-shared-control-types'
|
||||
|
||||
export function closeSharedControlConnectionSubscription(args: {
|
||||
subscriptions: Map<string, SharedControlLogicalSubscription<unknown>>
|
||||
retiredRequestIds: SharedControlRetiredRequestIds
|
||||
requestId: string
|
||||
deviceToken: string
|
||||
send: (payload: unknown) => boolean
|
||||
}): void {
|
||||
const subscription = args.subscriptions.get(args.requestId)
|
||||
closeSharedControlLogicalSubscription({
|
||||
subscriptions: args.subscriptions,
|
||||
subscription,
|
||||
request: (method, params) =>
|
||||
sendRetiredSharedControlCleanupRequest({
|
||||
retiredRequestIds: args.retiredRequestIds,
|
||||
deviceToken: args.deviceToken,
|
||||
method,
|
||||
params,
|
||||
send: args.send
|
||||
})
|
||||
})
|
||||
if (subscription && !args.subscriptions.has(args.requestId)) {
|
||||
args.retiredRequestIds.retire(args.requestId)
|
||||
}
|
||||
}
|
||||
|
||||
export function sendRetiredSharedControlCleanupRequest(args: {
|
||||
retiredRequestIds: SharedControlRetiredRequestIds
|
||||
deviceToken: string
|
||||
method: string
|
||||
params: unknown
|
||||
send: (payload: unknown) => boolean
|
||||
}): void {
|
||||
const requestId = sendSharedControlCleanupRequest(args)
|
||||
if (requestId) {
|
||||
args.retiredRequestIds.retire(requestId)
|
||||
}
|
||||
}
|
||||
@@ -86,15 +86,17 @@ export function sendSharedControlCleanupRequest(args: {
|
||||
method: string
|
||||
params: unknown
|
||||
send: (payload: unknown) => boolean
|
||||
}): void {
|
||||
}): string | null {
|
||||
// Why: cleanup is best-effort and often runs during teardown; send it
|
||||
// synchronously so close() cannot race the async request path.
|
||||
args.send({
|
||||
id: randomUUID(),
|
||||
const requestId = randomUUID()
|
||||
const sent = args.send({
|
||||
id: requestId,
|
||||
deviceToken: args.deviceToken,
|
||||
method: args.method,
|
||||
params: args.params
|
||||
})
|
||||
return sent ? requestId : null
|
||||
}
|
||||
|
||||
export function replaySharedControlSubscriptions(args: {
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { expect, test } from './helpers/orca-app'
|
||||
import { launchHeadlessPairedRuntimeHost } from './helpers/headless-paired-runtime-host'
|
||||
import { launchPairedWebClient, type PairedWebClient } from './helpers/paired-electron-client'
|
||||
import { runPairedTerminalColdActivationOracle } from './helpers/paired-terminal-cold-activation-oracle'
|
||||
import { runPairedTerminalParkingOracle } from './helpers/paired-terminal-parking-oracle'
|
||||
|
||||
test('ordinary-parks paired terminals against an isolated headless Orca host @headful', async ({
|
||||
test('ordinary-parks paired terminals against an isolated headless Orca host', async ({
|
||||
testRepoPath
|
||||
}) => {
|
||||
test.setTimeout(240_000)
|
||||
@@ -38,3 +39,35 @@ test('ordinary-parks paired terminals against an isolated headless Orca host @he
|
||||
await host.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
test('cold-activates only visible paired terminals against an isolated headless host', async ({
|
||||
testRepoPath
|
||||
}) => {
|
||||
test.setTimeout(240_000)
|
||||
const host = await launchHeadlessPairedRuntimeHost()
|
||||
let client: PairedWebClient | null = null
|
||||
try {
|
||||
const added = await host.client.call<{ repo: { id: string } }>('repo.add', {
|
||||
path: testRepoPath,
|
||||
kind: 'git'
|
||||
})
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const listed = await host.client.call<{ totalCount: number }>('worktree.list', {
|
||||
repo: `id:${added.result.repo.id}`
|
||||
})
|
||||
return listed.result.totalCount
|
||||
},
|
||||
{ timeout: 30_000 }
|
||||
)
|
||||
.toBeGreaterThan(0)
|
||||
client = await launchPairedWebClient(host.app, host.offer, {
|
||||
terminalParkingDelayMs: 100
|
||||
})
|
||||
await runPairedTerminalColdActivationOracle(client.page, { repoId: added.result.repo.id })
|
||||
} finally {
|
||||
await client?.dispose()
|
||||
await host.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
@@ -21,6 +21,10 @@ type ServeReady = {
|
||||
}
|
||||
}
|
||||
|
||||
const STARTUP_DIAGNOSTIC_LIMIT = 8_000
|
||||
const PAIRING_URL_PATTERN = /orca:\/\/[^\s"\\]+/g
|
||||
const WEB_CLIENT_PAIRING_PATTERN = /([#&]pairing=)[^&\s"\\]+/g
|
||||
|
||||
export type HeadlessPairedRuntimeHost = {
|
||||
app: ElectronApplication
|
||||
client: RuntimeClient
|
||||
@@ -28,6 +32,62 @@ export type HeadlessPairedRuntimeHost = {
|
||||
offer: RuntimeDesktopPairingOffer
|
||||
}
|
||||
|
||||
export class HeadlessPairedRuntimeStartupDiagnosticBuffer {
|
||||
private completed = ''
|
||||
private discardingOversizedLine = false
|
||||
private pending = ''
|
||||
|
||||
append(chunk: Buffer): void {
|
||||
let value = chunk.toString()
|
||||
if (this.discardingOversizedLine) {
|
||||
const newlineIndex = value.indexOf('\n')
|
||||
if (newlineIndex === -1) {
|
||||
return
|
||||
}
|
||||
value = value.slice(newlineIndex + 1)
|
||||
this.discardingOversizedLine = false
|
||||
}
|
||||
|
||||
const combined = `${this.pending}${value}`
|
||||
const newlineIndex = combined.lastIndexOf('\n')
|
||||
if (newlineIndex !== -1) {
|
||||
this.completed = `${this.completed}${redactPairingMaterial(
|
||||
combined.slice(0, newlineIndex + 1)
|
||||
)}`.slice(-STARTUP_DIAGNOSTIC_LIMIT)
|
||||
this.pending = combined.slice(newlineIndex + 1)
|
||||
} else {
|
||||
this.pending = combined
|
||||
}
|
||||
if (this.pending.length > STARTUP_DIAGNOSTIC_LIMIT) {
|
||||
this.pending = ''
|
||||
this.discardingOversizedLine = true
|
||||
}
|
||||
}
|
||||
|
||||
read(): string {
|
||||
const pending = this.discardingOversizedLine ? '' : redactPairingMaterial(this.pending)
|
||||
return `${this.completed}${pending}`.slice(-STARTUP_DIAGNOSTIC_LIMIT)
|
||||
}
|
||||
}
|
||||
|
||||
export function formatHeadlessPairedRuntimeStartupDiagnostics(
|
||||
stdout: string,
|
||||
stderr: string
|
||||
): string {
|
||||
return [
|
||||
stdout ? `stdout:\n${redactPairingMaterial(stdout)}` : '',
|
||||
stderr ? `stderr:\n${redactPairingMaterial(stderr)}` : ''
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('\n')
|
||||
}
|
||||
|
||||
function redactPairingMaterial(value: string): string {
|
||||
return value
|
||||
.replace(PAIRING_URL_PATTERN, 'orca://[redacted]')
|
||||
.replace(WEB_CLIENT_PAIRING_PATTERN, '$1[redacted]')
|
||||
}
|
||||
|
||||
async function readPairingOffer(app: ElectronApplication): Promise<RuntimeDesktopPairingOffer> {
|
||||
const child = app.process()
|
||||
const stdout = child.stdout
|
||||
@@ -36,24 +96,44 @@ async function readPairingOffer(app: ElectronApplication): Promise<RuntimeDeskto
|
||||
}
|
||||
return new Promise((resolve, reject) => {
|
||||
let buffered = ''
|
||||
const stdoutDiagnostic = new HeadlessPairedRuntimeStartupDiagnosticBuffer()
|
||||
const stderrDiagnostic = new HeadlessPairedRuntimeStartupDiagnosticBuffer()
|
||||
const stderr = child.stderr
|
||||
const timeout = setTimeout(() => {
|
||||
cleanup()
|
||||
reject(new Error('Headless runtime did not publish pairing readiness'))
|
||||
const diagnostics = formatHeadlessPairedRuntimeStartupDiagnostics(
|
||||
stdoutDiagnostic.read(),
|
||||
stderrDiagnostic.read()
|
||||
)
|
||||
reject(
|
||||
new Error(
|
||||
`Headless runtime did not publish pairing readiness${diagnostics ? `\n${diagnostics}` : ''}`
|
||||
)
|
||||
)
|
||||
}, 60_000)
|
||||
const cleanup = (): void => {
|
||||
clearTimeout(timeout)
|
||||
stdout.off('data', onData)
|
||||
stderr?.off('data', onStderr)
|
||||
child.off('close', onClose)
|
||||
}
|
||||
const onClose = (code: number | null, signal: NodeJS.Signals | null): void => {
|
||||
cleanup()
|
||||
const diagnostics = formatHeadlessPairedRuntimeStartupDiagnostics(
|
||||
stdoutDiagnostic.read(),
|
||||
stderrDiagnostic.read()
|
||||
)
|
||||
reject(
|
||||
new Error(
|
||||
`Headless runtime exited before pairing readiness (code=${code ?? 'none'}, signal=${signal ?? 'none'})`
|
||||
`Headless runtime exited before pairing readiness (code=${code ?? 'none'}, signal=${signal ?? 'none'})${diagnostics ? `\n${diagnostics}` : ''}`
|
||||
)
|
||||
)
|
||||
}
|
||||
const onStderr = (chunk: Buffer): void => {
|
||||
stderrDiagnostic.append(chunk)
|
||||
}
|
||||
const onData = (chunk: Buffer): void => {
|
||||
stdoutDiagnostic.append(chunk)
|
||||
buffered += chunk.toString()
|
||||
const lines = buffered.split(/\r?\n/)
|
||||
buffered = lines.pop() ?? ''
|
||||
@@ -79,6 +159,7 @@ async function readPairingOffer(app: ElectronApplication): Promise<RuntimeDeskto
|
||||
}
|
||||
}
|
||||
stdout.on('data', onData)
|
||||
stderr?.on('data', onStderr)
|
||||
child.on('close', onClose)
|
||||
if (child.exitCode !== null || child.signalCode !== null) {
|
||||
onClose(child.exitCode, child.signalCode)
|
||||
@@ -120,11 +201,12 @@ export async function launchHeadlessPairedRuntimeHost(): Promise<HeadlessPairedR
|
||||
],
|
||||
env: isolation.env
|
||||
})
|
||||
assertElectronResolvedIsolatedHome(
|
||||
await app.evaluate(({ app: electronApp }) => electronApp.getPath('home')),
|
||||
isolation
|
||||
)
|
||||
const offer = await readPairingOffer(app)
|
||||
const [offer] = await Promise.all([
|
||||
readPairingOffer(app),
|
||||
app
|
||||
.evaluate(({ app: electronApp }) => electronApp.getPath('home'))
|
||||
.then((home) => assertElectronResolvedIsolatedHome(home, isolation))
|
||||
])
|
||||
return {
|
||||
app,
|
||||
client: new RuntimeClient(userDataDir, 5_000),
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
HeadlessPairedRuntimeStartupDiagnosticBuffer,
|
||||
formatHeadlessPairedRuntimeStartupDiagnostics
|
||||
} from './headless-paired-runtime-host'
|
||||
|
||||
describe('headless paired runtime startup diagnostics', () => {
|
||||
it('redacts pairing URLs before truncation can remove their prefix', () => {
|
||||
const pairingUrl = `orca://${'secret'.repeat(1_500)}`
|
||||
const diagnostic = new HeadlessPairedRuntimeStartupDiagnosticBuffer()
|
||||
|
||||
diagnostic.append(Buffer.from(`prefix${pairingUrl}\n`))
|
||||
|
||||
expect(diagnostic.read()).toBe('prefixorca://[redacted]\n')
|
||||
expect(diagnostic.read()).not.toContain('secret')
|
||||
})
|
||||
|
||||
it('redacts pairing URLs split across chunks', () => {
|
||||
const diagnostic = new HeadlessPairedRuntimeStartupDiagnosticBuffer()
|
||||
diagnostic.append(Buffer.from('orca://p'))
|
||||
diagnostic.append(Buffer.from('airing-secret\nready'))
|
||||
|
||||
expect(formatHeadlessPairedRuntimeStartupDiagnostics(diagnostic.read(), '')).toBe(
|
||||
'stdout:\norca://[redacted]\nready'
|
||||
)
|
||||
})
|
||||
|
||||
it('redacts encoded pairing material from web-client URLs', () => {
|
||||
const pairingUrl = encodeURIComponent('orca://pairing-secret')
|
||||
const diagnostic = new HeadlessPairedRuntimeStartupDiagnosticBuffer()
|
||||
|
||||
diagnostic.append(Buffer.from(`https://host/web-index.html#pairing=${pairingUrl}\n`))
|
||||
|
||||
expect(diagnostic.read()).toBe('https://host/web-index.html#pairing=[redacted]\n')
|
||||
expect(diagnostic.read()).not.toContain('pairing-secret')
|
||||
})
|
||||
|
||||
it('drops oversized unfinished lines instead of retaining a pairing fragment', () => {
|
||||
const diagnostic = new HeadlessPairedRuntimeStartupDiagnosticBuffer()
|
||||
diagnostic.append(Buffer.from(`orca://${'secret'.repeat(1_500)}`))
|
||||
diagnostic.append(Buffer.from('still-secret\nsafe'))
|
||||
|
||||
expect(diagnostic.read()).toBe('safe')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,55 @@
|
||||
import type { Page } from '@stablyai/playwright-test'
|
||||
import { expect } from './orca-app'
|
||||
|
||||
export async function callColdActivationRuntime<TResult>(
|
||||
page: Page,
|
||||
method: string,
|
||||
params: unknown
|
||||
): Promise<TResult> {
|
||||
return page.evaluate(
|
||||
async ({ method, params }) => {
|
||||
const response = await window.api.runtime.call({ method, params })
|
||||
if (!response.ok) {
|
||||
throw new Error(`${response.error.code}: ${response.error.message}`)
|
||||
}
|
||||
return response.result
|
||||
},
|
||||
{ method, params }
|
||||
) as Promise<TResult>
|
||||
}
|
||||
|
||||
export async function readColdActivationMountState(
|
||||
page: Page,
|
||||
tabIds: string[]
|
||||
): Promise<{ mounted: number; parked: number }> {
|
||||
return page.evaluate((targets) => {
|
||||
const parked = new Set(window.__terminalParkingDebug?.parkedTabIds() ?? [])
|
||||
return {
|
||||
mounted: targets.filter((id) => window.__paneManagers?.has(id)).length,
|
||||
parked: targets.filter((id) => parked.has(id)).length
|
||||
}
|
||||
}, tabIds)
|
||||
}
|
||||
|
||||
export async function expectStableColdActivationMountState(
|
||||
page: Page,
|
||||
tabIds: string[],
|
||||
expected: { mounted: number; parked: number }
|
||||
): Promise<void> {
|
||||
await expect
|
||||
.poll(() => readColdActivationMountState(page, tabIds), { timeout: 30_000 })
|
||||
.toEqual(expected)
|
||||
const samples = await page.evaluate(async (targets) => {
|
||||
const result: { mounted: number; parked: number }[] = []
|
||||
for (let index = 0; index < 8; index += 1) {
|
||||
const parked = new Set(window.__terminalParkingDebug?.parkedTabIds() ?? [])
|
||||
result.push({
|
||||
mounted: targets.filter((id) => window.__paneManagers?.has(id)).length,
|
||||
parked: targets.filter((id) => parked.has(id)).length
|
||||
})
|
||||
await new Promise((resolve) => window.setTimeout(resolve, 25))
|
||||
}
|
||||
return result
|
||||
}, tabIds)
|
||||
expect(samples).toEqual(Array.from({ length: 8 }, () => expected))
|
||||
}
|
||||
@@ -0,0 +1,292 @@
|
||||
import type { Page } from '@stablyai/playwright-test'
|
||||
import { TERMINAL_PAIRED_PARKING_RUNTIME_CAPABILITY } from '../../../src/shared/protocol-version'
|
||||
import { toWebTerminalSurfaceTabId } from '../../../src/shared/terminal-surface-id'
|
||||
import { expect } from './orca-app'
|
||||
import {
|
||||
callColdActivationRuntime,
|
||||
expectStableColdActivationMountState,
|
||||
readColdActivationMountState
|
||||
} from './paired-terminal-cold-activation-observation'
|
||||
import { createPairedTerminalParkingFixture } from './paired-terminal-parking-fixture'
|
||||
import { getTerminalContent, waitForActivePanePtyId } from './terminal'
|
||||
|
||||
const TARGET_TAB_COUNT = 8
|
||||
|
||||
type ColdTab = {
|
||||
marker: string
|
||||
originalPtyId: string
|
||||
tabId: string
|
||||
terminal: string
|
||||
}
|
||||
|
||||
export async function runPairedTerminalColdActivationOracle(
|
||||
page: Page,
|
||||
seed: { repoId: string }
|
||||
): Promise<void> {
|
||||
const fixture = createPairedTerminalParkingFixture()
|
||||
const handles: string[] = []
|
||||
const createdWorktreeIds: string[] = []
|
||||
let fallbackWorktreeId: string | null = null
|
||||
let worktreeId: string | null = null
|
||||
try {
|
||||
await expect
|
||||
.poll(
|
||||
() =>
|
||||
page.evaluate((capability) => {
|
||||
const state = window.__store?.getState()
|
||||
const statuses = Array.from(state?.runtimeStatusByEnvironmentId.entries() ?? [])
|
||||
return JSON.stringify({
|
||||
capable: statuses.some(([, entry]) =>
|
||||
entry.status?.capabilities?.includes(capability)
|
||||
),
|
||||
statuses: statuses.map(([environmentId, entry]) => ({
|
||||
capabilities: entry.status?.capabilities ?? [],
|
||||
environmentId,
|
||||
hasStatus: entry.status != null
|
||||
})),
|
||||
workspaces: state?.allWorktrees().length ?? 0,
|
||||
workspaceSessionReady: state?.workspaceSessionReady ?? false
|
||||
})
|
||||
}, TERMINAL_PAIRED_PARKING_RUNTIME_CAPABILITY),
|
||||
{ timeout: 30_000 }
|
||||
)
|
||||
.toContain('"capable":true')
|
||||
|
||||
const fallback = await callColdActivationRuntime<{
|
||||
startupTerminal?: { handle?: string; tabId?: string }
|
||||
worktree: { id: string }
|
||||
}>(page, 'worktree.create', {
|
||||
repo: seed.repoId,
|
||||
name: `paired-cold-fallback-${Date.now()}`,
|
||||
setupDecision: 'skip',
|
||||
activate: false,
|
||||
noParent: true,
|
||||
startupCommand: fixture.command('PAIR_COLD_FALLBACK')
|
||||
})
|
||||
fallbackWorktreeId = fallback.worktree.id
|
||||
createdWorktreeIds.push(fallbackWorktreeId)
|
||||
if (!fallback.startupTerminal?.handle || !fallback.startupTerminal.tabId) {
|
||||
throw new Error('Paired cold-activation fallback terminal was not created')
|
||||
}
|
||||
handles.push(fallback.startupTerminal.handle)
|
||||
const fallbackTabId = toWebTerminalSurfaceTabId(fallback.startupTerminal.tabId)
|
||||
await page.evaluate(
|
||||
({ tabId, targetWorktreeId }) => {
|
||||
const state = window.__store?.getState()
|
||||
state?.setActiveTabForWorktree(targetWorktreeId, tabId)
|
||||
state?.setActiveView('terminal')
|
||||
state?.setActiveWorktree(targetWorktreeId)
|
||||
},
|
||||
{ tabId: fallbackTabId, targetWorktreeId: fallbackWorktreeId }
|
||||
)
|
||||
const fallbackTab = page.locator(`[data-testid="sortable-tab"][data-tab-id="${fallbackTabId}"]`)
|
||||
await expect(fallbackTab).toBeVisible({ timeout: 30_000 })
|
||||
await fallbackTab.click()
|
||||
await expect(fallbackTab).toHaveAttribute('data-active', 'true')
|
||||
|
||||
const firstMarker = 'PAIR_COLD_ACTIVATION_0'
|
||||
const created = await callColdActivationRuntime<{
|
||||
startupTerminal?: { handle?: string; tabId?: string }
|
||||
worktree: { id: string }
|
||||
}>(page, 'worktree.create', {
|
||||
repo: seed.repoId,
|
||||
name: `paired-cold-activation-${Date.now()}`,
|
||||
setupDecision: 'skip',
|
||||
activate: false,
|
||||
noParent: true,
|
||||
startupCommand: fixture.command(firstMarker)
|
||||
})
|
||||
worktreeId = created.worktree.id
|
||||
createdWorktreeIds.push(worktreeId)
|
||||
if (!created.startupTerminal?.handle || !created.startupTerminal.tabId) {
|
||||
throw new Error('Paired cold-activation startup terminal was not created')
|
||||
}
|
||||
handles.push(created.startupTerminal.handle)
|
||||
const pendingTabs = [
|
||||
{
|
||||
marker: firstMarker,
|
||||
tabId: toWebTerminalSurfaceTabId(created.startupTerminal.tabId),
|
||||
terminal: created.startupTerminal.handle
|
||||
}
|
||||
]
|
||||
|
||||
while (pendingTabs.length < TARGET_TAB_COUNT) {
|
||||
const marker = `PAIR_COLD_ACTIVATION_${pendingTabs.length}`
|
||||
const result = await callColdActivationRuntime<{
|
||||
tab: { parentTabId: string; terminal: string | null }
|
||||
}>(page, 'session.tabs.createTerminal', {
|
||||
worktree: `id:${worktreeId}`,
|
||||
command: fixture.command(marker),
|
||||
activate: false,
|
||||
select: false,
|
||||
navigation: 'caller'
|
||||
})
|
||||
if (!result.tab.terminal) {
|
||||
throw new Error(`Paired cold-activation terminal ${pendingTabs.length} was not created`)
|
||||
}
|
||||
handles.push(result.tab.terminal)
|
||||
pendingTabs.push({
|
||||
marker,
|
||||
tabId: toWebTerminalSurfaceTabId(result.tab.parentTabId),
|
||||
terminal: result.tab.terminal
|
||||
})
|
||||
}
|
||||
|
||||
let originalPtyIds: string[] | null = null
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
originalPtyIds = await page.evaluate(
|
||||
({ tabIds, targetWorktreeId }) => {
|
||||
const tabs = window.__store?.getState().tabsByWorktree[targetWorktreeId] ?? []
|
||||
const byId = new Map(tabs.map((tab) => [tab.id, tab.ptyId]))
|
||||
const ids = tabIds.map((id) => byId.get(id) ?? null)
|
||||
return ids.every((id): id is string => typeof id === 'string') ? ids : null
|
||||
},
|
||||
{
|
||||
tabIds: pendingTabs.map((tab) => tab.tabId),
|
||||
targetWorktreeId: worktreeId
|
||||
}
|
||||
)
|
||||
return originalPtyIds
|
||||
},
|
||||
{ timeout: 30_000 }
|
||||
)
|
||||
.not.toBeNull()
|
||||
if (originalPtyIds === null) {
|
||||
throw new Error('Paired cold-activation PTY ids were not captured')
|
||||
}
|
||||
const tabs: ColdTab[] = pendingTabs.map((tab, index) => ({
|
||||
...tab,
|
||||
originalPtyId: originalPtyIds[index]!
|
||||
}))
|
||||
const tabIds = tabs.map((tab) => tab.tabId)
|
||||
expect(await readColdActivationMountState(page, tabIds)).toEqual({ mounted: 0, parked: 0 })
|
||||
|
||||
await page.evaluate(
|
||||
({ activeTabId, targetWorktreeId }) => {
|
||||
const state = window.__store?.getState()
|
||||
state?.setActiveTabForWorktree(targetWorktreeId, activeTabId)
|
||||
state?.setActiveView('terminal')
|
||||
state?.setActiveWorktree(targetWorktreeId)
|
||||
},
|
||||
{ activeTabId: tabs[0].tabId, targetWorktreeId: worktreeId }
|
||||
)
|
||||
const firstTab = page.locator(`[data-testid="sortable-tab"][data-tab-id="${tabs[0].tabId}"]`)
|
||||
await expect(firstTab).toBeVisible({ timeout: 30_000 })
|
||||
await expect(firstTab).toHaveAttribute('data-active', 'true')
|
||||
await expectStableColdActivationMountState(page, tabIds, {
|
||||
mounted: 1,
|
||||
parked: TARGET_TAB_COUNT - 1
|
||||
})
|
||||
expect(await waitForActivePanePtyId(page, 30_000)).toBe(tabs[0].originalPtyId)
|
||||
|
||||
const deferred = tabs[4]
|
||||
await page.evaluate(async () => {
|
||||
await window.__store?.getState().updateSettings({ terminalHiddenViewParking: false })
|
||||
})
|
||||
await expectStableColdActivationMountState(page, tabIds, {
|
||||
mounted: TARGET_TAB_COUNT,
|
||||
parked: 0
|
||||
})
|
||||
const deferredTab = page.locator(
|
||||
`[data-testid="sortable-tab"][data-tab-id="${deferred.tabId}"]`
|
||||
)
|
||||
await deferredTab.click()
|
||||
await expect(deferredTab).toHaveAttribute('data-active', 'true')
|
||||
expect(await waitForActivePanePtyId(page, 30_000)).toBe(deferred.originalPtyId)
|
||||
await expect
|
||||
.poll(() => getTerminalContent(page), { timeout: 30_000 })
|
||||
.toContain(`READY:${deferred.marker}`)
|
||||
await firstTab.click()
|
||||
await expect(firstTab).toHaveAttribute('data-active', 'true')
|
||||
await page.evaluate(async () => {
|
||||
await window.__store?.getState().updateSettings({ terminalHiddenViewParking: true })
|
||||
})
|
||||
await expectStableColdActivationMountState(page, tabIds, {
|
||||
mounted: 2,
|
||||
parked: TARGET_TAB_COUNT - 2
|
||||
})
|
||||
|
||||
await page.evaluate(
|
||||
(fallbackWorktreeId) => window.__store?.getState().setActiveWorktree(fallbackWorktreeId),
|
||||
fallbackWorktreeId
|
||||
)
|
||||
await expect(firstTab).not.toBeVisible()
|
||||
await page.evaluate(
|
||||
(targetWorktreeId) => window.__store?.getState().setActiveWorktree(targetWorktreeId),
|
||||
worktreeId
|
||||
)
|
||||
await expect(firstTab).toHaveAttribute('data-active', 'true')
|
||||
await expectStableColdActivationMountState(page, tabIds, {
|
||||
mounted: 2,
|
||||
parked: TARGET_TAB_COUNT - 2
|
||||
})
|
||||
|
||||
const secondTab = page.locator(`[data-testid="sortable-tab"][data-tab-id="${tabs[1].tabId}"]`)
|
||||
await secondTab.click()
|
||||
await expect(secondTab).toHaveAttribute('data-active', 'true')
|
||||
await expectStableColdActivationMountState(page, tabIds, {
|
||||
mounted: 2,
|
||||
parked: TARGET_TAB_COUNT - 2
|
||||
})
|
||||
|
||||
await page.evaluate(
|
||||
(fallbackWorktreeId) => window.__store?.getState().setActiveWorktree(fallbackWorktreeId),
|
||||
fallbackWorktreeId
|
||||
)
|
||||
await expect(secondTab).not.toBeVisible()
|
||||
await page.evaluate(
|
||||
(targetWorktreeId) => window.__store?.getState().setActiveWorktree(targetWorktreeId),
|
||||
worktreeId
|
||||
)
|
||||
await expect(secondTab).toHaveAttribute('data-active', 'true')
|
||||
await expectStableColdActivationMountState(page, tabIds, {
|
||||
mounted: 2,
|
||||
parked: TARGET_TAB_COUNT - 2
|
||||
})
|
||||
|
||||
const deferredMarker = `PAIR_COLD_DEFERRED_${Date.now()}`
|
||||
const sent = await callColdActivationRuntime<{ send: { accepted: boolean } }>(
|
||||
page,
|
||||
'terminal.send',
|
||||
{
|
||||
terminal: deferred.terminal,
|
||||
text: deferredMarker,
|
||||
enter: true
|
||||
}
|
||||
)
|
||||
expect(sent.send.accepted).toBe(true)
|
||||
|
||||
await deferredTab.click()
|
||||
await expect(deferredTab).toHaveAttribute('data-active', 'true')
|
||||
await expectStableColdActivationMountState(page, tabIds, {
|
||||
mounted: 2,
|
||||
parked: TARGET_TAB_COUNT - 2
|
||||
})
|
||||
expect(await waitForActivePanePtyId(page, 30_000)).toBe(deferred.originalPtyId)
|
||||
await expect
|
||||
.poll(() => getTerminalContent(page), { timeout: 30_000 })
|
||||
.toContain(`READY:${deferred.marker}`)
|
||||
await expect
|
||||
.poll(() => getTerminalContent(page), { timeout: 30_000 })
|
||||
.toContain(`LIVE:${deferredMarker}`)
|
||||
} finally {
|
||||
for (const terminal of handles) {
|
||||
await callColdActivationRuntime(page, 'terminal.closeTab', { terminal }).catch(
|
||||
() => undefined
|
||||
)
|
||||
}
|
||||
await page
|
||||
.evaluate(() => window.__store?.getState().setActiveWorktree(null))
|
||||
.catch(() => undefined)
|
||||
for (const createdWorktreeId of createdWorktreeIds.toReversed()) {
|
||||
await callColdActivationRuntime(page, 'worktree.rm', {
|
||||
worktree: `id:${createdWorktreeId}`,
|
||||
force: true,
|
||||
runHooks: false
|
||||
}).catch(() => undefined)
|
||||
}
|
||||
fixture.dispose()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
import { request } from 'node:http'
|
||||
|
||||
import { expect, type ElectronApplication, type Page } from '@stablyai/playwright-test'
|
||||
import {
|
||||
execDockerSshRelayTargetCommand,
|
||||
shellQuote,
|
||||
type DockerSshRelayTarget
|
||||
} from './docker-ssh-relay-target'
|
||||
|
||||
export type PortForwardEvidence = {
|
||||
events: { targetId: string; forwards: { localPort: number; remotePort: number }[] }[]
|
||||
rendererForwards: { localPort: number; remotePort: number }[]
|
||||
managerForwards: { localPort: number; remotePort: number }[]
|
||||
persistedForwards: { localPort: number; remotePort: number }[]
|
||||
}
|
||||
|
||||
export function requestForward(localPort: number): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = request(
|
||||
{ host: '127.0.0.1', port: localPort, path: '/', method: 'GET', timeout: 2_000 },
|
||||
(response) => {
|
||||
let body = ''
|
||||
response.setEncoding('utf8')
|
||||
response.on('data', (chunk) => {
|
||||
body += chunk
|
||||
})
|
||||
response.on('end', () => resolve(body))
|
||||
}
|
||||
)
|
||||
req.once('error', reject)
|
||||
req.once('timeout', () => req.destroy(new Error('Forwarded HTTP request timed out')))
|
||||
req.end()
|
||||
})
|
||||
}
|
||||
|
||||
export function startRemoteHttpListener(
|
||||
target: DockerSshRelayTarget,
|
||||
port: number,
|
||||
marker: string
|
||||
): number {
|
||||
const script = [
|
||||
"const http = require('node:http')",
|
||||
`const marker = ${JSON.stringify(marker)}`,
|
||||
"const server = http.createServer((_request, response) => response.end(marker + '\\n'))",
|
||||
`server.listen(${port}, '127.0.0.1')`
|
||||
].join(';')
|
||||
execDockerSshRelayTargetCommand(
|
||||
target,
|
||||
[
|
||||
`nohup node -e ${shellQuote(script)} >/tmp/orca-http-${port}.log 2>&1 < /dev/null &`,
|
||||
`echo $! >/tmp/orca-http-${port}.pid`
|
||||
].join(' ')
|
||||
)
|
||||
return Number(execDockerSshRelayTargetCommand(target, `cat /tmp/orca-http-${port}.pid`))
|
||||
}
|
||||
|
||||
export function readRemoteListenerIdentity(
|
||||
target: DockerSshRelayTarget,
|
||||
port: number
|
||||
): { pid: number; executable: string; command: string } {
|
||||
const pid = Number(execDockerSshRelayTargetCommand(target, `cat /tmp/orca-http-${port}.pid`))
|
||||
return {
|
||||
pid,
|
||||
executable: execDockerSshRelayTargetCommand(target, `readlink /proc/${pid}/exe`),
|
||||
command: execDockerSshRelayTargetCommand(target, `tr '\\000' ' ' </proc/${pid}/cmdline`)
|
||||
}
|
||||
}
|
||||
|
||||
export async function installLifecycleWarningCapture(
|
||||
electronApp: ElectronApplication
|
||||
): Promise<void> {
|
||||
await electronApp.evaluate(() => {
|
||||
const scope = globalThis as typeof globalThis & {
|
||||
__sshPortForwardWarnings?: string[]
|
||||
__sshPortForwardOriginalWarn?: typeof console.warn
|
||||
}
|
||||
scope.__sshPortForwardWarnings = []
|
||||
scope.__sshPortForwardOriginalWarn = console.warn
|
||||
console.warn = (...args: unknown[]) => {
|
||||
const message = args.map(String).join(' ')
|
||||
if (message.includes('[ssh')) {
|
||||
scope.__sshPortForwardWarnings?.push(message)
|
||||
}
|
||||
scope.__sshPortForwardOriginalWarn?.(...args)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export async function readLifecycleWarnings(electronApp: ElectronApplication): Promise<string[]> {
|
||||
return electronApp.evaluate(() => {
|
||||
const scope = globalThis as typeof globalThis & {
|
||||
__sshPortForwardWarnings?: string[]
|
||||
}
|
||||
return scope.__sshPortForwardWarnings ?? []
|
||||
})
|
||||
}
|
||||
|
||||
export async function restoreLifecycleWarningCapture(
|
||||
electronApp: ElectronApplication
|
||||
): Promise<void> {
|
||||
await electronApp.evaluate(() => {
|
||||
const scope = globalThis as typeof globalThis & {
|
||||
__sshPortForwardWarnings?: string[]
|
||||
__sshPortForwardOriginalWarn?: typeof console.warn
|
||||
}
|
||||
if (scope.__sshPortForwardOriginalWarn) {
|
||||
console.warn = scope.__sshPortForwardOriginalWarn
|
||||
}
|
||||
delete scope.__sshPortForwardWarnings
|
||||
delete scope.__sshPortForwardOriginalWarn
|
||||
})
|
||||
}
|
||||
|
||||
export async function installRendererForwardCapture(page: Page): Promise<void> {
|
||||
await page.evaluate(() => {
|
||||
const scope = window as typeof window & {
|
||||
__sshPortForwardEvents?: unknown[]
|
||||
__sshPortForwardUnsubscribe?: () => void
|
||||
}
|
||||
scope.__sshPortForwardEvents = []
|
||||
scope.__sshPortForwardUnsubscribe?.()
|
||||
scope.__sshPortForwardUnsubscribe = window.api.ssh.onPortForwardsChanged((event) => {
|
||||
scope.__sshPortForwardEvents?.push(event)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
export async function readPortForwardEvidence(
|
||||
page: Page,
|
||||
targetId: string
|
||||
): Promise<PortForwardEvidence> {
|
||||
return page.evaluate(
|
||||
async ({ targetId }) => {
|
||||
const store = window.__store
|
||||
if (!store) {
|
||||
throw new Error('Store unavailable')
|
||||
}
|
||||
const target = (await window.api.ssh.listTargets()).find((entry) => entry.id === targetId)
|
||||
const scope = window as typeof window & {
|
||||
__sshPortForwardEvents?: PortForwardEvidence['events']
|
||||
}
|
||||
return {
|
||||
events: scope.__sshPortForwardEvents ?? [],
|
||||
rendererForwards: store.getState().portForwardsByConnection[targetId] ?? [],
|
||||
managerForwards: await window.api.ssh.listPortForwards({ targetId }),
|
||||
persistedForwards: target?.portForwards ?? []
|
||||
}
|
||||
},
|
||||
{ targetId }
|
||||
)
|
||||
}
|
||||
|
||||
export async function openPortsPanel(page: Page): Promise<void> {
|
||||
await page.evaluate(() => {
|
||||
const state = window.__store?.getState()
|
||||
state?.setRightSidebarTab('ports')
|
||||
state?.setRightSidebarOpen(true)
|
||||
})
|
||||
await expect(page.getByText('Ports', { exact: true }).last()).toBeVisible()
|
||||
}
|
||||
|
||||
export async function forwardPortFromPanel(
|
||||
page: Page,
|
||||
localPort: number,
|
||||
remotePort: number
|
||||
): Promise<void> {
|
||||
await page.getByRole('button', { name: 'Add', exact: true }).last().click()
|
||||
const dialog = page.getByRole('dialog', { name: 'Forward a Port' })
|
||||
await expect(dialog).toBeVisible()
|
||||
await dialog.getByLabel('Remote Port').fill(String(remotePort))
|
||||
await dialog.getByLabel('Local Port').fill(String(localPort))
|
||||
await dialog.getByRole('button', { name: 'Forward', exact: true }).click()
|
||||
await expect(dialog).not.toBeVisible()
|
||||
}
|
||||
|
||||
export async function addPortForward(
|
||||
page: Page,
|
||||
args: {
|
||||
targetId: string
|
||||
localPort: number
|
||||
remotePort: number
|
||||
label: string
|
||||
}
|
||||
): Promise<{ id: string }> {
|
||||
return page.evaluate(
|
||||
({ targetId, localPort, remotePort, label }) =>
|
||||
window.api.ssh.addPortForward({
|
||||
targetId,
|
||||
localPort,
|
||||
remoteHost: '127.0.0.1',
|
||||
remotePort,
|
||||
label
|
||||
}),
|
||||
args
|
||||
)
|
||||
}
|
||||
|
||||
export async function expectForwardEvidence(
|
||||
page: Page,
|
||||
targetId: string,
|
||||
expected: { localPort: number; remotePort: number }[]
|
||||
): Promise<void> {
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const evidence = await readPortForwardEvidence(page, targetId)
|
||||
return {
|
||||
renderer: evidence.rendererForwards.map(({ localPort, remotePort }) => ({
|
||||
localPort,
|
||||
remotePort
|
||||
})),
|
||||
manager: evidence.managerForwards.map(({ localPort, remotePort }) => ({
|
||||
localPort,
|
||||
remotePort
|
||||
})),
|
||||
persisted: evidence.persistedForwards.map(({ localPort, remotePort }) => ({
|
||||
localPort,
|
||||
remotePort
|
||||
}))
|
||||
}
|
||||
},
|
||||
{ timeout: 30_000, message: 'renderer, manager, and persisted forward state did not agree' }
|
||||
)
|
||||
.toEqual({ renderer: expected, manager: expected, persisted: expected })
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
import { createServer } from 'node:net'
|
||||
|
||||
import type { ElectronApplication } from '@stablyai/playwright-test'
|
||||
|
||||
type InvokeHandler = (event: unknown, args?: { targetId?: string }) => unknown
|
||||
|
||||
type SnapshotBarrierState = {
|
||||
targetId: string
|
||||
captureClaimed: boolean
|
||||
captured: boolean
|
||||
released: boolean
|
||||
release: () => void
|
||||
originalHandler: InvokeHandler
|
||||
handlerReturned: Promise<void>
|
||||
markHandlerReturned: () => void
|
||||
}
|
||||
|
||||
export type ReservedLocalPort = {
|
||||
port: number
|
||||
release: () => Promise<void>
|
||||
}
|
||||
|
||||
export async function reserveLocalPort(): Promise<ReservedLocalPort> {
|
||||
const server = createServer()
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.once('error', reject)
|
||||
server.listen(0, '127.0.0.1', resolve)
|
||||
})
|
||||
const address = server.address()
|
||||
if (!address || typeof address === 'string') {
|
||||
server.close()
|
||||
throw new Error('Unable to reserve a local port')
|
||||
}
|
||||
let released = false
|
||||
return {
|
||||
port: address.port,
|
||||
release: async () => {
|
||||
if (released) {
|
||||
return
|
||||
}
|
||||
released = true
|
||||
await new Promise<void>((resolve, reject) =>
|
||||
server.close((error) => (error ? reject(error) : resolve()))
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function installSshPortForwardSnapshotBarrier(
|
||||
app: ElectronApplication,
|
||||
targetId: string
|
||||
): Promise<void> {
|
||||
await app.evaluate(({ ipcMain }, targetId) => {
|
||||
const scope = globalThis as typeof globalThis & {
|
||||
__sshPortForwardSnapshotBarrier?: SnapshotBarrierState
|
||||
}
|
||||
const handlers = (
|
||||
ipcMain as unknown as {
|
||||
_invokeHandlers?: Map<string, InvokeHandler>
|
||||
}
|
||||
)._invokeHandlers
|
||||
const originalHandler = handlers?.get('ssh:listPortForwards')
|
||||
if (!handlers || !originalHandler) {
|
||||
throw new Error('ssh:listPortForwards handler is unavailable')
|
||||
}
|
||||
if (scope.__sshPortForwardSnapshotBarrier) {
|
||||
throw new Error('SSH port-forward snapshot barrier is already installed')
|
||||
}
|
||||
let release!: () => void
|
||||
const barrier = new Promise<void>((resolve) => {
|
||||
release = resolve
|
||||
})
|
||||
let markHandlerReturned!: () => void
|
||||
const handlerReturned = new Promise<void>((resolve) => {
|
||||
markHandlerReturned = resolve
|
||||
})
|
||||
const state: SnapshotBarrierState = {
|
||||
targetId,
|
||||
captureClaimed: false,
|
||||
captured: false,
|
||||
released: false,
|
||||
release,
|
||||
originalHandler,
|
||||
handlerReturned,
|
||||
markHandlerReturned
|
||||
}
|
||||
scope.__sshPortForwardSnapshotBarrier = state
|
||||
handlers.set('ssh:listPortForwards', async (event, args) => {
|
||||
if (state.captureClaimed || args?.targetId !== state.targetId) {
|
||||
return state.originalHandler(event, args)
|
||||
}
|
||||
state.captureClaimed = true
|
||||
const snapshot = await state.originalHandler(event, args)
|
||||
state.captured = true
|
||||
await barrier
|
||||
state.markHandlerReturned()
|
||||
return snapshot
|
||||
})
|
||||
}, targetId)
|
||||
}
|
||||
|
||||
export async function readSshPortForwardSnapshotBarrier(
|
||||
app: ElectronApplication
|
||||
): Promise<{ captured: boolean; released: boolean }> {
|
||||
return app.evaluate(() => {
|
||||
const state = (
|
||||
globalThis as typeof globalThis & {
|
||||
__sshPortForwardSnapshotBarrier?: SnapshotBarrierState
|
||||
}
|
||||
).__sshPortForwardSnapshotBarrier
|
||||
return {
|
||||
captured: state?.captured ?? false,
|
||||
released: state?.released ?? false
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export async function releaseSshPortForwardSnapshotBarrier(
|
||||
app: ElectronApplication
|
||||
): Promise<void> {
|
||||
await app.evaluate(async () => {
|
||||
const state = (
|
||||
globalThis as typeof globalThis & {
|
||||
__sshPortForwardSnapshotBarrier?: SnapshotBarrierState
|
||||
}
|
||||
).__sshPortForwardSnapshotBarrier
|
||||
if (state && !state.released) {
|
||||
state.released = true
|
||||
state.release()
|
||||
}
|
||||
await state?.handlerReturned
|
||||
})
|
||||
}
|
||||
|
||||
export async function restoreSshPortForwardSnapshotHandler(
|
||||
app: ElectronApplication
|
||||
): Promise<void> {
|
||||
await app.evaluate(({ ipcMain }) => {
|
||||
const scope = globalThis as typeof globalThis & {
|
||||
__sshPortForwardSnapshotBarrier?: SnapshotBarrierState
|
||||
}
|
||||
const state = scope.__sshPortForwardSnapshotBarrier
|
||||
if (!state) {
|
||||
return
|
||||
}
|
||||
if (!state.released) {
|
||||
state.released = true
|
||||
state.release()
|
||||
}
|
||||
const handlers = (
|
||||
ipcMain as unknown as {
|
||||
_invokeHandlers?: Map<string, InvokeHandler>
|
||||
}
|
||||
)._invokeHandlers
|
||||
handlers?.set('ssh:listPortForwards', state.originalHandler)
|
||||
delete scope.__sshPortForwardSnapshotBarrier
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import type { ElectronApplication } from '@stablyai/playwright-test'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
installSshPortForwardSnapshotBarrier,
|
||||
readSshPortForwardSnapshotBarrier,
|
||||
releaseSshPortForwardSnapshotBarrier,
|
||||
restoreSshPortForwardSnapshotHandler
|
||||
} from './ssh-port-forward-snapshot-barrier'
|
||||
|
||||
type InvokeHandler = (event: unknown, args?: { targetId?: string }) => unknown
|
||||
|
||||
describe('SSH port-forward snapshot barrier', () => {
|
||||
it('holds only the first matching request while its snapshot is unresolved', async () => {
|
||||
const handlers = new Map<string, InvokeHandler>()
|
||||
let resolveFirstSnapshot: (value: string[]) => void = () => {}
|
||||
const firstSnapshot = new Promise<string[]>((resolve) => {
|
||||
resolveFirstSnapshot = resolve
|
||||
})
|
||||
let callCount = 0
|
||||
const originalHandler = vi.fn(() => {
|
||||
callCount += 1
|
||||
return callCount === 1 ? firstSnapshot : Promise.resolve(['later-snapshot'])
|
||||
})
|
||||
handlers.set('ssh:listPortForwards', originalHandler)
|
||||
const app = {
|
||||
evaluate: (
|
||||
callback: (electron: unknown, arg?: unknown) => unknown,
|
||||
arg?: unknown
|
||||
): Promise<unknown> =>
|
||||
Promise.resolve(callback({ ipcMain: { _invokeHandlers: handlers } }, arg))
|
||||
} as unknown as ElectronApplication
|
||||
|
||||
let heldRequestStarted = false
|
||||
await installSshPortForwardSnapshotBarrier(app, 'target-1')
|
||||
try {
|
||||
const wrappedHandler = handlers.get('ssh:listPortForwards')
|
||||
expect(wrappedHandler).toBeTypeOf('function')
|
||||
if (!wrappedHandler) {
|
||||
throw new Error('Wrapped handler unavailable')
|
||||
}
|
||||
|
||||
heldRequestStarted = true
|
||||
const firstRequest = Promise.resolve(wrappedHandler({}, { targetId: 'target-1' }))
|
||||
await vi.waitFor(() => expect(originalHandler).toHaveBeenCalledOnce())
|
||||
const laterRequest = Promise.resolve(wrappedHandler({}, { targetId: 'target-1' }))
|
||||
await expect(laterRequest).resolves.toEqual(['later-snapshot'])
|
||||
expect(await readSshPortForwardSnapshotBarrier(app)).toEqual({
|
||||
captured: false,
|
||||
released: false
|
||||
})
|
||||
|
||||
resolveFirstSnapshot(['held-snapshot'])
|
||||
await vi.waitFor(async () => {
|
||||
expect(await readSshPortForwardSnapshotBarrier(app)).toEqual({
|
||||
captured: true,
|
||||
released: false
|
||||
})
|
||||
})
|
||||
await releaseSshPortForwardSnapshotBarrier(app)
|
||||
await expect(firstRequest).resolves.toEqual(['held-snapshot'])
|
||||
} finally {
|
||||
resolveFirstSnapshot(['cleanup-snapshot'])
|
||||
if (heldRequestStarted) {
|
||||
await releaseSshPortForwardSnapshotBarrier(app)
|
||||
}
|
||||
await restoreSshPortForwardSnapshotHandler(app)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,145 @@
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
|
||||
import { expect, type ElectronApplication, type Page } from '@stablyai/playwright-test'
|
||||
import {
|
||||
execDockerSshRelayTargetCommand,
|
||||
shellQuote,
|
||||
type DockerSshRelayTarget
|
||||
} from './docker-ssh-relay-target'
|
||||
|
||||
type CapturedSshState = {
|
||||
status: string
|
||||
providerEpoch?: string
|
||||
connectionGeneration?: number
|
||||
}
|
||||
|
||||
export async function trustDockerSshHost(
|
||||
electronApp: ElectronApplication,
|
||||
target: DockerSshRelayTarget
|
||||
): Promise<string> {
|
||||
const sshDir = join(target.tempDir, '.ssh')
|
||||
mkdirSync(sshDir, { recursive: true })
|
||||
const knownHostsPath = join(sshDir, 'known_hosts')
|
||||
const hostKeys = execFileSync('ssh-keyscan', ['-p', String(target.port), '127.0.0.1'], {
|
||||
encoding: 'utf8'
|
||||
})
|
||||
writeFileSync(knownHostsPath, hostKeys)
|
||||
const invocationLogPath = join(target.tempDir, 'system-ssh-invocations')
|
||||
const wrapperPath = join(target.tempDir, 'verified-system-ssh')
|
||||
writeFileSync(
|
||||
wrapperPath,
|
||||
[
|
||||
'#!/bin/sh',
|
||||
'kind=transport',
|
||||
'for arg in "$@"; do',
|
||||
' if [ "$arg" = "-L" ]; then kind=forward; fi',
|
||||
'done',
|
||||
`printf '%s\\n' "$kind" >> ${shellQuote(invocationLogPath)}`,
|
||||
`exec /usr/bin/ssh -o UserKnownHostsFile=${shellQuote(knownHostsPath)} -o StrictHostKeyChecking=yes "$@"`
|
||||
].join('\n')
|
||||
)
|
||||
chmodSync(wrapperPath, 0o755)
|
||||
await electronApp.evaluate((_electron, path) => {
|
||||
process.env.ORCA_SYSTEM_SSH_PATH = path
|
||||
}, wrapperPath)
|
||||
return invocationLogPath
|
||||
}
|
||||
|
||||
export function readSystemSshInvocationKinds(invocationLogPath: string): string[] {
|
||||
if (!existsSync(invocationLogPath)) {
|
||||
return []
|
||||
}
|
||||
return readFileSync(invocationLogPath, 'utf8').split(/\r?\n/).filter(Boolean)
|
||||
}
|
||||
|
||||
export function terminateDockerSshRelayConnectChannel(target: DockerSshRelayTarget): number {
|
||||
const output = execDockerSshRelayTargetCommand(
|
||||
target,
|
||||
`
|
||||
count=0
|
||||
for proc in /proc/[0-9]*; do
|
||||
[ -r "$proc/cmdline" ] || continue
|
||||
argv=()
|
||||
mapfile -d '' -t argv < "$proc/cmdline" 2>/dev/null || continue
|
||||
[ "\${argv[1]##*/}" = relay.js ] || continue
|
||||
mode=
|
||||
for arg in "\${argv[@]:2}"; do
|
||||
if [ "$arg" = --connect ]; then mode=connect; fi
|
||||
if [ "$arg" = --detached ]; then mode=detached; fi
|
||||
done
|
||||
if [ "$mode" = connect ]; then
|
||||
kill -TERM "\${proc##*/}"
|
||||
count=$((count + 1))
|
||||
fi
|
||||
done
|
||||
printf '%s' "$count"
|
||||
`
|
||||
)
|
||||
return Number(output)
|
||||
}
|
||||
|
||||
export async function installSshStateCapture(page: Page, targetId: string): Promise<void> {
|
||||
await page.evaluate((targetId) => {
|
||||
const scope = window as typeof window & {
|
||||
__sshLifecycleStates?: CapturedSshState[]
|
||||
__sshLifecycleStateUnsubscribe?: () => void
|
||||
}
|
||||
scope.__sshLifecycleStateUnsubscribe?.()
|
||||
scope.__sshLifecycleStates = []
|
||||
scope.__sshLifecycleStateUnsubscribe = window.api.ssh.onStateChanged((event) => {
|
||||
if (event.targetId === targetId) {
|
||||
scope.__sshLifecycleStates?.push(event.state)
|
||||
}
|
||||
})
|
||||
}, targetId)
|
||||
}
|
||||
|
||||
export async function readSshStateCapture(page: Page): Promise<CapturedSshState[]> {
|
||||
return page.evaluate(
|
||||
() =>
|
||||
(
|
||||
window as typeof window & {
|
||||
__sshLifecycleStates?: CapturedSshState[]
|
||||
}
|
||||
).__sshLifecycleStates ?? []
|
||||
)
|
||||
}
|
||||
|
||||
export async function forceDockerSshRelayChannelReconnect(
|
||||
page: Page,
|
||||
target: DockerSshRelayTarget,
|
||||
targetId: string
|
||||
): Promise<void> {
|
||||
await installSshStateCapture(page, targetId)
|
||||
const authority = await page.evaluate(
|
||||
(targetId) => window.__store?.getState().sshConnectionStates.get(targetId),
|
||||
targetId
|
||||
)
|
||||
expect(authority).toMatchObject({
|
||||
status: 'connected',
|
||||
providerEpoch: expect.any(String),
|
||||
connectionGeneration: expect.any(Number)
|
||||
})
|
||||
expect(terminateDockerSshRelayConnectChannel(target)).toBeGreaterThan(0)
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const states = await readSshStateCapture(page)
|
||||
const current = await page.evaluate(
|
||||
(targetId) => window.__store?.getState().sshConnectionStates.get(targetId),
|
||||
targetId
|
||||
)
|
||||
return (
|
||||
states.some((state) => state.status === 'reconnecting') &&
|
||||
states.some((state) => state.status === 'connected') &&
|
||||
current?.status === 'connected' &&
|
||||
(current.providerEpoch !== authority?.providerEpoch ||
|
||||
current.connectionGeneration !== authority?.connectionGeneration)
|
||||
)
|
||||
},
|
||||
{ timeout: 30_000, message: 'in-place relay channel did not reconnect' }
|
||||
)
|
||||
.toBe(true)
|
||||
}
|
||||
@@ -0,0 +1,370 @@
|
||||
import { createServer } from 'node:net'
|
||||
|
||||
import { test, expect } from './helpers/orca-app'
|
||||
import { waitForActiveWorktree, waitForSessionReady } from './helpers/store'
|
||||
import {
|
||||
cleanupDockerSshRelayTarget,
|
||||
startDockerSshRelayTarget,
|
||||
type DockerSshRelayTarget
|
||||
} from './helpers/docker-ssh-relay-target'
|
||||
import {
|
||||
connectDockerSshRelayTarget,
|
||||
reconnectDockerSshRelayTarget
|
||||
} from './helpers/docker-ssh-relay-connection'
|
||||
import {
|
||||
installSshPortForwardSnapshotBarrier,
|
||||
readSshPortForwardSnapshotBarrier,
|
||||
releaseSshPortForwardSnapshotBarrier,
|
||||
reserveLocalPort,
|
||||
restoreSshPortForwardSnapshotHandler
|
||||
} from './helpers/ssh-port-forward-snapshot-barrier'
|
||||
import {
|
||||
addPortForward,
|
||||
expectForwardEvidence,
|
||||
forwardPortFromPanel,
|
||||
installLifecycleWarningCapture,
|
||||
installRendererForwardCapture,
|
||||
openPortsPanel,
|
||||
readLifecycleWarnings,
|
||||
readPortForwardEvidence,
|
||||
readRemoteListenerIdentity,
|
||||
requestForward,
|
||||
restoreLifecycleWarningCapture,
|
||||
startRemoteHttpListener
|
||||
} from './helpers/ssh-port-forward-lifecycle-evidence'
|
||||
import {
|
||||
forceDockerSshRelayChannelReconnect,
|
||||
readSshStateCapture,
|
||||
readSystemSshInvocationKinds,
|
||||
trustDockerSshHost
|
||||
} from './helpers/ssh-port-forward-transport-evidence'
|
||||
|
||||
const RUN_DOCKER_SSH = process.env.ORCA_E2E_SSH_DOCKER === '1'
|
||||
const FORCE_SYSTEM_SSH = process.env.ORCA_SSH_FORCE_SYSTEM_TRANSPORT === '1'
|
||||
const REMOTE_PORT = 7860
|
||||
const REFRESH_BARRIER_PORT = 7861
|
||||
const SCAN_REFRESH_PORT = 7862
|
||||
|
||||
test.describe('Docker SSH port-forward lifecycle', () => {
|
||||
test.skip(!RUN_DOCKER_SSH, 'Set ORCA_E2E_SSH_DOCKER=1 to run Docker-backed SSH tests.')
|
||||
test.skip(process.platform === 'win32', 'Docker SSH lifecycle uses POSIX process inspection.')
|
||||
|
||||
test('keeps a user-forwarded listener live across scan refresh @headful', async ({
|
||||
electronApp,
|
||||
orcaPage
|
||||
}, testInfo) => {
|
||||
test.slow()
|
||||
let target: DockerSshRelayTarget | null = null
|
||||
const localPortReservation = await reserveLocalPort()
|
||||
const unrelatedLocalPortReservation = await reserveLocalPort()
|
||||
const localPort = localPortReservation.port
|
||||
const unrelatedLocalPort = unrelatedLocalPortReservation.port
|
||||
const marker = `ORCA_FORWARD_${Date.now()}`
|
||||
const unrelatedMarker = `${marker}_UNRELATED`
|
||||
try {
|
||||
target = startDockerSshRelayTarget(testInfo)
|
||||
const systemSshInvocationLogPath = await trustDockerSshHost(electronApp, target)
|
||||
await installLifecycleWarningCapture(electronApp)
|
||||
await waitForSessionReady(orcaPage)
|
||||
await waitForActiveWorktree(orcaPage)
|
||||
const remote = await connectDockerSshRelayTarget(orcaPage, target)
|
||||
const remotePid = startRemoteHttpListener(target, REMOTE_PORT, marker)
|
||||
const unrelatedRemotePid = startRemoteHttpListener(
|
||||
target,
|
||||
REFRESH_BARRIER_PORT,
|
||||
unrelatedMarker
|
||||
)
|
||||
await openPortsPanel(orcaPage)
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
() =>
|
||||
orcaPage.evaluate(
|
||||
({ targetId, port }) =>
|
||||
window.api.ssh
|
||||
.listDetectedPorts({ targetId })
|
||||
.then((ports) => ports.find((entry) => entry.port === port)?.pid ?? null),
|
||||
{ targetId: remote.targetId, port: REMOTE_PORT }
|
||||
),
|
||||
{ timeout: 45_000, message: 'remote HTTP listener was not detected' }
|
||||
)
|
||||
.toBe(remotePid)
|
||||
await expect(orcaPage.getByText(`:${REMOTE_PORT}`, { exact: true })).toBeVisible()
|
||||
await expect
|
||||
.poll(
|
||||
() =>
|
||||
orcaPage.evaluate(
|
||||
({ targetId, port }) =>
|
||||
window.api.ssh
|
||||
.listDetectedPorts({ targetId })
|
||||
.then((ports) => ports.some((entry) => entry.port === port)),
|
||||
{ targetId: remote.targetId, port: REFRESH_BARRIER_PORT }
|
||||
),
|
||||
{ timeout: 45_000, message: 'scan-refresh barrier listener was not detected' }
|
||||
)
|
||||
.toBe(true)
|
||||
await expect(orcaPage.getByText(`:${REFRESH_BARRIER_PORT}`, { exact: true })).toBeVisible()
|
||||
|
||||
await installSshPortForwardSnapshotBarrier(electronApp, remote.targetId)
|
||||
await orcaPage.evaluate(() => window.dispatchEvent(new Event('beforeunload')))
|
||||
await orcaPage.reload()
|
||||
await waitForSessionReady(orcaPage, 60_000)
|
||||
await expect
|
||||
.poll(() => waitForActiveWorktree(orcaPage), { timeout: 60_000 })
|
||||
.toBe(remote.worktreeId)
|
||||
await expect
|
||||
.poll(
|
||||
() =>
|
||||
orcaPage.evaluate(
|
||||
(targetId) => window.__store?.getState().sshConnectionStates.get(targetId)?.status,
|
||||
remote.targetId
|
||||
),
|
||||
{ timeout: 60_000, message: 'renderer did not restore the connected SSH target' }
|
||||
)
|
||||
.toBe('connected')
|
||||
await expect
|
||||
.poll(() => readSshPortForwardSnapshotBarrier(electronApp), {
|
||||
timeout: 30_000,
|
||||
message: 'renderer hydration did not capture an empty Forwarded snapshot'
|
||||
})
|
||||
.toEqual({ captured: true, released: false })
|
||||
|
||||
await installRendererForwardCapture(orcaPage)
|
||||
await openPortsPanel(orcaPage)
|
||||
await localPortReservation.release()
|
||||
await forwardPortFromPanel(orcaPage, localPort, REMOTE_PORT)
|
||||
await expect(orcaPage.getByText('Forwarded', { exact: true })).toBeVisible()
|
||||
await expect(
|
||||
orcaPage.getByText(`:${localPort} → :${REMOTE_PORT}`, { exact: true })
|
||||
).toBeVisible()
|
||||
await expect.poll(() => requestForward(localPort)).toContain(marker)
|
||||
await expectForwardEvidence(orcaPage, remote.targetId, [
|
||||
{ localPort, remotePort: REMOTE_PORT }
|
||||
])
|
||||
if (FORCE_SYSTEM_SSH) {
|
||||
await expect
|
||||
.poll(() => readSystemSshInvocationKinds(systemSshInvocationLogPath))
|
||||
.toContain('forward')
|
||||
} else {
|
||||
expect(readSystemSshInvocationKinds(systemSshInvocationLogPath)).not.toContain('forward')
|
||||
}
|
||||
|
||||
await releaseSshPortForwardSnapshotBarrier(electronApp)
|
||||
const postHydrationRoundTripForwards = await orcaPage.evaluate(
|
||||
(targetId) => window.api.ssh.listPortForwards({ targetId }),
|
||||
remote.targetId
|
||||
)
|
||||
expect(postHydrationRoundTripForwards).toContainEqual(
|
||||
expect.objectContaining({ localPort, remotePort: REMOTE_PORT })
|
||||
)
|
||||
await expect(orcaPage.getByText(`:${REFRESH_BARRIER_PORT}`, { exact: true })).toBeVisible()
|
||||
const staleSnapshotEvidence = await readPortForwardEvidence(orcaPage, remote.targetId)
|
||||
const staleSnapshotIdentity = readRemoteListenerIdentity(target, REMOTE_PORT)
|
||||
const staleSnapshotWarnings = await readLifecycleWarnings(electronApp)
|
||||
expect(staleSnapshotEvidence.managerForwards).toContainEqual(
|
||||
expect.objectContaining({ localPort, remotePort: REMOTE_PORT })
|
||||
)
|
||||
expect(staleSnapshotEvidence.persistedForwards).toContainEqual(
|
||||
expect.objectContaining({ localPort, remotePort: REMOTE_PORT })
|
||||
)
|
||||
await expect(requestForward(localPort)).resolves.toContain(marker)
|
||||
expect(staleSnapshotIdentity).toMatchObject({
|
||||
pid: remotePid,
|
||||
executable: expect.stringContaining('/node'),
|
||||
command: expect.stringContaining(String(REMOTE_PORT))
|
||||
})
|
||||
expect(staleSnapshotWarnings.filter((message) => message.includes('Port forward'))).toEqual(
|
||||
[]
|
||||
)
|
||||
await expect(
|
||||
orcaPage.getByText(`:${localPort} → :${REMOTE_PORT}`, { exact: true })
|
||||
).toBeVisible()
|
||||
await expectForwardEvidence(orcaPage, remote.targetId, [
|
||||
{ localPort, remotePort: REMOTE_PORT }
|
||||
])
|
||||
await restoreSshPortForwardSnapshotHandler(electronApp)
|
||||
|
||||
startRemoteHttpListener(target, SCAN_REFRESH_PORT, `${marker}_SCAN_REFRESH`)
|
||||
await expect
|
||||
.poll(
|
||||
() =>
|
||||
orcaPage.evaluate(
|
||||
({ targetId, port }) =>
|
||||
window.api.ssh
|
||||
.listDetectedPorts({ targetId })
|
||||
.then((ports) => ports.some((entry) => entry.port === port)),
|
||||
{ targetId: remote.targetId, port: SCAN_REFRESH_PORT }
|
||||
),
|
||||
{ timeout: 45_000, message: 'scan-refresh listener was not detected in main' }
|
||||
)
|
||||
.toBe(true)
|
||||
await expect(orcaPage.getByText(`:${SCAN_REFRESH_PORT}`, { exact: true })).toBeVisible()
|
||||
|
||||
await unrelatedLocalPortReservation.release()
|
||||
const unrelatedForward = await addPortForward(orcaPage, {
|
||||
targetId: remote.targetId,
|
||||
localPort: unrelatedLocalPort,
|
||||
remotePort: REFRESH_BARRIER_PORT,
|
||||
label: 'unrelated-listener'
|
||||
})
|
||||
await expectForwardEvidence(orcaPage, remote.targetId, [
|
||||
{ localPort, remotePort: REMOTE_PORT },
|
||||
{ localPort: unrelatedLocalPort, remotePort: REFRESH_BARRIER_PORT }
|
||||
])
|
||||
await expect.poll(() => requestForward(unrelatedLocalPort)).toContain(unrelatedMarker)
|
||||
|
||||
await forceDockerSshRelayChannelReconnect(orcaPage, target, remote.targetId)
|
||||
await expectForwardEvidence(orcaPage, remote.targetId, [
|
||||
{ localPort, remotePort: REMOTE_PORT },
|
||||
{ localPort: unrelatedLocalPort, remotePort: REFRESH_BARRIER_PORT }
|
||||
])
|
||||
await expect.poll(() => requestForward(localPort)).toContain(marker)
|
||||
await expect.poll(() => requestForward(unrelatedLocalPort)).toContain(unrelatedMarker)
|
||||
|
||||
const authorityBeforeTransportReconnect = await orcaPage.evaluate(
|
||||
(targetId) => window.__store?.getState().sshConnectionStates.get(targetId),
|
||||
remote.targetId
|
||||
)
|
||||
await reconnectDockerSshRelayTarget(orcaPage, remote.targetId)
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const state = await orcaPage.evaluate(
|
||||
(targetId) => window.__store?.getState().sshConnectionStates.get(targetId),
|
||||
remote.targetId
|
||||
)
|
||||
return (
|
||||
state?.status === 'connected' &&
|
||||
(state.providerEpoch !== authorityBeforeTransportReconnect?.providerEpoch ||
|
||||
state.connectionGeneration !==
|
||||
authorityBeforeTransportReconnect?.connectionGeneration)
|
||||
)
|
||||
},
|
||||
{ timeout: 30_000, message: 'renderer did not observe the reconnected SSH authority' }
|
||||
)
|
||||
.toBe(true)
|
||||
await expectForwardEvidence(orcaPage, remote.targetId, [
|
||||
{ localPort, remotePort: REMOTE_PORT },
|
||||
{ localPort: unrelatedLocalPort, remotePort: REFRESH_BARRIER_PORT }
|
||||
])
|
||||
await expect.poll(() => requestForward(localPort)).toContain(marker)
|
||||
await expect.poll(() => requestForward(unrelatedLocalPort)).toContain(unrelatedMarker)
|
||||
await expect(
|
||||
orcaPage.getByText(`:${localPort} → :${REMOTE_PORT}`, { exact: true })
|
||||
).toBeVisible()
|
||||
await expect(
|
||||
orcaPage.getByText(`:${unrelatedLocalPort} → :${REFRESH_BARRIER_PORT}`, { exact: true })
|
||||
).toBeVisible()
|
||||
|
||||
const collisionServer = createServer()
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
collisionServer.once('error', reject)
|
||||
collisionServer.listen(0, '127.0.0.1', resolve)
|
||||
})
|
||||
const collisionAddress = collisionServer.address()
|
||||
if (!collisionAddress || typeof collisionAddress === 'string') {
|
||||
throw new Error('Unable to reserve a collision port')
|
||||
}
|
||||
try {
|
||||
const collisionResult = await orcaPage.evaluate(
|
||||
async ({ targetId, localPort, remotePort }) => {
|
||||
try {
|
||||
await window.api.ssh.addPortForward({
|
||||
targetId,
|
||||
localPort,
|
||||
remoteHost: '127.0.0.1',
|
||||
remotePort,
|
||||
label: 'collision'
|
||||
})
|
||||
return { ok: true, message: '' }
|
||||
} catch (error) {
|
||||
return { ok: false, message: error instanceof Error ? error.message : String(error) }
|
||||
}
|
||||
},
|
||||
{
|
||||
targetId: remote.targetId,
|
||||
localPort: collisionAddress.port,
|
||||
remotePort: REMOTE_PORT
|
||||
}
|
||||
)
|
||||
expect(collisionResult).toMatchObject({ ok: false })
|
||||
expect(collisionResult.message).toMatch(/in use|EADDRINUSE/i)
|
||||
} finally {
|
||||
await new Promise<void>((resolve, reject) =>
|
||||
collisionServer.close((error) => (error ? reject(error) : resolve()))
|
||||
)
|
||||
}
|
||||
await expectForwardEvidence(orcaPage, remote.targetId, [
|
||||
{ localPort, remotePort: REMOTE_PORT },
|
||||
{ localPort: unrelatedLocalPort, remotePort: REFRESH_BARRIER_PORT }
|
||||
])
|
||||
|
||||
const primaryRow = orcaPage
|
||||
.getByText(`:${localPort} → :${REMOTE_PORT}`, { exact: true })
|
||||
.locator('../../..')
|
||||
await primaryRow.getByTitle('Remove').click()
|
||||
await expect(
|
||||
orcaPage.getByText(`:${localPort} → :${REMOTE_PORT}`, { exact: true })
|
||||
).not.toBeVisible()
|
||||
await expectForwardEvidence(orcaPage, remote.targetId, [
|
||||
{ localPort: unrelatedLocalPort, remotePort: REFRESH_BARRIER_PORT }
|
||||
])
|
||||
await expect(requestForward(localPort)).rejects.toThrow()
|
||||
await expect.poll(() => requestForward(unrelatedLocalPort)).toContain(unrelatedMarker)
|
||||
|
||||
const evidence = await readPortForwardEvidence(orcaPage, remote.targetId)
|
||||
const identity = readRemoteListenerIdentity(target, REMOTE_PORT)
|
||||
const unrelatedIdentity = readRemoteListenerIdentity(target, REFRESH_BARRIER_PORT)
|
||||
const warnings = await readLifecycleWarnings(electronApp)
|
||||
const relayReconnectStates = await readSshStateCapture(orcaPage)
|
||||
testInfo.annotations.push({
|
||||
type: 'ssh-port-forward-evidence',
|
||||
description: JSON.stringify({
|
||||
evidence,
|
||||
identity,
|
||||
unrelatedIdentity,
|
||||
removedForwardLocalPort: localPort,
|
||||
unrelatedForward,
|
||||
relayReconnectStates,
|
||||
staleSnapshotEvidence,
|
||||
systemSshInvocations: readSystemSshInvocationKinds(systemSshInvocationLogPath),
|
||||
warnings
|
||||
})
|
||||
})
|
||||
|
||||
expect(identity).toMatchObject({
|
||||
pid: remotePid,
|
||||
executable: expect.stringContaining('/node'),
|
||||
command: expect.stringContaining(String(REMOTE_PORT))
|
||||
})
|
||||
expect(unrelatedIdentity).toMatchObject({
|
||||
pid: unrelatedRemotePid,
|
||||
executable: expect.stringContaining('/node'),
|
||||
command: expect.stringContaining(String(REFRESH_BARRIER_PORT))
|
||||
})
|
||||
expect(evidence.rendererForwards).not.toContainEqual(
|
||||
expect.objectContaining({ localPort, remotePort: REMOTE_PORT })
|
||||
)
|
||||
expect(evidence.managerForwards).not.toContainEqual(
|
||||
expect.objectContaining({ localPort, remotePort: REMOTE_PORT })
|
||||
)
|
||||
expect(evidence.persistedForwards).not.toContainEqual(
|
||||
expect.objectContaining({ localPort, remotePort: REMOTE_PORT })
|
||||
)
|
||||
expect(evidence.events.at(-1)?.forwards).toContainEqual(
|
||||
expect.objectContaining({
|
||||
localPort: unrelatedLocalPort,
|
||||
remotePort: REFRESH_BARRIER_PORT
|
||||
})
|
||||
)
|
||||
await expect(requestForward(unrelatedLocalPort)).resolves.toContain(unrelatedMarker)
|
||||
expect(warnings.filter((message) => message.includes('Port forward'))).toEqual([])
|
||||
} finally {
|
||||
await restoreSshPortForwardSnapshotHandler(electronApp).catch(() => undefined)
|
||||
await restoreLifecycleWarningCapture(electronApp).catch(() => undefined)
|
||||
await localPortReservation.release().catch(() => undefined)
|
||||
await unrelatedLocalPortReservation.release().catch(() => undefined)
|
||||
cleanupDockerSshRelayTarget(target)
|
||||
}
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user