History

test(runtime-dynamic-worker): broaden dispatcher data-shape contract coverage (#484)

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.

Rhys Sullivan committed 6 months ago

Commit 4e4d4fc1 parent 292b6331 file · +182 −0Browse files

Unified · Split

packages/kernel/runtime-dynamic-worker/src/invocation.test.ts+182−0
@@ -421,4 +421,186 @@ describe("makeDynamicWorkerExecutor", () => { · 420 unmodified lines
421 expect(result.result).toEqual({ kind: "Blob", type: "text/plain", text: "DOWNLOAD" });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});