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
421421 expect(result.result).toEqual({ kind: "Blob", type: "text/plain", text: "DOWNLOAD" });
422422 }),
423423 );
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+ );
424606 });