// @vitest-environment jsdom import { act } from "react"; import { createRoot, type Root } from "react-dom/client"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { MemoryRouter, Route, Routes, useLocation } from "react-router-dom"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { GitHubChatSetup, gitHubAppManifestAction } from "./GitHubChatSetup"; import { defaultGitHubReviewPolicy } from "@paperclipai/shared"; import { ChatEndpointDetail } from "./ChatEndpointDetail"; import { TooltipProvider } from "@/components/ui/tooltip"; import { agentsApi } from "@/api/agents"; import { chatEndpointsApi } from "@/api/chatEndpoints"; import { githubChatApi } from "@/api/githubChat"; import { projectsApi } from "@/api/projects"; import { toolsApi } from "@/api/tools"; import { copyTextToClipboard } from "@/lib/clipboard"; const fixture = vi.hoisted(() => ({ agents: [] as any[], endpoint: null as any, setBreadcrumbs: vi.fn(), })); vi.mock("@/lib/router", async () => import("react-router-dom")); vi.mock("@/context/CompanyContext", () => ({ useCompany: () => ({ selectedCompanyId: "company-1", selectedCompany: { id: "company-1", name: "Bot Company", issuePrefix: "BOT", }, }), })); vi.mock("@/context/BreadcrumbContext", () => ({ useBreadcrumbs: () => ({ setBreadcrumbs: fixture.setBreadcrumbs }), })); vi.mock("@/context/ToastContext", () => ({ useToast: () => ({ pushToast: vi.fn() }), })); vi.mock("@/components/SetupWizard", () => ({ SetupWizardNavigation: () => null, SetupWizardFooter: ({ children, onSaveExit }: any) => ( ), })); vi.mock("@/components/AgentMultiSelect", () => ({ AgentSelect: ({ agents, value, onChange }: any) => ( ), })); vi.mock("@/api/agents", () => ({ agentsApi: { list: vi.fn(), get: vi.fn(), updatePermissions: vi.fn() }, })); vi.mock("@/api/chatEndpoints", () => ({ chatEndpointsApi: { list: vi.fn(), create: vi.fn(), get: vi.fn(), listResources: vi.fn(), setup: vi.fn(), generateSetupSecret: vi.fn(), }, })); vi.mock("@/api/projects", () => ({ projectsApi: { repositoryOptions: vi.fn() } })); vi.mock("@/api/githubChat", () => ({ githubChatApi: { configuration: vi.fn(), advance: vi.fn(), repositories: vi.fn(), registration: vi.fn(), restartRegistration: vi.fn(), saveDraft: vi.fn(), startIdentity: vi.fn(), confirmIdentity: vi.fn(), identity: vi.fn(), connectApp: vi.fn(), personalConnections: vi.fn(), }, })); vi.mock("@/api/tools", () => ({ toolsApi: { startCloudConnectorEnrollment: vi.fn() }, })); vi.mock("@/lib/clipboard", () => ({ copyTextToClipboard: vi.fn() })); function Location() { const location = useLocation(); return ( {location.pathname} {location.search} ); } describe("GitHub App wizard", () => { let root: Root; let container: HTMLDivElement; let client: QueryClient; beforeEach(() => { vi.clearAllMocks(); fixture.agents = [ { id: "reviewer", name: "Reviewer", status: "idle", role: "engineer", permissions: { canCreateAgents: true, canCreateSkills: false }, access: { canAssignTasks: true }, createdAt: "2026-01-01", }, { id: "helper", name: "Setup helper", status: "idle", role: "ceo", permissions: {}, createdAt: "2026-01-01", }, { id: "low", name: "Low-trust helper", status: "idle", role: "engineer", permissions: { trustPreset: "low_trust_review" }, createdAt: "2026-01-01", }, ]; fixture.endpoint = { id: "draft-1", companyId: "company-1", provider: "github", assignedAgentId: "reviewer", assignedAgentName: "Reviewer", status: "draft", setup: { github: { stage: "setup" } }, }; vi.mocked(agentsApi.list).mockImplementation(async () => fixture.agents); vi.mocked(agentsApi.get).mockImplementation(async (id) => fixture.agents.find((agent) => agent.id === id), ); vi.mocked(agentsApi.updatePermissions).mockImplementation( async (id, permissions) => { fixture.agents = fixture.agents.map((agent) => agent.id === id ? { ...agent, permissions } : agent, ); return fixture.agents.find((agent) => agent.id === id); }, ); vi.mocked(chatEndpointsApi.get).mockImplementation( async () => fixture.endpoint, ); vi.mocked(chatEndpointsApi.create).mockResolvedValue(fixture.endpoint); vi.mocked(chatEndpointsApi.list).mockResolvedValue([]); vi.mocked(projectsApi.repositoryOptions).mockResolvedValue({ repositories: [], connectionCount: 0, failedConnectionCount: 0, }); vi.mocked(chatEndpointsApi.listResources).mockResolvedValue([]); vi.mocked(githubChatApi.advance).mockResolvedValue({ endpointId: "draft-1", state: "create", }); vi.mocked(githubChatApi.configuration).mockResolvedValue({ revision: 1, configuration: { version: 1, toolsEnabled: true, responsibleUserId: "board", memberAccess: "selected", people: [], defaults: defaultGitHubReviewPolicy(), repositories: {} }, }); vi.mocked(githubChatApi.repositories).mockResolvedValue({ items: [], nextOffset: null, totalCount: 1, enabledCount: 1, availableCount: 1, }); vi.mocked(githubChatApi.saveDraft).mockResolvedValue({ saved: true }); vi.mocked(githubChatApi.personalConnections).mockResolvedValue([]); vi.mocked(copyTextToClipboard).mockResolvedValue(); client = new QueryClient({ defaultOptions: { queries: { retry: false, gcTime: 60_000 } }, }); container = document.createElement("div"); document.body.append(container); root = createRoot(container); }); afterEach(async () => { await act(async () => root.unmount()); client.clear(); container.remove(); }); async function settle() { await act(async () => { await new Promise((resolve) => setTimeout(resolve, 20)); }); } it("shows pending signed delivery as waiting, not failed setup", async () => { fixture.endpoint.botExternalId = "1234"; vi.mocked(githubChatApi.advance).mockResolvedValue({ endpointId: "draft-1", state: "verify", message: "Waiting for GitHub to verify webhook delivery…", verification: { ready: false, checks: [ { key: "webhook", label: "Signed webhook delivery", ok: false, detail: "Keep this page open. Setup will continue automatically when GitHub’s signed ping arrives.", }, ], }, }); await render("resume=draft-1"); expect(container.querySelector('[role="status"]')?.textContent).toContain( "Waiting for GitHub", ); expect(container.querySelector('[role="alert"]')).toBeNull(); expect(container.textContent).toContain("continue automatically"); expect(githubChatApi.registration).not.toHaveBeenCalled(); }); it("replaces a stale ping wait as soon as signed delivery arrives", async () => { vi.mocked(githubChatApi.advance).mockResolvedValue({ endpointId: "draft-1", state: "verify", message: "Waiting for GitHub to verify webhook delivery…", verification: { ready: false, checks: [{ key: "webhook", label: "Signed delivery", ok: false, detail: "Keep this page open until the signed ping arrives." }], }, }); await render("resume=draft-1"); expect(container.textContent).toContain("Waiting for GitHub"); // Endpoint polling observes the receipt while full access verification is // still in flight. The previous progress response must not mislead users. await act(async () => { client.setQueryData(["github-setup", "draft-1"], { ...fixture.endpoint, setup: { ...fixture.endpoint.setup, webhookVerifiedAt: "2026-10-08T10:31:10Z" }, }); }); await settle(); expect(container.querySelector('[role="status"]')?.textContent).toContain( "GitHub delivery verified. Checking App and repository access", ); expect(container.textContent).not.toContain("Waiting for GitHub"); expect(container.textContent).not.toContain("until the signed ping arrives"); expect(container.textContent).not.toContain("GitHub connected"); expect(githubChatApi.registration).not.toHaveBeenCalled(); }); it("opens a saved credential-entry handoff without starting another App registration", async () => { await render("resume=draft-1&stage=credentials"); expect(container.querySelector("#github-app-id")).not.toBeNull(); expect(container.querySelector("#github-webhook-secret")).not.toBeNull(); expect(githubChatApi.advance).not.toHaveBeenCalled(); expect(githubChatApi.registration).not.toHaveBeenCalled(); }); it("keeps a saved rotation handoff open through generation and reconnect failures", async () => { fixture.endpoint.status = "active"; fixture.endpoint.botExternalId = "1234"; const secret = "a".repeat(64); vi.mocked(githubChatApi.advance).mockResolvedValue({ endpointId: "draft-1", state: "connected" } as any); vi.mocked(chatEndpointsApi.generateSetupSecret).mockRejectedValueOnce(new Error("Temporarily unavailable")).mockResolvedValue({ webhookSecret: secret }); vi.mocked(chatEndpointsApi.setup).mockRejectedValueOnce(new Error("Waiting for the new signed webhook")).mockResolvedValue(fixture.endpoint); await render("resume=draft-1&stage=credentials&reconnect=1&rotateSecret=1"); const reconnect = () => [...container.querySelectorAll("button")].find(button => button.getAttribute("aria-label") === "Reconnect saved App")!; const generate = () => [...container.querySelectorAll("button")].find(button => button.textContent === "Generate webhook secret")!; expect(reconnect().disabled).toBe(true); expect(chatEndpointsApi.generateSetupSecret).not.toHaveBeenCalled(); expect(githubChatApi.advance).not.toHaveBeenCalled(); await act(async () => generate().click()); expect(container.querySelector('[role="alert"]')?.textContent).toContain("Temporarily unavailable"); expect(reconnect().disabled).toBe(true); await act(async () => generate().click()); expect(chatEndpointsApi.generateSetupSecret).toHaveBeenLastCalledWith("draft-1"); expect((container.querySelector("#github-rotation-secret") as HTMLInputElement).value).toBe(secret); expect((container.querySelector("#github-rotation-secret") as HTMLInputElement).type).toBe("password"); await act(async () => (container.querySelector('[aria-label="Copy webhook secret"]') as HTMLButtonElement).click()); expect(copyTextToClipboard).toHaveBeenLastCalledWith(secret); await act(async () => reconnect().click()); expect(container.querySelector('[role="alert"]')?.textContent).toContain("Waiting for the new signed webhook"); expect(reconnect().disabled).toBe(false); await act(async () => reconnect().click()); expect(chatEndpointsApi.setup).toHaveBeenLastCalledWith("draft-1", { action: "reconnect" }); expect(githubChatApi.connectApp).not.toHaveBeenCalled(); expect(chatEndpointsApi.create).not.toHaveBeenCalled(); }); it.each([ { generation: "22222222-2222-4222-8222-222222222222", verified: true, canResume: true }, { generation: "22222222-2222-4222-8222-222222222222", verified: false, canResume: true }, { generation: "11111111-1111-4111-8111-111111111111", verified: true, canResume: false }, ])("resumes only the saved rotation generation without requiring its secret again ($verified, $canResume)", async ({ generation, verified, canResume }) => { fixture.endpoint.status = "attention"; fixture.endpoint.botExternalId = "1234"; fixture.endpoint.setup.credentialRotationId = generation; fixture.endpoint.setup.webhookVerifiedAt = verified ? new Date().toISOString() : null; vi.mocked(chatEndpointsApi.setup).mockResolvedValue(fixture.endpoint); await render("resume=draft-1&stage=credentials&reconnect=1&rotateSecret=1&afterRotationId=11111111-1111-4111-8111-111111111111"); const reconnect = [...container.querySelectorAll("button")].find(button => button.getAttribute("aria-label") === "Reconnect saved App")!; expect(reconnect.disabled).toBe(!canResume); expect(container.querySelector("#github-rotation-secret")).toBeNull(); expect(chatEndpointsApi.generateSetupSecret).not.toHaveBeenCalled(); if (canResume) { await act(async () => reconnect.click()); expect(chatEndpointsApi.setup).toHaveBeenCalledWith("draft-1", { action: "reconnect" }); } }); it("reconnects the same App using vaulted credentials without requiring another paste", async () => { fixture.endpoint.status = "attention"; fixture.endpoint.botExternalId = "1234"; vi.mocked(chatEndpointsApi.setup).mockResolvedValue(fixture.endpoint); await render("resume=draft-1&reconnect=1"); const button = [...container.querySelectorAll("button")].find( (button) => button.getAttribute("aria-label") === "Reconnect saved App", )!; expect(button.disabled).toBe(false); expect(container.querySelector("#github-private-key")).toBeNull(); expect(container.querySelector("#github-webhook-secret")).toBeNull(); await act(async () => { button.click(); }); expect(chatEndpointsApi.setup).toHaveBeenCalledWith("draft-1", { action: "reconnect", }); expect(githubChatApi.connectApp).not.toHaveBeenCalled(); }); it("keeps a failed saved-App repair retryable and explains the credential fallback", async () => { fixture.endpoint.status = "attention"; fixture.endpoint.botExternalId = "1234"; vi.mocked(chatEndpointsApi.setup).mockRejectedValue(new Error("GitHub could not verify the saved private key")); await render("resume=draft-1&reconnect=1"); const button = [...container.querySelectorAll("button")].find(button => button.getAttribute("aria-label") === "Reconnect saved App")!; await act(async () => button.click()); expect(container.querySelector('[role="alert"]')?.textContent).toContain("Could not reconnect the saved App"); expect(button.disabled).toBe(false); expect(container.querySelector("#github-private-key")).toBeNull(); const replace = [...container.querySelectorAll("button")].find(button => button.textContent === "Replace App credentials…")!; await act(async () => replace.click()); expect((container.querySelector("#github-app-id") as HTMLInputElement).value).toBe("1234"); expect(container.textContent).toContain("Credentials → Key pairs → New key"); expect((container.querySelector("#github-private-key") as HTMLInputElement).type).toBe("password"); expect([...container.querySelectorAll("button")].find(button => button.getAttribute("aria-label") === "Verify and reconnect")?.disabled).toBe(true); expect(githubChatApi.advance).not.toHaveBeenCalled(); }); it("does not reuse an old connected result when fresh verification fails", async () => { fixture.endpoint.status = "attention"; fixture.endpoint.botExternalId = "1234"; client.setQueryData(["github-wizard", "draft-1"], { endpointId: "draft-1", state: "connected" }); vi.mocked(chatEndpointsApi.setup).mockResolvedValue(fixture.endpoint); vi.mocked(githubChatApi.advance).mockRejectedValue(new Error("Delivery verification is unavailable")); await render("resume=draft-1&reconnect=1"); await act(async () => [...container.querySelectorAll("button")].find(button => button.getAttribute("aria-label") === "Reconnect saved App")!.click()); expect(container.querySelector("output")?.textContent).toContain("/BOT/apps/chat/connect"); expect(container.textContent).toContain("Delivery verification is unavailable"); expect([...container.querySelectorAll("button")].find(button => button.getAttribute("aria-label") === "Reconnect saved App")?.disabled).toBe(false); }); it.each(["paste", "upload"])("preserves a multiline PEM from %s when replacing credentials", async (source) => { fixture.endpoint.status = "attention"; fixture.endpoint.botExternalId = "1234"; vi.mocked(chatEndpointsApi.setup).mockResolvedValue(fixture.endpoint); await render("resume=draft-1&reconnect=1"); await act(async () => [...container.querySelectorAll("button")].find(button => button.textContent === "Replace App credentials…")!.click()); const key = "-----BEGIN RSA PRIVATE KEY-----\nfixture-only\n-----END RSA PRIVATE KEY-----"; if (source === "paste") { const paste = new Event("paste", { bubbles: true, cancelable: true }); Object.defineProperty(paste, "clipboardData", { value: { getData: () => key } }); await act(async () => container.querySelector("#github-private-key")!.dispatchEvent(paste)); } else { const upload = container.querySelector('input[type="file"]')!; Object.defineProperty(upload, "files", { value: [{ text: async () => key }] }); await act(async () => upload.dispatchEvent(new Event("change", { bubbles: true }))); } expect((container.querySelector("#github-private-key") as HTMLInputElement).value).toBe(""); expect(container.querySelector('[role="status"]')?.textContent).toContain("Private key loaded"); await act(async () => [...container.querySelectorAll("button")].find(button => button.getAttribute("aria-label") === "Verify and reconnect")!.click()); expect(chatEndpointsApi.setup).toHaveBeenCalledWith("draft-1", { action: "reconnect", credentials: { appId: "1234", privateKey: key } }); }); it("treats typing after loading a PEM as a new key instead of editing a flattened PEM", async () => { fixture.endpoint.status = "attention"; fixture.endpoint.botExternalId = "1234"; await render("resume=draft-1&reconnect=1"); await act(async () => [...container.querySelectorAll("button")].find(button => button.textContent === "Replace App credentials…")!.click()); const input = container.querySelector("#github-private-key") as HTMLInputElement; const paste = new Event("paste", { bubbles: true, cancelable: true }); Object.defineProperty(paste, "clipboardData", { value: { getData: () => "-----BEGIN RSA PRIVATE KEY-----\nfixture-only\n-----END RSA PRIVATE KEY-----" } }); await act(async () => input.dispatchEvent(paste)); expect(input.value).toBe(""); await act(async () => { Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")!.set!.call(input, "replacement"); input.dispatchEvent(new Event("input", { bubbles: true })); }); expect(input.value).toBe("replacement"); expect(container.textContent).not.toContain("Private key loaded"); expect([...container.querySelectorAll("button")].find(button => button.getAttribute("aria-label") === "Verify and reconnect")?.disabled).toBe(true); expect(chatEndpointsApi.setup).not.toHaveBeenCalled(); }); async function render(query = "agentId=reviewer") { await act(async () => root.render( } /> } /> Connectors} /> , ), ); await settle(); // Completion mounts Settings and its own queries after the wizard redirect. await settle(); await settle(); } async function click(label: string) { const button = Array.from(document.querySelectorAll("button")).find( (button) => button.textContent === label || button.getAttribute("aria-label") === label, ); expect(button, label).toBeTruthy(); await act(async () => button!.click()); await settle(); } it("binds Cloud enrollment return to the same draft without a duplicated company prefix", async () => { vi.mocked(githubChatApi.advance).mockResolvedValue({ endpointId: "draft-1", state: "enrollment" }); vi.mocked(toolsApi.startCloudConnectorEnrollment).mockResolvedValue({ configured: false, status: "pending", brokerBaseUrl: "https://gateway.example", instanceId: "instance-1", environment: "staging", origins: [] }); const prior = `${window.location.pathname}${window.location.search}`; window.history.replaceState(null, "", "/BOT/apps/chat/connect?provider=github&purpose=chat&resume=draft-1"); try { await render("purpose=chat&resume=draft-1"); await click("Connect Paperclip Cloud"); expect(toolsApi.startCloudConnectorEnrollment).toHaveBeenCalledWith("company-1", undefined, "/apps/chat/connect?provider=github&purpose=chat&resume=draft-1"); } finally { window.history.replaceState(null, "", prior); } }); it("starts with agent selection and saves the low-trust repair before removing its warning", async () => { await render(); expect(container.querySelector("h1")?.textContent).toBe("Choose agent"); expect(container.textContent).not.toContain("permanent"); expect(container.textContent).not.toContain("Copy setup prompt"); await click("Change Reviewer to a low trust agent"); expect(agentsApi.updatePermissions).toHaveBeenCalledWith( "reviewer", expect.objectContaining({ trustPreset: "low_trust_review", canCreateAgents: false, canCreateSkills: false, canAssignTasks: true, }), "company-1", ); expect(container.querySelector('[role="alert"]')).toBeNull(); await click("Continue"); expect(chatEndpointsApi.create).toHaveBeenCalledWith("company-1", { provider: "github", assignedAgentId: "reviewer", }); expect(container.querySelector("h1")?.textContent).toBe("Connect GitHub"); expect(container.querySelector("output:not([for])")?.textContent).toContain( "resume=draft-1", ); }); it("does not offer a new connection while a saved draft cannot be loaded", async () => { vi.mocked(chatEndpointsApi.get).mockRejectedValue(new Error("Not found")); await render("resume=missing-draft&agentId=reviewer"); expect(container.textContent).toContain("Could not load this connection"); expect(container.textContent).not.toContain("Choose agent"); expect(chatEndpointsApi.create).not.toHaveBeenCalled(); }); it("keeps the warning and shows the failure when changing trust fails", async () => { vi.mocked(agentsApi.updatePermissions).mockRejectedValueOnce( new Error("Permission denied"), ); await render(); await click("Change Reviewer to a low trust agent"); expect(container.textContent).toContain("Permission denied"); expect(container.textContent).toContain( "Reviewer is not configured for low-trust review", ); }); it("resumes the assigned draft with only account and App name choices", async () => { await render("resume=draft-1"); expect(container.querySelector("h1")?.textContent).toBe("Connect GitHub"); expect(container.textContent).toContain("My account"); expect(container.textContent).toContain("Another organization"); expect(container.textContent).not.toContain("GitHub will ask you"); expect(container.textContent).not.toContain("Creates your own GitHub App"); expect(container.textContent).not.toContain("Assign setup task"); expect(container.textContent).not.toContain("Copy setup prompt"); expect(container.textContent).not.toContain("Choose setup method"); expect( container.querySelector("input#github-app-name")?.getAttribute("value"), ).toBe("Reviewer"); expect(chatEndpointsApi.create).not.toHaveBeenCalled(); }); it("offers known organizations from repositories and connected bots, without personal owners or duplicates", async () => { vi.mocked(projectsApi.repositoryOptions).mockResolvedValue({ repositories: [ { id: "1", fullName: "acme/api", ownerType: "organization", url: "https://github.com/acme/api", connections: ["GitHub"] }, { id: "2", fullName: "ACME/web", ownerType: "organization", url: "https://github.com/ACME/web", connections: ["GitHub"] }, { id: "3", fullName: "octocat/site", ownerType: "personal", url: "https://github.com/octocat/site", connections: ["GitHub"] }, { id: "4", fullName: "unknown/legacy", url: "https://github.com/unknown/legacy", connections: ["GitHub"] }, ], connectionCount: 1, failedConnectionCount: 0, }); vi.mocked(chatEndpointsApi.list).mockResolvedValue([ { ...fixture.endpoint, id: "connected", status: "active", setup: { github: { ownerType: "organization", ownerLogin: "paperclipai" } } }, { ...fixture.endpoint, id: "unconnected", setup: { github: { ownerType: "organization", ownerLogin: "unfinished" } } }, ]); await render("resume=draft-1"); const select = container.querySelector("#github-owner-type") as HTMLSelectElement; expect([...select.options].map(option => option.textContent)).toEqual([ "My account", "ACME", "paperclipai", "Another organization", ]); await act(async () => { select.value = "organization:paperclipai"; select.dispatchEvent(new Event("change", { bubbles: true })); }); expect(container.querySelector("#github-owner-login")).toBeNull(); await click("Continue to GitHub"); expect(githubChatApi.registration).toHaveBeenCalledWith("draft-1", { name: "Reviewer", ownerType: "organization", ownerLogin: "paperclipai", }); }); it("keeps Another organization editable even when the typed name is already known", async () => { vi.mocked(chatEndpointsApi.list).mockResolvedValue([ { ...fixture.endpoint, status: "active", setup: { github: { ownerType: "organization", ownerLogin: "acme" } } }, ]); await render("resume=draft-1"); const select = container.querySelector("#github-owner-type") as HTMLSelectElement; await act(async () => { select.value = "organization:acme"; select.dispatchEvent(new Event("change", { bubbles: true })); }); await act(async () => { select.value = "organization"; select.dispatchEvent(new Event("change", { bubbles: true })); }); const input = container.querySelector("#github-owner-login") as HTMLInputElement; expect(input.value).toBe(""); await act(async () => { Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")!.set!.call(input, "acme"); input.dispatchEvent(new Event("input", { bubbles: true })); }); expect(container.querySelector("#github-owner-login")).toBe(input); await click("Save & exit"); expect(githubChatApi.saveDraft).toHaveBeenCalledWith("draft-1", { name: "Reviewer", ownerType: "organization", ownerLogin: "acme", }); }); it("preserves the saved organization when known accounts arrive and allows manual setup after lookup failure", async () => { fixture.endpoint.setup.github = { ownerType: "organization", ownerLogin: "saved-org", appName: "Saved App" }; vi.mocked(projectsApi.repositoryOptions).mockRejectedValue(new Error("Unavailable")); vi.mocked(chatEndpointsApi.list).mockResolvedValue([ { ...fixture.endpoint, id: "connected", status: "active" }, ]); await render("resume=draft-1"); expect((container.querySelector("#github-owner-type") as HTMLSelectElement).value).toBe("organization:saved-org"); expect(container.textContent).toContain("Could not load some connected GitHub accounts"); await click("Save & exit"); expect(githubChatApi.saveDraft).toHaveBeenCalledWith("draft-1", { name: "Saved App", ownerType: "organization", ownerLogin: "saved-org", }); }); it("requires no-App-created confirmation before restarting an expired handoff", async () => { vi.mocked(githubChatApi.advance).mockResolvedValue({ endpointId: "draft-1", state: "recovery", restartableRegistrationId: "expired-1", message: "Handoff expired" }); vi.mocked(githubChatApi.restartRegistration).mockResolvedValue({ endpointId: "draft-1", state: "create" }); await render("resume=draft-1"); const button = [...container.querySelectorAll("button")].find(b => b.textContent === "Continue to GitHub")!; expect(button.disabled).toBe(true); expect(githubChatApi.restartRegistration).not.toHaveBeenCalled(); await act(async () => (container.querySelector("#github-app-not-created") as HTMLElement).click()); expect(button.disabled).toBe(false); await click("Continue to GitHub"); expect(githubChatApi.restartRegistration).toHaveBeenCalledWith("draft-1", "expired-1"); expect(chatEndpointsApi.create).not.toHaveBeenCalled(); expect(githubChatApi.registration).not.toHaveBeenCalled(); }); it("does not offer registration restart for an uncertain exchange", async () => { vi.mocked(githubChatApi.advance).mockResolvedValue({ endpointId: "draft-1", state: "recovery", message: "Recover existing App" }); await render("resume=draft-1"); expect(container.querySelector("#github-app-not-created")).toBeNull(); expect(container.textContent).not.toContain("Continue to GitHub"); expect(container.textContent).toContain("Use existing App credentials"); }); it("saves ownership and the suggested name when exiting", async () => { await render("resume=draft-1"); const owner = container.querySelector( "#github-owner-type", ) as HTMLSelectElement; await act(async () => { owner.value = "organization"; owner.dispatchEvent(new Event("change", { bubbles: true })); }); const input = container.querySelector( "#github-owner-login", ) as HTMLInputElement; await act(async () => { Object.getOwnPropertyDescriptor( HTMLInputElement.prototype, "value", )!.set!.call(input, "acme"); input.dispatchEvent(new Event("input", { bubbles: true })); }); await click("Save & exit"); expect(githubChatApi.saveDraft).toHaveBeenCalledWith("draft-1", { name: "Reviewer", ownerType: "organization", ownerLogin: "acme", }); expect(container.querySelector("output:not([for])")?.textContent).toBe("/apps"); }); it("replaces stale cached form defaults when the saved draft refreshes", async () => { await render("resume=draft-1"); expect( (container.querySelector("#github-app-name") as HTMLInputElement).value, ).toBe("Reviewer"); fixture.endpoint = { ...fixture.endpoint, setup: { github: { ownerType: "organization", ownerLogin: "acme", appName: "Saved App", }, }, }; await act(async () => client.invalidateQueries({ queryKey: ["github-setup", "draft-1"] }), ); await settle(); expect( (container.querySelector("#github-owner-type") as HTMLSelectElement) .value, ).toBe("organization"); expect( (container.querySelector("#github-owner-login") as HTMLInputElement) .value, ).toBe("acme"); expect( (container.querySelector("#github-app-name") as HTMLInputElement).value, ).toBe("Saved App"); }); it("submits the manifest directly, preserving the bound registration on retry", async () => { const registration = { registrationUrl: "https://github.com/organizations/acme/settings/apps/new?state=bound", manifest: { name: "Reviewer", public: false }, expiresAt: "2026-10-06T20:00:00Z", }; vi.mocked(githubChatApi.advance).mockResolvedValue({ endpointId: "draft-1", state: "create", registration, }); await render("resume=draft-1"); const form = container.querySelector("form")!; expect(form.method).toBe("post"); expect(form.action).toBe(registration.registrationUrl); expect((form.querySelector('[name="manifest"]') as HTMLInputElement).value) .toBe(JSON.stringify(registration.manifest)); expect(form.querySelector('button[type="submit"]')?.textContent).toBe("Continue to GitHub"); expect((form.querySelector('button[type="button"]') as HTMLButtonElement).textContent).toBe("Save & exit"); expect(githubChatApi.registration).not.toHaveBeenCalled(); expect((container.querySelector("#github-owner-type") as HTMLSelectElement).disabled).toBe(true); expect(() => gitHubAppManifestAction({ ...registration, registrationUrl: "https://evil.example/apps/new" })) .toThrow("invalid registration"); }); it("submits the mounted native form after preparing a fresh registration", async () => { const registration = { registrationUrl: "https://github.com/settings/apps/new?state=bound", manifest: { name: "Reviewer", public: false }, expiresAt: "2026-10-07T20:00:00Z", }; vi.mocked(githubChatApi.registration).mockResolvedValue({ endpointId: "draft-1", state: "create", registration }); const requestSubmit = vi.spyOn(HTMLFormElement.prototype, "requestSubmit").mockImplementation(function (this: HTMLFormElement) { expect(this.isConnected).toBe(true); expect((this.querySelector('[name="manifest"]') as HTMLInputElement).value).toBe(JSON.stringify(registration.manifest)); this.dispatchEvent(new Event("submit", { bubbles: true, cancelable: true })); }); try { await render("resume=draft-1"); await click("Continue to GitHub"); await settle(); expect(requestSubmit).toHaveBeenCalledTimes(1); expect(githubChatApi.registration).toHaveBeenCalledTimes(1); } finally { requestSubmit.mockRestore(); } }); it("shows passive installation progress without a duplicate repository picker or refresh gate", async () => { vi.mocked(githubChatApi.advance).mockResolvedValue({ endpointId: "draft-1", state: "install", installationUrl: "https://github.com/apps/reviewer/installations/new", }); await render("resume=draft-1"); expect(container.textContent).toContain("GitHub"); expect(container.textContent).not.toContain("I’ve installed it"); expect(container.textContent).not.toContain("Refresh repositories"); expect(container.textContent).not.toContain("Prepare registration"); expect( container.querySelector( 'a[href="https://github.com/apps/reviewer/installations/new"]', ), ).toBeTruthy(); }); it("requires an explicit identity confirmation only for an unlinked account", async () => { vi.mocked(githubChatApi.advance).mockResolvedValue({ endpointId: "draft-1", state: "identity", identity: { githubUserId: "42", login: "octocat", avatarUrl: null }, }); vi.mocked(githubChatApi.confirmIdentity).mockResolvedValue({ endpointId: "draft-1", state: "connected", }); await render("resume=draft-1"); await click("Confirm my account"); expect(githubChatApi.confirmIdentity).toHaveBeenCalledWith("draft-1", "42"); expect(githubChatApi.startIdentity).not.toHaveBeenCalled(); }); it("lets a member add a personal connection without manager setup APIs", async () => { vi.mocked(githubChatApi.advance).mockRejectedValue( new Error("Manager permission required"), ); await render("resume=draft-1&stage=identity"); expect( container.querySelector('a[href="/apps/connect?source=github"]'), ).toBeTruthy(); expect(container.textContent).not.toContain("Manager permission required"); expect(githubChatApi.advance).not.toHaveBeenCalled(); expect(githubChatApi.startIdentity).not.toHaveBeenCalled(); await click("Save & exit"); expect(githubChatApi.saveDraft).not.toHaveBeenCalled(); }); it("links a member's verified personal account without advancing bot setup", async () => { vi.mocked(githubChatApi.personalConnections).mockResolvedValue([ { connectionId: "personal-1", name: "My GitHub", login: "octocat", status: "active", enabled: true, }, ]); vi.mocked(githubChatApi.identity).mockResolvedValue({ connectionId: "personal-1", githubUserId: "42", login: "octocat", avatarUrl: null, }); await render("resume=draft-1&stage=identity"); const account = container.querySelector( "#github-personal-account", ) as HTMLSelectElement; await act(async () => { account.value = "personal-1"; account.dispatchEvent(new Event("change", { bubbles: true })); }); await click("Check my account"); expect(githubChatApi.identity).toHaveBeenLastCalledWith( "draft-1", "personal-1", undefined, ); await click("Confirm my account"); expect(githubChatApi.identity).toHaveBeenLastCalledWith( "draft-1", "personal-1", "42", ); expect(container.textContent).toContain( "You can now mention this bot on GitHub", ); expect(githubChatApi.advance).not.toHaveBeenCalled(); expect(githubChatApi.startIdentity).not.toHaveBeenCalled(); expect(githubChatApi.confirmIdentity).not.toHaveBeenCalled(); }); it("completes automatically while reporting runtime readiness separately", async () => { fixture.endpoint = { ...fixture.endpoint, status: "active", setup: { step: "complete" }, botLabel: "Actual GitHub Name", botUsername: "actual-agent[bot]", resources: [{ enabled: true, label: "acme/repo" }], }; vi.mocked(githubChatApi.advance).mockResolvedValue({ endpointId: "draft-1", state: "connected", runtimeChecks: [ { key: "runtime", label: "Runtime", ok: false, detail: "Configure a runtime before the first review.", }, ], }); await render("resume=draft-1"); expect(document.querySelector('[role="dialog"] h2')?.textContent).toBe("GitHub connected"); expect(document.body.textContent).toContain("Actual GitHub Name"); expect(document.body.textContent).toContain("GitHub App name and logo"); expect(document.body.textContent).toContain("1 repository enabled"); expect(container.querySelector("output:not([for])")?.textContent).toBe("/apps/chat/draft-1/settings"); expect(document.querySelector('[role="dialog"] details')?.hasAttribute("open")).toBe(false); expect(document.body.textContent).toContain("Before your first review"); expect(document.body.textContent).toContain("Configure a runtime"); expect(document.body.textContent).not.toContain("Finish"); await click("Copy mention"); expect(copyTextToClipboard).toHaveBeenCalledWith( "@actual-agent review this pull request", ); }); it.each(["Done", "Close", "Escape"])("dismisses with %s onto the same mounted Settings page", async (action) => { fixture.endpoint = { ...fixture.endpoint, status: "active", setup: { step: "complete" } }; vi.mocked(githubChatApi.advance).mockResolvedValue({ endpointId: "draft-1", state: "connected" }); await render("resume=draft-1"); const settings = container.querySelector("textarea"); expect(settings).not.toBeNull(); expect(document.querySelector('[role="dialog"]')).not.toBeNull(); if (action === "Escape") { await act(async () => { document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true })); }); await settle(); } else await click(action); expect(document.querySelector('[role="dialog"]')).toBeNull(); expect(container.querySelector("output:not([for])")?.textContent).toBe("/apps/chat/draft-1/settings"); expect(container.querySelector("textarea")).toBe(settings); expect(container.querySelector("h1")?.textContent).toBe("Reviewer"); expect(document.activeElement).toBe(container.querySelector("h1")); expect(githubChatApi.saveDraft).not.toHaveBeenCalled(); expect(chatEndpointsApi.create).not.toHaveBeenCalled(); }); it("reports copy failure without undoing the completed connection", async () => { fixture.endpoint = { ...fixture.endpoint, status: "active", setup: { step: "complete" }, botUsername: "actual-agent[bot]" }; vi.mocked(githubChatApi.advance).mockResolvedValue({ endpointId: "draft-1", state: "connected" }); vi.mocked(copyTextToClipboard).mockRejectedValueOnce(new Error("Clipboard unavailable")); await render("resume=draft-1"); await click("Copy mention"); expect(document.body.textContent).toContain("Copy failed"); expect(document.querySelector('[role="dialog"] h2')?.textContent).toBe("GitHub connected"); expect(githubChatApi.registration).not.toHaveBeenCalled(); }); it("shows a useful next step when a connected bot has no enabled repositories", async () => { fixture.endpoint = { ...fixture.endpoint, status: "active", setup: { step: "complete" } }; vi.mocked(githubChatApi.advance).mockResolvedValue({ endpointId: "draft-1", state: "connected" }); vi.mocked(githubChatApi.repositories).mockResolvedValue({ items: [], nextOffset: null, totalCount: 2, enabledCount: 0, availableCount: 2, }); await render("resume=draft-1"); expect(document.body.textContent).toContain("No repositories enabled"); expect(document.body.textContent).toContain("Enable a repository in Access to try your bot"); expect(document.querySelector('[role="dialog"] a[href="/apps/chat/draft-1/access"]')).not.toBeNull(); }); it("does not flash App creation controls while checking a resumed connection", async () => { fixture.endpoint = { ...fixture.endpoint, status: "active", setup: { step: "complete" }, botUsername: "actual-agent[bot]" }; vi.mocked(githubChatApi.advance).mockReturnValue(new Promise(() => {})); await render("resume=draft-1"); expect(container.textContent).toContain("Checking your GitHub connection"); expect(container.querySelector("#github-app-name")).toBeNull(); expect(container.textContent).not.toContain("I already have an App"); expect(container.textContent).not.toContain("GitHub connected"); }); });