Files
fusion/packages/dashboard/app/components/__tests__/GitHubImportModal.test.tsx
gsxdsm d893a026df FN-7971: hide GitLab import tab when GitLab is disabled
Hide the Import Tasks GitLab provider affordance when gitlabEnabled is off, coerce restored GitLab state to GitHub, and document the behavior.

- Gate GitLab provider tab visibility on effective gitlabEnabled and wait for settings before replaying persisted GitLab auto-load
- Coerce disabled GitLab provider preference to GitHub without firing GitLab fetch/import requests
- Cover hide/show/coerce paths in GitHubImportModal tests and update dashboard guide copy
- Add patch changeset for the published package

Files changed:
 .changeset/fn-7971-hide-gitlab-when-disabled.md    |  7 +++
 docs/dashboard-guide.md                            |  4 +-
 .../dashboard/app/components/GitHubImportModal.tsx | 27 ++++++++--
 .../__tests__/GitHubImportModal.test.tsx           | 62 +++++++++++++++++++---
 4 files changed, 88 insertions(+), 12 deletions(-)

Fusion-Task-Id: FN-7971

Fusion-Task-Lineage: e645a4a7-85e0-4635-8dad-f5839390e5c5

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
2026-07-15 12:47:14 -07:00

2673 lines
122 KiB
TypeScript

