@@ -2872,6 +2872,287 @@ describe('custom class serialization', () => {
28722872 } ) ;
28732873} ) ;
28742874
2875+ describe ( 'custom Error subclass serialization' , ( ) => {
2876+ const { context, globalThis : vmGlobalThis } = createContext ( {
2877+ seed : 'test-error-serde' ,
2878+ fixedTimestamp : 1714857600000 ,
2879+ } ) ;
2880+
2881+ // Make the serialization symbols available inside the VM
2882+ ( vmGlobalThis as any ) . WORKFLOW_SERIALIZE = WORKFLOW_SERIALIZE ;
2883+ ( vmGlobalThis as any ) . WORKFLOW_DESERIALIZE = WORKFLOW_DESERIALIZE ;
2884+
2885+ // Define registerSerializationClass inside the VM so that it uses the VM's globalThis.
2886+ runInContext (
2887+ `
2888+ const WORKFLOW_CLASS_REGISTRY = Symbol.for('workflow-class-registry');
2889+ function registerSerializationClass(classId, cls) {
2890+ let registry = globalThis[WORKFLOW_CLASS_REGISTRY];
2891+ if (!registry) {
2892+ registry = new Map();
2893+ globalThis[WORKFLOW_CLASS_REGISTRY] = registry;
2894+ }
2895+ registry.set(classId, cls);
2896+ Object.defineProperty(cls, 'classId', {
2897+ value: classId,
2898+ writable: false,
2899+ enumerable: false,
2900+ configurable: false,
2901+ });
2902+ }
2903+ globalThis.registerSerializationClass = registerSerializationClass;
2904+ ` ,
2905+ context
2906+ ) ;
2907+
2908+ it ( 'should use custom serialization for Error subclass with WORKFLOW_SERIALIZE instead of generic Error serialization' , async ( ) => {
2909+ // Define an Error subclass with custom serialization that preserves extra fields
2910+ class AppError extends Error {
2911+ constructor (
2912+ message : string ,
2913+ public code : number ,
2914+ public details : string
2915+ ) {
2916+ super ( message ) ;
2917+ this . name = 'AppError' ;
2918+ }
2919+
2920+ static [ WORKFLOW_SERIALIZE ] ( instance : AppError ) {
2921+ return {
2922+ message : instance . message ,
2923+ code : instance . code ,
2924+ details : instance . details ,
2925+ } ;
2926+ }
2927+
2928+ static [ WORKFLOW_DESERIALIZE ] ( data : {
2929+ message : string ;
2930+ code : number ;
2931+ details : string ;
2932+ } ) {
2933+ return new AppError ( data . message , data . code , data . details ) ;
2934+ }
2935+ }
2936+
2937+ // The classId is normally generated by the SWC compiler
2938+ ( AppError as any ) . classId = 'test/AppError' ;
2939+
2940+ // Register the class on the host for serialization
2941+ registerSerializationClass ( 'test/AppError' , AppError ) ;
2942+
2943+ // Define and register the class inside the VM
2944+ runInContext (
2945+ `
2946+ class AppError extends Error {
2947+ constructor(message, code, details) {
2948+ super(message);
2949+ this.name = 'AppError';
2950+ this.code = code;
2951+ this.details = details;
2952+ }
2953+ static [WORKFLOW_SERIALIZE](instance) {
2954+ return { message: instance.message, code: instance.code, details: instance.details };
2955+ }
2956+ static [WORKFLOW_DESERIALIZE](data) {
2957+ return new AppError(data.message, data.code, data.details);
2958+ }
2959+ }
2960+ AppError.classId = 'test/AppError';
2961+ registerSerializationClass('test/AppError', AppError);
2962+ ` ,
2963+ context
2964+ ) ;
2965+
2966+ const error = new AppError ( 'not found' , 404 , 'Resource does not exist' ) ;
2967+
2968+ // Serialize using workflow arguments (which uses getCommonReducers)
2969+ const serialized = await dehydrateWorkflowArguments (
2970+ error ,
2971+ mockRunId ,
2972+ noEncryptionKey ,
2973+ [ ]
2974+ ) ;
2975+
2976+ // Verify the serialized data uses Instance (custom class), NOT Error
2977+ const serializedStr = new TextDecoder ( ) . decode ( serialized ) ;
2978+ expect ( serializedStr ) . toContain ( 'test/AppError' ) ;
2979+ expect ( serializedStr ) . toContain ( 'Instance' ) ;
2980+ expect ( serializedStr ) . not . toMatch ( / " E r r o r " / ) ;
2981+
2982+ // Hydrate it back
2983+ const hydrated = await hydrateWorkflowArguments (
2984+ serialized ,
2985+ mockRunId ,
2986+ noEncryptionKey ,
2987+ vmGlobalThis
2988+ ) ;
2989+
2990+ // The hydrated object should be an AppError with custom fields preserved
2991+ expect ( hydrated . constructor . name ) . toBe ( 'AppError' ) ;
2992+ expect ( hydrated . message ) . toBe ( 'not found' ) ;
2993+ expect ( hydrated . code ) . toBe ( 404 ) ;
2994+ expect ( hydrated . details ) . toBe ( 'Resource does not exist' ) ;
2995+ } ) ;
2996+
2997+ it ( 'should still serialize plain Error instances using the generic Error reducer' , async ( ) => {
2998+ const error = new Error ( 'plain error' ) ;
2999+
3000+ const serialized = await dehydrateWorkflowArguments (
3001+ error ,
3002+ mockRunId ,
3003+ noEncryptionKey ,
3004+ [ ]
3005+ ) ;
3006+
3007+ // Verify it uses the Error reducer, not Instance
3008+ const serializedStr = new TextDecoder ( ) . decode ( serialized ) ;
3009+ expect ( serializedStr ) . toContain ( 'Error' ) ;
3010+ expect ( serializedStr ) . not . toContain ( 'Instance' ) ;
3011+
3012+ // Hydrate it back
3013+ const hydrated = await hydrateWorkflowArguments (
3014+ serialized ,
3015+ mockRunId ,
3016+ noEncryptionKey ,
3017+ globalThis
3018+ ) ;
3019+
3020+ expect ( hydrated ) . toBeInstanceOf ( Error ) ;
3021+ expect ( hydrated . message ) . toBe ( 'plain error' ) ;
3022+ } ) ;
3023+
3024+ it ( 'should serialize Error subclass WITHOUT WORKFLOW_SERIALIZE using generic Error reducer' , async ( ) => {
3025+ // An Error subclass that does NOT implement custom serialization
3026+ class SimpleError extends Error {
3027+ constructor ( message : string ) {
3028+ super ( message ) ;
3029+ this . name = 'SimpleError' ;
3030+ }
3031+ }
3032+
3033+ const error = new SimpleError ( 'simple error' ) ;
3034+
3035+ const serialized = await dehydrateWorkflowArguments (
3036+ error ,
3037+ mockRunId ,
3038+ noEncryptionKey ,
3039+ [ ]
3040+ ) ;
3041+
3042+ // Should use generic Error serialization since no WORKFLOW_SERIALIZE
3043+ const serializedStr = new TextDecoder ( ) . decode ( serialized ) ;
3044+ expect ( serializedStr ) . toContain ( 'Error' ) ;
3045+ expect ( serializedStr ) . not . toContain ( 'Instance' ) ;
3046+
3047+ const hydrated = await hydrateWorkflowArguments (
3048+ serialized ,
3049+ mockRunId ,
3050+ noEncryptionKey ,
3051+ globalThis
3052+ ) ;
3053+
3054+ expect ( hydrated ) . toBeInstanceOf ( Error ) ;
3055+ expect ( hydrated . name ) . toBe ( 'SimpleError' ) ;
3056+ expect ( hydrated . message ) . toBe ( 'simple error' ) ;
3057+ } ) ;
3058+
3059+ it ( 'should use custom serialization for Error subclass in step arguments' , async ( ) => {
3060+ class StepError extends Error {
3061+ constructor (
3062+ message : string ,
3063+ public statusCode : number
3064+ ) {
3065+ super ( message ) ;
3066+ this . name = 'StepError' ;
3067+ }
3068+
3069+ static [ WORKFLOW_SERIALIZE ] ( instance : StepError ) {
3070+ return { message : instance . message , statusCode : instance . statusCode } ;
3071+ }
3072+
3073+ static [ WORKFLOW_DESERIALIZE ] ( data : {
3074+ message : string ;
3075+ statusCode : number ;
3076+ } ) {
3077+ return new StepError ( data . message , data . statusCode ) ;
3078+ }
3079+ }
3080+
3081+ ( StepError as any ) . classId = 'test/StepError' ;
3082+ registerSerializationClass ( 'test/StepError' , StepError ) ;
3083+
3084+ const error = new StepError ( 'bad request' , 400 ) ;
3085+
3086+ const serialized = await dehydrateStepArguments (
3087+ [ error ] ,
3088+ mockRunId ,
3089+ noEncryptionKey ,
3090+ globalThis
3091+ ) ;
3092+
3093+ const ops : Promise < void > [ ] = [ ] ;
3094+ const hydrated = await hydrateStepArguments (
3095+ serialized ,
3096+ mockRunId ,
3097+ noEncryptionKey ,
3098+ ops ,
3099+ globalThis
3100+ ) ;
3101+
3102+ expect ( Array . isArray ( hydrated ) ) . toBe ( true ) ;
3103+ expect ( hydrated [ 0 ] ) . toBeInstanceOf ( StepError ) ;
3104+ expect ( hydrated [ 0 ] . message ) . toBe ( 'bad request' ) ;
3105+ expect ( ( hydrated [ 0 ] as StepError ) . statusCode ) . toBe ( 400 ) ;
3106+ } ) ;
3107+
3108+ it ( 'should use custom serialization for Error subclass in step return values' , async ( ) => {
3109+ class ReturnError extends Error {
3110+ constructor (
3111+ message : string ,
3112+ public errorCode : string
3113+ ) {
3114+ super ( message ) ;
3115+ this . name = 'ReturnError' ;
3116+ }
3117+
3118+ static [ WORKFLOW_SERIALIZE ] ( instance : ReturnError ) {
3119+ return { message : instance . message , errorCode : instance . errorCode } ;
3120+ }
3121+
3122+ static [ WORKFLOW_DESERIALIZE ] ( data : {
3123+ message : string ;
3124+ errorCode : string ;
3125+ } ) {
3126+ return new ReturnError ( data . message , data . errorCode ) ;
3127+ }
3128+ }
3129+
3130+ ( ReturnError as any ) . classId = 'test/ReturnError' ;
3131+ registerSerializationClass ( 'test/ReturnError' , ReturnError ) ;
3132+
3133+ const error = new ReturnError ( 'timeout' , 'ERR_TIMEOUT' ) ;
3134+
3135+ const serialized = await dehydrateStepReturnValue (
3136+ error ,
3137+ mockRunId ,
3138+ noEncryptionKey ,
3139+ [ ]
3140+ ) ;
3141+
3142+ // Step return values are hydrated with workflow revivers
3143+ const hydrated = await hydrateWorkflowArguments (
3144+ serialized ,
3145+ mockRunId ,
3146+ noEncryptionKey ,
3147+ globalThis
3148+ ) ;
3149+
3150+ expect ( hydrated ) . toBeInstanceOf ( ReturnError ) ;
3151+ expect ( hydrated . message ) . toBe ( 'timeout' ) ;
3152+ expect ( hydrated . errorCode ) . toBe ( 'ERR_TIMEOUT' ) ;
3153+ } ) ;
3154+ } ) ;
3155+
28753156describe ( 'format prefix system' , ( ) => {
28763157 const { globalThis : vmGlobalThis } = createContext ( {
28773158 seed : 'test' ,
0 commit comments