Files
civitai__civitai/scripts/ci/assert-workspace-suites-ran.mjs
Zachary Lowden ca699f5def ci(test): run the apps/* vitest suites — 369 tests CI had never executed (#3694)
The root vitest.config.mts registered `packages/*/vitest.config.*` as projects
and nothing for `apps/*`, and CI runs only `--project unit` (root-relative
`include`) and `--project '@civitai/*'`. So no test under apps/ has ever run in
CI: 43 files / 369 tests across five apps, green only for whoever remembered
`pnpm --filter <app> test` by hand.

This is the same gap #3591 documented for packages/, in the sibling directory,
missed when that one was closed.

Register `apps/*/vitest.config.{ts,mts}` — globbed on the CONFIG FILE for the
reason the packages comment already gives — and give each app project an
explicit `app:` name. That name is load-bearing: every app is also published as
`@civitai/*` (`@civitai/auth-app`, and `@civitai/orchestrator-gateway` with no
suffix), so under default naming no pattern separates apps from packages and
`--project '@civitai/*'` would have silently swept the apps into the packages
job. Verified the packages selector is unchanged: same 9 packages, same 957
tests, zero app files in the report.

apps/creator-studio gets a config it never had — it declared a `test` script
and owned a vitest test file that no runner could reach.

Generalize scripts/ci/assert-package-suites-ran.mjs to take the workspace dir
and rename it to assert-workspace-suites-ran.mjs, so one ledger serves both
jobs, and wire an `App unit tests` job that runs it.

Per-app counts: auth 29 files/237, notifications 10/101, storage 1/17,
orchestrator-gateway 2/6, creator-studio 1/8. All green.

Positive controls (both with vitest exiting 0, assertion exiting 1 on its own
`missing` branch): breaking one app's `include` so it collects nothing, and
dropping one app's `test.name` so the selector no longer matches it.

Also corrects an inherited premise in the script header — on vitest 4.0.18 a
`--project` filter matching nothing fails startup and exits 1, it does not
exit 0 as the comment claimed.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-06 12:52:36 -05:00

172 lines
7.4 KiB
JavaScript

#!/usr/bin/env node
/**
* Positive control for the `Package unit tests` and `App unit tests` CI jobs.
*
* A green vitest run is not evidence that anything ran. Ways either job could report success
* having tested nothing, EXIT CODE 0 AND ALL:
*
* - one workspace member quietly drops out — its `include` stops matching, or (for an app)
* its `test.name` is dropped so the `--project` pattern no longer selects it — while the
* others keep the totals looking healthy. Both measured: vitest exited 0 either way;
* - a member's suite self-skips in its entirety on a missing env var;
* - the `<workspace>/<name>/vitest.config.*` globs in the root config stop resolving, so
* the projects are simply absent.
*
* A `--project` filter matching NOTHING at all is the one case this no longer has to catch:
* vitest 4.0.18 fails startup with `No projects matched the filter` and exits 1. Do not
* assume that across upgrades — it is version-dependent behaviour, and if it ever softens
* back to a silent empty run, the ledger below is what notices.
*
* So this reads the run's JSON report and asserts a LEDGER rather than a floor: every
* directory under the given workspace root that has a vitest config AND at least one test
* file on disk must appear in the results with at least one test. The check fails when that
* set SHRINKS, which is the regression, and adapts on its own when a new one is added.
*
* Usage: node scripts/ci/assert-workspace-suites-ran.mjs <vitest-json-report> <packages|apps>
*/
import { readFileSync, readdirSync, existsSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { join, resolve } from 'node:path';
/**
* A floor as well as the ledger: the ledger passes if every workspace contributes one test,
* which is not the same as the suites being intact. Measured on the run that introduced each
* entry — 957 for packages, 369 for apps — and set below it, so ordinary churn does not trip
* it but a suite collapsing does.
*/
const WORKSPACES = {
packages: { minTests: 500 },
apps: { minTests: 250 },
};
const [reportPath, workspace] = process.argv.slice(2);
if (!reportPath || !WORKSPACES[workspace]) {
const choices = Object.keys(WORKSPACES).join('|');
console.error(`usage: assert-workspace-suites-ran.mjs <vitest-json-report> <${choices}>`);
process.exit(2);
}
const repoRoot = resolve(fileURLToPath(new URL('../..', import.meta.url)));
const workspaceDir = join(repoRoot, workspace);
/**
* Any test file anywhere under a workspace member, recursively.
*
* Both halves of this are deliberately WIDE, because this function decides what the ledger
* EXPECTS — and anything it fails to see is a member the ledger can never notice going
* missing. A narrow rule here does not produce a loud error; it silently shrinks the thing
* the check is checking.
*
* So: the whole directory, not just `src/` (a package that keeps its tests in a top-level
* `__tests__/` was invisible), and every extension Vitest will collect, not just
* `.test.ts`/`.spec.tsx` (`.mts`, `.cts` and plain `.js` were invisible).
*/
const TEST_FILE = /\.(test|spec)\.(c|m)?(t|j)sx?$/;
function hasTestFile(dir) {
if (!existsSync(dir)) return false;
for (const entry of readdirSync(dir, { withFileTypes: true })) {
const full = join(dir, entry.name);
if (entry.isDirectory()) {
if (entry.name === 'node_modules' || entry.name === 'dist') continue;
if (hasTestFile(full)) return true;
} else if (TEST_FILE.test(entry.name)) {
return true;
}
}
return false;
}
const expected = readdirSync(workspaceDir, { withFileTypes: true })
.filter((e) => e.isDirectory())
.map((e) => e.name)
.filter((name) => {
const dir = join(workspaceDir, name);
const hasConfig = ['vitest.config.ts', 'vitest.config.mts'].some((f) =>
existsSync(join(dir, f))
);
return hasConfig && hasTestFile(dir);
})
.sort();
if (expected.length === 0) {
console.error(
`FAIL: found no directory under ${workspace}/ with both a vitest config and a test file. ` +
'This check cannot have measured anything — it is running from the wrong directory, or ' +
`the ${workspace}/ layout changed.`
);
process.exit(1);
}
let report;
try {
report = JSON.parse(readFileSync(reportPath, 'utf8'));
} catch (error) {
console.error(`FAIL: could not read the vitest JSON report at ${reportPath}: ${error.message}`);
console.error('An unreadable report is not a pass; the run may not have produced one at all.');
process.exit(1);
}
// A SKIPPED test is not an executed one. Counting `assertionResults.length` meant a package
// that self-skipped in its entirety still satisfied the ledger — and self-skipping on a
// missing DATABASE_URL is exactly the pattern several of these suites use, so the failure
// mode is live rather than hypothetical. `executed` is what the ledger requires to be
// non-zero; `skipped` is reported beside it so a package quietly turning itself off is
// visible rather than merely absent from the arithmetic.
const EXECUTED = new Set(['passed', 'failed']);
const memberOfWorkspace = new RegExp(`(?:^|/)${workspace}/([^/]+)/`);
const ran = new Map(); // member name -> { files, executed, skipped }
for (const result of report.testResults ?? []) {
const match = memberOfWorkspace.exec(result.name ?? '');
if (!match) continue;
const entry = ran.get(match[1]) ?? { files: 0, executed: 0, skipped: 0 };
entry.files += 1;
for (const assertion of result.assertionResults ?? []) {
if (EXECUTED.has(assertion.status)) entry.executed += 1;
else entry.skipped += 1;
}
ran.set(match[1], entry);
}
const missing = expected.filter((name) => !ran.has(name) || ran.get(name).executed === 0);
console.log(`${workspace}/ dirs with a vitest config and tests on disk : ${expected.length}`);
console.log(`${workspace}/ dirs that appear in the run : ${ran.size}`);
for (const name of expected) {
const entry = ran.get(name);
const detail = entry
? `${entry.files} file(s), ${entry.executed} executed` +
(entry.skipped > 0 ? `, ${entry.skipped} skipped` : '')
: 'did not run';
console.log(` ${missing.includes(name) ? 'MISSING' : ' ok'} ${name.padEnd(28)} ${detail}`);
}
console.log(
`totals: ${report.numTotalTestSuites ?? '?'} suite(s), ${report.numTotalTests ?? 0} test(s), ` +
`${report.numFailedTests ?? 0} failed, ${report.numPendingTests ?? 0} skipped`
);
if (missing.length > 0) {
console.error(
`\nFAIL: ${missing.length} ${workspace}/ suite(s) executed no tests: ${missing.join(', ')}.\n` +
'The job reported success without running them. Either the project was not selected — ' +
`check the \`${workspace}/*/vitest.config.*\` globs in the root vitest.config.mts and ` +
'the --project filter; an app that lost its `test.name` falls back to its `@civitai/*` ' +
'package name and lands in the packages job instead — or the suite skipped itself in ' +
'its entirety, and a wholly-skipped suite is not a suite that ran.'
);
process.exit(1);
}
const { minTests } = WORKSPACES[workspace];
const totalExecuted = [...ran.values()].reduce((sum, e) => sum + e.executed, 0);
if (totalExecuted < minTests) {
console.error(
`\nFAIL: ${totalExecuted} tests executed, expected at least ${minTests}. Every ${workspace}/ ` +
'suite is present, so this is a suite that collapsed rather than a project that vanished.'
);
process.exit(1);
}
console.log(`\nOK: every ${workspace}/ suite with tests on disk executed.`);