diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index a926dc0fa11..1484b36460d 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -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: diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 33f8d94e0b6..4be04625bdd 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -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 diff --git a/config/scripts/layout-save-divergence-summary.mjs b/config/scripts/layout-save-divergence-summary.mjs new file mode 100644 index 00000000000..ea5e2519b3b --- /dev/null +++ b/config/scripts/layout-save-divergence-summary.mjs @@ -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 [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 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]} ${named[3]}` : path.replace(/^\d+ /, ' ') + return kind.replace(UUID, '') +} + +/** 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(`
${spec}: ${summary.bySpec[spec]} reports`, '') + lines.push('| Count | Kind |', '| ---: | --- |', ...rows(kinds), '', '
', '') + } + 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) + } +} diff --git a/config/scripts/pr-auxiliary-preflight-admission.test.mjs b/config/scripts/pr-auxiliary-preflight-admission.test.mjs index ff69c2c67c1..409c241f7ab 100644 --- a/config/scripts/pr-auxiliary-preflight-admission.test.mjs +++ b/config/scripts/pr-auxiliary-preflight-admission.test.mjs @@ -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' diff --git a/config/scripts/pr-layout-owner-switch-on-route.mjs b/config/scripts/pr-layout-owner-switch-on-route.mjs new file mode 100644 index 00000000000..a2c70580bf8 --- /dev/null +++ b/config/scripts/pr-layout-owner-switch-on-route.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`) +} diff --git a/config/scripts/pr-layout-owner-switch-on-route.test.mjs b/config/scripts/pr-layout-owner-switch-on-route.test.mjs new file mode 100644 index 00000000000..9795ec3da02 --- /dev/null +++ b/config/scripts/pr-layout-owner-switch-on-route.test.mjs @@ -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 changed: tabOrder': 1, + 'workspace added': 1, + 'terminal tab-bar entry removed': 1, + ' pane(s) rebound': 1 + }, + bySpec: { 'a.spec.ts:8 opens': 2, unattributed: 1 }, + bySpecAndKind: { + 'a.spec.ts:8 opens': { + 'group changed: tabOrder': 1, + 'workspace added': 1, + 'terminal tab-bar entry removed': 1 + }, + unattributed: { ' pane(s) rebound': 1 } + } + }) + expect(divergenceKind('tab picker-agent-tab changed: createdAt, terminal')).toBe( + 'tab changed: createdAt, terminal' + ) + expect(divergenceKind('editor view /tmp/a b/c.ts changed: viewState')).toBe( + 'editor view changed: viewState' + ) + expect(divergenceKind('file /tmp/a b/c.ts added')).toBe('file added') + }) +}) diff --git a/tests/e2e/editor-model-host-isolation.spec.ts b/tests/e2e/editor-model-host-isolation.spec.ts index 5c201a0ba54..b4fb8d83813 100644 --- a/tests/e2e/editor-model-host-isolation.spec.ts +++ b/tests/e2e/editor-model-host-isolation.spec.ts @@ -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 })