Skip to content

Commit b0f058d

Browse files
committed
[copilot-finds] Bug: Fix TestOrchestrationClient null serialization divergence from real client
TestOrchestrationClient.raiseOrchestrationEvent and terminateOrchestration handle null values differently from the real TaskHubGrpcClient, causing behavioral divergence between test and production environments. Real client (TaskHubGrpcClient): Always calls JSON.stringify(data), which turns null into the string "null". The sidecar stores this, and the orchestrator receives null when deserializing. Test client (TestOrchestrationClient): Skipped serialization for null with 'data !== null ? JSON.stringify(data) : undefined', which caused the orchestrator to receive undefined instead of null. This fix aligns the test client with the real client by unconditionally serializing the data, so orchestrations tested with the in-memory backend receive the same values they would in production. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 255790f commit b0f058d

2 files changed

Lines changed: 197 additions & 2 deletions

File tree

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)