Files
fusion/packages/dashboard/app/components/__tests__/MergeAdvanceNotice.test.tsx
gsxdsm 14bc63e813 feat(FN-5419): add stash conflict modal gating and smart pull routing for m
Implements a pull-based merge workflow by wiring the merger pull helpers from the engine, extending the git pull and stash routes, and aligning the `MergeAdvanceNotice` and `StashConflictModal` components to gate dismissal on stash drop. The `run-audit` module is updated with pull mutation documenta

Fusion-Task-Id: FN-5419

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
Fusion-Task-Id: FN-5419
2026-05-23 13:06:48 -07:00

164 lines
7.2 KiB
TypeScript

import { fireEvent, render, screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import MergeAdvanceNotice from "../MergeAdvanceNotice";
const mocked = vi.hoisted(() => ({
useMergeAdvanceNotice: vi.fn(),
pull: vi.fn(),
push: vi.fn(),
dismiss: vi.fn(),
clearPushError: vi.fn(),
setForceWithLease: vi.fn(),
setConflictState: vi.fn(),
stashModal: vi.fn(() => null),
}));
vi.mock("../../hooks/useMergeAdvanceNotice", () => ({ useMergeAdvanceNotice: mocked.useMergeAdvanceNotice }));
vi.mock("../StashConflictModal", () => ({ default: mocked.stashModal }));
function baseHookState(overrides: Record<string, unknown> = {}) {
return {
notice: {
taskId: "FN-1",
integrationBranch: "trunk",
toSha: "abcdef123456",
userCheckout: { worktreePath: "/repo", dirty: false, untrackedCount: 0 },
},
dismiss: mocked.dismiss,
pull: mocked.pull,
pullState: "idle",
conflictState: null,
setConflictState: mocked.setConflictState,
pushStatus: {
integrationBranch: "trunk",
aheadCount: 2,
remoteSha: "abcdef123456",
canPush: true,
disabledReason: undefined,
},
pushState: "idle",
push: mocked.push,
clearPushError: mocked.clearPushError,
forceWithLease: false,
setForceWithLease: mocked.setForceWithLease,
...overrides,
};
}
describe("MergeAdvanceNotice push affordance", () => {
beforeEach(() => {
vi.useFakeTimers();
vi.clearAllMocks();
mocked.stashModal.mockImplementation(() => null);
});
it("renders push section only when aheadCount > 0", () => {
mocked.useMergeAdvanceNotice.mockReturnValue(baseHookState());
const { rerender } = render(<MergeAdvanceNotice projectId="p1" />);
expect(screen.getByText(/Push trunk to origin — ahead by 2 commits\./)).toBeInTheDocument();
mocked.useMergeAdvanceNotice.mockReturnValue(baseHookState({ pushStatus: { integrationBranch: "trunk", aheadCount: 0, canPush: false, remoteSha: null } }));
rerender(<MergeAdvanceNotice projectId="p1" />);
expect(screen.queryByText(/Push trunk to origin/)).toBeNull();
});
it("push button interactions and force-with-lease styling", () => {
mocked.useMergeAdvanceNotice.mockReturnValue(baseHookState());
const { rerender } = render(<MergeAdvanceNotice projectId="p1" />);
fireEvent.click(screen.getByRole("button", { name: "Push to origin" }));
expect(mocked.push).toHaveBeenCalledTimes(1);
mocked.useMergeAdvanceNotice.mockReturnValue(baseHookState({ pushState: "pending" }));
rerender(<MergeAdvanceNotice projectId="p1" />);
expect(screen.getByRole("button", { name: "Pushing…" })).toBeDisabled();
mocked.useMergeAdvanceNotice.mockReturnValue(baseHookState({ forceWithLease: true }));
rerender(<MergeAdvanceNotice projectId="p1" />);
const warningButton = screen.getByRole("button", { name: "Push (force-with-lease)" });
expect(warningButton.className).toContain("btn-warning");
});
it.each([
["no-remote", "No `origin` remote configured."],
["no-upstream", "Branch has no upstream on origin."],
["merge-locked", "Push paused — a Fusion merge is in progress."],
])("shows disabled copy for %s", (reason, copy) => {
mocked.useMergeAdvanceNotice.mockReturnValue(baseHookState({ pushStatus: { integrationBranch: "trunk", aheadCount: 2, canPush: false, disabledReason: reason } }));
render(<MergeAdvanceNotice projectId="p1" />);
expect(screen.getByText(copy)).toBeInTheDocument();
});
it("hides section for not-ahead and not-a-git-repo", () => {
mocked.useMergeAdvanceNotice.mockReturnValue(baseHookState({ pushStatus: { integrationBranch: "trunk", aheadCount: 0, canPush: false, disabledReason: "not-ahead" } }));
const { rerender } = render(<MergeAdvanceNotice projectId="p1" />);
expect(screen.queryByText(/Push trunk to origin/)).toBeNull();
mocked.useMergeAdvanceNotice.mockReturnValue(baseHookState({ pushStatus: { integrationBranch: "trunk", aheadCount: 0, canPush: false, disabledReason: "not-a-git-repo" } }));
rerender(<MergeAdvanceNotice projectId="p1" />);
expect(screen.queryByText(/Push trunk to origin/)).toBeNull();
});
it("advanced toggle updates forceWithLease", () => {
mocked.useMergeAdvanceNotice.mockReturnValue(baseHookState());
render(<MergeAdvanceNotice projectId="p1" />);
const checkbox = screen.getByRole("checkbox");
fireEvent.click(checkbox);
expect(mocked.setForceWithLease).toHaveBeenCalledWith(true);
});
it("rejected-non-ff shows Smart Pull action", () => {
mocked.useMergeAdvanceNotice.mockReturnValue(baseHookState({ pushState: { error: "Remote diverged", outcome: "rejected-non-ff" } }));
render(<MergeAdvanceNotice projectId="p1" />);
fireEvent.click(screen.getByRole("button", { name: "Smart Pull" }));
expect(mocked.pull).toHaveBeenCalledTimes(1);
});
it("rejected-other/failed shows stderr and dismiss", () => {
mocked.useMergeAdvanceNotice.mockReturnValue(baseHookState({ pushState: { error: "Push failed", outcome: "rejected-other", stderr: "fatal" } }));
render(<MergeAdvanceNotice projectId="p1" />);
expect(screen.getByText("fatal")).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Dismiss" }));
expect(mocked.clearPushError).toHaveBeenCalledTimes(1);
});
it("push and pull state stay independent", () => {
mocked.useMergeAdvanceNotice.mockReturnValue(baseHookState({ pullState: "idle", pushState: { error: "locked", outcome: "merge-locked" } }));
render(<MergeAdvanceNotice projectId="p1" />);
expect(screen.getByRole("button", { name: "Pull" })).toBeEnabled();
});
it("shows pull and dirty stash copy when checkout is dirty", () => {
mocked.useMergeAdvanceNotice.mockReturnValue(baseHookState({ notice: { ...baseHookState().notice, userCheckout: { worktreePath: "/repo", dirty: true, untrackedCount: 2 } } }));
render(<MergeAdvanceNotice projectId="p1" />);
expect(screen.getByRole("button", { name: "Pull" })).toBeInTheDocument();
expect(screen.getByText(/local changes will be auto-stashed and restored/)).toBeInTheDocument();
});
it("hides pull button while stash conflict modal is open", () => {
mocked.stashModal.mockImplementation(() => null);
mocked.useMergeAdvanceNotice.mockReturnValue(baseHookState({
conflictState: {
stashSha: "abc1234",
stashLabel: "fusion-auto",
conflictedFiles: ["src/a.ts"],
autostashOutcome: "conflict-needs-manual",
},
}));
render(<MergeAdvanceNotice projectId="p1" />);
expect(screen.queryByRole("button", { name: "Pull" })).toBeNull();
});
it("does not dismiss notice when modal closes without dropping stash", () => {
mocked.useMergeAdvanceNotice.mockReturnValue(baseHookState({
conflictState: {
stashSha: "abc1234",
stashLabel: "fusion-auto",
conflictedFiles: ["src/a.ts"],
autostashOutcome: "conflict-needs-manual",
},
}));
render(<MergeAdvanceNotice projectId="p1" />);
const modalProps = mocked.stashModal.mock.calls.at(-1)?.[0] as { onClose: (stashDropped?: boolean) => void };
modalProps.onClose(false);
expect(mocked.dismiss).not.toHaveBeenCalled();
});
});