test(e2e): mixed-version, two-client and phone layout baselines recorded on main (#27688)

* test(e2e): record mixed-version, two-client and phone layout baselines on main (P3)

* test(e2e): P3 review fixes: reconnect and I/O proof, restart order bounds, Linux-only baselines

* test(e2e): re-record Linux phone and serve-restart baselines

* test(e2e): P3: echo drawn in the desktop pane; bound the group selection after restart

* test(e2e): re-record serve-restart group selection

* test(e2e): name P3 baseline helpers for what they record
This commit is contained in:
Jinwoo Hong
2026-10-11 21:18:16 -04:00
committed by GitHub
parent 9d305f3ce4
commit 8dfa2b1875
14 changed files with 3472 additions and 172 deletions
@@ -0,0 +1,42 @@
name: Download pinned Orca release
description: Downloads a released Linux .deb, checks its SHA-512, extracts it and points ORCA_CROSS_VERSION_PACKAGED_EXECUTABLE at its executable.
inputs:
version:
description: Release version without the leading v (e.g. 1.4.224).
required: true
sha512:
description: Base64 SHA-512 of orca-ide_<version>_amd64.deb.
required: true
github-token:
description: Token for gh release download.
required: true
runs:
using: composite
steps:
- name: Download and verify
shell: bash
env:
GH_TOKEN: ${{ inputs.github-token }}
ORCA_PINNED_VERSION: ${{ inputs.version }}
ORCA_PINNED_SHA512: ${{ inputs.sha512 }}
run: |
gh release download "v$ORCA_PINNED_VERSION" --repo stablyai/orca --pattern "orca-ide_${ORCA_PINNED_VERSION}_amd64.deb" --dir "$RUNNER_TEMP/old-orca"
python3 - <<'PYVERIFY'
import base64,hashlib,os,pathlib,subprocess
version=os.environ['ORCA_PINNED_VERSION']
root=pathlib.Path(os.environ['RUNNER_TEMP'])/'old-orca'
package=root/f'orca-ide_{version}_amd64.deb'
digest=hashlib.sha512()
with package.open('rb') as archive:
for block in iter(lambda: archive.read(1024*1024), b''):
digest.update(block)
assert base64.b64encode(digest.digest()).decode()==os.environ['ORCA_PINNED_SHA512']
extracted=root/'extracted'
subprocess.run(['dpkg-deb','-x',str(package),str(extracted)],check=True)
executable=extracted/'opt'/'Orca'/'orca-ide'
assert executable.is_file() and os.access(executable,os.X_OK)
with open(os.environ['GITHUB_ENV'],'a') as env: env.write('ORCA_CROSS_VERSION_PACKAGED_EXECUTABLE='+str(executable)+'\n')
print('Verified old package:',executable)
PYVERIFY
+61 -21
View File
@@ -1,4 +1,7 @@
name: Packaged browser compatibility
# Both jobs pair the current build with a pinned released one. `layout-compat` holds all the layout
# rework baselines (P3), compared with the shapes recorded on main in
# tests/e2e/layout-compat-baselines.json; its artifact carries the observed shapes for re-recording.
on:
workflow_dispatch:
inputs:
@@ -30,27 +33,11 @@ jobs:
with:
native-runtime: electron
- name: Download pinned old release
env:
GH_TOKEN: ${{ github.token }}
run: |
gh release download v1.4.188 --repo stablyai/orca --pattern orca-ide_1.4.188_amd64.deb --dir "$RUNNER_TEMP/old-orca"
python3 - <<'PYVERIFY'
import base64,hashlib,os,pathlib,subprocess
root=pathlib.Path(os.environ['RUNNER_TEMP'])/'old-orca'
package=root/'orca-ide_1.4.188_amd64.deb'
expected='uGONFUDfinYggxcT9ac72wnnlofLQaqasDDeP0HWOSqarBwTi1Ax3khmzKUY3vUnvuYOpSCEmsH4InzLZ2vg6g=='
digest=hashlib.sha512()
with package.open('rb') as archive:
for block in iter(lambda: archive.read(1024*1024), b''):
digest.update(block)
assert base64.b64encode(digest.digest()).decode()==expected
extracted=root/'extracted'
subprocess.run(['dpkg-deb','-x',str(package),str(extracted)],check=True)
executable=extracted/'opt'/'Orca'/'orca-ide'
assert executable.is_file() and os.access(executable,os.X_OK)
with open(os.environ['GITHUB_ENV'],'a') as env: env.write('ORCA_CROSS_VERSION_PACKAGED_EXECUTABLE='+str(executable)+'\n')
print('Verified old package:',executable)
PYVERIFY
uses: ./.github/actions/download-pinned-orca-release
with:
version: 1.4.188
sha512: uGONFUDfinYggxcT9ac72wnnlofLQaqasDDeP0HWOSqarBwTi1Ax3khmzKUY3vUnvuYOpSCEmsH4InzLZ2vg6g==
github-token: ${{ github.token }}
- name: Build current Electron app
env:
VITE_EXPOSE_STORE: 'true'
@@ -76,3 +63,56 @@ jobs:
name: packaged-mixed-version-audit
path: test-results/
retention-days: 3
layout-compat:
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- uses: actions/checkout@v6
with:
ref: ${{ inputs.ref || github.sha }}
persist-credentials: false
- name: Install headless tools
run: sudo apt-get update && sudo apt-get install -y build-essential openssh-client python3 ripgrep xvfb zsh openbox x11-utils
- uses: ./.github/actions/install-node-dependencies
with:
native-runtime: electron
- name: Download pinned old release
uses: ./.github/actions/download-pinned-orca-release
with:
version: 1.4.224
sha512: +/VyaYSgqSAuSSWe1ySrCKRjHHJ6REXqmz58g69bxvoUdV41iH9vlobM18xdxWTkgJF2kbu/tSKFvH0jMnZ0+Q==
github-token: ${{ github.token }}
- name: Build current Electron app
env:
VITE_EXPOSE_STORE: 'true'
run: |
pnpm run build:relay
pnpm exec electron-vite build --mode e2e
pnpm run build:cli
pnpm run build:web-from-renderer
# The two specs without a released build ride along, so one dispatch reruns every baseline.
- name: Run the layout baselines
env:
ORCA_LAYOUT_COMPAT_REPORT_DIR: test-results/layout-compat
run: >-
xvfb-run --auto-servernum bash .github/scripts/e2e-with-window-manager.sh
env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1
pnpm exec playwright test --config tests/playwright.config.ts
tests/e2e/layout-compat-old-desktop-on-new-server.spec.ts
tests/e2e/layout-compat-new-desktop-on-old-server.spec.ts
tests/e2e/layout-compat-old-phone-on-new-server.spec.ts
tests/e2e/layout-compat-serve-restart-keeps-at-at-terminals.spec.ts
--project=electron-headless --workers=1 --retries=0 --reporter=list
- name: Require every layout scenario to have run
if: always()
# A missing env skips the specs and passes; each run writes its observation first.
run: |
for scenario in old-desktop-on-new-server old-cli-terminal-create new-desktop-on-old-server old-phone-on-new-server serve-restart-keeps-@@-terminals; do
test -f "test-results/layout-compat/$scenario.linux.json" || { echo "::error::$scenario did not run"; exit 1; }
done
- uses: actions/upload-artifact@v7
if: always()
with:
name: packaged-layout-compat
path: test-results/
retention-days: 7
@@ -0,0 +1,34 @@
// Copies observations written under ORCA_LAYOUT_COMPAT_REPORT_DIR (<scenario>.<platform>.json)
// into tests/e2e/layout-compat-baselines.json. Usage: node <this> <report dir>
import { readFileSync, readdirSync, writeFileSync } from 'node:fs'
import path from 'node:path'
const reportDir = process.argv[2]
if (!reportDir) {
throw new Error('usage: record-layout-compat-baselines.mjs <report dir>')
}
const baselineFile = path.join(
import.meta.dirname,
'..',
'..',
'tests',
'e2e',
'layout-compat-baselines.json'
)
const baselines = JSON.parse(readFileSync(baselineFile, 'utf8'))
for (const name of readdirSync(reportDir).filter((file) => file.endsWith('.json'))) {
const match = /^(.+)\.(darwin|linux|win32)\.json$/.exec(name)
if (!match) {
continue
}
const [, scenario, platform] = match
baselines.scenarios[scenario] = {
...baselines.scenarios[scenario],
[platform]: JSON.parse(readFileSync(path.join(reportDir, name), 'utf8'))
}
console.log(`recorded ${scenario} (${platform})`)
}
baselines.scenarios = Object.fromEntries(
Object.entries(baselines.scenarios).sort(([a], [b]) => a.localeCompare(b))
)
writeFileSync(baselineFile, `${JSON.stringify(baselines, null, 2)}\n`)
+334
View File
@@ -0,0 +1,334 @@
/**
* Mixed-version, two-client and phone baselines (layout rework P3). Each scenario reduces what the
* server and its clients report to a shape with stable labels in place of generated ids, and
* compares it with the shape recorded on main in `layout-compat-baselines.json`. A change that
* moves a scenario updates its recorded shape in the same PR, so every difference is reviewed.
*/
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'
import path from 'node:path'
import type { Page, TestInfo } from '@stablyai/playwright-test'
import { expect } from './orca-app'
import type { RuntimeClient } from '../../../src/cli/runtime/client'
import { decodePairingOffer } from '../../../src/shared/pairing'
import { pairedRuntimeConnectionFromOffer } from '../../../src/shared/paired-runtime-connection'
import { subscribeRemoteRuntimeRequest } from '../../../src/shared/remote-runtime-client'
import type { RuntimeMobileSessionTabsResult } from '../../../src/shared/runtime-session-contracts'
import type {
RuntimeTerminalListResult,
RuntimeTerminalRead
} from '../../../src/shared/runtime-terminal-contracts'
import type { TerminalPaneLayoutNode } from '../../../src/shared/terminal-tab-types'
import type { PublishedWorkspaceLayout } from '../../../src/shared/workspace-layout/workspace-layout-published'
import { readWorkspaceLayoutStreamFrame } from '../../../src/shared/workspace-layout/workspace-layout-stream-frames'
const BASELINE_FILE = path.join(process.cwd(), 'tests', 'e2e', 'layout-compat-baselines.json')
/** Also writes each observation here, one file per scenario and platform, to record a baseline. */
export const LAYOUT_COMPAT_REPORT_DIR_ENV = 'ORCA_LAYOUT_COMPAT_REPORT_DIR'
type BaselineFile = { scenarios: Record<string, Partial<Record<NodeJS.Platform, unknown>>> }
/** Stable names for generated ids, numbered in first-seen order within one scenario. */
export class IdLabels {
private readonly labels = new Map<string, string>()
private readonly counts = new Map<string, number>()
label(kind: string, id: string | null | undefined): string | null {
if (!id) {
return null
}
const key = `${kind}:${id}`
let label = this.labels.get(key)
if (!label) {
const next = (this.counts.get(kind) ?? 0) + 1
this.counts.set(kind, next)
label = `${kind}${next}`
this.labels.set(key, label)
}
return label
}
/** A PTY id as its minting kind plus a label: daemon ids carry `@@`, serve-minted ones `serve-`. */
pty(id: string | null | undefined): string | null {
if (!id) {
return null
}
const kind = id.includes('@@') ? 'pty@@' : id.startsWith('serve-') ? 'ptyServe' : 'pty'
return this.label(kind, id)
}
}
function round(ratio: number | undefined): number {
return Math.round((ratio ?? 0.5) * 10) / 10
}
export type PaneTreeBaseline =
| string
| {
split: 'vertical' | 'horizontal'
ratio: number
first: PaneTreeBaseline
second: PaneTreeBaseline
}
export function paneTreeBaseline(
node: TerminalPaneLayoutNode | null | undefined,
labels: IdLabels
): PaneTreeBaseline | null {
if (!node) {
return null
}
if (node.type === 'leaf') {
return labels.label('leaf', node.leafId)!
}
return {
split: node.direction,
// Divider drags land on whole pixels; a tenth keeps the shape stable across window sizes.
ratio: round(node.ratio),
first: paneTreeBaseline(node.first, labels)!,
second: paneTreeBaseline(node.second, labels)!
}
}
/** `session.tabs.list` as every client version reads it: groups, rows, pane trees and titles. */
export function sessionTabsBaseline(result: RuntimeMobileSessionTabsResult, labels: IdLabels) {
const tabLabel = (id: string) => labels.label('tab', id)
// A terminal row's id names its pane; the strip and groups name the tab.
const rowTab = (id: string) => {
const row = result.tabs.find((tab) => tab.id === id)
return tabLabel(row?.type === 'terminal' ? row.parentTabId : id)
}
return {
activeTab: result.activeTabId ? rowTab(result.activeTabId) : null,
groups: (result.tabGroups ?? []).map((group) => ({
tabs: group.tabOrder.map(tabLabel),
active: group.activeTabId ? rowTab(group.activeTabId) : null
})),
rows: result.tabs.map((tab) =>
tab.type === 'terminal'
? {
type: tab.type,
tab: tabLabel(tab.parentTabId),
leaf: labels.label('leaf', tab.leafId),
pty: labels.pty(tab.ptyId),
title: tab.title,
...(tab.isPinned ? { pinned: true } : {}),
...(tab.parentLayout
? {
tree: paneTreeBaseline(tab.parentLayout.root, labels),
paneTitles: Object.entries(tab.parentLayout.titlesByLeafId ?? {})
.map(([leafId, title]) => `${labels.label('leaf', leafId)}=${title}`)
.sort()
}
: {})
}
: { type: tab.type, tab: tabLabel(tab.id), title: tab.title }
)
}
}
export async function readSessionTabs(
client: RuntimeClient,
worktree: string
): Promise<RuntimeMobileSessionTabsResult> {
return (await client.call<RuntimeMobileSessionTabsResult>('session.tabs.list', { worktree }))
.result
}
/** The host's live terminals: which pane each PTY is recorded under, and its title. */
export async function readTerminals(client: RuntimeClient, worktree: string, labels: IdLabels) {
const { terminals } = (
await client.call<RuntimeTerminalListResult>('terminal.list', { worktree })
).result
return terminals
.map((terminal) => ({
tab: labels.label('tab', terminal.tabId),
leaf: labels.label('leaf', terminal.leafId),
pty: labels.pty(terminal.ptyId),
title: terminal.title
}))
.sort((a, b) => String(a.pty).localeCompare(String(b.pty)))
}
/** One `layout.subscribe` snapshot from a host with a runtime-scope pairing. */
export async function readPublishedLayout(
pairingUrl: string,
worktreeId: string
): Promise<PublishedWorkspaceLayout | null> {
let resolveSnapshot: (layout: PublishedWorkspaceLayout | null) => void = () => {}
let rejectSnapshot: (error: Error) => void = () => {}
const snapshot = new Promise<PublishedWorkspaceLayout | null>((resolve, reject) => {
resolveSnapshot = resolve
rejectSnapshot = reject
})
const subscription = await subscribeRemoteRuntimeRequest<unknown>(
pairedRuntimeConnectionFromOffer(decodePairingOffer(pairingUrl)),
'layout.subscribe',
{},
30_000,
{
onResponse: (response) => {
if (!response.ok) {
rejectSnapshot(new Error(`layout.subscribe: ${response.error.code}`))
return
}
const frame = readWorkspaceLayoutStreamFrame(response.result)
if (frame?.type === 'snapshot') {
resolveSnapshot(
frame.workspaces.find((entry) => entry.layout.worktreeId === worktreeId)?.layout ?? null
)
}
},
onError: (error) => rejectSnapshot(error)
}
)
try {
return await snapshot
} finally {
subscription.close()
}
}
/** The runtime's published layout for one workspace, the model the rework will hold. */
export function publishedLayoutBaseline(layout: PublishedWorkspaceLayout | null, labels: IdLabels) {
if (!layout) {
return null
}
return {
groups: layout.groups.map((group) => group.tabIds.map((id) => labels.label('tab', id))),
tabs: layout.tabs.map((tab) => ({
tab: labels.label('tab', tab.id),
kind: tab.kind,
customTitle: tab.customTitle,
...(tab.isPinned ? { pinned: true } : {}),
...(tab.terminal
? {
tree: paneTreeBaseline(tab.terminal.root, labels),
panes: tab.terminal.panes.map((pane) => ({
leaf: labels.label('leaf', pane.leafId),
pty: labels.pty(pane.ptyId),
...(pane.title ? { title: pane.title } : {})
}))
}
: {})
}))
}
}
function readBaselines(): BaselineFile {
return JSON.parse(readFileSync(BASELINE_FILE, 'utf8'))
}
/** Fails unless `observation` equals the shape recorded on main for this scenario and platform. */
export async function expectLayoutCompatBaseline(
testInfo: TestInfo,
scenario: string,
observation: unknown
): Promise<void> {
const body = `${JSON.stringify(observation, null, 2)}\n`
await testInfo.attach(`${scenario}.json`, { body, contentType: 'application/json' })
const reportDir = process.env[LAYOUT_COMPAT_REPORT_DIR_ENV]
if (reportDir) {
mkdirSync(reportDir, { recursive: true })
writeFileSync(path.join(reportDir, `${scenario}.${process.platform}.json`), body)
}
const recorded = readBaselines().scenarios[scenario]?.[process.platform]
// The observation goes in the message, so a CI log alone is enough to record a new platform.
expect(
recorded,
`no ${process.platform} baseline for ${scenario}; record one on main. Observed:\n${body}`
).toBeDefined()
// Round-trip so `undefined` fields compare as the recorded JSON does.
expect(JSON.parse(body)).toEqual(recorded)
}
/**
* Reads until four reads in a row agree, so a step records where the server came to rest rather
* than a value mid-update. Only a convergence wait: the recorded shape is compared, never polled for.
*/
export async function readSettled<T>(read: () => Promise<T>, timeoutMs = 20_000): Promise<T> {
const deadline = Date.now() + timeoutMs
let last = JSON.stringify(await read())
let same = 1
while (same < 4) {
if (Date.now() > deadline) {
throw new Error(`never settled within ${timeoutMs} ms; last read: ${last}`)
}
await new Promise((resolve) => setTimeout(resolve, 500))
const next = JSON.stringify(await read())
same = next === last ? same + 1 : 1
last = next
}
return JSON.parse(last)
}
/**
* Reads until `ready` holds, or returns the last read at the deadline: for a step whose outcome
* the baseline records either way (it differs by platform), so neither outcome is a race.
*/
export async function readUntil<T>(
read: () => Promise<T>,
ready: (value: T) => boolean,
timeoutMs = 20_000
): Promise<T> {
const deadline = Date.now() + timeoutMs
let value = await read()
while (!ready(value) && Date.now() < deadline) {
await new Promise((resolve) => setTimeout(resolve, 500))
value = await read()
}
return value
}
/**
* After a server restart: waits until the client's own status call is answered, which only the
* new server process can do, then types an `echo` into one of its panes, reads the output on the
* host and waits for the desktop to draw it in that pane, so input and output both cross the
* reconnected link.
*/
export async function expectReconnectedPaneEcho(args: {
page: Page
environmentId: string
leafId: string
host: RuntimeClient
worktree: string
/** The text the desktop draws in that pane; null until it is there. */
readDrawn: () => Promise<string | null>
}): Promise<void> {
await expect
.poll(
() =>
args.page.evaluate(
async (selector) =>
(await window.api.runtimeEnvironments.getStatus({ selector, timeoutMs: 5_000 })).ok,
args.environmentId
),
{ timeout: 60_000 }
)
.toBe(true)
await args.page.locator(`.pane[data-leaf-id="${args.leafId}"] .xterm`).click()
// The shell prints 42 only by running the line, so the echoed input alone cannot match.
await args.page.keyboard.type('echo P3_RECONNECT_$((40+2))')
await args.page.keyboard.press('Enter')
await expect
.poll(
async () => {
const { terminals } = (
await args.host.call<RuntimeTerminalListResult>('terminal.list', {
worktree: args.worktree
})
).result
const handle = terminals.find((terminal) => terminal.leafId === args.leafId)?.handle
if (!handle) {
return ''
}
const read = await args.host.call<{ terminal: RuntimeTerminalRead }>('terminal.read', {
terminal: handle,
limit: 200
})
return read.result.terminal.tail.join('\n')
},
{ timeout: 30_000 }
)
.toContain('P3_RECONNECT_42')
await expect.poll(args.readDrawn, { timeout: 30_000 }).toContain('P3_RECONNECT_42')
}
+301
View File
@@ -0,0 +1,301 @@
/**
* A released, packaged Orca build (the "old" side of a mixed-version test). CI downloads a pinned
* release and points ORCA_CROSS_VERSION_PACKAGED_EXECUTABLE at its executable; specs skip without it.
*/
import { existsSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { createServer, type AddressInfo } from 'node:net'
import os from 'node:os'
import path from 'node:path'
import {
_electron as electron,
type ElectronApplication,
type Page,
type TestInfo
} from '@stablyai/playwright-test'
import { runProcess } from '@orca/process-host'
import { forwardElectronProcessLogs } from './orca-app'
import { getE2ECompletedOnboardingProfile } from './e2e-completed-onboarding-profile'
import { cleanupE2EDaemons, closeElectronAppForE2E } from './electron-process-shutdown'
import {
assertElectronResolvedIsolatedHome,
createElectronHomeIsolation
} from './electron-home-isolation'
import type { RuntimeDesktopPairingOffer } from './paired-electron-client'
import type { OrcaCliResult } from './compiled-orca-cli'
import { isolatedServeProfile, spawnUntilReady, type ReadyProcess } from './orca-serve-cli-host'
import {
isHeadlessPairedRuntimeServeReadyLine,
parseHeadlessPairedRuntimePairingOffer
} from './headless-paired-runtime-serve-readiness'
import { RuntimeClient } from '../../../src/cli/runtime/client'
export const PACKAGED_EXECUTABLE_ENV = 'ORCA_CROSS_VERSION_PACKAGED_EXECUTABLE'
export const packagedExecutable = process.env[PACKAGED_EXECUTABLE_ENV]
export const packagedExecutableMissing = !packagedExecutable || !existsSync(packagedExecutable)
export type PackagedPairedClient = {
app: ElectronApplication
environmentId: string
page: Page
status: { capabilities: string[] }
version: string
dispose(): Promise<void>
}
type Cleanup = () => Promise<void> | void
export async function collectCleanupFailures(cleanups: Cleanup[]): Promise<unknown[]> {
const failures: unknown[] = []
for (const cleanup of cleanups) {
try {
await cleanup()
} catch (error) {
failures.push(error)
}
}
return failures
}
async function removeProfile(userDataDir: string): Promise<void> {
for (let attempt = 0; attempt < 5; attempt += 1) {
try {
rmSync(userDataDir, { recursive: true, force: true })
return
} catch (error) {
if (attempt === 4) {
throw error
}
await new Promise((resolve) => setTimeout(resolve, 250 * (attempt + 1)))
}
}
}
/** The packaged desktop, hidden, paired to `offer` through its own Settings call. */
export async function launchPackagedPairedClient(args: {
executablePath: string
offer: RuntimeDesktopPairingOffer
testInfo: TestInfo
name?: string
}): Promise<PackagedPairedClient> {
const userDataDir = mkdtempSync(path.join(os.tmpdir(), 'orca-e2e-packaged-client-'))
let app: ElectronApplication | undefined
try {
const profile = getE2ECompletedOnboardingProfile()
// A released build exposes no terminal handle; the DOM renderer leaves drawn rows as text.
const settings = { ...profile.settings, terminalGpuAcceleration: 'off' }
writeFileSync(
path.join(userDataDir, 'orca-data.json'),
`${JSON.stringify({ ...profile, settings }, null, 2)}\n`
)
const { ELECTRON_RUN_AS_NODE: _unused, ...cleanEnv } = process.env
void _unused
const homeIsolation = createElectronHomeIsolation({
inheritedEnv: cleanEnv,
launchEnv: {},
extraEnv: {},
userDataDir
})
app = await electron.launch({
executablePath: args.executablePath,
args: [],
env: {
...homeIsolation.env,
NODE_ENV: 'production',
ORCA_BYPASS_SINGLE_INSTANCE_LOCK: '1',
ORCA_E2E_HEADLESS: '1'
}
})
forwardElectronProcessLogs(app, args.testInfo)
assertElectronResolvedIsolatedHome(
await app.evaluate(({ app: electronApp }) => electronApp.getPath('home')),
homeIsolation
)
const page = await app.firstWindow({ timeout: 120_000 })
await page.waitForLoadState('domcontentloaded')
const [status, version, environmentId] = await Promise.all([
page.evaluate(() => window.api.runtime.getStatus()),
app.evaluate(({ app: electronApp }) => electronApp.getVersion()),
page.evaluate(
async ({ pairingUrl, name }) => {
const result = await window.api.runtimeEnvironments.addFromPairingCode({
name,
pairingCode: pairingUrl
})
const response = await window.api.runtimeEnvironments.getStatus({
selector: result.environment.id,
timeoutMs: 30_000
})
if (!response.ok) {
throw new Error(`${response.error.code}: ${response.error.message}`)
}
return result.environment.id
},
{ pairingUrl: args.offer.pairingUrl, name: args.name ?? 'packaged old client' }
)
])
// A capability check against a missing list would pass vacuously.
if (!status.capabilities) {
throw new Error(`packaged Orca ${version} reported no runtime capabilities`)
}
const launched = app
return {
app: launched,
environmentId,
page,
status: { capabilities: status.capabilities },
version,
dispose: async () => {
const failures = await collectCleanupFailures([
() => closeElectronAppForE2E(launched),
() => cleanupE2EDaemons(userDataDir),
() => removeProfile(userDataDir)
])
if (failures.length > 0) {
throw new AggregateError(failures, 'Failed to clean up packaged paired client')
}
}
}
} catch (error) {
const cleanupErrors = await collectCleanupFailures([
...(app ? [() => closeElectronAppForE2E(app!)] : []),
() => cleanupE2EDaemons(userDataDir),
() => removeProfile(userDataDir)
])
if (cleanupErrors.length > 0) {
throw new AggregateError(
[error, ...cleanupErrors],
'Packaged client startup and cleanup failed'
)
}
throw error
}
}
/** The packaged build's bundled CLI entry, run the way its `bin/orca` launcher runs it. */
function packagedCliEntry(executablePath: string): string {
const resources =
process.platform === 'darwin'
? path.join(path.dirname(executablePath), '..', 'Resources')
: path.join(path.dirname(executablePath), 'resources')
return path.join(resources, 'app.asar.unpacked', 'out', 'cli', 'index.js')
}
/** The packaged build's own CLI against the runtime whose profile is `userDataDir`. */
export async function runPackagedOrcaCli(
executablePath: string,
userDataDir: string,
args: string[],
timeoutMs = 120_000
): Promise<OrcaCliResult> {
const result = await runProcess({
program: executablePath,
args: [packagedCliEntry(executablePath), ...args],
env: { ...process.env, ELECTRON_RUN_AS_NODE: '1', ORCA_USER_DATA_PATH: userDataDir },
timeoutMs,
maxOutputBytes: 4 * 1024 * 1024
})
let json: OrcaCliResult['json'] = null
try {
json = JSON.parse(result.stdout)
} catch {
json = null
}
return { args, code: result.code, stdout: result.stdout, stderr: result.stderr, json }
}
export type PackagedServeHost = {
offer: RuntimeDesktopPairingOffer
client: RuntimeClient
userDataDir: string
/** Stops serve (SIGTERM) and starts it again on the same profile and port. */
restart: () => Promise<void>
dispose: () => Promise<void>
}
async function reserveLoopbackPort(): Promise<number> {
const probe = createServer()
await new Promise<void>((resolve, reject) => {
probe.once('error', reject)
probe.listen(0, '127.0.0.1', () => resolve())
})
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: a TCP listen on a host address reports an AddressInfo.
const { port } = probe.address() as AddressInfo
await new Promise<void>((resolve) => probe.close(() => resolve()))
return port
}
/**
* The packaged build as a headless `orca serve`, spawned directly rather than through Playwright:
* a packaged build can print its readiness line before Playwright's launch resolves, and Playwright
* consumes stdout, so a listener attached afterwards misses it.
*/
export async function launchPackagedServeHost(executablePath: string): Promise<PackagedServeHost> {
// Why /tmp on macOS: the released helper has a 103-byte Unix socket ceiling.
const parent = process.platform === 'darwin' ? '/tmp' : os.tmpdir()
const userDataDir = mkdtempSync(path.join(parent, 'orca-e2e-packaged-serve-'))
const { env } = isolatedServeProfile(userDataDir, {
NODE_ENV: 'production',
ORCA_E2E_ENFORCE_SINGLE_INSTANCE_LOCK: '1',
ORCA_E2E_HEADLESS: '1'
})
const port = await reserveLoopbackPort()
const launch = <T>(parseLine: (line: string) => T | null): Promise<ReadyProcess<T>> =>
spawnUntilReady({
label: 'packaged orca serve',
program: executablePath,
args: [
// Why: a raw spawn gets no --no-sandbox from Playwright, and an extracted .deb's
// chrome-sandbox is not root-owned, so Chromium aborts at startup on Linux.
...(process.platform === 'linux' ? ['--no-sandbox'] : []),
'--serve',
'--serve-json',
'--serve-port',
String(port),
'--serve-pairing-address',
'127.0.0.1'
],
env,
timeoutMs: 120_000,
parseReady: (stdout) => {
for (const line of stdout.split('\n')) {
const parsed = parseLine(line)
if (parsed !== null) {
return parsed
}
}
return null
}
})
let serve: ReadyProcess<unknown> | null = null
const cleanup = async (): Promise<void> => {
const failures = await collectCleanupFailures([
() => serve?.stop(),
() => cleanupE2EDaemons(userDataDir),
() => removeProfile(userDataDir)
])
serve = null
if (failures.length > 0) {
throw new AggregateError(failures, 'Failed to clean up packaged serve host')
}
}
try {
const first = await launch(parseHeadlessPairedRuntimePairingOffer)
serve = first
return {
offer: first.ready,
client: new RuntimeClient(userDataDir, 30_000),
userDataDir,
restart: async () => {
await serve?.stop()
serve = null
serve = await launch((line) => (isHeadlessPairedRuntimeServeReadyLine(line) ? true : null))
},
dispose: cleanup
}
} catch (error) {
await cleanup()
throw error
}
}
+49 -5
View File
@@ -8,6 +8,8 @@ import {
type RemoteRuntimeSubscriptionCallbacks
} from '../../../src/shared/remote-runtime-client'
import type { RuntimeRpcResponse } from '../../../src/shared/runtime-rpc-envelope'
import { openRemoteRuntimePassthroughSocket } from '../../../src/shared/remote-runtime-passthrough-socket'
import type { RuntimeCapability } from '../../../src/shared/protocol-version'
import type { RuntimeDesktopPairingOffer } from './paired-electron-client'
const DEFAULT_TIMEOUT_MS = 15_000
@@ -32,18 +34,32 @@ export type PairedMobileClient = {
callbacks: RemoteRuntimeSubscriptionCallbacks<T>,
timeoutMs?: number
) => Promise<RemoteRuntimeSubscription>
/** Closes every subscription this client opened. */
/** One persistent phone socket, as the app holds one; needed for `executionHost` routing. */
openSocket: () => Promise<PairedMobileSocket>
/** Closes every subscription and socket this client opened. */
dispose: () => void
}
export function pairMobileClient(offer: RuntimeDesktopPairingOffer): PairedMobileClient {
/** Raw frames over one authenticated phone socket, exactly as the app writes them. */
export type PairedMobileSocket = {
/** Every text frame received, parsed, in order. */
frames: { id?: string; ok?: boolean; result?: unknown; error?: { code: string } }[]
send: (id: string, method: string, params: unknown, executionHost?: string) => void
close: () => void
}
/** `capabilities` defaults to this build's phone; a mixed-version test passes a released phone's. */
export function pairMobileClient(
offer: RuntimeDesktopPairingOffer,
capabilities: readonly RuntimeCapability[] = MOBILE_RUNTIME_CLIENT_CAPABILITIES
): PairedMobileClient {
const pairing = decodePairingOffer(offer.pairingUrl)
// Why: a runtime-scope token skips the mobile allowlist, so the oracle would not be a phone.
if (pairing.scope !== 'mobile') {
throw new Error(`Expected a mobile-scoped pairing offer, got scope ${String(pairing.scope)}`)
}
const connection = pairedRuntimeConnectionFromOffer(pairing)
const open = new Set<RemoteRuntimeSubscription>()
const open = new Set<{ close: () => void }>()
const request = <T>(
method: string,
params: unknown = {},
@@ -56,7 +72,7 @@ export function pairMobileClient(offer: RuntimeDesktopPairingOffer): PairedMobil
timeoutMs,
undefined,
undefined,
MOBILE_RUNTIME_CLIENT_CAPABILITIES
capabilities
)
return {
request,
@@ -74,11 +90,39 @@ export function pairMobileClient(offer: RuntimeDesktopPairingOffer): PairedMobil
params,
timeoutMs,
callbacks,
{ clientCapabilities: MOBILE_RUNTIME_CLIENT_CAPABILITIES }
{ clientCapabilities: capabilities }
)
open.add(subscription)
return subscription
},
openSocket: async () => {
const frames: PairedMobileSocket['frames'] = []
const socket = await openRemoteRuntimePassthroughSocket(
connection,
capabilities,
{
onText: (plaintext) => frames.push(JSON.parse(plaintext)),
onBinary: () => {},
onClose: () => {}
},
{ timeoutMs: DEFAULT_TIMEOUT_MS }
)
open.add(socket)
return {
frames,
send: (id, method, params, executionHost) =>
void socket.send(
JSON.stringify({
id,
deviceToken: pairing.deviceToken,
method,
params,
...(executionHost ? { executionHost } : {})
})
),
close: () => socket.close()
}
},
dispose: () => {
for (const subscription of open) {
subscription.close()
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,180 @@
/**
* Layout rework P3 baseline for the frozen path: the current desktop against a pinned released
* headless server. The desktop splits a server pane, drags the divider, renames a pane and
* reconnects after the server restarts. Each step records the old server's `session.tabs.list`
* and the pane tree the desktop holds and draws; the rework's frozen adapter must match them.
*
* Needs ORCA_CROSS_VERSION_PACKAGED_EXECUTABLE (CI: packaged-browser-e2e.yml, job layout-compat).
*/
import type { Page } from '@stablyai/playwright-test'
import { expect, test } from './helpers/orca-app'
import { launchPairedElectronClient } from './helpers/paired-electron-client'
import {
launchPackagedServeHost,
packagedExecutable,
packagedExecutableMissing,
PACKAGED_EXECUTABLE_ENV
} from './helpers/packaged-old-orca'
import {
expectLayoutCompatBaseline,
expectReconnectedPaneEcho,
IdLabels,
paneTreeBaseline,
readSessionTabs,
readSettled,
readTerminals,
sessionTabsBaseline
} from './helpers/layout-compat-baseline'
import { setPaneTitleFromTerminalMenu } from './helpers/terminal-pane-title-actions'
import { readTerminalAccessibilityText } from './helpers/terminal-accessibility-tree'
import type { TerminalLayoutSnapshot } from '../../src/shared/terminal-tab-types'
const PANE = '.pane[data-leaf-id]:visible'
// The default split-right binding, the same in the pinned release.
const SPLIT_RIGHT = process.platform === 'darwin' ? 'Meta+d' : 'Control+Shift+D'
/** The shown tab's pane tree and titles as the desktop holds them, and the panes it draws. */
async function readDesktop(page: Page, labels: IdLabels) {
const held = await page.evaluate(() => {
const state = window.__store?.getState()
const tabId = state?.activeTabId ?? null
const layout: TerminalLayoutSnapshot | null = tabId
? (state?.terminalLayoutsByTabId[tabId] ?? null)
: null
return {
tabCount: document.querySelectorAll('[data-testid="sortable-tab"]').length,
drawnPaneTitles: [...document.querySelectorAll<HTMLElement>('.pane-title-text')]
.filter((title) => title.offsetParent !== null)
.map((title) => title.textContent?.trim() ?? ''),
root: layout?.root ?? null,
titlesByLeafId: layout?.titlesByLeafId ?? {}
}
})
const drawn = await page
.locator(PANE)
.evaluateAll((panes) => panes.map((pane) => pane.dataset.leafId ?? ''))
return {
// A count: a tab's label follows its focused pane's shell title, which differs per machine.
tabs: held.tabCount,
tree: paneTreeBaseline(held.root, labels),
paneTitles: Object.entries(held.titlesByLeafId)
.map(([leafId, title]) => `${labels.label('leaf', leafId)}=${title}`)
.sort(),
drawnPanes: drawn.map((leafId) => labels.label('leaf', leafId)),
drawnPaneTitles: held.drawnPaneTitles
}
}
/** A pointer drag of the first vertical divider, a quarter of the pane area to the right. */
async function dragDivider(page: Page): Promise<void> {
const divider = page.locator('.pane-divider.is-vertical:visible').first()
const box = (await divider.boundingBox())!
const x = box.x + box.width / 2
const y = box.y + box.height / 2
await page.mouse.move(x, y)
await page.mouse.down()
await page.mouse.move(x + 200, y, { steps: 20 })
await page.mouse.up()
}
test.describe('layout compat: current desktop on a released server', () => {
test.skip(packagedExecutableMissing, `${PACKAGED_EXECUTABLE_ENV} must point at a released Orca`)
test('new-desktop-on-old-server: split, divider drag, pane rename, reconnect', async ({
testRepoPath
}, testInfo) => {
test.setTimeout(300_000)
const host = await launchPackagedServeHost(packagedExecutable!)
try {
await host.client.call('repo.add', { path: testRepoPath, kind: 'git' })
await host.client.call('terminal.create', {
worktree: `path:${testRepoPath}`,
title: 'host-made'
})
const desktop = await launchPairedElectronClient(host.offer, testInfo, 'released server')
try {
const page = desktop.page
let worktreeId = ''
await expect
.poll(
async () =>
(worktreeId = await page.evaluate(
(repo) =>
window.__store
?.getState()
.allWorktrees()
.find((worktree) => worktree.path === repo)?.id ?? '',
testRepoPath
)),
{ timeout: 60_000 }
)
.not.toBe('')
const status = await page.evaluate(async (environmentId) => {
const response = await window.api.runtimeEnvironments.getStatus({
selector: environmentId,
timeoutMs: 30_000
})
return response.ok ? response.result : null
}, desktop.environmentId)
const labels = new IdLabels()
const steps: unknown[] = []
const record = async (step: string): Promise<void> => {
const observed = await readSettled(async () => ({
oldServer: sessionTabsBaseline(
await readSessionTabs(host.client, `path:${testRepoPath}`),
labels
),
oldServerTerminals: await readTerminals(host.client, `path:${testRepoPath}`, labels),
desktop: await readDesktop(page, labels)
}))
steps.push({ step, ...observed })
}
await page.evaluate((id) => window.__store?.getState().setActiveWorktree(id), worktreeId)
await expect(page.locator(PANE)).toHaveCount(1, { timeout: 60_000 })
await record('desktop shows the server workspace')
const firstLeaf = await page
.locator(PANE)
.first()
.evaluate((pane) => pane.dataset.leafId ?? '')
await page.locator(`${PANE} .xterm`).first().click()
await page.keyboard.press(SPLIT_RIGHT)
await expect(page.locator(PANE)).toHaveCount(2, { timeout: 30_000 })
await record('desktop splits right')
await dragDivider(page)
await record('desktop drags the divider')
await setPaneTitleFromTerminalMenu(page, 'renamed-pane')
await record('desktop renames a pane')
await host.restart()
await expectReconnectedPaneEcho({
page,
environmentId: desktop.environmentId,
leafId: firstLeaf,
host: host.client,
worktree: `path:${testRepoPath}`,
// The focused pane is the one typed into; its tab's manager reads that pane.
readDrawn: async () =>
readTerminalAccessibilityText(
page,
(await page.evaluate(() => window.__store?.getState().activeTabId)) ?? ''
)
})
await record('after the server restarts and the desktop reconnects')
await expectLayoutCompatBaseline(testInfo, 'new-desktop-on-old-server', {
runtimeProtocolVersion: status?.runtimeProtocolVersion ?? null,
steps
})
} finally {
await desktop.dispose()
}
} finally {
await host.dispose()
}
})
})
@@ -0,0 +1,185 @@
/**
* Layout rework P3 baselines: a pinned released desktop build, and its bundled CLI, against the
* current `orca serve`. The old desktop splits a server pane (it sends `terminal.create` with the
* tab's id and a new leaf id), closes it, and rides out a server restart. Each step records what
* the server publishes and what the old desktop draws; the shapes are compared with main's.
*
* Needs ORCA_CROSS_VERSION_PACKAGED_EXECUTABLE (CI: packaged-browser-e2e.yml, job layout-compat).
*/
import type { Page } from '@stablyai/playwright-test'
import { expect, test } from './helpers/orca-app'
import { launchHeadlessPairedRuntimeHost } from './helpers/headless-paired-runtime-host'
import {
launchPackagedPairedClient,
packagedExecutable,
packagedExecutableMissing,
PACKAGED_EXECUTABLE_ENV,
runPackagedOrcaCli
} from './helpers/packaged-old-orca'
import {
expectLayoutCompatBaseline,
expectReconnectedPaneEcho,
IdLabels,
publishedLayoutBaseline,
readPublishedLayout,
readSessionTabs,
readSettled,
readTerminals,
sessionTabsBaseline
} from './helpers/layout-compat-baseline'
import type { RuntimeTerminalCreate } from '../../src/shared/runtime-terminal-contracts'
// Panes of the shown tab; other tabs keep theirs mounted but hidden.
const PANE = '.pane[data-leaf-id]:visible'
const TAB = '[data-testid="sortable-tab"]'
const MOD = process.platform === 'darwin' ? 'Meta' : 'Control'
// The released build's default split-right binding (definitions-core-4.ts at the pinned tag).
const SPLIT_RIGHT = process.platform === 'darwin' ? 'Meta+d' : 'Control+Shift+D'
function drawnLeaves(page: Page): Promise<string[]> {
return page.locator(PANE).evaluateAll((panes) => panes.map((pane) => pane.dataset.leafId ?? ''))
}
async function focusPane(page: Page, leafId: string): Promise<void> {
await page.locator(`${PANE}[data-leaf-id="${leafId}"] .xterm`).click()
}
test.describe('layout compat: released desktop and CLI on the current server', () => {
test.skip(packagedExecutableMissing, `${PACKAGED_EXECUTABLE_ENV} must point at a released Orca`)
test('old-desktop-on-new-server: split, close pane, server restart', async ({
testRepoPath
}, testInfo) => {
test.setTimeout(300_000)
const host = await launchHeadlessPairedRuntimeHost({ pinnedServePort: true })
try {
await host.client.call('repo.add', { path: testRepoPath, kind: 'git' })
const created = await host.client.call<{ terminal: RuntimeTerminalCreate }>(
'terminal.create',
{
worktree: `path:${testRepoPath}`,
title: 'host-made'
}
)
const worktreeId = created.result.terminal.worktreeId
const worktree = `id:${worktreeId}`
const client = await launchPackagedPairedClient({
executablePath: packagedExecutable!,
offer: host.offer,
testInfo,
name: 'released desktop'
})
try {
const labels = new IdLabels()
const steps: unknown[] = []
const record = async (step: string): Promise<void> => {
const observed = await readSettled(async () => ({
server: sessionTabsBaseline(await readSessionTabs(host.client, worktree), labels),
serverTerminals: await readTerminals(host.client, worktree, labels),
published: publishedLayoutBaseline(
await readPublishedLayout(host.offer.pairingUrl, worktreeId),
labels
),
// A count: a tab's label follows its focused pane's shell title, which differs per machine.
oldDesktopTabs: await client.page.locator(TAB).count(),
oldDesktopPanes: (await drawnLeaves(client.page)).map((id) => labels.label('leaf', id))
}))
steps.push({ step, ...observed })
}
await client.page
.locator(`[data-worktree-id="${worktreeId}"] [data-worktree-card-surface]`)
.click()
await expect(client.page.locator(PANE)).toHaveCount(1, { timeout: 60_000 })
// The released desktop opens a tab of its own in the server workspace; it draws that tab.
await record('old desktop opens the server workspace')
const [firstLeaf] = await drawnLeaves(client.page)
await focusPane(client.page, firstLeaf!)
await client.page.keyboard.press(SPLIT_RIGHT)
await expect(client.page.locator(PANE)).toHaveCount(2, { timeout: 30_000 })
await record('old desktop splits right')
const secondLeaf = (await drawnLeaves(client.page)).find((leaf) => leaf !== firstLeaf)!
await focusPane(client.page, secondLeaf)
await client.page.keyboard.press(`${MOD}+w`)
const confirm = client.page.getByRole('button', { name: 'Stop and Close' })
await expect
.poll(async () => {
if (await confirm.isVisible().catch(() => false)) {
await confirm.click()
}
return client.page.locator(PANE).count()
})
.toBe(1)
await record('old desktop closes the new pane')
await host.restartServeProcess()
await expectReconnectedPaneEcho({
page: client.page,
environmentId: client.environmentId,
leafId: firstLeaf!,
host: host.client,
worktree,
// The packaged client draws with the DOM renderer (packaged-old-orca.ts), so rows are text.
readDrawn: () =>
client.page.locator(`${PANE}[data-leaf-id="${firstLeaf}"] .xterm-rows`).textContent()
})
await expect(client.page.locator(PANE)).toHaveCount(1)
await record('after server restart')
await expectLayoutCompatBaseline(testInfo, 'old-desktop-on-new-server', {
oldDesktopVersion: client.version,
steps
})
} finally {
await client.dispose()
}
} finally {
await host.dispose()
}
})
test('old-cli-terminal-create: the released CLI creates a terminal on the current server', async ({
testRepoPath
}, testInfo) => {
test.setTimeout(180_000)
const host = await launchHeadlessPairedRuntimeHost()
try {
await host.client.call('repo.add', { path: testRepoPath, kind: 'git' })
const run = await runPackagedOrcaCli(packagedExecutable!, host.userDataDir, [
'terminal',
'create',
'--worktree',
`path:${testRepoPath}`,
'--title',
'old-cli',
'--json'
])
const result = run.json?.result
const terminal: object =
result &&
typeof result === 'object' &&
'terminal' in result &&
result.terminal &&
typeof result.terminal === 'object'
? result.terminal
: {}
const labels = new IdLabels()
const listed = await readSessionTabs(host.client, `path:${testRepoPath}`)
await expectLayoutCompatBaseline(testInfo, 'old-cli-terminal-create', {
exitCode: run.code,
ok: run.json?.ok ?? null,
error: run.json?.error?.code ?? null,
// Which fields the old CLI got back, and whether it can name the PTY and process.
resultFields: Object.keys(terminal).sort(),
ptyId: labels.pty(
'ptyId' in terminal && typeof terminal.ptyId === 'string' ? terminal.ptyId : null
),
server: sessionTabsBaseline(listed, labels)
})
} finally {
await host.dispose()
}
})
})
@@ -0,0 +1,232 @@
/**
* Layout rework P3 baseline: an old phone paired straight to the current `orca serve`. Not the
* released app: a capability-list client that advertises mobile-android-v0.0.52's capabilities
* and sends that release's request shapes. It creates, activates, renames, focuses and closes
* terminals (one of them pinned on the host), opens a file, and reads its own selection again
* after the server restarts. Each step records the phone's and
* the server's tab lists; the shapes are compared with main's.
*/
import { expect, test } from './helpers/orca-app'
import { launchHeadlessPairedRuntimeHost } from './helpers/headless-paired-runtime-host'
import { pairMobileClient, type PairedMobileClient } from './helpers/paired-mobile-client'
import {
expectLayoutCompatBaseline,
IdLabels,
readSessionTabs,
readSettled,
readUntil,
sessionTabsBaseline
} from './helpers/layout-compat-baseline'
import type { RuntimeCapability } from '../../src/shared/protocol-version'
import type { RuntimeMobileSessionTabsResult } from '../../src/shared/runtime-session-contracts'
import type { RuntimeTerminalListResult } from '../../src/shared/runtime-terminal-contracts'
import type { RuntimeWorktreePsSummary } from '../../src/shared/runtime-worktree-contracts'
/** What mobile-android-v0.0.52 advertises (its mobile-runtime-client-capabilities.ts). */
const OLD_PHONE_CAPABILITIES: readonly RuntimeCapability[] = [
'agent-session.background-task-stop.v1',
'agent-session.background-task-row-stop.v1',
'agent-session.turn-item.v1',
'session-tabs.close-intent.v1',
'session-tabs.authoritative-inventory.v1',
'agent-session.session-boundary.v1',
'skills.install-result.v2',
'worktree.github-pr-suppression.v1',
'worktree.visibility-defaults.v1',
'worktree.visibility-source-defaults.v1',
'automation.owner-fencing.v1',
'agent-session.structured.v1',
'agent-session.pending-send-result.v1',
'agent-session.structured.hold.v1',
'agent-session.structured.claude.v1',
'session-tabs.split-group-placement.v1',
'agent.launch.v2'
]
// Set until the restarted host hears from its paired hosts, or a deadline passes: timing, not shape.
const TRANSIENT_FIELDS = new Set(['clientHostedPagesUnreconciled'])
function fieldsOf(value: unknown): string[] {
return value && typeof value === 'object'
? Object.keys(value)
.filter((key) => !TRANSIENT_FIELDS.has(key))
.sort()
: []
}
/** A reply as the baseline keeps it: success and the result's fields, or the refusal code. */
async function reply(phone: PairedMobileClient, method: string, params: unknown) {
const response = await phone.request<unknown>(method, params)
if (!response.ok) {
// runtime_error is generic; its message names the actual refusal.
return response.error.code === 'runtime_error'
? { ok: false, error: response.error.code, message: response.error.message }
: { ok: false, error: response.error.code }
}
return { ok: true, fields: fieldsOf(response.result) }
}
async function phoneTerminals(phone: PairedMobileClient, worktree: string) {
return (await phone.call<RuntimeTerminalListResult>('terminal.list', { worktree })).terminals
}
/** The first snapshot of the phone's own tab stream: what the session strip is built from. */
async function firstSnapshot(
phone: PairedMobileClient,
worktree: string
): Promise<RuntimeMobileSessionTabsResult> {
let snapshot: RuntimeMobileSessionTabsResult | null = null
const subscription = await phone.subscribe<{ type: string } & RuntimeMobileSessionTabsResult>(
'session.tabs.subscribe',
{ worktree },
{
onResponse: (response) => {
if (response.ok && response.result.type === 'snapshot' && !snapshot) {
snapshot = response.result
}
},
onError: () => {}
},
30_000
)
try {
await expect.poll(() => snapshot !== null, { timeout: 30_000 }).toBe(true)
return snapshot!
} finally {
subscription.close()
}
}
test('old-phone-on-new-server: create, activate, rename, focus, close, open a file, reconnect', async ({
testRepoPath
}, testInfo) => {
test.setTimeout(240_000)
const host = await launchHeadlessPairedRuntimeHost({
pairingScope: 'mobile',
pinnedServePort: true
})
const phone = pairMobileClient(host.offer, OLD_PHONE_CAPABILITIES)
try {
await host.client.call('repo.add', { path: testRepoPath, kind: 'git' })
const { worktrees } = await phone.call<{ worktrees: RuntimeWorktreePsSummary[] }>(
'worktree.ps',
{ limit: 1_000 }
)
const worktreeId = worktrees.find((row) => row.path === testRepoPath)?.worktreeId ?? ''
expect(worktreeId, 'the phone lists the server workspace').not.toBe('')
const worktree = `id:${worktreeId}`
const labels = new IdLabels()
const steps: unknown[] = []
const record = async (step: string, sent?: unknown): Promise<void> => {
const observed = await readSettled(async () => ({
phone: sessionTabsBaseline(
await phone.call<RuntimeMobileSessionTabsResult>('session.tabs.list', { worktree }),
labels
),
server: sessionTabsBaseline(await readSessionTabs(host.client, worktree), labels)
}))
steps.push({ step, ...(sent ? { reply: sent } : {}), ...observed })
}
const create = (n: number) =>
phone.call<{ tab: { id: string; parentTabId: string } }>('session.tabs.createTerminal', {
worktree,
clientMutationId: `old-phone-create-${n}`,
activate: false,
select: true,
navigation: 'caller'
})
const first = (await create(1)).tab
const second = (await create(2)).tab
// Name tabs in creation order, so the recorded shape reads tab1, tab2.
labels.label('tab', first.parentTabId)
labels.label('tab', second.parentTabId)
await record('phone creates two terminals')
const activated = await reply(phone, 'session.tabs.activate', {
worktree,
tabId: first.id,
notifyClients: false,
navigation: 'caller',
intent: 'user'
})
await record('phone activates the first', activated)
const handleOf = async (tabId: string) =>
(await phoneTerminals(phone, worktree)).find((terminal) => terminal.tabId === tabId)
?.handle ?? ''
const renamed = await reply(phone, 'terminal.rename', {
terminal: await handleOf(first.parentTabId),
title: 'phone-renamed'
})
await record('phone renames the first', renamed)
const focused = await reply(phone, 'terminal.focus', {
terminal: await handleOf(second.parentTabId),
navigation: 'host'
})
await record('phone focuses the second', focused)
// A desktop pins the second tab; the old phone has no pin control and closes it anyway.
await host.client.call('session.tabs.setTabProps', {
worktree,
tabId: second.parentTabId,
isPinned: true
})
await record('host pins the second')
const closedPinned = await reply(phone, 'session.tabs.close', {
worktree,
tabId: second.id,
reason: 'user'
})
await record('phone closes the pinned tab', closedPinned)
// Today a headless server refuses: runtime_error "renderer_unavailable" (no window to open in).
const opened = await reply(phone, 'files.open', { worktree, relativePath: 'README.md' })
await record('phone opens a file', opened)
const selectedBefore = sessionTabsBaseline(await firstSnapshot(phone, worktree), labels)
const tabsOf = (terminals: { tabId: string }[]) =>
JSON.stringify(terminals.map((terminal) => terminal.tabId).sort())
const tabsBefore = tabsOf(await phoneTerminals(phone, worktree))
await host.restartServeProcess()
const selectedAfter = sessionTabsBaseline(await firstSnapshot(phone, worktree), labels)
// Linux: the server lists every terminal again shortly after it answers. macOS (no baseline):
// in this harness the list stayed empty for the 60 s measured, with the tabs still listed.
const listedAfter = await readUntil(
() => phoneTerminals(phone, worktree),
(terminals) => tabsOf(terminals) === tabsBefore
)
steps.push({
step: 'phone reconnects after a server restart',
snapshotBefore: selectedBefore,
snapshotAfter: selectedAfter,
terminalsAfter: listedAfter
.map((terminal) => ({
tab: labels.label('tab', terminal.tabId),
pty: labels.pty(terminal.ptyId)
}))
.sort((a, b) => String(a.tab).localeCompare(String(b.tab)))
})
const snapshot = await firstSnapshot(phone, worktree)
const firstHandle = await handleOf(first.parentTabId)
const closedLast = firstHandle
? await reply(phone, 'terminal.close', { terminal: firstHandle })
: { ok: false, error: 'no terminal.list row for the tab' }
await record('phone closes the first terminal', closedLast)
await expectLayoutCompatBaseline(testInfo, 'old-phone-on-new-server', {
steps,
// The payload the old phone parses: its snapshot and terminal-row fields.
snapshotFields: fieldsOf(snapshot),
terminalRowFields: [
...new Set(snapshot.tabs.flatMap((tab) => (tab.type === 'terminal' ? fieldsOf(tab) : [])))
].sort()
})
} finally {
phone.dispose()
await host.dispose()
}
})
@@ -0,0 +1,159 @@
/**
* Layout rework P3 baseline, regression #23429 (fixed by #26022): terminals whose PTY ids carry
* `@@` (daemon-minted: the paired desktop's own tab and a CLI `terminal.create`) stay listed after
* `orca serve` restarts, beside a serve-minted one. Records the server's tab list, live terminals
* and published layout before and after the restart, and what the desktop draws once reconnected.
*/
import { expect, test } from './helpers/orca-app'
import { launchHeadlessPairedRuntimeHost } from './helpers/headless-paired-runtime-host'
import { launchPairedElectronClient } from './helpers/paired-electron-client'
import {
expectLayoutCompatBaseline,
IdLabels,
publishedLayoutBaseline,
readPublishedLayout,
readSessionTabs,
readSettled,
readTerminals,
sessionTabsBaseline
} from './helpers/layout-compat-baseline'
import type { RuntimeTerminalCreate } from '../../src/shared/runtime-terminal-contracts'
const PANE = '.pane[data-leaf-id]:visible'
const TAB = '[data-testid="sortable-tab"]'
// tab1 is the desktop's, tab2 the CLI's, tab3 serve's; the published orders seen after a restart.
const RESTART_ORDERS = [
['tab1', 'tab2', 'tab3'],
['tab1', 'tab3', 'tab2']
]
/** An untitled row is named by its list position ("Terminal 2"); kept, but position-free. */
function positionalTitle(title: string, index: number): string {
if (!/^Terminal \d+$/.test(title)) {
return title
}
expect(title).toBe(`Terminal ${index + 1}`)
return 'Terminal <position>'
}
test('serve-restart-keeps-@@-terminals: desktop, CLI and serve terminals survive a serve restart', async ({
testRepoPath
}, testInfo) => {
test.setTimeout(240_000)
const host = await launchHeadlessPairedRuntimeHost({ pinnedServePort: true })
try {
await host.client.call('repo.add', { path: testRepoPath, kind: 'git' })
const desktop = await launchPairedElectronClient(host.offer, testInfo, 'serve restart')
try {
const page = desktop.page
let worktreeId = ''
await expect
.poll(
async () =>
(worktreeId = await page.evaluate(
(repo) =>
window.__store
?.getState()
.allWorktrees()
.find((worktree) => worktree.path === repo)?.id ?? '',
testRepoPath
)),
{ timeout: 60_000 }
)
.not.toBe('')
const worktree = `id:${worktreeId}`
// Opening an empty server workspace makes the desktop create its first terminal there.
await page.evaluate((id) => window.__store?.getState().setActiveWorktree(id), worktreeId)
await expect(page.locator(PANE)).toHaveCount(1, { timeout: 60_000 })
const cli = await host.client.call<{ terminal: RuntimeTerminalCreate }>('terminal.create', {
worktree,
title: 'cli-created'
})
// The CLI's tab is placed after its reply; wait for it so the tab order is fixed.
await expect
.poll(async () => (await readSessionTabs(host.client, worktree)).tabs.length)
.toBe(2)
const served = await host.client.call<{ tab: { parentTabId: string } }>(
'session.tabs.createTerminal',
{ worktree, activate: false, select: false, navigation: 'caller' }
)
const labels = new IdLabels()
const read = async () => ({
server: sessionTabsBaseline(await readSessionTabs(host.client, worktree), labels),
terminals: await readTerminals(host.client, worktree, labels),
published: publishedLayoutBaseline(
await readPublishedLayout(host.offer.pairingUrl, worktreeId),
labels
),
desktopTabs: await page.locator(TAB).count(),
desktopPanes: await page.locator(PANE).count()
})
await expect
.poll(async () => (await readSessionTabs(host.client, worktree)).tabs.length)
.toBe(3)
const before = await readSettled(read)
await host.restartServeProcess()
await expect(page.locator(TAB)).not.toHaveCount(0, { timeout: 60_000 })
// The restarted server lists its terminals a moment after it answers; this scenario is that they come back.
await expect
.poll(async () => (await readTerminals(host.client, worktree, labels)).length, {
timeout: 60_000
})
.toBe(before.terminals.length)
const settled = await readSettled(read)
// Bug on main: the order changes on restart. Repeated macOS runs saw session.tabs.list in
// four of the six orders, and the published layout with the CLI's tab (tab2) second or
// last. So the list must hold the same tabs in any order, the published layout one of those
// two orders; both are recorded in creation order. Any other change still fails.
expect(settled.server.groups).toHaveLength(1)
const canonicalSelection = before.server.groups[0]!.active
expect(settled.server.groups[0]!.tabs.toSorted()).toEqual(RESTART_ORDERS[0])
// The list's selected tab follows that order: it is whichever tab comes first. The group's
// selection is the desktop's tab or, like the list's, the first listed.
const firstListed = settled.server.groups[0]!.tabs[0]
expect(settled.server.activeTab).toBe(firstListed)
expect([canonicalSelection, firstListed]).toContain(settled.server.groups[0]!.active)
const published = settled.published?.groups
expect(published).toHaveLength(1)
expect(
RESTART_ORDERS.some((order) => JSON.stringify(order) === JSON.stringify(published?.[0])),
`published order after the restart: ${JSON.stringify(published)}`
).toBe(true)
expect(settled.published?.tabs.map((tab) => tab.tab)).toEqual(published?.[0])
const canonical = RESTART_ORDERS[0]!
const byCanonical = (a: { tab: string | null }, b: { tab: string | null }) =>
canonical.indexOf(a.tab ?? '') - canonical.indexOf(b.tab ?? '')
const after = {
...settled,
server: {
...settled.server,
activeTab: '<first listed>',
groups: [{ active: '<desktop tab or first listed>', tabs: canonical }],
rows: settled.server.rows
.map((row, index) => ({ ...row, title: positionalTitle(row.title, index) }))
.toSorted(byCanonical)
},
published: settled.published && {
...settled.published,
groups: [canonical],
tabs: settled.published.tabs.toSorted(byCanonical)
}
}
await expectLayoutCompatBaseline(testInfo, 'serve-restart-keeps-@@-terminals', {
created: {
cli: labels.pty(cli.result.terminal.ptyId),
serve: labels.label('tab', served.result.tab.parentTabId)
},
before,
after
})
} finally {
await desktop.dispose()
}
} finally {
await host.dispose()
}
})
@@ -1,30 +1,22 @@
import { createServer, type Server } from 'node:http'
import type { AddressInfo } from 'node:net'
import { existsSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import os from 'node:os'
import path from 'node:path'
import {
_electron as electron,
type ElectronApplication,
type Page,
type TestInfo
} from '@stablyai/playwright-test'
import type { ElectronApplication, Page } from '@stablyai/playwright-test'
import { expect, forwardElectronProcessLogs, test } from './helpers/orca-app'
import { getE2ECompletedOnboardingProfile } from './helpers/e2e-completed-onboarding-profile'
import { cleanupE2EDaemons, closeElectronAppForE2E } from './helpers/electron-process-shutdown'
import {
assertElectronResolvedIsolatedHome,
createElectronHomeIsolation
} from './helpers/electron-home-isolation'
import { expect, test } from './helpers/orca-app'
import { launchHeadlessPairedRuntimeHost } from './helpers/headless-paired-runtime-host'
import {
launchPairedElectronClient,
type PairedElectronClient,
type RuntimeDesktopPairingOffer
type PairedElectronClient
} from './helpers/paired-electron-client'
import {
collectCleanupFailures,
launchPackagedPairedClient,
packagedExecutable,
packagedExecutableMissing,
PACKAGED_EXECUTABLE_ENV,
type PackagedPairedClient
} from './helpers/packaged-old-orca'
const PACKAGED_EXECUTABLE_ENV = 'ORCA_CROSS_VERSION_PACKAGED_EXECUTABLE'
const CLIENT_HOST_CAPABILITY = 'browser.clientHost.v1'
const TUNNEL_CAPABILITY = 'network.browserTunnel.v1'
@@ -33,33 +25,12 @@ type BrowserFixture = {
url: string
}
type PackagedPairedClient = {
app: ElectronApplication
environmentId: string
page: Page
status: { capabilities: string[] }
version: string
dispose(): Promise<void>
}
type BrowserCreateResult = {
browserPageId: string
}
type PackagedPlacementCleanup = () => Promise<void> | void
async function collectCleanupFailures(cleanups: PackagedPlacementCleanup[]): Promise<unknown[]> {
const failures: unknown[] = []
for (const cleanup of cleanups) {
try {
await cleanup()
} catch (error) {
failures.push(error)
}
}
return failures
}
async function withPackagedPlacementCleanup(
run: (registerCleanup: (cleanup: PackagedPlacementCleanup) => void) => Promise<void>
): Promise<void> {
@@ -123,108 +94,6 @@ async function readOwnedPageUrls(app: ElectronApplication, url: string): Promise
)
}
async function removeProfile(userDataDir: string): Promise<void> {
for (let attempt = 0; attempt < 5; attempt += 1) {
try {
rmSync(userDataDir, { recursive: true, force: true })
return
} catch (error) {
if (attempt === 4) {
throw error
}
await new Promise((resolve) => setTimeout(resolve, 250 * (attempt + 1)))
}
}
}
async function launchPackagedPairedClient(args: {
executablePath: string
offer: RuntimeDesktopPairingOffer
testInfo: TestInfo
}): Promise<PackagedPairedClient> {
const userDataDir = mkdtempSync(path.join(os.tmpdir(), 'orca-e2e-packaged-client-'))
let app: ElectronApplication | undefined
try {
writeFileSync(
path.join(userDataDir, 'orca-data.json'),
`${JSON.stringify(getE2ECompletedOnboardingProfile(), null, 2)}\n`
)
const { ELECTRON_RUN_AS_NODE: _unused, ...cleanEnv } = process.env
void _unused
const homeIsolation = createElectronHomeIsolation({
inheritedEnv: cleanEnv,
launchEnv: {},
extraEnv: {},
userDataDir
})
app = await electron.launch({
executablePath: args.executablePath,
args: [],
env: {
...homeIsolation.env,
NODE_ENV: 'production',
ORCA_BYPASS_SINGLE_INSTANCE_LOCK: '1',
ORCA_E2E_HEADLESS: '1'
}
})
forwardElectronProcessLogs(app, args.testInfo)
assertElectronResolvedIsolatedHome(
await app.evaluate(({ app: electronApp }) => electronApp.getPath('home')),
homeIsolation
)
const page = await app.firstWindow({ timeout: 120_000 })
await page.waitForLoadState('domcontentloaded')
const [status, version, environmentId] = await Promise.all([
page.evaluate(() => window.api.runtime.getStatus()),
app.evaluate(({ app: electronApp }) => electronApp.getVersion()),
page.evaluate(async ({ pairingUrl }) => {
const result = await window.api.runtimeEnvironments.addFromPairingCode({
name: 'STA-4150 packaged old client',
pairingCode: pairingUrl
})
const response = await window.api.runtimeEnvironments.getStatus({
selector: result.environment.id,
timeoutMs: 30_000
})
if (!response.ok) {
throw new Error(`${response.error.code}: ${response.error.message}`)
}
return result.environment.id
}, args.offer)
])
return {
app,
environmentId,
page,
status,
version,
dispose: async () => {
const failures = await collectCleanupFailures([
() => closeElectronAppForE2E(app!),
() => cleanupE2EDaemons(userDataDir),
() => removeProfile(userDataDir)
])
if (failures.length > 0) {
throw new AggregateError(failures, 'Failed to clean up packaged paired client')
}
}
}
} catch (error) {
const cleanupErrors = await collectCleanupFailures([
...(app ? [() => closeElectronAppForE2E(app)] : []),
() => cleanupE2EDaemons(userDataDir),
() => removeProfile(userDataDir)
])
if (cleanupErrors.length > 0) {
throw new AggregateError(
[error, ...cleanupErrors],
'Packaged client startup and cleanup failed'
)
}
throw error
}
}
async function createBrowserThroughPackagedClient(args: {
client: PackagedPairedClient
url: string
@@ -383,11 +252,9 @@ async function createBrowserThroughCurrentClient(args: {
return mirrored
}
const packagedExecutable = process.env[PACKAGED_EXECUTABLE_ENV]
test.describe('packaged mixed-version browser placement', () => {
test.skip(
!packagedExecutable || !existsSync(packagedExecutable),
packagedExecutableMissing,
`${PACKAGED_EXECUTABLE_ENV} must point at an older packaged Orca executable`
)
@@ -404,7 +271,8 @@ test.describe('packaged mixed-version browser placement', () => {
const client = await launchPackagedPairedClient({
executablePath: packagedExecutable!,
offer: host.offer,
testInfo
testInfo,
name: 'STA-4150 packaged old client'
})
registerCleanup(() => client.dispose())
expect(client.status.capabilities).not.toContain(CLIENT_HOST_CAPABILITY)
@@ -0,0 +1,260 @@
/**
* A phone paired with a desktop sees a server workspace's tab strip as the desktop arranged it:
* the desktop writes every host-backed move through to the server, and the phone's relayed
* `session.tabs.*` reads the server. With the desktop window closed, nothing changes for the phone.
* Each arrangement is also checked against the server's own layout: its `session.tabs.list` and
* its published `layout.subscribe` groups (layout rework P3).
*/
import { mkdirSync, writeFileSync } from 'node:fs'
import type { Page } from '@stablyai/playwright-test'
import { expect, test } from './helpers/orca-app'
import { launchPhoneMirrorTopology } from './helpers/phone-mirror-topology'
import type { PairedMobileSocket } from './helpers/paired-mobile-client'
import { readPublishedLayout, readSessionTabs } from './helpers/layout-compat-baseline'
import { toHostSessionTabId, toWebTerminalSurfaceTabId } from '../../src/shared/terminal-surface-id'
import { z } from 'zod'
type Frame = PairedMobileSocket['frames'][number]
const SORTABLE_TAB = '[data-testid="sortable-tab"]'
const HostWorktreesSchema = z.looseObject({
worktrees: z.array(z.looseObject({ worktreeId: z.string(), path: z.string() })).nullable()
})
const CreatedTerminalSchema = z.looseObject({
tab: z.looseObject({ parentTabId: z.string() })
})
const SessionTabsSchema = z.looseObject({
activeTabId: z.string().nullable(),
tabGroups: z.array(z.looseObject({ id: z.string(), tabOrder: z.array(z.string()) })).optional(),
tabs: z.array(
z.looseObject({ id: z.string(), type: z.string(), parentTabId: z.string().optional() })
)
})
let requestId = 0
async function call<T>(
socket: PairedMobileSocket,
method: string,
params: unknown,
schema: z.ZodType<T>,
executionHost?: string
): Promise<T> {
requestId += 1
const id = `${method}-${requestId}`
socket.send(id, method, params, executionHost)
let found: Frame | undefined
await expect
.poll(() => (found = socket.frames.find((frame) => frame.id === id)), { timeout: 30_000 })
.toBeDefined()
expect(found!.ok, `${method}: ${JSON.stringify(found!.error)}`).toBe(true)
return schema.parse(found!.result)
}
/** Each tab group's tab order. Group ids are each host's own, so only order and membership compare. */
type Strip = string[][]
/** The desktop's tab groups for a workspace, each tab named by the server tab it mirrors. */
async function desktopStrip(page: Page, worktreeId: string): Promise<Strip> {
const groups = await page.evaluate(
(id) => (window.__store?.getState().groupsByWorktree[id] ?? []).map((group) => group.tabOrder),
worktreeId
)
return groups.map((tabOrder) => tabOrder.map(toHostSessionTabId))
}
function desktopActiveTab(page: Page, worktreeId: string): Promise<string | null> {
return page.evaluate((id) => {
const state = window.__store?.getState()
const activeGroupId = state?.activeGroupIdByWorktree[id]
return (
state?.groupsByWorktree[id]?.find((group) => group.id === activeGroupId)?.activeTabId ?? null
)
}, worktreeId)
}
function desktopTab(page: Page, hostTabId: string) {
return page.locator(`${SORTABLE_TAB}[data-tab-id="${toWebTerminalSurfaceTabId(hostTabId)}"]`)
}
/** Drops one desktop tab onto the leading edge of another, as a user drags in the tab strip. */
async function dragTab(page: Page, hostTabId: string, ontoHostTabId: string): Promise<void> {
const from = (await desktopTab(page, hostTabId).boundingBox())!
const onto = (await desktopTab(page, ontoHostTabId).boundingBox())!
await page.mouse.move(from.x + from.width / 2, from.y + from.height / 2)
await page.mouse.down()
await page.mouse.move(onto.x + onto.width * 0.25, onto.y + onto.height / 2, { steps: 8 })
await page.mouse.up()
}
test('phone shows the desktop tab strip of a server workspace, also with the window closed', async (// oxlint-disable-next-line no-empty-pattern -- this test owns its topology launch.
{}, testInfo) => {
test.setTimeout(240_000)
const serverFolder = testInfo.outputPath('server-folder')
mkdirSync(serverFolder, { recursive: true })
writeFileSync(`${serverFolder}/README.md`, 'server workspace\n')
const { host, desktop, phone, dispose } = await launchPhoneMirrorTopology(
{ phoneTo: 'desktop' },
testInfo
)
try {
await host.client.call('repo.add', { path: serverFolder, kind: 'folder' })
let desktopWorktree = { id: '', hostId: '' }
await expect
.poll(
async () =>
(desktopWorktree = await desktop.page.evaluate((folder) => {
const row = window.__store
?.getState()
.allWorktrees()
.find((worktree) => worktree.path === folder)
return { id: row?.id ?? '', hostId: row?.hostId ?? '' }
}, serverFolder)).hostId,
{ timeout: 30_000 }
)
.toMatch(/^runtime:/)
const serverHostId = desktopWorktree.hostId
const socket = await phone.openSocket()
let serverWorktreeId = ''
await expect
.poll(async () => {
const listed = await call(
socket,
'mobileRelay.hosts.worktrees',
{ hostId: serverHostId },
HostWorktreesSchema
)
serverWorktreeId =
(listed.worktrees ?? []).find((row) => row.path === serverFolder)?.worktreeId ?? ''
return serverWorktreeId
})
.not.toBe('')
const worktree = `id:${serverWorktreeId}`
const phoneTabs = () =>
call(socket, 'session.tabs.list', { worktree }, SessionTabsSchema, serverHostId)
const created: string[] = []
for (let i = 0; i < 3; i += 1) {
const { tab } = await call(
socket,
'session.tabs.createTerminal',
{ worktree, activate: false, select: false, navigation: 'caller' },
CreatedTerminalSchema,
serverHostId
)
created.push(tab.parentTabId)
}
await desktop.page.evaluate(
(id) => window.__store?.getState().setActiveWorktree(id),
desktopWorktree.id
)
await expect.poll(() => desktop.page.locator(SORTABLE_TAB).count(), { timeout: 30_000 }).toBe(3)
// A server-placed browser page sits in the same strip.
await call(
socket,
'browser.tabCreate',
{ worktree, url: 'about:blank', activate: false },
z.looseObject({}),
serverHostId
)
const phoneStrip = async (): Promise<Strip> =>
((await phoneTabs()).tabGroups ?? []).map((group) => group.tabOrder)
await expect
.poll(async () => (await desktopStrip(desktop.page, desktopWorktree.id))[0]?.length, {
timeout: 30_000
})
.toBe(4)
const [first, second, third] = created
// The server's own strip, read on the server: its tab list and its published layout.
const serverStrip = async (): Promise<Strip> =>
((await readSessionTabs(host.client, worktree)).tabGroups ?? []).map(
(group) => group.tabOrder
)
const publishedStrip = async (): Promise<Strip> =>
(await readPublishedLayout(host.offer.pairingUrl, serverWorktreeId))?.groups.map(
(group) => group.tabIds
) ?? []
// Reorder on the desktop by dragging the third terminal onto the first.
await dragTab(desktop.page, third, first)
await expect
.poll(async () => (await desktopStrip(desktop.page, desktopWorktree.id))[0]?.slice(0, 3))
.toEqual([third, first, second])
// Group on the desktop: move the second terminal into a split of its own.
await desktopTab(desktop.page, second).click({ button: 'right' })
await desktop.page.getByRole('menuitem', { name: 'Move Tab to Split' }).hover()
await desktop.page.getByRole('menuitem', { name: 'Right', exact: true }).click()
await expect
.poll(async () => (await desktopStrip(desktop.page, desktopWorktree.id)).length)
.toBe(2)
const splitOff = await desktopStrip(desktop.page, desktopWorktree.id)
await expect.poll(phoneStrip, { timeout: 30_000 }).toEqual(splitOff)
expect(await serverStrip()).toEqual(splitOff)
// Drag the first terminal into the group the desktop just split off.
await dragTab(desktop.page, first, second)
await expect
.poll(async () =>
(await desktopStrip(desktop.page, desktopWorktree.id)).find((group) =>
group.includes(second)
)
)
.toContain(first)
const arranged = await desktopStrip(desktop.page, desktopWorktree.id)
await expect.poll(phoneStrip, { timeout: 30_000 }).toEqual(arranged)
expect(await serverStrip()).toEqual(arranged)
// BASELINE (main): the headless server's published layout leaves out its own browser page;
// the rework publishes it (one layout), so this then compares with `arranged` as is.
const browserTabIds = new Set(
(await phoneTabs()).tabs.filter((tab) => tab.type === 'browser').map((tab) => tab.id)
)
expect(browserTabIds.size).toBe(1)
expect(await publishedStrip()).toEqual(
arranged.map((group) => group.filter((id) => !browserTabIds.has(id)))
)
// Only the server holds this workspace's tabs; the desktop's own answer has none of them.
const untargeted = await call(socket, 'session.tabs.list', { worktree }, SessionTabsSchema)
expect(untargeted.tabs).toEqual([])
// Selection is each device's own, as for a local workspace.
await call(
socket,
'session.tabs.activate',
{ worktree, tabId: first, navigation: 'caller' },
z.looseObject({}),
serverHostId
)
await desktopTab(desktop.page, third).click()
await expect
.poll(() => desktopActiveTab(desktop.page, desktopWorktree.id))
.toBe(toWebTerminalSurfaceTabId(third))
const phoneActive = async () => {
const { activeTabId, tabs } = await phoneTabs()
return tabs.find((candidate) => candidate.id === activeTabId)?.parentTabId
}
expect(await phoneActive()).toBe(first)
// With the desktop window closed, the phone still reads the server's strip. Only macOS keeps
// the app running without a window (window-all-closed-quit-policy.ts).
if (process.platform !== 'darwin') {
return
}
await desktop.app.evaluate(({ BrowserWindow }) => {
for (const window of BrowserWindow.getAllWindows()) {
window.close()
}
})
await expect
.poll(() => desktop.app.evaluate(({ BrowserWindow }) => BrowserWindow.getAllWindows().length))
.toBe(0)
expect(await phoneStrip()).toEqual(arranged)
expect(await serverStrip()).toEqual(arranged)
expect(await phoneActive()).toBe(first)
} finally {
await dispose()
}
})
+78
View File
@@ -9,6 +9,9 @@
* `ORCA_LAYOUT_ORACLE_REPEAT=<n>` repeats every scenario n times (catch-rate measurement).
*/
import { mkdtempSync, rmSync } from 'node:fs'
import os from 'node:os'
import path from 'node:path'
import type { Page } from '@stablyai/playwright-test'
import { test, expect } from './helpers/orca-app'
import {
@@ -33,6 +36,7 @@ import {
wakeByClick
} from './helpers/setup-split-and-sleep-journeys'
import { runOracleScenario, type OracleRun } from './helpers/workspace-layout-oracle-session'
import { RuntimeClient } from '../../src/cli/runtime/client'
import { unexpectedFindings } from './workspace-layout-oracle-known-on-main'
import type { RuntimeTerminalListResult } from '../../src/shared/runtime-types'
import type { RuntimeMobileSessionTabsResult } from '../../src/shared/runtime-session-contracts'
@@ -71,6 +75,46 @@ async function reopenActiveWorktree(page: Page): Promise<void> {
await waitForActiveTerminalManager(page, 30_000)
}
/** A client paired to the window's runtime over its WebSocket offer, with its own state directory. */
// Both clients use the window's one runtime offer, as the headless corpus's two clients share one.
async function pairWindowClient(run: OracleRun, stateDir: string): Promise<RuntimeClient> {
const offer = await run.page.evaluate(() =>
window.api.mobile.getRuntimePairingUrl({ address: '127.0.0.1' })
)
if (!offer.available || !offer.pairingUrl) {
throw new Error('The window runtime did not offer a pairing')
}
return new RuntimeClient(stateDir, 30_000, offer.pairingUrl, null)
}
/** Each paired client's tab order must equal the CLI's; a difference is a `client` finding. */
async function compareClientTabOrders(
run: OracleRun,
step: string,
worktreeId: string,
clients: Record<string, RuntimeClient>
): Promise<void> {
const order = async (client: RuntimeClient) =>
JSON.stringify(
(
await client.call<RuntimeMobileSessionTabsResult>('session.tabs.list', {
worktree: `id:${worktreeId}`
})
).result.tabGroups?.map((group) => group.tabOrder) ?? []
)
const expected = await order(run.client)
for (const [name, client] of Object.entries(clients)) {
const seen = await order(client)
if (seen !== expected) {
run.oracle.findings.push({
check: 'client',
step,
details: [`${name} tab order ${seen} vs CLI ${expected}`]
})
}
}
}
async function splitTwice(page: Page): Promise<void> {
await splitActiveTerminalPane(page, 'vertical')
await waitForBoundPanes(page, 2)
@@ -230,6 +274,40 @@ const SCENARIOS: Scenario[] = [
await run.oracle.step('client closes tab', { worktreeId, panesPerTab: [1], removed: 1 })
}
},
{
// #9450 with a window attached: paired client A creates, paired client B closes.
id: 'two-clients-create-close',
journey: async (run, worktreeId) => {
const stateDir = mkdtempSync(path.join(os.tmpdir(), 'layout-oracle-clients-'))
try {
const clients = {
'client A': await pairWindowClient(run, path.join(stateDir, 'a')),
'client B': await pairWindowClient(run, path.join(stateDir, 'b'))
}
const worktree = `id:${worktreeId}`
const create = async () =>
(
await clients['client A'].call<{ tab: { parentTabId: string } }>(
'session.tabs.createTerminal',
{ worktree }
)
).result.tab.parentTabId
const first = await create()
await create()
await run.oracle.step('A creates two tabs', { worktreeId, panesPerTab: [1, 1, 1] })
await compareClientTabOrders(run, 'A creates two tabs', worktreeId, clients)
await clients['client B'].call('session.tabs.close', {
worktree,
tabId: first,
reason: 'user'
})
await run.oracle.step('B closes one', { worktreeId, panesPerTab: [1, 1], removed: 1 })
await compareClientTabOrders(run, 'B closes one', worktreeId, clients)
} finally {
rmSync(stateDir, { recursive: true, force: true })
}
}
},
{
// The CLI and a phone move the host window ('host' navigation); the layout must not change.
id: 'host-navigation',