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
494 "@cloudflare/vitest-pool-workers": "^0.14.2",494 "@cloudflare/vitest-pool-workers": "^0.14.2",
495 "@cloudflare/workers-types": "^4.20250620.0",495 "@cloudflare/workers-types": "^4.20250620.0",
496 "@effect/vitest": "catalog:",496 "@effect/vitest": "catalog:",
497 "@executor-js/execution": "workspace:*",
498 "@executor-js/plugin-openapi": "workspace:*",
499 "@executor-js/sdk": "workspace:*",
497 "@types/node": "catalog:",500 "@types/node": "catalog:",
498 "vitest": "catalog:",501 "vitest": "catalog:",
499 "wrangler": "^4.81.0",502 "wrangler": "^4.81.0",
packages/kernel/runtime-dynamic-worker/package.json+3−0
@@ -20,6 +20,9 @@ · 19 unmodified lines
20 "@cloudflare/vitest-pool-workers": "^0.14.2",20 "@cloudflare/vitest-pool-workers": "^0.14.2",
21 "@cloudflare/workers-types": "^4.20250620.0",21 "@cloudflare/workers-types": "^4.20250620.0",
22 "@effect/vitest": "catalog:",22 "@effect/vitest": "catalog:",
23 "@executor-js/execution": "workspace:*",
24 "@executor-js/plugin-openapi": "workspace:*",
25 "@executor-js/sdk": "workspace:*",
23 "@types/node": "catalog:",26 "@types/node": "catalog:",
24 "vitest": "catalog:",27 "vitest": "catalog:",
25 "wrangler": "^4.81.0"28 "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
17import { describe, expect, it } from "@effect/vitest";
18import { env } from "cloudflare:workers";
19import * as Effect from "effect/Effect";
20import * as Layer from "effect/Layer";
21import { HttpClient, HttpClientResponse, type HttpClientRequest } from "effect/unstable/http";
22
23import {
24 createExecutor,
25 definePlugin,
26 makeTestConfig,
27 type InvokeOptions,
28 type SecretProvider,
29} from "@executor-js/sdk";
30import { makeExecutorToolInvoker } from "@executor-js/execution";
31import { openApiPlugin } from "@executor-js/plugin-openapi";
32
33import { makeDynamicWorkerExecutor } from "./executor";
34
35// ---------------------------------------------------------------------------
36// Test harness
37// ---------------------------------------------------------------------------
38
39const autoApprove: InvokeOptions = { onElicitation: "accept-all" };
40const TEST_SCOPE = "test-scope";
41
42const 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
57const memorySecretsPlugin = definePlugin(() => ({
58 id: "memory-secrets" as const,
59 storage: () => ({}),
60 secretProviders: [memoryProvider],
61}));
62
63type 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 */
78const 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
127const 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
146const 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
162const loader = (env as { LOADER: WorkerLoader }).LOADER;
163
164// ---------------------------------------------------------------------------
165// Tests
166// ---------------------------------------------------------------------------
167
168describe("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
6 "strict": true,6 "strict": true,
7 "skipLibCheck": true,7 "skipLibCheck": true,
8 "noEmit": true,8 "noEmit": true,
9 "lib": ["ES2022"],9 "lib": ["ES2022", "DOM"],
10 "types": ["@cloudflare/workers-types", "node"],10 "types": ["@cloudflare/workers-types", "node"],
11 "noUnusedLocals": true,11 "noUnusedLocals": true,
12 "noImplicitOverride": true,12 "noImplicitOverride": true,