Files
fusion/scripts/check-mock-completeness.mjs
gsxdsm a81486559e fix: dashboard lucide/CSS/SettingsModal/mcp-helper fixes + expand mock-completeness guard to dashboard (round 11) (#2049)
## Summary

Fixes all remaining full-suite failures from run 29231108919
(FN-7923–7931 drift) + expands the structural mock-completeness guard to
cover dashboard tests.

## Fixes

### Dashboard (shard 4) — 4 files
- **`TaskCard.cli-states.test.tsx`** — Proxy lucide-react mock needed
`has`/`getOwnPropertyDescriptor` traps; TaskCard now imports
`priorityIndicator.tsx` which reads `ArrowDown` at module-init. Vitest
validates ESM named exports via `in`/descriptor, not `get`. Also added
`useToast` mock.
- **`SettingsModalNodeRouting.test.tsx`** — Pass
`initialSection="node-routing"` (it's in
`ADVANCED_SETTINGS_SECTION_IDS`, nav hidden by default).
- **`styles-css-rgba-tokenization.test.ts`** — Removed stale
`.settings-sidebar` color-mix expectation (FN-7825 made it
structural-only).
- **`mcp-helper-forwarding.test.ts`** — Added
`resolvePlanningThinkingLevel` to `@fusion/engine` mock (insight
extraction calls it before MCP forwarding).

### Structural guard expansion
- **`.tsx` blind spot fixed** — `collectTs` and test file filter now
include `.tsx` files
- **Shorthand property extraction** — key extractor now matches both
`key: value` and `key,` (shorthand)
- **Convention mapping** — `.test.tsx → .tsx` source resolution added
- **Dashboard test coverage** — `@fusion/engine` barrel check now scans
`packages/dashboard/src/__tests__/`
- **7 latent mock gaps completed** — `pr-conflict-resolver`,
`project-pause-resume-routes`, `routes-approval-sandbox-provisioning`,
`routes-approval`, `routes-worktrunk`, `session-reconnect`,
`setup-routes`

### Engine (shards 1+2) — zero real failures
All 3 failing files are local-only (`@agentclientprotocol/sdk` + pi-ai
staleness). CI resolves them from lockfile.

## Verification
- Gate (with expanded guard): exit 0 ✅
- Dashboard (5 non-local files): 36/36 passed ✅
- 3 files skipped locally (`@agentclientprotocol/sdk`) — CI will verify

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **Tests**
* Improved test reliability by completing module mocks across dashboard
and routing scenarios.
* Updated settings and task card test coverage to reflect current UI
behavior.
* Enhanced mock validation to cover additional test files, TypeScript
React files, and shorthand exports.
* Prevented failures related to missing providers, engine helpers, and
planning configuration.


<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-07-13 07:10:14 -07:00

292 lines
10 KiB
JavaScript

/*
* FNXC:TestInfrastructure 2026-07-13-10:00:
* Static gate check that prevents the recurring full-suite failure pattern where
* a new export added to a barrel module (@fusion/dashboard, @fusion/engine) is
* imported by source code but missing from the hardcoded vi.mock factory in the
* corresponding test file.
*
* This runs as part of the merge gate (pnpm test:gate) so drift is caught
* before merge, not after full-suite fails on main.
*
* The check is purely static (regex-based, no module evaluation) and fast (<0.2s).
*
* Covers both CLI and engine test files. For each hardcoded vi.mock of a barrel
* module, it:
* 1. Extracts the mock factory's exported keys
* 2. Finds what the test's source files import from that barrel
* 3. Reports any barrel exports that are imported by source but absent from the mock
*
* Mocks that use importOriginal/importActual (auto-spread) are skipped — they
* inherit all real exports automatically.
*/
import { readFileSync, readdirSync, statSync, existsSync } from "node:fs";
import { join, dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
const __dirname = dirname(fileURLToPath(import.meta.url));
const root = resolve(__dirname, "..");
// ── Config: barrel modules to check ──────────────────────────────────────────
const BARRELS = [
{
moduleName: "@fusion/dashboard",
barrelPath: join(root, "packages/dashboard/src/index.ts"),
testDirs: [
join(root, "packages/cli/src/__tests__"),
join(root, "packages/cli/src/commands/__tests__"),
join(root, "packages/cli/src/plugins/__tests__"),
],
cliSrc: join(root, "packages/cli/src"),
},
{
moduleName: "@fusion/engine",
barrelPath: join(root, "packages/engine/src/index.ts"),
testDirs: [
join(root, "packages/cli/src/__tests__"),
join(root, "packages/cli/src/commands/__tests__"),
join(root, "packages/cli/src/plugins/__tests__"),
// FNXC:TestInfrastructure 2026-07-13-11:00: Dashboard API tests also mock @fusion/engine; include them to prevent the same barrel-export drift.
join(root, "packages/dashboard/src/__tests__"),
],
cliSrc: join(root, "packages/cli/src"),
},
];
// ── Helpers ──────────────────────────────────────────────────────────────────
function extractBarrelExports(src) {
const exports = new Set();
const namedRe = /export\s*\{([^}]+)\}\s*from\s*"[^"]+"/g;
let m;
while ((m = namedRe.exec(src)) !== null) {
for (let raw of m[1].split(",")) {
raw = raw.trim();
if (!raw || raw.startsWith("type ")) continue;
const aliased = raw.split(/\s+as\s+/);
const name = (aliased[aliased.length - 1] || raw).trim();
if (name && /^[A-Za-z_]/.test(name)) exports.add(name);
}
}
// Also catch `export function foo`, `export const bar`, `export class Baz`
const declRe = /export\s+(?:async\s+)?(?:function|const|class|let|var)\s+([A-Za-z_$][\w$]*)/g;
while ((m = declRe.exec(src)) !== null) {
exports.add(m[1]);
}
return exports;
}
function extractModuleUsage(filePath, moduleName) {
let src;
try { src = readFileSync(filePath, "utf8"); } catch { return new Set(); }
const used = new Set();
// Named imports: import { A, type B, C as D } from "@fusion/engine"
const namedRe = new RegExp(
`import\\s*\\{([^}]+)\\}\\s*from\\s*"${moduleName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}"`,
"g",
);
let m;
while ((m = namedRe.exec(src)) !== null) {
for (let raw of m[1].split(",")) {
raw = raw.trim();
if (!raw || raw.startsWith("type ")) continue;
const aliased = raw.split(/\s+as\s+/);
const name = (aliased[0] || raw).trim();
if (name && /^[A-Za-z_]/.test(name)) used.add(name);
}
}
// NOTE: namespace imports (import * as X) do NOT throw when a member is
// missing from the mock — the member is simply `undefined`. Only named
// imports trigger the "[vitest] No X export defined" error, so we skip
// namespace import member extraction here.
return used;
}
function resolveSourceFiles(testPath, cliSrc) {
const sources = new Set();
const testDir = dirname(testPath);
let testSrc;
try { testSrc = readFileSync(testPath, "utf8"); } catch { return sources; }
// Static imports: import { ... } from "../foo.js"
const staticRe = /import\s+(?:type\s+)?[\w{},\s*]*\s*from\s*"(\.\.?\/[^"]+\.js)"/g;
let m;
while ((m = staticRe.exec(testSrc)) !== null) {
const resolved = resolve(testDir, m[1].replace(/\.js$/, ".ts"));
if (existsSync(resolved)) sources.add(resolved);
}
// Dynamic imports: await import("../foo.js")
const dynRe = /import\(\s*"(\.\.?\/[^"]+\.js)"\s*\)/g;
while ((m = dynRe.exec(testSrc)) !== null) {
const resolved = resolve(testDir, m[1].replace(/\.js$/, ".ts"));
if (existsSync(resolved)) sources.add(resolved);
}
// Convention: __tests__/foo.test.ts → ../foo.ts, __tests__/foo.test.tsx → ../foo.tsx
const noTests = testPath.replace(/__tests\//, "");
const convPath = noTests.replace(/\.test\.ts$/, ".ts").replace(/\.test\.tsx$/, ".tsx");
if (existsSync(convPath)) sources.add(convPath);
// bin.test.ts special case
if (testPath.endsWith("__tests__/bin.test.ts")) {
const binPath = join(cliSrc, "bin.ts");
if (existsSync(binPath)) sources.add(binPath);
}
return sources;
}
function extractMockKeys(testSrc, moduleName, testPath) {
const escaped = moduleName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
if (
new RegExp(`vi\\.mock\\(\\s*"${escaped}"[^)]*importOriginal`).test(testSrc) ||
new RegExp(`vi\\.mock\\(\\s*"${escaped}"[^)]*importActual`).test(testSrc)
) {
return null; // auto-spread, safe
}
const startRe = new RegExp(
`vi\\.mock\\(\\s*"${escaped}"\\s*,\\s*\\([^)]*\\)\\s*=>\\s*\\(\\s*\\{`,
);
const startMatch = startRe.exec(testSrc);
if (!startMatch) return null;
const bodyStart = startMatch.index + startMatch[0].length;
let depth = 1;
let i = bodyStart;
while (i < testSrc.length && depth > 0) {
const ch = testSrc[i];
if (ch === "{") depth++;
else if (ch === "}") depth--;
i++;
}
const body = testSrc.slice(bodyStart, i - 1);
const keys = new Set();
// Match both regular properties (key: value) and shorthand (key, or key}).
const keyRe = /(?:^|\n)\s*([A-Za-z_$][\w$]*)\s*(?::|[,}])/g;
let km;
while ((km = keyRe.exec(body)) !== null) {
keys.add(km[1]);
}
// FNXC:TestInfrastructure 2026-07-13-10:15:
// Resolve spread helpers: when the mock body contains `...someHelper`,
// find the import for that helper, read its exported object, and add its keys.
// This handles centralized mock helpers like `workflowAuthoringEngineMock`.
const testDir = dirname(testPath);
const spreadRe = /\.\.\.\s*([A-Za-z_$][\w$]*)/g;
let sm;
while ((sm = spreadRe.exec(body)) !== null) {
const helperName = sm[1];
const importRe = new RegExp(
`import\\s*\\{[^}]*\\b${helperName}\\b[^}]*\\}\\s*from\\s*"([^"]+)"`,
);
const importMatch = importRe.exec(testSrc);
if (!importMatch) continue;
const helperPath = resolve(testDir, importMatch[1].replace(/\.js$/, ".ts"));
if (!existsSync(helperPath)) continue;
const helperSrc = readFileSync(helperPath, "utf8");
const objStartRe = new RegExp(`export\\s+const\\s+${helperName}\\s*=\\s*\\{`);
const objStart = objStartRe.exec(helperSrc);
if (!objStart) continue;
let depth2 = 1;
let j = objStart.index + objStart[0].length;
while (j < helperSrc.length && depth2 > 0) {
if (helperSrc[j] === "{") depth2++;
else if (helperSrc[j] === "}") depth2--;
j++;
}
const helperBody = helperSrc.slice(objStart.index + objStart[0].length, j - 1);
const helperKeyRe = /(?:^|\n)\s*([A-Za-z_$][\w$]*)\s*(?::)/g;
let hkm;
while ((hkm = helperKeyRe.exec(helperBody)) !== null) {
keys.add(hkm[1]);
}
}
return keys;
}
function collectTs(dir) {
let out = [];
try {
for (const entry of readdirSync(dir)) {
const full = join(dir, entry);
const st = statSync(full);
if (st.isDirectory()) out = out.concat(collectTs(full));
else if (entry.endsWith(".ts") || entry.endsWith(".tsx")) out.push(full);
}
} catch { /* dir may not exist */ }
return out;
}
// ── Run ──────────────────────────────────────────────────────────────────────
let totalErrors = 0;
for (const cfg of BARRELS) {
if (!existsSync(cfg.barrelPath)) continue;
const barrelSrc = readFileSync(cfg.barrelPath, "utf8");
const barrelExports = extractBarrelExports(barrelSrc);
const testFiles = new Set();
for (const dir of cfg.testDirs) {
for (const f of collectTs(dir)) {
if (f.endsWith(".test.ts") || f.endsWith(".test.tsx")) testFiles.add(f);
}
}
const errors = [];
for (const testFile of testFiles) {
const testSrc = readFileSync(testFile, "utf8");
if (!testSrc.includes(`vi.mock("${cfg.moduleName}"`)) continue;
const mockKeys = extractMockKeys(testSrc, cfg.moduleName, testFile);
if (mockKeys === null) continue;
const sourcePaths = resolveSourceFiles(testFile, cfg.cliSrc);
const requiredExports = new Set();
for (const srcPath of sourcePaths) {
const used = extractModuleUsage(srcPath, cfg.moduleName);
for (const e of used) {
if (barrelExports.has(e)) requiredExports.add(e);
}
}
const missing = [...requiredExports].filter((e) => !mockKeys.has(e));
if (missing.length > 0) {
const rel = testFile.replace(root + "/", "");
errors.push(
` ${rel}\n missing: ${missing.map((m) => `"${m}"`).join(", ")}` +
`\n (imported from ${cfg.moduleName} in source, absent from vi.mock factory)`,
);
}
}
if (errors.length > 0) {
console.error(
`\n❌ ${cfg.moduleName} mock completeness check failed (${errors.length} issue${errors.length > 1 ? "s" : ""}):\n`,
);
for (const e of errors) console.error(e + "\n");
console.error(
`Fix: add the missing export(s) to each vi.mock("${cfg.moduleName}") factory,\n` +
`or convert the mock to use importOriginal spread: { ...await importOriginal(), onlyOverride: vi.fn() }`,
);
totalErrors += errors.length;
} else {
console.log(`✅ ${cfg.moduleName} mock completeness: all hardcoded mocks cover source imports.`);
}
}
if (totalErrors > 0) {
process.exit(1);
}