Files
fusion/packages/cli/src/__tests__/experiment-finalize.test.ts
gsxdsm 97172fdcf2 fix(FN-7952): require PostgreSQL in CLI and desktop (#2110)
## Summary

CLI commands, daemon/dashboard startup, packaged desktop startup, and
live-data maintenance scripts now share the mandatory PostgreSQL
lifecycle. Operators no longer risk a command silently reading or
writing a disconnected SQLite shadow when PostgreSQL setup fails.

## Design decisions

- Every startup owner retains and awaits its PostgreSQL shutdown
callback, including partial-startup failure paths.
- CLI project context and lock-retry flows resolve through asynchronous
project stores.
- Maintenance scripts use the shared backend helper; explicit database
migration/inspection remains the only CLI surface allowed to read legacy
SQLite sources.

## Validation

- CLI and Desktop typechecks pass on the stacked branch.
- `pnpm test:gate` passes all 478 gate tests.
- This PR changes 54 files.

## Stack

- Depends on #2109, which depends on #2108.
- Bundled plugins and docs/release follow in later PRs.

Related: #2105


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **New Features**
* PostgreSQL is now the authoritative store for structured project and
task metadata.
* Projects can be recognized and initialized using
`.fusion/project.json`, without creating a legacy SQLite database.
  * CLI commands now retry transient PostgreSQL contention errors.

* **Bug Fixes**
* Improved cleanup when commands complete, fail, or run in the
background, preventing lingering resources.
  * Improved desktop, server, and session shutdown reliability.

* **Documentation**
* Updated storage and standalone binary guidance to reflect PostgreSQL
and legacy SQLite compatibility.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-07-14 23:18:55 -07:00

151 lines
6.3 KiB
TypeScript

import { beforeEach, describe, expect, it, vi } from "vitest";
import { writeFile, mkdtemp } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
function makeConstructibleMock<T extends (...args: any[]) => unknown>(impl?: T) {
const mock = vi.fn(function () {});
const originalMockImplementation = mock.mockImplementation.bind(mock);
const originalMockImplementationOnce = mock.mockImplementationOnce.bind(mock);
const wrap = (nextImpl: T) => function (this: unknown, ...args: Parameters<T>) {
return nextImpl(...args);
};
mock.mockImplementation = ((nextImpl: T) => originalMockImplementation(wrap(nextImpl))) as typeof mock.mockImplementation;
mock.mockImplementationOnce = ((nextImpl: T) => originalMockImplementationOnce(wrap(nextImpl))) as typeof mock.mockImplementationOnce;
if (impl) {
mock.mockImplementation(impl);
}
return mock;
}
const previewPlan = vi.fn();
const finalize = vi.fn();
const init = vi.fn();
const getExperimentSessionStore = vi.fn(() => ({}));
const backendShutdown = vi.fn(async () => undefined);
const mockErrors = vi.hoisted(() => ({
CherryPickConflictError: class extends Error {
code = "cherry_pick_conflict" as const;
groupId = "g1";
commit = "abc";
stderr = "conflict";
},
closeProjectStore: vi.fn(async () => undefined),
}));
vi.mock("@fusion/core", () => ({
createTaskStoreForBackend: vi.fn(async () => ({ taskStore: { init, getExperimentSessionStore }, shutdown: backendShutdown })),
TaskStore: makeConstructibleMock(() => ({ init, getExperimentSessionStore })),
}));
vi.mock("@fusion/engine", () => ({
defaultGitOps: vi.fn(() => ({})),
ExperimentFinalizeService: makeConstructibleMock(() => ({ previewPlan, finalize })),
ExperimentFinalizeStateError: class extends Error { code = "state_error" as const; },
ExperimentFinalizeNoKeptRunsError: class extends Error { code = "no_kept_runs" as const; },
ExperimentFinalizePlanError: class extends Error { code = "plan_error" as const; },
ExperimentFinalizeMergeBaseError: class extends Error { code = "merge_base_error" as const; },
ExperimentFinalizeBranchExistsError: class extends Error { code = "branch_exists" as const; },
ExperimentFinalizeCherryPickConflictError: mockErrors.CherryPickConflictError,
}));
vi.mock("../project-context.js", () => ({
resolveProject: vi.fn(async () => ({
projectId: "proj-1",
projectName: "demo",
projectPath: "/tmp/demo",
isRegistered: true,
store: {},
})),
closeProjectStore: mockErrors.closeProjectStore,
}));
import { runExperimentFinalize } from "../commands/experiment-finalize.js";
describe("runExperimentFinalize", () => {
beforeEach(() => {
vi.clearAllMocks();
init.mockResolvedValue(undefined);
getExperimentSessionStore.mockReturnValue({});
});
it("dry-run calls previewPlan and not finalize", async () => {
previewPlan.mockResolvedValue({ sessionId: "EXP-1", mergeBaseCommit: "mb", groups: [] });
const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
await runExperimentFinalize({ sessionId: "EXP-1", dryRun: true });
expect(getExperimentSessionStore).toHaveBeenCalled();
expect(previewPlan).toHaveBeenCalledWith({ sessionId: "EXP-1", integrationBranch: undefined });
expect(finalize).not.toHaveBeenCalled();
expect(logSpy).toHaveBeenCalled();
expect(backendShutdown).toHaveBeenCalledTimes(1);
});
it("plan-file loads override and passes to finalize", async () => {
finalize.mockResolvedValue({ sessionId: "EXP-1", branches: [] });
const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
const tempDir = await mkdtemp(join(tmpdir(), "fn-4222-"));
const planPath = join(tempDir, "plan.json");
await writeFile(planPath, JSON.stringify({ groups: [{ runRecordIds: ["RUN-1"] }] }), "utf8");
await runExperimentFinalize({ sessionId: "EXP-1", planFile: planPath });
expect(finalize).toHaveBeenCalledWith(expect.objectContaining({ planOverride: { groups: [{ runRecordIds: ["RUN-1"] }] } }));
logSpy.mockRestore();
});
it("cherry-pick conflict exits with code 6", async () => {
finalize.mockRejectedValue(new mockErrors.CherryPickConflictError("conflict"));
const exitSpy = vi.spyOn(process, "exit").mockImplementation(((code?: number) => { throw new Error(`exit:${code}`); }) as never);
const errSpy = vi.spyOn(console, "error").mockImplementation(() => {});
await expect(runExperimentFinalize({ sessionId: "EXP-1" })).rejects.toThrow("exit:6");
expect(errSpy).toHaveBeenCalled();
exitSpy.mockRestore();
errSpy.mockRestore();
});
it("json output is parseable", async () => {
previewPlan.mockResolvedValue({ sessionId: "EXP-1", mergeBaseCommit: "mb", groups: [] });
const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
await runExperimentFinalize({ sessionId: "EXP-1", dryRun: true, json: true });
expect(() => JSON.parse((logSpy.mock.calls[0] ?? ["{}"])[0] as string)).not.toThrow();
logSpy.mockRestore();
});
it("unexpected errors exit with code 1", async () => {
finalize.mockRejectedValue(new Error("boom"));
const exitSpy = vi.spyOn(process, "exit").mockImplementation(((code?: number) => { throw new Error(`exit:${code}`); }) as never);
await expect(runExperimentFinalize({ sessionId: "EXP-1" })).rejects.toThrow("exit:1");
exitSpy.mockRestore();
});
it("closes the resolver-owned project on success", async () => {
previewPlan.mockResolvedValue({ sessionId: "EXP-1", mergeBaseCommit: "mb", groups: [] });
const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
await runExperimentFinalize({ sessionId: "EXP-1", projectName: "demo", dryRun: true });
expect(mockErrors.closeProjectStore).toHaveBeenCalledTimes(1);
logSpy.mockRestore();
});
it("closes the resolver-owned project before backend startup fails", async () => {
const { createTaskStoreForBackend } = await import("@fusion/core");
vi.mocked(createTaskStoreForBackend).mockRejectedValueOnce(new Error("startup failed"));
const exitSpy = vi.spyOn(process, "exit").mockImplementation(((code?: number) => { throw new Error(`exit:${code}`); }) as never);
await expect(runExperimentFinalize({ sessionId: "EXP-1", projectName: "demo" })).rejects.toThrow("exit:1");
expect(mockErrors.closeProjectStore).toHaveBeenCalledTimes(1);
exitSpy.mockRestore();
});
});