FN-5814: add CLI onboarding gate coverage for TTY and skip paths

Expand CLI bin routing tests to cover onboarding auto-launch gating and bypass behavior.

- mock onboarding and TTY/core seams in bin command tests
- add coverage for interactive trigger vs non-TTY no-trigger paths
- verify serve/daemon bypass onboarding auto-launch
- verify skip controls via --skip-onboarding and FUSION_SKIP_ONBOARDING
- assert explicit onboard --force forwarding and marker-related invocation seam

Files changed:
 packages/cli/src/__tests__/bin.test.ts | 97 +++++++++++++++++++++++++++++++++-
 1 file changed, 96 insertions(+), 1 deletion(-)

Fusion-Task-Id: FN-5814

Fusion-Task-Lineage: 805bd91e-687d-4a67-821d-5f4982cfe96e
This commit is contained in:
gsxdsm
2026-06-01 17:40:55 -07:00
parent c676cbe12f
commit b6783dd33e

View File

@@ -1,5 +1,6 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { readFileSync } from "node:fs";
import { existsSync, mkdtempSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
const commandMocks = vi.hoisted(() => ({
@@ -8,6 +9,7 @@ const commandMocks = vi.hoisted(() => ({
runDaemon: vi.fn(),
runDesktop: vi.fn(),
runInit: vi.fn(),
runOnboard: vi.fn(),
runTaskCreate: vi.fn(),
runTaskList: vi.fn(),
@@ -112,6 +114,28 @@ const commandMocks = vi.hoisted(() => ({
runResearchRetry: vi.fn(),
}));
const onboardEnv = vi.hoisted(() => ({
centralDbPath: "/tmp/fusion-central.db",
}));
const ttyState = vi.hoisted(() => ({
isTTYAvailable: true,
}));
vi.mock("@fusion/core", async () => {
const actual = await vi.importActual<typeof import("@fusion/core")>("@fusion/core");
return {
...actual,
getDefaultCentralDbPath: vi.fn(() => onboardEnv.centralDbPath),
};
});
vi.mock("../commands/dashboard-tui/index.js", () => ({
isTTYAvailable: vi.fn(() => ttyState.isTTYAvailable),
}));
vi.mock("../commands/onboard.js", () => ({ runOnboard: commandMocks.runOnboard }));
vi.mock("../commands/dashboard.js", () => ({ runDashboard: commandMocks.runDashboard }));
vi.mock("../commands/serve.js", () => ({ runServe: commandMocks.runServe }));
vi.mock("../commands/daemon.js", () => ({ runDaemon: commandMocks.runDaemon }));
@@ -254,6 +278,7 @@ vi.mock("../commands/research.js", () => ({
const originalArgv = process.argv;
const originalExit = process.exit;
const originalPiPackageDir = process.env.PI_PACKAGE_DIR;
const originalSkipOnboardingEnv = process.env.FUSION_SKIP_ONBOARDING;
let importCounter = 0;
@@ -270,6 +295,9 @@ describe("bin command routing and fallbacks", () => {
beforeEach(() => {
vi.clearAllMocks();
delete process.env.PI_PACKAGE_DIR;
delete process.env.FUSION_SKIP_ONBOARDING;
ttyState.isTTYAvailable = true;
onboardEnv.centralDbPath = join(mkdtempSync(join(tmpdir(), "fn-bin-onboard-")), "fusion-central.db");
process.exit = vi.fn(((code?: number) => {
throw new Error(`process.exit:${code ?? 0}`);
}) as typeof process.exit);
@@ -284,6 +312,12 @@ describe("bin command routing and fallbacks", () => {
} else {
process.env.PI_PACKAGE_DIR = originalPiPackageDir;
}
if (originalSkipOnboardingEnv === undefined) {
delete process.env.FUSION_SKIP_ONBOARDING;
} else {
process.env.FUSION_SKIP_ONBOARDING = originalSkipOnboardingEnv;
}
});
it("configures pi to use .fusion as its project config directory", async () => {
@@ -616,6 +650,67 @@ describe("bin command routing and fallbacks", () => {
await expect(runBin(["goals", "bogus"])).rejects.toThrow("process.exit:1");
});
it("auto-launches onboarding for interactive commands when central DB is missing", async () => {
await runBin(["task", "list"]);
expect(commandMocks.runOnboard).toHaveBeenCalledTimes(1);
expect(commandMocks.runTaskList).toHaveBeenCalledTimes(1);
});
it("does not auto-launch onboarding for non-TTY invocations", async () => {
ttyState.isTTYAvailable = false;
await runBin(["task", "list"]);
expect(commandMocks.runOnboard).not.toHaveBeenCalled();
expect(commandMocks.runTaskList).toHaveBeenCalledTimes(1);
});
it.each(["serve", "daemon"])("skips auto-launch for %s command", async (command) => {
await runBin([command]);
expect(commandMocks.runOnboard).not.toHaveBeenCalled();
});
it("does not auto-launch when central DB and project DB already exist", async () => {
const projectDbPath = join(process.cwd(), ".fusion", "fusion.db");
mkdirSync(join(process.cwd(), ".fusion"), { recursive: true });
writeFileSync(onboardEnv.centralDbPath, "db");
writeFileSync(projectDbPath, "project-db");
await runBin(["task", "list"]);
expect(existsSync(onboardEnv.centralDbPath)).toBe(true);
expect(commandMocks.runOnboard).not.toHaveBeenCalled();
});
it("honors --skip-onboarding flag", async () => {
await runBin(["task", "list", "--skip-onboarding"]);
expect(commandMocks.runOnboard).not.toHaveBeenCalled();
expect(commandMocks.runTaskList).toHaveBeenCalledTimes(1);
});
it("honors FUSION_SKIP_ONBOARDING env var", async () => {
process.env.FUSION_SKIP_ONBOARDING = "1";
await runBin(["task", "list"]);
expect(commandMocks.runOnboard).not.toHaveBeenCalled();
});
it("passes --force to explicit onboard command and keeps marker semantics covered in onboard.test.ts", async () => {
await runBin(["onboard", "--force"]);
expect(commandMocks.runOnboard).toHaveBeenCalledWith({ force: true });
});
it("still invokes onboarding hook when marker would already be set (single-fire prompt behavior is covered in onboard.test.ts)", async () => {
await runBin(["task", "list"]);
expect(commandMocks.runOnboard).toHaveBeenCalledTimes(1);
});
it("routes daemon command with all flags", async () => {
await runBin(["daemon", "--port", "4040", "--host", "127.0.0.1", "--token", "fn_abc123", "--paused", "--token-only"]);