History

Stop CI failing on waits that were never about the product (#1707)

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.

Rhys Sullivan committed 2 months ago

Commit 91ee3db1 parent 8f2047760 files · +372 −187Browse files

Unified · Split

60 files
  1. e2e/cloud/admin-users-console.test.ts +4 −3
  2. e2e/cloud/auth-routing-flow.test.ts +2 −1
  3. e2e/cloud/billing-trial-checkout-stale.test.ts +3 −2
  4. e2e/cloud/connect-panel.test.ts +4 −3
  5. e2e/cloud/connection-modal-oauth-abandon.test.ts +3 −2
  6. e2e/cloud/logout-stale-session.test.ts +2 −1
  7. e2e/cloud/mcp-browser-approval-org-scope.test.ts +4 −3
  8. e2e/cloud/mcp-browser-resume-page.test.ts +2 −1
  9. e2e/cloud/member-invite-seat-limit.test.ts +3 −2
  10. e2e/cloud/oauth-callback-org-scope.test.ts +5 −6
  11. e2e/cloud/onboarding-mcp-url.test.ts +2 −1
  12. e2e/cloud/org-api-keys-console.test.ts +4 −3
  13. e2e/cloud/org-delete.test.ts +4 −3
  14. e2e/cloud/org-last-visited.test.ts +4 −3
  15. e2e/cloud/org-limit.test.ts +3 −2
  16. e2e/cloud/org-multitab-cookie.test.ts +4 −3
  17. e2e/cloud/org-slug-foreign.test.ts +3 −2
  18. e2e/cloud/org-switcher.test.ts +4 −3
  19. e2e/scenarios/artifact-loading-surface.test.ts +2 −1
  20. e2e/scenarios/artifact-preview-gallery.test.ts +4 −3
  21. e2e/scenarios/artifacts.test.ts +5 −4
  22. e2e/scenarios/auth-methods-ui.test.ts +2 −3
  23. e2e/scenarios/browser-approval.test.ts +2 −1
  24. e2e/scenarios/connect-deep-link.test.ts +4 −3
  25. e2e/scenarios/connect-handoff-session.test.ts +2 −1
  26. e2e/scenarios/connect-handoff.test.ts +2 −1
  27. e2e/scenarios/connection-remove-confirm.test.ts +2 −1
  28. e2e/scenarios/docs-link.test.ts +2 −1
  29. e2e/scenarios/first-party-oauth.test.ts +2 −2
  30. e2e/scenarios/google-health-checks.test.ts +33 −31
  31. e2e/scenarios/google-photos-preset-ui.test.ts +2 −2
  32. e2e/scenarios/graphql-introspection-health.test.ts +11 −5
  33. e2e/scenarios/health-checks-ui.test.ts +12 −11
  34. e2e/scenarios/mcp-catalog-sync-ui.test.ts +3 −4
  35. e2e/scenarios/oauth-callback-url.test.ts +2 −3
  36. e2e/scenarios/oauth-client-handoff.test.ts +2 −1
  37. e2e/scenarios/openapi-add-integration-action-bar.test.ts +2 −1
  38. e2e/scenarios/openapi-multi-key-auth-ui.test.ts +2 −1
  39. e2e/scenarios/openapi-server-selection-ui.test.ts +2 −1
  40. e2e/scenarios/org-slug-routing.test.ts +4 −3
  41. e2e/scenarios/policies-ui.test.ts +3 −2
  42. e2e/scenarios/provider-plugins-ui.test.ts +2 −2
  43. e2e/scripts/record-connect-card.ts +2 −1
  44. e2e/selfhost/admin-users-console.test.ts +4 −3
  45. e2e/selfhost/api-keys-feedback.test.ts +4 −3
  46. e2e/selfhost/auth-methods-ui.test.ts +5 −12
  47. e2e/selfhost/connect-modal-credential-ux.test.ts +2 −1
  48. e2e/selfhost/detected-auth-immutable-ui.test.ts +3 −4
  49. e2e/selfhost/mcp-auth-required-add.test.ts +2 −3
  50. e2e/selfhost/mcp-connect-card-url.test.ts +2 −1
  51. e2e/selfhost/mcp-oauth-consent.test.ts +3 −2
  52. e2e/selfhost/mcp-oauth-reconnect-health.test.ts +4 −3
  53. e2e/selfhost/mcp-oauth-slow-connect.test.ts +2 −1
  54. e2e/selfhost/mcp-opencode-consent.test.ts +2 −1
  55. e2e/selfhost/oauth-app-modal.test.ts +2 −1
  56. e2e/selfhost/oauth-callback-unauthenticated.test.ts +2 −1
  57. e2e/selfhost/posthog-mcp-oauth.test.ts +2 −1
  58. e2e/src/emulator-instance.ts +65 −14
  59. e2e/src/surfaces/browser.ts +95 −2
  60. e2e/src/update-card-render.ts +2 −1
e2e/cloud/admin-users-console.test.ts+4−3
@@ -24,6 +24,7 @@ import { AuthTemplateSlug, ConnectionName, IntegrationSlug } from "@executor-js/ · 23 unmodified lines
2424 import { scenario } from "../src/scenario";
2525 import { Api, Browser, Target } from "../src/services";
2626 import { accountIdOf, forBrowser, joinOrg } from "./support/session";
27+import { visit } from "../src/surfaces/browser";
2728
2829 const api = composePluginApi([openApiHttpPlugin()] as const);
2930 type Client = HttpApiClient.ForApi<typeof api>;
@@ -126,7 +127,7 @@ scenario( · 96 unmodified lines
126127 let slug = "";
127128
128129 await step("Land in the workspace and canonicalize onto the org slug", async () => {
129− await page.goto("/", { waitUntil: "networkidle" });
130+ await visit(page, "/");
130131 await page.waitForURL((url) => /^\/[a-z0-9-]+\/?$/.test(url.pathname), {
131132 timeout: 30_000,
132133 });
@@ -323,7 +324,7 @@ scenario( · 190 unmodified lines
323324 let slug = "";
324325
325326 await step("Sign in as a plain member of the same workspace", async () => {
326− await page.goto("/", { waitUntil: "networkidle" });
327+ await visit(page, "/");
327328 await page.waitForURL((url) => /^\/[a-z0-9-]+\/?$/.test(url.pathname), {
328329 timeout: 30_000,
329330 });
@@ -345,7 +346,7 @@ scenario( · 15 unmodified lines
345346 await step(
346347 "Reaching the URL directly says no access, not an empty workspace",
347348 async () => {
348− await page.goto(`/${slug}/users`, { waitUntil: "networkidle" });
349+ await visit(page, `/${slug}/users`);
349350 // The denial is the assertion: an empty table here would misreport
350351 // a populated workspace as having no users.
351352 await page
e2e/cloud/auth-routing-flow.test.ts+2−1
@@ -23,6 +23,7 @@ import { Effect } from "effect"; · 22 unmodified lines
2323
2424 import { scenario } from "../src/scenario";
2525 import { Browser, Target } from "../src/services";
26+import { settle } from "../src/surfaces/browser";
2627
2728 scenario(
2829 "Auth routing · signed out → login → onboarding → dashboard, one browser session",
@@ -58,7 +59,7 @@ scenario( · 29 unmodified lines
5859 await step("Create the first org → canonical dashboard at /<slug>", async () => {
5960 const orgName = "Flow Test Org";
6061 const orgNameInput = page.getByPlaceholder("Northwind Labs");
61− await page.waitForLoadState("networkidle");
62+ await settle(page);
6263 await orgNameInput.fill(orgName);
6364 await page.waitForTimeout(250);
6465 if ((await orgNameInput.inputValue()) !== orgName) {
e2e/cloud/billing-trial-checkout-stale.test.ts+3−2
@@ -20,6 +20,7 @@ import { Effect } from "effect"; · 19 unmodified lines
2020
2121 import { scenario } from "../src/scenario";
2222 import { Autumn, Billing, Browser, Target } from "../src/services";
23+import { visit } from "../src/surfaces/browser";
2324
2425 scenario(
2526 "Billing · completing the trial checkout shows the new plan without a reload",
@@ -51,12 +52,12 @@ scenario( · 25 unmodified lines
5152 // "/undefined/billing/plans" — the billing fetches fire under the bogus
5253 // slug, fail, and are never refetched (autumn-js staleTime 60s), leaving
5354 // the plans grid empty forever.
54− await page.goto("/", { waitUntil: "networkidle" });
55+ await visit(page, "/");
5556 await page.waitForURL((url) => /^\/[a-z0-9-]+\/?$/.test(url.pathname), {
5657 timeout: 30_000,
5758 });
5859 const slug = new URL(page.url()).pathname.split("/").filter(Boolean)[0];
59− await page.goto(`/${slug}/billing/plans`, { waitUntil: "networkidle" });
60+ await visit(page, `/${slug}/billing/plans`);
6061 await page.getByRole("heading", { name: "Choose a plan" }).waitFor();
6162 await startTrial.waitFor();
6263 });
e2e/cloud/connect-panel.test.ts+4−3
@@ -6,6 +6,7 @@ import { Effect } from "effect"; · 5 unmodified lines
66
77 import { scenario } from "../src/scenario";
88 import { Browser, Target } from "../src/services";
9+import { visit, settle } from "../src/surfaces/browser";
910
1011 scenario(
1112 "Connect · the agent-connect panel gives working copy for both transports",
@@ -17,7 +18,7 @@ scenario( · 5 unmodified lines
1718
1819 yield* browser.session(identity, async ({ page, step }) => {
1920 await step("Open the Integrations page", async () => {
20− await page.goto("/", { waitUntil: "networkidle" });
21+ await visit(page, "/");
2122 await page.getByText("Connect an agent").first().waitFor();
2223 });
2324
@@ -35,7 +36,7 @@ scenario( · 11 unmodified lines
3536
3637 await step("Switch to Standard I/O", async () => {
3738 await page.getByRole("tab", { name: "Standard I/O" }).click();
38− await page.waitForLoadState("networkidle");
39+ await settle(page);
3940 });
4041 const stdioCommand = await command();
4142 expect(stdioCommand, "the command changed for stdio").not.toBe(httpCommand);
@@ -45,7 +46,7 @@ scenario( · 3 unmodified lines
4546
4647 await step("Switch back to Remote HTTP", async () => {
4748 await page.getByRole("tab", { name: "Remote HTTP" }).click();
48− await page.waitForLoadState("networkidle");
49+ await settle(page);
4950 });
5051 expect(await command(), "the HTTP command is restored").toContain("--transport http");
5152 });
e2e/cloud/connection-modal-oauth-abandon.test.ts+3−2
@@ -21,6 +21,7 @@ import { serveOAuthTestServer } from "@executor-js/sdk/testing"; · 20 unmodified lines
2121
2222 import { scenario } from "../src/scenario";
2323 import { Api, Browser, Target } from "../src/services";
24+import { visit, settle } from "../src/surfaces/browser";
2425
2526 const api = composePluginApi([openApiHttpPlugin()] as const);
2627
@@ -98,7 +99,7 @@ scenario( · 71 unmodified lines
9899 const connecting = dialog.getByRole("button", { name: "Connecting…" });
99100
100101 await step("Open the integration and start a new connection", async () => {
101− await page.goto(`/integrations/${integration}`, { waitUntil: "networkidle" });
102+ await visit(page, `/integrations/${integration}`);
102103 await addConnection.click();
103104 // The registered app is auto-selected, so the OAuth connect button is
104105 // present and enabled.
@@ -133,7 +134,7 @@ scenario( · 28 unmodified lines
133134 async () => {
134135 await addConnection.click();
135136 await dialog.waitFor({ state: "visible", timeout: 15_000 });
136− await page.waitForLoadState("networkidle");
137+ await settle(page);
137138
138139 // The guarantee: the reopened modal is reset. Before the fix it stays
139140 // wedged on "Connecting…" (the abandoned flow's busy state survived the
e2e/cloud/logout-stale-session.test.ts+2−1
@@ -19,6 +19,7 @@ import type { Page } from "playwright"; · 18 unmodified lines
1919
2020 import { scenario } from "../src/scenario";
2121 import { Api, Browser, Target } from "../src/services";
22+import { settle } from "../src/surfaces/browser";
2223
2324 /** The display-only identity cookie the SSR gate mints (non-HttpOnly). */
2425 const HINT_COOKIE = "executor-auth-hint";
@@ -145,7 +146,7 @@ scenario( · 120 unmodified lines
145146 await page.waitForURL((url) => url.toString() !== before, {
146147 timeout: 15_000,
147148 });
148− await page.waitForLoadState("networkidle");
149+ await settle(page);
149150 });
150151
151152 const shown = (await page.locator("body").innerText()).trim();
e2e/cloud/mcp-browser-approval-org-scope.test.ts+4−3
@@ -14,6 +14,7 @@ import { composePluginApi } from "@executor-js/api/server"; · 13 unmodified lines
1414 import { scenario } from "../src/scenario";
1515 import { Api, Browser, Mcp, Target } from "../src/services";
1616 import { type McpBrowserApproval, parseBrowserApproval } from "../src/surfaces/mcp";
17+import { visit } from "../src/surfaces/browser";
1718
1819 const coreApi = composePluginApi([] as const);
1920
@@ -168,13 +169,13 @@ scenario( · 148 unmodified lines
168169 mcpSession.awaitResume(approval.executionId),
169170 browser.session(identity, async ({ page, step }) => {
170171 await step("Land in the original organization", async () => {
171− await page.goto(`/${orgA.slug}`, { waitUntil: "networkidle" });
172+ await visit(page, `/${orgA.slug}`);
172173 await expectOrgShell(page, orgA);
173174 });
174175
175176 await step("The browser session is switched to another organization", async () => {
176177 await setWorkosSessionCookie(page, target.baseUrl, sessionB);
177− await page.goto(`/${orgB.slug}`, { waitUntil: "networkidle" });
178+ await visit(page, `/${orgB.slug}`);
178179 await expectOrgShell(page, orgB);
179180 });
180181
@@ -182,7 +183,7 @@ scenario( · 1 unmodified line
182183 const loadRequest = page.waitForRequest(approvalApiRequest(approval, "GET"), {
183184 timeout: 30_000,
184185 });
185− await page.goto(approval.approvalUrl, { waitUntil: "networkidle" });
186+ await visit(page, approval.approvalUrl);
186187 expect(
187188 (await loadRequest).headers()["x-executor-organization"],
188189 "loading the approval page scopes the paused execution lookup to org A",
e2e/cloud/mcp-browser-resume-page.test.ts+2−1
@@ -18,6 +18,7 @@ import { scenario } from "../src/scenario"; · 17 unmodified lines
1818 import { Api, Browser, Mcp, Target } from "../src/services";
1919 import { parseBrowserApproval } from "../src/surfaces/mcp";
2020 import type { Identity } from "../src/target";
21+import { visit } from "../src/surfaces/browser";
2122
2223 const coreApi = composePluginApi([] as const);
2324
@@ -224,7 +225,7 @@ scenario( · 200 unmodified lines
224225 ),
225226 browser.session(identity, async ({ page, step }) => {
226227 await step("Open the paused execution approval page", async () => {
227− await page.goto(pathWithSearch(approval.approvalUrl), { waitUntil: "networkidle" });
228+ await visit(page, pathWithSearch(approval.approvalUrl));
228229 await page.getByText("User approval required").waitFor();
229230 });
230231
e2e/cloud/member-invite-seat-limit.test.ts+3−2
@@ -23,6 +23,7 @@ import { AccountHttpApi } from "@executor-js/api"; · 22 unmodified lines
2323
2424 import { scenario } from "../src/scenario";
2525 import { Api, Billing, Browser, Target } from "../src/services";
26+import { visit } from "../src/surfaces/browser";
2627
2728 // apps/cloud/src/extensions/billing/plans.ts → MEMBER_LIMITS.free
2829 const FREE_MEMBER_SEATS = 3;
@@ -45,7 +46,7 @@ scenario( · 16 unmodified lines
4546 let slug = "";
4647
4748 await step("Land in the app and canonicalize onto the org slug", async () => {
48− await page.goto("/", { waitUntil: "networkidle" });
49+ await visit(page, "/");
4950 await page.waitForURL((url) => /^\/[a-z0-9-]+\/?$/.test(url.pathname), {
5051 timeout: 30_000,
5152 });
@@ -54,7 +55,7 @@ scenario( · 2 unmodified lines
5455 });
5556
5657 await step("Open the organization members page", async () => {
57− await page.goto(`/${slug}/org`, { waitUntil: "networkidle" });
58+ await visit(page, `/${slug}/org`);
5859 await page.getByRole("button", { name: "Invite member" }).waitFor();
5960 });
6061
e2e/cloud/oauth-callback-org-scope.test.ts+5−6
@@ -17,6 +17,7 @@ import { serveOAuthTestServer } from "@executor-js/sdk/testing"; · 16 unmodified lines
1717 import { scenario } from "../src/scenario";
1818 import { Api, Browser, Target } from "../src/services";
1919 import type { Identity } from "../src/target";
20+import { visit } from "../src/surfaces/browser";
2021
2122 const api = composePluginApi([openApiHttpPlugin()] as const);
2223
@@ -212,13 +213,13 @@ scenario( · 189 unmodified lines
212213 await installSameWindowOAuthPopup(page);
213214
214215 await step("Land in the original organization", async () => {
215− await page.goto(`/${orgA.slug}`, { waitUntil: "networkidle" });
216+ await visit(page, `/${orgA.slug}`);
216217 await expectOrgShell(page, orgA);
217218 });
218219
219220 await step("The browser session is switched to another organization", async () => {
220221 await setWorkosSessionCookie(page, target.baseUrl, sessionB);
221− await page.goto(`/${orgB.slug}`, { waitUntil: "networkidle" });
222+ await visit(page, `/${orgB.slug}`);
222223 await expectOrgShell(page, orgB);
223224 });
224225
@@ -232,9 +233,7 @@ scenario( · 7 unmodified lines
232233 // refetch, and leave "Connect with OAuth" disabled forever (the flake
233234 // this step used to have). Waiting for org A's shell before opening
234235 // the modal makes every modal fetch run under the settled org A scope.
235− await page.goto(`/${orgA.slug}/integrations/${String(integration)}`, {
236− waitUntil: "networkidle",
237− });
236+ await visit(page, `/${orgA.slug}/integrations/${String(integration)}`);
238237 await page.getByRole("button", { name: new RegExp(escapeRegExp(orgA.name)) }).waitFor({
239238 timeout: 30_000,
240239 });
@@ -286,7 +285,7 @@ scenario( · 45 unmodified lines
286285 await step("The provider returns to the OAuth callback", async () => {
287286 const callbackUrl = await submitProviderLoginFromPage(page);
288287 callback = new URL(callbackUrl);
289− const response = await page.goto(callbackUrl, { waitUntil: "networkidle" });
288+ const response = await visit(page, callbackUrl);
290289 expect(response?.status(), "the callback renders its popup result page").toBe(200);
291290 });
292291
e2e/cloud/onboarding-mcp-url.test.ts+2−1
@@ -6,6 +6,7 @@ import { Effect } from "effect"; · 5 unmodified lines
66
77 import { scenario } from "../src/scenario";
88 import { Browser, Target } from "../src/services";
9+import { visit } from "../src/surfaces/browser";
910
1011 scenario(
1112 "Onboarding · the MCP setup step hands the user their org-scoped MCP server URL",
@@ -19,7 +20,7 @@ scenario( · 7 unmodified lines
1920 await step(
2021 "A fresh user without an org lands on the create-org onboarding page",
2122 async () => {
22− await page.goto("/", { waitUntil: "networkidle" });
23+ await visit(page, "/");
2324 // Step 1 of 2 — the org-name input is the landmark that proves we're on onboarding.
2425 await page.getByPlaceholder("Northwind Labs").waitFor();
2526 },
e2e/cloud/org-api-keys-console.test.ts+4−3
@@ -20,6 +20,7 @@ import { Effect } from "effect"; · 19 unmodified lines
2020 import { scenario } from "../src/scenario";
2121 import { Browser, Target } from "../src/services";
2222 import { forBrowser, joinOrg } from "./support/session";
23+import { visit } from "../src/surfaces/browser";
2324
2425 declare global {
2526 interface Window {
@@ -45,7 +46,7 @@ scenario( · 19 unmodified lines
4546 let slug = "";
4647
4748 await step("Land in the workspace and open the API keys page", async () => {
48− await page.goto("/", { waitUntil: "networkidle" });
49+ await visit(page, "/");
4950 await page.waitForURL((url) => /^\/[a-z0-9-]+\/?$/.test(url.pathname), {
5051 timeout: 30_000,
5152 });
@@ -120,12 +121,12 @@ scenario( · 68 unmodified lines
120121 // ── The plain member's view ───────────────────────────────────────────
121122 yield* browser.session(forBrowser(member), async ({ page, step }) => {
122123 await step("A plain member is not shown the Organization keys section", async () => {
123− await page.goto("/", { waitUntil: "networkidle" });
124+ await visit(page, "/");
124125 await page.waitForURL((url) => /^\/[a-z0-9-]+\/?$/.test(url.pathname), {
125126 timeout: 30_000,
126127 });
127128 const slug = new URL(page.url()).pathname.split("/").filter(Boolean)[0] ?? "";
128− await page.goto(`/${slug}/api-keys`, { waitUntil: "networkidle" });
129+ await visit(page, `/${slug}/api-keys`);
129130 // The personal half renders for everyone…
130131 await page
131132 .getByRole("heading", { name: "Personal keys" })
e2e/cloud/org-delete.test.ts+4−3
@@ -9,6 +9,7 @@ import { Effect } from "effect"; · 8 unmodified lines
99
1010 import { scenario } from "../src/scenario";
1111 import { Browser, Target } from "../src/services";
12+import { visit, settle } from "../src/surfaces/browser";
1213
1314 scenario(
1415 "Organizations · an admin deletes the organization from settings",
@@ -22,7 +23,7 @@ scenario( · 7 unmodified lines
2223 const ORG = "Doomed Org";
2324
2425 await step("Fresh user creates an org via onboarding", async () => {
25− await page.goto("/", { waitUntil: "networkidle" });
26+ await visit(page, "/");
2627 await page.getByPlaceholder("Northwind Labs").fill(ORG);
2728 await page.getByRole("button", { name: "Create organization" }).click();
2829 await page.getByText("Connect your MCP client").waitFor();
@@ -32,13 +33,13 @@ scenario( · 3 unmodified lines
3233 await page.waitForURL((url) => /^\/[a-z0-9-]+\/?$/.test(url.pathname), {
3334 timeout: 30_000,
3435 });
35− await page.waitForLoadState("networkidle");
36+ await settle(page);
3637 });
3738
3839 const slug = new URL(page.url()).pathname.split("/").filter(Boolean)[0]!;
3940
4041 await step("Open Organization settings and find the danger zone", async () => {
41− await page.goto(`/${slug}/org`, { waitUntil: "networkidle" });
42+ await visit(page, `/${slug}/org`);
4243 // The admin-only danger zone renders (a member would not see it).
4344 await page.getByText("Permanently delete this organization").waitFor();
4445 });
e2e/cloud/org-last-visited.test.ts+4−3
@@ -13,6 +13,7 @@ import { Effect } from "effect"; · 12 unmodified lines
1313
1414 import { scenario } from "../src/scenario";
1515 import { Browser, Target } from "../src/services";
16+import { visit } from "../src/surfaces/browser";
1617
1718 const CLOUD_ORIGIN_HEADERS = (baseUrl: string) => ({ origin: new URL(baseUrl).origin });
1819
@@ -64,7 +65,7 @@ scenario( · 45 unmodified lines
6465
6566 yield* browser.session(inB, async ({ page, step }) => {
6667 await step("Work in org A by its slug URL (the session still pins org B)", async () => {
67− await page.goto(`/${slugA}`, { waitUntil: "networkidle" });
68+ await visit(page, `/${slugA}`);
6869 await page.getByText("Integrations").first().waitFor({ timeout: 30_000 });
6970 // The client records the viewed org once /account/me confirms it.
7071 await page.waitForFunction(
@@ -75,7 +76,7 @@ scenario( · 4 unmodified lines
7576 });
7677
7778 await step("A bare entry (`/`) returns to org A, not the session's org B", async () => {
78− await page.goto("/", { waitUntil: "networkidle" });
79+ await visit(page, "/");
7980 await page.waitForURL(
8081 (url) => url.pathname === `/${slugA}` || url.pathname === `/${slugA}/`,
8182 {
@@ -86,7 +87,7 @@ scenario( · 4 unmodified lines
8687 });
8788
8889 await step("A bare deep link keeps its path while landing in org A", async () => {
89− await page.goto("/policies", { waitUntil: "networkidle" });
90+ await visit(page, "/policies");
9091 await page.waitForURL((url) => url.pathname === `/${slugA}/policies`, { timeout: 30_000 });
9192 await page.getByText("Policies").first().waitFor({ timeout: 30_000 });
9293 });
e2e/cloud/org-limit.test.ts+3−2
@@ -8,6 +8,7 @@ import { Effect } from "effect"; · 7 unmodified lines
88
99 import { scenario } from "../src/scenario";
1010 import { Billing, Browser, Target } from "../src/services";
11+import { visit, settle } from "../src/surfaces/browser";
1112
1213 const FREE_LIMIT = 3;
1314
@@ -23,7 +24,7 @@ scenario( · 9 unmodified lines
2324
2425 yield* browser.session(identity, async ({ page, step }) => {
2526 await step("A fresh user lands on onboarding (no organization yet)", async () => {
26− await page.goto("/", { waitUntil: "networkidle" });
27+ await visit(page, "/");
2728 await page.getByPlaceholder("Northwind Labs").waitFor();
2829 });
2930
@@ -43,7 +44,7 @@ scenario( · 13 unmodified lines
4344 await page.waitForURL((url) => /^\/[a-z0-9-]+\/?$/.test(url.pathname), {
4445 timeout: 30_000,
4546 });
46− await page.waitForLoadState("networkidle");
47+ await settle(page);
4748 });
4849
4950 const openCreateOrgModal = async (currentOrg: string) => {
e2e/cloud/org-multitab-cookie.test.ts+4−3
@@ -21,6 +21,7 @@ import { Effect } from "effect"; · 20 unmodified lines
2121
2222 import { scenario } from "../src/scenario";
2323 import { Browser, Target } from "../src/services";
24+import { visit } from "../src/surfaces/browser";
2425
2526 scenario(
2627 "Org tabs · two tabs on different orgs stay independent (URL-scoped, no cookie steal)",
@@ -53,7 +54,7 @@ scenario( · 26 unmodified lines
5354 let slugB = "";
5455
5556 await step("Onboard org A in tab 1", async () => {
56− await tab1.goto("/", { waitUntil: "networkidle" });
57+ await visit(tab1, "/");
5758 await tab1.getByPlaceholder("Northwind Labs").fill("Multitab A");
5859 await tab1.getByRole("button", { name: "Create organization" }).click();
5960 await tab1.getByText("Connect your MCP client").waitFor({ timeout: 30_000 });
@@ -83,7 +84,7 @@ scenario( · 23 unmodified lines
8384 const tab2 = await tab1.context().newPage();
8485
8586 await step("Tab 2 opens org A's URL and stays in A — no switch, no reload loop", async () => {
86− await tab2.goto(`/${slugA}/policies`, { waitUntil: "networkidle" });
87+ await visit(tab2, `/${slugA}/policies`);
8788 await tab2.getByText("Policies").first().waitFor({ timeout: 30_000 });
8889 expect(new URL(tab2.url()).pathname, "tab 2 stays on org A's URL").toBe(
8990 `/${slugA}/policies`,
@@ -134,7 +135,7 @@ scenario( · 44 unmodified lines
134135
135136 yield* browser.session(identity, async ({ page, step }) => {
136137 await step("Open the OpenAPI add form", async () => {
137− await page.goto("/integrations/add/openapi", { waitUntil: "networkidle" });
138+ await visit(page, "/integrations/add/openapi");
138139 await page.getByPlaceholder("https://api.example.com/openapi.json").waitFor();
139140 });
140141
e2e/cloud/org-slug-foreign.test.ts+3−2
@@ -13,6 +13,7 @@ import { Effect } from "effect"; · 12 unmodified lines
1313
1414 import { scenario } from "../src/scenario";
1515 import { Browser, Target } from "../src/services";
16+import { visit } from "../src/surfaces/browser";
1617
1718 const CLOUD_ORIGIN_HEADERS = (baseUrl: string) => ({ origin: new URL(baseUrl).origin });
1819
@@ -67,10 +68,10 @@ scenario( · 48 unmodified lines
6768
6869 yield* browser.session(inB, async ({ page, step }) => {
6970 await step("Land in org B, then open org A's slug URL directly", async () => {
70− await page.goto(`/${orgB.slug}`, { waitUntil: "networkidle" });
71+ await visit(page, `/${orgB.slug}`);
7172 await page.getByText("Integrations").first().waitFor({ timeout: 30_000 });
7273 // Open org A by its slug while the cookie is still pinned to B.
73− await page.goto(`/${slugA}/policies`, { waitUntil: "networkidle" });
74+ await visit(page, `/${slugA}/policies`);
7475 });
7576
7677 await step("Org A's page renders at its slugged URL — the URL is the scope", async () => {
e2e/cloud/org-switcher.test.ts+4−3
@@ -8,6 +8,7 @@ import { Effect } from "effect"; · 7 unmodified lines
88
99 import { scenario } from "../src/scenario";
1010 import { Browser, Target } from "../src/services";
11+import { visit, settle } from "../src/surfaces/browser";
1112
1213 scenario(
1314 "Organizations · switching organizations switches the workspace",
@@ -20,7 +21,7 @@ scenario( · 6 unmodified lines
2021 yield* browser.session(identity, async ({ page, step }) => {
2122 // ── Step 1: onboarding, create the first org ─────────────────────
2223 await step("Fresh user lands on onboarding (no organization yet)", async () => {
23− await page.goto("/", { waitUntil: "networkidle" });
24+ await visit(page, "/");
2425 await page.getByPlaceholder("Northwind Labs").waitFor();
2526 });
2627
@@ -43,7 +44,7 @@ scenario( · 16 unmodified lines
4344 await page.waitForURL((url) => /^\/[a-z0-9-]+\/?$/.test(url.pathname), {
4445 timeout: 30_000,
4546 });
46− await page.waitForLoadState("networkidle");
47+ await settle(page);
4748 });
4849
4950 // ── Step 2: create the second org via the account-menu switcher ──
@@ -89,7 +90,7 @@ scenario( · 39 unmodified lines
8990 // rendered with data-disabled="" (Radix convention). The only item without
9091 // data-disabled that isn't "Create organization" is ORG_1.
9192 await step(`Open the org switcher and switch back to "${ORG_1}"`, async () => {
92− await page.waitForLoadState("networkidle");
93+ await settle(page);
9394 await page.getByRole("button", { name: /Test User/ }).click();
9495 // Click the SubTrigger (shows current org name = ORG_2) to expand the list.
9596 await page.getByRole("menuitem", { name: ORG_2 }).click();
e2e/scenarios/artifact-loading-surface.test.ts+2−1
@@ -43,6 +43,7 @@ import type { ArtifactId } from "@executor-js/sdk/shared"; · 42 unmodified lines
4343
4444 import { scenario } from "../src/scenario";
4545 import { Api, Browser, Mcp, Target } from "../src/services";
46+import { visit } from "../src/surfaces/browser";
4647
4748 const api = composePluginApi([] as const);
4849
@@ -230,7 +231,7 @@ scenario( · 181 unmodified lines
230231 yield* browser.session(identity, async ({ page, step }) => {
231232 await step("Open the Artifacts gallery", async () => {
232233 await startSampling(page);
233− await page.goto(`${target.baseUrl}/artifacts`, { waitUntil: "networkidle" });
234+ await visit(page, `${target.baseUrl}/artifacts`);
234235 await page
235236 .getByRole("link", { name: `Open artifact ${title}` })
236237 .waitFor({ timeout: 30_000 });
e2e/scenarios/artifact-preview-gallery.test.ts+4−3
@@ -17,6 +17,7 @@ import { Effect } from "effect"; · 16 unmodified lines
1717
1818 import { scenario } from "../src/scenario";
1919 import { Browser, Mcp, Target } from "../src/services";
20+import { revisit, visit } from "../src/surfaces/browser";
2021
2122 const uniqueSuffix = () => randomBytes(3).toString("hex");
2223
@@ -167,7 +168,7 @@ scenario( · 144 unmodified lines
167168
168169 yield* browser.session(identity, async ({ page, step }) => {
169170 await step("Open the Artifacts gallery", async () => {
170− await page.goto(`${target.baseUrl}/artifacts`, { waitUntil: "networkidle" });
171+ await visit(page, `${target.baseUrl}/artifacts`);
171172 await page.getByRole("heading", { name: "Artifacts", level: 1 }).waitFor();
172173 // Every seeded artifact is present before anything is measured.
173174 for (const [name] of seeded) {
@@ -257,7 +258,7 @@ scenario( · 83 unmodified lines
257258 // The stored markup is token-based, so the SAME markup has to read
258259 // correctly against the console's dark surface without being restyled.
259260 await page.emulateMedia({ colorScheme: "dark" });
260− await page.reload({ waitUntil: "networkidle" });
261+ await revisit(page);
261262 await page.getByRole("heading", { name: "Artifacts", level: 1 }).waitFor();
262263 await page.locator('[data-slot="artifact-preview"]').first().waitFor({ timeout: 20_000 });
263264
@@ -272,7 +273,7 @@ scenario( · 8 unmodified lines
272273
273274 await step("Gallery in light mode", async () => {
274275 await page.emulateMedia({ colorScheme: "light" });
275− await page.reload({ waitUntil: "networkidle" });
276+ await revisit(page);
276277 await page.getByRole("heading", { name: "Artifacts", level: 1 }).waitFor();
277278 await page.locator('[data-slot="artifact-preview"]').first().waitFor({ timeout: 20_000 });
278279 });
e2e/scenarios/artifacts.test.ts+5−4
@@ -24,6 +24,7 @@ import type { ArtifactId } from "@executor-js/sdk/shared"; · 23 unmodified lines
2424
2525 import { scenario } from "../src/scenario";
2626 import { Api, Browser, Mcp, Target } from "../src/services";
27+import { visit } from "../src/surfaces/browser";
2728
2829 const api = composePluginApi([] as const);
2930
@@ -263,7 +264,7 @@ scenario( · 233 unmodified lines
263264
264265 await step("Open the artifact link the agent handed over", async () => {
265266 await recordHandshakeOrdering(page);
266− await page.goto(url, { waitUntil: "networkidle" });
267+ await visit(page, url);
267268 consoleStyleBefore = await readConsoleStyle(page);
268269 });
269270
@@ -596,7 +597,7 @@ scenario( · 326 unmodified lines
596597
597598 yield* browser.session(identity, async ({ page, step }) => {
598599 await step("Open the Artifacts tab", async () => {
599− await page.goto("/artifacts", { waitUntil: "networkidle" });
600+ await visit(page, "/artifacts");
600601 await page.getByRole("link", { name: `Open artifact ${originalTitle}` }).waitFor({
601602 timeout: 20_000,
602603 });
@@ -634,7 +635,7 @@ scenario( · 31 unmodified lines
634635
635636 yield* browser.session(identity, async ({ page, step }) => {
636637 await step("Delete the artifact from the list", async () => {
637− await page.goto("/artifacts", { waitUntil: "networkidle" });
638+ await visit(page, "/artifacts");
638639 const card = page.locator('[data-slot="artifact-card"]').filter({
639640 hasText: renamedTitle,
640641 });
@@ -738,7 +739,7 @@ scenario( · 97 unmodified lines
738739
739740 yield* browser.session(identity, async ({ page, step }) => {
740741 await step("The artifact's own URL shows the updated component", async () => {
741− await page.goto(`/artifacts/${artifactId}`, { waitUntil: "networkidle" });
742+ await visit(page, `/artifacts/${artifactId}`);
742743 const content = artifactContent(page);
743744 await content
744745 .locator('[data-testid="artifact-marker"]')
e2e/scenarios/auth-methods-ui.test.ts+2−3
@@ -15,6 +15,7 @@ import { makeGreetingMcpServer, serveMcpServer } from "@executor-js/plugin-mcp/t · 14 unmodified lines
1515
1616 import { scenario } from "../src/scenario";
1717 import { Browser, Target } from "../src/services";
18+import { visit } from "../src/surfaces/browser";
1819
1920 scenario(
2021 "Auth methods · the add flow declares an API key alongside the detected method",
@@ -34,9 +35,7 @@ scenario( · 13 unmodified lines
3435
3536 yield* browser.session(identity, async ({ page, step }) => {
3637 await step("Open the add-MCP flow pointed at the server", async () => {
37− await page.goto(`/integrations/add/mcp?url=${encodeURIComponent(server.endpoint)}`, {
38− waitUntil: "networkidle",
39− });
38+ await visit(page, `/integrations/add/mcp?url=${encodeURIComponent(server.endpoint)}`);
4039 // The URL auto-probes (debounced); the method list appears once
4140 // the probe lands. Generous timeout: the probe request can queue
4241 // behind a busy dev server under CI load, and there is no clean
e2e/scenarios/browser-approval.test.ts+2−1
@@ -25,6 +25,7 @@ import { Api, Browser, Mcp, Target } from "../src/services"; · 24 unmodified lines
2525 import { type McpBrowserApproval, parseBrowserApproval } from "../src/surfaces/mcp";
2626 import type { BrowserSurface } from "../src/surfaces/browser";
2727 import type { Identity } from "../src/target";
28+import { visit } from "../src/surfaces/browser";
2829
2930 const coreApi = composePluginApi([] as const);
3031
@@ -70,7 +71,7 @@ const decideInBrowser = ( · 39 unmodified lines
7071 await step(
7172 `Open the approval page and ${decision.toLowerCase()} the paused action`,
7273 async () => {
73− await page.goto(approval.approvalUrl, { waitUntil: "networkidle" });
74+ await visit(page, approval.approvalUrl);
7475 await page.getByRole("button", { name: decision }).click();
7576 // The page confirms the decision was recorded ("Approve sent" / "Decline sent").
7677 await page.getByText(`${decision} sent`).waitFor();
e2e/scenarios/connect-deep-link.test.ts+4−3
@@ -30,6 +30,7 @@ import { · 29 unmodified lines
3030
3131 import { scenario } from "../src/scenario";
3232 import { Api, Browser, Target } from "../src/services";
33+import { visit } from "../src/surfaces/browser";
3334
3435 const api = composePluginApi([openApiHttpPlugin()] as const);
3536
@@ -95,7 +96,7 @@ scenario( · 59 unmodified lines
9596
9697 yield* browser.session(identity, async ({ page, step }) => {
9798 await step("Open the connect deep link for the seeded integration", async () => {
98− await page.goto(`/connect/${slug}`, { waitUntil: "networkidle" });
99+ await visit(page, `/connect/${slug}`);
99100 // It forwards into the integration's OWN detail route rather than
100101 // rendering a parallel connect UI, carrying the add-account handoff.
101102 await page.waitForURL((url) => url.pathname === detailPath, { timeout: 30_000 });
@@ -115,7 +116,7 @@ scenario( · 13 unmodified lines
115116 });
116117
117118 await step("An unknown slug is a clear not-found, not a blank page", async () => {
118− await page.goto("/connect/zz-no-such-integration", { waitUntil: "networkidle" });
119+ await visit(page, "/connect/zz-no-such-integration");
119120 await page
120121 .getByText("Unknown integration: zz-no-such-integration")
121122 .waitFor({ timeout: 30_000 });
@@ -139,7 +140,7 @@ scenario( · 17 unmodified lines
139140
140141 yield* browser.session(identity, async ({ page, step }) => {
141142 await step("The deep link surfaces the existing connection", async () => {
142− await page.goto(`/connect/${slug}`, { waitUntil: "networkidle" });
143+ await visit(page, `/connect/${slug}`);
143144 await page.waitForURL((url) => url.pathname === detailPath, { timeout: 30_000 });
144145 // The connect flow still opens (this link means "connect me"), and
145146 // the already-saved account is visible behind it.
e2e/scenarios/connect-handoff-session.test.ts+2−1
@@ -20,6 +20,7 @@ import { scenario } from "../src/scenario"; · 19 unmodified lines
2020 import { Browser, Cli, Mcp, RunDir, Target } from "../src/services";
2121 import { withChatTheater } from "../src/clients/chat-theater";
2222 import type { McpSession } from "../src/surfaces/mcp";
23+import { visit } from "../src/surfaces/browser";
2324
2425 const unique = (prefix: string) => `${prefix}_${randomBytes(4).toString("hex")}`;
2526
@@ -148,7 +149,7 @@ scenario( · 122 unmodified lines
148149 yield* chat.status("you, in the browser: opening the link and pasting the API key…");
149150 yield* browser.session(identity, async ({ page, step }) => {
150151 await step("Open the connect link from the chat", async () => {
151− await page.goto(handoffUrl, { waitUntil: "networkidle" });
152+ await visit(page, handoffUrl);
152153 await page
153154 .getByRole("heading", { name: /Add connection/ })
154155 .waitFor({ timeout: 15_000 });
e2e/scenarios/connect-handoff.test.ts+2−1
@@ -32,6 +32,7 @@ import { Api, Browser, Mcp, Target } from "../src/services"; · 31 unmodified lines
3232 import type { Identity, Target as TargetShape } from "../src/target";
3333 import type { BrowserSurface } from "../src/surfaces/browser";
3434 import type { McpSession } from "../src/surfaces/mcp";
35+import { visit } from "../src/surfaces/browser";
3536
3637 const api = composePluginApi([openApiHttpPlugin()] as const);
3738
@@ -214,7 +215,7 @@ const runScenario = (input: { · 176 unmodified lines
214215 // 4. The user opens the handoff URL and pastes the credential.
215216 yield* browser.session(identity, async ({ page, step }) => {
216217 await step("Open the handoff URL from the agent", async () => {
217− await page.goto(handoffUrl, { waitUntil: "networkidle" });
218+ await visit(page, handoffUrl);
218219 });
219220
220221 await step("The Add connection modal is open", async () => {
e2e/scenarios/connection-remove-confirm.test.ts+2−1
@@ -14,6 +14,7 @@ import { AuthTemplateSlug, ConnectionName, IntegrationSlug } from "@executor-js/ · 13 unmodified lines
1414
1515 import { scenario } from "../src/scenario";
1616 import { Api, Browser, Target } from "../src/services";
17+import { visit } from "../src/surfaces/browser";
1718
1819 const api = composePluginApi([openApiHttpPlugin()] as const);
1920
@@ -77,7 +78,7 @@ scenario( · 57 unmodified lines
7778 const confirm = page.getByRole("alertdialog");
7879
7980 await step("Open the integration's connections", async () => {
80− await page.goto(`/integrations/${slug}`, { waitUntil: "networkidle" });
81+ await visit(page, `/integrations/${slug}`);
8182 await row.waitFor();
8283 });
8384
e2e/scenarios/docs-link.test.ts+2−1
@@ -3,6 +3,7 @@ import { Effect } from "effect"; · 2 unmodified lines
33
44 import { scenario } from "../src/scenario";
55 import { Browser, Target } from "../src/services";
6+import { visit } from "../src/surfaces/browser";
67
78 // A user inside the console should be able to reach the documentation without
89 // leaving the app for the marketing site (the original friction: docs were
@@ -18,7 +19,7 @@ scenario( · 9 unmodified lines
1819
1920 yield* browser.session(identity, async ({ page, step }) => {
2021 await step("Open the console", async () => {
21− await page.goto("/", { waitUntil: "networkidle" });
22+ await visit(page, "/");
2223 await page.getByText("Integrations").first().waitFor();
2324 });
2425
e2e/scenarios/first-party-oauth.test.ts+2−2
@@ -26,7 +26,7 @@ import { · 25 unmodified lines
2626
2727 import { scenario } from "../src/scenario";
2828 import { Api, Browser, Target } from "../src/services";
29−import { clickToReveal } from "../src/surfaces/browser";
29+import { clickToReveal, visit } from "../src/surfaces/browser";
3030
3131 const api = composePluginApi([openApiHttpPlugin()] as const);
3232
@@ -182,7 +182,7 @@ scenario( · 149 unmodified lines
182182
183183 yield* browser.session(identity, async ({ page, step }) => {
184184 await step("Open the Google integration catalog", async () => {
185− await page.goto("/integrations", { waitUntil: "networkidle" });
185+ await visit(page, "/integrations");
186186 const dialog = page.getByRole("dialog", { name: "Connect an integration" });
187187 await clickToReveal(page.getByRole("button", { name: /Connect/ }).first(), dialog);
188188 });
e2e/scenarios/google-health-checks.test.ts+33−31
@@ -13,10 +13,11 @@ import { · 12 unmodified lines
1313 OAuthClientSlug,
1414 } from "@executor-js/sdk/shared";
1515
16+import { createEmulatorInstance } from "../src/emulator-instance";
1617 import { scenario } from "../src/scenario";
1718 import { Api, Browser, Target } from "../src/services";
1819 import type { Identity, Target as TargetShape } from "../src/target";
19−import { clickToReveal, type BrowserSurface } from "../src/surfaces/browser";
20+import { type BrowserSurface, clickToReveal, visit } from "../src/surfaces/browser";
2021
2122 const api = composePluginApi([openApiHttpPlugin()] as const);
2223 type Client = HttpApiClient.ForApi<typeof api>;
@@ -69,14 +70,13 @@ const completeGoogleConsent = (authorizationUrl: string) => · 46 unmodified lines
6970 return code;
7071 });
7172
72−const createGoogleEmulator = Effect.promise(async () => {
73− const response = await fetch("https://google.emulators.dev/_emulate/instances", {
74− method: "POST",
75− });
76− if (!response.ok) throw new Error(`Google emulator instance failed: ${response.status}`);
77− const instance = (await response.json()) as { readonly providerBaseUrl: string };
78− const client = await connectEmulator({ baseUrl: instance.providerBaseUrl });
79− return { client, baseUrl: instance.providerBaseUrl };
73+const createGoogleEmulator = Effect.gen(function* () {
74+ // Through the shared helper: it bounds and retries the one request in this
75+ // scenario that leaves the runner, so a blip on the way to the edge doesn't
76+ // read as a Google health-check failure.
77+ const baseUrl = yield* createEmulatorInstance("google", "google-health");
78+ const client = yield* Effect.promise(() => connectEmulator({ baseUrl }));
79+ return { client, baseUrl };
8080 });
8181
8282 const addGooglePresetFromCatalog = (
@@ -87,7 +87,7 @@ const addGooglePresetFromCatalog = ( · 4 unmodified lines
8787 ) =>
8888 browser.session(identity, async ({ page, step }) => {
8989 await step(`Open ${presetName} from the connect catalog`, async () => {
90− await page.goto("/integrations", { waitUntil: "networkidle" });
90+ await visit(page, "/integrations");
9191 const dialog = page.getByRole("dialog", { name: "Connect an integration" });
9292 await clickToReveal(page.getByRole("button", { name: /Connect/ }).first(), dialog);
9393 await dialog.getByPlaceholder(/Search or paste a URL/).fill(presetName);
@@ -266,29 +266,31 @@ scenario( · 172 unmodified lines
266266 health.status,
267267 `${row.presetName} health check is healthy: ${JSON.stringify(health)}`,
268268 ).toBe("healthy");
269− }
270269
271− // The hosted emulator acknowledges a request before its ledger entry
272− // is readable, so a single list right after the probe races the write
273− // (the health checks above already came back healthy, which only the
274− // emulator can answer). Poll until every expected operation is
275− // visible; on timeout the assertion names what never arrived.
276− const missing = yield* Effect.gen(function* () {
277− const ledger = yield* Effect.promise(() => emulator.client.ledger.list(100));
278− return rows.filter(
279− (row) => !ledger.some((entry) => entry.operationId === row.expectedLedgerOperation),
270+ // Check this row's ledger entry HERE, not after both rows have run.
271+ // `ledger.list(n)` is the last n entries, and connecting the second
272+ // account is easily a hundred emulator requests, so the first row's
273+ // health check can be evicted from the window before a combined
274+ // assertion at the end ever looks for it — which reads as "Calendar's
275+ // health check never reached the emulator" when it plainly did (the
276+ // probe above came back healthy, and only the emulator can answer
277+ // that). The hosted emulator also acknowledges a request before its
278+ // ledger entry is readable, so poll rather than read once.
279+ const reached = yield* Effect.promise(() => emulator.client.ledger.list(50)).pipe(
280+ Effect.map((ledger) =>
281+ ledger.some((entry) => entry.operationId === row.expectedLedgerOperation),
282+ ),
283+ Effect.repeat({
284+ schedule: Schedule.spaced("500 millis"),
285+ until: (seen) => seen,
286+ times: 19,
287+ }),
280288 );
281− }).pipe(
282− Effect.repeat({
283− schedule: Schedule.spaced("500 millis"),
284− until: (unseen) => unseen.length === 0,
285− times: 19,
286− }),
287− );
288− expect(
289− missing.map((row) => row.presetName),
290− "every health check reached the Google emulator",
291− ).toEqual([]);
289+ expect(
290+ reached,
291+ `${row.presetName}'s health check reached the Google emulator as ${row.expectedLedgerOperation}`,
292+ ).toBe(true);
293+ }
292294 }),
293295 Effect.gen(function* () {
294296 for (const row of rows) {
e2e/scenarios/google-photos-preset-ui.test.ts+2−2
@@ -3,7 +3,7 @@ import { Effect } from "effect"; · 2 unmodified lines
33
44 import { scenario } from "../src/scenario";
55 import { Browser, Target } from "../src/services";
6−import { clickToReveal } from "../src/surfaces/browser";
6+import { clickToReveal, visit } from "../src/surfaces/browser";
77
88 scenario(
99 "Google Photos: separated catalog presets open a Photos service add flow",
@@ -17,7 +17,7 @@ scenario( · 7 unmodified lines
1717 await step(
1818 "Find the separated Google Photos presets from the integrations picker",
1919 async () => {
20− await page.goto("/integrations", { waitUntil: "networkidle" });
20+ await visit(page, "/integrations");
2121 const dialog = page.getByRole("dialog", { name: "Connect an integration" });
2222 await clickToReveal(page.getByRole("button", { name: "Connect" }), dialog);
2323 await dialog.getByPlaceholder(/Search or paste a URL/).fill("google photos");
e2e/scenarios/graphql-introspection-health.test.ts+11−5
@@ -11,6 +11,7 @@ import { variable } from "@executor-js/sdk/http-auth"; · 10 unmodified lines
1111 import { createEmulatorInstance } from "../src/emulator-instance";
1212 import { scenario } from "../src/scenario";
1313 import { Api, Browser, Target } from "../src/services";
14+import { visit } from "../src/surfaces/browser";
1415
1516 const api = composePluginApi([graphqlHttpPlugin()] as const);
1617 const unique = (prefix: string): string => `${prefix}_${randomBytes(4).toString("hex")}`;
@@ -30,11 +31,18 @@ scenario( · 13 unmodified lines
3031 connectEmulator({ baseUrl: emulatorBaseUrl, service: "github" }),
3132 );
3233
34+ // A budget, not a count: nothing in this scenario depends on how many
35+ // times the connect flow introspects, and the emulator's answer when the
36+ // budget runs out is not a neutral pass-through — an unauthenticated
37+ // GraphQL POST to the real handler is GitHub-shaped, so it comes back 403
38+ // "API rate limit exceeded". The UI then honestly reports HTTP 403 and the
39+ // assertion below fails on a message that has nothing to do with the
40+ // product. Arm enough that one connect attempt cannot exhaust it.
3341 yield* Effect.promise(() =>
3442 emulator.faults.arm({
3543 match: { method: "POST", pathPattern: "/graphql" },
3644 response: { status: 401, body: { message: "Bad credentials" } },
37− times: 10,
45+ times: 100,
3846 }),
3947 );
4048
@@ -56,9 +64,7 @@ scenario( · 15 unmodified lines
5664 yield* Effect.gen(function* () {
5765 yield* browser.session(identity, async ({ page, step }) => {
5866 await step("Open the connection flow", async () => {
59− await page.goto(`/integrations/${slug}?addAccount=1&owner=org&template=header`, {
60− waitUntil: "networkidle",
61− });
67+ await visit(page, `/integrations/${slug}?addAccount=1&owner=org&template=header`);
6268 await page.getByRole("heading", { name: /Add connection · GraphQL health/ }).waitFor();
6369 });
6470
@@ -97,7 +103,7 @@ scenario( · 32 unmodified lines
97103
98104 yield* browser.session(identity, async ({ page, step }) => {
99105 await step("A failed existing connection explains the empty tool catalogue", async () => {
100− await page.goto(`/integrations/${slug}?tab=tools`, { waitUntil: "networkidle" });
106+ await visit(page, `/integrations/${slug}?tab=tools`);
101107 await page.getByText("Connection rejected", { exact: true }).first().waitFor();
102108 await page
103109 .getByText("The endpoint rejected the credential with HTTP 401.", {
e2e/scenarios/health-checks-ui.test.ts+12−11
@@ -34,6 +34,7 @@ import { AuthTemplateSlug, ConnectionName, IntegrationSlug } from "@executor-js/ · 33 unmodified lines
3434
3535 import { scenario } from "../src/scenario";
3636 import { Api, Browser, Target } from "../src/services";
37+import { visit } from "../src/surfaces/browser";
3738
3839 const api = composePluginApi([openApiHttpPlugin()] as const);
3940 type Client = HttpApiClient.ForApi<typeof api>;
@@ -281,7 +282,7 @@ scenario( · 241 unmodified lines
281282 const dialog = page.getByRole("dialog");
282283
283284 await step("Open the Add Connection modal", async () => {
284− await page.goto(`/integrations/${slug}`, { waitUntil: "networkidle" });
285+ await visit(page, `/integrations/${slug}`);
285286 await page.getByRole("button", { name: "Add connection", exact: true }).click();
286287 await page.getByRole("heading", { name: /Add connection/ }).waitFor();
287288 });
@@ -395,7 +396,7 @@ scenario( · 107 unmodified lines
395396 const options = page.getByRole("option");
396397
397398 await step("Open the health-check editor over the large spec", async () => {
398− await page.goto(`/integrations/${slug}`, { waitUntil: "networkidle" });
399+ await visit(page, `/integrations/${slug}`);
399400 await page.getByRole("heading", { level: 3, name: "Health check" }).waitFor();
400401 await page.getByRole("button", { name: "Set up" }).click();
401402 await input.waitFor();
@@ -479,7 +480,7 @@ scenario( · 77 unmodified lines
479480 const options = page.getByRole("option");
480481
481482 await step("Open the Add form and paste the large spec", async () => {
482− await page.goto("/integrations/add/openapi", { waitUntil: "networkidle" });
483+ await visit(page, "/integrations/add/openapi");
483484 await page.getByPlaceholder("https://api.example.com/openapi.json").fill(spec);
484485 await page
485486 .getByRole("heading", { name: "Health check (optional)" })
@@ -558,7 +559,7 @@ scenario( · 72 unmodified lines
558559 const operationInput = page.locator("#health-check-operation");
559560
560561 await step("Open the health-check editor", async () => {
561− await page.goto(`/integrations/${slug}`, { waitUntil: "networkidle" });
562+ await visit(page, `/integrations/${slug}`);
562563 await page.getByRole("heading", { level: 3, name: "Health check" }).waitFor();
563564 await page.getByRole("button", { name: "Set up" }).click();
564565 await operationInput.waitFor();
@@ -629,7 +630,7 @@ scenario( · 64 unmodified lines
629630 const list = page.locator("[data-slot='combobox-list']").first();
630631
631632 await step("Open the operation combobox in the sheet", async () => {
632− await page.goto(`/integrations/${slug}`, { waitUntil: "networkidle" });
633+ await visit(page, `/integrations/${slug}`);
633634 await page.getByRole("heading", { level: 3, name: "Health check" }).waitFor();
634635 await page.getByRole("button", { name: "Set up" }).click();
635636 await operationInput.waitFor();
@@ -705,7 +706,7 @@ scenario( · 69 unmodified lines
705706 const menuTrigger = connections.locator('button[aria-haspopup="menu"]');
706707
707708 await step("Open the integration's connections", async () => {
708− await page.goto(`/integrations/${slug}`, { waitUntil: "networkidle" });
709+ await visit(page, `/integrations/${slug}`);
709710 await connections.getByText("main", { exact: true }).waitFor();
710711 await page.getByRole("heading", { level: 3, name: "Health check" }).waitFor();
711712 });
@@ -795,7 +796,7 @@ scenario( · 83 unmodified lines
795796 const dialog = page.getByRole("dialog");
796797
797798 await step("Open the Add Connection modal", async () => {
798− await page.goto(`/integrations/${slug}`, { waitUntil: "networkidle" });
799+ await visit(page, `/integrations/${slug}`);
799800 await page.getByRole("button", { name: "Add connection", exact: true }).click();
800801 await page.getByRole("heading", { name: /Add connection/ }).waitFor();
801802 });
@@ -888,7 +889,7 @@ scenario( · 86 unmodified lines
888889 await step(
889890 "A fresh page load shows the expired connection with NO clicking",
890891 async () => {
891− await page.goto(`/integrations/${slug}`, { waitUntil: "networkidle" });
892+ await visit(page, `/integrations/${slug}`);
892893 // The persisted verdict drives the row: red dot + Expired badge
893894 // are already there on first paint of the list.
894895 await connections.getByLabel("Status: Expired").waitFor({ timeout: 30_000 });
@@ -907,7 +908,7 @@ scenario( · 12 unmodified lines
907908 // not after the freshness window). Restore the key, reload,
908909 // and the dot flips back with no clicks.
909910 server.restore();
910− await page.goto(`/integrations/${slug}`, { waitUntil: "networkidle" });
911+ await visit(page, `/integrations/${slug}`);
911912 // The row mounts with the stale expired verdict, then the
912913 // background revalidation flips it to healthy in place.
913914 await connections.getByLabel("Status: Healthy").waitFor({ timeout: 30_000 });
@@ -993,7 +994,7 @@ scenario( · 79 unmodified lines
993994 // while the operation combobox still renders (a healthy probe
994995 // saves the check and turns the request line static).
995996 await page.setViewportSize({ width: 1280, height: 420 });
996− await page.goto(`/integrations/${slug}`, { waitUntil: "networkidle" });
997+ await visit(page, `/integrations/${slug}`);
997998 await page.getByRole("button", { name: "Add connection", exact: true }).click();
998999 await page.getByRole("heading", { name: /Add connection/ }).waitFor();
9991000 await page.keyboard.type(goodToken);
@@ -1136,7 +1137,7 @@ scenario( · 136 unmodified lines
11361137 await step(
11371138 "Load the integrations list: the dead MCP row reads Expired with no clicks",
11381139 async () => {
1139− await page.goto("/", { waitUntil: "networkidle" });
1140+ await visit(page, "/");
11401141 // The row itself is the assertion surface: the list-page
11411142 // summary probes in the background and paints the worst-of
11421143 // verdict onto the row, scoped so a verdict from another row
e2e/scenarios/mcp-catalog-sync-ui.test.ts+3−4
@@ -14,6 +14,7 @@ import { makeMutableCatalogMcpServer, serveMcpServer } from "@executor-js/plugin · 13 unmodified lines
1414
1515 import { scenario } from "../src/scenario";
1616 import { Browser, Target } from "../src/services";
17+import { revisit, visit } from "../src/surfaces/browser";
1718
1819 scenario(
1920 "MCP catalog · the Tools tab follows a server-side rename after a list_changed notification",
@@ -35,9 +36,7 @@ scenario( · 15 unmodified lines
3536
3637 yield* browser.session(identity, async ({ page, step }) => {
3738 await step("Open the add-MCP flow pointed at the live server", async () => {
38− await page.goto(`/integrations/add/mcp?url=${encodeURIComponent(server.endpoint)}`, {
39− waitUntil: "networkidle",
40− });
39+ await visit(page, `/integrations/add/mcp?url=${encodeURIComponent(server.endpoint)}`);
4140 // The URL auto-probes (debounced); the method list appears once the
4241 // probe lands — an open server seeds the detected no-auth method.
4342 await page.getByText("How does this server authenticate?").waitFor();
@@ -95,7 +94,7 @@ scenario( · 51 unmodified lines
9594 // Re-enter the page: a fresh tools read. The list_changed the server
9695 // sent during the call marked the catalog stale, so THIS read
9796 // re-lists — the renamed tool appears with no Refresh click.
98− await page.reload({ waitUntil: "networkidle" });
97+ await revisit(page);
9998 await page.getByRole("tab", { name: "Tools" }).click();
10099 await filterTools("greet");
101100 await toolRow(mutable.renamedToolName).waitFor({ timeout: 30_000 });
e2e/scenarios/oauth-callback-url.test.ts+2−3
@@ -31,6 +31,7 @@ import { serveOAuthTestServer } from "@executor-js/sdk/testing"; · 30 unmodified lines
3131
3232 import { scenario } from "../src/scenario";
3333 import { Api, Browser, Target } from "../src/services";
34+import { visit } from "../src/surfaces/browser";
3435
3536 const api = composePluginApi([openApiHttpPlugin()] as const);
3637
@@ -153,9 +154,7 @@ scenario( · 116 unmodified lines
153154
154155 yield* browser.session(identity, async ({ page, step }) => {
155156 await step("Open the connect modal for an OAuth integration", async () => {
156− await page.goto(`/integrations/${String(integration)}?addAccount=1`, {
157− waitUntil: "networkidle",
158− });
157+ await visit(page, `/integrations/${String(integration)}?addAccount=1`);
159158 await page.getByRole("button", { name: "Register app", exact: true }).click();
160159 });
161160
e2e/scenarios/oauth-client-handoff.test.ts+2−1
@@ -37,6 +37,7 @@ import { createEmulatorInstance } from "../src/emulator-instance"; · 36 unmodified lines
3737 import { scenario } from "../src/scenario";
3838 import { Api, Browser, Mcp, Target } from "../src/services";
3939 import type { McpSession } from "../src/surfaces/mcp";
40+import { visit } from "../src/surfaces/browser";
4041
4142 const microsoftApi = composePluginApi([
4243 openApiHttpPlugin({ presets: microsoftCatalog, specFormats: [microsoftGraphAdapter] }),
@@ -400,7 +401,7 @@ scenario( · 357 unmodified lines
400401 // pre-filled from the handoff. They type ONLY the secret.
401402 yield* browser.session(identity, async ({ page, step }) => {
402403 await step("Open the agent's handoff URL", async () => {
403− await page.goto(handoffUrl, { waitUntil: "networkidle" });
404+ await visit(page, handoffUrl);
404405 });
405406
406407 await step("The Register-OAuth-app form auto-opens, pre-filled", async () => {
e2e/scenarios/openapi-add-integration-action-bar.test.ts+2−1
@@ -9,6 +9,7 @@ import { Effect } from "effect"; · 8 unmodified lines
99
1010 import { scenario } from "../src/scenario";
1111 import { Browser, Target } from "../src/services";
12+import { visit } from "../src/surfaces/browser";
1213
1314 // One server so "Add integration" is enabled straight from the preview (no
1415 // base-URL picker to resolve first).
@@ -32,7 +33,7 @@ scenario( · 17 unmodified lines
3233
3334 yield* browser.session(identity, async ({ page, step }) => {
3435 await step("Open the Add OpenAPI integration form", async () => {
35− await page.goto("/integrations/add/openapi", { waitUntil: "networkidle" });
36+ await visit(page, "/integrations/add/openapi");
3637 await page.getByPlaceholder("https://api.example.com/openapi.json").waitFor();
3738 });
3839
e2e/scenarios/openapi-multi-key-auth-ui.test.ts+2−1
@@ -13,6 +13,7 @@ import { IntegrationSlug } from "@executor-js/sdk/shared"; · 12 unmodified lines
1313
1414 import { scenario } from "../src/scenario";
1515 import { Api, Browser, Target } from "../src/services";
16+import { visit } from "../src/surfaces/browser";
1617
1718 const api = composePluginApi([openApiHttpPlugin()] as const);
1819
@@ -61,7 +62,7 @@ scenario( · 42 unmodified lines
6162
6263 yield* browser.session(identity, async ({ page, step }) => {
6364 await step("Open the Cloudflare-style integration", async () => {
64− await page.goto(`/integrations/${slug}`, { waitUntil: "networkidle" });
65+ await visit(page, `/integrations/${slug}`);
6566 await page.getByText("Connections").first().waitFor();
6667 });
6768
e2e/scenarios/openapi-server-selection-ui.test.ts+2−1
@@ -9,6 +9,7 @@ import { Effect } from "effect"; · 8 unmodified lines
99
1010 import { scenario } from "../src/scenario";
1111 import { Browser, Target } from "../src/services";
12+import { visit } from "../src/surfaces/browser";
1213
1314 // Two servers — production and staging — so the form offers a base-URL picker
1415 // instead of the single locked input a one-server spec gets.
@@ -35,7 +36,7 @@ scenario( · 20 unmodified lines
3536
3637 yield* browser.session(identity, async ({ page, step }) => {
3738 await step("Open the Add OpenAPI integration form", async () => {
38− await page.goto("/integrations/add/openapi", { waitUntil: "networkidle" });
39+ await visit(page, "/integrations/add/openapi");
3940 await page.getByPlaceholder("https://api.example.com/openapi.json").waitFor();
4041 });
4142
e2e/scenarios/org-slug-routing.test.ts+4−3
@@ -18,6 +18,7 @@ import { AccountHttpApi, isValidOrgSlug } from "@executor-js/api"; · 17 unmodified lines
1818
1919 import { scenario } from "../src/scenario";
2020 import { Api, Browser, Target } from "../src/services";
21+import { visit } from "../src/surfaces/browser";
2122
2223 scenario(
2324 "Org URLs · console paths carry the organization slug",
@@ -38,7 +39,7 @@ scenario( · 14 unmodified lines
3839
3940 yield* browser.session(identity, async ({ page, step }) => {
4041 await step("A bare deep link canonicalizes onto the org slug", async () => {
41− await page.goto("/policies", { waitUntil: "networkidle" });
42+ await visit(page, "/policies");
4243 await page.waitForURL((url) => url.pathname === `/${slug}/policies`, {
4344 timeout: 30_000,
4445 });
@@ -52,13 +53,13 @@ scenario( · 7 unmodified lines
5253 // legitimately does not.
5354 if (!target.name.startsWith("selfhost")) {
5455 await step("An unknown org slug is a wrong address, not a redirect", async () => {
55− await page.goto("/zz-no-such-org/policies", { waitUntil: "networkidle" });
56+ await visit(page, "/zz-no-such-org/policies");
5657 await page.getByText("Page not found").waitFor({ timeout: 30_000 });
5758 });
5859 }
5960
6061 await step("In-shell navigation keeps the slug prefix", async () => {
61− await page.goto(`/${slug}`, { waitUntil: "networkidle" });
62+ await visit(page, `/${slug}`);
6263 await page.getByRole("link", { name: "Policies" }).first().click();
6364 await page.waitForURL((url) => url.pathname === `/${slug}/policies`, {
6465 timeout: 30_000,
e2e/scenarios/policies-ui.test.ts+3−2
@@ -27,6 +27,7 @@ import { AuthTemplateSlug, ConnectionName, IntegrationSlug } from "@executor-js/ · 26 unmodified lines
2727
2828 import { scenario } from "../src/scenario";
2929 import { Api, Browser, Target } from "../src/services";
30+import { visit } from "../src/surfaces/browser";
3031
3132 const api = composePluginApi([openApiHttpPlugin()] as const);
3233
@@ -161,7 +162,7 @@ scenario( · 128 unmodified lines
161162 const internalError = JSON.stringify({ _tag: "InternalError", traceId: "policy-write" });
162163
163164 await step("Open the integration's Tools tab", async () => {
164− await page.goto(`/integrations/${integration}`, { waitUntil: "networkidle" });
165+ await visit(page, `/integrations/${integration}`);
165166 await page.getByRole("tab", { name: "Tools" }).click();
166167 await sectionFor(alpha).waitFor();
167168 await sectionFor(beta).waitFor();
@@ -288,7 +289,7 @@ scenario( · 120 unmodified lines
288289 });
289290
290291 await step("Both rules are manageable rows on the Policies page", async () => {
291− await page.goto("/policies", { waitUntil: "networkidle" });
292+ await visit(page, "/policies");
292293 await page.getByText(leafPattern, { exact: true }).waitFor();
293294 await page.getByText(categoryPattern, { exact: true }).waitFor();
294295 });
e2e/scenarios/provider-plugins-ui.test.ts+2−2
@@ -3,7 +3,7 @@ import { Effect } from "effect"; · 2 unmodified lines
33
44 import { scenario } from "../src/scenario";
55 import { Browser, Target } from "../src/services";
6−import { clickToReveal } from "../src/surfaces/browser";
6+import { clickToReveal, visit } from "../src/surfaces/browser";
77
88 scenario(
99 "Provider catalog · Google and Microsoft services are OpenAPI presets",
@@ -15,7 +15,7 @@ scenario( · 5 unmodified lines
1515
1616 yield* browser.session(identity, async ({ page, step }) => {
1717 await step("Open the integrations picker", async () => {
18− await page.goto("/integrations", { waitUntil: "networkidle" });
18+ await visit(page, "/integrations");
1919 await clickToReveal(
2020 page.getByRole("button", { name: "Connect" }),
2121 page.getByRole("dialog", { name: "Connect an integration" }),
e2e/scripts/record-connect-card.ts+2−1
@@ -15,6 +15,7 @@ · 14 unmodified lines
1515 import { randomUUID } from "node:crypto";
1616
1717 import { chromium } from "playwright";
18+import { visit } from "../src/surfaces/browser";
1819
1920 const [baseUrl, outPrefix] = process.argv.slice(2);
2021 if (!baseUrl || !outPrefix) {
@@ -86,7 +87,7 @@ const shoot = async (label: string, javaScriptEnabled: boolean, outPath: string) · 65 unmodified lines
8687 // the light palette — an artifact no user sees. JS stays disabled for the
8788 // SSR shot only to freeze the pre-hydration origin (no client correction),
8889 // not to skip CSS.
89− await page.goto("/", { waitUntil: "networkidle" });
90+ await visit(page, "/");
9091 // The install command is the thing that flashes; screenshot just its block.
9192 const command = page.locator('pre:has-text("add-mcp")').first();
9293 await command.waitFor({ state: "visible", timeout: 15_000 });
e2e/selfhost/admin-users-console.test.ts+4−3
@@ -20,6 +20,7 @@ import { AuthTemplateSlug, ConnectionName, IntegrationSlug } from "@executor-js/ · 19 unmodified lines
2020 import { scenario } from "../src/scenario";
2121 import { createInvitedIdentity } from "../targets/selfhost";
2222 import { Api, Browser, Target } from "../src/services";
23+import { visit } from "../src/surfaces/browser";
2324
2425 const api = composePluginApi([openApiHttpPlugin()] as const);
2526 type Client = HttpApiClient.ForApi<typeof api>;
@@ -107,7 +108,7 @@ scenario( · 81 unmodified lines
107108
108109 yield* browser.session(owner, async ({ page, step }) => {
109110 await step("Open Users from the sidebar as the instance owner", async () => {
110− await page.goto("/", { waitUntil: "networkidle" });
111+ await visit(page, "/");
111112 await page
112113 .locator("nav")
113114 .getByRole("link", { name: "Users" })
@@ -289,7 +290,7 @@ scenario( · 175 unmodified lines
289290
290291 yield* browser.session(member, async ({ page, step }) => {
291292 await step("A plain member is not offered the section", async () => {
292− await page.goto("/", { waitUntil: "networkidle" });
293+ await visit(page, "/");
293294 await page
294295 .locator("nav")
295296 .getByRole("link", { name: "Integrations" })
@@ -302,7 +303,7 @@ scenario( · 6 unmodified lines
302303 });
303304
304305 await step("And reaching the URL directly is refused, not silently empty", async () => {
305− await page.goto("/users", { waitUntil: "networkidle" });
306+ await visit(page, "/users");
306307 await page
307308 .getByText("You don't have access to this workspace's users")
308309 .waitFor({ state: "visible", timeout: 30_000 });
e2e/selfhost/api-keys-feedback.test.ts+4−3
@@ -20,6 +20,7 @@ import { AccountHttpApi } from "@executor-js/api"; · 19 unmodified lines
2020
2121 import { scenario } from "../src/scenario";
2222 import { Api, Browser, Target } from "../src/services";
23+import { visit } from "../src/surfaces/browser";
2324
2425 declare global {
2526 interface Window {
@@ -39,7 +40,7 @@ scenario( · 13 unmodified lines
3940
4041 yield* browser.session(identity, async ({ page, step }) => {
4142 await step("Land on the dashboard", async () => {
42− await page.goto("/", { waitUntil: "networkidle" });
43+ await visit(page, "/");
4344 // The shared shell's main nav lists the workspace sections.
4445 await page.locator("nav").getByRole("link", { name: "Integrations" }).first().waitFor();
4546 });
@@ -105,7 +106,7 @@ scenario( · 59 unmodified lines
105106 });
106107
107108 await step("Open the API keys page", async () => {
108− await page.goto("/api-keys", { waitUntil: "networkidle" });
109+ await visit(page, "/api-keys");
109110 await page.getByRole("heading", { name: "API keys", exact: true }).waitFor();
110111 });
111112
@@ -174,7 +175,7 @@ scenario( · 62 unmodified lines
174175 });
175176
176177 await step("Create a new key", async () => {
177− await page.goto("/api-keys", { waitUntil: "networkidle" });
178+ await visit(page, "/api-keys");
178179 await page.getByRole("button", { name: "New key" }).click();
179180 const dialog = page.getByRole("dialog");
180181 await dialog.getByLabel("Name").fill(keyName);
e2e/selfhost/auth-methods-ui.test.ts+5−12
@@ -23,6 +23,7 @@ import { IntegrationSlug } from "@executor-js/sdk/shared"; · 22 unmodified lines
2323
2424 import { scenario } from "../src/scenario";
2525 import { Api, Browser, Target } from "../src/services";
26+import { visit } from "../src/surfaces/browser";
2627
2728 const api = composePluginApi([mcpHttpPlugin()] as const);
2829
@@ -160,9 +161,7 @@ scenario( · 131 unmodified lines
160161
161162 yield* browser.session(identity, async ({ page, step }) => {
162163 await step("Open the add-MCP flow pointed at the server", async () => {
163− await page.goto(`/integrations/add/mcp?url=${encodeURIComponent(server.endpoint)}`, {
164− waitUntil: "networkidle",
165− });
164+ await visit(page, `/integrations/add/mcp?url=${encodeURIComponent(server.endpoint)}`);
166165 // Generous timeout: the debounced probe request can queue behind a
167166 // busy dev server under CI load, and there is no clean client-side
168167 // re-trigger to poke it mid-flight.
@@ -216,9 +215,7 @@ scenario( · 47 unmodified lines
216215 yield* Effect.gen(function* () {
217216 yield* browser.session(identity, async ({ page, step }) => {
218217 await step("Open the add-MCP flow pointed at the Slack-shaped server", async () => {
219− await page.goto(`/integrations/add/mcp?url=${encodeURIComponent(server.endpoint)}`, {
220− waitUntil: "networkidle",
221− });
218+ await visit(page, `/integrations/add/mcp?url=${encodeURIComponent(server.endpoint)}`);
222219 await page.getByText("How does this server authenticate?").waitFor({ timeout: 90_000 });
223220 await page.getByText("OAuth metadata is discovered from this server").waitFor();
224221 });
@@ -324,9 +321,7 @@ scenario( · 99 unmodified lines
324321 yield* Effect.gen(function* () {
325322 yield* browser.session(identity, async ({ page, step }) => {
326323 await step("Open the integration's connect modal", async () => {
327− await page.goto(`/integrations/${slug}`, {
328− waitUntil: "networkidle",
329− });
324+ await visit(page, `/integrations/${slug}`);
330325 await page.getByRole("button", { name: "Add connection" }).first().click();
331326 await page.getByRole("tab", { name: "OAuth" }).waitFor();
332327 });
@@ -426,9 +421,7 @@ scenario( · 93 unmodified lines
426421 yield* Effect.gen(function* () {
427422 yield* browser.session(identity, async ({ page, step }) => {
428423 await step("Open the integration's connect modal", async () => {
429− await page.goto(`/integrations/${slug}`, {
430− waitUntil: "networkidle",
431− });
424+ await visit(page, `/integrations/${slug}`);
432425 await page.getByRole("button", { name: "Add connection" }).first().click();
433426 await page.getByRole("tab", { name: "API key (Authorization)" }).waitFor();
434427 });
e2e/selfhost/connect-modal-credential-ux.test.ts+2−1
@@ -18,6 +18,7 @@ import { IntegrationSlug } from "@executor-js/sdk/shared"; · 17 unmodified lines
1818
1919 import { scenario } from "../src/scenario";
2020 import { Api, Browser, Target } from "../src/services";
21+import { visit } from "../src/surfaces/browser";
2122
2223 const api = composePluginApi([openApiHttpPlugin()] as const);
2324
@@ -63,7 +64,7 @@ scenario( · 39 unmodified lines
6364
6465 yield* browser.session(identity, async ({ page, step }) => {
6566 await step("Open the connect modal", async () => {
66− await page.goto(`/integrations/${slug}?addAccount=1`, { waitUntil: "networkidle" });
67+ await visit(page, `/integrations/${slug}?addAccount=1`);
6768 await page.getByRole("heading", { name: /Add connection/ }).waitFor();
6869 });
6970
e2e/selfhost/detected-auth-immutable-ui.test.ts+3−4
@@ -16,6 +16,7 @@ import { OAuthTestServer } from "@executor-js/sdk/testing"; · 15 unmodified lines
1616
1717 import { scenario } from "../src/scenario";
1818 import { Browser, Target } from "../src/services";
19+import { visit } from "../src/surfaces/browser";
1920
2021 const REMOVE_HINT = "Pulled from spec. Remove to override.";
2122
@@ -36,9 +37,7 @@ scenario( · 14 unmodified lines
3637
3738 yield* browser.session(identity, async ({ page, step }) => {
3839 await step("Open the add-MCP flow pointed at the OAuth server", async () => {
39− await page.goto(`/integrations/add/mcp?url=${encodeURIComponent(server.endpoint)}`, {
40− waitUntil: "networkidle",
41− });
40+ await visit(page, `/integrations/add/mcp?url=${encodeURIComponent(server.endpoint)}`);
4241 await page.getByText("How does this server authenticate?").waitFor();
4342 await page.getByText("Method 1 · Detected").waitFor();
4443 });
@@ -145,7 +144,7 @@ scenario( · 100 unmodified lines
145144
146145 yield* browser.session(identity, async ({ page, step }) => {
147146 await step("Analyze a spec that declares both API key and OAuth", async () => {
148− await page.goto(`/integrations/add/openapi`, { waitUntil: "networkidle" });
147+ await visit(page, `/integrations/add/openapi`);
149148 await page
150149 .getByPlaceholder(/openapi\.json/i)
151150 .first()
e2e/selfhost/mcp-auth-required-add.test.ts+2−3
@@ -19,6 +19,7 @@ import { serveTestHttpApp } from "@executor-js/sdk/testing"; · 18 unmodified lines
1919
2020 import { scenario } from "../src/scenario";
2121 import { Api, Browser, Target } from "../src/services";
22+import { visit } from "../src/surfaces/browser";
2223
2324 const api = composePluginApi([mcpHttpPlugin()] as const);
2425
@@ -53,9 +54,7 @@ scenario( · 28 unmodified lines
5354 yield* Effect.gen(function* () {
5455 yield* browser.session(identity, async ({ page, step }) => {
5556 await step("Open the add-MCP flow pointed at the auth-gated server", async () => {
56− await page.goto(`/integrations/add/mcp?url=${encodeURIComponent(endpoint)}`, {
57− waitUntil: "networkidle",
58− });
57+ await visit(page, `/integrations/add/mcp?url=${encodeURIComponent(endpoint)}`);
5958 // Before the fix this dead-ended on a red "add credentials below"
6059 // error with no editor. Now the auth-method editor renders.
6160 await page.getByText("How does this server authenticate?").waitFor();
e2e/selfhost/mcp-connect-card-url.test.ts+2−1
@@ -15,6 +15,7 @@ import { Effect } from "effect"; · 14 unmodified lines
1515
1616 import { scenario } from "../src/scenario";
1717 import { Browser, Target } from "../src/services";
18+import { visit } from "../src/surfaces/browser";
1819
1920 scenario(
2021 "MCP · the URL printed by the Connect card is actually served",
@@ -28,7 +29,7 @@ scenario( · 7 unmodified lines
2829 let cardUrl = "";
2930 yield* browser.session(identity, async ({ page, step }) => {
3031 await step("Read the MCP URL from the Connect-an-agent card", async () => {
31− await page.goto("/", { waitUntil: "networkidle" });
32+ await visit(page, "/");
3233 await page.waitForTimeout(2000); // let the card resolve the server origin
3334 // The card renders `npx add-mcp <url> --transport http --name executor`.
3435 // The CodeBlock tokenizes it across spans; read the concatenated text.
e2e/selfhost/mcp-oauth-consent.test.ts+3−2
@@ -19,6 +19,7 @@ import { Effect } from "effect"; · 18 unmodified lines
1919
2020 import { scenario } from "../src/scenario";
2121 import { Browser, Target } from "../src/services";
22+import { visit } from "../src/surfaces/browser";
2223
2324 interface AuthServerMetadata {
2425 readonly authorization_endpoint: string;
@@ -78,7 +79,7 @@ scenario( · 53 unmodified lines
7879
7980 yield* browser.session(identity, async ({ page, step }) => {
8081 await step("A signed-in user is using their Executor instance", async () => {
81− await page.goto("/", { waitUntil: "networkidle" });
82+ await visit(page, "/");
8283 // Confirm we're in the app, not bounced to sign-in.
8384 expect(new URL(page.url()).pathname, "the session is active (not on /login)").not.toBe(
8485 "/login",
@@ -91,7 +92,7 @@ scenario( · 6 unmodified lines
9192 // A connecting MCP client opens this authorize URL. The server forces
9293 // prompt=consent, so it stops here on the approval screen instead of
9394 // auto-issuing a code.
94− await page.goto(authorize, { waitUntil: "networkidle" });
95+ await visit(page, authorize);
9596 await page.locator("#mcp-consent-allow").waitFor();
9697 expect(
9798 new URL(page.url()).pathname,
e2e/selfhost/mcp-oauth-reconnect-health.test.ts+4−3
@@ -21,6 +21,7 @@ import { serveOAuthTestServer, type OAuthTestServerShape } from "@executor-js/sd · 20 unmodified lines
2121
2222 import { scenario } from "../src/scenario";
2323 import { Api, Browser, Target } from "../src/services";
24+import { visit } from "../src/surfaces/browser";
2425
2526 const api = composePluginApi([mcpHttpPlugin()] as const);
2627 type Client = HttpApiClient.ForApi<typeof api>;
@@ -300,7 +301,7 @@ scenario( · 273 unmodified lines
300301 const menuTrigger = connections.locator('button[aria-haspopup="menu"]').first();
301302
302303 await step("Open the MCP integration with its expired OAuth connection", async () => {
303− await page.goto(`/integrations/${slug}`, { waitUntil: "networkidle" });
304+ await visit(page, `/integrations/${slug}`);
304305 await connections.getByText("main", { exact: true }).waitFor({ timeout: 30_000 });
305306 });
306307
@@ -380,7 +381,7 @@ scenario( · 73 unmodified lines
380381 const menuTrigger = connections.locator('button[aria-haspopup="menu"]').first();
381382
382383 await step("Open the integration: the connection reads Expired", async () => {
383− await page.goto(`/integrations/${slug}`, { waitUntil: "networkidle" });
384+ await visit(page, `/integrations/${slug}`);
384385 await connections.getByText("main", { exact: true }).waitFor({ timeout: 30_000 });
385386 await connections.getByLabel("Status: Expired").waitFor({ timeout: 30_000 });
386387 });
@@ -451,7 +452,7 @@ scenario( · 64 unmodified lines
451452 });
452453
453454 await step("Open the MCP integration with its DCR OAuth connection", async () => {
454− await page.goto(`/integrations/${slug}`, { waitUntil: "networkidle" });
455+ await visit(page, `/integrations/${slug}`);
455456 await connections.getByText("main", { exact: true }).waitFor({ timeout: 30_000 });
456457 });
457458
e2e/selfhost/mcp-oauth-slow-connect.test.ts+2−1
@@ -35,6 +35,7 @@ import { OAuthTestServer } from "@executor-js/sdk/testing"; · 34 unmodified lines
3535
3636 import { scenario } from "../src/scenario";
3737 import { Api, Browser, Target } from "../src/services";
38+import { visit } from "../src/surfaces/browser";
3839
3940 const api = composePluginApi([mcpHttpPlugin()] as const);
4041
@@ -68,7 +69,7 @@ scenario( · 27 unmodified lines
6869 await step("Add an OAuth-protected MCP integration", async () => {
6970 const addUrl = new URL("/integrations/add/mcp", target.baseUrl);
7071 addUrl.searchParams.set("url", server.endpoint);
71− await page.goto(addUrl.toString(), { waitUntil: "networkidle" });
72+ await visit(page, addUrl.toString());
7273 await page.getByText("How does this server authenticate?").waitFor({ timeout: 30_000 });
7374 await page.getByPlaceholder("e.g. Linear").fill(displayName);
7475 await page.getByRole("button", { name: "Add integration" }).click();
e2e/selfhost/mcp-opencode-consent.test.ts+2−1
@@ -22,6 +22,7 @@ import { Effect } from "effect"; · 21 unmodified lines
2222 import { scenario } from "../src/scenario";
2323 import { enterFocus, markRecordingStart } from "../src/timeline";
2424 import { Browser, Cli, Mcp, OpenCode, RunDir, Target } from "../src/services";
25+import { visit } from "../src/surfaces/browser";
2526
2627 const SERVER_NAME = "executor";
2728
@@ -119,7 +120,7 @@ scenario( · 91 unmodified lines
119120 await step("OpenCode asks to connect — the approval screen", async () => {
120121 // Authenticated (owner cookies) → authorize forces prompt=consent →
121122 // the approval screen.
122− await page.goto(authorizeUrl, { waitUntil: "networkidle" });
123+ await visit(page, authorizeUrl);
123124 await page.locator("#mcp-consent-allow").waitFor({ timeout: 30_000 });
124125 expect(new URL(page.url()).pathname, "lands on the approval screen").toBe(
125126 "/mcp-consent",
e2e/selfhost/oauth-app-modal.test.ts+2−1
@@ -16,6 +16,7 @@ import { openApiHttpPlugin } from "@executor-js/plugin-openapi/api"; · 15 unmodified lines
1616
1717 import { scenario } from "../src/scenario";
1818 import { Api, Browser, Target } from "../src/services";
19+import { visit } from "../src/surfaces/browser";
1920
2021 const api = composePluginApi([openApiHttpPlugin()] as const);
2122
@@ -81,7 +82,7 @@ scenario( · 59 unmodified lines
8182 const actions = page.getByRole("button", { name: `Actions for ${appName}` });
8283
8384 await step("Open the integration and start a new connection", async () => {
84− await page.goto(`/integrations/${integration}`, { waitUntil: "networkidle" });
85+ await visit(page, `/integrations/${integration}`);
8586 await page.getByRole("button", { name: "Add connection" }).click();
8687 // OAuth2 is the integration's only method, so the modal opens on
8788 // the OAuth app step with nothing registered yet. (`exact` avoids
e2e/selfhost/oauth-callback-unauthenticated.test.ts+2−1
@@ -14,6 +14,7 @@ import { serveOAuthTestServer } from "@executor-js/sdk/testing"; · 13 unmodified lines
1414
1515 import { scenario } from "../src/scenario";
1616 import { Api, Browser, Target } from "../src/services";
17+import { visit } from "../src/surfaces/browser";
1718
1819 const api = composePluginApi([openApiHttpPlugin()] as const);
1920
@@ -111,7 +112,7 @@ scenario( · 91 unmodified lines
111112
112113 yield* browser.session({ label: "anonymous" }, async ({ page, step }) => {
113114 await step("Provider sends a signed-out browser to the OAuth callback", async () => {
114− const response = await page.goto(callbackPath, { waitUntil: "networkidle" });
115+ const response = await visit(page, callbackPath);
115116 expect(response?.status(), "the callback redirects into the login flow").toBe(200);
116117 await page.getByRole("heading", { name: "Sign in" }).waitFor();
117118 });
e2e/selfhost/posthog-mcp-oauth.test.ts+2−1
@@ -22,6 +22,7 @@ import { OAuthTestServer } from "@executor-js/sdk/testing"; · 21 unmodified lines
2222
2323 import { scenario } from "../src/scenario";
2424 import { Api, Browser, Target } from "../src/services";
25+import { visit } from "../src/surfaces/browser";
2526
2627 const api = composePluginApi([mcpHttpPlugin()] as const);
2728
@@ -48,7 +49,7 @@ scenario( · 20 unmodified lines
4849 await step("Open the add-MCP flow pointed at the OAuth server", async () => {
4950 const addUrl = new URL("/integrations/add/mcp", target.baseUrl);
5051 addUrl.searchParams.set("url", server.endpoint);
51− await page.goto(addUrl.toString(), { waitUntil: "networkidle" });
52+ await visit(page, addUrl.toString());
5253 await page.getByText("How does this server authenticate?").waitFor({ timeout: 30_000 });
5354 await page.getByText("Method 1 · Detected").waitFor();
5455 await page.getByText("OAuth metadata is discovered from this server").waitFor();
e2e/src/emulator-instance.ts+65−14
@@ -1,20 +1,71 @@
1−import { Effect } from "effect";
1+import { Effect, Schedule } from "effect";
2+
3+/** The suite could not get an instance out of the hosted control plane. */
4+export class EmulatorInstanceError extends Error {
5+ readonly _tag = "EmulatorInstanceError";
6+
7+ constructor(
8+ readonly service: string,
9+ readonly reason: string,
10+ ) {
11+ super(`${service} emulator instance creation failed: ${reason}`);
12+ this.name = "EmulatorInstanceError";
13+ }
14+}
15+
16+// Bound each attempt: a hung connection to the edge must not eat the
17+// scenario's whole timeout before the first retry.
18+const ATTEMPT_TIMEOUT = "10 seconds";
19+const RETRIES = 3;
20+
21+const requestInstance = (service: string, label: string) =>
22+ Effect.tryPromise({
23+ try: async (): Promise<string> => {
24+ const response = await fetch(`https://${service}.emulators.dev/_emulate/instances`, {
25+ method: "POST",
26+ headers: { "content-type": "application/json" },
27+ body: JSON.stringify({ instance: label }),
28+ });
29+ if (!response.ok) {
30+ throw new EmulatorInstanceError(service, `HTTP ${response.status}`);
31+ }
32+ const instance = (await response.json()) as { readonly providerBaseUrl: string };
33+ return instance.providerBaseUrl;
34+ },
35+ catch: (cause) =>
36+ cause instanceof EmulatorInstanceError
37+ ? cause
38+ : new EmulatorInstanceError(service, String(cause)),
39+ });
240
341 // Hosted service hosts (e.g. resend.emulators.dev) are control plane only —
442 // there is no shared default instance behind them. Every scenario creates its
543 // own isolated instance and works against the returned providerBaseUrl, which
644 // also keeps ledger assertions free of cross-run pollution. The server
745 // generates an unguessable instance name; the label is a readable prefix.
8−export const createEmulatorInstance = (service: string, label = "e2e") =>
9− Effect.promise(async () => {
10− const response = await fetch(`https://${service}.emulators.dev/_emulate/instances`, {
11− method: "POST",
12− headers: { "content-type": "application/json" },
13− body: JSON.stringify({ instance: label }),
14− });
15− if (!response.ok) {
16− throw new Error(`${service} emulator instance creation failed: ${response.status}`);
17− }
18− const instance = (await response.json()) as { readonly providerBaseUrl: string };
19− return instance.providerBaseUrl;
20− });
46+//
47+// This is also the one request in a scenario that leaves the runner, so it is
48+// the one place where a CI runner's transient network trouble fails a scenario
49+// that has nothing to do with the network: `connect ETIMEDOUT` reaching the
50+// edge, plus the occasional bare 502, accounted for 17 shard failures in the
51+// two weeks to 2026-08-20. Asking for an instance is idempotent (a spare
52+// instance is nobody's business but the control plane's), so bound the attempt
53+// and retry with backoff. Nothing below this line retries anything the
54+// scenario is actually asserting on.
55+export const createEmulatorInstance = (service: string, label = "e2e"): Effect.Effect<string> =>
56+ requestInstance(service, label).pipe(
57+ Effect.timeoutOrElse({
58+ duration: ATTEMPT_TIMEOUT,
59+ orElse: () =>
60+ Effect.fail(new EmulatorInstanceError(service, `no response in ${ATTEMPT_TIMEOUT}`)),
61+ }),
62+ Effect.retry(
63+ Schedule.both(
64+ Schedule.exponential("500 millis").pipe(Schedule.jittered),
65+ Schedule.recurs(RETRIES),
66+ ),
67+ ),
68+ // An emulator the suite cannot reach at all is a defect in the run, not a
69+ // product failure the scenario should be asked to model.
70+ Effect.orDie,
71+ );
e2e/src/surfaces/browser.ts+95−2
@@ -9,7 +9,7 @@ import { join } from "node:path"; · 8 unmodified lines
99 import { promisify } from "node:util";
1010
1111 import { Effect } from "effect";
12−import { chromium, type Locator, type Page } from "playwright";
12+import { chromium, type Locator, type Page, type Response } from "playwright";
1313
1414 import { beat, enterFocus, markNavigation, markRecordingStart } from "../timeline";
1515 import { appendTraces, type TraceEntry } from "../trace-harvest";
@@ -36,10 +36,103 @@ const slug = (text: string): string => · 20 unmodified lines
3636 .replace(/^-+|-+$/g, "")
3737 .slice(0, 60);
3838
39+// How long a navigation may wait for the network to go quiet before giving up
40+// on quiet and letting the scenario's own assertion decide. Long enough to
41+// cover a cold vite compile's request burst, short enough that a page which
42+// never goes quiet costs seconds instead of the 30s navigation timeout.
43+const SETTLE_TIMEOUT_MS = 5_000;
44+
45+/**
46+ * Wait for the network to go quiet, but only for `SETTLE_TIMEOUT_MS`.
47+ *
48+ * Playwright's `networkidle` — 500ms with zero in-flight requests — cannot be
49+ * a hard gate in this suite, because the suite itself keeps the network busy:
50+ * every browser session exports OTel spans to the run's motel through
51+ * packages/react's `OtlpTracer` on a one-second interval (wired by
52+ * `setup/motel.ts` via VITE_PUBLIC_OTLP_TRACES_URL). A page that keeps
53+ * producing spans — a console retrying an org-scoped query that 403s, say —
54+ * feeds that exporter indefinitely, so the 500ms window never opens and the
55+ * wait burns its full timeout on a page that is, visibly, completely loaded.
56+ *
57+ * The quiet is worth waiting for when it comes (it usually arrives in
58+ * milliseconds, and it lets a step's screenshot catch a settled page), so keep
59+ * it — bounded. Readiness is asserted by whatever the scenario does next:
60+ * Playwright's locators auto-wait, so `getByRole(...).waitFor()` is a real,
61+ * page-specific readiness signal where `networkidle` was only ever a proxy.
62+ */
63+export const settle = async (page: Page): Promise<void> => {
64+ await page
65+ .waitForLoadState("networkidle", { timeout: SETTLE_TIMEOUT_MS })
66+ // oxlint-disable-next-line executor/no-promise-catch -- boundary: quiet is best-effort, the scenario's next assertion is the gate
67+ .catch(() => {});
68+};
69+
70+/**
71+ * Wait until React owns the document — the readiness `networkidle` was standing
72+ * in for, asked directly.
73+ *
74+ * React DOM stamps `__reactContainer$<id>` on the element it roots at the
75+ * moment `hydrateRoot` runs, so its presence says the SSR markup is under a
76+ * live root rather than inert HTML. That matters because it is the line either
77+ * side of which an interaction has a different meaning: before it, a click or
78+ * a `fill` goes to markup nobody is listening to and is lost without a trace;
79+ * after it, React records the event and replays it as hydration reaches that
80+ * subtree. Locators can't see the difference — the element is visible and
81+ * enabled either way — which is why a swallowed interaction surfaces 20
82+ * seconds later as "the thing it should have opened never appeared".
83+ *
84+ * Best-effort, like `settle`: plenty of pages the suite visits are not this
85+ * app at all (a provider's authorize screen, an OAuth callback result), and
86+ * they are none the worse for having no React root.
87+ */
88+const HYDRATION_TIMEOUT_MS = 10_000;
89+
90+export const hydrated = async (page: Page): Promise<void> => {
91+ await page
92+ .waitForFunction(
93+ () =>
94+ [...document.body.children].some((element) =>
95+ Object.keys(element).some((key) => key.startsWith("__reactContainer$")),
96+ ),
97+ undefined,
98+ { timeout: HYDRATION_TIMEOUT_MS },
99+ )
100+ // oxlint-disable-next-line executor/no-promise-catch -- boundary: a page with no React root is a legitimate destination
101+ .catch(() => {});
102+};
103+
104+/**
105+ * Navigate, then wait for the page to be interactive. The drop-in for a `goto`
106+ * that waited on `networkidle`, and a better one: it waits for React to take
107+ * the document (`hydrated`) and for the network to go quiet (`settle`), both
108+ * bounded, instead of one unbounded wait that proved neither. `timeout` bounds
109+ * the navigation itself, exactly as `goto`'s does.
110+ */
111+export const visit = async (
112+ page: Page,
113+ url: string,
114+ options: { readonly timeout?: number } = {},
115+): Promise<Response | null> => {
116+ const response = await page.goto(url, { waitUntil: "load", ...options });
117+ await hydrated(page);
118+ await settle(page);
119+ return response;
120+};
121+
122+/** `visit` for a reload — the drop-in for `reload({ waitUntil: "networkidle" })`. */
123+export const revisit = async (
124+ page: Page,
125+ options: { readonly timeout?: number } = {},
126+): Promise<void> => {
127+ await page.reload({ waitUntil: "load", ...options });
128+ await hydrated(page);
129+ await settle(page);
130+};
131+
39132 /**
40133 * Click `trigger` until `revealed` is visible.
41134 *
42− * `waitUntil: "networkidle"` does not mean the console has hydrated: a click
135+ * A settled network does not mean the console has hydrated: a click
43136 * that lands between the SSR paint and React attaching the handler is
44137 * swallowed without a trace, and whatever the click was meant to open never
45138 * appears (the "Connect an integration" dialog no-show flake). Re-clicking a
e2e/src/update-card-render.ts+2−1
@@ -10,6 +10,7 @@ import { Effect } from "effect"; · 9 unmodified lines
1010
1111 import { scenario } from "./scenario";
1212 import { Browser, Target } from "./services";
13+import { visit } from "./surfaces/browser";
1314
1415 export const FORCED_LATEST = "99.0.0";
1516
@@ -82,7 +83,7 @@ export const registerUpdateCardCurrentScenario = (name: string): void => · 66 unmodified lines
8283 );
8384
8485 await step("Open the console", async () => {
85− await page.goto("/", { waitUntil: "networkidle" });
86+ await visit(page, "/");
8687 await page.getByRole("heading", { name: "Integrations" }).waitFor({ timeout: 60_000 });
8788 });
8889

Keyboard shortcuts

General

Show keyboard shortcuts
?
Focus the current search
/

Issues and pull requests

Open filters
f

Issues, pull requests and Inbox

Select next item
j↓
Select previous item
k↑
Open selected item
Enter
Dismiss filters or clear selection
Esc

Repository code

Go to file
t