feat(FN-4222): add experiment-finalize CLI command and API route

Implements the `experiment-finalize` CLI command (FN-4222) and its companion dashboard API route, wiring the feature through the pi extension as a new callable tool. Includes the command implementation, extension tooling, API integration, and corresponding test coverage.

Fusion-Task-Id: FN-4222
This commit is contained in:
Fusion
2026-05-14 04:20:59 -07:00
committed by gsxdsm
parent 25234edc97
commit 81b1eae394
15 changed files with 732 additions and 2 deletions

View File

@@ -0,0 +1,88 @@
// @vitest-environment node
import { describe, expect, it, vi } from "vitest";
import express from "express";
import { get as performGet, request as performRequest } from "../test-request.js";
const previewPlanMock = vi.hoisted(() => vi.fn());
const finalizeMock = vi.hoisted(() => vi.fn());
const mockErrors = vi.hoisted(() => ({
StateError: class extends Error { code = "state_error" as const; },
NoKeptError: class extends Error { code = "no_kept_runs" as const; },
PlanError: class extends Error { code = "plan_error" as const; },
MergeBaseError: class extends Error { code = "merge_base_error" as const; },
BranchExistsError: class extends Error { code = "branch_exists" as const; },
CherryPickError: class extends Error {
code = "cherry_pick_conflict" as const;
groupId = "g-1";
commit = "abc";
stderr = "conflict";
},
}));
vi.mock("@fusion/engine", () => ({
defaultGitOps: vi.fn(() => ({})),
ExperimentFinalizeService: vi.fn(() => ({ previewPlan: previewPlanMock, finalize: finalizeMock })),
ExperimentFinalizeStateError: mockErrors.StateError,
ExperimentFinalizeNoKeptRunsError: mockErrors.NoKeptError,
ExperimentFinalizePlanError: mockErrors.PlanError,
ExperimentFinalizeMergeBaseError: mockErrors.MergeBaseError,
ExperimentFinalizeBranchExistsError: mockErrors.BranchExistsError,
ExperimentFinalizeCherryPickConflictError: mockErrors.CherryPickError,
}));
import { createExperimentRouter } from "../experiment-routes.js";
function appWithRouter() {
const app = express();
app.use(express.json());
app.use(createExperimentRouter({ getRootDir: () => process.cwd(), getExperimentSessionStore: () => ({}) } as any));
return app;
}
describe("experiment finalize routes", () => {
it("returns plan success", async () => {
previewPlanMock.mockResolvedValue({ sessionId: "EXP-1", groups: [], mergeBaseCommit: "mb" });
const response = await performGet(appWithRouter(), "/EXP-1/finalize/plan");
expect(response.status).toBe(200);
expect(response.body.plan.sessionId).toBe("EXP-1");
});
it("returns finalize success", async () => {
finalizeMock.mockResolvedValue({ sessionId: "EXP-1", branches: [] });
const response = await performRequest(appWithRouter(), "POST", "/EXP-1/finalize", JSON.stringify({ summary: "done" }), { "content-type": "application/json" });
expect(response.status).toBe(200);
expect(response.body.result.sessionId).toBe("EXP-1");
});
it("maps 404 for missing session", async () => {
previewPlanMock.mockRejectedValue(new mockErrors.StateError("session not found: EXP-x"));
const response = await performGet(appWithRouter(), "/EXP-x/finalize/plan");
expect(response.status).toBe(404);
});
it("maps plan error to 400", async () => {
finalizeMock.mockRejectedValue(new mockErrors.PlanError("bad plan"));
const response = await performRequest(appWithRouter(), "POST", "/EXP-1/finalize", "{}", { "content-type": "application/json" });
expect(response.status).toBe(400);
});
it("maps merge-base error to 422", async () => {
finalizeMock.mockRejectedValue(new mockErrors.MergeBaseError("no merge base"));
const response = await performRequest(appWithRouter(), "POST", "/EXP-1/finalize", "{}", { "content-type": "application/json" });
expect(response.status).toBe(422);
});
it("maps branch exists to 409", async () => {
finalizeMock.mockRejectedValue(new mockErrors.BranchExistsError("exists"));
const response = await performRequest(appWithRouter(), "POST", "/EXP-1/finalize", "{}", { "content-type": "application/json" });
expect(response.status).toBe(409);
});
it("maps cherry-pick conflict details to 422", async () => {
finalizeMock.mockRejectedValue(new mockErrors.CherryPickError("conflict"));
const response = await performRequest(appWithRouter(), "POST", "/EXP-1/finalize", "{}", { "content-type": "application/json" });
expect(response.status).toBe(422);
expect(response.body.details).toMatchObject({ code: "cherry_pick_conflict", groupId: "g-1", commit: "abc", stderr: "conflict" });
});
});