assistant-stream 0.3.27 → 0.3.28

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/dist/core/accumulators/AssistantMessageStream.d.ts.map +1 -1
  2. package/dist/core/accumulators/AssistantMessageStream.js +0 -1
  3. package/dist/core/accumulators/AssistantMessageStream.js.map +1 -1
  4. package/dist/core/modules/tool-call.d.ts.map +1 -1
  5. package/dist/core/modules/tool-call.js +1 -1
  6. package/dist/core/modules/tool-call.js.map +1 -1
  7. package/dist/core/serialization/data-stream/DataStream.js +48 -48
  8. package/dist/core/serialization/data-stream/DataStream.js.map +1 -1
  9. package/dist/core/serialization/data-stream/chunk-types.d.ts +23 -22
  10. package/dist/core/serialization/data-stream/chunk-types.d.ts.map +1 -1
  11. package/dist/core/serialization/data-stream/chunk-types.js +22 -23
  12. package/dist/core/serialization/data-stream/chunk-types.js.map +1 -1
  13. package/dist/core/serialization/data-stream/serialization.d.ts.map +1 -1
  14. package/dist/core/serialization/data-stream/serialization.js +14 -2
  15. package/dist/core/serialization/data-stream/serialization.js.map +1 -1
  16. package/dist/core/serialization/ui-message-stream/UIMessageStream.d.ts.map +1 -1
  17. package/dist/core/serialization/ui-message-stream/UIMessageStream.js +2 -1
  18. package/dist/core/serialization/ui-message-stream/UIMessageStream.js.map +1 -1
  19. package/dist/core/tool/ToolCallReader.d.ts.map +1 -1
  20. package/dist/core/tool/ToolCallReader.js.map +1 -1
  21. package/dist/core/tool/tool-types.d.ts +6 -0
  22. package/dist/core/tool/tool-types.d.ts.map +1 -1
  23. package/dist/core/utils/stream/SSE.d.ts.map +1 -1
  24. package/dist/core/utils/stream/SSE.js +11 -2
  25. package/dist/core/utils/stream/SSE.js.map +1 -1
  26. package/package.json +2 -2
  27. package/src/core/accumulators/AssistantMessageStream.ts +3 -1
  28. package/src/core/converters/toGenericMessages.test.ts +3 -39
  29. package/src/core/gorp/GorpStreamDeltaTracker.test.ts +30 -0
  30. package/src/core/modules/tool-call.ts +4 -1
  31. package/src/core/serialization/data-stream/DataStream.ts +1 -1
  32. package/src/core/serialization/data-stream/chunk-types.ts +24 -22
  33. package/src/core/serialization/data-stream/serialization.test.ts +133 -0
  34. package/src/core/serialization/data-stream/serialization.ts +20 -2
  35. package/src/core/serialization/ui-message-stream/UIMessageStream.test.ts +24 -0
  36. package/src/core/serialization/ui-message-stream/UIMessageStream.ts +2 -1
  37. package/src/core/tool/ToolCallReader.ts +5 -1
  38. package/src/core/tool/tool-types.ts +7 -0
  39. package/src/core/utils/stream/SSE.test.ts +97 -0
  40. package/src/core/utils/stream/SSE.ts +13 -2
  41. package/src/gorp.test.ts +0 -10
  42. package/src/core/accumulators/TimingTracker.test.ts +0 -100
  43. package/src/core/gorp/changeTree.test.ts +0 -168
