Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 50 additions & 11 deletions scripts/ci-test-plan.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,13 @@ import { isDirectlyInvoked } from './lib/directInvocation.js';
import { writeStepOutput } from './lib/githubOutput.js';

const TEST_FILE_RE = /\.(?:test|spec)\.[cm]?[jt]sx?$/i;
// `git grep` pathspecs matching every extension TEST_FILE_RE recognizes. A
// hardcoded `*.test.js`/`.jsx` pair would silently stop finding a `.test.ts(x)`
// or `.spec.*` contract test added later, reopening the exact under-selection
// bug the basename lookup below exists to close — the later trackedSet +
// runnerForTest(isTestFile) filter already narrows a broad hit back down, so
// over-matching here costs nothing.
const TEST_FILE_GLOBS = ['*.test.*', '*.spec.*'];
const CLIENT_LINT_RE = /^client\/src\/.*\.(?:js|jsx)$/i;
const EXECUTABLE_RE = /\.(?:cjs|css|html|js|jsx|json|mjs|sql|ts|tsx|ya?ml)$/i;
const MAX_CHANGED_CODE_FILES = 30;
Expand All @@ -16,9 +23,16 @@ const MAX_TARGETED_TEST_FILES = 120;
// with `git grep` and the plan runs exactly those — failing closed to the full
// suite for a script nothing names.
const PYTHON_SCRIPT_RE = /^scripts\/[^/]+\.py$/;
/** `git grep -E` pattern for a test that names this one script. */
export const pythonReferencePattern = (scriptPath) => (
`(^|[^A-Za-z0-9_])${scriptPath.slice(scriptPath.lastIndexOf('/') + 1).replace(/[.]/g, '\\.')}([^A-Za-z0-9_]|$)`

// Contract tests read a subject file with `readFileSync` (a cross-tree mirror
// parity test, `server/lib/navManifest.js` reading 20+ client files by path)
// instead of importing it, so no `vitest related` import edge reaches them
// either. `main()` finds the tests naming a changed file's basename with
// `git grep`, the same mechanism the python case below uses, and generalizes
// it to every changed source — not only `.py` (issue #6363).
/** `git grep -E` pattern for a test that names this one source file by basename. */
export const sourceReferencePattern = (sourcePath) => (
`(^|[^A-Za-z0-9_])${sourcePath.slice(sourcePath.lastIndexOf('/') + 1).replace(/[.]/g, '\\.')}([^A-Za-z0-9_]|$)`
);

// Parallel runners per test job on a full plan. Decided here rather than in
Expand Down Expand Up @@ -348,6 +362,13 @@ const structuralTestsFor = (changedFiles, trackedSet) => {
if (changedFiles.some((path) => /^client\/src\/lib\//.test(path))) {
add('client/src/lib/index.test.js');
}
// mirrorCoverage.test.js walks both directories and diffs their README mirror
// catalogs against the actual test files present; it names no file itself, so
// no import edge or basename `git grep` (pathContractTests, see rule 1 above)
// can reach it either.
if (changedFiles.some((path) => /^(?:server|client\/src)\/lib\//.test(path))) {
add('server/lib/mirrorCoverage.test.js');
}
if (changedFiles.some((path) => /^client\/src\/hooks\//.test(path))) {
add('client/src/hooks/index.test.js');
}
Expand Down Expand Up @@ -466,8 +487,10 @@ export function buildCiTestPlan(changedFiles, {
forceFull = false,
forceFullReason = 'full CI requested',
appRouteOnly = false,
// Changed python script → tracked test files naming it (see PYTHON_SCRIPT_RE).
pythonContractTests = {},
// Changed source path → tracked test files naming its basename (see
// sourceReferencePattern). Covers every changed source, not only python
// scripts — see rule 1 of issue #6363.
pathContractTests = {},
} = {}) {
const changed = uniqueSorted(changedFiles.filter(Boolean));
const trackedSet = new Set(trackedFiles);
Expand Down Expand Up @@ -570,14 +593,24 @@ export function buildCiTestPlan(changedFiles, {
];

for (const script of pythonSources) {
const pythonTests = (pythonContractTests[script] || [])
const pythonTests = (pathContractTests[script] || [])
.filter((path) => trackedSet.has(path) && runnerForTest(path));
if (pythonTests.length === 0) {
return fullPlan(changed, `python script with no parsing contract: ${script}`, { appRouteOnly });
}
selectedTests.push(...pythonTests);
}

// Every other changed source rides the same basename `git grep` lookup, but
// additively: unlike python, a JS/TS source is already reachable through the
// import graph or a feature directory, so an empty hit list here is not an
// error — it just means nothing reads this file as text.
for (const source of jsSources) {
const namedTests = (pathContractTests[source] || [])
.filter((path) => trackedSet.has(path) && runnerForTest(path));
selectedTests.push(...namedTests);
}

for (const testFile of trackedFiles.filter(isTestFile)) {
if (features.some((feature) => pathMatchesFeature(testFile, feature))) {
selectedTests.push(testFile);
Expand Down Expand Up @@ -760,16 +793,22 @@ function main() {
const appDiff = forceFull || !changedFiles.includes('client/src/App.jsx')
? null
: execFileSync('git', ['diff', '--unified=0', `${base}...HEAD`, '--', 'client/src/App.jsx'], { encoding: 'utf8' });
const pythonContractTests = Object.fromEntries(changedFiles.filter(isPythonScript).map((script) => [
script,
gitGrepFiles(pythonReferencePattern(script), ['*.test.js', '*.test.jsx']),
]));
// Every changed executable, non-test source — not only python scripts — gets
// a basename lookup: `git grep`-ing tracked test files for its filename finds
// the text-reading contract tests (mirror parity, navManifest.js's 20+ client
// reads) that no import edge or feature-directory match can reach. Basename
// matching is deliberate: a mirror test names the client copy only as e.g.
// 'canonPrompt.js', never by its full path. Over-selection here costs
// seconds; under-selection is the bug this closes (issue #6363).
const pathContractTests = Object.fromEntries(changedFiles
.filter((path) => isExecutable(path) && !isTestFile(path))
.map((path) => [path, gitGrepFiles(sourceReferencePattern(path), TEST_FILE_GLOBS)]));
emitGitHubPlan(buildCiTestPlan(changedFiles, {
trackedFiles,
forceFull,
forceFullReason,
appRouteOnly: isRouteOnlyAppDiff(appDiff),
pythonContractTests,
pathContractTests,
}));
}

Expand Down
69 changes: 63 additions & 6 deletions scripts/ci-test-plan.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,9 @@ import {
FULL_SUITE_SHARDS,
isRouteOnlyAppDiff,
needsSlashdoSubmodule,
pythonReferencePattern,
shardIndexes,
SLASHDO_GITLINK_PATH,
sourceReferencePattern,
splitByRunner,
WINDOWS_CONTRACT_TESTS,
} from './ci-test-plan.js';
Expand Down Expand Up @@ -400,7 +400,7 @@ describe('CI test impact planner', () => {
'server/services/videoGen/runtimes.test.js',
'client/src/lib/videoRenderPhase.test.js',
];
const pythonContractTests = {
const pathContractTests = {
'scripts/_runner_common.py': [
'scripts/generate_ltx2.test.js',
'server/services/videoGen/runtimes.test.js',
Expand All @@ -409,7 +409,7 @@ describe('CI test impact planner', () => {
],
};

const plan = buildCiTestPlan(['scripts/_runner_common.py'], { trackedFiles: tracked, pythonContractTests });
const plan = buildCiTestPlan(['scripts/_runner_common.py'], { trackedFiles: tracked, pathContractTests });

expect(plan).toMatchObject({
full: false,
Expand All @@ -432,14 +432,71 @@ describe('CI test impact planner', () => {
// python contracts ride along as explicit files.
const mixed = buildCiTestPlan(['scripts/_runner_common.py', 'server/services/auth.js'], {
trackedFiles: tracked,
pythonContractTests,
pathContractTests,
});
expect(mixed.reason).toBe('Vitest related-test fallback');
expect(mixed.server).toMatchObject({ mode: 'related', sources: ['server/services/auth.js'] });
expect(mixed.server.files).toContain('scripts/generate_ltx2.test.js');
expect(mixed.smoke).toBe(true);
});

it('selects a text-reading contract test by the changed file\'s basename (#6363)', () => {
const tracked = [
...TRACKED,
'client/src/pages/Calendar.jsx',
'server/lib/navManifest.js',
'server/lib/navManifest.test.js',
];
// navManifest.test.js reads client/src/pages/Calendar.jsx with readFileSync
// rather than importing it, so no `vitest related` edge reaches it — only
// the basename lookup computed by main() and threaded through as
// pathContractTests can.
const pathContractTests = {
'client/src/pages/Calendar.jsx': ['server/lib/navManifest.test.js'],
};

const plan = buildCiTestPlan(['client/src/pages/Calendar.jsx'], { trackedFiles: tracked, pathContractTests });

expect(plan.full).toBe(false);
expect(plan.server.files).toContain('server/lib/navManifest.test.js');
});

it('reaches a mirror-parity test from either side of the mirror by basename (#6363)', () => {
const tracked = [
...TRACKED,
'server/lib/seasonStructure.js',
'server/lib/seasonStructure.mirror.test.js',
'client/src/lib/seasonStructure.js',
];
// Both copies share one basename, and the mirror test names the OTHER copy
// only by that basename — a mirror test living in server/lib is what a
// change to either side must select.
const pathContractTests = {
'server/lib/seasonStructure.js': ['server/lib/seasonStructure.mirror.test.js'],
'client/src/lib/seasonStructure.js': ['server/lib/seasonStructure.mirror.test.js'],
};

const serverSide = buildCiTestPlan(['server/lib/seasonStructure.js'], { trackedFiles: tracked, pathContractTests });
expect(serverSide.server.files).toContain('server/lib/seasonStructure.mirror.test.js');

const clientSide = buildCiTestPlan(['client/src/lib/seasonStructure.js'], { trackedFiles: tracked, pathContractTests });
expect(clientSide.server.files).toContain('server/lib/seasonStructure.mirror.test.js');
});

it('runs mirrorCoverage.test.js whenever a mirrored directory changes, since it names no file (#6363)', () => {
const tracked = [...TRACKED, 'server/lib/mirrorCoverage.test.js'];

const serverLib = buildCiTestPlan(['server/lib/bufferedSpawn.js'], { trackedFiles: tracked });
expect(serverLib.server.files).toContain('server/lib/mirrorCoverage.test.js');

const clientLib = buildCiTestPlan(['client/src/lib/catalogLinks.js'], { trackedFiles: tracked });
expect(clientLib.server.files).toContain('server/lib/mirrorCoverage.test.js');

// Unrelated directories don't force it.
const unrelated = buildCiTestPlan(['server/services/auth.js'], { trackedFiles: tracked });
expect(unrelated.server.files).not.toContain('server/lib/mirrorCoverage.test.js');
});

it('runs the generated-manifest drift tests whenever a server source changes', () => {
const tracked = [
...TRACKED,
Expand All @@ -465,7 +522,7 @@ describe('CI test impact planner', () => {
// Per script: a pinned sibling in the same diff does not vouch for the orphan.
const plan = buildCiTestPlan(['scripts/generate_ltx2.py', 'scripts/orphan.py'], {
trackedFiles: [...TRACKED, 'scripts/generate_ltx2.py', 'scripts/orphan.py', 'scripts/generate_ltx2.test.js'],
pythonContractTests: { 'scripts/generate_ltx2.py': ['scripts/generate_ltx2.test.js'] },
pathContractTests: { 'scripts/generate_ltx2.py': ['scripts/generate_ltx2.test.js'] },
});
expect(plan.full).toBe(true);
expect(plan.reason).toMatch(/python script with no parsing contract: scripts\/orphan\.py/);
Expand All @@ -475,7 +532,7 @@ describe('CI test impact planner', () => {
});

it('matches the way tests name one python script, not every one', () => {
const re = new RegExp(pythonReferencePattern('scripts/generate_ltx2.py'));
const re = new RegExp(sourceReferencePattern('scripts/generate_ltx2.py'));
expect(re.test("join(SCRIPTS, 'generate_ltx2.py')")).toBe(true);
expect(re.test('readFileSync("scripts/generate_ltx2.py", "utf8")')).toBe(true);
expect(re.test('"generate_ltx2.py"')).toBe(true);
Expand Down
Loading