Files
PaperClipAI/tests/e2e/chat-adapters-ui-providers.spec.ts
T
DottaandPaperclip ff80845756 fix(ui): show Slack workspace reconnect on saved setup errors (#18837)
## 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>
2026-11-03 15:16:53 -06:00

1187 lines
60 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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();
});
}
});