Files
fusion/packages/cli/src/__tests__/pr-command.test.ts
gsxdsm 841ff9d838 Address PR review feedback (#1461)
- pr-nodes: forward post-increment entity to deps.respond (responseRounds off-by-one); branch-group entity fallback now keys on task.branchContext.groupId, not task.id
- core: hoist shared autoMergeGateReason into @fusion/core pr-entity.ts; CLI + dashboard import the single definition (R13)
- cli: fn pr create also writes the unified PR entity (ensure → open), keeping legacy prInfo; resolveBranchHeadOid uses argv-safe execFileAsync (injection fix)
- changeset: fn-pr-commands bumped minor → major (breaking CLI removal per AGENTS.md)
- tests: bin.test.ts daemon fixture off reserved port 4040; lazy-loaded-views title 19 → 20 views

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 00:09:53 -07:00

258 lines
9.7 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
// The pr command resolves its store via project-context.resolveProject (same
// pattern branch-group.ts uses) and fires user-controlled releases via the
// engine's releaseHeldTaskByEvent primitive — the EXACT path the dashboard U7
// routes use (register-integrated-routers.ts). Both are mocked so each
// subcommand can be asserted to route to the right store/engine path.
vi.mock("../project-context.js", () => ({
resolveProject: vi.fn(),
}));
const releaseHeldTaskByEvent = vi.fn();
vi.mock("@fusion/engine", () => ({
releaseHeldTaskByEvent: (...args: unknown[]) => releaseHeldTaskByEvent(...args),
}));
// @fusion/dashboard is touched by runPrCreate; stub it so importing the module
// never pulls the heavy dashboard graph. `createPr` is controllable so the create
// path can be asserted to write the unified PR entity.
const createPr = vi.fn();
vi.mock("@fusion/dashboard", () => ({
GitHubClient: class {
createPr(...args: unknown[]) {
return createPr(...args);
}
},
generatePrMetadata: vi.fn(),
}));
// gh-cli helpers used by runPrCreate (repo resolution + auth gating).
vi.mock("@fusion/core/gh-cli", () => ({
classifyGhError: vi.fn(() => ({ message: "err" })),
getGhErrorMessage: vi.fn(() => "err"),
getCurrentRepo: vi.fn(() => ({ owner: "owner", repo: "repo" })),
isGhAuthenticated: vi.fn(() => true),
isGhAvailable: vi.fn(() => true),
}));
const { resolveProject } = await import("../project-context.js");
const {
runPrCreate,
runPrList,
runPrShow,
runPrApprove,
runPrRespond,
runPrRetry,
runPrMerge,
runPrClose,
runPrAutomerge,
} = await import("../commands/pr.js");
function makeEntity(overrides: Record<string, unknown> = {}) {
return {
id: "PR-001",
sourceType: "task",
sourceId: "FN-001",
repo: "owner/repo",
headBranch: "fusion/fn-001",
baseBranch: "main",
state: "open",
prNumber: 42,
prUrl: "https://github.com/owner/repo/pull/42",
autoMerge: false,
unverified: false,
responseRounds: 0,
mergeable: "clean",
reviewDecision: "APPROVED",
checksRollup: "success",
createdAt: 0,
updatedAt: 0,
...overrides,
};
}
describe("fn pr commands", () => {
const originalExit = process.exit;
let storeMock: Record<string, ReturnType<typeof vi.fn>>;
function mockStore(store: Record<string, ReturnType<typeof vi.fn>>) {
storeMock = store;
vi.mocked(resolveProject).mockResolvedValue({
store: store as never,
projectPath: "/tmp/project",
projectName: "proj",
} as never);
}
beforeEach(() => {
vi.clearAllMocks();
releaseHeldTaskByEvent.mockResolvedValue({ released: true, toColumn: "merged" });
process.exit = vi.fn(((code?: number) => {
throw new Error(`process.exit:${code ?? 0}`);
}) as typeof process.exit);
vi.spyOn(console, "log").mockImplementation(() => undefined);
vi.spyOn(console, "error").mockImplementation(() => undefined);
});
afterEach(() => {
process.exit = originalExit;
vi.restoreAllMocks();
});
// ── create → legacy prInfo + unified PR entity ──────────────────────────────
it("runPrCreate writes the unified PR entity (not just legacy prInfo)", async () => {
const prInfo = {
url: "https://github.com/owner/repo/pull/7",
number: 7,
status: "open",
title: "T",
headBranch: "fusion/fn-001",
baseBranch: "main",
commentCount: 0,
};
delete process.env.GITHUB_REPOSITORY;
createPr.mockResolvedValue(prInfo);
const getTask = vi.fn().mockResolvedValue({
id: "FN-001",
title: "Task one",
description: "do a thing",
column: "in-review",
prInfo: undefined,
});
const updatePrInfo = vi.fn();
const ensurePrEntityForSource = vi.fn().mockReturnValue(makeEntity({ id: "PR-NEW", state: "creating" }));
const updatePrEntity = vi.fn().mockReturnValue(makeEntity({ id: "PR-NEW" }));
const logEntry = vi.fn();
mockStore({ getTask, updatePrInfo, ensurePrEntityForSource, updatePrEntity, logEntry });
await runPrCreate("FN-001", { ai: false });
// Legacy field is still written (additive, migration-safe).
expect(updatePrInfo).toHaveBeenCalledWith("FN-001", prInfo);
// Unified entity is created via the same store path the pr-create node uses.
expect(ensurePrEntityForSource).toHaveBeenCalledWith({
sourceType: "task",
sourceId: "FN-001",
repo: "owner/repo",
headBranch: "fusion/fn-001",
baseBranch: "main",
state: "creating",
});
// …then flipped to open with the persisted PR number/url.
expect(updatePrEntity).toHaveBeenCalledWith("PR-NEW", {
state: "open",
prNumber: 7,
prUrl: "https://github.com/owner/repo/pull/7",
});
});
// ── read commands ──────────────────────────────────────────────────────────
it("runPrList reads active entities from the store", async () => {
const listActivePrEntities = vi.fn().mockReturnValue([makeEntity()]);
mockStore({ listActivePrEntities });
await runPrList();
expect(listActivePrEntities).toHaveBeenCalledOnce();
});
it("runPrShow reads the entity + thread states by id", async () => {
const getPrEntity = vi.fn().mockReturnValue(makeEntity());
const listPrThreadStates = vi.fn().mockReturnValue([]);
mockStore({ getPrEntity, listPrThreadStates });
await runPrShow("PR-001");
expect(getPrEntity).toHaveBeenCalledWith("PR-001");
expect(listPrThreadStates).toHaveBeenCalledWith("PR-001");
});
it("runPrShow exits when the entity is missing", async () => {
mockStore({ getPrEntity: vi.fn().mockReturnValue(null), listPrThreadStates: vi.fn() });
await expect(runPrShow("PR-404")).rejects.toThrow("process.exit:1");
});
// ── user-controlled release actions → releaseHeldTaskByEvent ────────────────
it.each([
{ fn: runPrApprove, eventTag: "pr-approve" },
{ fn: runPrRespond, eventTag: "pr-respond" },
{ fn: runPrRetry, eventTag: "pr-retry" },
{ fn: runPrMerge, eventTag: "pr-merge" },
{ fn: runPrClose, eventTag: "pr-close" },
])("routes $eventTag to releaseHeldTaskByEvent on the source task", async ({ fn, eventTag }) => {
const getPrEntity = vi.fn().mockReturnValue(makeEntity());
mockStore({ getPrEntity });
await fn("PR-001");
expect(getPrEntity).toHaveBeenCalledWith("PR-001");
expect(releaseHeldTaskByEvent).toHaveBeenCalledWith(storeMock, "FN-001", eventTag);
});
it("merge is rejected on a conflicting entity (no release fired)", async () => {
mockStore({ getPrEntity: vi.fn().mockReturnValue(makeEntity({ mergeable: "conflicting" })) });
await expect(runPrMerge("PR-001")).rejects.toThrow("process.exit:1");
expect(releaseHeldTaskByEvent).not.toHaveBeenCalled();
});
it("release actions are rejected on a terminal entity", async () => {
mockStore({ getPrEntity: vi.fn().mockReturnValue(makeEntity({ state: "merged" })) });
await expect(runPrApprove("PR-001")).rejects.toThrow("process.exit:1");
expect(releaseHeldTaskByEvent).not.toHaveBeenCalled();
});
it("release action exits non-zero when the release does not fire", async () => {
mockStore({ getPrEntity: vi.fn().mockReturnValue(makeEntity()) });
releaseHeldTaskByEvent.mockResolvedValue({ released: false, rejection: "not-external-event-hold" });
await expect(runPrApprove("PR-001")).rejects.toThrow("process.exit:1");
});
// ── automerge → store.updatePrEntity ────────────────────────────────────────
it("runPrAutomerge toggles entity.autoMerge via updatePrEntity", async () => {
const getPrEntity = vi.fn().mockReturnValue(makeEntity({ autoMerge: false }));
const updatePrEntity = vi.fn().mockReturnValue(makeEntity({ autoMerge: true }));
mockStore({ getPrEntity, updatePrEntity });
await runPrAutomerge("PR-001", undefined);
expect(updatePrEntity).toHaveBeenCalledWith("PR-001", { autoMerge: true });
});
it("runPrAutomerge honors an explicit off toggle", async () => {
const getPrEntity = vi.fn().mockReturnValue(makeEntity({ autoMerge: true }));
const updatePrEntity = vi.fn().mockReturnValue(makeEntity({ autoMerge: false }));
mockStore({ getPrEntity, updatePrEntity });
await runPrAutomerge("PR-001", false);
expect(updatePrEntity).toHaveBeenCalledWith("PR-001", { autoMerge: false });
});
});
// Surface-parity consistency test: every PR action the dashboard exposes (U7's
// register-pull-requests-routes.ts / register-integrated-routers.ts) must have a
// `fn pr` subcommand — a capability can't exist on one surface only.
describe("PR surface parity (dashboard ⊆ CLI)", () => {
const cliSource = readFileSync(resolve(__dirname, "../bin.ts"), "utf8");
// The dashboard's PR action set, derived from the U7 routes:
// GET / (list), GET /:id (show), POST :id/approve|merge|retry|close,
// POST :id/automerge, plus the create capability (pr-create node).
// pr-respond is the CLI-exposed rework round (same release authority).
const dashboardActions = [
"create",
"list",
"show",
"approve",
"retry",
"merge",
"close",
"automerge",
];
it.each(dashboardActions)("`fn pr %s` is wired in bin.ts", (action) => {
expect(cliSource).toContain(`case "${action}":`);
});
it("respond (review-response loop) is also exposed", () => {
expect(cliSource).toContain('case "respond":');
});
});