diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 9edeeece8f3a..278c6ca42d51 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -860,21 +860,10 @@ jobs: DIFF_BASE="$(node config/scripts/git-pull-request-diff-base.mjs "$BASE_SHA")" pnpm run check:runtime-launcher-protocol-ratchet -- --base "$DIFF_BASE" - # A path filter that matches nothing exits 1 ("No test files found"), so this - # lane cannot report success while running zero tests. + # Runs the whole directory so a new file there cannot be left out; an empty match + # exits 1 ("No test files found"), so this lane cannot pass while running zero tests. - name: Old/new client and server compatibility journeys - run: >- - pnpm exec vitest run --config config/vitest.config.ts - tests/e2e/cross-version-wire/release-checkout.unit.test.ts - tests/e2e/cross-version-wire/cross-version-browser-placement.unit.test.ts - tests/e2e/cross-version-wire/cross-version-terminal-wire.unit.test.ts - tests/e2e/cross-version-wire/reported-lossy-initial-snapshot.unit.test.ts - tests/e2e/cross-version-wire/cross-version-agent-session-wire.unit.test.ts - tests/e2e/cross-version-wire/cross-version-worktree-identity-downgrade.unit.test.ts - tests/e2e/cross-version-wire/cross-version-session-tabs-retirement-proof.unit.test.ts - tests/e2e/cross-version-wire/agent-session-resume-marker-downgrade.unit.test.ts - tests/e2e/cross-version-wire/agent-session-stop-event-downgrade.unit.test.ts - tests/e2e/cross-version-wire/cross-version-worktree-ps-verdict.unit.test.ts + run: pnpm exec vitest run --config config/vitest.config.ts tests/e2e/cross-version-wire/ managed_hook_node18: name: managed hooks on Node 18 diff --git a/config/scripts/ci-unit-exclusion-coverage.test.mjs b/config/scripts/ci-unit-exclusion-coverage.test.mjs new file mode 100644 index 000000000000..9061b5723a6c --- /dev/null +++ b/config/scripts/ci-unit-exclusion-coverage.test.mjs @@ -0,0 +1,89 @@ +import { globSync, readFileSync } from 'node:fs' +import { parse } from 'yaml' +import { defaultExclude } from 'vitest/config' +import { describe, expect, it } from 'vitest' +import { CROSS_VERSION_WIRE_DIR, UNIT_INCLUDE, discoverUnitFiles } from './ci-unit-files.mjs' +import { classifyPrJobs } from './pr-code-change-scope.mjs' + +function posixPaths(files) { + return files.map((file) => file.replaceAll('\\', '/')) +} + +function workflowJobs(path) { + return parse(readFileSync(path, 'utf8')).jobs +} + +// Why verify.needs: a job left out of it (e2e, ime, wsl) can be red or skipped without blocking a merge. +// Each gating job carries the code_paths output that decides whether it runs; a called workflow's jobs inherit the caller's. +function gatingJobs() { + const jobs = workflowJobs('.github/workflows/pr.yml') + return jobs.verify.needs.flatMap((name) => { + const { if: condition = '', uses } = jobs[name] + const gate = /needs\.code_paths\.outputs\.([\w-]+) == 'true'/.exec(condition)?.[1] + const called = uses?.startsWith('./.github/workflows/') + ? Object.values(workflowJobs(uses.slice(2))) + : [jobs[name]] + return called.map((job) => ({ gate, pathArguments: vitestPathArguments(job) })) + }) +} + +// Path arguments of every step in the job that runs vitest; a trailing slash means a directory. +function vitestPathArguments(job) { + return (job.steps ?? []) + .map((step) => step.run) + .filter((run) => typeof run === 'string' && /\bvitest\b/.test(run)) + .flatMap((run) => run.replaceAll('\\\n', ' ').split(/\s+/)) + .map((token) => token.replace(/^['"]|['"]$/g, '')) + .filter((token) => /^(?:src|config|tests|mobile)\//.test(token)) +} + +function runByVitestStep(file, pathArguments) { + return pathArguments.some( + (path) => path === file || (path.endsWith('/') && file.startsWith(path)) + ) +} + +// Why module scope: globbing the unit tree costs seconds, and every test reads the same result. +const UNIT_FILES = posixPaths(globSync(UNIT_INCLUDE, { exclude: defaultExclude })) +const SHARDED_UNIT_FILES = new Set(discoverUnitFiles()) +const EXCLUDED_UNIT_FILES = UNIT_FILES.filter((file) => !SHARDED_UNIT_FILES.has(file)) +const GATING_JOBS = gatingJobs() + +describe('unit files kept out of the sharded test job', () => { + it('each still runs in a job that gates the PR', () => { + expect(EXCLUDED_UNIT_FILES.length).toBeGreaterThan(0) + // Why: an excluded file no gating step names guards nothing, and nothing else reports it. + expect( + EXCLUDED_UNIT_FILES.filter( + (file) => !GATING_JOBS.some((job) => runByVitestStep(file, job.pathArguments)) + ) + ).toEqual([]) + }) + + it('a change to each runs a gating job that names it', () => { + // Why: otherwise a PR editing only the file skips the one job that runs it. + expect( + EXCLUDED_UNIT_FILES.filter( + (file) => + !GATING_JOBS.some( + ({ gate, pathArguments }) => + runByVitestStep(file, pathArguments) && (!gate || classifyPrJobs([file])[gate]) + ) + ) + ).toEqual([]) + }) + + it('runs the whole cross-version-wire directory, not a list of its files', () => { + expect(GATING_JOBS.flatMap((job) => job.pathArguments)).toContain(CROSS_VERSION_WIRE_DIR) + }) + + it('names every cross-version-wire test so the unit include picks it up', () => { + // Why: a directory argument only reaches files the vitest include matches; any other name is silently skipped. + const tests = posixPaths( + globSync(`${CROSS_VERSION_WIRE_DIR}**/*.{test,spec}.{js,cjs,mjs,ts,tsx}`) + ) + const included = new Set(UNIT_FILES) + expect(tests.length).toBeGreaterThan(0) + expect(tests.filter((file) => !included.has(file))).toEqual([]) + }) +}) diff --git a/config/scripts/ci-unit-files.mjs b/config/scripts/ci-unit-files.mjs index ab8d5038aed9..80252973e224 100644 --- a/config/scripts/ci-unit-files.mjs +++ b/config/scripts/ci-unit-files.mjs @@ -1,6 +1,9 @@ import { globSync } from 'node:fs' import { defaultExclude } from 'vitest/config' +// Why one constant: the cross-version-wire job runs this directory, so a new file there runs with no list to update. +export const CROSS_VERSION_WIRE_DIR = 'tests/e2e/cross-version-wire/' + export const UNIT_INCLUDE = [ 'src/**/*.test.ts', 'src/**/*.test.tsx', @@ -32,7 +35,7 @@ export const UNIT_EXCLUDE = [ 'src/shared/posix-command-path-lookup.test.ts', 'tests/e2e/relay-region-compatibility.unit.test.ts', 'tests/e2e/relay-region-correction.unit.test.ts', - 'tests/e2e/cross-version-wire/**' + `${CROSS_VERSION_WIRE_DIR}**` ] export function discoverUnitFiles(root = process.cwd()) { diff --git a/config/scripts/pr-code-change-scope.mjs b/config/scripts/pr-code-change-scope.mjs index c72825c9fe1c..1b5679344c08 100644 --- a/config/scripts/pr-code-change-scope.mjs +++ b/config/scripts/pr-code-change-scope.mjs @@ -98,6 +98,9 @@ const SHELL_PREFIXES = [ 'src/main/shell-wrapper-', 'src/main/terminal-history-fish', 'src/main/zsh-', + 'src/main/runtime/structured-session-cli-login-shell', + 'src/main/runtime/structured-session-login-shell-test-harness', + 'src/main/runtime/structured-session-child-identity-env', 'src/renderer/src/components/terminal-pane/fish-color-scheme', 'src/shared/fish-', 'src/shared/pty-reply-echo-shapes', @@ -178,7 +181,19 @@ const CROSS_VERSION_WIRE_PREFIXES = [ 'src/main/runtime/runtime-worktree-agent-', 'src/main/runtime/runtime-worktree-pty-agent-sources', 'src/shared/runtime-worktree-contracts', - 'src/renderer/src/runtime/remote-runtime-terminal-multiplexer' + 'src/renderer/src/runtime/remote-runtime-terminal-multiplexer', + // Turn-end status a newer host publishes and an older desktop reads (cross-version-host-observed-turn-end). + 'src/shared/agent-turn-outcome', + 'src/shared/agent-status-types', + 'src/shared/agent-lead-status-fold', + 'src/shared/agent-session-turn-record', + 'src/shared/structured-agent-session-agent-status', + 'src/shared/structured-agent-session-projection', + 'src/shared/workspace-session-sleeping-agents', + // An older app opening a newer orchestration database (orchestration-delivery-downgrade). + 'src/main/runtime/orchestration/db.ts', + 'src/main/runtime/orchestration/db/', + 'src/main/runtime/orchestration/orchestration-schema-version-skew' ] const MANAGED_HOOK_PREFIXES = [ diff --git a/config/scripts/pr-code-change-scope.test.mjs b/config/scripts/pr-code-change-scope.test.mjs index 5c1302ca3546..da6670845e45 100644 --- a/config/scripts/pr-code-change-scope.test.mjs +++ b/config/scripts/pr-code-change-scope.test.mjs @@ -286,6 +286,23 @@ describe('per-job path classification', () => { }) }) + it('runs shell contracts for the structured-session login-shell test, its harness and its subject', () => { + expectClassification( + ['src/main/runtime/structured-session-cli-login-shell.live-shell.test.ts'], + { shell_contracts: true } + ) + for (const file of [ + 'src/main/runtime/structured-session-login-shell-test-harness.ts', + 'src/main/runtime/structured-session-child-identity-env.ts' + ]) { + expectClassification([file], { + shell_contracts: true, + package: true, + package_windows: true + }) + } + }) + it('runs orcad browser when Chrome launch, session, or tab modules change', () => { for (const file of [ 'src/main/orcad/external-chromium-browser-session.ts', @@ -367,7 +384,11 @@ describe('per-job path classification', () => { 'src/main/runtime/runtime-worktree-agent-rows.ts', 'src/main/runtime/runtime-worktree-pty-agent-sources.ts', 'src/shared/runtime-worktree-contracts.ts', - 'src/renderer/src/runtime/remote-runtime-terminal-multiplexer.ts' + 'src/renderer/src/runtime/remote-runtime-terminal-multiplexer.ts', + 'src/shared/structured-agent-session-projection.ts', + 'src/shared/agent-turn-outcome.ts', + 'src/main/runtime/orchestration/db.ts', + 'src/main/runtime/orchestration/orchestration-schema-version-skew.ts' ]) { expectClassification([file], { 'cross-version-wire': true, diff --git a/tests/e2e/cross-version-wire/orchestration-delivery-downgrade.unit.test.ts b/tests/e2e/cross-version-wire/orchestration-delivery-downgrade.unit.test.ts index 8126d33d6010..a836372642cd 100644 --- a/tests/e2e/cross-version-wire/orchestration-delivery-downgrade.unit.test.ts +++ b/tests/e2e/cross-version-wire/orchestration-delivery-downgrade.unit.test.ts @@ -9,6 +9,7 @@ import { importReleaseCheckoutModule, materializeReleaseCheckout } from './relea // Pin the last pre-v41 implementation: this contract specifically exercises status-only readers. const PRE_V41 = 'aac38d698ff75ac4c8658addab48ef5a83617619' +// Why 120 s: a cold release checkout materializes in the test body, and its 45 s extraction deadline alone exceeds the 30 s default. test('pre-v41 code opens, acknowledges and writes a current-schema database, then current code reopens it', async () => { const checkout = await materializeReleaseCheckout(PRE_V41) const baseline = await importReleaseCheckoutModule( @@ -67,4 +68,4 @@ test('pre-v41 code opens, acknowledges and writes a current-schema database, the db?.close() rmSync(directory, { recursive: true, force: true }) } -}) +}, 120_000)