mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-07 07:55:47 +01:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - Slack setup connects a saved agent draft to a workspace. > - Workspace authorization can fail before the agent's Slack app exists. > - The error requested reconnection while its primary action said Continue. > - Older grants could also make retry provision instead of reopening authorization. > - This change names the recovery action and keeps it on the authorization path. > - People can reconnect from the same setup page and resume the saved agent. ## Linked Issues or Issue Description Refs: #18722. **What happened?** A managed Slack draft showed “Reconnect your Slack workspace to continue setting up this agent.” Its primary action was Continue. The reconnect label depended on a registration that might not exist, and a saved error or failed explicit authorization could be ignored when an older grant remained available. **Expected behavior:** Offer one enabled Reconnect Slack workspace action that opens ordinary Slack authorization and resumes the saved setup. **Steps to reproduce:** Resume a managed Slack draft before app creation with a saved manager reauthorization error, or fail an explicit workspace authorization request. Inspect the recovery action with zero, one or multiple older workspace grants. ## What Changed - Show Reconnect Slack workspace for saved and local authorization failures before app creation and for existing-app recovery. - Honor the saved manager error and retain explicit authorization intent, so retry authorizes rather than provisioning with an older grant. - Hide irrelevant workspace controls during recovery and allow authorization before choosing an old grant. Use one recovery action. - Cover zero, one and two older grants in component regressions. Update the existing browser test to use the explicit action; add production-wizard desktop/mobile recovery previews and provider guidance. The Slack provider UI owns this change. It uses the existing authorization endpoint and shared footer; API contracts and provider permissions are unchanged. Maps are unchanged. ## Verification - Final head: `02234bd1afc61d404841a9fc2b7e457b0dc021bf`. Full CI [41715544834](https://github.com/paperclipai/paperclip/actions/runs/41715544834) passed on attempt 2, including all browser, server/chat, Runner, typecheck, build, policy and canary checks. Greptile and Ponytail both give this exact head 5/5; all review threads are resolved. - All 20 managed setup tests and 276 related chat UI tests across 19 files passed. All 9 Slack provider browser cases passed on the final source in 1.3 minutes using Node 24.22.2 and unchanged Playwright configuration. Provider responses are mocked in those cases. - Full repository typecheck and build passed earlier; final UI typecheck/build, token gates and Storybook catalog checks passed after the source refinement. All 30 map/catalog Node checks passed. Catalog/map checks are structural. The full local server Vitest suite was not repeated for this UI-only change; full CI covered it. - Production-wizard browser previews covered saved recovery on desktop and at 320 × 568, older grants, and failed explicit authorization. On the final source, the visible retry with older grants returned to an error-free workspace chooser, where Continue requires a choice. Selecting a workspace and continuing was also exercised through the message step during the preceding refinement. All provider actions in these previews are simulated. - The existing recovery path was independently exercised live in staging through ordinary controls: Continue → Slack Allow → Success → Open Slack → real agent DM reply → Done → full reload. The new action label still needs deployed acceptance. No live grant was revoked or duplicate bot created to force the error. Failures retained through acceptance: - New regressions failed on the earlier sources for missing recovery labels, ignored saved errors with active grants, a duplicate action, disabled multi-workspace recovery, and lost explicit authorization intent. The final tests require enabled recovery, authorization navigation and no provisioning on that action. - The first preview assertion raced workspace-options loading; it now waits for the same enabled condition. A later multi-workspace return assertion incorrectly expected the message step; it now checks the required workspace choice and disabled Continue before selection. The fixture's authorization flag is reset by a normal full reload. An unsupported Testing Library `exact` option was caught by typecheck and replaced with an anchored name regex. Failure screenshots remain preserved. - Earlier CI [41707550300](https://github.com/paperclipai/paperclip/actions/runs/41707550300) found the existing browser test still clicking Continue and an npm canary-version collision. The locator was updated without removing authorization-count/redirect assertions; subsequent packaging checks passed. CI [41709833527](https://github.com/paperclipai/paperclip/actions/runs/41709833527) had runner shutdown/lost-communication interruptions; those earlier successes were not used to qualify the final head. - Final-head attempt 1 had a native Runner shutdown while unpacking its cache, plus a distinct Cursor Cloud Stop assertion failure in the package lane (4890 passed, 10 skipped). The Cursor Cloud case passed unchanged in isolation, with its source subtree matching the CI merge tree; the CI cause is not established. Both full Runner lanes then passed on retry of the same reviewed commit, with successful checks retained. - One local browser invocation timed out during server startup on Node 26 before assertions. The first Node 24 wrapper invocation failed a nested-npx package selector before assertions. Direct Playwright execution with that wrapper variable cleared and unchanged timeouts passed the final 9-case suite. - Greptile flagged a routine regression paragraph outside feature-map scope; it was removed. Ponytail then found the active-grant mismatch; it was reproduced and fixed at the authorization decision. Both reviewers now report 5/5. ## Risks Saved reauthorization errors and retries of explicit authorization requests now reopen existing Slack consent even when older grants remain. Provider availability or browser consent can still prevent reconnection. This change does not establish why the original workspace grant became unusable; simulated previews are not live Slack proof. ## Model Used OpenAI Codex, GPT-6 family, with reasoning, repository editing, browser use and command execution. The runtime does not expose a more precise model ID or context-window size. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
1187 lines
60 KiB
TypeScript
1187 lines
60 KiB
TypeScript
import { oauthCallbackInterstitialHtml } from "../../server/src/lib/oauth-browser-return";
|
||
import { createServer } from "node:http";
|
||
import { readFile } from "node:fs/promises";
|
||
import { expect, test, type Page, type TestInfo } from "@playwright/test";
|
||
|
||
import {
|
||
expectMinimumProviderSetup,
|
||
expectProviderTryInstructions,
|
||
expectSetupRail,
|
||
fillProviderSetup,
|
||
installChatControlPlaneMock,
|
||
seedCompanyAndAgent,
|
||
selectMaya,
|
||
PROVIDER_LIFECYCLE_COPY,
|
||
PROVIDERS,
|
||
expectedCredentialKeys,
|
||
type ProviderCase,
|
||
type Seed,
|
||
type ChatMock,
|
||
GITHUB_PRIVATE_KEY_PASTE_FIXTURE,
|
||
} from "./chat-adapters-ui.shared";
|
||
|
||
async function findConnectorBySearch(page: Page, query: string, slug: string) {
|
||
const search = page.getByRole("searchbox", { name: "Search connectors" });
|
||
await expect(search).toBeVisible();
|
||
await search.fill(query);
|
||
const connector = page.locator(`[role="listitem"][data-app-slug="${slug}"]`);
|
||
await expect(connector).toBeVisible({ timeout: 30_000 });
|
||
return connector;
|
||
}
|
||
|
||
async function exerciseGitHubReviewSetup(page: Page, mock: ChatMock, seed: Seed, provider: ProviderCase, testInfo: TestInfo) {
|
||
await expect(page.getByRole("heading", { name: "Choose agent", exact: true })).toBeVisible();
|
||
await page.getByRole("button", { name: "Select agent…", exact: true }).click();
|
||
await page.getByRole("button", { name: "Select Maya", exact: true }).click();
|
||
await expect(page.getByText("Maya is not configured for low-trust review")).toBeVisible();
|
||
await expect(page.getByRole("link", { name: "Learn about low-trust agents" })).toHaveAttribute("href", /trust-and-low-trust-review/);
|
||
await testInfo.attach("Choose agent — inline low-trust warning", { body: await page.screenshot(), contentType: "image/png" });
|
||
await page.getByRole("button", { name: "Change Maya to a low trust agent" }).click();
|
||
await expect(page.getByText("Maya is not configured for low-trust review")).toHaveCount(0);
|
||
await page.getByRole("button", { name: "Continue", exact: true }).click();
|
||
await expect.poll(() => mock.createdWithAgentId).toBe(seed.agentId);
|
||
await expect(page.getByRole("heading", { name: "Connect GitHub", exact: true })).toBeVisible();
|
||
await expect(page.getByLabel("GitHub account")).toHaveValue("personal");
|
||
await expect(page.getByLabel("App name", { exact: true })).toHaveValue("Maya");
|
||
await page.getByLabel("GitHub account").selectOption("organization");
|
||
await page.getByLabel("Organization", { exact: true }).fill("paperclip-ai");
|
||
await testInfo.attach("Connect GitHub — organization", { body: await page.screenshot(), contentType: "image/png" });
|
||
await page.getByRole("button", { name: "Save & exit", exact: true }).click();
|
||
await page.goto(`/${seed.prefix}/apps/chat/connect?provider=github&purpose=chat&resume=endpoint-github`);
|
||
await expect(page.getByLabel("GitHub account")).toHaveValue("organization");
|
||
await expect(page.getByLabel("Organization", { exact: true })).toHaveValue("paperclip-ai");
|
||
await expect(page.getByRole("button", { name: "Select agent…", exact: true })).toHaveCount(0);
|
||
// Existing-App recovery stays available and preserves entered credentials after failure.
|
||
await page.getByRole("button", { name: "I already have an App" }).click();
|
||
await page.getByLabel("App ID", { exact: true }).fill("123456");
|
||
await page.getByLabel("Private key", { exact: true }).evaluate((element, privateKey) => {
|
||
const clipboardData = new DataTransfer();
|
||
clipboardData.setData("text/plain", privateKey);
|
||
element.dispatchEvent(new ClipboardEvent("paste", { bubbles: true, cancelable: true, clipboardData }));
|
||
}, GITHUB_PRIVATE_KEY_PASTE_FIXTURE);
|
||
await page.getByLabel("Webhook secret", { exact: true }).fill("github-webhook-secret");
|
||
const rejectedAppResponse = page.waitForResponse((response) =>
|
||
response.url().endsWith("/api/chat-endpoints/endpoint-github/github/app") && response.request().method() === "POST",
|
||
);
|
||
await page.getByRole("button", { name: "Connect existing App", exact: true }).click();
|
||
expect((await rejectedAppResponse).request().postDataJSON().privateKey).toBe(GITHUB_PRIVATE_KEY_PASTE_FIXTURE);
|
||
await expect(page.getByRole("alert")).toContainText("GitHub rejected the supplied App credentials.");
|
||
await expect(page.getByLabel("Private key", { exact: true })).toHaveValue("");
|
||
await expect(page.getByText("Private key loaded. Paste or upload another key to replace it.", { exact: true })).toBeVisible();
|
||
const retriedAppResponse = page.waitForResponse((response) =>
|
||
response.url().endsWith("/api/chat-endpoints/endpoint-github/github/app") && response.request().method() === "POST",
|
||
);
|
||
await page.getByRole("button", { name: "Connect existing App", exact: true }).click();
|
||
expect((await retriedAppResponse).request().postDataJSON().privateKey).toBe(GITHUB_PRIVATE_KEY_PASTE_FIXTURE);
|
||
await expect(page.getByRole("heading", { name: "Connect your account", exact: true })).toBeVisible();
|
||
await page.getByLabel("Existing GitHub connection").selectOption("personal-github");
|
||
await expect(page.getByRole("button", { name: "Check my account" })).toBeVisible();
|
||
await page.getByRole("button", { name: "Check my account" }).click();
|
||
expect(mock.githubIdentityConfirmed).toBe(false);
|
||
await page.getByRole("button", { name: "Confirm my account" }).click();
|
||
await expect.poll(() => mock.githubIdentityConfirmed).toBe(true);
|
||
await expect(page.getByRole("heading", { name: "GitHub connected", exact: true })).toBeVisible();
|
||
await expect(page.getByText("1 repository enabled", { exact: true })).toBeVisible();
|
||
await expect(page.getByRole("button", { name: /Finish|Verify connection|Refresh access|Save repositories/ })).toHaveCount(0);
|
||
await page.getByRole("dialog").getByRole("button", { name: "Done", exact: true }).click();
|
||
await expect(page).toHaveURL(/\/apps\/chat\/endpoint-github\/settings$/);
|
||
const nav = page.getByRole("navigation", { name: "Chat connection" });
|
||
for (const tab of ["Settings", "Access", "Reviews", "Conversations", "Activity"]) {
|
||
await expect(nav.getByRole("link", { name: tab, exact: true })).toBeVisible();
|
||
}
|
||
await nav.getByRole("link", { name: "Access", exact: true }).click();
|
||
await expect(page.getByRole("heading", { name: "People", exact: true })).toBeVisible();
|
||
await nav.getByRole("link", { name: "Reviews", exact: true }).click();
|
||
await expect(page.getByText("No reviews yet", { exact: true })).toBeVisible();
|
||
await nav.getByRole("link", { name: "Conversations", exact: true }).click();
|
||
await expect(page.getByRole("list", { name: "Conversations", exact: true })).toBeVisible();
|
||
await nav.getByRole("link", { name: "Activity", exact: true }).click();
|
||
await expect(page.getByRole("heading", { name: "Connection activity", exact: true })).toBeVisible();
|
||
await page.getByText("Connection health and controls", { exact: true }).click();
|
||
await page.getByRole("button", { name: "Pause", exact: true }).click();
|
||
await page.getByRole("button", { name: "Resume", exact: true }).click();
|
||
await expect.poll(() => mock.lifecycleActions).toEqual(["pause", "resume"]);
|
||
await page.getByRole("button", { name: "Replay failed delivery", exact: true }).click();
|
||
await expect.poll(() => mock.replayedDelivery).toBe(true);
|
||
mock.setStatus("attention");
|
||
await page.reload();
|
||
const recoveryNotice = page.getByRole("region", { name: "GitHub connection needs repair" });
|
||
await expect(recoveryNotice).toBeVisible();
|
||
await recoveryNotice.getByRole("link", { name: "Reconnect GitHub", exact: true }).click();
|
||
await expect(page.getByRole("heading", { name: "Reconnect GitHub", exact: true })).toBeVisible();
|
||
await expect(page.getByText(/using its saved credentials/)).toBeVisible();
|
||
await expect(page.getByRole("button", { name: "Choose an agent", exact: true })).toHaveCount(0);
|
||
await expect(page.getByLabel("Private key", { exact: true })).toHaveCount(0);
|
||
const reconnectResponse = page.waitForResponse((response) =>
|
||
response.url().endsWith("/api/chat-endpoints/endpoint-github/setup") &&
|
||
response.request().method() === "POST" &&
|
||
response.request().postDataJSON().action === "reconnect",
|
||
);
|
||
await page.getByRole("button", { name: "Reconnect saved App", exact: true }).click();
|
||
const reconnected = await reconnectResponse;
|
||
expect(reconnected.request().postDataJSON()).toEqual({ action: "reconnect" });
|
||
expect(await reconnected.json()).toMatchObject({ assignedAgentId: seed.agentId, assignedAgentName: "Maya" });
|
||
await expect(page.getByRole("heading", { name: "GitHub connected", exact: true })).toBeVisible();
|
||
mock.setStatus("active");
|
||
await page.getByRole("dialog").getByRole("button", { name: "Done", exact: true }).click();
|
||
await expect(page.getByRole("heading", { name: "Maya", exact: true })).toBeVisible();
|
||
await nav.getByRole("link", { name: "Activity", exact: true }).click();
|
||
await page.getByText("Connection health and controls", { exact: true }).click();
|
||
await page.getByRole("button", { name: "Remove connection", exact: true }).click();
|
||
await page.getByRole("alertdialog").getByRole("button", { name: "Remove connection", exact: true }).click();
|
||
await expect.poll(() => mock.removed).toBe(true);
|
||
await expect(page).toHaveURL(new RegExp(`/${seed.prefix}/apps$`));
|
||
}
|
||
|
||
/**
|
||
* Native chat-connector provider setup coverage: adapter install/lifecycle
|
||
* flows and the iMessage Photon variant. Shared fixtures and the
|
||
* chat-control-plane mock live in ./chat-adapters-ui.shared.ts; the
|
||
* messaging-flow describes run in chat-adapters-ui-messaging.spec.ts.
|
||
*/
|
||
test("Slack OAuth continuation reloads once from the callback origin without echoing the code", async ({ page }) => {
|
||
const sites: Array<string | undefined> = [];
|
||
const server = createServer((req, res) => {
|
||
sites.push(req.headers["sec-fetch-site"] as string | undefined);
|
||
res.setHeader("Content-Type", "text/html");
|
||
res.setHeader("Cache-Control", "no-store");
|
||
if (sites.length === 1) res.end(oauthCallbackInterstitialHtml());
|
||
else res.end("<h1>Callback completed</h1>");
|
||
});
|
||
await new Promise<void>(resolve => server.listen(0, "127.0.0.1", resolve));
|
||
try {
|
||
const address = server.address();
|
||
if (!address || typeof address === "string") throw new Error("Missing callback fixture listener");
|
||
const callback = `http://127.0.0.1:${address.port}/callback?code=fixture-code`;
|
||
await page.route("https://slack.test/consent", route => route.fulfill({ contentType: "text/html", body: `<a href="${callback}">Approve installation</a>` }));
|
||
await page.goto("https://slack.test/consent");
|
||
await page.getByRole("link", { name: "Approve installation" }).click();
|
||
await expect(page.getByRole("heading", { name: "Callback completed" })).toBeVisible();
|
||
expect(sites.slice(0, 2)).toEqual(["cross-site", "same-origin"]);
|
||
} finally { await new Promise<void>((resolve, reject) => server.close(error => error ? reject(error) : resolve())); }
|
||
});
|
||
|
||
test.describe.serial("native chat adapter UI", () => {
|
||
test.setTimeout(180_000);
|
||
|
||
let seed: Seed;
|
||
|
||
test.beforeAll(async ({ request }) => {
|
||
seed = await seedCompanyAndAgent(request);
|
||
});
|
||
|
||
test("Slack: managed workspace setup has two stops, resumes and completes without pasting credentials", async ({ page }) => {
|
||
const slack = PROVIDERS.find(provider => provider.provider === "slack")!;
|
||
const mock = await installChatControlPlaneMock(page, slack, seed, { enableChatConnectors: true, managedSlack: true });
|
||
await page.goto(`/${seed.prefix}/apps/chat/connect?provider=slack&purpose=chat`);
|
||
await selectMaya(page);
|
||
await expect(page.getByRole("navigation", { name: "Connection setup progress" }).getByRole("button")).toHaveCount(2);
|
||
await expect(page.getByRole("heading", { name: "Send a message to your agent", exact: true })).toBeVisible();
|
||
await expect(page.getByLabel("App configuration access token", { exact: true })).toHaveCount(0);
|
||
await expect(page.getByLabel("Signing Secret", { exact: true })).toHaveCount(0);
|
||
await page.reload();
|
||
await expect(page.getByRole("heading", { name: "Send a message to your agent", exact: true })).toBeVisible();
|
||
expect(mock.slackCreations).toBe(1); expect(mock.slackInstallations).toBe(1);
|
||
mock.setWebhookVerified();
|
||
await expect(page.getByRole("heading", { name: "Success", exact: true })).toBeVisible();
|
||
await expect(page.getByRole("navigation", { name: "Connection setup progress" }).getByRole("button")).toHaveCount(2);
|
||
await page.getByRole("button", { name: "Done", exact: true }).click();
|
||
await expect(page).toHaveURL(/\/apps\/chat\/endpoint-slack\/settings$/);
|
||
});
|
||
test("Slack: a failed manager refresh reloads workspace authorization before retry", async ({ page }) => {
|
||
const slack = PROVIDERS.find(provider => provider.provider === "slack")!;
|
||
await installChatControlPlaneMock(page, slack, seed, { enableChatConnectors: true, managedSlack: true });
|
||
let refreshFailed = false;
|
||
let authorizationRequests = 0;
|
||
await page.route("**/chat-slack/setup-options", async route => {
|
||
if (!refreshFailed) return route.fallback();
|
||
await route.fulfill({ json: { managedAvailable: true, defaultMethod: "managed", workspaces: [] } });
|
||
});
|
||
await page.route("**/slack/managed/provision", async route => {
|
||
refreshFailed = true;
|
||
await route.fulfill({ status: 422, json: { error: "Reconnect your Slack workspace.", details: { code: "slack_manager_reauthorize" } } });
|
||
});
|
||
await page.route("**/slack/managed/authorize", async route => {
|
||
authorizationRequests++;
|
||
await route.fulfill({ json: { authorizationUrl: "https://slack.test/manager-consent", expiresAt: new Date(Date.now() + 600_000).toISOString() } });
|
||
});
|
||
await page.route("https://slack.test/manager-consent", route => route.fulfill({ contentType: "text/html", body: "Slack authorization fixture" }));
|
||
await page.goto(`/${seed.prefix}/apps/chat/connect?provider=slack&purpose=chat&resume=endpoint-slack`);
|
||
await expect(page.getByRole("alert")).toBeVisible();
|
||
await page.getByRole("button", { name: "Reconnect Slack workspace", exact: true }).click();
|
||
await expect.poll(() => authorizationRequests).toBe(1);
|
||
await expect(page).toHaveURL("https://slack.test/manager-consent");
|
||
});
|
||
test("Slack: every uncertain creation retry needs a fresh confirmation", async ({ page }) => {
|
||
const slack = PROVIDERS.find(provider => provider.provider === "slack")!;
|
||
const mock = await installChatControlPlaneMock(page, slack, seed, { enableChatConnectors: true, managedSlack: true });
|
||
mock.setSlackUncertain();
|
||
const attempts: { requestId: string; confirmedNoAppCreated?: boolean }[] = [];
|
||
await page.route("**/slack/managed/provision", async route => {
|
||
attempts.push(route.request().postDataJSON());
|
||
await route.fulfill({ json: {} });
|
||
});
|
||
await page.goto(`/${seed.prefix}/apps/chat/connect?provider=slack&purpose=chat&resume=endpoint-slack`);
|
||
const confirm = page.getByRole("checkbox", { name: "I checked Slack and no app was created. Create a new app." });
|
||
const create = page.getByRole("button", { name: "Continue", exact: true });
|
||
await expect(create).toBeDisabled();
|
||
await confirm.check(); await create.click();
|
||
await expect(confirm).not.toBeChecked(); await expect(create).toBeDisabled();
|
||
expect(attempts).toHaveLength(1); expect(attempts[0]!.confirmedNoAppCreated).toBe(true);
|
||
await confirm.check(); await create.click();
|
||
await expect(confirm).not.toBeChecked(); await expect(create).toBeDisabled();
|
||
expect(attempts).toHaveLength(2); expect(attempts[1]!.requestId).not.toBe(attempts[0]!.requestId);
|
||
});
|
||
test("Slack: own-app creation consumes confirmation even when the response is lost", async ({ page }) => {
|
||
const slack = PROVIDERS.find(provider => provider.provider === "slack")!;
|
||
const mock = await installChatControlPlaneMock(page, slack, seed, { enableChatConnectors: true, automaticSlack: true });
|
||
mock.setSlackUncertain();
|
||
let attempts = 0;
|
||
await page.route("**/slack/registration", async route => {
|
||
attempts++; expect(route.request().postDataJSON().confirmedNoAppCreated).toBe(true);
|
||
await route.fulfill({ status: 502, json: { error: "Creation response unavailable" } });
|
||
});
|
||
await page.goto(`/${seed.prefix}/apps/chat/connect?provider=slack&purpose=chat&resume=endpoint-slack`);
|
||
const confirm = page.getByRole("checkbox");
|
||
await confirm.check();
|
||
await page.getByLabel("App configuration access token", { exact: true }).fill("fixture-config-token");
|
||
await page.getByRole("button", { name: "Create Slack app", exact: true }).click();
|
||
await expect(page.getByRole("alert")).toBeVisible();
|
||
await expect(confirm).not.toBeChecked();
|
||
await expect(page.getByRole("button", { name: "Create Slack app", exact: true })).toBeDisabled();
|
||
expect(attempts).toBe(1);
|
||
});
|
||
test("Slack: temporarily unavailable managed setup can retry in place", async ({ page }) => {
|
||
const slack = PROVIDERS.find(provider => provider.provider === "slack")!;
|
||
await installChatControlPlaneMock(page, slack, seed, { enableChatConnectors: true, managedSlack: true });
|
||
let available = false;
|
||
await page.route("**/chat-slack/setup-options", async route => {
|
||
if (available) return route.fallback();
|
||
await route.fulfill({ json: { managedAvailable: false, defaultMethod: "automatic", workspaces: [] } });
|
||
});
|
||
await page.goto(`/${seed.prefix}/apps/chat/connect?provider=slack&purpose=chat&resume=endpoint-slack`);
|
||
await expect(page.getByText("Managed Slack setup is unavailable. Try again later.")).toBeVisible();
|
||
available = true; await page.getByRole("button", { name: "Retry", exact: true }).click();
|
||
await expect(page.getByRole("heading", { name: "Send a message to your agent", exact: true })).toBeVisible();
|
||
});
|
||
test("Slack: own-app selection persists before provisioning", async ({ page }) => {
|
||
const slack = PROVIDERS.find(provider => provider.provider === "slack")!;
|
||
await installChatControlPlaneMock(page, slack, seed, { enableChatConnectors: true, managedSlack: true });
|
||
await page.goto(`/${seed.prefix}/apps/chat/connect?provider=slack&purpose=chat`);
|
||
await page.getByRole("button", { name: "Choose an active agent" }).click();
|
||
await page.getByRole("button", { name: "Select Maya" }).click();
|
||
await page.getByText("Advanced", { exact: true }).click();
|
||
await page.getByRole("button", { name: "Use your own app" }).click();
|
||
await expect(page.getByRole("heading", { name: "App configuration access token", exact: true })).toBeVisible();
|
||
await page.reload();
|
||
await expect(page.getByRole("heading", { name: "App configuration access token", exact: true })).toBeVisible();
|
||
await page.getByText("Advanced", { exact: true }).click();
|
||
await page.getByRole("button", { name: "Use managed setup" }).click();
|
||
await expect(page.getByRole("heading", { name: "Send a message to your agent", exact: true })).toBeVisible();
|
||
});
|
||
test("Slack: automatic creation survives refresh, consent, and connection evidence without copying durable secrets", async ({ page }) => {
|
||
const slack = PROVIDERS.find(provider => provider.provider === "slack")!;
|
||
const mock = await installChatControlPlaneMock(page, slack, seed, { enableChatConnectors: true, automaticSlack: true });
|
||
async function holdSetupRequest(action: "registration" | "install") {
|
||
let release!: () => void;
|
||
let received!: () => void;
|
||
const pending = new Promise<void>(resolve => { release = resolve; });
|
||
const requested = new Promise<void>(resolve => { received = resolve; });
|
||
await page.route(`**/api/chat-endpoints/endpoint-slack/slack/${action}`, async route => {
|
||
received();
|
||
await pending;
|
||
await route.fallback();
|
||
}, { times: 1 });
|
||
return { release, requested };
|
||
}
|
||
async function expectNavigationLocked() {
|
||
const steps = page.getByRole("navigation", { name: "Connection setup progress" }).getByRole("button");
|
||
await expect(steps).toHaveCount(4);
|
||
for (const step of await steps.all()) await expect(step).toBeDisabled();
|
||
await expect(page.getByRole("button", { name: "Save & exit", exact: true })).toBeDisabled();
|
||
}
|
||
const rootUrl = new URL(`/${seed.prefix}/apps/chat/connect?provider=slack&purpose=chat&resume=endpoint-slack`, test.info().project.use.baseURL).href;
|
||
const callbackUrl = new URL("/api/chat-slack/oauth/callback?state=fixture-state&code=fixture-code", rootUrl).href;
|
||
let callbackRequests = 0;
|
||
await page.route("**/api/chat-slack/oauth/callback?**", async route => {
|
||
callbackRequests += 1;
|
||
if (callbackRequests === 1) {
|
||
const body = oauthCallbackInterstitialHtml();
|
||
expect(body).not.toContain("fixture-code");
|
||
await route.fulfill({ contentType: "text/html", body });
|
||
} else {
|
||
mock.setSlackInstalled();
|
||
await route.fulfill({ status: 303, headers: { location: rootUrl } });
|
||
}
|
||
});
|
||
await page.context().route("https://slack.test/**", async route => {
|
||
await route.fulfill({ contentType: "text/html", body: `<h1>Fixture Slack consent</h1><a href="${callbackUrl}">Approve installation</a>` });
|
||
});
|
||
await page.goto(`/${seed.prefix}/apps/chat/connect?provider=slack&purpose=chat`);
|
||
await expect(page.locator("summary:visible").filter({ hasText: "Advanced" })).toHaveCount(0);
|
||
await expect(page.getByRole("button", { name: /Set up with agent/i })).toHaveCount(0);
|
||
await page.getByRole("button", { name: "Choose an active agent" }).click();
|
||
await page.getByRole("button", { name: "Select Maya", exact: true }).click();
|
||
await page.locator("summary:visible").filter({ hasText: "Advanced" }).click();
|
||
await expect(page.getByLabel("Slack app name", { exact: true })).toHaveValue("maya-paperclip");
|
||
await expect(page.getByLabel("Bot display name", { exact: true })).toHaveValue("maya");
|
||
await expect(page.getByLabel("Slash command", { exact: true })).toHaveValue("/maya");
|
||
await page.getByRole("button", { name: "Continue", exact: true }).click();
|
||
await expect(page.getByRole("heading", { name: "App configuration access token", exact: true })).toBeVisible();
|
||
await expect(page.getByRole("link", { name: "Get your App configuration access token" })).toBeVisible();
|
||
await expect(page.getByRole("button", { name: "Use an existing app", exact: true })).toBeHidden();
|
||
await page.getByLabel("App configuration access token", { exact: true }).fill("fixture-config-token");
|
||
const creation = await holdSetupRequest("registration");
|
||
const installationWindow = page.waitForEvent("popup");
|
||
try {
|
||
await page.getByRole("button", { name: "Create Slack app", exact: true }).click();
|
||
await creation.requested;
|
||
await expectNavigationLocked();
|
||
await expect(page.getByLabel("App configuration access token", { exact: true })).toHaveValue("");
|
||
} finally { creation.release(); }
|
||
const consentWindow = await installationWindow;
|
||
await expect(consentWindow.getByRole("heading", { name: "Fixture Slack consent" })).toBeVisible();
|
||
await expect(page.getByRole("heading", { name: "Install Slack app", exact: true })).toBeVisible();
|
||
await consentWindow.close();
|
||
await expect(page.getByRole("navigation", { name: "Connection setup progress" }).getByRole("button", { name: /Choose agent/ })).toBeEnabled();
|
||
expect(mock.slackCreations).toBe(1);
|
||
await expect(page.getByLabel("Bot User OAuth Token", { exact: true })).toHaveCount(0);
|
||
await expect(page.getByLabel("Signing Secret", { exact: true })).toHaveCount(0);
|
||
await page.reload();
|
||
await expect(page.getByRole("heading", { name: "Install Slack app", exact: true })).toBeVisible();
|
||
await page.getByRole("button", { name: "Save & exit", exact: true }).click();
|
||
await page.goto(rootUrl);
|
||
expect(mock.slackInstallations).toBe(1);
|
||
const installation = await holdSetupRequest("install");
|
||
try {
|
||
await page.getByRole("button", { name: "Install in Slack", exact: true }).click();
|
||
await installation.requested;
|
||
await expectNavigationLocked();
|
||
} finally { installation.release(); }
|
||
await expect(page.getByRole("heading", { name: "Fixture Slack consent" })).toBeVisible();
|
||
await page.getByRole("link", { name: "Approve installation" }).click();
|
||
await expect(page.getByRole("heading", { name: "Send a message to your agent", exact: true })).toBeVisible();
|
||
await expect(page.getByRole("link", { name: "Open Slack app Settings" })).toBeHidden();
|
||
await expect(page.getByRole("link", { name: "Open Slack", exact: true })).toHaveAttribute("href", "https://app.slack.com/client/TE2E/DE2E");
|
||
await page.getByText("Didn’t work?", { exact: true }).click();
|
||
await expect(page.getByRole("link", { name: "Open Slack app Settings" })).toHaveAttribute("href", "https://api.slack.com/apps/AE2E/event-subscriptions");
|
||
mock.setWebhookVerified();
|
||
await expect(page.getByRole("heading", { name: "Success", exact: true })).toBeVisible();
|
||
await expect(page.getByRole("link", { name: "Download avatar" })).toHaveCount(0);
|
||
await expect(page.getByRole("button", { name: /Link .* to my Paperclip account/ })).toHaveCount(0);
|
||
await expect(page.getByRole("link", { name: "Open your Slack DM" })).toHaveCount(0);
|
||
await expect(page.getByRole("button", { name: "Copy message" })).toHaveCount(0);
|
||
await page.reload();
|
||
await expect(page.getByRole("heading", { name: "Success", exact: true })).toBeVisible();
|
||
await expect(page.getByRole("navigation", { name: "Connection setup progress" }).getByRole("button")).toHaveCount(4);
|
||
expect(mock.slackCreations).toBe(1); expect(mock.slackInstallations).toBe(2);
|
||
expect(callbackRequests).toBe(2);
|
||
await page.screenshot({ path: test.info().outputPath("automatic-slack-success.png"), fullPage: true });
|
||
await page.getByRole("button", { name: "Done", exact: true }).click();
|
||
await expect(page).toHaveURL(/\/apps\/chat\/endpoint-slack\/settings$/);
|
||
const stored = await page.evaluate(() => JSON.stringify({ local: { ...localStorage }, session: { ...sessionStorage } }));
|
||
expect(stored).not.toContain("fixture-config-token");
|
||
});
|
||
|
||
test("Slack: uncertain creation locks fields and offers manual recovery on mobile", async ({ page }) => {
|
||
await page.setViewportSize({ width: 390, height: 844 });
|
||
const slack = PROVIDERS.find(provider => provider.provider === "slack")!;
|
||
const mock = await installChatControlPlaneMock(page, slack, seed, { enableChatConnectors: true, automaticSlack: true });
|
||
mock.setSlackUncertain();
|
||
await page.goto(`/${seed.prefix}/apps/chat/connect?provider=slack&purpose=chat&resume=endpoint-slack`);
|
||
await expect(page.getByRole("alert")).toContainText("Slack may have created the app");
|
||
await page.getByRole("button", { name: "Open sidebar" }).click();
|
||
await page.getByRole("navigation", { name: "Connection setup progress" }).getByRole("button", { name: /Choose agent/ }).click();
|
||
await page.locator("summary:visible").filter({ hasText: "Advanced" }).click();
|
||
await expect(page.getByLabel("Slack app name", { exact: true })).toHaveAttribute("readonly", "");
|
||
await page.getByRole("button", { name: "Continue", exact: true }).click();
|
||
await page.locator("summary:visible").filter({ hasText: "Advanced" }).click();
|
||
await expect(page.getByLabel("App configuration access token", { exact: true })).toHaveCount(0);
|
||
const save = await page.getByRole("button", { name: "Save & exit", exact: true }).boundingBox();
|
||
const create = await page.getByRole("button", { name: "Create Slack app", exact: true }).boundingBox();
|
||
expect(Math.abs(save!.y - create!.y)).toBeLessThan(4);
|
||
await page.getByRole("button", { name: "Use an existing app", exact: true }).click();
|
||
await expect(page.getByRole("heading", { name: "Add Slack credentials", exact: true })).toBeVisible();
|
||
await page.reload();
|
||
await expect(page.getByRole("heading", { name: "Add Slack credentials", exact: true })).toBeVisible();
|
||
await expect(page.getByRole("heading", { name: "Add Slack credentials", exact: true })).toBeVisible();
|
||
expect(mock.slackCreations).toBe(0);
|
||
});
|
||
|
||
test("GitHub: the default-off gate keeps direct tool setup and fences chat routes", async ({
|
||
page,
|
||
}) => {
|
||
const github = PROVIDERS.find(
|
||
(provider) => provider.provider === "github",
|
||
)!;
|
||
const mock = await installChatControlPlaneMock(page, github, seed, {
|
||
enableChatConnectors: true,
|
||
enableGitHubReviewBots: false,
|
||
});
|
||
|
||
await page.goto(`/${seed.prefix}/apps`);
|
||
await expect(page.getByRole("heading", { name: "Connectors" })).toBeVisible(
|
||
{ timeout: 30_000 },
|
||
);
|
||
const connector = await findConnectorBySearch(page, "GitHub", "github");
|
||
// Without the cloud connector GitHub's default method is a token, so the
|
||
// card's verb is "Add key" rather than "Connect".
|
||
await connector.getByRole("button", { name: "Add key GitHub" }).click();
|
||
|
||
await expect(page).toHaveURL(/\/apps\/connect\?/);
|
||
expect(new URL(page.url()).searchParams.get("source")).toBe("github");
|
||
// Identity is a stated default; its choices sit behind "Change".
|
||
await page.getByRole("button", { name: "Change", exact: true }).click();
|
||
await expect(
|
||
page.getByRole("heading", { name: "Connect GitHub as" }),
|
||
).toBeVisible();
|
||
await expect(
|
||
page.getByRole("heading", { name: "Choose how to connect" }),
|
||
).toHaveCount(0);
|
||
await expect(
|
||
page.getByText("Chat with an agent", { exact: true }),
|
||
).toHaveCount(0);
|
||
|
||
await page.goto(`/${seed.prefix}/apps/chat/connect?provider=github`);
|
||
await expect(page).toHaveURL(new RegExp(`/${seed.prefix}/apps$`));
|
||
await expect(
|
||
page.getByRole("heading", { name: "Connectors" }),
|
||
).toBeVisible();
|
||
await page.goto(`/${seed.prefix}/apps/chat/endpoint-github/settings`);
|
||
await expect(page).toHaveURL(new RegExp(`/${seed.prefix}/apps$`));
|
||
await expect.poll(() => mock.chatEndpointListReads).toBeGreaterThan(0);
|
||
await expect(page.getByRole("button", { name: "Connect AgentMail", exact: true })).toBeVisible();
|
||
expect(mock.createdWithAgentId).toBeNull();
|
||
});
|
||
|
||
for (const enabled of [false, true]) {
|
||
test(`Agent Channels: default email surface and experimental providers (${enabled})`, async ({
|
||
page,
|
||
}) => {
|
||
const github = PROVIDERS.find(
|
||
(provider) => provider.provider === "github",
|
||
)!;
|
||
const mock = await installChatControlPlaneMock(page, github, seed, {
|
||
enableChatConnectors: enabled,
|
||
});
|
||
|
||
await page.goto(`/${seed.prefix}/agents/${seed.agentId}/channels`);
|
||
const channelsHeading = page.getByRole("heading", {
|
||
name: "Channels",
|
||
exact: true,
|
||
});
|
||
await expect(page).toHaveURL(/\/channels$/);
|
||
await expect(channelsHeading).toHaveCount(1);
|
||
await expect(channelsHeading).toBeVisible();
|
||
await expect(
|
||
page.getByRole("link", { name: "Connect a channel" }),
|
||
).toBeVisible();
|
||
const agentNavigation = page.getByRole("navigation", { name: "Maya navigation" });
|
||
await expect(agentNavigation.getByRole("link", { name: "Identity", exact: true })).toBeVisible();
|
||
await expect(agentNavigation.getByRole("link", { name: "Channels", exact: true })).toHaveCount(0);
|
||
await expect.poll(() => mock.chatEndpointListReads).toBeGreaterThan(0);
|
||
if (!enabled) {
|
||
await expect(page.getByText("Connect AgentMail from Connectors.", { exact: true })).toBeVisible();
|
||
}
|
||
});
|
||
}
|
||
|
||
for (const provider of PROVIDERS) {
|
||
test(`${provider.name}: catalog, setup, and connection management tabs`, async ({
|
||
page,
|
||
}, testInfo) => {
|
||
const mock = await installChatControlPlaneMock(page, provider, seed, {
|
||
enableChatConnectors: provider.provider !== "github",
|
||
enableGitHubReviewBots: provider.provider === "github",
|
||
});
|
||
|
||
await page.goto(`/${seed.prefix}/apps`);
|
||
await expect(
|
||
page.getByRole("heading", { name: "Connectors" }),
|
||
).toBeVisible({ timeout: 30_000 });
|
||
let connector = await findConnectorBySearch(page, provider.name, provider.slug);
|
||
if (provider.provider === "github") {
|
||
const tools = await findConnectorBySearch(page, "GitHub", "github");
|
||
await tools.getByRole("button", { name: "Add key GitHub", exact: true }).click();
|
||
await page.getByRole("button", { name: "Change", exact: true }).click();
|
||
await expect(page.getByRole("heading", { name: "Connect GitHub as" })).toBeVisible();
|
||
await expect(page.getByRole("heading", { name: "Choose how to connect" })).toHaveCount(0);
|
||
await page.goto(`/${seed.prefix}/apps`);
|
||
connector = await findConnectorBySearch(page, provider.name, provider.slug);
|
||
}
|
||
await connector
|
||
.getByRole("button", { name: `Connect ${provider.provider === "github" ? "GitHub Code Review Bot" : provider.name}` })
|
||
.click();
|
||
|
||
if (provider.chatAndTool) {
|
||
await expect(
|
||
page.getByRole("heading", { name: "Choose how to connect" }),
|
||
).toBeVisible();
|
||
await expect(
|
||
page.getByRole("button", { name: /Chat with an agent/ }),
|
||
).toBeVisible();
|
||
await expect(
|
||
page.getByRole("button", {
|
||
name: /Use this connection as an agent tool/,
|
||
}),
|
||
).toBeVisible();
|
||
const chatSetupUrl = page.url();
|
||
const toolHref = new URL(chatSetupUrl).searchParams.get("toolHref");
|
||
expect(toolHref).toBeTruthy();
|
||
await page
|
||
.getByRole("button", {
|
||
name: /Use this connection as an agent tool/,
|
||
})
|
||
.click();
|
||
await expect(page).toHaveURL(/\/apps\/connect\?/);
|
||
expect(new URL(page.url()).searchParams.get("source")).toBe(
|
||
provider.provider,
|
||
);
|
||
await page.goto(chatSetupUrl);
|
||
await expect(
|
||
page.getByRole("heading", { name: "Choose how to connect" }),
|
||
).toBeVisible();
|
||
await page.getByRole("button", { name: /Chat with an agent/ }).click();
|
||
} else {
|
||
const chatOnlySetupUrl = new URL(page.url());
|
||
expect(chatOnlySetupUrl.searchParams.get("purpose")).toBe("chat");
|
||
expect(chatOnlySetupUrl.searchParams.get("toolHref")).toBeNull();
|
||
await expect(
|
||
page.getByRole("heading", { name: "Choose how to connect" }),
|
||
).toHaveCount(0);
|
||
}
|
||
|
||
if (provider.provider === "github") {
|
||
await exerciseGitHubReviewSetup(page, mock, seed, provider, testInfo);
|
||
return;
|
||
}
|
||
|
||
await expect(
|
||
page.getByRole("heading", {
|
||
name: "Which agent do you want to chat with?",
|
||
}),
|
||
).toBeVisible();
|
||
await expectSetupRail(page, true);
|
||
await selectMaya(page);
|
||
if (provider.provider === "slack") {
|
||
await page.getByText("Advanced", { exact: true }).click();
|
||
await page.getByRole("button", { name: "Create manually", exact: true }).click();
|
||
}
|
||
await expect.poll(() => mock.createdWithAgentId).toBe(seed.agentId);
|
||
expect(mock.createdWithAgentId).not.toBe(seed.otherAgentId);
|
||
await expect(
|
||
page.getByRole("button", { name: "Choose an active agent" }),
|
||
).toHaveCount(0);
|
||
await expect(
|
||
page.getByRole("heading", { name: provider.setupHeading }),
|
||
).toBeVisible();
|
||
await expect(
|
||
page.getByText(PROVIDER_LIFECYCLE_COPY[provider.provider].reconnect, {
|
||
exact: false,
|
||
}),
|
||
).toHaveCount(0);
|
||
await expectSetupRail(page);
|
||
await expectMinimumProviderSetup(page, provider);
|
||
await fillProviderSetup(page, provider);
|
||
|
||
if (provider.provider === "github") {
|
||
// A completed click does not mean the async configure request reached the mock.
|
||
await expect.poll(() => mock.setupAttempts).toBe(2);
|
||
expect(mock.githubPrivateKeyMatchedFile).toBe(true);
|
||
expect(mock.githubPrivateKeyMatchedPaste).toBe(true);
|
||
}
|
||
|
||
if (provider.provider === "telegram") {
|
||
const submittedToken = "123456:e2e-redacted";
|
||
// The next step intentionally has its own identity-readiness alert.
|
||
// Assert that this failed setup attempt clears, not that all alerts
|
||
// disappear during the transition between two valid wizard states.
|
||
const setupAlert = page.getByRole("alert").filter({
|
||
has: page.getByText("Connection failed", { exact: true }),
|
||
});
|
||
await expect(setupAlert).toContainText("Connection failed");
|
||
await expect(setupAlert).toContainText(
|
||
"Telegram rejected bot token [redacted]. Confirm the token in BotFather and try again.",
|
||
);
|
||
await expect(page.locator("body")).not.toContainText(submittedToken);
|
||
const tokenInput = page.getByLabel("Bot token");
|
||
await expect(tokenInput).toHaveAttribute("type", "password");
|
||
await expect(tokenInput).toHaveValue(submittedToken);
|
||
await tokenInput.focus();
|
||
await tokenInput.press("Tab");
|
||
await expect(setupAlert).toBeVisible();
|
||
|
||
await page.getByRole("button", { name: provider.setupButton }).click();
|
||
await expect(
|
||
page.getByRole("heading", { name: `Try Maya in ${provider.name}` }),
|
||
).toBeVisible();
|
||
await expect(setupAlert).toHaveCount(0);
|
||
expect(mock.setupAttempts).toBe(2);
|
||
}
|
||
|
||
if (provider.provider === "slack") {
|
||
await expect(page.getByRole("heading", { name: "Verify Slack connection" })).toBeVisible();
|
||
await expect(page.getByText("Slack needs to confirm that it can reach your Paperclip instance.")).toBeVisible();
|
||
mock.setWebhookVerified();
|
||
await expect(page.getByRole("heading", { name: "Connect your Slack account" })).toBeVisible();
|
||
await expect(page.getByText("/maya connect", { exact: true })).toBeVisible();
|
||
await page.getByRole("button", { name: "Link Test operator to my Paperclip account" }).click();
|
||
await page.getByRole("button", { name: "Continue to message test" }).click();
|
||
}
|
||
|
||
await expect(
|
||
page.getByRole("heading", { name: `Try Maya in ${provider.name}` }),
|
||
).toBeVisible();
|
||
await expect(
|
||
page.getByRole("button", { name: provider.provider === "slack" ? "Done" : "I've sent the test message" }),
|
||
).toBeVisible();
|
||
if (provider.provider !== "slack") {
|
||
await expect(
|
||
page.getByRole("heading", {
|
||
name: "Link the account you’re testing",
|
||
}),
|
||
).toBeVisible();
|
||
await expect(
|
||
page.getByText(
|
||
/An observed external account is unlinked, and isolated guest work is off, so it cannot safely start Maya/,
|
||
),
|
||
).toBeVisible();
|
||
await expect(
|
||
page.getByRole("button", { name: "Review identity access" }),
|
||
).toBeVisible();
|
||
await page
|
||
.getByRole("button", { name: "Review identity access" })
|
||
.click();
|
||
await expect(page).toHaveURL(
|
||
new RegExp(
|
||
`/${seed.prefix}/apps/chat/endpoint-${provider.provider}/access$`,
|
||
),
|
||
);
|
||
await expect(
|
||
page.getByRole("button", { name: "Continue setup" }),
|
||
).toBeVisible();
|
||
await page.getByRole("button", { name: "Continue setup" }).click();
|
||
expect(new URL(page.url()).searchParams.get("reconnect")).toBeNull();
|
||
await expect(
|
||
page.getByRole("heading", { name: `Try Maya in ${provider.name}` }),
|
||
).toBeVisible();
|
||
}
|
||
await expectSetupRail(page);
|
||
await expectProviderTryInstructions(page, provider);
|
||
expect(mock.configuredCredentialKeys).toEqual(
|
||
expectedCredentialKeys(provider.provider),
|
||
);
|
||
await page
|
||
.getByRole("button", { name: provider.provider === "slack" ? "Done" : "I've sent the test message" })
|
||
.click();
|
||
|
||
await expect(page).toHaveURL(
|
||
new RegExp(
|
||
`/${seed.prefix}/apps/chat/endpoint-${provider.provider}/settings$`,
|
||
),
|
||
);
|
||
await expect(
|
||
page.getByRole("heading", { name: "Maya", exact: true }),
|
||
).toBeVisible();
|
||
await expect(
|
||
page.getByText(provider.accountLabel, { exact: true }),
|
||
).toBeVisible();
|
||
await expect(
|
||
page.getByRole("button", { name: "Continue setup" }),
|
||
).toHaveCount(0);
|
||
await expect(page.getByText("Change agent", { exact: true })).toHaveCount(
|
||
0,
|
||
);
|
||
await expect(page.getByRole("navigation", { name: "Chat connection" }).getByRole("link")).toHaveCount(4);
|
||
for (const tab of ["Settings", "Access", "Conversations", "Activity"]) {
|
||
await expect(page.getByRole("navigation", { name: "Chat connection" }).getByRole("link", { name: tab , exact: true })).toBeVisible();
|
||
}
|
||
await expect(
|
||
page.getByRole("heading", { name: provider.provider === "slack" ? "Allowed Channels" : "Destinations" }),
|
||
).toBeVisible();
|
||
if (provider.provider === "slack") {
|
||
await expect(page.getByRole("heading", { name: "Allowed Channels" })).toBeVisible();
|
||
await expect(page.getByRole("button", { name: "Send me a test message" })).toBeVisible();
|
||
await expect(page.getByRole("link", { name: "Open Slack configuration" })).toBeVisible();
|
||
await expect(page.getByText("Message in a channel", { exact: true })).toHaveCount(0);
|
||
await expect(page.getByText("Agent avatar", { exact: true })).toHaveCount(0);
|
||
await expect(page.getByRole("link", { name: "View message" })).toHaveCount(0);
|
||
}
|
||
|
||
await expect(
|
||
page.getByRole("switch", {
|
||
name: `Enable ${provider.resourceLabel}`,
|
||
}),
|
||
).toBeVisible();
|
||
await expect(
|
||
page.getByText(provider.resourceLabel, { exact: true }),
|
||
).toBeVisible();
|
||
await expect(
|
||
page.getByText(provider.secondaryResourceLabel, { exact: true }),
|
||
).toBeVisible();
|
||
await expect(
|
||
page.getByRole("switch", {
|
||
name: `Enable ${provider.secondaryResourceLabel}`,
|
||
}),
|
||
).not.toBeChecked();
|
||
await page
|
||
.getByRole("switch", { name: `Enable ${provider.resourceLabel}` })
|
||
.click();
|
||
await expect.poll(() => mock.updatedResource).toBe(true);
|
||
expect(mock.resourceUpdates.at(-1)).toEqual([
|
||
{ id: `resource-${provider.provider}`, enabled: true },
|
||
]);
|
||
|
||
if (provider.provider === "github") {
|
||
await expect(
|
||
page.getByRole("heading", { name: "Private conversations" }),
|
||
).toHaveCount(0);
|
||
} else {
|
||
const directMessages = page.getByRole("switch", {
|
||
name: "Allow direct messages",
|
||
});
|
||
await expect(directMessages).toBeVisible();
|
||
if (provider.provider === "discord") {
|
||
await expect(
|
||
page.getByText(
|
||
"People must also enable Direct Messages in their shared Discord server’s Privacy Settings.",
|
||
),
|
||
).toBeVisible();
|
||
}
|
||
await directMessages.click();
|
||
await expect.poll(() => mock.allowDirectMessages).toBe(true);
|
||
}
|
||
if (provider.provider === "microsoft-teams") {
|
||
const groupChats = page.getByRole("switch", {
|
||
name: "Allow group chats",
|
||
});
|
||
await expect(groupChats).toBeVisible();
|
||
await groupChats.click();
|
||
await expect.poll(() => mock.allowGroupChats).toBe(true);
|
||
}
|
||
for (const lifecycleAction of [
|
||
"Pause",
|
||
"Resume",
|
||
"Reconnect",
|
||
"Remove connection",
|
||
]) {
|
||
await expect(
|
||
page.getByRole("button", {
|
||
name: lifecycleAction,
|
||
exact: true,
|
||
}),
|
||
).toHaveCount(0);
|
||
}
|
||
await expect(
|
||
page.getByRole("switch", { name: "Allow unlinked people" }),
|
||
).toHaveCount(0);
|
||
|
||
await page.getByRole("navigation", { name: "Chat connection" }).getByRole("link", { name: "Access", exact: true }).click();
|
||
await expect(
|
||
page.getByRole("heading", { name: "People" }),
|
||
).toBeVisible();
|
||
if (provider.provider === "slack") {
|
||
await page.context().grantPermissions(["clipboard-read", "clipboard-write"], { origin: new URL(page.url()).origin });
|
||
await page.getByRole("button", { name: "Invite people", exact: true }).click();
|
||
await page.getByRole("dialog").getByRole("button", { name: "Copy invitation" }).click();
|
||
await expect.poll(() => page.evaluate(() => navigator.clipboard.readText())).toContain("connect");
|
||
expect(await page.evaluate(() => navigator.clipboard.readText())).not.toContain("token=");
|
||
await page.getByRole("dialog").getByRole("button", { name: "Close", exact: true }).click();
|
||
}
|
||
await page.locator("summary").filter({ hasText: "Guest access" }).click();
|
||
await expect(page.getByText(/Requests are refused when isolation is unavailable/)).toBeVisible();
|
||
const allowUnlinked = page.getByRole("switch", {
|
||
name: "Allow unlinked people",
|
||
});
|
||
await expect(allowUnlinked).toBeVisible();
|
||
await allowUnlinked.click();
|
||
await expect.poll(() => mock.allowUnlinkedPeople).toBe(true);
|
||
await expect(
|
||
page.getByText("Ada Lovelace", { exact: true }),
|
||
).toBeVisible();
|
||
await expect(
|
||
page.getByText("Grace Hopper", { exact: true }),
|
||
).toBeVisible();
|
||
|
||
await expect(
|
||
page.getByRole("button", { name: "Create confirmation link" }),
|
||
).toBeVisible();
|
||
await page
|
||
.context()
|
||
.grantPermissions(["clipboard-read", "clipboard-write"], {
|
||
origin: new URL(page.url()).origin,
|
||
});
|
||
await page.getByRole("button", { name: "Create confirmation link" }).click();
|
||
await expect
|
||
.poll(() => mock.linkIntentPrincipalId)
|
||
.toBe(`principal-${provider.provider}`);
|
||
const confirmationUrl = `https://paperclip.example.test/${seed.prefix}/chat-identity/confirm?token=e2e-redacted`;
|
||
await expect(
|
||
page.getByText(confirmationUrl, { exact: true }),
|
||
).toBeVisible();
|
||
await page.getByRole("button", { name: "Copy link" }).click();
|
||
await expect
|
||
.poll(() => page.evaluate(() => navigator.clipboard.readText()))
|
||
.toBe(confirmationUrl);
|
||
await expect(
|
||
page.getByText("Confirmation link copied", { exact: true }),
|
||
).toBeVisible();
|
||
await page.getByRole("listitem").filter({ hasText: "Grace Hopper" }).getByRole("button", { name: "Disconnect", exact: true }).click();
|
||
await expect
|
||
.poll(() => mock.revokedPrincipalId)
|
||
.toBe(`principal-${provider.provider}-linked`);
|
||
await expect(page.getByText("Disconnected", { exact: true })).toBeVisible();
|
||
|
||
await page.getByRole("navigation", { name: "Chat connection" }).getByRole("link", { name: "Conversations", exact: true }).click();
|
||
await expect(
|
||
page.getByRole("list", { name: "Conversations", exact: true }),
|
||
).toBeVisible();
|
||
await expect(
|
||
page.getByText(`Investigate ${provider.name} delivery`, { exact: true }),
|
||
).toBeVisible();
|
||
await expect(
|
||
page.getByText(provider.resourceLabel, { exact: true }),
|
||
).toHaveCount(1);
|
||
const providerLink = page.getByRole("link", {
|
||
name: provider.resourceLabel,
|
||
});
|
||
await expect(providerLink).toHaveAttribute("href", provider.externalUrl);
|
||
const taskLink = page.getByRole("link", { name: `Investigate ${provider.name} delivery` });
|
||
await expect(taskLink).toHaveAttribute(
|
||
"href",
|
||
new RegExp(`/${seed.prefix}/issues/issue-${provider.provider}$`),
|
||
);
|
||
mock.conversationState = "waiting";
|
||
await expect(page.getByText("waiting", { exact: true })).toBeVisible({
|
||
timeout: 8_000,
|
||
});
|
||
|
||
await page.getByRole("navigation", { name: "Chat connection" }).getByRole("link", { name: "Activity", exact: true }).click();
|
||
await expect(
|
||
page.getByRole("heading", { name: "Connection activity" }),
|
||
).toBeVisible();
|
||
await expect(
|
||
page.getByText(
|
||
`Inbound ${provider.name} delivery could not be processed`,
|
||
),
|
||
).toBeVisible();
|
||
await expect(
|
||
page.getByText(`Published safe output to ${provider.name}`),
|
||
).toBeVisible();
|
||
await expect(page.getByText("Recent activity", { exact: true })).toBeVisible();
|
||
await page.getByText("Connection health and controls", { exact: true }).click();
|
||
const deliveryTimestamp = page
|
||
.getByText(`Inbound ${provider.name} delivery could not be processed`)
|
||
.locator("..")
|
||
.locator("time");
|
||
await expect(deliveryTimestamp).toHaveText(
|
||
/\w+ \d+, \d{4}, \d{1,2}:\d{2}:\d{2} [AP]M/,
|
||
);
|
||
await expect(deliveryTimestamp).toHaveAttribute(
|
||
"datetime",
|
||
/\d{4}-\d{2}-\d{2}T/,
|
||
);
|
||
await expect(deliveryTimestamp).toHaveAttribute(
|
||
"title",
|
||
(await deliveryTimestamp.getAttribute("datetime"))!,
|
||
);
|
||
await expect(page.getByText("xoxb-e2e-redacted")).toHaveCount(0);
|
||
await expect(page.getByText("teams-client-secret")).toHaveCount(0);
|
||
await expect(page.getByText("github-webhook-secret")).toHaveCount(0);
|
||
|
||
// A provider callback does not cause a Board mutation. Keep this tab
|
||
// mounted and focused: neither navigation nor Replay may refresh it.
|
||
mock.liveActivitySummary = `${provider.name} reaction removed while viewing Activity`;
|
||
mock.setStatus("paused");
|
||
await expect(
|
||
page.getByText(mock.liveActivitySummary, { exact: true }),
|
||
).toBeVisible({ timeout: 8_000 });
|
||
await expect(
|
||
page.getByRole("button", { name: "Resume", exact: true }),
|
||
).toBeVisible({ timeout: 8_000 });
|
||
mock.setStatus("active");
|
||
await expect(
|
||
page.getByRole("button", { name: "Pause", exact: true }),
|
||
).toBeVisible({ timeout: 8_000 });
|
||
|
||
await page.getByRole("button", { name: "Replay" }).click();
|
||
await expect.poll(() => mock.replayedDelivery).toBe(true);
|
||
|
||
await expect(
|
||
page.getByRole("button", { name: "Pause", exact: true }),
|
||
).toBeVisible();
|
||
await expect(
|
||
page.getByRole("button", { name: "Remove connection", exact: true }),
|
||
).toBeVisible();
|
||
await expect(
|
||
page.getByText(PROVIDER_LIFECYCLE_COPY[provider.provider].reconnect, {
|
||
exact: true,
|
||
}),
|
||
).toBeVisible();
|
||
await page.getByRole("button", { name: "Pause", exact: true }).click();
|
||
await expect(
|
||
page.getByRole("button", { name: "Resume", exact: true }),
|
||
).toBeVisible();
|
||
await page.getByRole("button", { name: "Resume", exact: true }).click();
|
||
await expect(
|
||
page.getByRole("button", { name: "Pause", exact: true }),
|
||
).toBeVisible();
|
||
await expect.poll(() => mock.lifecycleActions).toEqual(["pause", "resume"]);
|
||
|
||
mock.setStatus("attention");
|
||
await page.reload();
|
||
await page.getByText("Connection health and controls", { exact: true }).click();
|
||
await expect(
|
||
page.getByRole("button", { name: "Reconnect", exact: true }),
|
||
).toBeVisible();
|
||
await expect(
|
||
page.getByRole("button", { name: "Remove connection" }),
|
||
).toBeVisible();
|
||
await page
|
||
.getByRole("button", { name: "Reconnect", exact: true })
|
||
.click();
|
||
await expect(page).toHaveURL(
|
||
new RegExp(
|
||
`/${seed.prefix}/apps/chat/connect\\?.*resume=endpoint-${provider.provider}`,
|
||
),
|
||
);
|
||
expect(new URL(page.url()).searchParams.get("reconnect")).toBe("1");
|
||
await expect(
|
||
page.getByRole("heading", {
|
||
name:
|
||
provider.provider === "github"
|
||
? "Reconnect GitHub App"
|
||
: provider.provider === "slack" ? "Add Slack credentials" : provider.setupHeading,
|
||
}),
|
||
).toBeVisible();
|
||
await expect(
|
||
page.getByRole("button", { name: "Choose an active agent" }),
|
||
).toHaveCount(0);
|
||
|
||
if (provider.provider === "github") {
|
||
const connectButton = page.getByRole("button", {
|
||
name: provider.setupButton,
|
||
});
|
||
await expect(
|
||
page.getByText(
|
||
/Under the target user or organization, create a new GitHub App/,
|
||
),
|
||
).toHaveCount(0);
|
||
await expect(
|
||
page.getByText(
|
||
/Leave App ID and private key blank to reuse saved credentials/,
|
||
),
|
||
).toBeVisible();
|
||
await page.getByLabel("GitHub App ID").fill("123456");
|
||
await page
|
||
.getByLabel("Private key (PEM)")
|
||
.fill("reconnect-private-key");
|
||
await expect(connectButton).toBeEnabled();
|
||
await page.evaluate((buttonName) => {
|
||
const state = window as typeof window & {
|
||
__githubConnectEnabledAfterRotation?: boolean;
|
||
};
|
||
state.__githubConnectEnabledAfterRotation = false;
|
||
new MutationObserver(() => {
|
||
const button = [...document.querySelectorAll("button")].find(
|
||
(candidate) => candidate.textContent?.trim() === buttonName,
|
||
);
|
||
if (button instanceof HTMLButtonElement && !button.disabled) {
|
||
state.__githubConnectEnabledAfterRotation = true;
|
||
}
|
||
}).observe(document.body, {
|
||
attributes: true,
|
||
childList: true,
|
||
subtree: true,
|
||
});
|
||
}, provider.setupButton);
|
||
await page
|
||
.getByRole("button", { name: "Regenerate webhook secret" })
|
||
.click();
|
||
await expect(connectButton).toBeDisabled();
|
||
await page.waitForTimeout(100);
|
||
expect(
|
||
await page.evaluate(
|
||
() =>
|
||
(
|
||
window as typeof window & {
|
||
__githubConnectEnabledAfterRotation?: boolean;
|
||
}
|
||
).__githubConnectEnabledAfterRotation,
|
||
),
|
||
).toBe(false);
|
||
|
||
await page.goBack();
|
||
await page.getByText("Connection health and controls", { exact: true }).click();
|
||
await expect(
|
||
page.getByRole("heading", { name: "Connection activity" }),
|
||
).toBeVisible();
|
||
await page
|
||
.getByRole("button", { name: "Reconnect", exact: true })
|
||
.click();
|
||
await expect(
|
||
page.getByRole("heading", { name: "Reconnect GitHub App" }),
|
||
).toBeVisible();
|
||
await page.getByLabel("GitHub App ID").fill("123456");
|
||
await page
|
||
.getByLabel("Private key (PEM)")
|
||
.fill("reconnect-private-key");
|
||
await expect(
|
||
page.getByRole("button", { name: provider.setupButton }),
|
||
).toBeDisabled();
|
||
mock.setWebhookVerified();
|
||
await expect(
|
||
page.getByRole("button", { name: provider.setupButton }),
|
||
).toBeEnabled();
|
||
}
|
||
|
||
mock.setStatus("active");
|
||
await page.goto(
|
||
`/${seed.prefix}/apps/chat/endpoint-${provider.provider}/activity`,
|
||
);
|
||
await page.getByText("Connection health and controls", { exact: true }).click();
|
||
await page.getByRole("button", { name: "Remove connection" }).click();
|
||
const confirmation = page.getByRole("alertdialog");
|
||
await expect(confirmation).toContainText("Remove this connection?");
|
||
await expect(confirmation).toContainText(
|
||
PROVIDER_LIFECYCLE_COPY[provider.provider].remove,
|
||
);
|
||
await confirmation
|
||
.getByRole("button", { name: "Remove connection" })
|
||
.click();
|
||
await expect(page).toHaveURL(new RegExp(`/${seed.prefix}/apps$`));
|
||
await expect.poll(() => mock.removed).toBe(true);
|
||
await expect.poll(() => mock.lifecycleActions).toEqual(["pause", "resume", "remove"]);
|
||
});
|
||
}
|
||
});
|
||
|
||
test.describe("iMessage Photon setup and management", () => {
|
||
let seed: Seed;
|
||
const photon: ProviderCase = {
|
||
provider: "imessage-photon",
|
||
slug: "imessage-photon",
|
||
name: "iMessage Photon",
|
||
accountLabel: "Photon Test",
|
||
botLabel: "Maya",
|
||
botUsername: "+15555550100",
|
||
resourceLabel: "Family project",
|
||
secondaryResourceLabel: "Second group",
|
||
resourceType: "group_chat",
|
||
externalUrl: "https://app.photon.codes/",
|
||
setupHeading: /Connect iMessage Photon/,
|
||
setupButton: "Connect selected number",
|
||
chatAndTool: false,
|
||
};
|
||
test.beforeAll(async ({ request }) => {
|
||
seed = await seedCompanyAndAgent(request);
|
||
});
|
||
test("connects Pro shared DMs without presenting a fake owned number or enabling groups", async ({page}) => {
|
||
await installChatControlPlaneMock(page, photon, seed, { enableChatConnectors: true, photonShared: true });
|
||
await page.goto(`/${seed.prefix}/apps`);
|
||
await page.getByRole("button", {name:"Connect iMessage Photon",exact:true}).click();
|
||
await selectMaya(page);
|
||
await page.getByLabel("Project ID").fill("project-e2e");
|
||
await page.getByLabel("Project secret").fill("photon-test-secret");
|
||
await page.getByRole("button", {name:"Inspect Photon project"}).click();
|
||
await expect(page.getByText(/Groups cannot be enabled on this channel/)).toBeVisible();
|
||
await page.getByRole("button", {name:"Connect shared DMs"}).click();
|
||
await expect(page.getByRole("heading", {name:"Try Maya in iMessage Photon"})).toBeVisible();
|
||
await page.reload();
|
||
await expect(page.getByText(/enroll your sender in Users/)).toBeVisible();
|
||
await expect(page.getByRole("button", {name:/Copy \+1555/})).toHaveCount(0);
|
||
await page.getByRole("button", {name:"I've sent the test message"}).click();
|
||
await page.getByRole("navigation", { name: "Chat connection" }).getByRole("link", { name: "Settings", exact: true }).click();
|
||
await expect(page.getByText(/Shared Photon project · direct messages only/)).toBeVisible();
|
||
await expect(page.getByRole("switch", {name:"Enable Family project"})).toBeDisabled();
|
||
await expect(page.getByRole("button", {name:"Copy dedicated number"})).toHaveCount(0);
|
||
});
|
||
for (const theme of ["light", "dark"] as const) {
|
||
test(`discovers dedicated lines and completes the channel wizard (${theme})`, async ({
|
||
page,
|
||
}, testInfo) => {
|
||
const mock = await installChatControlPlaneMock(page, photon, seed, {
|
||
enableChatConnectors: true,
|
||
});
|
||
await page.addInitScript(
|
||
(value) => localStorage.setItem("paperclip.theme", value),
|
||
theme,
|
||
);
|
||
await page.goto(`/${seed.prefix}/apps`);
|
||
const card = await findConnectorBySearch(page, "iMessage Photon", "imessage-photon");
|
||
await card
|
||
.getByRole("button", { name: "Connect iMessage Photon" })
|
||
.click();
|
||
await selectMaya(page);
|
||
await expectSetupRail(page);
|
||
await expect(
|
||
page.getByRole("heading", { name: "Connect iMessage Photon" }),
|
||
).toBeVisible();
|
||
await page.getByLabel("Project ID").fill("project-e2e");
|
||
await page.getByLabel("Project secret").fill("photon-test-secret");
|
||
await expect(page.getByLabel("Project secret")).toHaveAttribute(
|
||
"type",
|
||
"password",
|
||
);
|
||
await page
|
||
.getByRole("button", { name: "Inspect Photon project" })
|
||
.click();
|
||
await expect(
|
||
page.getByRole("button", { name: "Connect selected number" }),
|
||
).toBeDisabled();
|
||
await page
|
||
.getByRole("radio", { name: "+15555550100", exact: true })
|
||
.focus();
|
||
await page.keyboard.press("Space");
|
||
await page
|
||
.getByRole("button", { name: "Connect selected number" })
|
||
.click();
|
||
await expect(
|
||
page.getByRole("heading", { name: "Try Maya in iMessage Photon" }),
|
||
).toBeVisible();
|
||
expect(mock.configuredCredentialKeys).toEqual(["projectSecret"]);
|
||
await expect(
|
||
page.getByRole("button", { name: "Copy +15555550100" }),
|
||
).toBeVisible();
|
||
await expect(
|
||
page.getByText("Link your Messages identity", { exact: true }),
|
||
).toBeVisible();
|
||
await page
|
||
.getByRole("button", { name: "I've sent the test message" })
|
||
.click();
|
||
await page.getByRole("navigation", { name: "Chat connection" }).getByRole("link", { name: "Settings", exact: true }).click();
|
||
await expect(
|
||
page.getByText(/replies are visible to everyone in that group/),
|
||
).toBeVisible();
|
||
const group = page.getByRole("switch", { name: "Enable Family project" });
|
||
await expect(group).not.toBeChecked();
|
||
await group.click();
|
||
await expect(group).toBeChecked();
|
||
await page.setViewportSize({ width: 390, height: 844 });
|
||
await page.getByRole("button", { name: "Open sidebar" }).click();
|
||
await page.getByRole("navigation", { name: "Chat connection" }).getByRole("link", { name: "Activity", exact: true }).click();
|
||
await page.getByText("Connection health and controls", { exact: true }).click();
|
||
await expect(
|
||
page.getByRole("button", { name: "Pause", exact: true }),
|
||
).toBeVisible();
|
||
await page.screenshot({
|
||
path: testInfo.outputPath(`photon-${theme}-mobile.png`),
|
||
fullPage: true,
|
||
});
|
||
expect(
|
||
await page.evaluate(
|
||
() => document.documentElement.scrollWidth <= window.innerWidth,
|
||
),
|
||
).toBe(true);
|
||
await page.getByRole("button", { name: "Pause", exact: true }).click();
|
||
await expect(
|
||
page.getByRole("button", { name: "Resume", exact: true }),
|
||
).toBeVisible();
|
||
await page.getByRole("button", { name: "Resume", exact: true }).click();
|
||
await expect(
|
||
page.getByRole("button", { name: "Pause", exact: true }),
|
||
).toBeVisible();
|
||
});
|
||
}
|
||
});
|