History

test(runtime-dynamic-worker): cross-layer integration with openApiPlugin (#485)

Adds the missing seam-test: sandboxed user code → ToolDispatcher RPC → makeExecutorToolInvoker → openApiPlugin → recording HttpClient layer. Each test inspects the actual request the plugin would have sent over the wire, so the JSON.stringify-strips-binary class of bug fails here even if it slips past every layer's individual unit tests. Cases: - multipart with Blob: file part contains the original bytes. - multipart with Uint8Array: bytes survive the dispatcher hop. - application/json: primitive object body round-trips. - application/octet-stream: Uint8Array body matches byte-for-byte. Implementation notes: - Recording layer built on `HttpClient.make` so it runs inside the Workers vitest pool (no real localhost server reachable from workerd). Multipart bodies are realised through `new Response(fd)` to get the actual on-the-wire boundary + bytes. - `runtime-dynamic-worker` adds dev-deps on `@executor-js/sdk`, `@executor-js/plugin-openapi`, `@executor-js/execution`. Adds `DOM` to the package's `lib` to satisfy `globalThis.crypto` in transitively imported sdk source. Why this matters: every prior multipart test in the openapi plugin called the plugin directly. Nothing exercised the chain from inside the sandbox, which is exactly where the binary-data bug lived.

Rhys Sullivan committed 6 months ago

Commit 4e7c61e1 parent 4e4d4fc4 files · +280 −1Browse files

Unified · Split

4 files
  1. bun.lock +3 −0
  2. packages/kernel/runtime-dynamic-worker/package.json +3 −0
  3. packages/kernel/runtime-dynamic-worker/src/integration.test.ts +273 −0
  4. packages/kernel/runtime-dynamic-worker/tsconfig.json +1 −1
bun.lock+3−0
@@ -494,6 +494,9 @@ · 493 unmodified lines
494494 "@cloudflare/vitest-pool-workers": "^0.14.2",
495495 "@cloudflare/workers-types": "^4.20250620.0",
496496 "@effect/vitest": "catalog:",
497+ "@executor-js/execution": "workspace:*",
498+ "@executor-js/plugin-openapi": "workspace:*",
499+ "@executor-js/sdk": "workspace:*",
497500 "@types/node": "catalog:",
498501 "vitest": "catalog:",
499502 "wrangler": "^4.81.0",
packages/kernel/runtime-dynamic-worker/package.json+3−0
@@ -20,6 +20,9 @@ · 19 unmodified lines
2020 "@cloudflare/vitest-pool-workers": "^0.14.2",
2121 "@cloudflare/workers-types": "^4.20250620.0",
2222 "@effect/vitest": "catalog:",
23+ "@executor-js/execution": "workspace:*",
24+ "@executor-js/plugin-openapi": "workspace:*",
25+ "@executor-js/sdk": "workspace:*",
2326 "@types/node": "catalog:",
2427 "vitest": "catalog:",
2528 "wrangler": "^4.81.0"
packages/kernel/runtime-dynamic-worker/src/integration.test.ts+273−0
@@ -0,0 +1,273 @@
1+// ---------------------------------------------------------------------------
2+// Cross-layer integration tests: sandboxed user code → ToolDispatcher RPC →
3+// makeExecutorToolInvoker → openApiPlugin → recording HttpClient.
4+//
5+// These exist to catch the class of bug where each layer's unit tests pass
6+// in isolation but the seam between two layers loses or corrupts data. The
7+// canonical case is a multipart upload where the user constructs a Blob in
8+// sandbox code: every layer accepts Blobs in its own contract, but the
9+// sandbox→host RPC hop used to JSON.stringify the args, leaving the
10+// upstream multipart encoder with `{}` where the file should have been.
11+//
12+// Each test runs real user code through the dynamic Worker, drives a real
13+// openApiPlugin (with a real spec), and inspects the actual request body
14+// that would have hit the wire.
15+// ---------------------------------------------------------------------------
16+
17+import { describe, expect, it } from "@effect/vitest";
18+import { env } from "cloudflare:workers";
19+import * as Effect from "effect/Effect";
20+import * as Layer from "effect/Layer";
21+import { HttpClient, HttpClientResponse, type HttpClientRequest } from "effect/unstable/http";
22+
23+import {
24+ createExecutor,
25+ definePlugin,
26+ makeTestConfig,
27+ type InvokeOptions,
28+ type SecretProvider,
29+} from "@executor-js/sdk";
30+import { makeExecutorToolInvoker } from "@executor-js/execution";
31+import { openApiPlugin } from "@executor-js/plugin-openapi";
32+
33+import { makeDynamicWorkerExecutor } from "./executor";
34+
35+// ---------------------------------------------------------------------------
36+// Test harness
37+// ---------------------------------------------------------------------------
38+
39+const autoApprove: InvokeOptions = { onElicitation: "accept-all" };
40+const TEST_SCOPE = "test-scope";
41+
42+const memoryProvider: SecretProvider = (() => {
43+ const store = new Map<string, string>();
44+ return {
45+ key: "memory",
46+ writable: true,
47+ get: (id, scope) => Effect.sync(() => store.get(`${scope}:${id}`) ?? null),
48+ set: (id, value, scope) =>
49+ Effect.sync(() => {
50+ store.set(`${scope}:${id}`, value);
51+ }),
52+ delete: (id, scope) => Effect.sync(() => store.delete(`${scope}:${id}`)),
53+ list: () => Effect.sync(() => []),
54+ };
55+})();
56+
57+const memorySecretsPlugin = definePlugin(() => ({
58+ id: "memory-secrets" as const,
59+ storage: () => ({}),
60+ secretProviders: [memoryProvider],
61+}));
62+
63+type CapturedRequest = {
64+ url: string;
65+ method: string;
66+ contentType: string;
67+ bodyKind: string;
68+ body: Uint8Array;
69+};
70+
71+/**
72+ * Build an HttpClient layer that captures every request the openApiPlugin
73+ * dispatches, returning a 200 OK with `{}`. Captured requests are exposed
74+ * via the returned `captured` array (mutated in place). Reads multipart
75+ * `FormData` bodies into their on-the-wire bytes via the platform `Response`
76+ * encoder so assertions can match the actual multipart frame.
77+ */
78+const makeRecordingHttpClient = () => {
79+ const captured: CapturedRequest[] = [];
80+
81+ const layer = Layer.succeed(
82+ HttpClient.HttpClient,
83+ HttpClient.make((request: HttpClientRequest.HttpClientRequest) =>
84+ Effect.gen(function* () {
85+ const headers = { ...request.headers };
86+ let bytes = new Uint8Array();
87+ let contentType = headers["content-type"] ?? "";
88+ const tag = request.body._tag;
89+
90+ if (tag === "Raw" || tag === "Uint8Array") {
91+ const wire = new Request("http://capture/", {
92+ method: "POST",
93+ body: request.body.body as BodyInit,
94+ });
95+ bytes = new Uint8Array(yield* Effect.promise(() => wire.arrayBuffer()));
96+ } else if (tag === "FormData") {
97+ // Letting `Response` realize the FormData yields the actual
98+ // multipart wire bytes plus a generated boundary in its
99+ // content-type header — exactly what the upstream server sees.
100+ const wire = new Response(request.body.formData);
101+ contentType = wire.headers.get("content-type") ?? contentType;
102+ bytes = new Uint8Array(yield* Effect.promise(() => wire.arrayBuffer()));
103+ }
104+
105+ captured.push({
106+ url: request.url,
107+ method: request.method,
108+ contentType,
109+ bodyKind: tag,
110+ body: bytes,
111+ });
112+
113+ return HttpClientResponse.fromWeb(
114+ request,
115+ new Response('{"ok":true}', {
116+ status: 200,
117+ headers: { "content-type": "application/json" },
118+ }),
119+ );
120+ }),
121+ ),
122+ );
123+
124+ return { layer, captured };
125+};
126+
127+const makeSpec = (contentType: string, schema: Record<string, unknown> = { type: "object" }) =>
128+ JSON.stringify({
129+ openapi: "3.0.0",
130+ info: { title: "IntegrationTest", version: "1.0.0" },
131+ paths: {
132+ "/submit": {
133+ post: {
134+ operationId: "submit",
135+ tags: ["body"],
136+ requestBody: {
137+ required: true,
138+ content: { [contentType]: { schema } },
139+ },
140+ responses: { "200": { description: "ok" } },
141+ },
142+ },
143+ },
144+ });
145+
146+const buildSandboxBridge = (spec: string, namespace: string, baseUrl = "https://upstream.test") =>
147+ Effect.gen(function* () {
148+ const recording = makeRecordingHttpClient();
149+ const executor = yield* createExecutor(
150+ makeTestConfig({
151+ plugins: [
152+ openApiPlugin({ httpClientLayer: recording.layer }),
153+ memorySecretsPlugin(),
154+ ] as const,
155+ }),
156+ );
157+ yield* executor.openapi.addSpec({ spec, scope: TEST_SCOPE, namespace, baseUrl });
158+ const invoker = makeExecutorToolInvoker(executor, { invokeOptions: autoApprove });
159+ return { executor, invoker, captured: recording.captured };
160+ });
161+
162+const loader = (env as { LOADER: WorkerLoader }).LOADER;
163+
164+// ---------------------------------------------------------------------------
165+// Tests
166+// ---------------------------------------------------------------------------
167+
168+describe("sandbox → openApiPlugin integration", () => {
169+ it.effect("multipart with Blob: file part contains the original bytes", () =>
170+ Effect.gen(function* () {
171+ const { invoker, captured } = yield* buildSandboxBridge(
172+ makeSpec("multipart/form-data"),
173+ "mp",
174+ );
175+ const sandbox = makeDynamicWorkerExecutor({ loader });
176+
177+ const result = yield* sandbox.execute(
178+ `async () => {
179+ const file = new Blob(["hello multipart"], { type: "text/plain" });
180+ await tools.mp.body.submit({ body: { file, name: "Acme" } });
181+ }`,
182+ invoker,
183+ );
184+
185+ expect(result.error).toBeUndefined();
186+ expect(captured).toHaveLength(1);
187+ const req = captured[0]!;
188+ expect(req.contentType).toMatch(/^multipart\/form-data; boundary=/);
189+ const wire = new TextDecoder().decode(req.body);
190+ expect(wire).toMatch(/name="file"[\s\S]*?hello multipart/);
191+ expect(wire).toContain('name="name"');
192+ expect(wire).toContain("Acme");
193+ // Regression guard for the JSON-stringify bug — the symptom was
194+ // either an empty body part or `[object Object]` in place of bytes.
195+ expect(wire).not.toContain("[object Object]");
196+ }),
197+ );
198+
199+ it.effect("multipart with Uint8Array: bytes survive intact", () =>
200+ Effect.gen(function* () {
201+ const { invoker, captured } = yield* buildSandboxBridge(
202+ makeSpec("multipart/form-data"),
203+ "u8",
204+ );
205+ const sandbox = makeDynamicWorkerExecutor({ loader });
206+
207+ const result = yield* sandbox.execute(
208+ `async () => {
209+ const bytes = new Uint8Array([0xde, 0xad, 0xbe, 0xef]);
210+ await tools.u8.body.submit({ body: { file: bytes } });
211+ }`,
212+ invoker,
213+ );
214+
215+ expect(result.error).toBeUndefined();
216+ const wire = captured[0]!.body;
217+ // Find DEADBEEF anywhere in the multipart frame.
218+ const needle = new Uint8Array([0xde, 0xad, 0xbe, 0xef]);
219+ let found = false;
220+ for (let i = 0; i <= wire.length - needle.length; i++) {
221+ if (
222+ wire[i] === needle[0] &&
223+ wire[i + 1] === needle[1] &&
224+ wire[i + 2] === needle[2] &&
225+ wire[i + 3] === needle[3]
226+ ) {
227+ found = true;
228+ break;
229+ }
230+ }
231+ expect(found).toBe(true);
232+ }),
233+ );
234+
235+ it.effect("application/json: primitive object body round-trips unchanged", () =>
236+ Effect.gen(function* () {
237+ const { invoker, captured } = yield* buildSandboxBridge(makeSpec("application/json"), "j");
238+ const sandbox = makeDynamicWorkerExecutor({ loader });
239+
240+ const result = yield* sandbox.execute(
241+ `async () => {
242+ await tools.j.body.submit({ body: { name: "Acme", count: 7, ok: true } });
243+ }`,
244+ invoker,
245+ );
246+
247+ expect(result.error).toBeUndefined();
248+ const json = JSON.parse(new TextDecoder().decode(captured[0]!.body));
249+ expect(json).toEqual({ name: "Acme", count: 7, ok: true });
250+ }),
251+ );
252+
253+ it.effect("application/octet-stream: Uint8Array body matches byte-for-byte", () =>
254+ Effect.gen(function* () {
255+ const { invoker, captured } = yield* buildSandboxBridge(
256+ makeSpec("application/octet-stream"),
257+ "oct",
258+ );
259+ const sandbox = makeDynamicWorkerExecutor({ loader });
260+
261+ const result = yield* sandbox.execute(
262+ `async () => {
263+ const payload = new Uint8Array([1, 2, 3, 4, 5, 0xff, 0x00, 0x7f]);
264+ await tools.oct.body.submit({ body: payload });
265+ }`,
266+ invoker,
267+ );
268+
269+ expect(result.error).toBeUndefined();
270+ expect(Array.from(captured[0]!.body)).toEqual([1, 2, 3, 4, 5, 0xff, 0x00, 0x7f]);
271+ }),
272+ );
273+});
packages/kernel/runtime-dynamic-worker/tsconfig.json+1−1
@@ -6,7 +6,7 @@ · 5 unmodified lines
66 "strict": true,
77 "skipLibCheck": true,
88 "noEmit": true,
9− "lib": ["ES2022"],
9+ "lib": ["ES2022", "DOM"],
1010 "types": ["@cloudflare/workers-types", "node"],
1111 "noUnusedLocals": true,
1212 "noImplicitOverride": true,