Adds 7 tests that pin down what crosses the sandbox→host RPC boundary beyond the minimal multipart repros in the previous commit. Each one documents an invariant that, if regressed, surfaces silently in user code: - File: name and lastModified survive the codec. - Blob nested deep in arrays-of-objects: codec walks plain objects and arrays symmetrically, doesn't bail at depth 1. - Date / Map / Set: types Workers RPC structured-clone supports natively. Regressing toward JSON-only would silently corrupt these. - tools proxy `then` returns undefined: awaiting `tools.foo` (without calling it) must not be thenable, or it would hang or trigger a phantom tool call. - tools proxy with empty path throws a useful error. - tools proxy with >2 segment paths joins them correctly. These didn't exist before the multipart bug surfaced; the dispatcher test contract had only ever fed JSON-clean shapes through the seam, so any non-JSON type was an undocumented question mark.
Unified · Split
| @@ -421,4 +421,186 @@ describe("makeDynamicWorkerExecutor", () => { · 420 unmodified lines | ||
| 421 | 421 | expect(result.result).toEqual({ kind: "Blob", type: "text/plain", text: "DOWNLOAD" }); |
| 422 | 422 | }), |
| 423 | 423 | ); |
| 424 | + | |
| 425 | + it.effect("preserves File tool args (name + lastModified survive)", () => | |
| 426 | + Effect.gen(function* () { | |
| 427 | + const executor = makeDynamicWorkerExecutor({ loader }); | |
| 428 | + let captured: { upload?: unknown } = {}; | |
| 429 | + const invoker = makeInvoker(({ args }) => { | |
| 430 | + captured = (args ?? {}) as { upload?: unknown }; | |
| 431 | + return null; | |
| 432 | + }); | |
| 433 | + | |
| 434 | + const result = yield* executor.execute( | |
| 435 | + `async () => { | |
| 436 | + const upload = new File(["hi"], "report.txt", { | |
| 437 | + type: "text/plain", | |
| 438 | + lastModified: 1700000000000, | |
| 439 | + }); | |
| 440 | + await tools.uploads.send({ upload }); | |
| 441 | + }`, | |
| 442 | + invoker, | |
| 443 | + ); | |
| 444 | + | |
| 445 | + expect(result.error).toBeUndefined(); | |
| 446 | + expect(captured.upload).toBeInstanceOf(File); | |
| 447 | + const file = captured.upload as File; | |
| 448 | + expect(file.name).toBe("report.txt"); | |
| 449 | + expect(file.type).toBe("text/plain"); | |
| 450 | + expect(file.lastModified).toBe(1700000000000); | |
| 451 | + }), | |
| 452 | + ); | |
| 453 | + | |
| 454 | + // Codec recursion: Blob nested inside an array inside an object inside | |
| 455 | + // an array. The structural concern is that `__encodeBinary` walks plain | |
| 456 | + // objects + arrays symmetrically. If it stopped at the first level the | |
| 457 | + // inner Blob would arrive as `{}`. | |
| 458 | + it.effect("preserves Blob args nested deep in arrays and objects", () => | |
| 459 | + Effect.gen(function* () { | |
| 460 | + const executor = makeDynamicWorkerExecutor({ loader }); | |
| 461 | + let captured: unknown; | |
| 462 | + const invoker = makeInvoker(({ args }) => { | |
| 463 | + captured = args; | |
| 464 | + return null; | |
| 465 | + }); | |
| 466 | + | |
| 467 | + yield* executor.execute( | |
| 468 | + `async () => { | |
| 469 | + const inner = new Blob(["MARKER"], { type: "text/plain" }); | |
| 470 | + await tools.deep.send({ | |
| 471 | + payload: { items: [{ blob: inner, name: "first" }, { name: "second" }] }, | |
| 472 | + }); | |
| 473 | + }`, | |
| 474 | + invoker, | |
| 475 | + ); | |
| 476 | + | |
| 477 | + const root = captured as { | |
| 478 | + payload: { items: Array<{ blob?: unknown; name: string }> }; | |
| 479 | + }; | |
| 480 | + expect(root.payload.items).toHaveLength(2); | |
| 481 | + expect(root.payload.items[0]!.blob).toBeInstanceOf(Blob); | |
| 482 | + expect(root.payload.items[0]!.name).toBe("first"); | |
| 483 | + expect(root.payload.items[1]!.name).toBe("second"); | |
| 484 | + expect(root.payload.items[1]!.blob).toBeUndefined(); | |
| 485 | + const blob = root.payload.items[0]!.blob as Blob; | |
| 486 | + const body = yield* Effect.promise(() => blob.text()); | |
| 487 | + expect(body).toBe("MARKER"); | |
| 488 | + }), | |
| 489 | + ); | |
| 490 | + | |
| 491 | + // Native types that Workers RPC structured-clone supports natively. If | |
| 492 | + // any of these stops working it almost certainly means the dispatcher's | |
| 493 | + // contract regressed back toward JSON-only. | |
| 494 | + it.effect("preserves Date tool args", () => | |
| 495 | + Effect.gen(function* () { | |
| 496 | + const executor = makeDynamicWorkerExecutor({ loader }); | |
| 497 | + let captured: { when?: unknown } = {}; | |
| 498 | + const invoker = makeInvoker(({ args }) => { | |
| 499 | + captured = (args ?? {}) as { when?: unknown }; | |
| 500 | + return null; | |
| 501 | + }); | |
| 502 | + | |
| 503 | + yield* executor.execute( | |
| 504 | + `async () => { | |
| 505 | + await tools.events.log({ when: new Date("2026-05-03T12:00:00Z") }); | |
| 506 | + }`, | |
| 507 | + invoker, | |
| 508 | + ); | |
| 509 | + | |
| 510 | + expect(captured.when).toBeInstanceOf(Date); | |
| 511 | + expect((captured.when as Date).toISOString()).toBe("2026-05-03T12:00:00.000Z"); | |
| 512 | + }), | |
| 513 | + ); | |
| 514 | + | |
| 515 | + it.effect("preserves Map and Set tool args", () => | |
| 516 | + Effect.gen(function* () { | |
| 517 | + const executor = makeDynamicWorkerExecutor({ loader }); | |
| 518 | + let captured: { tags?: unknown; index?: unknown } = {}; | |
| 519 | + const invoker = makeInvoker(({ args }) => { | |
| 520 | + captured = (args ?? {}) as { tags?: unknown; index?: unknown }; | |
| 521 | + return null; | |
| 522 | + }); | |
| 523 | + | |
| 524 | + yield* executor.execute( | |
| 525 | + `async () => { | |
| 526 | + await tools.search.run({ | |
| 527 | + tags: new Set(["red", "blue"]), | |
| 528 | + index: new Map([["a", 1], ["b", 2]]), | |
| 529 | + }); | |
| 530 | + }`, | |
| 531 | + invoker, | |
| 532 | + ); | |
| 533 | + | |
| 534 | + expect(captured.tags).toBeInstanceOf(Set); | |
| 535 | + expect([...(captured.tags as Set<string>)].sort()).toEqual(["blue", "red"]); | |
| 536 | + expect(captured.index).toBeInstanceOf(Map); | |
| 537 | + expect((captured.index as Map<string, number>).get("a")).toBe(1); | |
| 538 | + expect((captured.index as Map<string, number>).get("b")).toBe(2); | |
| 539 | + }), | |
| 540 | + ); | |
| 541 | + | |
| 542 | + // The `tools` Proxy has guards (`then`, symbol props, empty path) that | |
| 543 | + // never had tests. Each one is a foot-gun for sandbox code: if `then` | |
| 544 | + // ever stopped returning undefined, awaiting `tools.foo` would hang. | |
| 545 | + it.effect("tools proxy returns undefined for `then` (so it isn't thenable)", () => | |
| 546 | + Effect.gen(function* () { | |
| 547 | + const executor = makeDynamicWorkerExecutor({ loader }); | |
| 548 | + const invoker = makeInvoker(() => null); | |
| 549 | + | |
| 550 | + const result = yield* executor.execute( | |
| 551 | + `async () => { | |
| 552 | + // If the proxy were thenable, awaiting it would call .then(...) | |
| 553 | + // and either hang or invoke a phantom tool. We expect a plain | |
| 554 | + // object whose .then is undefined. | |
| 555 | + return { | |
| 556 | + thenIsUndefined: tools.foo.then === undefined, | |
| 557 | + thenableCheck: typeof tools.foo.then, | |
| 558 | + }; | |
| 559 | + }`, | |
| 560 | + invoker, | |
| 561 | + ); | |
| 562 | + | |
| 563 | + expect(result.error).toBeUndefined(); | |
| 564 | + expect(result.result).toEqual({ thenIsUndefined: true, thenableCheck: "undefined" }); | |
| 565 | + }), | |
| 566 | + ); | |
| 567 | + | |
| 568 | + it.effect("tools proxy throws when called with no path", () => | |
| 569 | + Effect.gen(function* () { | |
| 570 | + const executor = makeDynamicWorkerExecutor({ loader }); | |
| 571 | + const invoker = makeInvoker(() => null); | |
| 572 | + | |
| 573 | + const result = yield* executor.execute( | |
| 574 | + `async () => { | |
| 575 | + try { | |
| 576 | + await tools({}); | |
| 577 | + return "no error"; | |
| 578 | + } catch (e) { | |
| 579 | + return e instanceof Error ? e.message : String(e); | |
| 580 | + } | |
| 581 | + }`, | |
| 582 | + invoker, | |
| 583 | + ); | |
| 584 | + | |
| 585 | + expect(result.error).toBeUndefined(); | |
| 586 | + expect(result.result).toBe("Tool path missing in invocation"); | |
| 587 | + }), | |
| 588 | + ); | |
| 589 | + | |
| 590 | + it.effect("tools proxy supports deep paths (>2 segments)", () => | |
| 591 | + Effect.gen(function* () { | |
| 592 | + const executor = makeDynamicWorkerExecutor({ loader }); | |
| 593 | + let capturedPath = ""; | |
| 594 | + const invoker = makeInvoker(({ path }) => { | |
| 595 | + capturedPath = path; | |
| 596 | + return path; | |
| 597 | + }); | |
| 598 | + | |
| 599 | + const result = yield* executor.execute("async () => tools.a.b.c.d.e({})", invoker); | |
| 600 | + | |
| 601 | + expect(result.error).toBeUndefined(); | |
| 602 | + expect(capturedPath).toBe("a.b.c.d.e"); | |
| 603 | + expect(result.result).toBe("a.b.c.d.e"); | |
| 604 | + }), | |
| 605 | + ); | |
| 424 | 606 | }); |