Files
fusion/packages/dashboard/app/components/__tests__/PrCreateModal.test.tsx
gsxdsm 65abae26c6 FN-7181: improve PR metadata generation feedback
Ground PR metadata generation in repository evidence and make Create PR loading states explicit.

- Upgrade the PR metadata system/context prompt while preserving configurable title and description guidance.\n- Show disabled title/body skeleton states while AI metadata generates and block submission until content resolves.\n- Extend dashboard tests, docs, and changeset coverage for the improved PR prompt and loading behavior.\n\nFiles changed:\n .../fn-7181-pr-generation-prompt-and-loading.md    |  7 +++\n docs/dashboard-guide.md                            |  2 +-\n .../dashboard/app/components/PrCreateModal.css     | 62 ++++++++++++++++++++++\n .../dashboard/app/components/PrCreateModal.tsx     | 47 +++++++++++++---\n .../components/__tests__/PrCreateModal.test.tsx    | 60 ++++++++++++++++-----\n .../src/__tests__/pr-metadata-generator.test.ts    | 44 ++++++++++++---\n packages/dashboard/src/pr-metadata-generator.ts    | 33 +++++++++---\n 7 files changed, 221 insertions(+), 34 deletions(-)

Fusion-Task-Id: FN-7181

Fusion-Task-Lineage: 6565ddce-c205-4050-a37f-ffbb2f2d46d4

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
2026-06-28 01:17:22 -07:00

