From fc4bf8631972b56dcd5251b958f8b69bca6223b7 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Tue, 13 Oct 2026 08:35:56 -0700 Subject: [PATCH] [CI] Avoid native jobs for isolated policy self-test edits (#28334) --- config/scripts/node-server-change-scope.mjs | 21 +- .../node-server-policy-self-tests.test.mjs | 192 ++++++++++++++++++ 2 files changed, 204 insertions(+), 9 deletions(-) create mode 100644 config/scripts/node-server-policy-self-tests.test.mjs diff --git a/config/scripts/node-server-change-scope.mjs b/config/scripts/node-server-change-scope.mjs index 641ef054c07..7b14c8dbbdf 100644 --- a/config/scripts/node-server-change-scope.mjs +++ b/config/scripts/node-server-change-scope.mjs @@ -23,6 +23,10 @@ const BUILD_SCRIPTS = [ 'config/scripts/vitest-host-ports-setup.ts', 'config/scripts/vitest-real-agent-home-write-guard.ts' ] +const NODE_SERVER_POLICY_TESTS = new Set([ + 'config/scripts/node-server-change-scope.test.mjs', + 'config/scripts/node-server-qualification.test.mjs' +]) const ALWAYS_FILES = new Set([ 'package.json', 'pnpm-lock.yaml', @@ -32,12 +36,11 @@ const ALWAYS_FILES = new Set([ 'tsconfig.json', '.github/workflows/node-server-tests.yml', 'config/scripts/node-server-change-scope.mjs', - 'config/scripts/node-server-change-scope.test.mjs', + ...NODE_SERVER_POLICY_TESTS, 'config/scripts/workspace-source-exports.mjs', 'config/scripts/workspace-source-exports.test.mjs', 'config/scripts/headless-detector-compiler-cache.mjs', 'config/scripts/node-server-qualification.mjs', - 'config/scripts/node-server-qualification.test.mjs', // The source graph sees process-host source, not the build that emits the dist servers load. 'src/packages/process-host/.gitignore', 'src/packages/process-host/scripts/build-dist.mjs', @@ -153,10 +156,16 @@ export async function classifyNodeServerChanges( if (changedFiles.length === 0) { return { shouldRun: true, reason: 'No complete changed-file evidence' } } + const modifiedOnly = + Array.isArray(modifiedFiles) && + new Set(changedFiles).size === changedFiles.length && + new Set(modifiedFiles).size === modifiedFiles.length && + modifiedFiles.length === changedFiles.length && + changedFiles.every((file) => modifiedFiles.includes(file)) const selectors = nodeServerTestPaths({ artifact: true, crossRuntime: true, template: true }) const forced = changedFiles.find( (file) => - ALWAYS_FILES.has(file) || + (ALWAYS_FILES.has(file) && !(modifiedOnly && NODE_SERVER_POLICY_TESTS.has(file))) || (ALWAYS_PREFIXES.some((prefix) => file.startsWith(prefix)) && !isSourceUnitTest(file)) || /^config\/scripts\/[^/]*orcad[^/]*$/.test(file) || selectors.some((selector) => file.includes(selector)) @@ -171,12 +180,6 @@ export async function classifyNodeServerChanges( const inputs = await collect() const matchedFiles = changedFiles.filter((file) => inputs.has(file)) const matched = matchedFiles[0] - const modifiedOnly = - Array.isArray(modifiedFiles) && - new Set(changedFiles).size === changedFiles.length && - new Set(modifiedFiles).size === modifiedFiles.length && - modifiedFiles.length === changedFiles.length && - changedFiles.every((file) => modifiedFiles.includes(file)) return { ...(modifiedOnly ? { graphResolved: true, matchedFiles } : {}), shouldRun: Boolean(matched), diff --git a/config/scripts/node-server-policy-self-tests.test.mjs b/config/scripts/node-server-policy-self-tests.test.mjs new file mode 100644 index 00000000000..1a490dd2cac --- /dev/null +++ b/config/scripts/node-server-policy-self-tests.test.mjs @@ -0,0 +1,192 @@ +import { beforeAll, describe, expect, it } from 'vitest' +import { classifyNodeServerChanges, collectNodeServerInputs } from './node-server-change-scope.mjs' +import { NODE_SERVER_RUNNERS, nodeServerQualification } from './node-server-qualification.mjs' +import { discoverUnitFiles } from './ci-unit-files.mjs' +import { planUnitSelection } from './ci-unit-selection.mjs' +import { classifyPrJobs } from './pr-code-change-scope.mjs' + +const policyTests = [ + 'config/scripts/node-server-change-scope.test.mjs', + 'config/scripts/node-server-qualification.test.mjs' +] + +const unavailableGraph = async () => { + throw new Error('graph unavailable') +} + +describe('modified Node-server policy self-tests', () => { + let inputs + let unitFiles + beforeAll(async () => { + inputs = await collectNodeServerInputs() + unitFiles = discoverUnitFiles() + }, 60_000) + + it.each(policyTests)( + 'keeps %s in required root units without native qualification', + async (file) => { + expect(classifyPrJobs([file], { allowMobileCiTools: true })).toMatchObject({ + test: true, + static_analysis: true, + typecheck: true, + mobile_ci_tools: false + }) + expect(inputs.has(file)).toBe(false) + const scope = await classifyNodeServerChanges([file], async () => inputs, { + modifiedFiles: [file] + }) + expect(scope).toMatchObject({ shouldRun: false, graphResolved: true, matchedFiles: [] }) + const plan = planUnitSelection({ + files: unitFiles, + changed: [file], + graph: () => { + throw new Error('config edits must select the full unit inventory') + }, + timings: {}, + event: { pull_request: { draft: true } }, + mode: 'selected' + }) + expect(plan.mode).toBe('shadow') + expect(plan.executionFiles).toEqual(unitFiles) + expect(plan.executionFiles).toContain(file) + } + ) + + it('keeps both modified policy self-tests in root units', async () => { + const scope = await classifyNodeServerChanges(policyTests, async () => inputs, { + modifiedFiles: policyTests + }) + expect(scope.shouldRun).toBe(false) + expect(unitFiles).toEqual(expect.arrayContaining(policyTests)) + }) + + it.each(policyTests)('requests the real graph before excluding %s', async (file) => { + const scope = await classifyNodeServerChanges([file], unavailableGraph, { + deferGraph: true, + modifiedFiles: [file] + }) + expect(scope.graphRequired).toBe(true) + expect(scope.shouldRun).toBeUndefined() + }) + + it.each([ + { evidence: 'incomplete modified paths', changed: policyTests, modified: [policyTests[0]] }, + { evidence: 'missing modified paths', changed: policyTests }, + { + evidence: 'duplicate modified paths', + changed: policyTests, + modified: [policyTests[0], policyTests[0]] + }, + { + evidence: 'foreign modified path', + changed: policyTests, + modified: [policyTests[0], 'config/scripts/foreign.test.mjs'] + }, + { + evidence: 'unproven rename side', + changed: [...policyTests, 'config/scripts/node-server-renamed.test.mjs'], + modified: policyTests + }, + { + evidence: 'duplicate changed paths', + changed: [policyTests[0], policyTests[0]], + modified: policyTests + } + ])('retains full qualification with $evidence', async ({ changed, modified }) => { + const scope = await classifyNodeServerChanges(changed, unavailableGraph, { + modifiedFiles: modified + }) + expect(scope.shouldRun).toBe(true) + expect(scope.graphUnavailable).toBeUndefined() + expect(nodeServerQualification(changed, scope)).toEqual({ + qualification: true, + runners: NODE_SERVER_RUNNERS + }) + }) + + it.each([ + 'config/scripts/node-server-change-scope.mjs', + 'config/scripts/node-server-qualification.mjs', + 'config/scripts/workspace-source-exports.test.mjs', + '.github/workflows/node-server-tests.yml', + '.github/actions/prepare-native-runtime/action.yml', + 'pnpm-lock.yaml' + ])('keeps full qualification for mixed edits including %s', async (file) => { + const changed = [...policyTests, file] + const scope = await classifyNodeServerChanges(changed, unavailableGraph, { + modifiedFiles: changed + }) + expect(scope.shouldRun).toBe(true) + expect(scope.graphUnavailable).toBeUndefined() + expect(nodeServerQualification(changed, scope)).toEqual({ + qualification: true, + runners: NODE_SERVER_RUNNERS + }) + }) + + it.each([ + 'config/scripts/node-server-concurrency.test.mjs', + 'config/scripts/node-server-packaged-chat-selection.test.mjs' + ])( + 'preserves unrelated %s routing with complete, absent, or non-modification evidence', + async (file) => { + expect(inputs.has(file)).toBe(false) + for (const modifiedFiles of [[file], undefined, []]) { + const scope = await classifyNodeServerChanges([file], async () => inputs, { modifiedFiles }) + expect(scope.shouldRun).toBe(false) + expect(scope.graphUnavailable).toBeUndefined() + } + } + ) + + it.each([ + 'config/scripts/node-server-concurrency.test.mjs', + 'config/scripts/node-server-packaged-chat-selection.test.mjs' + ])('preserves both unmodified rename sides for unrelated %s', async (file) => { + const changed = [file, file.replace('.test.mjs', '-renamed.test.mjs')] + const scope = await classifyNodeServerChanges(changed, async () => inputs, { + modifiedFiles: [] + }) + expect(scope.shouldRun).toBe(false) + expect(scope.graphUnavailable).toBeUndefined() + }) + + it('preserves unrelated tests when only part of their changed vector is modified', async () => { + const changed = [ + 'config/scripts/node-server-concurrency.test.mjs', + 'config/scripts/node-server-packaged-chat-selection.test.mjs' + ] + const scope = await classifyNodeServerChanges(changed, async () => inputs, { + modifiedFiles: [changed[0]] + }) + expect(scope.shouldRun).toBe(false) + expect(scope.graphUnavailable).toBeUndefined() + }) + + it.each(policyTests)( + 'retains native qualification when %s becomes a real graph input', + async (file) => { + const reachable = await collectNodeServerInputs({ entryPoints: [file] }) + expect(reachable.has(file)).toBe(true) + const scope = await classifyNodeServerChanges([file], async () => reachable, { + modifiedFiles: [file] + }) + expect(scope).toMatchObject({ shouldRun: true, graphResolved: true, matchedFiles: [file] }) + expect(nodeServerQualification([file], scope)).toEqual({ + qualification: true, + runners: NODE_SERVER_RUNNERS + }) + } + ) + + it('retains full qualification when graph collection fails', async () => { + const scope = await classifyNodeServerChanges(policyTests, unavailableGraph, { + modifiedFiles: policyTests + }) + expect(scope).toMatchObject({ shouldRun: true, graphUnavailable: true }) + expect(nodeServerQualification(policyTests, scope)).toEqual({ + qualification: true, + runners: NODE_SERVER_RUNNERS + }) + }) +})