Add a git-backed plugin build cache to make root workspace builds skip safe unchanged plugin packages. - Route root pnpm build through a workspace build planner that always builds non-plugin packages and selectively skips cached plugin packages. - Hash plugin inputs with declared local workspace dependencies plus root build tooling/config and require dist outputs before skipping. - Cover cache planning and invalidation behavior with script tests and document the operator-facing build behavior. Files changed: .changeset/fn-7290-plugin-build-cache.md | 7 + docs/testing.md | 5 +- package.json | 2 +- scripts/__tests__/build-workspace.test.mjs | 253 +++++++++++++++++ scripts/build-workspace.mjs | 427 +++++++++++++++++++++++++++++ 5 files changed, 692 insertions(+), 2 deletions(-) Fusion-Task-Id: FN-7290 Fusion-Task-Lineage: a91861c7-385d-4e9c-bc43-11ed0ff188c8 Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
254 lines
10 KiB
JavaScript
254 lines
10 KiB
JavaScript
import test from "node:test";
|
|
import assert from "node:assert/strict";
|
|
import { mkdirSync, mkdtempSync, rmSync, writeFileSync, readFileSync } from "node:fs";
|
|
import { tmpdir } from "node:os";
|
|
import path from "node:path";
|
|
import { spawnSync } from "node:child_process";
|
|
|
|
import {
|
|
BUILD_CACHE_VERSION,
|
|
PLUGIN_BUILD_GLOBAL_INPUT_PATHS,
|
|
computePluginSourceHash,
|
|
discoverWorkspacePackages,
|
|
planWorkspaceBuild,
|
|
readPluginBuildCache,
|
|
requiredPluginOutputs,
|
|
} from "../build-workspace.mjs";
|
|
|
|
function createWorkspace() {
|
|
const root = mkdtempSync(path.join(tmpdir(), "fn-7290-build-workspace-"));
|
|
writeFileSync(path.join(root, "pnpm-workspace.yaml"), "packages:\n - 'packages/*'\n - 'plugins/*'\n");
|
|
writeFileSync(path.join(root, "package.json"), JSON.stringify({ name: "workspace-root", private: true }, null, 2));
|
|
writeFileSync(path.join(root, "pnpm-lock.yaml"), "lockfileVersion: '9.0'\n");
|
|
writeFileSync(path.join(root, "tsconfig.json"), JSON.stringify({ extends: "./tsconfig.base.json" }, null, 2));
|
|
writeFileSync(path.join(root, "tsconfig.base.json"), JSON.stringify({ compilerOptions: { strict: true } }, null, 2));
|
|
mkdirSync(path.join(root, "plugins"), { recursive: true });
|
|
writeFileSync(path.join(root, "plugins", "tsconfig.base.json"), JSON.stringify({ extends: "../tsconfig.base.json" }, null, 2));
|
|
mkdirSync(path.join(root, "scripts", "lib"), { recursive: true });
|
|
writeFileSync(path.join(root, "scripts", "build-workspace.mjs"), "export {};\n");
|
|
writeFileSync(path.join(root, "scripts", "lib", "content-hash.mjs"), "export {};\n");
|
|
writePackage(root, "packages/core", {
|
|
name: "@fusion/core",
|
|
scripts: { build: "tsc" },
|
|
});
|
|
mkdirSync(path.join(root, "packages/core", "src"), { recursive: true });
|
|
writeFileSync(path.join(root, "packages/core", "src", "index.ts"), "export const core = 1;\n");
|
|
writePackage(root, "packages/desktop", {
|
|
name: "@fusion/desktop",
|
|
scripts: { build: "tsc" },
|
|
});
|
|
writePackage(root, "plugins/fusion-plugin-alpha", {
|
|
name: "@fusion-plugin-examples/alpha",
|
|
scripts: { build: "tsc" },
|
|
dependencies: { "@fusion/core": "workspace:*" },
|
|
exports: { ".": { types: "./src/index.ts", import: "./dist/index.js" } },
|
|
});
|
|
mkdirSync(path.join(root, "plugins/fusion-plugin-alpha", "src"), { recursive: true });
|
|
writeFileSync(path.join(root, "plugins/fusion-plugin-alpha", "src", "index.ts"), "export const alpha = 1;\n");
|
|
return root;
|
|
}
|
|
|
|
function writePackage(root, dir, manifest) {
|
|
mkdirSync(path.join(root, dir), { recursive: true });
|
|
writeFileSync(path.join(root, dir, "package.json"), JSON.stringify(manifest, null, 2));
|
|
}
|
|
|
|
function withWorkspace(fn) {
|
|
const root = createWorkspace();
|
|
try {
|
|
return fn(root);
|
|
} finally {
|
|
rmSync(root, { recursive: true, force: true });
|
|
}
|
|
}
|
|
|
|
function initGit(root) {
|
|
spawnSync("git", ["init"], { cwd: root, stdio: "ignore" });
|
|
spawnSync("git", ["add", "."], { cwd: root, stdio: "ignore" });
|
|
}
|
|
|
|
function packageByName(packages, name) {
|
|
return packages.find((pkg) => pkg.name === name);
|
|
}
|
|
|
|
function writePluginDist(root, dir = "plugins/fusion-plugin-alpha") {
|
|
mkdirSync(path.join(root, dir, "dist"), { recursive: true });
|
|
writeFileSync(path.join(root, dir, "dist", "index.js"), "export const alpha = 1;\n");
|
|
}
|
|
|
|
test("discovers workspace packages and classifies plugin directories", () => {
|
|
withWorkspace((root) => {
|
|
const packages = discoverWorkspacePackages(root);
|
|
const plugin = packageByName(packages, "@fusion-plugin-examples/alpha");
|
|
const core = packageByName(packages, "@fusion/core");
|
|
|
|
assert.equal(plugin.isPlugin, true);
|
|
assert.equal(core.isPlugin, false);
|
|
assert.deepEqual(plugin.requiredOutputs, ["plugins/fusion-plugin-alpha/dist/index.js"]);
|
|
assert.deepEqual(plugin.inputPaths, [
|
|
...PLUGIN_BUILD_GLOBAL_INPUT_PATHS,
|
|
"packages/core",
|
|
"plugins/fusion-plugin-alpha",
|
|
].sort((a, b) => a.localeCompare(b)));
|
|
});
|
|
});
|
|
|
|
test("non-plugin build packages stay planned while unchanged plugins with outputs and cache are skipped", () => {
|
|
withWorkspace((root) => {
|
|
writePluginDist(root);
|
|
initGit(root);
|
|
const packages = discoverWorkspacePackages(root);
|
|
const plugin = packageByName(packages, "@fusion-plugin-examples/alpha");
|
|
const hash = computePluginSourceHash(plugin, root);
|
|
const cache = { version: BUILD_CACHE_VERSION, entries: { [plugin.name]: { sourceHash: hash } } };
|
|
|
|
const plan = planWorkspaceBuild({ rootDir: root, packages, cache });
|
|
|
|
assert.deepEqual(plan.plannedPackages.map((pkg) => pkg.name), ["@fusion/core"]);
|
|
assert.deepEqual(plan.skippedPlugins.map((pkg) => pkg.name), ["@fusion-plugin-examples/alpha"]);
|
|
assert.deepEqual(plan.excludedPackages.map((pkg) => pkg.name), ["@fusion/desktop"]);
|
|
});
|
|
});
|
|
|
|
test("plugin packages build when required outputs are missing even with a matching cache", () => {
|
|
withWorkspace((root) => {
|
|
initGit(root);
|
|
const packages = discoverWorkspacePackages(root);
|
|
const plugin = packageByName(packages, "@fusion-plugin-examples/alpha");
|
|
const hash = computePluginSourceHash(plugin, root);
|
|
const cache = { version: BUILD_CACHE_VERSION, entries: { [plugin.name]: { sourceHash: hash } } };
|
|
|
|
const plan = planWorkspaceBuild({ rootDir: root, packages, cache });
|
|
|
|
const plannedPlugin = packageByName(plan.plannedPackages, "@fusion-plugin-examples/alpha");
|
|
assert.equal(plannedPlugin.buildReason, "missing-output");
|
|
});
|
|
});
|
|
|
|
test("plugin packages build when no successful-build cache entry exists", () => {
|
|
withWorkspace((root) => {
|
|
writePluginDist(root);
|
|
initGit(root);
|
|
const packages = discoverWorkspacePackages(root);
|
|
|
|
const plan = planWorkspaceBuild({ rootDir: root, packages, cache: { version: BUILD_CACHE_VERSION, entries: {} } });
|
|
|
|
const plannedPlugin = packageByName(plan.plannedPackages, "@fusion-plugin-examples/alpha");
|
|
assert.equal(plannedPlugin.buildReason, "no-cache");
|
|
});
|
|
});
|
|
|
|
test("plugin packages build when tracked source files change", () => {
|
|
withWorkspace((root) => {
|
|
writePluginDist(root);
|
|
initGit(root);
|
|
const packages = discoverWorkspacePackages(root);
|
|
const plugin = packageByName(packages, "@fusion-plugin-examples/alpha");
|
|
const originalHash = computePluginSourceHash(plugin, root);
|
|
writeFileSync(path.join(root, plugin.dir, "src", "index.ts"), "export const alpha = 2;\n");
|
|
|
|
const plan = planWorkspaceBuild({
|
|
rootDir: root,
|
|
packages,
|
|
cache: { version: BUILD_CACHE_VERSION, entries: { [plugin.name]: { sourceHash: originalHash } } },
|
|
});
|
|
|
|
const plannedPlugin = packageByName(plan.plannedPackages, "@fusion-plugin-examples/alpha");
|
|
assert.equal(plannedPlugin.buildReason, "changed-inputs");
|
|
});
|
|
});
|
|
|
|
test("plugin packages build when untracked plugin source files are present", () => {
|
|
withWorkspace((root) => {
|
|
writePluginDist(root);
|
|
initGit(root);
|
|
const packages = discoverWorkspacePackages(root);
|
|
const plugin = packageByName(packages, "@fusion-plugin-examples/alpha");
|
|
const originalHash = computePluginSourceHash(plugin, root);
|
|
writeFileSync(path.join(root, plugin.dir, "src", "extra.ts"), "export const extra = true;\n");
|
|
|
|
const plan = planWorkspaceBuild({
|
|
rootDir: root,
|
|
packages,
|
|
cache: { version: BUILD_CACHE_VERSION, entries: { [plugin.name]: { sourceHash: originalHash } } },
|
|
});
|
|
|
|
const plannedPlugin = packageByName(plan.plannedPackages, "@fusion-plugin-examples/alpha");
|
|
assert.equal(plannedPlugin.buildReason, "changed-inputs");
|
|
});
|
|
});
|
|
|
|
test("plugin packages build when declared workspace dependency files change", () => {
|
|
withWorkspace((root) => {
|
|
writePluginDist(root);
|
|
initGit(root);
|
|
const packages = discoverWorkspacePackages(root);
|
|
const plugin = packageByName(packages, "@fusion-plugin-examples/alpha");
|
|
const originalHash = computePluginSourceHash(plugin, root);
|
|
writeFileSync(path.join(root, "packages/core", "src", "index.ts"), "export const core = 2;\n");
|
|
|
|
const plan = planWorkspaceBuild({
|
|
rootDir: root,
|
|
packages,
|
|
cache: { version: BUILD_CACHE_VERSION, entries: { [plugin.name]: { sourceHash: originalHash } } },
|
|
});
|
|
|
|
const plannedPlugin = packageByName(plan.plannedPackages, "@fusion-plugin-examples/alpha");
|
|
assert.equal(plannedPlugin.buildReason, "changed-inputs");
|
|
});
|
|
});
|
|
|
|
test("plugin packages build when root TypeScript/build-tooling inputs change", () => {
|
|
withWorkspace((root) => {
|
|
writePluginDist(root);
|
|
initGit(root);
|
|
const packages = discoverWorkspacePackages(root);
|
|
const plugin = packageByName(packages, "@fusion-plugin-examples/alpha");
|
|
const originalHash = computePluginSourceHash(plugin, root);
|
|
writeFileSync(path.join(root, "tsconfig.base.json"), JSON.stringify({ compilerOptions: { strict: false } }, null, 2));
|
|
|
|
const plan = planWorkspaceBuild({
|
|
rootDir: root,
|
|
packages,
|
|
cache: { version: BUILD_CACHE_VERSION, entries: { [plugin.name]: { sourceHash: originalHash } } },
|
|
});
|
|
|
|
const plannedPlugin = packageByName(plan.plannedPackages, "@fusion-plugin-examples/alpha");
|
|
assert.equal(plannedPlugin.buildReason, "changed-inputs");
|
|
});
|
|
});
|
|
|
|
test("invalid cache versions are ignored so plugins rebuild once", () => {
|
|
withWorkspace((root) => {
|
|
mkdirSync(path.join(root, ".fusion", "cache"), { recursive: true });
|
|
writeFileSync(path.join(root, ".fusion", "cache", "plugin-build-cache.json"), JSON.stringify({ version: -1, entries: { stale: {} } }));
|
|
|
|
assert.deepEqual(readPluginBuildCache(root), { version: BUILD_CACHE_VERSION, entries: {} });
|
|
});
|
|
});
|
|
|
|
test("required outputs include source-export dist counterparts", () => {
|
|
withWorkspace((root) => {
|
|
mkdirSync(path.join(root, "plugins/fusion-plugin-beta", "src", "nested"), { recursive: true });
|
|
writeFileSync(path.join(root, "plugins/fusion-plugin-beta", "src", "index.ts"), "export {};\n");
|
|
writeFileSync(path.join(root, "plugins/fusion-plugin-beta", "src", "nested", "view.tsx"), "export {};\n");
|
|
const outputs = requiredPluginOutputs(root, "plugins/fusion-plugin-beta", {
|
|
exports: {
|
|
".": { types: "./src/index.d.ts", import: "./src/index.ts" },
|
|
"./view": { types: "./src/nested/view.d.ts", import: "./src/nested/view.tsx" },
|
|
},
|
|
});
|
|
|
|
assert.deepEqual(outputs, [
|
|
"plugins/fusion-plugin-beta/dist/index.js",
|
|
"plugins/fusion-plugin-beta/dist/nested/view.js",
|
|
]);
|
|
});
|
|
});
|
|
|
|
test("root package build script points at the workspace build wrapper", () => {
|
|
const rootPackage = JSON.parse(readFileSync(path.resolve("package.json"), "utf8"));
|
|
|
|
assert.equal(rootPackage.scripts.build, "node scripts/build-workspace.mjs");
|
|
});
|