From 8dfa2b1875c039fc99dd0c5577d858ddbd4e6aa3 Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Sun, 11 Oct 2026 21:18:16 -0400 Subject: [PATCH] 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 --- .../download-pinned-orca-release/action.yml | 42 + .github/workflows/packaged-browser-e2e.yml | 82 +- .../record-layout-compat-baselines.mjs | 34 + tests/e2e/helpers/layout-compat-baseline.ts | 334 ++++ tests/e2e/helpers/packaged-old-orca.ts | 301 ++++ tests/e2e/helpers/paired-mobile-client.ts | 54 +- tests/e2e/layout-compat-baselines.json | 1543 +++++++++++++++++ ...t-compat-new-desktop-on-old-server.spec.ts | 180 ++ ...t-compat-old-desktop-on-new-server.spec.ts | 185 ++ ...out-compat-old-phone-on-new-server.spec.ts | 232 +++ ...erve-restart-keeps-at-at-terminals.spec.ts | 159 ++ ...ed-mixed-version-browser-placement.spec.ts | 160 +- ...red-mobile-desktop-tab-composition.spec.ts | 260 +++ tests/e2e/workspace-layout-oracle.spec.ts | 78 + 14 files changed, 3472 insertions(+), 172 deletions(-) create mode 100644 .github/actions/download-pinned-orca-release/action.yml create mode 100644 config/scripts/record-layout-compat-baselines.mjs create mode 100644 tests/e2e/helpers/layout-compat-baseline.ts create mode 100644 tests/e2e/helpers/packaged-old-orca.ts create mode 100644 tests/e2e/layout-compat-baselines.json create mode 100644 tests/e2e/layout-compat-new-desktop-on-old-server.spec.ts create mode 100644 tests/e2e/layout-compat-old-desktop-on-new-server.spec.ts create mode 100644 tests/e2e/layout-compat-old-phone-on-new-server.spec.ts create mode 100644 tests/e2e/layout-compat-serve-restart-keeps-at-at-terminals.spec.ts create mode 100644 tests/e2e/paired-mobile-desktop-tab-composition.spec.ts diff --git a/.github/actions/download-pinned-orca-release/action.yml b/.github/actions/download-pinned-orca-release/action.yml new file mode 100644 index 00000000000..b6dbe86aa6c --- /dev/null +++ b/.github/actions/download-pinned-orca-release/action.yml @@ -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__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 diff --git a/.github/workflows/packaged-browser-e2e.yml b/.github/workflows/packaged-browser-e2e.yml index 7d36e7fd1b1..1ca301ae4e8 100644 --- a/.github/workflows/packaged-browser-e2e.yml +++ b/.github/workflows/packaged-browser-e2e.yml @@ -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 diff --git a/config/scripts/record-layout-compat-baselines.mjs b/config/scripts/record-layout-compat-baselines.mjs new file mode 100644 index 00000000000..f4a691ea413 --- /dev/null +++ b/config/scripts/record-layout-compat-baselines.mjs @@ -0,0 +1,34 @@ +// Copies observations written under ORCA_LAYOUT_COMPAT_REPORT_DIR (..json) +// into tests/e2e/layout-compat-baselines.json. Usage: node +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 ') +} +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`) diff --git a/tests/e2e/helpers/layout-compat-baseline.ts b/tests/e2e/helpers/layout-compat-baseline.ts new file mode 100644 index 00000000000..0aa71ee4828 --- /dev/null +++ b/tests/e2e/helpers/layout-compat-baseline.ts @@ -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>> } + +/** Stable names for generated ids, numbered in first-seen order within one scenario. */ +export class IdLabels { + private readonly labels = new Map() + private readonly counts = new Map() + + 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 { + return (await client.call('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('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 { + let resolveSnapshot: (layout: PublishedWorkspaceLayout | null) => void = () => {} + let rejectSnapshot: (error: Error) => void = () => {} + const snapshot = new Promise((resolve, reject) => { + resolveSnapshot = resolve + rejectSnapshot = reject + }) + const subscription = await subscribeRemoteRuntimeRequest( + 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 { + 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(read: () => Promise, timeoutMs = 20_000): Promise { + 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( + read: () => Promise, + ready: (value: T) => boolean, + timeoutMs = 20_000 +): Promise { + 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 +}): Promise { + 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('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') +} diff --git a/tests/e2e/helpers/packaged-old-orca.ts b/tests/e2e/helpers/packaged-old-orca.ts new file mode 100644 index 00000000000..5566f039cc6 --- /dev/null +++ b/tests/e2e/helpers/packaged-old-orca.ts @@ -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 +} + +type Cleanup = () => Promise | void + +export async function collectCleanupFailures(cleanups: Cleanup[]): Promise { + const failures: unknown[] = [] + for (const cleanup of cleanups) { + try { + await cleanup() + } catch (error) { + failures.push(error) + } + } + return failures +} + +async function removeProfile(userDataDir: string): Promise { + 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 { + 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 { + 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 + dispose: () => Promise +} + +async function reserveLoopbackPort(): Promise { + const probe = createServer() + await new Promise((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((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 { + // 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 = (parseLine: (line: string) => T | null): Promise> => + 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 | null = null + const cleanup = async (): Promise => { + 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 + } +} diff --git a/tests/e2e/helpers/paired-mobile-client.ts b/tests/e2e/helpers/paired-mobile-client.ts index a8c89a06bfb..ef035cb4d7c 100644 --- a/tests/e2e/helpers/paired-mobile-client.ts +++ b/tests/e2e/helpers/paired-mobile-client.ts @@ -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, timeoutMs?: number ) => Promise - /** Closes every subscription this client opened. */ + /** One persistent phone socket, as the app holds one; needed for `executionHost` routing. */ + openSocket: () => Promise + /** 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() + const open = new Set<{ close: () => void }>() const request = ( 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() diff --git a/tests/e2e/layout-compat-baselines.json b/tests/e2e/layout-compat-baselines.json new file mode 100644 index 00000000000..8cfba39c480 --- /dev/null +++ b/tests/e2e/layout-compat-baselines.json @@ -0,0 +1,1543 @@ +{ + "about": "Layout rework P3: what the server and its clients report in each mixed-version, two-client and phone scenario, recorded from runs on main. Ids are replaced by labels in first-seen order; PTY labels keep their minting kind (pty@@ = daemon, ptyServe = serve). See tests/e2e/helpers/layout-compat-baseline.ts. Linux only: CI records and compares them; macOS and Windows have no baselines.", + "scenarios": { + "new-desktop-on-old-server": { + "linux": { + "runtimeProtocolVersion": 3, + "steps": [ + { + "step": "desktop shows the server workspace", + "oldServer": { + "activeTab": "tab1", + "groups": [ + { + "tabs": ["tab1"], + "active": "tab1" + } + ], + "rows": [ + { + "type": "terminal", + "tab": "tab1", + "leaf": "leaf1", + "pty": "pty@@1", + "title": "host-made", + "tree": "leaf1", + "paneTitles": [] + } + ] + }, + "oldServerTerminals": [ + { + "tab": "tab1", + "leaf": "leaf1", + "pty": "pty@@1", + "title": "host-made" + } + ], + "desktop": { + "tabs": 1, + "tree": "leaf1", + "paneTitles": [], + "drawnPanes": ["leaf1"], + "drawnPaneTitles": [] + } + }, + { + "step": "desktop splits right", + "oldServer": { + "activeTab": "tab1", + "groups": [ + { + "tabs": ["tab1"], + "active": "tab1" + } + ], + "rows": [ + { + "type": "terminal", + "tab": "tab1", + "leaf": "leaf1", + "pty": "pty@@1", + "title": "host-made", + "tree": { + "split": "vertical", + "ratio": 0.5, + "first": "leaf1", + "second": "leaf2" + }, + "paneTitles": [] + }, + { + "type": "terminal", + "tab": "tab1", + "leaf": "leaf2", + "pty": "pty@@2", + "title": "Terminal", + "tree": { + "split": "vertical", + "ratio": 0.5, + "first": "leaf1", + "second": "leaf2" + }, + "paneTitles": [] + } + ] + }, + "oldServerTerminals": [ + { + "tab": "tab1", + "leaf": "leaf1", + "pty": "pty@@1", + "title": "host-made" + }, + { + "tab": "tab1", + "leaf": "leaf2", + "pty": "pty@@2", + "title": null + } + ], + "desktop": { + "tabs": 1, + "tree": { + "split": "vertical", + "ratio": 0.5, + "first": "leaf1", + "second": "leaf2" + }, + "paneTitles": [], + "drawnPanes": ["leaf1", "leaf2"], + "drawnPaneTitles": [] + } + }, + { + "step": "desktop drags the divider", + "oldServer": { + "activeTab": "tab1", + "groups": [ + { + "tabs": ["tab1"], + "active": "tab1" + } + ], + "rows": [ + { + "type": "terminal", + "tab": "tab1", + "leaf": "leaf1", + "pty": "pty@@1", + "title": "host-made", + "tree": { + "split": "vertical", + "ratio": 0.8, + "first": "leaf1", + "second": "leaf2" + }, + "paneTitles": [] + }, + { + "type": "terminal", + "tab": "tab1", + "leaf": "leaf2", + "pty": "pty@@2", + "title": "Terminal", + "tree": { + "split": "vertical", + "ratio": 0.8, + "first": "leaf1", + "second": "leaf2" + }, + "paneTitles": [] + } + ] + }, + "oldServerTerminals": [ + { + "tab": "tab1", + "leaf": "leaf1", + "pty": "pty@@1", + "title": "host-made" + }, + { + "tab": "tab1", + "leaf": "leaf2", + "pty": "pty@@2", + "title": null + } + ], + "desktop": { + "tabs": 1, + "tree": { + "split": "vertical", + "ratio": 0.8, + "first": "leaf1", + "second": "leaf2" + }, + "paneTitles": [], + "drawnPanes": ["leaf1", "leaf2"], + "drawnPaneTitles": [] + } + }, + { + "step": "desktop renames a pane", + "oldServer": { + "activeTab": "tab1", + "groups": [ + { + "tabs": ["tab1"], + "active": "tab1" + } + ], + "rows": [ + { + "type": "terminal", + "tab": "tab1", + "leaf": "leaf1", + "pty": "pty@@1", + "title": "host-made", + "tree": { + "split": "vertical", + "ratio": 0.8, + "first": "leaf1", + "second": "leaf2" + }, + "paneTitles": ["leaf1=renamed-pane"] + }, + { + "type": "terminal", + "tab": "tab1", + "leaf": "leaf2", + "pty": "pty@@2", + "title": "Terminal", + "tree": { + "split": "vertical", + "ratio": 0.8, + "first": "leaf1", + "second": "leaf2" + }, + "paneTitles": ["leaf1=renamed-pane"] + } + ] + }, + "oldServerTerminals": [ + { + "tab": "tab1", + "leaf": "leaf1", + "pty": "pty@@1", + "title": "host-made" + }, + { + "tab": "tab1", + "leaf": "leaf2", + "pty": "pty@@2", + "title": null + } + ], + "desktop": { + "tabs": 1, + "tree": { + "split": "vertical", + "ratio": 0.8, + "first": "leaf1", + "second": "leaf2" + }, + "paneTitles": ["leaf1=renamed-pane"], + "drawnPanes": ["leaf1", "leaf2"], + "drawnPaneTitles": ["renamed-pane"] + } + }, + { + "step": "after the server restarts and the desktop reconnects", + "oldServer": { + "activeTab": "tab1", + "groups": [ + { + "tabs": ["tab1"], + "active": "tab1" + } + ], + "rows": [ + { + "type": "terminal", + "tab": "tab1", + "leaf": "leaf1", + "pty": "pty@@1", + "title": "Terminal 1", + "tree": { + "split": "vertical", + "ratio": 0.8, + "first": "leaf1", + "second": "leaf2" + }, + "paneTitles": ["leaf1=renamed-pane"] + }, + { + "type": "terminal", + "tab": "tab1", + "leaf": "leaf2", + "pty": "pty@@2", + "title": "Terminal 1", + "tree": { + "split": "vertical", + "ratio": 0.8, + "first": "leaf1", + "second": "leaf2" + }, + "paneTitles": ["leaf1=renamed-pane"] + } + ] + }, + "oldServerTerminals": [ + { + "tab": "tab1", + "leaf": "leaf1", + "pty": "pty@@1", + "title": null + }, + { + "tab": "tab1", + "leaf": "leaf2", + "pty": "pty@@2", + "title": null + } + ], + "desktop": { + "tabs": 1, + "tree": { + "split": "vertical", + "ratio": 0.8, + "first": "leaf1", + "second": "leaf2" + }, + "paneTitles": ["leaf1=renamed-pane"], + "drawnPanes": ["leaf1", "leaf2"], + "drawnPaneTitles": ["renamed-pane"] + } + } + ] + } + }, + "old-cli-terminal-create": { + "linux": { + "exitCode": 0, + "ok": true, + "error": null, + "resultFields": [ + "executionHostId", + "handle", + "hostPlatform", + "incarnationId", + "paneKey", + "ptyId", + "surface", + "tabId", + "title", + "warning", + "worktreeId" + ], + "ptyId": "pty@@1", + "server": { + "activeTab": "tab1", + "groups": [ + { + "tabs": ["tab1"], + "active": "tab1" + } + ], + "rows": [ + { + "type": "terminal", + "tab": "tab1", + "leaf": "leaf1", + "pty": "pty@@1", + "title": "old-cli", + "tree": "leaf1", + "paneTitles": [] + } + ] + } + } + }, + "old-desktop-on-new-server": { + "linux": { + "oldDesktopVersion": "1.4.224", + "steps": [ + { + "step": "old desktop opens the server workspace", + "server": { + "activeTab": "tab1", + "groups": [ + { + "tabs": ["tab1", "tab2"], + "active": "tab1" + } + ], + "rows": [ + { + "type": "terminal", + "tab": "tab1", + "leaf": "leaf1", + "pty": "pty@@1", + "title": "host-made", + "tree": "leaf1", + "paneTitles": [] + }, + { + "type": "terminal", + "tab": "tab2", + "leaf": "leaf2", + "pty": "ptyServe1", + "title": "Terminal", + "tree": "leaf2", + "paneTitles": [] + } + ] + }, + "serverTerminals": [ + { + "tab": "tab1", + "leaf": "leaf1", + "pty": "pty@@1", + "title": "host-made" + }, + { + "tab": "tab2", + "leaf": "leaf2", + "pty": "ptyServe1", + "title": null + } + ], + "published": { + "groups": [["tab1", "tab2"]], + "tabs": [ + { + "tab": "tab1", + "kind": "terminal", + "customTitle": null, + "tree": "leaf1", + "panes": [ + { + "leaf": "leaf1", + "pty": "pty@@1" + } + ] + }, + { + "tab": "tab2", + "kind": "terminal", + "customTitle": null, + "tree": "leaf2", + "panes": [ + { + "leaf": "leaf2", + "pty": "ptyServe1" + } + ] + } + ] + }, + "oldDesktopTabs": 2, + "oldDesktopPanes": ["leaf2"] + }, + { + "step": "old desktop splits right", + "server": { + "activeTab": "tab2", + "groups": [ + { + "tabs": ["tab1", "tab2"], + "active": "tab2" + } + ], + "rows": [ + { + "type": "terminal", + "tab": "tab1", + "leaf": "leaf1", + "pty": "pty@@1", + "title": "host-made", + "tree": "leaf1", + "paneTitles": [] + }, + { + "type": "terminal", + "tab": "tab2", + "leaf": "leaf2", + "pty": "ptyServe1", + "title": "Terminal", + "tree": { + "split": "vertical", + "ratio": 0.5, + "first": "leaf2", + "second": "leaf3" + }, + "paneTitles": [] + }, + { + "type": "terminal", + "tab": "tab2", + "leaf": "leaf3", + "pty": "pty@@2", + "title": "Terminal", + "tree": { + "split": "vertical", + "ratio": 0.5, + "first": "leaf2", + "second": "leaf3" + }, + "paneTitles": [] + } + ] + }, + "serverTerminals": [ + { + "tab": "tab1", + "leaf": "leaf1", + "pty": "pty@@1", + "title": "host-made" + }, + { + "tab": "tab2", + "leaf": "leaf3", + "pty": "pty@@2", + "title": null + }, + { + "tab": "tab2", + "leaf": "leaf2", + "pty": "ptyServe1", + "title": null + } + ], + "published": { + "groups": [["tab1", "tab2"]], + "tabs": [ + { + "tab": "tab1", + "kind": "terminal", + "customTitle": null, + "tree": "leaf1", + "panes": [ + { + "leaf": "leaf1", + "pty": "pty@@1" + } + ] + }, + { + "tab": "tab2", + "kind": "terminal", + "customTitle": null, + "tree": { + "split": "vertical", + "ratio": 0.5, + "first": "leaf2", + "second": "leaf3" + }, + "panes": [ + { + "leaf": "leaf2", + "pty": "ptyServe1" + }, + { + "leaf": "leaf3", + "pty": "pty@@2" + } + ] + } + ] + }, + "oldDesktopTabs": 2, + "oldDesktopPanes": ["leaf2", "leaf3"] + }, + { + "step": "old desktop closes the new pane", + "server": { + "activeTab": "tab2", + "groups": [ + { + "tabs": ["tab1", "tab2"], + "active": "tab2" + } + ], + "rows": [ + { + "type": "terminal", + "tab": "tab1", + "leaf": "leaf1", + "pty": "pty@@1", + "title": "host-made", + "tree": "leaf1", + "paneTitles": [] + }, + { + "type": "terminal", + "tab": "tab2", + "leaf": "leaf2", + "pty": "ptyServe1", + "title": "Terminal", + "tree": "leaf2", + "paneTitles": [] + } + ] + }, + "serverTerminals": [ + { + "tab": "tab1", + "leaf": "leaf1", + "pty": "pty@@1", + "title": "host-made" + }, + { + "tab": "tab2", + "leaf": "leaf2", + "pty": "ptyServe1", + "title": null + } + ], + "published": { + "groups": [["tab1", "tab2"]], + "tabs": [ + { + "tab": "tab1", + "kind": "terminal", + "customTitle": null, + "tree": "leaf1", + "panes": [ + { + "leaf": "leaf1", + "pty": "pty@@1" + } + ] + }, + { + "tab": "tab2", + "kind": "terminal", + "customTitle": null, + "tree": "leaf2", + "panes": [ + { + "leaf": "leaf2", + "pty": "ptyServe1" + } + ] + } + ] + }, + "oldDesktopTabs": 2, + "oldDesktopPanes": ["leaf2"] + }, + { + "step": "after server restart", + "server": { + "activeTab": "tab1", + "groups": [ + { + "tabs": ["tab1", "tab2"], + "active": "tab1" + } + ], + "rows": [ + { + "type": "terminal", + "tab": "tab1", + "leaf": "leaf1", + "pty": "pty@@1", + "title": "Terminal 1", + "tree": "leaf1", + "paneTitles": [] + }, + { + "type": "terminal", + "tab": "tab2", + "leaf": "leaf2", + "pty": "ptyServe1", + "title": "Terminal 2", + "tree": "leaf2", + "paneTitles": [] + } + ] + }, + "serverTerminals": [ + { + "tab": "tab1", + "leaf": "leaf1", + "pty": "pty@@1", + "title": null + }, + { + "tab": "tab2", + "leaf": "leaf2", + "pty": "ptyServe1", + "title": null + } + ], + "published": { + "groups": [["tab1", "tab2"]], + "tabs": [ + { + "tab": "tab1", + "kind": "terminal", + "customTitle": null, + "tree": "leaf1", + "panes": [ + { + "leaf": "leaf1", + "pty": "pty@@1" + } + ] + }, + { + "tab": "tab2", + "kind": "terminal", + "customTitle": null, + "tree": "leaf2", + "panes": [ + { + "leaf": "leaf2", + "pty": "ptyServe1" + } + ] + } + ] + }, + "oldDesktopTabs": 2, + "oldDesktopPanes": ["leaf2"] + } + ] + } + }, + "old-phone-on-new-server": { + "linux": { + "steps": [ + { + "step": "phone creates two terminals", + "phone": { + "activeTab": "tab2", + "groups": [ + { + "tabs": ["tab1", "tab2"], + "active": "tab2" + } + ], + "rows": [ + { + "type": "terminal", + "tab": "tab1", + "leaf": "leaf1", + "pty": "ptyServe1", + "title": "Terminal", + "tree": "leaf1", + "paneTitles": [] + }, + { + "type": "terminal", + "tab": "tab2", + "leaf": "leaf2", + "pty": "ptyServe2", + "title": "Terminal", + "tree": "leaf2", + "paneTitles": [] + } + ] + }, + "server": { + "activeTab": "tab1", + "groups": [ + { + "tabs": ["tab1", "tab2"], + "active": "tab1" + } + ], + "rows": [ + { + "type": "terminal", + "tab": "tab1", + "leaf": "leaf1", + "pty": "ptyServe1", + "title": "Terminal", + "tree": "leaf1", + "paneTitles": [] + }, + { + "type": "terminal", + "tab": "tab2", + "leaf": "leaf2", + "pty": "ptyServe2", + "title": "Terminal", + "tree": "leaf2", + "paneTitles": [] + } + ] + } + }, + { + "step": "phone activates the first", + "reply": { + "ok": true, + "fields": [ + "activeGroupId", + "activeTabId", + "activeTabType", + "publicationEpoch", + "snapshotVersion", + "tabGroups", + "tabs", + "worktree" + ] + }, + "phone": { + "activeTab": "tab1", + "groups": [ + { + "tabs": ["tab1", "tab2"], + "active": "tab1" + } + ], + "rows": [ + { + "type": "terminal", + "tab": "tab1", + "leaf": "leaf1", + "pty": "ptyServe1", + "title": "Terminal", + "tree": "leaf1", + "paneTitles": [] + }, + { + "type": "terminal", + "tab": "tab2", + "leaf": "leaf2", + "pty": "ptyServe2", + "title": "Terminal", + "tree": "leaf2", + "paneTitles": [] + } + ] + }, + "server": { + "activeTab": "tab1", + "groups": [ + { + "tabs": ["tab1", "tab2"], + "active": "tab1" + } + ], + "rows": [ + { + "type": "terminal", + "tab": "tab1", + "leaf": "leaf1", + "pty": "ptyServe1", + "title": "Terminal", + "tree": "leaf1", + "paneTitles": [] + }, + { + "type": "terminal", + "tab": "tab2", + "leaf": "leaf2", + "pty": "ptyServe2", + "title": "Terminal", + "tree": "leaf2", + "paneTitles": [] + } + ] + } + }, + { + "step": "phone renames the first", + "reply": { + "ok": true, + "fields": ["rename"] + }, + "phone": { + "activeTab": "tab1", + "groups": [ + { + "tabs": ["tab1", "tab2"], + "active": "tab1" + } + ], + "rows": [ + { + "type": "terminal", + "tab": "tab1", + "leaf": "leaf1", + "pty": "ptyServe1", + "title": "phone-renamed", + "tree": "leaf1", + "paneTitles": [] + }, + { + "type": "terminal", + "tab": "tab2", + "leaf": "leaf2", + "pty": "ptyServe2", + "title": "Terminal", + "tree": "leaf2", + "paneTitles": [] + } + ] + }, + "server": { + "activeTab": "tab1", + "groups": [ + { + "tabs": ["tab1", "tab2"], + "active": "tab1" + } + ], + "rows": [ + { + "type": "terminal", + "tab": "tab1", + "leaf": "leaf1", + "pty": "ptyServe1", + "title": "phone-renamed", + "tree": "leaf1", + "paneTitles": [] + }, + { + "type": "terminal", + "tab": "tab2", + "leaf": "leaf2", + "pty": "ptyServe2", + "title": "Terminal", + "tree": "leaf2", + "paneTitles": [] + } + ] + } + }, + { + "step": "phone focuses the second", + "reply": { + "ok": true, + "fields": ["focus"] + }, + "phone": { + "activeTab": "tab1", + "groups": [ + { + "tabs": ["tab1", "tab2"], + "active": "tab1" + } + ], + "rows": [ + { + "type": "terminal", + "tab": "tab1", + "leaf": "leaf1", + "pty": "ptyServe1", + "title": "phone-renamed", + "tree": "leaf1", + "paneTitles": [] + }, + { + "type": "terminal", + "tab": "tab2", + "leaf": "leaf2", + "pty": "ptyServe2", + "title": "Terminal", + "tree": "leaf2", + "paneTitles": [] + } + ] + }, + "server": { + "activeTab": "tab1", + "groups": [ + { + "tabs": ["tab1", "tab2"], + "active": "tab1" + } + ], + "rows": [ + { + "type": "terminal", + "tab": "tab1", + "leaf": "leaf1", + "pty": "ptyServe1", + "title": "phone-renamed", + "tree": "leaf1", + "paneTitles": [] + }, + { + "type": "terminal", + "tab": "tab2", + "leaf": "leaf2", + "pty": "ptyServe2", + "title": "Terminal", + "tree": "leaf2", + "paneTitles": [] + } + ] + } + }, + { + "step": "host pins the second", + "phone": { + "activeTab": "tab1", + "groups": [ + { + "tabs": ["tab1", "tab2"], + "active": "tab1" + } + ], + "rows": [ + { + "type": "terminal", + "tab": "tab1", + "leaf": "leaf1", + "pty": "ptyServe1", + "title": "phone-renamed", + "tree": "leaf1", + "paneTitles": [] + }, + { + "type": "terminal", + "tab": "tab2", + "leaf": "leaf2", + "pty": "ptyServe2", + "title": "Terminal", + "pinned": true, + "tree": "leaf2", + "paneTitles": [] + } + ] + }, + "server": { + "activeTab": "tab1", + "groups": [ + { + "tabs": ["tab1", "tab2"], + "active": "tab1" + } + ], + "rows": [ + { + "type": "terminal", + "tab": "tab1", + "leaf": "leaf1", + "pty": "ptyServe1", + "title": "phone-renamed", + "tree": "leaf1", + "paneTitles": [] + }, + { + "type": "terminal", + "tab": "tab2", + "leaf": "leaf2", + "pty": "ptyServe2", + "title": "Terminal", + "pinned": true, + "tree": "leaf2", + "paneTitles": [] + } + ] + } + }, + { + "step": "phone closes the pinned tab", + "reply": { + "ok": false, + "error": "terminal_tab_pinned" + }, + "phone": { + "activeTab": "tab1", + "groups": [ + { + "tabs": ["tab1", "tab2"], + "active": "tab1" + } + ], + "rows": [ + { + "type": "terminal", + "tab": "tab1", + "leaf": "leaf1", + "pty": "ptyServe1", + "title": "phone-renamed", + "tree": "leaf1", + "paneTitles": [] + }, + { + "type": "terminal", + "tab": "tab2", + "leaf": "leaf2", + "pty": "ptyServe2", + "title": "Terminal", + "pinned": true, + "tree": "leaf2", + "paneTitles": [] + } + ] + }, + "server": { + "activeTab": "tab1", + "groups": [ + { + "tabs": ["tab1", "tab2"], + "active": "tab1" + } + ], + "rows": [ + { + "type": "terminal", + "tab": "tab1", + "leaf": "leaf1", + "pty": "ptyServe1", + "title": "phone-renamed", + "tree": "leaf1", + "paneTitles": [] + }, + { + "type": "terminal", + "tab": "tab2", + "leaf": "leaf2", + "pty": "ptyServe2", + "title": "Terminal", + "pinned": true, + "tree": "leaf2", + "paneTitles": [] + } + ] + } + }, + { + "step": "phone opens a file", + "reply": { + "ok": false, + "error": "runtime_error", + "message": "renderer_unavailable" + }, + "phone": { + "activeTab": "tab1", + "groups": [ + { + "tabs": ["tab1", "tab2"], + "active": "tab1" + } + ], + "rows": [ + { + "type": "terminal", + "tab": "tab1", + "leaf": "leaf1", + "pty": "ptyServe1", + "title": "phone-renamed", + "tree": "leaf1", + "paneTitles": [] + }, + { + "type": "terminal", + "tab": "tab2", + "leaf": "leaf2", + "pty": "ptyServe2", + "title": "Terminal", + "pinned": true, + "tree": "leaf2", + "paneTitles": [] + } + ] + }, + "server": { + "activeTab": "tab1", + "groups": [ + { + "tabs": ["tab1", "tab2"], + "active": "tab1" + } + ], + "rows": [ + { + "type": "terminal", + "tab": "tab1", + "leaf": "leaf1", + "pty": "ptyServe1", + "title": "phone-renamed", + "tree": "leaf1", + "paneTitles": [] + }, + { + "type": "terminal", + "tab": "tab2", + "leaf": "leaf2", + "pty": "ptyServe2", + "title": "Terminal", + "pinned": true, + "tree": "leaf2", + "paneTitles": [] + } + ] + } + }, + { + "step": "phone reconnects after a server restart", + "snapshotBefore": { + "activeTab": "tab1", + "groups": [ + { + "tabs": ["tab1", "tab2"], + "active": "tab1" + } + ], + "rows": [ + { + "type": "terminal", + "tab": "tab1", + "leaf": "leaf1", + "pty": "ptyServe1", + "title": "phone-renamed", + "tree": "leaf1", + "paneTitles": [] + }, + { + "type": "terminal", + "tab": "tab2", + "leaf": "leaf2", + "pty": "ptyServe2", + "title": "Terminal", + "pinned": true, + "tree": "leaf2", + "paneTitles": [] + } + ] + }, + "snapshotAfter": { + "activeTab": "tab1", + "groups": [ + { + "tabs": ["tab1", "tab2"], + "active": "tab1" + } + ], + "rows": [ + { + "type": "terminal", + "tab": "tab1", + "leaf": "leaf1", + "pty": "ptyServe1", + "title": "phone-renamed", + "tree": "leaf1", + "paneTitles": [] + }, + { + "type": "terminal", + "tab": "tab2", + "leaf": "leaf2", + "pty": "ptyServe2", + "title": "Terminal 2", + "pinned": true, + "tree": "leaf2", + "paneTitles": [] + } + ] + }, + "terminalsAfter": [ + { + "tab": "tab1", + "pty": "ptyServe1" + }, + { + "tab": "tab2", + "pty": "ptyServe2" + } + ] + }, + { + "step": "phone closes the first terminal", + "reply": { + "ok": true, + "fields": ["close"] + }, + "phone": { + "activeTab": "tab2", + "groups": [ + { + "tabs": ["tab2"], + "active": "tab2" + } + ], + "rows": [ + { + "type": "terminal", + "tab": "tab2", + "leaf": "leaf2", + "pty": "ptyServe2", + "title": "Terminal 2", + "pinned": true, + "tree": "leaf2", + "paneTitles": [] + } + ] + }, + "server": { + "activeTab": "tab2", + "groups": [ + { + "tabs": ["tab2"], + "active": "tab2" + } + ], + "rows": [ + { + "type": "terminal", + "tab": "tab2", + "leaf": "leaf2", + "pty": "ptyServe2", + "title": "Terminal 2", + "pinned": true, + "tree": "leaf2", + "paneTitles": [] + } + ] + } + } + ], + "snapshotFields": [ + "activeGroupId", + "activeTabId", + "activeTabType", + "publicationEpoch", + "snapshotVersion", + "tabGroups", + "tabs", + "type", + "worktree" + ], + "terminalRowFields": [ + "id", + "incarnationId", + "isActive", + "isPinned", + "leafId", + "parentLayout", + "parentTabId", + "ptyId", + "startupCwd", + "status", + "terminal", + "title", + "type" + ] + } + }, + "serve-restart-keeps-@@-terminals": { + "linux": { + "created": { + "cli": "pty@@1", + "serve": "tab3" + }, + "before": { + "server": { + "activeTab": "tab1", + "groups": [ + { + "tabs": ["tab1", "tab2", "tab3"], + "active": "tab1" + } + ], + "rows": [ + { + "type": "terminal", + "tab": "tab1", + "leaf": "leaf1", + "pty": "ptyServe1", + "title": "Terminal", + "tree": "leaf1", + "paneTitles": [] + }, + { + "type": "terminal", + "tab": "tab2", + "leaf": "leaf2", + "pty": "pty@@1", + "title": "cli-created", + "tree": "leaf2", + "paneTitles": [] + }, + { + "type": "terminal", + "tab": "tab3", + "leaf": "leaf3", + "pty": "ptyServe2", + "title": "Terminal", + "tree": "leaf3", + "paneTitles": [] + } + ] + }, + "terminals": [ + { + "tab": "tab2", + "leaf": "leaf2", + "pty": "pty@@1", + "title": "cli-created" + }, + { + "tab": "tab1", + "leaf": "leaf1", + "pty": "ptyServe1", + "title": null + }, + { + "tab": "tab3", + "leaf": "leaf3", + "pty": "ptyServe2", + "title": null + } + ], + "published": { + "groups": [["tab1", "tab2", "tab3"]], + "tabs": [ + { + "tab": "tab1", + "kind": "terminal", + "customTitle": null, + "tree": "leaf1", + "panes": [ + { + "leaf": "leaf1", + "pty": "ptyServe1" + } + ] + }, + { + "tab": "tab2", + "kind": "terminal", + "customTitle": null, + "tree": "leaf2", + "panes": [ + { + "leaf": "leaf2", + "pty": "pty@@1" + } + ] + }, + { + "tab": "tab3", + "kind": "terminal", + "customTitle": null, + "tree": "leaf3", + "panes": [ + { + "leaf": "leaf3", + "pty": "ptyServe2" + } + ] + } + ] + }, + "desktopTabs": 3, + "desktopPanes": 1 + }, + "after": { + "server": { + "activeTab": "", + "groups": [ + { + "active": "", + "tabs": ["tab1", "tab2", "tab3"] + } + ], + "rows": [ + { + "type": "terminal", + "tab": "tab1", + "leaf": "leaf1", + "pty": "ptyServe1", + "title": "Terminal ", + "tree": "leaf1", + "paneTitles": [] + }, + { + "type": "terminal", + "tab": "tab2", + "leaf": "leaf2", + "pty": "pty@@1", + "title": "Terminal ", + "tree": "leaf2", + "paneTitles": [] + }, + { + "type": "terminal", + "tab": "tab3", + "leaf": "leaf3", + "pty": "ptyServe2", + "title": "Terminal ", + "tree": "leaf3", + "paneTitles": [] + } + ] + }, + "terminals": [ + { + "tab": "tab2", + "leaf": "leaf2", + "pty": "pty@@1", + "title": null + }, + { + "tab": "tab1", + "leaf": "leaf1", + "pty": "ptyServe1", + "title": null + }, + { + "tab": "tab3", + "leaf": "leaf3", + "pty": "ptyServe2", + "title": null + } + ], + "published": { + "groups": [["tab1", "tab2", "tab3"]], + "tabs": [ + { + "tab": "tab1", + "kind": "terminal", + "customTitle": null, + "tree": "leaf1", + "panes": [ + { + "leaf": "leaf1", + "pty": "ptyServe1" + } + ] + }, + { + "tab": "tab2", + "kind": "terminal", + "customTitle": null, + "tree": "leaf2", + "panes": [ + { + "leaf": "leaf2", + "pty": "pty@@1" + } + ] + }, + { + "tab": "tab3", + "kind": "terminal", + "customTitle": null, + "tree": "leaf3", + "panes": [ + { + "leaf": "leaf3", + "pty": "ptyServe2" + } + ] + } + ] + }, + "desktopTabs": 3, + "desktopPanes": 1 + } + } + } + } +} diff --git a/tests/e2e/layout-compat-new-desktop-on-old-server.spec.ts b/tests/e2e/layout-compat-new-desktop-on-old-server.spec.ts new file mode 100644 index 00000000000..14af5159e94 --- /dev/null +++ b/tests/e2e/layout-compat-new-desktop-on-old-server.spec.ts @@ -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('.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 { + 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 => { + 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() + } + }) +}) diff --git a/tests/e2e/layout-compat-old-desktop-on-new-server.spec.ts b/tests/e2e/layout-compat-old-desktop-on-new-server.spec.ts new file mode 100644 index 00000000000..394b8221524 --- /dev/null +++ b/tests/e2e/layout-compat-old-desktop-on-new-server.spec.ts @@ -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 { + return page.locator(PANE).evaluateAll((panes) => panes.map((pane) => pane.dataset.leafId ?? '')) +} + +async function focusPane(page: Page, leafId: string): Promise { + 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 => { + 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() + } + }) +}) diff --git a/tests/e2e/layout-compat-old-phone-on-new-server.spec.ts b/tests/e2e/layout-compat-old-phone-on-new-server.spec.ts new file mode 100644 index 00000000000..cf75752abad --- /dev/null +++ b/tests/e2e/layout-compat-old-phone-on-new-server.spec.ts @@ -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(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('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 { + 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 => { + const observed = await readSettled(async () => ({ + phone: sessionTabsBaseline( + await phone.call('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() + } +}) diff --git a/tests/e2e/layout-compat-serve-restart-keeps-at-at-terminals.spec.ts b/tests/e2e/layout-compat-serve-restart-keeps-at-at-terminals.spec.ts new file mode 100644 index 00000000000..61a1dd9223d --- /dev/null +++ b/tests/e2e/layout-compat-serve-restart-keeps-at-at-terminals.spec.ts @@ -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 ' +} + +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: '', + groups: [{ active: '', 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() + } +}) diff --git a/tests/e2e/packaged-mixed-version-browser-placement.spec.ts b/tests/e2e/packaged-mixed-version-browser-placement.spec.ts index 4b1a7f45449..ec71e7f36da 100644 --- a/tests/e2e/packaged-mixed-version-browser-placement.spec.ts +++ b/tests/e2e/packaged-mixed-version-browser-placement.spec.ts @@ -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 -} - type BrowserCreateResult = { browserPageId: string } type PackagedPlacementCleanup = () => Promise | void -async function collectCleanupFailures(cleanups: PackagedPlacementCleanup[]): Promise { - 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 ): Promise { @@ -123,108 +94,6 @@ async function readOwnedPageUrls(app: ElectronApplication, url: string): Promise ) } -async function removeProfile(userDataDir: string): Promise { - 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 { - 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) diff --git a/tests/e2e/paired-mobile-desktop-tab-composition.spec.ts b/tests/e2e/paired-mobile-desktop-tab-composition.spec.ts new file mode 100644 index 00000000000..4ce792afa4f --- /dev/null +++ b/tests/e2e/paired-mobile-desktop-tab-composition.spec.ts @@ -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( + socket: PairedMobileSocket, + method: string, + params: unknown, + schema: z.ZodType, + executionHost?: string +): Promise { + 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 { + 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 { + 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 { + 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 => + ((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 => + ((await readSessionTabs(host.client, worktree)).tabGroups ?? []).map( + (group) => group.tabOrder + ) + const publishedStrip = async (): Promise => + (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() + } +}) diff --git a/tests/e2e/workspace-layout-oracle.spec.ts b/tests/e2e/workspace-layout-oracle.spec.ts index 0852690984b..3137451b49d 100644 --- a/tests/e2e/workspace-layout-oracle.spec.ts +++ b/tests/e2e/workspace-layout-oracle.spec.ts @@ -9,6 +9,9 @@ * `ORCA_LAYOUT_ORACLE_REPEAT=` 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 { 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 { + 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 +): Promise { + const order = async (client: RuntimeClient) => + JSON.stringify( + ( + await client.call('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 { 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',