Files
fusion/packages/dashboard/app/components/__tests__/GitManagerModal.test.tsx
gsxdsm 6ea53966f6 FN-7885: migrate remaining direct clipboard callers to shared copyTextToClipboard helper
Replaces the last direct navigator.clipboard.writeText() call sites across dashboard components and the reports plugin with the shared copyTextToClipboard helper, fixing copy actions that crashed or silently failed on non-secure origins (HTTP/mobile).

- Migrated AgentDetailView, AgentErrorDetailsModal, CliBinaryPanel, GitManagerModal, LoginInstructions, PrPanel, SecretsView, and StashConflictModal to use copyTextToClipboard (secure-context guard + execCommand fallback, boolean result handling) instead of calling navigator.clipboard directly.
- Migrated the fusion-plugin-reports ShareBlocksPanel to the same helper and added a vitest alias so the plugin's subpath import resolves to the dashboard's copyToClipboard util instead of collapsing to its package root.
- Added ./app/utils/copyToClipboard subpath export to @fusion/dashboard's package.json.
- Added/extended tests covering copy success, fallback, and failure paths for AgentDetailView, CliBinaryPanel, AgentErrorDetailsModal, GitManagerModal, SecretsView, StashConflictModal, and ShareBlocksPanel.
- Added a patch changeset documenting the fix for @runfusion/fusion.

Files changed:
 .changeset/fn-7885-clipboard-migration.md          |  7 ++
 packages/dashboard/app/components/AgentDetailView.tsx   | 16 ++++-
 packages/dashboard/app/components/AgentErrorDetailsModal.tsx      |  5 +-
 packages/dashboard/app/components/CliBinaryPanel.tsx    | 16 +++--
 packages/dashboard/app/components/GitManagerModal.tsx   | 16 ++++-
 packages/dashboard/app/components/LoginInstructions.tsx | 21 +++---
 packages/dashboard/app/components/PrPanel.tsx      |  9 ++-
 packages/dashboard/app/components/SecretsView.tsx  | 11 ++-
 packages/dashboard/app/components/StashConflictModal.tsx          | 13 ++--
 packages/dashboard/app/components/__tests__/AgentDetailView.copy.test.tsx        | 56 +++++++++++++++
 packages/dashboard/app/components/__tests__/AgentErrorDetailsModal.test.tsx      | 18 +++++
 packages/dashboard/app/components/__tests__/CliBinaryPanel.copy.test.tsx         | 68 ++++++++++++++++++
 packages/dashboard/app/components/__tests__/GitManagerModal.test.tsx  | 23 ++++++
 packages/dashboard/app/components/__tests__/SecretsView.test.tsx  | 82 ++++++++++++++++++++++
 packages/dashboard/app/components/__tests__/StashConflictModal.test.tsx          | 25 ++++++-
 packages/dashboard/package.json                    |  4 ++
 plugins/fusion-plugin-reports/src/dashboard/components/ShareBlocksPanel.tsx  |  5 +-
 plugins/fusion-plugin-reports/src/dashboard/components/__tests__/ShareBlocksPanel.test.tsx | 43 +++++++++++-
 plugins/fusion-plugin-reports/vitest.config.ts     |  2 +
 19 files changed, 402 insertions(+), 38 deletions(-)

Fusion-Task-Id: FN-7885
Fusion-Task-Lineage: 122a54ea-962b-4ae1-98ac-c28e03f4f8ca
Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
2026-07-12 17:54:21 -07:00

3789 lines
141 KiB
TypeScript

