mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 16:47:34 +00:00
test(e2e): layout oracle reads the live runtime layout (#27560)
* test(e2e): read the live runtime layout in the layout oracle The oracle now reads the runtime's layout from a layout.subscribe consumer (the window's renderer, or a headless host's pairing offer) instead of session:get or the saved profile. Model rules run on every published frame, replacing the 300 ms settle read and the 100 ms rule sampler; disk-format rules and the round trip run on the saved profile at each settled step. Views are compared to the live layout exactly for every group and tab kind. The known-on-main list is refilled from runs of this oracle on main. * test(e2e): known-on-main differences do not hold the oracle's settle wait A difference main is known to keep never converges, so a step settles once only known-on-main differences remain. The orcad title-after-restart entry is narrowed to the scenario a run on main reproduced. * test(e2e): oracle settle counts frames across sleeps, pairs renamed groups, flushes saves The settle wait carries the frame count across reads, so a frame during the sleep restarts the quiet window. The client check pairs a group listed under another id with the runtime group holding the same tabs in the same order, so only that rename is known on main and a dropped group still fails. The window flushes main's debounced save before the saved-profile read. * test(e2e): refill known-on-main from Linux CI and post-flush runs on main Removes the two-writers saved-profile entry (gone once the window flushes main's save). Re-adds client-reorder tab order and the cold-relaunch client list, and adds the cold- and live-daemon relaunch tab-bar entries and the Electron rename-after-restart entry, each reproduced by Linux CI run 38170663248 or a local run on main's code. * test(e2e): scope known-on-main entries to the OS that reproduced them Entries reproduced on one OS only carry a platform and match only there. The cold-relaunch restart entry now matches only a single group whose tabs and order are unchanged, so a reorder still fails.
This commit is contained in:
+5
-5
@@ -47,10 +47,10 @@ Terminal-topology refactor PRs must be inert or fix a named bug. `terminal-layou
|
||||
|
||||
`workspace-layout-oracle.spec.ts` (window attached) and `workspace-layout-oracle-headless.spec.ts` (orcad and Electron `orca serve`, no window) drive layout journeys through the commands users, the CLI and paired clients send, and after every step decide pass/fail with no reviewer:
|
||||
|
||||
- **rules**: the runtime's layout (main's session over the read-only `session:get` IPC; a headless runtime's saved profile) against `workspace-layout-rules.ts`: a terminal in two panes, a pane in two tabs, a tab outside exactly one group, tab rows and tab bar disagreeing, two tab orders, an id changing for the same pane, tab or group. A sampler also runs the rules every 100 ms between steps; breaches that heal before a step settles are recorded as `(transient)`. (The unit model test checks every write exactly, via `store.onWorkspaceSessionWritten`.)
|
||||
- **roundTrip**: each partition, through the layout module's Loader then Serializer, must reach a fixed point: a second load changes nothing (`workspace-layout-round-trip-check.ts`). This is an idempotence check; its list of change kinds a first load may make is broad. The same check runs in the app on every Store write in dev and e2e, logged as `[workspace-layout] round-trip check:`, with each new kind of first-load change logged as `[workspace-layout] first-load changes:`.
|
||||
- **view**: what the window draws, read from the DOM (`[data-tab-group-strip-id]`, `[data-terminal-tab-id] .pane[data-leaf-id][data-pty-id]`), against the runtime. Exited or slept bindings are compared as unbound.
|
||||
- **client**: `session.tabs.list` and `terminal.list` against the runtime, including custom titles and running terminals with no pane.
|
||||
- **rules**: the runtime's live layout, read from its `layout.subscribe` stream (the window subscribes through its renderer; a headless host over its pairing offer), against the model rules in `workspace-layout-model-rules.ts` on every published frame: a terminal in two panes, a pane in two tabs, a tab outside exactly one group, an empty group, a group tree that disagrees with the groups, an id changing for the same pane, tab or group. Breaches that heal before a step settles are recorded as `(transient)`. On main the stream publishes a projection of the saved-format session, which already drops ungrouped tabs and stray pane data, so most model rules cannot fire there yet. At each settled step the saved profile (`profile-state.db`) is also checked against the disk-format rules in `workspace-layout-rules.ts` (tab rows and tab bar disagreeing, two tab orders), reported as `<rule>: saved profile: …`. (The unit model test checks every write exactly, via `store.onWorkspaceSessionWritten`.)
|
||||
- **roundTrip**: each saved partition, through the layout module's Loader then Serializer, must reach a fixed point: a second load changes nothing (`workspace-layout-round-trip-check.ts`). This is an idempotence check; its list of change kinds a first load may make is broad. The same check runs in the app on every Store write in dev and e2e, logged as `[workspace-layout] round-trip check:`, with each new kind of first-load change logged as `[workspace-layout] first-load changes:`.
|
||||
- **view**: what the window draws, read from the DOM (`[data-tab-group-strip-id]`, `[data-terminal-tab-id] .pane[data-leaf-id][data-pty-id]`), against the live layout: every group's tab order exactly, every tab of every kind drawn, and each terminal tab's panes. Exited or slept bindings are compared as unbound.
|
||||
- **client**: `session.tabs.list` and `terminal.list` against the live layout: every group and its tab order exactly (which covers editor, browser and agent tabs), terminal panes and custom titles, and running terminals with no pane.
|
||||
- **expected**: panes per tab the step must produce, which panes it removes, and that every other pane still shows the terminal it showed before (a command applied to the wrong pane, or lost). An empty layout cannot pass.
|
||||
- **restart**: the layout after a relaunch, a cold relaunch with the PTY daemon killed, or the in-app daemon restart, against before.
|
||||
- **marker**: an `echo` written to each visible pane's terminal must appear in that pane's xterm accessibility tree (frozen or crossed panes).
|
||||
@@ -58,4 +58,4 @@ Terminal-topology refactor PRs must be inert or fix a named bug. `terminal-layou
|
||||
|
||||
`workspace-layout-oracle-latency.spec.ts` records command-to-drawn-update p50/p95 for split, close pane, new tab, rename, tab reorder and close tab, idle and while another tab floods output. It never fails on a number.
|
||||
|
||||
A step waits up to `ORCA_LAYOUT_ORACLE_SETTLE_MS` (12 s) for every view to agree before recording. Findings main still has are listed in `workspace-layout-oracle-known-on-main.ts` with their bug; anything else fails. A new entry must cite, in its PR, a run on `main` that reproduced the finding; for an intermittent finding, rerun until one does. `ORCA_LAYOUT_ORACLE_RECORD=1` records without failing, `ORCA_LAYOUT_ORACLE_REPEAT=<n>` repeats each scenario, and each run writes a JSON report (with the evidence each side held) to `ORCA_LAYOUT_ORACLE_REPORT_DIR`. `node config/scripts/check-workspace-layout-rules.mjs <userDataDir>` runs the rules over any profile, including a running dev app's.
|
||||
A step waits up to `ORCA_LAYOUT_ORACLE_SETTLE_MS` (12 s) for every view to agree with the live layout, with no new frame across three reads, before recording; the views have no change events, so they are re-read every 300 ms. A difference the known-on-main list covers does not hold the wait, since it never converges. Findings main still has are listed in `workspace-layout-oracle-known-on-main.ts` with their bug; anything else fails. A new entry must cite, in its PR, a run on `main` that reproduced the finding; for an intermittent finding, rerun until one does. `ORCA_LAYOUT_ORACLE_RECORD=1` records without failing, `ORCA_LAYOUT_ORACLE_REPEAT=<n>` repeats each scenario, and each run writes a JSON report (with the evidence each side held) to `ORCA_LAYOUT_ORACLE_REPORT_DIR`. `node config/scripts/check-workspace-layout-rules.mjs <userDataDir>` runs the rules over any profile, including a running dev app's.
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* The two headless `orca serve` hosts the layout oracle drives with no window: orcad (plain Node)
|
||||
* and Electron serve. Both keep their pairing port across restarts so paired clients reconnect,
|
||||
* and a headless runtime's saved profile is the runtime layout the oracle checks.
|
||||
* and Electron serve. Both keep their pairing port across restarts so paired clients and the
|
||||
* oracle's layout stream reconnect.
|
||||
*/
|
||||
|
||||
import { existsSync, mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from 'node:fs'
|
||||
@@ -12,14 +12,9 @@ import { RuntimeClient } from '../../../src/cli/runtime/client'
|
||||
import { resolveBundledOrcadRuntime } from '../../../src/main/orcad/orcad-bundled-runtime'
|
||||
import { runProcess } from '@orca/process-host'
|
||||
import { NODE_RUNTIME_PIN } from '../../../src/shared/node-runtime-pin'
|
||||
import type { WorkspaceSessionState } from '../../../src/shared/workspace-session-state-types'
|
||||
import type { ExecutionHostId } from '../../../src/shared/execution-host'
|
||||
import { cleanupE2EDaemons } from './electron-process-shutdown'
|
||||
import { launchHeadlessPairedRuntimeHost } from './headless-paired-runtime-host'
|
||||
import { cliServeProfile, spawnUntilReady, type ReadyProcess } from './orca-serve-cli-host'
|
||||
import { readPersistedProfileState } from './persisted-profile-state'
|
||||
import { partitionsFromProfileRoot } from './workspace-layout-oracle-views'
|
||||
import type { WorkspaceLayoutPartition } from './workspace-layout-oracle-model'
|
||||
|
||||
export type HeadlessHostKind = 'orcad' | 'electron'
|
||||
|
||||
@@ -202,16 +197,6 @@ export function pairedClient(host: HeadlessOracleHost, name: string): RuntimeCli
|
||||
return new RuntimeClient(dir, 30_000, host.pairingUrl, null)
|
||||
}
|
||||
|
||||
/** The runtime's layout: a headless runtime has no window, so its saved session is the state. */
|
||||
export function readHeadlessPartitions(userDataDir: string): WorkspaceLayoutPartition[] {
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: committed profile documents are read verbatim; the oracle's rules judge their shape rather than a parser salvaging it.
|
||||
const root = readPersistedProfileState(userDataDir) as {
|
||||
workspaceSession?: WorkspaceSessionState
|
||||
workspaceSessionsByHostId?: Partial<Record<ExecutionHostId, WorkspaceSessionState>>
|
||||
}
|
||||
return partitionsFromProfileRoot(root)
|
||||
}
|
||||
|
||||
async function git(cwd: string, args: string[]): Promise<void> {
|
||||
const result = await runProcess({ program: 'git', args, cwd, timeoutMs: 30_000 })
|
||||
if (result.code !== 0) {
|
||||
|
||||
@@ -4,53 +4,56 @@
|
||||
*/
|
||||
|
||||
import { isTerminalOwnerPartition } from '../../../src/shared/workspace-layout/terminal-owner-invariants'
|
||||
import { leafIdsInOrder, type WorkspaceLayoutPartition } from './workspace-layout-oracle-model'
|
||||
import type { LiveWorkspace } from './workspace-layout-oracle-feed'
|
||||
import type { ClientView, DrawnLayout, DrawnTerminalSurface } from './workspace-layout-oracle-views'
|
||||
|
||||
type RuntimeTab = {
|
||||
hostId: string
|
||||
worktreeId: string
|
||||
customTitle: string | null
|
||||
panes: { leafId: string; ptyId: string | null }[]
|
||||
}
|
||||
|
||||
function indexRuntimeTabs(
|
||||
partitions: readonly WorkspaceLayoutPartition[]
|
||||
): Map<string, RuntimeTab[]> {
|
||||
/** Terminal tabs by id; a tab listed in two workspaces keeps both entries. */
|
||||
function indexRuntimeTabs(entries: readonly LiveWorkspace[]): Map<string, RuntimeTab[]> {
|
||||
const tabs = new Map<string, RuntimeTab[]>()
|
||||
for (const { hostId, session } of partitions) {
|
||||
for (const [worktreeId, rows] of Object.entries(session.tabsByWorktree ?? {})) {
|
||||
for (const row of rows) {
|
||||
const layout = session.terminalLayoutsByTabId?.[row.id]
|
||||
const entry: RuntimeTab = {
|
||||
hostId,
|
||||
worktreeId,
|
||||
customTitle: row.customTitle ?? null,
|
||||
panes: leafIdsInOrder(layout?.root).map((leafId) => ({
|
||||
leafId,
|
||||
ptyId: layout?.ptyIdsByLeafId?.[leafId] ?? null
|
||||
}))
|
||||
}
|
||||
tabs.set(row.id, [...(tabs.get(row.id) ?? []), entry])
|
||||
for (const { layout } of entries) {
|
||||
for (const tab of layout.tabs) {
|
||||
if (!tab.terminal) {
|
||||
continue
|
||||
}
|
||||
const entry: RuntimeTab = {
|
||||
worktreeId: layout.worktreeId,
|
||||
customTitle: tab.customTitle,
|
||||
panes: tab.terminal.panes.map((pane) => ({
|
||||
leafId: pane.leafId,
|
||||
ptyId: pane.ptyId ?? null
|
||||
}))
|
||||
}
|
||||
tabs.set(tab.id, [...(tabs.get(tab.id) ?? []), entry])
|
||||
}
|
||||
}
|
||||
return tabs
|
||||
}
|
||||
|
||||
function worktreeEntries(entries: readonly LiveWorkspace[], worktreeId: string): LiveWorkspace[] {
|
||||
return entries.filter((entry) => entry.layout.worktreeId === worktreeId)
|
||||
}
|
||||
|
||||
function runtimeGroups(
|
||||
partitions: readonly WorkspaceLayoutPartition[],
|
||||
entries: readonly LiveWorkspace[],
|
||||
worktreeId: string
|
||||
): Map<string, string[]> {
|
||||
const groups = new Map<string, string[]>()
|
||||
for (const { session } of partitions) {
|
||||
for (const group of session.tabGroups?.[worktreeId] ?? []) {
|
||||
groups.set(group.id, group.tabOrder)
|
||||
for (const { layout } of worktreeEntries(entries, worktreeId)) {
|
||||
for (const group of layout.groups) {
|
||||
groups.set(group.id, group.tabIds)
|
||||
}
|
||||
}
|
||||
return groups
|
||||
}
|
||||
|
||||
const orderText = (order: readonly string[]): string => JSON.stringify(order)
|
||||
|
||||
/** A binding to a terminal that is no longer running is a resume hint, not something shown. */
|
||||
function shownPanes(
|
||||
panes: readonly { leafId: string; ptyId: string | null }[],
|
||||
@@ -79,11 +82,11 @@ function surfacesByTab(drawn: DrawnLayout): DrawnTerminalSurface[] {
|
||||
|
||||
/** The window draws the layouts of the partitions it owns (local and SSH); a remote mirror is not checked. */
|
||||
export function compareDrawnToRuntime(
|
||||
partitions: readonly WorkspaceLayoutPartition[],
|
||||
entries: readonly LiveWorkspace[],
|
||||
drawn: DrawnLayout,
|
||||
livePtyIds: ReadonlySet<string>
|
||||
): string[] {
|
||||
const owned = partitions.filter((partition) => isTerminalOwnerPartition(partition.hostId))
|
||||
const owned = entries.filter((entry) => isTerminalOwnerPartition(entry.hostId))
|
||||
const differences: string[] = []
|
||||
const tabs = indexRuntimeTabs(owned)
|
||||
const drawnWorktrees = new Set(drawn.strips.map((strip) => strip.worktreeId))
|
||||
@@ -94,9 +97,9 @@ export function compareDrawnToRuntime(
|
||||
const order = groups.get(strip.groupId)
|
||||
if (!order) {
|
||||
differences.push(`drawn group ${strip.groupId} is not in the runtime (${worktreeId})`)
|
||||
} else if (JSON.stringify(order) !== JSON.stringify(strip.tabIds)) {
|
||||
} else if (orderText(order) !== orderText(strip.tabIds)) {
|
||||
differences.push(
|
||||
`group ${strip.groupId}: runtime order ${JSON.stringify(order)} but drawn ${JSON.stringify(strip.tabIds)}`
|
||||
`group ${strip.groupId}: runtime order ${orderText(order)} but drawn ${orderText(strip.tabIds)}`
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -105,21 +108,20 @@ export function compareDrawnToRuntime(
|
||||
differences.push(`runtime group ${groupId} is not drawn (${worktreeId})`)
|
||||
}
|
||||
}
|
||||
for (const [tabId, entries] of tabs) {
|
||||
const listed = strips.some((strip) => strip.tabIds.includes(tabId))
|
||||
if (entries.some((entry) => entry.worktreeId === worktreeId) && !listed) {
|
||||
differences.push(`runtime terminal tab ${tabId} has no drawn tab (${worktreeId})`)
|
||||
for (const tab of worktreeEntries(owned, worktreeId).flatMap(({ layout }) => layout.tabs)) {
|
||||
if (!strips.some((strip) => strip.tabIds.includes(tab.id))) {
|
||||
differences.push(`runtime ${tab.kind} tab ${tab.id} has no drawn tab (${worktreeId})`)
|
||||
}
|
||||
}
|
||||
}
|
||||
const shownBy = new Map<string, string>()
|
||||
for (const surface of surfacesByTab(drawn)) {
|
||||
const entries = tabs.get(surface.tabId) ?? []
|
||||
if (entries.length === 0) {
|
||||
const runtime = tabs.get(surface.tabId) ?? []
|
||||
if (runtime.length === 0) {
|
||||
differences.push(`drawn terminal tab ${surface.tabId} is not in the runtime`)
|
||||
} else {
|
||||
// Compared on running terminals only; both sides may keep or drop an exited binding.
|
||||
const want = panesText(shownPanes(entries[0]!.panes, livePtyIds))
|
||||
const want = panesText(shownPanes(runtime[0]!.panes, livePtyIds))
|
||||
const got = panesText(shownPanes(surface.panes, livePtyIds))
|
||||
if (want !== got) {
|
||||
differences.push(`tab ${surface.tabId}: runtime panes ${want} but drawn ${got}`)
|
||||
@@ -141,17 +143,19 @@ export function compareDrawnToRuntime(
|
||||
|
||||
/** What a paired client (session tabs) and the CLI (terminal list) are told for one worktree. */
|
||||
export function compareClientToRuntime(
|
||||
partitions: readonly WorkspaceLayoutPartition[],
|
||||
entries: readonly LiveWorkspace[],
|
||||
view: ClientView
|
||||
): string[] {
|
||||
if (view.error || !view.tabs) {
|
||||
return [`client view of ${view.worktreeId} failed: ${view.error ?? 'no snapshot'}`]
|
||||
}
|
||||
const differences: string[] = []
|
||||
const tabs = indexRuntimeTabs(partitions)
|
||||
const tabs = indexRuntimeTabs(entries)
|
||||
const runtimePanes = new Map<string, string | null>()
|
||||
for (const [tabId, entries] of tabs) {
|
||||
for (const entry of entries.filter((candidate) => candidate.worktreeId === view.worktreeId)) {
|
||||
for (const [tabId, tabEntries] of tabs) {
|
||||
for (const entry of tabEntries.filter(
|
||||
(candidate) => candidate.worktreeId === view.worktreeId
|
||||
)) {
|
||||
for (const pane of entry.panes) {
|
||||
runtimePanes.set(`${tabId}:${pane.leafId}`, pane.ptyId)
|
||||
}
|
||||
@@ -186,14 +190,37 @@ export function compareClientToRuntime(
|
||||
differences.push(`client pane ${key} is not in the runtime`)
|
||||
}
|
||||
}
|
||||
const groups = runtimeGroups(partitions, view.worktreeId)
|
||||
for (const group of view.tabs.tabGroups ?? []) {
|
||||
const order = groups.get(group.id)
|
||||
if (order && JSON.stringify(order) !== JSON.stringify(group.tabOrder)) {
|
||||
differences.push(
|
||||
`group ${group.id}: runtime order ${JSON.stringify(order)} but client ${JSON.stringify(group.tabOrder)}`
|
||||
)
|
||||
// Groups carry every tab kind, so exact group orders compare editor, browser and agent tabs too.
|
||||
const groups = runtimeGroups(entries, view.worktreeId)
|
||||
const clientGroups = new Map(
|
||||
(view.tabs.tabGroups ?? []).map((group) => [group.id, group.tabOrder])
|
||||
)
|
||||
const unmatchedRuntime = new Map([...groups].filter(([groupId]) => !clientGroups.has(groupId)))
|
||||
for (const [groupId, order] of clientGroups) {
|
||||
const want = groups.get(groupId)
|
||||
if (want) {
|
||||
if (orderText(want) !== orderText(order)) {
|
||||
differences.push(
|
||||
`group ${groupId}: runtime order ${orderText(want)} but client ${orderText(order)}`
|
||||
)
|
||||
}
|
||||
continue
|
||||
}
|
||||
// The same tabs in the same order under another id is a renamed group, not a lost one.
|
||||
const renamed = [...unmatchedRuntime].find(
|
||||
([, runtime]) => orderText(runtime) === orderText(order)
|
||||
)
|
||||
if (renamed) {
|
||||
unmatchedRuntime.delete(renamed[0])
|
||||
differences.push(
|
||||
`group id differs: runtime ${renamed[0]}, client ${groupId} (tabs ${orderText(order)})`
|
||||
)
|
||||
} else {
|
||||
differences.push(`client group ${groupId} is not in the runtime`)
|
||||
}
|
||||
}
|
||||
for (const groupId of unmatchedRuntime.keys()) {
|
||||
differences.push(`runtime group ${groupId} is missing from the client`)
|
||||
}
|
||||
for (const terminal of view.terminals) {
|
||||
const key = `${terminal.tabId}:${terminal.leafId}`
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
/**
|
||||
* The runtime's live layout as the oracle reads it: a `layout.subscribe` consumer's cache, applied
|
||||
* frame by frame, and the same layout as models for the model rules.
|
||||
*/
|
||||
|
||||
import { LOCAL_EXECUTION_HOST_ID, type ExecutionHostId } from '../../../src/shared/execution-host'
|
||||
import type {
|
||||
LayoutLeaf,
|
||||
LayoutTab,
|
||||
WorkspaceLayout,
|
||||
WorkspaceLayoutModel
|
||||
} from '../../../src/shared/workspace-layout/workspace-layout-model'
|
||||
import type { PublishedWorkspaceLayout } from '../../../src/shared/workspace-layout/workspace-layout-published'
|
||||
import {
|
||||
readWorkspaceLayoutStreamFrame,
|
||||
type WorkspaceLayoutChangeFrame
|
||||
} from '../../../src/shared/workspace-layout/workspace-layout-stream-frames'
|
||||
|
||||
/** One published workspace and the partition it belongs to. */
|
||||
export type LiveWorkspace = {
|
||||
key: string
|
||||
hostId: ExecutionHostId
|
||||
layout: PublishedWorkspaceLayout
|
||||
}
|
||||
|
||||
// A frame names no partition; a terminal tab runs on its partition, and only panes need one.
|
||||
function partitionOf(layout: PublishedWorkspaceLayout): ExecutionHostId {
|
||||
return (
|
||||
layout.tabs.find((tab) => tab.kind === 'terminal')?.executionHostId ?? LOCAL_EXECUTION_HOST_ID
|
||||
)
|
||||
}
|
||||
|
||||
export class LiveLayoutFeed {
|
||||
private workspaces = new Map<string, PublishedWorkspaceLayout>()
|
||||
/** False until the first snapshot frame, and again while a stream is reopened. */
|
||||
ready = false
|
||||
/** Frames applied so far; a step reads it to tell whether the runtime moved during a read. */
|
||||
frames = 0
|
||||
/** Why the stream failed, if it did; the step reports it. */
|
||||
error: string | null = null
|
||||
|
||||
constructor(private readonly onFrame: (feed: LiveLayoutFeed) => void) {}
|
||||
|
||||
/** Applies one `layout.subscribe` result; a malformed one is kept as a stream error. */
|
||||
apply(result: unknown): void {
|
||||
const frame = readWorkspaceLayoutStreamFrame(result)
|
||||
if (!frame) {
|
||||
this.fail(`unreadable layout frame: ${JSON.stringify(result)}`)
|
||||
return
|
||||
}
|
||||
if (frame.type === 'end') {
|
||||
this.ready = false
|
||||
return
|
||||
}
|
||||
this.applyChange(frame)
|
||||
this.frames += 1
|
||||
this.onFrame(this)
|
||||
}
|
||||
|
||||
private applyChange(frame: WorkspaceLayoutChangeFrame): void {
|
||||
switch (frame.type) {
|
||||
case 'snapshot':
|
||||
this.workspaces = new Map(frame.workspaces.map((entry) => [entry.key, entry.layout]))
|
||||
this.ready = true
|
||||
return
|
||||
case 'workspace':
|
||||
this.workspaces.set(frame.key, frame.layout)
|
||||
return
|
||||
case 'removed':
|
||||
this.workspaces.delete(frame.key)
|
||||
}
|
||||
}
|
||||
|
||||
fail(message: string): void {
|
||||
this.error = message
|
||||
this.ready = false
|
||||
}
|
||||
|
||||
/** The stream is being reopened (relaunch, reload, serve restart); its snapshot replaces the cache. */
|
||||
reopening(): void {
|
||||
this.ready = false
|
||||
this.error = null
|
||||
}
|
||||
|
||||
entries(): LiveWorkspace[] {
|
||||
return [...this.workspaces].map(([key, layout]) => ({
|
||||
key,
|
||||
hostId: partitionOf(layout),
|
||||
layout
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
function toLayoutTab(tab: PublishedWorkspaceLayout['tabs'][number]): LayoutTab {
|
||||
const fields = {
|
||||
id: tab.id,
|
||||
entityId: tab.entityId,
|
||||
createdAt: tab.createdAt,
|
||||
customTitle: tab.customTitle,
|
||||
color: tab.color
|
||||
}
|
||||
if (tab.kind !== 'terminal') {
|
||||
return { ...fields, kind: tab.kind }
|
||||
}
|
||||
return {
|
||||
...fields,
|
||||
kind: 'terminal',
|
||||
terminal: tab.terminal?.defaultTitle ? { defaultTitle: tab.terminal.defaultTitle } : {},
|
||||
panes: {
|
||||
root: tab.terminal?.root ?? null,
|
||||
...(tab.terminal?.chatLeafId ? { chatLeafId: tab.terminal.chatLeafId } : {})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function toWorkspaceLayout(layout: PublishedWorkspaceLayout): WorkspaceLayout {
|
||||
const leaves: Record<string, LayoutLeaf> = {}
|
||||
for (const pane of layout.tabs.flatMap((tab) => tab.terminal?.panes ?? [])) {
|
||||
leaves[pane.leafId] = {
|
||||
...(pane.ptyId ? { ptyId: pane.ptyId } : {}),
|
||||
...(pane.title ? { title: pane.title } : {})
|
||||
}
|
||||
}
|
||||
return {
|
||||
worktreeId: layout.worktreeId,
|
||||
tabs: layout.tabs.map(toLayoutTab),
|
||||
groups: layout.groups.map((group) => ({ id: group.id, tabOrder: [...group.tabIds] })),
|
||||
...(layout.groupLayout ? { groupLayout: layout.groupLayout } : {}),
|
||||
leaves
|
||||
}
|
||||
}
|
||||
|
||||
/** The published workspaces as one model per partition, for the model rules. */
|
||||
export function liveLayoutModels(entries: readonly LiveWorkspace[]): WorkspaceLayoutModel[] {
|
||||
const models = new Map<ExecutionHostId, WorkspaceLayoutModel>()
|
||||
for (const { key, hostId, layout } of entries) {
|
||||
const model = models.get(hostId) ?? {
|
||||
hostId,
|
||||
workspaces: {},
|
||||
records: {},
|
||||
legacy: { terminalRowOwners: {} }
|
||||
}
|
||||
model.workspaces[key] = toWorkspaceLayout(layout)
|
||||
models.set(hostId, model)
|
||||
}
|
||||
return [...models.values()]
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { PublishedWorkspaceLayout } from '../../../src/shared/workspace-layout/workspace-layout-published'
|
||||
import { checkWorkspaceLayoutModelRules } from '../../../src/shared/workspace-layout/workspace-layout-model-rules'
|
||||
import { LiveLayoutFeed, liveLayoutModels } from './workspace-layout-oracle-feed'
|
||||
|
||||
function terminalTab(id: string, leafId: string, ptyId: string) {
|
||||
return {
|
||||
id,
|
||||
kind: 'terminal' as const,
|
||||
entityId: id,
|
||||
executionHostId: 'local' as const,
|
||||
createdAt: 1,
|
||||
customTitle: null,
|
||||
color: null,
|
||||
terminal: { root: { type: 'leaf' as const, leafId }, panes: [{ leafId, ptyId }] }
|
||||
}
|
||||
}
|
||||
|
||||
function workspace(
|
||||
tabs: PublishedWorkspaceLayout['tabs'],
|
||||
groups: PublishedWorkspaceLayout['groups']
|
||||
): PublishedWorkspaceLayout {
|
||||
return { worktreeId: 'wt', groups, tabs, editorFiles: [], browserTabs: [] }
|
||||
}
|
||||
|
||||
describe('LiveLayoutFeed', () => {
|
||||
it('applies snapshot, workspace and removed frames and counts them', () => {
|
||||
const seen: number[] = []
|
||||
const feed = new LiveLayoutFeed((current) => seen.push(current.entries().length))
|
||||
const one = workspace([terminalTab('t1', 'l1', 'p1')], [{ id: 'g1', tabIds: ['t1'] }])
|
||||
feed.apply({ type: 'snapshot', workspaces: [{ key: 'wt', layout: one }] })
|
||||
feed.apply({ type: 'workspace', key: 'wt2', layout: { ...one, worktreeId: 'wt2' } })
|
||||
feed.apply({ type: 'removed', key: 'wt' })
|
||||
expect(seen).toEqual([1, 2, 1])
|
||||
expect(feed.frames).toBe(3)
|
||||
expect(feed.entries().map((entry) => [entry.key, entry.hostId])).toEqual([['wt2', 'local']])
|
||||
})
|
||||
|
||||
it('keeps an unreadable frame as a stream error', () => {
|
||||
const feed = new LiveLayoutFeed(() => {})
|
||||
feed.apply({ type: 'snapshot', workspaces: [] })
|
||||
feed.apply({ type: 'workspace' })
|
||||
expect(feed.ready).toBe(false)
|
||||
expect(feed.error).toMatch(/unreadable layout frame/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('liveLayoutModels', () => {
|
||||
it('lets the model rules see a breach a frame carries', () => {
|
||||
const feed = new LiveLayoutFeed(() => {})
|
||||
const layout = workspace(
|
||||
[terminalTab('t1', 'l1', 'p1'), terminalTab('t2', 'l2', 'p1')],
|
||||
[
|
||||
{ id: 'g1', tabIds: ['t1', 't2'] },
|
||||
{ id: 'g2', tabIds: ['t2'] }
|
||||
]
|
||||
)
|
||||
feed.apply({ type: 'snapshot', workspaces: [{ key: 'wt', layout }] })
|
||||
const rules = checkWorkspaceLayoutModelRules(liveLayoutModels(feed.entries())).map(
|
||||
(violation) => violation.rule
|
||||
)
|
||||
expect(rules).toEqual(
|
||||
expect.arrayContaining(['terminal_in_two_panes', 'tab_in_two_groups', 'group_tree_mismatch'])
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -1,22 +1,30 @@
|
||||
/**
|
||||
* The workspace tab layout reduced to what every view must agree on: groups and their tab order,
|
||||
* terminal tabs, their panes in tree order, which terminal each pane shows, and custom titles.
|
||||
* Per-viewer state (selection, focus, sizes, scroll) is left out on purpose.
|
||||
* every tab of every kind, terminal tabs' panes in tree order, which terminal each pane shows, and
|
||||
* custom titles. Per-viewer state (selection, focus, sizes, scroll) is left out on purpose.
|
||||
*/
|
||||
|
||||
import type { ExecutionHostId } from '../../../src/shared/execution-host'
|
||||
import type { TerminalPaneLayoutNode } from '../../../src/shared/terminal-tab-types'
|
||||
import type { WorkspaceSessionState } from '../../../src/shared/workspace-session-state-types'
|
||||
import {
|
||||
checkWorkspaceLayoutRules,
|
||||
type WorkspaceLayoutPartition,
|
||||
type WorkspaceLayoutViolation
|
||||
} from '../../../src/shared/workspace-layout/workspace-layout-rules'
|
||||
import { checkWorkspaceLayoutModelRules } from '../../../src/shared/workspace-layout/workspace-layout-model-rules'
|
||||
import type { LiveWorkspace } from './workspace-layout-oracle-feed'
|
||||
|
||||
export type OraclePane = { leafId: string; ptyId: string | null }
|
||||
export type OracleTab = { id: string; kind: string; entityId: string; customTitle: string | null }
|
||||
export type OracleTerminalTab = { id: string; customTitle: string | null; panes: OraclePane[] }
|
||||
export type OracleGroup = { id: string; tabOrder: string[] }
|
||||
export type OracleWorktreeLayout = { groups: OracleGroup[]; terminalTabs: OracleTerminalTab[] }
|
||||
export type OracleWorktreeLayout = {
|
||||
groups: OracleGroup[]
|
||||
/** Every tab, all kinds, in the one tab order. */
|
||||
tabs: OracleTab[]
|
||||
/** In the one tab order. */
|
||||
terminalTabs: OracleTerminalTab[]
|
||||
}
|
||||
/** Keyed by `${hostId}|${worktreeId}`. */
|
||||
export type OracleLayout = Record<string, OracleWorktreeLayout>
|
||||
|
||||
@@ -33,39 +41,39 @@ export function leafIdsInOrder(node: TerminalPaneLayoutNode | null | undefined):
|
||||
: [...leafIdsInOrder(node.first), ...leafIdsInOrder(node.second)]
|
||||
}
|
||||
|
||||
function worktreeLayout(session: WorkspaceSessionState, worktreeId: string): OracleWorktreeLayout {
|
||||
function worktreeLayout({ layout }: LiveWorkspace): OracleWorktreeLayout {
|
||||
return {
|
||||
groups: (session.tabGroups?.[worktreeId] ?? []).map((group) => ({
|
||||
id: group.id,
|
||||
tabOrder: [...group.tabOrder]
|
||||
groups: layout.groups.map((group) => ({ id: group.id, tabOrder: [...group.tabIds] })),
|
||||
tabs: layout.tabs.map((tab) => ({
|
||||
id: tab.id,
|
||||
kind: tab.kind,
|
||||
entityId: tab.entityId,
|
||||
customTitle: tab.customTitle
|
||||
})),
|
||||
terminalTabs: (session.tabsByWorktree[worktreeId] ?? []).map((tab) => {
|
||||
const layout = session.terminalLayoutsByTabId[tab.id]
|
||||
return {
|
||||
id: tab.id,
|
||||
customTitle: tab.customTitle ?? null,
|
||||
panes: leafIdsInOrder(layout?.root).map((leafId) => ({
|
||||
leafId,
|
||||
ptyId: layout?.ptyIdsByLeafId?.[leafId] ?? null
|
||||
}))
|
||||
}
|
||||
})
|
||||
terminalTabs: layout.tabs.flatMap((tab) =>
|
||||
tab.terminal
|
||||
? [
|
||||
{
|
||||
id: tab.id,
|
||||
customTitle: tab.customTitle,
|
||||
panes: tab.terminal.panes.map((pane) => ({
|
||||
leafId: pane.leafId,
|
||||
ptyId: pane.ptyId ?? null
|
||||
}))
|
||||
}
|
||||
]
|
||||
: []
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** Every worktree with any tab or group, across the runtime's partitions. */
|
||||
export function toOracleLayout(partitions: readonly WorkspaceLayoutPartition[]): OracleLayout {
|
||||
/** Every workspace with any tab or group in the runtime's live layout. */
|
||||
export function toOracleLayout(entries: readonly LiveWorkspace[]): OracleLayout {
|
||||
const layout: OracleLayout = {}
|
||||
for (const { hostId, session } of partitions) {
|
||||
const worktreeIds = new Set([
|
||||
...Object.keys(session.tabsByWorktree ?? {}),
|
||||
...Object.keys(session.tabGroups ?? {})
|
||||
])
|
||||
for (const worktreeId of worktreeIds) {
|
||||
const entry = worktreeLayout(session, worktreeId)
|
||||
if (entry.terminalTabs.length > 0 || entry.groups.some((group) => group.tabOrder.length)) {
|
||||
layout[oracleWorktreeKey(hostId, worktreeId)] = entry
|
||||
}
|
||||
for (const entry of entries) {
|
||||
const worktree = worktreeLayout(entry)
|
||||
if (worktree.tabs.length > 0 || worktree.groups.some((group) => group.tabOrder.length)) {
|
||||
layout[oracleWorktreeKey(entry.hostId, entry.layout.worktreeId)] = worktree
|
||||
}
|
||||
}
|
||||
return layout
|
||||
@@ -75,7 +83,12 @@ export function formatViolations(violations: readonly WorkspaceLayoutViolation[]
|
||||
return violations.map((violation) => `${violation.rule}: ${violation.detail}`)
|
||||
}
|
||||
|
||||
export { checkWorkspaceLayoutRules, type WorkspaceLayoutPartition, type WorkspaceLayoutViolation }
|
||||
export {
|
||||
checkWorkspaceLayoutModelRules,
|
||||
checkWorkspaceLayoutRules,
|
||||
type WorkspaceLayoutPartition,
|
||||
type WorkspaceLayoutViolation
|
||||
}
|
||||
|
||||
/** One line per difference between two layouts; `maskPtyIds` compares bound/unbound only. */
|
||||
export function diffOracleLayouts(
|
||||
@@ -96,10 +109,20 @@ export function diffOracleLayouts(
|
||||
}
|
||||
const groups = (layout: OracleWorktreeLayout): string =>
|
||||
JSON.stringify(layout.groups.map((group) => [group.id, group.tabOrder]))
|
||||
// Tab order is compared through the groups; the tab lists below are sorted.
|
||||
if (groups(want) !== groups(got)) {
|
||||
differences.push(`${key}: groups ${groups(want)} vs ${label} ${groups(got)}`)
|
||||
}
|
||||
// Row order is not drawn anywhere; the rules check owns it (one tab order).
|
||||
const contents = (layout: OracleWorktreeLayout): string =>
|
||||
JSON.stringify(
|
||||
layout.tabs
|
||||
.filter((tab) => tab.kind !== 'terminal')
|
||||
.toSorted((a, b) => a.id.localeCompare(b.id))
|
||||
.map((tab) => [tab.id, tab.kind, tab.entityId, tab.customTitle])
|
||||
)
|
||||
if (contents(want) !== contents(got)) {
|
||||
differences.push(`${key}: other tabs ${contents(want)} vs ${label} ${contents(got)}`)
|
||||
}
|
||||
const tabs = (layout: OracleWorktreeLayout): string =>
|
||||
JSON.stringify(
|
||||
layout.terminalTabs
|
||||
|
||||
@@ -13,7 +13,9 @@ import { createRestartSession } from './orca-restart'
|
||||
import { bootstrapRestoredLaunch } from './terminal-restart-persistence'
|
||||
import { waitForPaneCount } from './terminal'
|
||||
import { LayoutOracle, type OracleFinding } from './workspace-layout-oracle'
|
||||
import { readRuntimePartitions } from './workspace-layout-oracle-views'
|
||||
import { LiveLayoutFeed } from './workspace-layout-oracle-feed'
|
||||
import { subscribeWindowLayout } from './workspace-layout-oracle-stream'
|
||||
import { readSavedPartitions } from './workspace-layout-oracle-views'
|
||||
import { summarizeEventLoopDelay, type EventLoopDelaySummary } from './event-loop-delay-report'
|
||||
|
||||
export const ORACLE_REPORT_DIR_ENV = 'ORCA_LAYOUT_ORACLE_REPORT_DIR'
|
||||
@@ -77,10 +79,16 @@ export async function runOracleScenario(
|
||||
try {
|
||||
const first = await session.launch()
|
||||
app = first.app
|
||||
const feed = new LiveLayoutFeed((current) => run.oracle.checkFrame(current))
|
||||
const target = (page: Page, client: RuntimeClient) => ({
|
||||
page,
|
||||
client,
|
||||
readPartitions: () => readRuntimePartitions(page),
|
||||
feed,
|
||||
readSavedPartitions: async () => {
|
||||
// Main debounces saves by up to 5 s; flushing makes the read the state main holds now.
|
||||
await page.evaluate(() => window.api.session.flush())
|
||||
return readSavedPartitions(session.userDataDir)
|
||||
},
|
||||
worktreeIds: () => worktreeIds
|
||||
})
|
||||
const client = new RuntimeClient(session.userDataDir, 30_000)
|
||||
@@ -88,11 +96,12 @@ export async function runOracleScenario(
|
||||
page: first.page,
|
||||
userDataDir: session.userDataDir,
|
||||
client,
|
||||
oracle: new LayoutOracle(target(first.page, client)),
|
||||
oracle: new LayoutOracle(target(first.page, client), scenarioId),
|
||||
worktreeIds,
|
||||
reloadWindow: async () => {
|
||||
await run.page.reload()
|
||||
await run.page.waitForFunction(() => Boolean(window.__store), null, { timeout: 30_000 })
|
||||
await subscribeWindowLayout(run.page, feed)
|
||||
await run.oracle.retarget(target(run.page, run.client))
|
||||
},
|
||||
relaunch: async (options) => {
|
||||
@@ -111,6 +120,7 @@ export async function runOracleScenario(
|
||||
app = next.app
|
||||
run.page = next.page
|
||||
run.client = new RuntimeClient(session.userDataDir, 30_000)
|
||||
await subscribeWindowLayout(next.page, feed)
|
||||
await run.oracle.retarget(target(next.page, run.client))
|
||||
await (reopen ?? bootstrapRestoredLaunch)(next.page, worktreeId)
|
||||
await waitForPaneCount(next.page, paneCount, 30_000).catch(() => {})
|
||||
@@ -127,12 +137,9 @@ export async function runOracleScenario(
|
||||
}
|
||||
}
|
||||
}
|
||||
await subscribeWindowLayout(first.page, feed)
|
||||
await run.oracle.start()
|
||||
try {
|
||||
await body(run)
|
||||
} finally {
|
||||
await run.oracle.stop()
|
||||
}
|
||||
await body(run)
|
||||
writeReport(testInfo, scenarioId, {
|
||||
findings: run.oracle.findings,
|
||||
viewCreations: run.oracle.viewCreations.at(-1) ?? {},
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
/**
|
||||
* Opens the runtime's `layout.subscribe` stream for the oracle: in the window's renderer through
|
||||
* the local runtime bridge, or on a headless serve host over its pairing offer.
|
||||
*/
|
||||
|
||||
import type { Page } from '@stablyai/playwright-test'
|
||||
import { decodePairingOffer } from '../../../src/shared/pairing'
|
||||
import { subscribeRemoteRuntimeRequest } from '../../../src/shared/remote-runtime-client'
|
||||
import type { RuntimeRpcResponse } from '../../../src/shared/runtime-rpc-envelope'
|
||||
import type { LiveLayoutFeed } from './workspace-layout-oracle-feed'
|
||||
|
||||
declare global {
|
||||
// oxlint-disable-next-line typescript-eslint/consistent-type-definitions -- declaration merging requires interface
|
||||
interface Window {
|
||||
/** Forwards each layout stream response from the renderer to the oracle. */
|
||||
__oracleLayoutFrame?: (response: RuntimeRpcResponse<unknown>) => Promise<void>
|
||||
}
|
||||
}
|
||||
|
||||
function applyResponse(feed: LiveLayoutFeed, response: RuntimeRpcResponse<unknown>): void {
|
||||
if (response.ok) {
|
||||
feed.apply(response.result)
|
||||
} else {
|
||||
feed.fail(`layout stream error: ${response.error.code}: ${response.error.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
// Bindings survive a reload, and a second registration on one page throws.
|
||||
const boundPages = new WeakSet<Page>()
|
||||
|
||||
/** Subscribes in the window's renderer; call again after a reload, which ends the old stream. */
|
||||
export async function subscribeWindowLayout(page: Page, feed: LiveLayoutFeed): Promise<void> {
|
||||
feed.reopening()
|
||||
if (!boundPages.has(page)) {
|
||||
boundPages.add(page)
|
||||
await page.exposeFunction('__oracleLayoutFrame', (response: RuntimeRpcResponse<unknown>) =>
|
||||
applyResponse(feed, response)
|
||||
)
|
||||
}
|
||||
await page.evaluate(async () => {
|
||||
await window.api.runtime.subscribe({ method: 'layout.subscribe', params: {} }, (response) => {
|
||||
void window.__oracleLayoutFrame?.(response)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/** Subscribes over a headless host's pairing offer; a serve restart ends it, so reopen after one. */
|
||||
export async function subscribeHeadlessLayout(
|
||||
pairingUrl: string,
|
||||
feed: LiveLayoutFeed
|
||||
): Promise<{ close: () => void }> {
|
||||
feed.reopening()
|
||||
const subscription = await subscribeRemoteRuntimeRequest<unknown>(
|
||||
decodePairingOffer(pairingUrl),
|
||||
'layout.subscribe',
|
||||
{},
|
||||
30_000,
|
||||
{
|
||||
onResponse: (response) => applyResponse(feed, response),
|
||||
onError: (error) => feed.fail(`layout stream error: ${error.message}`),
|
||||
onClose: () => feed.reopening()
|
||||
}
|
||||
)
|
||||
return { close: () => subscription.close() }
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* Readers for each place the workspace layout shows up: the runtime's own copy, what the window
|
||||
* draws (DOM), and what a paired client or the CLI is told (runtime RPC).
|
||||
* Readers for each place the workspace layout shows up besides the live stream: the saved profile,
|
||||
* what the window draws (DOM), and what a paired client or the CLI is told (runtime RPC).
|
||||
*/
|
||||
|
||||
import type { Page } from '@stablyai/playwright-test'
|
||||
@@ -10,29 +10,19 @@ import {
|
||||
normalizeExecutionHostId,
|
||||
type ExecutionHostId
|
||||
} from '../../../src/shared/execution-host'
|
||||
import { readPersistedProfileState } from './persisted-profile-state'
|
||||
import type { RuntimeMobileSessionTabsResult } from '../../../src/shared/runtime-session-contracts'
|
||||
import type { RuntimeTerminalListResult } from '../../../src/shared/runtime-terminal-contracts'
|
||||
import type { WorkspaceSessionState } from '../../../src/shared/workspace-session-state-types'
|
||||
import type { WorkspaceLayoutPartition } from './workspace-layout-oracle-model'
|
||||
|
||||
/** Main's in-memory session for every partition, through the read-only session IPC. */
|
||||
export function readRuntimePartitions(page: Page): Promise<WorkspaceLayoutPartition[]> {
|
||||
return page.evaluate(async () => {
|
||||
const hostIds = await window.api.session.listHostIds()
|
||||
const ids: ExecutionHostId[] = hostIds.includes('local') ? hostIds : ['local', ...hostIds]
|
||||
const partitions: WorkspaceLayoutPartition[] = []
|
||||
for (const hostId of ids) {
|
||||
partitions.push({ hostId, session: await window.api.session.get(hostId) })
|
||||
}
|
||||
return partitions
|
||||
})
|
||||
}
|
||||
|
||||
/** The saved profile's partitions, for a runtime with no window (or a stopped one). */
|
||||
export function partitionsFromProfileRoot(root: {
|
||||
type ProfileRoot = {
|
||||
workspaceSession?: WorkspaceSessionState
|
||||
workspaceSessionsByHostId?: Partial<Record<ExecutionHostId, WorkspaceSessionState>>
|
||||
}): WorkspaceLayoutPartition[] {
|
||||
}
|
||||
|
||||
/** The partitions of a saved profile document. */
|
||||
export function partitionsFromProfileRoot(root: ProfileRoot): WorkspaceLayoutPartition[] {
|
||||
const byHost: WorkspaceLayoutPartition[] = Object.entries(
|
||||
root.workspaceSessionsByHostId ?? {}
|
||||
).flatMap(([id, session]) => {
|
||||
@@ -45,6 +35,12 @@ export function partitionsFromProfileRoot(root: {
|
||||
return [...local, ...byHost]
|
||||
}
|
||||
|
||||
/** What the runtime last saved to its profile, running or stopped. */
|
||||
export function readSavedPartitions(userDataDir: string): WorkspaceLayoutPartition[] {
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: committed profile documents are read verbatim; the oracle's rules judge their shape rather than a parser salvaging it.
|
||||
return partitionsFromProfileRoot(readPersistedProfileState(userDataDir) as ProfileRoot)
|
||||
}
|
||||
|
||||
export type DrawnPane = { leafId: string; ptyId: string | null }
|
||||
export type DrawnTerminalSurface = {
|
||||
tabId: string
|
||||
|
||||
@@ -1,17 +1,9 @@
|
||||
/**
|
||||
* Watches a scenario between checkpoints: samples the runtime's layout against the rules every
|
||||
* 100 ms (so a breach that heals before the next step still counts), and counts how many times
|
||||
* each pane's terminal view (its xterm) is created in the window.
|
||||
* Counts how many times each pane's terminal view (its xterm) is created in the window, so a
|
||||
* remount outside a relaunch, reload or daemon restart fails.
|
||||
*/
|
||||
|
||||
import type { Page } from '@stablyai/playwright-test'
|
||||
import {
|
||||
checkWorkspaceLayoutRules,
|
||||
formatViolations,
|
||||
type WorkspaceLayoutPartition
|
||||
} from './workspace-layout-oracle-model'
|
||||
|
||||
const SAMPLE_MS = 100
|
||||
|
||||
declare global {
|
||||
// oxlint-disable-next-line typescript-eslint/consistent-type-definitions -- declaration merging requires interface
|
||||
@@ -21,54 +13,6 @@ declare global {
|
||||
}
|
||||
}
|
||||
|
||||
export class LayoutRuleSampler {
|
||||
private running = false
|
||||
private loop: Promise<void> | null = null
|
||||
/** Breach line → the step label current when it was first seen. */
|
||||
readonly seen = new Map<string, string>()
|
||||
label = 'start'
|
||||
samples = 0
|
||||
|
||||
constructor(private readPartitions: () => Promise<WorkspaceLayoutPartition[]>) {}
|
||||
|
||||
retarget(readPartitions: () => Promise<WorkspaceLayoutPartition[]>): void {
|
||||
this.readPartitions = readPartitions
|
||||
}
|
||||
|
||||
start(): void {
|
||||
this.running = true
|
||||
this.loop = (async () => {
|
||||
while (this.running) {
|
||||
// A read can fail while the app quits or relaunches; the next sample retries.
|
||||
const partitions = await this.readPartitions().catch(() => null)
|
||||
if (partitions) {
|
||||
this.samples += 1
|
||||
for (const line of formatViolations(checkWorkspaceLayoutRules(partitions))) {
|
||||
if (!this.seen.has(line)) {
|
||||
this.seen.set(line, this.label)
|
||||
}
|
||||
}
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, SAMPLE_MS))
|
||||
}
|
||||
})()
|
||||
}
|
||||
|
||||
async stop(): Promise<void> {
|
||||
this.running = false
|
||||
await this.loop
|
||||
}
|
||||
|
||||
/** Breaches first seen while `label` was current, removed from the pending set. */
|
||||
take(label: string): string[] {
|
||||
const lines = [...this.seen].filter(([, seenAt]) => seenAt === label).map(([line]) => line)
|
||||
for (const line of lines) {
|
||||
this.seen.set(line, `${label} (reported)`)
|
||||
}
|
||||
return lines
|
||||
}
|
||||
}
|
||||
|
||||
/** Counts xterm creations per pane in this renderer; idempotent, so call it after every launch. */
|
||||
export function installRemountCounter(page: Page): Promise<void> {
|
||||
return page.evaluate(() => {
|
||||
|
||||
@@ -1,22 +1,24 @@
|
||||
/**
|
||||
* Runs the layout checks after each scenario step and records which check fired.
|
||||
* Runs the layout checks after each scenario step and records which check fired. The runtime's
|
||||
* layout is read live from its `layout.subscribe` stream.
|
||||
*
|
||||
* - rules: the runtime's layout breaks a structural rule (also on transient reads while settling),
|
||||
* or an id changed for the same pane, tab or group since the previous step.
|
||||
* - rules: the runtime's layout breaks a model rule (on every published frame, so a breach that
|
||||
* heals before the step settles is recorded as transient), or an id changed for the same pane,
|
||||
* tab or group since the previous step; or the saved profile breaks a disk-format rule.
|
||||
* - roundTrip: a saved partition does not load and save to a fixed point.
|
||||
* - view: what the window draws differs from the runtime's layout.
|
||||
* - client: what a paired client / the CLI is told differs from the runtime's layout.
|
||||
* - expected: the runtime's layout is not the one the scenario's commands should produce.
|
||||
* - restart: the layout after a relaunch (or PTY daemon restart) differs from before it.
|
||||
* - marker: text written to a pane's terminal never shows in that pane (a frozen or crossed pane).
|
||||
* - remount: a pane's terminal view was created again in the same window outside a restart.
|
||||
*
|
||||
* Between checkpoints a sampler runs the rules every 100 ms; a breach that heals before the next
|
||||
* step is recorded as transient.
|
||||
*/
|
||||
|
||||
import type { Page } from '@stablyai/playwright-test'
|
||||
import type { RuntimeClient } from '../../../src/cli/runtime/client'
|
||||
import type { WorkspaceLayoutModel } from '../../../src/shared/workspace-layout/workspace-layout-model'
|
||||
import {
|
||||
checkWorkspaceLayoutModelRules,
|
||||
checkWorkspaceLayoutRules,
|
||||
diffOracleLayouts,
|
||||
formatViolations,
|
||||
@@ -24,16 +26,18 @@ import {
|
||||
type OracleLayout,
|
||||
type WorkspaceLayoutPartition
|
||||
} from './workspace-layout-oracle-model'
|
||||
import {
|
||||
liveLayoutModels,
|
||||
type LiveLayoutFeed,
|
||||
type LiveWorkspace
|
||||
} from './workspace-layout-oracle-feed'
|
||||
import { compareClientToRuntime, compareDrawnToRuntime } from './workspace-layout-oracle-compare'
|
||||
import { readClientView, readDrawnLayout, type ClientView } from './workspace-layout-oracle-views'
|
||||
import { expectedDifferences, type ExpectedLayout } from './workspace-layout-oracle-expected'
|
||||
import { checkLayoutRoundTrip } from '../../../src/shared/workspace-layout/workspace-layout-round-trip-check'
|
||||
import { checkPaneMarkers } from './workspace-layout-oracle-markers'
|
||||
import {
|
||||
installRemountCounter,
|
||||
LayoutRuleSampler,
|
||||
readRemountCounts
|
||||
} from './workspace-layout-oracle-watch'
|
||||
import { installRemountCounter, readRemountCounts } from './workspace-layout-oracle-watch'
|
||||
import { unexpectedFindings } from '../workspace-layout-oracle-known-on-main'
|
||||
|
||||
export type OracleCheck =
|
||||
| 'rules'
|
||||
@@ -56,7 +60,10 @@ export type OracleTarget = {
|
||||
/** The window, when one is attached; a headless runtime has none. */
|
||||
page?: Page
|
||||
client: RuntimeClient
|
||||
readPartitions: () => Promise<WorkspaceLayoutPartition[]>
|
||||
/** The runtime's live layout; the same feed across retargets of one run. */
|
||||
feed: LiveLayoutFeed
|
||||
/** The saved profile, read once a step settles for the disk-format rules and the round trip. */
|
||||
readSavedPartitions: () => Promise<WorkspaceLayoutPartition[]>
|
||||
/** Worktrees whose client view is compared; the scenario's own worktrees. */
|
||||
worktreeIds: () => string[]
|
||||
}
|
||||
@@ -64,7 +71,11 @@ export type OracleTarget = {
|
||||
export type { ExpectedLayout }
|
||||
|
||||
const SETTLE_TIMEOUT_MS = Number(process.env.ORCA_LAYOUT_ORACLE_SETTLE_MS ?? 12_000)
|
||||
const POLL_MS = 300
|
||||
// Views (the window's drawing, session.tabs.list, terminal.list) catch up after the runtime's last
|
||||
// frame with no event of their own, so the step re-reads them on this interval until they agree.
|
||||
const VIEW_CONVERGENCE_POLL_MS = 300
|
||||
// Today's quiet window (three reads, ~600 ms): no frame across this many gaps between reads.
|
||||
const QUIET_GAPS = 2
|
||||
|
||||
type ViewRead = { view: string[]; client: string[]; evidence: Record<string, unknown> }
|
||||
|
||||
@@ -95,36 +106,59 @@ export type StepOptions = {
|
||||
allowRemount?: true
|
||||
}
|
||||
|
||||
/** A model-rule breach line → the step label current when a frame first showed it. */
|
||||
type FrameBreaches = Map<string, string>
|
||||
|
||||
export class LayoutOracle {
|
||||
readonly findings: OracleFinding[] = []
|
||||
private previous: WorkspaceLayoutPartition[] | null = null
|
||||
private previousModels: WorkspaceLayoutModel[] | null = null
|
||||
private previousSaved: WorkspaceLayoutPartition[] | null = null
|
||||
private previousLayout: OracleLayout | null = null
|
||||
private snapshot: OracleLayout | null = null
|
||||
private remountBaseline: Record<string, number> = {}
|
||||
/** Terminal views created per pane in the current window, for the report (and a precondition). */
|
||||
readonly viewCreations: Record<string, number>[] = []
|
||||
private readonly sampler: LayoutRuleSampler
|
||||
private readonly frameBreaches: FrameBreaches = new Map()
|
||||
private label = 'start'
|
||||
|
||||
constructor(private target: OracleTarget) {
|
||||
this.sampler = new LayoutRuleSampler(target.readPartitions)
|
||||
constructor(
|
||||
private target: OracleTarget,
|
||||
/** The scenario id the known-on-main list is keyed by. */
|
||||
private readonly scenarioId: string
|
||||
) {}
|
||||
|
||||
/** The feed's frame callback: the model rules on every published frame. */
|
||||
checkFrame(feed: LiveLayoutFeed): void {
|
||||
for (const line of formatViolations(
|
||||
checkWorkspaceLayoutModelRules(liveLayoutModels(feed.entries()))
|
||||
)) {
|
||||
if (!this.frameBreaches.has(line)) {
|
||||
this.frameBreaches.set(line, this.label)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Starts the between-steps sampler and, with a window, the remount counter. */
|
||||
/** With a window, starts the remount counter. */
|
||||
async start(): Promise<void> {
|
||||
if (this.target.page) {
|
||||
await installRemountCounter(this.target.page)
|
||||
}
|
||||
this.sampler.start()
|
||||
}
|
||||
|
||||
async stop(): Promise<void> {
|
||||
await this.sampler.stop()
|
||||
/** Breaches first seen on a frame while `label` was current, marked as reported. */
|
||||
private takeFrameBreaches(label: string): string[] {
|
||||
const lines = [...this.frameBreaches]
|
||||
.filter(([, seenAt]) => seenAt === label)
|
||||
.map(([line]) => line)
|
||||
for (const line of lines) {
|
||||
this.frameBreaches.set(line, `${label} (reported)`)
|
||||
}
|
||||
return lines
|
||||
}
|
||||
|
||||
/** After a relaunch: new window, new renderer, so remounts count from zero again. */
|
||||
async retarget(target: OracleTarget): Promise<void> {
|
||||
this.target = target
|
||||
this.sampler.retarget(target.readPartitions)
|
||||
this.remountBaseline = {}
|
||||
if (target.page) {
|
||||
await installRemountCounter(target.page)
|
||||
@@ -137,7 +171,7 @@ export class LayoutOracle {
|
||||
}
|
||||
}
|
||||
|
||||
private async readViews(partitions: WorkspaceLayoutPartition[]): Promise<ViewRead> {
|
||||
private async readViews(entries: LiveWorkspace[]): Promise<ViewRead> {
|
||||
const { page, client } = this.target
|
||||
const clientViews = await Promise.all(
|
||||
this.target.worktreeIds().map((worktreeId) => readClientView(client, worktreeId))
|
||||
@@ -147,13 +181,13 @@ export class LayoutOracle {
|
||||
clientViews.flatMap((view) => view.terminals.flatMap((terminal) => terminal.ptyId ?? []))
|
||||
)
|
||||
const drawn = page ? await readDrawnLayout(page) : null
|
||||
const view = drawn ? compareDrawnToRuntime(partitions, drawn, live) : []
|
||||
const view = drawn ? compareDrawnToRuntime(entries, drawn, live) : []
|
||||
if (drawn && drawn.strips.length === 0) {
|
||||
view.push('the window draws no tab strip')
|
||||
}
|
||||
return {
|
||||
view,
|
||||
client: clientViews.flatMap((view) => compareClientToRuntime(partitions, view)),
|
||||
client: clientViews.flatMap((view) => compareClientToRuntime(entries, view)),
|
||||
evidence: { drawn, clients: clientViews.map(summarizeClientView) }
|
||||
}
|
||||
}
|
||||
@@ -167,49 +201,60 @@ export class LayoutOracle {
|
||||
expected: ExpectedLayout | null,
|
||||
options: StepOptions = {}
|
||||
): Promise<OracleLayout> {
|
||||
const transient = new Set<string>()
|
||||
const { feed } = this.target
|
||||
const deadline = Date.now() + SETTLE_TIMEOUT_MS
|
||||
let last = ''
|
||||
let stableReads = 0
|
||||
let partitions: WorkspaceLayoutPartition[] = []
|
||||
let lastFrames = -1
|
||||
let entries: LiveWorkspace[] = []
|
||||
let differences: ViewRead = { view: [], client: [], evidence: {} }
|
||||
for (;;) {
|
||||
partitions = await this.target.readPartitions()
|
||||
for (const line of formatViolations(checkWorkspaceLayoutRules(partitions))) {
|
||||
transient.add(line)
|
||||
}
|
||||
const serialized = JSON.stringify(toOracleLayout(partitions))
|
||||
stableReads = serialized === last ? stableReads + 1 : 0
|
||||
last = serialized
|
||||
differences = await this.readViews(partitions)
|
||||
const agreed = differences.view.length === 0 && differences.client.length === 0
|
||||
if ((agreed && stableReads >= 2) || Date.now() > deadline) {
|
||||
entries = feed.entries()
|
||||
differences = await this.readViews(entries)
|
||||
// Quiet only if no frame arrived since the previous read, including during the sleep.
|
||||
stableReads = feed.ready && feed.frames === lastFrames ? stableReads + 1 : 0
|
||||
lastFrames = feed.frames
|
||||
// A difference main is known to keep never converges, so it does not hold the wait.
|
||||
const agreed =
|
||||
unexpectedFindings(this.scenarioId, [
|
||||
{ check: 'view', step: label, details: differences.view },
|
||||
{ check: 'client', step: label, details: differences.client }
|
||||
]).length === 0
|
||||
if ((agreed && stableReads >= QUIET_GAPS) || Date.now() > deadline) {
|
||||
break
|
||||
}
|
||||
await sleep(POLL_MS)
|
||||
await sleep(VIEW_CONVERGENCE_POLL_MS)
|
||||
}
|
||||
if (!feed.ready) {
|
||||
this.record('rules', label, [
|
||||
`no live layout: ${feed.error ?? 'the stream sent no snapshot'}`
|
||||
])
|
||||
}
|
||||
const models = liveLayoutModels(entries)
|
||||
const settled = formatViolations(
|
||||
checkWorkspaceLayoutRules(partitions, this.previous ?? undefined)
|
||||
checkWorkspaceLayoutModelRules(models, this.previousModels ?? undefined)
|
||||
)
|
||||
this.record('rules', label, settled)
|
||||
// The shadow self-check: each partition loads and saves to a fixed point (Loader, Serializer).
|
||||
// The saved profile, which the live stream does not carry: disk-format rules and round trip.
|
||||
const saved = await this.target.readSavedPartitions()
|
||||
const savedLines = formatViolations(
|
||||
checkWorkspaceLayoutRules(saved, this.previousSaved ?? undefined)
|
||||
).map((line) => line.replace(': ', ': saved profile: '))
|
||||
this.record('rules', label, [...settled, ...savedLines])
|
||||
this.record(
|
||||
'roundTrip',
|
||||
label,
|
||||
partitions.flatMap(({ hostId, session }) =>
|
||||
saved.flatMap(({ hostId, session }) =>
|
||||
checkLayoutRoundTrip(hostId, session).findings.map(
|
||||
(finding) => `${hostId} ${JSON.stringify(finding)}`
|
||||
)
|
||||
)
|
||||
)
|
||||
const between = this.sampler.take(this.sampler.label)
|
||||
this.record(
|
||||
'rules',
|
||||
`${label} (transient)`,
|
||||
[...new Set([...between, ...transient])].filter((line) => !settled.includes(line))
|
||||
this.takeFrameBreaches(this.label).filter((line) => !settled.includes(line))
|
||||
)
|
||||
this.sampler.label = label
|
||||
const layout = toOracleLayout(partitions)
|
||||
this.label = label
|
||||
const layout = toOracleLayout(entries)
|
||||
const evidence = { runtime: layout, ...differences.evidence }
|
||||
this.record('view', label, differences.view, evidence)
|
||||
this.record('client', label, differences.client, evidence)
|
||||
@@ -222,7 +267,8 @@ export class LayoutOracle {
|
||||
)
|
||||
}
|
||||
await this.checkRemounts(label, options)
|
||||
this.previous = partitions
|
||||
this.previousModels = models
|
||||
this.previousSaved = saved
|
||||
this.previousLayout = layout
|
||||
return layout
|
||||
}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
/**
|
||||
* Layout oracle scenarios on a headless `orca serve` (orcad and Electron serve), driven only through
|
||||
* runtime RPC from paired clients and the local CLI. After every step the runtime's layout (its
|
||||
* saved session) is checked against the structural rules, what each client is told, the expected
|
||||
* panes per tab and, across restarts, the layout from before. #12723 needs SSH and is not covered.
|
||||
* runtime RPC from paired clients and the local CLI. After every step the runtime's live layout
|
||||
* (its `layout.subscribe` stream) is checked against the model rules, what each client is told,
|
||||
* the expected panes per tab and, across restarts, the layout from before; its saved profile
|
||||
* against the disk-format rules and the round trip. #12723 needs SSH and is not covered.
|
||||
*
|
||||
* `ORCA_LAYOUT_ORACLE_RECORD=1` records without failing; `ORCA_LAYOUT_ORACLE_REPEAT=<n>` repeats;
|
||||
* `ORCA_LAYOUT_ORACLE_HEADLESS_HOSTS=orcad,electron` picks the hosts (both by default).
|
||||
@@ -29,13 +30,15 @@ import {
|
||||
cliClient,
|
||||
orcadNodeExecutable,
|
||||
pairedClient,
|
||||
readHeadlessPartitions,
|
||||
startHeadlessOracleHost,
|
||||
type HeadlessHostKind,
|
||||
type HeadlessOracleHost
|
||||
} from './helpers/headless-layout-oracle-hosts'
|
||||
import { LayoutOracle, type OracleFinding } from './helpers/workspace-layout-oracle'
|
||||
import type { OracleLayout } from './helpers/workspace-layout-oracle-model'
|
||||
import { LiveLayoutFeed } from './helpers/workspace-layout-oracle-feed'
|
||||
import { subscribeHeadlessLayout } from './helpers/workspace-layout-oracle-stream'
|
||||
import { readSavedPartitions } from './helpers/workspace-layout-oracle-views'
|
||||
import { ORACLE_REPORT_DIR_ENV } from './helpers/workspace-layout-oracle-session'
|
||||
import {
|
||||
summarizeEventLoopDelay,
|
||||
@@ -261,7 +264,7 @@ const SCENARIOS: Scenario[] = [
|
||||
)
|
||||
await run.check('CLI closes second', [1], undefined, 1)
|
||||
await run.restart('warm restart', [1])
|
||||
const left = readHeadlessPartitions(run.host.userDataDir)
|
||||
const left = readSavedPartitions(run.host.userDataDir)
|
||||
.flatMap(({ session }) => session.tabsByWorktree?.[run.worktreeId] ?? [])
|
||||
.map((tab) => tab.id)
|
||||
run.expectNone(
|
||||
@@ -411,16 +414,19 @@ async function runHeadlessScenario(
|
||||
const host = await startHeadlessOracleHost(kind)
|
||||
const started = Date.now()
|
||||
let findings: OracleFinding[] = []
|
||||
let stream: { close: () => void } | null = null
|
||||
try {
|
||||
const a = pairedClient(host, 'a')
|
||||
const cli = cliClient(host)
|
||||
const worktreeId = await addRepoWorktree(host, cli)
|
||||
const feed = new LiveLayoutFeed((current) => oracle.checkFrame(current))
|
||||
const target = (client: RuntimeClient) => ({
|
||||
client,
|
||||
readPartitions: async () => readHeadlessPartitions(host.userDataDir),
|
||||
feed,
|
||||
readSavedPartitions: async () => readSavedPartitions(host.userDataDir),
|
||||
worktreeIds: () => [worktreeId]
|
||||
})
|
||||
const oracle = new LayoutOracle(target(a))
|
||||
const oracle = new LayoutOracle(target(a), `headless-${kind}-${scenario.id}`)
|
||||
findings = oracle.findings
|
||||
// A cold restart gives every pane a new terminal.
|
||||
let restarting = false
|
||||
@@ -455,7 +461,9 @@ async function runHeadlessScenario(
|
||||
check,
|
||||
restart: async (label, panesPerTab, cold) => {
|
||||
oracle.rememberForRestart(await check(`before ${label}`, panesPerTab))
|
||||
stream?.close()
|
||||
await host.restart({ cold })
|
||||
stream = await subscribeHeadlessLayout(host.pairingUrl, feed)
|
||||
restarting = cold === true
|
||||
const after = await check(`after ${label}`, panesPerTab)
|
||||
restarting = false
|
||||
@@ -467,15 +475,13 @@ async function runHeadlessScenario(
|
||||
}
|
||||
}
|
||||
}
|
||||
stream = await subscribeHeadlessLayout(host.pairingUrl, feed)
|
||||
await oracle.start()
|
||||
try {
|
||||
await run.check('start', [])
|
||||
await scenario.journey(run)
|
||||
} finally {
|
||||
await oracle.stop()
|
||||
}
|
||||
await run.check('start', [])
|
||||
await scenario.journey(run)
|
||||
return findings
|
||||
} finally {
|
||||
stream?.close()
|
||||
const ended = Date.now()
|
||||
writeReport(testInfo, `${kind}-${scenario.id}`, {
|
||||
durationMs: ended - started,
|
||||
|
||||
@@ -16,58 +16,21 @@ type KnownOnMain = {
|
||||
rule?: string
|
||||
/** Pattern the detail line must match. */
|
||||
detail?: RegExp
|
||||
/** Only on this OS: the one every run that reproduced it ran on. Absent: reproduced on both. */
|
||||
platform?: 'darwin' | 'linux'
|
||||
cause: string
|
||||
}
|
||||
|
||||
const TRANSIENT = /\(transient\)$/
|
||||
|
||||
export const LAYOUT_ORACLE_KNOWN_ON_MAIN: readonly KnownOnMain[] = [
|
||||
// Two writers: main saves a new tab's row when its terminal starts; the window saves the tab
|
||||
// bar later. Rows and tab bar disagree until the window's save lands.
|
||||
{
|
||||
scenario: '*',
|
||||
check: 'rules',
|
||||
transient: true,
|
||||
rule: 'tab_lists_disagree',
|
||||
cause: 'two writers'
|
||||
},
|
||||
{
|
||||
scenario: '*',
|
||||
check: 'rules',
|
||||
transient: true,
|
||||
rule: 'tab_bar_missing',
|
||||
cause: 'two writers'
|
||||
},
|
||||
// STA-9417: first activation of a host-created setup split mints a second tab for the setup
|
||||
// terminal (one terminal in two panes) or leaves that terminal with no pane.
|
||||
...(['rules', 'view', 'client', 'expected', 'marker'] as const).map((check) => ({
|
||||
scenario: 'setup-split-first-activation',
|
||||
check,
|
||||
cause: 'STA-9417'
|
||||
})),
|
||||
// Three copies of tab order: a reorder or a cold relaunch moves the tab bar, not the rows.
|
||||
// Three copies of tab order: a reorder moves the tab bar, not the saved rows.
|
||||
{
|
||||
scenario: 'client-reorder-tabs',
|
||||
check: 'rules',
|
||||
rule: 'tab_order_disagrees',
|
||||
cause: 'one tab order'
|
||||
},
|
||||
{
|
||||
scenario: 'cold-daemon-restart',
|
||||
check: 'rules',
|
||||
rule: 'tab_order_disagrees',
|
||||
cause: 'one tab order'
|
||||
},
|
||||
// Intermittent (about 1 in 30 on main): the relaunch's exits for the killed daemon's terminals
|
||||
// retire both tab rows, the respawns re-add them in whichever order they bind, and the window's
|
||||
// next save keeps main's row order. Fixed in PR 6, where rows follow the group's tab order.
|
||||
{
|
||||
scenario: 'cold-daemon-restart',
|
||||
check: 'expected',
|
||||
step: 'after cold relaunch',
|
||||
detail: /: panes per tab \[1,2\], expected \[2,1\]$/,
|
||||
cause: 'one tab order'
|
||||
},
|
||||
// After a cold relaunch the paired-client tab list stays empty while the window shows the tabs.
|
||||
{
|
||||
scenario: 'cold-daemon-restart',
|
||||
@@ -75,14 +38,54 @@ export const LAYOUT_ORACLE_KNOWN_ON_MAIN: readonly KnownOnMain[] = [
|
||||
step: 'after cold relaunch',
|
||||
cause: 'client tab list empty after cold relaunch'
|
||||
},
|
||||
// After a cold relaunch main holds the tab rows but no tab bar for the worktree until the window
|
||||
// saves again, so its group is minted again under a new id (Linux CI).
|
||||
...(['rules', 'view'] as const).map((check) => ({
|
||||
scenario: 'cold-daemon-restart',
|
||||
check,
|
||||
step: 'after cold relaunch',
|
||||
platform: 'linux' as const,
|
||||
cause: 'tab bar not saved after relaunch'
|
||||
})),
|
||||
{
|
||||
scenario: 'cold-daemon-restart',
|
||||
check: 'restart',
|
||||
step: 'cold relaunch',
|
||||
// One group, same tabs in the same order, only its id changed; a reorder or lost tab still fails.
|
||||
detail: /: groups \[\["[^"]+",(\[[^\]]*\])\]\] vs after restart \[\["[^"]+",\1\]\]$/,
|
||||
platform: 'linux',
|
||||
cause: 'tab bar not saved after relaunch'
|
||||
},
|
||||
// STA-9417: first activation of a host-created setup split mints a second tab for the setup
|
||||
// terminal (one terminal in two panes) or leaves that terminal with no pane.
|
||||
...(['view', 'client'] as const).map((check) => ({
|
||||
scenario: 'setup-split-first-activation',
|
||||
check,
|
||||
cause: 'STA-9417'
|
||||
})),
|
||||
...(['rules', 'expected'] as const).map((check) => ({
|
||||
scenario: 'setup-split-first-activation',
|
||||
check,
|
||||
platform: 'darwin' as const,
|
||||
cause: 'STA-9417'
|
||||
})),
|
||||
// The in-app daemon restart drops the worktree's tabs from main while the window keeps them.
|
||||
...(['view', 'expected', 'restart', 'marker'] as const).map((check) => ({
|
||||
scenario: 'live-daemon-restart',
|
||||
check,
|
||||
cause: 'daemon restart empties the runtime layout'
|
||||
})),
|
||||
// A worktree woken after relaunch has no tab bar in main until the window next saves it.
|
||||
...(['rules', 'view', 'client', 'restart'] as const).map((check) => ({
|
||||
// The relaunch after it has no tab bar for the worktree, so its group is minted again (Linux CI).
|
||||
...(['rules', 'client'] as const).map((check) => ({
|
||||
scenario: 'live-daemon-restart',
|
||||
check,
|
||||
step: 'after relaunch',
|
||||
platform: 'linux' as const,
|
||||
cause: 'daemon restart empties the runtime layout'
|
||||
})),
|
||||
// A worktree woken after relaunch has no tab bar in main until the window next saves it, so the
|
||||
// runtime's group is minted again with a new id.
|
||||
...(['rules', 'view', 'client'] as const).map((check) => ({
|
||||
scenario: 'sleep-quit-resume',
|
||||
check,
|
||||
step: 'after relaunch',
|
||||
@@ -94,43 +97,32 @@ export const LAYOUT_ORACLE_KNOWN_ON_MAIN: readonly KnownOnMain[] = [
|
||||
step: 'relaunch',
|
||||
cause: 'tab bar not saved after wake'
|
||||
},
|
||||
// After a relaunch main holds the tab rows but no tab bar for the worktree until the window
|
||||
// saves again; slow enough on Linux CI to outlast the settle wait.
|
||||
...(['tab_lists_disagree', 'tab_bar_missing'] as const).flatMap((rule) =>
|
||||
['after relaunch', 'after cold relaunch'].map((step) => ({
|
||||
scenario: '*',
|
||||
check: 'rules' as const,
|
||||
step,
|
||||
rule,
|
||||
cause: 'tab bar not saved after relaunch'
|
||||
}))
|
||||
),
|
||||
...['after relaunch', 'after cold relaunch'].map((step) => ({
|
||||
scenario: '*',
|
||||
check: 'view' as const,
|
||||
step,
|
||||
detail: /^drawn group \S+ is not in the runtime/,
|
||||
cause: 'tab bar not saved after relaunch'
|
||||
})),
|
||||
...['relaunch', 'cold relaunch'].map((step) => ({
|
||||
scenario: '*',
|
||||
check: 'restart' as const,
|
||||
step,
|
||||
// Only the groups going missing; lost tabs or panes still fail.
|
||||
detail: /: groups \[.*\] vs after restart \[\]$/,
|
||||
cause: 'tab bar not saved after relaunch'
|
||||
})),
|
||||
// A renamed terminal's title lists as null after a serve restart (session tabs keep it): orcad
|
||||
// everywhere, Electron serve on Linux.
|
||||
// A headless host lists a worktree with no saved tab bar (and, after a restart, every worktree)
|
||||
// under a synthesized `headless-terminals:` group, while the layout stream names the same group
|
||||
// by its saved or minted id: same tabs and order, two group ids. One projection in PR 6.
|
||||
{
|
||||
scenario: 'headless-*',
|
||||
check: 'client',
|
||||
detail: /^group id differs: runtime \S+, client headless-terminals:\S+ \(tabs /,
|
||||
cause: 'two group ids'
|
||||
},
|
||||
// A renamed terminal's title lists as null in terminal.list after a serve restart (session tabs
|
||||
// keep it): orcad everywhere, Electron serve on Linux.
|
||||
{
|
||||
scenario: 'headless-orcad-rename-terminal',
|
||||
check: 'expected',
|
||||
step: 'rename after restart',
|
||||
cause: 'orcad title after restart'
|
||||
cause: 'title after restart'
|
||||
},
|
||||
{
|
||||
scenario: 'headless-electron-rename-terminal',
|
||||
check: 'expected',
|
||||
step: 'rename after restart',
|
||||
platform: 'linux',
|
||||
cause: 'title after restart'
|
||||
},
|
||||
// A headless reorder saves rows through the session merge door, which keeps the stored row order,
|
||||
// so rows and the group disagree; hidden until creates saved a tab bar. Fixed in PR 6 with
|
||||
// client-reorder-tabs.
|
||||
// so rows and the group disagree. Fixed in PR 6, where rows follow the group's tab order.
|
||||
...(['orcad', 'electron'] as const).map((kind) => ({
|
||||
scenario: `headless-${kind}-move-tab-between-groups`,
|
||||
check: 'rules' as const,
|
||||
@@ -157,7 +149,7 @@ function covers(
|
||||
const scenarioMatches = known.scenario.endsWith('*')
|
||||
? scenario.startsWith(known.scenario.slice(0, -1))
|
||||
: known.scenario === scenario
|
||||
if (!scenarioMatches) {
|
||||
if (!scenarioMatches || (known.platform && known.platform !== process.platform)) {
|
||||
return false
|
||||
}
|
||||
if (known.check !== finding.check) {
|
||||
|
||||
Reference in New Issue
Block a user