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