// @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 (
);
}
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");
});
});