Files
fusion/scripts/__tests__/dist-freshness.test.mjs
gsxdsm 8f7089ecc9 fix(FN-7779): rebuild changed plugins on pnpm dev + warn on stale plugin dist
The Grok stale-dist bug was possible because the dev/build path never
refreshed plugin dist:

- The `client` prebuild (default `pnpm dev dashboard`) rebuilt only
  @fusion/core + @fusion/engine + @fusion/dashboard, never plugins.
- The FN-6638 stale-dist startup warning only scanned packages/, never
  plugins/, so a source-ahead plugin dist ran phantom-old with no warning.

Changes:
- build-workspace.mjs: add `--plugins-only` to plan/build just the plugins
  that changed, reusing the existing content-hash skip cache (cheap no-op when
  unchanged).
- scripts/dev-prebuild-client.mjs: new orchestrator — fast core/engine/
  dashboard build, then incremental changed-plugin rebuild. The `client`
  prebuild now runs this single cross-platform command.
- dist-freshness.mjs: scan plugin roots (plugins/, plugins/examples/) so a
  stale plugin dist is warned like a stale package dist; the warning names the
  plugin dir.

Verified: --plugins-only plans only plugins, skips unchanged on the second
run, and re-plans exactly the one plugin whose source changed. All script and
CLI lib tests pass.

Fusion-Task-Id: FN-7779
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 13:26:23 -07:00

130 lines
5.0 KiB
JavaScript

import { test } from "node:test";
import assert from "node:assert/strict";
import { computeDistStaleness, formatDistStalenessWarning } from "../lib/dist-freshness.mjs";
/*
FNXC:DevWorkflow 2026-06-18-16:50:
FN-6638 stale-dist guard tests. Verifies the startup freshness check flags a
src-ahead-of-dist build, stays quiet when fresh, and never false-positives for
pure-source (no dist) or packaged (no src) layouts.
*/
// In-memory fs seam: paths are exact strings; dirs list children; files carry mtimeMs.
function makeFs({ dirs, files }) {
const dirSet = new Set(dirs);
// files: { "<dir>": [{ name, mtimeMs, isDir? }] } keyed by parent dir
return {
existsSync: (p) => dirSet.has(p),
readdirSync: (dir) =>
(files[dir] ?? []).map((e) => ({
name: e.name,
isDirectory: () => Boolean(e.isDir),
})),
statSync: (p) => {
// p is "<dir>/<name>"; look it up by scanning entries
for (const [dir, entries] of Object.entries(files)) {
for (const e of entries) {
if (`${dir}/${e.name}` === p) return { mtimeMs: e.mtimeMs };
}
}
return { mtimeMs: 0 };
},
};
}
const ROOT = "/repo";
function layout({ srcMs, distMs, withSrc = true, withDist = true }) {
const dirs = [];
const files = {};
const srcDir = `${ROOT}/packages/engine/src`;
const distDir = `${ROOT}/packages/engine/dist`;
if (withSrc) {
dirs.push(srcDir);
files[srcDir] = [{ name: "executor.ts", mtimeMs: srcMs }];
}
if (withDist) {
dirs.push(distDir);
files[distDir] = [{ name: "executor.js", mtimeMs: distMs }];
}
return makeFs({ dirs, files });
}
test("flags stale when src is newer than dist beyond slack", () => {
const fs = layout({ srcMs: 10_000, distMs: 1_000 });
const result = computeDistStaleness({ rootDir: ROOT, packages: ["engine"], fs });
assert.equal(result.stale, true);
assert.equal(result.packages[0].stale, true);
const warning = formatDistStalenessWarning(result);
assert.match(warning, /STALE BUILD/);
assert.match(warning, /@fusion\/engine/);
assert.match(warning, /pnpm build/);
});
test("not stale when dist is newer than src", () => {
const fs = layout({ srcMs: 1_000, distMs: 10_000 });
const result = computeDistStaleness({ rootDir: ROOT, packages: ["engine"], fs });
assert.equal(result.stale, false);
assert.equal(formatDistStalenessWarning(result), null);
});
test("not stale within slack window", () => {
const fs = layout({ srcMs: 1_500, distMs: 1_000 }); // 500ms < 2000ms slack
const result = computeDistStaleness({ rootDir: ROOT, packages: ["engine"], fs });
assert.equal(result.stale, false);
});
test("skips packages with no dist (pure source run)", () => {
const fs = layout({ srcMs: 10_000, distMs: 0, withDist: false });
const result = computeDistStaleness({ rootDir: ROOT, packages: ["engine"], fs });
assert.equal(result.stale, false);
assert.equal(result.packages.length, 0);
});
test("skips packages with no src (packaged install)", () => {
const fs = layout({ srcMs: 0, distMs: 10_000, withSrc: false });
const result = computeDistStaleness({ rootDir: ROOT, packages: ["engine"], fs });
assert.equal(result.stale, false);
assert.equal(result.packages.length, 0);
});
// FN-7779: plugin dist loads at runtime, so a src-ahead-of-dist plugin must be
// flagged too — and the warning names the plugin dir, not @fusion/<name>.
test("flags a stale plugin under a plugin root and labels it by dir name", () => {
const pluginsRoot = `${ROOT}/plugins`;
const srcDir = `${pluginsRoot}/fusion-plugin-grok-runtime/src`;
const distDir = `${pluginsRoot}/fusion-plugin-grok-runtime/dist`;
const fs = makeFs({
dirs: [pluginsRoot, srcDir, distDir],
files: {
[pluginsRoot]: [{ name: "fusion-plugin-grok-runtime", isDir: true }],
[srcDir]: [{ name: "runtime-adapter.ts", mtimeMs: 10_000 }],
[distDir]: [{ name: "runtime-adapter.js", mtimeMs: 1_000 }],
},
});
const result = computeDistStaleness({ rootDir: ROOT, packages: [], pluginRoots: ["plugins"], fs });
assert.equal(result.stale, true);
const warning = formatDistStalenessWarning(result);
assert.match(warning, /STALE BUILD/);
assert.match(warning, /fusion-plugin-grok-runtime/);
assert.doesNotMatch(warning, /@fusion\/fusion-plugin-grok-runtime/);
});
test("does not flag a fresh plugin (dist newer than src)", () => {
const pluginsRoot = `${ROOT}/plugins`;
const srcDir = `${pluginsRoot}/fusion-plugin-grok-runtime/src`;
const distDir = `${pluginsRoot}/fusion-plugin-grok-runtime/dist`;
const fs = makeFs({
dirs: [pluginsRoot, srcDir, distDir],
files: {
[pluginsRoot]: [{ name: "fusion-plugin-grok-runtime", isDir: true }],
[srcDir]: [{ name: "runtime-adapter.ts", mtimeMs: 1_000 }],
[distDir]: [{ name: "runtime-adapter.js", mtimeMs: 10_000 }],
},
});
const result = computeDistStaleness({ rootDir: ROOT, packages: [], pluginRoots: ["plugins"], fs });
assert.equal(result.stale, false);
assert.equal(formatDistStalenessWarning(result), null);
});