Skip to content

Commit de0dc1c

Browse files
committed
Stabilize cloud E2E scenarios
1 parent 0b0b74f commit de0dc1c

2 files changed

Lines changed: 144 additions & 144 deletions

File tree

e2e/cloud/mcp-browser-resume-page.test.ts

Lines changed: 29 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -215,43 +215,37 @@ scenario(
215215
session.transport.sessionId,
216216
);
217217

218-
const [resumed] = yield* Effect.all(
219-
[
220-
Effect.promise(() =>
221-
session.client.callTool({
222-
name: "resume",
223-
arguments: { executionId: approval.executionId },
224-
}),
225-
),
226-
browser.session(identity, async ({ page, step }) => {
227-
await step("Open the paused execution approval page", async () => {
228-
await visit(page, pathWithSearch(approval.approvalUrl));
229-
await page.getByText("User approval required").waitFor();
230-
});
218+
yield* browser.session(identity, async ({ page, step }) => {
219+
// Keep the entire decision in one recorded step. Each step holds on
220+
// its screenshot for review; splitting this into three steps can use
221+
// up the suite's intentionally compressed six-second idle window
222+
// before the click, while production allows nine minutes.
223+
await step("Approve the paused tool call through the browser page", async () => {
224+
await visit(page, pathWithSearch(approval.approvalUrl));
225+
await page.getByText("User approval required").waitFor();
226+
await page.getByText("Pending request").waitFor();
227+
await page.getByText(/Approve executor\.coreTools\.policies\.list\?/).waitFor();
231228

232-
await step("Review the paused tool call details", async () => {
233-
await page.getByText("Pending request").waitFor();
234-
await page.getByText(/Approve executor\.coreTools\.policies\.list\?/).waitFor();
235-
236-
const approve = page.getByRole("button", { name: "Approve" });
237-
await approve.waitFor();
238-
expect(
239-
await approve.isEnabled(),
240-
"the approve control is enabled for the paused execution",
241-
).toBe(true);
242-
expect(
243-
await page.getByText(UNAVAILABLE_COPY).count(),
244-
"the resume page does not show the expired-session failure copy",
245-
).toBe(0);
246-
});
229+
const approve = page.getByRole("button", { name: "Approve" });
230+
await approve.waitFor();
231+
expect(
232+
await approve.isEnabled(),
233+
"the approve control is enabled for the paused execution",
234+
).toBe(true);
235+
expect(
236+
await page.getByText(UNAVAILABLE_COPY).count(),
237+
"the resume page does not show the expired-session failure copy",
238+
).toBe(0);
239+
await page.getByRole("button", { name: "Approve" }).click();
240+
await page.getByText("Approve sent").waitFor();
241+
});
242+
});
247243

248-
await step("Approve the paused tool call", async () => {
249-
await page.getByRole("button", { name: "Approve" }).click();
250-
await page.getByText("Approve sent").waitFor();
251-
});
252-
}),
253-
],
254-
{ concurrency: "unbounded" },
244+
const resumed = yield* Effect.promise(() =>
245+
session.client.callTool({
246+
name: "resume",
247+
arguments: { executionId: approval.executionId },
248+
}),
255249
);
256250

257251
expect(resumed.isError, "browser-mode resume completed after the UI approval").not.toBe(
Lines changed: 115 additions & 109 deletions
Original file line numberDiff line numberDiff line change
@@ -1,140 +1,146 @@
11
import { randomBytes } from "node:crypto";
2+
import { createServer } from "node:http";
23

34
import { expect } from "@effect/vitest";
45
import { Effect } from "effect";
56
import { composePluginApi } from "@executor-js/api/server";
6-
import { connectEmulator } from "@executor-js/emulate";
77
import { graphqlHttpPlugin } from "@executor-js/plugin-graphql/api";
88
import { AuthTemplateSlug, ConnectionName, IntegrationSlug } from "@executor-js/sdk/shared";
99
import { variable } from "@executor-js/sdk/http-auth";
1010

11-
import { createEmulatorInstance } from "../src/emulator-instance";
1211
import { scenario } from "../src/scenario";
1312
import { Api, Browser, Target } from "../src/services";
1413
import { visit } from "../src/surfaces/browser";
1514

1615
const api = composePluginApi([graphqlHttpPlugin()] as const);
1716
const unique = (prefix: string): string => `${prefix}_${randomBytes(4).toString("hex")}`;
1817

18+
const serveRejectingGraphql = () =>
19+
Effect.acquireRelease(
20+
Effect.callback<{ readonly url: string; readonly close: () => void }>((resume) => {
21+
const server = createServer((request, response) => {
22+
if (request.method === "POST" && request.url === "/graphql") {
23+
response.writeHead(401, { "content-type": "application/json" });
24+
response.end(JSON.stringify({ message: "Bad credentials" }));
25+
return;
26+
}
27+
response.writeHead(404, { "content-type": "application/json" });
28+
response.end(JSON.stringify({ message: "Not found" }));
29+
});
30+
server.listen(0, "127.0.0.1", () => {
31+
const address = server.address();
32+
const port = typeof address === "object" && address ? address.port : 0;
33+
resume(
34+
Effect.succeed({
35+
url: `http://127.0.0.1:${port}`,
36+
close: () => {
37+
server.close();
38+
server.closeAllConnections();
39+
},
40+
}),
41+
);
42+
});
43+
}),
44+
(server) => Effect.sync(server.close),
45+
);
46+
1947
scenario(
2048
"GraphQL · failed introspection blocks connection creation with an actionable error",
2149
{},
22-
Effect.gen(function* () {
23-
const target = yield* Target;
24-
const browser = yield* Browser;
25-
const { client: makeApiClient } = yield* Api;
26-
const identity = yield* target.newIdentity();
27-
const client = yield* makeApiClient(api, identity);
28-
const slug = unique("graphql_health");
29-
const emulatorBaseUrl = yield* createEmulatorInstance("github", "graphql-health");
30-
const emulator = yield* Effect.promise(() =>
31-
connectEmulator({ baseUrl: emulatorBaseUrl, service: "github" }),
32-
);
50+
Effect.scoped(
51+
Effect.gen(function* () {
52+
const target = yield* Target;
53+
const browser = yield* Browser;
54+
const { client: makeApiClient } = yield* Api;
55+
const identity = yield* target.newIdentity();
56+
const client = yield* makeApiClient(api, identity);
57+
const slug = unique("graphql_health");
58+
const upstream = yield* serveRejectingGraphql();
3359

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.
41-
yield* Effect.promise(() =>
42-
emulator.faults.arm({
43-
match: { method: "POST", pathPattern: "/graphql" },
44-
response: { status: 401, body: { message: "Bad credentials" } },
45-
times: 100,
46-
}),
47-
);
60+
yield* client.graphql.addIntegration({
61+
payload: {
62+
endpoint: `${upstream.url}/graphql`,
63+
slug,
64+
name: "GraphQL health",
65+
authenticationTemplate: [
66+
{
67+
slug: "header",
68+
type: "apiKey",
69+
headers: { Authorization: [variable("token")] },
70+
},
71+
],
72+
},
73+
});
4874

49-
yield* client.graphql.addIntegration({
50-
payload: {
51-
endpoint: `${emulatorBaseUrl}/graphql`,
52-
slug,
53-
name: "GraphQL health",
54-
authenticationTemplate: [
55-
{
56-
slug: "header",
57-
type: "apiKey",
58-
headers: { Authorization: [variable("token")] },
59-
},
60-
],
61-
},
62-
});
75+
yield* Effect.gen(function* () {
76+
yield* browser.session(identity, async ({ page, step }) => {
77+
await step("Open the connection flow", async () => {
78+
await visit(page, `/integrations/${slug}?addAccount=1&owner=org&template=header`);
79+
await page.getByRole("heading", { name: /Add connection · GraphQL health/ }).waitFor();
80+
});
6381

64-
yield* Effect.gen(function* () {
65-
yield* browser.session(identity, async ({ page, step }) => {
66-
await step("Open the connection flow", async () => {
67-
await visit(page, `/integrations/${slug}?addAccount=1&owner=org&template=header`);
68-
await page.getByRole("heading", { name: /Add connection · GraphQL health/ }).waitFor();
69-
});
82+
await step("Submit a credential rejected during schema introspection", async () => {
83+
const dialog = page.getByRole("dialog", {
84+
name: /Add connection · GraphQL health/,
85+
});
86+
await dialog.getByRole("textbox", { name: "Authorization" }).fill("invalid-token");
87+
await dialog.getByRole("button", { name: "Continue" }).click();
7088

71-
await step("Submit a credential rejected during schema introspection", async () => {
72-
const dialog = page.getByRole("dialog", {
73-
name: /Add connection · GraphQL health/,
89+
const alert = dialog.getByRole("alert");
90+
await alert.waitFor();
91+
const message = await alert.textContent();
92+
expect(message).toContain("The endpoint rejected the credential with HTTP 401.");
93+
expect(message).toContain("Check the credential and selected authentication method.");
94+
await dialog.getByText("Step 1 of 2").waitFor();
95+
expect(
96+
await page.getByText("No connections yet").count(),
97+
"the rejected credential is not saved",
98+
).toBe(1);
7499
});
75-
await dialog.getByRole("textbox", { name: "Authorization" }).fill("invalid-token");
76-
await dialog.getByRole("button", { name: "Continue" }).click();
77-
78-
const alert = dialog.getByRole("alert");
79-
await alert.waitFor();
80-
const message = await alert.textContent();
81-
expect(message).toContain("The endpoint rejected the credential with HTTP 401.");
82-
expect(message).toContain("Check the credential and selected authentication method.");
83-
await dialog.getByText("Step 1 of 2").waitFor();
84-
expect(
85-
await page.getByText("No connections yet").count(),
86-
"the rejected credential is not saved",
87-
).toBe(1);
88100
});
89-
});
90-
91-
// The low-level API can still import an existing credential reference
92-
// without the browser's preflight. This models connections created before
93-
// the fix and proves their failed tool sync is no longer a silent zero.
94-
yield* client.connections.create({
95-
payload: {
96-
owner: "org",
97-
name: ConnectionName.make("legacy"),
98-
integration: IntegrationSlug.make(slug),
99-
template: AuthTemplateSlug.make("header"),
100-
value: "invalid-token",
101-
},
102-
});
103101

104-
yield* browser.session(identity, async ({ page, step }) => {
105-
await step("A failed existing connection explains the empty tool catalogue", async () => {
106-
await visit(page, `/integrations/${slug}?tab=tools`);
107-
await page.getByText("Connection rejected", { exact: true }).first().waitFor();
108-
await page
109-
.getByText("The endpoint rejected the credential with HTTP 401.", {
110-
exact: false,
111-
})
112-
.waitFor();
113-
await page.getByRole("button", { name: "Check and sync tools" }).waitFor();
102+
// The low-level API can still import an existing credential reference
103+
// without the browser's preflight. This models connections created before
104+
// the fix and proves their failed tool sync is no longer a silent zero.
105+
yield* client.connections.create({
106+
payload: {
107+
owner: "org",
108+
name: ConnectionName.make("legacy"),
109+
integration: IntegrationSlug.make(slug),
110+
template: AuthTemplateSlug.make("header"),
111+
value: "invalid-token",
112+
},
114113
});
115114

116-
await step("The account row carries the same actionable health verdict", async () => {
117-
await page.getByRole("tab", { name: "Accounts" }).click();
118-
await page.getByText("Expired", { exact: true }).waitFor();
119-
await page
120-
.getByText("Check the credential and selected authentication method.", {
121-
exact: false,
122-
})
123-
.waitFor();
115+
yield* browser.session(identity, async ({ page, step }) => {
116+
await step("A failed existing connection explains the empty tool catalogue", async () => {
117+
await visit(page, `/integrations/${slug}?tab=tools`);
118+
await page.getByText("Connection rejected", { exact: true }).first().waitFor();
119+
await page
120+
.getByText("The endpoint rejected the credential with HTTP 401.", {
121+
exact: false,
122+
})
123+
.waitFor();
124+
await page.getByRole("button", { name: "Check and sync tools" }).waitFor();
125+
});
126+
127+
await step("The account row carries the same actionable health verdict", async () => {
128+
await page.getByRole("tab", { name: "Accounts" }).click();
129+
await page.getByText("Expired", { exact: true }).waitFor();
130+
await page
131+
.getByText("Check the credential and selected authentication method.", {
132+
exact: false,
133+
})
134+
.waitFor();
135+
});
124136
});
125-
});
126-
}).pipe(
127-
Effect.ensuring(
128-
Effect.all(
129-
[
130-
client.integrations
131-
.remove({ params: { slug: IntegrationSlug.make(slug) } })
132-
.pipe(Effect.ignore),
133-
Effect.promise(() => emulator.faults.clear()).pipe(Effect.ignore),
134-
],
135-
{ concurrency: "unbounded" },
137+
}).pipe(
138+
Effect.ensuring(
139+
client.integrations
140+
.remove({ params: { slug: IntegrationSlug.make(slug) } })
141+
.pipe(Effect.ignore),
136142
),
137-
),
138-
);
139-
}),
143+
);
144+
}),
145+
),
140146
);

0 commit comments

Comments
 (0)