test(FN-2343): harden build-output contract coverage
- Add shared test setup helpers that always build dashboard client assets before assertions run - Remove skip-gated build-output tests and make CLI/dashboard suites deterministic by owning artifact setup - Enforce hashed vendor chunk naming checks for vendor-react and vendor-xterm in generated assets - Verify copied CLI client index references real built chunks and does not contain the dashboard stub marker
This commit is contained in:
31
packages/cli/src/__tests__/bundle-output-helpers.ts
Normal file
31
packages/cli/src/__tests__/bundle-output-helpers.ts
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import { execSync } from "node:child_process";
|
||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
import { join } from "node:path";
|
||||||
|
|
||||||
|
export const cliRoot = join(__dirname, "..", "..");
|
||||||
|
export const workspaceRoot = join(cliRoot, "..", "..");
|
||||||
|
export const bundlePath = join(cliRoot, "dist", "bin.js");
|
||||||
|
export const clientIndexPath = join(cliRoot, "dist", "client", "index.html");
|
||||||
|
|
||||||
|
export const dashboardClientStubMarker = "Dashboard assets not built";
|
||||||
|
|
||||||
|
function runBuildCommand(command: string, cwd: string) {
|
||||||
|
execSync(command, {
|
||||||
|
cwd,
|
||||||
|
stdio: "pipe",
|
||||||
|
timeout: 240_000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This suite verifies real copied dashboard client assets in CLI dist output.
|
||||||
|
* It must build those assets explicitly instead of skip-gating on ambient dist/.
|
||||||
|
*/
|
||||||
|
export function buildCliWithRealDashboardAssets() {
|
||||||
|
runBuildCommand("pnpm --filter @fusion/dashboard build:client", workspaceRoot);
|
||||||
|
runBuildCommand("pnpm build", cliRoot);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readClientIndexHtml() {
|
||||||
|
return readFileSync(clientIndexPath, "utf-8");
|
||||||
|
}
|
||||||
@@ -1,26 +1,21 @@
|
|||||||
import { describe, it, expect, beforeAll } from "vitest";
|
import { describe, it, expect, beforeAll } from "vitest";
|
||||||
import { execSync } from "node:child_process";
|
|
||||||
import { readFileSync, existsSync, readdirSync } from "node:fs";
|
import { readFileSync, existsSync, readdirSync } from "node:fs";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
|
import {
|
||||||
|
buildCliWithRealDashboardAssets,
|
||||||
|
bundlePath,
|
||||||
|
cliRoot,
|
||||||
|
clientIndexPath,
|
||||||
|
dashboardClientStubMarker,
|
||||||
|
readClientIndexHtml,
|
||||||
|
} from "./bundle-output-helpers";
|
||||||
|
|
||||||
const cliRoot = join(__dirname, "..", "..");
|
|
||||||
const bundlePath = join(cliRoot, "dist", "bin.js");
|
|
||||||
const clientIndexPath = join(cliRoot, "dist", "client", "index.html");
|
|
||||||
const tsupConfigPath = join(cliRoot, "tsup.config.ts");
|
const tsupConfigPath = join(cliRoot, "tsup.config.ts");
|
||||||
const clientDirExists = existsSync(clientIndexPath);
|
|
||||||
|
|
||||||
describe("CLI bundle output", () => {
|
describe("CLI bundle output", () => {
|
||||||
beforeAll(() => {
|
beforeAll(() => {
|
||||||
if (existsSync(bundlePath)) {
|
buildCliWithRealDashboardAssets();
|
||||||
return;
|
}, 300_000);
|
||||||
}
|
|
||||||
|
|
||||||
execSync("pnpm build", {
|
|
||||||
cwd: cliRoot,
|
|
||||||
stdio: "pipe",
|
|
||||||
timeout: 120_000,
|
|
||||||
});
|
|
||||||
}, 180_000);
|
|
||||||
|
|
||||||
it("dist/bin.js exists", () => {
|
it("dist/bin.js exists", () => {
|
||||||
expect(existsSync(bundlePath)).toBe(true);
|
expect(existsSync(bundlePath)).toBe(true);
|
||||||
@@ -46,8 +41,19 @@ describe("CLI bundle output", () => {
|
|||||||
expect(content).toContain("createServer");
|
expect(content).toContain("createServer");
|
||||||
});
|
});
|
||||||
|
|
||||||
it.skipIf(!clientDirExists)("dashboard client assets are included", () => {
|
it("dashboard client assets are included", () => {
|
||||||
expect(existsSync(clientIndexPath)).toBe(true);
|
expect(existsSync(clientIndexPath)).toBe(true);
|
||||||
|
|
||||||
|
const indexHtml = readClientIndexHtml();
|
||||||
|
expect(indexHtml).toContain("<script");
|
||||||
|
expect(indexHtml).toMatch(/assets\/.+-[A-Za-z0-9_-]+\.js/);
|
||||||
|
expect(indexHtml).toMatch(/assets\/vendor-react-[A-Za-z0-9_-]+\.js/);
|
||||||
|
expect(indexHtml).not.toContain(dashboardClientStubMarker);
|
||||||
|
|
||||||
|
const copiedAssetsDir = join(cliRoot, "dist", "client", "assets");
|
||||||
|
const copiedAssets = readdirSync(copiedAssetsDir);
|
||||||
|
expect(copiedAssets.some((file) => /^vendor-react-[A-Za-z0-9_-]+\.js$/.test(file))).toBe(true);
|
||||||
|
expect(copiedAssets.some((file) => /^vendor-xterm-[A-Za-z0-9_-]+\.js$/.test(file))).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("tsup config copies dashboard assets from dashboard/dist/client to dist/client", () => {
|
it("tsup config copies dashboard assets from dashboard/dist/client to dist/client", () => {
|
||||||
@@ -69,7 +75,7 @@ describe("CLI bundle output", () => {
|
|||||||
it("provides require via createRequire banner", () => {
|
it("provides require via createRequire banner", () => {
|
||||||
const content = readFileSync(bundlePath, "utf-8");
|
const content = readFileSync(bundlePath, "utf-8");
|
||||||
// Banner should inject createRequire for ESM CJS interop
|
// Banner should inject createRequire for ESM CJS interop
|
||||||
expect(content).toContain('createRequire');
|
expect(content).toContain("createRequire");
|
||||||
expect(content).toContain("import.meta.url");
|
expect(content).toContain("import.meta.url");
|
||||||
// Banner should be near the top of the file (after shebang)
|
// Banner should be near the top of the file (after shebang)
|
||||||
const shebangEnd = content.indexOf("\n");
|
const shebangEnd = content.indexOf("\n");
|
||||||
|
|||||||
24
packages/dashboard/app/__tests__/build-output-setup.ts
Normal file
24
packages/dashboard/app/__tests__/build-output-setup.ts
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import { execSync } from "node:child_process";
|
||||||
|
import { existsSync } from "node:fs";
|
||||||
|
import { resolve } from "node:path";
|
||||||
|
|
||||||
|
const dashboardRoot = resolve(__dirname, "../..");
|
||||||
|
|
||||||
|
export const dashboardClientDistDir = resolve(dashboardRoot, "dist/client");
|
||||||
|
export const dashboardClientAssetsDir = resolve(dashboardClientDistDir, "assets");
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build output verification must be deterministic: the suite owns artifact setup
|
||||||
|
* and never skip-gates coverage based on whatever dist/ state already exists.
|
||||||
|
*/
|
||||||
|
export function ensureDashboardClientBuild() {
|
||||||
|
execSync("pnpm build:client", {
|
||||||
|
cwd: dashboardRoot,
|
||||||
|
stdio: "pipe",
|
||||||
|
timeout: 180_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!existsSync(dashboardClientDistDir) || !existsSync(dashboardClientAssetsDir)) {
|
||||||
|
throw new Error("Dashboard client build did not produce dist/client assets");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,23 +1,28 @@
|
|||||||
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
import { readdirSync, readFileSync } from "node:fs";
|
||||||
import { resolve } from "node:path";
|
import { resolve } from "node:path";
|
||||||
import { describe, expect, test } from "vitest";
|
import { beforeAll, describe, expect, test } from "vitest";
|
||||||
|
import {
|
||||||
const distDir = resolve(__dirname, "../../dist/client");
|
dashboardClientAssetsDir,
|
||||||
const assetsDir = resolve(distDir, "assets");
|
dashboardClientDistDir,
|
||||||
const distExists = existsSync(distDir) && existsSync(assetsDir);
|
ensureDashboardClientBuild,
|
||||||
|
} from "./build-output-setup";
|
||||||
|
|
||||||
describe("mobile build output chunking", () => {
|
describe("mobile build output chunking", () => {
|
||||||
test.skipIf(!distExists)("creates vendor chunk files for core dependencies", () => {
|
beforeAll(() => {
|
||||||
const files = readdirSync(assetsDir);
|
ensureDashboardClientBuild();
|
||||||
|
}, 180_000);
|
||||||
|
|
||||||
|
test("creates vendor chunk files for core dependencies", () => {
|
||||||
|
const files = readdirSync(dashboardClientAssetsDir);
|
||||||
const jsFiles = files.filter((file) => file.endsWith(".js"));
|
const jsFiles = files.filter((file) => file.endsWith(".js"));
|
||||||
|
|
||||||
expect(jsFiles.length).toBeGreaterThan(2);
|
expect(jsFiles.length).toBeGreaterThan(2);
|
||||||
expect(jsFiles.some((file) => file.includes("vendor-react"))).toBe(true);
|
expect(jsFiles.some((file) => /^vendor-react-[A-Za-z0-9_-]+\.js$/.test(file))).toBe(true);
|
||||||
expect(jsFiles.some((file) => file.includes("vendor-xterm"))).toBe(true);
|
expect(jsFiles.some((file) => /^vendor-xterm-[A-Za-z0-9_-]+\.js$/.test(file))).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
test.skipIf(!distExists)("index.html references chunked asset scripts", () => {
|
test("index.html references chunked asset scripts", () => {
|
||||||
const indexHtml = readFileSync(resolve(distDir, "index.html"), "utf8");
|
const indexHtml = readFileSync(resolve(dashboardClientDistDir, "index.html"), "utf8");
|
||||||
|
|
||||||
expect(indexHtml).toContain("<script");
|
expect(indexHtml).toContain("<script");
|
||||||
expect(indexHtml).toMatch(/assets\/.+-[A-Za-z0-9_-]+\.js/);
|
expect(indexHtml).toMatch(/assets\/.+-[A-Za-z0-9_-]+\.js/);
|
||||||
|
|||||||
Reference in New Issue
Block a user