import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, screen, fireEvent, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { GitManagerModal } from "../GitManagerModal";
import type { Task } from "@fusion/core";
import { loadAllAppCss } from "../../test/cssFixture";
// Mock SSE bus before module imports so the GitManagerModal SSE effect is a no-op
vi.mock("../../sse-bus", () => ({
subscribeSse: vi.fn(() => () => {}),
}));
const mockUseViewportMode = vi.fn(() => "desktop");
const mockUseMobileKeyboard = vi.fn(() => ({
keyboardOverlap: 0,
viewportHeight: null,
viewportOffsetTop: 0,
keyboardOpen: false,
}));
vi.mock("../../hooks/useViewportMode", () => ({
MOBILE_MEDIA_QUERY: "(max-width: 768px), (max-height: 480px)",
getViewportMode: () => mockUseViewportMode(),
isMobileViewport: () => mockUseViewportMode() === "mobile",
useViewportMode: () => mockUseViewportMode(),
}));
vi.mock("../../hooks/useMobileKeyboard", () => ({
useMobileKeyboard: () => mockUseMobileKeyboard(),
}));
vi.mock("../../hooks/useMobileScrollLock", () => ({
useMobileScrollLock: vi.fn(),
useMobileKeyboardViewportLock: vi.fn(),
useMobileViewportRestoreReset: vi.fn(),
}));
// Mock the API module with all functions
vi.mock("../../api", async () => {
return {
api: vi.fn(),
fetchConfig: vi.fn(),
fetchGitStatus: vi.fn(),
fetchGitCommits: vi.fn(),
fetchCommitDiff: vi.fn(),
fetchGitBranches: vi.fn(),
fetchGitWorktrees: vi.fn(),
createBranch: vi.fn(),
checkoutBranch: vi.fn(),
deleteBranch: vi.fn(),
fetchRemote: vi.fn(),
pullBranch: vi.fn(),
pushBranch: vi.fn(),
fetchGitStashList: vi.fn(),
createStash: vi.fn(),
applyStash: vi.fn(),
dropStash: vi.fn(),
fetchStashDiff: vi.fn(),
fetchFileChanges: vi.fn(),
fetchGitFileDiff: vi.fn(),
stageFiles: vi.fn(),
unstageFiles: vi.fn(),
createCommit: vi.fn(),
discardChanges: vi.fn(),
fetchGitRemotesDetailed: vi.fn(),
addGitRemote: vi.fn(),
removeGitRemote: vi.fn(),
renameGitRemote: vi.fn(),
updateGitRemoteUrl: vi.fn(),
fetchAheadCommits: vi.fn(),
fetchRemoteCommits: vi.fn(),
fetchBranchCommits: vi.fn(),
// FNXC:Test 2026-06-25-00:10: GitManagerModal detects workspace sub-repos on mount via
// fetchWorkspaceRepos; the mock was never added when that call landed, breaking the whole suite
// at import. Default to a non-workspace project ({ repos: [] }) so the root git path is exercised.
fetchWorkspaceRepos: vi.fn().mockResolvedValue({ repos: [] }),
};
});
const originalClipboard = navigator.clipboard;
const originalExecCommand = document.execCommand;
function mockClipboardFallback(result: boolean) {
Object.defineProperty(navigator, "clipboard", { configurable: true, value: undefined });
const execCommand = vi.fn().mockReturnValue(result);
Object.defineProperty(document, "execCommand", { configurable: true, value: execCommand });
return execCommand;
}
const mockConfirm = vi.fn();
vi.mock("../../hooks/useConfirm", () => ({
useConfirm: () => ({ confirm: mockConfirm }),
}));
import {
api,
fetchConfig,
fetchGitStatus,
fetchGitCommits,
fetchCommitDiff,
fetchGitBranches,
fetchGitWorktrees,
createBranch,
checkoutBranch,
deleteBranch,
fetchRemote,
pullBranch,
pushBranch,
fetchGitStashList,
createStash,
applyStash,
dropStash,
fetchStashDiff,
fetchFileChanges,
fetchGitFileDiff,
stageFiles,
unstageFiles,
createCommit,
discardChanges,
fetchGitRemotesDetailed,
addGitRemote,
removeGitRemote,
renameGitRemote,
updateGitRemoteUrl,
fetchAheadCommits,
fetchRemoteCommits,
fetchBranchCommits,
fetchWorkspaceRepos,
} from "../../api";
import { subscribeSse } from "../../sse-bus";
function expectLatestCallStartsWith(mockFn: { mock: { calls: unknown[][] } }, ...expectedArgs: unknown[]) {
expect(mockFn.mock.calls.length).toBeGreaterThan(0);
expect(mockFn.mock.calls.at(-1)?.slice(0, expectedArgs.length)).toEqual(expectedArgs);
}
function getMediaBlocks(css: string, pattern: RegExp): string[] {
const matches = [...css.matchAll(pattern)];
expect(matches.length).toBeGreaterThan(0);
return matches.map((match) => {
const start = match.index!;
const open = css.indexOf("{", start);
let depth = 1;
let i = open + 1;
while (i < css.length && depth > 0) {
if (css[i] === "{") depth++;
else if (css[i] === "}") depth--;
i++;
}
return css.slice(start, i);
});
}
function getRuleBlocks(css: string, selector: string): string[] {
const escapedSelector = selector.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
return [...css.matchAll(new RegExp(`${escapedSelector}\\s*\\{([^}]*)\\}`, "g"))]
.map((match) => match[1]);
}
const gitManagerSectionLabels = [
"Status",
"Changes",
"Commits",
"Branches",
"Worktrees",
"Stashes",
"Recovery",
"Remotes",
];
const mockAddToast = vi.fn();
const mockTasks: Task[] = [
{
id: "FN-001",
description: "Test task 1",
column: "in-progress",
dependencies: [],
worktree: "/worktrees/kb-001",
steps: [],
currentStep: 0,
log: [],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
},
{
id: "FN-002",
description: "Test task 2",
column: "todo",
dependencies: [],
steps: [],
currentStep: 0,
log: [],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
},
];
describe("GitManagerModal", () => {
afterEach(() => {
Object.defineProperty(navigator, "clipboard", { configurable: true, value: originalClipboard });
Object.defineProperty(document, "execCommand", { configurable: true, value: originalExecCommand });
vi.useRealTimers();
});
beforeEach(() => {
vi.clearAllMocks();
mockUseViewportMode.mockReturnValue("desktop");
mockUseMobileKeyboard.mockReturnValue({
keyboardOverlap: 0,
viewportHeight: null,
viewportOffsetTop: 0,
keyboardOpen: false,
});
mockConfirm.mockReset();
mockConfirm.mockResolvedValue(true);
// Default mock implementations
(fetchConfig as any).mockResolvedValue({ maxConcurrent: 4, rootDir: "/project/root" });
(api as any).mockResolvedValue({ events: [] });
(subscribeSse as any).mockReturnValue(() => {});
(fetchGitStatus as any).mockResolvedValue({
branch: "main",
commit: "abc1234",
isDirty: false,
ahead: 0,
behind: 0,
});
(fetchGitCommits as any).mockResolvedValue([
{
hash: "abc1234def5678",
shortHash: "abc1234",
message: "Test commit",
body: "Detailed description\n\nMultiple paragraphs.",
author: "User",
date: "2026-01-01T00:00:00Z",
parents: [],
},
]);
(fetchCommitDiff as any).mockResolvedValue({
stat: " file.ts | 2 +-",
patch: "diff --git a/file.ts b/file.ts\n-old\n+new",
});
(fetchGitBranches as any).mockResolvedValue([
{ name: "main", isCurrent: true, remote: "origin/main", lastCommitDate: "2026-01-01T00:00:00Z" },
{ name: "feature", isCurrent: false, lastCommitDate: "2026-01-02T00:00:00Z" },
]);
(fetchGitWorktrees as any).mockResolvedValue([
{
path: "/worktrees/kb-001",
branch: "fusion/fn-001",
isMain: false,
isBare: false,
taskId: "FN-001",
},
{ path: "/repo", branch: "main", isMain: true, isBare: false },
]);
(fetchGitStashList as any).mockResolvedValue([
{
index: 0,
message: "WIP on main: abc1234 Test commit",
date: "2026-01-01T00:00:00Z",
branch: "main",
},
]);
(fetchFileChanges as any).mockResolvedValue([
{ file: "src/app.ts", status: "modified", staged: false },
{ file: "src/index.ts", status: "added", staged: true },
]);
(fetchGitFileDiff as any).mockResolvedValue({
stat: " src/app.ts | 5 ++---",
patch: "diff --git a/src/app.ts b/src/app.ts\n-old\n+new",
});
(stageFiles as any).mockResolvedValue({ staged: ["src/app.ts"] });
(unstageFiles as any).mockResolvedValue({ unstaged: ["src/index.ts"] });
(createCommit as any).mockResolvedValue({ hash: "def5678", message: "test commit" });
(createStash as any).mockResolvedValue({ message: "Stash created" });
(applyStash as any).mockResolvedValue({ message: "Stash applied" });
(dropStash as any).mockResolvedValue({ message: "Stash dropped" });
(fetchStashDiff as any).mockResolvedValue({
stat: " README.md | 2 ++",
patch: "diff --git a/README.md b/README.md\n+stash diff",
});
(discardChanges as any).mockResolvedValue({ discarded: ["src/app.ts"] });
(createBranch as any).mockResolvedValue(undefined);
(checkoutBranch as any).mockResolvedValue(undefined);
(deleteBranch as any).mockResolvedValue(undefined);
(fetchRemote as any).mockResolvedValue({ fetched: true, message: "Fetched" });
(pullBranch as any).mockResolvedValue({ success: true, message: "Already up to date." });
(pushBranch as any).mockResolvedValue({ success: true, message: "Push completed" });
(fetchGitRemotesDetailed as any).mockResolvedValue([
{ name: "origin", fetchUrl: "https://github.com/dustinbyrne/kb.git", pushUrl: "https://github.com/dustinbyrne/kb.git" },
]);
(addGitRemote as any).mockResolvedValue(undefined);
(removeGitRemote as any).mockResolvedValue(undefined);
(renameGitRemote as any).mockResolvedValue(undefined);
(updateGitRemoteUrl as any).mockResolvedValue(undefined);
(fetchAheadCommits as any).mockResolvedValue([]);
(fetchRemoteCommits as any).mockResolvedValue([]);
(fetchWorkspaceRepos as any).mockResolvedValue({ repos: [] });
});
// ── Workspace root-race toast suppression ───────────────────
// FNXC:Workspace 2026-06-25-00:10: a workspace project's root is non-git, so the first git status
// (no repoPath yet) fails "Not a git repository". That benign race must NOT toast; a real
// non-workspace project with the same error must.
it("does NOT toast 'Not a git repository' for a workspace project's initial root-race fetch", async () => {
(fetchWorkspaceRepos as any).mockResolvedValue({ repos: ["openvide", "swarmclaw"] });
// Root (no repoPath) → not a git repo; a real sub-repo → resolves.
(fetchGitStatus as any).mockImplementation((_pid: unknown, _opts: unknown, repoPath?: string) =>
repoPath
? Promise.resolve({ branch: "main", commit: "abc1234", isDirty: false, ahead: 0, behind: 0 })
: Promise.reject(new Error("Not a git repository")),
);
render(<GitManagerModal isOpen={true} onClose={vi.fn()} tasks={mockTasks} addToast={mockAddToast} />);
// Wait until the re-fetch against the selected sub-repo has happened.
await waitFor(() => {
expect((fetchGitStatus as any).mock.calls.some((c: unknown[]) => c[2] === "openvide")).toBe(true);
});
expect(mockAddToast).not.toHaveBeenCalledWith(expect.stringMatching(/not a git repository/i), "error");
});
it("DOES toast 'Not a git repository' for a real non-workspace project", async () => {
(fetchWorkspaceRepos as any).mockResolvedValue({ repos: [] });
(fetchGitStatus as any).mockRejectedValue(new Error("Not a git repository"));
render(<GitManagerModal isOpen={true} onClose={vi.fn()} tasks={mockTasks} addToast={mockAddToast} />);
await waitFor(() => {
expect(mockAddToast).toHaveBeenCalledWith(expect.stringMatching(/not a git repository/i), "error");
});
});
it("does not let a stale workspace project's late detection suppress a real error after a rapid project switch", async () => {
// FNXC:Workspace 2026-06-25-09:40 (generation guard): switch from workspace project A (whose
// fetchWorkspaceRepos resolves LATE) to broken non-workspace project B before A resolves. A's late
// "workspace" verdict must be abandoned (generation guard) so it can't suppress B's real error.
let resolveA: (v: { repos: string[] }) => void = () => {};
const aPromise = new Promise<{ repos: string[] }>((r) => { resolveA = r; });
(fetchWorkspaceRepos as any).mockImplementation((pid: string) =>
pid === "projA" ? aPromise : Promise.resolve({ repos: [] }));
(fetchGitStatus as any).mockRejectedValue(new Error("Not a git repository"));
const { rerender } = render(
<GitManagerModal isOpen={true} onClose={vi.fn()} tasks={mockTasks} addToast={mockAddToast} projectId="projA" />,
);
// Switch to B before A's detection resolves.
rerender(<GitManagerModal isOpen={true} onClose={vi.fn()} tasks={mockTasks} addToast={mockAddToast} projectId="projB" />);
// A resolves late as a workspace — must be ignored for the now-current project B.
resolveA({ repos: ["openvide"] });
// B is a genuinely broken non-workspace repo → its error must still surface.
await waitFor(() => {
expect(mockAddToast).toHaveBeenCalledWith(expect.stringMatching(/not a git repository/i), "error");
});
});
// ── Basic Rendering ─────────────────────────────────────────
it("renders nothing when not open", () => {
const { container } = render(
<GitManagerModal isOpen={false} onClose={vi.fn()} tasks={mockTasks} addToast={mockAddToast} />
);
expect(container.firstChild).toBeNull();
});
it("renders modal when open", async () => {
render(
<GitManagerModal isOpen={true} onClose={vi.fn()} tasks={mockTasks} addToast={mockAddToast} />
);
await waitFor(() => {
expect(screen.getByText("Git Manager")).toBeInTheDocument();
});
});
it("renders git-manager overlay class hook for mobile fullscreen CSS", async () => {
const { container } = render(
<GitManagerModal isOpen={true} onClose={vi.fn()} tasks={mockTasks} addToast={mockAddToast} />
);
await waitFor(() => {
expect(screen.getByText("Git Manager")).toBeInTheDocument();
});
expect(container.querySelector(".modal-overlay.git-manager-modal-overlay")).toBeTruthy();
});
it("keeps the sidebar-launched Git Manager overlay transparent and click-through like Files", () => {
const css = loadAllAppCss();
const overlayRule = css.match(/\.modal-overlay\.git-manager-modal-overlay\.git-manager-modal-overlay\s*\{([^}]*)\}/)?.[1] ?? "";
const panelRule = css.match(/\.modal\.gm-modal\s*\{([^}]*)\}/)?.[1] ?? "";
expect(overlayRule).toContain("background: transparent");
expect(overlayRule).toContain("backdrop-filter: none");
expect(overlayRule).toContain("-webkit-backdrop-filter: none");
expect(overlayRule).toContain("pointer-events: none");
expect(panelRule).toContain("pointer-events: auto");
});
it("applies mobile keyboard CSS variables to gm-modal when keyboard is open", async () => {
mockUseViewportMode.mockReturnValue("mobile");
mockUseMobileKeyboard.mockReturnValue({
keyboardOverlap: 240,
viewportHeight: 620,
viewportOffsetTop: 18,
keyboardOpen: true,
});
const { container } = render(
<GitManagerModal isOpen={true} onClose={vi.fn()} tasks={mockTasks} addToast={mockAddToast} />
);
await waitFor(() => {
expect(screen.getByText("Git Manager")).toBeInTheDocument();
});
const modal = container.querySelector(".modal.gm-modal") as HTMLElement;
expect(modal.style.getPropertyValue("--keyboard-overlap")).toBe("240px");
expect(modal.style.getPropertyValue("--vv-height")).toBe("620px");
expect(modal.style.getPropertyValue("--vv-offset-top")).toBe("18px");
});
it("renders all navigation sections plus Refresh without a workspace selector by default", async () => {
render(
<GitManagerModal isOpen={true} onClose={vi.fn()} tasks={mockTasks} addToast={mockAddToast} />
);
const tablist = await screen.findByRole("tablist", { name: /git manager sections/i });
for (const label of gitManagerSectionLabels) {
expect(within(tablist).getByRole("tab", { name: label })).toBeInTheDocument();
}
expect(within(tablist).getByRole("button", { name: "Refresh" })).toBeInTheDocument();
expect(within(tablist).queryByRole("combobox", { name: "Select repository" })).not.toBeInTheDocument();
});
it("renders all navigation sections plus Refresh when the workspace repo selector is present", async () => {
(fetchWorkspaceRepos as any).mockResolvedValue({ repos: ["apps/dashboard", "packages/core"] });
render(
<GitManagerModal isOpen={true} onClose={vi.fn()} tasks={mockTasks} addToast={mockAddToast} />
);
const tablist = await screen.findByRole("tablist", { name: /git manager sections/i });
const repoSelector = await within(tablist).findByRole("combobox", { name: "Select repository" });
expect(repoSelector).toHaveValue("apps/dashboard");
for (const label of gitManagerSectionLabels) {
expect(within(tablist).getByRole("tab", { name: label })).toBeInTheDocument();
}
expect(within(tablist).getByRole("button", { name: "Refresh" })).toBeInTheDocument();
});
it.each([
["null status and no file changes", null, []],
["populated status and populated file changes", {
branch: "main",
commit: "abc1234",
isDirty: true,
ahead: 1,
behind: 0,
}, [
{ file: "src/app.ts", status: "modified", staged: false },
{ file: "src/index.ts", status: "added", staged: true },
]],
])("renders the mobile tablist and all static section tabs with %s", async (_name, statusResult, fileChangeResult) => {
mockUseViewportMode.mockReturnValue("mobile");
(fetchGitStatus as any).mockResolvedValue(statusResult);
(fetchFileChanges as any).mockResolvedValue(fileChangeResult);
render(
<GitManagerModal isOpen={true} onClose={vi.fn()} tasks={mockTasks} addToast={mockAddToast} />
);
const tablist = await screen.findByRole("tablist", { name: /git manager sections/i });
const tabs = within(tablist).getAllByRole("tab");
expect(tabs).toHaveLength(gitManagerSectionLabels.length);
for (const label of gitManagerSectionLabels) {
expect(within(tablist).getByRole("tab", { name: label })).toBeInTheDocument();
}
});
it("switches sections from the tab strip on mobile", async () => {
mockUseViewportMode.mockReturnValue("mobile");
render(
<GitManagerModal isOpen={true} onClose={vi.fn()} tasks={mockTasks} addToast={mockAddToast} />
);
const tablist = await screen.findByRole("tablist", { name: /git manager sections/i });
const statusTab = within(tablist).getByRole("tab", { name: "Status" });
const branchesTab = within(tablist).getByRole("tab", { name: "Branches" });
expect(statusTab).toHaveAttribute("aria-selected", "true");
await userEvent.click(branchesTab);
await waitFor(() => {
expect(branchesTab).toHaveAttribute("aria-selected", "true");
expect(statusTab).toHaveAttribute("aria-selected", "false");
expect(screen.getByTestId("branches-panel")).toBeInTheDocument();
});
});
it("renders Stash Recovery inside the Recovery section", async () => {
(api as any).mockImplementation((path: string) => {
if (path === "/stash-recovery/orphans") {
return Promise.resolve({
records: [
{
sha: "abcdef1234567890",
sourceTaskId: "FN-100",
createdAt: "2026-06-21T00:00:00Z",
classification: "unknown",
changedPaths: ["src/file.ts"],
},
],
});
}
return Promise.resolve({ events: [] });
});
render(
<GitManagerModal isOpen={true} onClose={vi.fn()} tasks={mockTasks} addToast={mockAddToast} />
);
fireEvent.click(await screen.findByRole("tab", { name: /recovery/i }));
await waitFor(() => {
expect(screen.getByRole("heading", { name: "Stash Recovery" })).toBeInTheDocument();
expect(screen.getByText("1 orphans")).toBeInTheDocument();
expect(screen.getByText("FN-100")).toBeInTheDocument();
});
});
it("renders the empty Stash Recovery state inside the Recovery section", async () => {
(api as any).mockImplementation((path: string) => {
if (path === "/stash-recovery/orphans") {
return Promise.resolve({ records: [] });
}
return Promise.resolve({ events: [] });
});
render(
<GitManagerModal isOpen={true} onClose={vi.fn()} tasks={mockTasks} addToast={mockAddToast} />
);
fireEvent.click(await screen.findByRole("tab", { name: /recovery/i }));
await waitFor(() => {
expect(screen.getByText("No orphaned merger autostashes found.")).toBeInTheDocument();
});
});
// ── Keyboard Navigation ─────────────────────────────────────
it("closes on Escape key", async () => {
const onClose = vi.fn();
render(
<GitManagerModal isOpen={true} onClose={onClose} tasks={mockTasks} addToast={mockAddToast} />
);
await waitFor(() => {
expect(screen.getByText("Git Manager")).toBeInTheDocument();
});
fireEvent.keyDown(document, { key: "Escape" });
expect(onClose).toHaveBeenCalled();
});
it("navigates sections with Alt+Arrow keys", async () => {
render(
<GitManagerModal isOpen={true} onClose={vi.fn()} tasks={mockTasks} addToast={mockAddToast} />
);
await waitFor(() => {
expect(screen.getByText("Repository Status")).toBeInTheDocument();
});
// Alt+Down should go to Changes
fireEvent.keyDown(document, { key: "ArrowDown", altKey: true });
await waitFor(() => {
expect(fetchFileChanges).toHaveBeenCalled();
});
});
// ── Status Panel ────────────────────────────────────────────
it("fetches status on mount and shows data", async () => {
render(
<GitManagerModal isOpen={true} onClose={vi.fn()} tasks={mockTasks} addToast={mockAddToast} />
);
await waitFor(() => {
expect(fetchGitStatus).toHaveBeenCalled();
expect(screen.getByText("main")).toBeInTheDocument();
expect(screen.getByText("Clean")).toBeInTheDocument();
});
});
it("copies status commit hash through execCommand when Clipboard API is unavailable", async () => {
const execCommand = mockClipboardFallback(true);
render(<GitManagerModal isOpen={true} onClose={vi.fn()} tasks={mockTasks} addToast={mockAddToast} />);
await waitFor(() => expect(screen.getByText("main")).toBeInTheDocument());
fireEvent.click(screen.getByTitle("Copy short commit hash"));
await waitFor(() => expect(execCommand).toHaveBeenCalledWith("copy"));
expect(mockAddToast).toHaveBeenCalledWith("Copied commit hash", "success");
});
it("shows dirty status when working tree is modified", async () => {
(fetchGitStatus as any).mockResolvedValue({
branch: "main",
commit: "abc1234",
isDirty: true,
ahead: 0,
behind: 0,
});
render(
<GitManagerModal isOpen={true} onClose={vi.fn()} tasks={mockTasks} addToast={mockAddToast} />
);
await waitFor(() => {
expect(screen.getByText("Modified")).toBeInTheDocument();
});
});
it("shows ahead/behind indicators", async () => {
(fetchGitStatus as any).mockResolvedValue({
branch: "main",
commit: "abc1234",
isDirty: false,
ahead: 2,
behind: 3,
});
render(
<GitManagerModal isOpen={true} onClose={vi.fn()} tasks={mockTasks} addToast={mockAddToast} />
);
await waitFor(() => {
expect(screen.getByText("2")).toBeInTheDocument();
expect(screen.getByText("3")).toBeInTheDocument();
});
});
it("shows up to date when in sync", async () => {
render(
<GitManagerModal isOpen={true} onClose={vi.fn()} tasks={mockTasks} addToast={mockAddToast} />
);
await waitFor(() => {
expect(screen.getByText("Up to date")).toBeInTheDocument();
});
});
// ── Tab Switching ───────────────────────────────────────────
it("switches tabs when clicking navigation", async () => {
render(
<GitManagerModal isOpen={true} onClose={vi.fn()} tasks={mockTasks} addToast={mockAddToast} />
);
await waitFor(() => {
expect(screen.getByText("Repository Status")).toBeInTheDocument();
});
// Switch to Commits
fireEvent.click(screen.getByRole("tab", { name: /commits/i }));
await waitFor(() => {
expect(fetchGitCommits).toHaveBeenCalled();
});
// Switch to Branches
fireEvent.click(screen.getByRole("tab", { name: /branches/i }));
await waitFor(() => {
expect(fetchGitBranches).toHaveBeenCalled();
});
});
// ── Changes Panel ──────────────────────────────────────────
it("shows file changes in the Changes panel", async () => {
render(
<GitManagerModal isOpen={true} onClose={vi.fn()} tasks={mockTasks} addToast={mockAddToast} />
);
fireEvent.click(screen.getByRole("tab", { name: /changes/i }));
await waitFor(() => {
expect(screen.getByText("src/app.ts")).toBeInTheDocument();
expect(screen.getByText("src/index.ts")).toBeInTheDocument();
});
});
it("shows unstaged and staged file sections", async () => {
render(
<GitManagerModal isOpen={true} onClose={vi.fn()} tasks={mockTasks} addToast={mockAddToast} />
);
fireEvent.click(screen.getByRole("tab", { name: /changes/i }));
await waitFor(() => {
expect(screen.getByText("Unstaged Changes (1)")).toBeInTheDocument();
expect(screen.getByText("Staged Changes (1)")).toBeInTheDocument();
});
});
it("keeps separate scroll containers for unstaged and staged file lists", async () => {
(fetchFileChanges as any).mockResolvedValue([
...Array.from({ length: 30 }, (_, index) => ({
file: `src/unstaged-${index}.ts`,
status: "modified",
staged: false,
})),
{ file: "src/staged.ts", status: "added", staged: true },
]);
render(
<GitManagerModal isOpen={true} onClose={vi.fn()} tasks={mockTasks} addToast={mockAddToast} />
);
fireEvent.click(screen.getByRole("tab", { name: /changes/i }));
await waitFor(() => {
expect(screen.getByText("Unstaged Changes (30)")).toBeInTheDocument();
expect(screen.getByText("Staged Changes (1)")).toBeInTheDocument();
});
const unstagedList = screen.getByTestId("gm-file-list-unstaged");
const stagedList = screen.getByTestId("gm-file-list-staged");
expect(unstagedList).toHaveClass("gm-file-list", "gm-file-list-unstaged");
expect(stagedList).toHaveClass("gm-file-list", "gm-file-list-staged");
expect(within(unstagedList).getAllByRole("button").length).toBeGreaterThan(10);
expect(within(stagedList).getAllByRole("button").length).toBeGreaterThan(0);
});
it("stages all files when Stage All is clicked", async () => {
const user = userEvent.setup();
render(
<GitManagerModal isOpen={true} onClose={vi.fn()} tasks={mockTasks} addToast={mockAddToast} />
);
fireEvent.click(screen.getByRole("tab", { name: /changes/i }));
await waitFor(() => {
expect(screen.getByText("Stage All")).toBeInTheDocument();
});
await user.click(screen.getByText("Stage All"));
await waitFor(() => {
expectLatestCallStartsWith(stageFiles as any, ["src/app.ts"]);
});
});
it("unstages all files when Unstage All is clicked", async () => {
const user = userEvent.setup();
render(
<GitManagerModal isOpen={true} onClose={vi.fn()} tasks={mockTasks} addToast={mockAddToast} />
);
fireEvent.click(screen.getByRole("tab", { name: /changes/i }));
await waitFor(() => {
expect(screen.getByText("Unstage All")).toBeInTheDocument();
});
await user.click(screen.getByText("Unstage All"));
await waitFor(() => {
expectLatestCallStartsWith(unstageFiles as any, ["src/index.ts"]);
});
});
it("commits staged changes", async () => {
const user = userEvent.setup();
render(
<GitManagerModal isOpen={true} onClose={vi.fn()} tasks={mockTasks} addToast={mockAddToast} />
);
fireEvent.click(screen.getByRole("tab", { name: /changes/i }));
await waitFor(() => {
expect(screen.getByPlaceholderText("Commit message...")).toBeInTheDocument();
});
const textarea = screen.getByPlaceholderText("Commit message...");
await user.type(textarea, "fix: update app");
const commitBtn = screen.getByRole("button", { name: /^commit$/i });
await user.click(commitBtn);
await waitFor(() => {
expectLatestCallStartsWith(createCommit as any, "fix: update app");
});
});
it("commits staged changes and then pushes from the Changes form", async () => {
const user = userEvent.setup();
render(
<GitManagerModal isOpen={true} onClose={vi.fn()} tasks={mockTasks} addToast={mockAddToast} />
);
fireEvent.click(screen.getByRole("tab", { name: /changes/i }));
await screen.findByPlaceholderText("Commit message...");
await user.type(screen.getByPlaceholderText("Commit message..."), "feat: publish staged work");
await user.click(screen.getByRole("button", { name: /commit and push/i }));
await waitFor(() => {
expectLatestCallStartsWith(createCommit as any, "feat: publish staged work");
expect(pushBranch).toHaveBeenCalledWith(undefined, undefined);
});
expect((createCommit as any).mock.invocationCallOrder[0]).toBeLessThan(
(pushBranch as any).mock.invocationCallOrder[0]
);
expect(mockAddToast).toHaveBeenCalledWith("Push completed", "success");
});
it("disables Commit and Push when the message is empty or no staged files exist", async () => {
const user = userEvent.setup();
const { unmount } = render(
<GitManagerModal isOpen={true} onClose={vi.fn()} tasks={mockTasks} addToast={mockAddToast} />
);
fireEvent.click(screen.getByRole("tab", { name: /changes/i }));
const commitAndPush = await screen.findByRole("button", { name: /commit and push/i });
expect(commitAndPush).toBeDisabled();
unmount();
(fetchFileChanges as any).mockResolvedValueOnce([
{ file: "src/app.ts", status: "modified", staged: false },
]);
render(
<GitManagerModal isOpen={true} onClose={vi.fn()} tasks={mockTasks} addToast={mockAddToast} />
);
fireEvent.click(screen.getByRole("tab", { name: /changes/i }));
const textarea = await screen.findByPlaceholderText("Commit message...");
await user.type(textarea, "fix: no staged changes");
expect(screen.getByRole("button", { name: /commit and push/i })).toBeDisabled();
});
it("keeps local commit context visible when commit succeeds but push fails", async () => {
const user = userEvent.setup();
(pushBranch as any).mockRejectedValueOnce(new Error("Remote rejected"));
render(
<GitManagerModal isOpen={true} onClose={vi.fn()} tasks={mockTasks} addToast={mockAddToast} />
);
fireEvent.click(screen.getByRole("tab", { name: /changes/i }));
const textarea = await screen.findByPlaceholderText("Commit message...");
await user.type(textarea, "feat: keep context");
await user.click(screen.getByRole("button", { name: /commit and push/i }));
await waitFor(() => {
expectLatestCallStartsWith(createCommit as any, "feat: keep context");
expect(pushBranch).toHaveBeenCalled();
expect(mockAddToast).toHaveBeenCalledWith(
expect.stringContaining("Committed locally"),
"error"
);
});
expect(screen.getByDisplayValue("feat: keep context")).toBeInTheDocument();
});
it("disables Commit button when no message or no staged files", async () => {
(fetchFileChanges as any).mockResolvedValue([
{ file: "src/app.ts", status: "modified", staged: false },
]);
render(
<GitManagerModal isOpen={true} onClose={vi.fn()} tasks={mockTasks} addToast={mockAddToast} />
);
fireEvent.click(screen.getByRole("tab", { name: /changes/i }));
await waitFor(() => {
const commitBtn = screen.getByRole("button", { name: /^commit$/i });
expect(commitBtn).toBeDisabled();
});
});
it("fetches unstaged file diff when clicking an unstaged file", async () => {
const user = userEvent.setup();
render(
<GitManagerModal isOpen={true} onClose={vi.fn()} tasks={mockTasks} addToast={mockAddToast} />
);
fireEvent.click(screen.getByRole("tab", { name: /changes/i }));
await waitFor(() => {
expect(screen.getByText("src/app.ts")).toBeInTheDocument();
});
await user.click(screen.getByText("src/app.ts"));
await waitFor(() => {
expectLatestCallStartsWith(fetchGitFileDiff as any, "src/app.ts", false);
});
});
it("fetches staged file diff when clicking a staged file", async () => {
const user = userEvent.setup();
render(
<GitManagerModal isOpen={true} onClose={vi.fn()} tasks={mockTasks} addToast={mockAddToast} />
);
fireEvent.click(screen.getByRole("tab", { name: /changes/i }));
await waitFor(() => {
expect(screen.getByText("src/index.ts")).toBeInTheDocument();
});
await user.click(screen.getByText("src/index.ts"));
await waitFor(() => {
expectLatestCallStartsWith(fetchGitFileDiff as any, "src/index.ts", true);
});
});
it("stage action button still stages file without triggering diff fetch", async () => {
const user = userEvent.setup();
render(
<GitManagerModal isOpen={true} onClose={vi.fn()} tasks={mockTasks} addToast={mockAddToast} />
);
fireEvent.click(screen.getByRole("tab", { name: /changes/i }));
const stageButton = await screen.findByTitle("Stage file");
await user.click(stageButton);
await waitFor(() => {
expectLatestCallStartsWith(stageFiles as any, ["src/app.ts"]);
expect(fetchGitFileDiff).not.toHaveBeenCalled();
});
});
it("unstage action button still unstages file without triggering diff fetch", async () => {
const user = userEvent.setup();
render(
<GitManagerModal isOpen={true} onClose={vi.fn()} tasks={mockTasks} addToast={mockAddToast} />
);
fireEvent.click(screen.getByRole("tab", { name: /changes/i }));
const unstageButton = await screen.findByTitle("Unstage file");
await user.click(unstageButton);
await waitFor(() => {
expectLatestCallStartsWith(unstageFiles as any, ["src/index.ts"]);
expect(fetchGitFileDiff).not.toHaveBeenCalled();
});
});
it("renders fetched file diff patch content", async () => {
const user = userEvent.setup();
(fetchGitFileDiff as any).mockResolvedValue({
stat: " src/app.ts | 2 +\\-",
patch: "diff --git a/src/app.ts b/src/app.ts\\n+line",
});
render(
<GitManagerModal isOpen={true} onClose={vi.fn()} tasks={mockTasks} addToast={mockAddToast} />
);
fireEvent.click(screen.getByRole("tab", { name: /changes/i }));
await waitFor(() => {
expect(screen.getByText("src/app.ts")).toBeInTheDocument();
});
await user.click(screen.getByText("src/app.ts"));
await waitFor(() => {
const patchBlock = document.querySelector(".gm-diff-patch");
expect(patchBlock?.textContent).toContain("diff --git a/src/app.ts b/src/app.ts");
expect(patchBlock?.textContent).toContain("+line");
});
});
// ── Commits Panel ──────────────────────────────────────────
it("loads commits and shows them", async () => {
render(
<GitManagerModal isOpen={true} onClose={vi.fn()} tasks={mockTasks} addToast={mockAddToast} />
);
fireEvent.click(screen.getByRole("tab", { name: /commits/i }));
await waitFor(() => {
expect(screen.getByText("Test commit")).toBeInTheDocument();
expect(screen.getByText("abc1234")).toBeInTheDocument();
});
});
it("searches commits by message", async () => {
const user = userEvent.setup();
(fetchGitCommits as any).mockResolvedValue([
{ hash: "abc1234", shortHash: "abc1", message: "fix bug", author: "User", date: "2026-01-01T00:00:00Z", parents: [] },
{ hash: "def5678", shortHash: "def5", message: "add feature", author: "User", date: "2026-01-02T00:00:00Z", parents: [] },
]);
render(
<GitManagerModal isOpen={true} onClose={vi.fn()} tasks={mockTasks} addToast={mockAddToast} />
);
fireEvent.click(screen.getByRole("tab", { name: /commits/i }));
await waitFor(() => {
expect(screen.getByText("fix bug")).toBeInTheDocument();
expect(screen.getByText("add feature")).toBeInTheDocument();
});
const searchInput = screen.getByPlaceholderText("Search commits...");
await user.type(searchInput, "fix");
await waitFor(() => {
expect(screen.getByText("fix bug")).toBeInTheDocument();
expect(screen.queryByText("add feature")).not.toBeInTheDocument();
});
});
it("shows merge badge for merge commits", async () => {
(fetchGitCommits as any).mockResolvedValue([
{ hash: "abc1234", shortHash: "abc1", message: "Merge branch", author: "User", date: "2026-01-01T00:00:00Z", parents: ["111", "222"] },
]);
render(
<GitManagerModal isOpen={true} onClose={vi.fn()} tasks={mockTasks} addToast={mockAddToast} />
);
fireEvent.click(screen.getByRole("tab", { name: /commits/i }));
await waitFor(() => {
expect(screen.getByText("merge")).toBeInTheDocument();
});
});
it("shows commit diff when commit is clicked", async () => {
render(
<GitManagerModal isOpen={true} onClose={vi.fn()} tasks={mockTasks} addToast={mockAddToast} />
);
fireEvent.click(screen.getByRole("tab", { name: /commits/i }));
await waitFor(() => {
expect(screen.getByText("Test commit")).toBeInTheDocument();
});
// Click the commit to expand diff
fireEvent.click(screen.getByText("Test commit"));
await waitFor(() => {
expectLatestCallStartsWith(fetchCommitDiff as any, "abc1234def5678");
});
});
it("shows commit body when expanding a commit in commits panel", async () => {
render(
<GitManagerModal isOpen={true} onClose={vi.fn()} tasks={mockTasks} addToast={mockAddToast} />
);
fireEvent.click(screen.getByRole("tab", { name: /commits/i }));
await waitFor(() => {
expect(screen.getByText("Test commit")).toBeInTheDocument();
});
fireEvent.click(screen.getByText("Test commit"));
await waitFor(() => {
const fullMessage = document.querySelector(".gm-commit-message-full");
expect(fullMessage?.textContent).toContain("Detailed description");
expect(fullMessage?.textContent).toContain("Multiple paragraphs.");
});
});
it("targets current checkout commits by default and registered worktree commits when selected", async () => {
const user = userEvent.setup();
(fetchGitCommits as any).mockImplementation(async (_limit: number, _projectId?: string, _repoPath?: string, worktreePath?: string) => [
{
hash: worktreePath ? "def5678" : "abc1234",
shortHash: worktreePath ? "def5678" : "abc1234",
message: worktreePath ? "Worktree commit" : "Current checkout commit",
author: "User",
date: "2026-01-01T00:00:00Z",
parents: [],
},
]);
render(
<GitManagerModal isOpen={true} onClose={vi.fn()} tasks={mockTasks} addToast={mockAddToast} />
);
fireEvent.click(screen.getByRole("tab", { name: /commits/i }));
await waitFor(() => expect(screen.getByText("Current checkout commit")).toBeInTheDocument());
expectLatestCallStartsWith(fetchGitCommits as any, 20, undefined, undefined);
expect((fetchGitCommits as any).mock.calls.at(-1)?.[3]).toBeUndefined();
await user.selectOptions(screen.getByLabelText("Select commit history target"), "/worktrees/kb-001");
await waitFor(() => expect(screen.getByText("Worktree commit")).toBeInTheDocument());
expectLatestCallStartsWith(fetchGitCommits as any, 20, undefined, undefined, "/worktrees/kb-001");
fireEvent.click(screen.getByText("Worktree commit"));
await waitFor(() => expect(fetchCommitDiff).toHaveBeenCalledWith("def5678", undefined, undefined, "/worktrees/kb-001"));
});
it("labels duplicate basename worktree commit targets with branch task and full path", async () => {
(fetchGitWorktrees as any).mockResolvedValue([
{ path: "/tmp/a/kb", branch: "fusion/fn-111", isMain: false, isBare: false, taskId: "FN-111" },
{ path: "/tmp/b/kb", branch: "fusion/fn-222", isMain: false, isBare: false, taskId: "FN-222" },
]);
render(
<GitManagerModal isOpen={true} onClose={vi.fn()} tasks={mockTasks} addToast={mockAddToast} />
);
fireEvent.click(screen.getByRole("tab", { name: /commits/i }));
const select = await screen.findByLabelText("Select commit history target");
const optionLabels = within(select).getAllByRole("option").map((option) => option.textContent);
expect(optionLabels).toContain("fusion/fn-111 — FN-111 — /tmp/a/kb");
expect(optionLabels).toContain("fusion/fn-222 — FN-222 — /tmp/b/kb");
});
it("switches from populated worktrees to their commit history without empty action shells", async () => {
const user = userEvent.setup();
render(
<GitManagerModal isOpen={true} onClose={vi.fn()} tasks={mockTasks} addToast={mockAddToast} />
);
fireEvent.click(screen.getByRole("tab", { name: /worktrees/i }));
const worktreesPanel = await screen.findByTestId("worktrees-panel");
expect(within(worktreesPanel).getAllByRole("button", { name: /view commits/i })).toHaveLength(2);
await user.click(within(worktreesPanel).getByRole("button", { name: /View commits for fusion\/fn-001 FN-001 \/worktrees\/kb-001/i }));
await screen.findByTestId("commits-panel");
expectLatestCallStartsWith(fetchGitCommits as any, 20, undefined, undefined, "/worktrees/kb-001");
});
it("renders empty worktrees without orphaned View commits buttons", async () => {
(fetchGitWorktrees as any).mockResolvedValue([]);
render(
<GitManagerModal isOpen={true} onClose={vi.fn()} tasks={mockTasks} addToast={mockAddToast} />
);
fireEvent.click(screen.getByRole("tab", { name: /worktrees/i }));
const worktreesPanel = await screen.findByTestId("worktrees-panel");
expect(within(worktreesPanel).getByText("No worktrees found")).toBeInTheDocument();
expect(within(worktreesPanel).queryByRole("button", { name: /view commits/i })).not.toBeInTheDocument();
});
it("clears expanded commit diff state before fetching a new worktree target", async () => {
const user = userEvent.setup();
(fetchGitCommits as any).mockResolvedValue([
{ hash: "abc1234", shortHash: "abc1234", message: "Current checkout commit", author: "User", date: "2026-01-01T00:00:00Z", parents: [], body: "Current body" },
]);
render(
<GitManagerModal isOpen={true} onClose={vi.fn()} tasks={mockTasks} addToast={mockAddToast} />
);
fireEvent.click(screen.getByRole("tab", { name: /commits/i }));
await waitFor(() => expect(screen.getByText("Current checkout commit")).toBeInTheDocument());
fireEvent.click(screen.getByText("Current checkout commit"));
await waitFor(() => expect(screen.getByText(/diff --git/)).toBeInTheDocument());
await user.selectOptions(screen.getByLabelText("Select commit history target"), "/worktrees/kb-001");
expect(screen.queryByText(/diff --git/)).not.toBeInTheDocument();
});
it("omits empty worktree selector shells but dedupes populated commit targets in embedded mobile layout", async () => {
const user = userEvent.setup();
(fetchGitWorktrees as any).mockResolvedValueOnce([]).mockResolvedValueOnce([
{ path: "/worktrees/kb-001", branch: "fusion/fn-001", isMain: false, isBare: false, taskId: "FN-001" },
{ path: "/worktrees/kb-001", branch: "fusion/fn-001", isMain: false, isBare: false, taskId: "FN-001" },
{ path: "/repo", branch: "main", isMain: true, isBare: false },
]);
const { rerender } = render(
<GitManagerModal isOpen={true} onClose={vi.fn()} tasks={mockTasks} addToast={mockAddToast} />
);
fireEvent.click(screen.getByRole("tab", { name: /commits/i }));
await waitFor(() => expect(fetchGitCommits).toHaveBeenCalled());
expect(screen.queryByLabelText("Select commit history target")).not.toBeInTheDocument();
mockUseViewportMode.mockReturnValue("mobile");
rerender(<GitManagerModal isOpen={false} onClose={vi.fn()} tasks={mockTasks} addToast={mockAddToast} presentation="embedded" />);
rerender(<GitManagerModal isOpen={true} onClose={vi.fn()} tasks={mockTasks} addToast={mockAddToast} presentation="embedded" />);
fireEvent.click(screen.getByRole("tab", { name: /commits/i }));
const select = await screen.findByLabelText("Select commit history target");
expect(screen.getByTestId("commits-panel").closest(".gm-modal--embedded")).toBeTruthy();
expect(within(select).getAllByRole("option")).toHaveLength(3);
expect(within(select).getByRole("option", { name: "fusion/fn-001 — FN-001 — /worktrees/kb-001" })).toBeInTheDocument();
expect(within(select).getByRole("option", { name: "Main — /repo" })).toBeInTheDocument();
await user.selectOptions(select, "/repo");
expectLatestCallStartsWith(fetchGitCommits as any, 20, undefined, undefined, "/repo");
});
it("keeps mutation routes scoped to the selected repository after changing the commits worktree target", async () => {
const user = userEvent.setup();
(fetchWorkspaceRepos as any).mockResolvedValue({ repos: ["packages/app"] });
render(
<GitManagerModal isOpen={true} onClose={vi.fn()} tasks={mockTasks} addToast={mockAddToast} projectId="proj-1" />
);
fireEvent.click(screen.getByRole("tab", { name: /commits/i }));
await screen.findByLabelText("Select commit history target");
await waitFor(() => expectLatestCallStartsWith(fetchGitCommits as any, 20, "proj-1", "packages/app"));
await user.selectOptions(screen.getByLabelText("Select commit history target"), "/worktrees/kb-001");
fireEvent.click(screen.getByRole("tab", { name: /changes/i }));
await waitFor(() => expect(screen.getByText("src/app.ts")).toBeInTheDocument());
await user.click(screen.getByRole("button", { name: "Stage file" }));
await waitFor(() => expect(stageFiles).toHaveBeenCalled());
expectLatestCallStartsWith(stageFiles as any, ["src/app.ts"], "proj-1");
expect((stageFiles as any).mock.calls.at(-1)).toHaveLength(3);
expect((stageFiles as any).mock.calls.at(-1)?.[2]).not.toBe("/worktrees/kb-001");
});
it("does not render full message block for commits without body", async () => {
(fetchGitCommits as any).mockResolvedValue([
{
hash: "abc1234def5678",
shortHash: "abc1234",
message: "Subject only commit",
body: "",
author: "User",
date: "2026-01-01T00:00:00Z",
parents: [],
},
]);
render(
<GitManagerModal isOpen={true} onClose={vi.fn()} tasks={mockTasks} addToast={mockAddToast} />
);
fireEvent.click(screen.getByRole("tab", { name: /commits/i }));
await waitFor(() => {
expect(screen.getByText("Subject only commit")).toBeInTheDocument();
});
fireEvent.click(screen.getByText("Subject only commit"));
await waitFor(() => {
expectLatestCallStartsWith(fetchCommitDiff as any, "abc1234def5678");
});
const expandedDiff = document.querySelector(".gm-commit-diff");
expect(expandedDiff?.querySelector(".gm-commit-message-full")).toBeNull();
});
// ── Branches Panel ─────────────────────────────────────────
it("loads branches and shows current branch", async () => {
render(
<GitManagerModal isOpen={true} onClose={vi.fn()} tasks={mockTasks} addToast={mockAddToast} />
);
fireEvent.click(screen.getByRole("tab", { name: /branches/i }));
await waitFor(() => {
// "feature" appears in both the branch list and the base branch dropdown
const matches = screen.getAllByText("feature");
expect(matches.length).toBeGreaterThanOrEqual(1);
});
});
it("creates a new branch", async () => {
const user = userEvent.setup();
render(
<GitManagerModal isOpen={true} onClose={vi.fn()} tasks={mockTasks} addToast={mockAddToast} />
);
fireEvent.click(screen.getByRole("tab", { name: /branches/i }));
await waitFor(() => {
expect(screen.getByPlaceholderText("New branch name")).toBeInTheDocument();
});
const nameInput = screen.getByPlaceholderText("New branch name");
await user.type(nameInput, "new-feature");
const createButton = screen.getByRole("button", { name: /create/i });
await user.click(createButton);
await waitFor(() => {
expectLatestCallStartsWith(createBranch as any, "new-feature", undefined);
});
});
it("creates a branch from a selected base", async () => {
const user = userEvent.setup();
render(
<GitManagerModal isOpen={true} onClose={vi.fn()} tasks={mockTasks} addToast={mockAddToast} />
);
fireEvent.click(screen.getByRole("tab", { name: /branches/i }));
await waitFor(() => {
expect(screen.getByPlaceholderText("New branch name")).toBeInTheDocument();
});
await user.type(screen.getByPlaceholderText("New branch name"), "hotfix");
// Select base branch from dropdown
const select = screen.getByDisplayValue("Base: HEAD");
await user.selectOptions(select, "feature");
const createButton = screen.getByRole("button", { name: /create/i });
await user.click(createButton);
await waitFor(() => {
expectLatestCallStartsWith(createBranch as any, "hotfix", "feature");
});
});
it("filters branches by search", async () => {
const user = userEvent.setup();
render(
<GitManagerModal isOpen={true} onClose={vi.fn()} tasks={mockTasks} addToast={mockAddToast} />
);
fireEvent.click(screen.getByRole("tab", { name: /branches/i }));
await waitFor(() => {
const matches = screen.getAllByText("feature");
expect(matches.length).toBeGreaterThanOrEqual(1);
});
const searchInput = screen.getByPlaceholderText("Filter branches...");
await user.type(searchInput, "feat");
await waitFor(() => {
const featureMatches = screen.getAllByText("feature");
expect(featureMatches.length).toBeGreaterThan(0);
});
});
it("calls checkoutBranch when checkout button clicked", async () => {
render(
<GitManagerModal isOpen={true} onClose={vi.fn()} tasks={mockTasks} addToast={mockAddToast} />
);
fireEvent.click(screen.getByRole("tab", { name: /branches/i }));
await waitFor(() => {
const matches = screen.getAllByText("feature");
expect(matches.length).toBeGreaterThanOrEqual(1);
});
// The checkout button is associated with the "feature" branch (non-current)
const checkoutButtons = screen.getAllByTitle("Checkout");
expect(checkoutButtons.length).toBeGreaterThan(0);
fireEvent.click(checkoutButtons[0]);
await waitFor(() => {
expectLatestCallStartsWith(checkoutBranch as any, "feature");
});
});
it("calls deleteBranch when delete button clicked", async () => {
// Mock confirm
mockConfirm.mockResolvedValue(true);
render(
<GitManagerModal isOpen={true} onClose={vi.fn()} tasks={mockTasks} addToast={mockAddToast} />
);
fireEvent.click(screen.getByRole("tab", { name: /branches/i }));
await waitFor(() => {
const matches = screen.getAllByText("feature");
expect(matches.length).toBeGreaterThanOrEqual(1);
});
const deleteButtons = screen.getAllByTitle("Delete");
expect(deleteButtons.length).toBeGreaterThan(0);
fireEvent.click(deleteButtons[0]);
await waitFor(() => {
expectLatestCallStartsWith(deleteBranch as any, "feature");
});
});
// ── Branch Selection & Commits ───────────────────────────────
it("selects a branch and fetches its commits on click", async () => {
(fetchBranchCommits as any).mockResolvedValue([
{
hash: "def456789abc",
shortHash: "def4567",
message: "Feature commit",
author: "Dev",
date: "2026-03-01T00:00:00Z",
parents: [],
},
]);
render(
<GitManagerModal isOpen={true} onClose={vi.fn()} tasks={mockTasks} addToast={mockAddToast} />
);
fireEvent.click(screen.getByRole("tab", { name: /branches/i }));
await waitFor(() => {
expect(screen.getByTestId("branches-panel")).toBeInTheDocument();
});
// Find the branch items inside the branches list
const branchItems = screen.getByTestId("branches-panel").querySelectorAll(".gm-branch-item");
// "feature" is the non-current branch, should be the second one
expect(branchItems.length).toBe(2);
fireEvent.click(branchItems[1]);
await waitFor(() => {
expectLatestCallStartsWith(fetchBranchCommits as any, "feature", 10);
});
await waitFor(() => {
expect(screen.getByText("Feature commit")).toBeInTheDocument();
expect(screen.getByText("def4567")).toBeInTheDocument();
});
});
it("deselects a branch when clicking it again", async () => {
(fetchBranchCommits as any).mockResolvedValue([
{
hash: "def456789abc",
shortHash: "def4567",
message: "Feature commit",
author: "Dev",
date: "2026-03-01T00:00:00Z",
parents: [],
},
]);
render(
<GitManagerModal isOpen={true} onClose={vi.fn()} tasks={mockTasks} addToast={mockAddToast} />
);
fireEvent.click(screen.getByRole("tab", { name: /branches/i }));
await waitFor(() => {
expect(screen.getByTestId("branches-panel")).toBeInTheDocument();
});
const branchItems = screen.getByTestId("branches-panel").querySelectorAll(".gm-branch-item");
fireEvent.click(branchItems[1]);
await waitFor(() => {
expectLatestCallStartsWith(fetchBranchCommits as any, "feature", 10);
});
// Click again to deselect
fireEvent.click(branchItems[1]);
await waitFor(() => {
expect(screen.queryByText("Commits on feature")).not.toBeInTheDocument();
});
});
it("shows loading state while fetching branch commits", async () => {
// Make fetchBranchCommits hang (never resolve)
(fetchBranchCommits as any).mockImplementation(() => new Promise(() => {}));
render(
<GitManagerModal isOpen={true} onClose={vi.fn()} tasks={mockTasks} addToast={mockAddToast} />
);
fireEvent.click(screen.getByRole("tab", { name: /branches/i }));
await waitFor(() => {
expect(screen.getByTestId("branches-panel")).toBeInTheDocument();
});
const branchItems = screen.getByTestId("branches-panel").querySelectorAll(".gm-branch-item");
fireEvent.click(branchItems[1]);
await waitFor(() => {
expect(screen.getByText("Loading commits...")).toBeInTheDocument();
});
});
it("closes branch details via close button", async () => {
(fetchBranchCommits as any).mockResolvedValue([
{
hash: "def456789abc",
shortHash: "def4567",
message: "Feature commit",
author: "Dev",
date: "2026-03-01T00:00:00Z",
parents: [],
},
]);
render(
<GitManagerModal isOpen={true} onClose={vi.fn()} tasks={mockTasks} addToast={mockAddToast} />
);
fireEvent.click(screen.getByRole("tab", { name: /branches/i }));
await waitFor(() => {
expect(screen.getByTestId("branches-panel")).toBeInTheDocument();
});
const branchItems = screen.getByTestId("branches-panel").querySelectorAll(".gm-branch-item");
fireEvent.click(branchItems[1]);
await waitFor(() => {
expect(screen.getByText("Commits on feature")).toBeInTheDocument();
});
// Click the close button
const closeBtn = screen.getByTestId("close-branch-details");
fireEvent.click(closeBtn);
await waitFor(() => {
expect(screen.queryByText("Commits on feature")).not.toBeInTheDocument();
});
});
it("expands commit diff when clicking a commit in branch view", async () => {
(fetchBranchCommits as any).mockResolvedValue([
{
hash: "def456789abc",
shortHash: "def4567",
message: "Feature commit",
author: "Dev",
date: "2026-03-01T00:00:00Z",
parents: [],
},
]);
(fetchCommitDiff as any).mockResolvedValue({
stat: " file.ts | 2 +-",
patch: "diff --git a/file.ts b/file.ts\n-old\n+new",
});
render(
<GitManagerModal isOpen={true} onClose={vi.fn()} tasks={mockTasks} addToast={mockAddToast} />
);
fireEvent.click(screen.getByRole("tab", { name: /branches/i }));
await waitFor(() => {
expect(screen.getByTestId("branches-panel")).toBeInTheDocument();
});
const branchItems = screen.getByTestId("branches-panel").querySelectorAll(".gm-branch-item");
fireEvent.click(branchItems[1]);
await waitFor(() => {
expect(screen.getByText("Feature commit")).toBeInTheDocument();
});
// Click on the commit to expand diff
const commitRow = screen.getByTestId("branch-commit-def4567");
fireEvent.click(commitRow);
await waitFor(() => {
expectLatestCallStartsWith(fetchCommitDiff as any, "def456789abc");
});
});
it("handles fetchBranchCommits error gracefully", async () => {
(fetchBranchCommits as any).mockRejectedValue(new Error("Network error"));
render(
<GitManagerModal isOpen={true} onClose={vi.fn()} tasks={mockTasks} addToast={mockAddToast} />
);
fireEvent.click(screen.getByRole("tab", { name: /branches/i }));
await waitFor(() => {
expect(screen.getByTestId("branches-panel")).toBeInTheDocument();
});
const branchItems = screen.getByTestId("branches-panel").querySelectorAll(".gm-branch-item");
fireEvent.click(branchItems[1]);
await waitFor(() => {
expectLatestCallStartsWith(fetchBranchCommits as any, "feature", 10);
});
// Should show empty state since fetch failed
await waitFor(() => {
expect(screen.getByText("No commits found")).toBeInTheDocument();
});
});
it("shows merge badge for merge commits in branch view", async () => {
(fetchBranchCommits as any).mockResolvedValue([
{
hash: "def456789abc",
shortHash: "def4567",
message: "Merge PR #42",
author: "Dev",
date: "2026-03-01T00:00:00Z",
parents: ["abc123", "def456"],
},
]);
render(
<GitManagerModal isOpen={true} onClose={vi.fn()} tasks={mockTasks} addToast={mockAddToast} />
);
fireEvent.click(screen.getByRole("tab", { name: /branches/i }));
await waitFor(() => {
expect(screen.getByTestId("branches-panel")).toBeInTheDocument();
});
const branchItems = screen.getByTestId("branches-panel").querySelectorAll(".gm-branch-item");
fireEvent.click(branchItems[1]);
await waitFor(() => {
expect(screen.getByText("merge")).toBeInTheDocument();
});
});
// ── relativeDate function ──────────────────────────────────────
it("shows em-dash for branches with empty lastCommitDate", async () => {
(fetchGitBranches as any).mockResolvedValue([
{ name: "main", isCurrent: true, lastCommitDate: "" },
]);
render(
<GitManagerModal isOpen={true} onClose={vi.fn()} tasks={mockTasks} addToast={mockAddToast} />
);
fireEvent.click(screen.getByRole("tab", { name: /branches/i }));
// With empty lastCommitDate, the date span should not render at all
// because the component conditionally renders only when lastCommitDate is truthy
await waitFor(() => {
const panel = screen.getByTestId("branches-panel");
const branchDates = panel.querySelectorAll(".gm-branch-date");
expect(branchDates.length).toBe(0);
});
});
it("shows em-dash for branches with invalid lastCommitDate", async () => {
(fetchGitBranches as any).mockResolvedValue([
{ name: "stale", isCurrent: true, lastCommitDate: "not-a-date" },
]);
render(
<GitManagerModal isOpen={true} onClose={vi.fn()} tasks={mockTasks} addToast={mockAddToast} />
);
fireEvent.click(screen.getByRole("tab", { name: /branches/i }));
await waitFor(() => {
const panel = screen.getByTestId("branches-panel");
const dateSpan = panel.querySelector(".gm-branch-date");
expect(dateSpan).toBeTruthy();
expect(dateSpan!.textContent).toBe("—");
});
});
it("preserves branch relative-date buckets including the 30 day threshold", async () => {
vi.useFakeTimers({ toFake: ["Date"] });
vi.setSystemTime(new Date("2026-06-17T20:00:00.000Z"));
(fetchGitBranches as any).mockResolvedValue([
{ name: "now", isCurrent: true, lastCommitDate: "2026-06-17T19:59:30.000Z" },
{ name: "minutes", isCurrent: false, lastCommitDate: "2026-06-17T19:55:00.000Z" },
{ name: "hours", isCurrent: false, lastCommitDate: "2026-06-17T17:00:00.000Z" },
{ name: "twenty-nine", isCurrent: false, lastCommitDate: "2026-05-19T20:00:00.000Z" },
{ name: "thirty", isCurrent: false, lastCommitDate: "2026-05-18T20:00:00.000Z" },
{ name: "future", isCurrent: false, lastCommitDate: "2026-06-17T20:00:01.000Z" },
]);
render(
<GitManagerModal isOpen={true} onClose={vi.fn()} tasks={mockTasks} addToast={mockAddToast} />
);
fireEvent.click(screen.getByRole("tab", { name: /branches/i }));
await waitFor(() => {
const panel = screen.getByTestId("branches-panel");
const dateTexts = Array.from(panel.querySelectorAll(".gm-branch-date")).map((node) => node.textContent);
expect(dateTexts).toEqual(expect.arrayContaining([
"just now",
"5m ago",
"3h ago",
"29d ago",
new Date("2026-05-18T20:00:00.000Z").toLocaleDateString(),
]));
});
});
// ── Worktrees Panel ────────────────────────────────────────
it("loads worktrees and shows task associations", async () => {
render(
<GitManagerModal isOpen={true} onClose={vi.fn()} tasks={mockTasks} addToast={mockAddToast} />
);
fireEvent.click(screen.getByRole("tab", { name: /worktrees/i }));
await waitFor(() => {
expect(screen.getByText("FN-001")).toBeInTheDocument();
expect(screen.getByText("2 total")).toBeInTheDocument();
expect(screen.getByText("1 in use")).toBeInTheDocument();
});
});
it("shows worktree branch and path", async () => {
render(
<GitManagerModal isOpen={true} onClose={vi.fn()} tasks={mockTasks} addToast={mockAddToast} />
);
fireEvent.click(screen.getByRole("tab", { name: /worktrees/i }));
await waitFor(() => {
expect(screen.getByText("fusion/fn-001")).toBeInTheDocument();
});
});
// ── Stashes Panel ──────────────────────────────────────────
it("loads stashes and shows them", async () => {
render(
<GitManagerModal isOpen={true} onClose={vi.fn()} tasks={mockTasks} addToast={mockAddToast} />
);
fireEvent.click(screen.getByRole("tab", { name: /stashes/i }));
await waitFor(() => {
expect(screen.getByText("stash@{0}")).toBeInTheDocument();
expect(screen.getByText("WIP on main: abc1234 Test commit")).toBeInTheDocument();
});
});
it("views stash contents", async () => {
const user = userEvent.setup();
render(
<GitManagerModal isOpen={true} onClose={vi.fn()} tasks={mockTasks} addToast={mockAddToast} />
);
fireEvent.click(screen.getByRole("tab", { name: /stashes/i }));
await waitFor(() => {
expect(screen.getByRole("button", { name: "View" })).toBeInTheDocument();
});
await user.click(screen.getByRole("button", { name: "View" }));
await waitFor(() => {
expectLatestCallStartsWith(fetchStashDiff as any, 0);
expect(screen.getByText("Hide")).toBeInTheDocument();
expect(screen.getByText("README.md | 2 ++")).toBeInTheDocument();
expect(screen.getByText(/\+stash diff/)).toBeInTheDocument();
});
});
it("shows stash diff loading and error states", async () => {
const user = userEvent.setup();
let resolveDiff: ((value: { stat: string; patch: string }) => void) | null = null;
(fetchStashDiff as any).mockImplementationOnce(
() =>
new Promise<{ stat: string; patch: string }>((resolve) => {
resolveDiff = resolve;
})
);
render(
<GitManagerModal isOpen={true} onClose={vi.fn()} tasks={mockTasks} addToast={mockAddToast} />
);
fireEvent.click(screen.getByRole("tab", { name: /stashes/i }));
await user.click(await screen.findByRole("button", { name: "View" }));
expect(screen.getByText("Loading stash diff…")).toBeInTheDocument();
resolveDiff?.({ stat: " README.md | 1 +", patch: "diff --git a/README.md b/README.md\n+ok" });
await waitFor(() => {
expect(screen.getByText("README.md | 1 +")).toBeInTheDocument();
});
(fetchStashDiff as any).mockRejectedValueOnce(new Error("stash diff failed"));
await user.click(screen.getByRole("button", { name: "Hide" }));
await user.click(screen.getByRole("button", { name: "View" }));
await waitFor(() => {
expect(screen.getByText("stash diff failed")).toBeInTheDocument();
});
});
it("keeps stash actions available when viewing stash contents", async () => {
const user = userEvent.setup();
render(
<GitManagerModal isOpen={true} onClose={vi.fn()} tasks={mockTasks} addToast={mockAddToast} />
);
fireEvent.click(screen.getByRole("tab", { name: /stashes/i }));
await user.click(await screen.findByRole("button", { name: "View" }));
await user.click(screen.getByText("Apply"));
await user.click(screen.getByText("Pop"));
await waitFor(() => {
expect((applyStash as any).mock.calls).toContainEqual([0, false, undefined, undefined]);
expect((applyStash as any).mock.calls).toContainEqual([0, true, undefined, undefined]);
expect(screen.getByTitle("Drop stash")).toBeInTheDocument();
});
});
it("creates a stash", async () => {
const user = userEvent.setup();
render(
<GitManagerModal isOpen={true} onClose={vi.fn()} tasks={mockTasks} addToast={mockAddToast} />
);
fireEvent.click(screen.getByRole("tab", { name: /stashes/i }));
await waitFor(() => {
expect(screen.getByPlaceholderText("Stash message (optional)")).toBeInTheDocument();
});
await user.type(screen.getByPlaceholderText("Stash message (optional)"), "my stash");
// The "Stash" button is the submit button in the create form
const stashBtn = screen.getByRole("button", { name: /^stash$/i });
await user.click(stashBtn);
await waitFor(() => {
expectLatestCallStartsWith(createStash as any, "my stash");
});
});
it("applies a stash", async () => {
const user = userEvent.setup();
render(
<GitManagerModal isOpen={true} onClose={vi.fn()} tasks={mockTasks} addToast={mockAddToast} />
);
fireEvent.click(screen.getByRole("tab", { name: /stashes/i }));
await waitFor(() => {
expect(screen.getByText("Apply")).toBeInTheDocument();
});
await user.click(screen.getByText("Apply"));
await waitFor(() => {
expectLatestCallStartsWith(applyStash as any, 0, false);
});
});
it("pops a stash (apply and drop)", async () => {
const user = userEvent.setup();
render(
<GitManagerModal isOpen={true} onClose={vi.fn()} tasks={mockTasks} addToast={mockAddToast} />
);
fireEvent.click(screen.getByRole("tab", { name: /stashes/i }));
await waitFor(() => {
expect(screen.getByText("Pop")).toBeInTheDocument();
});
await user.click(screen.getByText("Pop"));
await waitFor(() => {
expectLatestCallStartsWith(applyStash as any, 0, true);
});
});
it("drops a stash with confirmation", async () => {
const user = userEvent.setup();
mockConfirm.mockResolvedValue(true);
render(
<GitManagerModal isOpen={true} onClose={vi.fn()} tasks={mockTasks} addToast={mockAddToast} />
);
fireEvent.click(screen.getByRole("tab", { name: /stashes/i }));
await waitFor(() => {
expect(screen.getByTitle("Drop stash")).toBeInTheDocument();
});
await user.click(screen.getByTitle("Drop stash"));
await waitFor(() => {
expectLatestCallStartsWith(dropStash as any, 0);
});
});
it("shows empty stash state", async () => {
(fetchGitStashList as any).mockResolvedValue([]);
render(
<GitManagerModal isOpen={true} onClose={vi.fn()} tasks={mockTasks} addToast={mockAddToast} />
);
fireEvent.click(screen.getByRole("tab", { name: /stashes/i }));
await waitFor(() => {
expect(screen.getByText("No stashes")).toBeInTheDocument();
});
});
// ── Remotes Panel ──────────────────────────────────────────
it("shows remote operation buttons in sync card", async () => {
render(
<GitManagerModal isOpen={true} onClose={vi.fn()} tasks={mockTasks} addToast={mockAddToast} />
);
fireEvent.click(screen.getByRole("tab", { name: /remotes/i }));
await waitFor(() => {
expect(screen.getByTestId("remote-sync-card")).toBeInTheDocument();
});
const syncCard = screen.getByTestId("remote-sync-card");
expect(syncCard.textContent).toContain("Fetch");
expect(syncCard.textContent).toContain("Pull");
expect(syncCard.textContent).toContain("Push");
});
it("calls fetchRemote when Fetch button clicked", async () => {
const user = userEvent.setup();
(fetchGitRemotesDetailed as any).mockResolvedValue([
{ name: "origin", fetchUrl: "https://github.com/test/repo.git", pushUrl: "" },
]);
render(
<GitManagerModal isOpen={true} onClose={vi.fn()} tasks={mockTasks} addToast={mockAddToast} />
);
fireEvent.click(screen.getByRole("tab", { name: /remotes/i }));
// Wait for sync card and scope button search within it to avoid matching the Refresh button
const syncCard = await screen.findByTestId("remote-sync-card");
const fetchButton = within(syncCard).getByRole("button", { name: /fetch/i });
await user.click(fetchButton);
await waitFor(() => {
expect(fetchRemote).toHaveBeenCalled();
});
});
it("calls pullBranch with rebase false when Pull button clicked", async () => {
const user = userEvent.setup();
render(
<GitManagerModal isOpen={true} onClose={vi.fn()} tasks={mockTasks} addToast={mockAddToast} />
);
fireEvent.click(screen.getByRole("tab", { name: /remotes/i }));
const syncCard = await screen.findByTestId("remote-sync-card");
const pullButton = within(syncCard).getByRole("button", { name: /^pull$/i });
await user.click(pullButton);
await waitFor(() => {
expect(pullBranch).toHaveBeenCalledWith({ rebase: false }, undefined, undefined);
});
});
it("calls pullBranch with rebase true from pull options menu", async () => {
const user = userEvent.setup();
render(
<GitManagerModal isOpen={true} onClose={vi.fn()} tasks={mockTasks} addToast={mockAddToast} />
);
fireEvent.click(screen.getByRole("tab", { name: /remotes/i }));
const syncCard = await screen.findByTestId("remote-sync-card");
await user.click(within(syncCard).getByRole("button", { name: /pull options/i }));
await user.click(screen.getByRole("menuitem", { name: /pull --rebase/i }));
await waitFor(() => {
expect(pullBranch).toHaveBeenCalledWith({ rebase: true }, undefined, undefined);
});
});
it("FN-3753: pull split toggle is narrower than the main Pull button", async () => {
render(
<GitManagerModal isOpen={true} onClose={vi.fn()} tasks={mockTasks} addToast={mockAddToast} />
);
fireEvent.click(screen.getByRole("tab", { name: /remotes/i }));
const syncCard = await screen.findByTestId("remote-sync-card");
const pullMainButton = within(syncCard).getByRole("button", { name: /^pull$/i });
const pullToggleButton = within(syncCard).getByRole("button", { name: /pull options/i });
expect(pullToggleButton).toHaveClass("gm-pull-split-toggle");
expect(pullToggleButton).toHaveClass("btn-icon");
vi.spyOn(pullMainButton, "getBoundingClientRect").mockReturnValue({
x: 0,
y: 0,
width: 96,
height: 36,
top: 0,
right: 96,
bottom: 36,
left: 0,
toJSON: () => ({}),
});
vi.spyOn(pullToggleButton, "getBoundingClientRect").mockReturnValue({
x: 96,
y: 0,
width: 36,
height: 36,
top: 0,
right: 132,
bottom: 36,
left: 96,
toJSON: () => ({}),
});
expect(pullToggleButton.getBoundingClientRect().width).toBeLessThan(
pullMainButton.getBoundingClientRect().width
);
});
it("FN-4985: pull split toggle matches the main Pull button height on non-mobile", async () => {
render(
<GitManagerModal isOpen={true} onClose={vi.fn()} tasks={mockTasks} addToast={mockAddToast} />
);
fireEvent.click(screen.getByRole("tab", { name: /remotes/i }));
const syncCard = await screen.findByTestId("remote-sync-card");
const pullMainButton = within(syncCard).getByRole("button", { name: /^pull$/i });
const pullToggleButton = within(syncCard).getByRole("button", { name: /pull options/i });
vi.spyOn(pullMainButton, "getBoundingClientRect").mockReturnValue({
x: 0,
y: 0,
width: 96,
height: 36,
top: 0,
right: 96,
bottom: 36,
left: 0,
toJSON: () => ({}),
});
vi.spyOn(pullToggleButton, "getBoundingClientRect").mockReturnValue({
x: 96,
y: 0,
width: 36,
height: 36,
top: 0,
right: 132,
bottom: 36,
left: 96,
toJSON: () => ({}),
});
expect(pullToggleButton.getBoundingClientRect().height).toBe(
pullMainButton.getBoundingClientRect().height
);
});
it("closes pull options menu on outside click and Escape", async () => {
const user = userEvent.setup();
render(
<GitManagerModal isOpen={true} onClose={vi.fn()} tasks={mockTasks} addToast={mockAddToast} />
);
fireEvent.click(screen.getByRole("tab", { name: /remotes/i }));
const syncCard = await screen.findByTestId("remote-sync-card");
const pullOptionsButton = within(syncCard).getByRole("button", { name: /pull options/i });
await user.click(pullOptionsButton);
expect(screen.getByRole("menu", { name: /pull options menu/i })).toBeInTheDocument();
await user.click(document.body);
await waitFor(() => {
expect(screen.queryByRole("menu", { name: /pull options menu/i })).not.toBeInTheDocument();
});
await user.click(pullOptionsButton);
expect(screen.getByRole("menu", { name: /pull options menu/i })).toBeInTheDocument();
await user.keyboard("{Escape}");
await waitFor(() => {
expect(screen.queryByRole("menu", { name: /pull options menu/i })).not.toBeInTheDocument();
});
});
it("calls pushBranch when Push button clicked", async () => {
const user = userEvent.setup();
render(
<GitManagerModal isOpen={true} onClose={vi.fn()} tasks={mockTasks} addToast={mockAddToast} />
);
fireEvent.click(screen.getByRole("tab", { name: /remotes/i }));
// Wait for sync card and scope button search within it
const syncCard = await screen.findByTestId("remote-sync-card");
const pushButton = within(syncCard).getByRole("button", { name: /push/i });
await user.click(pushButton);
await waitFor(() => {
expect(pushBranch).toHaveBeenCalled();
});
});
it("syncs with origin by running pull --rebase before push", async () => {
const user = userEvent.setup();
render(
<GitManagerModal isOpen={true} onClose={vi.fn()} tasks={mockTasks} addToast={mockAddToast} />
);
fireEvent.click(screen.getByRole("tab", { name: /remotes/i }));
const syncButton = await screen.findByTestId("remotes-sync-origin-btn");
await user.click(syncButton);
await waitFor(() => {
expect(pullBranch).toHaveBeenCalledWith({ rebase: true }, undefined, undefined);
expect(pushBranch).toHaveBeenCalledWith(undefined, undefined);
});
expect((pullBranch as any).mock.invocationCallOrder[0]).toBeLessThan(
(pushBranch as any).mock.invocationCallOrder[0]
);
expect(mockAddToast).toHaveBeenCalledWith(
"Synced with origin (pull --rebase + push)",
"success"
);
expectLatestCallStartsWith(fetchGitStatus as any, undefined, { extended: true });
});
it("does not push when sync pull reports a conflict", async () => {
const user = userEvent.setup();
(pullBranch as any).mockResolvedValue({
success: false,
message: "Rebase stopped on conflict",
conflict: true,
});
render(
<GitManagerModal isOpen={true} onClose={vi.fn()} tasks={mockTasks} addToast={mockAddToast} />
);
fireEvent.click(screen.getByRole("tab", { name: /remotes/i }));
const syncButton = await screen.findByTestId("remotes-sync-origin-btn");
await user.click(syncButton);
await waitFor(() => {
expect(pullBranch).toHaveBeenCalledWith({ rebase: true }, undefined, undefined);
});
expect(pushBranch).not.toHaveBeenCalled();
expect(mockAddToast).toHaveBeenCalledWith("Merge conflict detected. Resolve manually.", "error");
});
it("does not push when sync pull rejects", async () => {
const user = userEvent.setup();
(pullBranch as any).mockRejectedValue(new Error("sync pull failed"));
render(
<GitManagerModal isOpen={true} onClose={vi.fn()} tasks={mockTasks} addToast={mockAddToast} />
);
fireEvent.click(screen.getByRole("tab", { name: /remotes/i }));
const syncButton = await screen.findByTestId("remotes-sync-origin-btn");
await user.click(syncButton);
await waitFor(() => {
expect(pullBranch).toHaveBeenCalledWith({ rebase: true }, undefined, undefined);
});
expect(pushBranch).not.toHaveBeenCalled();
expect(mockAddToast).toHaveBeenCalledWith("sync pull failed", "error");
});
it("shows a spinner and disables the Sync button while syncing", async () => {
const user = userEvent.setup();
let resolvePull: ((value: { success: boolean; message: string }) => void) | undefined;
const pendingPull = new Promise<{ success: boolean; message: string }>((resolve) => {
resolvePull = resolve;
});
(pullBranch as any).mockReturnValue(pendingPull);
render(
<GitManagerModal isOpen={true} onClose={vi.fn()} tasks={mockTasks} addToast={mockAddToast} />
);
fireEvent.click(screen.getByRole("tab", { name: /remotes/i }));
const syncButton = await screen.findByTestId("remotes-sync-origin-btn");
expect(syncButton).not.toBeDisabled();
await user.click(syncButton);
await waitFor(() => {
expect(syncButton).toBeDisabled();
expect(syncButton.querySelector(".spin")).not.toBeNull();
});
resolvePull?.({ success: true, message: "Already up to date." });
await waitFor(() => {
expect(pushBranch).toHaveBeenCalledWith(undefined, undefined);
expect(syncButton).not.toBeDisabled();
});
expectLatestCallStartsWith(fetchGitStatus as any, undefined, { extended: true });
});
it("shows error toast when fetch fails", async () => {
const user = userEvent.setup();
(fetchGitRemotesDetailed as any).mockResolvedValue([
{ name: "origin", fetchUrl: "https://github.com/test/repo.git", pushUrl: "" },
]);
(fetchRemote as any).mockRejectedValue(new Error("Network error"));
render(
<GitManagerModal isOpen={true} onClose={vi.fn()} tasks={mockTasks} addToast={mockAddToast} />
);
fireEvent.click(screen.getByRole("tab", { name: /remotes/i }));
// Wait for sync card and scope button search within it
const syncCard = await screen.findByTestId("remote-sync-card");
const fetchButton = within(syncCard).getByRole("button", { name: /fetch/i });
await user.click(fetchButton);
await waitFor(() => {
expect(mockAddToast).toHaveBeenCalledWith("Network error", "error");
});
});
it("shows ahead/behind indicators in sync card", async () => {
(fetchGitStatus as any).mockResolvedValue({
branch: "main",
commit: "abc1234",
isDirty: false,
ahead: 2,
behind: 3,
});
render(
<GitManagerModal isOpen={true} onClose={vi.fn()} tasks={mockTasks} addToast={mockAddToast} />
);
fireEvent.click(screen.getByRole("tab", { name: /remotes/i }));
await waitFor(() => {
expect(screen.getByTestId("remote-sync-card")).toBeInTheDocument();
});
const syncCard = screen.getByTestId("remote-sync-card");
expect(syncCard.textContent).toContain("2 to push");
expect(syncCard.textContent).toContain("3 to pull");
});
// ── Remote Management Tests ───────────────────────────────────
it("shows selector/detail split and selected highlight in remotes tab", async () => {
(fetchGitRemotesDetailed as any).mockResolvedValue([
{ name: "origin", fetchUrl: "https://github.com/dustinbyrne/kb.git", pushUrl: "https://github.com/dustinbyrne/kb.git" },
{ name: "upstream", fetchUrl: "https://github.com/upstream/kb.git", pushUrl: "git@github.com:upstream/kb.git" },
]);
const user = userEvent.setup();
const { container } = render(
<GitManagerModal isOpen={true} onClose={vi.fn()} tasks={mockTasks} addToast={mockAddToast} />
);
fireEvent.click(screen.getByRole("tab", { name: /remotes/i }));
await waitFor(() => {
expect(screen.getByTestId("remote-selector")).toBeInTheDocument();
expect(screen.getByTestId("remote-detail-panel")).toBeInTheDocument();
});
await waitFor(() => {
const selectedOrigin = container.querySelector(".gm-remote-selector-item.selected");
expect(selectedOrigin).toBeTruthy();
expect(selectedOrigin?.textContent ?? "").toContain("origin");
});
const remoteSelector = screen.getByTestId("remote-selector");
const upstreamButton = within(remoteSelector).getByText("upstream").closest(".gm-remote-selector-item");
expect(upstreamButton).toBeTruthy();
await user.click(upstreamButton as HTMLElement);
await waitFor(() => {
const selectedUpstream = container.querySelector(".gm-remote-selector-item.selected");
expect(selectedUpstream).toBeTruthy();
expect(selectedUpstream?.textContent ?? "").toContain("upstream");
});
});
it("renders full URL in selected detail card and not in selector row", async () => {
(fetchGitRemotesDetailed as any).mockResolvedValue([
{ name: "origin", fetchUrl: "https://github.com/dustinbyrne/kb.git", pushUrl: "https://github.com/dustinbyrne/kb.git" },
{ name: "upstream", fetchUrl: "https://example.com/some/very/long/path/repository-name.git", pushUrl: "https://example.com/some/very/long/path/repository-name.git" },
]);
const user = userEvent.setup();
render(
<GitManagerModal isOpen={true} onClose={vi.fn()} tasks={mockTasks} addToast={mockAddToast} />
);
fireEvent.click(screen.getByRole("tab", { name: /remotes/i }));
await user.click(await screen.findByText("upstream"));
await waitFor(() => {
const detailCard = screen.getByTestId("remote-detail-card");
expect(within(detailCard).getByText("https://example.com/some/very/long/path/repository-name.git")).toBeInTheDocument();
expect(screen.getByText("example.com")).toBeInTheDocument();
});
});
it("shows loading state while fetching remotes", async () => {
(fetchGitRemotesDetailed as any).mockReturnValue(new Promise(() => {}));
render(
<GitManagerModal isOpen={true} onClose={vi.fn()} tasks={mockTasks} addToast={mockAddToast} />
);
fireEvent.click(screen.getByRole("tab", { name: /remotes/i }));
await waitFor(() => {
expect(screen.getByText("Loading...")).toBeInTheDocument();
});
});
it("adds a new remote successfully", async () => {
const user = userEvent.setup();
(fetchGitRemotesDetailed as any).mockResolvedValue([]);
(addGitRemote as any).mockResolvedValue(undefined);
render(
<GitManagerModal isOpen={true} onClose={vi.fn()} tasks={mockTasks} addToast={mockAddToast} />
);
fireEvent.click(screen.getByRole("tab", { name: /remotes/i }));
await waitFor(() => {
expect(screen.getByTestId("remote-selector")).toBeInTheDocument();
});
await user.click(screen.getByTitle("Add Remote"));
const nameInput = screen.getByPlaceholderText("Remote name");
const urlInput = screen.getByPlaceholderText("Repository URL");
await user.type(nameInput, "newremote");
await user.type(urlInput, "https://github.com/test/repo.git");
await user.click(screen.getByRole("button", { name: /^add$/i }));
await waitFor(() => {
expectLatestCallStartsWith(addGitRemote as any, "newremote", "https://github.com/test/repo.git");
expect(mockAddToast).toHaveBeenCalledWith("Remote 'newremote' added successfully", "success");
});
});
it("shows error when adding remote fails", async () => {
const user = userEvent.setup();
(fetchGitRemotesDetailed as any).mockResolvedValue([]);
(addGitRemote as any).mockRejectedValue(new Error("Remote 'newremote' already exists"));
render(
<GitManagerModal isOpen={true} onClose={vi.fn()} tasks={mockTasks} addToast={mockAddToast} />
);
fireEvent.click(screen.getByRole("tab", { name: /remotes/i }));
await waitFor(() => {
expect(screen.getByTestId("remote-selector")).toBeInTheDocument();
});
await user.click(screen.getByTitle("Add Remote"));
const nameInput = screen.getByPlaceholderText("Remote name");
const urlInput = screen.getByPlaceholderText("Repository URL");
await user.type(nameInput, "newremote");
await user.type(urlInput, "https://github.com/test/repo.git");
await user.click(screen.getByRole("button", { name: /^add$/i }));
await waitFor(() => {
expect(mockAddToast).toHaveBeenCalledWith("Remote 'newremote' already exists", "error");
});
});
it("removes a remote with confirmation", async () => {
const user = userEvent.setup();
mockConfirm.mockResolvedValue(true);
(fetchGitRemotesDetailed as any).mockResolvedValue([
{ name: "origin", fetchUrl: "https://github.com/dustinbyrne/kb.git", pushUrl: "https://github.com/dustinbyrne/kb.git" },
]);
(removeGitRemote as any).mockResolvedValue(undefined);
render(
<GitManagerModal isOpen={true} onClose={vi.fn()} tasks={mockTasks} addToast={mockAddToast} />
);
fireEvent.click(screen.getByRole("tab", { name: /remotes/i }));
await waitFor(() => {
expect(screen.getByText("origin")).toBeInTheDocument();
});
const removeButton = screen.getByTitle("Remove remote");
await user.click(removeButton);
await waitFor(() => {
expect(mockConfirm).toHaveBeenCalledWith({
title: "Remove Remote",
message: "Are you sure you want to remove remote 'origin'?",
danger: true,
});
expectLatestCallStartsWith(removeGitRemote as any, "origin");
expect(mockAddToast).toHaveBeenCalledWith("Remote 'origin' removed", "success");
});
});
it("renames a remote in detail panel", async () => {
const user = userEvent.setup();
(fetchGitRemotesDetailed as any).mockResolvedValue([
{ name: "origin", fetchUrl: "https://github.com/dustinbyrne/kb.git", pushUrl: "https://github.com/dustinbyrne/kb.git" },
]);
(renameGitRemote as any).mockResolvedValue(undefined);
render(
<GitManagerModal isOpen={true} onClose={vi.fn()} tasks={mockTasks} addToast={mockAddToast} />
);
fireEvent.click(screen.getByRole("tab", { name: /remotes/i }));
await waitFor(() => {
expect(screen.getByTestId("remote-detail-card")).toBeInTheDocument();
});
const renameButton = screen.getByTitle("Edit remote name");
await user.click(renameButton);
const nameInput = screen.getByDisplayValue("origin");
await user.clear(nameInput);
await user.type(nameInput, "upstream");
const saveButton = nameInput.closest(".gm-remote-edit")?.querySelector(".btn.btn-sm.btn-primary");
expect(saveButton).toBeTruthy();
await user.click(saveButton as HTMLButtonElement);
await waitFor(() => {
expectLatestCallStartsWith(renameGitRemote as any, "origin", "upstream");
expect(mockAddToast).toHaveBeenCalledWith("Remote renamed to 'upstream'", "success");
});
});
it("updates remote URL in detail panel", async () => {
const user = userEvent.setup();
(fetchGitRemotesDetailed as any).mockResolvedValue([
{ name: "origin", fetchUrl: "https://old-url.com/repo.git", pushUrl: "https://old-url.com/repo.git" },
]);
(updateGitRemoteUrl as any).mockResolvedValue(undefined);
render(
<GitManagerModal isOpen={true} onClose={vi.fn()} tasks={mockTasks} addToast={mockAddToast} />
);
fireEvent.click(screen.getByRole("tab", { name: /remotes/i }));
await waitFor(() => {
expect(screen.getByTestId("remote-detail-card")).toBeInTheDocument();
});
const editButton = screen.getByTitle("Edit remote URL");
await user.click(editButton);
const urlInput = screen.getByDisplayValue("https://old-url.com/repo.git");
await user.clear(urlInput);
await user.type(urlInput, "https://new-url.com/repo.git");
const saveButton = urlInput.closest(".gm-remote-edit")?.querySelector(".btn.btn-sm.btn-primary");
expect(saveButton).toBeTruthy();
await user.click(saveButton as HTMLButtonElement);
await waitFor(() => {
expectLatestCallStartsWith(updateGitRemoteUrl as any, "origin", "https://new-url.com/repo.git");
expect(mockAddToast).toHaveBeenCalledWith("Remote URL updated", "success");
});
});
// ── Edit Affordance Regression ──────────────────────────────────
it("shows editor-style icon for remote name edit action in detail panel", async () => {
(fetchGitRemotesDetailed as any).mockResolvedValue([
{ name: "origin", fetchUrl: "https://github.com/a/b.git", pushUrl: "https://github.com/a/b.git" },
]);
render(
<GitManagerModal isOpen={true} onClose={vi.fn()} tasks={mockTasks} addToast={mockAddToast} />
);
fireEvent.click(screen.getByRole("tab", { name: /remotes/i }));
await waitFor(() => {
expect(screen.getByTestId("remote-detail-card")).toBeInTheDocument();
});
const nameEditBtn = screen.getByTitle("Edit remote name");
expect(nameEditBtn).toBeInTheDocument();
expect(nameEditBtn).toBeVisible();
fireEvent.click(nameEditBtn);
expect(screen.getByDisplayValue("origin")).toBeInTheDocument();
});
it("shows editor-style icon for remote URL edit action in detail panel", async () => {
(fetchGitRemotesDetailed as any).mockResolvedValue([
{ name: "origin", fetchUrl: "https://github.com/a/b.git", pushUrl: "https://github.com/a/b.git" },
]);
render(
<GitManagerModal isOpen={true} onClose={vi.fn()} tasks={mockTasks} addToast={mockAddToast} />
);
fireEvent.click(screen.getByRole("tab", { name: /remotes/i }));
await waitFor(() => {
expect(screen.getByTestId("remote-detail-card")).toBeInTheDocument();
});
const urlEditBtn = screen.getByTitle("Edit remote URL");
expect(urlEditBtn).toBeInTheDocument();
expect(urlEditBtn).toBeVisible();
fireEvent.click(urlEditBtn);
expect(screen.getByDisplayValue("https://github.com/a/b.git")).toBeInTheDocument();
});
it("distinguishes name edit and URL edit controls with unique titles", async () => {
(fetchGitRemotesDetailed as any).mockResolvedValue([
{ name: "origin", fetchUrl: "https://github.com/a/b.git", pushUrl: "https://github.com/a/b.git" },
]);
render(
<GitManagerModal isOpen={true} onClose={vi.fn()} tasks={mockTasks} addToast={mockAddToast} />
);
fireEvent.click(screen.getByRole("tab", { name: /remotes/i }));
// Wait for both cards to appear
await waitFor(() => {
expect(screen.getByTestId("remote-sync-card")).toBeInTheDocument();
expect(screen.getByTestId("remote-detail-card")).toBeInTheDocument();
});
// Both edit buttons are in the detail card
const detailCard = screen.getByTestId("remote-detail-card");
const nameEditBtn = within(detailCard).getByTitle("Edit remote name");
const urlEditBtn = within(detailCard).getByTitle("Edit remote URL");
expect(nameEditBtn).not.toBe(urlEditBtn);
fireEvent.click(nameEditBtn);
expect(screen.getByDisplayValue("origin")).toBeInTheDocument();
// After clicking edit, the button is replaced with input + cancel button inside .gm-remote-edit
const cancelBtn = screen.getByTitle("Cancel");
expect(cancelBtn).toBeTruthy();
fireEvent.click(cancelBtn);
fireEvent.click(urlEditBtn);
expect(screen.getByDisplayValue("https://github.com/a/b.git")).toBeInTheDocument();
});
it("preserves rename API call behavior through edit affordance", async () => {
(fetchGitRemotesDetailed as any).mockResolvedValue([
{ name: "origin", fetchUrl: "https://github.com/a/b.git", pushUrl: "https://github.com/a/b.git" },
]);
(renameGitRemote as any).mockResolvedValue(undefined);
const user = userEvent.setup();
render(
<GitManagerModal isOpen={true} onClose={vi.fn()} tasks={mockTasks} addToast={mockAddToast} />
);
fireEvent.click(screen.getByRole("tab", { name: /remotes/i }));
await waitFor(() => {
expect(screen.getByTestId("remote-detail-card")).toBeInTheDocument();
});
const nameEditBtn = screen.getByTitle("Edit remote name");
await user.click(nameEditBtn);
const nameInput = screen.getByDisplayValue("origin");
await user.clear(nameInput);
await user.type(nameInput, "upstream");
const saveButton = nameInput.closest(".gm-remote-edit")?.querySelector(".btn.btn-sm.btn-primary");
expect(saveButton).toBeTruthy();
await user.click(saveButton as HTMLButtonElement);
await waitFor(() => {
expectLatestCallStartsWith(renameGitRemote as any, "origin", "upstream");
});
});
it("preserves URL update API call behavior through edit affordance", async () => {
(fetchGitRemotesDetailed as any).mockResolvedValue([
{ name: "origin", fetchUrl: "https://old-url.com/repo.git", pushUrl: "https://old-url.com/repo.git" },
]);
(updateGitRemoteUrl as any).mockResolvedValue(undefined);
const user = userEvent.setup();
render(
<GitManagerModal isOpen={true} onClose={vi.fn()} tasks={mockTasks} addToast={mockAddToast} />
);
fireEvent.click(screen.getByRole("tab", { name: /remotes/i }));
await waitFor(() => {
expect(screen.getByTestId("remote-detail-card")).toBeInTheDocument();
});
const urlEditBtn = screen.getByTitle("Edit remote URL");
await user.click(urlEditBtn);
const urlInput = screen.getByDisplayValue("https://old-url.com/repo.git");
await user.clear(urlInput);
await user.type(urlInput, "https://new-url.com/repo.git");
const saveButton = urlInput.closest(".gm-remote-edit")?.querySelector(".btn.btn-sm.btn-primary");
expect(saveButton).toBeTruthy();
await user.click(saveButton as HTMLButtonElement);
await waitFor(() => {
expectLatestCallStartsWith(updateGitRemoteUrl as any, "origin", "https://new-url.com/repo.git");
});
});
it("handles API errors gracefully", async () => {
(fetchGitRemotesDetailed as any).mockRejectedValue(new Error("Failed to load remotes"));
render(
<GitManagerModal isOpen={true} onClose={vi.fn()} tasks={mockTasks} addToast={mockAddToast} />
);
fireEvent.click(screen.getByRole("tab", { name: /remotes/i }));
await waitFor(() => {
expect(mockAddToast).toHaveBeenCalledWith("Failed to load remotes", "error");
});
});
// ── Error States ───────────────────────────────────────────
it("shows error state when data fetch fails", async () => {
(fetchGitStatus as any).mockRejectedValue(new Error("Connection failed"));
render(
<GitManagerModal isOpen={true} onClose={vi.fn()} tasks={mockTasks} addToast={mockAddToast} />
);
await waitFor(() => {
expect(screen.getByText("Connection failed")).toBeInTheDocument();
});
});
it("shows retry button on error", async () => {
(fetchGitStatus as any).mockRejectedValue(new Error("Connection failed"));
render(
<GitManagerModal isOpen={true} onClose={vi.fn()} tasks={mockTasks} addToast={mockAddToast} />
);
await waitFor(() => {
expect(screen.getByText("Retry")).toBeInTheDocument();
});
});
// ── Loading States ─────────────────────────────────────────
it("shows loading indicator while fetching data", async () => {
// Make fetchGitStatus very slow
(fetchGitStatus as any).mockReturnValue(new Promise(() => {}));
render(
<GitManagerModal isOpen={true} onClose={vi.fn()} tasks={mockTasks} addToast={mockAddToast} />
);
expect(screen.getByText("Loading...")).toBeInTheDocument();
});
// ── Commits to Push Section ───────────────────────────────────
it("shows commits to push section when ahead > 0", async () => {
(fetchGitStatus as any).mockResolvedValue({
branch: "main",
commit: "abc1234",
isDirty: false,
ahead: 2,
behind: 0,
});
(fetchAheadCommits as any).mockResolvedValue([
{ hash: "aaa1111", shortHash: "aaa1", message: "First ahead commit", author: "Dev", date: "2026-01-01T00:00:00Z", parents: [] },
{ hash: "bbb2222", shortHash: "bbb2", message: "Second ahead commit", author: "Dev", date: "2026-01-02T00:00:00Z", parents: [] },
]);
render(
<GitManagerModal isOpen={true} onClose={vi.fn()} tasks={mockTasks} addToast={mockAddToast} />
);
fireEvent.click(screen.getByRole("tab", { name: /remotes/i }));
await waitFor(() => {
expect(screen.getByTestId("commits-to-push")).toBeInTheDocument();
expect(screen.getByText("First ahead commit")).toBeInTheDocument();
expect(screen.getByText("Second ahead commit")).toBeInTheDocument();
});
});
it("shows empty state when ahead > 0 but no ahead commits returned", async () => {
(fetchGitStatus as any).mockResolvedValue({
branch: "main",
commit: "abc1234",
isDirty: false,
ahead: 1,
behind: 0,
});
(fetchAheadCommits as any).mockResolvedValue([]);
render(
<GitManagerModal isOpen={true} onClose={vi.fn()} tasks={mockTasks} addToast={mockAddToast} />
);
fireEvent.click(screen.getByRole("tab", { name: /remotes/i }));
await waitFor(() => {
expect(screen.getByTestId("commits-to-push")).toBeInTheDocument();
expect(screen.getByText(/No ahead commits found/)).toBeInTheDocument();
});
});
it("does not show commits to push when ahead === 0", async () => {
(fetchGitStatus as any).mockResolvedValue({
branch: "main",
commit: "abc1234",
isDirty: false,
ahead: 0,
behind: 0,
});
render(
<GitManagerModal isOpen={true} onClose={vi.fn()} tasks={mockTasks} addToast={mockAddToast} />
);
fireEvent.click(screen.getByRole("tab", { name: /remotes/i }));
await waitFor(() => {
expect(screen.getByText("origin")).toBeInTheDocument();
});
expect(screen.queryByTestId("commits-to-push")).not.toBeInTheDocument();
});
it("re-fetches ahead commits when status changes after fetch operation", async () => {
// This test verifies the regression fix: ahead commits are re-fetched when
// the ahead count changes after a remote action.
//
// Setup: mock status to go from ahead=0 → ahead=2 after fetch
// 1st call: mount (status tab)
// 2nd call: remotes tab load
// 3rd call: parent's handleFetch refreshes status
(fetchGitStatus as any)
.mockResolvedValueOnce({
branch: "main", commit: "abc1234", isDirty: false, ahead: 0, behind: 0,
})
.mockResolvedValueOnce({
branch: "main", commit: "abc1234", isDirty: false, ahead: 0, behind: 0,
})
.mockResolvedValueOnce({
branch: "main", commit: "abc1234", isDirty: false, ahead: 2, behind: 0,
});
// fetchAheadCommits should not be called initially (ahead=0)
(fetchAheadCommits as any).mockResolvedValue([]);
const user = userEvent.setup();
render(
<GitManagerModal isOpen={true} onClose={vi.fn()} tasks={mockTasks} addToast={mockAddToast} />
);
// Switch to remotes tab (uses 2nd mock: ahead=0)
fireEvent.click(screen.getByRole("tab", { name: /remotes/i }));
await waitFor(() => {
expect(screen.getByText("origin")).toBeInTheDocument();
});
// No commits-to-push section since ahead=0
expect(screen.queryByTestId("commits-to-push")).not.toBeInTheDocument();
// Now mock ahead commits to return data when called next
(fetchAheadCommits as any).mockResolvedValue([
{ hash: "aaa1111", shortHash: "aaa1", message: "Ahead commit after fetch", author: "Dev", date: "2026-01-01T00:00:00Z", parents: [] },
{ hash: "bbb2222", shortHash: "bbb2", message: "Another ahead commit", author: "Dev", date: "2026-01-02T00:00:00Z", parents: [] },
]);
// Click the Refresh button in the header to trigger a full status refresh
// This causes fetchGitStatus to be called again (3rd mock: ahead=2)
const refreshBtn = screen.getByTitle("Refresh");
await user.click(refreshBtn);
// After status refresh, ahead=2 triggers loadAheadCommits
await waitFor(() => {
expect(fetchAheadCommits).toHaveBeenCalled();
});
await waitFor(() => {
expect(screen.getByTestId("commits-to-push")).toBeInTheDocument();
expect(screen.getByText("Ahead commit after fetch")).toBeInTheDocument();
expect(screen.getByText("Another ahead commit")).toBeInTheDocument();
});
});
it("clears ahead commits after push succeeds and ahead count drops to 0", async () => {
const user = userEvent.setup();
// First call: initial mount (status tab)
// Second call: switching to remotes tab — shows ahead commits
// Third call: after push — ahead drops to 0
(fetchGitStatus as any)
.mockResolvedValueOnce({
branch: "main",
commit: "abc1234",
isDirty: false,
ahead: 2,
behind: 0,
})
.mockResolvedValueOnce({
branch: "main",
commit: "abc1234",
isDirty: false,
ahead: 2,
behind: 0,
})
.mockResolvedValueOnce({
branch: "main",
commit: "abc1234",
isDirty: false,
ahead: 0,
behind: 0,
});
(fetchAheadCommits as any).mockResolvedValue([
{ hash: "aaa1111", shortHash: "aaa1", message: "First commit", author: "Dev", date: "2026-01-01T00:00:00Z", parents: [] },
{ hash: "bbb2222", shortHash: "bbb2", message: "Second commit", author: "Dev", date: "2026-01-02T00:00:00Z", parents: [] },
]);
render(
<GitManagerModal isOpen={true} onClose={vi.fn()} tasks={mockTasks} addToast={mockAddToast} />
);
fireEvent.click(screen.getByRole("tab", { name: /remotes/i }));
// Initially shows commits to push
await waitFor(() => {
expect(screen.getByTestId("commits-to-push")).toBeInTheDocument();
});
// Push — parent handler refreshes status (3rd mock returns ahead: 0)
// Use getAllByRole since "2 commit(s) to push" text also matches /push/i
const pushButtons = screen.getAllByRole("button").filter(
(btn) => btn.textContent?.includes("Push") && !btn.textContent?.includes("commit")
);
expect(pushButtons.length).toBeGreaterThan(0);
await user.click(pushButtons[0]);
// After push, commits-to-push section should disappear
await waitFor(() => {
expect(screen.queryByTestId("commits-to-push")).not.toBeInTheDocument();
});
});
// ── Remote Selection & Recent Commits ──────────────────────────
it("shows recent commits section for auto-selected remote", async () => {
(fetchRemoteCommits as any).mockResolvedValue([
{ hash: "rc1", shortHash: "rc1", message: "Remote commit 1", author: "Dev", date: "2026-01-01T00:00:00Z", parents: [] },
]);
render(
<GitManagerModal isOpen={true} onClose={vi.fn()} tasks={mockTasks} addToast={mockAddToast} />
);
fireEvent.click(screen.getByRole("tab", { name: /remotes/i }));
await waitFor(() => {
expect(screen.getByTestId("remote-commits-section")).toBeInTheDocument();
expect(screen.getByText("Remote commit 1")).toBeInTheDocument();
});
});
it("shows empty state when remote has no commits", async () => {
(fetchRemoteCommits as any).mockResolvedValue([]);
render(
<GitManagerModal isOpen={true} onClose={vi.fn()} tasks={mockTasks} addToast={mockAddToast} />
);
fireEvent.click(screen.getByRole("tab", { name: /remotes/i }));
await waitFor(() => {
expect(screen.getByText(/No commits found on origin/)).toBeInTheDocument();
});
});
it("shows error state when remote commits fetch fails", async () => {
(fetchRemoteCommits as any).mockRejectedValue(new Error("Network failure"));
render(
<GitManagerModal isOpen={true} onClose={vi.fn()} tasks={mockTasks} addToast={mockAddToast} />
);
fireEvent.click(screen.getByRole("tab", { name: /remotes/i }));
await waitFor(() => {
expect(screen.getByText("Network failure")).toBeInTheDocument();
});
});
it("refreshes recent remote commits after pull without reopening modal", async () => {
const user = userEvent.setup();
(fetchRemoteCommits as any)
.mockResolvedValueOnce([
{ hash: "rc1", shortHash: "rc1", message: "Remote commit before pull", author: "Dev", date: "2026-01-01T00:00:00Z", parents: [] },
])
.mockResolvedValueOnce([
{ hash: "rc2", shortHash: "rc2", message: "Remote commit after pull", author: "Dev", date: "2026-01-02T00:00:00Z", parents: [] },
]);
render(
<GitManagerModal isOpen={true} onClose={vi.fn()} tasks={mockTasks} addToast={mockAddToast} />
);
fireEvent.click(screen.getByRole("tab", { name: /remotes/i }));
await waitFor(() => {
expect(screen.getByText("Remote commit before pull")).toBeInTheDocument();
});
const syncCard = screen.getByTestId("remote-sync-card");
await user.click(within(syncCard).getByRole("button", { name: /^pull$/i }));
await waitFor(() => {
expect(pullBranch).toHaveBeenCalledWith({ rebase: false }, undefined, undefined);
expect(fetchRemoteCommits).toHaveBeenCalledTimes(2);
expect(screen.getByText("Remote commit after pull")).toBeInTheDocument();
expect(screen.queryByText("Remote commit before pull")).not.toBeInTheDocument();
});
});
it("refreshes recent remote commits after push without reopening modal", async () => {
const user = userEvent.setup();
let remoteCommitsCallCount = 0;
(fetchRemoteCommits as any).mockImplementation(() => {
remoteCommitsCallCount += 1;
if (remoteCommitsCallCount <= 1) {
return Promise.resolve([
{ hash: "rc10", shortHash: "rc10", message: "Remote commit before push", author: "Dev", date: "2026-01-01T00:00:00Z", parents: [] },
]);
}
return Promise.resolve([
{ hash: "rc11", shortHash: "rc11", message: "Remote commit after push", author: "Dev", date: "2026-01-02T00:00:00Z", parents: [] },
]);
});
render(
<GitManagerModal isOpen={true} onClose={vi.fn()} tasks={mockTasks} addToast={mockAddToast} />
);
fireEvent.click(screen.getByRole("tab", { name: /remotes/i }));
await waitFor(() => {
expect(screen.getByText("Remote commit before push")).toBeInTheDocument();
});
const syncCard = screen.getByTestId("remote-sync-card");
await user.click(within(syncCard).getByRole("button", { name: /push/i }));
await waitFor(() => {
expect(pushBranch).toHaveBeenCalled();
expect(fetchRemoteCommits).toHaveBeenCalledTimes(2);
expect(screen.getByText("Remote commit after push")).toBeInTheDocument();
expect(screen.queryByText("Remote commit before push")).not.toBeInTheDocument();
});
});
it("does not show remote commits section when no remotes configured", async () => {
(fetchGitRemotesDetailed as any).mockResolvedValue([]);
render(
<GitManagerModal isOpen={true} onClose={vi.fn()} tasks={mockTasks} addToast={mockAddToast} />
);
fireEvent.click(screen.getByRole("tab", { name: /remotes/i }));
await waitFor(() => {
expect(screen.getByText("No remotes")).toBeInTheDocument();
});
expect(screen.queryByTestId("remote-commits-section")).not.toBeInTheDocument();
});
it("highlights selected remote in selector", async () => {
(fetchGitRemotesDetailed as any).mockResolvedValue([
{ name: "origin", fetchUrl: "https://github.com/a/b.git", pushUrl: "https://github.com/a/b.git" },
{ name: "upstream", fetchUrl: "https://github.com/c/d.git", pushUrl: "https://github.com/c/d.git" },
]);
render(
<GitManagerModal isOpen={true} onClose={vi.fn()} tasks={mockTasks} addToast={mockAddToast} />
);
fireEvent.click(screen.getByRole("tab", { name: /remotes/i }));
await waitFor(() => {
expect(screen.getByTestId("remote-selector")).toBeInTheDocument();
});
await waitFor(() => {
expect(screen.getByTestId("remote-sync-card")).toBeInTheDocument();
});
const remoteSelector = screen.getByTestId("remote-selector");
const selectorItems = remoteSelector.querySelectorAll(".gm-remote-selector-item");
expect(selectorItems.length).toBe(2);
expect(selectorItems[0].classList.contains("selected")).toBe(true);
});
it("shows compact remote selector with hostnames, not full URLs", async () => {
(fetchGitRemotesDetailed as any).mockResolvedValue([
{ name: "origin", fetchUrl: "https://github.com/user/repo.git", pushUrl: "https://github.com/user/repo.git" },
]);
render(
<GitManagerModal isOpen={true} onClose={vi.fn()} tasks={mockTasks} addToast={mockAddToast} />
);
fireEvent.click(screen.getByRole("tab", { name: /remotes/i }));
await waitFor(() => {
expect(screen.getByTestId("remote-selector")).toBeInTheDocument();
});
await waitFor(() => {
expect(screen.getByTestId("remote-detail-card")).toBeInTheDocument();
});
const selector = screen.getByTestId("remote-selector");
// Selector shows the remote name
expect(within(selector).getByText("origin")).toBeInTheDocument();
// Selector shows hostname, not full URL
expect(within(selector).getByText("github.com")).toBeInTheDocument();
// Full URL should NOT be in the selector — only in the detail card
expect(selector.textContent).not.toContain("https://github.com/user/repo.git");
// Full URL appears in the detail card
expect(screen.getByTestId("remote-detail-card").textContent).toContain("https://github.com/user/repo.git");
});
it("shows default badge for origin remote only", async () => {
(fetchGitRemotesDetailed as any).mockResolvedValue([
{ name: "origin", fetchUrl: "https://github.com/user/repo.git", pushUrl: "" },
{ name: "upstream", fetchUrl: "https://gitlab.com/other/repo.git", pushUrl: "" },
]);
render(
<GitManagerModal isOpen={true} onClose={vi.fn()} tasks={mockTasks} addToast={mockAddToast} />
);
fireEvent.click(screen.getByRole("tab", { name: /remotes/i }));
await waitFor(() => {
expect(screen.getByTestId("remote-selector")).toBeInTheDocument();
});
await waitFor(() => {
expect(within(screen.getByTestId("remote-selector")).getByText("upstream")).toBeInTheDocument();
});
// Only origin has default badge — look for "default" text in selector
const selector = screen.getByTestId("remote-selector");
const badges = within(selector).getAllByText("default");
expect(badges.length).toBe(1);
});
it("shows full URLs and edit controls only for selected remote", async () => {
(fetchGitRemotesDetailed as any).mockResolvedValue([
{ name: "origin", fetchUrl: "https://github.com/user/repo.git", pushUrl: "" },
{ name: "upstream", fetchUrl: "https://gitlab.com/other/repo.git", pushUrl: "" },
]);
render(
<GitManagerModal isOpen={true} onClose={vi.fn()} tasks={mockTasks} addToast={mockAddToast} />
);
fireEvent.click(screen.getByRole("tab", { name: /remotes/i }));
await waitFor(() => {
expect(screen.getByTestId("remote-detail-card")).toBeInTheDocument();
});
// Detail card shows full URL for auto-selected remote (origin)
expect(screen.getByTestId("remote-detail-card").textContent).toContain("https://github.com/user/repo.git");
// upstream URL should NOT appear in the detail card
expect(screen.getByTestId("remote-detail-card").textContent).not.toContain("gitlab.com/other/repo.git");
// Edit buttons are in the detail card
expect(screen.getByTitle("Edit remote URL")).toBeInTheDocument();
});
it("shows placeholder in detail panel when no remote selected", async () => {
(fetchGitRemotesDetailed as any).mockResolvedValue([
{ name: "origin", fetchUrl: "https://github.com/user/repo.git", pushUrl: "" },
]);
render(
<GitManagerModal isOpen={true} onClose={vi.fn()} tasks={mockTasks} addToast={mockAddToast} />
);
fireEvent.click(screen.getByRole("tab", { name: /remotes/i }));
// Remote is auto-selected, so detail card should be visible
await waitFor(() => {
expect(screen.getByTestId("remote-detail-card")).toBeInTheDocument();
});
// When there are no remotes, the detail panel shows a placeholder
(fetchGitRemotesDetailed as any).mockResolvedValue([]);
// Trigger reload by switching tabs
fireEvent.click(screen.getByRole("tab", { name: /status/i }));
fireEvent.click(screen.getByRole("tab", { name: /remotes/i }));
await waitFor(() => {
expect(screen.getByText("No remotes")).toBeInTheDocument();
});
// Detail panel should show empty state message
expect(screen.getByText("Select a remote to view details")).toBeInTheDocument();
});
// ── Refresh Button ─────────────────────────────────────────
it("refreshes data when refresh button is clicked", async () => {
const user = userEvent.setup();
render(
<GitManagerModal isOpen={true} onClose={vi.fn()} tasks={mockTasks} addToast={mockAddToast} />
);
await waitFor(() => {
expect(screen.getByText("Repository Status")).toBeInTheDocument();
});
const refreshBtn = screen.getByTitle("Refresh");
await user.click(refreshBtn);
// Should call fetchGitStatus again (already called once on mount)
await waitFor(() => {
expect(fetchGitStatus).toHaveBeenCalledTimes(2);
});
});
// ── Remotes Tab: Click-to-Diff for Commits to Push ────────────
it("expands diff when clicking a commit in Commits to Push", async () => {
(fetchGitStatus as any).mockResolvedValue({
branch: "main",
commit: "abc1234",
isDirty: false,
ahead: 2,
behind: 0,
});
(fetchAheadCommits as any).mockResolvedValue([
{ hash: "aaa1111", shortHash: "aaa1", message: "First ahead commit", author: "Dev", date: "2026-01-01T00:00:00Z", parents: [] },
{ hash: "bbb2222", shortHash: "bbb2", message: "Second ahead commit", author: "Dev", date: "2026-01-02T00:00:00Z", parents: [] },
]);
(fetchCommitDiff as any).mockResolvedValue({
stat: " file.ts | 3 ++-",
patch: "diff --git a/file.ts b/file.ts\n-old\n+new",
});
const user = userEvent.setup();
render(
<GitManagerModal isOpen={true} onClose={vi.fn()} tasks={mockTasks} addToast={mockAddToast} />
);
fireEvent.click(screen.getByRole("tab", { name: /remotes/i }));
// Wait for ahead commits to appear
await waitFor(() => {
expect(screen.getByTestId("commits-to-push")).toBeInTheDocument();
expect(screen.getByText("First ahead commit")).toBeInTheDocument();
});
// Click on the commit to expand diff
await user.click(screen.getByText("First ahead commit"));
await waitFor(() => {
expectLatestCallStartsWith(fetchCommitDiff as any, "aaa1111");
});
// Diff content should be rendered
await waitFor(() => {
expect(screen.getByText(/file\.ts \| 3/)).toBeInTheDocument();
expect(screen.getByText(/-old/)).toBeInTheDocument();
expect(screen.getByText(/\+new/)).toBeInTheDocument();
});
});
it("collapses diff when clicking same ahead commit again", async () => {
(fetchGitStatus as any).mockResolvedValue({
branch: "main",
commit: "abc1234",
isDirty: false,
ahead: 1,
behind: 0,
});
(fetchAheadCommits as any).mockResolvedValue([
{ hash: "aaa1111", shortHash: "aaa1", message: "Toggle commit", author: "Dev", date: "2026-01-01T00:00:00Z", parents: [] },
]);
(fetchCommitDiff as any).mockResolvedValue({
stat: " file.ts | 1 +",
patch: "diff --git a/file.ts\n+line",
});
const user = userEvent.setup();
render(
<GitManagerModal isOpen={true} onClose={vi.fn()} tasks={mockTasks} addToast={mockAddToast} />
);
fireEvent.click(screen.getByRole("tab", { name: /remotes/i }));
await waitFor(() => {
expect(screen.getByText("Toggle commit")).toBeInTheDocument();
});
// Click to expand
await user.click(screen.getByText("Toggle commit"));
await waitFor(() => {
expectLatestCallStartsWith(fetchCommitDiff as any, "aaa1111");
});
// Diff should be visible
await waitFor(() => {
expect(screen.getByText(/file\.ts \| 1/)).toBeInTheDocument();
});
// Click again to collapse (use first match — the compact header is the clickable element)
await user.click(screen.getAllByText("Toggle commit")[0]);
// Diff content should be gone
await waitFor(() => {
expect(screen.queryByText(/file\.ts \| 1/)).not.toBeInTheDocument();
});
});
it("shows error state when diff fetch fails for ahead commit", async () => {
(fetchGitStatus as any).mockResolvedValue({
branch: "main",
commit: "abc1234",
isDirty: false,
ahead: 1,
behind: 0,
});
(fetchAheadCommits as any).mockResolvedValue([
{ hash: "aaa1111", shortHash: "aaa1", message: "Error commit", author: "Dev", date: "2026-01-01T00:00:00Z", parents: [] },
]);
(fetchCommitDiff as any).mockRejectedValue(new Error("Diff load failed"));
const user = userEvent.setup();
render(
<GitManagerModal isOpen={true} onClose={vi.fn()} tasks={mockTasks} addToast={mockAddToast} />
);
fireEvent.click(screen.getByRole("tab", { name: /remotes/i }));
await waitFor(() => {
expect(screen.getByText("Error commit")).toBeInTheDocument();
});
// Click to expand
await user.click(screen.getByText("Error commit"));
// Should show error fallback
await waitFor(() => {
expect(screen.getByText("Failed to load diff")).toBeInTheDocument();
});
});
// ── Remotes Tab: Click-to-Diff for Remote Commits ─────────────
it("expands diff when clicking a commit in remote commits section", async () => {
(fetchRemoteCommits as any).mockResolvedValue([
{ hash: "rc1hash1", shortHash: "rc1", message: "Remote commit 1", author: "Dev", date: "2026-01-01T00:00:00Z", parents: [] },
{ hash: "rc2hash2", shortHash: "rc2", message: "Remote commit 2", author: "Dev", date: "2026-01-02T00:00:00Z", parents: [] },
]);
(fetchCommitDiff as any).mockResolvedValue({
stat: " src/app.ts | 5 ++---",
patch: "diff --git a/src/app.ts\n-old line\n+new line",
});
const user = userEvent.setup();
render(
<GitManagerModal isOpen={true} onClose={vi.fn()} tasks={mockTasks} addToast={mockAddToast} />
);
fireEvent.click(screen.getByRole("tab", { name: /remotes/i }));
// Wait for remote commits to appear
await waitFor(() => {
expect(screen.getByTestId("remote-commits-section")).toBeInTheDocument();
expect(screen.getByText("Remote commit 1")).toBeInTheDocument();
});
// Click on the remote commit to expand diff
await user.click(screen.getByText("Remote commit 1"));
await waitFor(() => {
expectLatestCallStartsWith(fetchCommitDiff as any, "rc1hash1");
});
// Diff content should be rendered
await waitFor(() => {
expect(screen.getByText(/src\/app\.ts \| 5/)).toBeInTheDocument();
});
});
it("collapses diff when clicking same remote commit again", async () => {
(fetchRemoteCommits as any).mockResolvedValue([
{ hash: "rc1hash1", shortHash: "rc1", message: "Remote toggle commit", author: "Dev", date: "2026-01-01T00:00:00Z", parents: [] },
]);
(fetchCommitDiff as any).mockResolvedValue({
stat: " file.ts | 2 +",
patch: "diff --git a/file.ts\n+line",
});
const user = userEvent.setup();
render(
<GitManagerModal isOpen={true} onClose={vi.fn()} tasks={mockTasks} addToast={mockAddToast} />
);
fireEvent.click(screen.getByRole("tab", { name: /remotes/i }));
await waitFor(() => {
expect(screen.getByText("Remote toggle commit")).toBeInTheDocument();
});
// Click to expand
await user.click(screen.getByText("Remote toggle commit"));
await waitFor(() => {
expectLatestCallStartsWith(fetchCommitDiff as any, "rc1hash1");
});
// Diff should be visible
await waitFor(() => {
expect(screen.getByText(/file\.ts \| 2/)).toBeInTheDocument();
});
// Click again to collapse (use first match — the compact header is the clickable element)
await user.click(screen.getAllByText("Remote toggle commit")[0]);
// Diff content should be gone
await waitFor(() => {
expect(screen.queryByText(/file\.ts \| 2/)).not.toBeInTheDocument();
});
});
it("shows error state when diff fetch fails for remote commit", async () => {
(fetchRemoteCommits as any).mockResolvedValue([
{ hash: "rc1hash1", shortHash: "rc1", message: "Error remote commit", author: "Dev", date: "2026-01-01T00:00:00Z", parents: [] },
]);
(fetchCommitDiff as any).mockRejectedValue(new Error("Diff load failed"));
const user = userEvent.setup();
render(
<GitManagerModal isOpen={true} onClose={vi.fn()} tasks={mockTasks} addToast={mockAddToast} />
);
fireEvent.click(screen.getByRole("tab", { name: /remotes/i }));
await waitFor(() => {
expect(screen.getByText("Error remote commit")).toBeInTheDocument();
});
// Click to expand
await user.click(screen.getByText("Error remote commit"));
// Should show error fallback
await waitFor(() => {
expect(screen.getByText("Failed to load diff")).toBeInTheDocument();
});
});
it("only expands one diff at a time in remote commits list", async () => {
(fetchRemoteCommits as any).mockResolvedValue([
{ hash: "rc1hash1", shortHash: "rc1", message: "First remote", author: "Dev", date: "2026-01-01T00:00:00Z", parents: [] },
{ hash: "rc2hash2", shortHash: "rc2", message: "Second remote", author: "Dev", date: "2026-01-02T00:00:00Z", parents: [] },
]);
(fetchCommitDiff as any).mockResolvedValue({
stat: " file.ts | 1 +",
patch: "diff --git a/file.ts\n+line",
});
const user = userEvent.setup();
render(
<GitManagerModal isOpen={true} onClose={vi.fn()} tasks={mockTasks} addToast={mockAddToast} />
);
fireEvent.click(screen.getByRole("tab", { name: /remotes/i }));
await waitFor(() => {
expect(screen.getByText("First remote")).toBeInTheDocument();
expect(screen.getByText("Second remote")).toBeInTheDocument();
});
// Click first commit
await user.click(screen.getByText("First remote"));
await waitFor(() => {
expectLatestCallStartsWith(fetchCommitDiff as any, "rc1hash1");
});
// Click second commit — should collapse first, expand second
await user.click(screen.getByText("Second remote"));
await waitFor(() => {
expectLatestCallStartsWith(fetchCommitDiff as any, "rc2hash2");
});
// fetchCommitDiff should have been called for both commits
expect(fetchCommitDiff).toHaveBeenCalledTimes(2);
});
// ── projectId Propagation ───────────────────────────────────────
describe("projectId propagation", () => {
it("passes projectId to fetchGitRemotesDetailed when remotes tab loads", async () => {
render(
<GitManagerModal
isOpen={true}
onClose={vi.fn()}
tasks={mockTasks}
addToast={mockAddToast}
projectId="proj-abc"
/>
);
fireEvent.click(screen.getByRole("tab", { name: /remotes/i }));
await waitFor(() => {
expect(fetchGitRemotesDetailed).toHaveBeenCalledWith("proj-abc");
});
});
it("passes projectId to fetchRemote when Fetch is clicked", async () => {
const user = userEvent.setup();
// Ensure remotes are available for selection
(fetchGitRemotesDetailed as any).mockResolvedValue([
{ name: "origin", fetchUrl: "https://github.com/dustinbyrne/kb.git", pushUrl: "https://github.com/dustinbyrne/kb.git" },
]);
render(
<GitManagerModal
isOpen={true}
onClose={vi.fn()}
tasks={mockTasks}
addToast={mockAddToast}
projectId="proj-abc"
/>
);
fireEvent.click(screen.getByRole("tab", { name: /remotes/i }));
// Wait for the sync card to appear (indicates remote is selected)
const syncCard = await screen.findByTestId("remote-sync-card");
// Scope button search within the sync card to avoid matching the Refresh button
const fetchButton = within(syncCard).getByRole("button", { name: /fetch/i });
await user.click(fetchButton);
await waitFor(() => {
expectLatestCallStartsWith(fetchRemote as any, undefined, "proj-abc");
});
});
it("passes projectId to fetchAheadCommits when ahead commits are loaded", async () => {
// Mock status with ahead > 0 to trigger loadAheadCommits
(fetchGitStatus as any).mockResolvedValue({
branch: "main",
commit: "abc1234",
isDirty: false,
ahead: 2,
behind: 0,
});
(fetchAheadCommits as any).mockResolvedValue([
{
hash: "aaa1111",
shortHash: "aaa1",
message: "Ahead commit 1",
author: "Dev",
date: "2026-01-01T00:00:00Z",
parents: [],
},
]);
render(
<GitManagerModal
isOpen={true}
onClose={vi.fn()}
tasks={mockTasks}
addToast={mockAddToast}
projectId="proj-abc"
/>
);
fireEvent.click(screen.getByRole("tab", { name: /remotes/i }));
await waitFor(() => {
expect(fetchAheadCommits).toHaveBeenCalledWith("proj-abc");
});
});
it("passes projectId to fetchRemoteCommits when remote commits are loaded", async () => {
(fetchRemoteCommits as any).mockResolvedValue([
{
hash: "rc1",
shortHash: "rc1",
message: "Remote commit 1",
author: "Dev",
date: "2026-01-01T00:00:00Z",
parents: [],
},
]);
render(
<GitManagerModal
isOpen={true}
onClose={vi.fn()}
tasks={mockTasks}
addToast={mockAddToast}
projectId="proj-abc"
/>
);
fireEvent.click(screen.getByRole("tab", { name: /remotes/i }));
await waitFor(() => {
expect(fetchRemoteCommits).toHaveBeenCalledWith("origin", undefined, 10, "proj-abc");
});
});
it("passes projectId to addGitRemote when adding a new remote", async () => {
const user = userEvent.setup();
(fetchGitRemotesDetailed as any).mockResolvedValue([]);
(addGitRemote as any).mockResolvedValue(undefined);
render(
<GitManagerModal
isOpen={true}
onClose={vi.fn()}
tasks={mockTasks}
addToast={mockAddToast}
projectId="proj-abc"
/>
);
fireEvent.click(screen.getByRole("tab", { name: /remotes/i }));
await waitFor(() => {
expect(screen.getByTitle("Add Remote")).toBeInTheDocument();
});
await user.click(screen.getByTitle("Add Remote"));
const nameInput = screen.getByPlaceholderText("Remote name");
const urlInput = screen.getByPlaceholderText("Repository URL");
await user.type(nameInput, "newremote");
await user.type(urlInput, "https://github.com/test/repo.git");
await user.click(screen.getByRole("button", { name: /^add$/i }));
await waitFor(() => {
expect(addGitRemote).toHaveBeenCalledWith("newremote", "https://github.com/test/repo.git", "proj-abc");
});
});
it("passes projectId to removeGitRemote when removing a remote", async () => {
const user = userEvent.setup();
mockConfirm.mockResolvedValue(true);
(fetchGitRemotesDetailed as any).mockResolvedValue([
{
name: "origin",
fetchUrl: "https://github.com/dustinbyrne/kb.git",
pushUrl: "https://github.com/dustinbyrne/kb.git",
},
]);
(removeGitRemote as any).mockResolvedValue(undefined);
render(
<GitManagerModal
isOpen={true}
onClose={vi.fn()}
tasks={mockTasks}
addToast={mockAddToast}
projectId="proj-abc"
/>
);
fireEvent.click(screen.getByRole("tab", { name: /remotes/i }));
await waitFor(() => {
expect(screen.getByText("origin")).toBeInTheDocument();
});
const removeButton = screen.getByTitle("Remove remote");
await user.click(removeButton);
await waitFor(() => {
expect(removeGitRemote).toHaveBeenCalledWith("origin", "proj-abc");
});
});
it("passes projectId to renameGitRemote when renaming a remote", async () => {
const user = userEvent.setup();
(fetchGitRemotesDetailed as any).mockResolvedValue([
{
name: "origin",
fetchUrl: "https://github.com/dustinbyrne/kb.git",
pushUrl: "https://github.com/dustinbyrne/kb.git",
},
]);
(renameGitRemote as any).mockResolvedValue(undefined);
render(
<GitManagerModal
isOpen={true}
onClose={vi.fn()}
tasks={mockTasks}
addToast={mockAddToast}
projectId="proj-abc"
/>
);
fireEvent.click(screen.getByRole("tab", { name: /remotes/i }));
await waitFor(() => {
expect(screen.getByTestId("remote-detail-card")).toBeInTheDocument();
});
const detailCard = screen.getByTestId("remote-detail-card");
const renameButton = within(detailCard).getByTitle("Edit remote name");
await user.click(renameButton);
const nameInput = screen.getByDisplayValue("origin");
await user.clear(nameInput);
await user.type(nameInput, "upstream");
const saveButton = nameInput.closest(".gm-remote-edit")?.querySelector(".btn.btn-sm.btn-primary");
expect(saveButton).toBeTruthy();
await user.click(saveButton as HTMLButtonElement);
await waitFor(() => {
expect(renameGitRemote).toHaveBeenCalledWith("origin", "upstream", "proj-abc");
});
});
it("passes projectId to updateGitRemoteUrl when updating remote URL", async () => {
const user = userEvent.setup();
(fetchGitRemotesDetailed as any).mockResolvedValue([
{
name: "origin",
fetchUrl: "https://old-url.com/repo.git",
pushUrl: "https://old-url.com/repo.git",
},
]);
(updateGitRemoteUrl as any).mockResolvedValue(undefined);
render(
<GitManagerModal
isOpen={true}
onClose={vi.fn()}
tasks={mockTasks}
addToast={mockAddToast}
projectId="proj-abc"
/>
);
fireEvent.click(screen.getByRole("tab", { name: /remotes/i }));
await waitFor(() => {
expect(screen.getByTestId("remote-detail-card")).toBeInTheDocument();
});
const editButton = screen.getByTitle("Edit remote URL");
await user.click(editButton);
const urlInput = screen.getByDisplayValue("https://old-url.com/repo.git");
await user.clear(urlInput);
await user.type(urlInput, "https://new-url.com/repo.git");
const saveButton = urlInput.closest(".gm-remote-edit")?.querySelector(".btn.btn-sm.btn-primary");
expect(saveButton).toBeTruthy();
await user.click(saveButton as HTMLButtonElement);
await waitFor(() => {
expect(updateGitRemoteUrl).toHaveBeenCalledWith("origin", "https://new-url.com/repo.git", "proj-abc");
});
});
});
describe("Sync local tip button (Remotes panel)", () => {
it("renders the Sync local tip button in the remotes panel when integrationBranch is set", async () => {
(fetchGitStatus as any).mockResolvedValue({
branch: "main",
commit: "abc1234",
isDirty: false,
ahead: 0,
behind: 0,
integrationBranch: "main",
isOnIntegrationBranch: true,
});
render(
<GitManagerModal isOpen={true} onClose={vi.fn()} tasks={mockTasks} addToast={mockAddToast} />
);
fireEvent.click(screen.getByRole("tab", { name: /remotes/i }));
await waitFor(() => {
expect(screen.getByTestId("remotes-sync-integration-tip-btn")).toBeInTheDocument();
});
});
it("the Remotes Sync local tip button is disabled when not on integration branch", async () => {
(fetchGitStatus as any).mockResolvedValue({
branch: "feature/bar",
commit: "abc1234",
isDirty: false,
ahead: 0,
behind: 0,
integrationBranch: "main",
isOnIntegrationBranch: false,
});
render(
<GitManagerModal isOpen={true} onClose={vi.fn()} tasks={mockTasks} addToast={mockAddToast} />
);
fireEvent.click(screen.getByRole("tab", { name: /remotes/i }));
await waitFor(() => {
const btn = screen.getByTestId("remotes-sync-integration-tip-btn");
expect(btn).toBeDisabled();
});
});
});
describe("recent merge advances panel", () => {
it("hides sync CTA when advances are handled and head is aligned", async () => {
const supersededSha = "e".repeat(40);
(fetchGitStatus as any).mockResolvedValue({
branch: "main",
commit: "abc1234",
isDirty: false,
ahead: 0,
behind: 0,
integrationBranch: "main",
aheadOfIntegration: 0,
behindIntegration: 0,
recentMergeAdvances: [
{ taskId: "FN-1", fromSha: null, toSha: "a".repeat(40), advancedAt: new Date().toISOString(), needsAction: false, resolution: "orphaned" },
{ taskId: "FN-2", fromSha: null, toSha: "b".repeat(40), advancedAt: new Date().toISOString(), needsAction: false, resolution: "subsumed" },
{ taskId: "FN-3", fromSha: null, toSha: supersededSha, advancedAt: new Date().toISOString(), needsAction: false, resolution: "superseded" },
],
});
render(<GitManagerModal isOpen={true} onClose={vi.fn()} tasks={mockTasks} addToast={mockAddToast} />);
await waitFor(() => expect(screen.getByTestId("recent-merge-advances")).toBeInTheDocument());
expect(screen.getByText(/\(0 need action\)/i)).toBeInTheDocument();
expect(screen.queryByTestId("sync-working-tree-btn")).not.toBeInTheDocument();
expect(screen.getByTestId(`dismiss-advance-${supersededSha}`)).toBeInTheDocument();
});
it("shows sync CTA when pending advance exists", async () => {
(fetchGitStatus as any).mockResolvedValue({
branch: "main",
commit: "abc1234",
isDirty: false,
ahead: 0,
behind: 0,
integrationBranch: "main",
aheadOfIntegration: 1,
behindIntegration: 0,
recentMergeAdvances: [
{ taskId: "FN-3", fromSha: null, toSha: "c".repeat(40), advancedAt: new Date().toISOString(), needsAction: true, resolution: "pending" },
],
});
render(<GitManagerModal isOpen={true} onClose={vi.fn()} tasks={mockTasks} addToast={mockAddToast} />);
await waitFor(() => expect(screen.getByTestId("sync-working-tree-btn")).toBeInTheDocument());
await userEvent.click(screen.getByTestId("sync-working-tree-btn"));
});
it("dismisses orphaned/subsumed/superseded entries", async () => {
const toSha = "d".repeat(40);
(fetchGitStatus as any).mockResolvedValue({
branch: "main",
commit: "abc1234",
isDirty: false,
ahead: 0,
behind: 0,
integrationBranch: "main",
aheadOfIntegration: 0,
behindIntegration: 0,
recentMergeAdvances: [
{ taskId: "FN-4", fromSha: null, toSha, advancedAt: new Date().toISOString(), needsAction: false, resolution: "orphaned" },
],
});
render(<GitManagerModal isOpen={true} onClose={vi.fn()} tasks={mockTasks} addToast={mockAddToast} />);
await waitFor(() => expect(screen.getByText("FN-4")).toBeInTheDocument());
await userEvent.click(screen.getByTestId(`dismiss-advance-${toSha}`));
expect(screen.queryByText("FN-4")).not.toBeInTheDocument();
});
});
describe("CSS regression coverage", () => {
it("includes remotes layout selectors and mobile rules", () => {
const css = loadAllAppCss();
expect(css).toContain(".gm-remotes-layout");
expect(css).toContain(".gm-remote-selector");
expect(css).toContain(".gm-remote-detail");
expect(css).toContain(".gm-remote-sync-card");
expect(css).toContain(".gm-remote-detail-card");
expect(css).toMatch(/@media[^{]*\(max-width: 768px\)[^{]*\{[\s\S]*?\.gm-remotes-layout[\s\S]*?\.gm-remote-selector[\s\S]*?\}/);
});
it("keeps remotes-specific status/surface styles tokenized", () => {
const css = loadAllAppCss();
const remoteSection = css.slice(css.indexOf("/* ── Remote Panel ── */"), css.indexOf("/* ── Commit Items (shared) ── */"));
expect(remoteSection).toContain("color-mix(");
expect(remoteSection).not.toMatch(/rgba\(/i);
expect(remoteSection).not.toMatch(/#[0-9a-f]{3,8}/i);
});
it("includes mobile wrapping rules for changes file rows and section actions", () => {
const css = loadAllAppCss();
expect(css).toMatch(/@media[^{]*\(max-width: 768px\)[^{]*\{[\s\S]*?\.gm-file-section-actions\s*\{[\s\S]*?flex-wrap:\s*wrap;[\s\S]*?flex:\s*1 1 100%;/);
expect(css).toMatch(/@media[^{]*\(max-width: 768px\)[^{]*\{[\s\S]*?\.gm-file-item\s*\{[\s\S]*?min-width:\s*0;[\s\S]*?flex-wrap:\s*wrap;/);
expect(css).toMatch(/@media[^{]*\(max-width: 768px\)[^{]*\{[\s\S]*?\.gm-file-section\s*\{[\s\S]*?max-width:\s*100%;/);
});
it("includes commit target and worktree actions in modal mobile and embedded narrow layouts", () => {
const css = loadAllAppCss();
const mobile768 = getMediaBlocks(css, /@media[^{]*\(max-width:\s*768px\)[^{]*\{/g).join("\n");
const embeddedNarrow = getMediaBlocks(css, /@container\s+gm-embedded\s+\(max-width:\s*560px\)\s*\{/g).join("\n");
expect(css).toContain(".gm-commit-target");
expect(css).toContain(".gm-worktree-actions");
expect(mobile768).toContain(".gm-commit-target select");
expect(mobile768).toContain(".gm-worktree-actions .btn");
expect(embeddedNarrow).toContain(".gm-modal--embedded .gm-commit-target select");
expect(embeddedNarrow).toContain(".gm-modal--embedded .gm-worktree-actions .btn");
const mobileTargetRules = getRuleBlocks(mobile768, ".gm-commit-target,\n .gm-commit-target select,\n .gm-commit-controls .gm-search-box");
expect(mobileTargetRules).toHaveLength(1);
expect(mobileTargetRules[0]).toContain("width: 100%;");
const embeddedTargetRules = getRuleBlocks(embeddedNarrow, ".gm-modal--embedded .gm-commit-target,\n .gm-modal--embedded .gm-commit-target select,\n .gm-modal--embedded .gm-commit-controls .gm-search-box");
expect(embeddedTargetRules).toHaveLength(1);
expect(embeddedTargetRules[0]).toContain("width: 100%;");
});
it("keeps the mobile Git Manager tab strip non-shrinking at 768px and 720px breakpoints", () => {
const css = loadAllAppCss();
const mobile768 = getMediaBlocks(css, /@media[^{]*\(max-width:\s*768px\)[^{]*\{/g).join("\n");
const mobile720 = getMediaBlocks(css, /@media[^{]*\(max-width:\s*720px\)[^{]*\{/g).join("\n");
const sidebarRules = getRuleBlocks(mobile768, ".gm-sidebar");
expect(sidebarRules).toHaveLength(1);
expect(sidebarRules[0]).toContain("flex: 0 0 auto;");
expect(sidebarRules[0]).toContain("min-height: calc(var(--space-2xl) + var(--space-md));");
expect(sidebarRules[0]).toContain("overflow-x: auto;");
expect(sidebarRules[0]).toContain("overflow-y: hidden;");
expect(sidebarRules[0]).toContain("flex-wrap: nowrap;");
expect(sidebarRules[0]).toContain("touch-action: pan-x pan-y;");
expect(sidebarRules[0]).toContain("-webkit-overflow-scrolling: touch;");
expect(sidebarRules[0]).toContain("overscroll-behavior-x: contain;");
const navItemRules = getRuleBlocks(mobile768, ".gm-nav-item");
expect(navItemRules).toHaveLength(1);
// Mobile tabs are compact ICON-ONLY in one scrolling row: non-shrinking via flex:0 0 auto + intrinsic width:auto (overrides the base .gm-nav-item width:100% that otherwise made one tab fill the row).
expect(navItemRules[0]).toContain("flex: 0 0 auto;");
expect(navItemRules[0]).toContain("width: auto;");
const refreshRules = getRuleBlocks(mobile768, ".gm-nav-refresh");
expect(refreshRules).toHaveLength(1);
expect(refreshRules[0]).toContain("flex: 0 0 auto;");
expect(refreshRules[0]).toContain("width: auto;");
expect(mobile720).not.toContain(".gm-sidebar");
expect(mobile720).not.toContain(".gm-nav-item");
});
});
});