From 29dca8c031c800a25defbcb4392c4d7fa6426e90 Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Sun, 11 Oct 2026 19:39:59 -0400 Subject: [PATCH] ci: the layout oracle gates merges (#27600) * ci: the layout oracle gates merges An oracle-only job joins verify's needs. It runs only the two layout oracle specs through the existing e2e workflow, routed by layout writer paths; a PR outside the route skips it and verify treats the skip as a pass. The route is widened to the terminal store, terminal pane, graph sync, frozen mirror and compatibility folders while the layout-holder stack is open. The oracle specs leave the advisory e2e job so a PR does not run them twice. * ci: the layout oracle tests the merge commit and routes its harness and stack writers Review fixes: run on the merge commit so a stale-base PR cannot pass on an old main; route the oracle's own e2e harness and runtime client (a change there broke main's oracle without running it), and the window-side layout writers the stack edits; move the route into its own module. * ci: route the remaining layout writers the stack edits to the layout oracle --- .github/workflows/e2e.yml | 28 ++-- .github/workflows/mobile.yml | 1 + .github/workflows/pr.yml | 29 ++++ .../mobile-release-check-scope.test.mjs | 1 + config/scripts/mobile-test-change-scope.mjs | 1 + .../pr-auxiliary-preflight-admission.test.mjs | 1 + config/scripts/pr-code-change-scope.mjs | 6 +- config/scripts/pr-code-change-scope.test.mjs | 4 +- .../pr-e2e-layout-oracle-routing.test.mjs | 155 +++++++++++++----- config/scripts/pr-e2e-source-routing.mjs | 35 +--- config/scripts/pr-layout-oracle-route.mjs | 81 +++++++++ 11 files changed, 257 insertions(+), 85 deletions(-) create mode 100644 config/scripts/pr-layout-oracle-route.mjs diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 6f67dd25ee4..6b07312e158 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -27,6 +27,12 @@ on: required: false type: boolean default: true + # Why: artifact names are unique per workflow run, and pr.yml calls this workflow twice. + artifact_suffix: + description: Appended to the build and trace artifact names so two calls in one run do not collide + required: false + type: string + default: '' workflow_dispatch: inputs: ref: @@ -91,7 +97,7 @@ jobs: - name: Upload E2E build output uses: actions/upload-artifact@v7 with: - name: e2e-build-out + name: e2e-build-out${{ inputs.artifact_suffix }} path: out/ # Why: build-relay.mjs writes each relay's marker as `out/relay//.version`, # and upload-artifact drops dotfiles by default — consumers then fail SSH specs with @@ -201,7 +207,7 @@ jobs: - name: Download E2E build output uses: actions/download-artifact@v8 with: - name: e2e-build-out + name: e2e-build-out${{ inputs.artifact_suffix }} path: out/ # Why: the Electron suite is wall-clock constrained on OSS runners, but @@ -291,7 +297,7 @@ jobs: - name: Download E2E build output uses: actions/download-artifact@v8 with: - name: e2e-build-out + name: e2e-build-out${{ inputs.artifact_suffix }} path: out/ - name: Run changed E2E specs @@ -345,7 +351,7 @@ jobs: if: failure() || cancelled() uses: actions/upload-artifact@v7 with: - name: playwright-traces-changed + name: playwright-traces-changed${{ inputs.artifact_suffix }} path: test-results/ retention-days: 7 if-no-files-found: ignore @@ -393,7 +399,7 @@ jobs: native-runtime: electron - uses: actions/download-artifact@v8 with: - name: e2e-build-out + name: e2e-build-out${{ inputs.artifact_suffix }} path: out/ # 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. @@ -423,7 +429,7 @@ jobs: - uses: actions/upload-artifact@v7 if: failure() with: - name: orcad-serve-mode-switch-traces + name: orcad-serve-mode-switch-traces${{ inputs.artifact_suffix }} path: test-results/ retention-days: 7 if-no-files-found: ignore @@ -451,7 +457,7 @@ jobs: native-runtime: electron - uses: actions/download-artifact@v8 with: - name: e2e-build-out + name: e2e-build-out${{ inputs.artifact_suffix }} path: out/ # Built to a path the app does not look at by default, so the relay phase has no template. - name: Build the linux-x64-glibc orcad template @@ -474,7 +480,7 @@ jobs: - uses: actions/upload-artifact@v7 if: failure() with: - name: orcad-auto-convert-docker-traces + name: orcad-auto-convert-docker-traces${{ inputs.artifact_suffix }} path: test-results/ retention-days: 7 if-no-files-found: ignore @@ -501,7 +507,7 @@ jobs: native-runtime: electron - uses: actions/download-artifact@v8 with: - name: e2e-build-out + name: e2e-build-out${{ inputs.artifact_suffix }} path: out/ - name: Build the linux-x64-glibc orcad template run: | @@ -522,7 +528,7 @@ jobs: - uses: actions/upload-artifact@v7 if: failure() with: - name: orcad-journeys-docker-traces + name: orcad-journeys-docker-traces${{ inputs.artifact_suffix }} path: test-results/ retention-days: 7 if-no-files-found: ignore @@ -580,7 +586,7 @@ jobs: - uses: actions/upload-artifact@v7 if: failure() with: - name: orcad-serve-mode-switch-windows-traces + name: orcad-serve-mode-switch-windows-traces${{ inputs.artifact_suffix }} path: test-results/ retention-days: 7 if-no-files-found: ignore diff --git a/.github/workflows/mobile.yml b/.github/workflows/mobile.yml index 381b3a24232..30dc63186e1 100644 --- a/.github/workflows/mobile.yml +++ b/.github/workflows/mobile.yml @@ -36,6 +36,7 @@ on: - 'config/scripts/mobile-release-check-scope*' - 'config/scripts/mobile-test-change-scope*' - 'config/scripts/pr-code-change-scope.mjs' + - 'config/scripts/pr-layout-oracle-route.mjs' # Why main too: two pull requests can each pass against their own base and disagree once both # land, and `verify` never runs on main. The same source paths as above, less the CI inputs only # `verify` reads. diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 2d3b3c1e5c7..ba11ca658fd 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -50,6 +50,7 @@ jobs: managed_hook_node18: ${{ steps.readiness.outputs.reused != 'true' && steps.filter.outputs.managed_hook_node18 }} package: ${{ steps.readiness.outputs.reused != 'true' && steps.filter.outputs.package }} package_windows: ${{ steps.readiness.outputs.reused != 'true' && steps.filter.outputs.package_windows }} + layout_oracle: ${{ steps.readiness.outputs.reused != 'true' && steps.filter.outputs.layout_oracle }} e2e_should_run: ${{ steps.e2e_filter.outputs.should_run }} test_files: ${{ steps.e2e_filter.outputs.test_files }} e2e_run_changed: ${{ steps.e2e_filter.outputs.e2e_run_changed }} @@ -75,6 +76,7 @@ jobs: /.github/scripts/check-root-directory-entries.mjs /config/scripts/check-readme-local-links.mjs /config/scripts/pr-code-change-scope.mjs + /config/scripts/pr-layout-oracle-route.mjs /config/scripts/pr-e2e-source-routing.mjs /config/scripts/ci-e2e-job-selection.mjs /config/scripts/pr-ready-check-reuse.mjs @@ -1377,6 +1379,29 @@ jobs: run_changed_e2e: ${{ needs.code_paths.outputs.e2e_run_changed != 'false' }} needs_build: ${{ needs.code_paths.outputs.e2e_needs_build != 'false' }} + # Why its own required job, apart from `e2e`: the rest of the suite is red on main, but the + # layout oracle is green there, and its known-on-main list absorbs today's findings. Only the two + # oracle specs run, through the same reusable workflow: the window spec in the changed-spec lane, + # the headless spec in the orcad lane on both serve hosts. + layout_oracle: + name: layout oracle + needs: [code_paths, preflight] + if: >- + !cancelled() && + needs.code_paths.result == 'success' && + needs.code_paths.outputs.layout_oracle == 'true' && + needs.preflight.result == 'success' + permissions: + contents: read + uses: ./.github/workflows/e2e.yml + with: + # Why the merge commit, unlike `e2e`: a gate must test what lands on main, not a stale head. + ref: ${{ github.sha }} + test_files: '["tests/e2e/workspace-layout-oracle-headless.spec.ts","tests/e2e/workspace-layout-oracle.spec.ts"]' + run_changed_e2e: true + needs_build: true + artifact_suffix: -layout-oracle + # 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 @@ -1417,6 +1442,7 @@ jobs: - managed_hook_node18 - package - package_windows + - layout_oracle # Evaluating job results only needs the lightweight container runner. runs-on: ubuntu-slim timeout-minutes: 5 @@ -1456,6 +1482,8 @@ jobs: PACKAGE_SHOULD_RUN: ${{ needs.code_paths.outputs.package }} PACKAGE_WINDOWS: ${{ needs.package_windows.result }} PACKAGE_WINDOWS_SHOULD_RUN: ${{ needs.code_paths.outputs.package_windows }} + LAYOUT_ORACLE: ${{ needs.layout_oracle.result }} + LAYOUT_ORACLE_SHOULD_RUN: ${{ needs.code_paths.outputs.layout_oracle }} run: | if [ "$CODE_PATHS" != "success" ]; then exit 1 @@ -1490,4 +1518,5 @@ jobs: check_job managed_hook_node18 "$MANAGED_HOOK_NODE18" "$MANAGED_HOOK_NODE18_SHOULD_RUN" check_job package "$PACKAGE" "$PACKAGE_SHOULD_RUN" check_job package_windows "$PACKAGE_WINDOWS" "$PACKAGE_WINDOWS_SHOULD_RUN" + check_job layout_oracle "$LAYOUT_ORACLE" "$LAYOUT_ORACLE_SHOULD_RUN" exit "$failed" diff --git a/config/scripts/mobile-release-check-scope.test.mjs b/config/scripts/mobile-release-check-scope.test.mjs index 2283827ad0a..7a07b19af2f 100644 --- a/config/scripts/mobile-release-check-scope.test.mjs +++ b/config/scripts/mobile-release-check-scope.test.mjs @@ -137,6 +137,7 @@ function fixture(detectorStep = detector) { for (const file of [ 'package.json', 'config/scripts/pr-code-change-scope.mjs', + 'config/scripts/pr-layout-oracle-route.mjs', 'config/scripts/mobile-release-check-scope.mjs', 'config/scripts/mobile-test-change-scope.mjs' ]) { diff --git a/config/scripts/mobile-test-change-scope.mjs b/config/scripts/mobile-test-change-scope.mjs index cdd04d6edf5..f773fb8fa02 100644 --- a/config/scripts/mobile-test-change-scope.mjs +++ b/config/scripts/mobile-test-change-scope.mjs @@ -9,6 +9,7 @@ const NON_TEST_FILES = new Set([ '.github/workflows/mobile-ios-release.yml', 'config/scripts/pr-code-change-scope.mjs', 'config/scripts/pr-code-change-scope.test.mjs', + 'config/scripts/pr-layout-oracle-route.mjs', 'config/scripts/mobile-release-check-scope.mjs', 'config/scripts/mobile-release-check-scope.test.mjs' ]) diff --git a/config/scripts/pr-auxiliary-preflight-admission.test.mjs b/config/scripts/pr-auxiliary-preflight-admission.test.mjs index f177f3040fe..d0c41ffc7c7 100644 --- a/config/scripts/pr-auxiliary-preflight-admission.test.mjs +++ b/config/scripts/pr-auxiliary-preflight-admission.test.mjs @@ -301,6 +301,7 @@ function createMobileCiScopeRepository( for (const file of [ 'package.json', 'config/scripts/pr-code-change-scope.mjs', + 'config/scripts/pr-layout-oracle-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-code-change-scope.mjs b/config/scripts/pr-code-change-scope.mjs index 59bf0e191e4..149c49a2d11 100644 --- a/config/scripts/pr-code-change-scope.mjs +++ b/config/scripts/pr-code-change-scope.mjs @@ -2,6 +2,7 @@ import { readFileSync } from 'node:fs' import { join } from 'node:path' import process from 'node:process' import { pathToFileURL } from 'node:url' +import { isLayoutOracleSource } from './pr-layout-oracle-route.mjs' const DOCS_ONLY_FILES = new Set([ 'README.md', @@ -30,7 +31,8 @@ export const PR_CHECK_JOBS = [ 'cross-version-wire', 'managed_hook_node18', 'package', - 'package_windows' + 'package_windows', + 'layout_oracle' ] const ALWAYS_ON_CODE_JOBS = new Set(['static_analysis', 'typecheck', 'test']) @@ -591,6 +593,8 @@ function jobDetector(job) { return (files) => files.some(isLinuxPackagePath) case 'package_windows': return (files) => files.some(isWindowsPackagePath) + case 'layout_oracle': + return (files) => files.some(isLayoutOracleSource) default: return () => true } diff --git a/config/scripts/pr-code-change-scope.test.mjs b/config/scripts/pr-code-change-scope.test.mjs index 97a1b0f11e3..5f80a7112da 100644 --- a/config/scripts/pr-code-change-scope.test.mjs +++ b/config/scripts/pr-code-change-scope.test.mjs @@ -394,7 +394,9 @@ describe('per-job path classification', () => { expectClassification([file], { 'cross-version-wire': true, package: true, - package_windows: true + package_windows: true, + // The session-tabs RPC publishes layout, so the layout oracle gates it too. + layout_oracle: file === 'src/main/runtime/rpc/methods/session-tabs.ts' }) } expectClassification( diff --git a/config/scripts/pr-e2e-layout-oracle-routing.test.mjs b/config/scripts/pr-e2e-layout-oracle-routing.test.mjs index f0962edff15..b4afd8e5c4a 100644 --- a/config/scripts/pr-e2e-layout-oracle-routing.test.mjs +++ b/config/scripts/pr-e2e-layout-oracle-routing.test.mjs @@ -1,71 +1,104 @@ import { describe, expect, it } from 'vitest' -import { existsSync } from 'node:fs' -import { join, resolve } from 'node:path' +import { existsSync, readFileSync } from 'node:fs' +import { dirname, join, relative, resolve } from 'node:path' +import { parse } from 'yaml' import { classifyE2eJobs, selectGeneralE2eSpecs } from './ci-e2e-job-selection.mjs' -import { - PR_E2E_SOURCE_ROUTES, - selectPrE2eSpecs, - shouldRunReusablePrE2e -} from './pr-e2e-source-routing.mjs' +import { classifyPrJobs } from './pr-code-change-scope.mjs' +import { isLayoutOracleSource, LAYOUT_ORACLE_E2E_SPECS } from './pr-layout-oracle-route.mjs' +import { selectPrE2eSpecs, shouldRunReusablePrE2e } from './pr-e2e-source-routing.mjs' const projectDir = resolve(import.meta.dirname, '../..') - -const ORACLE_ROUTE = PR_E2E_SOURCE_ROUTES.find((route) => route.id === 'workspace-layout.oracle') -const TAB_STORE_ROUTE = PR_E2E_SOURCE_ROUTES.find( - (route) => route.id === 'workspace-layout.oracle-tab-store' -) +const prWorkflow = parse(readFileSync(join(projectDir, '.github/workflows/pr.yml'), 'utf8')) /** One product file per layout writer family; each alone must run the oracle. */ const LAYOUT_AUTHORITIES = [ 'src/shared/workspace-layout/workspace-layout-pane-commands.ts', + 'src/shared/workspace-layout/old-client/index.ts', 'src/main/persistence/terminal-topology/terminal-topology-commit.ts', 'src/main/persistence/restoring-sessions/session-owner-fields.ts', 'src/main/persistence/loading-store/pty-binding-persistence.ts', + 'src/main/remote-layout-snapshot/index.ts', 'src/main/runtime/orca-runtime-split-pty-backed-terminal.ts', 'src/main/runtime/orca-runtime-move-headless-mobile-session-tab.ts', 'src/main/runtime/orca-runtime-create-terminal.ts', 'src/renderer/src/runtime/mobile-session-tab-close.ts', 'src/renderer/src/runtime/runtime-layout-client.ts', + 'src/renderer/src/store/slices/tabs.ts', + 'src/renderer/src/store/slices/tabs/tabs-move-actions.ts', 'tests/e2e/workspace-layout-oracle-known-on-main.ts', - 'tests/e2e/helpers/workspace-layout-oracle-compare.ts' + 'tests/e2e/helpers/workspace-layout-oracle-compare.ts', + ...LAYOUT_ORACLE_E2E_SPECS ] -describe('workspace layout oracle PR E2E routing', () => { - it('routes each layout authority on its own', () => { - expect(ORACLE_ROUTE, 'the oracle route was renamed or removed').toBeDefined() - for (const file of LAYOUT_AUTHORITIES) { - expect(selectPrE2eSpecs([file]), file).toEqual(ORACLE_ROUTE.specs.toSorted()) - expect(shouldRunReusablePrE2e([file]), file).toBe(true) - } - }) +/** Widened while the layout-holder stack is open: these still write layout. */ +const STACK_WRITERS = [ + 'src/renderer/src/store/slices/terminals.ts', + 'src/renderer/src/components/terminal-pane/TerminalPane.tsx', + 'src/renderer/src/runtime/sync-runtime-graph.ts', + 'src/renderer/src/runtime/web-session-tabs-sync.ts', + 'src/main/runtime/orca-runtime-split-terminal.ts', + 'src/main/runtime/orca-runtime-sync-window-graph.ts', + 'src/main/runtime/mobile-session-layout-projection.ts', + 'src/main/runtime/headless-runtime-graph.ts', + 'src/main/persistence/loading-store/session-host-partitions.ts', + 'src/main/persistence/loading-store/workspace-session-snapshot-publication.ts', + 'src/renderer/src/store/slices/tab-group-reference-repair.ts', + 'src/renderer/src/store/slices/terminal-session-row-hydration.ts', + 'src/renderer/src/store/workspace-layout-cache.ts', + 'src/renderer/src/app-shell/use-runtime-graph-sync.ts', + 'src/renderer/src/components/tab-group/tab-drag-drop-commit.ts', + 'src/shared/remote-layout-snapshot.ts', + 'src/shared/terminal-leaf-move.ts', + 'src/main/ipc/pty/ipc/leaf-move.ts', + 'src/main/persistence/closed-terminal-tab-write-fence.ts', + 'src/main/runtime/runtime-workspace-session-controller.ts', + 'src/main/runtime/terminal-surface-close.ts', + 'src/renderer/src/components/terminal/terminal-tab-actions.ts', + 'src/renderer/src/components/tab-bar/web-runtime-tab-move-mirror.ts', + 'src/shared/rpc-contract/workspace-layout-params.ts', + 'src/renderer/src/store/slices/recently-closed-tabs.ts' +] - it('runs the window and headless oracles, not the SSH lane, for the tab store', () => { - for (const file of [ - 'src/renderer/src/store/slices/tabs.ts', - 'src/renderer/src/store/slices/tabs/tabs-move-actions.ts' - ]) { - expect(selectPrE2eSpecs([file]), file).toEqual(TAB_STORE_ROUTE.specs.toSorted()) +/** Every test file the oracle specs import, transitively: a change there can break the gate. */ +function oracleHarnessFiles() { + const seen = new Set() + const pending = [...LAYOUT_ORACLE_E2E_SPECS] + while (pending.length > 0) { + const file = pending.pop() + if (seen.has(file)) { + continue + } + seen.add(file) + const source = readFileSync(join(projectDir, file), 'utf8') + for (const [, specifier] of source.matchAll(/(?:from|import)\s*\(?\s*'(\.[^']+)'/g)) { + const base = relative(projectDir, join(projectDir, dirname(file), specifier)) + const resolved = [base, `${base}.ts`, `${base}.tsx`].find( + (candidate) => /\.tsx?$/.test(candidate) && existsSync(join(projectDir, candidate)) + ) + if (resolved?.startsWith('tests/')) { + pending.push(resolved) + } + } + } + return [...seen] +} + +describe('workspace layout oracle merge gate', () => { + it('routes each layout authority and stack writer on its own', () => { + for (const file of [...LAYOUT_AUTHORITIES, ...STACK_WRITERS]) { + expect(isLayoutOracleSource(file), file).toBe(true) + expect(classifyPrJobs([file]).layout_oracle, file).toBe(true) expect(existsSync(join(projectDir, file)), file).toBe(true) } }) - it('sends each oracle spec to the job that can run it', () => { - const specs = ORACLE_ROUTE.specs - // Window spec runs in the general shards; headless runs in its dedicated job. - expect(selectGeneralE2eSpecs(specs)).toEqual(['tests/e2e/workspace-layout-oracle.spec.ts']) - expect(classifyE2eJobs(JSON.stringify(specs))).toEqual({ - e2e_run_changed: true, - e2e_needs_build: true - }) + it('routes every test file the oracle specs import', () => { + const harness = oracleHarnessFiles() + expect(harness.length).toBeGreaterThan(LAYOUT_ORACLE_E2E_SPECS.length) + expect(harness.filter((file) => !isLayoutOracleSource(file))).toEqual([]) }) - it('keeps every routed spec and authority a real file', () => { - for (const file of [...ORACLE_ROUTE.specs, ...LAYOUT_AUTHORITIES]) { - expect(existsSync(join(projectDir, file)), file).toBe(true) - } - }) - - it('leaves tests, fixtures and unrelated runtime modules alone', () => { + it('leaves tests, fixtures, harnesses and unrelated runtime modules alone', () => { for (const file of [ 'src/main/persistence/terminal-topology/terminal-topology-commit.test.ts', 'src/main/persistence/terminal-topology/terminal-leaf-move-fixture.ts', @@ -75,12 +108,46 @@ describe('workspace layout oracle PR E2E routing', () => { 'src/main/runtime/rpc/methods/terminal/terminal-create.ts', 'src/main/runtime/runtime-rpc-database.ts', 'src/main/runtime/orca-runtime-files.ts', - 'src/renderer/src/runtime/sync-runtime-graph.ts', 'src/renderer/src/runtime/remote-runtime-terminal-multiplexer.ts', 'src/renderer/src/runtime/web-session-tabs-sync-test-harness.ts', + 'src/renderer/src/runtime/sync-runtime-graph-test-harness.ts', + 'src/renderer/src/runtime/sync-runtime-graph.test.ts', 'tests/e2e/workspace-layout-oracle-latency.spec.ts' ]) { - expect(ORACLE_ROUTE.matches(file) || TAB_STORE_ROUTE.matches(file), file).toBe(false) + expect(isLayoutOracleSource(file), file).toBe(false) } + expect(classifyPrJobs(['src/main/runtime/orca-runtime-files.ts']).layout_oracle).toBe(false) + }) + + it('keeps the oracle specs out of the advisory e2e job', () => { + expect(selectPrE2eSpecs(LAYOUT_AUTHORITIES)).toEqual([]) + expect(shouldRunReusablePrE2e(LAYOUT_AUTHORITIES)).toBe(false) + }) + + it('runs exactly the oracle specs, each in the lane that can run it', () => { + const job = prWorkflow.jobs.layout_oracle + expect(job.uses).toBe('./.github/workflows/e2e.yml') + // The merge commit, so a stale-base PR cannot pass on an old main. + expect(job.with.ref).toBe('${{ github.sha }}') + const specs = JSON.parse(job.with.test_files) + expect(specs.toSorted()).toEqual(LAYOUT_ORACLE_E2E_SPECS.toSorted()) + // Window spec runs in the changed-spec lane; headless runs in the orcad lane. + expect(selectGeneralE2eSpecs(specs)).toEqual(['tests/e2e/workspace-layout-oracle.spec.ts']) + expect(classifyE2eJobs(job.with.test_files)).toEqual({ + e2e_run_changed: true, + e2e_needs_build: true + }) + }) + + it('blocks the merge on the oracle and passes when it is skipped', () => { + const verify = prWorkflow.jobs.verify + const gate = verify.steps.find((step) => step.name === 'Require successful checks') + expect(verify.needs).toContain('layout_oracle') + expect(gate.run).toContain( + 'check_job layout_oracle "$LAYOUT_ORACLE" "$LAYOUT_ORACLE_SHOULD_RUN"' + ) + // Two calls of the reusable workflow in one run must not share artifact names. + expect(prWorkflow.jobs.layout_oracle.with.artifact_suffix).toBeTruthy() + expect(prWorkflow.jobs.e2e.with.artifact_suffix).toBeUndefined() }) }) diff --git a/config/scripts/pr-e2e-source-routing.mjs b/config/scripts/pr-e2e-source-routing.mjs index fdfc3ae1237..9b989d7cf1b 100644 --- a/config/scripts/pr-e2e-source-routing.mjs +++ b/config/scripts/pr-e2e-source-routing.mjs @@ -5,10 +5,9 @@ import { isDaemonInitTestSupportChange, isUnitTestSupportSource } from './pr-code-change-scope.mjs' +import { LAYOUT_ORACLE_E2E_SPECS } from './pr-layout-oracle-route.mjs' const isProductSource = (file) => !/\.test\.tsx?$/.test(file) && !isUnitTestSupportSource(file) -const isLayoutOracleSource = (file) => - isProductSource(file) && !/\.spec\.ts$|-fixtures?\.ts$/.test(file) // Why config/patches: the xterm fork owns the helper textarea an input method attaches to, so a // patch edit can break composition without touching a file named "ime". @@ -384,31 +383,6 @@ export const PR_E2E_SOURCE_ROUTES = [ /^src\/.*(?:[Cc]lient-?[Hh]osted|browser-host-(?:lease|page|client-page)|browser-client-(?:host|page)|runtime-browser-(?:client-)?page|session-tabs-sync|host-session-snapshot-authority|orca-runtime(?:-browser)?\.ts|\/runtime-(?:status|types)\.ts)/.test( file ) - }, - { - // Why: the oracle is the only check that reads every view of the stored layout back. Runtime - // matches only the modules that create, move, close, persist or publish layout, not every - // session or terminal file, so the orcad build and Docker SSH lane stay off unrelated work. - id: 'workspace-layout.oracle', - specs: [ - 'tests/e2e/workspace-layout-oracle-headless.spec.ts', - 'tests/e2e/workspace-layout-oracle.spec.ts' - ], - matches: (file) => - isLayoutOracleSource(file) && - /^(?:src\/shared\/workspace-layout\/|src\/main\/persistence\/(?:terminal-topology\/|restoring-sessions\/|loading-store\/pty-binding-)|src\/(?:main|renderer\/src)\/runtime\/(?:[^/]+\/)*[^/]*(?:pane-admission|workspace-layout|session-tab|headless-[^/]*tab|layout-client|create-terminal|split-pty-backed-terminal|terminal-split-layout|spawn-placement)[^/]*\.ts$|tests\/e2e\/(?:helpers\/)?(?:workspace-layout-oracle|headless-layout-oracle|terminal-layout-journeys))/.test( - file - ) - }, - { - // The window's tab store reaches SSH through the same layout writers, so not the SSH lane. - id: 'workspace-layout.oracle-tab-store', - specs: [ - 'tests/e2e/workspace-layout-oracle-headless.spec.ts', - 'tests/e2e/workspace-layout-oracle.spec.ts' - ], - matches: (file) => - isLayoutOracleSource(file) && file.startsWith('src/renderer/src/store/slices/tabs') } ] @@ -420,7 +394,12 @@ export function selectPrE2eSpecs( const routePaths = isDaemonInitTestSupportChange(changedPaths, allowDaemonInitTestSupport) ? changedPaths.filter((file) => file !== DAEMON_INIT_TEST_SUPPORT) : changedPaths - const specs = new Set(changedPaths.filter((file) => /^tests\/e2e\/.*\.spec\.ts$/.test(file))) + // The required layout_oracle job owns the oracle specs; running them here too would double the cost. + const specs = new Set( + changedPaths.filter( + (file) => /^tests\/e2e\/.*\.spec\.ts$/.test(file) && !LAYOUT_ORACLE_E2E_SPECS.includes(file) + ) + ) for (const route of PR_E2E_SOURCE_ROUTES) { const matchedFiles = routePaths.filter(route.matches) if (matchedFiles.length === 0) { diff --git a/config/scripts/pr-layout-oracle-route.mjs b/config/scripts/pr-layout-oracle-route.mjs new file mode 100644 index 00000000000..d1c18eeb9c2 --- /dev/null +++ b/config/scripts/pr-layout-oracle-route.mjs @@ -0,0 +1,81 @@ +// Paths whose change runs the required `layout_oracle` job in pr.yml. + +/** The layout oracle's two specs; the job runs both and nothing else. */ +export const LAYOUT_ORACLE_E2E_SPECS = [ + 'tests/e2e/workspace-layout-oracle-headless.spec.ts', + 'tests/e2e/workspace-layout-oracle.spec.ts' +] + +const LAYOUT_ORACLE_PREFIXES = [ + // Layout model, persistence and compatibility folders. + 'src/shared/workspace-layout/', + 'src/shared/remote-layout-snapshot.ts', + 'src/shared/terminal-leaf-move.ts', + 'src/main/persistence/terminal-topology/', + 'src/main/persistence/restoring-sessions/', + 'src/main/persistence/loading-store/pty-binding-', + 'src/main/persistence/loading-store/session-host-partitions.ts', + 'src/main/persistence/loading-store/workspace-session-snapshot-publication.ts', + 'src/main/remote-layout-snapshot/', + 'src/main/runtime/old-session-tabs-stream/', + 'src/renderer/src/runtime/remote-workspace-frozen/', + // Window-side layout writers the layout-holder stack replaces; widened while it is open. + 'src/renderer/src/store/slices/tabs', + 'src/renderer/src/store/slices/tab-group', + 'src/renderer/src/store/slices/terminals', + 'src/renderer/src/store/slices/terminal-', + 'src/renderer/src/store/terminals/', + 'src/renderer/src/store/workspace-layout-cache', + 'src/renderer/src/components/terminal-pane/', + 'src/renderer/src/components/tab-group/tab-drag-drop-commit.ts', + 'src/renderer/src/app-shell/use-runtime-graph-sync.ts', + 'src/renderer/src/runtime/sync-runtime-graph', + 'src/renderer/src/runtime/web-session-tabs-sync', + // Other layout writers the stack edits. + 'src/main/ipc/pty/ipc/leaf-move.ts', + 'src/main/persistence/closed-terminal-tab-write-fence.ts', + 'src/main/runtime/runtime-workspace-session-controller.ts', + 'src/main/runtime/terminal-surface-close.ts', + 'src/renderer/src/components/terminal/terminal-tab-actions.ts', + 'src/renderer/src/components/tab-bar/web-runtime-tab-move-mirror.ts', + 'src/shared/rpc-contract/workspace-layout-params.ts', + 'src/renderer/src/store/slices/recently-closed-tabs.ts', + // The client the oracle reads every view through. + 'src/cli/runtime/client.ts', + 'src/shared/remote-runtime-client.ts', + 'src/shared/remote-runtime-subscription-transport.ts', + 'src/shared/paired-runtime-connection.ts', + 'src/shared/pairing.ts', + // The harness: the oracle imports most of the shared e2e helpers, not just its own. + 'tests/e2e/workspace-layout-oracle', + 'tests/e2e/terminal-probe-input-sequence.ts', + 'tests/e2e/global-', + 'tests/e2e/helpers/', + 'tests/playwright.config.ts', + '.github/scripts/e2e-with-window-manager.sh', + // The job itself: the called workflow, its window-spec lane picker, and this route. + '.github/workflows/e2e.yml', + 'config/scripts/ci-e2e-job-selection.mjs', + 'config/scripts/pr-layout-oracle-route.mjs' +] + +// Runtime matches only the modules that create, move, close, persist or publish layout. +const LAYOUT_ORACLE_RUNTIME_MODULE = + /^src\/(?:main|renderer\/src)\/runtime\/(?:[^/]+\/)*[^/]*(?:pane-admission|workspace-layout|session-tab|headless-[^/]*tab|layout-client|layout-command-router|layout-projection|create-terminal|split-terminal|split-pty-backed-terminal|terminal-split-layout|spawn-placement|sync-window-graph|runtime-graph|leaf-move)[^/]*\.ts$/ + +// Why fixtures only under src/: there they serve unit tests; e2e fixtures are the oracle's harness. +const TEST_SUPPORT = + /\.(?:test|spec)\.(?:[cm]?js|tsx?)$|\/__tests__\/|^src\/.*-fixtures?\.ts$|-test-harness\.tsx?$/ + +export function isLayoutOracleSource(file) { + if (LAYOUT_ORACLE_E2E_SPECS.includes(file)) { + return true + } + if (TEST_SUPPORT.test(file)) { + return false + } + return ( + LAYOUT_ORACLE_PREFIXES.some((prefix) => file.startsWith(prefix)) || + LAYOUT_ORACLE_RUNTIME_MODULE.test(file) + ) +}