@@ -0,0 +1,133 @@
1
+ import { afterEach, describe, expect, it, vi } from "vitest";
2
+ import { LineDecoderStream } from "../../utils/stream/LineDecoderStream";
3
+ import {
4
+ DataStreamChunkDecoder,
5
+ DataStreamChunkEncoder,
6
+ } from "./serialization";
7
+ import { type DataStreamChunk, DataStreamStreamChunkType } from "./chunk-types";
8
+
9
+ async function collectChunks<T>(stream: ReadableStream<T>): Promise<T[]> {
10
+ const reader = stream.getReader();
11
+ const chunks: T[] = [];
12
+ try {
13
+ while (true) {
14
+ const { done, value } = await reader.read();
15
+ if (done) break;
16
+ chunks.push(value);
17
+ }
18
+ } finally {
19
+ reader.releaseLock();
20
+ }
21
+ return chunks;
22
+ }
23
+
24
+ function createLineStream(lines: string[]): ReadableStream<string> {
25
+ return new ReadableStream<string>({
26
+ start(controller) {
27
+ for (const line of lines) controller.enqueue(line);
28
+ controller.close();
29
+ },
30
+ });
31
+ }
32
+
33
+ describe("DataStreamChunkDecoder", () => {
34
+ afterEach(() => {
35
+ vi.restoreAllMocks();
36
+ });
37
+
38
+ it("decodes text-delta chunks", async () => {
39
+ const chunks = await collectChunks(
40
+ createLineStream(['0:"hello"', '0:" world"']).pipeThrough(
41
+ new DataStreamChunkDecoder(),
42
+ ),
43
+ );
44
+ expect(chunks).toEqual([
45
+ { type: DataStreamStreamChunkType.TextDelta, value: "hello" },
46
+ { type: DataStreamStreamChunkType.TextDelta, value: " world" },
47
+ ]);
48
+ });
49
+
50
+ it("round-trips through DataStreamChunkEncoder", async () => {
51
+ const values: DataStreamChunk[] = [
52
+ { type: DataStreamStreamChunkType.TextDelta, value: "hello" },
53
+ { type: DataStreamStreamChunkType.Error, value: "boom" },
54
+ ];
55
+ const source = new ReadableStream<DataStreamChunk>({
56
+ start(controller) {
57
+ for (const v of values) controller.enqueue(v);
58
+ controller.close();
59
+ },
60
+ });
61
+ const decoded = await collectChunks(
62
+ source
63
+ .pipeThrough(new DataStreamChunkEncoder())
64
+ .pipeThrough(new LineDecoderStream())
65
+ .pipeThrough(new DataStreamChunkDecoder()),
66
+ );
67
+ expect(decoded).toEqual(values);
68
+ });
69
+
70
+ it("drops chunks carrying prototype-pollution keys", async () => {
71
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
72
+ const chunks = await collectChunks(
73
+ createLineStream([
74
+ '0:{"__proto__":{"polluted":true}}',
75
+ '0:{"constructor":{"prototype":{"polluted":true}}}',
76
+ '0:"ok"',
77
+ ]).pipeThrough(new DataStreamChunkDecoder()),
78
+ );
79
+ expect(warn).toHaveBeenCalledTimes(2);
80
+ expect(chunks).toEqual([
81
+ { type: DataStreamStreamChunkType.TextDelta, value: "ok" },
82
+ ]);
83
+ });
84
+
85
+ it("drops chunks that are not valid JSON", async () => {
86
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
87
+ const invalid = ["0:{not json", "0:not json"];
88
+ const chunks = await collectChunks(
89
+ createLineStream([...invalid, '0:"ok"']).pipeThrough(
90
+ new DataStreamChunkDecoder(),
91
+ ),
92
+ );
93
+ expect(warn.mock.calls.map((c) => c[0])).toEqual(
94
+ invalid.map((l) => `Dropped invalid data-stream chunk: ${l}`),
95
+ );
96
+ expect(chunks).toEqual([
97
+ { type: DataStreamStreamChunkType.TextDelta, value: "ok" },
98
+ ]);
99
+ });
100
+
101
+ it("skips blank and whitespace-only lines silently", async () => {
102
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
103
+ const chunks = await collectChunks(
104
+ createLineStream([
105
+ "",
106
+ '0:"hello"',
107
+ " ",
108
+ "\t",
109
+ '0:" world"',
110
+ ]).pipeThrough(new DataStreamChunkDecoder()),
111
+ );
112
+ expect(warn).not.toHaveBeenCalled();
113
+ expect(chunks).toEqual([
114
+ { type: DataStreamStreamChunkType.TextDelta, value: "hello" },
115
+ { type: DataStreamStreamChunkType.TextDelta, value: " world" },
116
+ ]);
117
+ });
118
+
119
+ it("drops a chunk without a colon separator with a warning", async () => {
120
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
121
+ const chunks = await collectChunks(
122
+ createLineStream(["garbage", '0:"ok"']).pipeThrough(
123
+ new DataStreamChunkDecoder(),
124
+ ),
125
+ );
126
+ expect(warn).toHaveBeenCalledWith(
127
+ "Dropped invalid data-stream chunk: garbage",
128
+ );
129
+ expect(chunks).toEqual([
130
+ { type: DataStreamStreamChunkType.TextDelta, value: "ok" },
131
+ ]);
132
+ });
133
+ });
@@ -1,3 +1,4 @@
1
+ import sjson from "secure-json-parse";
1
2
  import type { DataStreamChunk, DataStreamStreamChunkType } from "./chunk-types";
