ci: run the layout-owner switch-on specs as an advisory PR check (dark, layout-owner build only) (#30582)

* ci: run the layout-owner switch-on specs as an advisory PR job

Build the e2e app with ORCA_LAYOUT_OWNER=main through the existing e2e.yml
(new layout_owner input, read only by the app and orcad builds), run the
layout oracle, its positive control and the layout-owner acceptance specs
with the save-divergence log on, and upload a summary by kind and spec.
Not in the PR aggregate's needs.

* ci: summarize divergence logs without a test report, and drop file-path ids from kinds

* ci: switch-on job adds the stream-loss spec and the managed-SSH Docker lane, drops diff-word-wrap (fails switch off), and names each looped test in the summary

* test(e2e): editor-model-host-isolation opens both files through the store's open action, so a layout-owner build's main holds the tabs

* ci: the divergence summary skips a cut-short test report instead of failing

* ci: re-run PR checks on a merge ref that includes the main typecheck repair (#30680)
This commit is contained in:
Jinwoo Hong
2026-10-19 06:41:26 -04:00
committed by GitHub
parent 1cf7472d1e
commit 9def13dde2
7 changed files with 536 additions and 50 deletions
+105 -4
View File
@@ -33,6 +33,12 @@ on:
required: false
type: string
default: ''
# Why only the app builds read it: the orcad template build refuses a dark switch.
layout_owner:
description: Set to main to build with ORCA_LAYOUT_OWNER=main and record save-divergence reports
required: false
type: string
default: ''
workflow_dispatch:
inputs:
ref:
@@ -107,7 +113,12 @@ jobs:
env:
VITE_EXPOSE_STORE: 'true'
ORCA_CI_MAIN_SOURCEMAPS: '0'
run: pnpm run build:electron-vite:parallel --mode e2e
LAYOUT_OWNER: ${{ inputs.layout_owner }}
run: |
if [ -n "$LAYOUT_OWNER" ]; then
export ORCA_LAYOUT_OWNER="$LAYOUT_OWNER"
fi
pnpm run build:electron-vite:parallel --mode e2e
# The CLI emits into out/main too; start only after Electron finishes clearing that tree.
- name: Build shared E2E CLI
@@ -125,6 +136,11 @@ jobs:
- wait: e2e-cli
# The switch-on specs skip, rather than fail, when the build left no marker.
- name: Check layout-owner build marker
if: inputs.layout_owner == 'main'
run: test -f out/main/ORCA_LAYOUT_OWNER_MAIN
- name: Check shared native runtime action
id: native-runtime-action
run: |
@@ -325,6 +341,7 @@ jobs:
- name: Run changed E2E specs
env:
TEST_FILES_JSON: ${{ inputs.test_files }}
LAYOUT_OWNER: ${{ inputs.layout_owner }}
run: |
if [ -f config/scripts/ci-e2e-job-selection.mjs ]; then
printf '%s\n' "$TEST_FILES_JSON" | node config/scripts/ci-e2e-job-selection.mjs > "$RUNNER_TEMP/general-e2e-specs"
@@ -347,12 +364,43 @@ jobs:
fi
E2E_ENV=(SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 ORCA_E2E_WEB_CLIENT=1)
E2E_PROJECT_ARGS=()
if [ -n "$LAYOUT_OWNER" ]; then
test -f out/main/ORCA_LAYOUT_OWNER_MAIN
mkdir -p layout-divergence
E2E_ENV+=(ORCA_LAYOUT_SAVE_DIVERGENCE_LOG="$PWD/layout-divergence/window-specs.jsonl")
E2E_ENV+=(PLAYWRIGHT_JSON_OUTPUT_FILE="$PWD/layout-divergence/window-specs-results.json")
E2E_PROJECT_ARGS+=('--reporter=list,json')
fi
if grep -l '@headful' "${TEST_FILES[@]}" >/dev/null; then
E2E_PROJECT_ARGS+=(--project=electron-headful)
fi
xvfb-run --auto-servernum bash .github/scripts/e2e-with-window-manager.sh env "${E2E_ENV[@]}" \
pnpm run test:e2e "${TEST_FILES[@]}" --workers=1 "${E2E_PROJECT_ARGS[@]}"
- name: Summarize layout save divergence
if: always() && inputs.layout_owner == 'main'
continue-on-error: true
run: &summarize_layout_divergence |
# Either file names a run: a log with no report is unattributed, a report with no log is zero.
shopt -s nullglob
runs=(layout-divergence/*.jsonl layout-divergence/*-results.json)
for run in $(printf '%s\n' "${runs[@]%.jsonl}" | sed 's/-results\.json$//' | sort -u); do
node config/scripts/layout-save-divergence-summary.mjs "$run.jsonl" "$run-summary.json" "$run-results.json"
if [ -f "$run-results.json" ]; then
node config/scripts/ci-e2e-failure-summary.mjs "$run-results.json"
fi
done
- name: Upload layout save divergence report
if: always() && inputs.layout_owner == 'main'
continue-on-error: true
uses: actions/upload-artifact@v7
with:
name: layout-divergence-changed${{ inputs.artifact_suffix }}-attempt-${{ github.run_attempt }}
path: layout-divergence/
retention-days: 14
if-no-files-found: warn
- name: Upload Playwright traces
if: failure() || cancelled()
uses: actions/upload-artifact@v7
@@ -410,11 +458,18 @@ jobs:
# The packaged orcad slot the T6-11 launcher runs: its own node-pty under the pinned Node.
# The template is what `orca serve`'s default selection materializes that slot from.
- name: Build this runner's orcad slot and template
env:
LAYOUT_OWNER: ${{ inputs.layout_owner }}
run: |
slot="$(node config/scripts/build-orcad-prebuilds.mjs --print-slot)"
pnpm build:orcad-prebuilds
pnpm build:orcad-prebuilds --require-slots "$slot"
pnpm build:orcad
# The headless oracle's orcad host must hold the same layout owner as the Electron host.
if [ -n "$LAYOUT_OWNER" ]; then
ORCA_LAYOUT_OWNER="$LAYOUT_OWNER" pnpm build:orcad
else
pnpm build:orcad
fi
node config/scripts/build-orcad-template.mjs --targets "$slot"
- name: Switch serve hosts on one profile
if: inputs.test_files == '' || contains(inputs.test_files, 'tests/e2e/orcad-serve-mode-switch.spec.ts')
@@ -431,7 +486,30 @@ jobs:
env:
SKIP_BUILD: '1'
ORCA_E2E_FORWARD_APP_LOGS: '1'
run: xvfb-run --auto-servernum bash .github/scripts/e2e-with-window-manager.sh pnpm exec playwright test --config tests/playwright.config.ts tests/e2e/workspace-layout-oracle-headless.spec.ts --project=electron-headless --workers=1
LAYOUT_OWNER: ${{ inputs.layout_owner }}
run: |
REPORT_ARGS=()
if [ -n "$LAYOUT_OWNER" ]; then
test -f out/main/ORCA_LAYOUT_OWNER_MAIN
mkdir -p layout-divergence
export ORCA_LAYOUT_SAVE_DIVERGENCE_LOG="$PWD/layout-divergence/headless-oracle.jsonl"
export PLAYWRIGHT_JSON_OUTPUT_FILE="$PWD/layout-divergence/headless-oracle-results.json"
REPORT_ARGS=('--reporter=list,json')
fi
xvfb-run --auto-servernum bash .github/scripts/e2e-with-window-manager.sh pnpm exec playwright test --config tests/playwright.config.ts tests/e2e/workspace-layout-oracle-headless.spec.ts --project=electron-headless --workers=1 "${REPORT_ARGS[@]}"
- name: Summarize layout save divergence
if: always() && inputs.layout_owner == 'main'
continue-on-error: true
run: *summarize_layout_divergence
- name: Upload layout save divergence report
if: always() && inputs.layout_owner == 'main'
continue-on-error: true
uses: actions/upload-artifact@v7
with:
name: layout-divergence-headless${{ inputs.artifact_suffix }}-attempt-${{ github.run_attempt }}
path: layout-divergence/
retention-days: 14
if-no-files-found: warn
- uses: actions/upload-artifact@v7
if: failure()
with:
@@ -486,9 +564,32 @@ jobs:
ORCA_E2E_ORCAD_CONVERT_HOST: docker
ORCA_E2E_FORWARD_APP_LOGS: '1'
SHARD: ${{ matrix.shard }}
LAYOUT_OWNER: ${{ inputs.layout_owner }}
SHARD_INDEX: ${{ strategy.job-index }}
run: |
export ORCA_E2E_ORCAD_CONVERT_TEMPLATE="$RUNNER_TEMP/orcad-convert-template"
xvfb-run --auto-servernum bash .github/scripts/e2e-with-window-manager.sh pnpm exec playwright test --config tests/playwright.config.ts tests/e2e/ssh-orcad- --project=electron-headless --workers=1 --shard="$SHARD"
REPORT_ARGS=()
if [ -n "$LAYOUT_OWNER" ]; then
test -f out/main/ORCA_LAYOUT_OWNER_MAIN
mkdir -p layout-divergence
export ORCA_LAYOUT_SAVE_DIVERGENCE_LOG="$PWD/layout-divergence/managed-ssh-$SHARD_INDEX.jsonl"
export PLAYWRIGHT_JSON_OUTPUT_FILE="$PWD/layout-divergence/managed-ssh-$SHARD_INDEX-results.json"
REPORT_ARGS=('--reporter=list,json')
fi
xvfb-run --auto-servernum bash .github/scripts/e2e-with-window-manager.sh pnpm exec playwright test --config tests/playwright.config.ts tests/e2e/ssh-orcad- --project=electron-headless --workers=1 --shard="$SHARD" "${REPORT_ARGS[@]}"
- name: Summarize layout save divergence
if: always() && inputs.layout_owner == 'main'
continue-on-error: true
run: *summarize_layout_divergence
- name: Upload layout save divergence report
if: always() && inputs.layout_owner == 'main'
continue-on-error: true
uses: actions/upload-artifact@v7
with:
name: layout-divergence-managed-ssh-${{ strategy.job-index }}${{ inputs.artifact_suffix }}-attempt-${{ github.run_attempt }}
path: layout-divergence/
retention-days: 14
if-no-files-found: warn
- uses: actions/upload-artifact@v7
if: failure()
with:
+26
View File
@@ -60,6 +60,7 @@ jobs:
e2e_needs_build: ${{ steps.e2e_filter.outputs.e2e_needs_build }}
native_ime_source_changed: ${{ steps.e2e_filter.outputs.native_ime_source_changed }}
wsl_source_changed: ${{ steps.e2e_filter.outputs.wsl_source_changed }}
layout_owner_switch_on: ${{ steps.e2e_filter.outputs.layout_owner_switch_on }}
steps:
- name: Checkout
uses: actions/checkout@v6
@@ -84,6 +85,7 @@ jobs:
/config/scripts/pr-code-change-scope.mjs
/config/scripts/pr-package-test-paths.mjs
/config/scripts/pr-layout-oracle-route.mjs
/config/scripts/pr-layout-owner-switch-on-route.mjs
/config/scripts/pr-e2e-source-routing.mjs
/config/scripts/ci-e2e-job-selection.mjs
/config/scripts/pr-ready-check-reuse.mjs
@@ -164,6 +166,8 @@ jobs:
WSL_CHANGED="$(git diff --name-only --no-renames --diff-filter=ACDMR "$DIFF_BASE" HEAD)"
WSL_SOURCE_CHANGED="$(printf '%s\n' "$WSL_CHANGED" | node config/scripts/pr-e2e-source-routing.mjs --wsl-source)"
echo "wsl_source_changed=$WSL_SOURCE_CHANGED" >> "$GITHUB_OUTPUT"
LAYOUT_OWNER_SWITCH_ON="$(printf '%s\n' "$WSL_CHANGED" | node config/scripts/pr-layout-owner-switch-on-route.mjs)"
echo "layout_owner_switch_on=$LAYOUT_OWNER_SWITCH_ON" >> "$GITHUB_OUTPUT"
echo "Native IME source changed: $NATIVE_IME_SOURCE_CHANGED"
SHOULD_RUN="$(printf '%s\n' "$CHANGED" | node config/scripts/pr-e2e-source-routing.mjs --reusable-workflow "${E2E_SCOPE_ARGS[@]}")"
if [ "$SHOULD_RUN" = true ]; then
@@ -1532,6 +1536,28 @@ jobs:
needs_build: true
artifact_suffix: -layout-oracle
# Why not in verify's needs: the layout-owner switch is off in shipped builds, and some of these
# specs fail with it on until that work lands. It reports a red X without blocking the merge.
layout_owner_switch_on:
name: layout owner (switch on)
needs: [code_paths, preflight]
if: >-
!cancelled() &&
needs.code_paths.result == 'success' &&
needs.code_paths.outputs.layout_owner_switch_on == 'true' &&
needs.preflight.result == 'success'
permissions:
contents: read
uses: ./.github/workflows/e2e.yml
with:
ref: ${{ github.sha }}
# LAYOUT_OWNER_SWITCH_ON_E2E_SPECS in config/scripts/pr-layout-owner-switch-on-route.mjs.
test_files: '["tests/e2e/workspace-layout-oracle-headless.spec.ts","tests/e2e/workspace-layout-oracle.spec.ts","tests/e2e/cli-file-open-position.spec.ts","tests/e2e/relaunch-first-terminal.spec.ts","tests/e2e/relaunch-sleeping-agents-resume-on-view.spec.ts","tests/e2e/terminal-cold-activation-deferral.spec.ts","tests/e2e/cli-first-worktree-single-terminal.spec.ts","tests/e2e/unopened-workspace-agent-single-resume.spec.ts","tests/e2e/terminal-pane-close-layout-consistency.spec.ts","tests/e2e/golden-file-open-edit-save.spec.ts","tests/e2e/golden-source-control-open-diff.spec.ts","tests/e2e/paired-cli-terminal-graph-sync-tab-retention.spec.ts","tests/e2e/floating-workspace-pane-height.spec.ts","tests/e2e/editor-model-host-isolation.spec.ts","tests/e2e/editor-tab-selection-restore.spec.ts","tests/e2e/finished-agent-ghost-resume.spec.ts","tests/e2e/live-background-terminal-mount-authority.spec.ts","tests/e2e/workspace-back-forward-navigation.spec.ts","tests/e2e/worktree-lineage-toggle-shortcut.spec.ts","tests/e2e/local-cli-terminal-retention.spec.ts","tests/e2e/default-tabs-mark-crash-relaunch.spec.ts","tests/e2e/terminal-cli-split-closed-before-mount.spec.ts","tests/e2e/layout-stream-loss-reopen.spec.ts","tests/e2e/ssh-orcad-"]'
run_changed_e2e: true
needs_build: true
artifact_suffix: -layout-owner-switch-on
layout_owner: main
# Why this is not in verify's needs: it is the first PR-gate run of a harness whose reliability
# is only known from nightly main runs (20/20 green, 2026-08-09..2026-08-29, p50 3m25s). It
# reports a red X on the PR without blocking, exactly like `e2e` above. Deliberately no
@@ -0,0 +1,148 @@
// Counts a layout-owner run's save-divergence reports (ORCA_LAYOUT_SAVE_DIVERGENCE_LOG) by kind
// and by the spec that was running when each was written.
// Usage: node layout-save-divergence-summary.mjs <divergence.jsonl> <summary.json> [playwright.json...]
import { appendFileSync, existsSync, readFileSync, writeFileSync } from 'node:fs'
import { basename } from 'node:path'
import process from 'node:process'
import { pathToFileURL } from 'node:url'
const UUID = /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/g
// Saves land a little after the action that caused them, sometimes after the test ends.
const AFTER_END_MS = 15_000
const BEFORE_START_MS = 2_000
/** A report path with its record id (a uuid or a file path) dropped: `group <id> changed: tabOrder`. */
export function divergenceKind(path) {
const named =
/^(editor view|pane tree|group|tab|file|browser|leaf|terminal) (.+?) (added|removed|changed: .*|tab-bar entry removed|kept over .*)$/s.exec(
path
)
const kind = named ? `${named[1]} <id> ${named[3]}` : path.replace(/^\d+ /, '<n> ')
return kind.replace(UUID, '<id>')
}
/** Every test attempt's time span, from Playwright JSON reports. */
export function testSpans(reports) {
const spans = []
const visit = (suite, file) => {
const suiteFile = suite.file ?? file
for (const spec of suite.specs ?? []) {
for (const test of spec.tests ?? []) {
for (const result of test.results ?? []) {
const start = Date.parse(result.startTime)
if (Number.isFinite(start)) {
spans.push({
// The title too: a loop declares many tests on one line.
spec: `${spec.file ?? suiteFile}:${spec.line} ${spec.title}`,
start,
end: start + (result.duration ?? 0)
})
}
}
}
}
for (const child of suite.suites ?? []) {
visit(child, suiteFile)
}
}
for (const report of reports) {
for (const suite of report.suites ?? []) {
visit(suite)
}
}
return spans.sort((left, right) => left.start - right.start)
}
function specAt(at, spans) {
const inside = spans.findLast((span) => span.start <= at && at <= span.end)
if (inside) {
return inside.spec
}
const near = spans.findLast((span) => span.end < at && at - span.end <= AFTER_END_MS)
const next = spans.find((span) => span.start > at && span.start - at <= BEFORE_START_MS)
return (near ?? next)?.spec ?? 'unattributed'
}
const increment = (counts, key) => {
counts[key] = (counts[key] ?? 0) + 1
}
export function summarizeDivergence(lines, reports) {
const spans = testSpans(reports)
const summary = {
reports: 0,
paths: 0,
unreadable: 0,
byKind: {},
bySpec: {},
bySpecAndKind: {}
}
for (const line of lines) {
if (!line.trim()) {
continue
}
let report
try {
report = JSON.parse(line)
} catch {
// A line cut short by a killed app.
summary.unreadable++
continue
}
const spec = specAt(report.at, spans)
summary.reports++
increment(summary.bySpec, spec)
for (const path of report.paths ?? []) {
const kind = divergenceKind(path)
summary.paths++
increment(summary.byKind, kind)
summary.bySpecAndKind[spec] ??= {}
increment(summary.bySpecAndKind[spec], kind)
}
}
return summary
}
export function divergenceMarkdown(summary, title) {
const rows = (counts) =>
Object.entries(counts)
.sort((left, right) => right[1] - left[1] || left[0].localeCompare(right[0]))
.map(([key, count]) => `| ${count} | \`${key.replaceAll('|', '\\|')}\` |`)
const lines = [
`### ${title}`,
'',
`${summary.reports} reports, ${summary.paths} differences, ${summary.unreadable} unreadable lines.`,
''
]
if (summary.reports === 0) {
return `${lines.join('\n')}\n`
}
lines.push('| Count | Kind |', '| ---: | --- |', ...rows(summary.byKind), '')
for (const [spec, kinds] of Object.entries(summary.bySpecAndKind).sort()) {
lines.push(`<details><summary>${spec}: ${summary.bySpec[spec]} reports</summary>`, '')
lines.push('| Count | Kind |', '| ---: | --- |', ...rows(kinds), '', '</details>', '')
}
return `${lines.join('\n')}\n`
}
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
const [logPath, summaryPath, ...reportPaths] = process.argv.slice(2)
const lines = existsSync(logPath) ? readFileSync(logPath, 'utf8').split('\n') : []
const reports = reportPaths
.filter((path) => existsSync(path))
.flatMap((path) => {
try {
return [JSON.parse(readFileSync(path, 'utf8'))]
} catch {
// A report cut short by a killed run: its divergences count as unattributed.
return []
}
})
const summary = summarizeDivergence(lines, reports)
writeFileSync(summaryPath, `${JSON.stringify(summary, null, 2)}\n`)
const markdown = divergenceMarkdown(summary, `Layout save divergence: ${basename(logPath)}`)
process.stdout.write(markdown)
if (process.env.GITHUB_STEP_SUMMARY) {
appendFileSync(process.env.GITHUB_STEP_SUMMARY, markdown)
}
}
@@ -312,6 +312,7 @@ function createMobileCiScopeRepository(
'config/scripts/pr-code-change-scope.mjs',
'config/scripts/pr-package-test-paths.mjs',
'config/scripts/pr-layout-oracle-route.mjs',
'config/scripts/pr-layout-owner-switch-on-route.mjs',
'config/scripts/git-pull-request-diff-base.mjs',
'config/scripts/pr-e2e-source-routing.mjs',
'config/scripts/ci-e2e-job-selection.mjs'
@@ -0,0 +1,65 @@
// Paths whose change runs the advisory `layout_owner_switch_on` job in pr.yml.
import process from 'node:process'
import { pathToFileURL } from 'node:url'
import { MANAGED_SSH_E2E_SPEC_PREFIX } from './ci-e2e-job-selection.mjs'
import { LAYOUT_ORACLE_E2E_SPECS } from './pr-layout-oracle-route.mjs'
/** The oracle, its positive control, and the layout-owner acceptance specs. */
export const LAYOUT_OWNER_SWITCH_ON_E2E_SPECS = [
...LAYOUT_ORACLE_E2E_SPECS,
// Positive control: its direct store write must leave at least one divergence report.
'tests/e2e/cli-file-open-position.spec.ts',
'tests/e2e/relaunch-first-terminal.spec.ts',
'tests/e2e/relaunch-sleeping-agents-resume-on-view.spec.ts',
'tests/e2e/terminal-cold-activation-deferral.spec.ts',
'tests/e2e/cli-first-worktree-single-terminal.spec.ts',
'tests/e2e/unopened-workspace-agent-single-resume.spec.ts',
'tests/e2e/terminal-pane-close-layout-consistency.spec.ts',
// Saves that drop a terminal's tab-bar entry. Not diff-word-wrap.spec.ts: its file-diff case
// fails with the switch off too (main's scheduled e2e run 39148564778, shard 2).
'tests/e2e/golden-file-open-edit-save.spec.ts',
'tests/e2e/golden-source-control-open-diff.spec.ts',
'tests/e2e/paired-cli-terminal-graph-sync-tab-retention.spec.ts',
// Saves whose tab order differs from main's while a frame is held.
'tests/e2e/floating-workspace-pane-height.spec.ts',
'tests/e2e/editor-model-host-isolation.spec.ts',
'tests/e2e/editor-tab-selection-restore.spec.ts',
'tests/e2e/finished-agent-ghost-resume.spec.ts',
'tests/e2e/live-background-terminal-mount-authority.spec.ts',
'tests/e2e/workspace-back-forward-navigation.spec.ts',
'tests/e2e/worktree-lineage-toggle-shortcut.spec.ts',
'tests/e2e/local-cli-terminal-retention.spec.ts',
'tests/e2e/default-tabs-mark-crash-relaunch.spec.ts',
'tests/e2e/terminal-cli-split-closed-before-mount.spec.ts',
'tests/e2e/layout-stream-loss-reopen.spec.ts',
// Every managed-SSH spec, in e2e.yml's Docker lane; its orcad template stays switch-off.
MANAGED_SSH_E2E_SPEC_PREFIX
]
const LAYOUT_OWNER_SWITCH_ON_PREFIXES = [
'src/shared/workspace-layout',
'src/shared/layout-owner-switch.ts',
'src/main/persistence/',
'src/main/runtime/',
'src/renderer/src/store/',
'tests/e2e/',
// The job itself.
'.github/workflows/pr.yml',
'.github/workflows/e2e.yml',
'config/scripts/pr-layout-owner-switch-on-route.mjs',
'config/scripts/layout-save-divergence-summary.mjs'
]
export function isLayoutOwnerSwitchOnSource(file) {
return LAYOUT_OWNER_SWITCH_ON_PREFIXES.some((prefix) => file.startsWith(prefix))
}
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
let input = ''
process.stdin.setEncoding('utf8')
for await (const chunk of process.stdin) {
input += chunk
}
const changedPaths = input.split(/\r?\n/).filter(Boolean)
process.stdout.write(`${changedPaths.some(isLayoutOwnerSwitchOnSource)}\n`)
}
@@ -0,0 +1,142 @@
import { describe, expect, it } from 'vitest'
import { existsSync, readdirSync, readFileSync } from 'node:fs'
import { join, resolve } from 'node:path'
import { parse } from 'yaml'
import { MANAGED_SSH_E2E_SPEC_PREFIX, selectGeneralE2eSpecs } from './ci-e2e-job-selection.mjs'
import { divergenceKind, summarizeDivergence } from './layout-save-divergence-summary.mjs'
import {
isLayoutOwnerSwitchOnSource,
LAYOUT_OWNER_SWITCH_ON_E2E_SPECS
} from './pr-layout-owner-switch-on-route.mjs'
const projectDir = resolve(import.meta.dirname, '../..')
const read = (file) => parse(readFileSync(join(projectDir, file), 'utf8'))
const prWorkflow = read('.github/workflows/pr.yml')
const e2eWorkflow = read('.github/workflows/e2e.yml')
describe('layout owner switch-on job', () => {
const job = prWorkflow.jobs.layout_owner_switch_on
it('runs the listed specs on a switch-on build of the merge commit', () => {
expect(job.uses).toBe('./.github/workflows/e2e.yml')
expect(job.with.ref).toBe('${{ github.sha }}')
expect(job.with.layout_owner).toBe('main')
expect(JSON.parse(job.with.test_files)).toEqual(LAYOUT_OWNER_SWITCH_ON_E2E_SPECS)
for (const spec of LAYOUT_OWNER_SWITCH_ON_E2E_SPECS) {
if (spec !== MANAGED_SSH_E2E_SPEC_PREFIX) {
expect(existsSync(join(projectDir, spec)), spec).toBe(true)
}
}
// Every spec that gates on the switch-on build marker runs here.
const gated = readdirSync(join(projectDir, 'tests/e2e'))
.filter((file) => file.endsWith('.spec.ts'))
.map((file) => `tests/e2e/${file}`)
.filter((spec) =>
readFileSync(join(projectDir, spec), 'utf8').includes('ORCA_LAYOUT_OWNER_MAIN')
)
expect(gated.length).toBeGreaterThan(0)
expect(LAYOUT_OWNER_SWITCH_ON_E2E_SPECS).toEqual(expect.arrayContaining(gated))
expect(selectGeneralE2eSpecs(LAYOUT_OWNER_SWITCH_ON_E2E_SPECS)).not.toContain(
'tests/e2e/workspace-layout-oracle-headless.spec.ts'
)
})
it('does not block the merge and keeps its artifacts apart', () => {
expect(prWorkflow.jobs.verify.needs).not.toContain('layout_owner_switch_on')
const suffixes = Object.values(prWorkflow.jobs)
.filter((other) => other.uses === './.github/workflows/e2e.yml')
.map((other) => other.with.artifact_suffix ?? '')
expect(new Set(suffixes).size).toBe(suffixes.length)
})
it('sets the switch only on the app builds, never job-wide', () => {
for (const other of Object.values(e2eWorkflow.jobs)) {
expect(JSON.stringify(other.env ?? {})).not.toContain('layout_owner')
}
const build = e2eWorkflow.jobs.build.steps.find((step) => step.name === 'Build E2E outputs')
expect(build.run).toContain('export ORCA_LAYOUT_OWNER="$LAYOUT_OWNER"')
expect(e2eWorkflow.on.workflow_call.inputs.layout_owner.default).toBe('')
})
it('routes layout sources, the e2e tree and the job itself', () => {
for (const file of [
'src/shared/workspace-layout/workspace-layout-load.ts',
'src/shared/layout-owner-switch.ts',
'src/main/persistence/window-save-divergence.ts',
'src/main/runtime/terminal-surface-close.ts',
'src/renderer/src/store/slices/terminals.ts',
'tests/e2e/helpers/orca-app.ts',
'.github/workflows/pr.yml',
'.github/workflows/e2e.yml'
]) {
expect(isLayoutOwnerSwitchOnSource(file), file).toBe(true)
}
for (const file of ['src/renderer/src/components/ui/button.tsx', 'docs/STYLEGUIDE.md']) {
expect(isLayoutOwnerSwitchOnSource(file), file).toBe(false)
}
const filter = prWorkflow.jobs.code_paths.steps.find((step) => step.id === 'e2e_filter')
expect(filter.run).toContain('node config/scripts/pr-layout-owner-switch-on-route.mjs')
expect(prWorkflow.jobs.code_paths.steps[0].with['sparse-checkout']).toContain(
'/config/scripts/pr-layout-owner-switch-on-route.mjs'
)
})
})
describe('layout save divergence summary', () => {
it('counts reports by kind and by the spec running when each was written', () => {
const id = 'c47bcbc2-ec0c-458b-9d54-a2252e8f747a'
const report = {
suites: [
{
file: 'a.spec.ts',
specs: [
{
line: 8,
title: 'opens',
tests: [{ results: [{ startTime: '2026-10-19T00:00:00.000Z', duration: 1000 }] }]
}
]
}
]
}
const start = Date.parse('2026-10-19T00:00:00.000Z')
const lines = [
JSON.stringify({
at: start + 500,
paths: [`group ${id} changed: tabOrder`, 'workspace added']
}),
// Written after the test ended, as delayed saves are.
JSON.stringify({ at: start + 5000, paths: [`terminal ${id} tab-bar entry removed`] }),
JSON.stringify({ at: start + 60_000, paths: ['1 pane(s) rebound'] }),
'{"at":',
''
]
expect(summarizeDivergence(lines, [report])).toEqual({
reports: 3,
paths: 4,
unreadable: 1,
byKind: {
'group <id> changed: tabOrder': 1,
'workspace added': 1,
'terminal <id> tab-bar entry removed': 1,
'<n> pane(s) rebound': 1
},
bySpec: { 'a.spec.ts:8 opens': 2, unattributed: 1 },
bySpecAndKind: {
'a.spec.ts:8 opens': {
'group <id> changed: tabOrder': 1,
'workspace added': 1,
'terminal <id> tab-bar entry removed': 1
},
unattributed: { '<n> pane(s) rebound': 1 }
}
})
expect(divergenceKind('tab picker-agent-tab changed: createdAt, terminal')).toBe(
'tab <id> changed: createdAt, terminal'
)
expect(divergenceKind('editor view /tmp/a b/c.ts changed: viewState')).toBe(
'editor view <id> changed: viewState'
)
expect(divergenceKind('file /tmp/a b/c.ts added')).toBe('file <id> added')
})
})
+49 -46
View File
@@ -18,21 +18,15 @@ test('keeps same-path host models and undo history separate', async ({
registerPostElectronShutdownCleanup(() => rm(filePath, { force: true }))
const remoteId = buildOwnedEditorFileId(filePath, context.worktreeId, 'host-model-fixture')
// Controlled owner records exercise renderer identity; file transport remains local.
await orcaPage.evaluate(
({ filePath, remoteId, worktreeId }) => {
// Both tabs open through the user's open path, so a layout-owner build's main holds them too.
// The second owner is keyed apart by a runtime id, then its record is pointed back at the
// fixture's SSH route with local transport before it first loads (it opens unselected).
const openedIds = await orcaPage.evaluate(
({ filePath, worktreeId }) => {
const store = window.__store
if (!store) {
throw new Error('Editor store unavailable')
}
const base = {
filePath,
relativePath: filePath.split(/[\\/]/).pop() ?? filePath,
worktreeId,
language: 'plaintext',
mode: 'edit' as const,
isDirty: true
}
const generation = {
runtimeConnectionGeneration: null,
runtimePairingRevision: undefined,
@@ -40,49 +34,58 @@ test('keeps same-path host models and undo history separate', async ({
nestedSshGeneration: null,
directSshGeneration: null
}
const provenance = (
executionHostId: 'local' | 'ssh:fixture-target',
runtimeEnvironmentId: string | null
) => ({
ownershipProjection: 'explicit' as const,
generation: { ...generation, route: { executionHostId, runtimeEnvironmentId } }
})
const file = {
filePath,
relativePath: filePath.split(/[\\/]/).pop() ?? filePath,
worktreeId,
language: 'plaintext',
mode: 'edit' as const
}
const localId = store.getState().openFile({
...file,
runtimeEnvironmentId: null,
operationProvenance: provenance('local', null)
})
const remoteId = store.getState().openFile(
{
...file,
runtimeEnvironmentId: 'host-model-fixture',
operationProvenance: provenance('ssh:fixture-target', 'host-model-fixture')
},
{ selection: 'none' }
)
store.setState((state) => ({
openFiles: [
{
...base,
id: filePath,
operationProvenance: {
ownershipProjection: 'explicit',
generation: {
...generation,
route: { executionHostId: 'local', runtimeEnvironmentId: null }
openFiles: state.openFiles.map((entry) =>
entry.id === remoteId
? {
...entry,
isDirty: true,
runtimeEnvironmentId: undefined,
operationProvenance: provenance('ssh:fixture-target', null)
}
}
},
{
...base,
id: remoteId,
operationProvenance: {
ownershipProjection: 'explicit',
generation: {
...generation,
route: { executionHostId: 'ssh:fixture-target', runtimeEnvironmentId: null }
}
}
}
],
: entry.id === localId
? { ...entry, isDirty: true }
: entry
),
editorDrafts: {
...state.editorDrafts,
[filePath]: 'LOCAL_MARKER',
[localId]: 'LOCAL_MARKER',
[remoteId]: 'REMOTE_MARKER'
},
activeFileId: filePath,
activeFileIdByWorktree: { ...state.activeFileIdByWorktree, [worktreeId]: filePath }
}
}))
const state = store.getState()
const localTab = state.createUnifiedTab(worktreeId, 'editor', {
entityId: filePath,
label: 'Local file'
})
state.createUnifiedTab(worktreeId, 'editor', { entityId: remoteId, label: 'Remote file' })
store.getState().activateTab(localTab.id)
store.getState().setActiveFile(localId)
return { localId, remoteId }
},
{ filePath, remoteId, worktreeId: context.worktreeId }
{ filePath, worktreeId: context.worktreeId }
)
expect(openedIds).toEqual({ localId: filePath, remoteId })
const editor = orcaPage.locator('.monaco-editor').first()
await expect(editor).toBeVisible({ timeout: 25_000 })