802 lines
37 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { readFileSync } from "node:fs";
import { cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import { act } from "react";
import type { ComponentProps } from "react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { PrCreateModal } from "../PrCreateModal";
import type { PrInfo } from "@fusion/core";
const prCreateModalCss = readFileSync("app/components/PrCreateModal.css", "utf8");
const mocks = vi.hoisted(() => ({
generatePrMetadata: vi.fn(),
fetchPrPreflight: vi.fn(),
fetchPrOptions: vi.fn(),
createPr: vi.fn(),
pushPrBranch: vi.fn(),
resolvePrConflicts: vi.fn(),
}));
vi.mock("../../api", () => ({
generatePrMetadata: mocks.generatePrMetadata,
fetchPrPreflight: mocks.fetchPrPreflight,
fetchPrOptions: mocks.fetchPrOptions,
createPr: mocks.createPr,
pushPrBranch: mocks.pushPrBranch,
resolvePrConflicts: mocks.resolvePrConflicts,
}));
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,
conflictsWithBase: false,
ghAuthOk: true,
defaultBaseBranch: "main",
head: "fusion/FN-4756",
commits: [{ sha: "abcdef1", subject: "feat", author: "dev" }],
changedFiles: [{ path: "a.ts", additions: 1, deletions: 0, status: "modified" as const }],
};
const options = {
baseBranches: ["main", "develop"],
reviewers: [{ login: "rev1", name: "Reviewer 1" }],
assignees: [{ login: "assign1", name: "Assignee 1" }],
labels: [{ name: "bug", color: "ff0000" }],
};
function createDeferred<T>() {
let resolve!: (value: T) => void;
let reject!: (reason?: unknown) => void;
const promise = new Promise<T>((res, rej) => {
resolve = res;
reject = rej;
});
return { promise, resolve, reject };
}
function renderModal(overrides?: Partial<ComponentProps<typeof PrCreateModal>>) {
const onClose = vi.fn();
const onCreated = vi.fn();
const addToast = vi.fn();
render(
<PrCreateModal
open
taskId="FN-4756"
onClose={onClose}
onCreated={onCreated}
addToast={addToast}
{...overrides}
/>,
);
return { onClose, onCreated, addToast };
}
async function renderModalLoaded(overrides?: Partial<ComponentProps<typeof PrCreateModal>>) {
const handles = renderModal(overrides);
await screen.findByDisplayValue("AI title");
return handles;
}
function setViewport(width: number, height: number) {
Object.defineProperty(window, "innerWidth", { configurable: true, value: width });
Object.defineProperty(window, "innerHeight", { configurable: true, value: height });
}
function stubPointerCapture(element: HTMLElement) {
Object.defineProperty(element, "setPointerCapture", { configurable: true, value: vi.fn() });
Object.defineProperty(element, "releasePointerCapture", { configurable: true, value: vi.fn() });
}
function expectMetadataLoadingAffordance() {
const titleInput = screen.getByLabelText(/title/i);
const bodyInput = screen.getByLabelText(/body/i);
expect(screen.getByText(/generating ai title/i)).toBeInTheDocument();
expect(screen.getByText(/generating ai body/i)).toBeInTheDocument();
expect(screen.getByTestId("pr-title-loading-skeleton")).toBeInTheDocument();
expect(screen.getByTestId("pr-body-loading-skeleton")).toBeInTheDocument();
expect(titleInput).toBeDisabled();
expect(bodyInput).toBeDisabled();
expect(titleInput).toHaveAttribute("aria-busy", "true");
expect(bodyInput).toHaveAttribute("aria-busy", "true");
}
function expectMetadataLoadingCleared() {
const titleInput = screen.getByLabelText(/title/i);
const bodyInput = screen.getByLabelText(/body/i);
expect(screen.queryByText(/generating ai title/i)).not.toBeInTheDocument();
expect(screen.queryByText(/generating ai body/i)).not.toBeInTheDocument();
expect(screen.queryByTestId("pr-title-loading-skeleton")).not.toBeInTheDocument();
expect(screen.queryByTestId("pr-body-loading-skeleton")).not.toBeInTheDocument();
expect(titleInput).toBeEnabled();
expect(bodyInput).toBeEnabled();
expect(titleInput).not.toHaveAttribute("aria-busy");
expect(bodyInput).not.toHaveAttribute("aria-busy");
}
describe("PrCreateModal", () => {
beforeEach(() => {
localStorage.clear();
vi.useRealTimers();
vi.clearAllMocks();
mocks.generatePrMetadata.mockResolvedValue(metadata);
mocks.fetchPrPreflight.mockResolvedValue(preflight);
mocks.fetchPrOptions.mockResolvedValue(options);
mocks.createPr.mockResolvedValue({ number: 12, title: "AI title", url: "url", status: "open", headBranch: "h", baseBranch: "main", commentCount: 0 } as PrInfo);
mocks.pushPrBranch.mockResolvedValue({ result: { pushed: true, head: "fusion/fn-4756", message: "Pushed fusion/fn-4756 to origin." }, preflight });
mocks.resolvePrConflicts.mockResolvedValue({ result: { resolved: true, pushed: true, conflictedFiles: ["a.ts"], message: "resolved" }, preflight });
});
afterEach(() => {
vi.useRealTimers();
});
it("renders nothing when closed", () => {
render(<PrCreateModal open={false} taskId="FN-4756" onClose={vi.fn()} onCreated={vi.fn()} addToast={vi.fn()} />);
expect(screen.queryByRole("dialog")).toBeNull();
expect(document.body.querySelector('[role="dialog"]')).toBeNull();
});
it("renders inside the shared floating window and portals out of a container-type containing block", async () => {
const { container } = render(
<div data-testid="trap" style={{ containerType: "inline-size" }}>
<PrCreateModal open taskId="FN-4756" onClose={vi.fn()} onCreated={vi.fn()} addToast={vi.fn()} />
</div>,
);
await screen.findByDisplayValue("AI title");
const trap = within(container).getByTestId("trap");
expect(trap.querySelector('[role="dialog"]')).toBeNull();
const panel = screen.getByTestId("floating-window-pr-create");
expect(panel).toHaveClass("floating-window--pr-create", "floating-window--headerless");
expect(panel.style.width).toBe("720px");
expect(panel.style.height).toBe("680px");
expect(screen.queryByTestId("floating-window-drag-handle-pr-create")).toBeNull();
expect(panel.parentElement).toHaveClass("floating-window-overlay");
expect(panel.parentElement?.parentElement).toBe(document.body);
const dialog = screen.getByRole("dialog", { name: "Create Pull Request" });
expect(dialog).toHaveClass("modal", "pr-create-modal");
expect(screen.getAllByRole("button", { name: "Close" })).toHaveLength(1);
expect(dialog.querySelector(":scope > .modal-header")).toHaveClass("pr-create-modal__drag-handle");
expect(dialog.querySelector(":scope > .modal-actions")?.parentElement).toBe(dialog);
});
it("drags and resizes the Create PR floating window from the embedded header", async () => {
setViewport(1200, 1000);
await renderModalLoaded();
const panel = screen.getByTestId("floating-window-pr-create");
const header = screen.getByRole("dialog", { name: "Create Pull Request" }).querySelector(".pr-create-modal__drag-handle") as HTMLElement;
stubPointerCapture(panel);
const initialLeft = Number.parseFloat(panel.style.left);
const initialTop = Number.parseFloat(panel.style.top);
act(() => {
fireEvent.pointerDown(header, { pointerId: 7, clientX: 120, clientY: 80 });
fireEvent.pointerMove(panel, { pointerId: 7, clientX: 220, clientY: 150 });
fireEvent.pointerUp(panel, { pointerId: 7, clientX: 220, clientY: 150 });
});
await waitFor(() => {
expect(Number.parseFloat(panel.style.left)).toBeGreaterThan(initialLeft);
expect(Number.parseFloat(panel.style.top)).toBeGreaterThan(initialTop);
});
const initialWidth = Number.parseFloat(panel.style.width);
const initialHeight = Number.parseFloat(panel.style.height);
const resizeHandle = screen.getByTestId("floating-window-resize-se") as HTMLElement;
stubPointerCapture(resizeHandle);
act(() => {
fireEvent.pointerDown(resizeHandle, { pointerId: 8, clientX: 700, clientY: 600 });
fireEvent.pointerMove(resizeHandle, { pointerId: 8, clientX: 820, clientY: 720 });
fireEvent.pointerUp(resizeHandle, { pointerId: 8, clientX: 820, clientY: 720 });
});
await waitFor(() => {
expect(Number.parseFloat(panel.style.width)).toBeGreaterThan(initialWidth);
expect(Number.parseFloat(panel.style.height)).toBeGreaterThan(initialHeight);
});
});
it("keeps mobile Create PR full-screen and hides resize handles by CSS contract", () => {
const mobileBlock = prCreateModalCss.match(/@media\s*\(max-width:\s*768px\)\s*\{[\s\S]*?\.floating-window--pr-create \.modal\.pr-create-modal\s*\{[\s\S]*?\n\}/)?.[0];
expect(mobileBlock).toContain(".floating-window--pr-create");
expect(mobileBlock).toContain("width: 100vw !important;");
expect(mobileBlock).toContain("height: 100dvh !important;");
expect(mobileBlock).toContain(".floating-window--pr-create .floating-window__resize-handle");
expect(mobileBlock).toContain("display: none;");
});
it("portals independently of an outer modal overlay", async () => {
const outerOnClose = vi.fn();
const innerOnClose = vi.fn();
const { container } = render(
<div>
<div className="modal-overlay open" data-testid="outer-overlay" onClick={outerOnClose}>
<div className="modal" role="dialog" aria-modal="true" aria-label="Outer modal">Outer modal</div>
</div>
<div data-testid="outer-shell">
<PrCreateModal open taskId="FN-4756" onClose={innerOnClose} onCreated={vi.fn()} addToast={vi.fn()} />
</div>
</div>,
);
await screen.findByDisplayValue("AI title");
const outerShell = within(container).getByTestId("outer-shell");
expect(outerShell.querySelector('[role="dialog"]')).toBeNull();
const overlays = Array.from(document.body.querySelectorAll(".modal-overlay.open"));
expect(overlays).toHaveLength(1);
expect(screen.getByTestId("floating-window-pr-create")).toBeInTheDocument();
const outerOverlay = within(container).getByTestId("outer-overlay");
const innerDialog = screen.getByRole("dialog", { name: "Create Pull Request" });
expect(outerOverlay.contains(innerDialog)).toBe(false);
fireEvent.click(outerOverlay);
expect(outerOnClose).toHaveBeenCalledTimes(1);
expect(innerOnClose).not.toHaveBeenCalled();
expect(screen.getByRole("dialog", { name: "Create Pull Request" })).toBeInTheDocument();
});
it("loads metadata/preflight/options on open and renders key sections", async () => {
renderModal();
await waitFor(() => {
expect(mocks.generatePrMetadata).toHaveBeenCalledTimes(1);
expect(mocks.fetchPrPreflight).toHaveBeenCalledTimes(1);
expect(mocks.fetchPrOptions).toHaveBeenCalledTimes(1);
});
expect(await screen.findByDisplayValue("AI title")).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("defaults to raw edit mode and persists the body preview toggle preference", async () => {
localStorage.setItem("fn-pr-create-body-preview", "true");
await renderModalLoaded();
expect(screen.queryByLabelText(/body/i, { selector: "textarea" })).toBeNull();
expect(screen.getByRole("region", { name: "Body markdown preview" })).toBeInTheDocument();
expect(screen.getByRole("heading", { level: 2, name: "Summary" })).toBeInTheDocument();
const toggle = screen.getByTestId("pr-create-body-preview-toggle");
expect(toggle).toHaveAttribute("aria-pressed", "true");
fireEvent.click(toggle);
expect(await screen.findByLabelText(/body/i, { selector: "textarea" })).toBeInTheDocument();
expect(toggle).toHaveAttribute("aria-pressed", "false");
await waitFor(() => expect(localStorage.getItem("fn-pr-create-body-preview")).toBe("false"));
});
it("shows editable body by default and previews current markdown without mutating edits", async () => {
await renderModalLoaded();
const bodyInput = screen.getByLabelText(/body/i) as HTMLTextAreaElement;
expect(bodyInput).toHaveValue(metadata.body);
expect(screen.queryByRole("region", { name: "Body markdown preview" })).toBeNull();
fireEvent.change(bodyInput, { target: { value: "# Custom title\n\nPlain text body\n\n<details><summary>More</summary>Hidden</details>" } });
const toggle = screen.getByTestId("pr-create-body-preview-toggle");
fireEvent.click(toggle);
expect(screen.queryByLabelText(/body/i, { selector: "textarea" })).toBeNull();
expect(screen.getByRole("region", { name: "Body markdown preview" })).toBeInTheDocument();
expect(screen.getByRole("heading", { level: 1, name: "Custom title" })).toBeInTheDocument();
expect(screen.getByText("Plain text body")).toBeInTheDocument();
expect(screen.getByText("More")).toBeInTheDocument();
expect(toggle).toHaveAttribute("aria-pressed", "true");
fireEvent.click(toggle);
expect(screen.getByLabelText(/body/i)).toHaveValue("# Custom title\n\nPlain text body\n\n<details><summary>More</summary>Hidden</details>");
});
it("renders empty and fallback body preview states without crashing", async () => {
await renderModalLoaded();
fireEvent.change(screen.getByLabelText(/body/i), { target: { value: "" } });
fireEvent.click(screen.getByTestId("pr-create-body-preview-toggle"));
const emptyPreview = screen.getByRole("region", { name: "Body markdown preview" });
expect(emptyPreview).toBeInTheDocument();
expect(screen.getByTestId("pr-create-body-preview-toggle")).toHaveAttribute("aria-pressed", "true");
cleanup();
localStorage.clear();
mocks.generatePrMetadata.mockRejectedValueOnce(new Error("metadata blew up"));
renderModal();
expect(await screen.findByText("metadata blew up")).toBeInTheDocument();
fireEvent.click(screen.getByTestId("pr-create-body-preview-toggle"));
expect(screen.getByRole("heading", { level: 2, name: "Summary" })).toBeInTheDocument();
expect(screen.getByRole("heading", { level: 2, name: "Linked Task" })).toBeInTheDocument();
expect(screen.getByText("Closes FN-4756")).toBeInTheDocument();
});
it("re-renders preview for regenerate and Revert to AI version body changes", async () => {
mocks.generatePrMetadata
.mockResolvedValueOnce(metadata)
.mockResolvedValueOnce({ title: "New title", body: "# Regenerated body\n\nFresh content", templateUsed: false });
await renderModalLoaded();
fireEvent.click(screen.getByTestId("pr-create-body-preview-toggle"));
expect(screen.getByRole("heading", { level: 2, name: "Summary" })).toBeInTheDocument();
fireEvent.click(screen.getAllByRole("button", { name: /^regenerate$/i })[1]);
expect(await screen.findByRole("heading", { level: 1, name: "Regenerated body" })).toBeInTheDocument();
expect(screen.queryByRole("heading", { level: 2, name: "Summary" })).toBeNull();
expect(screen.getByText("Fresh content")).toBeInTheDocument();
fireEvent.click(screen.getByTestId("pr-create-body-preview-toggle"));
fireEvent.change(screen.getByLabelText(/body/i), { target: { value: "Plain edited body" } });
fireEvent.click(screen.getByTestId("pr-create-body-preview-toggle"));
expect(screen.getByText("Plain edited body")).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: /revert to ai version/i }));
expect(screen.getByRole("heading", { level: 1, name: "Regenerated body" })).toBeInTheDocument();
expect(screen.queryByText("Plain edited body")).toBeNull();
});
it("submits the same body payload while markdown preview is active", async () => {
await renderModalLoaded();
const bodyText = "# Submit me\n\nPreview mode should not change payload.";
fireEvent.change(screen.getByLabelText(/body/i), { target: { value: bodyText } });
fireEvent.click(screen.getByTestId("pr-create-body-preview-toggle"));
expect(screen.getByRole("heading", { level: 1, name: "Submit me" })).toBeInTheDocument();
const submitButton = screen.getByRole("button", { name: "Create PR" });
await waitFor(() => expect(submitButton).toBeEnabled());
fireEvent.click(submitButton);
await waitFor(() => expect(mocks.createPr).toHaveBeenCalledTimes(1));
expect(mocks.createPr.mock.calls[0][1]).toMatchObject({
title: "AI title",
body: bodyText,
});
});
it("renders commit preview rows in SHA, subject, author order", async () => {
const commits = [
{
sha: "1234567890abcdef1234567890abcdef12345678",
subject: "feat: add a very long subject that should use the flexible column instead of forcing the short SHA to wrap",
author: "A very long author name that remains in the trailing column",
},
{
sha: "abcdef0123456789abcdef0123456789abcdef01",
subject: "fix: keep another subject readable",
author: "dev",
},
];
mocks.fetchPrPreflight.mockResolvedValueOnce({
...preflight,
commits,
changedFiles: [
{ path: "src/very/long/path/that/should/still/use/the/file-row-flex-column.ts", additions: 10, deletions: 2, status: "modified" as const },
],
});
renderModal();
expect(await screen.findByDisplayValue("AI title")).toBeInTheDocument();
const rows = Array.from(document.querySelectorAll<HTMLDivElement>(".pr-create-modal__commit-row"));
expect(rows).toHaveLength(commits.length);
rows.forEach((row, index) => {
const codeCells = row.querySelectorAll("code");
expect(codeCells).toHaveLength(1);
expect(codeCells[0]).toHaveTextContent(commits[index]!.sha.slice(0, 7));
expect(row.children).toHaveLength(3);
expect(row.children[0]).toBe(codeCells[0]);
expect(row.children[0]).toHaveTextContent(commits[index]!.sha.slice(0, 7));
expect(row.children[1]).toHaveTextContent(commits[index]!.subject);
expect(row.children[2]).toHaveTextContent(commits[index]!.author);
});
const fileRow = document.querySelector(".pr-create-modal__file-row");
expect(fileRow?.children[0]).toHaveTextContent("src/very/long/path/that/should/still/use/the/file-row-flex-column.ts");
expect(fileRow?.children[1]).toHaveTextContent("+10 / −2");
expect(fileRow?.children[2]).toHaveTextContent("modified");
});
it("renders empty commit hint without commit rows", async () => {
mocks.fetchPrPreflight.mockResolvedValueOnce({
...preflight,
commitsPresent: false,
commits: [],
});
renderModal();
expect(await screen.findByText("No commits found.")).toBeInTheDocument();
expect(document.querySelectorAll(".pr-create-modal__commit-row")).toHaveLength(0);
});
it("renders preflight and options while metadata shows a disabled loading affordance, then clears into AI content", async () => {
const metadataDeferred = createDeferred<typeof metadata>();
mocks.generatePrMetadata.mockReturnValueOnce(metadataDeferred.promise);
renderModal();
expect(await screen.findByText("Branch pushed to remote")).toBeInTheDocument();
expect(screen.getByLabelText(/base branch/i)).toBeEnabled();
expect(screen.getByText("Reviewers")).toBeInTheDocument();
expectMetadataLoadingAffordance();
expect(screen.getByRole("button", { name: "Create PR" })).toBeDisabled();
fireEvent.change(screen.getByPlaceholderText("Filter reviewers"), { target: { value: "rev" } });
fireEvent.click(screen.getByRole("button", { name: /reviewer 1/i }));
expect(screen.getByRole("button", { name: /remove reviewer 1/i })).toBeInTheDocument();
metadataDeferred.resolve(metadata);
expect(await screen.findByDisplayValue("AI title")).toBeInTheDocument();
const bodyInput = screen.getByLabelText(/body/i) as HTMLTextAreaElement;
expect(bodyInput.value).toContain("## Summary");
expectMetadataLoadingCleared();
expect(screen.getByRole("button", { name: "Create PR" })).toBeEnabled();
});
it("keeps submit disabled while preflight is pending or failed", async () => {
const preflightDeferred = createDeferred<typeof preflight>();
mocks.fetchPrPreflight.mockReturnValueOnce(preflightDeferred.promise);
renderModal();
expect(await screen.findByDisplayValue("AI title")).toBeInTheDocument();
expect(screen.getByText(/loading pre-flight checks/i)).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Create PR" })).toBeDisabled();
preflightDeferred.reject(new Error("preflight blew up"));
expect(await screen.findByText("preflight blew up")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Create PR" })).toBeDisabled();
});
it("clears metadata loading affordance into the error and manual fallback state", async () => {
const metadataDeferred = createDeferred<typeof metadata>();
mocks.generatePrMetadata.mockReturnValueOnce(metadataDeferred.promise);
renderModal();
expect(await screen.findByText("Branch pushed to remote")).toBeInTheDocument();
expectMetadataLoadingAffordance();
metadataDeferred.reject(new Error("metadata blew up"));
expect(await screen.findByText("metadata blew up")).toBeInTheDocument();
expect(screen.getByText("Branch pushed to remote")).toBeInTheDocument();
expect(screen.getByLabelText(/base branch/i)).toBeEnabled();
expectMetadataLoadingCleared();
const bodyInput = screen.getByLabelText(/body/i) as HTMLTextAreaElement;
expect(bodyInput.value).toContain("## Summary");
expect(bodyInput.value).toContain("## Changes");
expect(bodyInput.value).toContain("## Testing");
expect(bodyInput.value).toContain("Closes FN-4756");
fireEvent.change(screen.getByLabelText(/title/i), { target: { value: "Manual fallback title" } });
await waitFor(() => expect(screen.getByRole("button", { name: "Create PR" })).toBeEnabled());
});
it("times out hung metadata generation into the manual fallback state", async () => {
vi.useFakeTimers();
mocks.generatePrMetadata.mockReturnValueOnce(new Promise(() => {}));
renderModal();
expect(screen.getByText(/generating ai title/i)).toBeInTheDocument();
expect(screen.getByText(/generating ai body/i)).toBeInTheDocument();
act(() => {
vi.advanceTimersByTime(15001);
});
await act(async () => {
await Promise.resolve();
});
vi.useRealTimers();
expect(await screen.findByText("Timed out generating PR metadata")).toBeInTheDocument();
expect(screen.queryByText(/generating ai title/i)).not.toBeInTheDocument();
expect(screen.queryByText(/generating ai body/i)).not.toBeInTheDocument();
const bodyInput = screen.getByLabelText(/body/i) as HTMLTextAreaElement;
expect(bodyInput.value).toContain("## Summary");
expect(bodyInput.value).toContain("Closes FN-4756");
});
it("requires a non-empty body before submitting manual PR metadata", async () => {
mocks.generatePrMetadata.mockRejectedValueOnce(new Error("metadata blew up"));
renderModal();
expect(await screen.findByText("metadata blew up")).toBeInTheDocument();
fireEvent.change(screen.getByLabelText(/title/i), { target: { value: "Manual fallback title" } });
await waitFor(() => expect(screen.getByRole("button", { name: "Create PR" })).toBeEnabled());
fireEvent.change(screen.getByLabelText(/body/i), { target: { value: "" } });
expect(screen.getByRole("button", { name: "Create PR" })).toBeDisabled();
fireEvent.click(screen.getByRole("button", { name: "Create PR" }));
expect(mocks.createPr).not.toHaveBeenCalled();
fireEvent.change(screen.getByLabelText(/body/i), { target: { value: "Manual fallback body" } });
await waitFor(() => expect(screen.getByRole("button", { name: "Create PR" })).toBeEnabled());
});
it("uses route fallback metadata as editable content after a timeout response", async () => {
mocks.generatePrMetadata.mockResolvedValueOnce({
title: "Fallback title",
body: "## Summary\n\nSummary unavailable.\n\n## Changes\n\n- Details unavailable.\n\n## Testing\n\n- Not provided.\n\n## Linked Task\n\nCloses FN-4756",
templateUsed: false,
});
renderModal();
expect(await screen.findByDisplayValue("Fallback title")).toBeInTheDocument();
const bodyInput = screen.getByLabelText(/body/i) as HTMLTextAreaElement;
expect(bodyInput.value).toContain("## Summary");
expect(bodyInput.value).toContain("Closes FN-4756");
expect(screen.queryByText(/generating ai title/i)).not.toBeInTheDocument();
expect(screen.getByRole("button", { name: "Create PR" })).toBeEnabled();
});
it("keeps options failure scoped so metadata and preflight still render", async () => {
mocks.fetchPrOptions.mockRejectedValueOnce(new Error("options blew up"));
renderModal();
expect(await screen.findByDisplayValue("AI title")).toBeInTheDocument();
expect(screen.getByText("Branch pushed to remote")).toBeInTheDocument();
expect(await screen.findByText("options blew up")).toBeInTheDocument();
expect(screen.getByLabelText(/base branch/i)).toBeDisabled();
expect(screen.getByRole("button", { name: "Create PR" })).toBeEnabled();
});
it("renders empty option and preview states collapsed by default without throwing", async () => {
mocks.fetchPrPreflight.mockResolvedValueOnce({ ...preflight, commits: [], changedFiles: [] });
mocks.fetchPrOptions.mockResolvedValueOnce({ ...options, baseBranches: [], reviewers: [], assignees: [], labels: [] });
await renderModalLoaded();
const preview = document.querySelector(".pr-create-collapsible");
expect(preview).toBeInstanceOf(HTMLDetailsElement);
expect(preview).not.toHaveAttribute("open");
expect(screen.getByText("No commits found.")).toBeInTheDocument();
expect(screen.getByText("No changed files detected.")).toBeInTheDocument();
expect(screen.getByLabelText(/base branch/i)).toBeDisabled();
});
it("regenerates AI content without re-blocking preflight and options while restoring the loading affordance", async () => {
const regenerateDeferred = createDeferred<typeof metadata>();
mocks.generatePrMetadata.mockResolvedValueOnce(metadata).mockReturnValueOnce(regenerateDeferred.promise);
await renderModalLoaded();
const titleInput = screen.getByDisplayValue("AI title");
fireEvent.change(titleInput, { target: { value: "custom" } });
expect(screen.getByRole("button", { name: /revert to ai version/i })).toBeInTheDocument();
fireEvent.click(screen.getAllByRole("button", { name: /^regenerate$/i })[0]);
expect(screen.getByText("Branch pushed to remote")).toBeInTheDocument();
expect(screen.getByLabelText(/base branch/i)).toBeEnabled();
expectMetadataLoadingAffordance();
expect(screen.getByRole("button", { name: "Create PR" })).toBeDisabled();
fireEvent.click(screen.getByRole("button", { name: "Create PR" }));
expect(mocks.createPr).not.toHaveBeenCalled();
regenerateDeferred.resolve({ title: "New title", body: "New body", templateUsed: false });
await screen.findByDisplayValue("New title");
expectMetadataLoadingCleared();
fireEvent.change(screen.getByDisplayValue("New title"), { target: { value: "edited" } });
fireEvent.click(screen.getByRole("button", { name: /revert to ai version/i }));
expect(screen.getByDisplayValue("New title")).toBeInTheDocument();
});
it("disables submit when preflight fails and toggles draft label", async () => {
mocks.fetchPrPreflight.mockResolvedValueOnce({ ...preflight, ghAuthOk: false });
renderModal();
const submitButton = await screen.findByRole("button", { name: "Create PR" });
expect(submitButton).toBeDisabled();
fireEvent.click(screen.getByLabelText(/create as draft/i));
expect(screen.getByRole("button", { name: "Create draft PR" })).toBeInTheDocument();
});
it("refreshes preflight independently when the base branch changes", async () => {
const baseChangeDeferred = createDeferred<typeof preflight>();
mocks.fetchPrPreflight
.mockResolvedValueOnce(preflight)
.mockReturnValueOnce(baseChangeDeferred.promise);
await renderModalLoaded();
fireEvent.change(screen.getByLabelText(/base branch/i), { target: { value: "develop" } });
expect(mocks.fetchPrPreflight).toHaveBeenLastCalledWith("FN-4756", undefined, "develop");
expect(screen.getByText(/loading pre-flight checks/i)).toBeInTheDocument();
expect(screen.getByDisplayValue("AI title")).toBeInTheDocument();
baseChangeDeferred.resolve({ ...preflight, defaultBaseBranch: "develop" });
await waitFor(() => expect(screen.queryByText(/loading pre-flight checks/i)).not.toBeInTheDocument());
expect(screen.getByLabelText(/base branch/i)).toHaveValue("develop");
});
it("adds and removes chips and submits payload", async () => {
const { onCreated, addToast, onClose } = await renderModalLoaded();
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" } });
fireEvent.click(screen.getByRole("button", { name: /assignee 1/i }));
fireEvent.change(screen.getByPlaceholderText("Filter labels"), { target: { value: "bug" } });
fireEvent.click(screen.getByRole("button", { name: "bug" }));
const submitButton = screen.getByRole("button", { name: "Create draft PR" });
expect(submitButton).toHaveClass("btn-primary");
await waitFor(() => expect(submitButton).toBeEnabled());
fireEvent.click(submitButton);
await waitFor(() => expect(mocks.createPr).toHaveBeenCalledTimes(1));
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();
fireEvent.click(screen.getByRole("button", { name: /remove reviewer 1/i }));
});
it("renders push-branch affordance and enables submit after success", async () => {
mocks.fetchPrPreflight.mockResolvedValue({ ...preflight, branchOnRemote: false });
mocks.pushPrBranch.mockResolvedValueOnce({ result: { pushed: true, head: "fusion/fn-4756", message: "Pushed fusion/fn-4756 to origin." }, preflight });
const { addToast } = await renderModalLoaded();
expect(screen.getByRole("button", { name: "Create PR" })).toBeDisabled();
fireEvent.click(screen.getByRole("button", { name: "Push branch to remote" }));
await waitFor(() => expect(mocks.pushPrBranch).toHaveBeenCalledWith("FN-4756", "main", undefined));
await waitFor(() => expect(screen.getByRole("button", { name: "Create PR" })).toBeEnabled());
expect(addToast).toHaveBeenCalledWith("Pushed fusion/fn-4756 to origin.", "success");
});
it("surfaces push-branch failures", async () => {
mocks.fetchPrPreflight.mockResolvedValue({ ...preflight, branchOnRemote: false });
mocks.pushPrBranch.mockRejectedValueOnce(new Error("unable to push branch"));
await renderModalLoaded();
fireEvent.click(screen.getByRole("button", { name: "Push branch to remote" }));
expect(await screen.findByText("unable to push branch")).toBeInTheDocument();
});
it("keeps the push-branch remediation usable on narrow mobile widths", async () => {
mocks.fetchPrPreflight.mockResolvedValue({ ...preflight, branchOnRemote: false });
const originalInnerWidth = window.innerWidth;
await act(async () => {
Object.defineProperty(window, "innerWidth", { configurable: true, value: 375 });
window.dispatchEvent(new Event("resize"));
});
await renderModalLoaded();
expect(screen.getByRole("button", { name: "Push branch to remote" })).toBeVisible();
Object.defineProperty(window, "innerWidth", { configurable: true, value: originalInnerWidth });
window.dispatchEvent(new Event("resize"));
});
it("renders AI conflict resolution affordance and enables submit after success", async () => {
mocks.fetchPrPreflight.mockResolvedValue({ ...preflight, conflictsWithBase: true, branchOnRemote: false });
mocks.resolvePrConflicts.mockResolvedValueOnce({ result: { resolved: true, pushed: true, conflictedFiles: ["a.ts"], message: "resolved" }, preflight });
const { addToast } = await renderModalLoaded();
const submitButton = screen.getByRole("button", { name: "Create PR" });
expect(submitButton).toBeDisabled();
const resolveButton = await screen.findByRole("button", { name: "Resolve conflicts with AI" });
fireEvent.click(resolveButton);
await waitFor(() => expect(mocks.resolvePrConflicts).toHaveBeenCalledWith("FN-4756", "main", undefined));
await waitFor(() => expect(screen.getByRole("button", { name: "Create PR" })).toBeEnabled());
expect(addToast).toHaveBeenCalledWith("Resolved PR conflicts and pushed branch", "success");
});
it("surfaces conflict resolution failures", async () => {
mocks.fetchPrPreflight.mockResolvedValue({ ...preflight, conflictsWithBase: true });
mocks.resolvePrConflicts.mockRejectedValueOnce(new Error("unable to resolve"));
await renderModalLoaded();
fireEvent.click(await screen.findByRole("button", { name: "Resolve conflicts with AI" }));
expect(await screen.findByText("unable to resolve")).toBeInTheDocument();
});
it("shows submit error and retries with same payload", async () => {
mocks.createPr.mockRejectedValueOnce(new Error("bad")).mockResolvedValueOnce({ number: 22, title: "ok", url: "u", status: "open", headBranch: "h", baseBranch: "main", commentCount: 0 } as PrInfo);
await renderModalLoaded();
await waitFor(() => expect(screen.getByRole("button", { name: "Create PR" })).toBeEnabled());
fireEvent.click(screen.getByRole("button", { name: "Create PR" }));
await waitFor(() => expect(mocks.createPr).toHaveBeenCalledTimes(1));
expect(await screen.findByText("bad")).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Retry" }));
await waitFor(() => expect(mocks.createPr).toHaveBeenCalledTimes(2));
expect(mocks.createPr.mock.calls[0][1]).toEqual(mocks.createPr.mock.calls[1][1]);
});
it("renders structured gh auth hint", async () => {
const err = Object.assign(new Error("auth failed"), {
details: {
githubError: {
code: "not-authenticated",
message: "GitHub CLI is not authenticated.",
hint: "Run 'gh auth login' to authenticate with GitHub.",
action: { kind: "shell", command: "gh auth login" },
retryable: true,
},
},
});
mocks.createPr.mockRejectedValueOnce(err);
await renderModalLoaded();
await waitFor(() => expect(screen.getByRole("button", { name: "Create PR" })).toBeEnabled());
fireEvent.click(screen.getByRole("button", { name: "Create PR" }));
await waitFor(() => expect(mocks.createPr).toHaveBeenCalledTimes(1));
expect((await screen.findAllByText(/gh auth login/i)).length).toBeGreaterThan(0);
});
it("does not close from bare page clicks because the floating shell is non-blocking", async () => {
const { onClose } = await renderModalLoaded();
fireEvent.pointerDown(document.body);
fireEvent.click(document.body);
expect(onClose).not.toHaveBeenCalled();
expect(screen.getByTestId("floating-window-pr-create")).toBeInTheDocument();
});
it("keeps self-removing inner controls from closing the floating modal", async () => {
const { onClose } = await renderModalLoaded();
fireEvent.change(screen.getByLabelText(/title/i), { target: { value: "Edited title" } });
fireEvent.click(screen.getByRole("button", { name: /revert to ai version/i }));
expect(onClose).not.toHaveBeenCalled();
expect(screen.getByRole("dialog", { name: "Create Pull Request" })).toBeInTheDocument();
expect(screen.getByDisplayValue("AI title")).toBeInTheDocument();
});
it("closes on escape, header close, and cancel", async () => {
const escapeHandles = await renderModalLoaded();
fireEvent.keyDown(document, { key: "Escape" });
expect(escapeHandles.onClose).toHaveBeenCalledTimes(1);
cleanup();
const headerHandles = await renderModalLoaded();
fireEvent.click(screen.getByRole("button", { name: "Close" }));
expect(headerHandles.onClose).toHaveBeenCalledTimes(1);
cleanup();
const cancelHandles = await renderModalLoaded();
fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
expect(cancelHandles.onClose).toHaveBeenCalledTimes(1);
});
});