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 | 24 | import { scenario } from "../src/scenario"; |
| 25 | 25 | import { Api, Browser, Target } from "../src/services"; |
| 26 | 26 | import { accountIdOf, forBrowser, joinOrg } from "./support/session"; |
| 27 | +import { visit } from "../src/surfaces/browser"; | |
| 27 | 28 | |
| 28 | 29 | const api = composePluginApi([openApiHttpPlugin()] as const); |
| 29 | 30 | type Client = HttpApiClient.ForApi<typeof api>; |
| @@ -126,7 +127,7 @@ scenario( · 96 unmodified lines | ||
| 126 | 127 | let slug = ""; |
| 127 | 128 | |
| 128 | 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 | 131 | await page.waitForURL((url) => /^\/[a-z0-9-]+\/?$/.test(url.pathname), { |
| 131 | 132 | timeout: 30_000, |
| 132 | 133 | }); |
| @@ -323,7 +324,7 @@ scenario( · 190 unmodified lines | ||
| 323 | 324 | let slug = ""; |
| 324 | 325 | |
| 325 | 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 | 328 | await page.waitForURL((url) => /^\/[a-z0-9-]+\/?$/.test(url.pathname), { |
| 328 | 329 | timeout: 30_000, |
| 329 | 330 | }); |
| @@ -345,7 +346,7 @@ scenario( · 15 unmodified lines | ||
| 345 | 346 | await step( |
| 346 | 347 | "Reaching the URL directly says no access, not an empty workspace", |
| 347 | 348 | async () => { |
| 348 | − await page.goto(`/${slug}/users`, { waitUntil: "networkidle" }); | |
| 349 | + await visit(page, `/${slug}/users`); | |
| 349 | 350 | // The denial is the assertion: an empty table here would misreport |
| 350 | 351 | // a populated workspace as having no users. |
| 351 | 352 | await page |
| @@ -23,6 +23,7 @@ import { Effect } from "effect"; · 22 unmodified lines | ||
| 23 | 23 | |
| 24 | 24 | import { scenario } from "../src/scenario"; |
| 25 | 25 | import { Browser, Target } from "../src/services"; |
| 26 | +import { settle } from "../src/surfaces/browser"; | |
| 26 | 27 | |
| 27 | 28 | scenario( |
| 28 | 29 | "Auth routing · signed out → login → onboarding → dashboard, one browser session", |
| @@ -58,7 +59,7 @@ scenario( · 29 unmodified lines | ||
| 58 | 59 | await step("Create the first org → canonical dashboard at /<slug>", async () => { |
| 59 | 60 | const orgName = "Flow Test Org"; |
| 60 | 61 | const orgNameInput = page.getByPlaceholder("Northwind Labs"); |
| 61 | − await page.waitForLoadState("networkidle"); | |
| 62 | + await settle(page); | |
| 62 | 63 | await orgNameInput.fill(orgName); |
| 63 | 64 | await page.waitForTimeout(250); |
| 64 | 65 | if ((await orgNameInput.inputValue()) !== orgName) { |
| @@ -20,6 +20,7 @@ import { Effect } from "effect"; · 19 unmodified lines | ||
| 20 | 20 | |
| 21 | 21 | import { scenario } from "../src/scenario"; |
| 22 | 22 | import { Autumn, Billing, Browser, Target } from "../src/services"; |
| 23 | +import { visit } from "../src/surfaces/browser"; | |
| 23 | 24 | |
| 24 | 25 | scenario( |
| 25 | 26 | "Billing · completing the trial checkout shows the new plan without a reload", |
| @@ -51,12 +52,12 @@ scenario( · 25 unmodified lines | ||
| 51 | 52 | // "/undefined/billing/plans" — the billing fetches fire under the bogus |
| 52 | 53 | // slug, fail, and are never refetched (autumn-js staleTime 60s), leaving |
| 53 | 54 | // the plans grid empty forever. |
| 54 | − await page.goto("/", { waitUntil: "networkidle" }); | |
| 55 | + await visit(page, "/"); | |
| 55 | 56 | await page.waitForURL((url) => /^\/[a-z0-9-]+\/?$/.test(url.pathname), { |
| 56 | 57 | timeout: 30_000, |
| 57 | 58 | }); |
| 58 | 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 | 61 | await page.getByRole("heading", { name: "Choose a plan" }).waitFor(); |
| 61 | 62 | await startTrial.waitFor(); |
| 62 | 63 | }); |
| @@ -6,6 +6,7 @@ import { Effect } from "effect"; · 5 unmodified lines | ||
| 6 | 6 | |
| 7 | 7 | import { scenario } from "../src/scenario"; |
| 8 | 8 | import { Browser, Target } from "../src/services"; |
| 9 | +import { visit, settle } from "../src/surfaces/browser"; | |
| 9 | 10 | |
| 10 | 11 | scenario( |
| 11 | 12 | "Connect · the agent-connect panel gives working copy for both transports", |
| @@ -17,7 +18,7 @@ scenario( · 5 unmodified lines | ||
| 17 | 18 | |
| 18 | 19 | yield* browser.session(identity, async ({ page, step }) => { |
| 19 | 20 | await step("Open the Integrations page", async () => { |
| 20 | − await page.goto("/", { waitUntil: "networkidle" }); | |
| 21 | + await visit(page, "/"); | |
| 21 | 22 | await page.getByText("Connect an agent").first().waitFor(); |
| 22 | 23 | }); |
| 23 | 24 | |
| @@ -35,7 +36,7 @@ scenario( · 11 unmodified lines | ||
| 35 | 36 | |
| 36 | 37 | await step("Switch to Standard I/O", async () => { |
| 37 | 38 | await page.getByRole("tab", { name: "Standard I/O" }).click(); |
| 38 | − await page.waitForLoadState("networkidle"); | |
| 39 | + await settle(page); | |
| 39 | 40 | }); |
| 40 | 41 | const stdioCommand = await command(); |
| 41 | 42 | expect(stdioCommand, "the command changed for stdio").not.toBe(httpCommand); |
| @@ -45,7 +46,7 @@ scenario( · 3 unmodified lines | ||
| 45 | 46 | |
| 46 | 47 | await step("Switch back to Remote HTTP", async () => { |
| 47 | 48 | await page.getByRole("tab", { name: "Remote HTTP" }).click(); |
| 48 | − await page.waitForLoadState("networkidle"); | |
| 49 | + await settle(page); | |
| 49 | 50 | }); |
| 50 | 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 | 22 | import { scenario } from "../src/scenario"; |
| 23 | 23 | import { Api, Browser, Target } from "../src/services"; |
| 24 | +import { visit, settle } from "../src/surfaces/browser"; | |
| 24 | 25 | |
| 25 | 26 | const api = composePluginApi([openApiHttpPlugin()] as const); |
| 26 | 27 | |
| @@ -98,7 +99,7 @@ scenario( · 71 unmodified lines | ||
| 98 | 99 | const connecting = dialog.getByRole("button", { name: "Connecting…" }); |
| 99 | 100 | |
| 100 | 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 | 103 | await addConnection.click(); |
| 103 | 104 | // The registered app is auto-selected, so the OAuth connect button is |
| 104 | 105 | // present and enabled. |
| @@ -133,7 +134,7 @@ scenario( · 28 unmodified lines | ||
| 133 | 134 | async () => { |
| 134 | 135 | await addConnection.click(); |
| 135 | 136 | await dialog.waitFor({ state: "visible", timeout: 15_000 }); |
| 136 | − await page.waitForLoadState("networkidle"); | |
| 137 | + await settle(page); | |
| 137 | 138 | |
| 138 | 139 | // The guarantee: the reopened modal is reset. Before the fix it stays |
| 139 | 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 | 20 | import { scenario } from "../src/scenario"; |
| 21 | 21 | import { Api, Browser, Target } from "../src/services"; |
| 22 | +import { settle } from "../src/surfaces/browser"; | |
| 22 | 23 | |
| 23 | 24 | /** The display-only identity cookie the SSR gate mints (non-HttpOnly). */ |
| 24 | 25 | const HINT_COOKIE = "executor-auth-hint"; |
| @@ -145,7 +146,7 @@ scenario( · 120 unmodified lines | ||
| 145 | 146 | await page.waitForURL((url) => url.toString() !== before, { |
| 146 | 147 | timeout: 15_000, |
| 147 | 148 | }); |
| 148 | − await page.waitForLoadState("networkidle"); | |
| 149 | + await settle(page); | |
| 149 | 150 | }); |
| 150 | 151 | |
| 151 | 152 | const shown = (await page.locator("body").innerText()).trim(); |
| @@ -14,6 +14,7 @@ import { composePluginApi } from "@executor-js/api/server"; · 13 unmodified lines | ||
| 14 | 14 | import { scenario } from "../src/scenario"; |
| 15 | 15 | import { Api, Browser, Mcp, Target } from "../src/services"; |
| 16 | 16 | import { type McpBrowserApproval, parseBrowserApproval } from "../src/surfaces/mcp"; |
| 17 | +import { visit } from "../src/surfaces/browser"; | |
| 17 | 18 | |
| 18 | 19 | const coreApi = composePluginApi([] as const); |
| 19 | 20 | |
| @@ -168,13 +169,13 @@ scenario( · 148 unmodified lines | ||
| 168 | 169 | mcpSession.awaitResume(approval.executionId), |
| 169 | 170 | browser.session(identity, async ({ page, step }) => { |
| 170 | 171 | await step("Land in the original organization", async () => { |
| 171 | − await page.goto(`/${orgA.slug}`, { waitUntil: "networkidle" }); | |
| 172 | + await visit(page, `/${orgA.slug}`); | |
| 172 | 173 | await expectOrgShell(page, orgA); |
| 173 | 174 | }); |
| 174 | 175 | |
| 175 | 176 | await step("The browser session is switched to another organization", async () => { |
| 176 | 177 | await setWorkosSessionCookie(page, target.baseUrl, sessionB); |
| 177 | − await page.goto(`/${orgB.slug}`, { waitUntil: "networkidle" }); | |
| 178 | + await visit(page, `/${orgB.slug}`); | |
| 178 | 179 | await expectOrgShell(page, orgB); |
| 179 | 180 | }); |
| 180 | 181 | |
| @@ -182,7 +183,7 @@ scenario( · 1 unmodified line | ||
| 182 | 183 | const loadRequest = page.waitForRequest(approvalApiRequest(approval, "GET"), { |
| 183 | 184 | timeout: 30_000, |
| 184 | 185 | }); |
| 185 | − await page.goto(approval.approvalUrl, { waitUntil: "networkidle" }); | |
| 186 | + await visit(page, approval.approvalUrl); | |
| 186 | 187 | expect( |
| 187 | 188 | (await loadRequest).headers()["x-executor-organization"], |
| 188 | 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 | 18 | import { Api, Browser, Mcp, Target } from "../src/services"; |
| 19 | 19 | import { parseBrowserApproval } from "../src/surfaces/mcp"; |
| 20 | 20 | import type { Identity } from "../src/target"; |
| 21 | +import { visit } from "../src/surfaces/browser"; | |
| 21 | 22 | |
| 22 | 23 | const coreApi = composePluginApi([] as const); |
| 23 | 24 | |
| @@ -224,7 +225,7 @@ scenario( · 200 unmodified lines | ||
| 224 | 225 | ), |
| 225 | 226 | browser.session(identity, async ({ page, step }) => { |
| 226 | 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 | 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 | 24 | import { scenario } from "../src/scenario"; |
| 25 | 25 | import { Api, Billing, Browser, Target } from "../src/services"; |
| 26 | +import { visit } from "../src/surfaces/browser"; | |
| 26 | 27 | |
| 27 | 28 | // apps/cloud/src/extensions/billing/plans.ts → MEMBER_LIMITS.free |
| 28 | 29 | const FREE_MEMBER_SEATS = 3; |
| @@ -45,7 +46,7 @@ scenario( · 16 unmodified lines | ||
| 45 | 46 | let slug = ""; |
| 46 | 47 | |
| 47 | 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 | 50 | await page.waitForURL((url) => /^\/[a-z0-9-]+\/?$/.test(url.pathname), { |
| 50 | 51 | timeout: 30_000, |
| 51 | 52 | }); |
| @@ -54,7 +55,7 @@ scenario( · 2 unmodified lines | ||
| 54 | 55 | }); |
| 55 | 56 | |
| 56 | 57 | await step("Open the organization members page", async () => { |
| 57 | − await page.goto(`/${slug}/org`, { waitUntil: "networkidle" }); | |
| 58 | + await visit(page, `/${slug}/org`); | |
| 58 | 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 | 17 | import { scenario } from "../src/scenario"; |
| 18 | 18 | import { Api, Browser, Target } from "../src/services"; |
| 19 | 19 | import type { Identity } from "../src/target"; |
| 20 | +import { visit } from "../src/surfaces/browser"; | |
| 20 | 21 | |
| 21 | 22 | const api = composePluginApi([openApiHttpPlugin()] as const); |
| 22 | 23 | |
| @@ -212,13 +213,13 @@ scenario( · 189 unmodified lines | ||
| 212 | 213 | await installSameWindowOAuthPopup(page); |
| 213 | 214 | |
| 214 | 215 | await step("Land in the original organization", async () => { |
| 215 | − await page.goto(`/${orgA.slug}`, { waitUntil: "networkidle" }); | |
| 216 | + await visit(page, `/${orgA.slug}`); | |
| 216 | 217 | await expectOrgShell(page, orgA); |
| 217 | 218 | }); |
| 218 | 219 | |
| 219 | 220 | await step("The browser session is switched to another organization", async () => { |
| 220 | 221 | await setWorkosSessionCookie(page, target.baseUrl, sessionB); |
| 221 | − await page.goto(`/${orgB.slug}`, { waitUntil: "networkidle" }); | |
| 222 | + await visit(page, `/${orgB.slug}`); | |
| 222 | 223 | await expectOrgShell(page, orgB); |
| 223 | 224 | }); |
| 224 | 225 | |
| @@ -232,9 +233,7 @@ scenario( · 7 unmodified lines | ||
| 232 | 233 | // refetch, and leave "Connect with OAuth" disabled forever (the flake |
| 233 | 234 | // this step used to have). Waiting for org A's shell before opening |
| 234 | 235 | // the modal makes every modal fetch run under the settled org A scope. |
| 235 | − await page.goto(`/${orgA.slug}/integrations/${String(integration)}`, { | |
| 236 | − waitUntil: "networkidle", | |
| 237 | − }); | |
| 236 | + await visit(page, `/${orgA.slug}/integrations/${String(integration)}`); | |
| 238 | 237 | await page.getByRole("button", { name: new RegExp(escapeRegExp(orgA.name)) }).waitFor({ |
| 239 | 238 | timeout: 30_000, |
| 240 | 239 | }); |
| @@ -286,7 +285,7 @@ scenario( · 45 unmodified lines | ||
| 286 | 285 | await step("The provider returns to the OAuth callback", async () => { |
| 287 | 286 | const callbackUrl = await submitProviderLoginFromPage(page); |
| 288 | 287 | callback = new URL(callbackUrl); |
| 289 | − const response = await page.goto(callbackUrl, { waitUntil: "networkidle" }); | |
| 288 | + const response = await visit(page, callbackUrl); | |
| 290 | 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 | 7 | import { scenario } from "../src/scenario"; |
| 8 | 8 | import { Browser, Target } from "../src/services"; |
| 9 | +import { visit } from "../src/surfaces/browser"; | |
| 9 | 10 | |
| 10 | 11 | scenario( |
| 11 | 12 | "Onboarding · the MCP setup step hands the user their org-scoped MCP server URL", |
| @@ -19,7 +20,7 @@ scenario( · 7 unmodified lines | ||
| 19 | 20 | await step( |
| 20 | 21 | "A fresh user without an org lands on the create-org onboarding page", |
| 21 | 22 | async () => { |
| 22 | − await page.goto("/", { waitUntil: "networkidle" }); | |
| 23 | + await visit(page, "/"); | |
| 23 | 24 | // Step 1 of 2 — the org-name input is the landmark that proves we're on onboarding. |
| 24 | 25 | await page.getByPlaceholder("Northwind Labs").waitFor(); |
| 25 | 26 | }, |
| @@ -20,6 +20,7 @@ import { Effect } from "effect"; · 19 unmodified lines | ||
| 20 | 20 | import { scenario } from "../src/scenario"; |
| 21 | 21 | import { Browser, Target } from "../src/services"; |
| 22 | 22 | import { forBrowser, joinOrg } from "./support/session"; |
| 23 | +import { visit } from "../src/surfaces/browser"; | |
| 23 | 24 | |
| 24 | 25 | declare global { |
| 25 | 26 | interface Window { |
| @@ -45,7 +46,7 @@ scenario( · 19 unmodified lines | ||
| 45 | 46 | let slug = ""; |
| 46 | 47 | |
| 47 | 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 | 50 | await page.waitForURL((url) => /^\/[a-z0-9-]+\/?$/.test(url.pathname), { |
| 50 | 51 | timeout: 30_000, |
| 51 | 52 | }); |
| @@ -120,12 +121,12 @@ scenario( · 68 unmodified lines | ||
| 120 | 121 | // ── The plain member's view ─────────────────────────────────────────── |
| 121 | 122 | yield* browser.session(forBrowser(member), async ({ page, step }) => { |
| 122 | 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 | 125 | await page.waitForURL((url) => /^\/[a-z0-9-]+\/?$/.test(url.pathname), { |
| 125 | 126 | timeout: 30_000, |
| 126 | 127 | }); |
| 127 | 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 | 130 | // The personal half renders for everyone… |
| 130 | 131 | await page |
| 131 | 132 | .getByRole("heading", { name: "Personal keys" }) |
| @@ -9,6 +9,7 @@ import { Effect } from "effect"; · 8 unmodified lines | ||
| 9 | 9 | |
| 10 | 10 | import { scenario } from "../src/scenario"; |
| 11 | 11 | import { Browser, Target } from "../src/services"; |
| 12 | +import { visit, settle } from "../src/surfaces/browser"; | |
| 12 | 13 | |
| 13 | 14 | scenario( |
| 14 | 15 | "Organizations · an admin deletes the organization from settings", |
| @@ -22,7 +23,7 @@ scenario( · 7 unmodified lines | ||
| 22 | 23 | const ORG = "Doomed Org"; |
| 23 | 24 | |
| 24 | 25 | await step("Fresh user creates an org via onboarding", async () => { |
| 25 | − await page.goto("/", { waitUntil: "networkidle" }); | |
| 26 | + await visit(page, "/"); | |
| 26 | 27 | await page.getByPlaceholder("Northwind Labs").fill(ORG); |
| 27 | 28 | await page.getByRole("button", { name: "Create organization" }).click(); |
| 28 | 29 | await page.getByText("Connect your MCP client").waitFor(); |
| @@ -32,13 +33,13 @@ scenario( · 3 unmodified lines | ||
| 32 | 33 | await page.waitForURL((url) => /^\/[a-z0-9-]+\/?$/.test(url.pathname), { |
| 33 | 34 | timeout: 30_000, |
| 34 | 35 | }); |
| 35 | − await page.waitForLoadState("networkidle"); | |
| 36 | + await settle(page); | |
| 36 | 37 | }); |
| 37 | 38 | |
| 38 | 39 | const slug = new URL(page.url()).pathname.split("/").filter(Boolean)[0]!; |
| 39 | 40 | |
| 40 | 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 | 43 | // The admin-only danger zone renders (a member would not see it). |
| 43 | 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 | 14 | import { scenario } from "../src/scenario"; |
| 15 | 15 | import { Browser, Target } from "../src/services"; |
| 16 | +import { visit } from "../src/surfaces/browser"; | |
| 16 | 17 | |
| 17 | 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 | 66 | yield* browser.session(inB, async ({ page, step }) => { |
| 66 | 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 | 69 | await page.getByText("Integrations").first().waitFor({ timeout: 30_000 }); |
| 69 | 70 | // The client records the viewed org once /account/me confirms it. |
| 70 | 71 | await page.waitForFunction( |
| @@ -75,7 +76,7 @@ scenario( · 4 unmodified lines | ||
| 75 | 76 | }); |
| 76 | 77 | |
| 77 | 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 | 80 | await page.waitForURL( |
| 80 | 81 | (url) => url.pathname === `/${slugA}` || url.pathname === `/${slugA}/`, |
| 81 | 82 | { |
| @@ -86,7 +87,7 @@ scenario( · 4 unmodified lines | ||
| 86 | 87 | }); |
| 87 | 88 | |
| 88 | 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 | 91 | await page.waitForURL((url) => url.pathname === `/${slugA}/policies`, { timeout: 30_000 }); |
| 91 | 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 | 9 | import { scenario } from "../src/scenario"; |
| 10 | 10 | import { Billing, Browser, Target } from "../src/services"; |
| 11 | +import { visit, settle } from "../src/surfaces/browser"; | |
| 11 | 12 | |
| 12 | 13 | const FREE_LIMIT = 3; |
| 13 | 14 | |
| @@ -23,7 +24,7 @@ scenario( · 9 unmodified lines | ||
| 23 | 24 | |
| 24 | 25 | yield* browser.session(identity, async ({ page, step }) => { |
| 25 | 26 | await step("A fresh user lands on onboarding (no organization yet)", async () => { |
| 26 | − await page.goto("/", { waitUntil: "networkidle" }); | |
| 27 | + await visit(page, "/"); | |
| 27 | 28 | await page.getByPlaceholder("Northwind Labs").waitFor(); |
| 28 | 29 | }); |
| 29 | 30 | |
| @@ -43,7 +44,7 @@ scenario( · 13 unmodified lines | ||
| 43 | 44 | await page.waitForURL((url) => /^\/[a-z0-9-]+\/?$/.test(url.pathname), { |
| 44 | 45 | timeout: 30_000, |
| 45 | 46 | }); |
| 46 | − await page.waitForLoadState("networkidle"); | |
| 47 | + await settle(page); | |
| 47 | 48 | }); |
| 48 | 49 | |
| 49 | 50 | const openCreateOrgModal = async (currentOrg: string) => { |
| @@ -21,6 +21,7 @@ import { Effect } from "effect"; · 20 unmodified lines | ||
| 21 | 21 | |
| 22 | 22 | import { scenario } from "../src/scenario"; |
| 23 | 23 | import { Browser, Target } from "../src/services"; |
| 24 | +import { visit } from "../src/surfaces/browser"; | |
| 24 | 25 | |
| 25 | 26 | scenario( |
| 26 | 27 | "Org tabs · two tabs on different orgs stay independent (URL-scoped, no cookie steal)", |
| @@ -53,7 +54,7 @@ scenario( · 26 unmodified lines | ||
| 53 | 54 | let slugB = ""; |
| 54 | 55 | |
| 55 | 56 | await step("Onboard org A in tab 1", async () => { |
| 56 | − await tab1.goto("/", { waitUntil: "networkidle" }); | |
| 57 | + await visit(tab1, "/"); | |
| 57 | 58 | await tab1.getByPlaceholder("Northwind Labs").fill("Multitab A"); |
| 58 | 59 | await tab1.getByRole("button", { name: "Create organization" }).click(); |
| 59 | 60 | await tab1.getByText("Connect your MCP client").waitFor({ timeout: 30_000 }); |
| @@ -83,7 +84,7 @@ scenario( · 23 unmodified lines | ||
| 83 | 84 | const tab2 = await tab1.context().newPage(); |
| 84 | 85 | |
| 85 | 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 | 88 | await tab2.getByText("Policies").first().waitFor({ timeout: 30_000 }); |
| 88 | 89 | expect(new URL(tab2.url()).pathname, "tab 2 stays on org A's URL").toBe( |
| 89 | 90 | `/${slugA}/policies`, |
| @@ -134,7 +135,7 @@ scenario( · 44 unmodified lines | ||
| 134 | 135 | |
| 135 | 136 | yield* browser.session(identity, async ({ page, step }) => { |
| 136 | 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 | 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 | 14 | import { scenario } from "../src/scenario"; |
| 15 | 15 | import { Browser, Target } from "../src/services"; |
| 16 | +import { visit } from "../src/surfaces/browser"; | |
| 16 | 17 | |
| 17 | 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 | 69 | yield* browser.session(inB, async ({ page, step }) => { |
| 69 | 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 | 72 | await page.getByText("Integrations").first().waitFor({ timeout: 30_000 }); |
| 72 | 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 | 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 | 9 | import { scenario } from "../src/scenario"; |
| 10 | 10 | import { Browser, Target } from "../src/services"; |
| 11 | +import { visit, settle } from "../src/surfaces/browser"; | |
| 11 | 12 | |
| 12 | 13 | scenario( |
| 13 | 14 | "Organizations · switching organizations switches the workspace", |
| @@ -20,7 +21,7 @@ scenario( · 6 unmodified lines | ||
| 20 | 21 | yield* browser.session(identity, async ({ page, step }) => { |
| 21 | 22 | // ── Step 1: onboarding, create the first org ───────────────────── |
| 22 | 23 | await step("Fresh user lands on onboarding (no organization yet)", async () => { |
| 23 | − await page.goto("/", { waitUntil: "networkidle" }); | |
| 24 | + await visit(page, "/"); | |
| 24 | 25 | await page.getByPlaceholder("Northwind Labs").waitFor(); |
| 25 | 26 | }); |
| 26 | 27 | |
| @@ -43,7 +44,7 @@ scenario( · 16 unmodified lines | ||
| 43 | 44 | await page.waitForURL((url) => /^\/[a-z0-9-]+\/?$/.test(url.pathname), { |
| 44 | 45 | timeout: 30_000, |
| 45 | 46 | }); |
| 46 | − await page.waitForLoadState("networkidle"); | |
| 47 | + await settle(page); | |
| 47 | 48 | }); |
| 48 | 49 | |
| 49 | 50 | // ── Step 2: create the second org via the account-menu switcher ── |
| @@ -89,7 +90,7 @@ scenario( · 39 unmodified lines | ||
| 89 | 90 | // rendered with data-disabled="" (Radix convention). The only item without |
| 90 | 91 | // data-disabled that isn't "Create organization" is ORG_1. |
| 91 | 92 | await step(`Open the org switcher and switch back to "${ORG_1}"`, async () => { |
| 92 | − await page.waitForLoadState("networkidle"); | |
| 93 | + await settle(page); | |
| 93 | 94 | await page.getByRole("button", { name: /Test User/ }).click(); |
| 94 | 95 | // Click the SubTrigger (shows current org name = ORG_2) to expand the list. |
| 95 | 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 | 44 | import { scenario } from "../src/scenario"; |
| 45 | 45 | import { Api, Browser, Mcp, Target } from "../src/services"; |
| 46 | +import { visit } from "../src/surfaces/browser"; | |
| 46 | 47 | |
| 47 | 48 | const api = composePluginApi([] as const); |
| 48 | 49 | |
| @@ -230,7 +231,7 @@ scenario( · 181 unmodified lines | ||
| 230 | 231 | yield* browser.session(identity, async ({ page, step }) => { |
| 231 | 232 | await step("Open the Artifacts gallery", async () => { |
| 232 | 233 | await startSampling(page); |
| 233 | − await page.goto(`${target.baseUrl}/artifacts`, { waitUntil: "networkidle" }); | |
| 234 | + await visit(page, `${target.baseUrl}/artifacts`); | |
| 234 | 235 | await page |
| 235 | 236 | .getByRole("link", { name: `Open artifact ${title}` }) |
| 236 | 237 | .waitFor({ timeout: 30_000 }); |
| @@ -17,6 +17,7 @@ import { Effect } from "effect"; · 16 unmodified lines | ||
| 17 | 17 | |
| 18 | 18 | import { scenario } from "../src/scenario"; |
| 19 | 19 | import { Browser, Mcp, Target } from "../src/services"; |
| 20 | +import { revisit, visit } from "../src/surfaces/browser"; | |
| 20 | 21 | |
| 21 | 22 | const uniqueSuffix = () => randomBytes(3).toString("hex"); |
| 22 | 23 | |
| @@ -167,7 +168,7 @@ scenario( · 144 unmodified lines | ||
| 167 | 168 | |
| 168 | 169 | yield* browser.session(identity, async ({ page, step }) => { |
| 169 | 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 | 172 | await page.getByRole("heading", { name: "Artifacts", level: 1 }).waitFor(); |
| 172 | 173 | // Every seeded artifact is present before anything is measured. |
| 173 | 174 | for (const [name] of seeded) { |
| @@ -257,7 +258,7 @@ scenario( · 83 unmodified lines | ||
| 257 | 258 | // The stored markup is token-based, so the SAME markup has to read |
| 258 | 259 | // correctly against the console's dark surface without being restyled. |
| 259 | 260 | await page.emulateMedia({ colorScheme: "dark" }); |
| 260 | − await page.reload({ waitUntil: "networkidle" }); | |
| 261 | + await revisit(page); | |
| 261 | 262 | await page.getByRole("heading", { name: "Artifacts", level: 1 }).waitFor(); |
| 262 | 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 | 274 | await step("Gallery in light mode", async () => { |
| 274 | 275 | await page.emulateMedia({ colorScheme: "light" }); |
| 275 | − await page.reload({ waitUntil: "networkidle" }); | |
| 276 | + await revisit(page); | |
| 276 | 277 | await page.getByRole("heading", { name: "Artifacts", level: 1 }).waitFor(); |
| 277 | 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 | 25 | import { scenario } from "../src/scenario"; |
| 26 | 26 | import { Api, Browser, Mcp, Target } from "../src/services"; |
| 27 | +import { visit } from "../src/surfaces/browser"; | |
| 27 | 28 | |
| 28 | 29 | const api = composePluginApi([] as const); |
| 29 | 30 | |
| @@ -263,7 +264,7 @@ scenario( · 233 unmodified lines | ||
| 263 | 264 | |
| 264 | 265 | await step("Open the artifact link the agent handed over", async () => { |
| 265 | 266 | await recordHandshakeOrdering(page); |
| 266 | − await page.goto(url, { waitUntil: "networkidle" }); | |
| 267 | + await visit(page, url); | |
| 267 | 268 | consoleStyleBefore = await readConsoleStyle(page); |
| 268 | 269 | }); |
| 269 | 270 | |
| @@ -596,7 +597,7 @@ scenario( · 326 unmodified lines | ||
| 596 | 597 | |
| 597 | 598 | yield* browser.session(identity, async ({ page, step }) => { |
| 598 | 599 | await step("Open the Artifacts tab", async () => { |
| 599 | − await page.goto("/artifacts", { waitUntil: "networkidle" }); | |
| 600 | + await visit(page, "/artifacts"); | |
| 600 | 601 | await page.getByRole("link", { name: `Open artifact ${originalTitle}` }).waitFor({ |
| 601 | 602 | timeout: 20_000, |
| 602 | 603 | }); |
| @@ -634,7 +635,7 @@ scenario( · 31 unmodified lines | ||
| 634 | 635 | |
| 635 | 636 | yield* browser.session(identity, async ({ page, step }) => { |
| 636 | 637 | await step("Delete the artifact from the list", async () => { |
| 637 | − await page.goto("/artifacts", { waitUntil: "networkidle" }); | |
| 638 | + await visit(page, "/artifacts"); | |
| 638 | 639 | const card = page.locator('[data-slot="artifact-card"]').filter({ |
| 639 | 640 | hasText: renamedTitle, |
| 640 | 641 | }); |
| @@ -738,7 +739,7 @@ scenario( · 97 unmodified lines | ||
| 738 | 739 | |
| 739 | 740 | yield* browser.session(identity, async ({ page, step }) => { |
| 740 | 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 | 743 | const content = artifactContent(page); |
| 743 | 744 | await content |
| 744 | 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 | 16 | import { scenario } from "../src/scenario"; |
| 17 | 17 | import { Browser, Target } from "../src/services"; |
| 18 | +import { visit } from "../src/surfaces/browser"; | |
| 18 | 19 | |
| 19 | 20 | scenario( |
| 20 | 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 | 36 | yield* browser.session(identity, async ({ page, step }) => { |
| 36 | 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 | − waitUntil: "networkidle", | |
| 39 | − }); | |
| 38 | + await visit(page, `/integrations/add/mcp?url=${encodeURIComponent(server.endpoint)}`); | |
| 40 | 39 | // The URL auto-probes (debounced); the method list appears once |
| 41 | 40 | // the probe lands. Generous timeout: the probe request can queue |
| 42 | 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 | 25 | import { type McpBrowserApproval, parseBrowserApproval } from "../src/surfaces/mcp"; |
| 26 | 26 | import type { BrowserSurface } from "../src/surfaces/browser"; |
| 27 | 27 | import type { Identity } from "../src/target"; |
| 28 | +import { visit } from "../src/surfaces/browser"; | |
| 28 | 29 | |
| 29 | 30 | const coreApi = composePluginApi([] as const); |
| 30 | 31 | |
| @@ -70,7 +71,7 @@ const decideInBrowser = ( · 39 unmodified lines | ||
| 70 | 71 | await step( |
| 71 | 72 | `Open the approval page and ${decision.toLowerCase()} the paused action`, |
| 72 | 73 | async () => { |
| 73 | − await page.goto(approval.approvalUrl, { waitUntil: "networkidle" }); | |
| 74 | + await visit(page, approval.approvalUrl); | |
| 74 | 75 | await page.getByRole("button", { name: decision }).click(); |
| 75 | 76 | // The page confirms the decision was recorded ("Approve sent" / "Decline sent"). |
| 76 | 77 | await page.getByText(`${decision} sent`).waitFor(); |
| @@ -30,6 +30,7 @@ import { · 29 unmodified lines | ||
| 30 | 30 | |
| 31 | 31 | import { scenario } from "../src/scenario"; |
| 32 | 32 | import { Api, Browser, Target } from "../src/services"; |
| 33 | +import { visit } from "../src/surfaces/browser"; | |
| 33 | 34 | |
| 34 | 35 | const api = composePluginApi([openApiHttpPlugin()] as const); |
| 35 | 36 | |
| @@ -95,7 +96,7 @@ scenario( · 59 unmodified lines | ||
| 95 | 96 | |
| 96 | 97 | yield* browser.session(identity, async ({ page, step }) => { |
| 97 | 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 | 100 | // It forwards into the integration's OWN detail route rather than |
| 100 | 101 | // rendering a parallel connect UI, carrying the add-account handoff. |
| 101 | 102 | await page.waitForURL((url) => url.pathname === detailPath, { timeout: 30_000 }); |
| @@ -115,7 +116,7 @@ scenario( · 13 unmodified lines | ||
| 115 | 116 | }); |
| 116 | 117 | |
| 117 | 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 | 120 | await page |
| 120 | 121 | .getByText("Unknown integration: zz-no-such-integration") |
| 121 | 122 | .waitFor({ timeout: 30_000 }); |
| @@ -139,7 +140,7 @@ scenario( · 17 unmodified lines | ||
| 139 | 140 | |
| 140 | 141 | yield* browser.session(identity, async ({ page, step }) => { |
| 141 | 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 | 144 | await page.waitForURL((url) => url.pathname === detailPath, { timeout: 30_000 }); |
| 144 | 145 | // The connect flow still opens (this link means "connect me"), and |
| 145 | 146 | // the already-saved account is visible behind it. |
| @@ -20,6 +20,7 @@ import { scenario } from "../src/scenario"; · 19 unmodified lines | ||
| 20 | 20 | import { Browser, Cli, Mcp, RunDir, Target } from "../src/services"; |
| 21 | 21 | import { withChatTheater } from "../src/clients/chat-theater"; |
| 22 | 22 | import type { McpSession } from "../src/surfaces/mcp"; |
| 23 | +import { visit } from "../src/surfaces/browser"; | |
| 23 | 24 | |
| 24 | 25 | const unique = (prefix: string) => `${prefix}_${randomBytes(4).toString("hex")}`; |
| 25 | 26 | |
| @@ -148,7 +149,7 @@ scenario( · 122 unmodified lines | ||
| 148 | 149 | yield* chat.status("you, in the browser: opening the link and pasting the API key…"); |
| 149 | 150 | yield* browser.session(identity, async ({ page, step }) => { |
| 150 | 151 | await step("Open the connect link from the chat", async () => { |
| 151 | − await page.goto(handoffUrl, { waitUntil: "networkidle" }); | |
| 152 | + await visit(page, handoffUrl); | |
| 152 | 153 | await page |
| 153 | 154 | .getByRole("heading", { name: /Add connection/ }) |
| 154 | 155 | .waitFor({ timeout: 15_000 }); |
| @@ -32,6 +32,7 @@ import { Api, Browser, Mcp, Target } from "../src/services"; · 31 unmodified lines | ||
| 32 | 32 | import type { Identity, Target as TargetShape } from "../src/target"; |
| 33 | 33 | import type { BrowserSurface } from "../src/surfaces/browser"; |
| 34 | 34 | import type { McpSession } from "../src/surfaces/mcp"; |
| 35 | +import { visit } from "../src/surfaces/browser"; | |
| 35 | 36 | |
| 36 | 37 | const api = composePluginApi([openApiHttpPlugin()] as const); |
| 37 | 38 | |
| @@ -214,7 +215,7 @@ const runScenario = (input: { · 176 unmodified lines | ||
| 214 | 215 | // 4. The user opens the handoff URL and pastes the credential. |
| 215 | 216 | yield* browser.session(identity, async ({ page, step }) => { |
| 216 | 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 | 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 | 15 | import { scenario } from "../src/scenario"; |
| 16 | 16 | import { Api, Browser, Target } from "../src/services"; |
| 17 | +import { visit } from "../src/surfaces/browser"; | |
| 17 | 18 | |
| 18 | 19 | const api = composePluginApi([openApiHttpPlugin()] as const); |
| 19 | 20 | |
| @@ -77,7 +78,7 @@ scenario( · 57 unmodified lines | ||
| 77 | 78 | const confirm = page.getByRole("alertdialog"); |
| 78 | 79 | |
| 79 | 80 | await step("Open the integration's connections", async () => { |
| 80 | − await page.goto(`/integrations/${slug}`, { waitUntil: "networkidle" }); | |
| 81 | + await visit(page, `/integrations/${slug}`); | |
| 81 | 82 | await row.waitFor(); |
| 82 | 83 | }); |
| 83 | 84 | |
| @@ -3,6 +3,7 @@ import { Effect } from "effect"; · 2 unmodified lines | ||
| 3 | 3 | |
| 4 | 4 | import { scenario } from "../src/scenario"; |
| 5 | 5 | import { Browser, Target } from "../src/services"; |
| 6 | +import { visit } from "../src/surfaces/browser"; | |
| 6 | 7 | |
| 7 | 8 | // A user inside the console should be able to reach the documentation without |
| 8 | 9 | // leaving the app for the marketing site (the original friction: docs were |
| @@ -18,7 +19,7 @@ scenario( · 9 unmodified lines | ||
| 18 | 19 | |
| 19 | 20 | yield* browser.session(identity, async ({ page, step }) => { |
| 20 | 21 | await step("Open the console", async () => { |
| 21 | − await page.goto("/", { waitUntil: "networkidle" }); | |
| 22 | + await visit(page, "/"); | |
| 22 | 23 | await page.getByText("Integrations").first().waitFor(); |
| 23 | 24 | }); |
| 24 | 25 | |
| @@ -26,7 +26,7 @@ import { · 25 unmodified lines | ||
| 26 | 26 | |
| 27 | 27 | import { scenario } from "../src/scenario"; |
| 28 | 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 | 31 | const api = composePluginApi([openApiHttpPlugin()] as const); |
| 32 | 32 | |
| @@ -182,7 +182,7 @@ scenario( · 149 unmodified lines | ||
| 182 | 182 | |
| 183 | 183 | yield* browser.session(identity, async ({ page, step }) => { |
| 184 | 184 | await step("Open the Google integration catalog", async () => { |
| 185 | − await page.goto("/integrations", { waitUntil: "networkidle" }); | |
| 185 | + await visit(page, "/integrations"); | |
| 186 | 186 | const dialog = page.getByRole("dialog", { name: "Connect an integration" }); |
| 187 | 187 | await clickToReveal(page.getByRole("button", { name: /Connect/ }).first(), dialog); |
| 188 | 188 | }); |
| @@ -13,10 +13,11 @@ import { · 12 unmodified lines | ||
| 13 | 13 | OAuthClientSlug, |
| 14 | 14 | } from "@executor-js/sdk/shared"; |
| 15 | 15 | |
| 16 | +import { createEmulatorInstance } from "../src/emulator-instance"; | |
| 16 | 17 | import { scenario } from "../src/scenario"; |
| 17 | 18 | import { Api, Browser, Target } from "../src/services"; |
| 18 | 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 | 22 | const api = composePluginApi([openApiHttpPlugin()] as const); |
| 22 | 23 | type Client = HttpApiClient.ForApi<typeof api>; |
| @@ -69,14 +70,13 @@ const completeGoogleConsent = (authorizationUrl: string) => · 46 unmodified lines | ||
| 69 | 70 | return code; |
| 70 | 71 | }); |
| 71 | 72 | |
| 72 | −const createGoogleEmulator = Effect.promise(async () => { | |
| 73 | − const response = await fetch("https://google.emulators.dev/_emulate/instances", { | |
| 74 | − method: "POST", | |
| 75 | − }); | |
| 76 | − if (!response.ok) throw new Error(`Google emulator instance failed: ${response.status}`); | |
| 77 | − const instance = (await response.json()) as { readonly providerBaseUrl: string }; | |
| 78 | − const client = await connectEmulator({ baseUrl: instance.providerBaseUrl }); | |
| 79 | − return { client, baseUrl: instance.providerBaseUrl }; | |
| 73 | +const createGoogleEmulator = Effect.gen(function* () { | |
| 74 | + // Through the shared helper: it bounds and retries the one request in this | |
| 75 | + // scenario that leaves the runner, so a blip on the way to the edge doesn't | |
| 76 | + // read as a Google health-check failure. | |
| 77 | + const baseUrl = yield* createEmulatorInstance("google", "google-health"); | |
| 78 | + const client = yield* Effect.promise(() => connectEmulator({ baseUrl })); | |
| 79 | + return { client, baseUrl }; | |
| 80 | 80 | }); |
| 81 | 81 | |
| 82 | 82 | const addGooglePresetFromCatalog = ( |
| @@ -87,7 +87,7 @@ const addGooglePresetFromCatalog = ( · 4 unmodified lines | ||
| 87 | 87 | ) => |
| 88 | 88 | browser.session(identity, async ({ page, step }) => { |
| 89 | 89 | await step(`Open ${presetName} from the connect catalog`, async () => { |
| 90 | − await page.goto("/integrations", { waitUntil: "networkidle" }); | |
| 90 | + await visit(page, "/integrations"); | |
| 91 | 91 | const dialog = page.getByRole("dialog", { name: "Connect an integration" }); |
| 92 | 92 | await clickToReveal(page.getByRole("button", { name: /Connect/ }).first(), dialog); |
| 93 | 93 | await dialog.getByPlaceholder(/Search or paste a URL/).fill(presetName); |
| @@ -266,29 +266,31 @@ scenario( · 172 unmodified lines | ||
| 266 | 266 | health.status, |
| 267 | 267 | `${row.presetName} health check is healthy: ${JSON.stringify(health)}`, |
| 268 | 268 | ).toBe("healthy"); |
| 269 | − } | |
| 270 | 269 | |
| 271 | − // The hosted emulator acknowledges a request before its ledger entry | |
| 272 | − // is readable, so a single list right after the probe races the write | |
| 273 | − // (the health checks above already came back healthy, which only the | |
| 274 | − // emulator can answer). Poll until every expected operation is | |
| 275 | − // visible; on timeout the assertion names what never arrived. | |
| 276 | − const missing = yield* Effect.gen(function* () { | |
| 277 | − const ledger = yield* Effect.promise(() => emulator.client.ledger.list(100)); | |
| 278 | − return rows.filter( | |
| 279 | − (row) => !ledger.some((entry) => entry.operationId === row.expectedLedgerOperation), | |
| 270 | + // Check this row's ledger entry HERE, not after both rows have run. | |
| 271 | + // `ledger.list(n)` is the last n entries, and connecting the second | |
| 272 | + // account is easily a hundred emulator requests, so the first row's | |
| 273 | + // health check can be evicted from the window before a combined | |
| 274 | + // assertion at the end ever looks for it — which reads as "Calendar's | |
| 275 | + // health check never reached the emulator" when it plainly did (the | |
| 276 | + // probe above came back healthy, and only the emulator can answer | |
| 277 | + // that). The hosted emulator also acknowledges a request before its | |
| 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( | |
| 282 | − Effect.repeat({ | |
| 283 | − schedule: Schedule.spaced("500 millis"), | |
| 284 | − until: (unseen) => unseen.length === 0, | |
| 285 | − times: 19, | |
| 286 | − }), | |
| 287 | − ); | |
| 288 | − expect( | |
| 289 | − missing.map((row) => row.presetName), | |
| 290 | − "every health check reached the Google emulator", | |
| 291 | − ).toEqual([]); | |
| 289 | + expect( | |
| 290 | + reached, | |
| 291 | + `${row.presetName}'s health check reached the Google emulator as ${row.expectedLedgerOperation}`, | |
| 292 | + ).toBe(true); | |
| 293 | + } | |
| 292 | 294 | }), |
| 293 | 295 | Effect.gen(function* () { |
| 294 | 296 | for (const row of rows) { |
| @@ -3,7 +3,7 @@ import { Effect } from "effect"; · 2 unmodified lines | ||
| 3 | 3 | |
| 4 | 4 | import { scenario } from "../src/scenario"; |
| 5 | 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 | 8 | scenario( |
| 9 | 9 | "Google Photos: separated catalog presets open a Photos service add flow", |
| @@ -17,7 +17,7 @@ scenario( · 7 unmodified lines | ||
| 17 | 17 | await step( |
| 18 | 18 | "Find the separated Google Photos presets from the integrations picker", |
| 19 | 19 | async () => { |
| 20 | − await page.goto("/integrations", { waitUntil: "networkidle" }); | |
| 20 | + await visit(page, "/integrations"); | |
| 21 | 21 | const dialog = page.getByRole("dialog", { name: "Connect an integration" }); |
| 22 | 22 | await clickToReveal(page.getByRole("button", { name: "Connect" }), dialog); |
| 23 | 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 | 11 | import { createEmulatorInstance } from "../src/emulator-instance"; |
| 12 | 12 | import { scenario } from "../src/scenario"; |
| 13 | 13 | import { Api, Browser, Target } from "../src/services"; |
| 14 | +import { visit } from "../src/surfaces/browser"; | |
| 14 | 15 | |
| 15 | 16 | const api = composePluginApi([graphqlHttpPlugin()] as const); |
| 16 | 17 | const unique = (prefix: string): string => `${prefix}_${randomBytes(4).toString("hex")}`; |
| @@ -30,11 +31,18 @@ scenario( · 13 unmodified lines | ||
| 30 | 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 | 41 | yield* Effect.promise(() => |
| 34 | 42 | emulator.faults.arm({ |
| 35 | 43 | match: { method: "POST", pathPattern: "/graphql" }, |
| 36 | 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 | 64 | yield* Effect.gen(function* () { |
| 57 | 65 | yield* browser.session(identity, async ({ page, step }) => { |
| 58 | 66 | await step("Open the connection flow", async () => { |
| 59 | − await page.goto(`/integrations/${slug}?addAccount=1&owner=org&template=header`, { | |
| 60 | − waitUntil: "networkidle", | |
| 61 | − }); | |
| 67 | + await visit(page, `/integrations/${slug}?addAccount=1&owner=org&template=header`); | |
| 62 | 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 | 104 | yield* browser.session(identity, async ({ page, step }) => { |
| 99 | 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 | 107 | await page.getByText("Connection rejected", { exact: true }).first().waitFor(); |
| 102 | 108 | await page |
| 103 | 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 | 35 | import { scenario } from "../src/scenario"; |
| 36 | 36 | import { Api, Browser, Target } from "../src/services"; |
| 37 | +import { visit } from "../src/surfaces/browser"; | |
| 37 | 38 | |
| 38 | 39 | const api = composePluginApi([openApiHttpPlugin()] as const); |
| 39 | 40 | type Client = HttpApiClient.ForApi<typeof api>; |
| @@ -281,7 +282,7 @@ scenario( · 241 unmodified lines | ||
| 281 | 282 | const dialog = page.getByRole("dialog"); |
| 282 | 283 | |
| 283 | 284 | await step("Open the Add Connection modal", async () => { |
| 284 | − await page.goto(`/integrations/${slug}`, { waitUntil: "networkidle" }); | |
| 285 | + await visit(page, `/integrations/${slug}`); | |
| 285 | 286 | await page.getByRole("button", { name: "Add connection", exact: true }).click(); |
| 286 | 287 | await page.getByRole("heading", { name: /Add connection/ }).waitFor(); |
| 287 | 288 | }); |
| @@ -395,7 +396,7 @@ scenario( · 107 unmodified lines | ||
| 395 | 396 | const options = page.getByRole("option"); |
| 396 | 397 | |
| 397 | 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 | 400 | await page.getByRole("heading", { level: 3, name: "Health check" }).waitFor(); |
| 400 | 401 | await page.getByRole("button", { name: "Set up" }).click(); |
| 401 | 402 | await input.waitFor(); |
| @@ -479,7 +480,7 @@ scenario( · 77 unmodified lines | ||
| 479 | 480 | const options = page.getByRole("option"); |
| 480 | 481 | |
| 481 | 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 | 484 | await page.getByPlaceholder("https://api.example.com/openapi.json").fill(spec); |
| 484 | 485 | await page |
| 485 | 486 | .getByRole("heading", { name: "Health check (optional)" }) |
| @@ -558,7 +559,7 @@ scenario( · 72 unmodified lines | ||
| 558 | 559 | const operationInput = page.locator("#health-check-operation"); |
| 559 | 560 | |
| 560 | 561 | await step("Open the health-check editor", async () => { |
| 561 | − await page.goto(`/integrations/${slug}`, { waitUntil: "networkidle" }); | |
| 562 | + await visit(page, `/integrations/${slug}`); | |
| 562 | 563 | await page.getByRole("heading", { level: 3, name: "Health check" }).waitFor(); |
| 563 | 564 | await page.getByRole("button", { name: "Set up" }).click(); |
| 564 | 565 | await operationInput.waitFor(); |
| @@ -629,7 +630,7 @@ scenario( · 64 unmodified lines | ||
| 629 | 630 | const list = page.locator("[data-slot='combobox-list']").first(); |
| 630 | 631 | |
| 631 | 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 | 634 | await page.getByRole("heading", { level: 3, name: "Health check" }).waitFor(); |
| 634 | 635 | await page.getByRole("button", { name: "Set up" }).click(); |
| 635 | 636 | await operationInput.waitFor(); |
| @@ -705,7 +706,7 @@ scenario( · 69 unmodified lines | ||
| 705 | 706 | const menuTrigger = connections.locator('button[aria-haspopup="menu"]'); |
| 706 | 707 | |
| 707 | 708 | await step("Open the integration's connections", async () => { |
| 708 | − await page.goto(`/integrations/${slug}`, { waitUntil: "networkidle" }); | |
| 709 | + await visit(page, `/integrations/${slug}`); | |
| 709 | 710 | await connections.getByText("main", { exact: true }).waitFor(); |
| 710 | 711 | await page.getByRole("heading", { level: 3, name: "Health check" }).waitFor(); |
| 711 | 712 | }); |
| @@ -795,7 +796,7 @@ scenario( · 83 unmodified lines | ||
| 795 | 796 | const dialog = page.getByRole("dialog"); |
| 796 | 797 | |
| 797 | 798 | await step("Open the Add Connection modal", async () => { |
| 798 | − await page.goto(`/integrations/${slug}`, { waitUntil: "networkidle" }); | |
| 799 | + await visit(page, `/integrations/${slug}`); | |
| 799 | 800 | await page.getByRole("button", { name: "Add connection", exact: true }).click(); |
| 800 | 801 | await page.getByRole("heading", { name: /Add connection/ }).waitFor(); |
| 801 | 802 | }); |
| @@ -888,7 +889,7 @@ scenario( · 86 unmodified lines | ||
| 888 | 889 | await step( |
| 889 | 890 | "A fresh page load shows the expired connection with NO clicking", |
| 890 | 891 | async () => { |
| 891 | − await page.goto(`/integrations/${slug}`, { waitUntil: "networkidle" }); | |
| 892 | + await visit(page, `/integrations/${slug}`); | |
| 892 | 893 | // The persisted verdict drives the row: red dot + Expired badge |
| 893 | 894 | // are already there on first paint of the list. |
| 894 | 895 | await connections.getByLabel("Status: Expired").waitFor({ timeout: 30_000 }); |
| @@ -907,7 +908,7 @@ scenario( · 12 unmodified lines | ||
| 907 | 908 | // not after the freshness window). Restore the key, reload, |
| 908 | 909 | // and the dot flips back with no clicks. |
| 909 | 910 | server.restore(); |
| 910 | − await page.goto(`/integrations/${slug}`, { waitUntil: "networkidle" }); | |
| 911 | + await visit(page, `/integrations/${slug}`); | |
| 911 | 912 | // The row mounts with the stale expired verdict, then the |
| 912 | 913 | // background revalidation flips it to healthy in place. |
| 913 | 914 | await connections.getByLabel("Status: Healthy").waitFor({ timeout: 30_000 }); |
| @@ -993,7 +994,7 @@ scenario( · 79 unmodified lines | ||
| 993 | 994 | // while the operation combobox still renders (a healthy probe |
| 994 | 995 | // saves the check and turns the request line static). |
| 995 | 996 | await page.setViewportSize({ width: 1280, height: 420 }); |
| 996 | − await page.goto(`/integrations/${slug}`, { waitUntil: "networkidle" }); | |
| 997 | + await visit(page, `/integrations/${slug}`); | |
| 997 | 998 | await page.getByRole("button", { name: "Add connection", exact: true }).click(); |
| 998 | 999 | await page.getByRole("heading", { name: /Add connection/ }).waitFor(); |
| 999 | 1000 | await page.keyboard.type(goodToken); |
| @@ -1136,7 +1137,7 @@ scenario( · 136 unmodified lines | ||
| 1136 | 1137 | await step( |
| 1137 | 1138 | "Load the integrations list: the dead MCP row reads Expired with no clicks", |
| 1138 | 1139 | async () => { |
| 1139 | − await page.goto("/", { waitUntil: "networkidle" }); | |
| 1140 | + await visit(page, "/"); | |
| 1140 | 1141 | // The row itself is the assertion surface: the list-page |
| 1141 | 1142 | // summary probes in the background and paints the worst-of |
| 1142 | 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 | 15 | import { scenario } from "../src/scenario"; |
| 16 | 16 | import { Browser, Target } from "../src/services"; |
| 17 | +import { revisit, visit } from "../src/surfaces/browser"; | |
| 17 | 18 | |
| 18 | 19 | scenario( |
| 19 | 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 | 37 | yield* browser.session(identity, async ({ page, step }) => { |
| 37 | 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 | − waitUntil: "networkidle", | |
| 40 | − }); | |
| 39 | + await visit(page, `/integrations/add/mcp?url=${encodeURIComponent(server.endpoint)}`); | |
| 41 | 40 | // The URL auto-probes (debounced); the method list appears once the |
| 42 | 41 | // probe lands — an open server seeds the detected no-auth method. |
| 43 | 42 | await page.getByText("How does this server authenticate?").waitFor(); |
| @@ -95,7 +94,7 @@ scenario( · 51 unmodified lines | ||
| 95 | 94 | // Re-enter the page: a fresh tools read. The list_changed the server |
| 96 | 95 | // sent during the call marked the catalog stale, so THIS read |
| 97 | 96 | // re-lists — the renamed tool appears with no Refresh click. |
| 98 | − await page.reload({ waitUntil: "networkidle" }); | |
| 97 | + await revisit(page); | |
| 99 | 98 | await page.getByRole("tab", { name: "Tools" }).click(); |
| 100 | 99 | await filterTools("greet"); |
| 101 | 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 | 32 | import { scenario } from "../src/scenario"; |
| 33 | 33 | import { Api, Browser, Target } from "../src/services"; |
| 34 | +import { visit } from "../src/surfaces/browser"; | |
| 34 | 35 | |
| 35 | 36 | const api = composePluginApi([openApiHttpPlugin()] as const); |
| 36 | 37 | |
| @@ -153,9 +154,7 @@ scenario( · 116 unmodified lines | ||
| 153 | 154 | |
| 154 | 155 | yield* browser.session(identity, async ({ page, step }) => { |
| 155 | 156 | await step("Open the connect modal for an OAuth integration", async () => { |
| 156 | − await page.goto(`/integrations/${String(integration)}?addAccount=1`, { | |
| 157 | − waitUntil: "networkidle", | |
| 158 | − }); | |
| 157 | + await visit(page, `/integrations/${String(integration)}?addAccount=1`); | |
| 159 | 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 | 37 | import { scenario } from "../src/scenario"; |
| 38 | 38 | import { Api, Browser, Mcp, Target } from "../src/services"; |
| 39 | 39 | import type { McpSession } from "../src/surfaces/mcp"; |
| 40 | +import { visit } from "../src/surfaces/browser"; | |
| 40 | 41 | |
| 41 | 42 | const microsoftApi = composePluginApi([ |
| 42 | 43 | openApiHttpPlugin({ presets: microsoftCatalog, specFormats: [microsoftGraphAdapter] }), |
| @@ -400,7 +401,7 @@ scenario( · 357 unmodified lines | ||
| 400 | 401 | // pre-filled from the handoff. They type ONLY the secret. |
| 401 | 402 | yield* browser.session(identity, async ({ page, step }) => { |
| 402 | 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 | 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 | 10 | import { scenario } from "../src/scenario"; |
| 11 | 11 | import { Browser, Target } from "../src/services"; |
| 12 | +import { visit } from "../src/surfaces/browser"; | |
| 12 | 13 | |
| 13 | 14 | // One server so "Add integration" is enabled straight from the preview (no |
| 14 | 15 | // base-URL picker to resolve first). |
| @@ -32,7 +33,7 @@ scenario( · 17 unmodified lines | ||
| 32 | 33 | |
| 33 | 34 | yield* browser.session(identity, async ({ page, step }) => { |
| 34 | 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 | 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 | 14 | import { scenario } from "../src/scenario"; |
| 15 | 15 | import { Api, Browser, Target } from "../src/services"; |
| 16 | +import { visit } from "../src/surfaces/browser"; | |
| 16 | 17 | |
| 17 | 18 | const api = composePluginApi([openApiHttpPlugin()] as const); |
| 18 | 19 | |
| @@ -61,7 +62,7 @@ scenario( · 42 unmodified lines | ||
| 61 | 62 | |
| 62 | 63 | yield* browser.session(identity, async ({ page, step }) => { |
| 63 | 64 | await step("Open the Cloudflare-style integration", async () => { |
| 64 | − await page.goto(`/integrations/${slug}`, { waitUntil: "networkidle" }); | |
| 65 | + await visit(page, `/integrations/${slug}`); | |
| 65 | 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 | 10 | import { scenario } from "../src/scenario"; |
| 11 | 11 | import { Browser, Target } from "../src/services"; |
| 12 | +import { visit } from "../src/surfaces/browser"; | |
| 12 | 13 | |
| 13 | 14 | // Two servers — production and staging — so the form offers a base-URL picker |
| 14 | 15 | // instead of the single locked input a one-server spec gets. |
| @@ -35,7 +36,7 @@ scenario( · 20 unmodified lines | ||
| 35 | 36 | |
| 36 | 37 | yield* browser.session(identity, async ({ page, step }) => { |
| 37 | 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 | 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 | 19 | import { scenario } from "../src/scenario"; |
| 20 | 20 | import { Api, Browser, Target } from "../src/services"; |
| 21 | +import { visit } from "../src/surfaces/browser"; | |
| 21 | 22 | |
| 22 | 23 | scenario( |
| 23 | 24 | "Org URLs · console paths carry the organization slug", |
| @@ -38,7 +39,7 @@ scenario( · 14 unmodified lines | ||
| 38 | 39 | |
| 39 | 40 | yield* browser.session(identity, async ({ page, step }) => { |
| 40 | 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 | 43 | await page.waitForURL((url) => url.pathname === `/${slug}/policies`, { |
| 43 | 44 | timeout: 30_000, |
| 44 | 45 | }); |
| @@ -52,13 +53,13 @@ scenario( · 7 unmodified lines | ||
| 52 | 53 | // legitimately does not. |
| 53 | 54 | if (!target.name.startsWith("selfhost")) { |
| 54 | 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 | 57 | await page.getByText("Page not found").waitFor({ timeout: 30_000 }); |
| 57 | 58 | }); |
| 58 | 59 | } |
| 59 | 60 | |
| 60 | 61 | await step("In-shell navigation keeps the slug prefix", async () => { |
| 61 | − await page.goto(`/${slug}`, { waitUntil: "networkidle" }); | |
| 62 | + await visit(page, `/${slug}`); | |
| 62 | 63 | await page.getByRole("link", { name: "Policies" }).first().click(); |
| 63 | 64 | await page.waitForURL((url) => url.pathname === `/${slug}/policies`, { |
| 64 | 65 | timeout: 30_000, |
| @@ -27,6 +27,7 @@ import { AuthTemplateSlug, ConnectionName, IntegrationSlug } from "@executor-js/ · 26 unmodified lines | ||
| 27 | 27 | |
| 28 | 28 | import { scenario } from "../src/scenario"; |
| 29 | 29 | import { Api, Browser, Target } from "../src/services"; |
| 30 | +import { visit } from "../src/surfaces/browser"; | |
| 30 | 31 | |
| 31 | 32 | const api = composePluginApi([openApiHttpPlugin()] as const); |
| 32 | 33 | |
| @@ -161,7 +162,7 @@ scenario( · 128 unmodified lines | ||
| 161 | 162 | const internalError = JSON.stringify({ _tag: "InternalError", traceId: "policy-write" }); |
| 162 | 163 | |
| 163 | 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 | 166 | await page.getByRole("tab", { name: "Tools" }).click(); |
| 166 | 167 | await sectionFor(alpha).waitFor(); |
| 167 | 168 | await sectionFor(beta).waitFor(); |
| @@ -288,7 +289,7 @@ scenario( · 120 unmodified lines | ||
| 288 | 289 | }); |
| 289 | 290 | |
| 290 | 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 | 293 | await page.getByText(leafPattern, { exact: true }).waitFor(); |
| 293 | 294 | await page.getByText(categoryPattern, { exact: true }).waitFor(); |
| 294 | 295 | }); |
| @@ -3,7 +3,7 @@ import { Effect } from "effect"; · 2 unmodified lines | ||
| 3 | 3 | |
| 4 | 4 | import { scenario } from "../src/scenario"; |
| 5 | 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 | 8 | scenario( |
| 9 | 9 | "Provider catalog · Google and Microsoft services are OpenAPI presets", |
| @@ -15,7 +15,7 @@ scenario( · 5 unmodified lines | ||
| 15 | 15 | |
| 16 | 16 | yield* browser.session(identity, async ({ page, step }) => { |
| 17 | 17 | await step("Open the integrations picker", async () => { |
| 18 | − await page.goto("/integrations", { waitUntil: "networkidle" }); | |
| 18 | + await visit(page, "/integrations"); | |
| 19 | 19 | await clickToReveal( |
| 20 | 20 | page.getByRole("button", { name: "Connect" }), |
| 21 | 21 | page.getByRole("dialog", { name: "Connect an integration" }), |
| @@ -15,6 +15,7 @@ · 14 unmodified lines | ||
| 15 | 15 | import { randomUUID } from "node:crypto"; |
| 16 | 16 | |
| 17 | 17 | import { chromium } from "playwright"; |
| 18 | +import { visit } from "../src/surfaces/browser"; | |
| 18 | 19 | |
| 19 | 20 | const [baseUrl, outPrefix] = process.argv.slice(2); |
| 20 | 21 | if (!baseUrl || !outPrefix) { |
| @@ -86,7 +87,7 @@ const shoot = async (label: string, javaScriptEnabled: boolean, outPath: string) · 65 unmodified lines | ||
| 86 | 87 | // the light palette — an artifact no user sees. JS stays disabled for the |
| 87 | 88 | // SSR shot only to freeze the pre-hydration origin (no client correction), |
| 88 | 89 | // not to skip CSS. |
| 89 | − await page.goto("/", { waitUntil: "networkidle" }); | |
| 90 | + await visit(page, "/"); | |
| 90 | 91 | // The install command is the thing that flashes; screenshot just its block. |
| 91 | 92 | const command = page.locator('pre:has-text("add-mcp")').first(); |
| 92 | 93 | await command.waitFor({ state: "visible", timeout: 15_000 }); |
| @@ -20,6 +20,7 @@ import { AuthTemplateSlug, ConnectionName, IntegrationSlug } from "@executor-js/ · 19 unmodified lines | ||
| 20 | 20 | import { scenario } from "../src/scenario"; |
| 21 | 21 | import { createInvitedIdentity } from "../targets/selfhost"; |
| 22 | 22 | import { Api, Browser, Target } from "../src/services"; |
| 23 | +import { visit } from "../src/surfaces/browser"; | |
| 23 | 24 | |
| 24 | 25 | const api = composePluginApi([openApiHttpPlugin()] as const); |
| 25 | 26 | type Client = HttpApiClient.ForApi<typeof api>; |
| @@ -107,7 +108,7 @@ scenario( · 81 unmodified lines | ||
| 107 | 108 | |
| 108 | 109 | yield* browser.session(owner, async ({ page, step }) => { |
| 109 | 110 | await step("Open Users from the sidebar as the instance owner", async () => { |
| 110 | − await page.goto("/", { waitUntil: "networkidle" }); | |
| 111 | + await visit(page, "/"); | |
| 111 | 112 | await page |
| 112 | 113 | .locator("nav") |
| 113 | 114 | .getByRole("link", { name: "Users" }) |
| @@ -289,7 +290,7 @@ scenario( · 175 unmodified lines | ||
| 289 | 290 | |
| 290 | 291 | yield* browser.session(member, async ({ page, step }) => { |
| 291 | 292 | await step("A plain member is not offered the section", async () => { |
| 292 | − await page.goto("/", { waitUntil: "networkidle" }); | |
| 293 | + await visit(page, "/"); | |
| 293 | 294 | await page |
| 294 | 295 | .locator("nav") |
| 295 | 296 | .getByRole("link", { name: "Integrations" }) |
| @@ -302,7 +303,7 @@ scenario( · 6 unmodified lines | ||
| 302 | 303 | }); |
| 303 | 304 | |
| 304 | 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 | 307 | await page |
| 307 | 308 | .getByText("You don't have access to this workspace's users") |
| 308 | 309 | .waitFor({ state: "visible", timeout: 30_000 }); |
| @@ -20,6 +20,7 @@ import { AccountHttpApi } from "@executor-js/api"; · 19 unmodified lines | ||
| 20 | 20 | |
| 21 | 21 | import { scenario } from "../src/scenario"; |
| 22 | 22 | import { Api, Browser, Target } from "../src/services"; |
| 23 | +import { visit } from "../src/surfaces/browser"; | |
| 23 | 24 | |
| 24 | 25 | declare global { |
| 25 | 26 | interface Window { |
| @@ -39,7 +40,7 @@ scenario( · 13 unmodified lines | ||
| 39 | 40 | |
| 40 | 41 | yield* browser.session(identity, async ({ page, step }) => { |
| 41 | 42 | await step("Land on the dashboard", async () => { |
| 42 | − await page.goto("/", { waitUntil: "networkidle" }); | |
| 43 | + await visit(page, "/"); | |
| 43 | 44 | // The shared shell's main nav lists the workspace sections. |
| 44 | 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 | 108 | await step("Open the API keys page", async () => { |
| 108 | − await page.goto("/api-keys", { waitUntil: "networkidle" }); | |
| 109 | + await visit(page, "/api-keys"); | |
| 109 | 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 | 177 | await step("Create a new key", async () => { |
| 177 | − await page.goto("/api-keys", { waitUntil: "networkidle" }); | |
| 178 | + await visit(page, "/api-keys"); | |
| 178 | 179 | await page.getByRole("button", { name: "New key" }).click(); |
| 179 | 180 | const dialog = page.getByRole("dialog"); |
| 180 | 181 | await dialog.getByLabel("Name").fill(keyName); |
| @@ -23,6 +23,7 @@ import { IntegrationSlug } from "@executor-js/sdk/shared"; · 22 unmodified lines | ||
| 23 | 23 | |
| 24 | 24 | import { scenario } from "../src/scenario"; |
| 25 | 25 | import { Api, Browser, Target } from "../src/services"; |
| 26 | +import { visit } from "../src/surfaces/browser"; | |
| 26 | 27 | |
| 27 | 28 | const api = composePluginApi([mcpHttpPlugin()] as const); |
| 28 | 29 | |
| @@ -160,9 +161,7 @@ scenario( · 131 unmodified lines | ||
| 160 | 161 | |
| 161 | 162 | yield* browser.session(identity, async ({ page, step }) => { |
| 162 | 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 | − waitUntil: "networkidle", | |
| 165 | − }); | |
| 164 | + await visit(page, `/integrations/add/mcp?url=${encodeURIComponent(server.endpoint)}`); | |
| 166 | 165 | // Generous timeout: the debounced probe request can queue behind a |
| 167 | 166 | // busy dev server under CI load, and there is no clean client-side |
| 168 | 167 | // re-trigger to poke it mid-flight. |
| @@ -216,9 +215,7 @@ scenario( · 47 unmodified lines | ||
| 216 | 215 | yield* Effect.gen(function* () { |
| 217 | 216 | yield* browser.session(identity, async ({ page, step }) => { |
| 218 | 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)}`, { | |
| 220 | − waitUntil: "networkidle", | |
| 221 | − }); | |
| 218 | + await visit(page, `/integrations/add/mcp?url=${encodeURIComponent(server.endpoint)}`); | |
| 222 | 219 | await page.getByText("How does this server authenticate?").waitFor({ timeout: 90_000 }); |
| 223 | 220 | await page.getByText("OAuth metadata is discovered from this server").waitFor(); |
| 224 | 221 | }); |
| @@ -324,9 +321,7 @@ scenario( · 99 unmodified lines | ||
| 324 | 321 | yield* Effect.gen(function* () { |
| 325 | 322 | yield* browser.session(identity, async ({ page, step }) => { |
| 326 | 323 | await step("Open the integration's connect modal", async () => { |
| 327 | − await page.goto(`/integrations/${slug}`, { | |
| 328 | − waitUntil: "networkidle", | |
| 329 | − }); | |
| 324 | + await visit(page, `/integrations/${slug}`); | |
| 330 | 325 | await page.getByRole("button", { name: "Add connection" }).first().click(); |
| 331 | 326 | await page.getByRole("tab", { name: "OAuth" }).waitFor(); |
| 332 | 327 | }); |
| @@ -426,9 +421,7 @@ scenario( · 93 unmodified lines | ||
| 426 | 421 | yield* Effect.gen(function* () { |
| 427 | 422 | yield* browser.session(identity, async ({ page, step }) => { |
| 428 | 423 | await step("Open the integration's connect modal", async () => { |
| 429 | − await page.goto(`/integrations/${slug}`, { | |
| 430 | − waitUntil: "networkidle", | |
| 431 | − }); | |
| 424 | + await visit(page, `/integrations/${slug}`); | |
| 432 | 425 | await page.getByRole("button", { name: "Add connection" }).first().click(); |
| 433 | 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 | 19 | import { scenario } from "../src/scenario"; |
| 20 | 20 | import { Api, Browser, Target } from "../src/services"; |
| 21 | +import { visit } from "../src/surfaces/browser"; | |
| 21 | 22 | |
| 22 | 23 | const api = composePluginApi([openApiHttpPlugin()] as const); |
| 23 | 24 | |
| @@ -63,7 +64,7 @@ scenario( · 39 unmodified lines | ||
| 63 | 64 | |
| 64 | 65 | yield* browser.session(identity, async ({ page, step }) => { |
| 65 | 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 | 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 | 17 | import { scenario } from "../src/scenario"; |
| 18 | 18 | import { Browser, Target } from "../src/services"; |
| 19 | +import { visit } from "../src/surfaces/browser"; | |
| 19 | 20 | |
| 20 | 21 | const REMOVE_HINT = "Pulled from spec. Remove to override."; |
| 21 | 22 | |
| @@ -36,9 +37,7 @@ scenario( · 14 unmodified lines | ||
| 36 | 37 | |
| 37 | 38 | yield* browser.session(identity, async ({ page, step }) => { |
| 38 | 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 | − waitUntil: "networkidle", | |
| 41 | − }); | |
| 40 | + await visit(page, `/integrations/add/mcp?url=${encodeURIComponent(server.endpoint)}`); | |
| 42 | 41 | await page.getByText("How does this server authenticate?").waitFor(); |
| 43 | 42 | await page.getByText("Method 1 · Detected").waitFor(); |
| 44 | 43 | }); |
| @@ -145,7 +144,7 @@ scenario( · 100 unmodified lines | ||
| 145 | 144 | |
| 146 | 145 | yield* browser.session(identity, async ({ page, step }) => { |
| 147 | 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 | 148 | await page |
| 150 | 149 | .getByPlaceholder(/openapi\.json/i) |
| 151 | 150 | .first() |
| @@ -19,6 +19,7 @@ import { serveTestHttpApp } from "@executor-js/sdk/testing"; · 18 unmodified lines | ||
| 19 | 19 | |
| 20 | 20 | import { scenario } from "../src/scenario"; |
| 21 | 21 | import { Api, Browser, Target } from "../src/services"; |
| 22 | +import { visit } from "../src/surfaces/browser"; | |
| 22 | 23 | |
| 23 | 24 | const api = composePluginApi([mcpHttpPlugin()] as const); |
| 24 | 25 | |
| @@ -53,9 +54,7 @@ scenario( · 28 unmodified lines | ||
| 53 | 54 | yield* Effect.gen(function* () { |
| 54 | 55 | yield* browser.session(identity, async ({ page, step }) => { |
| 55 | 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 | − waitUntil: "networkidle", | |
| 58 | − }); | |
| 57 | + await visit(page, `/integrations/add/mcp?url=${encodeURIComponent(endpoint)}`); | |
| 59 | 58 | // Before the fix this dead-ended on a red "add credentials below" |
| 60 | 59 | // error with no editor. Now the auth-method editor renders. |
| 61 | 60 | await page.getByText("How does this server authenticate?").waitFor(); |
| @@ -15,6 +15,7 @@ import { Effect } from "effect"; · 14 unmodified lines | ||
| 15 | 15 | |
| 16 | 16 | import { scenario } from "../src/scenario"; |
| 17 | 17 | import { Browser, Target } from "../src/services"; |
| 18 | +import { visit } from "../src/surfaces/browser"; | |
| 18 | 19 | |
| 19 | 20 | scenario( |
| 20 | 21 | "MCP · the URL printed by the Connect card is actually served", |
| @@ -28,7 +29,7 @@ scenario( · 7 unmodified lines | ||
| 28 | 29 | let cardUrl = ""; |
| 29 | 30 | yield* browser.session(identity, async ({ page, step }) => { |
| 30 | 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 | 33 | await page.waitForTimeout(2000); // let the card resolve the server origin |
| 33 | 34 | // The card renders `npx add-mcp <url> --transport http --name executor`. |
| 34 | 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 | 20 | import { scenario } from "../src/scenario"; |
| 21 | 21 | import { Browser, Target } from "../src/services"; |
| 22 | +import { visit } from "../src/surfaces/browser"; | |
| 22 | 23 | |
| 23 | 24 | interface AuthServerMetadata { |
| 24 | 25 | readonly authorization_endpoint: string; |
| @@ -78,7 +79,7 @@ scenario( · 53 unmodified lines | ||
| 78 | 79 | |
| 79 | 80 | yield* browser.session(identity, async ({ page, step }) => { |
| 80 | 81 | await step("A signed-in user is using their Executor instance", async () => { |
| 81 | − await page.goto("/", { waitUntil: "networkidle" }); | |
| 82 | + await visit(page, "/"); | |
| 82 | 83 | // Confirm we're in the app, not bounced to sign-in. |
| 83 | 84 | expect(new URL(page.url()).pathname, "the session is active (not on /login)").not.toBe( |
| 84 | 85 | "/login", |
| @@ -91,7 +92,7 @@ scenario( · 6 unmodified lines | ||
| 91 | 92 | // A connecting MCP client opens this authorize URL. The server forces |
| 92 | 93 | // prompt=consent, so it stops here on the approval screen instead of |
| 93 | 94 | // auto-issuing a code. |
| 94 | − await page.goto(authorize, { waitUntil: "networkidle" }); | |
| 95 | + await visit(page, authorize); | |
| 95 | 96 | await page.locator("#mcp-consent-allow").waitFor(); |
| 96 | 97 | expect( |
| 97 | 98 | new URL(page.url()).pathname, |
| @@ -21,6 +21,7 @@ import { serveOAuthTestServer, type OAuthTestServerShape } from "@executor-js/sd · 20 unmodified lines | ||
| 21 | 21 | |
| 22 | 22 | import { scenario } from "../src/scenario"; |
| 23 | 23 | import { Api, Browser, Target } from "../src/services"; |
| 24 | +import { visit } from "../src/surfaces/browser"; | |
| 24 | 25 | |
| 25 | 26 | const api = composePluginApi([mcpHttpPlugin()] as const); |
| 26 | 27 | type Client = HttpApiClient.ForApi<typeof api>; |
| @@ -300,7 +301,7 @@ scenario( · 273 unmodified lines | ||
| 300 | 301 | const menuTrigger = connections.locator('button[aria-haspopup="menu"]').first(); |
| 301 | 302 | |
| 302 | 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 | 305 | await connections.getByText("main", { exact: true }).waitFor({ timeout: 30_000 }); |
| 305 | 306 | }); |
| 306 | 307 | |
| @@ -380,7 +381,7 @@ scenario( · 73 unmodified lines | ||
| 380 | 381 | const menuTrigger = connections.locator('button[aria-haspopup="menu"]').first(); |
| 381 | 382 | |
| 382 | 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 | 385 | await connections.getByText("main", { exact: true }).waitFor({ timeout: 30_000 }); |
| 385 | 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 | 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 | 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 | 36 | import { scenario } from "../src/scenario"; |
| 37 | 37 | import { Api, Browser, Target } from "../src/services"; |
| 38 | +import { visit } from "../src/surfaces/browser"; | |
| 38 | 39 | |
| 39 | 40 | const api = composePluginApi([mcpHttpPlugin()] as const); |
| 40 | 41 | |
| @@ -68,7 +69,7 @@ scenario( · 27 unmodified lines | ||
| 68 | 69 | await step("Add an OAuth-protected MCP integration", async () => { |
| 69 | 70 | const addUrl = new URL("/integrations/add/mcp", target.baseUrl); |
| 70 | 71 | addUrl.searchParams.set("url", server.endpoint); |
| 71 | − await page.goto(addUrl.toString(), { waitUntil: "networkidle" }); | |
| 72 | + await visit(page, addUrl.toString()); | |
| 72 | 73 | await page.getByText("How does this server authenticate?").waitFor({ timeout: 30_000 }); |
| 73 | 74 | await page.getByPlaceholder("e.g. Linear").fill(displayName); |
| 74 | 75 | await page.getByRole("button", { name: "Add integration" }).click(); |
| @@ -22,6 +22,7 @@ import { Effect } from "effect"; · 21 unmodified lines | ||
| 22 | 22 | import { scenario } from "../src/scenario"; |
| 23 | 23 | import { enterFocus, markRecordingStart } from "../src/timeline"; |
| 24 | 24 | import { Browser, Cli, Mcp, OpenCode, RunDir, Target } from "../src/services"; |
| 25 | +import { visit } from "../src/surfaces/browser"; | |
| 25 | 26 | |
| 26 | 27 | const SERVER_NAME = "executor"; |
| 27 | 28 | |
| @@ -119,7 +120,7 @@ scenario( · 91 unmodified lines | ||
| 119 | 120 | await step("OpenCode asks to connect — the approval screen", async () => { |
| 120 | 121 | // Authenticated (owner cookies) → authorize forces prompt=consent → |
| 121 | 122 | // the approval screen. |
| 122 | − await page.goto(authorizeUrl, { waitUntil: "networkidle" }); | |
| 123 | + await visit(page, authorizeUrl); | |
| 123 | 124 | await page.locator("#mcp-consent-allow").waitFor({ timeout: 30_000 }); |
| 124 | 125 | expect(new URL(page.url()).pathname, "lands on the approval screen").toBe( |
| 125 | 126 | "/mcp-consent", |
| @@ -16,6 +16,7 @@ import { openApiHttpPlugin } from "@executor-js/plugin-openapi/api"; · 15 unmodified lines | ||
| 16 | 16 | |
| 17 | 17 | import { scenario } from "../src/scenario"; |
| 18 | 18 | import { Api, Browser, Target } from "../src/services"; |
| 19 | +import { visit } from "../src/surfaces/browser"; | |
| 19 | 20 | |
| 20 | 21 | const api = composePluginApi([openApiHttpPlugin()] as const); |
| 21 | 22 | |
| @@ -81,7 +82,7 @@ scenario( · 59 unmodified lines | ||
| 81 | 82 | const actions = page.getByRole("button", { name: `Actions for ${appName}` }); |
| 82 | 83 | |
| 83 | 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 | 86 | await page.getByRole("button", { name: "Add connection" }).click(); |
| 86 | 87 | // OAuth2 is the integration's only method, so the modal opens on |
| 87 | 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 | 15 | import { scenario } from "../src/scenario"; |
| 16 | 16 | import { Api, Browser, Target } from "../src/services"; |
| 17 | +import { visit } from "../src/surfaces/browser"; | |
| 17 | 18 | |
| 18 | 19 | const api = composePluginApi([openApiHttpPlugin()] as const); |
| 19 | 20 | |
| @@ -111,7 +112,7 @@ scenario( · 91 unmodified lines | ||
| 111 | 112 | |
| 112 | 113 | yield* browser.session({ label: "anonymous" }, async ({ page, step }) => { |
| 113 | 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 | 116 | expect(response?.status(), "the callback redirects into the login flow").toBe(200); |
| 116 | 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 | 23 | import { scenario } from "../src/scenario"; |
| 24 | 24 | import { Api, Browser, Target } from "../src/services"; |
| 25 | +import { visit } from "../src/surfaces/browser"; | |
| 25 | 26 | |
| 26 | 27 | const api = composePluginApi([mcpHttpPlugin()] as const); |
| 27 | 28 | |
| @@ -48,7 +49,7 @@ scenario( · 20 unmodified lines | ||
| 48 | 49 | await step("Open the add-MCP flow pointed at the OAuth server", async () => { |
| 49 | 50 | const addUrl = new URL("/integrations/add/mcp", target.baseUrl); |
| 50 | 51 | addUrl.searchParams.set("url", server.endpoint); |
| 51 | − await page.goto(addUrl.toString(), { waitUntil: "networkidle" }); | |
| 52 | + await visit(page, addUrl.toString()); | |
| 52 | 53 | await page.getByText("How does this server authenticate?").waitFor({ timeout: 30_000 }); |
| 53 | 54 | await page.getByText("Method 1 · Detected").waitFor(); |
| 54 | 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 | 41 | // Hosted service hosts (e.g. resend.emulators.dev) are control plane only — |
| 4 | 42 | // there is no shared default instance behind them. Every scenario creates its |
| 5 | 43 | // own isolated instance and works against the returned providerBaseUrl, which |
| 6 | 44 | // also keeps ledger assertions free of cross-run pollution. The server |
| 7 | 45 | // generates an unguessable instance name; the label is a readable prefix. |
| 8 | −export const createEmulatorInstance = (service: string, label = "e2e") => | |
| 9 | − Effect.promise(async () => { | |
| 10 | − const response = await fetch(`https://${service}.emulators.dev/_emulate/instances`, { | |
| 11 | − method: "POST", | |
| 12 | − headers: { "content-type": "application/json" }, | |
| 13 | − body: JSON.stringify({ instance: label }), | |
| 14 | − }); | |
| 15 | − if (!response.ok) { | |
| 16 | − throw new Error(`${service} emulator instance creation failed: ${response.status}`); | |
| 17 | − } | |
| 18 | − const instance = (await response.json()) as { readonly providerBaseUrl: string }; | |
| 19 | − return instance.providerBaseUrl; | |
| 20 | − }); | |
| 46 | +// | |
| 47 | +// This is also the one request in a scenario that leaves the runner, so it is | |
| 48 | +// the one place where a CI runner's transient network trouble fails a scenario | |
| 49 | +// that has nothing to do with the network: `connect ETIMEDOUT` reaching the | |
| 50 | +// edge, plus the occasional bare 502, accounted for 17 shard failures in the | |
| 51 | +// two weeks to 2026-08-20. Asking for an instance is idempotent (a spare | |
| 52 | +// instance is nobody's business but the control plane's), so bound the attempt | |
| 53 | +// and retry with backoff. Nothing below this line retries anything the | |
| 54 | +// scenario is actually asserting on. | |
| 55 | +export const createEmulatorInstance = (service: string, label = "e2e"): Effect.Effect<string> => | |
| 56 | + requestInstance(service, label).pipe( | |
| 57 | + Effect.timeoutOrElse({ | |
| 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 | 9 | import { promisify } from "node:util"; |
| 10 | 10 | |
| 11 | 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 | 14 | import { beat, enterFocus, markNavigation, markRecordingStart } from "../timeline"; |
| 15 | 15 | import { appendTraces, type TraceEntry } from "../trace-harvest"; |
| @@ -36,10 +36,103 @@ const slug = (text: string): string => · 20 unmodified lines | ||
| 36 | 36 | .replace(/^-+|-+$/g, "") |
| 37 | 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 | 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 | 136 | * that lands between the SSR paint and React attaching the handler is |
| 44 | 137 | * swallowed without a trace, and whatever the click was meant to open never |
| 45 | 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 | 11 | import { scenario } from "./scenario"; |
| 12 | 12 | import { Browser, Target } from "./services"; |
| 13 | +import { visit } from "./surfaces/browser"; | |
| 13 | 14 | |
| 14 | 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 | 85 | await step("Open the console", async () => { |
| 85 | − await page.goto("/", { waitUntil: "networkidle" }); | |
| 86 | + await visit(page, "/"); | |
| 86 | 87 | await page.getByRole("heading", { name: "Integrations" }).waitFor({ timeout: 60_000 }); |
| 87 | 88 | }); |
| 88 | 89 | |