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
3 changes: 3 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -889,6 +889,9 @@ jobs:
# matches the pattern already used by "Save Turborepo cache"/"Save TypeScript incremental build
# cache" elsewhere in this file: still run after an earlier step's failure, just not after the job
# was cancelled outright. The job as a whole still fails if any of the three fails.
- name: UI tests (ui-kit)
if: ${{ !cancelled() && (github.event_name == 'push' || needs.changes.outputs.ui == 'true') }}
run: npm --workspace @loopover/ui-kit run test
- name: UI tests (ui)
if: ${{ !cancelled() && (github.event_name == 'push' || needs.changes.outputs.ui == 'true') }}
run: npm --workspace @loopover/ui run test
Expand Down
5 changes: 3 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@
"db:migrations:immutable:check": "tsx scripts/check-released-migrations-immutable.ts",
"turbo-inputs:check": "tsx scripts/check-turbo-typecheck-inputs.ts",
"typecheck-coverage:check": "tsx scripts/check-typecheck-coverage.ts",
"test-wiring:check": "tsx scripts/check-test-coverage-wiring.ts",
"fixture-clock-races:check": "tsx scripts/check-fixture-clock-races.ts",
"workspace-dep-ranges:check": "tsx scripts/check-workspace-dep-ranges.ts",
"db:schema-drift:check": "tsx scripts/check-schema-drift.ts",
Expand All @@ -73,7 +74,7 @@
"ui:lint": "npm run ui:kit:build && npm --workspace @loopover/ui-kit run format:check && npm --workspace @loopover/ui run format:check && npm --workspace @loopover/ui run lint && npm --workspace @loopover/ui-miner run format:check && npm --workspace @loopover/ui-miner run lint",
"ui:typecheck": "npm run ui:kit:build && npm --workspace @loopover/ui-kit run typecheck && npm --workspace @loopover/ui run typecheck && npm --workspace @loopover/ui-miner run typecheck",
"preui:test": "npm run check-node-version",
"ui:test": "npm run ui:kit:build && npm --workspace @loopover/ui run test && npm --workspace @loopover/ui-miner run test",
"ui:test": "npm run ui:kit:build && npm --workspace @loopover/ui-kit run test && npm --workspace @loopover/ui run test && npm --workspace @loopover/ui-miner run test",
"ui:openapi": "tsx scripts/write-ui-openapi.ts",
"ui:openapi:check": "tsx scripts/write-ui-openapi.ts --check",
"control-plane:contract": "tsx scripts/gen-control-plane-contract.ts",
Expand Down Expand Up @@ -148,7 +149,7 @@
"test:smoke:browser:install": "playwright install chromium",
"test:smoke:browser": "node --experimental-strip-types scripts/smoke-ui-browser.ts",
"pretest:ci": "npm run check-node-version",
"test:ci": "git diff --check && npm run actionlint && npm run lint:composite-actions && npm run db:migrations:check && npm run db:migrations:immutable:check && npm run turbo-inputs:check && npm run workspace-dep-ranges:check && npm run db:schema-drift:check && npm run selfhost:env-reference:check && npm run miner:env-reference:check && npm run selfhost:validate-observability && npm run cf-typegen:check && npm run build --workspace @loopover/engine && npm run build --workspace @loopover/discovery-index && npm run build:mcp && npm run build:miner && npm run build --workspace @loopover/ui-kit && npm run typecheck && npm run test:coverage && npm run test:engine-parity && npm run test:live-gate-parity && npm run test:driver-parity && npm run validate:mcp && npm run test --workspace @loopover/engine && npm run test:workers && npm run test:mcp-pack && npm run test:contract-pack && npm run test:miner-pack && npm run test:engine-pack && npm run test:ui-kit-pack && npm run test:miner-deployment-docs-audit && npm run rees:test && npm run ui:openapi:check && npm run control-plane:contract:check && npm run control-plane:openapi:check && npm run ui:version-audit && npm run docs:drift-check && npm run coverage-boltons:check && npm run import-specifiers:check && npm run ui-derived-types:check && npm run server-manifest:check && npm run dead-source-files:check && npm run dead-exports:check && npm run publishable-deps:check && npm run fixture-clock-races:check && npm run typecheck-coverage:check && npm run checkers-wired:check && npm run focus-manifest-fields:check && npm run release-commit-parsing:check && npm run releasable-commit-types:check && npm run regate-sort-key:check && npm run maintainer-associations:check && npm run command-redelivery-guards:check && npm run dispatch-gate-reasons:check && npm run validate:no-hand-written-js && npm run replay-runner-manifest:check && npm run coco-dev-versions:check && npm run branding-drift:check && npm run manifest:drift-check && npm run engine-parity:drift-check && npm run engines-nvmrc:check && npm run release-manifest:sync:check && npm run release-linked-versions:check && npm run command-reference:check && npm run mcp:tool-reference:check && npm run mcp:client-config:check && npm run contract:api-schemas:check && npm run ui:lint && npm run ui:typecheck && npm run ui:test && npm run ui:build",
"test:ci": "git diff --check && npm run actionlint && npm run lint:composite-actions && npm run db:migrations:check && npm run db:migrations:immutable:check && npm run turbo-inputs:check && npm run workspace-dep-ranges:check && npm run db:schema-drift:check && npm run selfhost:env-reference:check && npm run miner:env-reference:check && npm run selfhost:validate-observability && npm run cf-typegen:check && npm run build --workspace @loopover/engine && npm run build --workspace @loopover/discovery-index && npm run build:mcp && npm run build:miner && npm run build --workspace @loopover/ui-kit && npm run typecheck && npm run test:coverage && npm run test:engine-parity && npm run test:live-gate-parity && npm run test:driver-parity && npm run validate:mcp && npm run test --workspace @loopover/engine && npm run test:workers && npm run test:mcp-pack && npm run test:contract-pack && npm run test:miner-pack && npm run test:engine-pack && npm run test:ui-kit-pack && npm run test:miner-deployment-docs-audit && npm run rees:test && npm run ui:openapi:check && npm run control-plane:contract:check && npm run control-plane:openapi:check && npm run ui:version-audit && npm run docs:drift-check && npm run coverage-boltons:check && npm run import-specifiers:check && npm run ui-derived-types:check && npm run server-manifest:check && npm run dead-source-files:check && npm run dead-exports:check && npm run publishable-deps:check && npm run fixture-clock-races:check && npm run typecheck-coverage:check && npm run test-wiring:check && npm run checkers-wired:check && npm run focus-manifest-fields:check && npm run release-commit-parsing:check && npm run releasable-commit-types:check && npm run regate-sort-key:check && npm run maintainer-associations:check && npm run command-redelivery-guards:check && npm run dispatch-gate-reasons:check && npm run validate:no-hand-written-js && npm run replay-runner-manifest:check && npm run coco-dev-versions:check && npm run branding-drift:check && npm run manifest:drift-check && npm run engine-parity:drift-check && npm run engines-nvmrc:check && npm run release-manifest:sync:check && npm run release-linked-versions:check && npm run command-reference:check && npm run mcp:tool-reference:check && npm run mcp:client-config:check && npm run contract:api-schemas:check && npm run ui:lint && npm run ui:typecheck && npm run ui:test && npm run ui:build",
"test:release": "npm run test:ci && npm run changelog:check",
"test:release:mcp": "npm run test:ci",
"test:watch": "vitest",
Expand Down
129 changes: 129 additions & 0 deletions scripts/check-test-coverage-wiring.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
#!/usr/bin/env node
// `npm run test:ci` must actually RUN every workspace's declared `test` script (#10049).
//
// THE INCIDENT. `packages/loopover-ui-kit` ships `"test": "vitest run"` and a package-local vitest.config.ts
// whose own header states the acceptance signal is "this suite runs and passes". Nothing invokes it: root
// `ui:test` builds ui-kit but only ever runs @loopover/ui and @loopover/ui-miner's tests, `test:ci` reaches
// ui-kit only through build/pack/lint/typecheck steps, and ci.yml has no ui-kit test step. The suite's 12
// files (60+ tests) have sat there, never executed, since #7437/#7495 added them.
//
// That is the exact shape #9860 named: not a MISSING check, but a SILENTLY PARTIAL one -- trusted precisely
// because everything around it (build, pack, lint, typecheck) is green. #9860's own fix for this class was
// `scripts/check-typecheck-coverage.ts`, which proved `npm run typecheck` reaches every workspace that
// declares one. This is that same computation for `test`.
//
// WHAT THIS COMPUTES. Every workspace under apps/*/packages/* that declares its own `test` script, and
// whether the root `test:ci` chain reaches it -- following `npm run <script>` references transitively, so a
// workspace covered through an intermediate script (`ui:test` -> `npm --workspace @loopover/ui-kit run
// test`) counts as covered. Anything declaring a `test` nobody runs is reported.
import { readFileSync, readdirSync } from "node:fs";
import { join } from "node:path";
import { fileURLToPath, URL } from "node:url";

