diff --git a/scripts/lib/ci-changed-node-test-plan.mts b/scripts/lib/ci-changed-node-test-plan.mts index ea79e583706b..3348585b6b15 100644 --- a/scripts/lib/ci-changed-node-test-plan.mts +++ b/scripts/lib/ci-changed-node-test-plan.mts @@ -8,7 +8,11 @@ import { isTestFileTarget, resolveChangedTestTargetPlan, } from "../test-projects.test-support.mts"; -import { createNodeTestShards } from "./ci-node-test-plan.mts"; +import { + createNodeTestShards, + isPolicyTestOwnedPath, + resolvePolicyTestTargets, +} from "./ci-node-test-plan.mts"; import { buildPluginSdkEntrySources, publicPluginSdkEntrypoints } from "./plugin-sdk-entries.mts"; type ChangedNodeTestShard = { @@ -183,6 +187,11 @@ export function createChangedNodeTestShards( return null; } + const policyTargetsByPath = new Map( + livePaths.map((changedPath) => [changedPath, resolvePolicyTestTargets([changedPath])]), + ); + const regularLivePaths = livePaths.filter((changedPath) => !isPolicyTestOwnedPath(changedPath)); + // Workspace package consumers often use package specifiers, which the // relative import graph cannot connect back to the changed package source. if (changedPaths.some((changedPath) => changedPath.startsWith("packages/"))) { @@ -193,8 +202,8 @@ export function createChangedNodeTestShards( // Fail safe when a core change reaches a public SDK entrypoint indirectly. if ( detectChangedLanes(changedPaths).extensionImpactFromCore || - (livePaths.some((changedPath) => changedPath.startsWith("src/")) && - hasImportGraphImpactOnTargets(livePaths, publicPluginSdkEntrySources, cwd)) + (regularLivePaths.some((changedPath) => changedPath.startsWith("src/")) && + hasImportGraphImpactOnTargets(regularLivePaths, publicPluginSdkEntrySources, cwd)) ) { return null; } @@ -208,11 +217,13 @@ export function createChangedNodeTestShards( includeExtensionImpact: false, }); const plan = - livePaths.length > 0 ? resolveTargetPlan(livePaths) : { mode: "targets", targets: [] }; + regularLivePaths.length > 0 + ? resolveTargetPlan(regularLivePaths) + : { mode: "targets" as const, targets: [] }; // Aggregate resolution must not let one precise path hide another path that // contributes no tests. Partial plans silently drop coverage. if ( - livePaths.some((changedPath) => { + regularLivePaths.some((changedPath) => { const changedPathPlan = resolveTargetPlan([changedPath]); return changedPathPlan.mode !== "targets" || changedPathPlan.targets.length === 0; }) @@ -222,7 +233,7 @@ export function createChangedNodeTestShards( if (plan.mode !== "targets") { return null; } - const targets = [...new Set(plan.targets)]; + const targets = [...new Set([...plan.targets, ...[...policyTargetsByPath.values()].flat()])]; if ( targets.length > MAX_CHANGED_NODE_TEST_TARGETS || targets.some( diff --git a/scripts/lib/ci-node-test-plan.mts b/scripts/lib/ci-node-test-plan.mts index 5bf7e52b5cb0..4b8ee840ed01 100644 --- a/scripts/lib/ci-node-test-plan.mts +++ b/scripts/lib/ci-node-test-plan.mts @@ -1,5 +1,5 @@ // Builds CI node/Vitest shard plans from the full suite configuration. -import { relative } from "node:path"; +import { matchesGlob, relative } from "node:path"; import { agentVitestProjectOwners, embeddedAgentVitestProjectOwners, @@ -50,6 +50,66 @@ type NodeTestPlanOptions = { compactWholeGroupCount?: number; }; +type PolicyTestWatch = { + ownerGlobs?: readonly string[]; + testFile: string; + watchGlobs: readonly string[]; +}; + +// These tests read source trees instead of importing every file whose policy +// they enforce. Boundary and contract suites have dedicated always-on lanes; +// this inventory covers the remaining tests that changed targeting cannot +// discover from imports alone. +const policyTestWatches = [ + { + testFile: "ui/src/components/web-awesome-migration.node.test.ts", + watchGlobs: ["ui/src/**/*.ts"], + }, + { + testFile: "ui/src/styles/base-theme-tokens.node.test.ts", + ownerGlobs: ["ui/src/**/*.css"], + watchGlobs: ["ui/src/**/*.css", "ui/src/**/*.ts"], + }, + { + testFile: "ui/src/styles/cursor-policy.node.test.ts", + ownerGlobs: ["ui/index.html", "ui/src/**/*.css"], + watchGlobs: ["ui/index.html", "ui/src/**/*.css", "ui/src/**/*.ts"], + }, + ...[ + "src/cron/service.stream-trigger.test.ts", + "src/cron/service.stream-validation.test.ts", + "src/cron/service/timer.timeout-watchdog.test.ts", + ].map((testFile) => ({ + testFile, + ownerGlobs: ["src/cron/failure-notification-text.ts"], + watchGlobs: ["src/cron/failure-notification-text.ts"], + })), +] satisfies readonly PolicyTestWatch[]; + +function normalizeChangedPath(changedPath: string): string { + return changedPath.replaceAll("\\", "/").replace(/^\.\//u, ""); +} + +/** Resolve policy tests whose scanned source surface intersects this diff. */ +export function resolvePolicyTestTargets(changedPaths: readonly string[]): string[] { + const normalizedPaths = changedPaths.map(normalizeChangedPath); + return policyTestWatches + .filter(({ watchGlobs }) => + normalizedPaths.some((changedPath) => + watchGlobs.some((watchGlob) => matchesGlob(changedPath, watchGlob)), + ), + ) + .map(({ testFile }) => testFile); +} + +/** True when the policy tests are the complete bounded owner for this path. */ +export function isPolicyTestOwnedPath(changedPath: string): boolean { + const normalizedPath = normalizeChangedPath(changedPath); + return policyTestWatches.some(({ ownerGlobs }) => + ownerGlobs?.some((ownerGlob) => matchesGlob(normalizedPath, ownerGlob)), + ); +} + type CompactNodeTestShard = Omit & { groups: NodeTestShardGroup[]; }; diff --git a/test/scripts/ci-changed-node-test-plan.test.ts b/test/scripts/ci-changed-node-test-plan.test.ts index 85b6c945b331..46c7db7ccb1d 100644 --- a/test/scripts/ci-changed-node-test-plan.test.ts +++ b/test/scripts/ci-changed-node-test-plan.test.ts @@ -13,6 +13,27 @@ import { listGitTrackedFiles } from "../../src/test-utils/repo-files.js"; import { isGatewayServerTestFile } from "../vitest/vitest.gateway-server-paths.mjs"; describe("CI changed Node test plan", () => { + it("routes Control UI style changes through source-scanning policy tests", () => { + const shards = createChangedNodeTestShards(["ui/src/styles/chat/layout.css"]); + const targets = shards?.flatMap((shard) => shard.targets ?? []) ?? []; + + expect(targets).toEqual([ + "ui/src/styles/base-theme-tokens.node.test.ts", + "ui/src/styles/cursor-policy.node.test.ts", + ]); + }); + + it("routes cron alert sanitization changes through alert policy suites", () => { + const shards = createChangedNodeTestShards(["src/cron/failure-notification-text.ts"]); + const targets = shards?.flatMap((shard) => shard.targets ?? []) ?? []; + + expect(targets).toEqual([ + "src/cron/service.stream-trigger.test.ts", + "src/cron/service.stream-validation.test.ts", + "src/cron/service/timer.timeout-watchdog.test.ts", + ]); + }); + it("routes a focused source change into one targeted job", () => { expect(createChangedNodeTestShards(["src/agents/live-model-filter.ts"])).toEqual([ { diff --git a/test/scripts/ci-node-test-plan.test.ts b/test/scripts/ci-node-test-plan.test.ts index 5f9b1758367d..9f7b8d70d9f1 100644 --- a/test/scripts/ci-node-test-plan.test.ts +++ b/test/scripts/ci-node-test-plan.test.ts @@ -6,6 +6,7 @@ import { assignVitestFsCacheWriter, createNodeTestShardBundles, createNodeTestShards, + resolvePolicyTestTargets, type NodeTestShard, } from "../../scripts/lib/ci-node-test-plan.mts"; import { expectNoNodeFsScans } from "../../src/test-utils/fs-scan-assertions.js"; @@ -91,6 +92,15 @@ function listAllToolingTestFiles(): string[] { } describe("scripts/lib/ci-node-test-plan.mts", () => { + it("inventories source-scanning Control UI policy tests", () => { + expect(resolvePolicyTestTargets(["ui/src/pages/chat/view.ts"])).toEqual([ + "ui/src/components/web-awesome-migration.node.test.ts", + "ui/src/styles/base-theme-tokens.node.test.ts", + "ui/src/styles/cursor-policy.node.test.ts", + ]); + expect(resolvePolicyTestTargets(["docs/web/control-ui.md"])).toEqual([]); + }); + it("assigns one semantic Vitest cache writer without changing shard order", () => { const full = createNodeTestShardBundles({ includeReleaseOnlyPluginShards: false }); const compact = createNodeTestShardBundles({