import { describe, it, expect, vi, beforeEach } from "vitest"; import { render, screen, waitFor, fireEvent } from "@testing-library/react"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import type { ReactNode } from "react"; import React from "react"; import { Team } from "@/types"; import type { Project } from "@/types"; // jsdom has no ResizeObserver; Radix Switch (the always-rendered "Active" // toggle) measures its thumb via one on mount — mirrors // a2a-conversation-list.test.tsx's stub. if (typeof window !== "undefined" && !window.ResizeObserver) { window.ResizeObserver = class { observe() {} unobserve() {} disconnect() {} } as unknown as typeof ResizeObserver; } // Radix Select's SelectValue sizing hook calls ResizeObserver, absent in // jsdom — mirrors select-repo-picker.test.tsx's functional replacement // (SelectItem wired to onValueChange via context) so the dialog's Forge / // Assigned Cell selects mount without crashing. vi.mock("@/components/ui/select", () => { const Ctx = React.createContext<(v: string) => void>(() => {}); return { Select: ({ onValueChange, children, }: { onValueChange?: (v: string) => void; children: React.ReactNode; }) => ( {})}> {children} ), SelectTrigger: ({ children }: { children: React.ReactNode }) => (
{children}
), SelectValue: () => null, SelectContent: ({ children }: { children: React.ReactNode }) => (
{children}
), SelectItem: ({ value, children, }: { value: string; children: React.ReactNode; }) => { const onValueChange = React.useContext(Ctx); return ( ); }, }; }); const { useProject, useUpdateProject, mutateAsync } = vi.hoisted(() => ({ useProject: vi.fn(), useUpdateProject: vi.fn(), mutateAsync: vi.fn(), })); vi.mock("@/hooks/use-projects", () => ({ useProject, useUpdateProject })); const { getCredentialsStatus, listInstallations, listInstallationRepositories, } = vi.hoisted(() => ({ getCredentialsStatus: vi.fn(async () => ({ has_credentials: true })), listInstallations: vi.fn(async () => [{ id: 42, account_login: "acme" }]), listInstallationRepositories: vi.fn(async () => [ { full_name: "acme/widgets", clone_url: "https://github.com/acme/widgets.git", private: false, }, ]), })); vi.mock("@/lib/api", () => ({ githubAppApi: { getCredentialsStatus, listInstallations, listInstallationRepositories, }, })); import { EditProjectDialog } from "../edit-project-dialog"; function makeProject(overrides: Partial = {}): Project { return { id: "proj-1", name: "RoboCo API", slug: "roboco-api", git_url: "https://github.com/org/repo.git", git_provider: "github", github_installation_id: null, default_branch: "main", environments: null, protected_branches: ["main"], assigned_cell: Team.BACKEND, has_git_token: true, is_active: true, test_command: null, lint_command: null, format_command: null, typecheck_command: null, build_command: null, quality_command: null, codegen_command: null, ci_watch_enabled: false, ci_watch_workflow: null, video_engine_enabled: false, dep_update_command: null, dep_update_paths: null, sandbox_services: null, sandbox_extensions: null, workspace_path: null, last_synced_at: null, head_commit: null, created_by: "ceo", created_at: "2026-01-01T00:00:00Z", updated_at: null, ...overrides, }; } function withQueryClient(ui: ReactNode) { const client = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, }); return {ui}; } function renderDialog(project: Project) { useProject.mockReturnValue({ data: project, isLoading: false }); return render( withQueryClient( , ), ); } describe("EditProjectDialog — GitHub App binding", () => { beforeEach(() => { vi.clearAllMocks(); getCredentialsStatus.mockResolvedValue({ has_credentials: true }); listInstallations.mockResolvedValue([{ id: 42, account_login: "acme" }]); listInstallationRepositories.mockResolvedValue([ { full_name: "acme/widgets", clone_url: "https://github.com/acme/widgets.git", private: false, }, ]); mutateAsync.mockResolvedValue(makeProject()); useUpdateProject.mockReturnValue({ mutateAsync, isPending: false }); }); it("shows the PAT state and a repo picker when the App is configured", async () => { renderDialog(makeProject()); expect( await screen.findByText("Using personal access token"), ).toBeInTheDocument(); expect( screen.getByRole("button", { name: /Select repo/i }), ).toBeInTheDocument(); }); it("shows the current binding and an Unbind button when already bound", async () => { renderDialog(makeProject({ github_installation_id: 42 })); expect( await screen.findByText("Using GitHub App (installation #42)"), ).toBeInTheDocument(); expect(screen.getByRole("button", { name: /Unbind/i })).toBeInTheDocument(); }); it("shows a muted note and no picker when the App is not configured", async () => { getCredentialsStatus.mockResolvedValue({ has_credentials: false }); renderDialog(makeProject()); expect( await screen.findByText(/Configure the GitHub App/i), ).toBeInTheDocument(); expect( screen.queryByRole("button", { name: /Select repo/i }), ).not.toBeInTheDocument(); }); it("hides the picker for a non-GitHub forge provider", async () => { renderDialog(makeProject({ git_provider: "gitlab" })); expect( await screen.findByText(/App auth is GitHub-only/i), ).toBeInTheDocument(); expect( screen.queryByRole("button", { name: /Select repo/i }), ).not.toBeInTheDocument(); }); it("hides the picker for an auto-detected gitlab.com URL (git_provider stored as null)", async () => { renderDialog( makeProject({ git_provider: null, git_url: "https://gitlab.com/acme/widgets.git", }), ); expect( await screen.findByText(/App auth is GitHub-only/i), ).toBeInTheDocument(); expect( screen.queryByRole("button", { name: /Select repo/i }), ).not.toBeInTheDocument(); }); it("warns when saving would clear both the App binding and the PAT", async () => { renderDialog( makeProject({ github_installation_id: null, has_git_token: true }), ); expect( screen.queryByText(/no git credentials at all/i), ).not.toBeInTheDocument(); fireEvent.click(await screen.findByRole("switch", { name: /clear token/i })); expect( await screen.findByText(/no git credentials at all/i), ).toBeInTheDocument(); }); it("binding via the repo picker sets github_installation_id in the submitted payload", async () => { renderDialog(makeProject()); const pickButton = await screen.findByRole("button", { name: /Select repo/i, }); fireEvent.click(pickButton); fireEvent.click(await screen.findByText("acme/widgets")); fireEvent.click(screen.getByRole("button", { name: /Save Changes/i })); await waitFor(() => expect(mutateAsync).toHaveBeenCalled()); const call = mutateAsync.mock.calls[0][0] as { updates: { github_installation_id?: number | null }; }; expect(call.updates.github_installation_id).toBe(42); }); it("unbinding sends an explicit null so the backend clears the stored installation", async () => { renderDialog(makeProject({ github_installation_id: 42 })); fireEvent.click(await screen.findByRole("button", { name: /Unbind/i })); fireEvent.click(screen.getByRole("button", { name: /Save Changes/i })); await waitFor(() => expect(mutateAsync).toHaveBeenCalled()); const call = mutateAsync.mock.calls[0][0] as { updates: { github_installation_id?: number | null }; }; expect(call.updates.github_installation_id).toBeNull(); }); });