| @@ -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 | }); |