diff --git a/packages/durabletask-js/src/worker/entity-executor.ts b/packages/durabletask-js/src/worker/entity-executor.ts index 1061c1a..e97a5de 100644 --- a/packages/durabletask-js/src/worker/entity-executor.ts +++ b/packages/durabletask-js/src/worker/entity-executor.ts @@ -11,6 +11,7 @@ import * as pb from "../proto/orchestrator_service_pb"; import { StringValue } from "google-protobuf/google/protobuf/wrappers_pb"; import { Timestamp } from "google-protobuf/google/protobuf/timestamp_pb"; import { randomUUID } from "crypto"; +import { parseJsonField } from "../utils/pb-helper.util"; /** * Internal type representing actions collected during entity execution. @@ -356,7 +357,11 @@ export class TaskEntityShim { try { const inputValue = opRequest.getInput(); - const input = inputValue ? JSON.parse(inputValue.getValue()) : undefined; + // Use the shared parseJsonField helper so an empty StringValue is treated as + // "no input" (returns undefined) instead of crashing on JSON.parse(""). A + // StringValue object is always truthy, so the previous `inputValue ? ...` + // guard did not protect against an empty wrapped string. + const input = parseJsonField(inputValue); // Set operation details this.operationShim.setNameAndInput(opRequest.getOperation(), input); diff --git a/packages/durabletask-js/test/entity-executor.spec.ts b/packages/durabletask-js/test/entity-executor.spec.ts index aa462d2..232417c 100644 --- a/packages/durabletask-js/test/entity-executor.spec.ts +++ b/packages/durabletask-js/test/entity-executor.spec.ts @@ -69,6 +69,17 @@ class CounterEntity extends TaskEntity<{ count: number }> { } } +// Entity used to assert how operation input is delivered to the dispatched method. +class InputCaptureEntity extends TaskEntity<{ calls: number }> { + capture(input?: unknown): { hadInput: boolean; value: unknown } { + return { hadInput: input !== undefined, value: input ?? null }; + } + + protected initializeState(): { calls: number } { + return { calls: 0 }; + } +} + describe("TaskEntityShim", () => { const entityId = new EntityInstanceId("counter", "test"); @@ -614,4 +625,79 @@ describe("TaskEntityShim", () => { expect(JSON.parse(result.getResultsList()[1].getSuccess()!.getResult()!.getValue())).toBe(10); }); }); + + describe("empty operation input", () => { + it("should treat an empty StringValue input as no input instead of throwing SyntaxError", async () => { + const entity = new CounterEntity(); + const shim = new TaskEntityShim(entity, entityId); + + // Manually build a request whose operation input is an empty StringValue. + // A StringValue wrapping "" is a truthy object, so the previous inline + // `inputValue ? JSON.parse(inputValue.getValue()) : undefined` guard used to + // call JSON.parse("") and throw "Unexpected end of JSON input", failing the + // operation and rolling it back instead of treating it as no input. + const request = new pb.EntityBatchRequest(); + request.setInstanceid(entityId.toString()); + + const stateValue = new StringValue(); + stateValue.setValue(JSON.stringify({ count: 7 })); + request.setEntitystate(stateValue); + + const opRequest = new pb.OperationRequest(); + opRequest.setOperation("get"); + opRequest.setInput(new StringValue()); // empty StringValue, getValue() === "" + request.addOperations(opRequest); + + const result = await shim.executeAsync(request); + + const opResult = result.getResultsList()[0]; + expect(opResult.hasFailure()).toBe(false); + expect(opResult.hasSuccess()).toBe(true); + expect(JSON.parse(opResult.getSuccess()!.getResult()!.getValue())).toBe(7); + }); + + it("should dispatch the operation with no input when the StringValue is empty", async () => { + const entity = new InputCaptureEntity(); + const shim = new TaskEntityShim(entity, entityId); + + const request = new pb.EntityBatchRequest(); + request.setInstanceid(entityId.toString()); + + const opRequest = new pb.OperationRequest(); + opRequest.setOperation("capture"); + opRequest.setInput(new StringValue()); // empty StringValue + request.addOperations(opRequest); + + const result = await shim.executeAsync(request); + + const opResult = result.getResultsList()[0]; + expect(opResult.hasSuccess()).toBe(true); + const captured = JSON.parse(opResult.getSuccess()!.getResult()!.getValue()); + expect(captured).toEqual({ hadInput: false, value: null }); + }); + + it("should still deliver a genuine empty-string JSON value when explicitly provided", async () => { + // Sanity check: a StringValue holding the JSON text '""' is a real empty + // string input and must be delivered as "" (not dropped as "no input"). + const entity = new InputCaptureEntity(); + const shim = new TaskEntityShim(entity, entityId); + + const request = new pb.EntityBatchRequest(); + request.setInstanceid(entityId.toString()); + + const opRequest = new pb.OperationRequest(); + opRequest.setOperation("capture"); + const inputValue = new StringValue(); + inputValue.setValue(JSON.stringify("")); // the two-character string: "" + opRequest.setInput(inputValue); + request.addOperations(opRequest); + + const result = await shim.executeAsync(request); + + const opResult = result.getResultsList()[0]; + expect(opResult.hasSuccess()).toBe(true); + const captured = JSON.parse(opResult.getSuccess()!.getResult()!.getValue()); + expect(captured).toEqual({ hadInput: true, value: "" }); + }); + }); });