import { useState } from "react"; import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { ToastProvider } from "@/components/toast-provider"; import type { TaskGitCredentialsState } from "@/hooks/domains/github/use-task-git-credentials"; import type { GitHubAppRegistrationCatalogItem, GitHubStatus } from "@/lib/types/github"; import { GitHubAppConnectionPanel } from "./github-app-connection-panel"; import { GitHubConnectionDialog } from "./github-connection-dialog"; const mocks = vi.hoisted(() => ({ mobile: false, registrations: [] as GitHubAppRegistrationCatalogItem[], setConnection: vi.fn(), })); const changeConnectionLabel = "Change connection"; const registrationDisplayName = "Work automation"; const githubAppLabel = "GitHub App"; const saveChangesLabel = "Save changes"; const WORKSPACE_ID = "workspace-1"; const DESKTOP_CONNECTION_TEST_ID = "github-connection-desktop"; const taskAccess: TaskGitCredentialsState = { mode: "managed", loading: false, error: false, save: vi.fn(), }; vi.mock("@/hooks/use-responsive-breakpoint", () => ({ useResponsiveBreakpoint: () => ({ isMobile: mocks.mobile }), })); vi.mock("@/hooks/domains/github/use-github-app-registrations", () => ({ useGitHubAppRegistrations: (workspaceId: string) => ({ workspaceId, catalog: { workspace_id: workspaceId, registrations: mocks.registrations }, registrations: mocks.registrations, selected: mocks.registrations.find((item) => item.selected) ?? null, loaded: true, loading: false, error: null, mutating: false, refresh: vi.fn(), startManifest: vi.fn(), prepareImport: vi.fn(), importRegistration: vi.fn(), rename: vi.fn(), remove: vi.fn(), startInstall: vi.fn(), }), })); vi.mock("./github-app-import-form", () => ({ GitHubAppImportForm: ({ onImported }: { onImported: (registrationId: string) => void }) => ( ), })); vi.mock("@/lib/api/domains/github-api", async (importOriginal) => ({ ...(await importOriginal()), setGitHubWorkspaceConnection: mocks.setConnection, })); const status: GitHubStatus = { workspace_id: WORKSPACE_ID, automation: { workspace_id: WORKSPACE_ID, source: "pat", github_host: "github.com", login: "octocat", status: "active", credential_generation: 1, }, personal: null, authenticated: true, username: "octocat", auth_method: "pat", token_configured: true, required_scopes: [], }; const registration: GitHubAppRegistrationCatalogItem = { id: "registration-1", source: "managed", display_name: registrationDisplayName, github_host: "github.com", app_id: 42, client_id: "Iv1.test", slug: "work-automation", owner_login: "acme", owner_type: "Organization", visibility: "private", public_base_url: "https://kandev.example", credential_generation: 1, status: "active", webhook_status: "verified", created_at: "2026-07-21T00:00:00Z", updated_at: "2026-07-21T00:00:00Z", selected: true, binding_count: 3, workspace_binding_count: 2, shared: true, manifest_callback_url: "https://kandev.example/manifest/callback", install_callback_url: "https://kandev.example/install/callback", personal_callback_url: "https://kandev.example/personal/callback", webhook_url: "https://kandev.example/webhook", }; function view(workspaceId = WORKSPACE_ID, currentTaskAccess = taskAccess) { const onSaved = vi.fn(); return render( , ); } function PartialSaveHarness() { const [mode, setMode] = useState("managed"); const save = vi.fn(async (nextMode: TaskGitCredentialsState["mode"]) => { setMode(nextMode); return true; }); return ( ); } beforeEach(() => { mocks.mobile = false; mocks.registrations = [registration]; mocks.setConnection.mockReset(); mocks.setConnection.mockResolvedValue(status.automation); }); describe("GitHubConnectionDialog task access", () => { it("saves connection and task access drafts with one action", async () => { const save = vi.fn().mockResolvedValue(true); const rendered = view(WORKSPACE_ID, { ...taskAccess, save }); fireEvent.click(screen.getByRole("button", { name: changeConnectionLabel })); const dialog = await screen.findByTestId(DESKTOP_CONNECTION_TEST_ID); const executor = screen .getByTestId("github-task-access-option-executor") .querySelector('[role="radio"]')!; fireEvent.click(executor); fireEvent.change(screen.getByLabelText("Personal access token"), { target: { value: "ghp_replacement" }, }); expect(screen.queryByRole("button", { name: "Connect token" })).toBeNull(); expect(screen.queryByRole("button", { name: "Save task access" })).toBeNull(); expect(screen.getAllByRole("button", { name: saveChangesLabel })).toHaveLength(1); fireEvent.click(screen.getByRole("button", { name: saveChangesLabel })); await waitFor(() => expect(mocks.setConnection).toHaveBeenCalledWith(WORKSPACE_ID, { source: "pat", token: "ghp_replacement", }), ); expect(save).toHaveBeenCalledWith("executor"); await waitFor(() => expect(dialog.getAttribute("data-state")).toBe("closed")); expect(rendered.baseElement.textContent).toContain("GitHub access settings saved"); }); it("does not report a discarded task access save as successful", async () => { const save = vi.fn().mockResolvedValue(false); view(WORKSPACE_ID, { ...taskAccess, save }); fireEvent.click(screen.getByRole("button", { name: changeConnectionLabel })); const dialog = await screen.findByTestId(DESKTOP_CONNECTION_TEST_ID); const executor = screen .getByTestId("github-task-access-option-executor") .querySelector('[role="radio"]')!; fireEvent.click(executor); fireEvent.click(screen.getByRole("button", { name: saveChangesLabel })); await waitFor(() => expect(save).toHaveBeenCalledWith("executor")); expect(screen.queryByText("GitHub access settings saved")).toBeNull(); expect(dialog.getAttribute("data-state")).toBe("open"); }); it("preserves a failed PAT draft when task access saves successfully", async () => { mocks.setConnection.mockRejectedValue(new Error("Token rejected")); render(); fireEvent.click(screen.getByRole("button", { name: changeConnectionLabel })); const token = screen.getByLabelText("Personal access token") as HTMLInputElement; fireEvent.change(token, { target: { value: "ghp_retry_me" } }); fireEvent.click( screen.getByTestId("github-task-access-option-executor").querySelector('[role="radio"]')!, ); fireEvent.click(screen.getByRole("button", { name: saveChangesLabel })); await screen.findByText(/Some settings were saved/); expect(token.value).toBe("ghp_retry_me"); }); }); afterEach(() => cleanup()); describe("GitHubConnectionDialog", () => { it("uses a method list and shows workspace App choices and sharing impact", async () => { view(); fireEvent.click(screen.getByRole("button", { name: changeConnectionLabel })); expect(await screen.findByTestId(DESKTOP_CONNECTION_TEST_ID)).toBeTruthy(); expect(screen.getByLabelText("Connection method")).toBeTruthy(); expect(screen.getAllByText("Personal access token")).not.toHaveLength(0); expect(screen.getByText("GitHub CLI account")).toBeTruthy(); fireEvent.click(screen.getByText(githubAppLabel, { selector: "span" })); expect(await screen.findByText(registrationDisplayName)).toBeTruthy(); expect(screen.getByText(/Used by 2 workspaces/)).toBeTruthy(); expect(screen.getByRole("button", { name: /Install for this workspace/ })).toBeTruthy(); expect(screen.getByRole("button", { name: "Add existing App" })).toBeTruthy(); expect(screen.getByRole("button", { name: "Create new App" })).toBeTruthy(); expect(screen.queryByTestId("github-app-system-setup-link")).toBeNull(); }); it("renders one bounded mobile drawer with a single scroll body", async () => { mocks.mobile = true; view(); fireEvent.click(screen.getByRole("button", { name: changeConnectionLabel })); const drawer = await screen.findByTestId("github-connection-mobile"); expect(drawer.className).toContain("100dvh"); expect(drawer.querySelectorAll(".overflow-y-auto")).toHaveLength(1); expect(screen.queryByTestId(DESKTOP_CONNECTION_TEST_ID)).toBeNull(); }); it("returns to the registration catalog after importing an App", async () => { view(); fireEvent.click(screen.getByRole("button", { name: changeConnectionLabel })); fireEvent.click(await screen.findByText(githubAppLabel, { selector: "span" })); fireEvent.click(screen.getByRole("button", { name: "Add existing App" })); fireEvent.click(screen.getByRole("button", { name: "Complete test import" })); expect(await screen.findByText(registrationDisplayName)).toBeTruthy(); const installButton = screen.getByRole("button", { name: /Install for this workspace/ }); expect((installButton as HTMLButtonElement).disabled).toBe(false); }); it("does not install invalid App registrations", async () => { mocks.registrations = [ { ...registration, status: "invalid", last_error: "Credentials could not be restored.", }, ]; view(); fireEvent.click(screen.getByRole("button", { name: changeConnectionLabel })); fireEvent.click(await screen.findByText(githubAppLabel, { selector: "span" })); expect(await screen.findByText("Needs attention")).toBeTruthy(); expect(screen.getByText("Credentials could not be restored.")).toBeTruthy(); expect( (screen.getByRole("button", { name: /Install for this workspace/ }) as HTMLButtonElement) .disabled, ).toBe(true); }); it("clears a selected App when it disappears from the catalog", async () => { const rendered = render( , ); expect( (screen.getByRole("button", { name: /Install for this workspace/ }) as HTMLButtonElement) .disabled, ).toBe(false); mocks.registrations = []; rendered.rerender( , ); await waitFor(() => expect( (screen.getByRole("button", { name: /Install for this workspace/ }) as HTMLButtonElement) .disabled, ).toBe(true), ); }); it("closes and resets local setup state when the workspace changes", async () => { const rendered = view(); fireEvent.click(screen.getByRole("button", { name: changeConnectionLabel })); fireEvent.click(await screen.findByText(githubAppLabel, { selector: "span" })); expect(await screen.findByText(registrationDisplayName)).toBeTruthy(); rendered.rerender( , ); await waitFor(() => expect(screen.queryByTestId(DESKTOP_CONNECTION_TEST_ID)).toBeNull()); }); });