mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 16:47:34 +00:00
test(e2e): delete helpers orphaned by relay deletion (#27411)
Co-authored-by: m4air <m4air@Mac.localdomain>
This commit is contained in:
@@ -18870,15 +18870,14 @@
|
||||
"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-agent-status-ssh-authority.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 tests/e2e/helpers/ssh-port-forward-snapshot-barrier.unit.test.ts --reporter=dot",
|
||||
"pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/hooks/useIpcEvents-agent-status-ssh-authority.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 --reporter=dot",
|
||||
"pnpm exec electron-vite build --mode e2e"
|
||||
],
|
||||
"testFiles": [
|
||||
"src/renderer/src/hooks/useIpcEvents-agent-status-ssh-authority.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",
|
||||
"tests/e2e/helpers/ssh-port-forward-snapshot-barrier.unit.test.ts"
|
||||
"src/main/ipc/ssh.test.ts"
|
||||
],
|
||||
"assertionRefs": [
|
||||
{
|
||||
@@ -18903,10 +18902,6 @@
|
||||
"preserves active port forwards and live connections across handler re-registration",
|
||||
"persists desired forwards and broadcasts when an active forward closes unexpectedly"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "tests/e2e/helpers/ssh-port-forward-snapshot-barrier.unit.test.ts",
|
||||
"assertions": ["holds only the first matching request while its snapshot is unresolved"]
|
||||
}
|
||||
],
|
||||
"evidenceRuns": [
|
||||
@@ -18914,7 +18909,7 @@
|
||||
"date": "2026-07-30",
|
||||
"runner": "local",
|
||||
"platform": "macos",
|
||||
"command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/hooks/useIpcEvents-agent-status-ssh-authority.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 tests/e2e/helpers/ssh-port-forward-snapshot-barrier.unit.test.ts --reporter=dot",
|
||||
"command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/hooks/useIpcEvents-agent-status-ssh-authority.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 --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."
|
||||
@@ -20148,13 +20143,9 @@
|
||||
"invariant": "Transport loss and frozen-host silence must preserve the remote session; host relay loss may rebind a pane without accumulating reattachable leases. Reconnects must preserve usable terminal content, bounded PTYs/fds/processes, complete large listings, and independently recoverable watcher processes. Electron test shutdown must release inherited pipes after confirmed root exit without closing live-process pipes.",
|
||||
"oracle": "Poll a changed connected SSH authority after injected faults, then require terminal output and appropriate PTY identity. Read remote process/fd state, listFiles replies, and rendered explorer rows. Resolve Playwright cleanup only after the root process exits and its inherited pipes close; live-process pipes remain untouched.",
|
||||
"commands": [
|
||||
"pnpm exec vitest run --config config/vitest.config.ts tests/e2e/helpers/electron-process-shutdown.unit.test.ts",
|
||||
"pnpm exec vitest run --config config/vitest.config.ts tests/e2e/ssh-codex-replay-reply-probe.unit.test.ts"
|
||||
],
|
||||
"testFiles": [
|
||||
"tests/e2e/helpers/electron-process-shutdown.unit.test.ts",
|
||||
"tests/e2e/ssh-codex-replay-reply-probe.unit.test.ts"
|
||||
"pnpm exec vitest run --config config/vitest.config.ts tests/e2e/helpers/electron-process-shutdown.unit.test.ts"
|
||||
],
|
||||
"testFiles": ["tests/e2e/helpers/electron-process-shutdown.unit.test.ts"],
|
||||
"assertionRefs": [
|
||||
{
|
||||
"file": "tests/e2e/helpers/electron-process-shutdown.unit.test.ts",
|
||||
@@ -20162,12 +20153,6 @@
|
||||
"releases inherited pipes after confirmed exit, including prior exit",
|
||||
"retains live-process pipes on shutdown timeout"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "tests/e2e/ssh-codex-replay-reply-probe.unit.test.ts",
|
||||
"assertions": [
|
||||
"unrelated, replacement, initial-spawn, empty and non-replay replies do not count; original reattach results and failures pass through unchanged"
|
||||
]
|
||||
}
|
||||
],
|
||||
"evidenceRuns": [
|
||||
|
||||
@@ -11763,14 +11763,11 @@
|
||||
"tests/e2e/helpers/git-status-retry-barrier.unit.test.ts": 59,
|
||||
"tests/e2e/helpers/golden-source-control.unit.test.ts": 645,
|
||||
"tests/e2e/helpers/headless-paired-runtime-serve-readiness.unit.test.ts": 56,
|
||||
"tests/e2e/helpers/nested-runtime-proxy-jump-fixture.unit.test.ts": 45,
|
||||
"tests/e2e/helpers/paired-client-runtime-environment.unit.test.ts": 59,
|
||||
"tests/e2e/helpers/paired-client-window-reveal.unit.test.ts": 179,
|
||||
"tests/e2e/helpers/persisted-profile-state.unit.test.ts": 470,
|
||||
"tests/e2e/helpers/remote-skill-cloud-fixture.unit.test.ts": 421,
|
||||
"tests/e2e/helpers/remote-terminal-source-range-contract-fixture.unit.test.ts": 65,
|
||||
"tests/e2e/helpers/remote-typing-load-script.unit.test.ts": 276,
|
||||
"tests/e2e/helpers/ssh-port-forward-snapshot-barrier.unit.test.ts": 117,
|
||||
"tests/e2e/helpers/streaming-terminal-cleanup.unit.test.ts": 51,
|
||||
"tests/e2e/helpers/terminal-pty-write-spy.unit.test.ts": 64,
|
||||
"tests/e2e/host-cold-park-remote-subscriber.unit.test.ts": 7476,
|
||||
@@ -11792,7 +11789,6 @@
|
||||
"tests/e2e/session-tabs-empty-inventory-daemon-oracle.unit.test.ts": 1142,
|
||||
"tests/e2e/session-tabs-rich-status-boundaries.unit.test.ts": 5281,
|
||||
"tests/e2e/ssh-browser-network-execution-route.docker.unit.test.ts": 362,
|
||||
"tests/e2e/ssh-codex-replay-reply-probe.unit.test.ts": 64,
|
||||
"tests/e2e/structured-agent-session-read-owner.unit.test.ts": 2988,
|
||||
"tests/e2e/structured-attention-explicit-read.unit.test.ts": 13645,
|
||||
"tests/e2e/structured-attention-headless-delivery.unit.test.ts": 259,
|
||||
|
||||
@@ -1,133 +0,0 @@
|
||||
import { connectSshTestTarget } from './ssh-test-target-connection'
|
||||
import { expect, type Page } from '@stablyai/playwright-test'
|
||||
|
||||
import {
|
||||
DOCKER_SSH_PROXY_JUMP_REMOTE_REPO_PATH,
|
||||
DOCKER_SSH_RELAY_REMOTE_REPO_PATH,
|
||||
type DockerSshRelayTarget
|
||||
} from './docker-ssh-relay-target'
|
||||
|
||||
export type ConnectedDockerSshRelayTarget = {
|
||||
targetId: string
|
||||
repoId: string
|
||||
worktreeId: string
|
||||
}
|
||||
|
||||
type DockerSshRelayConnectionOptions = {
|
||||
relayGracePeriodSeconds?: number
|
||||
remotePath?: string
|
||||
viaProxyJump?: boolean
|
||||
/**
|
||||
* Seed a terminal tab when the worktree has none. Default true.
|
||||
*
|
||||
* Why it is optional: a spec asking whether the PRODUCT adds a tab cannot tell this helper's
|
||||
* tab from the one under test, so it must be able to leave the worktree empty.
|
||||
*/
|
||||
seedInitialTab?: boolean
|
||||
}
|
||||
|
||||
export async function connectDockerSshRelayTarget(
|
||||
page: Page,
|
||||
target: DockerSshRelayTarget,
|
||||
options: DockerSshRelayConnectionOptions = {}
|
||||
): Promise<ConnectedDockerSshRelayTarget> {
|
||||
const viaProxyJump = options.viaProxyJump ?? false
|
||||
return connectSshTestTarget(
|
||||
page,
|
||||
{
|
||||
label: `${viaProxyJump ? 'Docker SSH ProxyJump' : 'Docker SSH Relay'} E2E ${Date.now()}`,
|
||||
...(viaProxyJump ? { configHost: 'orca-e2e-destination' } : {}),
|
||||
host: target.host,
|
||||
port: viaProxyJump ? 22 : target.port,
|
||||
username: 'root',
|
||||
identityFile: target.identityFile,
|
||||
identitiesOnly: true,
|
||||
...(viaProxyJump ? { jumpHost: 'orca-e2e-jump' } : {}),
|
||||
relayGracePeriodSeconds: options.relayGracePeriodSeconds ?? 1
|
||||
},
|
||||
{
|
||||
remotePath:
|
||||
options.remotePath ??
|
||||
(viaProxyJump ? DOCKER_SSH_PROXY_JUMP_REMOTE_REPO_PATH : DOCKER_SSH_RELAY_REMOTE_REPO_PATH),
|
||||
displayName: viaProxyJump ? 'Docker SSH ProxyJump E2E' : 'Docker SSH Relay E2E',
|
||||
seedInitialTab: options.seedInitialTab
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
export async function disconnectDockerSshRelayTarget(page: Page, targetId: string): Promise<void> {
|
||||
await page.evaluate(async (targetId) => {
|
||||
await window.api.ssh.disconnect({ targetId })
|
||||
}, targetId)
|
||||
}
|
||||
|
||||
export async function resetDockerSshRelayTarget(page: Page, targetId: string): Promise<void> {
|
||||
await page.evaluate(async (targetId) => {
|
||||
await window.api.ssh.resetRelay({ targetId })
|
||||
}, targetId)
|
||||
}
|
||||
|
||||
async function performDockerSshRelayReconnect(
|
||||
page: Page,
|
||||
targetId: string,
|
||||
disconnectFirst: boolean
|
||||
): Promise<void> {
|
||||
await page.evaluate(
|
||||
async ({ targetId, disconnectFirst }) => {
|
||||
const store = window.__store
|
||||
if (!store) {
|
||||
throw new Error('Store unavailable')
|
||||
}
|
||||
if (disconnectFirst) {
|
||||
await window.api.ssh.disconnect({ targetId })
|
||||
}
|
||||
const state = await window.api.ssh.connect({ targetId })
|
||||
if (!state || state.status !== 'connected') {
|
||||
throw new Error(`SSH target did not reconnect: ${JSON.stringify(state)}`)
|
||||
}
|
||||
store.getState().setSshConnectionState(targetId, state)
|
||||
},
|
||||
{ targetId, disconnectFirst }
|
||||
)
|
||||
}
|
||||
|
||||
export async function reconnectDockerSshRelayTarget(page: Page, targetId: string): Promise<void> {
|
||||
return performDockerSshRelayReconnect(page, targetId, true)
|
||||
}
|
||||
|
||||
export async function reconnectDisconnectedDockerSshRelayTarget(
|
||||
page: Page,
|
||||
targetId: string
|
||||
): Promise<void> {
|
||||
return performDockerSshRelayReconnect(page, targetId, false)
|
||||
}
|
||||
|
||||
export async function recoverDockerSshRelayAfterFault(
|
||||
page: Page,
|
||||
targetId: string,
|
||||
injectFault: () => void | Promise<void>
|
||||
): Promise<void> {
|
||||
const readAuthority = () =>
|
||||
page.evaluate((id) => window.__store?.getState().sshConnectionStates.get(id), targetId)
|
||||
const before = await readAuthority()
|
||||
expect(before).toMatchObject({
|
||||
status: 'connected',
|
||||
providerEpoch: expect.any(String),
|
||||
connectionGeneration: expect.any(Number)
|
||||
})
|
||||
await injectFault()
|
||||
// The pre-fault connected publication can remain visible until the next IPC event.
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const after = await readAuthority()
|
||||
return (
|
||||
after?.status === 'connected' &&
|
||||
(after.providerEpoch !== before?.providerEpoch ||
|
||||
after.connectionGeneration !== before?.connectionGeneration)
|
||||
)
|
||||
},
|
||||
{ timeout: 120_000, message: 'SSH authority did not recover after the injected fault' }
|
||||
)
|
||||
.toBe(true)
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
import { expect, type Page } from '@stablyai/playwright-test'
|
||||
|
||||
import { waitForActivePanePtyId, waitForActiveTerminalManager } from './terminal'
|
||||
|
||||
/** Add one more terminal tab to a connected remote worktree and wait for it to own a PTY. */
|
||||
export async function createRemoteTerminalTab(page: Page, worktreeId: string): Promise<void> {
|
||||
const tabId = await page.evaluate((id) => {
|
||||
const state = window.__store?.getState()
|
||||
if (!state) {
|
||||
throw new Error('Store unavailable')
|
||||
}
|
||||
const tab = state.createTab(id, undefined, undefined, { activate: true })
|
||||
state.setActiveTab(tab.id)
|
||||
state.setActiveTabType('terminal', window.__store?.getState().activeWorktreeId ?? null)
|
||||
return tab.id
|
||||
}, worktreeId)
|
||||
await expect
|
||||
.poll(() => page.evaluate(() => window.__store?.getState().activeTabId ?? null), {
|
||||
timeout: 10_000
|
||||
})
|
||||
.toBe(tabId)
|
||||
await waitForActiveTerminalManager(page, 60_000)
|
||||
await waitForActivePanePtyId(page, 60_000)
|
||||
}
|
||||
|
||||
export async function readRemoteTerminalTabs(
|
||||
page: Page,
|
||||
worktreeId: string
|
||||
): Promise<{ id: string; ptyId: string | null }[]> {
|
||||
return page.evaluate(
|
||||
(id) =>
|
||||
(window.__store?.getState().tabsByWorktree[id] ?? []).map((tab) => ({
|
||||
id: tab.id,
|
||||
ptyId: tab.ptyId
|
||||
})),
|
||||
worktreeId
|
||||
)
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
import { chmodSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
|
||||
export type NestedRuntimeProxyJumpFixture = {
|
||||
configPath: string
|
||||
directory: string
|
||||
wrapperPath: string
|
||||
dispose(): void
|
||||
writeConfig(contents: string): void
|
||||
}
|
||||
|
||||
export function createNestedRuntimeProxyJumpFixture(): NestedRuntimeProxyJumpFixture {
|
||||
const directory = mkdtempSync(path.join(os.tmpdir(), 'orca-e2e-proxy-jump-'))
|
||||
const configPath = path.join(directory, 'ssh-config')
|
||||
const wrapperPath = path.join(directory, 'ssh')
|
||||
try {
|
||||
// Why: OpenSSH ignores an overridden HOME on macOS, so force the disposable HUB-only config explicitly.
|
||||
writeFileSync(wrapperPath, `#!/bin/sh\nexec /usr/bin/ssh -F "${configPath}" "$@"\n`, {
|
||||
mode: 0o700
|
||||
})
|
||||
chmodSync(wrapperPath, 0o700)
|
||||
} catch (error) {
|
||||
rmSync(directory, { force: true, recursive: true })
|
||||
throw error
|
||||
}
|
||||
return {
|
||||
configPath,
|
||||
directory,
|
||||
wrapperPath,
|
||||
dispose: () => rmSync(directory, { force: true, recursive: true }),
|
||||
writeConfig: (contents) => writeFileSync(configPath, contents, { mode: 0o600 })
|
||||
}
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
import { existsSync, readFileSync, statSync } from 'node:fs'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import {
|
||||
createNestedRuntimeProxyJumpFixture,
|
||||
type NestedRuntimeProxyJumpFixture
|
||||
} from './nested-runtime-proxy-jump-fixture'
|
||||
|
||||
describe('nested runtime ProxyJump fixture', () => {
|
||||
let fixture: NestedRuntimeProxyJumpFixture | null = null
|
||||
|
||||
afterEach(() => fixture?.dispose())
|
||||
|
||||
it('removes its exact wrapper and config directory on disposal', () => {
|
||||
fixture = createNestedRuntimeProxyJumpFixture()
|
||||
fixture.writeConfig('Host destination\n HostName 127.0.0.1\n')
|
||||
|
||||
expect(statSync(fixture.wrapperPath).mode & 0o111).not.toBe(0)
|
||||
expect(readFileSync(fixture.configPath, 'utf8')).toContain('Host destination')
|
||||
|
||||
const directory = fixture.directory
|
||||
fixture.dispose()
|
||||
fixture = null
|
||||
|
||||
expect(existsSync(directory)).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -1,22 +0,0 @@
|
||||
export function remoteTypingLoadScript(runId: string): string {
|
||||
return [
|
||||
"process.stdin.setEncoding('utf8')",
|
||||
'if (process.stdin.isTTY) process.stdin.setRawMode(true)',
|
||||
'process.stdin.resume()',
|
||||
'const statusRow = Math.max(2, process.stdout.rows || 24)',
|
||||
"process.stdout.write('\\x1b[1;' + (statusRow - 1) + 'r')",
|
||||
'let seq = 0',
|
||||
'let frame = 0',
|
||||
'let bg = null',
|
||||
`process.stdout.write('REMOTE_TUI_READY_${runId}\\n')`,
|
||||
"setTimeout(() => { bg = setInterval(() => { frame += 1; process.stdout.write('\\x1b[1;1HBG_' + frame + '_' + 'x'.repeat(4096) + '\\n') }, 8) }, 500)",
|
||||
"process.stdin.on('data', (chunk) => {",
|
||||
' if (chunk.includes(String.fromCharCode(3))) { if (bg) clearInterval(bg); process.exit(0) }',
|
||||
' for (const char of chunk) {',
|
||||
" if (char === '\\r' || char === '\\n') continue",
|
||||
' seq += 1',
|
||||
` process.stdout.write('\\x1b[' + statusRow + ';2H\\x1b[2KKEY_${runId}_' + seq + '_' + char)`,
|
||||
' }',
|
||||
'})'
|
||||
].join(';')
|
||||
}
|
||||
@@ -1,69 +0,0 @@
|
||||
import '../../../src/main/daemon/xterm-env-polyfill'
|
||||
import { EventEmitter } from 'node:events'
|
||||
import { runInNewContext } from 'node:vm'
|
||||
import { Terminal } from '@xterm/headless'
|
||||
import { SerializeAddon } from '@xterm/addon-serialize'
|
||||
import { expect, it } from 'vitest'
|
||||
import { remoteTypingLoadScript } from './remote-typing-load-script'
|
||||
|
||||
it.each([
|
||||
{ rows: 12, cols: 80 },
|
||||
{ rows: 24, cols: 80 },
|
||||
{ rows: 40, cols: 80 },
|
||||
{ rows: 12, cols: 35 },
|
||||
{ rows: 24, cols: 35 },
|
||||
{ rows: 40, cols: 35 }
|
||||
])(
|
||||
'keeps typed output visible through background pressure at $rows rows and $cols columns',
|
||||
async ({ rows, cols }) => {
|
||||
const terminal = new Terminal({ rows, cols, scrollback: 1000, allowProposedApi: true })
|
||||
const serializer = new SerializeAddon()
|
||||
terminal.loadAddon(serializer)
|
||||
const runId = cols === 80 ? 'test' : '1790930671681_active'
|
||||
const input = new EventEmitter()
|
||||
const chunks: string[] = []
|
||||
let background = (): void => {
|
||||
throw new Error('Background pressure did not start')
|
||||
}
|
||||
try {
|
||||
runInNewContext(remoteTypingLoadScript(runId), {
|
||||
process: {
|
||||
stdin: {
|
||||
isTTY: true,
|
||||
setEncoding() {},
|
||||
setRawMode() {},
|
||||
resume() {},
|
||||
on: input.on.bind(input)
|
||||
},
|
||||
stdout: { rows, cols, write: (chunk: string) => chunks.push(chunk) },
|
||||
exit() {}
|
||||
},
|
||||
setTimeout: (callback: () => void) => callback(),
|
||||
setInterval: (callback: () => void) => {
|
||||
background = callback
|
||||
return 1
|
||||
},
|
||||
clearInterval() {}
|
||||
})
|
||||
input.emit('data', cols === 80 ? 'ab\r\ncd' : 'abcdefghij\r\n')
|
||||
for (let frame = 0; frame < 40; frame += 1) {
|
||||
background()
|
||||
}
|
||||
const output = chunks.join('')
|
||||
expect(output.length).toBeGreaterThan(160_000)
|
||||
await new Promise<void>((resolve) => terminal.write(output, resolve))
|
||||
const screen = Array.from(
|
||||
{ length: rows },
|
||||
(_, row) =>
|
||||
terminal.buffer.active
|
||||
.getLine(terminal.buffer.active.baseY + row)
|
||||
?.translateToString(true) ?? ''
|
||||
).join('\n')
|
||||
const reply = cols === 80 ? 'KEY_test_4_d' : `KEY_${runId}_10_j`
|
||||
expect(screen).toContain(reply)
|
||||
expect(serializer.serialize()).toContain(reply)
|
||||
} finally {
|
||||
terminal.dispose()
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -0,0 +1,32 @@
|
||||
import { createServer } from 'node:net'
|
||||
|
||||
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()))
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,158 +0,0 @@
|
||||
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
|
||||
})
|
||||
}
|
||||
@@ -1,69 +0,0 @@
|
||||
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)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -1,55 +0,0 @@
|
||||
import type { ElectronApplication } from '@stablyai/playwright-test'
|
||||
|
||||
type ReplayPayload = { id: string; length: number; preview: string; source: 'spawn-reply' }
|
||||
type SpawnHandler = (event: unknown, args: Record<string, unknown>) => Promise<unknown>
|
||||
type ReplayReplyScope = typeof globalThis & {
|
||||
__orcaSshCodexReplayReplies?: ReplayPayload[]
|
||||
}
|
||||
|
||||
export async function installSshReplayReplyProbe(
|
||||
app: ElectronApplication,
|
||||
ptyId: string
|
||||
): Promise<void> {
|
||||
await app.evaluate(({ ipcMain }, expectedPtyId) => {
|
||||
const scope = globalThis as ReplayReplyScope
|
||||
if (scope.__orcaSshCodexReplayReplies) {
|
||||
throw new Error('SSH replay reply probe already installed')
|
||||
}
|
||||
const handlers = (ipcMain as unknown as { _invokeHandlers?: Map<string, SpawnHandler> })
|
||||
._invokeHandlers
|
||||
const original = handlers?.get('pty:spawn')
|
||||
if (!handlers || !original) {
|
||||
throw new Error('PTY spawn handler unavailable')
|
||||
}
|
||||
const payloads: ReplayPayload[] = []
|
||||
scope.__orcaSshCodexReplayReplies = payloads
|
||||
// SSH reconnect returns its replay with the reattach reply, without a pty:replay push.
|
||||
handlers.set('pty:spawn', async (event, args) => {
|
||||
const result = await original(event, args)
|
||||
if (
|
||||
args.sessionId === expectedPtyId &&
|
||||
result &&
|
||||
typeof result === 'object' &&
|
||||
'id' in result &&
|
||||
result.id === expectedPtyId &&
|
||||
'isReattach' in result &&
|
||||
result.isReattach === true &&
|
||||
'replay' in result &&
|
||||
typeof result.replay === 'string' &&
|
||||
result.replay.length > 0
|
||||
) {
|
||||
payloads.push({
|
||||
id: expectedPtyId,
|
||||
length: result.replay.length,
|
||||
preview: result.replay.slice(-400),
|
||||
source: 'spawn-reply'
|
||||
})
|
||||
}
|
||||
return result
|
||||
})
|
||||
}, ptyId)
|
||||
}
|
||||
|
||||
export async function readSshReplayReplies(app: ElectronApplication): Promise<ReplayPayload[]> {
|
||||
return app.evaluate(() => (globalThis as ReplayReplyScope).__orcaSshCodexReplayReplies ?? [])
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
import type { ElectronApplication } from '@stablyai/playwright-test'
|
||||
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
|
||||
import { installSshReplayReplyProbe, readSshReplayReplies } from './ssh-codex-replay-reply-probe'
|
||||
|
||||
beforeEach(() => vi.stubGlobal('__orcaSshCodexReplayReplies', undefined))
|
||||
afterEach(() => vi.unstubAllGlobals())
|
||||
|
||||
function harness(result: unknown) {
|
||||
const original = vi.fn().mockResolvedValue(result)
|
||||
const handlers = new Map([['pty:spawn', original]])
|
||||
const app = {
|
||||
evaluate: (fn: (electron: unknown, arg: unknown) => unknown, arg: unknown) =>
|
||||
fn({ ipcMain: { _invokeHandlers: handlers } }, arg)
|
||||
} as unknown as ElectronApplication
|
||||
return { app, original, handlers }
|
||||
}
|
||||
|
||||
it('records the original PTY reattach reply without changing the handler result', async () => {
|
||||
const result = { id: 'ssh:target@@pty-1', isReattach: true, replay: 'restored output' }
|
||||
const { app, handlers, original } = harness(result)
|
||||
await installSshReplayReplyProbe(app, result.id)
|
||||
const event = {}
|
||||
const args = { sessionId: result.id }
|
||||
expect(await handlers.get('pty:spawn')!(event, args)).toBe(result)
|
||||
expect(original).toHaveBeenCalledWith(event, args)
|
||||
expect(await readSshReplayReplies(app)).toEqual([
|
||||
{ id: result.id, length: 15, preview: 'restored output', source: 'spawn-reply' }
|
||||
])
|
||||
})
|
||||
|
||||
it.each([
|
||||
[{}, { id: 'wanted', isReattach: true, replay: 'initial' }],
|
||||
[{ sessionId: 'other' }, { id: 'other', isReattach: true, replay: 'other PTY' }],
|
||||
[{ sessionId: 'wanted' }, { id: 'replacement', isReattach: true, replay: 'new PTY' }],
|
||||
[{ sessionId: 'wanted' }, { id: 'wanted', replay: 'no reattach proof' }],
|
||||
[{ sessionId: 'wanted' }, { id: 'wanted', isReattach: true, replay: '' }],
|
||||
[{ sessionId: 'wanted' }, { id: 'wanted', isReattach: true, snapshot: 'not replay' }]
|
||||
])('does not count unrelated or unproven replay: %j', async (args, result) => {
|
||||
const { app, handlers } = harness(result)
|
||||
await installSshReplayReplyProbe(app, 'wanted')
|
||||
expect(await handlers.get('pty:spawn')!({}, args)).toBe(result)
|
||||
expect(await readSshReplayReplies(app)).toEqual([])
|
||||
})
|
||||
|
||||
it('preserves a failed reattach without recording replay', async () => {
|
||||
const { app, handlers, original } = harness(null)
|
||||
const error = new Error('unverifiable')
|
||||
original.mockRejectedValue(error)
|
||||
await installSshReplayReplyProbe(app, 'wanted')
|
||||
await expect(handlers.get('pty:spawn')!({}, { sessionId: 'wanted' })).rejects.toBe(error)
|
||||
expect(await readSshReplayReplies(app)).toEqual([])
|
||||
})
|
||||
@@ -7,7 +7,7 @@ import { expect, test } from './helpers/orca-app'
|
||||
import { reconnect } from './helpers/orcad-convert-flow'
|
||||
import { shellQuote } from './helpers/docker-ssh-relay-target'
|
||||
import { callEnvironment, createPairedHostTerminal } from './helpers/paired-host-terminal'
|
||||
import { reserveLocalPort } from './helpers/ssh-port-forward-snapshot-barrier'
|
||||
import { reserveLocalPort } from './helpers/reserve-local-port'
|
||||
import { openTerminalWorkspaceRootLink } from './helpers/terminal-workspace-root-link'
|
||||
import {
|
||||
echoMarker,
|
||||
|
||||
Reference in New Issue
Block a user