import { describe, it, expect, vi, beforeEach } from "vitest";
import { act, render, screen, fireEvent, waitFor, within } from "@testing-library/react";
import { GitHubImportModal } from "../GitHubImportModal";
import {
apiFetchGitHubIssues,
apiImportGitHubIssue,
apiFetchGitHubPulls,
apiFetchGitHubPullDetail,
apiFetchGitHubIssueDetail,
apiCloseGitHubIssue,
apiImportGitHubPull,
apiFetchGitLabProjectIssues,
apiFetchGitLabGroupIssues,
apiFetchGitLabMergeRequests,
apiImportGitLabProjectIssue,
apiImportGitLabGroupIssue,
apiImportGitLabMergeRequest,
fetchSettings,
fetchGitRemotes,
translateImportContent,
} from "../../api";
import type { Task } from "@fusion/core";
import type { GitRemote } from "../../api";
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
// Mock the API module
vi.mock("../../api", async (importOriginal) => {
const actual = await importOriginal<typeof import("../../api")>();
return {
...actual,
apiFetchGitHubIssues: vi.fn(),
apiImportGitHubIssue: vi.fn(),
apiFetchGitHubPulls: vi.fn(),
apiFetchGitHubPullDetail: vi.fn(),
apiFetchGitHubIssueDetail: vi.fn(),
apiCloseGitHubIssue: vi.fn(),
apiImportGitHubPull: vi.fn(),
apiFetchGitLabProjectIssues: vi.fn(),
apiFetchGitLabGroupIssues: vi.fn(),
apiFetchGitLabMergeRequests: vi.fn(),
apiImportGitLabProjectIssue: vi.fn(),
apiImportGitLabGroupIssue: vi.fn(),
apiImportGitLabMergeRequest: vi.fn(),
fetchSettings: vi.fn(),
fetchGitRemotes: vi.fn(),
translateImportContent: vi.fn(),
};
});
const mockTask: Task = {
id: "FN-001",
title: "Test Issue",
description: "Test body\n\nSource: https://github.com/owner/repo/issues/1",
column: "triage",
dependencies: [],
steps: [],
currentStep: 0,
log: [],
createdAt: "2024-01-01T00:00:00Z",
updatedAt: "2024-01-01T00:00:00Z",
};
const mockPRTask: Task = {
id: "FN-002",
title: "Review PR #1: Test PR",
description: "Review and address any issues in this pull request.\n\nPR: https://github.com/owner/repo/pull/1\nBranch: feature → main\n\nPR body",
column: "triage",
dependencies: [],
steps: [],
currentStep: 0,
log: [],
createdAt: "2024-01-01T00:00:00Z",
updatedAt: "2024-01-01T00:00:00Z",
};
const singleRemote: GitRemote[] = [
{ name: "origin", owner: "dustinbyrne", repo: "kb", url: "https://github.com/dustinbyrne/kb.git" },
];
const multipleRemotes: GitRemote[] = [
{ name: "origin", owner: "dustinbyrne", repo: "kb", url: "https://github.com/dustinbyrne/kb.git" },
{ name: "upstream", owner: "upstream", repo: "kb", url: "https://github.com/upstream/kb.git" },
];
const multipleRemotesWithoutOrigin: GitRemote[] = [
{ name: "upstream", owner: "upstream", repo: "kb", url: "https://github.com/upstream/kb.git" },
{ name: "fork", owner: "dustinbyrne", repo: "kb", url: "https://github.com/dustinbyrne/kb.git" },
];
const mockPulls = [
{ number: 1, title: "Test PR", body: "PR body", html_url: "https://github.com/owner/repo/pull/1", headBranch: "feature", baseBranch: "main" },
{ number: 2, title: "Another PR", body: "Another PR body", html_url: "https://github.com/owner/repo/pull/2", headBranch: "bugfix", baseBranch: "main" },
];
describe("GitHubImportModal", () => {
const onClose = vi.fn();
const onImport = vi.fn();
it("uses color-mix tokens for focus and selection surfaces", () => {
const source = readFileSync(resolve(__dirname, "../GitHubImportModal.css"), "utf8");
expect(source).not.toContain("rgba(var(--color-primary-rgb)");
expect(source).not.toContain("rgba(var(--in-progress-rgb)");
expect(source).toContain("color-mix(in srgb, var(--in-progress) 12%, transparent)");
expect(source).toContain("Some hardcoded colors below");
});
it("keeps mobile preview content vertically scrollable inside the active pane", () => {
const source = readFileSync(resolve(__dirname, "../GitHubImportModal.css"), "utf8");
expect(source).toContain(".github-import-preview-pane.mobile.active {\n display: flex;\n flex: 1;\n min-height: 0;\n max-height: none;\n overflow: hidden;");
expect(source).toContain(".github-import-preview-pane.mobile.active .github-import-pane-content {\n flex: 1;\n min-height: 0;\n overflow-y: auto;\n overscroll-behavior: contain;");
});
it("makes the embedded import body fill the pane outside the mobile media block", () => {
const source = readFileSync(resolve(__dirname, "../GitHubImportModal.css"), "utf8");
const topLevelEmbeddedBodyRules = Array.from(
source.matchAll(/(?:^|\n)\.github-import-modal--embedded \.github-import-modal__body\s*\{[^}]*\}/g),
(match) => match[0],
);
// FNXC:GitHubImport 2026-06-28-00:00: Issues and Pull Requests share `.github-import-workspace`, so the base embedded body fill rule protects both tabs in wide two-pane and narrow stacked layouts before the <=640px media override applies.
expect(topLevelEmbeddedBodyRules.some((rule) => rule.includes("flex: 1;") && rule.includes("min-height: 0;") && rule.includes("overflow-y: auto;"))).toBe(true);
const mobileEmbeddedBodyRule = source.match(/@media \(max-width: 640px\) \{[\s\S]*?\.github-import-modal--embedded \.github-import-modal__body\s*\{[^}]*\}/)?.[0] ?? "";
expect(mobileEmbeddedBodyRule).toContain("flex: 1;");
expect(mobileEmbeddedBodyRule).toContain("overflow-y: auto;");
});
it("keeps the non-embedded modal body and dialog sizing rules unchanged", () => {
const source = readFileSync(resolve(__dirname, "../GitHubImportModal.css"), "utf8");
const baseModalBodyRule = source.match(/(?:^|\n)\.github-import-modal__body\s*\{[^}]*\}/)?.[0] ?? "";
expect(baseModalBodyRule).toContain("display: flex;");
expect(baseModalBodyRule).toContain("flex-direction: column;");
expect(baseModalBodyRule).toContain("padding: var(--space-lg) var(--space-xl);");
expect(baseModalBodyRule).toContain("overflow-y: auto;");
expect(baseModalBodyRule).toContain("min-height: 0;");
expect(baseModalBodyRule).not.toContain("flex: 1;");
expect(source).toContain(".github-import-modal:not(.github-import-modal--embedded) {");
expect(source).toContain(".modal-overlay:has(.github-import-modal:not(.github-import-modal--embedded)) {");
expect(source).toContain(".modal.github-import-modal:not(.github-import-modal--embedded) {");
});
it("FN-7694: does not viewport-cap the embedded preview pane in the tablet @media (max-width: 860px) band", () => {
const source = readFileSync(resolve(__dirname, "../GitHubImportModal.css"), "utf8");
const tabletBlock = source.match(/@media \(max-width: 860px\) \{[\s\S]*?\n\}\n/)?.[0] ?? "";
expect(tabletBlock).not.toBe("");
// The regressed leak: an UNSCOPED `.github-import-preview-pane { ... max-height: 50% ... }`
// (or `.github-import-list-pane`) rule inside the tablet media block would apply to the
// embedded surface too, clipping a tall selected issue/PR preview (FN-7694 symptom).
expect(tabletBlock).not.toMatch(/(?:^|\n)\s*\.github-import-preview-pane\s*\{[^}]*max-height:\s*50%/);
expect(tabletBlock).not.toMatch(/(?:^|\n)\s*\.github-import-list-pane\s*\{[^}]*max-height:\s*50%/);
// Every pane/layout rule in the tablet block must be scoped to the non-embedded dialog only.
expect(tabletBlock).toContain(
".github-import-modal:not(.github-import-modal--embedded) .github-import-preview-pane {\n flex: none;\n max-height: 50%;\n }",
);
expect(tabletBlock).toContain(".github-import-modal:not(.github-import-modal--embedded) .github-import-list-pane {");
expect(tabletBlock).toContain(".github-import-modal:not(.github-import-modal--embedded) .github-import-workspace {");
expect(tabletBlock).toContain(".github-import-modal:not(.github-import-modal--embedded) .github-import-workspace__resize-handle {");
// Guard: the dialog path must still receive the tablet stacking/cap behavior (unchanged for non-embedded).
expect(tabletBlock).toMatch(
/\.github-import-modal:not\(\.github-import-modal--embedded\) \.github-import-list-pane \{[^}]*max-height:\s*50%[^}]*\}/,
);
// The embedded wide two-pane preview rule must keep its own non-capped layout so the fix
// cannot be silently reverted by re-adding a viewport cap.
const wideContainerBlock = source.match(/@container github-import-embedded \(min-width: 720px\) \{[\s\S]*?\n\}\n/)?.[0] ?? "";
expect(wideContainerBlock).toContain(".github-import-modal--embedded .github-import-preview-pane {\n flex: 1 1 auto;\n min-height: 0;\n }");
expect(wideContainerBlock).not.toContain("max-height: 50%");
});
it("styles import type tabs like the Artifacts button bar", () => {
const source = readFileSync(resolve(__dirname, "../GitHubImportModal.css"), "utf8");
const tabsRule = source.match(/\.github-import-tabs\s*\{[^}]*\}/)?.[0] ?? "";
const tabRule = source.match(/\.github-import-tab\s*\{[^}]*\}/)?.[0] ?? "";
const activeRule = source.match(/\.github-import-tab\.active\s*\{[^}]*\}/)?.[0] ?? "";
expect(tabsRule).toContain("background: transparent;");
expect(tabsRule).toContain("border-bottom: none;");
expect(tabRule).toContain("border: 1px solid var(--border);");
expect(tabRule).toContain("background: var(--surface);");
expect(activeRule).toContain("color: var(--todo);");
expect(activeRule).toContain("border-color: var(--todo);");
expect(activeRule).toContain("background: color-mix(in srgb, var(--todo) 12%, transparent);");
});
/*
* FNXC:GitHubImport 2026-07-07-00:00:
* FN-7657 introduced per-project persistence for the import view (provider/tab/labels/remote/selection) under
* `kb-dashboard-github-import-state` (unscoped when no projectId is passed, `kb:{projectId}:...` otherwise). Most
* pre-existing tests in this file render without a projectId and therefore share the SAME unscoped storage key, so
* that key (and the projectIds exercised anywhere in this file) must be cleared before EVERY test or state written
* by one test would leak into the next test's initial render.
*/
const GITHUB_IMPORT_STATE_KEY = "kb-dashboard-github-import-state";
const clearAllPersistedImportState = () => {
window.localStorage.removeItem(GITHUB_IMPORT_STATE_KEY);
for (const projectId of ["project-1", "project-2", "project-a", "project-b"]) {
window.localStorage.removeItem(`kb:${projectId}:${GITHUB_IMPORT_STATE_KEY}`);
}
};
beforeEach(() => {
vi.clearAllMocks();
clearAllPersistedImportState();
vi.mocked(fetchGitRemotes).mockReset();
vi.mocked(apiFetchGitHubIssues).mockReset();
vi.mocked(apiImportGitHubIssue).mockReset();
vi.mocked(apiFetchGitHubPulls).mockReset();
vi.mocked(apiFetchGitHubPullDetail).mockReset();
vi.mocked(apiFetchGitHubIssueDetail).mockReset();
vi.mocked(apiCloseGitHubIssue).mockReset();
vi.mocked(apiImportGitHubPull).mockReset();
vi.mocked(apiFetchGitLabProjectIssues).mockReset();
vi.mocked(apiFetchGitLabGroupIssues).mockReset();
vi.mocked(apiFetchGitLabMergeRequests).mockReset();
vi.mocked(apiImportGitLabProjectIssue).mockReset();
vi.mocked(apiImportGitLabGroupIssue).mockReset();
vi.mocked(apiImportGitLabMergeRequest).mockReset();
vi.mocked(fetchSettings).mockReset();
vi.mocked(fetchSettings).mockResolvedValue({ gitlabEnabled: true } as never);
// Set default mock for apiFetchGitHubIssues to return empty array (prevents undefined issues state)
vi.mocked(apiFetchGitHubIssues).mockResolvedValue([]);
vi.mocked(apiFetchGitHubPulls).mockResolvedValue([]);
vi.mocked(apiFetchGitHubPullDetail).mockResolvedValue({ comments: [], checks: [] });
vi.mocked(apiFetchGitHubIssueDetail).mockResolvedValue({ comments: [] });
vi.mocked(apiCloseGitHubIssue).mockResolvedValue(undefined);
vi.mocked(apiFetchGitLabProjectIssues).mockResolvedValue([]);
vi.mocked(apiFetchGitLabGroupIssues).mockResolvedValue([]);
vi.mocked(apiFetchGitLabMergeRequests).mockResolvedValue([]);
vi.mocked(apiImportGitLabProjectIssue).mockResolvedValue(mockTask);
vi.mocked(apiImportGitLabGroupIssue).mockResolvedValue(mockTask);
vi.mocked(apiImportGitLabMergeRequest).mockResolvedValue(mockTask);
onClose.mockReset();
onImport.mockReset();
});
it("renders when isOpen is true", async () => {
vi.mocked(fetchGitRemotes).mockResolvedValueOnce([]);
render(<GitHubImportModal isOpen={true} onClose={onClose} onImport={onImport} tasks={[]} />);
await waitFor(() => {
expect(screen.getByText("Import from GitHub")).toBeTruthy();
});
});
it("fetches, previews, and imports GitLab project issues", async () => {
vi.mocked(fetchGitRemotes).mockResolvedValueOnce([]);
vi.mocked(apiFetchGitLabProjectIssues).mockResolvedValueOnce([
{ resourceKind: "project_issue", id: 1, iid: 2, projectId: 3, projectPath: "group/project", title: "GitLab bug", description: "Body", webUrl: "https://gitlab.example.com/group/project/-/issues/2", state: "opened", labels: ["bug"] },
]);
vi.mocked(apiImportGitLabProjectIssue).mockResolvedValueOnce({ ...mockTask, id: "FN-099", title: "GitLab bug", description: "Body\n\nSource: https://gitlab.example.com/group/project/-/issues/2" });
render(<GitHubImportModal isOpen={true} onClose={onClose} onImport={onImport} tasks={[]} />);
fireEvent.click(await screen.findByRole("button", { name: "GitLab" }));
fireEvent.change(screen.getByLabelText("GitLab project path or ID"), { target: { value: "group/project" } });
fireEvent.click(screen.getByRole("button", { name: /Load/ }));
expect(await screen.findByText(/#2 GitLab bug/)).toBeTruthy();
fireEvent.click(screen.getByText(/#2 GitLab bug/));
expect(await screen.findByTestId("gitlab-import-preview-body")).toHaveTextContent("Body");
fireEvent.click(screen.getAllByRole("button", { name: "Import" })[0]);
await waitFor(() => expect(apiImportGitLabProjectIssue).toHaveBeenCalledWith("group/project", 2, undefined));
expect(onImport).toHaveBeenCalledWith(expect.objectContaining({ id: "FN-099" }));
});
it("hides the GitLab import provider and keeps GitHub active when GitLab is off", async () => {
vi.mocked(fetchGitRemotes).mockResolvedValueOnce([]);
vi.mocked(fetchSettings).mockResolvedValueOnce({ gitlabEnabled: false } as never);
render(<GitHubImportModal isOpen={true} onClose={onClose} onImport={onImport} tasks={[]} />);
await waitFor(() => {
expect(screen.queryByRole("button", { name: "GitLab" })).toBeNull();
});
expect(screen.getByRole("button", { name: "GitHub" })).toHaveAttribute("aria-pressed", "true");
expect(screen.queryByTestId("gitlab-import-panel")).toBeNull();
expect(screen.queryByTestId("gitlab-import-disabled")).toBeNull();
expect(apiFetchGitLabProjectIssues).not.toHaveBeenCalled();
expect(apiImportGitLabProjectIssue).not.toHaveBeenCalled();
});
it("shows the GitLab import provider when GitLab is explicitly enabled", async () => {
vi.mocked(fetchGitRemotes).mockResolvedValueOnce([]);
vi.mocked(fetchSettings).mockResolvedValueOnce({ gitlabEnabled: true } as never);
render(<GitHubImportModal isOpen={true} onClose={onClose} onImport={onImport} tasks={[]} />);
expect(await screen.findByRole("button", { name: "GitLab" })).toBeInTheDocument();
});
it("shows the GitLab import provider when the GitLab enabled setting is undefined", async () => {
vi.mocked(fetchGitRemotes).mockResolvedValueOnce([]);
vi.mocked(fetchSettings).mockResolvedValueOnce({} as never);
render(<GitHubImportModal isOpen={true} onClose={onClose} onImport={onImport} tasks={[]} />);
expect(await screen.findByRole("button", { name: "GitLab" })).toBeInTheDocument();
});
it("coerces a persisted GitLab provider to GitHub without auto-loading when GitLab is off", async () => {
vi.mocked(fetchGitRemotes).mockResolvedValueOnce([]);
vi.mocked(fetchSettings).mockResolvedValueOnce({ gitlabEnabled: false } as never);
window.localStorage.setItem(`kb:project-1:${GITHUB_IMPORT_STATE_KEY}`, JSON.stringify({
provider: "gitlab",
activeTab: "issues",
labels: "bug",
selectedRemoteName: "",
owner: "",
repo: "",
gitlabResource: "project_issue",
gitlabProject: "group/project",
gitlabGroup: "",
selectedIssueNumber: null,
selectedPullNumber: null,
selectedGitlabKey: "project_issue:3:2",
}));
render(<GitHubImportModal isOpen={true} onClose={onClose} onImport={onImport} tasks={[]} projectId="project-1" />);
await waitFor(() => {
expect(screen.queryByRole("button", { name: "GitLab" })).toBeNull();
});
expect(screen.getByRole("button", { name: "GitHub" })).toHaveAttribute("aria-pressed", "true");
expect(screen.queryByTestId("gitlab-import-panel")).toBeNull();
expect(screen.queryByTestId("gitlab-import-disabled")).toBeNull();
expect(apiFetchGitLabProjectIssues).not.toHaveBeenCalled();
expect(apiFetchGitLabGroupIssues).not.toHaveBeenCalled();
expect(apiFetchGitLabMergeRequests).not.toHaveBeenCalled();
});
it("fetches group issues and merge requests without GitHub-only copy", async () => {
vi.mocked(fetchGitRemotes).mockResolvedValue([]);
vi.mocked(apiFetchGitLabGroupIssues).mockResolvedValueOnce([
{ resourceKind: "group_issue", id: 3, iid: 7, projectId: 8, projectPath: "group/project", groupPath: "group", title: "Group issue", description: null, webUrl: "https://gitlab.example.com/group/project/-/issues/7", state: "opened", labels: [] },
]);
vi.mocked(apiFetchGitLabMergeRequests).mockResolvedValueOnce([
{ resourceKind: "merge_request", id: 4, iid: 5, projectId: 8, projectPath: "group/project", title: "Review me", description: "MR body", webUrl: "https://gitlab.example.com/group/project/-/merge_requests/5", state: "opened", labels: [], sourceBranch: "feat", targetBranch: "main" },
]);
vi.mocked(apiImportGitLabGroupIssue).mockResolvedValueOnce({ ...mockTask, id: "FN-100", title: "Group issue" });
vi.mocked(apiImportGitLabMergeRequest).mockResolvedValueOnce({ ...mockTask, id: "FN-101", title: "Review MR !5: Review me" });
render(<GitHubImportModal isOpen={true} onClose={onClose} onImport={onImport} tasks={[]} />);
fireEvent.click(await screen.findByRole("button", { name: "GitLab" }));
fireEvent.click(screen.getByRole("tab", { name: "Group issues" }));
fireEvent.change(screen.getByLabelText("GitLab group path or ID"), { target: { value: "group" } });
fireEvent.click(screen.getByRole("button", { name: /Load/ }));
expect(await screen.findByText(/#7 Group issue/)).toBeInTheDocument();
fireEvent.click(screen.getByText(/#7 Group issue/));
expect(screen.getByTestId("gitlab-import-preview-body")).toHaveTextContent("(no description)");
fireEvent.click(screen.getAllByRole("button", { name: "Import" })[0]);
await waitFor(() => expect(apiImportGitLabGroupIssue).toHaveBeenCalledWith(expect.objectContaining({ iid: 7 }), "group", undefined));
fireEvent.click(screen.getByRole("tab", { name: "Merge requests" }));
fireEvent.change(screen.getByLabelText("GitLab project path or ID"), { target: { value: "group/project" } });
fireEvent.click(screen.getByRole("button", { name: /Load/ }));
expect(await screen.findByText(/!5 Review me/)).toBeInTheDocument();
fireEvent.click(screen.getByText(/!5 Review me/));
expect(screen.getByTestId("gitlab-import-preview-body")).toHaveTextContent("MR body");
expect(screen.getByTestId("gitlab-import-panel").textContent).not.toContain("GitHub");
fireEvent.click(screen.getAllByRole("button", { name: "Import" })[0]);
await waitFor(() => expect(apiImportGitLabMergeRequest).toHaveBeenCalledWith("group/project", 5, undefined));
});
it("does not render when isOpen is false", () => {
render(<GitHubImportModal isOpen={false} onClose={onClose} onImport={onImport} tasks={[]} />);
expect(screen.queryByText("Import from GitHub")).toBeNull();
});
// FNXC:EmbeddedPresentation 2026-06-22-12:00:
// presentation="embedded" was a zero-coverage branch. Assert the embedded contract via useEmbeddedPresentation:
// embedded root class present, no fixed .modal-overlay backdrop, no close button, and Escape does NOT dismiss.
describe("embedded presentation", () => {
it("renders the embedded root class with no modal overlay or close button", async () => {
vi.mocked(fetchGitRemotes).mockResolvedValueOnce([]);
const { container } = render(
<GitHubImportModal isOpen={true} onClose={onClose} onImport={onImport} tasks={[]} presentation="embedded" />,
);
await waitFor(() => {
expect(screen.getByText("Import Tasks")).toBeTruthy();
});
expect(container.querySelector(".github-import-embedded")).not.toBeNull();
expect(container.querySelector(".github-import-modal--embedded")).not.toBeNull();
// No fixed full-screen overlay backdrop, and no modal-header / close button in embedded mode.
expect(container.querySelector(".modal-overlay")).toBeNull();
expect(screen.queryByText("Import from GitHub")).toBeNull();
expect(container.querySelector(".github-import-modal__header")).toBeNull();
});
it("does not dismiss on Escape in embedded mode", async () => {
vi.mocked(fetchGitRemotes).mockResolvedValueOnce([]);
render(<GitHubImportModal isOpen={true} onClose={onClose} onImport={onImport} tasks={[]} presentation="embedded" />);
await waitFor(() => {
expect(screen.getByText("Import Tasks")).toBeTruthy();
});
fireEvent.keyDown(document, { key: "Escape" });
expect(onClose).not.toHaveBeenCalled();
});
// FNXC:GitHubImport 2026-06-23-02:00: embedded sidebar drops the bottom Cancel+Import bar (no modal to cancel)
// and surfaces the import action at the TOP of the preview pane via github-import-action-top. The non-embedded
// modal keeps its bottom Cancel+Import bar.
it("removes the bottom action bar in embedded mode but keeps the top import button", async () => {
vi.mocked(fetchGitRemotes).mockResolvedValueOnce([]);
const { container } = render(
<GitHubImportModal isOpen={true} onClose={onClose} onImport={onImport} tasks={[]} presentation="embedded" />,
);
await waitFor(() => {
expect(screen.getByText("Import Tasks")).toBeTruthy();
});
// No bottom Cancel+Import bar in embedded mode.
expect(container.querySelector(".github-import-modal__actions")).toBeNull();
expect(screen.queryByRole("button", { name: /Cancel/i })).toBeNull();
// Top import action present.
expect(screen.getByTestId("github-import-action-top")).toBeTruthy();
});
it("keeps the bottom action bar with Cancel in modal mode plus the top import button", async () => {
vi.mocked(fetchGitRemotes).mockResolvedValueOnce([]);
const { container } = render(
<GitHubImportModal isOpen={true} onClose={onClose} onImport={onImport} tasks={[]} />,
);
await waitFor(() => {
expect(screen.getByText("Import from GitHub")).toBeTruthy();
});
expect(container.querySelector(".github-import-modal__actions")).not.toBeNull();
expect(screen.getByRole("button", { name: /Cancel/i })).toBeTruthy();
expect(screen.getByTestId("github-import-action-top")).toBeTruthy();
});
it("keeps the modal overlay and Escape-to-close in modal mode", async () => {
vi.mocked(fetchGitRemotes).mockResolvedValueOnce([]);
const { container } = render(
<GitHubImportModal isOpen={true} onClose={onClose} onImport={onImport} tasks={[]} />,
);
await waitFor(() => {
expect(screen.getByText("Import from GitHub")).toBeTruthy();
});
expect(container.querySelector(".modal-overlay")).not.toBeNull();
expect(container.querySelector(".github-import-modal--embedded")).toBeNull();
fireEvent.keyDown(document, { key: "Escape" });
expect(onClose).toHaveBeenCalled();
});
});
it("renders compact toolbar and two-pane layout", async () => {
vi.mocked(fetchGitRemotes).mockResolvedValueOnce(singleRemote);
render(<GitHubImportModal isOpen={true} onClose={onClose} onImport={onImport} tasks={[]} />);
await waitFor(() => {
// Toolbar should be present
expect(screen.getByTestId("github-import-toolbar")).toBeTruthy();
// Two panes should be present
expect(screen.getByTestId("github-import-list-pane")).toBeTruthy();
expect(screen.getByTestId("github-import-preview-pane")).toBeTruthy();
// Pane headings
expect(screen.getByRole("heading", { name: "Issues" })).toBeTruthy();
expect(screen.getByRole("heading", { name: "Preview" })).toBeTruthy();
});
expect(screen.getByTestId("github-import-results-idle")).toBeTruthy();
expect(screen.getByTestId("github-import-preview-empty")).toBeTruthy();
});
it("shows compact toolbar with remote, filter, and load button", async () => {
vi.mocked(fetchGitRemotes).mockResolvedValueOnce(singleRemote);
render(<GitHubImportModal isOpen={true} onClose={onClose} onImport={onImport} tasks={[]} />);
await waitFor(() => {
const toolbar = screen.getByTestId("github-import-toolbar");
expect(toolbar).toBeTruthy();
// Remote pill should be in toolbar
expect(within(toolbar).getByTestId("github-import-single-remote")).toBeTruthy();
// Filter input
expect(within(toolbar).getByPlaceholderText(/Filter:/)).toBeTruthy();
// Load button
expect(within(toolbar).getByRole("button", { name: /Load/i })).toBeTruthy();
});
});
it("displays two-pane layout on desktop after loading issues", async () => {
const issues = [
{ number: 1, title: "First Issue", body: "Body 1", html_url: "https://github.com/owner/repo/issues/1", labels: [] },
];
vi.mocked(fetchGitRemotes).mockResolvedValueOnce(singleRemote);
vi.mocked(apiFetchGitHubIssues).mockResolvedValueOnce(issues);
render(<GitHubImportModal isOpen={true} onClose={onClose} onImport={onImport} tasks={[]} />);
await waitFor(() => {
expect(screen.getByTestId("github-import-list-pane")).toBeTruthy();
expect(screen.getByTestId("github-import-preview-pane")).toBeTruthy();
expect(screen.getByText("First Issue")).toBeTruthy();
});
});
it("preview pane shows selected issue details", async () => {
const issues = [
{ number: 1, title: "First Issue", body: "Body 1", html_url: "https://github.com/owner/repo/issues/1", labels: [] },
];
vi.mocked(fetchGitRemotes).mockResolvedValueOnce(singleRemote);
vi.mocked(apiFetchGitHubIssues).mockResolvedValueOnce(issues);
render(<GitHubImportModal isOpen={true} onClose={onClose} onImport={onImport} tasks={[]} />);
await waitFor(() => {
expect(screen.getByText("First Issue")).toBeTruthy();
});
fireEvent.click(screen.getByRole("radio", { name: /Select issue #1/i }));
const previewCard = await screen.findByTestId("github-import-preview-card");
expect(within(previewCard).getByText("First Issue")).toBeTruthy();
expect(within(previewCard).getByText("Body 1")).toBeTruthy();
expect(screen.queryByTestId("github-import-preview-empty")).toBeNull();
});
/*
FNXC:GitHubImportTranslate 2026-07-14-12:00:
When selected issue prose is not the dashboard language, the preview must offer Translate / Dismiss and swap title+body after a successful AI translation without changing import provenance.
*/
it("offers translation when selected issue content is not the dashboard language", async () => {
const frenchBody =
"Cette issue décrit le problème avec l'aperçu d'importation et ce que nous devrions changer pour les utilisateurs qui ont du contenu dans une autre langue dans le tableau de bord.";
const issues = [
{
number: 7,
title: "Problème d'aperçu d'importation",
body: frenchBody,
html_url: "https://github.com/owner/repo/issues/7",
labels: [],
},
];
vi.mocked(fetchGitRemotes).mockResolvedValueOnce(singleRemote);
vi.mocked(apiFetchGitHubIssues).mockResolvedValueOnce(issues);
vi.mocked(translateImportContent).mockResolvedValueOnce({
title: "Import preview problem",
body: "This issue describes the import preview problem and what we should change for users who have content in another language in the dashboard.",
});
render(<GitHubImportModal isOpen={true} onClose={onClose} onImport={onImport} tasks={[]} />);
await waitFor(() => {
expect(screen.getByText(/Problème d'aperçu/)).toBeTruthy();
});
fireEvent.click(screen.getByRole("radio", { name: /Select issue #7/i }));
const translateRegion = await screen.findByTestId("github-import-translate");
expect(translateRegion).toBeTruthy();
expect(screen.getByTestId("github-import-translate-action")).toBeTruthy();
fireEvent.click(screen.getByTestId("github-import-translate-action"));
await waitFor(() => {
expect(translateImportContent).toHaveBeenCalled();
expect(screen.getByText("Import preview problem")).toBeTruthy();
});
expect(screen.getByTestId("github-import-translate-toggle")).toBeTruthy();
fireEvent.click(screen.getByTestId("github-import-translate-toggle"));
const previewCard = screen.getByTestId("github-import-preview-card");
expect(within(previewCard).getByText(/Problème d'aperçu d'importation/)).toBeTruthy();
});
it("does not show translate controls for English content when dashboard language is English", async () => {
const issues = [
{
number: 8,
title: "Import preview problem",
body: "This issue describes the problem with the import preview and what we should change for the users that have content in another language when they open the dashboard.",
html_url: "https://github.com/owner/repo/issues/8",
labels: [],
},
];
vi.mocked(fetchGitRemotes).mockResolvedValueOnce(singleRemote);
vi.mocked(apiFetchGitHubIssues).mockResolvedValueOnce(issues);
render(<GitHubImportModal isOpen={true} onClose={onClose} onImport={onImport} tasks={[]} />);
await waitFor(() => {
expect(screen.getByText("Import preview problem")).toBeTruthy();
});
fireEvent.click(screen.getByRole("radio", { name: /Select issue #8/i }));
await screen.findByTestId("github-import-preview-card");
expect(screen.queryByTestId("github-import-translate")).toBeNull();
});
it("preserves the no-description fallback for empty and null issue bodies", async () => {
const issues = [
{ number: 1, title: "Empty Issue", body: "", html_url: "https://github.com/owner/repo/issues/1", labels: [] },
{ number: 2, title: "Null Issue", body: null, html_url: "https://github.com/owner/repo/issues/2", labels: [] },
];
vi.mocked(fetchGitRemotes).mockResolvedValueOnce(singleRemote);
vi.mocked(apiFetchGitHubIssues).mockResolvedValueOnce(issues);
render(<GitHubImportModal isOpen={true} onClose={onClose} onImport={onImport} tasks={[]} />);
await waitFor(() => {
expect(screen.getByText("Empty Issue")).toBeTruthy();
expect(screen.getByText("Null Issue")).toBeTruthy();
});
fireEvent.click(screen.getByRole("radio", { name: /Select issue #1/i }));
let previewCard = await screen.findByTestId("github-import-preview-card");
expect(within(previewCard).getByText("(no description)")).toBeTruthy();
fireEvent.click(screen.getByRole("radio", { name: /Select issue #2/i }));
previewCard = await screen.findByTestId("github-import-preview-card");
expect(within(previewCard).getByText("(no description)")).toBeTruthy();
});
it("has optional labels input with filter placeholder", async () => {
vi.mocked(fetchGitRemotes).mockResolvedValueOnce(singleRemote);
render(<GitHubImportModal isOpen={true} onClose={onClose} onImport={onImport} tasks={[]} />);
await waitFor(() => {
expect(screen.getByPlaceholderText(/Filter:/)).toBeTruthy();
});
});
describe("with no remotes", () => {
it("shows 'No GitHub remotes detected' message", async () => {
vi.mocked(fetchGitRemotes).mockResolvedValueOnce([]);
render(<GitHubImportModal isOpen={true} onClose={onClose} onImport={onImport} tasks={[]} />);
await waitFor(() => {
expect(screen.getByText(/No GitHub remotes detected/)).toBeTruthy();
});
});
it("disables Load button when no remotes available", async () => {
vi.mocked(fetchGitRemotes).mockResolvedValueOnce([]);
render(<GitHubImportModal isOpen={true} onClose={onClose} onImport={onImport} tasks={[]} />);
await waitFor(() => {
expect(screen.getByText(/No GitHub remotes detected/)).toBeTruthy();
});
const loadButton = screen.getByRole("button", { name: /Load issues/i }) as HTMLButtonElement;
expect(loadButton.disabled).toBe(true);
});
});
describe("with single remote", () => {
it("loads remotes using the active project id", async () => {
vi.mocked(fetchGitRemotes).mockResolvedValueOnce(singleRemote);
render(<GitHubImportModal isOpen={true} onClose={onClose} onImport={onImport} tasks={[]} projectId="project-1" />);
await waitFor(() => {
expect(fetchGitRemotes).toHaveBeenCalledWith("project-1");
});
});
it("ignores stale remote responses after the active project changes", async () => {
const projectARemote: GitRemote[] = [
{ name: "origin", owner: "project-a", repo: "old-repo", url: "https://github.com/project-a/old-repo.git" },
];
const projectBRemote: GitRemote[] = [
{ name: "origin", owner: "project-b", repo: "new-repo", url: "https://github.com/project-b/new-repo.git" },
];
let resolveProjectA!: (value: GitRemote[]) => void;
let resolveProjectB!: (value: GitRemote[]) => void;
vi.mocked(fetchGitRemotes)
.mockImplementationOnce(() => new Promise((resolve) => {
resolveProjectA = resolve;
}))
.mockImplementationOnce(() => new Promise((resolve) => {
resolveProjectB = resolve;
}));
const { rerender } = render(
<GitHubImportModal isOpen={true} onClose={onClose} onImport={onImport} tasks={[]} projectId="project-a" />,
);
await waitFor(() => {
expect(fetchGitRemotes).toHaveBeenCalledWith("project-a");
});
rerender(<GitHubImportModal isOpen={true} onClose={onClose} onImport={onImport} tasks={[]} projectId="project-b" />);
await waitFor(() => {
expect(fetchGitRemotes).toHaveBeenCalledWith("project-b");
});
await act(async () => {
resolveProjectB(projectBRemote);
});
await waitFor(() => {
expect(screen.getByText("project-b/new-repo")).toBeTruthy();
});
await act(async () => {
resolveProjectA(projectARemote);
});
await waitFor(() => {
expect(screen.getByText("project-b/new-repo")).toBeTruthy();
expect(screen.queryByText("project-a/old-repo")).toBeNull();
});
});
it("auto-selects the remote and shows compact pill", async () => {
vi.mocked(fetchGitRemotes).mockResolvedValueOnce(singleRemote);
render(<GitHubImportModal isOpen={true} onClose={onClose} onImport={onImport} tasks={[]} />);
await waitFor(() => {
const remoteCard = screen.getByTestId("github-import-single-remote");
expect(within(remoteCard).getByText(/origin/i)).toBeTruthy();
expect(within(remoteCard).getByText("dustinbyrne/kb")).toBeTruthy();
});
});
it("does not show a dropdown", async () => {
vi.mocked(fetchGitRemotes).mockResolvedValueOnce(singleRemote);
render(<GitHubImportModal isOpen={true} onClose={onClose} onImport={onImport} tasks={[]} />);
await waitFor(() => {
expect(screen.queryByRole("combobox")).toBeNull();
});
});
it("enables Load button when remote is auto-selected", async () => {
vi.mocked(fetchGitRemotes).mockResolvedValueOnce(singleRemote);
// Mock empty response so loading finishes quickly
vi.mocked(apiFetchGitHubIssues).mockResolvedValueOnce([]);
render(<GitHubImportModal isOpen={true} onClose={onClose} onImport={onImport} tasks={[]} />);
// Wait for auto-load to complete (issues appear or empty state shows)
await waitFor(() => {
const resultsSection = screen.queryByText(/No open issues found/) || screen.queryByTestId("github-import-results-idle");
expect(resultsSection).toBeTruthy();
});
await waitFor(() => {
const loadButton = screen.getByRole("button", { name: /Load issues/i }) as HTMLButtonElement;
expect(loadButton.disabled).toBe(false);
});
});
it("auto-loads issues when single remote is detected", async () => {
vi.mocked(fetchGitRemotes).mockResolvedValueOnce(singleRemote);
vi.mocked(apiFetchGitHubIssues).mockResolvedValueOnce([
{ number: 1, title: "Auto-loaded Issue", body: "Body", html_url: "https://github.com/dustinbyrne/kb/issues/1", labels: [] },
]);
render(<GitHubImportModal isOpen={true} onClose={onClose} onImport={onImport} tasks={[]} />);
await waitFor(() => {
expect(apiFetchGitHubIssues).toHaveBeenCalledWith("dustinbyrne", "kb", 30, undefined);
});
expect(screen.getByText("Auto-loaded Issue")).toBeTruthy();
});
});
describe("with multiple remotes", () => {
it("shows a dropdown with all remotes", async () => {
vi.mocked(fetchGitRemotes).mockResolvedValueOnce(multipleRemotes);
render(<GitHubImportModal isOpen={true} onClose={onClose} onImport={onImport} tasks={[]} />);
await waitFor(() => {
expect(screen.getByRole("combobox")).toBeTruthy();
});
});
it("dropdown has placeholder and all remote options", async () => {
vi.mocked(fetchGitRemotes).mockResolvedValueOnce(multipleRemotes);
render(<GitHubImportModal isOpen={true} onClose={onClose} onImport={onImport} tasks={[]} />);
await waitFor(() => {
const select = screen.getByRole("combobox") as HTMLSelectElement;
const options = Array.from(select.options).map((option) => option.text);
expect(options).toContain("Select remote…");
expect(options).toContain("origin (dustinbyrne/kb)");
expect(options).toContain("upstream (upstream/kb)");
});
});
it("defaults to origin and auto-loads when multiple remotes include origin", async () => {
vi.mocked(fetchGitRemotes).mockResolvedValueOnce(multipleRemotes);
vi.mocked(apiFetchGitHubIssues).mockResolvedValueOnce([
{ number: 1, title: "Auto-loaded from origin", body: "", html_url: "https://github.com/dustinbyrne/kb/issues/1", labels: [] },
]);
render(<GitHubImportModal isOpen={true} onClose={onClose} onImport={onImport} tasks={[]} />);
await waitFor(() => {
const select = screen.getByRole("combobox") as HTMLSelectElement;
expect(select.value).toBe("origin");
expect(apiFetchGitHubIssues).toHaveBeenCalledWith("dustinbyrne", "kb", 30, undefined);
expect(screen.getByText("Auto-loaded from origin")).toBeTruthy();
});
const loadButton = screen.getByRole("button", { name: /Load issues/i }) as HTMLButtonElement;
expect(loadButton.disabled).toBe(false);
});
it("keeps placeholder selected and does not auto-load when multiple remotes omit origin", async () => {
vi.mocked(fetchGitRemotes).mockResolvedValueOnce(multipleRemotesWithoutOrigin);
render(<GitHubImportModal isOpen={true} onClose={onClose} onImport={onImport} tasks={[]} />);
await waitFor(() => {
const select = screen.getByRole("combobox") as HTMLSelectElement;
expect(select.value).toBe("");
});
const loadButton = screen.getByRole("button", { name: /Load issues/i }) as HTMLButtonElement;
expect(loadButton.disabled).toBe(true);
expect(apiFetchGitHubIssues).not.toHaveBeenCalled();
});
it("switches owner/repo and auto-loads when changing remote selection", async () => {
vi.mocked(fetchGitRemotes).mockResolvedValueOnce(multipleRemotes);
vi.mocked(apiFetchGitHubIssues)
.mockResolvedValueOnce([{ number: 1, title: "Issue from origin", body: "", html_url: "https://github.com/dustinbyrne/kb/issues/1", labels: [] }])
.mockResolvedValueOnce([{ number: 2, title: "Issue from upstream", body: "", html_url: "https://github.com/upstream/kb/issues/2", labels: [] }]);
render(<GitHubImportModal isOpen={true} onClose={onClose} onImport={onImport} tasks={[]} />);
await waitFor(() => {
const select = screen.getByRole("combobox") as HTMLSelectElement;
expect(select.value).toBe("origin");
expect(apiFetchGitHubIssues).toHaveBeenCalledWith("dustinbyrne", "kb", 30, undefined);
expect(screen.getByText("Issue from origin")).toBeTruthy();
});
fireEvent.change(screen.getByRole("combobox"), { target: { value: "upstream" } });
await waitFor(() => {
expect(apiFetchGitHubIssues).toHaveBeenLastCalledWith("upstream", "kb", 30, undefined);
expect(screen.getByText("Issue from upstream")).toBeTruthy();
});
});
});
describe("issue loading and import", () => {
it("displays auto-loaded issues for single remote", async () => {
const issues = [
{ number: 1, title: "First Issue", body: "Body 1", html_url: "https://github.com/owner/repo/issues/1", labels: [] },
{ number: 2, title: "Second Issue", body: "Body 2", html_url: "https://github.com/owner/repo/issues/2", labels: [] },
];
vi.mocked(fetchGitRemotes).mockResolvedValueOnce(singleRemote);
vi.mocked(apiFetchGitHubIssues).mockResolvedValueOnce(issues);
render(<GitHubImportModal isOpen={true} onClose={onClose} onImport={onImport} tasks={[]} />);
await waitFor(() => {
expect(screen.getByText("First Issue")).toBeTruthy();
expect(screen.getByText("Second Issue")).toBeTruthy();
});
});
it("disables Import button when no issue is selected", async () => {
const issues = [
{ number: 1, title: "First Issue", body: "Body 1", html_url: "https://github.com/owner/repo/issues/1", labels: [] },
];
vi.mocked(fetchGitRemotes).mockResolvedValueOnce(singleRemote);
vi.mocked(apiFetchGitHubIssues).mockResolvedValueOnce(issues);
render(<GitHubImportModal isOpen={true} onClose={onClose} onImport={onImport} tasks={[]} />);
await waitFor(() => {
expect(screen.getByText("First Issue")).toBeTruthy();
});
const importButton = screen.getByTestId("github-import-action-top") as HTMLButtonElement;
expect(importButton.disabled).toBe(true);
});
it("calls apiImportGitHubIssue and onImport when Import is clicked", async () => {
vi.mocked(fetchGitRemotes).mockResolvedValueOnce(singleRemote);
vi.mocked(apiFetchGitHubIssues).mockResolvedValueOnce([
{ number: 1, title: "First Issue", body: "Body 1", html_url: "https://github.com/owner/repo/issues/1", labels: [] },
]);
vi.mocked(apiImportGitHubIssue).mockResolvedValueOnce(mockTask);
render(<GitHubImportModal isOpen={true} onClose={onClose} onImport={onImport} tasks={[]} projectId="project-1" />);
await waitFor(() => {
expect(screen.getByText("First Issue")).toBeTruthy();
});
fireEvent.click(screen.getByRole("radio", { name: /Select issue #1/i }));
fireEvent.click(screen.getByTestId("github-import-action-top"));
await waitFor(() => {
expect(apiImportGitHubIssue).toHaveBeenCalledWith("dustinbyrne", "kb", 1, "project-1");
expect(onImport).toHaveBeenCalledWith(mockTask);
expect(onClose).not.toHaveBeenCalled();
expect(screen.getByText("Import from GitHub")).toBeTruthy();
});
});
it("stays open and returns desktop issue imports to the no-selection list state", async () => {
const issues = [
{ number: 1, title: "First Issue", body: null, html_url: "https://github.com/owner/repo/issues/1", labels: [] },
];
vi.mocked(fetchGitRemotes).mockResolvedValueOnce([{ name: "origin", owner: "owner", repo: "repo", url: "" }]);
vi.mocked(apiFetchGitHubIssues).mockResolvedValueOnce(issues);
vi.mocked(apiImportGitHubIssue).mockResolvedValueOnce(mockTask);
const { rerender } = render(
<GitHubImportModal isOpen={true} onClose={onClose} onImport={onImport} tasks={[]} projectId="project-1" />,
);
await waitFor(() => {
expect(screen.getByText("First Issue")).toBeTruthy();
});
const importButton = screen.getByTestId("github-import-action-top") as HTMLButtonElement;
const radio = screen.getByRole("radio", { name: /Select issue #1/i }) as HTMLInputElement;
fireEvent.click(radio);
expect(await screen.findByTestId("github-import-preview-card")).toBeTruthy();
expect(importButton.disabled).toBe(false);
fireEvent.click(importButton);
await waitFor(() => {
expect(apiImportGitHubIssue).toHaveBeenCalledWith("owner", "repo", 1, "project-1");
expect(onClose).not.toHaveBeenCalled();
expect(radio.checked).toBe(false);
expect(screen.queryByTestId("github-import-preview-card")).toBeNull();
expect(screen.getByTestId("github-import-preview-empty")).toHaveTextContent("No issue selected");
expect((screen.getByTestId("github-import-action-top") as HTMLButtonElement).disabled).toBe(true);
});
rerender(
<GitHubImportModal isOpen={true} onClose={onClose} onImport={onImport} tasks={[mockTask]} projectId="project-1" />,
);
await waitFor(() => {
expect(screen.getByText("First Issue")).toBeTruthy();
expect(screen.getByText("Imported")).toBeTruthy();
});
});
it("returns modal bottom issue imports to the list without dismissing the modal", async () => {
const issues = [
{ number: 2, title: "Bottom Action Issue", body: "Body", html_url: "https://github.com/owner/repo/issues/2", labels: [] },
];
vi.mocked(fetchGitRemotes).mockResolvedValueOnce([{ name: "origin", owner: "owner", repo: "repo", url: "" }]);
vi.mocked(apiFetchGitHubIssues).mockResolvedValueOnce(issues);
vi.mocked(apiImportGitHubIssue).mockResolvedValueOnce({ ...mockTask, id: "FN-002" });
render(<GitHubImportModal isOpen={true} onClose={onClose} onImport={onImport} tasks={[]} projectId="project-1" />);
await waitFor(() => expect(screen.getByText("Bottom Action Issue")).toBeTruthy());
fireEvent.click(screen.getByRole("radio", { name: /Select issue #2/i }));
expect(await screen.findByTestId("github-import-preview-card")).toBeTruthy();
const bottomImportButton = screen.getAllByRole("button", { name: "Import" }).at(-1) as HTMLButtonElement;
fireEvent.click(bottomImportButton);
await waitFor(() => {
expect(apiImportGitHubIssue).toHaveBeenCalledWith("owner", "repo", 2, "project-1");
expect(onClose).not.toHaveBeenCalled();
expect(screen.queryByTestId("github-import-preview-card")).toBeNull();
expect(screen.getByTestId("github-import-preview-empty")).toHaveTextContent("No issue selected");
expect((screen.getByRole("radio", { name: /Select issue #2/i }) as HTMLInputElement).checked).toBe(false);
});
});
it("keeps the selected issue preview open when issue import fails", async () => {
const issues = [
{ number: 3, title: "Retry Issue", body: "Retry body", html_url: "https://github.com/owner/repo/issues/3", labels: [] },
];
vi.mocked(fetchGitRemotes).mockResolvedValueOnce([{ name: "origin", owner: "owner", repo: "repo", url: "" }]);
vi.mocked(apiFetchGitHubIssues).mockResolvedValueOnce(issues);
vi.mocked(apiImportGitHubIssue).mockRejectedValueOnce(new Error("already imported elsewhere"));
render(<GitHubImportModal isOpen={true} onClose={onClose} onImport={onImport} tasks={[]} projectId="project-1" />);
await waitFor(() => expect(screen.getByText("Retry Issue")).toBeTruthy());
const radio = screen.getByRole("radio", { name: /Select issue #3/i }) as HTMLInputElement;
fireEvent.click(radio);
expect(await screen.findByTestId("github-import-preview-card")).toHaveTextContent("Retry Issue");
fireEvent.click(screen.getByTestId("github-import-action-top"));
await waitFor(() => {
expect(screen.getByText("already imported elsewhere")).toBeTruthy();
expect(radio.checked).toBe(true);
expect(screen.getByTestId("github-import-preview-card")).toHaveTextContent("Retry Issue");
expect(screen.queryByTestId("github-import-preview-empty")).toBeNull();
});
});
it("shows 'Imported' badge for already imported issues", async () => {
const existingTask: Task = {
...mockTask,
description: "Existing\n\nSource: https://github.com/owner/repo/issues/1",
};
const issues = [
{ number: 1, title: "First Issue", body: "Body 1", html_url: "https://github.com/owner/repo/issues/1", labels: [] },
];
vi.mocked(fetchGitRemotes).mockResolvedValueOnce([{ name: "origin", owner: "owner", repo: "repo", url: "" }]);
vi.mocked(apiFetchGitHubIssues).mockResolvedValueOnce(issues);
render(<GitHubImportModal isOpen={true} onClose={onClose} onImport={onImport} tasks={[existingTask]} />);
await waitFor(() => {
expect(screen.getByText("Imported")).toBeTruthy();
});
});
it("disables radio buttons for already imported issues", async () => {
const existingTask: Task = {
...mockTask,
description: "Existing\n\nSource: https://github.com/owner/repo/issues/1",
};
const issues = [
{ number: 1, title: "First Issue", body: "Body 1", html_url: "https://github.com/owner/repo/issues/1", labels: [] },
];
vi.mocked(fetchGitRemotes).mockResolvedValueOnce([{ name: "origin", owner: "owner", repo: "repo", url: "" }]);
vi.mocked(apiFetchGitHubIssues).mockResolvedValueOnce(issues);
render(<GitHubImportModal isOpen={true} onClose={onClose} onImport={onImport} tasks={[existingTask]} />);
await waitFor(() => {
const radio = screen.getByRole("radio", { name: /Select issue #1/i }) as HTMLInputElement;
expect(radio.disabled).toBe(true);
});
});
it("renders the empty results state when GitHub returns no open issues", async () => {
vi.mocked(fetchGitRemotes).mockResolvedValueOnce(singleRemote);
vi.mocked(apiFetchGitHubIssues).mockResolvedValueOnce([]);
render(<GitHubImportModal isOpen={true} onClose={onClose} onImport={onImport} tasks={[]} />);
await waitFor(() => {
expect(screen.getByText("No open issues found")).toBeTruthy();
expect(screen.getByText(/Try a different label filter/)).toBeTruthy();
});
});
it("displays error state on fetch failure", async () => {
vi.mocked(fetchGitRemotes).mockResolvedValueOnce(singleRemote);
vi.mocked(apiFetchGitHubIssues).mockRejectedValueOnce(new Error("Repository not found"));
render(<GitHubImportModal isOpen={true} onClose={onClose} onImport={onImport} tasks={[]} />);
await waitFor(() => {
expect(screen.getByText("Could not load issues")).toBeTruthy();
expect(screen.getByText("Repository not found")).toBeTruthy();
});
});
it("displays label chips for issues with labels", async () => {
const issues = [
{ number: 1, title: "Bug Issue", body: "Body", html_url: "https://github.com/owner/repo/issues/1", labels: [{ name: "bug" }, { name: "urgent" }] },
];
vi.mocked(fetchGitRemotes).mockResolvedValueOnce(singleRemote);
vi.mocked(apiFetchGitHubIssues).mockResolvedValueOnce(issues);
render(<GitHubImportModal isOpen={true} onClose={onClose} onImport={onImport} tasks={[]} />);
await waitFor(() => {
expect(screen.getByText("bug")).toBeTruthy();
expect(screen.getByText("urgent")).toBeTruthy();
});
});
it("re-fetches issues when Load is clicked with different labels", async () => {
// Set up mocks - first for auto-load, second for manual refresh
vi.mocked(apiFetchGitHubIssues)
.mockResolvedValueOnce([{ number: 1, title: "Issue without labels", body: "", html_url: "https://github.com/dustinbyrne/kb/issues/1", labels: [] }])
.mockResolvedValueOnce([{ number: 2, title: "Bug issue", body: "", html_url: "https://github.com/dustinbyrne/kb/issues/2", labels: [{ name: "bug" }] }]);
vi.mocked(fetchGitRemotes).mockResolvedValueOnce(singleRemote);
render(<GitHubImportModal isOpen={true} onClose={onClose} onImport={onImport} tasks={[]} />);
// Wait for initial auto-load without labels
await waitFor(() => {
expect(apiFetchGitHubIssues).toHaveBeenCalledWith("dustinbyrne", "kb", 30, undefined);
expect(screen.getByText("Issue without labels")).toBeTruthy();
});
// Enter label filter
const labelsInput = screen.getByPlaceholderText(/Filter:/);
fireEvent.change(labelsInput, { target: { value: "bug" } });
// Find and click the Load button by id (more reliable)
const loadButton = screen.getByTestId("github-import-toolbar").querySelector("#gh-load") as HTMLButtonElement;
fireEvent.click(loadButton);
// Verify re-fetch with labels
await waitFor(() => {
expect(apiFetchGitHubIssues).toHaveBeenLastCalledWith("dustinbyrne", "kb", 30, ["bug"]);
expect(screen.getByText("Bug issue")).toBeTruthy();
});
});
});
describe("mobile responsive view", () => {
const originalInnerWidth = window.innerWidth;
afterEach(() => {
// Restore window width
Object.defineProperty(window, "innerWidth", {
writable: true,
configurable: true,
value: originalInnerWidth,
});
window.dispatchEvent(new Event("resize"));
});
it("defaults to origin and auto-loads on mobile", async () => {
Object.defineProperty(window, "innerWidth", {
writable: true,
configurable: true,
value: 640,
});
window.dispatchEvent(new Event("resize"));
vi.mocked(fetchGitRemotes).mockResolvedValueOnce(multipleRemotes);
vi.mocked(apiFetchGitHubIssues).mockResolvedValueOnce([
{ number: 1, title: "Mobile origin issue", body: "", html_url: "https://github.com/dustinbyrne/kb/issues/1", labels: [] },
]);
render(<GitHubImportModal isOpen={true} onClose={onClose} onImport={onImport} tasks={[]} />);
await waitFor(() => {
const select = screen.getByRole("combobox") as HTMLSelectElement;
expect(select.value).toBe("origin");
expect(apiFetchGitHubIssues).toHaveBeenCalledWith("dustinbyrne", "kb", 30, undefined);
expect(screen.getByText("Mobile origin issue")).toBeTruthy();
});
expect(screen.getByTestId("github-import-list-pane").classList.contains("mobile")).toBe(true);
});
it("shows back button in preview header when on mobile", async () => {
// Set mobile viewport
Object.defineProperty(window, "innerWidth", {
writable: true,
configurable: true,
value: 480,
});
const issues = [
{ number: 1, title: "First Issue", body: "Body 1", html_url: "https://github.com/owner/repo/issues/1", labels: [] },
];
vi.mocked(fetchGitRemotes).mockResolvedValueOnce(singleRemote);
vi.mocked(apiFetchGitHubIssues).mockResolvedValueOnce(issues);
render(<GitHubImportModal isOpen={true} onClose={onClose} onImport={onImport} tasks={[]} />);
await waitFor(() => {
expect(screen.getByText("First Issue")).toBeTruthy();
});
// Select an issue
fireEvent.click(screen.getByRole("radio", { name: /Select issue #1/i }));
// Back button should be visible
await waitFor(() => {
expect(screen.getByTestId("github-import-back-button")).toBeTruthy();
});
});
it("mobile back button returns to list view", async () => {
// Set mobile viewport
Object.defineProperty(window, "innerWidth", {
writable: true,
configurable: true,
value: 480,
});
const issues = [
{ number: 1, title: "First Issue", body: "Body 1", html_url: "https://github.com/owner/repo/issues/1", labels: [] },
];
vi.mocked(fetchGitRemotes).mockResolvedValueOnce(singleRemote);
vi.mocked(apiFetchGitHubIssues).mockResolvedValueOnce(issues);
render(<GitHubImportModal isOpen={true} onClose={onClose} onImport={onImport} tasks={[]} />);
await waitFor(() => {
expect(screen.getByText("First Issue")).toBeTruthy();
});
// Select an issue to show preview
fireEvent.click(screen.getByRole("radio", { name: /Select issue #1/i }));
// Wait for preview to show
await waitFor(() => {
expect(screen.getByTestId("github-import-back-button")).toBeTruthy();
});
// Click back button
fireEvent.click(screen.getByTestId("github-import-back-button"));
// Preview pane should be hidden (back button won't be visible in list view)
// The back button is still in DOM but hidden via CSS - check that preview pane doesn't have 'active' class
const previewPane = screen.getByTestId("github-import-preview-pane");
expect(previewPane.classList.contains("active")).toBe(false);
});
it("returns mobile issue imports from preview to the active list pane", async () => {
Object.defineProperty(window, "innerWidth", {
writable: true,
configurable: true,
value: 480,
});
window.dispatchEvent(new Event("resize"));
const issues = [
{ number: 1, title: "Mobile Import Issue", body: "Body", html_url: "https://github.com/owner/repo/issues/1", labels: [] },
];
vi.mocked(fetchGitRemotes).mockResolvedValueOnce(singleRemote);
vi.mocked(apiFetchGitHubIssues).mockResolvedValueOnce(issues);
vi.mocked(apiImportGitHubIssue).mockResolvedValueOnce(mockTask);
render(<GitHubImportModal isOpen={true} onClose={onClose} onImport={onImport} tasks={[]} />);
await waitFor(() => expect(screen.getByText("Mobile Import Issue")).toBeTruthy());
fireEvent.click(screen.getByRole("radio", { name: /Select issue #1/i }));
const previewPane = screen.getByTestId("github-import-preview-pane");
const listPane = screen.getByTestId("github-import-list-pane");
await waitFor(() => expect(previewPane.classList.contains("active")).toBe(true));
expect(listPane.classList.contains("active")).toBe(false);
fireEvent.click(screen.getByTestId("github-import-action-top"));
await waitFor(() => {
expect(apiImportGitHubIssue).toHaveBeenCalledWith("dustinbyrne", "kb", 1, undefined);
expect(previewPane.classList.contains("active")).toBe(false);
expect(listPane.classList.contains("active")).toBe(true);
expect(screen.queryByTestId("github-import-preview-card")).toBeNull();
expect(screen.getByTestId("github-import-preview-empty")).toHaveTextContent("No issue selected");
});
});
it("returns mobile issue close from preview to the active list pane", async () => {
Object.defineProperty(window, "innerWidth", {
writable: true,
configurable: true,
value: 480,
});
window.dispatchEvent(new Event("resize"));
const issues = [
{ number: 4, title: "Mobile Close Issue", body: "Body", html_url: "https://github.com/owner/repo/issues/4", labels: [], state: "open" as const },
];
vi.mocked(fetchGitRemotes).mockResolvedValueOnce(singleRemote);
vi.mocked(apiFetchGitHubIssues).mockResolvedValueOnce(issues);
render(<GitHubImportModal isOpen={true} onClose={onClose} onImport={onImport} tasks={[]} />);
await waitFor(() => expect(screen.getByText("Mobile Close Issue")).toBeTruthy());
fireEvent.click(screen.getByRole("radio", { name: /Select issue #4/i }));
const previewPane = screen.getByTestId("github-import-preview-pane");
const listPane = screen.getByTestId("github-import-list-pane");
await waitFor(() => expect(previewPane.classList.contains("active")).toBe(true));
fireEvent.click(await screen.findByTestId("github-import-issue-close"));
await waitFor(() => {
expect(apiCloseGitHubIssue).toHaveBeenCalledWith("dustinbyrne/kb", 4);
expect(previewPane.classList.contains("active")).toBe(false);
expect(listPane.classList.contains("active")).toBe(true);
expect(screen.queryByTestId("github-import-preview-card")).toBeNull();
expect(screen.getByTestId("github-import-preview-empty")).toHaveTextContent("No issue selected");
});
expect(await screen.findByTestId("github-import-issue-close-toast")).toHaveTextContent("Issue #4 closed");
});
it("keeps the mobile issue preview active when import fails", async () => {
Object.defineProperty(window, "innerWidth", {
writable: true,
configurable: true,
value: 480,
});
window.dispatchEvent(new Event("resize"));
const issues = [
{ number: 6, title: "Mobile Retry Issue", body: "Body", html_url: "https://github.com/owner/repo/issues/6", labels: [] },
];
vi.mocked(fetchGitRemotes).mockResolvedValueOnce(singleRemote);
vi.mocked(apiFetchGitHubIssues).mockResolvedValueOnce(issues);
vi.mocked(apiImportGitHubIssue).mockRejectedValueOnce(new Error("Duplicate server error"));
render(<GitHubImportModal isOpen={true} onClose={onClose} onImport={onImport} tasks={[]} />);
await waitFor(() => expect(screen.getByText("Mobile Retry Issue")).toBeTruthy());
fireEvent.click(screen.getByRole("radio", { name: /Select issue #6/i }));
const previewPane = screen.getByTestId("github-import-preview-pane");
await waitFor(() => expect(previewPane.classList.contains("active")).toBe(true));
fireEvent.click(screen.getByTestId("github-import-action-top"));
await waitFor(() => {
expect(screen.getByText("Duplicate server error")).toBeTruthy();
expect(previewPane.classList.contains("active")).toBe(true);
expect(screen.getByTestId("github-import-preview-card")).toHaveTextContent("Mobile Retry Issue");
});
});
it("renders long selected issue body in full on mobile without a truncation ellipsis", async () => {
Object.defineProperty(window, "innerWidth", {
writable: true,
configurable: true,
value: 480,
});
const beyondPreviousCutoff = "visible body text after the old cutoff";
const longBody = `${"A".repeat(210)} ${beyondPreviousCutoff}`;
const issues = [
{ number: 1, title: "Long Issue", body: longBody, html_url: "https://github.com/owner/repo/issues/1", labels: [] },
];
vi.mocked(fetchGitRemotes).mockResolvedValueOnce(singleRemote);
vi.mocked(apiFetchGitHubIssues).mockResolvedValueOnce(issues);
render(<GitHubImportModal isOpen={true} onClose={onClose} onImport={onImport} tasks={[]} />);
await waitFor(() => {
expect(screen.getByText("Long Issue")).toBeTruthy();
});
fireEvent.click(screen.getByRole("radio", { name: /Select issue #1/i }));
const previewPane = screen.getByTestId("github-import-preview-pane");
await waitFor(() => {
expect(previewPane.classList.contains("active")).toBe(true);
});
const previewCard = await screen.findByTestId("github-import-preview-card");
expect(within(previewCard).getByText((content) => content.includes(beyondPreviousCutoff))).toBeTruthy();
expect(previewCard.textContent).toContain(longBody);
expect(previewCard.textContent).not.toContain(`${"A".repeat(200)}…`);
});
it("renders long selected pull request body in full on mobile without a truncation ellipsis", async () => {
Object.defineProperty(window, "innerWidth", {
writable: true,
configurable: true,
value: 480,
});
const beyondPreviousCutoff = "visible pull request body text after the old cutoff";
const longBody = `${"P".repeat(210)} ${beyondPreviousCutoff}`;
const pulls = [
{ number: 1, title: "Long PR", body: longBody, html_url: "https://github.com/owner/repo/pull/1", headBranch: "feature", baseBranch: "main" },
];
vi.mocked(fetchGitRemotes).mockResolvedValueOnce(singleRemote);
vi.mocked(apiFetchGitHubPulls).mockResolvedValueOnce(pulls);
render(<GitHubImportModal isOpen={true} onClose={onClose} onImport={onImport} tasks={[]} />);
fireEvent.click(await screen.findByRole("tab", { name: /Pull Requests/i }));
await waitFor(() => {
expect(screen.getByText("Long PR")).toBeTruthy();
});
fireEvent.click(screen.getByRole("radio", { name: /Select pull request #1/i }));
const previewPane = screen.getByTestId("github-import-preview-pane");
await waitFor(() => {
expect(previewPane.classList.contains("active")).toBe(true);
});
const previewCard = await screen.findByTestId("github-import-preview-card");
expect(within(previewCard).getByText((content) => content.includes(beyondPreviousCutoff))).toBeTruthy();
expect(previewCard.textContent).toContain(longBody);
expect(previewCard.textContent).not.toContain(`${"P".repeat(200)}…`);
});
// FNXC:GitHubImport 2026-06-23-01:00: Selecting a PR fetches its detail and renders the full comment thread + per-check status below the body, scoped to PRs (issues unchanged).
it("renders the selected PR's checks and comments from the detail fetch", async () => {
Object.defineProperty(window, "innerWidth", { writable: true, configurable: true, value: 1200 });
const pulls = [
{ number: 7, title: "Detail PR", body: "PR body text", html_url: "https://github.com/owner/repo/pull/7", headBranch: "feature", baseBranch: "main" },
];
vi.mocked(fetchGitRemotes).mockResolvedValueOnce(singleRemote);
vi.mocked(apiFetchGitHubPulls).mockResolvedValueOnce(pulls);
vi.mocked(apiFetchGitHubPullDetail).mockResolvedValueOnce({
comments: [
{ author: "alice", body: "First comment from alice", createdAt: "2024-01-01T00:00:00Z", authorIsBot: false, authorAvatarUrl: "https://github.com/alice.png?size=40" },
{ author: "github-actions[bot]", body: "Second comment from bot", createdAt: "2024-01-02T00:00:00Z", authorIsBot: true },
],
checks: [
{ name: "build", status: "completed", conclusion: "success" },
{ name: "lint", status: "completed", conclusion: "failure" },
],
});
render(<GitHubImportModal isOpen={true} onClose={onClose} onImport={onImport} tasks={[]} />);
fireEvent.click(await screen.findByRole("tab", { name: /Pull Requests/i }));
await waitFor(() => {
expect(screen.getByText("Detail PR")).toBeTruthy();
});
fireEvent.click(screen.getByRole("radio", { name: /Select pull request #7/i }));
// Detail fetch is scoped to the selected PR by "owner/repo" + number.
await waitFor(() => {
expect(vi.mocked(apiFetchGitHubPullDetail)).toHaveBeenCalledWith("dustinbyrne/kb", 7);
});
const checks = await screen.findByTestId("github-import-pr-checks");
const comments = await screen.findByTestId("github-import-pr-comments");
// Body still renders immediately, independent of detail.
expect(screen.getByTestId("github-import-preview-body").textContent).toContain("PR body text");
// Per-check status surfaces both name and conclusion.
await waitFor(() => {
expect(checks.textContent).toContain("build");
expect(checks.textContent).toContain("success");
expect(checks.textContent).toContain("lint");
expect(checks.textContent).toContain("failure");
});
// Failed check gets the failure pill variant.
expect(checks.querySelector(".github-import-pr-check-pill--failure")).toBeTruthy();
expect(checks.querySelector(".github-import-pr-check-pill--success")).toBeTruthy();
// Full comment thread renders, chronological, with authors + bodies.
await waitFor(() => {
expect(comments.textContent).toContain("alice");
expect(comments.textContent).toContain("First comment from alice");
expect(comments.textContent).toContain("github-actions[bot]");
expect(comments.textContent).toContain("Second comment from bot");
});
// FNXC:GitHubImport 2026-06-23-03:30: per-comment testid + human/bot indicator via data-comment-author-type.
const commentEls = within(comments).getAllByTestId("github-import-comment");
expect(commentEls).toHaveLength(2);
expect(commentEls[0].getAttribute("data-comment-author-type")).toBe("human");
expect(commentEls[1].getAttribute("data-comment-author-type")).toBe("bot");
// Human/bot badge labels render.
expect(commentEls[0].textContent).toContain("Human");
expect(commentEls[1].textContent).toContain("Bot");
// Avatar image renders for the human author (with the provided avatar URL).
const avatarImg = commentEls[0].querySelector("img.github-import-comment__avatar-img") as HTMLImageElement | null;
expect(avatarImg?.getAttribute("src")).toBe("https://github.com/alice.png?size=40");
// Readable timestamp renders with the full ISO as the title/datetime.
const timeEl = commentEls[0].querySelector("time");
expect(timeEl?.getAttribute("title")).toBe("2024-01-01T00:00:00Z");
expect(timeEl?.textContent?.length).toBeGreaterThan(0);
});
// FNXC:GitHubImport 2026-06-23-03:30: The Human filter hides bot comments; All (default) shows both.
it("filters bot comments out when the comments filter is set to Human", async () => {
Object.defineProperty(window, "innerWidth", { writable: true, configurable: true, value: 1200 });
const pulls = [
{ number: 11, title: "Filter PR", body: "PR body", html_url: "https://github.com/owner/repo/pull/11", headBranch: "feature", baseBranch: "main" },
];
vi.mocked(fetchGitRemotes).mockResolvedValueOnce(singleRemote);
vi.mocked(apiFetchGitHubPulls).mockResolvedValueOnce(pulls);
vi.mocked(apiFetchGitHubPullDetail).mockResolvedValueOnce({
comments: [
{ author: "alice", body: "human comment text", createdAt: "2024-01-01T00:00:00Z", authorIsBot: false },
{ author: "dependabot[bot]", body: "bot comment text", createdAt: "2024-01-02T00:00:00Z", authorIsBot: true },
],
checks: [],
});
render(<GitHubImportModal isOpen={true} onClose={onClose} onImport={onImport} tasks={[]} />);
fireEvent.click(await screen.findByRole("tab", { name: /Pull Requests/i }));
await waitFor(() => {
expect(screen.getByText("Filter PR")).toBeTruthy();
});
fireEvent.click(screen.getByRole("radio", { name: /Select pull request #11/i }));
const comments = await screen.findByTestId("github-import-pr-comments");
// Default (All): both comments show.
await waitFor(() => {
expect(within(comments).getAllByTestId("github-import-comment")).toHaveLength(2);
});
// Switch to Human: bot comment is hidden.
const filter = within(comments).getByTestId("github-import-comments-filter");
fireEvent.click(within(filter).getByText("Human"));
await waitFor(() => {
const remaining = within(comments).getAllByTestId("github-import-comment");
expect(remaining).toHaveLength(1);
expect(remaining[0].getAttribute("data-comment-author-type")).toBe("human");
});
expect(comments.textContent).not.toContain("bot comment text");
});
// FNXC:GitHubImport 2026-06-23-03:30: Prev/Next nav advances the active comment index across the thread.
it("advances the active comment with the prev/next navigation", async () => {
Object.defineProperty(window, "innerWidth", { writable: true, configurable: true, value: 1200 });
const pulls = [
{ number: 13, title: "Nav PR", body: "PR body", html_url: "https://github.com/owner/repo/pull/13", headBranch: "feature", baseBranch: "main" },
];
vi.mocked(fetchGitRemotes).mockResolvedValueOnce(singleRemote);
vi.mocked(apiFetchGitHubPulls).mockResolvedValueOnce(pulls);
vi.mocked(apiFetchGitHubPullDetail).mockResolvedValueOnce({
comments: [
{ author: "alice", body: "comment one", createdAt: "2024-01-01T00:00:00Z", authorIsBot: false },
{ author: "bob", body: "comment two", createdAt: "2024-01-02T00:00:00Z", authorIsBot: false },
],
checks: [],
});
render(<GitHubImportModal isOpen={true} onClose={onClose} onImport={onImport} tasks={[]} />);
fireEvent.click(await screen.findByRole("tab", { name: /Pull Requests/i }));
await waitFor(() => {
expect(screen.getByText("Nav PR")).toBeTruthy();
});
fireEvent.click(screen.getByRole("radio", { name: /Select pull request #13/i }));
const comments = await screen.findByTestId("github-import-pr-comments");
const prev = await within(comments).findByTestId("github-import-comment-prev");
const next = within(comments).getByTestId("github-import-comment-next");
// At the first comment: prev disabled, next enabled.
expect((prev as HTMLButtonElement).disabled).toBe(true);
expect((next as HTMLButtonElement).disabled).toBe(false);
// Advance to the last comment: next becomes disabled, prev enabled.
fireEvent.click(next);
await waitFor(() => {
expect((next as HTMLButtonElement).disabled).toBe(true);
expect((prev as HTMLButtonElement).disabled).toBe(false);
});
});
// FNXC:GitHubImport 2026-06-23-01:00: Empty detail shows the "No checks"/"No comments" empty states.
it("shows empty states when the selected PR has no checks or comments", async () => {
Object.defineProperty(window, "innerWidth", { writable: true, configurable: true, value: 1200 });
const pulls = [
{ number: 9, title: "Bare PR", body: "Bare body", html_url: "https://github.com/owner/repo/pull/9", headBranch: "feature", baseBranch: "main" },
];
vi.mocked(fetchGitRemotes).mockResolvedValueOnce(singleRemote);
vi.mocked(apiFetchGitHubPulls).mockResolvedValueOnce(pulls);
vi.mocked(apiFetchGitHubPullDetail).mockResolvedValueOnce({ comments: [], checks: [] });
render(<GitHubImportModal isOpen={true} onClose={onClose} onImport={onImport} tasks={[]} />);
fireEvent.click(await screen.findByRole("tab", { name: /Pull Requests/i }));
await waitFor(() => {
expect(screen.getByText("Bare PR")).toBeTruthy();
});
fireEvent.click(screen.getByRole("radio", { name: /Select pull request #9/i }));
expect(await screen.findByTestId("github-import-pr-checks-empty")).toBeTruthy();
expect(await screen.findByTestId("github-import-pr-comments-empty")).toBeTruthy();
});
// FNXC:GitHubImport 2026-06-23-03:15: Selecting an issue fetches its detail and renders the full comment thread below the body (mirrors the PR tab; issues have no checks).
it("renders the selected issue's comments from the detail fetch", async () => {
Object.defineProperty(window, "innerWidth", { writable: true, configurable: true, value: 1200 });
const issues = [
{ number: 7, title: "Detail Issue", body: "Issue body text", html_url: "https://github.com/owner/repo/issues/7", labels: [], state: "open" as const, author: "carol" },
];
vi.mocked(fetchGitRemotes).mockResolvedValueOnce(singleRemote);
vi.mocked(apiFetchGitHubIssues).mockResolvedValueOnce(issues);
vi.mocked(apiFetchGitHubIssueDetail).mockResolvedValueOnce({
comments: [
{ author: "alice", body: "First issue comment", createdAt: "2024-01-01T00:00:00Z", authorIsBot: false },
{ author: "bob", body: "Second issue comment", createdAt: "2024-01-02T00:00:00Z", authorIsBot: false },
],
});
render(<GitHubImportModal isOpen={true} onClose={onClose} onImport={onImport} tasks={[]} />);
await waitFor(() => {
expect(screen.getByText("Detail Issue")).toBeTruthy();
});
fireEvent.click(screen.getByRole("radio", { name: /Select issue #7/i }));
// Detail fetch is scoped to the selected issue by "owner/repo" + number.
await waitFor(() => {
expect(vi.mocked(apiFetchGitHubIssueDetail)).toHaveBeenCalledWith("dustinbyrne/kb", 7);
});
const comments = await screen.findByTestId("github-import-issue-comments");
// Body still renders immediately, independent of detail.
expect(screen.getByTestId("github-import-preview-body").textContent).toContain("Issue body text");
// Full comment thread renders, chronological, with authors + bodies.
await waitFor(() => {
expect(comments.textContent).toContain("alice");
expect(comments.textContent).toContain("First issue comment");
expect(comments.textContent).toContain("bob");
expect(comments.textContent).toContain("Second issue comment");
});
});
// FNXC:GitHubImport 2026-07-02-00:00: Successful Close issue returns to the issue list/no-selection state; failure stays on the preview so the user can retry.
it("closes the selected issue via the close API and returns desktop to the no-selection list state", async () => {
Object.defineProperty(window, "innerWidth", { writable: true, configurable: true, value: 1200 });
const issues = [
{ number: 5, title: "Closable Issue", body: "Body", html_url: "https://github.com/owner/repo/issues/5", labels: [], state: "open" as const, author: "dave" },
];
vi.mocked(fetchGitRemotes).mockResolvedValueOnce(singleRemote);
vi.mocked(apiFetchGitHubIssues).mockResolvedValueOnce(issues);
render(<GitHubImportModal isOpen={true} onClose={onClose} onImport={onImport} tasks={[]} />);
await waitFor(() => {
expect(screen.getByText("Closable Issue")).toBeTruthy();
});
fireEvent.click(screen.getByRole("radio", { name: /Select issue #5/i }));
const closeButton = await screen.findByTestId("github-import-issue-close");
fireEvent.click(closeButton);
// Calls the close API scoped to "owner/repo" + number.
await waitFor(() => {
expect(vi.mocked(apiCloseGitHubIssue)).toHaveBeenCalledWith("dustinbyrne/kb", 5);
});
// Success toast surfaces without dismissing the modal, while the completed issue preview is cleared.
expect(await screen.findByTestId("github-import-issue-close-toast")).toHaveTextContent("Issue #5 closed");
await waitFor(() => {
expect((screen.getByRole("radio", { name: /Select issue #5/i }) as HTMLInputElement).checked).toBe(false);
expect(screen.queryByTestId("github-import-preview-card")).toBeNull();
expect(screen.getByTestId("github-import-preview-empty")).toHaveTextContent("No issue selected");
expect(screen.queryByTestId("github-import-issue-close")).toBeNull();
});
expect(onClose).not.toHaveBeenCalled();
});
it("keeps the selected issue preview open when close fails", async () => {
Object.defineProperty(window, "innerWidth", { writable: true, configurable: true, value: 1200 });
const issues = [
{ number: 8, title: "Close Retry Issue", body: "Retry close body", html_url: "https://github.com/owner/repo/issues/8", labels: [], state: "open" as const, author: "dave" },
];
vi.mocked(fetchGitRemotes).mockResolvedValueOnce(singleRemote);
vi.mocked(apiFetchGitHubIssues).mockResolvedValueOnce(issues);
vi.mocked(apiCloseGitHubIssue).mockRejectedValueOnce(new Error("close failed"));
render(<GitHubImportModal isOpen={true} onClose={onClose} onImport={onImport} tasks={[]} />);
await waitFor(() => expect(screen.getByText("Close Retry Issue")).toBeTruthy());
const radio = screen.getByRole("radio", { name: /Select issue #8/i }) as HTMLInputElement;
fireEvent.click(radio);
expect(await screen.findByTestId("github-import-preview-card")).toHaveTextContent("Close Retry Issue");
fireEvent.click(await screen.findByTestId("github-import-issue-close"));
await waitFor(() => {
expect(apiCloseGitHubIssue).toHaveBeenCalledWith("dustinbyrne/kb", 8);
expect(screen.getByTestId("github-import-issue-close-toast")).toHaveTextContent("close failed");
expect(radio.checked).toBe(true);
expect(screen.getByTestId("github-import-preview-card")).toHaveTextContent("Close Retry Issue");
expect(screen.getByTestId("github-import-issue-close")).toBeTruthy();
});
});
// FNXC:GitHubImport 2026-06-22-18:30: Desktop preview must show the FULL issue/PR body (no 200-char clamp). The list response already carries the complete body, so no detail fetch is needed.
it("renders long selected issue body in full on desktop without a truncation ellipsis", async () => {
Object.defineProperty(window, "innerWidth", {
writable: true,
configurable: true,
value: 1200,
});
const beyondDesktopCutoff = "desktop issue text after the cutoff";
const longBody = `${"I".repeat(210)} ${beyondDesktopCutoff}`;
const issues = [
{ number: 1, title: "Long Desktop Issue", body: longBody, html_url: "https://github.com/owner/repo/issues/1", labels: [] },
];
vi.mocked(fetchGitRemotes).mockResolvedValueOnce(singleRemote);
vi.mocked(apiFetchGitHubIssues).mockResolvedValueOnce(issues);
render(<GitHubImportModal isOpen={true} onClose={onClose} onImport={onImport} tasks={[]} />);
await waitFor(() => {
expect(screen.getByText("Long Desktop Issue")).toBeTruthy();
});
fireEvent.click(screen.getByRole("radio", { name: /Select issue #1/i }));
const previewCard = await screen.findByTestId("github-import-preview-card");
expect(previewCard.textContent).toContain(longBody);
expect(previewCard.textContent).toContain(beyondDesktopCutoff);
expect(previewCard.textContent).not.toContain(`${"I".repeat(200)}…`);
// Body renders as markdown via the shared MailboxMessageContent surface.
expect(screen.getByTestId("github-import-preview-body")).toBeTruthy();
});
it("renders long selected pull request body in full on desktop without a truncation ellipsis", async () => {
Object.defineProperty(window, "innerWidth", {
writable: true,
configurable: true,
value: 1200,
});
const beyondDesktopCutoff = "desktop pull request text after the cutoff";
const longBody = `${"R".repeat(210)} ${beyondDesktopCutoff}`;
const pulls = [
{ number: 1, title: "Long Desktop PR", body: longBody, html_url: "https://github.com/owner/repo/pull/1", headBranch: "feature", baseBranch: "main" },
];
vi.mocked(fetchGitRemotes).mockResolvedValueOnce(singleRemote);
vi.mocked(apiFetchGitHubPulls).mockResolvedValueOnce(pulls);
render(<GitHubImportModal isOpen={true} onClose={onClose} onImport={onImport} tasks={[]} />);
fireEvent.click(await screen.findByRole("tab", { name: /Pull Requests/i }));
await waitFor(() => {
expect(screen.getByText("Long Desktop PR")).toBeTruthy();
});
fireEvent.click(screen.getByRole("radio", { name: /Select pull request #1/i }));
const previewCard = await screen.findByTestId("github-import-preview-card");
expect(previewCard.textContent).toContain(longBody);
expect(previewCard.textContent).toContain(beyondDesktopCutoff);
expect(previewCard.textContent).not.toContain(`${"R".repeat(200)}…`);
expect(screen.getByTestId("github-import-preview-body")).toBeTruthy();
});
// FNXC:GitHubImport 2026-06-22-18:30: Full-issue preview must surface key metadata (state, author, GitHub URL) alongside the full markdown body.
it("renders full issue metadata (state, author, GitHub link) in the desktop preview", async () => {
Object.defineProperty(window, "innerWidth", {
writable: true,
configurable: true,
value: 1200,
});
const issues = [
{
number: 7,
title: "Metadata Issue",
body: "**bold** issue body with `code`",
html_url: "https://github.com/owner/repo/issues/7",
labels: [{ name: "bug" }],
state: "open" as const,
author: "octocat",
},
];
vi.mocked(fetchGitRemotes).mockResolvedValueOnce(singleRemote);
vi.mocked(apiFetchGitHubIssues).mockResolvedValueOnce(issues);
render(<GitHubImportModal isOpen={true} onClose={onClose} onImport={onImport} tasks={[]} />);
await waitFor(() => {
expect(screen.getByText("Metadata Issue")).toBeTruthy();
});
fireEvent.click(screen.getByRole("radio", { name: /Select issue #7/i }));
const previewCard = await screen.findByTestId("github-import-preview-card");
expect(within(previewCard).getByText("open")).toBeTruthy();
expect(within(previewCard).getByText(/octocat/)).toBeTruthy();
expect(within(previewCard).getByText("bug")).toBeTruthy();
const link = within(previewCard).getByRole("link", { name: /View on GitHub/i }) as HTMLAnchorElement;
expect(link.getAttribute("href")).toBe("https://github.com/owner/repo/issues/7");
// Markdown is rendered (bold/code become elements, not literal asterisks/backticks).
const body = screen.getByTestId("github-import-preview-body");
expect(body.querySelector("strong")).toBeTruthy();
expect(body.querySelector("code")).toBeTruthy();
});
it("returns to list view on mobile after successful import", async () => {
Object.defineProperty(window, "innerWidth", {
writable: true,
configurable: true,
value: 480,
});
const issues = [
{ number: 1, title: "First Issue", body: "Body 1", html_url: "https://github.com/owner/repo/issues/1", labels: [] },
];
vi.mocked(fetchGitRemotes).mockResolvedValueOnce([{ name: "origin", owner: "owner", repo: "repo", url: "" }]);
vi.mocked(apiFetchGitHubIssues).mockResolvedValueOnce(issues);
vi.mocked(apiImportGitHubIssue).mockResolvedValueOnce(mockTask);
render(<GitHubImportModal isOpen={true} onClose={onClose} onImport={onImport} tasks={[]} projectId="project-1" />);
await waitFor(() => {
expect(screen.getByText("First Issue")).toBeTruthy();
});
fireEvent.click(screen.getByRole("radio", { name: /Select issue #1/i }));
const previewPane = screen.getByTestId("github-import-preview-pane");
await waitFor(() => {
expect(previewPane.classList.contains("active")).toBe(true);
});
fireEvent.click(screen.getByTestId("github-import-action-top"));
await waitFor(() => {
expect(apiImportGitHubIssue).toHaveBeenCalledWith("owner", "repo", 1, "project-1");
});
expect(previewPane.classList.contains("active")).toBe(false);
expect(onClose).not.toHaveBeenCalled();
});
});
describe("list pane resize handle", () => {
const originalInnerWidth = window.innerWidth;
const setViewportWidth = (width: number) => {
Object.defineProperty(window, "innerWidth", {
writable: true,
configurable: true,
value: width,
});
window.dispatchEvent(new Event("resize"));
};
const renderWithIssues = async () => {
vi.mocked(fetchGitRemotes).mockResolvedValueOnce(singleRemote);
vi.mocked(apiFetchGitHubIssues).mockResolvedValueOnce([
{ number: 1, title: "Resize Test Issue", body: "Body", html_url: "https://github.com/owner/repo/issues/1", labels: [] },
]);
const rendered = render(<GitHubImportModal isOpen={true} onClose={onClose} onImport={onImport} tasks={[]} />);
await waitFor(() => {
expect(screen.getByText("Resize Test Issue")).toBeTruthy();
});
return rendered;
};
const renderWithEmptyIssues = async () => {
vi.mocked(fetchGitRemotes).mockResolvedValueOnce(singleRemote);
vi.mocked(apiFetchGitHubIssues).mockResolvedValueOnce([]);
const rendered = render(<GitHubImportModal isOpen={true} onClose={onClose} onImport={onImport} tasks={[]} />);
await waitFor(() => {
expect(screen.getByText("No open issues found")).toBeTruthy();
});
return rendered;
};
const renderWithPulls = async () => {
vi.mocked(fetchGitRemotes).mockResolvedValueOnce(singleRemote);
vi.mocked(apiFetchGitHubIssues).mockResolvedValueOnce([]);
vi.mocked(apiFetchGitHubPulls).mockResolvedValueOnce([
{ number: 7, title: "Resize Test Pull", body: "Pull body", html_url: "https://github.com/owner/repo/pull/7", headBranch: "feature", baseBranch: "main" },
]);
const rendered = render(<GitHubImportModal isOpen={true} onClose={onClose} onImport={onImport} tasks={[]} />);
fireEvent.click(screen.getByRole("tab", { name: /Pull Requests/i }));
await waitFor(() => {
expect(screen.getByText("Resize Test Pull")).toBeTruthy();
});
return rendered;
};
const stubPointerCapture = (handle: HTMLElement) => {
handle.setPointerCapture = vi.fn();
handle.releasePointerCapture = vi.fn();
handle.hasPointerCapture = vi.fn(() => true);
};
const dragHandle = (handle: HTMLElement, startX: number, endX: number) => {
stubPointerCapture(handle);
fireEvent.pointerDown(handle, { pointerId: 1, clientX: startX });
fireEvent.pointerMove(document, { pointerId: 1, clientX: endX });
fireEvent.pointerUp(document, { pointerId: 1, clientX: endX });
};
/*
* FNXC:GitHubImport 2026-06-23-00:30:
* The list pane defaults narrow (256px) and clamps to [160px, 480px] (the absolute cap; a 50%-of-container cap also
* applies once the workspace is measured, which jsdom reports as 0 so the absolute cap governs here). Width persists
* per-project via projectStorage under the unscoped key `kb-dashboard-github-import-list-width` (no projectId in tests).
* Pointer drags map absolute pointer X to the list width relative to the workspace left edge (jsdom rect is all-zeros).
*/
const LIST_WIDTH_KEY = "kb-dashboard-github-import-list-width";
beforeEach(() => {
window.localStorage.removeItem(LIST_WIDTH_KEY);
setViewportWidth(1200);
});
afterEach(() => {
window.localStorage.removeItem(LIST_WIDTH_KEY);
setViewportWidth(originalInnerWidth);
});
it("renders handle only in the side-by-side two-pane band", async () => {
await renderWithIssues();
expect(screen.getByTestId("github-import-resize-handle")).toBeTruthy();
expect(screen.getByTestId("github-import-list-pane").getAttribute("style")).toContain("flex: 0 0 256px");
setViewportWidth(800);
await waitFor(() => {
expect(screen.queryByTestId("github-import-resize-handle")).toBeNull();
});
expect(screen.getByTestId("github-import-list-pane").getAttribute("style") ?? "").not.toContain("flex: 0 0");
setViewportWidth(480);
await waitFor(() => {
expect(screen.queryByTestId("github-import-resize-handle")).toBeNull();
});
expect(screen.getByTestId("github-import-list-pane").getAttribute("style") ?? "").not.toContain("flex: 0 0");
});
it("resizes the list pane with pointer drags and clamps to bounds", async () => {
await renderWithIssues();
const handle = screen.getByTestId("github-import-resize-handle");
const listPane = screen.getByTestId("github-import-list-pane");
// jsdom workspace rect is all-zeros, so the pane width equals the clamped absolute pointer X.
dragHandle(handle, 256, 300);
expect(handle.getAttribute("aria-valuenow")).toBe("300");
expect(listPane.getAttribute("style")).toContain("flex: 0 0 300px");
dragHandle(handle, 300, 200);
expect(handle.getAttribute("aria-valuenow")).toBe("200");
expect(listPane.getAttribute("style")).toContain("flex: 0 0 200px");
// Below the 160px minimum clamps up.
dragHandle(handle, 200, 40);
expect(handle.getAttribute("aria-valuenow")).toBe("160");
expect(listPane.getAttribute("style")).toContain("flex: 0 0 160px");
// Above the 480px maximum clamps down.
dragHandle(handle, 40, 900);
expect(handle.getAttribute("aria-valuenow")).toBe("480");
expect(listPane.getAttribute("style")).toContain("flex: 0 0 480px");
});
it("exposes the resize width as an inline CSS var for the embedded container query", async () => {
await renderWithIssues();
const listPane = screen.getByTestId("github-import-list-pane");
expect(listPane.getAttribute("style")).toContain("--gh-import-list-width: 256px");
const handle = screen.getByTestId("github-import-resize-handle");
dragHandle(handle, 256, 320);
expect(listPane.getAttribute("style")).toContain("--gh-import-list-width: 320px");
});
it("renders the desktop handle regardless of list content or active tab", async () => {
const mounted = await renderWithEmptyIssues();
expect(screen.getByTestId("github-import-resize-handle")).toBeTruthy();
expect(screen.getByTestId("github-import-list-pane").getAttribute("style")).toContain("flex: 0 0 256px");
mounted.unmount();
vi.clearAllMocks();
window.localStorage.removeItem(LIST_WIDTH_KEY);
setViewportWidth(1200);
await renderWithPulls();
expect(screen.getByTestId("github-import-resize-handle")).toBeTruthy();
expect(screen.getByTestId("github-import-list-pane").getAttribute("style")).toContain("flex: 0 0 256px");
});
it.each([
[{ key: "ArrowRight" }, 272],
[{ key: "ArrowLeft" }, 240],
[{ key: "ArrowRight", shiftKey: true }, 320],
])("handles keyboard nudge %#", async (eventInit, expected) => {
await renderWithIssues();
const handle = screen.getByTestId("github-import-resize-handle");
fireEvent.keyDown(handle, eventInit);
expect(handle.getAttribute("aria-valuenow")).toBe(String(expected));
});
it("supports Home and End keys", async () => {
await renderWithIssues();
const handle = screen.getByTestId("github-import-resize-handle");
fireEvent.keyDown(handle, { key: "Home" });
expect(handle.getAttribute("aria-valuenow")).toBe("160");
fireEvent.keyDown(handle, { key: "End" });
expect(handle.getAttribute("aria-valuenow")).toBe("480");
});
it("clamps keyboard resizing to min and max bounds", async () => {
await renderWithIssues();
const handle = screen.getByTestId("github-import-resize-handle");
for (let i = 0; i < 30; i += 1) {
fireEvent.keyDown(handle, { key: "ArrowLeft" });
}
expect(handle.getAttribute("aria-valuenow")).toBe("160");
for (let i = 0; i < 60; i += 1) {
fireEvent.keyDown(handle, { key: "ArrowRight" });
}
expect(handle.getAttribute("aria-valuenow")).toBe("480");
});
it("persists width across remounts", async () => {
const mounted = await renderWithIssues();
let handle = screen.getByTestId("github-import-resize-handle");
fireEvent.keyDown(handle, { key: "ArrowRight", shiftKey: true });
expect(handle.getAttribute("aria-valuenow")).toBe("320");
// Persisted under the projectStorage key (unscoped without a projectId).
expect(window.localStorage.getItem(LIST_WIDTH_KEY)).toBe("320");
mounted.unmount();
await renderWithIssues();
handle = await screen.findByTestId("github-import-resize-handle");
expect(handle.getAttribute("aria-valuenow")).toBe("320");
});
it("clamps an out-of-range stored width back into bounds on mount", async () => {
window.localStorage.setItem(LIST_WIDTH_KEY, "9000");
await renderWithIssues();
// Stored value above the 480px max is clamped down on read.
expect(screen.getByTestId("github-import-resize-handle").getAttribute("aria-valuenow")).toBe("480");
});
it("falls back to default width for invalid stored values", async () => {
window.localStorage.setItem(LIST_WIDTH_KEY, "not-a-number");
await renderWithIssues();
expect(screen.getByTestId("github-import-resize-handle").getAttribute("aria-valuenow")).toBe("256");
});
});
describe("modal actions", () => {
it("closes modal on Cancel button click", async () => {
vi.mocked(fetchGitRemotes).mockResolvedValueOnce([]);
render(<GitHubImportModal isOpen={true} onClose={onClose} onImport={onImport} tasks={[]} />);
await waitFor(() => {
expect(screen.getByRole("button", { name: /Cancel/i })).toBeTruthy();
});
fireEvent.click(screen.getByRole("button", { name: /Cancel/i }));
expect(onClose).toHaveBeenCalled();
});
it("closes modal on X button click", async () => {
vi.mocked(fetchGitRemotes).mockResolvedValueOnce([]);
render(<GitHubImportModal isOpen={true} onClose={onClose} onImport={onImport} tasks={[]} />);
await waitFor(() => {
expect(screen.getByLabelText("Close import modal")).toBeTruthy();
});
fireEvent.click(screen.getByLabelText("Close import modal"));
expect(onClose).toHaveBeenCalled();
});
});
// ============================================================================
// PULL REQUEST TAB TESTS
// ============================================================================
describe("PR tab", () => {
it("renders Issues and Pull Requests tabs", async () => {
vi.mocked(fetchGitRemotes).mockResolvedValueOnce([]);
render(<GitHubImportModal isOpen={true} onClose={onClose} onImport={onImport} tasks={[]} />);
await waitFor(() => {
expect(screen.getByRole("tab", { name: /Issues/i })).toBeTruthy();
expect(screen.getByRole("tab", { name: /Pull Requests/i })).toBeTruthy();
});
});
it("switches to Pull Requests tab when clicked", async () => {
vi.mocked(fetchGitRemotes).mockResolvedValueOnce(singleRemote);
render(<GitHubImportModal isOpen={true} onClose={onClose} onImport={onImport} tasks={[]} />);
await waitFor(() => {
expect(screen.getByRole("tab", { name: /Pull Requests/i })).toBeTruthy();
});
// Click on Pull Requests tab
fireEvent.click(screen.getByRole("tab", { name: /Pull Requests/i }));
// Should show Pull Requests heading
await waitFor(() => {
expect(screen.getByRole("heading", { name: "Pull Requests" })).toBeTruthy();
});
});
it("shows filter input for Issues tab, hint text for Pulls tab", async () => {
vi.mocked(fetchGitRemotes).mockResolvedValueOnce(singleRemote);
render(<GitHubImportModal isOpen={true} onClose={onClose} onImport={onImport} tasks={[]} />);
await waitFor(() => {
// Default is Issues tab, should show filter input
expect(screen.getByPlaceholderText(/Filter:/)).toBeTruthy();
});
// Click on Pull Requests tab
fireEvent.click(screen.getByRole("tab", { name: /Pull Requests/i }));
await waitFor(() => {
// Should show hint text instead of filter input
expect(screen.queryByPlaceholderText(/Filter:/)).toBeNull();
expect(screen.getByText(/Open pull requests from/i)).toBeTruthy();
});
});
it("auto-loads pull requests when switching to Pulls tab with remote selected", async () => {
vi.mocked(fetchGitRemotes).mockResolvedValueOnce(singleRemote);
vi.mocked(apiFetchGitHubPulls).mockResolvedValueOnce(mockPulls);
render(<GitHubImportModal isOpen={true} onClose={onClose} onImport={onImport} tasks={[]} />);
await waitFor(() => {
expect(screen.getByRole("tab", { name: /Pull Requests/i })).toBeTruthy();
});
// Click on Pull Requests tab
fireEvent.click(screen.getByRole("tab", { name: /Pull Requests/i }));
// Should auto-load PRs
await waitFor(() => {
expect(apiFetchGitHubPulls).toHaveBeenCalledWith("dustinbyrne", "kb", 30);
expect(screen.getByText("Test PR")).toBeTruthy();
});
});
it("uses the default origin remote when switching to Pull Requests", async () => {
vi.mocked(fetchGitRemotes).mockResolvedValueOnce(multipleRemotes);
vi.mocked(apiFetchGitHubPulls).mockResolvedValueOnce(mockPulls);
render(<GitHubImportModal isOpen={true} onClose={onClose} onImport={onImport} tasks={[]} />);
await waitFor(() => {
const select = screen.getByRole("combobox") as HTMLSelectElement;
expect(select.value).toBe("origin");
});
fireEvent.click(screen.getByRole("tab", { name: /Pull Requests/i }));
await waitFor(() => {
expect(apiFetchGitHubPulls).toHaveBeenCalledWith("dustinbyrne", "kb", 30);
expect(screen.getByText("Test PR")).toBeTruthy();
});
});
it("displays PR list with branch info", async () => {
vi.mocked(fetchGitRemotes).mockResolvedValueOnce(singleRemote);
vi.mocked(apiFetchGitHubPulls).mockResolvedValueOnce(mockPulls);
render(<GitHubImportModal isOpen={true} onClose={onClose} onImport={onImport} tasks={[]} />);
// Switch to Pull Requests tab
fireEvent.click(screen.getByRole("tab", { name: /Pull Requests/i }));
await waitFor(() => {
expect(screen.getByText("Test PR")).toBeTruthy();
// Check for branch info
expect(screen.getByText(/feature → main/)).toBeTruthy();
});
});
it("selects PR and shows preview with branch info", async () => {
vi.mocked(fetchGitRemotes).mockResolvedValueOnce(singleRemote);
vi.mocked(apiFetchGitHubPulls).mockResolvedValueOnce(mockPulls);
render(<GitHubImportModal isOpen={true} onClose={onClose} onImport={onImport} tasks={[]} />);
// Switch to Pull Requests tab
fireEvent.click(screen.getByRole("tab", { name: /Pull Requests/i }));
await waitFor(() => {
expect(screen.getByText("Test PR")).toBeTruthy();
});
// Select the PR
fireEvent.click(screen.getByRole("radio", { name: /Select pull request #1/i }));
// Preview should show with branch info
const previewCard = await screen.findByTestId("github-import-preview-card");
expect(within(previewCard).getByText("Test PR")).toBeTruthy();
expect(within(previewCard).getByText(/feature → main/)).toBeTruthy();
});
it("disables Import button when no PR is selected", async () => {
vi.mocked(fetchGitRemotes).mockResolvedValueOnce(singleRemote);
vi.mocked(apiFetchGitHubPulls).mockResolvedValueOnce(mockPulls);
render(<GitHubImportModal isOpen={true} onClose={onClose} onImport={onImport} tasks={[]} />);
// Switch to Pull Requests tab
fireEvent.click(screen.getByRole("tab", { name: /Pull Requests/i }));
await waitFor(() => {
expect(screen.getByText("Test PR")).toBeTruthy();
});
// Import button should be disabled
const importButton = screen.getByTestId("github-import-action-top") as HTMLButtonElement;
expect(importButton.disabled).toBe(true);
});
it("calls apiImportGitHubPull when Import is clicked on PRs tab", async () => {
vi.mocked(fetchGitRemotes).mockResolvedValueOnce(singleRemote);
vi.mocked(apiFetchGitHubPulls).mockResolvedValueOnce(mockPulls);
vi.mocked(apiImportGitHubPull).mockResolvedValueOnce(mockPRTask);
render(<GitHubImportModal isOpen={true} onClose={onClose} onImport={onImport} tasks={[]} projectId="project-1" />);
// Switch to Pull Requests tab
fireEvent.click(screen.getByRole("tab", { name: /Pull Requests/i }));
await waitFor(() => {
expect(screen.getByText("Test PR")).toBeTruthy();
});
// Select the PR
fireEvent.click(screen.getByRole("radio", { name: /Select pull request #1/i }));
// Click Import
fireEvent.click(screen.getByTestId("github-import-action-top"));
await waitFor(() => {
expect(apiImportGitHubPull).toHaveBeenCalledWith("dustinbyrne", "kb", 1, "project-1");
expect(onImport).toHaveBeenCalledWith(mockPRTask);
expect(onClose).not.toHaveBeenCalled();
expect(screen.getByText("Import from GitHub")).toBeTruthy();
});
});
it("stays open and resets selection after successful PR import", async () => {
vi.mocked(fetchGitRemotes).mockResolvedValueOnce([{ name: "origin", owner: "owner", repo: "repo", url: "" }]);
vi.mocked(apiFetchGitHubPulls).mockResolvedValueOnce(mockPulls);
vi.mocked(apiImportGitHubPull).mockResolvedValueOnce(mockPRTask);
const { rerender } = render(
<GitHubImportModal isOpen={true} onClose={onClose} onImport={onImport} tasks={[]} projectId="project-1" />,
);
fireEvent.click(screen.getByRole("tab", { name: /Pull Requests/i }));
await waitFor(() => {
expect(screen.getByText("Test PR")).toBeTruthy();
});
const importButton = screen.getByTestId("github-import-action-top") as HTMLButtonElement;
fireEvent.click(screen.getByRole("radio", { name: /Select pull request #1/i }));
expect(importButton.disabled).toBe(false);
fireEvent.click(importButton);
await waitFor(() => {
expect(apiImportGitHubPull).toHaveBeenCalledWith("owner", "repo", 1, "project-1");
expect(onClose).not.toHaveBeenCalled();
});
await waitFor(() => {
expect((screen.getByTestId("github-import-action-top") as HTMLButtonElement).disabled).toBe(true);
});
rerender(
<GitHubImportModal isOpen={true} onClose={onClose} onImport={onImport} tasks={[mockPRTask]} projectId="project-1" />,
);
await waitFor(() => {
expect(screen.getByText("Test PR")).toBeTruthy();
expect(screen.getByText("Imported")).toBeTruthy();
});
});
it("shows 'Imported' badge for already imported PRs", async () => {
const existingTask: Task = {
...mockPRTask,
description: "Review and address any issues in this pull request.\n\nPR: https://github.com/owner/repo/pull/1",
};
vi.mocked(fetchGitRemotes).mockResolvedValueOnce([{ name: "origin", owner: "owner", repo: "repo", url: "" }]);
vi.mocked(apiFetchGitHubPulls).mockResolvedValueOnce(mockPulls);
render(<GitHubImportModal isOpen={true} onClose={onClose} onImport={onImport} tasks={[existingTask]} />);
// Switch to Pull Requests tab
fireEvent.click(screen.getByRole("tab", { name: /Pull Requests/i }));
await waitFor(() => {
expect(screen.getByText("Imported")).toBeTruthy();
});
});
it("disables radio buttons for already imported PRs", async () => {
const existingTask: Task = {
...mockPRTask,
description: "Review and address any issues in this pull request.\n\nPR: https://github.com/owner/repo/pull/1",
};
vi.mocked(fetchGitRemotes).mockResolvedValueOnce([{ name: "origin", owner: "owner", repo: "repo", url: "" }]);
vi.mocked(apiFetchGitHubPulls).mockResolvedValueOnce(mockPulls);
render(<GitHubImportModal isOpen={true} onClose={onClose} onImport={onImport} tasks={[existingTask]} />);
// Switch to Pull Requests tab
fireEvent.click(screen.getByRole("tab", { name: /Pull Requests/i }));
await waitFor(() => {
const radio = screen.getByRole("radio", { name: /Select pull request #1/i }) as HTMLInputElement;
expect(radio.disabled).toBe(true);
});
});
it("shows empty state when no open pull requests found", async () => {
vi.mocked(fetchGitRemotes).mockResolvedValueOnce(singleRemote);
vi.mocked(apiFetchGitHubPulls).mockResolvedValueOnce([]);
render(<GitHubImportModal isOpen={true} onClose={onClose} onImport={onImport} tasks={[]} />);
// Switch to Pull Requests tab
fireEvent.click(screen.getByRole("tab", { name: /Pull Requests/i }));
await waitFor(() => {
expect(screen.getByText("No open pull requests found")).toBeTruthy();
});
});
it("clears selection when switching tabs", async () => {
vi.mocked(fetchGitRemotes).mockResolvedValueOnce(singleRemote);
vi.mocked(apiFetchGitHubIssues).mockResolvedValueOnce([
{ number: 1, title: "Issue 1", body: "Body", html_url: "https://github.com/dustinbyrne/kb/issues/1", labels: [] },
]);
vi.mocked(apiFetchGitHubPulls).mockResolvedValueOnce(mockPulls);
render(<GitHubImportModal isOpen={true} onClose={onClose} onImport={onImport} tasks={[]} />);
// Wait for issues to load
await waitFor(() => {
expect(screen.getByText("Issue 1")).toBeTruthy();
});
// Select an issue
fireEvent.click(screen.getByRole("radio", { name: /Select issue #1/i }));
// Verify selection
let previewCard = await screen.findByTestId("github-import-preview-card");
expect(within(previewCard).getByText("Issue 1")).toBeTruthy();
// Switch to PR tab
fireEvent.click(screen.getByRole("tab", { name: /Pull Requests/i }));
await waitFor(() => {
expect(screen.getByText("Test PR")).toBeTruthy();
});
// Should show empty preview (issue selection cleared)
previewCard = screen.getByTestId("github-import-preview-empty");
expect(previewCard).toBeTruthy();
});
it("displays error state on PR fetch failure", async () => {
vi.mocked(fetchGitRemotes).mockResolvedValueOnce(singleRemote);
vi.mocked(apiFetchGitHubPulls).mockRejectedValueOnce(new Error("Repository not found"));
render(<GitHubImportModal isOpen={true} onClose={onClose} onImport={onImport} tasks={[]} />);
// Switch to Pull Requests tab
fireEvent.click(screen.getByRole("tab", { name: /Pull Requests/i }));
await waitFor(() => {
expect(screen.getByText("Could not load pull requests")).toBeTruthy();
expect(screen.getByText("Repository not found")).toBeTruthy();
});
});
it("shows PR count and imported count in header", async () => {
vi.mocked(fetchGitRemotes).mockResolvedValueOnce(singleRemote);
vi.mocked(apiFetchGitHubPulls).mockResolvedValueOnce(mockPulls);
render(<GitHubImportModal isOpen={true} onClose={onClose} onImport={onImport} tasks={[]} />);
// Switch to Pull Requests tab
fireEvent.click(screen.getByRole("tab", { name: /Pull Requests/i }));
await waitFor(() => {
// Should show 2 pull requests, 0 imported
expect(screen.getByText("2 pull requests")).toBeTruthy();
expect(screen.getByText("0 imported")).toBeTruthy();
});
});
});
/*
* FNXC:GitHubImport 2026-07-07-00:00:
* FN-7657 symptom verification. The embedded Import Tasks view fully unmounts on navigation away (e.g. to Board) and
* remounts fresh on return; before this fix every one of these fields reset to defaults on remount. These tests
* unmount + remount a fresh instance with the SAME projectId to simulate exactly that, and assert restoration.
*/
describe("import state retention on exit and return (FN-7657)", () => {
const GITHUB_IMPORT_STATE_KEY = "kb-dashboard-github-import-state";
const originalInnerWidth = window.innerWidth;
const clearImportState = (projectId?: string) => {
const key = projectId ? `kb:${projectId}:${GITHUB_IMPORT_STATE_KEY}` : GITHUB_IMPORT_STATE_KEY;
window.localStorage.removeItem(key);
};
beforeEach(() => {
clearImportState("project-1");
clearImportState("project-2");
clearImportState(undefined);
});
afterEach(() => {
clearImportState("project-1");
clearImportState("project-2");
clearImportState(undefined);
Object.defineProperty(window, "innerWidth", {
writable: true,
configurable: true,
value: originalInnerWidth,
});
window.dispatchEvent(new Event("resize"));
});
it("restores the active tab, label filter, and selected issue after unmount and remount for the same project", async () => {
vi.mocked(fetchGitRemotes).mockResolvedValue(singleRemote);
vi.mocked(apiFetchGitHubIssues).mockResolvedValue([
{ number: 1, title: "Persisted Issue", body: "Body", html_url: "https://github.com/dustinbyrne/kb/issues/1", labels: [] },
]);
const first = render(
<GitHubImportModal isOpen={true} onClose={onClose} onImport={onImport} tasks={[]} projectId="project-1" presentation="embedded" />,
);
await waitFor(() => {
expect(screen.getByText("Persisted Issue")).toBeTruthy();
});
fireEvent.change(screen.getByPlaceholderText(/Filter:/), { target: { value: "bug" } });
// The label change re-triggers auto-load (briefly disabling the list); wait for it to settle before selecting.
await waitFor(() => {
expect(screen.getByRole("radio", { name: /Select issue #1/i })).not.toBeDisabled();
});
fireEvent.click(screen.getByRole("radio", { name: /Select issue #1/i }));
await waitFor(() => {
expect(within(screen.getByTestId("github-import-preview-card")).getByText("Persisted Issue")).toBeTruthy();
});
// Simulate navigating away from the embedded view (component fully unmounts).
first.unmount();
// Simulate returning to the view: a brand-new instance mounts for the same project.
render(
<GitHubImportModal isOpen={true} onClose={onClose} onImport={onImport} tasks={[]} projectId="project-1" presentation="embedded" />,
);
await waitFor(() => {
expect((screen.getByPlaceholderText(/Filter:/) as HTMLInputElement).value).toBe("bug");
});
await waitFor(() => {
expect(within(screen.getByTestId("github-import-preview-card")).getByText("Persisted Issue")).toBeTruthy();
});
});
it("restores the Pull Requests tab and selected PR after unmount and remount", async () => {
vi.mocked(fetchGitRemotes).mockResolvedValue(singleRemote);
vi.mocked(apiFetchGitHubPulls).mockResolvedValue(mockPulls);
const first = render(
<GitHubImportModal isOpen={true} onClose={onClose} onImport={onImport} tasks={[]} projectId="project-1" presentation="embedded" />,
);
fireEvent.click(await screen.findByRole("tab", { name: /Pull Requests/i }));
await waitFor(() => {
expect(screen.getByText("Test PR")).toBeTruthy();
});
fireEvent.click(screen.getByRole("radio", { name: /Select pull request #1/i }));
await waitFor(() => {
expect(within(screen.getByTestId("github-import-preview-card")).getByText("Test PR")).toBeTruthy();
});
first.unmount();
render(
<GitHubImportModal isOpen={true} onClose={onClose} onImport={onImport} tasks={[]} projectId="project-1" presentation="embedded" />,
);
// Restored straight to the Pull Requests tab, with the prior PR selection re-applied.
await waitFor(() => {
expect(screen.getByRole("tab", { name: /Pull Requests/i })).toHaveAttribute("aria-selected", "true");
});
await waitFor(() => {
expect(within(screen.getByTestId("github-import-preview-card")).getByText("Test PR")).toBeTruthy();
});
});
it("restores GitLab provider, resource inputs, and selection after unmount and remount", async () => {
vi.mocked(fetchGitRemotes).mockResolvedValue([]);
vi.mocked(apiFetchGitLabProjectIssues).mockResolvedValue([
{ resourceKind: "project_issue", id: 1, iid: 2, projectId: 3, projectPath: "group/project", title: "GitLab bug", description: "Body", webUrl: "https://gitlab.example.com/group/project/-/issues/2", state: "opened", labels: ["bug"] },
]);
const first = render(
<GitHubImportModal isOpen={true} onClose={onClose} onImport={onImport} tasks={[]} projectId="project-1" presentation="embedded" />,
);
fireEvent.click(await screen.findByRole("button", { name: "GitLab" }));
fireEvent.change(screen.getByLabelText("GitLab project path or ID"), { target: { value: "group/project" } });
fireEvent.click(screen.getByRole("button", { name: /Load/ }));
fireEvent.click(await screen.findByText(/#2 GitLab bug/));
await waitFor(() => {
expect(screen.getByTestId("gitlab-import-preview-card")).toBeTruthy();
});
first.unmount();
render(
<GitHubImportModal isOpen={true} onClose={onClose} onImport={onImport} tasks={[]} projectId="project-1" presentation="embedded" />,
);
// Provider tab and GitLab project input are restored immediately from persisted state.
await waitFor(() => {
expect(screen.getByRole("button", { name: "GitLab" })).toHaveAttribute("aria-pressed", "true");
});
await waitFor(() => {
expect((screen.getByLabelText("GitLab project path or ID") as HTMLInputElement).value).toBe("group/project");
});
// The hydrated-on-mount auto-load re-fetches the list and re-applies the restored selection.
await waitFor(() => {
expect(screen.getByTestId("gitlab-import-preview-card")).toBeTruthy();
});
});
it("keeps the existing default remote auto-detect behavior when no state has ever been persisted", async () => {
vi.mocked(fetchGitRemotes).mockResolvedValueOnce(singleRemote);
vi.mocked(apiFetchGitHubIssues).mockResolvedValueOnce([
{ number: 9, title: "Fresh Issue", body: "", html_url: "https://github.com/dustinbyrne/kb/issues/9", labels: [] },
]);
render(
<GitHubImportModal isOpen={true} onClose={onClose} onImport={onImport} tasks={[]} projectId="project-1" presentation="embedded" />,
);
// No persisted state exists for this project: the single detected remote is still auto-selected and its issues load.
await waitFor(() => {
expect(screen.getByTestId("github-import-single-remote")).toBeTruthy();
expect(apiFetchGitHubIssues).toHaveBeenCalledWith("dustinbyrne", "kb", 30, undefined);
expect(screen.getByText("Fresh Issue")).toBeTruthy();
});
});
it("does not leak persisted state across different projects", async () => {
vi.mocked(fetchGitRemotes).mockResolvedValue(singleRemote);
vi.mocked(apiFetchGitHubIssues).mockResolvedValue([
{ number: 1, title: "Project One Issue", body: "", html_url: "https://github.com/dustinbyrne/kb/issues/1", labels: [] },
]);
const first = render(
<GitHubImportModal isOpen={true} onClose={onClose} onImport={onImport} tasks={[]} projectId="project-1" presentation="embedded" />,
);
await waitFor(() => expect(screen.getByText("Project One Issue")).toBeTruthy());
fireEvent.change(screen.getByPlaceholderText(/Filter:/), { target: { value: "bug" } });
// The label change re-triggers auto-load (briefly disabling the list); wait for it to settle before selecting.
await waitFor(() => {
expect(screen.getByRole("radio", { name: /Select issue #1/i })).not.toBeDisabled();
});
fireEvent.click(screen.getByRole("radio", { name: /Select issue #1/i }));
await waitFor(() => {
expect(within(screen.getByTestId("github-import-preview-card")).getByText("Project One Issue")).toBeTruthy();
});
first.unmount();
// (project-1's own selection is verified above; now assert isolation for project-2.)
// A different project must NOT see project-1's persisted filter/selection.
render(
<GitHubImportModal isOpen={true} onClose={onClose} onImport={onImport} tasks={[]} projectId="project-2" presentation="embedded" />,
);
await waitFor(() => {
expect((screen.getByPlaceholderText(/Filter:/) as HTMLInputElement).value).toBe("");
});
expect(screen.getByTestId("github-import-preview-empty")).toBeTruthy();
});
it("clears gracefully when a persisted selection is no longer present in the reloaded list", async () => {
vi.mocked(fetchGitRemotes).mockResolvedValue(singleRemote);
vi.mocked(apiFetchGitHubIssues).mockResolvedValueOnce([
{ number: 1, title: "Will Vanish", body: "", html_url: "https://github.com/dustinbyrne/kb/issues/1", labels: [] },
]);
const first = render(
<GitHubImportModal isOpen={true} onClose={onClose} onImport={onImport} tasks={[]} projectId="project-1" presentation="embedded" />,
);
await waitFor(() => expect(screen.getByText("Will Vanish")).toBeTruthy());
fireEvent.click(screen.getByRole("radio", { name: /Select issue #1/i }));
await waitFor(() => {
expect(within(screen.getByTestId("github-import-preview-card")).getByText("Will Vanish")).toBeTruthy();
});
first.unmount();
// On return, the reloaded list no longer contains issue #1 (e.g. closed/merged/deleted upstream).
vi.mocked(apiFetchGitHubIssues).mockResolvedValueOnce([
{ number: 2, title: "Still Here", body: "", html_url: "https://github.com/dustinbyrne/kb/issues/2", labels: [] },
]);
render(
<GitHubImportModal isOpen={true} onClose={onClose} onImport={onImport} tasks={[]} projectId="project-1" presentation="embedded" />,
);
// No crash and no stuck preview: the stale selection is dropped and the list/empty-preview state renders cleanly.
await waitFor(() => {
expect(screen.getByText("Still Here")).toBeTruthy();
});
expect(screen.getByTestId("github-import-preview-empty")).toBeTruthy();
});
it("does not strand a restored selection on an empty mobile preview pane after remount", async () => {
Object.defineProperty(window, "innerWidth", {
writable: true,
configurable: true,
value: 480,
});
window.dispatchEvent(new Event("resize"));
vi.mocked(fetchGitRemotes).mockResolvedValue(singleRemote);
vi.mocked(apiFetchGitHubIssues).mockResolvedValue([
{ number: 1, title: "Mobile Persisted Issue", body: "", html_url: "https://github.com/dustinbyrne/kb/issues/1", labels: [] },
]);
const first = render(
<GitHubImportModal isOpen={true} onClose={onClose} onImport={onImport} tasks={[]} projectId="project-1" presentation="embedded" />,
);
await waitFor(() => expect(screen.getByText("Mobile Persisted Issue")).toBeTruthy());
fireEvent.click(screen.getByRole("radio", { name: /Select issue #1/i }));
first.unmount();
render(
<GitHubImportModal isOpen={true} onClose={onClose} onImport={onImport} tasks={[]} projectId="project-1" presentation="embedded" />,
);
// A fresh mount always starts on the list pane (never the preview pane), so a restored selection can never strand
// the user staring at a bare/empty preview — the list (with the restored selection re-applied) is visible instead.
await waitFor(() => {
const listPane = screen.getByTestId("github-import-list-pane");
expect(listPane.classList.contains("active")).toBe(true);
expect(screen.getByTestId("github-import-preview-pane").classList.contains("active")).toBe(false);
});
await waitFor(() => {
const radio = screen.getByRole("radio", { name: /Select issue #1/i }) as HTMLInputElement;
expect(radio.checked).toBe(true);
});
});
});
});