diff --git a/packages/cli/src/__tests__/bundle-output-helpers.ts b/packages/cli/src/__tests__/bundle-output-helpers.ts index 80bb094556..d0504a3222 100644 --- a/packages/cli/src/__tests__/bundle-output-helpers.ts +++ b/packages/cli/src/__tests__/bundle-output-helpers.ts @@ -1,6 +1,7 @@ import { execFileSync, execSync } from "node:child_process"; -import { existsSync, readFileSync } from "node:fs"; +import { existsSync, mkdirSync, readFileSync, rmSync } from "node:fs"; import { join } from "node:path"; +import { tmpdir } from "node:os"; export const cliRoot = join(__dirname, "..", ".."); export const workspaceRoot = join(cliRoot, "..", ".."); @@ -61,24 +62,74 @@ export function hasBuiltDashboardAssets(): boolean { /** * 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/. + * + * FNXC:TestInfrastructure 2026-07-13-12:20: + * bundle-output.test.ts and extension-integration.test.ts both call this helper, + * and Vitest runs test files in parallel (pool: "forks", fileParallelism: true). + * Without a cross-worker lock, two workers can simultaneously trigger vite/tsup + * builds that clean and write dist/client concurrently, causing ENOENT on + * content-hashed chunk files. The lock uses atomic mkdirSync — the winner builds, + * losers poll until the lock disappears then re-check hasBuiltDashboardAssets(). */ +const buildLockDir = join(tmpdir(), "fusion-cli-build-assets.lock"); +const BUILD_LOCK_TIMEOUT_MS = 300_000; + export function buildCliWithRealDashboardAssets() { if (hasBuiltDashboardAssets()) { return; } - runBuildCommand(`node ${join(workspaceRoot, "scripts", "ensure-test-artifacts.mjs")}`, workspaceRoot); - runBuildCommand("pnpm --filter @fusion/dashboard build:client", workspaceRoot); - runBuildCommand("pnpm build", cliRoot); - - if (hasBuiltDashboardAssets()) { - return; + // Try to acquire the lock atomically. mkdirSync throws EEXIST if the dir exists. + let acquiredLock = false; + try { + mkdirSync(buildLockDir); + acquiredLock = true; + } catch { + // Another worker holds the lock — wait for it. } - // Fallback for environments where build:client alone does not refresh the - // dashboard dist/client bundle consumed by the CLI copy step. - runBuildCommand("pnpm --filter @fusion/dashboard build", workspaceRoot); - runBuildCommand("pnpm build", cliRoot); + if (!acquiredLock) { + const deadline = Date.now() + BUILD_LOCK_TIMEOUT_MS; + while (existsSync(buildLockDir) && Date.now() < deadline) { + // Synchronous sleep without spawning a child process. + Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 500); + } + // The other worker should have finished building. Re-check assets. + if (hasBuiltDashboardAssets()) { + return; + } + // Lock is still held after timeout — do NOT build without owning the lock + // (that reintroduces the concurrent-build race). Throw so the test fails loudly. + if (existsSync(buildLockDir)) { + throw new Error( + `buildCliWithRealDashboardAssets: timed out after ${BUILD_LOCK_TIMEOUT_MS}ms waiting for another worker's build lock at ${buildLockDir}. ` + + `If the other worker crashed, remove the lock dir manually and rerun.`, + ); + } + // Lock disappeared but assets weren't built — try to acquire for our own build. + try { mkdirSync(buildLockDir); acquiredLock = true; } catch { + throw new Error(`buildCliWithRealDashboardAssets: could not acquire build lock at ${buildLockDir} after previous holder exited.`); + } + } + + try { + runBuildCommand(`node ${join(workspaceRoot, "scripts", "ensure-test-artifacts.mjs")}`, workspaceRoot); + runBuildCommand("pnpm --filter @fusion/dashboard build:client", workspaceRoot); + runBuildCommand("pnpm build", cliRoot); + + if (hasBuiltDashboardAssets()) { + return; + } + + // Fallback for environments where build:client alone does not refresh the + // dashboard dist/client bundle consumed by the CLI copy step. + runBuildCommand("pnpm --filter @fusion/dashboard build", workspaceRoot); + runBuildCommand("pnpm build", cliRoot); + } finally { + if (acquiredLock) { + rmSync(buildLockDir, { recursive: true, force: true }); + } + } } export function readClientIndexHtml() { diff --git a/packages/cli/src/__tests__/package-config.test.ts b/packages/cli/src/__tests__/package-config.test.ts index 105f453204..fc990738b5 100644 --- a/packages/cli/src/__tests__/package-config.test.ts +++ b/packages/cli/src/__tests__/package-config.test.ts @@ -220,7 +220,8 @@ describe("CLI package.json publishing config", () => { "cpu-features": "transitive dep of dockerode (via ssh2)", "@homebridge/node-pty-prebuilt-multiarch": "aliased as node-pty in dependencies; the alias entry satisfies the import", - "@fusion/core": "plugin-entry bundling external only; not a runtime dep of the CLI bin", + // FNXC:BuildConfig 2026-07-13-12:00: FN-7936 aliased @fusion/core to a runtime shim in bundled plugin outputs; it's no longer a tsup external, so this allowlist entry is stale. + // "@fusion/core": REMOVED — was "plugin-entry bundling external only; not a runtime dep of the CLI bin", "@fusion/engine": "plugin-entry bundling external only; not a runtime dep of the CLI bin", }; diff --git a/packages/desktop/scripts/workspace-tools.ts b/packages/desktop/scripts/workspace-tools.ts index 19ba8f8228..a3560512cf 100644 --- a/packages/desktop/scripts/workspace-tools.ts +++ b/packages/desktop/scripts/workspace-tools.ts @@ -168,6 +168,14 @@ export async function stageDesktopDeploy(): Promise { } export async function buildDashboardClient(): Promise { + // FNXC:DesktopBuild 2026-07-13-12:15: + // Clean the dashboard client output directory before building. Vite generates + // content-hashed chunk filenames; if a previous build left stale assets, the + // rollup write queue can hit ENOENT on stale-hashed paths. A clean outDir + // ensures the new build owns all filenames deterministically. + const clientDir = resolve(workspaceRoot, "packages", "dashboard", "dist", "client"); + await rm(clientDir, { recursive: true, force: true }); + // Desktop loads index.html via file:// from inside the asar, so absolute // asset paths (/assets/...) resolve to the filesystem root and fail. Build // with a relative base so the bundled HTML references ./assets/... instead.