From b806f5c72ef6478fdf1a342e8e95b476bb3d194b Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Thu, 20 Aug 2026 10:31:30 -0700 Subject: [PATCH] Stop CI failing on waits that were never about the product 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. --- e2e/cloud/admin-users-console.test.ts | 7 +- e2e/cloud/auth-routing-flow.test.ts | 3 +- .../billing-trial-checkout-stale.test.ts | 5 +- e2e/cloud/connect-panel.test.ts | 7 +- .../connection-modal-oauth-abandon.test.ts | 5 +- e2e/cloud/logout-stale-session.test.ts | 3 +- .../mcp-browser-approval-org-scope.test.ts | 7 +- e2e/cloud/mcp-browser-resume-page.test.ts | 3 +- e2e/cloud/member-invite-seat-limit.test.ts | 5 +- e2e/cloud/oauth-callback-org-scope.test.ts | 11 +-- e2e/cloud/onboarding-mcp-url.test.ts | 3 +- e2e/cloud/org-api-keys-console.test.ts | 7 +- e2e/cloud/org-delete.test.ts | 7 +- e2e/cloud/org-last-visited.test.ts | 7 +- e2e/cloud/org-limit.test.ts | 5 +- e2e/cloud/org-multitab-cookie.test.ts | 7 +- e2e/cloud/org-slug-foreign.test.ts | 5 +- e2e/cloud/org-switcher.test.ts | 7 +- .../artifact-loading-surface.test.ts | 3 +- .../artifact-preview-gallery.test.ts | 7 +- e2e/scenarios/artifacts.test.ts | 9 +- e2e/scenarios/auth-methods-ui.test.ts | 5 +- e2e/scenarios/browser-approval.test.ts | 3 +- e2e/scenarios/connect-deep-link.test.ts | 7 +- e2e/scenarios/connect-handoff-session.test.ts | 3 +- e2e/scenarios/connect-handoff.test.ts | 3 +- .../connection-remove-confirm.test.ts | 3 +- e2e/scenarios/docs-link.test.ts | 3 +- e2e/scenarios/first-party-oauth.test.ts | 4 +- e2e/scenarios/google-health-checks.test.ts | 64 ++++++------ e2e/scenarios/google-photos-preset-ui.test.ts | 4 +- .../graphql-introspection-health.test.ts | 16 ++- e2e/scenarios/health-checks-ui.test.ts | 23 ++--- e2e/scenarios/mcp-catalog-sync-ui.test.ts | 7 +- e2e/scenarios/oauth-callback-url.test.ts | 5 +- e2e/scenarios/oauth-client-handoff.test.ts | 3 +- ...openapi-add-integration-action-bar.test.ts | 3 +- .../openapi-multi-key-auth-ui.test.ts | 3 +- .../openapi-server-selection-ui.test.ts | 3 +- e2e/scenarios/org-slug-routing.test.ts | 7 +- e2e/scenarios/policies-ui.test.ts | 5 +- e2e/scenarios/provider-plugins-ui.test.ts | 4 +- e2e/scripts/record-connect-card.ts | 3 +- e2e/selfhost/admin-users-console.test.ts | 7 +- e2e/selfhost/api-keys-feedback.test.ts | 7 +- e2e/selfhost/auth-methods-ui.test.ts | 17 +--- .../connect-modal-credential-ux.test.ts | 3 +- .../detected-auth-immutable-ui.test.ts | 7 +- e2e/selfhost/mcp-auth-required-add.test.ts | 5 +- e2e/selfhost/mcp-connect-card-url.test.ts | 3 +- e2e/selfhost/mcp-oauth-consent.test.ts | 5 +- .../mcp-oauth-reconnect-health.test.ts | 7 +- e2e/selfhost/mcp-oauth-slow-connect.test.ts | 3 +- e2e/selfhost/mcp-opencode-consent.test.ts | 3 +- e2e/selfhost/oauth-app-modal.test.ts | 3 +- .../oauth-callback-unauthenticated.test.ts | 3 +- e2e/selfhost/posthog-mcp-oauth.test.ts | 3 +- e2e/src/emulator-instance.ts | 79 ++++++++++++--- e2e/src/surfaces/browser.ts | 97 ++++++++++++++++++- e2e/src/update-card-render.ts | 3 +- 60 files changed, 372 insertions(+), 187 deletions(-) diff --git a/e2e/cloud/admin-users-console.test.ts b/e2e/cloud/admin-users-console.test.ts index b30fcb8d83..d5eaa0521f 100644 --- a/e2e/cloud/admin-users-console.test.ts +++ b/e2e/cloud/admin-users-console.test.ts @@ -24,6 +24,7 @@ import { AuthTemplateSlug, ConnectionName, IntegrationSlug } from "@executor-js/ import { scenario } from "../src/scenario"; import { Api, Browser, Target } from "../src/services"; import { accountIdOf, forBrowser, joinOrg } from "./support/session"; +import { visit } from "../src/surfaces/browser"; const api = composePluginApi([openApiHttpPlugin()] as const); type Client = HttpApiClient.ForApi; @@ -126,7 +127,7 @@ scenario( let slug = ""; await step("Land in the workspace and canonicalize onto the org slug", async () => { - await page.goto("/", { waitUntil: "networkidle" }); + await visit(page, "/"); await page.waitForURL((url) => /^\/[a-z0-9-]+\/?$/.test(url.pathname), { timeout: 30_000, }); @@ -323,7 +324,7 @@ scenario( let slug = ""; await step("Sign in as a plain member of the same workspace", async () => { - await page.goto("/", { waitUntil: "networkidle" }); + await visit(page, "/"); await page.waitForURL((url) => /^\/[a-z0-9-]+\/?$/.test(url.pathname), { timeout: 30_000, }); @@ -345,7 +346,7 @@ scenario( await step( "Reaching the URL directly says no access, not an empty workspace", async () => { - await page.goto(`/${slug}/users`, { waitUntil: "networkidle" }); + await visit(page, `/${slug}/users`); // The denial is the assertion: an empty table here would misreport // a populated workspace as having no users. await page diff --git a/e2e/cloud/auth-routing-flow.test.ts b/e2e/cloud/auth-routing-flow.test.ts index 63210d8933..30efb17cdd 100644 --- a/e2e/cloud/auth-routing-flow.test.ts +++ b/e2e/cloud/auth-routing-flow.test.ts @@ -23,6 +23,7 @@ import { Effect } from "effect"; import { scenario } from "../src/scenario"; import { Browser, Target } from "../src/services"; +import { settle } from "../src/surfaces/browser"; scenario( "Auth routing · signed out → login → onboarding → dashboard, one browser session", @@ -58,7 +59,7 @@ scenario( await step("Create the first org → canonical dashboard at /", async () => { const orgName = "Flow Test Org"; const orgNameInput = page.getByPlaceholder("Northwind Labs"); - await page.waitForLoadState("networkidle"); + await settle(page); await orgNameInput.fill(orgName); await page.waitForTimeout(250); if ((await orgNameInput.inputValue()) !== orgName) { diff --git a/e2e/cloud/billing-trial-checkout-stale.test.ts b/e2e/cloud/billing-trial-checkout-stale.test.ts index ba64ae461d..453a7bfd1f 100644 --- a/e2e/cloud/billing-trial-checkout-stale.test.ts +++ b/e2e/cloud/billing-trial-checkout-stale.test.ts @@ -20,6 +20,7 @@ import { Effect } from "effect"; import { scenario } from "../src/scenario"; import { Autumn, Billing, Browser, Target } from "../src/services"; +import { visit } from "../src/surfaces/browser"; scenario( "Billing · completing the trial checkout shows the new plan without a reload", @@ -51,12 +52,12 @@ scenario( // "/undefined/billing/plans" — the billing fetches fire under the bogus // slug, fail, and are never refetched (autumn-js staleTime 60s), leaving // the plans grid empty forever. - await page.goto("/", { waitUntil: "networkidle" }); + await visit(page, "/"); await page.waitForURL((url) => /^\/[a-z0-9-]+\/?$/.test(url.pathname), { timeout: 30_000, }); const slug = new URL(page.url()).pathname.split("/").filter(Boolean)[0]; - await page.goto(`/${slug}/billing/plans`, { waitUntil: "networkidle" }); + await visit(page, `/${slug}/billing/plans`); await page.getByRole("heading", { name: "Choose a plan" }).waitFor(); await startTrial.waitFor(); }); diff --git a/e2e/cloud/connect-panel.test.ts b/e2e/cloud/connect-panel.test.ts index 0c5e906c79..12202df779 100644 --- a/e2e/cloud/connect-panel.test.ts +++ b/e2e/cloud/connect-panel.test.ts @@ -6,6 +6,7 @@ import { Effect } from "effect"; import { scenario } from "../src/scenario"; import { Browser, Target } from "../src/services"; +import { visit, settle } from "../src/surfaces/browser"; scenario( "Connect · the agent-connect panel gives working copy for both transports", @@ -17,7 +18,7 @@ scenario( yield* browser.session(identity, async ({ page, step }) => { await step("Open the Integrations page", async () => { - await page.goto("/", { waitUntil: "networkidle" }); + await visit(page, "/"); await page.getByText("Connect an agent").first().waitFor(); }); @@ -35,7 +36,7 @@ scenario( await step("Switch to Standard I/O", async () => { await page.getByRole("tab", { name: "Standard I/O" }).click(); - await page.waitForLoadState("networkidle"); + await settle(page); }); const stdioCommand = await command(); expect(stdioCommand, "the command changed for stdio").not.toBe(httpCommand); @@ -45,7 +46,7 @@ scenario( await step("Switch back to Remote HTTP", async () => { await page.getByRole("tab", { name: "Remote HTTP" }).click(); - await page.waitForLoadState("networkidle"); + await settle(page); }); expect(await command(), "the HTTP command is restored").toContain("--transport http"); }); diff --git a/e2e/cloud/connection-modal-oauth-abandon.test.ts b/e2e/cloud/connection-modal-oauth-abandon.test.ts index 2b33bb82a9..8e816ced65 100644 --- a/e2e/cloud/connection-modal-oauth-abandon.test.ts +++ b/e2e/cloud/connection-modal-oauth-abandon.test.ts @@ -21,6 +21,7 @@ import { serveOAuthTestServer } from "@executor-js/sdk/testing"; import { scenario } from "../src/scenario"; import { Api, Browser, Target } from "../src/services"; +import { visit, settle } from "../src/surfaces/browser"; const api = composePluginApi([openApiHttpPlugin()] as const); @@ -98,7 +99,7 @@ scenario( const connecting = dialog.getByRole("button", { name: "Connecting…" }); await step("Open the integration and start a new connection", async () => { - await page.goto(`/integrations/${integration}`, { waitUntil: "networkidle" }); + await visit(page, `/integrations/${integration}`); await addConnection.click(); // The registered app is auto-selected, so the OAuth connect button is // present and enabled. @@ -133,7 +134,7 @@ scenario( async () => { await addConnection.click(); await dialog.waitFor({ state: "visible", timeout: 15_000 }); - await page.waitForLoadState("networkidle"); + await settle(page); // The guarantee: the reopened modal is reset. Before the fix it stays // wedged on "Connecting…" (the abandoned flow's busy state survived the diff --git a/e2e/cloud/logout-stale-session.test.ts b/e2e/cloud/logout-stale-session.test.ts index 8093cc24d2..8701865e46 100644 --- a/e2e/cloud/logout-stale-session.test.ts +++ b/e2e/cloud/logout-stale-session.test.ts @@ -19,6 +19,7 @@ import type { Page } from "playwright"; import { scenario } from "../src/scenario"; import { Api, Browser, Target } from "../src/services"; +import { settle } from "../src/surfaces/browser"; /** The display-only identity cookie the SSR gate mints (non-HttpOnly). */ const HINT_COOKIE = "executor-auth-hint"; @@ -145,7 +146,7 @@ scenario( await page.waitForURL((url) => url.toString() !== before, { timeout: 15_000, }); - await page.waitForLoadState("networkidle"); + await settle(page); }); const shown = (await page.locator("body").innerText()).trim(); diff --git a/e2e/cloud/mcp-browser-approval-org-scope.test.ts b/e2e/cloud/mcp-browser-approval-org-scope.test.ts index 1c931e3bfa..7c2e158f15 100644 --- a/e2e/cloud/mcp-browser-approval-org-scope.test.ts +++ b/e2e/cloud/mcp-browser-approval-org-scope.test.ts @@ -14,6 +14,7 @@ import { composePluginApi } from "@executor-js/api/server"; import { scenario } from "../src/scenario"; import { Api, Browser, Mcp, Target } from "../src/services"; import { type McpBrowserApproval, parseBrowserApproval } from "../src/surfaces/mcp"; +import { visit } from "../src/surfaces/browser"; const coreApi = composePluginApi([] as const); @@ -168,13 +169,13 @@ scenario( mcpSession.awaitResume(approval.executionId), browser.session(identity, async ({ page, step }) => { await step("Land in the original organization", async () => { - await page.goto(`/${orgA.slug}`, { waitUntil: "networkidle" }); + await visit(page, `/${orgA.slug}`); await expectOrgShell(page, orgA); }); await step("The browser session is switched to another organization", async () => { await setWorkosSessionCookie(page, target.baseUrl, sessionB); - await page.goto(`/${orgB.slug}`, { waitUntil: "networkidle" }); + await visit(page, `/${orgB.slug}`); await expectOrgShell(page, orgB); }); @@ -182,7 +183,7 @@ scenario( const loadRequest = page.waitForRequest(approvalApiRequest(approval, "GET"), { timeout: 30_000, }); - await page.goto(approval.approvalUrl, { waitUntil: "networkidle" }); + await visit(page, approval.approvalUrl); expect( (await loadRequest).headers()["x-executor-organization"], "loading the approval page scopes the paused execution lookup to org A", diff --git a/e2e/cloud/mcp-browser-resume-page.test.ts b/e2e/cloud/mcp-browser-resume-page.test.ts index 02eb39e4cd..26be5910a4 100644 --- a/e2e/cloud/mcp-browser-resume-page.test.ts +++ b/e2e/cloud/mcp-browser-resume-page.test.ts @@ -18,6 +18,7 @@ import { scenario } from "../src/scenario"; import { Api, Browser, Mcp, Target } from "../src/services"; import { parseBrowserApproval } from "../src/surfaces/mcp"; import type { Identity } from "../src/target"; +import { visit } from "../src/surfaces/browser"; const coreApi = composePluginApi([] as const); @@ -224,7 +225,7 @@ scenario( ), browser.session(identity, async ({ page, step }) => { await step("Open the paused execution approval page", async () => { - await page.goto(pathWithSearch(approval.approvalUrl), { waitUntil: "networkidle" }); + await visit(page, pathWithSearch(approval.approvalUrl)); await page.getByText("User approval required").waitFor(); }); diff --git a/e2e/cloud/member-invite-seat-limit.test.ts b/e2e/cloud/member-invite-seat-limit.test.ts index 470bb9a019..e465c1396e 100644 --- a/e2e/cloud/member-invite-seat-limit.test.ts +++ b/e2e/cloud/member-invite-seat-limit.test.ts @@ -23,6 +23,7 @@ import { AccountHttpApi } from "@executor-js/api"; import { scenario } from "../src/scenario"; import { Api, Billing, Browser, Target } from "../src/services"; +import { visit } from "../src/surfaces/browser"; // apps/cloud/src/extensions/billing/plans.ts → MEMBER_LIMITS.free const FREE_MEMBER_SEATS = 3; @@ -45,7 +46,7 @@ scenario( let slug = ""; await step("Land in the app and canonicalize onto the org slug", async () => { - await page.goto("/", { waitUntil: "networkidle" }); + await visit(page, "/"); await page.waitForURL((url) => /^\/[a-z0-9-]+\/?$/.test(url.pathname), { timeout: 30_000, }); @@ -54,7 +55,7 @@ scenario( }); await step("Open the organization members page", async () => { - await page.goto(`/${slug}/org`, { waitUntil: "networkidle" }); + await visit(page, `/${slug}/org`); await page.getByRole("button", { name: "Invite member" }).waitFor(); }); diff --git a/e2e/cloud/oauth-callback-org-scope.test.ts b/e2e/cloud/oauth-callback-org-scope.test.ts index afe0214743..66f1f50415 100644 --- a/e2e/cloud/oauth-callback-org-scope.test.ts +++ b/e2e/cloud/oauth-callback-org-scope.test.ts @@ -17,6 +17,7 @@ import { serveOAuthTestServer } from "@executor-js/sdk/testing"; import { scenario } from "../src/scenario"; import { Api, Browser, Target } from "../src/services"; import type { Identity } from "../src/target"; +import { visit } from "../src/surfaces/browser"; const api = composePluginApi([openApiHttpPlugin()] as const); @@ -212,13 +213,13 @@ scenario( await installSameWindowOAuthPopup(page); await step("Land in the original organization", async () => { - await page.goto(`/${orgA.slug}`, { waitUntil: "networkidle" }); + await visit(page, `/${orgA.slug}`); await expectOrgShell(page, orgA); }); await step("The browser session is switched to another organization", async () => { await setWorkosSessionCookie(page, target.baseUrl, sessionB); - await page.goto(`/${orgB.slug}`, { waitUntil: "networkidle" }); + await visit(page, `/${orgB.slug}`); await expectOrgShell(page, orgB); }); @@ -232,9 +233,7 @@ scenario( // refetch, and leave "Connect with OAuth" disabled forever (the flake // this step used to have). Waiting for org A's shell before opening // the modal makes every modal fetch run under the settled org A scope. - await page.goto(`/${orgA.slug}/integrations/${String(integration)}`, { - waitUntil: "networkidle", - }); + await visit(page, `/${orgA.slug}/integrations/${String(integration)}`); await page.getByRole("button", { name: new RegExp(escapeRegExp(orgA.name)) }).waitFor({ timeout: 30_000, }); @@ -286,7 +285,7 @@ scenario( await step("The provider returns to the OAuth callback", async () => { const callbackUrl = await submitProviderLoginFromPage(page); callback = new URL(callbackUrl); - const response = await page.goto(callbackUrl, { waitUntil: "networkidle" }); + const response = await visit(page, callbackUrl); expect(response?.status(), "the callback renders its popup result page").toBe(200); }); diff --git a/e2e/cloud/onboarding-mcp-url.test.ts b/e2e/cloud/onboarding-mcp-url.test.ts index b1998668f2..84de5e0a70 100644 --- a/e2e/cloud/onboarding-mcp-url.test.ts +++ b/e2e/cloud/onboarding-mcp-url.test.ts @@ -6,6 +6,7 @@ import { Effect } from "effect"; import { scenario } from "../src/scenario"; import { Browser, Target } from "../src/services"; +import { visit } from "../src/surfaces/browser"; scenario( "Onboarding · the MCP setup step hands the user their org-scoped MCP server URL", @@ -19,7 +20,7 @@ scenario( await step( "A fresh user without an org lands on the create-org onboarding page", async () => { - await page.goto("/", { waitUntil: "networkidle" }); + await visit(page, "/"); // Step 1 of 2 — the org-name input is the landmark that proves we're on onboarding. await page.getByPlaceholder("Northwind Labs").waitFor(); }, diff --git a/e2e/cloud/org-api-keys-console.test.ts b/e2e/cloud/org-api-keys-console.test.ts index fae9f61e63..828dcb9afb 100644 --- a/e2e/cloud/org-api-keys-console.test.ts +++ b/e2e/cloud/org-api-keys-console.test.ts @@ -20,6 +20,7 @@ import { Effect } from "effect"; import { scenario } from "../src/scenario"; import { Browser, Target } from "../src/services"; import { forBrowser, joinOrg } from "./support/session"; +import { visit } from "../src/surfaces/browser"; declare global { interface Window { @@ -45,7 +46,7 @@ scenario( let slug = ""; await step("Land in the workspace and open the API keys page", async () => { - await page.goto("/", { waitUntil: "networkidle" }); + await visit(page, "/"); await page.waitForURL((url) => /^\/[a-z0-9-]+\/?$/.test(url.pathname), { timeout: 30_000, }); @@ -120,12 +121,12 @@ scenario( // ── The plain member's view ─────────────────────────────────────────── yield* browser.session(forBrowser(member), async ({ page, step }) => { await step("A plain member is not shown the Organization keys section", async () => { - await page.goto("/", { waitUntil: "networkidle" }); + await visit(page, "/"); await page.waitForURL((url) => /^\/[a-z0-9-]+\/?$/.test(url.pathname), { timeout: 30_000, }); const slug = new URL(page.url()).pathname.split("/").filter(Boolean)[0] ?? ""; - await page.goto(`/${slug}/api-keys`, { waitUntil: "networkidle" }); + await visit(page, `/${slug}/api-keys`); // The personal half renders for everyone… await page .getByRole("heading", { name: "Personal keys" }) diff --git a/e2e/cloud/org-delete.test.ts b/e2e/cloud/org-delete.test.ts index f18a6fc8c2..bb3f9f187c 100644 --- a/e2e/cloud/org-delete.test.ts +++ b/e2e/cloud/org-delete.test.ts @@ -9,6 +9,7 @@ import { Effect } from "effect"; import { scenario } from "../src/scenario"; import { Browser, Target } from "../src/services"; +import { visit, settle } from "../src/surfaces/browser"; scenario( "Organizations · an admin deletes the organization from settings", @@ -22,7 +23,7 @@ scenario( const ORG = "Doomed Org"; await step("Fresh user creates an org via onboarding", async () => { - await page.goto("/", { waitUntil: "networkidle" }); + await visit(page, "/"); await page.getByPlaceholder("Northwind Labs").fill(ORG); await page.getByRole("button", { name: "Create organization" }).click(); await page.getByText("Connect your MCP client").waitFor(); @@ -32,13 +33,13 @@ scenario( await page.waitForURL((url) => /^\/[a-z0-9-]+\/?$/.test(url.pathname), { timeout: 30_000, }); - await page.waitForLoadState("networkidle"); + await settle(page); }); const slug = new URL(page.url()).pathname.split("/").filter(Boolean)[0]!; await step("Open Organization settings and find the danger zone", async () => { - await page.goto(`/${slug}/org`, { waitUntil: "networkidle" }); + await visit(page, `/${slug}/org`); // The admin-only danger zone renders (a member would not see it). await page.getByText("Permanently delete this organization").waitFor(); }); diff --git a/e2e/cloud/org-last-visited.test.ts b/e2e/cloud/org-last-visited.test.ts index 65a351ff22..6912fce1cc 100644 --- a/e2e/cloud/org-last-visited.test.ts +++ b/e2e/cloud/org-last-visited.test.ts @@ -13,6 +13,7 @@ import { Effect } from "effect"; import { scenario } from "../src/scenario"; import { Browser, Target } from "../src/services"; +import { visit } from "../src/surfaces/browser"; const CLOUD_ORIGIN_HEADERS = (baseUrl: string) => ({ origin: new URL(baseUrl).origin }); @@ -64,7 +65,7 @@ scenario( yield* browser.session(inB, async ({ page, step }) => { await step("Work in org A by its slug URL (the session still pins org B)", async () => { - await page.goto(`/${slugA}`, { waitUntil: "networkidle" }); + await visit(page, `/${slugA}`); await page.getByText("Integrations").first().waitFor({ timeout: 30_000 }); // The client records the viewed org once /account/me confirms it. await page.waitForFunction( @@ -75,7 +76,7 @@ scenario( }); await step("A bare entry (`/`) returns to org A, not the session's org B", async () => { - await page.goto("/", { waitUntil: "networkidle" }); + await visit(page, "/"); await page.waitForURL( (url) => url.pathname === `/${slugA}` || url.pathname === `/${slugA}/`, { @@ -86,7 +87,7 @@ scenario( }); await step("A bare deep link keeps its path while landing in org A", async () => { - await page.goto("/policies", { waitUntil: "networkidle" }); + await visit(page, "/policies"); await page.waitForURL((url) => url.pathname === `/${slugA}/policies`, { timeout: 30_000 }); await page.getByText("Policies").first().waitFor({ timeout: 30_000 }); }); diff --git a/e2e/cloud/org-limit.test.ts b/e2e/cloud/org-limit.test.ts index 3bd8b2b256..dcdb570936 100644 --- a/e2e/cloud/org-limit.test.ts +++ b/e2e/cloud/org-limit.test.ts @@ -8,6 +8,7 @@ import { Effect } from "effect"; import { scenario } from "../src/scenario"; import { Billing, Browser, Target } from "../src/services"; +import { visit, settle } from "../src/surfaces/browser"; const FREE_LIMIT = 3; @@ -23,7 +24,7 @@ scenario( yield* browser.session(identity, async ({ page, step }) => { await step("A fresh user lands on onboarding (no organization yet)", async () => { - await page.goto("/", { waitUntil: "networkidle" }); + await visit(page, "/"); await page.getByPlaceholder("Northwind Labs").waitFor(); }); @@ -43,7 +44,7 @@ scenario( await page.waitForURL((url) => /^\/[a-z0-9-]+\/?$/.test(url.pathname), { timeout: 30_000, }); - await page.waitForLoadState("networkidle"); + await settle(page); }); const openCreateOrgModal = async (currentOrg: string) => { diff --git a/e2e/cloud/org-multitab-cookie.test.ts b/e2e/cloud/org-multitab-cookie.test.ts index 27c8a967a5..75125828ba 100644 --- a/e2e/cloud/org-multitab-cookie.test.ts +++ b/e2e/cloud/org-multitab-cookie.test.ts @@ -21,6 +21,7 @@ import { Effect } from "effect"; import { scenario } from "../src/scenario"; import { Browser, Target } from "../src/services"; +import { visit } from "../src/surfaces/browser"; scenario( "Org tabs · two tabs on different orgs stay independent (URL-scoped, no cookie steal)", @@ -53,7 +54,7 @@ scenario( let slugB = ""; await step("Onboard org A in tab 1", async () => { - await tab1.goto("/", { waitUntil: "networkidle" }); + await visit(tab1, "/"); await tab1.getByPlaceholder("Northwind Labs").fill("Multitab A"); await tab1.getByRole("button", { name: "Create organization" }).click(); await tab1.getByText("Connect your MCP client").waitFor({ timeout: 30_000 }); @@ -83,7 +84,7 @@ scenario( const tab2 = await tab1.context().newPage(); await step("Tab 2 opens org A's URL and stays in A — no switch, no reload loop", async () => { - await tab2.goto(`/${slugA}/policies`, { waitUntil: "networkidle" }); + await visit(tab2, `/${slugA}/policies`); await tab2.getByText("Policies").first().waitFor({ timeout: 30_000 }); expect(new URL(tab2.url()).pathname, "tab 2 stays on org A's URL").toBe( `/${slugA}/policies`, @@ -134,7 +135,7 @@ scenario( yield* browser.session(identity, async ({ page, step }) => { await step("Open the OpenAPI add form", async () => { - await page.goto("/integrations/add/openapi", { waitUntil: "networkidle" }); + await visit(page, "/integrations/add/openapi"); await page.getByPlaceholder("https://api.example.com/openapi.json").waitFor(); }); diff --git a/e2e/cloud/org-slug-foreign.test.ts b/e2e/cloud/org-slug-foreign.test.ts index 04aee48d35..c50e024d2b 100644 --- a/e2e/cloud/org-slug-foreign.test.ts +++ b/e2e/cloud/org-slug-foreign.test.ts @@ -13,6 +13,7 @@ import { Effect } from "effect"; import { scenario } from "../src/scenario"; import { Browser, Target } from "../src/services"; +import { visit } from "../src/surfaces/browser"; const CLOUD_ORIGIN_HEADERS = (baseUrl: string) => ({ origin: new URL(baseUrl).origin }); @@ -67,10 +68,10 @@ scenario( yield* browser.session(inB, async ({ page, step }) => { await step("Land in org B, then open org A's slug URL directly", async () => { - await page.goto(`/${orgB.slug}`, { waitUntil: "networkidle" }); + await visit(page, `/${orgB.slug}`); await page.getByText("Integrations").first().waitFor({ timeout: 30_000 }); // Open org A by its slug while the cookie is still pinned to B. - await page.goto(`/${slugA}/policies`, { waitUntil: "networkidle" }); + await visit(page, `/${slugA}/policies`); }); await step("Org A's page renders at its slugged URL — the URL is the scope", async () => { diff --git a/e2e/cloud/org-switcher.test.ts b/e2e/cloud/org-switcher.test.ts index eaf1aa9980..62f77bd4ed 100644 --- a/e2e/cloud/org-switcher.test.ts +++ b/e2e/cloud/org-switcher.test.ts @@ -8,6 +8,7 @@ import { Effect } from "effect"; import { scenario } from "../src/scenario"; import { Browser, Target } from "../src/services"; +import { visit, settle } from "../src/surfaces/browser"; scenario( "Organizations · switching organizations switches the workspace", @@ -20,7 +21,7 @@ scenario( yield* browser.session(identity, async ({ page, step }) => { // ── Step 1: onboarding, create the first org ───────────────────── await step("Fresh user lands on onboarding (no organization yet)", async () => { - await page.goto("/", { waitUntil: "networkidle" }); + await visit(page, "/"); await page.getByPlaceholder("Northwind Labs").waitFor(); }); @@ -43,7 +44,7 @@ scenario( await page.waitForURL((url) => /^\/[a-z0-9-]+\/?$/.test(url.pathname), { timeout: 30_000, }); - await page.waitForLoadState("networkidle"); + await settle(page); }); // ── Step 2: create the second org via the account-menu switcher ── @@ -89,7 +90,7 @@ scenario( // rendered with data-disabled="" (Radix convention). The only item without // data-disabled that isn't "Create organization" is ORG_1. await step(`Open the org switcher and switch back to "${ORG_1}"`, async () => { - await page.waitForLoadState("networkidle"); + await settle(page); await page.getByRole("button", { name: /Test User/ }).click(); // Click the SubTrigger (shows current org name = ORG_2) to expand the list. await page.getByRole("menuitem", { name: ORG_2 }).click(); diff --git a/e2e/scenarios/artifact-loading-surface.test.ts b/e2e/scenarios/artifact-loading-surface.test.ts index 993228eb13..141d61dda1 100644 --- a/e2e/scenarios/artifact-loading-surface.test.ts +++ b/e2e/scenarios/artifact-loading-surface.test.ts @@ -43,6 +43,7 @@ import type { ArtifactId } from "@executor-js/sdk/shared"; import { scenario } from "../src/scenario"; import { Api, Browser, Mcp, Target } from "../src/services"; +import { visit } from "../src/surfaces/browser"; const api = composePluginApi([] as const); @@ -230,7 +231,7 @@ scenario( yield* browser.session(identity, async ({ page, step }) => { await step("Open the Artifacts gallery", async () => { await startSampling(page); - await page.goto(`${target.baseUrl}/artifacts`, { waitUntil: "networkidle" }); + await visit(page, `${target.baseUrl}/artifacts`); await page .getByRole("link", { name: `Open artifact ${title}` }) .waitFor({ timeout: 30_000 }); diff --git a/e2e/scenarios/artifact-preview-gallery.test.ts b/e2e/scenarios/artifact-preview-gallery.test.ts index f3e08643e5..a468f5897b 100644 --- a/e2e/scenarios/artifact-preview-gallery.test.ts +++ b/e2e/scenarios/artifact-preview-gallery.test.ts @@ -17,6 +17,7 @@ import { Effect } from "effect"; import { scenario } from "../src/scenario"; import { Browser, Mcp, Target } from "../src/services"; +import { revisit, visit } from "../src/surfaces/browser"; const uniqueSuffix = () => randomBytes(3).toString("hex"); @@ -167,7 +168,7 @@ scenario( yield* browser.session(identity, async ({ page, step }) => { await step("Open the Artifacts gallery", async () => { - await page.goto(`${target.baseUrl}/artifacts`, { waitUntil: "networkidle" }); + await visit(page, `${target.baseUrl}/artifacts`); await page.getByRole("heading", { name: "Artifacts", level: 1 }).waitFor(); // Every seeded artifact is present before anything is measured. for (const [name] of seeded) { @@ -257,7 +258,7 @@ scenario( // The stored markup is token-based, so the SAME markup has to read // correctly against the console's dark surface without being restyled. await page.emulateMedia({ colorScheme: "dark" }); - await page.reload({ waitUntil: "networkidle" }); + await revisit(page); await page.getByRole("heading", { name: "Artifacts", level: 1 }).waitFor(); await page.locator('[data-slot="artifact-preview"]').first().waitFor({ timeout: 20_000 }); @@ -272,7 +273,7 @@ scenario( await step("Gallery in light mode", async () => { await page.emulateMedia({ colorScheme: "light" }); - await page.reload({ waitUntil: "networkidle" }); + await revisit(page); await page.getByRole("heading", { name: "Artifacts", level: 1 }).waitFor(); await page.locator('[data-slot="artifact-preview"]').first().waitFor({ timeout: 20_000 }); }); diff --git a/e2e/scenarios/artifacts.test.ts b/e2e/scenarios/artifacts.test.ts index 29f4be3e90..fa9a82d03b 100644 --- a/e2e/scenarios/artifacts.test.ts +++ b/e2e/scenarios/artifacts.test.ts @@ -24,6 +24,7 @@ import type { ArtifactId } from "@executor-js/sdk/shared"; import { scenario } from "../src/scenario"; import { Api, Browser, Mcp, Target } from "../src/services"; +import { visit } from "../src/surfaces/browser"; const api = composePluginApi([] as const); @@ -263,7 +264,7 @@ scenario( await step("Open the artifact link the agent handed over", async () => { await recordHandshakeOrdering(page); - await page.goto(url, { waitUntil: "networkidle" }); + await visit(page, url); consoleStyleBefore = await readConsoleStyle(page); }); @@ -596,7 +597,7 @@ scenario( yield* browser.session(identity, async ({ page, step }) => { await step("Open the Artifacts tab", async () => { - await page.goto("/artifacts", { waitUntil: "networkidle" }); + await visit(page, "/artifacts"); await page.getByRole("link", { name: `Open artifact ${originalTitle}` }).waitFor({ timeout: 20_000, }); @@ -634,7 +635,7 @@ scenario( yield* browser.session(identity, async ({ page, step }) => { await step("Delete the artifact from the list", async () => { - await page.goto("/artifacts", { waitUntil: "networkidle" }); + await visit(page, "/artifacts"); const card = page.locator('[data-slot="artifact-card"]').filter({ hasText: renamedTitle, }); @@ -738,7 +739,7 @@ scenario( yield* browser.session(identity, async ({ page, step }) => { await step("The artifact's own URL shows the updated component", async () => { - await page.goto(`/artifacts/${artifactId}`, { waitUntil: "networkidle" }); + await visit(page, `/artifacts/${artifactId}`); const content = artifactContent(page); await content .locator('[data-testid="artifact-marker"]') diff --git a/e2e/scenarios/auth-methods-ui.test.ts b/e2e/scenarios/auth-methods-ui.test.ts index 058fbd77f1..224dd11f27 100644 --- a/e2e/scenarios/auth-methods-ui.test.ts +++ b/e2e/scenarios/auth-methods-ui.test.ts @@ -15,6 +15,7 @@ import { makeGreetingMcpServer, serveMcpServer } from "@executor-js/plugin-mcp/t import { scenario } from "../src/scenario"; import { Browser, Target } from "../src/services"; +import { visit } from "../src/surfaces/browser"; scenario( "Auth methods · the add flow declares an API key alongside the detected method", @@ -34,9 +35,7 @@ scenario( yield* browser.session(identity, async ({ page, step }) => { await step("Open the add-MCP flow pointed at the server", async () => { - await page.goto(`/integrations/add/mcp?url=${encodeURIComponent(server.endpoint)}`, { - waitUntil: "networkidle", - }); + await visit(page, `/integrations/add/mcp?url=${encodeURIComponent(server.endpoint)}`); // The URL auto-probes (debounced); the method list appears once // the probe lands. Generous timeout: the probe request can queue // behind a busy dev server under CI load, and there is no clean diff --git a/e2e/scenarios/browser-approval.test.ts b/e2e/scenarios/browser-approval.test.ts index 3736316650..63d0d020ac 100644 --- a/e2e/scenarios/browser-approval.test.ts +++ b/e2e/scenarios/browser-approval.test.ts @@ -25,6 +25,7 @@ import { Api, Browser, Mcp, Target } from "../src/services"; import { type McpBrowserApproval, parseBrowserApproval } from "../src/surfaces/mcp"; import type { BrowserSurface } from "../src/surfaces/browser"; import type { Identity } from "../src/target"; +import { visit } from "../src/surfaces/browser"; const coreApi = composePluginApi([] as const); @@ -70,7 +71,7 @@ const decideInBrowser = ( await step( `Open the approval page and ${decision.toLowerCase()} the paused action`, async () => { - await page.goto(approval.approvalUrl, { waitUntil: "networkidle" }); + await visit(page, approval.approvalUrl); await page.getByRole("button", { name: decision }).click(); // The page confirms the decision was recorded ("Approve sent" / "Decline sent"). await page.getByText(`${decision} sent`).waitFor(); diff --git a/e2e/scenarios/connect-deep-link.test.ts b/e2e/scenarios/connect-deep-link.test.ts index 4961392198..6e296a8f9b 100644 --- a/e2e/scenarios/connect-deep-link.test.ts +++ b/e2e/scenarios/connect-deep-link.test.ts @@ -30,6 +30,7 @@ import { import { scenario } from "../src/scenario"; import { Api, Browser, Target } from "../src/services"; +import { visit } from "../src/surfaces/browser"; const api = composePluginApi([openApiHttpPlugin()] as const); @@ -95,7 +96,7 @@ scenario( yield* browser.session(identity, async ({ page, step }) => { await step("Open the connect deep link for the seeded integration", async () => { - await page.goto(`/connect/${slug}`, { waitUntil: "networkidle" }); + await visit(page, `/connect/${slug}`); // It forwards into the integration's OWN detail route rather than // rendering a parallel connect UI, carrying the add-account handoff. await page.waitForURL((url) => url.pathname === detailPath, { timeout: 30_000 }); @@ -115,7 +116,7 @@ scenario( }); await step("An unknown slug is a clear not-found, not a blank page", async () => { - await page.goto("/connect/zz-no-such-integration", { waitUntil: "networkidle" }); + await visit(page, "/connect/zz-no-such-integration"); await page .getByText("Unknown integration: zz-no-such-integration") .waitFor({ timeout: 30_000 }); @@ -139,7 +140,7 @@ scenario( yield* browser.session(identity, async ({ page, step }) => { await step("The deep link surfaces the existing connection", async () => { - await page.goto(`/connect/${slug}`, { waitUntil: "networkidle" }); + await visit(page, `/connect/${slug}`); await page.waitForURL((url) => url.pathname === detailPath, { timeout: 30_000 }); // The connect flow still opens (this link means "connect me"), and // the already-saved account is visible behind it. diff --git a/e2e/scenarios/connect-handoff-session.test.ts b/e2e/scenarios/connect-handoff-session.test.ts index e91feeca37..055baf44ed 100644 --- a/e2e/scenarios/connect-handoff-session.test.ts +++ b/e2e/scenarios/connect-handoff-session.test.ts @@ -20,6 +20,7 @@ import { scenario } from "../src/scenario"; import { Browser, Cli, Mcp, RunDir, Target } from "../src/services"; import { withChatTheater } from "../src/clients/chat-theater"; import type { McpSession } from "../src/surfaces/mcp"; +import { visit } from "../src/surfaces/browser"; const unique = (prefix: string) => `${prefix}_${randomBytes(4).toString("hex")}`; @@ -148,7 +149,7 @@ scenario( yield* chat.status("you, in the browser: opening the link and pasting the API key…"); yield* browser.session(identity, async ({ page, step }) => { await step("Open the connect link from the chat", async () => { - await page.goto(handoffUrl, { waitUntil: "networkidle" }); + await visit(page, handoffUrl); await page .getByRole("heading", { name: /Add connection/ }) .waitFor({ timeout: 15_000 }); diff --git a/e2e/scenarios/connect-handoff.test.ts b/e2e/scenarios/connect-handoff.test.ts index d4cc80692e..e9f7696cd4 100644 --- a/e2e/scenarios/connect-handoff.test.ts +++ b/e2e/scenarios/connect-handoff.test.ts @@ -32,6 +32,7 @@ import { Api, Browser, Mcp, Target } from "../src/services"; import type { Identity, Target as TargetShape } from "../src/target"; import type { BrowserSurface } from "../src/surfaces/browser"; import type { McpSession } from "../src/surfaces/mcp"; +import { visit } from "../src/surfaces/browser"; const api = composePluginApi([openApiHttpPlugin()] as const); @@ -214,7 +215,7 @@ const runScenario = (input: { // 4. The user opens the handoff URL and pastes the credential. yield* browser.session(identity, async ({ page, step }) => { await step("Open the handoff URL from the agent", async () => { - await page.goto(handoffUrl, { waitUntil: "networkidle" }); + await visit(page, handoffUrl); }); await step("The Add connection modal is open", async () => { diff --git a/e2e/scenarios/connection-remove-confirm.test.ts b/e2e/scenarios/connection-remove-confirm.test.ts index 52877548f1..b0c8ab8146 100644 --- a/e2e/scenarios/connection-remove-confirm.test.ts +++ b/e2e/scenarios/connection-remove-confirm.test.ts @@ -14,6 +14,7 @@ import { AuthTemplateSlug, ConnectionName, IntegrationSlug } from "@executor-js/ import { scenario } from "../src/scenario"; import { Api, Browser, Target } from "../src/services"; +import { visit } from "../src/surfaces/browser"; const api = composePluginApi([openApiHttpPlugin()] as const); @@ -77,7 +78,7 @@ scenario( const confirm = page.getByRole("alertdialog"); await step("Open the integration's connections", async () => { - await page.goto(`/integrations/${slug}`, { waitUntil: "networkidle" }); + await visit(page, `/integrations/${slug}`); await row.waitFor(); }); diff --git a/e2e/scenarios/docs-link.test.ts b/e2e/scenarios/docs-link.test.ts index 5017f23d39..8445f421b5 100644 --- a/e2e/scenarios/docs-link.test.ts +++ b/e2e/scenarios/docs-link.test.ts @@ -3,6 +3,7 @@ import { Effect } from "effect"; import { scenario } from "../src/scenario"; import { Browser, Target } from "../src/services"; +import { visit } from "../src/surfaces/browser"; // A user inside the console should be able to reach the documentation without // leaving the app for the marketing site (the original friction: docs were @@ -18,7 +19,7 @@ scenario( yield* browser.session(identity, async ({ page, step }) => { await step("Open the console", async () => { - await page.goto("/", { waitUntil: "networkidle" }); + await visit(page, "/"); await page.getByText("Integrations").first().waitFor(); }); diff --git a/e2e/scenarios/first-party-oauth.test.ts b/e2e/scenarios/first-party-oauth.test.ts index f0473784ab..f162163a1a 100644 --- a/e2e/scenarios/first-party-oauth.test.ts +++ b/e2e/scenarios/first-party-oauth.test.ts @@ -26,7 +26,7 @@ import { import { scenario } from "../src/scenario"; import { Api, Browser, Target } from "../src/services"; -import { clickToReveal } from "../src/surfaces/browser"; +import { clickToReveal, visit } from "../src/surfaces/browser"; const api = composePluginApi([openApiHttpPlugin()] as const); @@ -182,7 +182,7 @@ scenario( yield* browser.session(identity, async ({ page, step }) => { await step("Open the Google integration catalog", async () => { - await page.goto("/integrations", { waitUntil: "networkidle" }); + await visit(page, "/integrations"); const dialog = page.getByRole("dialog", { name: "Connect an integration" }); await clickToReveal(page.getByRole("button", { name: /Connect/ }).first(), dialog); }); diff --git a/e2e/scenarios/google-health-checks.test.ts b/e2e/scenarios/google-health-checks.test.ts index 6dc532ac5b..8cdb602a1e 100644 --- a/e2e/scenarios/google-health-checks.test.ts +++ b/e2e/scenarios/google-health-checks.test.ts @@ -13,10 +13,11 @@ import { OAuthClientSlug, } from "@executor-js/sdk/shared"; +import { createEmulatorInstance } from "../src/emulator-instance"; import { scenario } from "../src/scenario"; import { Api, Browser, Target } from "../src/services"; import type { Identity, Target as TargetShape } from "../src/target"; -import { clickToReveal, type BrowserSurface } from "../src/surfaces/browser"; +import { type BrowserSurface, clickToReveal, visit } from "../src/surfaces/browser"; const api = composePluginApi([openApiHttpPlugin()] as const); type Client = HttpApiClient.ForApi; @@ -69,14 +70,13 @@ const completeGoogleConsent = (authorizationUrl: string) => return code; }); -const createGoogleEmulator = Effect.promise(async () => { - const response = await fetch("https://google.emulators.dev/_emulate/instances", { - method: "POST", - }); - if (!response.ok) throw new Error(`Google emulator instance failed: ${response.status}`); - const instance = (await response.json()) as { readonly providerBaseUrl: string }; - const client = await connectEmulator({ baseUrl: instance.providerBaseUrl }); - return { client, baseUrl: instance.providerBaseUrl }; +const createGoogleEmulator = Effect.gen(function* () { + // Through the shared helper: it bounds and retries the one request in this + // scenario that leaves the runner, so a blip on the way to the edge doesn't + // read as a Google health-check failure. + const baseUrl = yield* createEmulatorInstance("google", "google-health"); + const client = yield* Effect.promise(() => connectEmulator({ baseUrl })); + return { client, baseUrl }; }); const addGooglePresetFromCatalog = ( @@ -87,7 +87,7 @@ const addGooglePresetFromCatalog = ( ) => browser.session(identity, async ({ page, step }) => { await step(`Open ${presetName} from the connect catalog`, async () => { - await page.goto("/integrations", { waitUntil: "networkidle" }); + await visit(page, "/integrations"); const dialog = page.getByRole("dialog", { name: "Connect an integration" }); await clickToReveal(page.getByRole("button", { name: /Connect/ }).first(), dialog); await dialog.getByPlaceholder(/Search or paste a URL/).fill(presetName); @@ -266,29 +266,31 @@ scenario( health.status, `${row.presetName} health check is healthy: ${JSON.stringify(health)}`, ).toBe("healthy"); - } - // The hosted emulator acknowledges a request before its ledger entry - // is readable, so a single list right after the probe races the write - // (the health checks above already came back healthy, which only the - // emulator can answer). Poll until every expected operation is - // visible; on timeout the assertion names what never arrived. - const missing = yield* Effect.gen(function* () { - const ledger = yield* Effect.promise(() => emulator.client.ledger.list(100)); - return rows.filter( - (row) => !ledger.some((entry) => entry.operationId === row.expectedLedgerOperation), + // Check this row's ledger entry HERE, not after both rows have run. + // `ledger.list(n)` is the last n entries, and connecting the second + // account is easily a hundred emulator requests, so the first row's + // health check can be evicted from the window before a combined + // assertion at the end ever looks for it — which reads as "Calendar's + // health check never reached the emulator" when it plainly did (the + // probe above came back healthy, and only the emulator can answer + // that). The hosted emulator also acknowledges a request before its + // ledger entry is readable, so poll rather than read once. + const reached = yield* Effect.promise(() => emulator.client.ledger.list(50)).pipe( + Effect.map((ledger) => + ledger.some((entry) => entry.operationId === row.expectedLedgerOperation), + ), + Effect.repeat({ + schedule: Schedule.spaced("500 millis"), + until: (seen) => seen, + times: 19, + }), ); - }).pipe( - Effect.repeat({ - schedule: Schedule.spaced("500 millis"), - until: (unseen) => unseen.length === 0, - times: 19, - }), - ); - expect( - missing.map((row) => row.presetName), - "every health check reached the Google emulator", - ).toEqual([]); + expect( + reached, + `${row.presetName}'s health check reached the Google emulator as ${row.expectedLedgerOperation}`, + ).toBe(true); + } }), Effect.gen(function* () { for (const row of rows) { diff --git a/e2e/scenarios/google-photos-preset-ui.test.ts b/e2e/scenarios/google-photos-preset-ui.test.ts index 8b5788493b..463f4e8bd3 100644 --- a/e2e/scenarios/google-photos-preset-ui.test.ts +++ b/e2e/scenarios/google-photos-preset-ui.test.ts @@ -3,7 +3,7 @@ import { Effect } from "effect"; import { scenario } from "../src/scenario"; import { Browser, Target } from "../src/services"; -import { clickToReveal } from "../src/surfaces/browser"; +import { clickToReveal, visit } from "../src/surfaces/browser"; scenario( "Google Photos: separated catalog presets open a Photos service add flow", @@ -17,7 +17,7 @@ scenario( await step( "Find the separated Google Photos presets from the integrations picker", async () => { - await page.goto("/integrations", { waitUntil: "networkidle" }); + await visit(page, "/integrations"); const dialog = page.getByRole("dialog", { name: "Connect an integration" }); await clickToReveal(page.getByRole("button", { name: "Connect" }), dialog); await dialog.getByPlaceholder(/Search or paste a URL/).fill("google photos"); diff --git a/e2e/scenarios/graphql-introspection-health.test.ts b/e2e/scenarios/graphql-introspection-health.test.ts index 185ec36653..b0ca487fb9 100644 --- a/e2e/scenarios/graphql-introspection-health.test.ts +++ b/e2e/scenarios/graphql-introspection-health.test.ts @@ -11,6 +11,7 @@ import { variable } from "@executor-js/sdk/http-auth"; import { createEmulatorInstance } from "../src/emulator-instance"; import { scenario } from "../src/scenario"; import { Api, Browser, Target } from "../src/services"; +import { visit } from "../src/surfaces/browser"; const api = composePluginApi([graphqlHttpPlugin()] as const); const unique = (prefix: string): string => `${prefix}_${randomBytes(4).toString("hex")}`; @@ -30,11 +31,18 @@ scenario( connectEmulator({ baseUrl: emulatorBaseUrl, service: "github" }), ); + // A budget, not a count: nothing in this scenario depends on how many + // times the connect flow introspects, and the emulator's answer when the + // budget runs out is not a neutral pass-through — an unauthenticated + // GraphQL POST to the real handler is GitHub-shaped, so it comes back 403 + // "API rate limit exceeded". The UI then honestly reports HTTP 403 and the + // assertion below fails on a message that has nothing to do with the + // product. Arm enough that one connect attempt cannot exhaust it. yield* Effect.promise(() => emulator.faults.arm({ match: { method: "POST", pathPattern: "/graphql" }, response: { status: 401, body: { message: "Bad credentials" } }, - times: 10, + times: 100, }), ); @@ -56,9 +64,7 @@ scenario( yield* Effect.gen(function* () { yield* browser.session(identity, async ({ page, step }) => { await step("Open the connection flow", async () => { - await page.goto(`/integrations/${slug}?addAccount=1&owner=org&template=header`, { - waitUntil: "networkidle", - }); + await visit(page, `/integrations/${slug}?addAccount=1&owner=org&template=header`); await page.getByRole("heading", { name: /Add connection · GraphQL health/ }).waitFor(); }); @@ -97,7 +103,7 @@ scenario( yield* browser.session(identity, async ({ page, step }) => { await step("A failed existing connection explains the empty tool catalogue", async () => { - await page.goto(`/integrations/${slug}?tab=tools`, { waitUntil: "networkidle" }); + await visit(page, `/integrations/${slug}?tab=tools`); await page.getByText("Connection rejected", { exact: true }).first().waitFor(); await page .getByText("The endpoint rejected the credential with HTTP 401.", { diff --git a/e2e/scenarios/health-checks-ui.test.ts b/e2e/scenarios/health-checks-ui.test.ts index 10ae01dcaf..ce74cd9d6b 100644 --- a/e2e/scenarios/health-checks-ui.test.ts +++ b/e2e/scenarios/health-checks-ui.test.ts @@ -34,6 +34,7 @@ import { AuthTemplateSlug, ConnectionName, IntegrationSlug } from "@executor-js/ import { scenario } from "../src/scenario"; import { Api, Browser, Target } from "../src/services"; +import { visit } from "../src/surfaces/browser"; const api = composePluginApi([openApiHttpPlugin()] as const); type Client = HttpApiClient.ForApi; @@ -281,7 +282,7 @@ scenario( const dialog = page.getByRole("dialog"); await step("Open the Add Connection modal", async () => { - await page.goto(`/integrations/${slug}`, { waitUntil: "networkidle" }); + await visit(page, `/integrations/${slug}`); await page.getByRole("button", { name: "Add connection", exact: true }).click(); await page.getByRole("heading", { name: /Add connection/ }).waitFor(); }); @@ -395,7 +396,7 @@ scenario( const options = page.getByRole("option"); await step("Open the health-check editor over the large spec", async () => { - await page.goto(`/integrations/${slug}`, { waitUntil: "networkidle" }); + await visit(page, `/integrations/${slug}`); await page.getByRole("heading", { level: 3, name: "Health check" }).waitFor(); await page.getByRole("button", { name: "Set up" }).click(); await input.waitFor(); @@ -479,7 +480,7 @@ scenario( const options = page.getByRole("option"); await step("Open the Add form and paste the large spec", async () => { - await page.goto("/integrations/add/openapi", { waitUntil: "networkidle" }); + await visit(page, "/integrations/add/openapi"); await page.getByPlaceholder("https://api.example.com/openapi.json").fill(spec); await page .getByRole("heading", { name: "Health check (optional)" }) @@ -558,7 +559,7 @@ scenario( const operationInput = page.locator("#health-check-operation"); await step("Open the health-check editor", async () => { - await page.goto(`/integrations/${slug}`, { waitUntil: "networkidle" }); + await visit(page, `/integrations/${slug}`); await page.getByRole("heading", { level: 3, name: "Health check" }).waitFor(); await page.getByRole("button", { name: "Set up" }).click(); await operationInput.waitFor(); @@ -629,7 +630,7 @@ scenario( const list = page.locator("[data-slot='combobox-list']").first(); await step("Open the operation combobox in the sheet", async () => { - await page.goto(`/integrations/${slug}`, { waitUntil: "networkidle" }); + await visit(page, `/integrations/${slug}`); await page.getByRole("heading", { level: 3, name: "Health check" }).waitFor(); await page.getByRole("button", { name: "Set up" }).click(); await operationInput.waitFor(); @@ -705,7 +706,7 @@ scenario( const menuTrigger = connections.locator('button[aria-haspopup="menu"]'); await step("Open the integration's connections", async () => { - await page.goto(`/integrations/${slug}`, { waitUntil: "networkidle" }); + await visit(page, `/integrations/${slug}`); await connections.getByText("main", { exact: true }).waitFor(); await page.getByRole("heading", { level: 3, name: "Health check" }).waitFor(); }); @@ -795,7 +796,7 @@ scenario( const dialog = page.getByRole("dialog"); await step("Open the Add Connection modal", async () => { - await page.goto(`/integrations/${slug}`, { waitUntil: "networkidle" }); + await visit(page, `/integrations/${slug}`); await page.getByRole("button", { name: "Add connection", exact: true }).click(); await page.getByRole("heading", { name: /Add connection/ }).waitFor(); }); @@ -888,7 +889,7 @@ scenario( await step( "A fresh page load shows the expired connection with NO clicking", async () => { - await page.goto(`/integrations/${slug}`, { waitUntil: "networkidle" }); + await visit(page, `/integrations/${slug}`); // The persisted verdict drives the row: red dot + Expired badge // are already there on first paint of the list. await connections.getByLabel("Status: Expired").waitFor({ timeout: 30_000 }); @@ -907,7 +908,7 @@ scenario( // not after the freshness window). Restore the key, reload, // and the dot flips back with no clicks. server.restore(); - await page.goto(`/integrations/${slug}`, { waitUntil: "networkidle" }); + await visit(page, `/integrations/${slug}`); // The row mounts with the stale expired verdict, then the // background revalidation flips it to healthy in place. await connections.getByLabel("Status: Healthy").waitFor({ timeout: 30_000 }); @@ -993,7 +994,7 @@ scenario( // while the operation combobox still renders (a healthy probe // saves the check and turns the request line static). await page.setViewportSize({ width: 1280, height: 420 }); - await page.goto(`/integrations/${slug}`, { waitUntil: "networkidle" }); + await visit(page, `/integrations/${slug}`); await page.getByRole("button", { name: "Add connection", exact: true }).click(); await page.getByRole("heading", { name: /Add connection/ }).waitFor(); await page.keyboard.type(goodToken); @@ -1136,7 +1137,7 @@ scenario( await step( "Load the integrations list: the dead MCP row reads Expired with no clicks", async () => { - await page.goto("/", { waitUntil: "networkidle" }); + await visit(page, "/"); // The row itself is the assertion surface: the list-page // summary probes in the background and paints the worst-of // verdict onto the row, scoped so a verdict from another row diff --git a/e2e/scenarios/mcp-catalog-sync-ui.test.ts b/e2e/scenarios/mcp-catalog-sync-ui.test.ts index 780ed83ebe..bda26e2cd7 100644 --- a/e2e/scenarios/mcp-catalog-sync-ui.test.ts +++ b/e2e/scenarios/mcp-catalog-sync-ui.test.ts @@ -14,6 +14,7 @@ import { makeMutableCatalogMcpServer, serveMcpServer } from "@executor-js/plugin import { scenario } from "../src/scenario"; import { Browser, Target } from "../src/services"; +import { revisit, visit } from "../src/surfaces/browser"; scenario( "MCP catalog · the Tools tab follows a server-side rename after a list_changed notification", @@ -35,9 +36,7 @@ scenario( yield* browser.session(identity, async ({ page, step }) => { await step("Open the add-MCP flow pointed at the live server", async () => { - await page.goto(`/integrations/add/mcp?url=${encodeURIComponent(server.endpoint)}`, { - waitUntil: "networkidle", - }); + await visit(page, `/integrations/add/mcp?url=${encodeURIComponent(server.endpoint)}`); // The URL auto-probes (debounced); the method list appears once the // probe lands — an open server seeds the detected no-auth method. await page.getByText("How does this server authenticate?").waitFor(); @@ -95,7 +94,7 @@ scenario( // Re-enter the page: a fresh tools read. The list_changed the server // sent during the call marked the catalog stale, so THIS read // re-lists — the renamed tool appears with no Refresh click. - await page.reload({ waitUntil: "networkidle" }); + await revisit(page); await page.getByRole("tab", { name: "Tools" }).click(); await filterTools("greet"); await toolRow(mutable.renamedToolName).waitFor({ timeout: 30_000 }); diff --git a/e2e/scenarios/oauth-callback-url.test.ts b/e2e/scenarios/oauth-callback-url.test.ts index f2012b7e31..ff78f6fcff 100644 --- a/e2e/scenarios/oauth-callback-url.test.ts +++ b/e2e/scenarios/oauth-callback-url.test.ts @@ -31,6 +31,7 @@ import { serveOAuthTestServer } from "@executor-js/sdk/testing"; import { scenario } from "../src/scenario"; import { Api, Browser, Target } from "../src/services"; +import { visit } from "../src/surfaces/browser"; const api = composePluginApi([openApiHttpPlugin()] as const); @@ -153,9 +154,7 @@ scenario( yield* browser.session(identity, async ({ page, step }) => { await step("Open the connect modal for an OAuth integration", async () => { - await page.goto(`/integrations/${String(integration)}?addAccount=1`, { - waitUntil: "networkidle", - }); + await visit(page, `/integrations/${String(integration)}?addAccount=1`); await page.getByRole("button", { name: "Register app", exact: true }).click(); }); diff --git a/e2e/scenarios/oauth-client-handoff.test.ts b/e2e/scenarios/oauth-client-handoff.test.ts index 258ce3276c..a9575c2e7f 100644 --- a/e2e/scenarios/oauth-client-handoff.test.ts +++ b/e2e/scenarios/oauth-client-handoff.test.ts @@ -37,6 +37,7 @@ import { createEmulatorInstance } from "../src/emulator-instance"; import { scenario } from "../src/scenario"; import { Api, Browser, Mcp, Target } from "../src/services"; import type { McpSession } from "../src/surfaces/mcp"; +import { visit } from "../src/surfaces/browser"; const microsoftApi = composePluginApi([ openApiHttpPlugin({ presets: microsoftCatalog, specFormats: [microsoftGraphAdapter] }), @@ -400,7 +401,7 @@ scenario( // pre-filled from the handoff. They type ONLY the secret. yield* browser.session(identity, async ({ page, step }) => { await step("Open the agent's handoff URL", async () => { - await page.goto(handoffUrl, { waitUntil: "networkidle" }); + await visit(page, handoffUrl); }); await step("The Register-OAuth-app form auto-opens, pre-filled", async () => { diff --git a/e2e/scenarios/openapi-add-integration-action-bar.test.ts b/e2e/scenarios/openapi-add-integration-action-bar.test.ts index 3f963a9da3..839c483856 100644 --- a/e2e/scenarios/openapi-add-integration-action-bar.test.ts +++ b/e2e/scenarios/openapi-add-integration-action-bar.test.ts @@ -9,6 +9,7 @@ import { Effect } from "effect"; import { scenario } from "../src/scenario"; import { Browser, Target } from "../src/services"; +import { visit } from "../src/surfaces/browser"; // One server so "Add integration" is enabled straight from the preview (no // base-URL picker to resolve first). @@ -32,7 +33,7 @@ scenario( yield* browser.session(identity, async ({ page, step }) => { await step("Open the Add OpenAPI integration form", async () => { - await page.goto("/integrations/add/openapi", { waitUntil: "networkidle" }); + await visit(page, "/integrations/add/openapi"); await page.getByPlaceholder("https://api.example.com/openapi.json").waitFor(); }); diff --git a/e2e/scenarios/openapi-multi-key-auth-ui.test.ts b/e2e/scenarios/openapi-multi-key-auth-ui.test.ts index 42fbd9047b..dd38630d08 100644 --- a/e2e/scenarios/openapi-multi-key-auth-ui.test.ts +++ b/e2e/scenarios/openapi-multi-key-auth-ui.test.ts @@ -13,6 +13,7 @@ import { IntegrationSlug } from "@executor-js/sdk/shared"; import { scenario } from "../src/scenario"; import { Api, Browser, Target } from "../src/services"; +import { visit } from "../src/surfaces/browser"; const api = composePluginApi([openApiHttpPlugin()] as const); @@ -61,7 +62,7 @@ scenario( yield* browser.session(identity, async ({ page, step }) => { await step("Open the Cloudflare-style integration", async () => { - await page.goto(`/integrations/${slug}`, { waitUntil: "networkidle" }); + await visit(page, `/integrations/${slug}`); await page.getByText("Connections").first().waitFor(); }); diff --git a/e2e/scenarios/openapi-server-selection-ui.test.ts b/e2e/scenarios/openapi-server-selection-ui.test.ts index 0451f7e892..256434ad8d 100644 --- a/e2e/scenarios/openapi-server-selection-ui.test.ts +++ b/e2e/scenarios/openapi-server-selection-ui.test.ts @@ -9,6 +9,7 @@ import { Effect } from "effect"; import { scenario } from "../src/scenario"; import { Browser, Target } from "../src/services"; +import { visit } from "../src/surfaces/browser"; // Two servers — production and staging — so the form offers a base-URL picker // instead of the single locked input a one-server spec gets. @@ -35,7 +36,7 @@ scenario( yield* browser.session(identity, async ({ page, step }) => { await step("Open the Add OpenAPI integration form", async () => { - await page.goto("/integrations/add/openapi", { waitUntil: "networkidle" }); + await visit(page, "/integrations/add/openapi"); await page.getByPlaceholder("https://api.example.com/openapi.json").waitFor(); }); diff --git a/e2e/scenarios/org-slug-routing.test.ts b/e2e/scenarios/org-slug-routing.test.ts index a39b021f4b..3ba129bee6 100644 --- a/e2e/scenarios/org-slug-routing.test.ts +++ b/e2e/scenarios/org-slug-routing.test.ts @@ -18,6 +18,7 @@ import { AccountHttpApi, isValidOrgSlug } from "@executor-js/api"; import { scenario } from "../src/scenario"; import { Api, Browser, Target } from "../src/services"; +import { visit } from "../src/surfaces/browser"; scenario( "Org URLs · console paths carry the organization slug", @@ -38,7 +39,7 @@ scenario( yield* browser.session(identity, async ({ page, step }) => { await step("A bare deep link canonicalizes onto the org slug", async () => { - await page.goto("/policies", { waitUntil: "networkidle" }); + await visit(page, "/policies"); await page.waitForURL((url) => url.pathname === `/${slug}/policies`, { timeout: 30_000, }); @@ -52,13 +53,13 @@ scenario( // legitimately does not. if (!target.name.startsWith("selfhost")) { await step("An unknown org slug is a wrong address, not a redirect", async () => { - await page.goto("/zz-no-such-org/policies", { waitUntil: "networkidle" }); + await visit(page, "/zz-no-such-org/policies"); await page.getByText("Page not found").waitFor({ timeout: 30_000 }); }); } await step("In-shell navigation keeps the slug prefix", async () => { - await page.goto(`/${slug}`, { waitUntil: "networkidle" }); + await visit(page, `/${slug}`); await page.getByRole("link", { name: "Policies" }).first().click(); await page.waitForURL((url) => url.pathname === `/${slug}/policies`, { timeout: 30_000, diff --git a/e2e/scenarios/policies-ui.test.ts b/e2e/scenarios/policies-ui.test.ts index cc400cd0ab..6e42865ea2 100644 --- a/e2e/scenarios/policies-ui.test.ts +++ b/e2e/scenarios/policies-ui.test.ts @@ -27,6 +27,7 @@ import { AuthTemplateSlug, ConnectionName, IntegrationSlug } from "@executor-js/ import { scenario } from "../src/scenario"; import { Api, Browser, Target } from "../src/services"; +import { visit } from "../src/surfaces/browser"; const api = composePluginApi([openApiHttpPlugin()] as const); @@ -161,7 +162,7 @@ scenario( const internalError = JSON.stringify({ _tag: "InternalError", traceId: "policy-write" }); await step("Open the integration's Tools tab", async () => { - await page.goto(`/integrations/${integration}`, { waitUntil: "networkidle" }); + await visit(page, `/integrations/${integration}`); await page.getByRole("tab", { name: "Tools" }).click(); await sectionFor(alpha).waitFor(); await sectionFor(beta).waitFor(); @@ -288,7 +289,7 @@ scenario( }); await step("Both rules are manageable rows on the Policies page", async () => { - await page.goto("/policies", { waitUntil: "networkidle" }); + await visit(page, "/policies"); await page.getByText(leafPattern, { exact: true }).waitFor(); await page.getByText(categoryPattern, { exact: true }).waitFor(); }); diff --git a/e2e/scenarios/provider-plugins-ui.test.ts b/e2e/scenarios/provider-plugins-ui.test.ts index dd59d5f4b4..add10ccd15 100644 --- a/e2e/scenarios/provider-plugins-ui.test.ts +++ b/e2e/scenarios/provider-plugins-ui.test.ts @@ -3,7 +3,7 @@ import { Effect } from "effect"; import { scenario } from "../src/scenario"; import { Browser, Target } from "../src/services"; -import { clickToReveal } from "../src/surfaces/browser"; +import { clickToReveal, visit } from "../src/surfaces/browser"; scenario( "Provider catalog · Google and Microsoft services are OpenAPI presets", @@ -15,7 +15,7 @@ scenario( yield* browser.session(identity, async ({ page, step }) => { await step("Open the integrations picker", async () => { - await page.goto("/integrations", { waitUntil: "networkidle" }); + await visit(page, "/integrations"); await clickToReveal( page.getByRole("button", { name: "Connect" }), page.getByRole("dialog", { name: "Connect an integration" }), diff --git a/e2e/scripts/record-connect-card.ts b/e2e/scripts/record-connect-card.ts index 8266da9229..0085740077 100644 --- a/e2e/scripts/record-connect-card.ts +++ b/e2e/scripts/record-connect-card.ts @@ -15,6 +15,7 @@ import { randomUUID } from "node:crypto"; import { chromium } from "playwright"; +import { visit } from "../src/surfaces/browser"; const [baseUrl, outPrefix] = process.argv.slice(2); if (!baseUrl || !outPrefix) { @@ -86,7 +87,7 @@ const shoot = async (label: string, javaScriptEnabled: boolean, outPath: string) // the light palette — an artifact no user sees. JS stays disabled for the // SSR shot only to freeze the pre-hydration origin (no client correction), // not to skip CSS. - await page.goto("/", { waitUntil: "networkidle" }); + await visit(page, "/"); // The install command is the thing that flashes; screenshot just its block. const command = page.locator('pre:has-text("add-mcp")').first(); await command.waitFor({ state: "visible", timeout: 15_000 }); diff --git a/e2e/selfhost/admin-users-console.test.ts b/e2e/selfhost/admin-users-console.test.ts index 23959c8d8b..fccdef9251 100644 --- a/e2e/selfhost/admin-users-console.test.ts +++ b/e2e/selfhost/admin-users-console.test.ts @@ -20,6 +20,7 @@ import { AuthTemplateSlug, ConnectionName, IntegrationSlug } from "@executor-js/ import { scenario } from "../src/scenario"; import { createInvitedIdentity } from "../targets/selfhost"; import { Api, Browser, Target } from "../src/services"; +import { visit } from "../src/surfaces/browser"; const api = composePluginApi([openApiHttpPlugin()] as const); type Client = HttpApiClient.ForApi; @@ -107,7 +108,7 @@ scenario( yield* browser.session(owner, async ({ page, step }) => { await step("Open Users from the sidebar as the instance owner", async () => { - await page.goto("/", { waitUntil: "networkidle" }); + await visit(page, "/"); await page .locator("nav") .getByRole("link", { name: "Users" }) @@ -289,7 +290,7 @@ scenario( yield* browser.session(member, async ({ page, step }) => { await step("A plain member is not offered the section", async () => { - await page.goto("/", { waitUntil: "networkidle" }); + await visit(page, "/"); await page .locator("nav") .getByRole("link", { name: "Integrations" }) @@ -302,7 +303,7 @@ scenario( }); await step("And reaching the URL directly is refused, not silently empty", async () => { - await page.goto("/users", { waitUntil: "networkidle" }); + await visit(page, "/users"); await page .getByText("You don't have access to this workspace's users") .waitFor({ state: "visible", timeout: 30_000 }); diff --git a/e2e/selfhost/api-keys-feedback.test.ts b/e2e/selfhost/api-keys-feedback.test.ts index eee5fde723..0f7cecce2d 100644 --- a/e2e/selfhost/api-keys-feedback.test.ts +++ b/e2e/selfhost/api-keys-feedback.test.ts @@ -20,6 +20,7 @@ import { AccountHttpApi } from "@executor-js/api"; import { scenario } from "../src/scenario"; import { Api, Browser, Target } from "../src/services"; +import { visit } from "../src/surfaces/browser"; declare global { interface Window { @@ -39,7 +40,7 @@ scenario( yield* browser.session(identity, async ({ page, step }) => { await step("Land on the dashboard", async () => { - await page.goto("/", { waitUntil: "networkidle" }); + await visit(page, "/"); // The shared shell's main nav lists the workspace sections. await page.locator("nav").getByRole("link", { name: "Integrations" }).first().waitFor(); }); @@ -105,7 +106,7 @@ scenario( }); await step("Open the API keys page", async () => { - await page.goto("/api-keys", { waitUntil: "networkidle" }); + await visit(page, "/api-keys"); await page.getByRole("heading", { name: "API keys", exact: true }).waitFor(); }); @@ -174,7 +175,7 @@ scenario( }); await step("Create a new key", async () => { - await page.goto("/api-keys", { waitUntil: "networkidle" }); + await visit(page, "/api-keys"); await page.getByRole("button", { name: "New key" }).click(); const dialog = page.getByRole("dialog"); await dialog.getByLabel("Name").fill(keyName); diff --git a/e2e/selfhost/auth-methods-ui.test.ts b/e2e/selfhost/auth-methods-ui.test.ts index 6860eeafb0..fd06b0c7c6 100644 --- a/e2e/selfhost/auth-methods-ui.test.ts +++ b/e2e/selfhost/auth-methods-ui.test.ts @@ -23,6 +23,7 @@ import { IntegrationSlug } from "@executor-js/sdk/shared"; import { scenario } from "../src/scenario"; import { Api, Browser, Target } from "../src/services"; +import { visit } from "../src/surfaces/browser"; const api = composePluginApi([mcpHttpPlugin()] as const); @@ -160,9 +161,7 @@ scenario( yield* browser.session(identity, async ({ page, step }) => { await step("Open the add-MCP flow pointed at the server", async () => { - await page.goto(`/integrations/add/mcp?url=${encodeURIComponent(server.endpoint)}`, { - waitUntil: "networkidle", - }); + await visit(page, `/integrations/add/mcp?url=${encodeURIComponent(server.endpoint)}`); // Generous timeout: the debounced probe request can queue behind a // busy dev server under CI load, and there is no clean client-side // re-trigger to poke it mid-flight. @@ -216,9 +215,7 @@ scenario( yield* Effect.gen(function* () { yield* browser.session(identity, async ({ page, step }) => { await step("Open the add-MCP flow pointed at the Slack-shaped server", async () => { - await page.goto(`/integrations/add/mcp?url=${encodeURIComponent(server.endpoint)}`, { - waitUntil: "networkidle", - }); + await visit(page, `/integrations/add/mcp?url=${encodeURIComponent(server.endpoint)}`); await page.getByText("How does this server authenticate?").waitFor({ timeout: 90_000 }); await page.getByText("OAuth metadata is discovered from this server").waitFor(); }); @@ -324,9 +321,7 @@ scenario( yield* Effect.gen(function* () { yield* browser.session(identity, async ({ page, step }) => { await step("Open the integration's connect modal", async () => { - await page.goto(`/integrations/${slug}`, { - waitUntil: "networkidle", - }); + await visit(page, `/integrations/${slug}`); await page.getByRole("button", { name: "Add connection" }).first().click(); await page.getByRole("tab", { name: "OAuth" }).waitFor(); }); @@ -426,9 +421,7 @@ scenario( yield* Effect.gen(function* () { yield* browser.session(identity, async ({ page, step }) => { await step("Open the integration's connect modal", async () => { - await page.goto(`/integrations/${slug}`, { - waitUntil: "networkidle", - }); + await visit(page, `/integrations/${slug}`); await page.getByRole("button", { name: "Add connection" }).first().click(); await page.getByRole("tab", { name: "API key (Authorization)" }).waitFor(); }); diff --git a/e2e/selfhost/connect-modal-credential-ux.test.ts b/e2e/selfhost/connect-modal-credential-ux.test.ts index 1c994cfcee..f1350ee8fb 100644 --- a/e2e/selfhost/connect-modal-credential-ux.test.ts +++ b/e2e/selfhost/connect-modal-credential-ux.test.ts @@ -18,6 +18,7 @@ import { IntegrationSlug } from "@executor-js/sdk/shared"; import { scenario } from "../src/scenario"; import { Api, Browser, Target } from "../src/services"; +import { visit } from "../src/surfaces/browser"; const api = composePluginApi([openApiHttpPlugin()] as const); @@ -63,7 +64,7 @@ scenario( yield* browser.session(identity, async ({ page, step }) => { await step("Open the connect modal", async () => { - await page.goto(`/integrations/${slug}?addAccount=1`, { waitUntil: "networkidle" }); + await visit(page, `/integrations/${slug}?addAccount=1`); await page.getByRole("heading", { name: /Add connection/ }).waitFor(); }); diff --git a/e2e/selfhost/detected-auth-immutable-ui.test.ts b/e2e/selfhost/detected-auth-immutable-ui.test.ts index 2b9bfa795f..4fa7c8ff1e 100644 --- a/e2e/selfhost/detected-auth-immutable-ui.test.ts +++ b/e2e/selfhost/detected-auth-immutable-ui.test.ts @@ -16,6 +16,7 @@ import { OAuthTestServer } from "@executor-js/sdk/testing"; import { scenario } from "../src/scenario"; import { Browser, Target } from "../src/services"; +import { visit } from "../src/surfaces/browser"; const REMOVE_HINT = "Pulled from spec. Remove to override."; @@ -36,9 +37,7 @@ scenario( yield* browser.session(identity, async ({ page, step }) => { await step("Open the add-MCP flow pointed at the OAuth server", async () => { - await page.goto(`/integrations/add/mcp?url=${encodeURIComponent(server.endpoint)}`, { - waitUntil: "networkidle", - }); + await visit(page, `/integrations/add/mcp?url=${encodeURIComponent(server.endpoint)}`); await page.getByText("How does this server authenticate?").waitFor(); await page.getByText("Method 1 · Detected").waitFor(); }); @@ -145,7 +144,7 @@ scenario( yield* browser.session(identity, async ({ page, step }) => { await step("Analyze a spec that declares both API key and OAuth", async () => { - await page.goto(`/integrations/add/openapi`, { waitUntil: "networkidle" }); + await visit(page, `/integrations/add/openapi`); await page .getByPlaceholder(/openapi\.json/i) .first() diff --git a/e2e/selfhost/mcp-auth-required-add.test.ts b/e2e/selfhost/mcp-auth-required-add.test.ts index 836175aa42..dfd6c7dd35 100644 --- a/e2e/selfhost/mcp-auth-required-add.test.ts +++ b/e2e/selfhost/mcp-auth-required-add.test.ts @@ -19,6 +19,7 @@ import { serveTestHttpApp } from "@executor-js/sdk/testing"; import { scenario } from "../src/scenario"; import { Api, Browser, Target } from "../src/services"; +import { visit } from "../src/surfaces/browser"; const api = composePluginApi([mcpHttpPlugin()] as const); @@ -53,9 +54,7 @@ scenario( yield* Effect.gen(function* () { yield* browser.session(identity, async ({ page, step }) => { await step("Open the add-MCP flow pointed at the auth-gated server", async () => { - await page.goto(`/integrations/add/mcp?url=${encodeURIComponent(endpoint)}`, { - waitUntil: "networkidle", - }); + await visit(page, `/integrations/add/mcp?url=${encodeURIComponent(endpoint)}`); // Before the fix this dead-ended on a red "add credentials below" // error with no editor. Now the auth-method editor renders. await page.getByText("How does this server authenticate?").waitFor(); diff --git a/e2e/selfhost/mcp-connect-card-url.test.ts b/e2e/selfhost/mcp-connect-card-url.test.ts index 69c7b22341..99fb59f022 100644 --- a/e2e/selfhost/mcp-connect-card-url.test.ts +++ b/e2e/selfhost/mcp-connect-card-url.test.ts @@ -15,6 +15,7 @@ import { Effect } from "effect"; import { scenario } from "../src/scenario"; import { Browser, Target } from "../src/services"; +import { visit } from "../src/surfaces/browser"; scenario( "MCP · the URL printed by the Connect card is actually served", @@ -28,7 +29,7 @@ scenario( let cardUrl = ""; yield* browser.session(identity, async ({ page, step }) => { await step("Read the MCP URL from the Connect-an-agent card", async () => { - await page.goto("/", { waitUntil: "networkidle" }); + await visit(page, "/"); await page.waitForTimeout(2000); // let the card resolve the server origin // The card renders `npx add-mcp --transport http --name executor`. // The CodeBlock tokenizes it across spans; read the concatenated text. diff --git a/e2e/selfhost/mcp-oauth-consent.test.ts b/e2e/selfhost/mcp-oauth-consent.test.ts index e62a9d281c..1e6023be82 100644 --- a/e2e/selfhost/mcp-oauth-consent.test.ts +++ b/e2e/selfhost/mcp-oauth-consent.test.ts @@ -19,6 +19,7 @@ import { Effect } from "effect"; import { scenario } from "../src/scenario"; import { Browser, Target } from "../src/services"; +import { visit } from "../src/surfaces/browser"; interface AuthServerMetadata { readonly authorization_endpoint: string; @@ -78,7 +79,7 @@ scenario( yield* browser.session(identity, async ({ page, step }) => { await step("A signed-in user is using their Executor instance", async () => { - await page.goto("/", { waitUntil: "networkidle" }); + await visit(page, "/"); // Confirm we're in the app, not bounced to sign-in. expect(new URL(page.url()).pathname, "the session is active (not on /login)").not.toBe( "/login", @@ -91,7 +92,7 @@ scenario( // A connecting MCP client opens this authorize URL. The server forces // prompt=consent, so it stops here on the approval screen instead of // auto-issuing a code. - await page.goto(authorize, { waitUntil: "networkidle" }); + await visit(page, authorize); await page.locator("#mcp-consent-allow").waitFor(); expect( new URL(page.url()).pathname, diff --git a/e2e/selfhost/mcp-oauth-reconnect-health.test.ts b/e2e/selfhost/mcp-oauth-reconnect-health.test.ts index cbb6ff5090..1be7de3909 100644 --- a/e2e/selfhost/mcp-oauth-reconnect-health.test.ts +++ b/e2e/selfhost/mcp-oauth-reconnect-health.test.ts @@ -21,6 +21,7 @@ import { serveOAuthTestServer, type OAuthTestServerShape } from "@executor-js/sd import { scenario } from "../src/scenario"; import { Api, Browser, Target } from "../src/services"; +import { visit } from "../src/surfaces/browser"; const api = composePluginApi([mcpHttpPlugin()] as const); type Client = HttpApiClient.ForApi; @@ -300,7 +301,7 @@ scenario( const menuTrigger = connections.locator('button[aria-haspopup="menu"]').first(); await step("Open the MCP integration with its expired OAuth connection", async () => { - await page.goto(`/integrations/${slug}`, { waitUntil: "networkidle" }); + await visit(page, `/integrations/${slug}`); await connections.getByText("main", { exact: true }).waitFor({ timeout: 30_000 }); }); @@ -380,7 +381,7 @@ scenario( const menuTrigger = connections.locator('button[aria-haspopup="menu"]').first(); await step("Open the integration: the connection reads Expired", async () => { - await page.goto(`/integrations/${slug}`, { waitUntil: "networkidle" }); + await visit(page, `/integrations/${slug}`); await connections.getByText("main", { exact: true }).waitFor({ timeout: 30_000 }); await connections.getByLabel("Status: Expired").waitFor({ timeout: 30_000 }); }); @@ -451,7 +452,7 @@ scenario( }); await step("Open the MCP integration with its DCR OAuth connection", async () => { - await page.goto(`/integrations/${slug}`, { waitUntil: "networkidle" }); + await visit(page, `/integrations/${slug}`); await connections.getByText("main", { exact: true }).waitFor({ timeout: 30_000 }); }); diff --git a/e2e/selfhost/mcp-oauth-slow-connect.test.ts b/e2e/selfhost/mcp-oauth-slow-connect.test.ts index 6a7b7fd9d5..56422ba598 100644 --- a/e2e/selfhost/mcp-oauth-slow-connect.test.ts +++ b/e2e/selfhost/mcp-oauth-slow-connect.test.ts @@ -35,6 +35,7 @@ import { OAuthTestServer } from "@executor-js/sdk/testing"; import { scenario } from "../src/scenario"; import { Api, Browser, Target } from "../src/services"; +import { visit } from "../src/surfaces/browser"; const api = composePluginApi([mcpHttpPlugin()] as const); @@ -68,7 +69,7 @@ scenario( await step("Add an OAuth-protected MCP integration", async () => { const addUrl = new URL("/integrations/add/mcp", target.baseUrl); addUrl.searchParams.set("url", server.endpoint); - await page.goto(addUrl.toString(), { waitUntil: "networkidle" }); + await visit(page, addUrl.toString()); await page.getByText("How does this server authenticate?").waitFor({ timeout: 30_000 }); await page.getByPlaceholder("e.g. Linear").fill(displayName); await page.getByRole("button", { name: "Add integration" }).click(); diff --git a/e2e/selfhost/mcp-opencode-consent.test.ts b/e2e/selfhost/mcp-opencode-consent.test.ts index ceda537798..9461189d84 100644 --- a/e2e/selfhost/mcp-opencode-consent.test.ts +++ b/e2e/selfhost/mcp-opencode-consent.test.ts @@ -22,6 +22,7 @@ import { Effect } from "effect"; import { scenario } from "../src/scenario"; import { enterFocus, markRecordingStart } from "../src/timeline"; import { Browser, Cli, Mcp, OpenCode, RunDir, Target } from "../src/services"; +import { visit } from "../src/surfaces/browser"; const SERVER_NAME = "executor"; @@ -119,7 +120,7 @@ scenario( await step("OpenCode asks to connect — the approval screen", async () => { // Authenticated (owner cookies) → authorize forces prompt=consent → // the approval screen. - await page.goto(authorizeUrl, { waitUntil: "networkidle" }); + await visit(page, authorizeUrl); await page.locator("#mcp-consent-allow").waitFor({ timeout: 30_000 }); expect(new URL(page.url()).pathname, "lands on the approval screen").toBe( "/mcp-consent", diff --git a/e2e/selfhost/oauth-app-modal.test.ts b/e2e/selfhost/oauth-app-modal.test.ts index d699d6c501..a4ec6b7371 100644 --- a/e2e/selfhost/oauth-app-modal.test.ts +++ b/e2e/selfhost/oauth-app-modal.test.ts @@ -16,6 +16,7 @@ import { openApiHttpPlugin } from "@executor-js/plugin-openapi/api"; import { scenario } from "../src/scenario"; import { Api, Browser, Target } from "../src/services"; +import { visit } from "../src/surfaces/browser"; const api = composePluginApi([openApiHttpPlugin()] as const); @@ -81,7 +82,7 @@ scenario( const actions = page.getByRole("button", { name: `Actions for ${appName}` }); await step("Open the integration and start a new connection", async () => { - await page.goto(`/integrations/${integration}`, { waitUntil: "networkidle" }); + await visit(page, `/integrations/${integration}`); await page.getByRole("button", { name: "Add connection" }).click(); // OAuth2 is the integration's only method, so the modal opens on // the OAuth app step with nothing registered yet. (`exact` avoids diff --git a/e2e/selfhost/oauth-callback-unauthenticated.test.ts b/e2e/selfhost/oauth-callback-unauthenticated.test.ts index ae43ffb837..72126a33fc 100644 --- a/e2e/selfhost/oauth-callback-unauthenticated.test.ts +++ b/e2e/selfhost/oauth-callback-unauthenticated.test.ts @@ -14,6 +14,7 @@ import { serveOAuthTestServer } from "@executor-js/sdk/testing"; import { scenario } from "../src/scenario"; import { Api, Browser, Target } from "../src/services"; +import { visit } from "../src/surfaces/browser"; const api = composePluginApi([openApiHttpPlugin()] as const); @@ -111,7 +112,7 @@ scenario( yield* browser.session({ label: "anonymous" }, async ({ page, step }) => { await step("Provider sends a signed-out browser to the OAuth callback", async () => { - const response = await page.goto(callbackPath, { waitUntil: "networkidle" }); + const response = await visit(page, callbackPath); expect(response?.status(), "the callback redirects into the login flow").toBe(200); await page.getByRole("heading", { name: "Sign in" }).waitFor(); }); diff --git a/e2e/selfhost/posthog-mcp-oauth.test.ts b/e2e/selfhost/posthog-mcp-oauth.test.ts index 49d20349a9..e6c3efe854 100644 --- a/e2e/selfhost/posthog-mcp-oauth.test.ts +++ b/e2e/selfhost/posthog-mcp-oauth.test.ts @@ -22,6 +22,7 @@ import { OAuthTestServer } from "@executor-js/sdk/testing"; import { scenario } from "../src/scenario"; import { Api, Browser, Target } from "../src/services"; +import { visit } from "../src/surfaces/browser"; const api = composePluginApi([mcpHttpPlugin()] as const); @@ -48,7 +49,7 @@ scenario( await step("Open the add-MCP flow pointed at the OAuth server", async () => { const addUrl = new URL("/integrations/add/mcp", target.baseUrl); addUrl.searchParams.set("url", server.endpoint); - await page.goto(addUrl.toString(), { waitUntil: "networkidle" }); + await visit(page, addUrl.toString()); await page.getByText("How does this server authenticate?").waitFor({ timeout: 30_000 }); await page.getByText("Method 1 · Detected").waitFor(); await page.getByText("OAuth metadata is discovered from this server").waitFor(); diff --git a/e2e/src/emulator-instance.ts b/e2e/src/emulator-instance.ts index 6d5174b206..d17ebc7117 100644 --- a/e2e/src/emulator-instance.ts +++ b/e2e/src/emulator-instance.ts @@ -1,20 +1,71 @@ -import { Effect } from "effect"; +import { Effect, Schedule } from "effect"; + +/** The suite could not get an instance out of the hosted control plane. */ +export class EmulatorInstanceError extends Error { + readonly _tag = "EmulatorInstanceError"; + + constructor( + readonly service: string, + readonly reason: string, + ) { + super(`${service} emulator instance creation failed: ${reason}`); + this.name = "EmulatorInstanceError"; + } +} + +// Bound each attempt: a hung connection to the edge must not eat the +// scenario's whole timeout before the first retry. +const ATTEMPT_TIMEOUT = "10 seconds"; +const RETRIES = 3; + +const requestInstance = (service: string, label: string) => + Effect.tryPromise({ + try: async (): Promise => { + const response = await fetch(`https://${service}.emulators.dev/_emulate/instances`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ instance: label }), + }); + if (!response.ok) { + throw new EmulatorInstanceError(service, `HTTP ${response.status}`); + } + const instance = (await response.json()) as { readonly providerBaseUrl: string }; + return instance.providerBaseUrl; + }, + catch: (cause) => + cause instanceof EmulatorInstanceError + ? cause + : new EmulatorInstanceError(service, String(cause)), + }); // Hosted service hosts (e.g. resend.emulators.dev) are control plane only — // there is no shared default instance behind them. Every scenario creates its // own isolated instance and works against the returned providerBaseUrl, which // also keeps ledger assertions free of cross-run pollution. The server // generates an unguessable instance name; the label is a readable prefix. -export const createEmulatorInstance = (service: string, label = "e2e") => - Effect.promise(async () => { - const response = await fetch(`https://${service}.emulators.dev/_emulate/instances`, { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ instance: label }), - }); - if (!response.ok) { - throw new Error(`${service} emulator instance creation failed: ${response.status}`); - } - const instance = (await response.json()) as { readonly providerBaseUrl: string }; - return instance.providerBaseUrl; - }); +// +// This is also the one request in a scenario that leaves the runner, so it is +// the one place where a CI runner's transient network trouble fails a scenario +// that has nothing to do with the network: `connect ETIMEDOUT` reaching the +// edge, plus the occasional bare 502, accounted for 17 shard failures in the +// two weeks to 2026-08-20. Asking for an instance is idempotent (a spare +// instance is nobody's business but the control plane's), so bound the attempt +// and retry with backoff. Nothing below this line retries anything the +// scenario is actually asserting on. +export const createEmulatorInstance = (service: string, label = "e2e"): Effect.Effect => + requestInstance(service, label).pipe( + Effect.timeoutOrElse({ + duration: ATTEMPT_TIMEOUT, + orElse: () => + Effect.fail(new EmulatorInstanceError(service, `no response in ${ATTEMPT_TIMEOUT}`)), + }), + Effect.retry( + Schedule.both( + Schedule.exponential("500 millis").pipe(Schedule.jittered), + Schedule.recurs(RETRIES), + ), + ), + // An emulator the suite cannot reach at all is a defect in the run, not a + // product failure the scenario should be asked to model. + Effect.orDie, + ); diff --git a/e2e/src/surfaces/browser.ts b/e2e/src/surfaces/browser.ts index 1ac33e305c..6510f77d9a 100644 --- a/e2e/src/surfaces/browser.ts +++ b/e2e/src/surfaces/browser.ts @@ -9,7 +9,7 @@ import { join } from "node:path"; import { promisify } from "node:util"; import { Effect } from "effect"; -import { chromium, type Locator, type Page } from "playwright"; +import { chromium, type Locator, type Page, type Response } from "playwright"; import { beat, enterFocus, markNavigation, markRecordingStart } from "../timeline"; import { appendTraces, type TraceEntry } from "../trace-harvest"; @@ -36,10 +36,103 @@ const slug = (text: string): string => .replace(/^-+|-+$/g, "") .slice(0, 60); +// How long a navigation may wait for the network to go quiet before giving up +// on quiet and letting the scenario's own assertion decide. Long enough to +// cover a cold vite compile's request burst, short enough that a page which +// never goes quiet costs seconds instead of the 30s navigation timeout. +const SETTLE_TIMEOUT_MS = 5_000; + +/** + * Wait for the network to go quiet, but only for `SETTLE_TIMEOUT_MS`. + * + * Playwright's `networkidle` — 500ms with zero in-flight requests — cannot be + * a hard gate in this suite, because the suite itself keeps the network busy: + * every browser session exports OTel spans to the run's motel through + * packages/react's `OtlpTracer` on a one-second interval (wired by + * `setup/motel.ts` via VITE_PUBLIC_OTLP_TRACES_URL). A page that keeps + * producing spans — a console retrying an org-scoped query that 403s, say — + * feeds that exporter indefinitely, so the 500ms window never opens and the + * wait burns its full timeout on a page that is, visibly, completely loaded. + * + * The quiet is worth waiting for when it comes (it usually arrives in + * milliseconds, and it lets a step's screenshot catch a settled page), so keep + * it — bounded. Readiness is asserted by whatever the scenario does next: + * Playwright's locators auto-wait, so `getByRole(...).waitFor()` is a real, + * page-specific readiness signal where `networkidle` was only ever a proxy. + */ +export const settle = async (page: Page): Promise => { + await page + .waitForLoadState("networkidle", { timeout: SETTLE_TIMEOUT_MS }) + // oxlint-disable-next-line executor/no-promise-catch -- boundary: quiet is best-effort, the scenario's next assertion is the gate + .catch(() => {}); +}; + +/** + * Wait until React owns the document — the readiness `networkidle` was standing + * in for, asked directly. + * + * React DOM stamps `__reactContainer$` on the element it roots at the + * moment `hydrateRoot` runs, so its presence says the SSR markup is under a + * live root rather than inert HTML. That matters because it is the line either + * side of which an interaction has a different meaning: before it, a click or + * a `fill` goes to markup nobody is listening to and is lost without a trace; + * after it, React records the event and replays it as hydration reaches that + * subtree. Locators can't see the difference — the element is visible and + * enabled either way — which is why a swallowed interaction surfaces 20 + * seconds later as "the thing it should have opened never appeared". + * + * Best-effort, like `settle`: plenty of pages the suite visits are not this + * app at all (a provider's authorize screen, an OAuth callback result), and + * they are none the worse for having no React root. + */ +const HYDRATION_TIMEOUT_MS = 10_000; + +export const hydrated = async (page: Page): Promise => { + await page + .waitForFunction( + () => + [...document.body.children].some((element) => + Object.keys(element).some((key) => key.startsWith("__reactContainer$")), + ), + undefined, + { timeout: HYDRATION_TIMEOUT_MS }, + ) + // oxlint-disable-next-line executor/no-promise-catch -- boundary: a page with no React root is a legitimate destination + .catch(() => {}); +}; + +/** + * Navigate, then wait for the page to be interactive. The drop-in for a `goto` + * that waited on `networkidle`, and a better one: it waits for React to take + * the document (`hydrated`) and for the network to go quiet (`settle`), both + * bounded, instead of one unbounded wait that proved neither. `timeout` bounds + * the navigation itself, exactly as `goto`'s does. + */ +export const visit = async ( + page: Page, + url: string, + options: { readonly timeout?: number } = {}, +): Promise => { + const response = await page.goto(url, { waitUntil: "load", ...options }); + await hydrated(page); + await settle(page); + return response; +}; + +/** `visit` for a reload — the drop-in for `reload({ waitUntil: "networkidle" })`. */ +export const revisit = async ( + page: Page, + options: { readonly timeout?: number } = {}, +): Promise => { + await page.reload({ waitUntil: "load", ...options }); + await hydrated(page); + await settle(page); +}; + /** * Click `trigger` until `revealed` is visible. * - * `waitUntil: "networkidle"` does not mean the console has hydrated: a click + * A settled network does not mean the console has hydrated: a click * that lands between the SSR paint and React attaching the handler is * swallowed without a trace, and whatever the click was meant to open never * appears (the "Connect an integration" dialog no-show flake). Re-clicking a diff --git a/e2e/src/update-card-render.ts b/e2e/src/update-card-render.ts index 4046ab5353..66c4ef3fd0 100644 --- a/e2e/src/update-card-render.ts +++ b/e2e/src/update-card-render.ts @@ -10,6 +10,7 @@ import { Effect } from "effect"; import { scenario } from "./scenario"; import { Browser, Target } from "./services"; +import { visit } from "./surfaces/browser"; export const FORCED_LATEST = "99.0.0"; @@ -82,7 +83,7 @@ export const registerUpdateCardCurrentScenario = (name: string): void => ); await step("Open the console", async () => { - await page.goto("/", { waitUntil: "networkidle" }); + await visit(page, "/"); await page.getByRole("heading", { name: "Integrations" }).waitFor({ timeout: 60_000 }); });