export type TestWiringGap = { workspace: string; script: string };

export type WorkspaceWithTest = { name: string; dir: string };

/** Every `npm run <name>` this script body invokes (the root package's own scripts). */
function referencedRootScripts(body: string): string[] {
// `npm run x`, `npm run x --silent`, `npm --silent run x` -- all forms used in this package.json.
return [...body.matchAll(/npm\s+(?:--\S+\s+)*run\s+([\w:.-]+)/g)].map((match) => match[1]).filter((name): name is string => Boolean(name));
}

/** Every workspace whose OWN `test` this script body invokes directly. */
function referencedWorkspaces(body: string): string[] {
// `npm --workspace @scope/name run test` and `npm run test --workspace @scope/name`.
const names = [
...body.matchAll(/npm\s+--workspace[= ]\s*(\S+)\s+run\s+([\w:.-]+)/g),
...body.matchAll(/npm\s+run\s+([\w:.-]+)\s+--workspace[= ]\s*(\S+)/g),
];
const out: string[] = [];
for (const match of names) {
// The two patterns capture (workspace, script) and (script, workspace) respectively; the workspace is
// whichever capture looks like a package name.
const [a, b] = [match[1], match[2]];
const workspace = a?.startsWith("@") || a?.includes("/") ? a : b;
const script = workspace === a ? b : a;
if (workspace && script === "test") out.push(workspace);
}
return out;
}

