Skip to content

Commit 446f806

Browse files
authored
[copilot-finds] Bug: Fix TestOrchestrationClient null serialization divergence from real client (#199)
1 parent 73785fe commit 446f806

File tree

2 files changed

+197
-2
lines changed

2 files changed

+197
-2
lines changed

packages/durabletask-js/src/testing/test-client.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -92,15 +92,19 @@ export class TestOrchestrationClient {
9292
* Raises an event to an orchestration.
9393
*/
9494
async raiseOrchestrationEvent(instanceId: string, eventName: string, data: any = null): Promise<void> {
95-
const encodedData = data !== null ? JSON.stringify(data) : undefined;
95+
// Always serialize data — including null — to match TaskHubGrpcClient behavior.
96+
// The real client unconditionally calls JSON.stringify(data), which turns null into "null".
97+
const encodedData = JSON.stringify(data);
9698
this.backend.raiseEvent(instanceId, eventName, encodedData);
9799
}
98100

99101
/**
100102
* Terminates an orchestration.
101103
*/
102104
async terminateOrchestration(instanceId: string, output: any = null): Promise<void> {
103-
const encodedOutput = output !== null ? JSON.stringify(output) : undefined;
105+
// Always serialize output — including null — to match TaskHubGrpcClient behavior.
106+
// The real client unconditionally calls JSON.stringify(output), which turns null into "null".
107+
const encodedOutput = JSON.stringify(output);
104108
this.backend.terminate(instanceId, encodedOutput);
105109
}
106110

Lines changed: 191 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,191 @@
1+
// Copyright (c) Microsoft Corporation. All rights reserved.
2+
// Licensed under the MIT License.
3+
4+
import {
5+
InMemoryOrchestrationBackend,
6+
TestOrchestrationClient,
7+
TestOrchestrationWorker,
8+
OrchestrationStatus,
9+
OrchestrationContext,
10+
TOrchestrator,
11+
} from "../src";
12+
13+
/**
14+
* Tests that TestOrchestrationClient serializes null values the same way as the
15+
* real TaskHubGrpcClient.
16+
*
17+
* The real client unconditionally calls JSON.stringify(data) even when the value
18+
* is null, which produces the string "null". The test client must match this
19+
* behavior so that orchestrations tested with the in-memory backend receive the
20+
* same values they would in production.
21+
*/
22+
describe("TestOrchestrationClient null serialization", () => {
23+
let backend: InMemoryOrchestrationBackend;
24+
let client: TestOrchestrationClient;
25+
let worker: TestOrchestrationWorker;
26+
27+
beforeEach(async () => {
28+
backend = new InMemoryOrchestrationBackend();
29+
client = new TestOrchestrationClient(backend);
30+
worker = new TestOrchestrationWorker(backend);
31+
});
32+
33+
afterEach(async () => {
34+
if (worker) {
35+
try {
36+
await worker.stop();
37+
} catch {
38+
// Ignore if not running
39+
}
40+
}
41+
backend.reset();
42+
});
43+
44+
it("raiseOrchestrationEvent with null data should deliver null (not undefined)", async () => {
45+
let receivedValue: any = "sentinel";
46+
47+
const orchestrator: TOrchestrator = async function* (ctx: OrchestrationContext): any {
48+
receivedValue = yield ctx.waitForExternalEvent("my_event");
49+
return receivedValue;
50+
};
51+
52+
worker.addOrchestrator(orchestrator);
53+
await worker.start();
54+
55+
const id = await client.scheduleNewOrchestration(orchestrator);
56+
await client.waitForOrchestrationStart(id, false, 5);
57+
58+
// Raise event with no data (defaults to null)
59+
await client.raiseOrchestrationEvent(id, "my_event");
60+
61+
const state = await client.waitForOrchestrationCompletion(id, true, 10);
62+
63+
expect(state).toBeDefined();
64+
expect(state?.runtimeStatus).toEqual(OrchestrationStatus.COMPLETED);
65+
// The orchestrator should receive null — the same value the real client delivers.
66+
// Before this fix, the test client would deliver undefined instead.
67+
expect(receivedValue).toBeNull();
68+
expect(state?.serializedOutput).toEqual("null");
69+
});
70+
71+
it("raiseOrchestrationEvent with explicit null should deliver null", async () => {
72+
let receivedValue: any = "sentinel";
73+
74+
const orchestrator: TOrchestrator = async function* (ctx: OrchestrationContext): any {
75+
receivedValue = yield ctx.waitForExternalEvent("my_event");
76+
return receivedValue;
77+
};
78+
79+
worker.addOrchestrator(orchestrator);
80+
await worker.start();
81+
82+
const id = await client.scheduleNewOrchestration(orchestrator);
83+
await client.waitForOrchestrationStart(id, false, 5);
84+
85+
// Raise event with explicit null
86+
await client.raiseOrchestrationEvent(id, "my_event", null);
87+
88+
const state = await client.waitForOrchestrationCompletion(id, true, 10);
89+
90+
expect(state).toBeDefined();
91+
expect(state?.runtimeStatus).toEqual(OrchestrationStatus.COMPLETED);
92+
expect(receivedValue).toBeNull();
93+
});
94+
95+
it("raiseOrchestrationEvent with non-null data should work normally", async () => {
96+
let receivedValue: any = "sentinel";
97+
98+
const orchestrator: TOrchestrator = async function* (ctx: OrchestrationContext): any {
99+
receivedValue = yield ctx.waitForExternalEvent("my_event");
100+
return receivedValue;
101+
};
102+
103+
worker.addOrchestrator(orchestrator);
104+
await worker.start();
105+
106+
const id = await client.scheduleNewOrchestration(orchestrator);
107+
await client.waitForOrchestrationStart(id, false, 5);
108+
109+
await client.raiseOrchestrationEvent(id, "my_event", { key: "value" });
110+
111+
const state = await client.waitForOrchestrationCompletion(id, true, 10);
112+
113+
expect(state).toBeDefined();
114+
expect(state?.runtimeStatus).toEqual(OrchestrationStatus.COMPLETED);
115+
expect(receivedValue).toEqual({ key: "value" });
116+
});
117+
118+
it("raiseOrchestrationEvent with falsy values should serialize them", async () => {
119+
const receivedValues: any[] = [];
120+
121+
const orchestrator: TOrchestrator = async function* (ctx: OrchestrationContext): any {
122+
receivedValues.push(yield ctx.waitForExternalEvent("e1"));
123+
receivedValues.push(yield ctx.waitForExternalEvent("e2"));
124+
receivedValues.push(yield ctx.waitForExternalEvent("e3"));
125+
return receivedValues;
126+
};
127+
128+
worker.addOrchestrator(orchestrator);
129+
await worker.start();
130+
131+
const id = await client.scheduleNewOrchestration(orchestrator);
132+
await client.waitForOrchestrationStart(id, false, 5);
133+
134+
// 0, false, and "" are all falsy but valid JSON values
135+
await client.raiseOrchestrationEvent(id, "e1", 0);
136+
await client.raiseOrchestrationEvent(id, "e2", false);
137+
await client.raiseOrchestrationEvent(id, "e3", "");
138+
139+
const state = await client.waitForOrchestrationCompletion(id, true, 10);
140+
141+
expect(state).toBeDefined();
142+
expect(state?.runtimeStatus).toEqual(OrchestrationStatus.COMPLETED);
143+
expect(receivedValues[0]).toBe(0);
144+
expect(receivedValues[1]).toBe(false);
145+
expect(receivedValues[2]).toBe("");
146+
});
147+
148+
it("terminateOrchestration with null output should store null (not undefined)", async () => {
149+
const orchestrator: TOrchestrator = async function* (ctx: OrchestrationContext): any {
150+
yield ctx.waitForExternalEvent("never");
151+
return "never reached";
152+
};
153+
154+
worker.addOrchestrator(orchestrator);
155+
await worker.start();
156+
157+
const id = await client.scheduleNewOrchestration(orchestrator);
158+
await client.waitForOrchestrationStart(id, false, 5);
159+
160+
// Terminate with no output (defaults to null)
161+
await client.terminateOrchestration(id);
162+
163+
const state = await client.waitForOrchestrationCompletion(id, true, 10);
164+
165+
expect(state).toBeDefined();
166+
expect(state?.runtimeStatus).toEqual(OrchestrationStatus.TERMINATED);
167+
// The real client stores "null" as the serialized output, not undefined
168+
expect(state?.serializedOutput).toEqual("null");
169+
});
170+
171+
it("terminateOrchestration with explicit output should serialize it", async () => {
172+
const orchestrator: TOrchestrator = async function* (ctx: OrchestrationContext): any {
173+
yield ctx.waitForExternalEvent("never");
174+
return "never reached";
175+
};
176+
177+
worker.addOrchestrator(orchestrator);
178+
await worker.start();
179+
180+
const id = await client.scheduleNewOrchestration(orchestrator);
181+
await client.waitForOrchestrationStart(id, false, 5);
182+
183+
await client.terminateOrchestration(id, "stopped");
184+
185+
const state = await client.waitForOrchestrationCompletion(id, true, 10);
186+
187+
expect(state).toBeDefined();
188+
expect(state?.runtimeStatus).toEqual(OrchestrationStatus.TERMINATED);
189+
expect(state?.serializedOutput).toEqual(JSON.stringify("stopped"));
190+
});
191+
});

0 commit comments

Comments
 (0)