FN-8954: preserve CLI liveness during startup
Ensure CLI startup operations settle before process exit on supported Node runtimes. - Keep awaited QMD probes and ephemeral port selection ref'd until completion. - Add CLI process regressions for init persistence and exit code 13. - Declare the Node 22.4 runtime floor and extend boot smoke coverage. Files changed: .changeset/fn-8954-cli-exit-13.md | 7 ++ docs/testing.md | 4 +- package.json | 3 + packages/cli/agent-browser.mjs | 6 ++ packages/cli/bin.mjs | 7 ++ packages/cli/package.json | 3 + packages/cli/src/__tests__/ci-workflow.test.ts | 9 +++ packages/cli/src/__tests__/cli-exit-code.test.ts | 82 ++++++++++++++++++++++ packages/cli/src/__tests__/package-config.test.ts | 12 ++++ packages/cli/src/bin.ts | 6 ++ .../__tests__/postgres/embedded-free-port.test.ts | 37 ++++++++++ packages/core/src/memory/memory-backend.ts | 17 +++-- packages/core/src/postgres/embedded-lifecycle.ts | 16 +++-- scripts/boot-smoke.mjs | 62 +++++++++++----- 14 files changed, 244 insertions(+), 27 deletions(-) Fusion-Task-Id: FN-8954 Fusion-Task-Lineage: 05303d07-2662-48d5-a442-6d43fa0a4493 Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
7
.changeset/fn-8954-cli-exit-13.md
Normal file
7
.changeset/fn-8954-cli-exit-13.md
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
---
|
||||||
|
"@runfusion/fusion": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
summary: Fix CLI commands aborting mid-command on Node 22.4+ so fn init completes.
|
||||||
|
category: fix
|
||||||
|
dev: Keeps awaited startup handles ref'd and declares Node >=22.4.0 support.
|
||||||
@@ -6,7 +6,7 @@ This guide consolidates the detailed testing guidance moved from `AGENTS.md`.
|
|||||||
|
|
||||||
## The merge gate
|
## The merge gate
|
||||||
|
|
||||||
CI blocks PRs on exactly four checks (`.github/workflows/pr-checks.yml`): **Lint, Typecheck, Build, Gate**. The Gate job runs the boot smoke (`scripts/boot-smoke.mjs`: CLI `--help` + a real `fn serve` answering `GET /api/health`) and `pnpm test:gate`: 11 static policy validators, 22 curated `engine-core` files, two PostgreSQL canaries, four core unit files, then the CI-shape test. Everything else — the 4-way shards, the engine slow tier, the dashboard inventory guard — runs NON-BLOCKING in `.github/workflows/full-suite.yml` on push to main.
|
CI blocks PRs on exactly four checks (`.github/workflows/pr-checks.yml`): **Lint, Typecheck, Build, Gate**. The Gate job runs the boot smoke (`scripts/boot-smoke.mjs`: CLI `--help`, real `fn init` with a durable `.fusion/project.json` marker, then a real `fn serve` answering `GET /api/health`, all against one isolated home) and `pnpm test:gate`: 11 static policy validators, 22 curated `engine-core` files, two PostgreSQL canaries, four core unit files, then the CI-shape test. Everything else — the 4-way shards, the engine slow tier, the dashboard inventory guard — runs NON-BLOCKING in `.github/workflows/full-suite.yml` on push to main.
|
||||||
|
|
||||||
Gate membership is the explicit allow-list in `packages/engine/vitest.config.ts` (`engine-core` project). Admission requires evidence of value (the test catches real regressions); tests never graduate in by default. A flaky gate test is evicted by deleting its allow-list line — the eviction PR does not need the flaky test to pass. The whole `engine-core` project must stay under ~60s wall-clock.
|
Gate membership is the explicit allow-list in `packages/engine/vitest.config.ts` (`engine-core` project). Admission requires evidence of value (the test catches real regressions); tests never graduate in by default. A flaky gate test is evicted by deleting its allow-list line — the eviction PR does not need the flaky test to pass. The whole `engine-core` project must stay under ~60s wall-clock.
|
||||||
|
|
||||||
@@ -37,7 +37,7 @@ Use the narrowest command that exercises the behavior you changed, then broaden
|
|||||||
```bash
|
```bash
|
||||||
pnpm test # gate suite + changed-only affected tests (bounded; never full-suite)
|
pnpm test # gate suite + changed-only affected tests (bounded; never full-suite)
|
||||||
pnpm test:gate # the merge gate: curated engine-core suite + CI-shape test
|
pnpm test:gate # the merge gate: curated engine-core suite + CI-shape test
|
||||||
pnpm smoke:boot # boot smoke: CLI --help + real serve /api/health
|
pnpm smoke:boot # boot smoke: CLI --help + init marker + real serve /api/health
|
||||||
pnpm verify:fast # TEST-FREE: static check:* gates + bootstrap + scoped typecheck/build + CLI build + boot smoke
|
pnpm verify:fast # TEST-FREE: static check:* gates + bootstrap + scoped typecheck/build + CLI build + boot smoke
|
||||||
pnpm test:full # full workspace suite — explicit opt-in only
|
pnpm test:full # full workspace suite — explicit opt-in only
|
||||||
pnpm lint # lint all packages
|
pnpm lint # lint all packages
|
||||||
|
|||||||
@@ -130,5 +130,8 @@
|
|||||||
"typescript": "^5.7.0",
|
"typescript": "^5.7.0",
|
||||||
"typescript-eslint": "^8.66.0",
|
"typescript-eslint": "^8.66.0",
|
||||||
"yaml": "^2.8.3"
|
"yaml": "^2.8.3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=22.4.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,4 +5,10 @@ FNXC:AgentBrowserPackaging 2026-07-22-12:19:
|
|||||||
Publish this top-level bin shim with Fusion so npm can expose agent-browser and
|
Publish this top-level bin shim with Fusion so npm can expose agent-browser and
|
||||||
delegate to the pinned dependency's platform-aware launcher.
|
delegate to the pinned dependency's platform-aware launcher.
|
||||||
*/
|
*/
|
||||||
|
/*
|
||||||
|
* FNXC:CliAwaitLiveness 2026-08-11-09:17:
|
||||||
|
* This delegation intentionally retains top-level await. Replacing it with a
|
||||||
|
* forced successful exit could hide an unsettled delegated command and truncate
|
||||||
|
* its long-running process; the FN-8954 fix belongs in the stranded operation.
|
||||||
|
*/
|
||||||
await import("agent-browser/bin/agent-browser.js");
|
await import("agent-browser/bin/agent-browser.js");
|
||||||
|
|||||||
@@ -17,4 +17,11 @@ try {
|
|||||||
globalThis.process.exit(1);
|
globalThis.process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* FNXC:CliAwaitLiveness 2026-08-11-09:17:
|
||||||
|
* Retain top-level await so an unsettled CLI completion remains Node's loud,
|
||||||
|
* non-zero diagnostic rather than a silent partial success. FN-8954 fixes the
|
||||||
|
* awaited QMD child liveness at its source; this shim must not force exit 0,
|
||||||
|
* which would truncate `serve`, `dashboard`, or `daemon` as well.
|
||||||
|
*/
|
||||||
await import(pathToFileURL(distEntry).href);
|
await import(pathToFileURL(distEntry).href);
|
||||||
|
|||||||
@@ -121,5 +121,8 @@
|
|||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://github.com/Runfusion/Fusion"
|
"url": "https://github.com/Runfusion/Fusion"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=22.4.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -257,6 +257,15 @@ describe("Merge gate (.github/workflows/pr-checks.yml)", () => {
|
|||||||
expect(engineVitestConfigContent).toContain('name: "engine-core"');
|
expect(engineVitestConfigContent).toContain('name: "engine-core"');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/*
|
||||||
|
* FNXC:CliRuntimeContract 2026-08-11-09:30:
|
||||||
|
* The modern Node 24 default must remain above the CLI's 22.4 engine floor;
|
||||||
|
* FN-8954's gap was missing init coverage, not an outdated CI runtime.
|
||||||
|
*/
|
||||||
|
it("pins the composite action to the modern Node 24 default", () => {
|
||||||
|
expect(compositeAction.inputs?.["node-version"]?.default).toBe("24");
|
||||||
|
});
|
||||||
|
|
||||||
it("pins dependency bootstrap to frozen lockfile in every job", () => {
|
it("pins dependency bootstrap to frozen lockfile in every job", () => {
|
||||||
for (const jobName of ["lint", "typecheck", "build", "gate"]) {
|
for (const jobName of ["lint", "typecheck", "build", "gate"]) {
|
||||||
expect(findCompositeSetupStep(workflow.jobs?.[jobName]?.steps ?? [])).toBeDefined();
|
expect(findCompositeSetupStep(workflow.jobs?.[jobName]?.steps ?? [])).toBeDefined();
|
||||||
|
|||||||
82
packages/cli/src/__tests__/cli-exit-code.test.ts
Normal file
82
packages/cli/src/__tests__/cli-exit-code.test.ts
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
import { afterAll, describe, expect, it } from "vitest";
|
||||||
|
import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
|
||||||
|
import { spawnSync } from "node:child_process";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { join } from "node:path";
|
||||||
|
|
||||||
|
const workspaceRoot = join(import.meta.dirname, "..", "..", "..", "..");
|
||||||
|
const cliBin = join(workspaceRoot, "packages", "cli", "bin.mjs");
|
||||||
|
const builtEntry = join(workspaceRoot, "packages", "cli", "dist", "bin.js");
|
||||||
|
const tempDirs: string[] = [];
|
||||||
|
const isolatedHome = mkdtempSync(join(tmpdir(), "fn-cli-exit-home-"));
|
||||||
|
tempDirs.push(isolatedHome);
|
||||||
|
|
||||||
|
function runCli(args: string[]) {
|
||||||
|
const {
|
||||||
|
DATABASE_URL: _databaseUrl,
|
||||||
|
FUSION_NO_EMBEDDED_PG: _noEmbeddedPg,
|
||||||
|
NODE_ENV: _nodeEnv,
|
||||||
|
VITEST: _vitest,
|
||||||
|
...env
|
||||||
|
} = process.env;
|
||||||
|
const result = spawnSync(process.execPath, [cliBin, ...args], {
|
||||||
|
cwd: workspaceRoot,
|
||||||
|
env: {
|
||||||
|
...env,
|
||||||
|
HOME: isolatedHome,
|
||||||
|
/*
|
||||||
|
* FNXC:CliAwaitLiveness 2026-08-11-09:30:
|
||||||
|
* The subprocess must be production-shaped: inherited Vitest markers reject
|
||||||
|
* implicit global config directories before CentralCore can initialize.
|
||||||
|
*/
|
||||||
|
NODE_ENV: "production",
|
||||||
|
VITEST: undefined,
|
||||||
|
},
|
||||||
|
encoding: "utf8",
|
||||||
|
timeout: 180_000,
|
||||||
|
});
|
||||||
|
expect(result.error, `CLI timed out or could not start: ${result.stderr}`).toBeUndefined();
|
||||||
|
expect(result.stderr).not.toMatch(/Detected unsettled top-level await/);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function expectSuccessfulExit(result: ReturnType<typeof runCli>): void {
|
||||||
|
expect(result.status, `stdout:\n${result.stdout}\nstderr:\n${result.stderr}`).toBe(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
for (const path of tempDirs) rmSync(path, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
/*
|
||||||
|
* FNXC:CliAwaitLiveness 2026-08-11-09:17:
|
||||||
|
* This uses the built launcher in a real isolated process because Node's exit-13
|
||||||
|
* unsettled-top-level-await path cannot be reproduced by mocked in-process init.
|
||||||
|
* One HOME shares the cold embedded-PostgreSQL initialization across every surface.
|
||||||
|
*/
|
||||||
|
describe.skipIf(!existsSync(builtEntry))("built CLI completion", () => {
|
||||||
|
const projectDir = mkdtempSync(join(tmpdir(), "fn-cli-exit-project-"));
|
||||||
|
tempDirs.push(projectDir);
|
||||||
|
|
||||||
|
it("keeps the help fast path successful", () => {
|
||||||
|
expectSuccessfulExit(runCli(["--help"]));
|
||||||
|
}, 180_000);
|
||||||
|
|
||||||
|
it("completes init, persists the project marker, and does not exit 13", () => {
|
||||||
|
const result = runCli(["init", "--name", "exit-code-repro", "--path", projectDir]);
|
||||||
|
expectSuccessfulExit(result);
|
||||||
|
expect(result.status).not.toBe(13);
|
||||||
|
|
||||||
|
const identityPath = join(projectDir, ".fusion", "project.json");
|
||||||
|
expect(existsSync(identityPath)).toBe(true);
|
||||||
|
expect(JSON.parse(readFileSync(identityPath, "utf8"))).toMatchObject({ id: expect.any(String) });
|
||||||
|
}, 180_000);
|
||||||
|
|
||||||
|
it("lets project list and idempotent re-init complete with the marker preserved", () => {
|
||||||
|
expectSuccessfulExit(runCli(["project", "list"]));
|
||||||
|
expectSuccessfulExit(runCli(["init", "--name", "exit-code-repro", "--path", projectDir]));
|
||||||
|
expect(JSON.parse(readFileSync(join(projectDir, ".fusion", "project.json"), "utf8"))).toMatchObject({
|
||||||
|
id: expect.any(String),
|
||||||
|
});
|
||||||
|
}, 180_000);
|
||||||
|
});
|
||||||
@@ -93,6 +93,18 @@ describe("CLI package.json publishing config", () => {
|
|||||||
const pkg = loadPackageJson("cli");
|
const pkg = loadPackageJson("cli");
|
||||||
const prepackScript = loadCliPrepackScript();
|
const prepackScript = loadCliPrepackScript();
|
||||||
|
|
||||||
|
/*
|
||||||
|
* FNXC:CliRuntimeContract 2026-08-11-09:30:
|
||||||
|
* Node 22.4 is the supported floor because the CLI uses import attributes and
|
||||||
|
* `node:fs/promises` glob; declaring it prevents unsupported runtimes from
|
||||||
|
* silently reaching the Node 22.4+ exit-13 liveness path FN-8954 repaired.
|
||||||
|
*/
|
||||||
|
it("declares the supported Node runtime in both manifests", () => {
|
||||||
|
const rootPkg = loadRootPackageJson();
|
||||||
|
expect(pkg.engines?.node).toBe(">=22.4.0");
|
||||||
|
expect(rootPkg.engines?.node).toBe(pkg.engines.node);
|
||||||
|
});
|
||||||
|
|
||||||
it('has "bin" field with fn/fusion pointing to committed launcher', () => {
|
it('has "bin" field with fn/fusion pointing to committed launcher', () => {
|
||||||
expect(pkg.bin).toBeDefined();
|
expect(pkg.bin).toBeDefined();
|
||||||
expect(pkg.bin.fn).toBe("./bin.mjs");
|
expect(pkg.bin.fn).toBe("./bin.mjs");
|
||||||
|
|||||||
@@ -2362,6 +2362,12 @@ async function main() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* FNXC:CliAwaitLiveness 2026-08-11-09:17:
|
||||||
|
* Preserve this await and the skip-main build/test guard. A forced success exit
|
||||||
|
* would mask a non-settling command promise and could terminate long-running
|
||||||
|
* CLI modes before their intended shutdown path completes.
|
||||||
|
*/
|
||||||
if (process.env.FUSION_CLI_SKIP_MAIN !== "1") {
|
if (process.env.FUSION_CLI_SKIP_MAIN !== "1") {
|
||||||
await main();
|
await main();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import type { Server } from "node:net";
|
||||||
|
import { findFreePort } from "../../postgres/embedded-lifecycle.js";
|
||||||
|
|
||||||
|
describe("findFreePort", () => {
|
||||||
|
it("resolves to a positive ephemeral TCP port", async () => {
|
||||||
|
await expect(findFreePort()).resolves.toSatisfy(
|
||||||
|
(port: unknown) => typeof port === "number" && Number.isInteger(port) && port > 0,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the listener ref'd until the listen/close promise settles", async () => {
|
||||||
|
let unrefCalls = 0;
|
||||||
|
let closeCalls = 0;
|
||||||
|
const fakeServer = {
|
||||||
|
on: () => fakeServer,
|
||||||
|
listen: (_port: number, _host: string, onListening: () => void) => {
|
||||||
|
onListening();
|
||||||
|
return fakeServer;
|
||||||
|
},
|
||||||
|
address: () => ({ address: "127.0.0.1", family: "IPv4", port: 43123 }),
|
||||||
|
close: (callback?: () => void) => {
|
||||||
|
closeCalls += 1;
|
||||||
|
callback?.();
|
||||||
|
return fakeServer;
|
||||||
|
},
|
||||||
|
unref: () => {
|
||||||
|
unrefCalls += 1;
|
||||||
|
return fakeServer;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
await expect(findFreePort(() => fakeServer as unknown as Server)).resolves.toBe(43123);
|
||||||
|
expect(closeCalls).toBe(1);
|
||||||
|
expect(unrefCalls).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -30,7 +30,7 @@ const QMD_COLLECTION_PREFIX = "fusion-memory";
|
|||||||
type ExecFileAsync = (
|
type ExecFileAsync = (
|
||||||
file: string,
|
file: string,
|
||||||
args: readonly string[],
|
args: readonly string[],
|
||||||
options?: { cwd?: string; timeout?: number; maxBuffer?: number },
|
options?: { cwd?: string; timeout?: number; maxBuffer?: number; keepAlive?: boolean },
|
||||||
) => Promise<{ stdout: string; stderr: string }>;
|
) => Promise<{ stdout: string; stderr: string }>;
|
||||||
|
|
||||||
const qmdRefreshState = new Map<string, { lastStartedAt: number; inFlight?: Promise<void> }>();
|
const qmdRefreshState = new Map<string, { lastStartedAt: number; inFlight?: Promise<void> }>();
|
||||||
@@ -1120,10 +1120,16 @@ async function getDefaultExecFileAsync(): Promise<ExecFileAsync> {
|
|||||||
stdio: ["ignore", "pipe", "pipe"],
|
stdio: ["ignore", "pipe", "pipe"],
|
||||||
windowsHide: true,
|
windowsHide: true,
|
||||||
});
|
});
|
||||||
// FNXC:ProjectMemory 2026-07-08-00:00: unref synchronously right after spawn —
|
/*
|
||||||
// see the doc comment above this function for why this must NOT go through
|
* FNXC:CliAwaitLiveness 2026-08-11-09:17:
|
||||||
// promisify(execFile).
|
* Background QMD refreshes must unref their child, but an awaited availability
|
||||||
unrefQmdChildProcess(child);
|
* probe must retain its child and stdio until close/error settles. Otherwise a
|
||||||
|
* missing `qmd` leaves `fn init`'s top-level await pending with an empty loop,
|
||||||
|
* so Node exits 13 before durable project registration.
|
||||||
|
*/
|
||||||
|
if (!options?.keepAlive) {
|
||||||
|
unrefQmdChildProcess(child);
|
||||||
|
}
|
||||||
|
|
||||||
let stdout = "";
|
let stdout = "";
|
||||||
let stderr = "";
|
let stderr = "";
|
||||||
@@ -1305,6 +1311,7 @@ export async function isQmdAvailable(): Promise<boolean> {
|
|||||||
await execFileAsync("qmd", ["--help"], {
|
await execFileAsync("qmd", ["--help"], {
|
||||||
timeout: 3000,
|
timeout: 3000,
|
||||||
maxBuffer: 128 * 1024,
|
maxBuffer: 128 * 1024,
|
||||||
|
keepAlive: true,
|
||||||
});
|
});
|
||||||
return true;
|
return true;
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
@@ -1230,11 +1230,19 @@ async function isAlreadyRunning(
|
|||||||
* is tiny. For the zero-config default this is acceptable; callers needing a
|
* is tiny. For the zero-config default this is acceptable; callers needing a
|
||||||
* fixed port can pass `options.port`.
|
* fixed port can pass `options.port`.
|
||||||
*/
|
*/
|
||||||
function findFreePort(): Promise<number> {
|
/*
|
||||||
|
* FNXC:CliAwaitLiveness 2026-08-11-09:17:
|
||||||
|
* Keep this temporary listener ref'd until its listen/close promise settles.
|
||||||
|
* An unref'd sole handle lets Node drain the event loop beneath a pending top-level
|
||||||
|
* await, terminating `fn init` with exit 13 before project registration. Exporting
|
||||||
|
* the helper provides the direct test seam for this listener-liveness contract.
|
||||||
|
*/
|
||||||
|
export function findFreePort(createServerFn: () => Server = createServer): Promise<number> {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const srv: Server = createServer();
|
const srv = createServerFn();
|
||||||
srv.unref();
|
srv.on("error", (error) => {
|
||||||
srv.on("error", reject);
|
srv.close(() => reject(error));
|
||||||
|
});
|
||||||
srv.listen(0, "127.0.0.1", () => {
|
srv.listen(0, "127.0.0.1", () => {
|
||||||
const addr = srv.address();
|
const addr = srv.address();
|
||||||
if (addr && typeof addr === "object") {
|
if (addr && typeof addr === "object") {
|
||||||
|
|||||||
@@ -29,7 +29,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { spawn, spawnSync } from "node:child_process";
|
import { spawn, spawnSync } from "node:child_process";
|
||||||
import { mkdtempSync, rmSync } from "node:fs";
|
import { existsSync, mkdtempSync, rmSync } from "node:fs";
|
||||||
import { createServer } from "node:net";
|
import { createServer } from "node:net";
|
||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
@@ -188,6 +188,50 @@ async function bootAndVerify(attempt, registerCleanup) {
|
|||||||
const isolatedHome = mkdtempSync(path.join(tmpdir(), "fusion-boot-smoke-home-"));
|
const isolatedHome = mkdtempSync(path.join(tmpdir(), "fusion-boot-smoke-home-"));
|
||||||
const isolatedProject = mkdtempSync(path.join(tmpdir(), "fusion-boot-smoke-project-"));
|
const isolatedProject = mkdtempSync(path.join(tmpdir(), "fusion-boot-smoke-project-"));
|
||||||
let stderrBuf = "";
|
let stderrBuf = "";
|
||||||
|
const childEnv = {
|
||||||
|
...process.env,
|
||||||
|
HOME: isolatedHome,
|
||||||
|
FUSION_SKIP_ONBOARDING: "1",
|
||||||
|
// FNXC:BackendFlip 2026-06-26-14:55:
|
||||||
|
// Force the smoke to exercise the embedded PostgreSQL backend. Unset
|
||||||
|
// DATABASE_URL so a developer's external DB connection never leaks in
|
||||||
|
// (the smoke must prove the zero-config embedded path boots). Unset
|
||||||
|
// FUSION_NO_EMBEDDED_PG so the smoke cannot be opted out by an
|
||||||
|
// inherited env var — the embedded default is what the gate must prove.
|
||||||
|
DATABASE_URL: undefined,
|
||||||
|
FUSION_NO_EMBEDDED_PG: undefined,
|
||||||
|
// Make sure nothing inherits a PORT that fights the explicit flag.
|
||||||
|
PORT: undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
registerCleanup(() => {
|
||||||
|
removeTempDir(isolatedHome);
|
||||||
|
removeTempDir(isolatedProject);
|
||||||
|
});
|
||||||
|
|
||||||
|
/*
|
||||||
|
* FNXC:CliAwaitLiveness 2026-08-11-09:30:
|
||||||
|
* CI already uses Node 24; it missed FN-8954 because help-only smoke never
|
||||||
|
* ran `fn init`. Share this attempt's isolated HOME with serve so cold initdb
|
||||||
|
* is paid once while the check detects exit 13 before project registration.
|
||||||
|
*/
|
||||||
|
const init = spawnSync(process.execPath, [cliBin, "init", "--name", "boot-smoke", "--path", isolatedProject], {
|
||||||
|
cwd: isolatedProject,
|
||||||
|
env: childEnv,
|
||||||
|
encoding: "utf8",
|
||||||
|
timeout: HEALTH_TIMEOUT_MS,
|
||||||
|
});
|
||||||
|
const initOutput = `${init.stdout ?? ""}${init.stderr ?? ""}`;
|
||||||
|
if (init.error || init.status !== 0 || /Detected unsettled top-level await/.test(initOutput)) {
|
||||||
|
fail(
|
||||||
|
`\`fn init\` exited ${init.status ?? `signal ${init.signal ?? "timeout"}`}`,
|
||||||
|
`${init.error?.message ? `${init.error.message}\n` : ""}${initOutput}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!existsSync(path.join(isolatedProject, ".fusion", "project.json"))) {
|
||||||
|
fail("`fn init` did not write .fusion/project.json", initOutput);
|
||||||
|
}
|
||||||
|
console.log("boot-smoke: `fn init` OK");
|
||||||
|
|
||||||
const child = spawn(
|
const child = spawn(
|
||||||
process.execPath,
|
process.execPath,
|
||||||
@@ -203,21 +247,7 @@ async function bootAndVerify(attempt, registerCleanup) {
|
|||||||
],
|
],
|
||||||
{
|
{
|
||||||
cwd: isolatedProject,
|
cwd: isolatedProject,
|
||||||
env: {
|
env: childEnv,
|
||||||
...process.env,
|
|
||||||
HOME: isolatedHome,
|
|
||||||
FUSION_SKIP_ONBOARDING: "1",
|
|
||||||
// FNXC:BackendFlip 2026-06-26-14:55:
|
|
||||||
// Force the smoke to exercise the embedded PostgreSQL backend. Unset
|
|
||||||
// DATABASE_URL so a developer's external DB connection never leaks in
|
|
||||||
// (the smoke must prove the zero-config embedded path boots). Unset
|
|
||||||
// FUSION_NO_EMBEDDED_PG so the smoke cannot be opted out by an
|
|
||||||
// inherited env var — the embedded default is what the gate must prove.
|
|
||||||
DATABASE_URL: undefined,
|
|
||||||
FUSION_NO_EMBEDDED_PG: undefined,
|
|
||||||
// Make sure nothing inherits a PORT that fights the explicit flag.
|
|
||||||
PORT: undefined,
|
|
||||||
},
|
|
||||||
stdio: ["ignore", "pipe", "pipe"],
|
stdio: ["ignore", "pipe", "pipe"],
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user