Three causes accounted for 31 of the 40 flake-signature E2E shard failures on the current 16-shard layout (145 runs, Aug 18-20). networkidle cannot be a gate in this suite, because the suite keeps the network busy: every browser session exports OTel spans to the run's motel on a one-second interval, and Playwright wants 500ms of silence. A page that keeps producing spans never opens that window, so the navigation burns its full 30s on a page that is visibly loaded. Route the 104 goto sites and 9 waitForLoadState sites through visit()/settle(): wait for load, then for React to own the document (__reactContainer$, the interactivity networkidle was standing in for), then bounded quiet. Readiness is the assertion that follows. createEmulatorInstance had no timeout and no retry on the one request a scenario makes to the public internet, so connect ETIMEDOUT and the occasional 502 failed scenarios over the network. ledger.list(n) is the last n entries, so checking both Google rows at the end let the first fall out of the window behind the second account's traffic, and read as "Calendar never reached the emulator" when its probe had already come back healthy. Also arm the GraphQL introspection fault with a budget rather than a count: when the armed 401s run out the emulator answers as GitHub would, 403 rate limited, and the assertion fails on a message about nothing.
Unified · Split
| @@ -24,6 +24,7 @@ import { AuthTemplateSlug, ConnectionName, IntegrationSlug } from "@executor-js/ · 23 unmodified lines | |||
| 24 | import { scenario } from "../src/scenario"; | 24 | import { scenario } from "../src/scenario"; |
| 25 | import { Api, Browser, Target } from "../src/services"; | 25 | import { Api, Browser, Target } from "../src/services"; |
| 26 | import { accountIdOf, forBrowser, joinOrg } from "./support/session"; | 26 | import { accountIdOf, forBrowser, joinOrg } from "./support/session"; |
| 27 | import { visit } from "../src/surfaces/browser"; | ||
| 27 | 28 | ||
| 28 | const api = composePluginApi([openApiHttpPlugin()] as const); | 29 | const api = composePluginApi([openApiHttpPlugin()] as const); |
| 29 | type Client = HttpApiClient.ForApi<typeof api>; | 30 | type Client = HttpApiClient.ForApi<typeof api>; |
| @@ -126,7 +127,7 @@ scenario( · 96 unmodified lines | |||
| 126 | let slug = ""; | 127 | let slug = ""; |
| 127 | 128 | ||
| 128 | await step("Land in the workspace and canonicalize onto the org slug", async () => { | 129 | await step("Land in the workspace and canonicalize onto the org slug", async () => { |
| 129 | await page.goto("/", { waitUntil: "networkidle" }); | 130 | await visit(page, "/"); |
| 130 | await page.waitForURL((url) => /^\/[a-z0-9-]+\/?$/.test(url.pathname), { | 131 | await page.waitForURL((url) => /^\/[a-z0-9-]+\/?$/.test(url.pathname), { |
| 131 | timeout: 30_000, | 132 | timeout: 30_000, |
| 132 | }); | 133 | }); |
| @@ -323,7 +324,7 @@ scenario( · 190 unmodified lines | |||
| 323 | let slug = ""; | 324 | let slug = ""; |
| 324 | 325 | ||
| 325 | await step("Sign in as a plain member of the same workspace", async () => { | 326 | await step("Sign in as a plain member of the same workspace", async () => { |
| 326 | await page.goto("/", { waitUntil: "networkidle" }); | 327 | await visit(page, "/"); |
| 327 | await page.waitForURL((url) => /^\/[a-z0-9-]+\/?$/.test(url.pathname), { | 328 | await page.waitForURL((url) => /^\/[a-z0-9-]+\/?$/.test(url.pathname), { |
| 328 | timeout: 30_000, | 329 | timeout: 30_000, |
| 329 | }); | 330 | }); |
| @@ -345,7 +346,7 @@ scenario( · 15 unmodified lines | |||
| 345 | await step( | 346 | await step( |
| 346 | "Reaching the URL directly says no access, not an empty workspace", | 347 | "Reaching the URL directly says no access, not an empty workspace", |
| 347 | async () => { | 348 | async () => { |
| 348 | await page.goto(`/${slug}/users`, { waitUntil: "networkidle" }); | 349 | await visit(page, `/${slug}/users`); |
| 349 | // The denial is the assertion: an empty table here would misreport | 350 | // The denial is the assertion: an empty table here would misreport |
| 350 | // a populated workspace as having no users. | 351 | // a populated workspace as having no users. |
| 351 | await page | 352 | await page |
| @@ -23,6 +23,7 @@ import { Effect } from "effect"; · 22 unmodified lines | |||
| 23 | 23 | ||
| 24 | import { scenario } from "../src/scenario"; | 24 | import { scenario } from "../src/scenario"; |
| 25 | import { Browser, Target } from "../src/services"; | 25 | import { Browser, Target } from "../src/services"; |
| 26 | import { settle } from "../src/surfaces/browser"; | ||
| 26 | 27 | ||
| 27 | scenario( | 28 | scenario( |
| 28 | "Auth routing · signed out → login → onboarding → dashboard, one browser session", | 29 | "Auth routing · signed out → login → onboarding → dashboard, one browser session", |
| @@ -58,7 +59,7 @@ scenario( · 29 unmodified lines | |||
| 58 | await step("Create the first org → canonical dashboard at /<slug>", async () => { | 59 | await step("Create the first org → canonical dashboard at /<slug>", async () => { |
| 59 | const orgName = "Flow Test Org"; | 60 | const orgName = "Flow Test Org"; |
| 60 | const orgNameInput = page.getByPlaceholder("Northwind Labs"); | 61 | const orgNameInput = page.getByPlaceholder("Northwind Labs"); |
| 61 | await page.waitForLoadState("networkidle"); | 62 | await settle(page); |
| 62 | await orgNameInput.fill(orgName); | 63 | await orgNameInput.fill(orgName); |
| 63 | await page.waitForTimeout(250); | 64 | await page.waitForTimeout(250); |
| 64 | if ((await orgNameInput.inputValue()) !== orgName) { | 65 | if ((await orgNameInput.inputValue()) !== orgName) { |
| @@ -20,6 +20,7 @@ import { Effect } from "effect"; · 19 unmodified lines | |||
| 20 | 20 | ||
| 21 | import { scenario } from "../src/scenario"; | 21 | import { scenario } from "../src/scenario"; |
| 22 | import { Autumn, Billing, Browser, Target } from "../src/services"; | 22 | import { Autumn, Billing, Browser, Target } from "../src/services"; |
| 23 | import { visit } from "../src/surfaces/browser"; | ||
| 23 | 24 | ||
| 24 | scenario( | 25 | scenario( |
| 25 | "Billing · completing the trial checkout shows the new plan without a reload", | 26 | "Billing · completing the trial checkout shows the new plan without a reload", |
| @@ -51,12 +52,12 @@ scenario( · 25 unmodified lines | |||
| 51 | // "/undefined/billing/plans" — the billing fetches fire under the bogus | 52 | // "/undefined/billing/plans" — the billing fetches fire under the bogus |
| 52 | // slug, fail, and are never refetched (autumn-js staleTime 60s), leaving | 53 | // slug, fail, and are never refetched (autumn-js staleTime 60s), leaving |
| 53 | // the plans grid empty forever. | 54 | // the plans grid empty forever. |
| 54 | await page.goto("/", { waitUntil: "networkidle" }); | 55 | await visit(page, "/"); |
| 55 | await page.waitForURL((url) => /^\/[a-z0-9-]+\/?$/.test(url.pathname), { | 56 | await page.waitForURL((url) => /^\/[a-z0-9-]+\/?$/.test(url.pathname), { |
| 56 | timeout: 30_000, | 57 | timeout: 30_000, |
| 57 | }); | 58 | }); |
| 58 | const slug = new URL(page.url()).pathname.split("/").filter(Boolean)[0]; | 59 | const slug = new URL(page.url()).pathname.split("/").filter(Boolean)[0]; |
| 59 | await page.goto(`/${slug}/billing/plans`, { waitUntil: "networkidle" }); | 60 | await visit(page, `/${slug}/billing/plans`); |
| 60 | await page.getByRole("heading", { name: "Choose a plan" }).waitFor(); | 61 | await page.getByRole("heading", { name: "Choose a plan" }).waitFor(); |
| 61 | await startTrial.waitFor(); | 62 | await startTrial.waitFor(); |
| 62 | }); | 63 | }); |
| @@ -6,6 +6,7 @@ import { Effect } from "effect"; · 5 unmodified lines | |||
| 6 | 6 | ||
| 7 | import { scenario } from "../src/scenario"; | 7 | import { scenario } from "../src/scenario"; |
| 8 | import { Browser, Target } from "../src/services"; | 8 | import { Browser, Target } from "../src/services"; |
| 9 | import { visit, settle } from "../src/surfaces/browser"; | ||
| 9 | 10 | ||
| 10 | scenario( | 11 | scenario( |
| 11 | "Connect · the agent-connect panel gives working copy for both transports", | 12 | "Connect · the agent-connect panel gives working copy for both transports", |
| @@ -17,7 +18,7 @@ scenario( · 5 unmodified lines | |||
| 17 | 18 | ||
| 18 | yield* browser.session(identity, async ({ page, step }) => { | 19 | yield* browser.session(identity, async ({ page, step }) => { |
| 19 | await step("Open the Integrations page", async () => { | 20 | await step("Open the Integrations page", async () => { |
| 20 | await page.goto("/", { waitUntil: "networkidle" }); | 21 | await visit(page, "/"); |
| 21 | await page.getByText("Connect an agent").first().waitFor(); | 22 | await page.getByText("Connect an agent").first().waitFor(); |
| 22 | }); | 23 | }); |
| 23 | 24 | ||
| @@ -35,7 +36,7 @@ scenario( · 11 unmodified lines | |||
| 35 | 36 | ||
| 36 | await step("Switch to Standard I/O", async () => { | 37 | await step("Switch to Standard I/O", async () => { |
| 37 | await page.getByRole("tab", { name: "Standard I/O" }).click(); | 38 | await page.getByRole("tab", { name: "Standard I/O" }).click(); |
| 38 | await page.waitForLoadState("networkidle"); | 39 | await settle(page); |
| 39 | }); | 40 | }); |
| 40 | const stdioCommand = await command(); | 41 | const stdioCommand = await command(); |
| 41 | expect(stdioCommand, "the command changed for stdio").not.toBe(httpCommand); | 42 | expect(stdioCommand, "the command changed for stdio").not.toBe(httpCommand); |
| @@ -45,7 +46,7 @@ scenario( · 3 unmodified lines | |||
| 45 | 46 | ||
| 46 | await step("Switch back to Remote HTTP", async () => { | 47 | await step("Switch back to Remote HTTP", async () => { |
| 47 | await page.getByRole("tab", { name: "Remote HTTP" }).click(); | 48 | await page.getByRole("tab", { name: "Remote HTTP" }).click(); |
| 48 | await page.waitForLoadState("networkidle"); | 49 | await settle(page); |
| 49 | }); | 50 | }); |
| 50 | expect(await command(), "the HTTP command is restored").toContain("--transport http"); | 51 | expect(await command(), "the HTTP command is restored").toContain("--transport http"); |
| 51 | }); | 52 | }); |
| @@ -21,6 +21,7 @@ import { serveOAuthTestServer } from "@executor-js/sdk/testing"; · 20 unmodified lines | |||
| 21 | 21 | ||
| 22 | import { scenario } from "../src/scenario"; | 22 | import { scenario } from "../src/scenario"; |
| 23 | import { Api, Browser, Target } from "../src/services"; | 23 | import { Api, Browser, Target } from "../src/services"; |
| 24 | import { visit, settle } from "../src/surfaces/browser"; | ||
| 24 | 25 | ||
| 25 | const api = composePluginApi([openApiHttpPlugin()] as const); | 26 | const api = composePluginApi([openApiHttpPlugin()] as const); |
| 26 | 27 | ||
| @@ -98,7 +99,7 @@ scenario( · 71 unmodified lines | |||
| 98 | const connecting = dialog.getByRole("button", { name: "Connecting…" }); | 99 | const connecting = dialog.getByRole("button", { name: "Connecting…" }); |
| 99 | 100 | ||
| 100 | await step("Open the integration and start a new connection", async () => { | 101 | await step("Open the integration and start a new connection", async () => { |
| 101 | await page.goto(`/integrations/${integration}`, { waitUntil: "networkidle" }); | 102 | await visit(page, `/integrations/${integration}`); |
| 102 | await addConnection.click(); | 103 | await addConnection.click(); |
| 103 | // The registered app is auto-selected, so the OAuth connect button is | 104 | // The registered app is auto-selected, so the OAuth connect button is |
| 104 | // present and enabled. | 105 | // present and enabled. |
| @@ -133,7 +134,7 @@ scenario( · 28 unmodified lines | |||
| 133 | async () => { | 134 | async () => { |
| 134 | await addConnection.click(); | 135 | await addConnection.click(); |
| 135 | await dialog.waitFor({ state: "visible", timeout: 15_000 }); | 136 | await dialog.waitFor({ state: "visible", timeout: 15_000 }); |
| 136 | await page.waitForLoadState("networkidle"); | 137 | await settle(page); |
| 137 | 138 | ||
| 138 | // The guarantee: the reopened modal is reset. Before the fix it stays | 139 | // The guarantee: the reopened modal is reset. Before the fix it stays |
| 139 | // wedged on "Connecting…" (the abandoned flow's busy state survived the | 140 | // wedged on "Connecting…" (the abandoned flow's busy state survived the |
| @@ -19,6 +19,7 @@ import type { Page } from "playwright"; · 18 unmodified lines | |||
| 19 | 19 | ||
| 20 | import { scenario } from "../src/scenario"; | 20 | import { scenario } from "../src/scenario"; |
| 21 | import { Api, Browser, Target } from "../src/services"; | 21 | import { Api, Browser, Target } from "../src/services"; |
| 22 | import { settle } from "../src/surfaces/browser"; | ||
| 22 | 23 | ||
| 23 | /** The display-only identity cookie the SSR gate mints (non-HttpOnly). */ | 24 | /** The display-only identity cookie the SSR gate mints (non-HttpOnly). */ |
| 24 | const HINT_COOKIE = "executor-auth-hint"; | 25 | const HINT_COOKIE = "executor-auth-hint"; |
| @@ -145,7 +146,7 @@ scenario( · 120 unmodified lines | |||
| 145 | await page.waitForURL((url) => url.toString() !== before, { | 146 | await page.waitForURL((url) => url.toString() !== before, { |
| 146 | timeout: 15_000, | 147 | timeout: 15_000, |
| 147 | }); | 148 | }); |
| 148 | await page.waitForLoadState("networkidle"); | 149 | await settle(page); |
| 149 | }); | 150 | }); |
| 150 | 151 | ||
| 151 | const shown = (await page.locator("body").innerText()).trim(); | 152 | const shown = (await page.locator("body").innerText()).trim(); |
| @@ -14,6 +14,7 @@ import { composePluginApi } from "@executor-js/api/server"; · 13 unmodified lines | |||
| 14 | import { scenario } from "../src/scenario"; | 14 | import { scenario } from "../src/scenario"; |
| 15 | import { Api, Browser, Mcp, Target } from "../src/services"; | 15 | import { Api, Browser, Mcp, Target } from "../src/services"; |
| 16 | import { type McpBrowserApproval, parseBrowserApproval } from "../src/surfaces/mcp"; | 16 | import { type McpBrowserApproval, parseBrowserApproval } from "../src/surfaces/mcp"; |
| 17 | import { visit } from "../src/surfaces/browser"; | ||
| 17 | 18 | ||
| 18 | const coreApi = composePluginApi([] as const); | 19 | const coreApi = composePluginApi([] as const); |
| 19 | 20 | ||
| @@ -168,13 +169,13 @@ scenario( · 148 unmodified lines | |||
| 168 | mcpSession.awaitResume(approval.executionId), | 169 | mcpSession.awaitResume(approval.executionId), |
| 169 | browser.session(identity, async ({ page, step }) => { | 170 | browser.session(identity, async ({ page, step }) => { |
| 170 | await step("Land in the original organization", async () => { | 171 | await step("Land in the original organization", async () => { |
| 171 | await page.goto(`/${orgA.slug}`, { waitUntil: "networkidle" }); | 172 | await visit(page, `/${orgA.slug}`); |
| 172 | await expectOrgShell(page, orgA); | 173 | await expectOrgShell(page, orgA); |
| 173 | }); | 174 | }); |
| 174 | 175 | ||
| 175 | await step("The browser session is switched to another organization", async () => { | 176 | await step("The browser session is switched to another organization", async () => { |
| 176 | await setWorkosSessionCookie(page, target.baseUrl, sessionB); | 177 | await setWorkosSessionCookie(page, target.baseUrl, sessionB); |
| 177 | await page.goto(`/${orgB.slug}`, { waitUntil: "networkidle" }); | 178 | await visit(page, `/${orgB.slug}`); |
| 178 | await expectOrgShell(page, orgB); | 179 | await expectOrgShell(page, orgB); |
| 179 | }); | 180 | }); |
| 180 | 181 | ||
| @@ -182,7 +183,7 @@ scenario( · 1 unmodified line | |||
| 182 | const loadRequest = page.waitForRequest(approvalApiRequest(approval, "GET"), { | 183 | const loadRequest = page.waitForRequest(approvalApiRequest(approval, "GET"), { |
| 183 | timeout: 30_000, | 184 | timeout: 30_000, |
| 184 | }); | 185 | }); |
| 185 | await page.goto(approval.approvalUrl, { waitUntil: "networkidle" }); | 186 | await visit(page, approval.approvalUrl); |
| 186 | expect( | 187 | expect( |
| 187 | (await loadRequest).headers()["x-executor-organization"], | 188 | (await loadRequest).headers()["x-executor-organization"], |
| 188 | "loading the approval page scopes the paused execution lookup to org A", | 189 | "loading the approval page scopes the paused execution lookup to org A", |
| @@ -18,6 +18,7 @@ import { scenario } from "../src/scenario"; · 17 unmodified lines | |||
| 18 | import { Api, Browser, Mcp, Target } from "../src/services"; | 18 | import { Api, Browser, Mcp, Target } from "../src/services"; |
| 19 | import { parseBrowserApproval } from "../src/surfaces/mcp"; | 19 | import { parseBrowserApproval } from "../src/surfaces/mcp"; |
| 20 | import type { Identity } from "../src/target"; | 20 | import type { Identity } from "../src/target"; |
| 21 | import { visit } from "../src/surfaces/browser"; | ||
| 21 | 22 | ||
| 22 | const coreApi = composePluginApi([] as const); | 23 | const coreApi = composePluginApi([] as const); |
| 23 | 24 | ||
| @@ -224,7 +225,7 @@ scenario( · 200 unmodified lines | |||
| 224 | ), | 225 | ), |
| 225 | browser.session(identity, async ({ page, step }) => { | 226 | browser.session(identity, async ({ page, step }) => { |
| 226 | await step("Open the paused execution approval page", async () => { | 227 | await step("Open the paused execution approval page", async () => { |
| 227 | await page.goto(pathWithSearch(approval.approvalUrl), { waitUntil: "networkidle" }); | 228 | await visit(page, pathWithSearch(approval.approvalUrl)); |
| 228 | await page.getByText("User approval required").waitFor(); | 229 | await page.getByText("User approval required").waitFor(); |
| 229 | }); | 230 | }); |
| 230 | 231 | ||
| @@ -23,6 +23,7 @@ import { AccountHttpApi } from "@executor-js/api"; · 22 unmodified lines | |||
| 23 | 23 | ||
| 24 | import { scenario } from "../src/scenario"; | 24 | import { scenario } from "../src/scenario"; |
| 25 | import { Api, Billing, Browser, Target } from "../src/services"; | 25 | import { Api, Billing, Browser, Target } from "../src/services"; |
| 26 | import { visit } from "../src/surfaces/browser"; | ||
| 26 | 27 | ||
| 27 | // apps/cloud/src/extensions/billing/plans.ts → MEMBER_LIMITS.free | 28 | // apps/cloud/src/extensions/billing/plans.ts → MEMBER_LIMITS.free |
| 28 | const FREE_MEMBER_SEATS = 3; | 29 | const FREE_MEMBER_SEATS = 3; |
| @@ -45,7 +46,7 @@ scenario( · 16 unmodified lines | |||
| 45 | let slug = ""; | 46 | let slug = ""; |
| 46 | 47 | ||
| 47 | await step("Land in the app and canonicalize onto the org slug", async () => { | 48 | await step("Land in the app and canonicalize onto the org slug", async () => { |
| 48 | await page.goto("/", { waitUntil: "networkidle" }); | 49 | await visit(page, "/"); |
| 49 | await page.waitForURL((url) => /^\/[a-z0-9-]+\/?$/.test(url.pathname), { | 50 | await page.waitForURL((url) => /^\/[a-z0-9-]+\/?$/.test(url.pathname), { |
| 50 | timeout: 30_000, | 51 | timeout: 30_000, |
| 51 | }); | 52 | }); |
| @@ -54,7 +55,7 @@ scenario( · 2 unmodified lines | |||
| 54 | }); | 55 | }); |
| 55 | 56 | ||
| 56 | await step("Open the organization members page", async () => { | 57 | await step("Open the organization members page", async () => { |
| 57 | await page.goto(`/${slug}/org`, { waitUntil: "networkidle" }); | 58 | await visit(page, `/${slug}/org`); |
| 58 | await page.getByRole("button", { name: "Invite member" }).waitFor(); | 59 | await page.getByRole("button", { name: "Invite member" }).waitFor(); |
| 59 | }); | 60 | }); |
| 60 | 61 | ||
| @@ -17,6 +17,7 @@ import { serveOAuthTestServer } from "@executor-js/sdk/testing"; · 16 unmodified lines | |||
| 17 | import { scenario } from "../src/scenario"; | 17 | import { scenario } from "../src/scenario"; |
| 18 | import { Api, Browser, Target } from "../src/services"; | 18 | import { Api, Browser, Target } from "../src/services"; |
| 19 | import type { Identity } from "../src/target"; | 19 | import type { Identity } from "../src/target"; |
| 20 | import { visit } from "../src/surfaces/browser"; | ||
| 20 | 21 | ||
| 21 | const api = composePluginApi([openApiHttpPlugin()] as const); | 22 | const api = composePluginApi([openApiHttpPlugin()] as const); |
| 22 | 23 | ||
| @@ -212,13 +213,13 @@ scenario( · 189 unmodified lines | |||
| 212 | await installSameWindowOAuthPopup(page); | 213 | await installSameWindowOAuthPopup(page); |
| 213 | 214 | ||
| 214 | await step("Land in the original organization", async () => { | 215 | await step("Land in the original organization", async () => { |
| 215 | await page.goto(`/${orgA.slug}`, { waitUntil: "networkidle" }); | 216 | await visit(page, `/${orgA.slug}`); |
| 216 | await expectOrgShell(page, orgA); | 217 | await expectOrgShell(page, orgA); |
| 217 | }); | 218 | }); |
| 218 | 219 | ||
| 219 | await step("The browser session is switched to another organization", async () => { | 220 | await step("The browser session is switched to another organization", async () => { |
| 220 | await setWorkosSessionCookie(page, target.baseUrl, sessionB); | 221 | await setWorkosSessionCookie(page, target.baseUrl, sessionB); |
| 221 | await page.goto(`/${orgB.slug}`, { waitUntil: "networkidle" }); | 222 | await visit(page, `/${orgB.slug}`); |
| 222 | await expectOrgShell(page, orgB); | 223 | await expectOrgShell(page, orgB); |
| 223 | }); | 224 | }); |
| 224 | 225 | ||
| @@ -232,9 +233,7 @@ scenario( · 7 unmodified lines | |||
| 232 | // refetch, and leave "Connect with OAuth" disabled forever (the flake | 233 | // refetch, and leave "Connect with OAuth" disabled forever (the flake |
| 233 | // this step used to have). Waiting for org A's shell before opening | 234 | // this step used to have). Waiting for org A's shell before opening |
| 234 | // the modal makes every modal fetch run under the settled org A scope. | 235 | // the modal makes every modal fetch run under the settled org A scope. |
| 235 | await page.goto(`/${orgA.slug}/integrations/${String(integration)}`, { | 236 | await visit(page, `/${orgA.slug}/integrations/${String(integration)}`); |
| 236 | waitUntil: "networkidle", | ||
| 237 | }); | ||
| 238 | await page.getByRole("button", { name: new RegExp(escapeRegExp(orgA.name)) }).waitFor({ | 237 | await page.getByRole("button", { name: new RegExp(escapeRegExp(orgA.name)) }).waitFor({ |
| 239 | timeout: 30_000, | 238 | timeout: 30_000, |
| 240 | }); | 239 | }); |
| @@ -286,7 +285,7 @@ scenario( · 45 unmodified lines | |||
| 286 | await step("The provider returns to the OAuth callback", async () => { | 285 | await step("The provider returns to the OAuth callback", async () => { |
| 287 | const callbackUrl = await submitProviderLoginFromPage(page); | 286 | const callbackUrl = await submitProviderLoginFromPage(page); |
| 288 | callback = new URL(callbackUrl); | 287 | callback = new URL(callbackUrl); |
| 289 | const response = await page.goto(callbackUrl, { waitUntil: "networkidle" }); | 288 | const response = await visit(page, callbackUrl); |
| 290 | expect(response?.status(), "the callback renders its popup result page").toBe(200); | 289 | expect(response?.status(), "the callback renders its popup result page").toBe(200); |
| 291 | }); | 290 | }); |
| 292 | 291 | ||
| @@ -6,6 +6,7 @@ import { Effect } from "effect"; · 5 unmodified lines | |||
| 6 | 6 | ||
| 7 | import { scenario } from "../src/scenario"; | 7 | import { scenario } from "../src/scenario"; |
| 8 | import { Browser, Target } from "../src/services"; | 8 | import { Browser, Target } from "../src/services"; |
| 9 | import { visit } from "../src/surfaces/browser"; | ||
| 9 | 10 | ||
| 10 | scenario( | 11 | scenario( |
| 11 | "Onboarding · the MCP setup step hands the user their org-scoped MCP server URL", | 12 | "Onboarding · the MCP setup step hands the user their org-scoped MCP server URL", |
| @@ -19,7 +20,7 @@ scenario( · 7 unmodified lines | |||
| 19 | await step( | 20 | await step( |
| 20 | "A fresh user without an org lands on the create-org onboarding page", | 21 | "A fresh user without an org lands on the create-org onboarding page", |
| 21 | async () => { | 22 | async () => { |
| 22 | await page.goto("/", { waitUntil: "networkidle" }); | 23 | await visit(page, "/"); |
| 23 | // Step 1 of 2 — the org-name input is the landmark that proves we're on onboarding. | 24 | // Step 1 of 2 — the org-name input is the landmark that proves we're on onboarding. |
| 24 | await page.getByPlaceholder("Northwind Labs").waitFor(); | 25 | await page.getByPlaceholder("Northwind Labs").waitFor(); |
| 25 | }, | 26 | }, |
| @@ -20,6 +20,7 @@ import { Effect } from "effect"; · 19 unmodified lines | |||
| 20 | import { scenario } from "../src/scenario"; | 20 | import { scenario } from "../src/scenario"; |
| 21 | import { Browser, Target } from "../src/services"; | 21 | import { Browser, Target } from "../src/services"; |
| 22 | import { forBrowser, joinOrg } from "./support/session"; | 22 | import { forBrowser, joinOrg } from "./support/session"; |
| 23 | import { visit } from "../src/surfaces/browser"; | ||
| 23 | 24 | ||
| 24 | declare global { | 25 | declare global { |
| 25 | interface Window { | 26 | interface Window { |
| @@ -45,7 +46,7 @@ scenario( · 19 unmodified lines | |||
| 45 | let slug = ""; | 46 | let slug = ""; |
| 46 | 47 | ||
| 47 | await step("Land in the workspace and open the API keys page", async () => { | 48 | await step("Land in the workspace and open the API keys page", async () => { |
| 48 | await page.goto("/", { waitUntil: "networkidle" }); | 49 | await visit(page, "/"); |
| 49 | await page.waitForURL((url) => /^\/[a-z0-9-]+\/?$/.test(url.pathname), { | 50 | await page.waitForURL((url) => /^\/[a-z0-9-]+\/?$/.test(url.pathname), { |
| 50 | timeout: 30_000, | 51 | timeout: 30_000, |
| 51 | }); | 52 | }); |
| @@ -120,12 +121,12 @@ scenario( · 68 unmodified lines | |||
| 120 | // ── The plain member's view ─────────────────────────────────────────── | 121 | // ── The plain member's view ─────────────────────────────────────────── |
| 121 | yield* browser.session(forBrowser(member), async ({ page, step }) => { | 122 | yield* browser.session(forBrowser(member), async ({ page, step }) => { |
| 122 | await step("A plain member is not shown the Organization keys section", async () => { | 123 | await step("A plain member is not shown the Organization keys section", async () => { |
| 123 | await page.goto("/", { waitUntil: "networkidle" }); | 124 | await visit(page, "/"); |
| 124 | await page.waitForURL((url) => /^\/[a-z0-9-]+\/?$/.test(url.pathname), { | 125 | await page.waitForURL((url) => /^\/[a-z0-9-]+\/?$/.test(url.pathname), { |
| 125 | timeout: 30_000, | 126 | timeout: 30_000, |
| 126 | }); | 127 | }); |
| 127 | const slug = new URL(page.url()).pathname.split("/").filter(Boolean)[0] ?? ""; | 128 | const slug = new URL(page.url()).pathname.split("/").filter(Boolean)[0] ?? ""; |
| 128 | await page.goto(`/${slug}/api-keys`, { waitUntil: "networkidle" }); | 129 | await visit(page, `/${slug}/api-keys`); |
| 129 | // The personal half renders for everyone… | 130 | // The personal half renders for everyone… |
| 130 | await page | 131 | await page |
| 131 | .getByRole("heading", { name: "Personal keys" }) | 132 | .getByRole("heading", { name: "Personal keys" }) |
| @@ -9,6 +9,7 @@ import { Effect } from "effect"; · 8 unmodified lines | |||
| 9 | 9 | ||
| 10 | import { scenario } from "../src/scenario"; | 10 | import { scenario } from "../src/scenario"; |
| 11 | import { Browser, Target } from "../src/services"; | 11 | import { Browser, Target } from "../src/services"; |
| 12 | import { visit, settle } from "../src/surfaces/browser"; | ||
| 12 | 13 | ||
| 13 | scenario( | 14 | scenario( |
| 14 | "Organizations · an admin deletes the organization from settings", | 15 | "Organizations · an admin deletes the organization from settings", |
| @@ -22,7 +23,7 @@ scenario( · 7 unmodified lines | |||
| 22 | const ORG = "Doomed Org"; | 23 | const ORG = "Doomed Org"; |
| 23 | 24 | ||
| 24 | await step("Fresh user creates an org via onboarding", async () => { | 25 | await step("Fresh user creates an org via onboarding", async () => { |
| 25 | await page.goto("/", { waitUntil: "networkidle" }); | 26 | await visit(page, "/"); |
| 26 | await page.getByPlaceholder("Northwind Labs").fill(ORG); | 27 | await page.getByPlaceholder("Northwind Labs").fill(ORG); |
| 27 | await page.getByRole("button", { name: "Create organization" }).click(); | 28 | await page.getByRole("button", { name: "Create organization" }).click(); |
| 28 | await page.getByText("Connect your MCP client").waitFor(); | 29 | await page.getByText("Connect your MCP client").waitFor(); |
| @@ -32,13 +33,13 @@ scenario( · 3 unmodified lines | |||
| 32 | await page.waitForURL((url) => /^\/[a-z0-9-]+\/?$/.test(url.pathname), { | 33 | await page.waitForURL((url) => /^\/[a-z0-9-]+\/?$/.test(url.pathname), { |
| 33 | timeout: 30_000, | 34 | timeout: 30_000, |
| 34 | }); | 35 | }); |
| 35 | await page.waitForLoadState("networkidle"); | 36 | await settle(page); |
| 36 | }); | 37 | }); |
| 37 | 38 | ||
| 38 | const slug = new URL(page.url()).pathname.split("/").filter(Boolean)[0]!; | 39 | const slug = new URL(page.url()).pathname.split("/").filter(Boolean)[0]!; |
| 39 | 40 | ||
| 40 | await step("Open Organization settings and find the danger zone", async () => { | 41 | await step("Open Organization settings and find the danger zone", async () => { |
| 41 | await page.goto(`/${slug}/org`, { waitUntil: "networkidle" }); | 42 | await visit(page, `/${slug}/org`); |
| 42 | // The admin-only danger zone renders (a member would not see it). | 43 | // The admin-only danger zone renders (a member would not see it). |
| 43 | await page.getByText("Permanently delete this organization").waitFor(); | 44 | await page.getByText("Permanently delete this organization").waitFor(); |
| 44 | }); | 45 | }); |
| @@ -13,6 +13,7 @@ import { Effect } from "effect"; · 12 unmodified lines | |||
| 13 | 13 | ||
| 14 | import { scenario } from "../src/scenario"; | 14 | import { scenario } from "../src/scenario"; |
| 15 | import { Browser, Target } from "../src/services"; | 15 | import { Browser, Target } from "../src/services"; |
| 16 | import { visit } from "../src/surfaces/browser"; | ||
| 16 | 17 | ||
| 17 | const CLOUD_ORIGIN_HEADERS = (baseUrl: string) => ({ origin: new URL(baseUrl).origin }); | 18 | const CLOUD_ORIGIN_HEADERS = (baseUrl: string) => ({ origin: new URL(baseUrl).origin }); |
| 18 | 19 | ||
| @@ -64,7 +65,7 @@ scenario( · 45 unmodified lines | |||
| 64 | 65 | ||
| 65 | yield* browser.session(inB, async ({ page, step }) => { | 66 | yield* browser.session(inB, async ({ page, step }) => { |
| 66 | await step("Work in org A by its slug URL (the session still pins org B)", async () => { | 67 | await step("Work in org A by its slug URL (the session still pins org B)", async () => { |
| 67 | await page.goto(`/${slugA}`, { waitUntil: "networkidle" }); | 68 | await visit(page, `/${slugA}`); |
| 68 | await page.getByText("Integrations").first().waitFor({ timeout: 30_000 }); | 69 | await page.getByText("Integrations").first().waitFor({ timeout: 30_000 }); |
| 69 | // The client records the viewed org once /account/me confirms it. | 70 | // The client records the viewed org once /account/me confirms it. |
| 70 | await page.waitForFunction( | 71 | await page.waitForFunction( |
| @@ -75,7 +76,7 @@ scenario( · 4 unmodified lines | |||
| 75 | }); | 76 | }); |
| 76 | 77 | ||
| 77 | await step("A bare entry (`/`) returns to org A, not the session's org B", async () => { | 78 | await step("A bare entry (`/`) returns to org A, not the session's org B", async () => { |
| 78 | await page.goto("/", { waitUntil: "networkidle" }); | 79 | await visit(page, "/"); |
| 79 | await page.waitForURL( | 80 | await page.waitForURL( |
| 80 | (url) => url.pathname === `/${slugA}` || url.pathname === `/${slugA}/`, | 81 | (url) => url.pathname === `/${slugA}` || url.pathname === `/${slugA}/`, |
| 81 | { | 82 | { |
| @@ -86,7 +87,7 @@ scenario( · 4 unmodified lines | |||
| 86 | }); | 87 | }); |
| 87 | 88 | ||
| 88 | await step("A bare deep link keeps its path while landing in org A", async () => { | 89 | await step("A bare deep link keeps its path while landing in org A", async () => { |
| 89 | await page.goto("/policies", { waitUntil: "networkidle" }); | 90 | await visit(page, "/policies"); |
| 90 | await page.waitForURL((url) => url.pathname === `/${slugA}/policies`, { timeout: 30_000 }); | 91 | await page.waitForURL((url) => url.pathname === `/${slugA}/policies`, { timeout: 30_000 }); |
| 91 | await page.getByText("Policies").first().waitFor({ timeout: 30_000 }); | 92 | await page.getByText("Policies").first().waitFor({ timeout: 30_000 }); |
| 92 | }); | 93 | }); |
| @@ -8,6 +8,7 @@ import { Effect } from "effect"; · 7 unmodified lines | |||
| 8 | 8 | ||
| 9 | import { scenario } from "../src/scenario"; | 9 | import { scenario } from "../src/scenario"; |
| 10 | import { Billing, Browser, Target } from "../src/services"; | 10 | import { Billing, Browser, Target } from "../src/services"; |
| 11 | import { visit, settle } from "../src/surfaces/browser"; | ||
| 11 | 12 | ||
| 12 | const FREE_LIMIT = 3; | 13 | const FREE_LIMIT = 3; |
| 13 | 14 | ||
| @@ -23,7 +24,7 @@ scenario( · 9 unmodified lines | |||
| 23 | 24 | ||
| 24 | yield* browser.session(identity, async ({ page, step }) => { | 25 | yield* browser.session(identity, async ({ page, step }) => { |
| 25 | await step("A fresh user lands on onboarding (no organization yet)", async () => { | 26 | await step("A fresh user lands on onboarding (no organization yet)", async () => { |
| 26 | await page.goto("/", { waitUntil: "networkidle" }); | 27 | await visit(page, "/"); |
| 27 | await page.getByPlaceholder("Northwind Labs").waitFor(); | 28 | await page.getByPlaceholder("Northwind Labs").waitFor(); |
| 28 | }); | 29 | }); |
| 29 | 30 | ||
| @@ -43,7 +44,7 @@ scenario( · 13 unmodified lines | |||
| 43 | await page.waitForURL((url) => /^\/[a-z0-9-]+\/?$/.test(url.pathname), { | 44 | await page.waitForURL((url) => /^\/[a-z0-9-]+\/?$/.test(url.pathname), { |
| 44 | timeout: 30_000, | 45 | timeout: 30_000, |
| 45 | }); | 46 | }); |
| 46 | await page.waitForLoadState("networkidle"); | 47 | await settle(page); |
| 47 | }); | 48 | }); |
| 48 | 49 | ||
| 49 | const openCreateOrgModal = async (currentOrg: string) => { | 50 | const openCreateOrgModal = async (currentOrg: string) => { |
| @@ -21,6 +21,7 @@ import { Effect } from "effect"; · 20 unmodified lines | |||
| 21 | 21 | ||
| 22 | import { scenario } from "../src/scenario"; | 22 | import { scenario } from "../src/scenario"; |
| 23 | import { Browser, Target } from "../src/services"; | 23 | import { Browser, Target } from "../src/services"; |
| 24 | import { visit } from "../src/surfaces/browser"; | ||
| 24 | 25 | ||
| 25 | scenario( | 26 | scenario( |
| 26 | "Org tabs · two tabs on different orgs stay independent (URL-scoped, no cookie steal)", | 27 | "Org tabs · two tabs on different orgs stay independent (URL-scoped, no cookie steal)", |
| @@ -53,7 +54,7 @@ scenario( · 26 unmodified lines | |||
| 53 | let slugB = ""; | 54 | let slugB = ""; |
| 54 | 55 | ||
| 55 | await step("Onboard org A in tab 1", async () => { | 56 | await step("Onboard org A in tab 1", async () => { |
| 56 | await tab1.goto("/", { waitUntil: "networkidle" }); | 57 | await visit(tab1, "/"); |
| 57 | await tab1.getByPlaceholder("Northwind Labs").fill("Multitab A"); | 58 | await tab1.getByPlaceholder("Northwind Labs").fill("Multitab A"); |
| 58 | await tab1.getByRole("button", { name: "Create organization" }).click(); | 59 | await tab1.getByRole("button", { name: "Create organization" }).click(); |
| 59 | await tab1.getByText("Connect your MCP client").waitFor({ timeout: 30_000 }); | 60 | await tab1.getByText("Connect your MCP client").waitFor({ timeout: 30_000 }); |
| @@ -83,7 +84,7 @@ scenario( · 23 unmodified lines | |||
| 83 | const tab2 = await tab1.context().newPage(); | 84 | const tab2 = await tab1.context().newPage(); |
| 84 | 85 | ||
| 85 | await step("Tab 2 opens org A's URL and stays in A — no switch, no reload loop", async () => { | 86 | await step("Tab 2 opens org A's URL and stays in A — no switch, no reload loop", async () => { |
| 86 | await tab2.goto(`/${slugA}/policies`, { waitUntil: "networkidle" }); | 87 | await visit(tab2, `/${slugA}/policies`); |
| 87 | await tab2.getByText("Policies").first().waitFor({ timeout: 30_000 }); | 88 | await tab2.getByText("Policies").first().waitFor({ timeout: 30_000 }); |
| 88 | expect(new URL(tab2.url()).pathname, "tab 2 stays on org A's URL").toBe( | 89 | expect(new URL(tab2.url()).pathname, "tab 2 stays on org A's URL").toBe( |
| 89 | `/${slugA}/policies`, | 90 | `/${slugA}/policies`, |
| @@ -134,7 +135,7 @@ scenario( · 44 unmodified lines | |||
| 134 | 135 | ||
| 135 | yield* browser.session(identity, async ({ page, step }) => { | 136 | yield* browser.session(identity, async ({ page, step }) => { |
| 136 | await step("Open the OpenAPI add form", async () => { | 137 | await step("Open the OpenAPI add form", async () => { |
| 137 | await page.goto("/integrations/add/openapi", { waitUntil: "networkidle" }); | 138 | await visit(page, "/integrations/add/openapi"); |
| 138 | await page.getByPlaceholder("https://api.example.com/openapi.json").waitFor(); | 139 | await page.getByPlaceholder("https://api.example.com/openapi.json").waitFor(); |
| 139 | }); | 140 | }); |
| 140 | 141 | ||
| @@ -13,6 +13,7 @@ import { Effect } from "effect"; · 12 unmodified lines | |||
| 13 | 13 | ||
| 14 | import { scenario } from "../src/scenario"; | 14 | import { scenario } from "../src/scenario"; |
| 15 | import { Browser, Target } from "../src/services"; | 15 | import { Browser, Target } from "../src/services"; |
| 16 | import { visit } from "../src/surfaces/browser"; | ||
| 16 | 17 | ||
| 17 | const CLOUD_ORIGIN_HEADERS = (baseUrl: string) => ({ origin: new URL(baseUrl).origin }); | 18 | const CLOUD_ORIGIN_HEADERS = (baseUrl: string) => ({ origin: new URL(baseUrl).origin }); |
| 18 | 19 | ||
| @@ -67,10 +68,10 @@ scenario( · 48 unmodified lines | |||
| 67 | 68 | ||
| 68 | yield* browser.session(inB, async ({ page, step }) => { | 69 | yield* browser.session(inB, async ({ page, step }) => { |
| 69 | await step("Land in org B, then open org A's slug URL directly", async () => { | 70 | await step("Land in org B, then open org A's slug URL directly", async () => { |
| 70 | await page.goto(`/${orgB.slug}`, { waitUntil: "networkidle" }); | 71 | await visit(page, `/${orgB.slug}`); |
| 71 | await page.getByText("Integrations").first().waitFor({ timeout: 30_000 }); | 72 | await page.getByText("Integrations").first().waitFor({ timeout: 30_000 }); |
| 72 | // Open org A by its slug while the cookie is still pinned to B. | 73 | // Open org A by its slug while the cookie is still pinned to B. |
| 73 | await page.goto(`/${slugA}/policies`, { waitUntil: "networkidle" }); | 74 | await visit(page, `/${slugA}/policies`); |
| 74 | }); | 75 | }); |
| 75 | 76 | ||
| 76 | await step("Org A's page renders at its slugged URL — the URL is the scope", async () => { | 77 | await step("Org A's page renders at its slugged URL — the URL is the scope", async () => { |
| @@ -8,6 +8,7 @@ import { Effect } from "effect"; · 7 unmodified lines | |||
| 8 | 8 | ||
| 9 | import { scenario } from "../src/scenario"; | 9 | import { scenario } from "../src/scenario"; |
| 10 | import { Browser, Target } from "../src/services"; | 10 | import { Browser, Target } from "../src/services"; |
| 11 | import { visit, settle } from "../src/surfaces/browser"; | ||
| 11 | 12 | ||
| 12 | scenario( | 13 | scenario( |
| 13 | "Organizations · switching organizations switches the workspace", | 14 | "Organizations · switching organizations switches the workspace", |
| @@ -20,7 +21,7 @@ scenario( · 6 unmodified lines | |||
| 20 | yield* browser.session(identity, async ({ page, step }) => { | 21 | yield* browser.session(identity, async ({ page, step }) => { |
| 21 | // ── Step 1: onboarding, create the first org ───────────────────── | 22 | // ── Step 1: onboarding, create the first org ───────────────────── |
| 22 | await step("Fresh user lands on onboarding (no organization yet)", async () => { | 23 | await step("Fresh user lands on onboarding (no organization yet)", async () => { |
| 23 | await page.goto("/", { waitUntil: "networkidle" }); | 24 | await visit(page, "/"); |
| 24 | await page.getByPlaceholder("Northwind Labs").waitFor(); | 25 | await page.getByPlaceholder("Northwind Labs").waitFor(); |
| 25 | }); | 26 | }); |
| 26 | 27 | ||
| @@ -43,7 +44,7 @@ scenario( · 16 unmodified lines | |||
| 43 | await page.waitForURL((url) => /^\/[a-z0-9-]+\/?$/.test(url.pathname), { | 44 | await page.waitForURL((url) => /^\/[a-z0-9-]+\/?$/.test(url.pathname), { |
| 44 | timeout: 30_000, | 45 | timeout: 30_000, |
| 45 | }); | 46 | }); |
| 46 | await page.waitForLoadState("networkidle"); | 47 | await settle(page); |
| 47 | }); | 48 | }); |
| 48 | 49 | ||
| 49 | // ── Step 2: create the second org via the account-menu switcher ── | 50 | // ── Step 2: create the second org via the account-menu switcher ── |
| @@ -89,7 +90,7 @@ scenario( · 39 unmodified lines | |||
| 89 | // rendered with data-disabled="" (Radix convention). The only item without | 90 | // rendered with data-disabled="" (Radix convention). The only item without |
| 90 | // data-disabled that isn't "Create organization" is ORG_1. | 91 | // data-disabled that isn't "Create organization" is ORG_1. |
| 91 | await step(`Open the org switcher and switch back to "${ORG_1}"`, async () => { | 92 | await step(`Open the org switcher and switch back to "${ORG_1}"`, async () => { |
| 92 | await page.waitForLoadState("networkidle"); | 93 | await settle(page); |
| 93 | await page.getByRole("button", { name: /Test User/ }).click(); | 94 | await page.getByRole("button", { name: /Test User/ }).click(); |
| 94 | // Click the SubTrigger (shows current org name = ORG_2) to expand the list. | 95 | // Click the SubTrigger (shows current org name = ORG_2) to expand the list. |
| 95 | await page.getByRole("menuitem", { name: ORG_2 }).click(); | 96 | await page.getByRole("menuitem", { name: ORG_2 }).click(); |
| @@ -43,6 +43,7 @@ import type { ArtifactId } from "@executor-js/sdk/shared"; · 42 unmodified lines | |||
| 43 | 43 | ||
| 44 | import { scenario } from "../src/scenario"; | 44 | import { scenario } from "../src/scenario"; |
| 45 | import { Api, Browser, Mcp, Target } from "../src/services"; | 45 | import { Api, Browser, Mcp, Target } from "../src/services"; |
| 46 | import { visit } from "../src/surfaces/browser"; | ||
| 46 | 47 | ||
| 47 | const api = composePluginApi([] as const); | 48 | const api = composePluginApi([] as const); |
| 48 | 49 | ||
| @@ -230,7 +231,7 @@ scenario( · 181 unmodified lines | |||
| 230 | yield* browser.session(identity, async ({ page, step }) => { | 231 | yield* browser.session(identity, async ({ page, step }) => { |
| 231 | await step("Open the Artifacts gallery", async () => { | 232 | await step("Open the Artifacts gallery", async () => { |
| 232 | await startSampling(page); | 233 | await startSampling(page); |
| 233 | await page.goto(`${target.baseUrl}/artifacts`, { waitUntil: "networkidle" }); | 234 | await visit(page, `${target.baseUrl}/artifacts`); |
| 234 | await page | 235 | await page |
| 235 | .getByRole("link", { name: `Open artifact ${title}` }) | 236 | .getByRole("link", { name: `Open artifact ${title}` }) |
| 236 | .waitFor({ timeout: 30_000 }); | 237 | .waitFor({ timeout: 30_000 }); |
| @@ -17,6 +17,7 @@ import { Effect } from "effect"; · 16 unmodified lines | |||
| 17 | 17 | ||
| 18 | import { scenario } from "../src/scenario"; | 18 | import { scenario } from "../src/scenario"; |
| 19 | import { Browser, Mcp, Target } from "../src/services"; | 19 | import { Browser, Mcp, Target } from "../src/services"; |
| 20 | import { revisit, visit } from "../src/surfaces/browser"; | ||
| 20 | 21 | ||
| 21 | const uniqueSuffix = () => randomBytes(3).toString("hex"); | 22 | const uniqueSuffix = () => randomBytes(3).toString("hex"); |
| 22 | 23 | ||
| @@ -167,7 +168,7 @@ scenario( · 144 unmodified lines | |||
| 167 | 168 | ||
| 168 | yield* browser.session(identity, async ({ page, step }) => { | 169 | yield* browser.session(identity, async ({ page, step }) => { |
| 169 | await step("Open the Artifacts gallery", async () => { | 170 | await step("Open the Artifacts gallery", async () => { |
| 170 | await page.goto(`${target.baseUrl}/artifacts`, { waitUntil: "networkidle" }); | 171 | await visit(page, `${target.baseUrl}/artifacts`); |
| 171 | await page.getByRole("heading", { name: "Artifacts", level: 1 }).waitFor(); | 172 | await page.getByRole("heading", { name: "Artifacts", level: 1 }).waitFor(); |
| 172 | // Every seeded artifact is present before anything is measured. | 173 | // Every seeded artifact is present before anything is measured. |
| 173 | for (const [name] of seeded) { | 174 | for (const [name] of seeded) { |
| @@ -257,7 +258,7 @@ scenario( · 83 unmodified lines | |||
| 257 | // The stored markup is token-based, so the SAME markup has to read | 258 | // The stored markup is token-based, so the SAME markup has to read |
| 258 | // correctly against the console's dark surface without being restyled. | 259 | // correctly against the console's dark surface without being restyled. |
| 259 | await page.emulateMedia({ colorScheme: "dark" }); | 260 | await page.emulateMedia({ colorScheme: "dark" }); |
| 260 | await page.reload({ waitUntil: "networkidle" }); | 261 | await revisit(page); |
| 261 | await page.getByRole("heading", { name: "Artifacts", level: 1 }).waitFor(); | 262 | await page.getByRole("heading", { name: "Artifacts", level: 1 }).waitFor(); |
| 262 | await page.locator('[data-slot="artifact-preview"]').first().waitFor({ timeout: 20_000 }); | 263 | await page.locator('[data-slot="artifact-preview"]').first().waitFor({ timeout: 20_000 }); |
| 263 | 264 | ||
| @@ -272,7 +273,7 @@ scenario( · 8 unmodified lines | |||
| 272 | 273 | ||
| 273 | await step("Gallery in light mode", async () => { | 274 | await step("Gallery in light mode", async () => { |
| 274 | await page.emulateMedia({ colorScheme: "light" }); | 275 | await page.emulateMedia({ colorScheme: "light" }); |
| 275 | await page.reload({ waitUntil: "networkidle" }); | 276 | await revisit(page); |
| 276 | await page.getByRole("heading", { name: "Artifacts", level: 1 }).waitFor(); | 277 | await page.getByRole("heading", { name: "Artifacts", level: 1 }).waitFor(); |
| 277 | await page.locator('[data-slot="artifact-preview"]').first().waitFor({ timeout: 20_000 }); | 278 | await page.locator('[data-slot="artifact-preview"]').first().waitFor({ timeout: 20_000 }); |
| 278 | }); | 279 | }); |
| @@ -24,6 +24,7 @@ import type { ArtifactId } from "@executor-js/sdk/shared"; · 23 unmodified lines | |||
| 24 | 24 | ||
| 25 | import { scenario } from "../src/scenario"; | 25 | import { scenario } from "../src/scenario"; |
| 26 | import { Api, Browser, Mcp, Target } from "../src/services"; | 26 | import { Api, Browser, Mcp, Target } from "../src/services"; |
| 27 | import { visit } from "../src/surfaces/browser"; | ||
| 27 | 28 | ||
| 28 | const api = composePluginApi([] as const); | 29 | const api = composePluginApi([] as const); |
| 29 | 30 | ||
| @@ -263,7 +264,7 @@ scenario( · 233 unmodified lines | |||
| 263 | 264 | ||
| 264 | await step("Open the artifact link the agent handed over", async () => { | 265 | await step("Open the artifact link the agent handed over", async () => { |
| 265 | await recordHandshakeOrdering(page); | 266 | await recordHandshakeOrdering(page); |
| 266 | await page.goto(url, { waitUntil: "networkidle" }); | 267 | await visit(page, url); |
| 267 | consoleStyleBefore = await readConsoleStyle(page); | 268 | consoleStyleBefore = await readConsoleStyle(page); |
| 268 | }); | 269 | }); |
| 269 | 270 | ||
| @@ -596,7 +597,7 @@ scenario( · 326 unmodified lines | |||
| 596 | 597 | ||
| 597 | yield* browser.session(identity, async ({ page, step }) => { | 598 | yield* browser.session(identity, async ({ page, step }) => { |
| 598 | await step("Open the Artifacts tab", async () => { | 599 | await step("Open the Artifacts tab", async () => { |
| 599 | await page.goto("/artifacts", { waitUntil: "networkidle" }); | 600 | await visit(page, "/artifacts"); |
| 600 | await page.getByRole("link", { name: `Open artifact ${originalTitle}` }).waitFor({ | 601 | await page.getByRole("link", { name: `Open artifact ${originalTitle}` }).waitFor({ |
| 601 | timeout: 20_000, | 602 | timeout: 20_000, |
| 602 | }); | 603 | }); |
| @@ -634,7 +635,7 @@ scenario( · 31 unmodified lines | |||
| 634 | 635 | ||
| 635 | yield* browser.session(identity, async ({ page, step }) => { | 636 | yield* browser.session(identity, async ({ page, step }) => { |
| 636 | await step("Delete the artifact from the list", async () => { | 637 | await step("Delete the artifact from the list", async () => { |
| 637 | await page.goto("/artifacts", { waitUntil: "networkidle" }); | 638 | await visit(page, "/artifacts"); |
| 638 | const card = page.locator('[data-slot="artifact-card"]').filter({ | 639 | const card = page.locator('[data-slot="artifact-card"]').filter({ |
| 639 | hasText: renamedTitle, | 640 | hasText: renamedTitle, |
| 640 | }); | 641 | }); |
| @@ -738,7 +739,7 @@ scenario( · 97 unmodified lines | |||
| 738 | 739 | ||
| 739 | yield* browser.session(identity, async ({ page, step }) => { | 740 | yield* browser.session(identity, async ({ page, step }) => { |
| 740 | await step("The artifact's own URL shows the updated component", async () => { | 741 | await step("The artifact's own URL shows the updated component", async () => { |
| 741 | await page.goto(`/artifacts/${artifactId}`, { waitUntil: "networkidle" }); | 742 | await visit(page, `/artifacts/${artifactId}`); |
| 742 | const content = artifactContent(page); | 743 | const content = artifactContent(page); |
| 743 | await content | 744 | await content |
| 744 | .locator('[data-testid="artifact-marker"]') | 745 | .locator('[data-testid="artifact-marker"]') |
| @@ -15,6 +15,7 @@ import { makeGreetingMcpServer, serveMcpServer } from "@executor-js/plugin-mcp/t · 14 unmodified lines | |||
| 15 | 15 | ||
| 16 | import { scenario } from "../src/scenario"; | 16 | import { scenario } from "../src/scenario"; |
| 17 | import { Browser, Target } from "../src/services"; | 17 | import { Browser, Target } from "../src/services"; |
| 18 | import { visit } from "../src/surfaces/browser"; | ||
| 18 | 19 | ||
| 19 | scenario( | 20 | scenario( |
| 20 | "Auth methods · the add flow declares an API key alongside the detected method", | 21 | "Auth methods · the add flow declares an API key alongside the detected method", |
| @@ -34,9 +35,7 @@ scenario( · 13 unmodified lines | |||
| 34 | 35 | ||
| 35 | yield* browser.session(identity, async ({ page, step }) => { | 36 | yield* browser.session(identity, async ({ page, step }) => { |
| 36 | await step("Open the add-MCP flow pointed at the server", async () => { | 37 | await step("Open the add-MCP flow pointed at the server", async () => { |
| 37 | await page.goto(`/integrations/add/mcp?url=${encodeURIComponent(server.endpoint)}`, { | 38 | await visit(page, `/integrations/add/mcp?url=${encodeURIComponent(server.endpoint)}`); |
| 38 | waitUntil: "networkidle", | ||
| 39 | }); | ||
| 40 | // The URL auto-probes (debounced); the method list appears once | 39 | // The URL auto-probes (debounced); the method list appears once |
| 41 | // the probe lands. Generous timeout: the probe request can queue | 40 | // the probe lands. Generous timeout: the probe request can queue |
| 42 | // behind a busy dev server under CI load, and there is no clean | 41 | // behind a busy dev server under CI load, and there is no clean |
| @@ -25,6 +25,7 @@ import { Api, Browser, Mcp, Target } from "../src/services"; · 24 unmodified lines | |||
| 25 | import { type McpBrowserApproval, parseBrowserApproval } from "../src/surfaces/mcp"; | 25 | import { type McpBrowserApproval, parseBrowserApproval } from "../src/surfaces/mcp"; |
| 26 | import type { BrowserSurface } from "../src/surfaces/browser"; | 26 | import type { BrowserSurface } from "../src/surfaces/browser"; |
| 27 | import type { Identity } from "../src/target"; | 27 | import type { Identity } from "../src/target"; |
| 28 | import { visit } from "../src/surfaces/browser"; | ||
| 28 | 29 | ||
| 29 | const coreApi = composePluginApi([] as const); | 30 | const coreApi = composePluginApi([] as const); |
| 30 | 31 | ||
| @@ -70,7 +71,7 @@ const decideInBrowser = ( · 39 unmodified lines | |||
| 70 | await step( | 71 | await step( |
| 71 | `Open the approval page and ${decision.toLowerCase()} the paused action`, | 72 | `Open the approval page and ${decision.toLowerCase()} the paused action`, |
| 72 | async () => { | 73 | async () => { |
| 73 | await page.goto(approval.approvalUrl, { waitUntil: "networkidle" }); | 74 | await visit(page, approval.approvalUrl); |
| 74 | await page.getByRole("button", { name: decision }).click(); | 75 | await page.getByRole("button", { name: decision }).click(); |
| 75 | // The page confirms the decision was recorded ("Approve sent" / "Decline sent"). | 76 | // The page confirms the decision was recorded ("Approve sent" / "Decline sent"). |
| 76 | await page.getByText(`${decision} sent`).waitFor(); | 77 | await page.getByText(`${decision} sent`).waitFor(); |
| @@ -30,6 +30,7 @@ import { · 29 unmodified lines | |||
| 30 | 30 | ||
| 31 | import { scenario } from "../src/scenario"; | 31 | import { scenario } from "../src/scenario"; |
| 32 | import { Api, Browser, Target } from "../src/services"; | 32 | import { Api, Browser, Target } from "../src/services"; |
| 33 | import { visit } from "../src/surfaces/browser"; | ||
| 33 | 34 | ||
| 34 | const api = composePluginApi([openApiHttpPlugin()] as const); | 35 | const api = composePluginApi([openApiHttpPlugin()] as const); |
| 35 | 36 | ||
| @@ -95,7 +96,7 @@ scenario( · 59 unmodified lines | |||
| 95 | 96 | ||
| 96 | yield* browser.session(identity, async ({ page, step }) => { | 97 | yield* browser.session(identity, async ({ page, step }) => { |
| 97 | await step("Open the connect deep link for the seeded integration", async () => { | 98 | await step("Open the connect deep link for the seeded integration", async () => { |
| 98 | await page.goto(`/connect/${slug}`, { waitUntil: "networkidle" }); | 99 | await visit(page, `/connect/${slug}`); |
| 99 | // It forwards into the integration's OWN detail route rather than | 100 | // It forwards into the integration's OWN detail route rather than |
| 100 | // rendering a parallel connect UI, carrying the add-account handoff. | 101 | // rendering a parallel connect UI, carrying the add-account handoff. |
| 101 | await page.waitForURL((url) => url.pathname === detailPath, { timeout: 30_000 }); | 102 | await page.waitForURL((url) => url.pathname === detailPath, { timeout: 30_000 }); |
| @@ -115,7 +116,7 @@ scenario( · 13 unmodified lines | |||
| 115 | }); | 116 | }); |
| 116 | 117 | ||
| 117 | await step("An unknown slug is a clear not-found, not a blank page", async () => { | 118 | await step("An unknown slug is a clear not-found, not a blank page", async () => { |
| 118 | await page.goto("/connect/zz-no-such-integration", { waitUntil: "networkidle" }); | 119 | await visit(page, "/connect/zz-no-such-integration"); |
| 119 | await page | 120 | await page |
| 120 | .getByText("Unknown integration: zz-no-such-integration") | 121 | .getByText("Unknown integration: zz-no-such-integration") |
| 121 | .waitFor({ timeout: 30_000 }); | 122 | .waitFor({ timeout: 30_000 }); |
| @@ -139,7 +140,7 @@ scenario( · 17 unmodified lines | |||
| 139 | 140 | ||
| 140 | yield* browser.session(identity, async ({ page, step }) => { | 141 | yield* browser.session(identity, async ({ page, step }) => { |
| 141 | await step("The deep link surfaces the existing connection", async () => { | 142 | await step("The deep link surfaces the existing connection", async () => { |
| 142 | await page.goto(`/connect/${slug}`, { waitUntil: "networkidle" }); | 143 | await visit(page, `/connect/${slug}`); |
| 143 | await page.waitForURL((url) => url.pathname === detailPath, { timeout: 30_000 }); | 144 | await page.waitForURL((url) => url.pathname === detailPath, { timeout: 30_000 }); |
| 144 | // The connect flow still opens (this link means "connect me"), and | 145 | // The connect flow still opens (this link means "connect me"), and |
| 145 | // the already-saved account is visible behind it. | 146 | // the already-saved account is visible behind it. |
| @@ -20,6 +20,7 @@ import { scenario } from "../src/scenario"; · 19 unmodified lines | |||
| 20 | import { Browser, Cli, Mcp, RunDir, Target } from "../src/services"; | 20 | import { Browser, Cli, Mcp, RunDir, Target } from "../src/services"; |
| 21 | import { withChatTheater } from "../src/clients/chat-theater"; | 21 | import { withChatTheater } from "../src/clients/chat-theater"; |
| 22 | import type { McpSession } from "../src/surfaces/mcp"; | 22 | import type { McpSession } from "../src/surfaces/mcp"; |
| 23 | import { visit } from "../src/surfaces/browser"; | ||
| 23 | 24 | ||
| 24 | const unique = (prefix: string) => `${prefix}_${randomBytes(4).toString("hex")}`; | 25 | const unique = (prefix: string) => `${prefix}_${randomBytes(4).toString("hex")}`; |
| 25 | 26 | ||
| @@ -148,7 +149,7 @@ scenario( · 122 unmodified lines | |||
| 148 | yield* chat.status("you, in the browser: opening the link and pasting the API key…"); | 149 | yield* chat.status("you, in the browser: opening the link and pasting the API key…"); |
| 149 | yield* browser.session(identity, async ({ page, step }) => { | 150 | yield* browser.session(identity, async ({ page, step }) => { |
| 150 | await step("Open the connect link from the chat", async () => { | 151 | await step("Open the connect link from the chat", async () => { |
| 151 | await page.goto(handoffUrl, { waitUntil: "networkidle" }); | 152 | await visit(page, handoffUrl); |
| 152 | await page | 153 | await page |
| 153 | .getByRole("heading", { name: /Add connection/ }) | 154 | .getByRole("heading", { name: /Add connection/ }) |
| 154 | .waitFor({ timeout: 15_000 }); | 155 | .waitFor({ timeout: 15_000 }); |
| @@ -32,6 +32,7 @@ import { Api, Browser, Mcp, Target } from "../src/services"; · 31 unmodified lines | |||
| 32 | import type { Identity, Target as TargetShape } from "../src/target"; | 32 | import type { Identity, Target as TargetShape } from "../src/target"; |
| 33 | import type { BrowserSurface } from "../src/surfaces/browser"; | 33 | import type { BrowserSurface } from "../src/surfaces/browser"; |
| 34 | import type { McpSession } from "../src/surfaces/mcp"; | 34 | import type { McpSession } from "../src/surfaces/mcp"; |
| 35 | import { visit } from "../src/surfaces/browser"; | ||
| 35 | 36 | ||
| 36 | const api = composePluginApi([openApiHttpPlugin()] as const); | 37 | const api = composePluginApi([openApiHttpPlugin()] as const); |
| 37 | 38 | ||
| @@ -214,7 +215,7 @@ const runScenario = (input: { · 176 unmodified lines | |||
| 214 | // 4. The user opens the handoff URL and pastes the credential. | 215 | // 4. The user opens the handoff URL and pastes the credential. |
| 215 | yield* browser.session(identity, async ({ page, step }) => { | 216 | yield* browser.session(identity, async ({ page, step }) => { |
| 216 | await step("Open the handoff URL from the agent", async () => { | 217 | await step("Open the handoff URL from the agent", async () => { |
| 217 | await page.goto(handoffUrl, { waitUntil: "networkidle" }); | 218 | await visit(page, handoffUrl); |
| 218 | }); | 219 | }); |
| 219 | 220 | ||
| 220 | await step("The Add connection modal is open", async () => { | 221 | await step("The Add connection modal is open", async () => { |
| @@ -14,6 +14,7 @@ import { AuthTemplateSlug, ConnectionName, IntegrationSlug } from "@executor-js/ · 13 unmodified lines | |||
| 14 | 14 | ||
| 15 | import { scenario } from "../src/scenario"; | 15 | import { scenario } from "../src/scenario"; |
| 16 | import { Api, Browser, Target } from "../src/services"; | 16 | import { Api, Browser, Target } from "../src/services"; |
| 17 | import { visit } from "../src/surfaces/browser"; | ||
| 17 | 18 | ||
| 18 | const api = composePluginApi([openApiHttpPlugin()] as const); | 19 | const api = composePluginApi([openApiHttpPlugin()] as const); |
| 19 | 20 | ||
| @@ -77,7 +78,7 @@ scenario( · 57 unmodified lines | |||
| 77 | const confirm = page.getByRole("alertdialog"); | 78 | const confirm = page.getByRole("alertdialog"); |
| 78 | 79 | ||
| 79 | await step("Open the integration's connections", async () => { | 80 | await step("Open the integration's connections", async () => { |
| 80 | await page.goto(`/integrations/${slug}`, { waitUntil: "networkidle" }); | 81 | await visit(page, `/integrations/${slug}`); |
| 81 | await row.waitFor(); | 82 | await row.waitFor(); |
| 82 | }); | 83 | }); |
| 83 | 84 | ||
| @@ -3,6 +3,7 @@ import { Effect } from "effect"; · 2 unmodified lines | |||
| 3 | 3 | ||
| 4 | import { scenario } from "../src/scenario"; | 4 | import { scenario } from "../src/scenario"; |
| 5 | import { Browser, Target } from "../src/services"; | 5 | import { Browser, Target } from "../src/services"; |
| 6 | import { visit } from "../src/surfaces/browser"; | ||
| 6 | 7 | ||
| 7 | // A user inside the console should be able to reach the documentation without | 8 | // A user inside the console should be able to reach the documentation without |
| 8 | // leaving the app for the marketing site (the original friction: docs were | 9 | // leaving the app for the marketing site (the original friction: docs were |
| @@ -18,7 +19,7 @@ scenario( · 9 unmodified lines | |||
| 18 | 19 | ||
| 19 | yield* browser.session(identity, async ({ page, step }) => { | 20 | yield* browser.session(identity, async ({ page, step }) => { |
| 20 | await step("Open the console", async () => { | 21 | await step("Open the console", async () => { |
| 21 | await page.goto("/", { waitUntil: "networkidle" }); | 22 | await visit(page, "/"); |
| 22 | await page.getByText("Integrations").first().waitFor(); | 23 | await page.getByText("Integrations").first().waitFor(); |
| 23 | }); | 24 | }); |
| 24 | 25 | ||
| @@ -26,7 +26,7 @@ import { · 25 unmodified lines | |||
| 26 | 26 | ||
| 27 | import { scenario } from "../src/scenario"; | 27 | import { scenario } from "../src/scenario"; |
| 28 | import { Api, Browser, Target } from "../src/services"; | 28 | import { Api, Browser, Target } from "../src/services"; |
| 29 | import { clickToReveal } from "../src/surfaces/browser"; | 29 | import { clickToReveal, visit } from "../src/surfaces/browser"; |
| 30 | 30 | ||
| 31 | const api = composePluginApi([openApiHttpPlugin()] as const); | 31 | const api = composePluginApi([openApiHttpPlugin()] as const); |
| 32 | 32 | ||
| @@ -182,7 +182,7 @@ scenario( · 149 unmodified lines | |||
| 182 | 182 | ||
| 183 | yield* browser.session(identity, async ({ page, step }) => { | 183 | yield* browser.session(identity, async ({ page, step }) => { |
| 184 | await step("Open the Google integration catalog", async () => { | 184 | await step("Open the Google integration catalog", async () => { |
| 185 | await page.goto("/integrations", { waitUntil: "networkidle" }); | 185 | await visit(page, "/integrations"); |
| 186 | const dialog = page.getByRole("dialog", { name: "Connect an integration" }); | 186 | const dialog = page.getByRole("dialog", { name: "Connect an integration" }); |
| 187 | await clickToReveal(page.getByRole("button", { name: /Connect/ }).first(), dialog); | 187 | await clickToReveal(page.getByRole("button", { name: /Connect/ }).first(), dialog); |
| 188 | }); | 188 | }); |
| @@ -13,10 +13,11 @@ import { · 12 unmodified lines | |||
| 13 | OAuthClientSlug, | 13 | OAuthClientSlug, |
| 14 | } from "@executor-js/sdk/shared"; | 14 | } from "@executor-js/sdk/shared"; |
| 15 | 15 | ||
| 16 | import { createEmulatorInstance } from "../src/emulator-instance"; | ||
| 16 | import { scenario } from "../src/scenario"; | 17 | import { scenario } from "../src/scenario"; |
| 17 | import { Api, Browser, Target } from "../src/services"; | 18 | import { Api, Browser, Target } from "../src/services"; |
| 18 | import type { Identity, Target as TargetShape } from "../src/target"; | 19 | import type { Identity, Target as TargetShape } from "../src/target"; |
| 19 | import { clickToReveal, type BrowserSurface } from "../src/surfaces/browser"; | 20 | import { type BrowserSurface, clickToReveal, visit } from "../src/surfaces/browser"; |
| 20 | 21 | ||
| 21 | const api = composePluginApi([openApiHttpPlugin()] as const); | 22 | const api = composePluginApi([openApiHttpPlugin()] as const); |
| 22 | type Client = HttpApiClient.ForApi<typeof api>; | 23 | type Client = HttpApiClient.ForApi<typeof api>; |
| @@ -69,14 +70,13 @@ const completeGoogleConsent = (authorizationUrl: string) => · 46 unmodified lines | |||
| 69 | return code; | 70 | return code; |
| 70 | }); | 71 | }); |
| 71 | 72 | ||
| 72 | const createGoogleEmulator = Effect.promise(async () => { | 73 | const createGoogleEmulator = Effect.gen(function* () { |
| 73 | const response = await fetch("https://google.emulators.dev/_emulate/instances", { | 74 | // Through the shared helper: it bounds and retries the one request in this |
| 74 | method: "POST", | 75 | // scenario that leaves the runner, so a blip on the way to the edge doesn't |
| 75 | }); | 76 | // read as a Google health-check failure. |
| 76 | if (!response.ok) throw new Error(`Google emulator instance failed: ${response.status}`); | 77 | const baseUrl = yield* createEmulatorInstance("google", "google-health"); |
| 77 | const instance = (await response.json()) as { readonly providerBaseUrl: string }; | 78 | const client = yield* Effect.promise(() => connectEmulator({ baseUrl })); |
| 78 | const client = await connectEmulator({ baseUrl: instance.providerBaseUrl }); | 79 | return { client, baseUrl }; |
| 79 | return { client, baseUrl: instance.providerBaseUrl }; | ||
| 80 | }); | 80 | }); |
| 81 | 81 | ||
| 82 | const addGooglePresetFromCatalog = ( | 82 | const addGooglePresetFromCatalog = ( |
| @@ -87,7 +87,7 @@ const addGooglePresetFromCatalog = ( · 4 unmodified lines | |||
| 87 | ) => | 87 | ) => |
| 88 | browser.session(identity, async ({ page, step }) => { | 88 | browser.session(identity, async ({ page, step }) => { |
| 89 | await step(`Open ${presetName} from the connect catalog`, async () => { | 89 | await step(`Open ${presetName} from the connect catalog`, async () => { |
| 90 | await page.goto("/integrations", { waitUntil: "networkidle" }); | 90 | await visit(page, "/integrations"); |
| 91 | const dialog = page.getByRole("dialog", { name: "Connect an integration" }); | 91 | const dialog = page.getByRole("dialog", { name: "Connect an integration" }); |
| 92 | await clickToReveal(page.getByRole("button", { name: /Connect/ }).first(), dialog); | 92 | await clickToReveal(page.getByRole("button", { name: /Connect/ }).first(), dialog); |
| 93 | await dialog.getByPlaceholder(/Search or paste a URL/).fill(presetName); | 93 | await dialog.getByPlaceholder(/Search or paste a URL/).fill(presetName); |
| @@ -266,29 +266,31 @@ scenario( · 172 unmodified lines | |||
| 266 | health.status, | 266 | health.status, |
| 267 | `${row.presetName} health check is healthy: ${JSON.stringify(health)}`, | 267 | `${row.presetName} health check is healthy: ${JSON.stringify(health)}`, |
| 268 | ).toBe("healthy"); | 268 | ).toBe("healthy"); |
| 269 | } | ||
| 270 | 269 | ||
| 271 | // The hosted emulator acknowledges a request before its ledger entry | 270 | // Check this row's ledger entry HERE, not after both rows have run. |
| 272 | // is readable, so a single list right after the probe races the write | 271 | // `ledger.list(n)` is the last n entries, and connecting the second |
| 273 | // (the health checks above already came back healthy, which only the | 272 | // account is easily a hundred emulator requests, so the first row's |
| 274 | // emulator can answer). Poll until every expected operation is | 273 | // health check can be evicted from the window before a combined |
| 275 | // visible; on timeout the assertion names what never arrived. | 274 | // assertion at the end ever looks for it — which reads as "Calendar's |
| 276 | const missing = yield* Effect.gen(function* () { | 275 | // health check never reached the emulator" when it plainly did (the |
| 277 | const ledger = yield* Effect.promise(() => emulator.client.ledger.list(100)); | 276 | // probe above came back healthy, and only the emulator can answer |
| 278 | return rows.filter( | 277 | // that). The hosted emulator also acknowledges a request before its |
| 279 | (row) => !ledger.some((entry) => entry.operationId === row.expectedLedgerOperation), | 278 | // ledger entry is readable, so poll rather than read once. |
| 279 | const reached = yield* Effect.promise(() => emulator.client.ledger.list(50)).pipe( | ||
| 280 | Effect.map((ledger) => | ||
| 281 | ledger.some((entry) => entry.operationId === row.expectedLedgerOperation), | ||
| 282 | ), | ||
| 283 | Effect.repeat({ | ||
| 284 | schedule: Schedule.spaced("500 millis"), | ||
| 285 | until: (seen) => seen, | ||
| 286 | times: 19, | ||
| 287 | }), | ||
| 280 | ); | 288 | ); |
| 281 | }).pipe( | 289 | expect( |
| 282 | Effect.repeat({ | 290 | reached, |
| 283 | schedule: Schedule.spaced("500 millis"), | 291 | `${row.presetName}'s health check reached the Google emulator as ${row.expectedLedgerOperation}`, |
| 284 | until: (unseen) => unseen.length === 0, | 292 | ).toBe(true); |
| 285 | times: 19, | 293 | } |
| 286 | }), | ||
| 287 | ); | ||
| 288 | expect( | ||
| 289 | missing.map((row) => row.presetName), | ||
| 290 | "every health check reached the Google emulator", | ||
| 291 | ).toEqual([]); | ||
| 292 | }), | 294 | }), |
| 293 | Effect.gen(function* () { | 295 | Effect.gen(function* () { |
| 294 | for (const row of rows) { | 296 | for (const row of rows) { |
| @@ -3,7 +3,7 @@ import { Effect } from "effect"; · 2 unmodified lines | |||
| 3 | 3 | ||
| 4 | import { scenario } from "../src/scenario"; | 4 | import { scenario } from "../src/scenario"; |
| 5 | import { Browser, Target } from "../src/services"; | 5 | import { Browser, Target } from "../src/services"; |
| 6 | import { clickToReveal } from "../src/surfaces/browser"; | 6 | import { clickToReveal, visit } from "../src/surfaces/browser"; |
| 7 | 7 | ||
| 8 | scenario( | 8 | scenario( |
| 9 | "Google Photos: separated catalog presets open a Photos service add flow", | 9 | "Google Photos: separated catalog presets open a Photos service add flow", |
| @@ -17,7 +17,7 @@ scenario( · 7 unmodified lines | |||
| 17 | await step( | 17 | await step( |
| 18 | "Find the separated Google Photos presets from the integrations picker", | 18 | "Find the separated Google Photos presets from the integrations picker", |
| 19 | async () => { | 19 | async () => { |
| 20 | await page.goto("/integrations", { waitUntil: "networkidle" }); | 20 | await visit(page, "/integrations"); |
| 21 | const dialog = page.getByRole("dialog", { name: "Connect an integration" }); | 21 | const dialog = page.getByRole("dialog", { name: "Connect an integration" }); |
| 22 | await clickToReveal(page.getByRole("button", { name: "Connect" }), dialog); | 22 | await clickToReveal(page.getByRole("button", { name: "Connect" }), dialog); |
| 23 | await dialog.getByPlaceholder(/Search or paste a URL/).fill("google photos"); | 23 | await dialog.getByPlaceholder(/Search or paste a URL/).fill("google photos"); |
| @@ -11,6 +11,7 @@ import { variable } from "@executor-js/sdk/http-auth"; · 10 unmodified lines | |||
| 11 | import { createEmulatorInstance } from "../src/emulator-instance"; | 11 | import { createEmulatorInstance } from "../src/emulator-instance"; |
| 12 | import { scenario } from "../src/scenario"; | 12 | import { scenario } from "../src/scenario"; |
| 13 | import { Api, Browser, Target } from "../src/services"; | 13 | import { Api, Browser, Target } from "../src/services"; |
| 14 | import { visit } from "../src/surfaces/browser"; | ||
| 14 | 15 | ||
| 15 | const api = composePluginApi([graphqlHttpPlugin()] as const); | 16 | const api = composePluginApi([graphqlHttpPlugin()] as const); |
| 16 | const unique = (prefix: string): string => `${prefix}_${randomBytes(4).toString("hex")}`; | 17 | const unique = (prefix: string): string => `${prefix}_${randomBytes(4).toString("hex")}`; |
| @@ -30,11 +31,18 @@ scenario( · 13 unmodified lines | |||
| 30 | connectEmulator({ baseUrl: emulatorBaseUrl, service: "github" }), | 31 | connectEmulator({ baseUrl: emulatorBaseUrl, service: "github" }), |
| 31 | ); | 32 | ); |
| 32 | 33 | ||
| 34 | // A budget, not a count: nothing in this scenario depends on how many | ||
| 35 | // times the connect flow introspects, and the emulator's answer when the | ||
| 36 | // budget runs out is not a neutral pass-through — an unauthenticated | ||
| 37 | // GraphQL POST to the real handler is GitHub-shaped, so it comes back 403 | ||
| 38 | // "API rate limit exceeded". The UI then honestly reports HTTP 403 and the | ||
| 39 | // assertion below fails on a message that has nothing to do with the | ||
| 40 | // product. Arm enough that one connect attempt cannot exhaust it. | ||
| 33 | yield* Effect.promise(() => | 41 | yield* Effect.promise(() => |
| 34 | emulator.faults.arm({ | 42 | emulator.faults.arm({ |
| 35 | match: { method: "POST", pathPattern: "/graphql" }, | 43 | match: { method: "POST", pathPattern: "/graphql" }, |
| 36 | response: { status: 401, body: { message: "Bad credentials" } }, | 44 | response: { status: 401, body: { message: "Bad credentials" } }, |
| 37 | times: 10, | 45 | times: 100, |
| 38 | }), | 46 | }), |
| 39 | ); | 47 | ); |
| 40 | 48 | ||
| @@ -56,9 +64,7 @@ scenario( · 15 unmodified lines | |||
| 56 | yield* Effect.gen(function* () { | 64 | yield* Effect.gen(function* () { |
| 57 | yield* browser.session(identity, async ({ page, step }) => { | 65 | yield* browser.session(identity, async ({ page, step }) => { |
| 58 | await step("Open the connection flow", async () => { | 66 | await step("Open the connection flow", async () => { |
| 59 | await page.goto(`/integrations/${slug}?addAccount=1&owner=org&template=header`, { | 67 | await visit(page, `/integrations/${slug}?addAccount=1&owner=org&template=header`); |
| 60 | waitUntil: "networkidle", | ||
| 61 | }); | ||
| 62 | await page.getByRole("heading", { name: /Add connection · GraphQL health/ }).waitFor(); | 68 | await page.getByRole("heading", { name: /Add connection · GraphQL health/ }).waitFor(); |
| 63 | }); | 69 | }); |
| 64 | 70 | ||
| @@ -97,7 +103,7 @@ scenario( · 32 unmodified lines | |||
| 97 | 103 | ||
| 98 | yield* browser.session(identity, async ({ page, step }) => { | 104 | yield* browser.session(identity, async ({ page, step }) => { |
| 99 | await step("A failed existing connection explains the empty tool catalogue", async () => { | 105 | await step("A failed existing connection explains the empty tool catalogue", async () => { |
| 100 | await page.goto(`/integrations/${slug}?tab=tools`, { waitUntil: "networkidle" }); | 106 | await visit(page, `/integrations/${slug}?tab=tools`); |
| 101 | await page.getByText("Connection rejected", { exact: true }).first().waitFor(); | 107 | await page.getByText("Connection rejected", { exact: true }).first().waitFor(); |
| 102 | await page | 108 | await page |
| 103 | .getByText("The endpoint rejected the credential with HTTP 401.", { | 109 | .getByText("The endpoint rejected the credential with HTTP 401.", { |
| @@ -34,6 +34,7 @@ import { AuthTemplateSlug, ConnectionName, IntegrationSlug } from "@executor-js/ · 33 unmodified lines | |||
| 34 | 34 | ||
| 35 | import { scenario } from "../src/scenario"; | 35 | import { scenario } from "../src/scenario"; |
| 36 | import { Api, Browser, Target } from "../src/services"; | 36 | import { Api, Browser, Target } from "../src/services"; |
| 37 | import { visit } from "../src/surfaces/browser"; | ||
| 37 | 38 | ||
| 38 | const api = composePluginApi([openApiHttpPlugin()] as const); | 39 | const api = composePluginApi([openApiHttpPlugin()] as const); |
| 39 | type Client = HttpApiClient.ForApi<typeof api>; | 40 | type Client = HttpApiClient.ForApi<typeof api>; |
| @@ -281,7 +282,7 @@ scenario( · 241 unmodified lines | |||
| 281 | const dialog = page.getByRole("dialog"); | 282 | const dialog = page.getByRole("dialog"); |
| 282 | 283 | ||
| 283 | await step("Open the Add Connection modal", async () => { | 284 | await step("Open the Add Connection modal", async () => { |
| 284 | await page.goto(`/integrations/${slug}`, { waitUntil: "networkidle" }); | 285 | await visit(page, `/integrations/${slug}`); |
| 285 | await page.getByRole("button", { name: "Add connection", exact: true }).click(); | 286 | await page.getByRole("button", { name: "Add connection", exact: true }).click(); |
| 286 | await page.getByRole("heading", { name: /Add connection/ }).waitFor(); | 287 | await page.getByRole("heading", { name: /Add connection/ }).waitFor(); |
| 287 | }); | 288 | }); |
| @@ -395,7 +396,7 @@ scenario( · 107 unmodified lines | |||
| 395 | const options = page.getByRole("option"); | 396 | const options = page.getByRole("option"); |
| 396 | 397 | ||
| 397 | await step("Open the health-check editor over the large spec", async () => { | 398 | await step("Open the health-check editor over the large spec", async () => { |
| 398 | await page.goto(`/integrations/${slug}`, { waitUntil: "networkidle" }); | 399 | await visit(page, `/integrations/${slug}`); |
| 399 | await page.getByRole("heading", { level: 3, name: "Health check" }).waitFor(); | 400 | await page.getByRole("heading", { level: 3, name: "Health check" }).waitFor(); |
| 400 | await page.getByRole("button", { name: "Set up" }).click(); | 401 | await page.getByRole("button", { name: "Set up" }).click(); |
| 401 | await input.waitFor(); | 402 | await input.waitFor(); |
| @@ -479,7 +480,7 @@ scenario( · 77 unmodified lines | |||
| 479 | const options = page.getByRole("option"); | 480 | const options = page.getByRole("option"); |
| 480 | 481 | ||
| 481 | await step("Open the Add form and paste the large spec", async () => { | 482 | await step("Open the Add form and paste the large spec", async () => { |
| 482 | await page.goto("/integrations/add/openapi", { waitUntil: "networkidle" }); | 483 | await visit(page, "/integrations/add/openapi"); |
| 483 | await page.getByPlaceholder("https://api.example.com/openapi.json").fill(spec); | 484 | await page.getByPlaceholder("https://api.example.com/openapi.json").fill(spec); |
| 484 | await page | 485 | await page |
| 485 | .getByRole("heading", { name: "Health check (optional)" }) | 486 | .getByRole("heading", { name: "Health check (optional)" }) |
| @@ -558,7 +559,7 @@ scenario( · 72 unmodified lines | |||
| 558 | const operationInput = page.locator("#health-check-operation"); | 559 | const operationInput = page.locator("#health-check-operation"); |
| 559 | 560 | ||
| 560 | await step("Open the health-check editor", async () => { | 561 | await step("Open the health-check editor", async () => { |
| 561 | await page.goto(`/integrations/${slug}`, { waitUntil: "networkidle" }); | 562 | await visit(page, `/integrations/${slug}`); |
| 562 | await page.getByRole("heading", { level: 3, name: "Health check" }).waitFor(); | 563 | await page.getByRole("heading", { level: 3, name: "Health check" }).waitFor(); |
| 563 | await page.getByRole("button", { name: "Set up" }).click(); | 564 | await page.getByRole("button", { name: "Set up" }).click(); |
| 564 | await operationInput.waitFor(); | 565 | await operationInput.waitFor(); |
| @@ -629,7 +630,7 @@ scenario( · 64 unmodified lines | |||
| 629 | const list = page.locator("[data-slot='combobox-list']").first(); | 630 | const list = page.locator("[data-slot='combobox-list']").first(); |
| 630 | 631 | ||
| 631 | await step("Open the operation combobox in the sheet", async () => { | 632 | await step("Open the operation combobox in the sheet", async () => { |
| 632 | await page.goto(`/integrations/${slug}`, { waitUntil: "networkidle" }); | 633 | await visit(page, `/integrations/${slug}`); |
| 633 | await page.getByRole("heading", { level: 3, name: "Health check" }).waitFor(); | 634 | await page.getByRole("heading", { level: 3, name: "Health check" }).waitFor(); |
| 634 | await page.getByRole("button", { name: "Set up" }).click(); | 635 | await page.getByRole("button", { name: "Set up" }).click(); |
| 635 | await operationInput.waitFor(); | 636 | await operationInput.waitFor(); |
| @@ -705,7 +706,7 @@ scenario( · 69 unmodified lines | |||
| 705 | const menuTrigger = connections.locator('button[aria-haspopup="menu"]'); | 706 | const menuTrigger = connections.locator('button[aria-haspopup="menu"]'); |
| 706 | 707 | ||
| 707 | await step("Open the integration's connections", async () => { | 708 | await step("Open the integration's connections", async () => { |
| 708 | await page.goto(`/integrations/${slug}`, { waitUntil: "networkidle" }); | 709 | await visit(page, `/integrations/${slug}`); |
| 709 | await connections.getByText("main", { exact: true }).waitFor(); | 710 | await connections.getByText("main", { exact: true }).waitFor(); |
| 710 | await page.getByRole("heading", { level: 3, name: "Health check" }).waitFor(); | 711 | await page.getByRole("heading", { level: 3, name: "Health check" }).waitFor(); |
| 711 | }); | 712 | }); |
| @@ -795,7 +796,7 @@ scenario( · 83 unmodified lines | |||
| 795 | const dialog = page.getByRole("dialog"); | 796 | const dialog = page.getByRole("dialog"); |
| 796 | 797 | ||
| 797 | await step("Open the Add Connection modal", async () => { | 798 | await step("Open the Add Connection modal", async () => { |
| 798 | await page.goto(`/integrations/${slug}`, { waitUntil: "networkidle" }); | 799 | await visit(page, `/integrations/${slug}`); |
| 799 | await page.getByRole("button", { name: "Add connection", exact: true }).click(); | 800 | await page.getByRole("button", { name: "Add connection", exact: true }).click(); |
| 800 | await page.getByRole("heading", { name: /Add connection/ }).waitFor(); | 801 | await page.getByRole("heading", { name: /Add connection/ }).waitFor(); |
| 801 | }); | 802 | }); |
| @@ -888,7 +889,7 @@ scenario( · 86 unmodified lines | |||
| 888 | await step( | 889 | await step( |
| 889 | "A fresh page load shows the expired connection with NO clicking", | 890 | "A fresh page load shows the expired connection with NO clicking", |
| 890 | async () => { | 891 | async () => { |
| 891 | await page.goto(`/integrations/${slug}`, { waitUntil: "networkidle" }); | 892 | await visit(page, `/integrations/${slug}`); |
| 892 | // The persisted verdict drives the row: red dot + Expired badge | 893 | // The persisted verdict drives the row: red dot + Expired badge |
| 893 | // are already there on first paint of the list. | 894 | // are already there on first paint of the list. |
| 894 | await connections.getByLabel("Status: Expired").waitFor({ timeout: 30_000 }); | 895 | await connections.getByLabel("Status: Expired").waitFor({ timeout: 30_000 }); |
| @@ -907,7 +908,7 @@ scenario( · 12 unmodified lines | |||
| 907 | // not after the freshness window). Restore the key, reload, | 908 | // not after the freshness window). Restore the key, reload, |
| 908 | // and the dot flips back with no clicks. | 909 | // and the dot flips back with no clicks. |
| 909 | server.restore(); | 910 | server.restore(); |
| 910 | await page.goto(`/integrations/${slug}`, { waitUntil: "networkidle" }); | 911 | await visit(page, `/integrations/${slug}`); |
| 911 | // The row mounts with the stale expired verdict, then the | 912 | // The row mounts with the stale expired verdict, then the |
| 912 | // background revalidation flips it to healthy in place. | 913 | // background revalidation flips it to healthy in place. |
| 913 | await connections.getByLabel("Status: Healthy").waitFor({ timeout: 30_000 }); | 914 | await connections.getByLabel("Status: Healthy").waitFor({ timeout: 30_000 }); |
| @@ -993,7 +994,7 @@ scenario( · 79 unmodified lines | |||
| 993 | // while the operation combobox still renders (a healthy probe | 994 | // while the operation combobox still renders (a healthy probe |
| 994 | // saves the check and turns the request line static). | 995 | // saves the check and turns the request line static). |
| 995 | await page.setViewportSize({ width: 1280, height: 420 }); | 996 | await page.setViewportSize({ width: 1280, height: 420 }); |
| 996 | await page.goto(`/integrations/${slug}`, { waitUntil: "networkidle" }); | 997 | await visit(page, `/integrations/${slug}`); |
| 997 | await page.getByRole("button", { name: "Add connection", exact: true }).click(); | 998 | await page.getByRole("button", { name: "Add connection", exact: true }).click(); |
| 998 | await page.getByRole("heading", { name: /Add connection/ }).waitFor(); | 999 | await page.getByRole("heading", { name: /Add connection/ }).waitFor(); |
| 999 | await page.keyboard.type(goodToken); | 1000 | await page.keyboard.type(goodToken); |
| @@ -1136,7 +1137,7 @@ scenario( · 136 unmodified lines | |||
| 1136 | await step( | 1137 | await step( |
| 1137 | "Load the integrations list: the dead MCP row reads Expired with no clicks", | 1138 | "Load the integrations list: the dead MCP row reads Expired with no clicks", |
| 1138 | async () => { | 1139 | async () => { |
| 1139 | await page.goto("/", { waitUntil: "networkidle" }); | 1140 | await visit(page, "/"); |
| 1140 | // The row itself is the assertion surface: the list-page | 1141 | // The row itself is the assertion surface: the list-page |
| 1141 | // summary probes in the background and paints the worst-of | 1142 | // summary probes in the background and paints the worst-of |
| 1142 | // verdict onto the row, scoped so a verdict from another row | 1143 | // verdict onto the row, scoped so a verdict from another row |
| @@ -14,6 +14,7 @@ import { makeMutableCatalogMcpServer, serveMcpServer } from "@executor-js/plugin · 13 unmodified lines | |||
| 14 | 14 | ||
| 15 | import { scenario } from "../src/scenario"; | 15 | import { scenario } from "../src/scenario"; |
| 16 | import { Browser, Target } from "../src/services"; | 16 | import { Browser, Target } from "../src/services"; |
| 17 | import { revisit, visit } from "../src/surfaces/browser"; | ||
| 17 | 18 | ||
| 18 | scenario( | 19 | scenario( |
| 19 | "MCP catalog · the Tools tab follows a server-side rename after a list_changed notification", | 20 | "MCP catalog · the Tools tab follows a server-side rename after a list_changed notification", |
| @@ -35,9 +36,7 @@ scenario( · 15 unmodified lines | |||
| 35 | 36 | ||
| 36 | yield* browser.session(identity, async ({ page, step }) => { | 37 | yield* browser.session(identity, async ({ page, step }) => { |
| 37 | await step("Open the add-MCP flow pointed at the live server", async () => { | 38 | await step("Open the add-MCP flow pointed at the live server", async () => { |
| 38 | await page.goto(`/integrations/add/mcp?url=${encodeURIComponent(server.endpoint)}`, { | 39 | await visit(page, `/integrations/add/mcp?url=${encodeURIComponent(server.endpoint)}`); |
| 39 | waitUntil: "networkidle", | ||
| 40 | }); | ||
| 41 | // The URL auto-probes (debounced); the method list appears once the | 40 | // The URL auto-probes (debounced); the method list appears once the |
| 42 | // probe lands — an open server seeds the detected no-auth method. | 41 | // probe lands — an open server seeds the detected no-auth method. |
| 43 | await page.getByText("How does this server authenticate?").waitFor(); | 42 | await page.getByText("How does this server authenticate?").waitFor(); |
| @@ -95,7 +94,7 @@ scenario( · 51 unmodified lines | |||
| 95 | // Re-enter the page: a fresh tools read. The list_changed the server | 94 | // Re-enter the page: a fresh tools read. The list_changed the server |
| 96 | // sent during the call marked the catalog stale, so THIS read | 95 | // sent during the call marked the catalog stale, so THIS read |
| 97 | // re-lists — the renamed tool appears with no Refresh click. | 96 | // re-lists — the renamed tool appears with no Refresh click. |
| 98 | await page.reload({ waitUntil: "networkidle" }); | 97 | await revisit(page); |
| 99 | await page.getByRole("tab", { name: "Tools" }).click(); | 98 | await page.getByRole("tab", { name: "Tools" }).click(); |
| 100 | await filterTools("greet"); | 99 | await filterTools("greet"); |
| 101 | await toolRow(mutable.renamedToolName).waitFor({ timeout: 30_000 }); | 100 | await toolRow(mutable.renamedToolName).waitFor({ timeout: 30_000 }); |
| @@ -31,6 +31,7 @@ import { serveOAuthTestServer } from "@executor-js/sdk/testing"; · 30 unmodified lines | |||
| 31 | 31 | ||
| 32 | import { scenario } from "../src/scenario"; | 32 | import { scenario } from "../src/scenario"; |
| 33 | import { Api, Browser, Target } from "../src/services"; | 33 | import { Api, Browser, Target } from "../src/services"; |
| 34 | import { visit } from "../src/surfaces/browser"; | ||
| 34 | 35 | ||
| 35 | const api = composePluginApi([openApiHttpPlugin()] as const); | 36 | const api = composePluginApi([openApiHttpPlugin()] as const); |
| 36 | 37 | ||
| @@ -153,9 +154,7 @@ scenario( · 116 unmodified lines | |||
| 153 | 154 | ||
| 154 | yield* browser.session(identity, async ({ page, step }) => { | 155 | yield* browser.session(identity, async ({ page, step }) => { |
| 155 | await step("Open the connect modal for an OAuth integration", async () => { | 156 | await step("Open the connect modal for an OAuth integration", async () => { |
| 156 | await page.goto(`/integrations/${String(integration)}?addAccount=1`, { | 157 | await visit(page, `/integrations/${String(integration)}?addAccount=1`); |
| 157 | waitUntil: "networkidle", | ||
| 158 | }); | ||
| 159 | await page.getByRole("button", { name: "Register app", exact: true }).click(); | 158 | await page.getByRole("button", { name: "Register app", exact: true }).click(); |
| 160 | }); | 159 | }); |
| 161 | 160 | ||
| @@ -37,6 +37,7 @@ import { createEmulatorInstance } from "../src/emulator-instance"; · 36 unmodified lines | |||
| 37 | import { scenario } from "../src/scenario"; | 37 | import { scenario } from "../src/scenario"; |
| 38 | import { Api, Browser, Mcp, Target } from "../src/services"; | 38 | import { Api, Browser, Mcp, Target } from "../src/services"; |
| 39 | import type { McpSession } from "../src/surfaces/mcp"; | 39 | import type { McpSession } from "../src/surfaces/mcp"; |
| 40 | import { visit } from "../src/surfaces/browser"; | ||
| 40 | 41 | ||
| 41 | const microsoftApi = composePluginApi([ | 42 | const microsoftApi = composePluginApi([ |
| 42 | openApiHttpPlugin({ presets: microsoftCatalog, specFormats: [microsoftGraphAdapter] }), | 43 | openApiHttpPlugin({ presets: microsoftCatalog, specFormats: [microsoftGraphAdapter] }), |
| @@ -400,7 +401,7 @@ scenario( · 357 unmodified lines | |||
| 400 | // pre-filled from the handoff. They type ONLY the secret. | 401 | // pre-filled from the handoff. They type ONLY the secret. |
| 401 | yield* browser.session(identity, async ({ page, step }) => { | 402 | yield* browser.session(identity, async ({ page, step }) => { |
| 402 | await step("Open the agent's handoff URL", async () => { | 403 | await step("Open the agent's handoff URL", async () => { |
| 403 | await page.goto(handoffUrl, { waitUntil: "networkidle" }); | 404 | await visit(page, handoffUrl); |
| 404 | }); | 405 | }); |
| 405 | 406 | ||
| 406 | await step("The Register-OAuth-app form auto-opens, pre-filled", async () => { | 407 | await step("The Register-OAuth-app form auto-opens, pre-filled", async () => { |
| @@ -9,6 +9,7 @@ import { Effect } from "effect"; · 8 unmodified lines | |||
| 9 | 9 | ||
| 10 | import { scenario } from "../src/scenario"; | 10 | import { scenario } from "../src/scenario"; |
| 11 | import { Browser, Target } from "../src/services"; | 11 | import { Browser, Target } from "../src/services"; |
| 12 | import { visit } from "../src/surfaces/browser"; | ||
| 12 | 13 | ||
| 13 | // One server so "Add integration" is enabled straight from the preview (no | 14 | // One server so "Add integration" is enabled straight from the preview (no |
| 14 | // base-URL picker to resolve first). | 15 | // base-URL picker to resolve first). |
| @@ -32,7 +33,7 @@ scenario( · 17 unmodified lines | |||
| 32 | 33 | ||
| 33 | yield* browser.session(identity, async ({ page, step }) => { | 34 | yield* browser.session(identity, async ({ page, step }) => { |
| 34 | await step("Open the Add OpenAPI integration form", async () => { | 35 | await step("Open the Add OpenAPI integration form", async () => { |
| 35 | await page.goto("/integrations/add/openapi", { waitUntil: "networkidle" }); | 36 | await visit(page, "/integrations/add/openapi"); |
| 36 | await page.getByPlaceholder("https://api.example.com/openapi.json").waitFor(); | 37 | await page.getByPlaceholder("https://api.example.com/openapi.json").waitFor(); |
| 37 | }); | 38 | }); |
| 38 | 39 | ||
| @@ -13,6 +13,7 @@ import { IntegrationSlug } from "@executor-js/sdk/shared"; · 12 unmodified lines | |||
| 13 | 13 | ||
| 14 | import { scenario } from "../src/scenario"; | 14 | import { scenario } from "../src/scenario"; |
| 15 | import { Api, Browser, Target } from "../src/services"; | 15 | import { Api, Browser, Target } from "../src/services"; |
| 16 | import { visit } from "../src/surfaces/browser"; | ||
| 16 | 17 | ||
| 17 | const api = composePluginApi([openApiHttpPlugin()] as const); | 18 | const api = composePluginApi([openApiHttpPlugin()] as const); |
| 18 | 19 | ||
| @@ -61,7 +62,7 @@ scenario( · 42 unmodified lines | |||
| 61 | 62 | ||
| 62 | yield* browser.session(identity, async ({ page, step }) => { | 63 | yield* browser.session(identity, async ({ page, step }) => { |
| 63 | await step("Open the Cloudflare-style integration", async () => { | 64 | await step("Open the Cloudflare-style integration", async () => { |
| 64 | await page.goto(`/integrations/${slug}`, { waitUntil: "networkidle" }); | 65 | await visit(page, `/integrations/${slug}`); |
| 65 | await page.getByText("Connections").first().waitFor(); | 66 | await page.getByText("Connections").first().waitFor(); |
| 66 | }); | 67 | }); |
| 67 | 68 | ||
| @@ -9,6 +9,7 @@ import { Effect } from "effect"; · 8 unmodified lines | |||
| 9 | 9 | ||
| 10 | import { scenario } from "../src/scenario"; | 10 | import { scenario } from "../src/scenario"; |
| 11 | import { Browser, Target } from "../src/services"; | 11 | import { Browser, Target } from "../src/services"; |
| 12 | import { visit } from "../src/surfaces/browser"; | ||
| 12 | 13 | ||
| 13 | // Two servers — production and staging — so the form offers a base-URL picker | 14 | // Two servers — production and staging — so the form offers a base-URL picker |
| 14 | // instead of the single locked input a one-server spec gets. | 15 | // instead of the single locked input a one-server spec gets. |
| @@ -35,7 +36,7 @@ scenario( · 20 unmodified lines | |||
| 35 | 36 | ||
| 36 | yield* browser.session(identity, async ({ page, step }) => { | 37 | yield* browser.session(identity, async ({ page, step }) => { |
| 37 | await step("Open the Add OpenAPI integration form", async () => { | 38 | await step("Open the Add OpenAPI integration form", async () => { |
| 38 | await page.goto("/integrations/add/openapi", { waitUntil: "networkidle" }); | 39 | await visit(page, "/integrations/add/openapi"); |
| 39 | await page.getByPlaceholder("https://api.example.com/openapi.json").waitFor(); | 40 | await page.getByPlaceholder("https://api.example.com/openapi.json").waitFor(); |
| 40 | }); | 41 | }); |
| 41 | 42 | ||
| @@ -18,6 +18,7 @@ import { AccountHttpApi, isValidOrgSlug } from "@executor-js/api"; · 17 unmodified lines | |||
| 18 | 18 | ||
| 19 | import { scenario } from "../src/scenario"; | 19 | import { scenario } from "../src/scenario"; |
| 20 | import { Api, Browser, Target } from "../src/services"; | 20 | import { Api, Browser, Target } from "../src/services"; |
| 21 | import { visit } from "../src/surfaces/browser"; | ||
| 21 | 22 | ||
| 22 | scenario( | 23 | scenario( |
| 23 | "Org URLs · console paths carry the organization slug", | 24 | "Org URLs · console paths carry the organization slug", |
| @@ -38,7 +39,7 @@ scenario( · 14 unmodified lines | |||
| 38 | 39 | ||
| 39 | yield* browser.session(identity, async ({ page, step }) => { | 40 | yield* browser.session(identity, async ({ page, step }) => { |
| 40 | await step("A bare deep link canonicalizes onto the org slug", async () => { | 41 | await step("A bare deep link canonicalizes onto the org slug", async () => { |
| 41 | await page.goto("/policies", { waitUntil: "networkidle" }); | 42 | await visit(page, "/policies"); |
| 42 | await page.waitForURL((url) => url.pathname === `/${slug}/policies`, { | 43 | await page.waitForURL((url) => url.pathname === `/${slug}/policies`, { |
| 43 | timeout: 30_000, | 44 | timeout: 30_000, |
| 44 | }); | 45 | }); |
| @@ -52,13 +53,13 @@ scenario( · 7 unmodified lines | |||
| 52 | // legitimately does not. | 53 | // legitimately does not. |
| 53 | if (!target.name.startsWith("selfhost")) { | 54 | if (!target.name.startsWith("selfhost")) { |
| 54 | await step("An unknown org slug is a wrong address, not a redirect", async () => { | 55 | await step("An unknown org slug is a wrong address, not a redirect", async () => { |
| 55 | await page.goto("/zz-no-such-org/policies", { waitUntil: "networkidle" }); | 56 | await visit(page, "/zz-no-such-org/policies"); |
| 56 | await page.getByText("Page not found").waitFor({ timeout: 30_000 }); | 57 | await page.getByText("Page not found").waitFor({ timeout: 30_000 }); |
| 57 | }); | 58 | }); |
| 58 | } | 59 | } |
| 59 | 60 | ||
| 60 | await step("In-shell navigation keeps the slug prefix", async () => { | 61 | await step("In-shell navigation keeps the slug prefix", async () => { |
| 61 | await page.goto(`/${slug}`, { waitUntil: "networkidle" }); | 62 | await visit(page, `/${slug}`); |
| 62 | await page.getByRole("link", { name: "Policies" }).first().click(); | 63 | await page.getByRole("link", { name: "Policies" }).first().click(); |
| 63 | await page.waitForURL((url) => url.pathname === `/${slug}/policies`, { | 64 | await page.waitForURL((url) => url.pathname === `/${slug}/policies`, { |
| 64 | timeout: 30_000, | 65 | timeout: 30_000, |
| @@ -27,6 +27,7 @@ import { AuthTemplateSlug, ConnectionName, IntegrationSlug } from "@executor-js/ · 26 unmodified lines | |||
| 27 | 27 | ||
| 28 | import { scenario } from "../src/scenario"; | 28 | import { scenario } from "../src/scenario"; |
| 29 | import { Api, Browser, Target } from "../src/services"; | 29 | import { Api, Browser, Target } from "../src/services"; |
| 30 | import { visit } from "../src/surfaces/browser"; | ||
| 30 | 31 | ||
| 31 | const api = composePluginApi([openApiHttpPlugin()] as const); | 32 | const api = composePluginApi([openApiHttpPlugin()] as const); |
| 32 | 33 | ||
| @@ -161,7 +162,7 @@ scenario( · 128 unmodified lines | |||
| 161 | const internalError = JSON.stringify({ _tag: "InternalError", traceId: "policy-write" }); | 162 | const internalError = JSON.stringify({ _tag: "InternalError", traceId: "policy-write" }); |
| 162 | 163 | ||
| 163 | await step("Open the integration's Tools tab", async () => { | 164 | await step("Open the integration's Tools tab", async () => { |
| 164 | await page.goto(`/integrations/${integration}`, { waitUntil: "networkidle" }); | 165 | await visit(page, `/integrations/${integration}`); |
| 165 | await page.getByRole("tab", { name: "Tools" }).click(); | 166 | await page.getByRole("tab", { name: "Tools" }).click(); |
| 166 | await sectionFor(alpha).waitFor(); | 167 | await sectionFor(alpha).waitFor(); |
| 167 | await sectionFor(beta).waitFor(); | 168 | await sectionFor(beta).waitFor(); |
| @@ -288,7 +289,7 @@ scenario( · 120 unmodified lines | |||
| 288 | }); | 289 | }); |
| 289 | 290 | ||
| 290 | await step("Both rules are manageable rows on the Policies page", async () => { | 291 | await step("Both rules are manageable rows on the Policies page", async () => { |
| 291 | await page.goto("/policies", { waitUntil: "networkidle" }); | 292 | await visit(page, "/policies"); |
| 292 | await page.getByText(leafPattern, { exact: true }).waitFor(); | 293 | await page.getByText(leafPattern, { exact: true }).waitFor(); |
| 293 | await page.getByText(categoryPattern, { exact: true }).waitFor(); | 294 | await page.getByText(categoryPattern, { exact: true }).waitFor(); |
| 294 | }); | 295 | }); |
| @@ -3,7 +3,7 @@ import { Effect } from "effect"; · 2 unmodified lines | |||
| 3 | 3 | ||
| 4 | import { scenario } from "../src/scenario"; | 4 | import { scenario } from "../src/scenario"; |
| 5 | import { Browser, Target } from "../src/services"; | 5 | import { Browser, Target } from "../src/services"; |
| 6 | import { clickToReveal } from "../src/surfaces/browser"; | 6 | import { clickToReveal, visit } from "../src/surfaces/browser"; |
| 7 | 7 | ||
| 8 | scenario( | 8 | scenario( |
| 9 | "Provider catalog · Google and Microsoft services are OpenAPI presets", | 9 | "Provider catalog · Google and Microsoft services are OpenAPI presets", |
| @@ -15,7 +15,7 @@ scenario( · 5 unmodified lines | |||
| 15 | 15 | ||
| 16 | yield* browser.session(identity, async ({ page, step }) => { | 16 | yield* browser.session(identity, async ({ page, step }) => { |
| 17 | await step("Open the integrations picker", async () => { | 17 | await step("Open the integrations picker", async () => { |
| 18 | await page.goto("/integrations", { waitUntil: "networkidle" }); | 18 | await visit(page, "/integrations"); |
| 19 | await clickToReveal( | 19 | await clickToReveal( |
| 20 | page.getByRole("button", { name: "Connect" }), | 20 | page.getByRole("button", { name: "Connect" }), |
| 21 | page.getByRole("dialog", { name: "Connect an integration" }), | 21 | page.getByRole("dialog", { name: "Connect an integration" }), |
| @@ -15,6 +15,7 @@ · 14 unmodified lines | |||
| 15 | import { randomUUID } from "node:crypto"; | 15 | import { randomUUID } from "node:crypto"; |
| 16 | 16 | ||
| 17 | import { chromium } from "playwright"; | 17 | import { chromium } from "playwright"; |
| 18 | import { visit } from "../src/surfaces/browser"; | ||
| 18 | 19 | ||
| 19 | const [baseUrl, outPrefix] = process.argv.slice(2); | 20 | const [baseUrl, outPrefix] = process.argv.slice(2); |
| 20 | if (!baseUrl || !outPrefix) { | 21 | if (!baseUrl || !outPrefix) { |
| @@ -86,7 +87,7 @@ const shoot = async (label: string, javaScriptEnabled: boolean, outPath: string) · 65 unmodified lines | |||
| 86 | // the light palette — an artifact no user sees. JS stays disabled for the | 87 | // the light palette — an artifact no user sees. JS stays disabled for the |
| 87 | // SSR shot only to freeze the pre-hydration origin (no client correction), | 88 | // SSR shot only to freeze the pre-hydration origin (no client correction), |
| 88 | // not to skip CSS. | 89 | // not to skip CSS. |
| 89 | await page.goto("/", { waitUntil: "networkidle" }); | 90 | await visit(page, "/"); |
| 90 | // The install command is the thing that flashes; screenshot just its block. | 91 | // The install command is the thing that flashes; screenshot just its block. |
| 91 | const command = page.locator('pre:has-text("add-mcp")').first(); | 92 | const command = page.locator('pre:has-text("add-mcp")').first(); |
| 92 | await command.waitFor({ state: "visible", timeout: 15_000 }); | 93 | await command.waitFor({ state: "visible", timeout: 15_000 }); |
| @@ -20,6 +20,7 @@ import { AuthTemplateSlug, ConnectionName, IntegrationSlug } from "@executor-js/ · 19 unmodified lines | |||
| 20 | import { scenario } from "../src/scenario"; | 20 | import { scenario } from "../src/scenario"; |
| 21 | import { createInvitedIdentity } from "../targets/selfhost"; | 21 | import { createInvitedIdentity } from "../targets/selfhost"; |
| 22 | import { Api, Browser, Target } from "../src/services"; | 22 | import { Api, Browser, Target } from "../src/services"; |
| 23 | import { visit } from "../src/surfaces/browser"; | ||
| 23 | 24 | ||
| 24 | const api = composePluginApi([openApiHttpPlugin()] as const); | 25 | const api = composePluginApi([openApiHttpPlugin()] as const); |
| 25 | type Client = HttpApiClient.ForApi<typeof api>; | 26 | type Client = HttpApiClient.ForApi<typeof api>; |
| @@ -107,7 +108,7 @@ scenario( · 81 unmodified lines | |||
| 107 | 108 | ||
| 108 | yield* browser.session(owner, async ({ page, step }) => { | 109 | yield* browser.session(owner, async ({ page, step }) => { |
| 109 | await step("Open Users from the sidebar as the instance owner", async () => { | 110 | await step("Open Users from the sidebar as the instance owner", async () => { |
| 110 | await page.goto("/", { waitUntil: "networkidle" }); | 111 | await visit(page, "/"); |
| 111 | await page | 112 | await page |
| 112 | .locator("nav") | 113 | .locator("nav") |
| 113 | .getByRole("link", { name: "Users" }) | 114 | .getByRole("link", { name: "Users" }) |
| @@ -289,7 +290,7 @@ scenario( · 175 unmodified lines | |||
| 289 | 290 | ||
| 290 | yield* browser.session(member, async ({ page, step }) => { | 291 | yield* browser.session(member, async ({ page, step }) => { |
| 291 | await step("A plain member is not offered the section", async () => { | 292 | await step("A plain member is not offered the section", async () => { |
| 292 | await page.goto("/", { waitUntil: "networkidle" }); | 293 | await visit(page, "/"); |
| 293 | await page | 294 | await page |
| 294 | .locator("nav") | 295 | .locator("nav") |
| 295 | .getByRole("link", { name: "Integrations" }) | 296 | .getByRole("link", { name: "Integrations" }) |
| @@ -302,7 +303,7 @@ scenario( · 6 unmodified lines | |||
| 302 | }); | 303 | }); |
| 303 | 304 | ||
| 304 | await step("And reaching the URL directly is refused, not silently empty", async () => { | 305 | await step("And reaching the URL directly is refused, not silently empty", async () => { |
| 305 | await page.goto("/users", { waitUntil: "networkidle" }); | 306 | await visit(page, "/users"); |
| 306 | await page | 307 | await page |
| 307 | .getByText("You don't have access to this workspace's users") | 308 | .getByText("You don't have access to this workspace's users") |
| 308 | .waitFor({ state: "visible", timeout: 30_000 }); | 309 | .waitFor({ state: "visible", timeout: 30_000 }); |
| @@ -20,6 +20,7 @@ import { AccountHttpApi } from "@executor-js/api"; · 19 unmodified lines | |||
| 20 | 20 | ||
| 21 | import { scenario } from "../src/scenario"; | 21 | import { scenario } from "../src/scenario"; |
| 22 | import { Api, Browser, Target } from "../src/services"; | 22 | import { Api, Browser, Target } from "../src/services"; |
| 23 | import { visit } from "../src/surfaces/browser"; | ||
| 23 | 24 | ||
| 24 | declare global { | 25 | declare global { |
| 25 | interface Window { | 26 | interface Window { |
| @@ -39,7 +40,7 @@ scenario( · 13 unmodified lines | |||
| 39 | 40 | ||
| 40 | yield* browser.session(identity, async ({ page, step }) => { | 41 | yield* browser.session(identity, async ({ page, step }) => { |
| 41 | await step("Land on the dashboard", async () => { | 42 | await step("Land on the dashboard", async () => { |
| 42 | await page.goto("/", { waitUntil: "networkidle" }); | 43 | await visit(page, "/"); |
| 43 | // The shared shell's main nav lists the workspace sections. | 44 | // The shared shell's main nav lists the workspace sections. |
| 44 | await page.locator("nav").getByRole("link", { name: "Integrations" }).first().waitFor(); | 45 | await page.locator("nav").getByRole("link", { name: "Integrations" }).first().waitFor(); |
| 45 | }); | 46 | }); |
| @@ -105,7 +106,7 @@ scenario( · 59 unmodified lines | |||
| 105 | }); | 106 | }); |
| 106 | 107 | ||
| 107 | await step("Open the API keys page", async () => { | 108 | await step("Open the API keys page", async () => { |
| 108 | await page.goto("/api-keys", { waitUntil: "networkidle" }); | 109 | await visit(page, "/api-keys"); |
| 109 | await page.getByRole("heading", { name: "API keys", exact: true }).waitFor(); | 110 | await page.getByRole("heading", { name: "API keys", exact: true }).waitFor(); |
| 110 | }); | 111 | }); |
| 111 | 112 | ||
| @@ -174,7 +175,7 @@ scenario( · 62 unmodified lines | |||
| 174 | }); | 175 | }); |
| 175 | 176 | ||
| 176 | await step("Create a new key", async () => { | 177 | await step("Create a new key", async () => { |
| 177 | await page.goto("/api-keys", { waitUntil: "networkidle" }); | 178 | await visit(page, "/api-keys"); |
| 178 | await page.getByRole("button", { name: "New key" }).click(); | 179 | await page.getByRole("button", { name: "New key" }).click(); |
| 179 | const dialog = page.getByRole("dialog"); | 180 | const dialog = page.getByRole("dialog"); |
| 180 | await dialog.getByLabel("Name").fill(keyName); | 181 | await dialog.getByLabel("Name").fill(keyName); |
| @@ -23,6 +23,7 @@ import { IntegrationSlug } from "@executor-js/sdk/shared"; · 22 unmodified lines | |||
| 23 | 23 | ||
| 24 | import { scenario } from "../src/scenario"; | 24 | import { scenario } from "../src/scenario"; |
| 25 | import { Api, Browser, Target } from "../src/services"; | 25 | import { Api, Browser, Target } from "../src/services"; |
| 26 | import { visit } from "../src/surfaces/browser"; | ||
| 26 | 27 | ||
| 27 | const api = composePluginApi([mcpHttpPlugin()] as const); | 28 | const api = composePluginApi([mcpHttpPlugin()] as const); |
| 28 | 29 | ||
| @@ -160,9 +161,7 @@ scenario( · 131 unmodified lines | |||
| 160 | 161 | ||
| 161 | yield* browser.session(identity, async ({ page, step }) => { | 162 | yield* browser.session(identity, async ({ page, step }) => { |
| 162 | await step("Open the add-MCP flow pointed at the server", async () => { | 163 | await step("Open the add-MCP flow pointed at the server", async () => { |
| 163 | await page.goto(`/integrations/add/mcp?url=${encodeURIComponent(server.endpoint)}`, { | 164 | await visit(page, `/integrations/add/mcp?url=${encodeURIComponent(server.endpoint)}`); |
| 164 | waitUntil: "networkidle", | ||
| 165 | }); | ||
| 166 | // Generous timeout: the debounced probe request can queue behind a | 165 | // Generous timeout: the debounced probe request can queue behind a |
| 167 | // busy dev server under CI load, and there is no clean client-side | 166 | // busy dev server under CI load, and there is no clean client-side |
| 168 | // re-trigger to poke it mid-flight. | 167 | // re-trigger to poke it mid-flight. |
| @@ -216,9 +215,7 @@ scenario( · 47 unmodified lines | |||
| 216 | yield* Effect.gen(function* () { | 215 | yield* Effect.gen(function* () { |
| 217 | yield* browser.session(identity, async ({ page, step }) => { | 216 | yield* browser.session(identity, async ({ page, step }) => { |
| 218 | await step("Open the add-MCP flow pointed at the Slack-shaped server", async () => { | 217 | await step("Open the add-MCP flow pointed at the Slack-shaped server", async () => { |
| 219 | await page.goto(`/integrations/add/mcp?url=${encodeURIComponent(server.endpoint)}`, { | 218 | await visit(page, `/integrations/add/mcp?url=${encodeURIComponent(server.endpoint)}`); |
| 220 | waitUntil: "networkidle", | ||
| 221 | }); | ||
| 222 | await page.getByText("How does this server authenticate?").waitFor({ timeout: 90_000 }); | 219 | await page.getByText("How does this server authenticate?").waitFor({ timeout: 90_000 }); |
| 223 | await page.getByText("OAuth metadata is discovered from this server").waitFor(); | 220 | await page.getByText("OAuth metadata is discovered from this server").waitFor(); |
| 224 | }); | 221 | }); |
| @@ -324,9 +321,7 @@ scenario( · 99 unmodified lines | |||
| 324 | yield* Effect.gen(function* () { | 321 | yield* Effect.gen(function* () { |
| 325 | yield* browser.session(identity, async ({ page, step }) => { | 322 | yield* browser.session(identity, async ({ page, step }) => { |
| 326 | await step("Open the integration's connect modal", async () => { | 323 | await step("Open the integration's connect modal", async () => { |
| 327 | await page.goto(`/integrations/${slug}`, { | 324 | await visit(page, `/integrations/${slug}`); |
| 328 | waitUntil: "networkidle", | ||
| 329 | }); | ||
| 330 | await page.getByRole("button", { name: "Add connection" }).first().click(); | 325 | await page.getByRole("button", { name: "Add connection" }).first().click(); |
| 331 | await page.getByRole("tab", { name: "OAuth" }).waitFor(); | 326 | await page.getByRole("tab", { name: "OAuth" }).waitFor(); |
| 332 | }); | 327 | }); |
| @@ -426,9 +421,7 @@ scenario( · 93 unmodified lines | |||
| 426 | yield* Effect.gen(function* () { | 421 | yield* Effect.gen(function* () { |
| 427 | yield* browser.session(identity, async ({ page, step }) => { | 422 | yield* browser.session(identity, async ({ page, step }) => { |
| 428 | await step("Open the integration's connect modal", async () => { | 423 | await step("Open the integration's connect modal", async () => { |
| 429 | await page.goto(`/integrations/${slug}`, { | 424 | await visit(page, `/integrations/${slug}`); |
| 430 | waitUntil: "networkidle", | ||
| 431 | }); | ||
| 432 | await page.getByRole("button", { name: "Add connection" }).first().click(); | 425 | await page.getByRole("button", { name: "Add connection" }).first().click(); |
| 433 | await page.getByRole("tab", { name: "API key (Authorization)" }).waitFor(); | 426 | await page.getByRole("tab", { name: "API key (Authorization)" }).waitFor(); |
| 434 | }); | 427 | }); |
| @@ -18,6 +18,7 @@ import { IntegrationSlug } from "@executor-js/sdk/shared"; · 17 unmodified lines | |||
| 18 | 18 | ||
| 19 | import { scenario } from "../src/scenario"; | 19 | import { scenario } from "../src/scenario"; |
| 20 | import { Api, Browser, Target } from "../src/services"; | 20 | import { Api, Browser, Target } from "../src/services"; |
| 21 | import { visit } from "../src/surfaces/browser"; | ||
| 21 | 22 | ||
| 22 | const api = composePluginApi([openApiHttpPlugin()] as const); | 23 | const api = composePluginApi([openApiHttpPlugin()] as const); |
| 23 | 24 | ||
| @@ -63,7 +64,7 @@ scenario( · 39 unmodified lines | |||
| 63 | 64 | ||
| 64 | yield* browser.session(identity, async ({ page, step }) => { | 65 | yield* browser.session(identity, async ({ page, step }) => { |
| 65 | await step("Open the connect modal", async () => { | 66 | await step("Open the connect modal", async () => { |
| 66 | await page.goto(`/integrations/${slug}?addAccount=1`, { waitUntil: "networkidle" }); | 67 | await visit(page, `/integrations/${slug}?addAccount=1`); |
| 67 | await page.getByRole("heading", { name: /Add connection/ }).waitFor(); | 68 | await page.getByRole("heading", { name: /Add connection/ }).waitFor(); |
| 68 | }); | 69 | }); |
| 69 | 70 | ||
| @@ -16,6 +16,7 @@ import { OAuthTestServer } from "@executor-js/sdk/testing"; · 15 unmodified lines | |||
| 16 | 16 | ||
| 17 | import { scenario } from "../src/scenario"; | 17 | import { scenario } from "../src/scenario"; |
| 18 | import { Browser, Target } from "../src/services"; | 18 | import { Browser, Target } from "../src/services"; |
| 19 | import { visit } from "../src/surfaces/browser"; | ||
| 19 | 20 | ||
| 20 | const REMOVE_HINT = "Pulled from spec. Remove to override."; | 21 | const REMOVE_HINT = "Pulled from spec. Remove to override."; |
| 21 | 22 | ||
| @@ -36,9 +37,7 @@ scenario( · 14 unmodified lines | |||
| 36 | 37 | ||
| 37 | yield* browser.session(identity, async ({ page, step }) => { | 38 | yield* browser.session(identity, async ({ page, step }) => { |
| 38 | await step("Open the add-MCP flow pointed at the OAuth server", async () => { | 39 | await step("Open the add-MCP flow pointed at the OAuth server", async () => { |
| 39 | await page.goto(`/integrations/add/mcp?url=${encodeURIComponent(server.endpoint)}`, { | 40 | await visit(page, `/integrations/add/mcp?url=${encodeURIComponent(server.endpoint)}`); |
| 40 | waitUntil: "networkidle", | ||
| 41 | }); | ||
| 42 | await page.getByText("How does this server authenticate?").waitFor(); | 41 | await page.getByText("How does this server authenticate?").waitFor(); |
| 43 | await page.getByText("Method 1 · Detected").waitFor(); | 42 | await page.getByText("Method 1 · Detected").waitFor(); |
| 44 | }); | 43 | }); |
| @@ -145,7 +144,7 @@ scenario( · 100 unmodified lines | |||
| 145 | 144 | ||
| 146 | yield* browser.session(identity, async ({ page, step }) => { | 145 | yield* browser.session(identity, async ({ page, step }) => { |
| 147 | await step("Analyze a spec that declares both API key and OAuth", async () => { | 146 | await step("Analyze a spec that declares both API key and OAuth", async () => { |
| 148 | await page.goto(`/integrations/add/openapi`, { waitUntil: "networkidle" }); | 147 | await visit(page, `/integrations/add/openapi`); |
| 149 | await page | 148 | await page |
| 150 | .getByPlaceholder(/openapi\.json/i) | 149 | .getByPlaceholder(/openapi\.json/i) |
| 151 | .first() | 150 | .first() |
| @@ -19,6 +19,7 @@ import { serveTestHttpApp } from "@executor-js/sdk/testing"; · 18 unmodified lines | |||
| 19 | 19 | ||
| 20 | import { scenario } from "../src/scenario"; | 20 | import { scenario } from "../src/scenario"; |
| 21 | import { Api, Browser, Target } from "../src/services"; | 21 | import { Api, Browser, Target } from "../src/services"; |
| 22 | import { visit } from "../src/surfaces/browser"; | ||
| 22 | 23 | ||
| 23 | const api = composePluginApi([mcpHttpPlugin()] as const); | 24 | const api = composePluginApi([mcpHttpPlugin()] as const); |
| 24 | 25 | ||
| @@ -53,9 +54,7 @@ scenario( · 28 unmodified lines | |||
| 53 | yield* Effect.gen(function* () { | 54 | yield* Effect.gen(function* () { |
| 54 | yield* browser.session(identity, async ({ page, step }) => { | 55 | yield* browser.session(identity, async ({ page, step }) => { |
| 55 | await step("Open the add-MCP flow pointed at the auth-gated server", async () => { | 56 | await step("Open the add-MCP flow pointed at the auth-gated server", async () => { |
| 56 | await page.goto(`/integrations/add/mcp?url=${encodeURIComponent(endpoint)}`, { | 57 | await visit(page, `/integrations/add/mcp?url=${encodeURIComponent(endpoint)}`); |
| 57 | waitUntil: "networkidle", | ||
| 58 | }); | ||
| 59 | // Before the fix this dead-ended on a red "add credentials below" | 58 | // Before the fix this dead-ended on a red "add credentials below" |
| 60 | // error with no editor. Now the auth-method editor renders. | 59 | // error with no editor. Now the auth-method editor renders. |
| 61 | await page.getByText("How does this server authenticate?").waitFor(); | 60 | await page.getByText("How does this server authenticate?").waitFor(); |
| @@ -15,6 +15,7 @@ import { Effect } from "effect"; · 14 unmodified lines | |||
| 15 | 15 | ||
| 16 | import { scenario } from "../src/scenario"; | 16 | import { scenario } from "../src/scenario"; |
| 17 | import { Browser, Target } from "../src/services"; | 17 | import { Browser, Target } from "../src/services"; |
| 18 | import { visit } from "../src/surfaces/browser"; | ||
| 18 | 19 | ||
| 19 | scenario( | 20 | scenario( |
| 20 | "MCP · the URL printed by the Connect card is actually served", | 21 | "MCP · the URL printed by the Connect card is actually served", |
| @@ -28,7 +29,7 @@ scenario( · 7 unmodified lines | |||
| 28 | let cardUrl = ""; | 29 | let cardUrl = ""; |
| 29 | yield* browser.session(identity, async ({ page, step }) => { | 30 | yield* browser.session(identity, async ({ page, step }) => { |
| 30 | await step("Read the MCP URL from the Connect-an-agent card", async () => { | 31 | await step("Read the MCP URL from the Connect-an-agent card", async () => { |
| 31 | await page.goto("/", { waitUntil: "networkidle" }); | 32 | await visit(page, "/"); |
| 32 | await page.waitForTimeout(2000); // let the card resolve the server origin | 33 | await page.waitForTimeout(2000); // let the card resolve the server origin |
| 33 | // The card renders `npx add-mcp <url> --transport http --name executor`. | 34 | // The card renders `npx add-mcp <url> --transport http --name executor`. |
| 34 | // The CodeBlock tokenizes it across spans; read the concatenated text. | 35 | // The CodeBlock tokenizes it across spans; read the concatenated text. |
| @@ -19,6 +19,7 @@ import { Effect } from "effect"; · 18 unmodified lines | |||
| 19 | 19 | ||
| 20 | import { scenario } from "../src/scenario"; | 20 | import { scenario } from "../src/scenario"; |
| 21 | import { Browser, Target } from "../src/services"; | 21 | import { Browser, Target } from "../src/services"; |
| 22 | import { visit } from "../src/surfaces/browser"; | ||
| 22 | 23 | ||
| 23 | interface AuthServerMetadata { | 24 | interface AuthServerMetadata { |
| 24 | readonly authorization_endpoint: string; | 25 | readonly authorization_endpoint: string; |
| @@ -78,7 +79,7 @@ scenario( · 53 unmodified lines | |||
| 78 | 79 | ||
| 79 | yield* browser.session(identity, async ({ page, step }) => { | 80 | yield* browser.session(identity, async ({ page, step }) => { |
| 80 | await step("A signed-in user is using their Executor instance", async () => { | 81 | await step("A signed-in user is using their Executor instance", async () => { |
| 81 | await page.goto("/", { waitUntil: "networkidle" }); | 82 | await visit(page, "/"); |
| 82 | // Confirm we're in the app, not bounced to sign-in. | 83 | // Confirm we're in the app, not bounced to sign-in. |
| 83 | expect(new URL(page.url()).pathname, "the session is active (not on /login)").not.toBe( | 84 | expect(new URL(page.url()).pathname, "the session is active (not on /login)").not.toBe( |
| 84 | "/login", | 85 | "/login", |
| @@ -91,7 +92,7 @@ scenario( · 6 unmodified lines | |||
| 91 | // A connecting MCP client opens this authorize URL. The server forces | 92 | // A connecting MCP client opens this authorize URL. The server forces |
| 92 | // prompt=consent, so it stops here on the approval screen instead of | 93 | // prompt=consent, so it stops here on the approval screen instead of |
| 93 | // auto-issuing a code. | 94 | // auto-issuing a code. |
| 94 | await page.goto(authorize, { waitUntil: "networkidle" }); | 95 | await visit(page, authorize); |
| 95 | await page.locator("#mcp-consent-allow").waitFor(); | 96 | await page.locator("#mcp-consent-allow").waitFor(); |
| 96 | expect( | 97 | expect( |
| 97 | new URL(page.url()).pathname, | 98 | new URL(page.url()).pathname, |
| @@ -21,6 +21,7 @@ import { serveOAuthTestServer, type OAuthTestServerShape } from "@executor-js/sd · 20 unmodified lines | |||
| 21 | 21 | ||
| 22 | import { scenario } from "../src/scenario"; | 22 | import { scenario } from "../src/scenario"; |
| 23 | import { Api, Browser, Target } from "../src/services"; | 23 | import { Api, Browser, Target } from "../src/services"; |
| 24 | import { visit } from "../src/surfaces/browser"; | ||
| 24 | 25 | ||
| 25 | const api = composePluginApi([mcpHttpPlugin()] as const); | 26 | const api = composePluginApi([mcpHttpPlugin()] as const); |
| 26 | type Client = HttpApiClient.ForApi<typeof api>; | 27 | type Client = HttpApiClient.ForApi<typeof api>; |
| @@ -300,7 +301,7 @@ scenario( · 273 unmodified lines | |||
| 300 | const menuTrigger = connections.locator('button[aria-haspopup="menu"]').first(); | 301 | const menuTrigger = connections.locator('button[aria-haspopup="menu"]').first(); |
| 301 | 302 | ||
| 302 | await step("Open the MCP integration with its expired OAuth connection", async () => { | 303 | await step("Open the MCP integration with its expired OAuth connection", async () => { |
| 303 | await page.goto(`/integrations/${slug}`, { waitUntil: "networkidle" }); | 304 | await visit(page, `/integrations/${slug}`); |
| 304 | await connections.getByText("main", { exact: true }).waitFor({ timeout: 30_000 }); | 305 | await connections.getByText("main", { exact: true }).waitFor({ timeout: 30_000 }); |
| 305 | }); | 306 | }); |
| 306 | 307 | ||
| @@ -380,7 +381,7 @@ scenario( · 73 unmodified lines | |||
| 380 | const menuTrigger = connections.locator('button[aria-haspopup="menu"]').first(); | 381 | const menuTrigger = connections.locator('button[aria-haspopup="menu"]').first(); |
| 381 | 382 | ||
| 382 | await step("Open the integration: the connection reads Expired", async () => { | 383 | await step("Open the integration: the connection reads Expired", async () => { |
| 383 | await page.goto(`/integrations/${slug}`, { waitUntil: "networkidle" }); | 384 | await visit(page, `/integrations/${slug}`); |
| 384 | await connections.getByText("main", { exact: true }).waitFor({ timeout: 30_000 }); | 385 | await connections.getByText("main", { exact: true }).waitFor({ timeout: 30_000 }); |
| 385 | await connections.getByLabel("Status: Expired").waitFor({ timeout: 30_000 }); | 386 | await connections.getByLabel("Status: Expired").waitFor({ timeout: 30_000 }); |
| 386 | }); | 387 | }); |
| @@ -451,7 +452,7 @@ scenario( · 64 unmodified lines | |||
| 451 | }); | 452 | }); |
| 452 | 453 | ||
| 453 | await step("Open the MCP integration with its DCR OAuth connection", async () => { | 454 | await step("Open the MCP integration with its DCR OAuth connection", async () => { |
| 454 | await page.goto(`/integrations/${slug}`, { waitUntil: "networkidle" }); | 455 | await visit(page, `/integrations/${slug}`); |
| 455 | await connections.getByText("main", { exact: true }).waitFor({ timeout: 30_000 }); | 456 | await connections.getByText("main", { exact: true }).waitFor({ timeout: 30_000 }); |
| 456 | }); | 457 | }); |
| 457 | 458 | ||
| @@ -35,6 +35,7 @@ import { OAuthTestServer } from "@executor-js/sdk/testing"; · 34 unmodified lines | |||
| 35 | 35 | ||
| 36 | import { scenario } from "../src/scenario"; | 36 | import { scenario } from "../src/scenario"; |
| 37 | import { Api, Browser, Target } from "../src/services"; | 37 | import { Api, Browser, Target } from "../src/services"; |
| 38 | import { visit } from "../src/surfaces/browser"; | ||
| 38 | 39 | ||
| 39 | const api = composePluginApi([mcpHttpPlugin()] as const); | 40 | const api = composePluginApi([mcpHttpPlugin()] as const); |
| 40 | 41 | ||
| @@ -68,7 +69,7 @@ scenario( · 27 unmodified lines | |||
| 68 | await step("Add an OAuth-protected MCP integration", async () => { | 69 | await step("Add an OAuth-protected MCP integration", async () => { |
| 69 | const addUrl = new URL("/integrations/add/mcp", target.baseUrl); | 70 | const addUrl = new URL("/integrations/add/mcp", target.baseUrl); |
| 70 | addUrl.searchParams.set("url", server.endpoint); | 71 | addUrl.searchParams.set("url", server.endpoint); |
| 71 | await page.goto(addUrl.toString(), { waitUntil: "networkidle" }); | 72 | await visit(page, addUrl.toString()); |
| 72 | await page.getByText("How does this server authenticate?").waitFor({ timeout: 30_000 }); | 73 | await page.getByText("How does this server authenticate?").waitFor({ timeout: 30_000 }); |
| 73 | await page.getByPlaceholder("e.g. Linear").fill(displayName); | 74 | await page.getByPlaceholder("e.g. Linear").fill(displayName); |
| 74 | await page.getByRole("button", { name: "Add integration" }).click(); | 75 | await page.getByRole("button", { name: "Add integration" }).click(); |
| @@ -22,6 +22,7 @@ import { Effect } from "effect"; · 21 unmodified lines | |||
| 22 | import { scenario } from "../src/scenario"; | 22 | import { scenario } from "../src/scenario"; |
| 23 | import { enterFocus, markRecordingStart } from "../src/timeline"; | 23 | import { enterFocus, markRecordingStart } from "../src/timeline"; |
| 24 | import { Browser, Cli, Mcp, OpenCode, RunDir, Target } from "../src/services"; | 24 | import { Browser, Cli, Mcp, OpenCode, RunDir, Target } from "../src/services"; |
| 25 | import { visit } from "../src/surfaces/browser"; | ||
| 25 | 26 | ||
| 26 | const SERVER_NAME = "executor"; | 27 | const SERVER_NAME = "executor"; |
| 27 | 28 | ||
| @@ -119,7 +120,7 @@ scenario( · 91 unmodified lines | |||
| 119 | await step("OpenCode asks to connect — the approval screen", async () => { | 120 | await step("OpenCode asks to connect — the approval screen", async () => { |
| 120 | // Authenticated (owner cookies) → authorize forces prompt=consent → | 121 | // Authenticated (owner cookies) → authorize forces prompt=consent → |
| 121 | // the approval screen. | 122 | // the approval screen. |
| 122 | await page.goto(authorizeUrl, { waitUntil: "networkidle" }); | 123 | await visit(page, authorizeUrl); |
| 123 | await page.locator("#mcp-consent-allow").waitFor({ timeout: 30_000 }); | 124 | await page.locator("#mcp-consent-allow").waitFor({ timeout: 30_000 }); |
| 124 | expect(new URL(page.url()).pathname, "lands on the approval screen").toBe( | 125 | expect(new URL(page.url()).pathname, "lands on the approval screen").toBe( |
| 125 | "/mcp-consent", | 126 | "/mcp-consent", |
| @@ -16,6 +16,7 @@ import { openApiHttpPlugin } from "@executor-js/plugin-openapi/api"; · 15 unmodified lines | |||
| 16 | 16 | ||
| 17 | import { scenario } from "../src/scenario"; | 17 | import { scenario } from "../src/scenario"; |
| 18 | import { Api, Browser, Target } from "../src/services"; | 18 | import { Api, Browser, Target } from "../src/services"; |
| 19 | import { visit } from "../src/surfaces/browser"; | ||
| 19 | 20 | ||
| 20 | const api = composePluginApi([openApiHttpPlugin()] as const); | 21 | const api = composePluginApi([openApiHttpPlugin()] as const); |
| 21 | 22 | ||
| @@ -81,7 +82,7 @@ scenario( · 59 unmodified lines | |||
| 81 | const actions = page.getByRole("button", { name: `Actions for ${appName}` }); | 82 | const actions = page.getByRole("button", { name: `Actions for ${appName}` }); |
| 82 | 83 | ||
| 83 | await step("Open the integration and start a new connection", async () => { | 84 | await step("Open the integration and start a new connection", async () => { |
| 84 | await page.goto(`/integrations/${integration}`, { waitUntil: "networkidle" }); | 85 | await visit(page, `/integrations/${integration}`); |
| 85 | await page.getByRole("button", { name: "Add connection" }).click(); | 86 | await page.getByRole("button", { name: "Add connection" }).click(); |
| 86 | // OAuth2 is the integration's only method, so the modal opens on | 87 | // OAuth2 is the integration's only method, so the modal opens on |
| 87 | // the OAuth app step with nothing registered yet. (`exact` avoids | 88 | // the OAuth app step with nothing registered yet. (`exact` avoids |
| @@ -14,6 +14,7 @@ import { serveOAuthTestServer } from "@executor-js/sdk/testing"; · 13 unmodified lines | |||
| 14 | 14 | ||
| 15 | import { scenario } from "../src/scenario"; | 15 | import { scenario } from "../src/scenario"; |
| 16 | import { Api, Browser, Target } from "../src/services"; | 16 | import { Api, Browser, Target } from "../src/services"; |
| 17 | import { visit } from "../src/surfaces/browser"; | ||
| 17 | 18 | ||
| 18 | const api = composePluginApi([openApiHttpPlugin()] as const); | 19 | const api = composePluginApi([openApiHttpPlugin()] as const); |
| 19 | 20 | ||
| @@ -111,7 +112,7 @@ scenario( · 91 unmodified lines | |||
| 111 | 112 | ||
| 112 | yield* browser.session({ label: "anonymous" }, async ({ page, step }) => { | 113 | yield* browser.session({ label: "anonymous" }, async ({ page, step }) => { |
| 113 | await step("Provider sends a signed-out browser to the OAuth callback", async () => { | 114 | await step("Provider sends a signed-out browser to the OAuth callback", async () => { |
| 114 | const response = await page.goto(callbackPath, { waitUntil: "networkidle" }); | 115 | const response = await visit(page, callbackPath); |
| 115 | expect(response?.status(), "the callback redirects into the login flow").toBe(200); | 116 | expect(response?.status(), "the callback redirects into the login flow").toBe(200); |
| 116 | await page.getByRole("heading", { name: "Sign in" }).waitFor(); | 117 | await page.getByRole("heading", { name: "Sign in" }).waitFor(); |
| 117 | }); | 118 | }); |
| @@ -22,6 +22,7 @@ import { OAuthTestServer } from "@executor-js/sdk/testing"; · 21 unmodified lines | |||
| 22 | 22 | ||
| 23 | import { scenario } from "../src/scenario"; | 23 | import { scenario } from "../src/scenario"; |
| 24 | import { Api, Browser, Target } from "../src/services"; | 24 | import { Api, Browser, Target } from "../src/services"; |
| 25 | import { visit } from "../src/surfaces/browser"; | ||
| 25 | 26 | ||
| 26 | const api = composePluginApi([mcpHttpPlugin()] as const); | 27 | const api = composePluginApi([mcpHttpPlugin()] as const); |
| 27 | 28 | ||
| @@ -48,7 +49,7 @@ scenario( · 20 unmodified lines | |||
| 48 | await step("Open the add-MCP flow pointed at the OAuth server", async () => { | 49 | await step("Open the add-MCP flow pointed at the OAuth server", async () => { |
| 49 | const addUrl = new URL("/integrations/add/mcp", target.baseUrl); | 50 | const addUrl = new URL("/integrations/add/mcp", target.baseUrl); |
| 50 | addUrl.searchParams.set("url", server.endpoint); | 51 | addUrl.searchParams.set("url", server.endpoint); |
| 51 | await page.goto(addUrl.toString(), { waitUntil: "networkidle" }); | 52 | await visit(page, addUrl.toString()); |
| 52 | await page.getByText("How does this server authenticate?").waitFor({ timeout: 30_000 }); | 53 | await page.getByText("How does this server authenticate?").waitFor({ timeout: 30_000 }); |
| 53 | await page.getByText("Method 1 · Detected").waitFor(); | 54 | await page.getByText("Method 1 · Detected").waitFor(); |
| 54 | await page.getByText("OAuth metadata is discovered from this server").waitFor(); | 55 | await page.getByText("OAuth metadata is discovered from this server").waitFor(); |
| @@ -1,20 +1,71 @@ | |||
| 1 | import { Effect } from "effect"; | 1 | import { Effect, Schedule } from "effect"; |
| 2 | |||
| 3 | /** The suite could not get an instance out of the hosted control plane. */ | ||
| 4 | export class EmulatorInstanceError extends Error { | ||
| 5 | readonly _tag = "EmulatorInstanceError"; | ||
| 6 | |||
| 7 | constructor( | ||
| 8 | readonly service: string, | ||
| 9 | readonly reason: string, | ||
| 10 | ) { | ||
| 11 | super(`${service} emulator instance creation failed: ${reason}`); | ||
| 12 | this.name = "EmulatorInstanceError"; | ||
| 13 | } | ||
| 14 | } | ||
| 15 | |||
| 16 | // Bound each attempt: a hung connection to the edge must not eat the | ||
| 17 | // scenario's whole timeout before the first retry. | ||
| 18 | const ATTEMPT_TIMEOUT = "10 seconds"; | ||
| 19 | const RETRIES = 3; | ||
| 20 | |||
| 21 | const requestInstance = (service: string, label: string) => | ||
| 22 | Effect.tryPromise({ | ||
| 23 | try: async (): Promise<string> => { | ||
| 24 | const response = await fetch(`https://${service}.emulators.dev/_emulate/instances`, { | ||
| 25 | method: "POST", | ||
| 26 | headers: { "content-type": "application/json" }, | ||
| 27 | body: JSON.stringify({ instance: label }), | ||
| 28 | }); | ||
| 29 | if (!response.ok) { | ||
| 30 | throw new EmulatorInstanceError(service, `HTTP ${response.status}`); | ||
| 31 | } | ||
| 32 | const instance = (await response.json()) as { readonly providerBaseUrl: string }; | ||
| 33 | return instance.providerBaseUrl; | ||
| 34 | }, | ||
| 35 | catch: (cause) => | ||
| 36 | cause instanceof EmulatorInstanceError | ||
| 37 | ? cause | ||
| 38 | : new EmulatorInstanceError(service, String(cause)), | ||
| 39 | }); | ||
| 2 | 40 | ||
| 3 | // Hosted service hosts (e.g. resend.emulators.dev) are control plane only — | 41 | // Hosted service hosts (e.g. resend.emulators.dev) are control plane only — |
| 4 | // there is no shared default instance behind them. Every scenario creates its | 42 | // there is no shared default instance behind them. Every scenario creates its |
| 5 | // own isolated instance and works against the returned providerBaseUrl, which | 43 | // own isolated instance and works against the returned providerBaseUrl, which |
| 6 | // also keeps ledger assertions free of cross-run pollution. The server | 44 | // also keeps ledger assertions free of cross-run pollution. The server |
| 7 | // generates an unguessable instance name; the label is a readable prefix. | 45 | // generates an unguessable instance name; the label is a readable prefix. |
| 8 | export const createEmulatorInstance = (service: string, label = "e2e") => | 46 | // |
| 9 | Effect.promise(async () => { | 47 | // This is also the one request in a scenario that leaves the runner, so it is |
| 10 | const response = await fetch(`https://${service}.emulators.dev/_emulate/instances`, { | 48 | // the one place where a CI runner's transient network trouble fails a scenario |
| 11 | method: "POST", | 49 | // that has nothing to do with the network: `connect ETIMEDOUT` reaching the |
| 12 | headers: { "content-type": "application/json" }, | 50 | // edge, plus the occasional bare 502, accounted for 17 shard failures in the |
| 13 | body: JSON.stringify({ instance: label }), | 51 | // two weeks to 2026-08-20. Asking for an instance is idempotent (a spare |
| 14 | }); | 52 | // instance is nobody's business but the control plane's), so bound the attempt |
| 15 | if (!response.ok) { | 53 | // and retry with backoff. Nothing below this line retries anything the |
| 16 | throw new Error(`${service} emulator instance creation failed: ${response.status}`); | 54 | // scenario is actually asserting on. |
| 17 | } | 55 | export const createEmulatorInstance = (service: string, label = "e2e"): Effect.Effect<string> => |
| 18 | const instance = (await response.json()) as { readonly providerBaseUrl: string }; | 56 | requestInstance(service, label).pipe( |
| 19 | return instance.providerBaseUrl; | 57 | Effect.timeoutOrElse({ |
| 20 | }); | 58 | duration: ATTEMPT_TIMEOUT, |
| 59 | orElse: () => | ||
| 60 | Effect.fail(new EmulatorInstanceError(service, `no response in ${ATTEMPT_TIMEOUT}`)), | ||
| 61 | }), | ||
| 62 | Effect.retry( | ||
| 63 | Schedule.both( | ||
| 64 | Schedule.exponential("500 millis").pipe(Schedule.jittered), | ||
| 65 | Schedule.recurs(RETRIES), | ||
| 66 | ), | ||
| 67 | ), | ||
| 68 | // An emulator the suite cannot reach at all is a defect in the run, not a | ||
| 69 | // product failure the scenario should be asked to model. | ||
| 70 | Effect.orDie, | ||
| 71 | ); | ||
| @@ -9,7 +9,7 @@ import { join } from "node:path"; · 8 unmodified lines | |||
| 9 | import { promisify } from "node:util"; | 9 | import { promisify } from "node:util"; |
| 10 | 10 | ||
| 11 | import { Effect } from "effect"; | 11 | import { Effect } from "effect"; |
| 12 | import { chromium, type Locator, type Page } from "playwright"; | 12 | import { chromium, type Locator, type Page, type Response } from "playwright"; |
| 13 | 13 | ||
| 14 | import { beat, enterFocus, markNavigation, markRecordingStart } from "../timeline"; | 14 | import { beat, enterFocus, markNavigation, markRecordingStart } from "../timeline"; |
| 15 | import { appendTraces, type TraceEntry } from "../trace-harvest"; | 15 | import { appendTraces, type TraceEntry } from "../trace-harvest"; |
| @@ -36,10 +36,103 @@ const slug = (text: string): string => · 20 unmodified lines | |||
| 36 | .replace(/^-+|-+$/g, "") | 36 | .replace(/^-+|-+$/g, "") |
| 37 | .slice(0, 60); | 37 | .slice(0, 60); |
| 38 | 38 | ||
| 39 | // How long a navigation may wait for the network to go quiet before giving up | ||
| 40 | // on quiet and letting the scenario's own assertion decide. Long enough to | ||
| 41 | // cover a cold vite compile's request burst, short enough that a page which | ||
| 42 | // never goes quiet costs seconds instead of the 30s navigation timeout. | ||
| 43 | const SETTLE_TIMEOUT_MS = 5_000; | ||
| 44 | |||
| 45 | /** | ||
| 46 | * Wait for the network to go quiet, but only for `SETTLE_TIMEOUT_MS`. | ||
| 47 | * | ||
| 48 | * Playwright's `networkidle` — 500ms with zero in-flight requests — cannot be | ||
| 49 | * a hard gate in this suite, because the suite itself keeps the network busy: | ||
| 50 | * every browser session exports OTel spans to the run's motel through | ||
| 51 | * packages/react's `OtlpTracer` on a one-second interval (wired by | ||
| 52 | * `setup/motel.ts` via VITE_PUBLIC_OTLP_TRACES_URL). A page that keeps | ||
| 53 | * producing spans — a console retrying an org-scoped query that 403s, say — | ||
| 54 | * feeds that exporter indefinitely, so the 500ms window never opens and the | ||
| 55 | * wait burns its full timeout on a page that is, visibly, completely loaded. | ||
| 56 | * | ||
| 57 | * The quiet is worth waiting for when it comes (it usually arrives in | ||
| 58 | * milliseconds, and it lets a step's screenshot catch a settled page), so keep | ||
| 59 | * it — bounded. Readiness is asserted by whatever the scenario does next: | ||
| 60 | * Playwright's locators auto-wait, so `getByRole(...).waitFor()` is a real, | ||
| 61 | * page-specific readiness signal where `networkidle` was only ever a proxy. | ||
| 62 | */ | ||
| 63 | export const settle = async (page: Page): Promise<void> => { | ||
| 64 | await page | ||
| 65 | .waitForLoadState("networkidle", { timeout: SETTLE_TIMEOUT_MS }) | ||
| 66 | // oxlint-disable-next-line executor/no-promise-catch -- boundary: quiet is best-effort, the scenario's next assertion is the gate | ||
| 67 | .catch(() => {}); | ||
| 68 | }; | ||
| 69 | |||
| 70 | /** | ||
| 71 | * Wait until React owns the document — the readiness `networkidle` was standing | ||
| 72 | * in for, asked directly. | ||
| 73 | * | ||
| 74 | * React DOM stamps `__reactContainer$<id>` on the element it roots at the | ||
| 75 | * moment `hydrateRoot` runs, so its presence says the SSR markup is under a | ||
| 76 | * live root rather than inert HTML. That matters because it is the line either | ||
| 77 | * side of which an interaction has a different meaning: before it, a click or | ||
| 78 | * a `fill` goes to markup nobody is listening to and is lost without a trace; | ||
| 79 | * after it, React records the event and replays it as hydration reaches that | ||
| 80 | * subtree. Locators can't see the difference — the element is visible and | ||
| 81 | * enabled either way — which is why a swallowed interaction surfaces 20 | ||
| 82 | * seconds later as "the thing it should have opened never appeared". | ||
| 83 | * | ||
| 84 | * Best-effort, like `settle`: plenty of pages the suite visits are not this | ||
| 85 | * app at all (a provider's authorize screen, an OAuth callback result), and | ||
| 86 | * they are none the worse for having no React root. | ||
| 87 | */ | ||
| 88 | const HYDRATION_TIMEOUT_MS = 10_000; | ||
| 89 | |||
| 90 | export const hydrated = async (page: Page): Promise<void> => { | ||
| 91 | await page | ||
| 92 | .waitForFunction( | ||
| 93 | () => | ||
| 94 | [...document.body.children].some((element) => | ||
| 95 | Object.keys(element).some((key) => key.startsWith("__reactContainer$")), | ||
| 96 | ), | ||
| 97 | undefined, | ||
| 98 | { timeout: HYDRATION_TIMEOUT_MS }, | ||
| 99 | ) | ||
| 100 | // oxlint-disable-next-line executor/no-promise-catch -- boundary: a page with no React root is a legitimate destination | ||
| 101 | .catch(() => {}); | ||
| 102 | }; | ||
| 103 | |||
| 104 | /** | ||
| 105 | * Navigate, then wait for the page to be interactive. The drop-in for a `goto` | ||
| 106 | * that waited on `networkidle`, and a better one: it waits for React to take | ||
| 107 | * the document (`hydrated`) and for the network to go quiet (`settle`), both | ||
| 108 | * bounded, instead of one unbounded wait that proved neither. `timeout` bounds | ||
| 109 | * the navigation itself, exactly as `goto`'s does. | ||
| 110 | */ | ||
| 111 | export const visit = async ( | ||
| 112 | page: Page, | ||
| 113 | url: string, | ||
| 114 | options: { readonly timeout?: number } = {}, | ||
| 115 | ): Promise<Response | null> => { | ||
| 116 | const response = await page.goto(url, { waitUntil: "load", ...options }); | ||
| 117 | await hydrated(page); | ||
| 118 | await settle(page); | ||
| 119 | return response; | ||
| 120 | }; | ||
| 121 | |||
| 122 | /** `visit` for a reload — the drop-in for `reload({ waitUntil: "networkidle" })`. */ | ||
| 123 | export const revisit = async ( | ||
| 124 | page: Page, | ||
| 125 | options: { readonly timeout?: number } = {}, | ||
| 126 | ): Promise<void> => { | ||
| 127 | await page.reload({ waitUntil: "load", ...options }); | ||
| 128 | await hydrated(page); | ||
| 129 | await settle(page); | ||
| 130 | }; | ||
| 131 | |||
| 39 | /** | 132 | /** |
| 40 | * Click `trigger` until `revealed` is visible. | 133 | * Click `trigger` until `revealed` is visible. |
| 41 | * | 134 | * |
| 42 | * `waitUntil: "networkidle"` does not mean the console has hydrated: a click | 135 | * A settled network does not mean the console has hydrated: a click |
| 43 | * that lands between the SSR paint and React attaching the handler is | 136 | * that lands between the SSR paint and React attaching the handler is |
| 44 | * swallowed without a trace, and whatever the click was meant to open never | 137 | * swallowed without a trace, and whatever the click was meant to open never |
| 45 | * appears (the "Connect an integration" dialog no-show flake). Re-clicking a | 138 | * appears (the "Connect an integration" dialog no-show flake). Re-clicking a |
| @@ -10,6 +10,7 @@ import { Effect } from "effect"; · 9 unmodified lines | |||
| 10 | 10 | ||
| 11 | import { scenario } from "./scenario"; | 11 | import { scenario } from "./scenario"; |
| 12 | import { Browser, Target } from "./services"; | 12 | import { Browser, Target } from "./services"; |
| 13 | import { visit } from "./surfaces/browser"; | ||
| 13 | 14 | ||
| 14 | export const FORCED_LATEST = "99.0.0"; | 15 | export const FORCED_LATEST = "99.0.0"; |
| 15 | 16 | ||
| @@ -82,7 +83,7 @@ export const registerUpdateCardCurrentScenario = (name: string): void => · 66 unmodified lines | |||
| 82 | ); | 83 | ); |
| 83 | 84 | ||
| 84 | await step("Open the console", async () => { | 85 | await step("Open the console", async () => { |
| 85 | await page.goto("/", { waitUntil: "networkidle" }); | 86 | await visit(page, "/"); |
| 86 | await page.getByRole("heading", { name: "Integrations" }).waitFor({ timeout: 60_000 }); | 87 | await page.getByRole("heading", { name: "Integrations" }).waitFor({ timeout: 60_000 }); |
| 87 | }); | 88 | }); |
| 88 | 89 | ||