From b0f058dd135aee08f7e36eb252840ec5ab862da6 Mon Sep 17 00:00:00 2001 From: Copilot <223556219+Copilot@users.noreply.github.com> Date: Tue, 17 Mar 2026 08:45:15 +0000 Subject: [PATCH] [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> --- .../durabletask-js/src/testing/test-client.ts | 8 +- .../test/test-client-serialization.spec.ts | 191 ++++++++++++++++++ 2 files changed, 197 insertions(+), 2 deletions(-) create mode 100644 packages/durabletask-js/test/test-client-serialization.spec.ts diff --git a/packages/durabletask-js/src/testing/test-client.ts b/packages/durabletask-js/src/testing/test-client.ts index f7af0a93..5b8f4481 100644 --- a/packages/durabletask-js/src/testing/test-client.ts +++ b/packages/durabletask-js/src/testing/test-client.ts @@ -92,7 +92,9 @@ export class TestOrchestrationClient { * Raises an event to an orchestration. */ async raiseOrchestrationEvent(instanceId: string, eventName: string, data: any = null): Promise { - const encodedData = data !== null ? JSON.stringify(data) : undefined; + // Always serialize data — including null — to match TaskHubGrpcClient behavior. + // The real client unconditionally calls JSON.stringify(data), which turns null into "null". + const encodedData = JSON.stringify(data); this.backend.raiseEvent(instanceId, eventName, encodedData); } @@ -100,7 +102,9 @@ export class TestOrchestrationClient { * Terminates an orchestration. */ async terminateOrchestration(instanceId: string, output: any = null): Promise { - const encodedOutput = output !== null ? JSON.stringify(output) : undefined; + // Always serialize output — including null — to match TaskHubGrpcClient behavior. + // The real client unconditionally calls JSON.stringify(output), which turns null into "null". + const encodedOutput = JSON.stringify(output); this.backend.terminate(instanceId, encodedOutput); } diff --git a/packages/durabletask-js/test/test-client-serialization.spec.ts b/packages/durabletask-js/test/test-client-serialization.spec.ts new file mode 100644 index 00000000..32f65c16 --- /dev/null +++ b/packages/durabletask-js/test/test-client-serialization.spec.ts @@ -0,0 +1,191 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +import { + InMemoryOrchestrationBackend, + TestOrchestrationClient, + TestOrchestrationWorker, + OrchestrationStatus, + OrchestrationContext, + TOrchestrator, +} from "../src"; + +/** + * Tests that TestOrchestrationClient serializes null values the same way as the + * real TaskHubGrpcClient. + * + * The real client unconditionally calls JSON.stringify(data) even when the value + * is null, which produces the string "null". The test client must match this + * behavior so that orchestrations tested with the in-memory backend receive the + * same values they would in production. + */ +describe("TestOrchestrationClient null serialization", () => { + let backend: InMemoryOrchestrationBackend; + let client: TestOrchestrationClient; + let worker: TestOrchestrationWorker; + + beforeEach(async () => { + backend = new InMemoryOrchestrationBackend(); + client = new TestOrchestrationClient(backend); + worker = new TestOrchestrationWorker(backend); + }); + + afterEach(async () => { + if (worker) { + try { + await worker.stop(); + } catch { + // Ignore if not running + } + } + backend.reset(); + }); + + it("raiseOrchestrationEvent with null data should deliver null (not undefined)", async () => { + let receivedValue: any = "sentinel"; + + const orchestrator: TOrchestrator = async function* (ctx: OrchestrationContext): any { + receivedValue = yield ctx.waitForExternalEvent("my_event"); + return receivedValue; + }; + + worker.addOrchestrator(orchestrator); + await worker.start(); + + const id = await client.scheduleNewOrchestration(orchestrator); + await client.waitForOrchestrationStart(id, false, 5); + + // Raise event with no data (defaults to null) + await client.raiseOrchestrationEvent(id, "my_event"); + + const state = await client.waitForOrchestrationCompletion(id, true, 10); + + expect(state).toBeDefined(); + expect(state?.runtimeStatus).toEqual(OrchestrationStatus.COMPLETED); + // The orchestrator should receive null — the same value the real client delivers. + // Before this fix, the test client would deliver undefined instead. + expect(receivedValue).toBeNull(); + expect(state?.serializedOutput).toEqual("null"); + }); + + it("raiseOrchestrationEvent with explicit null should deliver null", async () => { + let receivedValue: any = "sentinel"; + + const orchestrator: TOrchestrator = async function* (ctx: OrchestrationContext): any { + receivedValue = yield ctx.waitForExternalEvent("my_event"); + return receivedValue; + }; + + worker.addOrchestrator(orchestrator); + await worker.start(); + + const id = await client.scheduleNewOrchestration(orchestrator); + await client.waitForOrchestrationStart(id, false, 5); + + // Raise event with explicit null + await client.raiseOrchestrationEvent(id, "my_event", null); + + const state = await client.waitForOrchestrationCompletion(id, true, 10); + + expect(state).toBeDefined(); + expect(state?.runtimeStatus).toEqual(OrchestrationStatus.COMPLETED); + expect(receivedValue).toBeNull(); + }); + + it("raiseOrchestrationEvent with non-null data should work normally", async () => { + let receivedValue: any = "sentinel"; + + const orchestrator: TOrchestrator = async function* (ctx: OrchestrationContext): any { + receivedValue = yield ctx.waitForExternalEvent("my_event"); + return receivedValue; + }; + + worker.addOrchestrator(orchestrator); + await worker.start(); + + const id = await client.scheduleNewOrchestration(orchestrator); + await client.waitForOrchestrationStart(id, false, 5); + + await client.raiseOrchestrationEvent(id, "my_event", { key: "value" }); + + const state = await client.waitForOrchestrationCompletion(id, true, 10); + + expect(state).toBeDefined(); + expect(state?.runtimeStatus).toEqual(OrchestrationStatus.COMPLETED); + expect(receivedValue).toEqual({ key: "value" }); + }); + + it("raiseOrchestrationEvent with falsy values should serialize them", async () => { + const receivedValues: any[] = []; + + const orchestrator: TOrchestrator = async function* (ctx: OrchestrationContext): any { + receivedValues.push(yield ctx.waitForExternalEvent("e1")); + receivedValues.push(yield ctx.waitForExternalEvent("e2")); + receivedValues.push(yield ctx.waitForExternalEvent("e3")); + return receivedValues; + }; + + worker.addOrchestrator(orchestrator); + await worker.start(); + + const id = await client.scheduleNewOrchestration(orchestrator); + await client.waitForOrchestrationStart(id, false, 5); + + // 0, false, and "" are all falsy but valid JSON values + await client.raiseOrchestrationEvent(id, "e1", 0); + await client.raiseOrchestrationEvent(id, "e2", false); + await client.raiseOrchestrationEvent(id, "e3", ""); + + const state = await client.waitForOrchestrationCompletion(id, true, 10); + + expect(state).toBeDefined(); + expect(state?.runtimeStatus).toEqual(OrchestrationStatus.COMPLETED); + expect(receivedValues[0]).toBe(0); + expect(receivedValues[1]).toBe(false); + expect(receivedValues[2]).toBe(""); + }); + + it("terminateOrchestration with null output should store null (not undefined)", async () => { + const orchestrator: TOrchestrator = async function* (ctx: OrchestrationContext): any { + yield ctx.waitForExternalEvent("never"); + return "never reached"; + }; + + worker.addOrchestrator(orchestrator); + await worker.start(); + + const id = await client.scheduleNewOrchestration(orchestrator); + await client.waitForOrchestrationStart(id, false, 5); + + // Terminate with no output (defaults to null) + await client.terminateOrchestration(id); + + const state = await client.waitForOrchestrationCompletion(id, true, 10); + + expect(state).toBeDefined(); + expect(state?.runtimeStatus).toEqual(OrchestrationStatus.TERMINATED); + // The real client stores "null" as the serialized output, not undefined + expect(state?.serializedOutput).toEqual("null"); + }); + + it("terminateOrchestration with explicit output should serialize it", async () => { + const orchestrator: TOrchestrator = async function* (ctx: OrchestrationContext): any { + yield ctx.waitForExternalEvent("never"); + return "never reached"; + }; + + worker.addOrchestrator(orchestrator); + await worker.start(); + + const id = await client.scheduleNewOrchestration(orchestrator); + await client.waitForOrchestrationStart(id, false, 5); + + await client.terminateOrchestration(id, "stopped"); + + const state = await client.waitForOrchestrationCompletion(id, true, 10); + + expect(state).toBeDefined(); + expect(state?.runtimeStatus).toEqual(OrchestrationStatus.TERMINATED); + expect(state?.serializedOutput).toEqual(JSON.stringify("stopped")); + }); +});