2
3
 
3
4
  export class DataStreamChunkEncoder extends TransformStream<
@@ -21,10 +22,27 @@ export class DataStreamChunkDecoder extends TransformStream<
21
22
  super({
22
23
  transform: (chunk, controller) => {
23
24
  const index = chunk.indexOf(":");
24
- if (index === -1) throw new Error("Invalid stream part");
25
+ if (index === -1) {
26
+ // Blank lines are benign data-stream framing (keepalive newlines,
27
+ // replay-buffer separators emitted on resume/reconnect).
28
+ if (chunk.trim().length === 0) return;
29
+ console.warn(
30
+ `Dropped invalid data-stream chunk: ${chunk.slice(0, 200)}`,
31
+ );
32
+ return;
33
+ }
34
+ let value;
35
+ try {
36
+ value = sjson.parse(chunk.slice(index + 1));
37
+ } catch {
38
+ console.warn(
39
+ `Dropped invalid data-stream chunk: ${chunk.slice(0, 200)}`,
40
+ );
41
+ return;
42
+ }
25
43
  controller.enqueue({
26
44
  type: chunk.slice(0, index) as DataStreamStreamChunkType,
27
- value: JSON.parse(chunk.slice(index + 1)),
45
+ value,
28
46
  });
29
47
  },
30
48
  });
@@ -604,6 +604,30 @@ describe("UIMessageStreamDecoder", () => {
604
604
  warn.mockRestore();
605
605
  });
606
606
 
