diff --git a/e2e/cloud/admin-users-console.test.ts b/e2e/cloud/admin-users-console.test.ts index b30fcb8d8..d5eaa0521 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 63210d893..30efb17cd 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 ba64ae461..453a7bfd1 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 0c5e906c7..12202df77 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 2b33bb82a..8e816ced6 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 8093cc24d..8701865e4 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 1c931e3bf..7c2e158f1 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 02eb39e4c..26be5910a 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 470bb9a01..e465c1396 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 afe021474..66f1f5041 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 b1998668f..84de5e0a7 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 fae9f61e6..828dcb9af 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 f18a6fc8c..bb3f9f187 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 65a351ff2..6912fce1c 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 3bd8b2b25..dcdb57093 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 27c8a967a..75125828b 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 04aee48d3..c50e024d2 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 eaf1aa998..62f77bd4e 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 993228eb1..141d61dda 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 f3e08643e..a468f5897 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 29f4be3e9..fa9a82d03 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 058fbd77f..224dd11f2 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 373631665..63d0d020a 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 496139219..6e296a8f9 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 e91feeca3..055baf44e 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 d4cc80692..e9f7696cd 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 52877548f..b0c8ab814 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 5017f23d3..8445f421b 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 f0473784a..f162163a1 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 6dc532ac5..8cdb602a1 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 8b5788493..463f4e8bd 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 185ec3665..b0ca487fb 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 10ae01dca..ce74cd9d6 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 780ed83eb..bda26e2cd 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 f2012b7e3..ff78f6fcf 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 258ce3276..a9575c2e7 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 3f963a9da..839c48385 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 42fbd9047..dd38630d0 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 0451f7e89..256434ad8 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 a39b021f4..3ba129bee 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 cc400cd0a..6e42865ea 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 dd59d5f4b..add10ccd1 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 8266da922..008574007 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 23959c8d8..fccdef925 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 eee5fde72..0f7cecce2 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 6860eeafb..fd06b0c7c 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 1c994cfce..f1350ee8f 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 2b9bfa795..4fa7c8ff1 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 836175aa4..dfd6c7dd3 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 69c7b2234..99fb59f02 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 e62a9d281..1e6023be8 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 cbb6ff509..1be7de390 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 6a7b7fd9d..56422ba59 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 ceda53779..9461189d8 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 d699d6c50..a4ec6b737 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 ae43ffb83..72126a33f 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 49d20349a..e6c3efe85 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 6d5174b20..d17ebc711 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 1ac33e305..6510f77d9 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 4046ab535..66c4ef3fd 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 }); });