feat(FN-4766): merge fusion/fn-4766
This commit is contained in:
@@ -18,7 +18,7 @@ vi.mock("../../api", () => ({
|
||||
createPr: mocks.createPr,
|
||||
}));
|
||||
|
||||
const metadata = { title: "AI title", body: "AI body", templateUsed: true };
|
||||
const metadata = { title: "AI title", body: "## Summary\n\n## Changes\n\n## Testing\n\n## Linked Task\n", templateUsed: true };
|
||||
const preflight = {
|
||||
branchOnRemote: true,
|
||||
commitsPresent: true,
|
||||
@@ -67,7 +67,7 @@ describe("PrCreateModal", () => {
|
||||
expect(screen.queryByRole("dialog")).toBeNull();
|
||||
});
|
||||
|
||||
it("loads metadata/preflight/options on open", async () => {
|
||||
it("loads metadata/preflight/options on open and renders key sections", async () => {
|
||||
renderModal();
|
||||
await waitFor(() => {
|
||||
expect(mocks.generatePrMetadata).toHaveBeenCalledTimes(1);
|
||||
@@ -75,7 +75,23 @@ describe("PrCreateModal", () => {
|
||||
expect(mocks.fetchPrOptions).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
expect(await screen.findByDisplayValue("AI title")).toBeInTheDocument();
|
||||
expect(await screen.findByDisplayValue("AI body")).toBeInTheDocument();
|
||||
const bodyInput = (await screen.findByLabelText(/body/i)) as HTMLTextAreaElement;
|
||||
expect(bodyInput).toBeInTheDocument();
|
||||
expect(bodyInput.value).toContain("## Summary");
|
||||
expect(bodyInput.value).toContain("## Changes");
|
||||
expect(bodyInput.value).toContain("## Testing");
|
||||
expect(bodyInput.value).toContain("## Linked Task");
|
||||
|
||||
expect(screen.getByText(/pre-flight checks/i)).toBeInTheDocument();
|
||||
expect(screen.getByLabelText(/base branch/i)).toBeInTheDocument();
|
||||
expect(screen.getByLabelText(/create as draft/i)).toBeInTheDocument();
|
||||
expect(screen.getByText("Reviewers")).toBeInTheDocument();
|
||||
expect(screen.getByText("Assignees")).toBeInTheDocument();
|
||||
expect(screen.getByText("Labels")).toBeInTheDocument();
|
||||
expect(screen.getByText(/diff & commit preview/i)).toBeInTheDocument();
|
||||
expect(screen.getByText("Commits")).toBeInTheDocument();
|
||||
expect(screen.getByText("Changed files")).toBeInTheDocument();
|
||||
expect(screen.getByText(/using/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("regenerates and reverts AI content", async () => {
|
||||
@@ -104,6 +120,8 @@ describe("PrCreateModal", () => {
|
||||
const { onCreated, addToast, onClose } = renderModal();
|
||||
await screen.findByDisplayValue("AI title");
|
||||
|
||||
fireEvent.change(screen.getByLabelText(/base branch/i), { target: { value: "develop" } });
|
||||
fireEvent.click(screen.getByLabelText(/create as draft/i));
|
||||
fireEvent.change(screen.getByPlaceholderText("Filter reviewers"), { target: { value: "rev" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: /reviewer 1/i }));
|
||||
fireEvent.change(screen.getByPlaceholderText("Filter assignees"), { target: { value: "assign" } });
|
||||
@@ -111,9 +129,20 @@ describe("PrCreateModal", () => {
|
||||
fireEvent.change(screen.getByPlaceholderText("Filter labels"), { target: { value: "bug" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "bug" }));
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Create PR" }));
|
||||
const submitButton = screen.getByRole("button", { name: "Create draft PR" });
|
||||
expect(submitButton).toHaveClass("btn-primary");
|
||||
fireEvent.click(submitButton);
|
||||
|
||||
await waitFor(() => expect(mocks.createPr).toHaveBeenCalledTimes(1));
|
||||
expect(mocks.createPr.mock.calls[0][1]).toMatchObject({ reviewers: ["rev1"], assignees: ["assign1"], labels: ["bug"] });
|
||||
expect(mocks.createPr.mock.calls[0][1]).toMatchObject({
|
||||
title: "AI title",
|
||||
body: metadata.body.trim(),
|
||||
base: "develop",
|
||||
draft: true,
|
||||
reviewers: ["rev1"],
|
||||
assignees: ["assign1"],
|
||||
labels: ["bug"],
|
||||
});
|
||||
expect(onCreated).toHaveBeenCalled();
|
||||
expect(addToast).toHaveBeenCalledWith("Created PR #12", "success");
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
|
||||
@@ -28,6 +28,13 @@ const mockPrInfo = {
|
||||
lastCommentAt: "2026-01-01T00:00:00.000Z",
|
||||
};
|
||||
|
||||
const checksByRollup = {
|
||||
success: [{ name: "build", required: true, state: "success", detailsUrl: "https://ci.example/build" }],
|
||||
failure: [{ name: "build", required: true, state: "failure", detailsUrl: "https://ci.example/build" }],
|
||||
pending: [{ name: "build", required: true, state: "pending", detailsUrl: "https://ci.example/build" }],
|
||||
none: [],
|
||||
} as const;
|
||||
|
||||
describe("PrPanel", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
@@ -145,6 +152,42 @@ describe("PrPanel", () => {
|
||||
expect(await screen.findByText(/No checks reported yet/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ status: "open", rollup: "success", expectMerge: true, expectReadonly: false, expectChecksVisible: true },
|
||||
{ status: "draft", rollup: "failure", expectMerge: false, expectReadonly: true, expectChecksVisible: true },
|
||||
{ status: "merged", rollup: "pending", expectMerge: false, expectReadonly: true, expectChecksVisible: false },
|
||||
{ status: "closed", rollup: "none", expectMerge: false, expectReadonly: true, expectChecksVisible: false },
|
||||
])("handles state=$status and checks=$rollup", async ({ status, rollup, expectMerge, expectReadonly, expectChecksVisible }) => {
|
||||
(refreshPrStatus as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
prInfo: { ...mockPrInfo, status, draft: status === "draft" },
|
||||
checks: checksByRollup[rollup],
|
||||
reviewDecision: null,
|
||||
blockingReasons: status === "open" && rollup === "success" ? [] : ["waiting"],
|
||||
mergeReady: status === "open" && rollup === "success",
|
||||
});
|
||||
|
||||
render(<PrPanel taskId="FN-001" prInfo={{ ...mockPrInfo, status, draft: status === "draft" }} prAuthAvailable={true} onPrUpdated={mockOnPrUpdated} addToast={mockAddToast} />);
|
||||
fireEvent.click(screen.getByTitle("Refresh PR status"));
|
||||
|
||||
await screen.findByText(/View on GitHub/i);
|
||||
if (expectChecksVisible) {
|
||||
expect(screen.getByText(/passing, .*failing, .*pending/i)).toBeInTheDocument();
|
||||
} else {
|
||||
expect(screen.queryByText(/passing, .*failing, .*pending/i)).toBeNull();
|
||||
}
|
||||
|
||||
const mergeButton = screen.queryByRole("button", { name: /merge pull request/i });
|
||||
if (expectMerge) {
|
||||
expect(mergeButton).toBeInTheDocument();
|
||||
} else {
|
||||
expect(mergeButton).toBeNull();
|
||||
}
|
||||
|
||||
if (expectReadonly) {
|
||||
expect(screen.queryByRole("combobox")).toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
it("renders review decision states", async () => {
|
||||
(refreshPrStatus as ReturnType<typeof vi.fn>)
|
||||
.mockResolvedValueOnce({ prInfo: mockPrInfo, checks: [], reviewDecision: "CHANGES_REQUESTED", blockingReasons: [] })
|
||||
@@ -192,6 +235,29 @@ describe("PrPanel", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("shows reviewer feedback hint when changes are requested and task is in todo", async () => {
|
||||
(fetchPrReviews as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
snapshot: {
|
||||
decision: "CHANGES_REQUESTED",
|
||||
items: [
|
||||
{
|
||||
id: "gh-review-1",
|
||||
state: "CHANGES_REQUESTED",
|
||||
body: "Please split this function",
|
||||
author: { login: "reviewer" },
|
||||
htmlUrl: "https://github.com/owner/repo/pull/42#review-1",
|
||||
},
|
||||
],
|
||||
},
|
||||
comments: [],
|
||||
});
|
||||
|
||||
render(<PrPanel taskId="FN-001" taskColumn="todo" prInfo={mockPrInfo} prAuthAvailable={true} onPrUpdated={mockOnPrUpdated} addToast={mockAddToast} />);
|
||||
|
||||
expect(await screen.findByText(/Auto-moved to Todo/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/Please split this function/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows conflict hint from blocking reasons after refresh", async () => {
|
||||
(refreshPrStatus as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
prInfo: mockPrInfo,
|
||||
|
||||
117
packages/dashboard/src/__tests__/pr-routes-auto-merge.test.ts
Normal file
117
packages/dashboard/src/__tests__/pr-routes-auto-merge.test.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
// @vitest-environment node
|
||||
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import type { Task, TaskStore } from "@fusion/core";
|
||||
import { createServer } from "../server.js";
|
||||
import { request as performRequest } from "../test-request.js";
|
||||
|
||||
function createTask(overrides: Partial<Task> = {}): Task {
|
||||
return {
|
||||
id: "FN-001",
|
||||
title: "Task",
|
||||
description: "desc",
|
||||
column: "in-review",
|
||||
status: "in-review",
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
prInfo: {
|
||||
url: "https://github.com/owner/repo/pull/1",
|
||||
number: 1,
|
||||
status: "open",
|
||||
title: "PR",
|
||||
headBranch: "fusion/fn-001",
|
||||
baseBranch: "main",
|
||||
commentCount: 0,
|
||||
autoMergeOnGreen: false,
|
||||
autoMergeStrategy: "squash",
|
||||
lastMergeError: "old",
|
||||
lastMergeErrorAt: new Date().toISOString(),
|
||||
},
|
||||
...overrides,
|
||||
} as Task;
|
||||
}
|
||||
|
||||
function createStore(task: Task): TaskStore {
|
||||
return {
|
||||
getTask: vi.fn().mockResolvedValue(task),
|
||||
updatePrInfo: vi.fn().mockResolvedValue(undefined),
|
||||
listTasks: vi.fn().mockResolvedValue([]),
|
||||
createTask: vi.fn(),
|
||||
moveTask: vi.fn(),
|
||||
updateTask: vi.fn(),
|
||||
deleteTask: vi.fn(),
|
||||
mergeTask: vi.fn(),
|
||||
archiveTask: vi.fn(),
|
||||
unarchiveTask: vi.fn(),
|
||||
getSettings: vi.fn().mockResolvedValue({}),
|
||||
updateSettings: vi.fn(),
|
||||
logEntry: vi.fn().mockResolvedValue(undefined),
|
||||
getAgentLogs: vi.fn().mockResolvedValue([]),
|
||||
addSteeringComment: vi.fn(),
|
||||
updateIssueInfo: vi.fn().mockResolvedValue(undefined),
|
||||
getRootDir: vi.fn().mockReturnValue("/tmp/project"),
|
||||
getFusionDir: vi.fn().mockReturnValue("/tmp/project/.fusion"),
|
||||
getDatabase: vi.fn().mockReturnValue({
|
||||
exec: vi.fn(),
|
||||
prepare: vi.fn().mockReturnValue({ run: vi.fn().mockReturnValue({ changes: 0 }), get: vi.fn(), all: vi.fn().mockReturnValue([]) }),
|
||||
}),
|
||||
getMissionStore: vi.fn().mockReturnValue({ listMissions: vi.fn().mockReturnValue([]) }),
|
||||
on: vi.fn(),
|
||||
off: vi.fn(),
|
||||
} as unknown as TaskStore;
|
||||
}
|
||||
|
||||
describe("PR auto-merge routes", () => {
|
||||
it("updates auto-merge settings and clears stale merge error fields", async () => {
|
||||
const task = createTask();
|
||||
const store = createStore(task);
|
||||
const app = createServer(store);
|
||||
|
||||
const response = await performRequest(
|
||||
app,
|
||||
"POST",
|
||||
"/api/tasks/FN-001/pr/auto-merge",
|
||||
JSON.stringify({ enabled: true, strategy: "rebase" }),
|
||||
{ "content-type": "application/json" },
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(store.updatePrInfo).toHaveBeenCalledWith(
|
||||
"FN-001",
|
||||
expect.objectContaining({
|
||||
autoMergeOnGreen: true,
|
||||
autoMergeStrategy: "rebase",
|
||||
lastMergeError: undefined,
|
||||
lastMergeErrorAt: undefined,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects invalid auto-merge strategy", async () => {
|
||||
const app = createServer(createStore(createTask()));
|
||||
const response = await performRequest(
|
||||
app,
|
||||
"POST",
|
||||
"/api/tasks/FN-001/pr/auto-merge",
|
||||
JSON.stringify({ enabled: true, strategy: "invalid" }),
|
||||
{ "content-type": "application/json" },
|
||||
);
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.error).toContain("Invalid auto-merge strategy");
|
||||
});
|
||||
|
||||
it("rejects invalid merge method on merge endpoint", async () => {
|
||||
const app = createServer(createStore(createTask()));
|
||||
const response = await performRequest(
|
||||
app,
|
||||
"POST",
|
||||
"/api/tasks/FN-001/pr/merge",
|
||||
JSON.stringify({ method: "invalid" }),
|
||||
{ "content-type": "application/json" },
|
||||
);
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.error).toContain("Invalid merge method");
|
||||
});
|
||||
});
|
||||
94
packages/dashboard/src/__tests__/pr-routes.contract.test.ts
Normal file
94
packages/dashboard/src/__tests__/pr-routes.contract.test.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
// @vitest-environment node
|
||||
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import type { Task, TaskStore } from "@fusion/core";
|
||||
import { createServer } from "../server.js";
|
||||
import { get as performGet, request as performRequest } from "../test-request.js";
|
||||
|
||||
function createTask(overrides: Partial<Task> = {}): Task {
|
||||
return {
|
||||
id: "FN-001",
|
||||
title: "Task",
|
||||
description: "desc",
|
||||
column: "in-review",
|
||||
status: "in-review",
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
prInfo: {
|
||||
url: "https://github.com/owner/repo/pull/1",
|
||||
number: 1,
|
||||
status: "open",
|
||||
title: "PR",
|
||||
headBranch: "fusion/fn-001",
|
||||
baseBranch: "main",
|
||||
commentCount: 0,
|
||||
},
|
||||
comments: [],
|
||||
...overrides,
|
||||
} as Task;
|
||||
}
|
||||
|
||||
function createStore(task: Task): TaskStore {
|
||||
return {
|
||||
getTask: vi.fn().mockResolvedValue(task),
|
||||
listTasks: vi.fn().mockResolvedValue([]),
|
||||
createTask: vi.fn(),
|
||||
moveTask: vi.fn(),
|
||||
updateTask: vi.fn(),
|
||||
deleteTask: vi.fn(),
|
||||
mergeTask: vi.fn(),
|
||||
archiveTask: vi.fn(),
|
||||
unarchiveTask: vi.fn(),
|
||||
getSettings: vi.fn().mockResolvedValue({}),
|
||||
updateSettings: vi.fn(),
|
||||
logEntry: vi.fn().mockResolvedValue(undefined),
|
||||
getAgentLogs: vi.fn().mockResolvedValue([]),
|
||||
addSteeringComment: vi.fn(),
|
||||
updatePrInfo: vi.fn().mockResolvedValue(undefined),
|
||||
updateIssueInfo: vi.fn().mockResolvedValue(undefined),
|
||||
getRootDir: vi.fn().mockReturnValue("/tmp/project"),
|
||||
getFusionDir: vi.fn().mockReturnValue("/tmp/project/.fusion"),
|
||||
getDatabase: vi.fn().mockReturnValue({
|
||||
exec: vi.fn(),
|
||||
prepare: vi.fn().mockReturnValue({ run: vi.fn().mockReturnValue({ changes: 0 }), get: vi.fn(), all: vi.fn().mockReturnValue([]) }),
|
||||
}),
|
||||
getMissionStore: vi.fn().mockReturnValue({ listMissions: vi.fn().mockReturnValue([]) }),
|
||||
on: vi.fn(),
|
||||
off: vi.fn(),
|
||||
} as unknown as TaskStore;
|
||||
}
|
||||
|
||||
describe("PR routes contract", () => {
|
||||
it("returns structured 404 when task has no PR for status/refresh/reviews", async () => {
|
||||
const app = createServer(createStore(createTask({ prInfo: undefined })));
|
||||
|
||||
const statusRes = await performGet(app, "/api/tasks/FN-001/pr/status");
|
||||
const refreshRes = await performRequest(app, "POST", "/api/tasks/FN-001/pr/refresh", "{}", { "content-type": "application/json" });
|
||||
const reviewsRes = await performGet(app, "/api/tasks/FN-001/pr/reviews");
|
||||
|
||||
for (const res of [statusRes, refreshRes, reviewsRes]) {
|
||||
expect(res.status).toBe(404);
|
||||
expect(res.body).toMatchObject({ error: expect.stringContaining("no associated PR") });
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects invalid PR create request body with structured error", async () => {
|
||||
const app = createServer(createStore(createTask({ prInfo: undefined })));
|
||||
const response = await performRequest(app, "POST", "/api/tasks/FN-001/pr/create", JSON.stringify({}), {
|
||||
"content-type": "application/json",
|
||||
});
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.error).toContain("title is required");
|
||||
});
|
||||
|
||||
it("returns conflict when task already has PR", async () => {
|
||||
const app = createServer(createStore(createTask()));
|
||||
const response = await performRequest(app, "POST", "/api/tasks/FN-001/pr/create", JSON.stringify({ title: "x" }), {
|
||||
"content-type": "application/json",
|
||||
});
|
||||
|
||||
expect(response.status).toBe(409);
|
||||
expect(response.body.error).toContain("already has PR");
|
||||
});
|
||||
});
|
||||
@@ -21,7 +21,7 @@ const qualityAppTests = [
|
||||
const qualityApiTests = [
|
||||
// Critical HTTP/server behavior: auth, task/project/settings mutation,
|
||||
// git/GitHub, agents, nodes, chat/files, realtime, and isolation guards.
|
||||
"src/__tests__/{api-error,auth-middleware,auth-middleware-integration,chat-attachment-routes,chat-routes,file-service,github,github-webhooks,initialize,planning-flow-diagnostics-guardrail,project-routes,project-store-resolver,remote-access-routes,remote-auth,routes-agent-budget,routes-agent-keys,routes-agent-permissions,routes-agent-ratings,routes-agent-runs,routes-agent-soul-memory,routes-agents,routes-automation,routes-git,routes-github,routes-nodes,routes-nodes-sync-contract,routes-secrets-sync,routes-settings,routes-task-commit-associations,routes-tasks,routes-tasks-duplicate-check,server,server-static-assets,server-webhook,server.events,setup-routes,sse,sse-buffer,test-isolation-guard,update-check-route,websocket}.test.ts",
|
||||
"src/__tests__/{api-error,auth-middleware,auth-middleware-integration,chat-attachment-routes,chat-routes,file-service,github,github-webhooks,initialize,planning-flow-diagnostics-guardrail,pr-routes-auto-merge,pr-routes.contract,project-routes,project-store-resolver,remote-access-routes,remote-auth,routes-agent-budget,routes-agent-keys,routes-agent-permissions,routes-agent-ratings,routes-agent-runs,routes-agent-soul-memory,routes-agents,routes-automation,routes-git,routes-github,routes-nodes,routes-nodes-sync-contract,routes-secrets-sync,routes-settings,routes-task-commit-associations,routes-tasks,routes-tasks-duplicate-check,server,server-static-assets,server-webhook,server.events,setup-routes,sse,sse-buffer,test-isolation-guard,update-check-route,websocket}.test.ts",
|
||||
"src/routes/__tests__/{custom-provider-routes,custom-providers,register-docker-node-routes,stash-recovery-routes}.test.ts",
|
||||
];
|
||||
|
||||
|
||||
Reference in New Issue
Block a user