blob: 1486e01f96a39dda634e33586aa4b7bf4f96d496 [file]
#!/usr/bin/env node
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
/**
* Stops the Karma suite from growing while it is being migrated away.
*
* Karma is deprecated and `@angular/build:karma` goes with it (DOCS/adr/0004-vitest-migration.md).
* The suite moves to Vitest one batch at a time, which leaves a window — months, realistically —
* where both runners are wired up. The failure mode in that window is not that migration is
* hard; it is that new specs keep landing on the runner being removed, so the finish line moves
* away as fast as it is approached.
*
* This is a ratchet, in the same shape as `eslint-suppressions.json`: the set of files still on
* the old runner is recorded, a file may leave that set, and nothing may join it.
*
* ## The convention it enforces
*
* - `*.spec.ts` — Jasmine dialect, run by Karma (`npm test`). Legacy. Shrinking.
* - `*.test.ts` — Vitest dialect, run by Vitest (`npm run test:unit`). Everything new.
*
* The two runners select by filename, so a migration is a rename plus a dialect swap, and the
* progress metric is `wc -l` on the baseline.
*
* ## Modes
*
* node scripts/check-test-runner.mjs verify (exit 1 when the set grew)
* node scripts/check-test-runner.mjs --write re-record after migrating a batch
*
* `--write` is for a batch that has *moved*, and the check refuses to write a baseline that
* grew — otherwise the ratchet could be released by running the very command that documents it.
*/
import { readdirSync, readFileSync, writeFileSync, existsSync } from 'node:fs';
import { join } from 'node:path';
const BASELINE = 'karma-baseline.json';
/**
* Header fields the baseline carries for `scripts/check-license.sh`, which requires a licence
* field on every JSON file. Written on every `--write` so re-recording a migrated batch cannot
* silently drop them and fail a later, unrelated CI run.
*/
const HEADER = {
license: 'Apache-2.0',
$comment:
'Specs still on the deprecated Karma runner. Written by scripts/check-test-runner.mjs; ' +
'files may leave this list, never join it. See DOCS/adr/0004-vitest-migration.md.',
};
const ROOTS = ['src', 'projects'];
const WRITE = process.argv.includes('--write');
/** Every `.spec.ts` under the source roots, excluding the generated client. */
function* legacySpecs(dir) {
let entries;
try {
entries = readdirSync(dir, { withFileTypes: true });
} catch {
return;
}
for (const entry of entries) {
const full = join(dir, entry.name).replaceAll('\\', '/');
if (entry.isDirectory()) {
if (full.includes('src/app/api')) continue;
yield* legacySpecs(full);
} else if (entry.name.endsWith('.spec.ts')) {
yield full;
}
}
}
/** Byte-order path comparison, so the baseline diffs cleanly across platforms and locales. */
function byPath(a, b) {
if (a < b) return -1;
return a > b ? 1 : 0;
}
const current = [...ROOTS.flatMap((root) => [...legacySpecs(root)])].sort(byPath);
if (!existsSync(BASELINE)) {
if (!WRITE) {
console.error(`Missing ${BASELINE}. Run: node scripts/check-test-runner.mjs --write`);
process.exit(1);
}
writeFileSync(BASELINE, `${JSON.stringify({ ...HEADER, specs: current }, null, 2)}\n`);
console.log(`Recorded ${current.length} spec(s) still on Karma.`);
process.exit(0);
}
const stored = JSON.parse(readFileSync(BASELINE, 'utf8'));
const baseline = stored.specs ?? [];
/**
* Specs that were already in flight in an open pull request when this ratchet landed.
*
* They are admitted because the rule did not exist when they were written, and failing
* somebody's open PR for not following a convention introduced after they started is a good way
* to make a migration unpopular. They are listed separately from `specs` rather than merged into
* it so that the exception is visible, finite and reviewable — and so `--write` cannot quietly
* turn "grandfathered" into "normal".
*
* Nothing should ever be added here. A new spec after this point is a `.test.ts`.
*/
const grandfathered = stored.grandfathered ?? [];
const recorded = new Set([...baseline, ...grandfathered]);
const added = current.filter((file) => !recorded.has(file));
const migrated = baseline.filter((file) => !current.includes(file));
if (WRITE) {
if (added.length > 0) {
console.error(
`Refusing to write: ${added.length} spec(s) would be ADDED to the Karma baseline.\n` +
`--write records a batch that has moved off Karma; it is not a way to admit new ones.\n\n` +
added.map((f) => ` + ${f}`).join('\n') +
`\n\nRename these to .test.ts and convert them:\n` +
` node scripts/codemod-jasmine-to-vitest.mjs ${added.slice(0, 3).join(' ')}${added.length > 3 ? ' ...' : ''}\n`,
);
process.exit(1);
}
writeFileSync(
BASELINE,
`${JSON.stringify({ ...HEADER, grandfathered, specs: current }, null, 2)}\n`,
);
console.log(
`Baseline updated: ${migrated.length} spec(s) migrated, ${current.length} still on Karma.`,
);
process.exit(0);
}
if (added.length > 0) {
console.error(
`\n${added.length} new Jasmine/Karma spec(s) — Karma is being removed, so new tests go to Vitest.\n`,
);
for (const file of added) console.error(` + ${file}`);
console.error(
`\nName the file .test.ts instead of .spec.ts and write it in Vitest dialect\n` +
`(vi.fn() rather than jasmine.createSpy, .mockReturnValue rather than .and.returnValue).\n` +
`Run it with: npm run test:unit\n\n` +
`If you are moving an existing spec, the codemod does the dialect and the rename:\n` +
` node scripts/codemod-jasmine-to-vitest.mjs <paths...>\n` +
` node scripts/check-test-runner.mjs --write\n\n` +
`See DOCS/adr/0004-vitest-migration.md.\n`,
);
process.exit(1);
}
if (migrated.length > 0) {
console.log(
`${migrated.length} spec(s) migrated off Karma since the baseline was recorded.\n` +
`Record it: node scripts/check-test-runner.mjs --write`,
);
}
const total = current.length + countVitest();
const done = total === 0 ? 100 : Math.round(((total - current.length) / total) * 100);
console.log(`✓ No new Karma specs. ${current.length} remaining (${done}% migrated).`);
/** Counts migrated specs, for the progress line only. */
function countVitest() {
let n = 0;
for (const root of ROOTS) {
const walk = (dir) => {
let entries;
try {
entries = readdirSync(dir, { withFileTypes: true });
} catch {
return;
}
for (const entry of entries) {
const full = join(dir, entry.name).replaceAll('\\', '/');
if (entry.isDirectory()) {
if (full.includes('src/app/api')) continue;
walk(full);
} else if (entry.name.endsWith('.test.ts')) n++;
}
};
walk(root);
}
return n;
}