import { afterAll, afterEach, beforeAll, beforeEach, expect, mock, test } from "bun:test"; import { GlobalRegistrator } from "@happy-dom/global-registrator"; import { act } from "react"; import { createRoot, type Root } from "react-dom/client"; import { SessionConnectorsMenuBody } from "./session-connectors-menu-body"; import { useState } from "react"; import type { CapabilityCatalogItem, ConnectionMetadata } from "@opengeni/sdk"; import type { OpenGeniBrowserClient } from "@opengeni/sdk/browser"; import type { SessionToolSelection } from "./pickers"; import { useConnectionAccounts } from "./capabilities/use-connection-accounts"; import { getComposerSendBlocker } from "../lib/composer-send-blocking"; import { connectionAccountLabel } from "./capabilities/connection-account-picker"; import type { ConnectionAccountChoices } from "./capabilities/session-connection-accounts"; let container: HTMLDivElement; let root: Root; beforeAll(() => { GlobalRegistrator.register(); ( globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean } ).IS_REACT_ACT_ENVIRONMENT = true; }); beforeEach(() => { container = document.createElement("div"); document.body.append(container); root = createRoot(container); }); afterEach(async () => { await act(async () => root.unmount()); container.remove(); }); afterAll(() => GlobalRegistrator.unregister()); for (const status of ["connect", "reconnect", "unavailable"] as const) { test(`Customize can deselect a ${status} connector with no accounts and unblock send without reconnecting`, async () => { const recover = mock(); const client = { listOwnConnectionAccounts: async () => [], } as unknown as OpenGeniBrowserClient; const catalog = [ { enabled: true, name: "Mail", runtime: { mcpServerId: "mail" }, connectionRef: { providerDomain: "example.com", kind: "oauth2", subjectScope: "subject" }, }, ] as CapabilityCatalogItem[]; let current: SessionToolSelection; let blocked: ReturnType; function Preview() { const [selection, setSelection] = useState({ mcpServerIds: new Set(["mail", "files", "hidden"]), firstPartyToolIds: new Set(["session_get"]), }); const [customizing, setCustomizing] = useState(false); const accountState = useConnectionAccounts( client, { id: "session", workspaceId: "workspace", selectedIds: [...selection.mcpServerIds], }, catalog, true, ); current = selection; blocked = getComposerSendBlocker({ uploadPending: false, repositoryError: null, policyValid: true, personalDecision: accountState.requiresAccountChoice, personalLoading: accountState.loading || accountState.error !== null, }); return ( <> ); } await act(async () => root.render()); expect(blocked!).toBeNull(); const send = [...container.querySelectorAll("button")].find( (button) => button.textContent === "Send", )!; expect(send.disabled).toBe(false); await act(async () => container.querySelector('[aria-label="Customize connectors"]')!.click(), ); const toggle = container.querySelector( '[role="switch"][aria-label="Mail"]', )!; expect(toggle?.getAttribute("aria-checked")).toBe("true"); await act(async () => toggle.click()); expect(current!.mcpServerIds).toEqual(new Set(["files", "hidden"])); expect(current!.firstPartyToolIds).toEqual(new Set(["session_get"])); expect(recover).not.toHaveBeenCalled(); expect(blocked!).toBeNull(); expect(send.disabled).toBe(false); const repairLabel = status === "connect" ? "Connect your Mail account" : status === "reconnect" ? "Reconnect Mail" : "Mail unavailable"; const repair = container.querySelector(`[aria-label="${repairLabel}"]`)!; expect(repair.hasAttribute("aria-checked")).toBe(false); await act(async () => repair.click()); expect(recover).toHaveBeenCalledWith("mail"); expect(current!.mcpServerIds.has("mail")).toBe(false); expect(send.disabled).toBe(false); }); } test("missing personal accounts offer setup without toggling the session selection", async () => { const recover = mock(); const change = mock(); await act(async () => root.render( , ), ); const setup = container.querySelector( 'button[aria-label="Connect your Calendar account"]', )!; expect(container.textContent).toContain("No connected account"); expect(setup.hasAttribute("aria-checked")).toBe(false); expect(container.querySelector('button[aria-label="Reconnect Mail"]')).not.toBeNull(); expect(container.textContent).toContain("Status unavailable"); await act(async () => setup.click()); expect(recover).toHaveBeenCalledWith("personal"); expect(change).not.toHaveBeenCalled(); }); const accounts = [ { id: "personal-id", subjectId: "me", metadata: { email: "alex@example.com" } }, { id: "workspace-id", subjectId: null, metadata: { team: { name: "Support team" } } }, ] as unknown as ConnectionMetadata[]; // Identity and ownership identify an account; formatter punctuation and fallback prefixes do not. function accountSwitch(identity: string, ownership: "Only me" | "This workspace") { const matches = [...container.querySelectorAll('[role="switch"]')].filter( (button) => { const label = button.getAttribute("aria-label") ?? ""; return label.includes(identity) && label.endsWith(`, ${ownership}`); }, ); expect(matches).toHaveLength(1); const button = matches[0]!; expect(button.textContent).toContain(identity); expect(button.textContent).toContain(ownership); for (const account of accounts) { expect(button.getAttribute("aria-label")).not.toContain(account.id); expect(button.textContent).not.toContain(account.id); } return button; } test("connector settings attach multiple readable personal/workspace accounts without closing", async () => { const reconnect = mock(); let chosen!: ConnectionAccountChoices; function Preview() { const [choices, setChoices] = useState({}); const [selection, setSelection] = useState({ mcpServerIds: new Set(["mail"]), firstPartyToolIds: new Set(), }); chosen = choices; return ( setChoices({ [id]: ids }), }} /> ); } await act(async () => root.render()); expect(container.textContent).not.toContain("alex@example.com"); await act(async () => container.querySelector('[aria-label="Mail account settings"]')!.click(), ); const personal = accountSwitch("alex@example.com", "Only me"); const workspace = accountSwitch("Support team", "This workspace"); expect(personal.getAttribute("aria-checked")).toBe("true"); expect(workspace.getAttribute("aria-checked")).toBe("true"); await act(async () => personal.click()); expect(chosen.mail).toEqual(["workspace-id"]); expect(personal.getAttribute("aria-checked")).toBe("false"); expect(workspace.getAttribute("aria-checked")).toBe("true"); await act(async () => workspace.click()); expect(chosen.mail).toEqual([]); expect(container.textContent).toContain("No accounts selected."); await act(async () => personal.click()); expect(chosen.mail).toEqual(["personal-id"]); expect(personal.getAttribute("aria-checked")).toBe("true"); expect(workspace.getAttribute("aria-checked")).toBe("false"); await act(async () => container.querySelector('[aria-label="Back to connectors"]')!.click(), ); expect(container.textContent).not.toContain("alex@example.com"); await act(async () => container.querySelector('[aria-label="Mail account settings"]')!.click(), ); expect(container.textContent).toContain("Connected accounts"); expect(accountSwitch("alex@example.com", "Only me").getAttribute("aria-checked")).toBe("true"); expect(accountSwitch("Support team", "This workspace").getAttribute("aria-checked")).toBe( "false", ); expect(container.textContent).not.toContain("Connect another account"); // Healthy chat settings contain only navigation and account attachment controls. expect(container.querySelectorAll("button")).toHaveLength(3); expect(container.querySelectorAll('[role="switch"]')).toHaveLength(2); expect(reconnect).not.toHaveBeenCalled(); }); test("revoked account access shows ask-admin guidance without Retry in both composer menu views", async () => { let denied = false; const client = { listOwnConnectionAccounts: async () => { if (denied) throw { status: 403 }; return [ { id: "personal-id", subjectId: "me", authorityId: "authority", status: "active", providerDomain: "example.com", metadata: { email: "alex@example.com" }, }, ] as unknown as ConnectionMetadata[]; }, } as unknown as OpenGeniBrowserClient; const catalog = [ { enabled: true, name: "Mail", runtime: { mcpServerId: "mail" }, connectionRef: { providerDomain: "example.com", subjectScope: "subject" }, }, ] as CapabilityCatalogItem[]; let refreshAccounts!: () => Promise; function Preview() { const accountState = useConnectionAccounts( client, { id: "session", workspaceId: "workspace", selectedIds: ["mail"] }, catalog, true, ); refreshAccounts = accountState.refresh; return ( {}} accountControls={{ groups: accountState.availableAccountGroups, choices: accountState.accountChoices, onChoose: accountState.selectAccount, error: accountState.error, accessDenied: accountState.accessDenied, onRefresh: () => void accountState.refresh(), }} /> ); } await act(async () => root.render()); expect(container.querySelector('[aria-label="Mail account settings"]')).not.toBeNull(); await act(async () => container.querySelector('[aria-label="Mail account settings"]')!.click(), ); denied = true; await act(async () => refreshAccounts()); expect(container.querySelector('[role="alert"]')?.textContent).toContain( "Ask a workspace admin for connection access", ); expect(container.textContent).not.toContain("alex@example.com"); expect(container.textContent).not.toContain("Retry accounts"); await act(async () => container.querySelector('[aria-label="Back to connectors"]')!.click(), ); expect(container.querySelector('[role="alert"]')?.textContent).toContain( "Ask a workspace admin for connection access", ); expect(container.textContent).not.toContain("Retry accounts"); }); test("live grant removal hides open account labels and choices; Send remains available without a native connector", async () => { const client = { listOwnConnectionAccounts: async () => [ { id: "personal-id", subjectId: "me", authorityId: "authority", status: "active", providerDomain: "example.com", metadata: { email: "alex@example.com" }, }, ], } as unknown as OpenGeniBrowserClient; const catalog = [ { enabled: true, name: "Mail", runtime: { mcpServerId: "mail" }, connectionRef: { providerDomain: "example.com", subjectScope: "subject" }, }, ] as CapabilityCatalogItem[]; let canRead = true; let choose!: (serverId: string, ids: string[]) => void; function Preview() { const [customizing, setCustomizing] = useState(false); const [selection, setSelection] = useState({ mcpServerIds: new Set(["mail"]), firstPartyToolIds: new Set(), }); const accountState = useConnectionAccounts( client, { id: "session", workspaceId: "workspace", selectedIds: [...selection.mcpServerIds] }, catalog, canRead, ); choose = accountState.selectAccount; const blocked = getComposerSendBlocker({ uploadPending: false, repositoryError: null, policyValid: true, personalDecision: accountState.requiresAccountChoice, personalLoading: accountState.loading || accountState.error !== null, }); return ( <> ); } await act(async () => root.render()); await act(async () => container.querySelector('[aria-label="Mail account settings"]')!.click(), ); expect(container.textContent).toContain("alex@example.com"); await act(async () => choose("mail", ["personal-id"])); canRead = false; await act(async () => root.render()); expect(container.textContent).not.toContain("alex@example.com"); expect(container.querySelector('[role="alert"]')?.textContent).toContain("Ask a workspace admin"); const send = [...container.querySelectorAll("button")].find( (button) => button.textContent === "Send", )!; expect(send.disabled).toBe(true); await act(async () => container.querySelector('[aria-label="Back to connectors"]')!.click(), ); await act(async () => container.querySelector('[aria-label="Customize connectors"]')!.click(), ); await act(async () => container.querySelector('[role="switch"][aria-label="Mail"]')!.click(), ); expect(send.disabled).toBe(false); }); test("transient account failure still offers Retry in the composer menu", async () => { const retry = mock(); await act(async () => root.render( {}} accountControls={{ groups: [], choices: {}, onChoose: () => {}, error: "Connection accounts could not be checked.", onRefresh: retry, }} />, ), ); await act(async () => [...container.querySelectorAll("button")] .find((button) => button.textContent === "Retry accounts")! .click(), ); expect(retry).toHaveBeenCalledTimes(1); }); test("account labels use readable metadata and never fall back to a raw connection ID", () => { for (const metadata of [{ email: "Label" }, { displayName: "Label" }, { accountName: "Label" }]) { const label = connectionAccountLabel({ ...accounts[0]!, metadata }, "Mail account 1"); expect(label).toBe("Label"); expect(label).not.toContain(accounts[0]!.id); } // Workspace metadata supplements identity; it must not replace the account fallback. for (const metadata of [ { teamName: "Label" }, { workspaceName: "Label" }, { team: { name: "Label" } }, { workspace: { name: "Label" } }, ]) { const label = connectionAccountLabel({ ...accounts[1]!, metadata }, "Mail account 2"); expect(label).toContain("Mail account 2"); expect(label).toContain("Label"); expect(label).not.toContain(accounts[1]!.id); } const combined = connectionAccountLabel( { ...accounts[0]!, metadata: { email: "alex@example.com", teamName: "Support team" } }, "Mail account 1", ); expect(combined).toContain("alex@example.com"); expect(combined).toContain("Support team"); expect(combined).not.toContain(accounts[0]!.id); for (const account of accounts) { const label = connectionAccountLabel({ ...account, metadata: {} }, "Mail account 1"); expect(label).toBe("Mail account 1"); expect(label).not.toContain(account.id); } }); test("last account off disables only its connector; reenable restores accounts and ordinary off/on preserves narrowing", async () => { let current!: SessionToolSelection; let chosen!: ConnectionAccountChoices; function Preview() { const [selection, setSelection] = useState({ mcpServerIds: new Set(["mail", "files", "hidden"]), firstPartyToolIds: new Set(["session_get"]), }); const [choices, setChoices] = useState({}); const [customizing, setCustomizing] = useState(false); current = selection; chosen = choices; return ( setChoices((previous) => ({ ...previous, [id]: ids })), }} /> ); } const click = async (label: string) => act(async () => container.querySelector(`[aria-label="${label}"]`)!.click()); await act(async () => root.render()); await click("Mail account settings"); await act(async () => accountSwitch("alex@example.com", "Only me").click()); await act(async () => accountSwitch("Support team", "This workspace").click()); expect(current.mcpServerIds).toEqual(new Set(["files", "hidden"])); expect(current.firstPartyToolIds).toEqual(new Set(["session_get"])); expect(chosen.mail).toEqual([]); await click("Back to connectors"); const settings = container.querySelector('[aria-label="Mail account settings"]')!; const toggle = container.querySelector('[aria-label="Mail"]')!; expect(settings.compareDocumentPosition(toggle) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); await click("Mail"); expect(current.mcpServerIds.has("mail")).toBe(true); expect(chosen.mail).toEqual(accounts.map((account) => account.id)); await click("Mail account settings"); expect(accountSwitch("alex@example.com", "Only me").getAttribute("aria-checked")).toBe("true"); expect(accountSwitch("Support team", "This workspace").getAttribute("aria-checked")).toBe("true"); await act(async () => accountSwitch("alex@example.com", "Only me").click()); await click("Back to connectors"); await click("Mail"); expect(container.querySelector('[aria-label="Mail account settings"]')).not.toBeNull(); await click("Mail"); expect(chosen.mail).toEqual(["workspace-id"]); expect(current.mcpServerIds).toEqual(new Set(["mail", "files", "hidden"])); expect(current.firstPartyToolIds).toEqual(new Set(["session_get"])); }); test("settings remains reachable to remove the last disconnected account", async () => { const change = mock(); const choose = mock(); await act(async () => root.render( , ), ); await act(async () => container.querySelector('[aria-label="Mail account settings"]')!.click(), ); await act(async () => [...container.querySelectorAll("button")] .find((button) => button.textContent === "Remove disconnected account")! .click(), ); expect(choose).toHaveBeenCalledWith("mail", []); expect(change.mock.calls[0]![0].mcpServerIds.size).toBe(0); });