607
+ it("drops frames carrying prototype-pollution keys", async () => {
608
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
609
+ const events = [
610
+ '{"type":"text-delta","delta":"x","__proto__":{"polluted":true}}',
611
+ '{"type":"text-delta","delta":"y","constructor":{"prototype":{"polluted":true}}}',
612
+ JSON.stringify({ type: "start", messageId: "msg_123" }),
613
+ JSON.stringify({ type: "text-delta", id: "t", delta: "ok" }),
614
+ "[DONE]",
615
+ ];
616
+
617
+ const chunks = await collectChunks(
618
+ createUIMessageStream(events).pipeThrough(new UIMessageStreamDecoder()),
619
+ );
620
+
621
+ expect(warn).toHaveBeenCalledTimes(2);
622
+ const deltas = chunks.filter(
623
+ (c): c is AssistantStreamChunk & { type: "text-delta" } =>
624
+ c.type === "text-delta",
625
+ );
626
+ expect(deltas).toHaveLength(1);
627
+ expect(deltas[0]!.textDelta).toBe("ok");
628
+ warn.mockRestore();
629
+ });
630
+
607
631
  it("drops frames that are not valid JSON", async () => {
608
632
  const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
609
633
  const invalidFrames = ['{"type":"te', "not json"];
@@ -1,3 +1,4 @@
1
+ import sjson from "secure-json-parse";
1
2
  import type { AssistantStreamChunk } from "../../AssistantStreamChunk";
2
3
  import type { ToolCallStreamController } from "../../modules/tool-call";
3
4
  import type { TextStreamController } from "../../modules/text";
@@ -227,7 +228,7 @@ export class UIMessageStreamDecoder extends PipeableTransformStream<
227
228
 
228
229
  let chunk;
229
230
  try {
230
- chunk = JSON.parse(event.data);
231
+ chunk = sjson.parse(event.data);
231
232
  } catch {
232
233
  chunk = undefined;
233
234
  }
@@ -441,7 +441,11 @@ export class ToolCallArgsReaderImpl<
441
441
  export class ToolCallResponseReaderImpl<
442
442
  TResult extends ReadonlyJSONValue,
443
443
  > implements ToolCallResponseReader<TResult> {
444
- constructor(private readonly promise: Promise<ToolResponse<TResult>>) {}
444
+ private readonly promise: Promise<ToolResponse<TResult>>;
445
+
446
+ constructor(promise: Promise<ToolResponse<TResult>>) {
447
+ this.promise = promise;
448
+ }
445
449
 
446
450
  public get() {
447
451
  return this.promise;
@@ -226,6 +226,13 @@ type ToolBase<
226
226
  unstable_backendDefault?: {
227
227
  parameters?: boolean;
228
228
  };
229
+
230
+ /**
231
+ * Replaces an already-registered same-priority tool with the same name
232
+ * instead of throwing. Discouraged escape hatch — overwriting a same-priority
233
+ * tool is usually a design smell; prefer distinct names or priorities.
234
+ */
235
+ overwrite?: boolean;
229
236
  };
230
237
 
231
238
  type BackendTool<
@@ -0,0 +1,97 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+ import { SSEDecoder, SSEEncoder } from "./SSE";
3
+
4
+ async function collectChunks<T>(stream: ReadableStream<T>): Promise<T[]> {
5
+ const reader = stream.getReader();
6
+ const chunks: T[] = [];
7
+ try {
8
+ while (true) {
9
+ const { done, value } = await reader.read();
10
+ if (done) break;
11
+ chunks.push(value);
12
+ }
13
+ } finally {
14
+ reader.releaseLock();
15
+ }
16
+ return chunks;
17
+ }
18
+
19
+ function createSSEStream(messages: string[]): ReadableStream<Uint8Array> {
20
+ const encoder = new TextEncoder();
21
+ const sseText = messages.map((m) => `data: ${m}\n\n`).join("");
22
+ return new ReadableStream<Uint8Array>({
23
+ start(controller) {
24
+ controller.enqueue(encoder.encode(sseText));
25
+ controller.close();
26
+ },
27
+ });
28
+ }
29
+
30
+ describe("SSEDecoder", () => {
31
+ it("decodes message events as JSON", async () => {
32
+ const chunks = await collectChunks(
33
+ createSSEStream([
34
+ JSON.stringify({ hello: "world" }),
35
+ JSON.stringify([1, 2, 3]),
36
+ ]).pipeThrough(new SSEDecoder()),
37
+ );
38
+ expect(chunks).toEqual([{ hello: "world" }, [1, 2, 3]]);
39
+ });
40
+
41
+ it("round-trips values through SSEEncoder and SSEDecoder", async () => {
42
+ type V = { hello: string } | number[];
43
+ const values: V[] = [{ hello: "world" }, [1, 2, 3]];
44
+ const source = new ReadableStream<V>({
45
+ start(controller) {
46
+ for (const v of values) controller.enqueue(v);
47
+ controller.close();
48
+ },
49
+ });
50
+ const decoded = await collectChunks(
51
+ source.pipeThrough(new SSEEncoder<V>()).pipeThrough(new SSEDecoder<V>()),
52
+ );
53
+ expect(decoded).toEqual(values);
54
+ });
55
+
56
+ it("drops frames carrying prototype-pollution keys", async () => {
57
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
58
+ const chunks = await collectChunks(
59
+ createSSEStream([
60
+ '{"__proto__":{"polluted":true},"ok":1}',
61
+ '{"constructor":{"prototype":{"polluted":true}},"ok":2}',
62
+ JSON.stringify({ ok: 3 }),
63
+ ]).pipeThrough(new SSEDecoder()),
64
+ );
65
+ expect(warn).toHaveBeenCalledTimes(2);
66
+ expect(chunks).toEqual([{ ok: 3 }]);
67
+ warn.mockRestore();
68
+ });
69
+
70
+ it("drops frames that are not valid JSON", async () => {
71
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
72
+ const invalid = ["{not json", "not json"];
73
+ const chunks = await collectChunks(
74
+ createSSEStream([...invalid, JSON.stringify({ ok: 1 })]).pipeThrough(
75
+ new SSEDecoder(),
76
+ ),
77
+ );
78
+ expect(warn.mock.calls.map((c) => c[0])).toEqual(
79
+ invalid.map((m) => `Dropped invalid SSE message: ${m}`),
80
+ );
81
+ expect(chunks).toEqual([{ ok: 1 }]);
82
+ warn.mockRestore();
83
+ });
84
+
85
+ it("errors the stream on an unknown event type", async () => {
86
+ const encoder = new TextEncoder();
87
+ const stream = new ReadableStream<Uint8Array>({
88
+ start(controller) {
89
+ controller.enqueue(encoder.encode("event: custom\ndata: x\n\n"));
90
+ controller.close();
91
+ },
92
+ });
93
+ await expect(
94
+ collectChunks(stream.pipeThrough(new SSEDecoder())),
95
+ ).rejects.toThrow("Unknown SSE event type: custom");
96
+ });
97
+ });
@@ -1,3 +1,4 @@
1
+ import sjson from "secure-json-parse";
1
2
  import { PipeableTransformStream } from "./PipeableTransformStream";
2
3
  import {
3
4
  SSEEventDecoderStream,
@@ -44,9 +45,19 @@ export class SSEDecoder<T> extends PipeableTransformStream<
44
45
  new TransformStream<PipelineSSEEvent, T>({
45
46
  transform(event, controller) {
46
47
  switch (event.event) {
47
- case "message":
48
- controller.enqueue(JSON.parse(event.data));
48
+ case "message": {
49
+ let value;
50
+ try {
51
+ value = sjson.parse(event.data);
52
+ } catch {
53
+ console.warn(
54
+ `Dropped invalid SSE message: ${event.data.slice(0, 200)}`,
55
+ );
56
+ break;
57
+ }
58
+ controller.enqueue(value);
49
59
  break;
60
+ }
50
61
  default:
51
62
  throw new Error(`Unknown SSE event type: ${event.event}`);
52
63
  }
package/src/gorp.test.ts CHANGED
@@ -2,16 +2,6 @@ import { describe, expect, it } from "vitest";
2
2
  import * as entry from "./index";
3
3
 
4
4
  describe("assistant-transport state exports", () => {
5
- it("exposes the assistant-transport surface and no gorp-shaped names", () => {
6
- expect(typeof entry.AssistantTransportDeltaTracker).toBe("function");
7
- expect(typeof entry.createObjectStream).toBe("function");
8
- expect(typeof entry.ObjectStreamResponse).toBe("function");
9
- expect(typeof entry.fromObjectStreamResponse).toBe("function");
10
- for (const name of Object.keys(entry)) {
11
- expect(name.toLowerCase()).not.toContain("gorp");
12
- }
13
- });
14
-
15
5
  it("streams operations end to end through the SSE wire", async () => {
16
6
  const stream = entry.createObjectStream({
17
7
  execute: (controller) => {
@@ -1,100 +0,0 @@
1
- import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
2
- import { TimingTracker } from "./TimingTracker";
3
-
4
- describe("TimingTracker", () => {
5
- beforeEach(() => {
6
- vi.useFakeTimers();
7
- vi.setSystemTime(1000);
8
- });
9
-
10
- afterEach(() => {
11
- vi.useRealTimers();
12
- });
13
-
14
- it("should record stream start time", () => {
15
- const tracker = new TimingTracker();
16
- const timing = tracker.getTiming();
17
- expect(timing.streamStartTime).toBe(1000);
18
- });
19
-
20
- it("should count chunks", () => {
21
- const tracker = new TimingTracker();
22
- tracker.recordChunk();
23
- tracker.recordChunk();
24
- tracker.recordChunk();
25
- const timing = tracker.getTiming();
26
- expect(timing.totalChunks).toBe(3);
27
- });
28
-
29
- it("should record first token time", () => {
30
- const tracker = new TimingTracker();
31
- vi.advanceTimersByTime(50);
32
- tracker.recordFirstToken();
33
- vi.advanceTimersByTime(100);
34
- tracker.recordFirstToken(); // should not overwrite
35
- const timing = tracker.getTiming();
36
- expect(timing.firstTokenTime).toBe(50);
37
- });
38
-
39
- it("should not set firstTokenTime if no text tokens", () => {
40
- const tracker = new TimingTracker();
41
- const timing = tracker.getTiming();
42
- expect(timing.firstTokenTime).toBeUndefined();
43
- });
44
-
45
- it("should track unique tool calls", () => {
46
- const tracker = new TimingTracker();
47
- tracker.recordToolCallStart("tc-1");
48
- tracker.recordToolCallStart("tc-2");
49
- tracker.recordToolCallStart("tc-1"); // duplicate
50
- const timing = tracker.getTiming();
51
- expect(timing.toolCallCount).toBe(2);
52
- });
53
-
54
- it("should compute totalStreamTime", () => {
55
- const tracker = new TimingTracker();
56
- vi.advanceTimersByTime(200);
57
- const timing = tracker.getTiming();
58
- expect(timing.totalStreamTime).toBe(200);
59
- });
60
-
61
- it("should use outputTokens when available", () => {
62
- const tracker = new TimingTracker();
63
- vi.advanceTimersByTime(1000);
64
- const timing = tracker.getTiming(42, "some text");
65
- expect(timing.tokenCount).toBe(42);
66
- expect(timing.tokensPerSecond).toBe(42);
67
- });
68
-
69
- it("should estimate tokens from text when no outputTokens", () => {
70
- const tracker = new TimingTracker();
71
- vi.advanceTimersByTime(1000);
72
- // 20 chars / 4 = 5 tokens
73
- const timing = tracker.getTiming(undefined, "12345678901234567890");
74
- expect(timing.tokenCount).toBe(5);
75
- expect(timing.tokensPerSecond).toBe(5);
76
- });
77
-
78
- it("should handle zero outputTokens by falling back to text", () => {
79
- const tracker = new TimingTracker();
80
- vi.advanceTimersByTime(1000);
81
- const timing = tracker.getTiming(0, "1234567890"); // 10 chars -> 3 tokens
82
- expect(timing.tokenCount).toBe(3);
83
- });
84
-
85
- it("should handle no tokens and no text", () => {
86
- const tracker = new TimingTracker();
87
- vi.advanceTimersByTime(100);
88
- const timing = tracker.getTiming();
89
- expect(timing.tokenCount).toBeUndefined();
90
- expect(timing.tokensPerSecond).toBeUndefined();
91
- });
92
-
93
- it("should handle zero stream time (no division by zero)", () => {
94
- const tracker = new TimingTracker();
95
- // Don't advance time — totalStreamTime = 0
96
- const timing = tracker.getTiming(100, "text");
97
- expect(timing.totalStreamTime).toBe(0);
98
- expect(timing.tokensPerSecond).toBeUndefined();
99
- });
100
- });
@@ -1,168 +0,0 @@
1
- import { describe, expect, it } from "vitest";
2
- import {
3
- diffKeys,
4
- lookupChange,
5
- markChanged,
6
- mergeChanged,
7
- type ChangeNode,
8
- } from "./changeTree";
9
-
10
- describe("markChanged", () => {
11
- it("collapses to true when path is empty", () => {
12
- expect(markChanged({}, [])).toBe(true);
13
- });
14
-
15
- it("keeps `true` after attempting to descend into it", () => {
16
- const node: ChangeNode = { a: true };
17
- const result = markChanged(node, ["a", "b"]);
18
- expect(result).toBe(node);
19
- expect(node.a).toBe(true);
20
- });
21
-
22
- it("overwrites a nested subtree with `true` when the path collapses to the parent", () => {
23
- const node: ChangeNode = { a: { b: true } };
24
- markChanged(node, ["a"]);
25
- expect(node.a).toBe(true);
26
- });
27
-
28
- it("creates intermediate nodes lazily without disturbing siblings", () => {
29
- const node: ChangeNode = { a: { existing: true } };
30
- markChanged(node, ["a", "b", "c"]);
31
- expect(node).toEqual({
32
- a: { existing: true, b: { c: true } },
33
- });
34
- });
35
-
36
- it("is idempotent when the same leaf is marked twice", () => {
37
- const node: ChangeNode = {};
38
- markChanged(node, ["a", "b"]);
39
- markChanged(node, ["a", "b"]);
40
- expect(node).toEqual({ a: { b: true } });
41
- });
42
-
43
- it("rejects unsafe path segments", () => {
44
- const node: ChangeNode = {};
45
- expect(() => markChanged(node, ["__proto__", "polluted"])).toThrow(
46
- /Unsafe gorp path segment/,
47
- );
48
- expect(({} as Record<string, unknown>).polluted).toBeUndefined();
49
- });
50
- });
51
-
52
- describe("mergeChanged", () => {
53
- it("returns true when either side is true", () => {
54
- expect(mergeChanged(true, {})).toBe(true);
55
- expect(mergeChanged({}, true)).toBe(true);
56
- });
57
-
58
- it("merges disjoint subtrees in place", () => {
59
- const target: ChangeNode = { a: true };
60
- const source: ChangeNode = { b: true };
61
- const result = mergeChanged(target, source);
62
- expect(result).toBe(target);
63
- expect(target).toEqual({ a: true, b: true });
64
- });
65
-
66
- it("clones source subtrees before assigning them", () => {
67
- const target: ChangeNode = {};
68
- const source: ChangeNode = { a: { b: true } };
69
- mergeChanged(target, source);
70
- (source as { a: { c?: true } }).a.c = true;
71
- expect(target).toEqual({ a: { b: true } });
72
- });
73
-
74
- it("deep-merges overlapping subtrees", () => {
75
- const target: ChangeNode = { a: { x: true } };
76
- const source: ChangeNode = { a: { y: true } };
77
- mergeChanged(target, source);
78
- expect(target).toEqual({ a: { x: true, y: true } });
79
- });
80
-
81
- it("propagates `true` upward when one side has it at a shared key", () => {
82
- const target: ChangeNode = { a: { x: true } };
83
- const source: ChangeNode = { a: true };
84
- mergeChanged(target, source);
85
- expect(target).toEqual({ a: true });
86
- });
87
-
88
- it("rejects unsafe source keys", () => {
89
- const target: ChangeNode = {};
90
- const source = JSON.parse('{"__proto__":true}') as ChangeNode;
91
- expect(() => mergeChanged(target, source)).toThrow(
92
- /Unsafe gorp path segment/,
93
- );
94
- expect(({} as Record<string, unknown>).polluted).toBeUndefined();
95
- });
96
-
97
- it("rejects nested unsafe source keys before assigning a subtree", () => {
98
- const target: ChangeNode = {};
99
- const source = JSON.parse('{"a":{"constructor":true}}') as ChangeNode;
100
- expect(() => mergeChanged(target, source)).toThrow(
101
- /Unsafe gorp path segment/,
102
- );
103
- });
104
- });
105
-
106
- describe("lookupChange", () => {
107
- it("returns false for paths that don't exist", () => {
108
- expect(lookupChange({ a: true }, ["b"])).toBe(false);
109
- });
110
-
111
- it("returns true when traversal hits a `true` sentinel", () => {
112
- expect(lookupChange({ a: true }, ["a", "b", "c"])).toBe(true);
113
- });
114
-
115
- it("returns the subtree when the path lands on an interior node", () => {
116
- const node: ChangeNode = { a: { b: true } };
117
- const result = lookupChange(node, ["a"]);
118
- expect(result).toEqual({ b: true });
119
- });
120
-
121
- it("returns the root node for the empty path", () => {
122
- const node: ChangeNode = { a: true };
123
- expect(lookupChange(node, [])).toBe(node);
124
- });
125
-
126
- it("returns false for inherited object members", () => {
127
- expect(lookupChange({}, ["toString"])).toBe(false);
128
- expect(lookupChange({ a: {} }, ["a", "hasOwnProperty"])).toBe(false);
129
- expect(lookupChange({ a: true }, ["constructor"])).toBe(false);
130
- });
131
- });
132
-
133
- describe("inherited object members", () => {
134
- it("markChanged does not descend into inherited members", () => {
135
- const node: ChangeNode = {};
136
- markChanged(node, ["toString", "x"]);
137
- expect(node).toEqual({ toString: { x: true } });
138
- expect(lookupChange(node, ["toString", "x"])).toBe(true);
139
- expect(lookupChange(node, ["valueOf"])).toBe(false);
140
- });
141
-
142
- it("mergeChanged ignores inherited target members", () => {
143
- const target: ChangeNode = {};
144
- const source: ChangeNode = { toString: { x: true } };
145
- mergeChanged(target, source);
146
- expect(target).toEqual({ toString: { x: true } });
147
- });
148
- });
149
-
150
- describe("diffKeys", () => {
151
- it("returns new keys in order, then deleted keys in reverse", () => {
152
- expect(diffKeys({ b: 1, c: 2 }, { a: 1, b: 2, d: 3 })).toEqual([
153
- "b",
154
- "c",
155
- "d",
156
- "a",
157
- ]);
158
- });
159
-
160
- it("returns new keys when the old value is not an object", () => {
161
- expect(diffKeys({ a: 1 }, null)).toEqual(["a"]);
162
- expect(diffKeys({ a: 1 }, "str")).toEqual(["a"]);
163
- });
164
-
165
- it("returns deleted keys when the new value is not an object", () => {
166
- expect(diffKeys(null, { a: 1, b: 2 })).toEqual(["b", "a"]);
167
- });
168
- });