mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-12 22:56:58 +01:00
## 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>
1125 lines
59 KiB
TypeScript
1125 lines
59 KiB
TypeScript
import { oauthCallbackInterstitialHtml } from "../../server/src/lib/oauth-browser-return";
|
||
import { createServer } from "node:http";
|
||
import { readFile } from "node:fs/promises";
|
||
import { expect, test, type Page, type TestInfo } from "@playwright/test";
|
||
|
||
import {
|
||
expectMinimumProviderSetup,
|
||
expectProviderTryInstructions,
|
||
expectSetupRail,
|
||
fillProviderSetup,
|
||
installChatControlPlaneMock,
|
||
seedCompanyAndAgent,
|
||
selectMaya,
|
||
PROVIDER_LIFECYCLE_COPY,
|
||
PROVIDERS,
|
||
expectedCredentialKeys,
|
||
type ProviderCase,
|
||
type Seed,
|
||
type ChatMock,
|
||
GITHUB_PRIVATE_KEY_PASTE_FIXTURE,
|
||
} from "./chat-adapters-ui.shared";
|
||
|
||
async function 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();
|
||
});
|
||
}
|
||
});
|