/**
* PURE: workspaces that declare a `test` script the root `test:ci` chain never reaches.
*
* `scripts` is the root package's script map; `workspacesWithTest` is every workspace that declares one
* (package name + directory). Reachability follows `npm run` references transitively from `entry`, because
* a workspace is covered whether its `test` is invoked directly or through an intermediate script.
*/
export function findTestWiringGaps(
scripts: Readonly<Record<string, string>>,
workspacesWithTest: readonly WorkspaceWithTest[],
entry = "test:ci",
): TestWiringGap[] {
const covered = new Set<string>();
const seen = new Set<string>();
const queue: string[] = [entry];
while (queue.length > 0) {
const name = queue.shift();
if (!name || seen.has(name)) continue;
seen.add(name);
const body = scripts[name];
if (body === undefined) continue;
for (const workspace of referencedWorkspaces(body)) covered.add(workspace);
queue.push(...referencedRootScripts(body));
}
return workspacesWithTest
.filter(
(workspace) =>
!covered.has(workspace.name) &&
!covered.has(workspace.name.replace(/^@[\w-]+\//, "")) &&
!covered.has(workspace.dir),
)
.map((workspace) => ({ workspace: workspace.name, script: "test" }));
}

/** Workspace package names (and their directories) that declare their own `test` script. */
export function workspacesDeclaringTest(root: string): WorkspaceWithTest[] {
const out: WorkspaceWithTest[] = [];
for (const group of ["apps", "packages"]) {
let dirs: string[];
try {
dirs = readdirSync(join(root, group), { withFileTypes: true }).filter((entry) => entry.isDirectory()).map((entry) => entry.name);
} catch {
continue;
}
for (const dir of dirs) {
try {
const manifest = JSON.parse(readFileSync(join(root, group, dir, "package.json"), "utf8")) as { name?: string; scripts?: Record<string, string> };
if (manifest.name && manifest.scripts?.test) out.push({ name: manifest.name, dir: `${group}/${dir}` });
} catch {
// not a workspace package
}
}
}
return out;
}

function main(): void {
const root = join(fileURLToPath(new URL(".", import.meta.url)), "..");
const rootManifest = JSON.parse(readFileSync(join(root, "package.json"), "utf8")) as { scripts?: Record<string, string> };
const declared = workspacesDeclaringTest(root);
const gaps = findTestWiringGaps(rootManifest.scripts ?? {}, declared);

if (gaps.length > 0) {
console.error("`npm run test:ci` does not reach every workspace that declares a `test` script:\n");
for (const gap of gaps) console.error(` ${gap.workspace} (declares "${gap.script}", never invoked)`);
console.error(
"\n A suite that exists, is written to pin real behaviour, and never runs is worse than no suite: it is\n" +
" trusted BECAUSE nothing around it is red. #10049 found exactly this for @loopover/ui-kit's 12-file\n" +
" vitest suite -- present since #7437/#7495, never executed.\n\n" +
" Fix: chain the workspace's `test` into the root `test:ci` script (directly, or through one it\n" +
" already calls, e.g. `ui:test`), so a declared test script always means a running test script.",
);
process.exit(1);
}
console.log(`test-wiring: OK — all ${declared.length} workspace test script(s) are reachable from \`npm run test:ci\`.`);
}

if (process.argv[1]?.endsWith("check-test-coverage-wiring.ts")) main();
109 changes: 109 additions & 0 deletions test/unit/check-test-coverage-wiring-script.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
// The test-wiring checker must catch a workspace `test:ci` never reaches (#10049).
//
// The failure it guards is specific: a workspace declares its own `test` script and nothing in the root
// `test:ci` chain ever invokes it, so the suite sits there -- written, reviewed, never executed -- while
// everything ELSE (build, pack, lint, typecheck) stays green. #10049 found exactly this for
// @loopover/ui-kit's 12-file vitest suite. So the positive case here is "a declared test nobody runs is
// reported", and the negative cases pin every legitimate way a workspace can be covered, since a checker
// that reports a covered workspace would be noise and would get muted.
import { describe, expect, it } from "vitest";

import { findTestWiringGaps } from "../../scripts/check-test-coverage-wiring";

describe("findTestWiringGaps (#10049)", () => {
it("REGRESSION: reports the exact #10049 shape — ui-kit's test script the root chain never invokes", () => {
const scripts = {
"test:ci": "npm run ui:lint && npm run ui:typecheck && npm run ui:test",
"ui:lint": "npm run ui:kit:build && npm --workspace @loopover/ui-kit run format:check",
"ui:typecheck": "npm run ui:kit:build && npm --workspace @loopover/ui-kit run typecheck",
// Present in the repo, but only ever builds ui-kit -- never runs its test script.
"ui:test": "npm run ui:kit:build && npm --workspace @loopover/ui run test && npm --workspace @loopover/ui-miner run test",
"ui:kit:build": "npm run build --workspace @loopover/ui-kit",
};
expect(
findTestWiringGaps(scripts, [
{ name: "@loopover/ui-kit", dir: "packages/loopover-ui-kit" },
{ name: "@loopover/ui", dir: "apps/loopover-ui" },
]),
).toEqual([{ workspace: "@loopover/ui-kit", script: "test" }]);
});

it("counts a workspace reached THROUGH an intermediate script as covered", () => {
// The fix chains ui-kit's test into ui:test; the workspace is then covered transitively.
const scripts = {
"test:ci": "npm run ui:test",
"ui:test": "npm run ui:kit:build && npm --workspace @loopover/ui-kit run test && npm --workspace @loopover/ui run test",
"ui:kit:build": "npm run build --workspace @loopover/ui-kit",
};
expect(findTestWiringGaps(scripts, [{ name: "@loopover/ui-kit", dir: "packages/loopover-ui-kit" }])).toEqual([]);
});

it("reports a workspace whose BUILD is invoked but whose test is not", () => {
// A build may exercise some code as a side effect, but that is not a guarantee the test script runs.
const scripts = { "test:ci": "npm run ui:kit:build", "ui:kit:build": "npm run build --workspace @loopover/ui-kit" };
expect(findTestWiringGaps(scripts, [{ name: "@loopover/ui-kit", dir: "packages/loopover-ui-kit" }])).toEqual([
{ workspace: "@loopover/ui-kit", script: "test" },
]);
});

it("handles the reversed flag order, since both spellings appear in this package.json", () => {
const scripts = { "test:ci": "npm run test --workspace @loopover/engine" };
expect(findTestWiringGaps(scripts, [{ name: "@loopover/engine", dir: "packages/loopover-engine" }])).toEqual([]);
});

it("does NOT count a non-test script invoked via --workspace as coverage", () => {
const scripts = { "test:ci": "npm --workspace @loopover/ui-kit run build" };
expect(findTestWiringGaps(scripts, [{ name: "@loopover/ui-kit", dir: "packages/loopover-ui-kit" }])).toEqual([
{ workspace: "@loopover/ui-kit", script: "test" },
]);
});

it("does NOT count a non-test script invoked via the reversed --workspace flag order", () => {
const scripts = { "test:ci": "npm run build --workspace @loopover/ui-kit" };
expect(findTestWiringGaps(scripts, [{ name: "@loopover/ui-kit", dir: "packages/loopover-ui-kit" }])).toEqual([
{ workspace: "@loopover/ui-kit", script: "test" },
]);
});

it("treats a workspace as covered when covered holds the unscoped name (scoped-name strip hit)", () => {
const scripts = { "test:ci": "npm run test --workspace ui-kit" };
expect(findTestWiringGaps(scripts, [{ name: "@loopover/ui-kit", dir: "packages/loopover-ui-kit" }])).toEqual([]);
});

it("does not treat a workspace as covered via scoped-name strip when the unscoped token is absent", () => {
const scripts = { "test:ci": "echo noop" };
expect(findTestWiringGaps(scripts, [{ name: "@loopover/ui-kit", dir: "packages/loopover-ui-kit" }])).toEqual([
{ workspace: "@loopover/ui-kit", script: "test" },
]);
});

it("terminates on a cyclic script graph instead of looping forever", () => {
const scripts = { "test:ci": "npm run a", a: "npm run b", b: "npm run a" };
expect(findTestWiringGaps(scripts, [{ name: "@loopover/ui-kit", dir: "packages/loopover-ui-kit" }])).toEqual([
{ workspace: "@loopover/ui-kit", script: "test" },
]);
});

it("reports nothing when no workspace declares a test at all", () => {
expect(findTestWiringGaps({ "test:ci": "echo noop" }, [])).toEqual([]);
});

it("tolerates a missing entry script rather than throwing", () => {
// A renamed root script must fail loudly as a REPORT, not as a crash mid-CI.
expect(findTestWiringGaps({}, [{ name: "@loopover/ui-kit", dir: "packages/loopover-ui-kit" }])).toEqual([
{ workspace: "@loopover/ui-kit", script: "test" },
]);
});

it("treats a workspace as covered when covered holds its directory path (npm --workspace <path> form)", () => {
const scripts = { "test:ci": "npm --workspace packages/loopover-ui-kit run test" };
expect(findTestWiringGaps(scripts, [{ name: "@loopover/ui-kit", dir: "packages/loopover-ui-kit" }])).toEqual([]);
});

it("accepts an explicit entry point other than the test:ci default", () => {
const scripts = { "ui:test": "npm --workspace @loopover/ui-kit run test" };
expect(
findTestWiringGaps(scripts, [{ name: "@loopover/ui-kit", dir: "packages/loopover-ui-kit" }], "ui:test"),
).toEqual([]);
});
});
Loading