Files
PaperClipAI/tests/e2e/chat-adapters-ui-providers.spec.ts
T
96bc021edb fix(server): certify settled legacy processes for future retries (#19377)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - Bounded retries must preserve current authority, budgets and
deadlines.
> - The tenant can wait for supported future work after the source
process and its cleanup stop.
> - Retained process identity alone cannot prove that cleanup finished.
> - This change records host-owned settlement and checks its exact
identity before a retry can cover its wait.
> - Uncertain process state, changed authority, live claims and cleanup
debt keep the tenant awake.

## Linked Issues or Issue Description

Refs #19001. Related dependencies: #19019 and #18988. Related #19340
covers explicit Native task Retry; this change covers future Legacy
process retries.

Current integrated source: `1645970a9322d1213ba25fc7b52366c285682977`,
normally merged with actual master
`131b566ca4fa866c4348739c75cfe48c805b3398`. The prerequisites above are
merged. This PR targets `master`.

**What happened?**

A future issue, company or review retry can retain its failed source
process identity. The deadline reader could not prove that host cleanup
finished. Supported waits stayed blocked. Ordinary issue retries also
needed an exact source-settlement check, and represented coalesced
requests needed exact input coverage.

**Expected behavior**

A supported future retry covers only its existing wait, under current
company and actor authority, after exact host settlement. Missing,
malformed, stale, cancelled, Native or unfinished source state stays
blocked. No retry gains new execution authority.

**Steps to reproduce**

Fail an ordinary process or fail environment preparation before adapter
entry. Let normal cleanup schedule the existing retry. Inspect its
settlement and deadline, stop and reconstruct the owner, and admit one
useful successor at the saved deadline. Hold cleanup or change authority
to verify that the reader refuses coverage.

## What Changed

- Add nullable internal `legacyProcessSettlement` state through normally
generated migration `0439_cultured_impossible_man`. No backfill or
adapter result creates the record.
- Write exact host settlement after stopped-process and cleanup checks.
Compare the current run and reject live claims or cleanup debt. Optional
persistence failure preserves normal cleanup and keeps sleep coverage
blocked.
- Add a separate preparation receipt for a claimed Legacy run that
failed before any adapter or Native dispatch. Require the same boot, run
times, preparing stage, released claim and completed cleanup. A null PID
alone proves nothing.
- Clear settlement on a new claim, process metadata or Native selection.
Omit the internal record from public serialized data.
- Check exact source lineage and current authority for ordinary issue,
company and taskless retries. Cover completed coalesced requests only
when the admitted retry represents their exact inputs.
- Add the closed `current_review_retry` wait with current reviewer,
stage and existing issue authority checks. Require a nonempty current
stage ID.
- Add actual producer, cold reconstruction, SQL negative, redaction and
optional-persistence controls.
- Repair CI fixture expectations for the additive column, startup mock,
budget owner and popup return URL. Bind the controlled Native comment
server explicitly to loopback.
- Replace temporary Slack clock logging with an exact persisted-deadline
assertion. Wait until that real deadline before the existing single
default poll. Keep both forced polls, business assertions and timing
limits.

## Verification

- On final integrated source `1645970a`, normal Shared/SDK
prerequisites, Runner TypeScript and the entire normal App layered
typecheck pass. Tracked source and lockfile remain unchanged.
- All 26 whole-file settlement, recovery-reader and bootstrap controls
pass. Seven separate real PostgreSQL producer/negative cases pass at
`cf16c6a`; each has one selected passing case and 18 filtered
registrations. Filtered registrations are not acceptance.
- Master then changed the preparation source-lock scope. Its three
intersecting cases were rerun on final `1645970a`: an actual failed
company process, an actual failed ordinary issue process, and held
preparation cleanup. Each preserves its source identity and original
deadline and admits one useful successor. Issue and preparation cases
exercise normal automatic retry; the company case uses the normal
explicit bounded-retry producer.
- The unchanged 26 controls and four other PostgreSQL cases retain their
`cf16c6a` proof after a scoped source comparison. Optional
settlement/preparation persistence failures keep certification blocked
while normal cleanup completes. An entered adapter without process
identity stays uncertified. This is scoped carry, not a claim that every
earlier case ran again on the final head.
- All recorded test owners and controlled groups closed. Final read-only
SQL audits returned no test sessions. One earlier postcase resource
barrier observed a transient unattributed database backend; that
database and original evidence remain preserved. Its later disappearance
is recovery, not an explained cause. No assertion, deadline or test body
was relaxed.
- Migration0439 was generated normally after master history. All
executable SQL and journal history are preserved; the five retained
snapshots are0435–0439. Every Legacy-owned patch and unrelated
actual-master blob/mode is preserved.
- Published exact head `1645970a9322d1213ba25fc7b52366c285682977` passes
all current required checks: 68 successful contexts and eight expected
skips after exhaustive pagination and superseded-run accounting. The
fresh complete Greptile review scores this exact head 5/5. All four
inline review threads are resolved. The latest Ponytail turn stopped
without a verdict; one normal retry was requested, with no active turn
or new finding observed. No old-head score is used. The original
old-base policy failure, its dependent aggregate failure, and the
corrected description-gate failure remain recorded. No full local
repository suite is claimed; remote typecheck, test and build gates
pass.

To repeat the real database coverage, set both
`PAPERCLIP_RECOVERY_DEADLINES_TEST_DATABASE_URL` and
`PAPERCLIP_RECOVERY_DEADLINES_TEST_ADMIN_DATABASE_URL` to the same fresh
dedicated test database and run the selected
`recovery-deadline-acceptance.test.ts` cases with one worker. An unset
URL, skipped case or filtered registration is not acceptance.

The fixtures use controlled local processes and real PostgreSQL.
Reconstruction creates new service owners inside the same test process.
They do not prove deployed Cloud alarms, physical host/database
suspension, new-host recovery or Native capacity settlement. The
original30-second retry remains below the Cloud270-second minimum wake
lead.

The merge projection also includes actual master
`c3180398faca815fed7a62674bd4b0c4e88884ae`, whose common completion path
now sends deferred/recovery notifications. Its unpublished normal merge
tree is `16b028d57150f8caa576e913f38343a365b542dd`. Normal Shared/SDK,
Runner TypeScript and the full App graph pass on that combined source.
One unchanged ordinary-issue acceptance case was rerun there: an actual
failed process retains the original automatic retry deadline, admits one
useful successor, and passes the settlement, authority and no-duplicate
assertions. It passed in 45.055 s; 18 filtered registrations are not
acceptance. All known owners and groups closed, and the independent
final database audit found zero remaining test sessions. The successful
database was removed normally. Recorded worker socket peak is
unavailable for this run; parent-process zero values are not used as
worker measurements. All 29 Legacy-owned paths and every unrelated
master blob and mode are preserved.

Two outside-diff review observations concern existing waiting-chat
compaction and cross-host Native result-lease recovery. Their files are
byte-identical to current master. This focused Legacy change does not
fix or qualify those separate paths.

## Risks

Settlement proves exact local closure. It does not cover live provider
work, Native ownership, cancellation, costs, claims, cleanup debt or
changed authority. Independent checks remain.

Ship normal migration0439 with the reader and writer. Preserve
executable history and the newest five snapshots0435–0439. Generate the
MCP child's next migration normally after this parent.

The prerequisites are merged; the final remote checks must use this
integrated exact head. This proof uses controlled local processes and
external PostgreSQL. It does not establish deployed Cloud alarm
acceptance, physical host or database suspension, cold host recovery, or
R5 Native capacity settlement. The30-second retry is below the Cloud
minimum wake lead.

This focused repair follows the roadmap's bounded recovery and Cloud
deployment work. It adds no new provider policy.

## Model Used

OpenAI `gpt-6.1-sol` assisted with readers, fixtures, source review and
verification through Codex. A coordinating GPT-6-family agent
implemented and reviewed shared lifecycle wiring and integration. Tools
included Git, native TypeScript checks and controlled tests. Exact
served revisions and context limits were not exposed.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used and its available capability
details
- [x] I have checked ROADMAP.md and related work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have linked existing public work and described the issue
- [x] I have not included internal issue IDs or private links
- [x] My branch name describes the change
- [x] Required focused checks pass at their explicitly recorded
source/carry scopes above
- [x] I have added or updated meaningful tests
- [x] I have recorded the internal contract and migration requirements
- [x] I have documented risks and evidence limits
- [x] All required published exact-head Paperclip CI gates are green
- [x] Published exact-head Greptile is 5/5 with no open findings in this
focused diff; separate unchanged master observations are identified
above
- [x] I will address reviewer findings before requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
Co-authored-by: Codex <noreply@openai.com>
2026-11-07 00:56:04 -06:00

1125 lines
59 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 expectCompletedSlackSetupCloses(page: Page, seed: Seed) {
let finishRequests = 0;
await page.route("**/api/chat-endpoints/endpoint-slack/finish", async route => {
finishRequests++;
await route.fulfill({ status: 409, json: { error: "This connection is not waiting for a setup test" } });
});
await page.goto(`/${seed.prefix}/apps/chat/connect?provider=slack&toolHref=%2Fapps%2Fconnect%3Fsource%3Dslack&resume=endpoint-slack`);
await expect(page.getByRole("heading", { name: "Success", exact: true })).toBeVisible();
await expect(page.getByRole("link", { name: "Open Slack", exact: true })).toBeVisible();
await page.getByRole("button", { name: "Done", exact: true }).click();
await expect(page).toHaveURL(/\/apps\/chat\/endpoint-slack\/settings$/);
await page.reload();
await expect(page.getByRole("link", { name: "Open Slack", exact: true })).toBeVisible();
expect(finishRequests).toBe(0);
}
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$/);
await expectCompletedSlackSetupCloses(page, seed);
expect(mock.slackCreations).toBe(1); expect(mock.slackInstallations).toBe(1);
});
test("Slack: a failed manager refresh opens workspace authorization in a separate tab", 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.context().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();
const authorizationOpened = page.context().waitForEvent("page");
await page.getByRole("button", { name: "Reconnect Slack workspace", exact: true }).click();
const authorizationPage = await authorizationOpened;
await expect.poll(() => authorizationRequests).toBe(1);
await expect(authorizationPage).toHaveURL("https://slack.test/manager-consent");
await expect(page).toHaveURL(new RegExp(`/${seed.prefix}/apps/chat/connect\\?provider=slack`));
await authorizationPage.close();
});
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.context().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");
const retryWindowOpened = page.context().waitForEvent("page");
try {
await page.getByRole("button", { name: "Install in Slack", exact: true }).click();
await installation.requested;
await expectNavigationLocked();
} finally { installation.release(); }
const retryConsentWindow = await retryWindowOpened;
await expect(retryConsentWindow.getByRole("heading", { name: "Fixture Slack consent" })).toBeVisible();
await expect(page).toHaveURL(rootUrl);
await retryConsentWindow.getByRole("link", { name: "Approve installation" }).click();
await expect.poll(() => callbackRequests).toBe(2);
await expect(retryConsentWindow).toHaveURL(rootUrl);
await retryConsentWindow.close();
await page.bringToFront();
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$/);
await expectCompletedSlackSetupCloses(page, seed);
expect(mock.slackCreations).toBe(1); expect(mock.slackInstallations).toBe(2);
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 === "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 },
]);
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 === "slack" ? "Add Slack credentials" : provider.setupHeading,
}),
).toBeVisible();
await expect(
page.getByRole("button", { name: "Choose an active agent" }),
).toHaveCount(0);
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();
});
}
});