Files
orca/tests/e2e/helpers/layout-compat-baseline.ts
T
NeilandBing.Z 8ea884135e fix(runtime): bind serve-host terminals to their workspace at create (#38864)
Terminals a paired desktop or mobile client opened on a headless serve
host got a workspace-less serve-<uuid> session id, so the host inferred
their owner from the cwd. Folder workspaces that share a path then traded
those terminals whenever one was listed, and deleting a workspace stopped
terminals shown in another.

Serve ids now carry the workspace like every other daemon id. Inventory
refresh keeps a persisted layout binding even when a targeted listing does
not include its workspace, so restored serve-<uuid> terminals stay put.
A cwd guess is recorded as such and never drives a worktree-scoped stop.

Fixes #36716

Reported-by: Guy Shechter (@shechter)

Co-authored-by: Bing.Z <zzb@gxsmjx.com>
2026-11-05 22:53:21 -08:00

320 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Normalize generated ids and compare real multi-client observations to main’s per-platform recorded shapes; review differences in the same PR.
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/remote-runtime/pairing/pairing'
import { pairedRuntimeConnectionFromOffer } from '../../../src/shared/remote-runtime/pairing/paired-runtime-connection'
import { subscribeRemoteRuntimeRequest } from '../../../src/shared/remote-runtime/remote-runtime-client'
import type { RuntimeMobileSessionTabsResult } from '../../../src/shared/runtime-session-contracts'
import { isServeMintedPtySessionId } from '../../../src/shared/terminal/pty/lifecycle/pty-session-id-format'
import type {
RuntimeTerminalListResult,
RuntimeTerminalRead
} from '../../../src/shared/runtime-terminal-contracts'
import type { TerminalPaneLayoutNode } from '../../../src/shared/workspace-layout/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 = isServeMintedPtySessionId(id) ? 'ptyServe' : id.includes('@@') ? 'pty@@' : '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)
}
// Require four matching reads before recording; compare the baseline once, never poll until the expected shape appears.
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)
}
// Return the last read on timeout so platform-specific outcomes can still be recorded without 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
}
// Prove reconnection with a new-server status reply, then echo through host PTY and desktop rendering in both directions.
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')
}