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

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