@@ -585,66 +585,202 @@ describe('e2e', () => {
585585 expect ( returnValue ) . toEqual ( [ 0 , 1 , 2 , 3 , 4 ] ) ;
586586 } ) ;
587587
588- test ( 'retryAttemptCounterWorkflow' , { timeout : 60_000 } , async ( ) => {
589- const run = await triggerWorkflow ( 'retryAttemptCounterWorkflow' , [ ] ) ;
590- const returnValue = await getWorkflowReturnValue ( run . runId ) ;
588+ // ==================== ERROR HANDLING TESTS ====================
589+ describe ( 'error handling' , ( ) => {
590+ describe ( 'error propagation' , ( ) => {
591+ describe ( 'workflow errors' , ( ) => {
592+ test (
593+ 'nested function calls preserve message and stack trace' ,
594+ { timeout : 60_000 } ,
595+ async ( ) => {
596+ const run = await triggerWorkflow ( 'errorWorkflowNested' , [ ] ) ;
597+ const result = await getWorkflowReturnValue ( run . runId ) ;
598+
599+ expect ( result . name ) . toBe ( 'WorkflowRunFailedError' ) ;
600+ expect ( result . cause . message ) . toContain ( 'Nested workflow error' ) ;
601+ // Stack shows call chain: errorNested1 -> errorNested2 -> errorNested3
602+ expect ( result . cause . stack ) . toContain ( 'errorNested1' ) ;
603+ expect ( result . cause . stack ) . toContain ( 'errorNested2' ) ;
604+ expect ( result . cause . stack ) . toContain ( 'errorNested3' ) ;
605+ expect ( result . cause . stack ) . toContain ( 'errorWorkflowNested' ) ;
606+ expect ( result . cause . stack ) . toContain ( '99_e2e.ts' ) ;
607+ expect ( result . cause . stack ) . not . toContain ( 'evalmachine' ) ;
608+
609+ const { json : runData } = await cliInspectJson ( `runs ${ run . runId } ` ) ;
610+ expect ( runData . status ) . toBe ( 'failed' ) ;
611+ }
612+ ) ;
591613
592- // The step should have succeeded on attempt 3
593- expect ( returnValue ) . toEqual ( { finalAttempt : 3 } ) ;
614+ test (
615+ 'cross-file imports preserve message and stack trace' ,
616+ { timeout : 60_000 } ,
617+ async ( ) => {
618+ const run = await triggerWorkflow ( 'errorWorkflowCrossFile' , [ ] ) ;
619+ const result = await getWorkflowReturnValue ( run . runId ) ;
620+
621+ expect ( result . name ) . toBe ( 'WorkflowRunFailedError' ) ;
622+ expect ( result . cause . message ) . toContain (
623+ 'Error from imported helper module'
624+ ) ;
625+ expect ( result . cause . stack ) . toContain ( 'throwError' ) ;
626+ expect ( result . cause . stack ) . toContain ( 'callThrower' ) ;
627+ expect ( result . cause . stack ) . toContain ( 'errorWorkflowCrossFile' ) ;
628+ expect ( result . cause . stack ) . not . toContain ( 'evalmachine' ) ;
629+
630+ // helpers.ts reference (known issue: vite-based frameworks dev mode)
631+ const isViteBasedFrameworkDevMode =
632+ ( process . env . APP_NAME === 'sveltekit' ||
633+ process . env . APP_NAME === 'vite' ||
634+ process . env . APP_NAME === 'astro' ) &&
635+ isLocalDeployment ( ) ;
636+ if ( ! isViteBasedFrameworkDevMode ) {
637+ expect ( result . cause . stack ) . toContain ( 'helpers.ts' ) ;
638+ }
639+
640+ const { json : runData } = await cliInspectJson ( `runs ${ run . runId } ` ) ;
641+ expect ( runData . status ) . toBe ( 'failed' ) ;
642+ }
643+ ) ;
644+ } ) ;
594645
595- // Also verify the run data shows the correct output
596- const { json : runData } = await cliInspectJson (
597- `runs ${ run . runId } --withData`
598- ) ;
599- expect ( runData ) . toMatchObject ( {
600- runId : run . runId ,
601- status : 'completed' ,
602- output : { finalAttempt : 3 } ,
646+ describe ( 'step errors' , ( ) => {
647+ test (
648+ 'basic step error preserves message' ,
649+ { timeout : 60_000 } ,
650+ async ( ) => {
651+ const run = await triggerWorkflow ( 'errorStepBasic' , [ ] ) ;
652+ const result = await getWorkflowReturnValue ( run . runId ) ;
653+
654+ expect ( result . name ) . toBe ( 'WorkflowRunFailedError' ) ;
655+ expect ( result . cause . message ) . toContain ( 'Step error message' ) ;
656+
657+ const { json : steps } = await cliInspectJson (
658+ `steps --runId ${ run . runId } `
659+ ) ;
660+ const failedStep = steps . find ( ( s : any ) =>
661+ s . stepName . includes ( 'errorStepFn' )
662+ ) ;
663+ expect ( failedStep . status ) . toBe ( 'failed' ) ;
664+
665+ const { json : runData } = await cliInspectJson ( `runs ${ run . runId } ` ) ;
666+ expect ( runData . status ) . toBe ( 'failed' ) ;
667+ }
668+ ) ;
669+
670+ test (
671+ 'cross-file step error preserves message and function names in stack' ,
672+ { timeout : 60_000 } ,
673+ async ( ) => {
674+ const run = await triggerWorkflow ( 'errorStepCrossFile' , [ ] ) ;
675+ const result = await getWorkflowReturnValue ( run . runId ) ;
676+
677+ expect ( result . name ) . toBe ( 'WorkflowRunFailedError' ) ;
678+ expect ( result . cause . message ) . toContain (
679+ 'Step error from imported helper module'
680+ ) ;
681+
682+ const { json : steps } = await cliInspectJson (
683+ `steps --runId ${ run . runId } `
684+ ) ;
685+ const failedStep = steps . find ( ( s : any ) =>
686+ s . stepName . includes ( 'stepThatThrowsFromHelper' )
687+ ) ;
688+ expect ( failedStep . status ) . toBe ( 'failed' ) ;
689+ // Note: Step errors don't have source-mapped stack traces (known limitation)
690+ expect ( failedStep . error . stack ) . toContain ( 'throwErrorFromStep' ) ;
691+ expect ( failedStep . error . stack ) . toContain (
692+ 'stepThatThrowsFromHelper'
693+ ) ;
694+
695+ const { json : runData } = await cliInspectJson ( `runs ${ run . runId } ` ) ;
696+ expect ( runData . status ) . toBe ( 'failed' ) ;
697+ }
698+ ) ;
699+ } ) ;
603700 } ) ;
604701
605- // Query steps separately to verify the step data
606- const { json : stepsData } = await cliInspectJson (
607- `steps --runId ${ run . runId } --withData`
608- ) ;
609- expect ( stepsData ) . toBeDefined ( ) ;
610- expect ( Array . isArray ( stepsData ) ) . toBe ( true ) ;
611- expect ( stepsData . length ) . toBeGreaterThan ( 0 ) ;
702+ describe ( 'retry behavior' , ( ) => {
703+ test (
704+ 'regular Error retries until success' ,
705+ { timeout : 60_000 } ,
706+ async ( ) => {
707+ const run = await triggerWorkflow ( 'errorRetrySuccess' , [ ] ) ;
708+ const result = await getWorkflowReturnValue ( run . runId ) ;
709+
710+ expect ( result . finalAttempt ) . toBe ( 3 ) ;
711+
712+ const { json : steps } = await cliInspectJson (
713+ `steps --runId ${ run . runId } `
714+ ) ;
715+ const step = steps . find ( ( s : any ) =>
716+ s . stepName . includes ( 'retryUntilAttempt3' )
717+ ) ;
718+ expect ( step . status ) . toBe ( 'completed' ) ;
719+ expect ( step . attempt ) . toBe ( 3 ) ;
720+ }
721+ ) ;
612722
613- // Find the stepThatRetriesAndSucceeds step
614- const retryStep = stepsData . find ( ( s : any ) =>
615- s . stepName . includes ( 'stepThatRetriesAndSucceeds' )
616- ) ;
617- expect ( retryStep ) . toBeDefined ( ) ;
618- expect ( retryStep . status ) . toBe ( 'completed' ) ;
619- expect ( retryStep . attempt ) . toBe ( 3 ) ;
620- expect ( retryStep . output ) . toEqual ( [ 3 ] ) ;
621- } ) ;
723+ test (
724+ 'FatalError fails immediately without retries' ,
725+ { timeout : 60_000 } ,
726+ async ( ) => {
727+ const run = await triggerWorkflow ( 'errorRetryFatal' , [ ] ) ;
728+ const result = await getWorkflowReturnValue ( run . runId ) ;
729+
730+ expect ( result . name ) . toBe ( 'WorkflowRunFailedError' ) ;
731+ expect ( result . cause . message ) . toContain ( 'Fatal step error' ) ;
732+
733+ const { json : steps } = await cliInspectJson (
734+ `steps --runId ${ run . runId } `
735+ ) ;
736+ const step = steps . find ( ( s : any ) =>
737+ s . stepName . includes ( 'throwFatalError' )
738+ ) ;
739+ expect ( step . status ) . toBe ( 'failed' ) ;
740+ expect ( step . attempt ) . toBe ( 1 ) ;
741+ }
742+ ) ;
622743
623- test ( 'retryableAndFatalErrorWorkflow' , { timeout : 60_000 } , async ( ) => {
624- const run = await triggerWorkflow ( 'retryableAndFatalErrorWorkflow' , [ ] ) ;
625- const returnValue = await getWorkflowReturnValue ( run . runId ) ;
626- expect ( returnValue . retryableResult . attempt ) . toEqual ( 2 ) ;
627- expect ( returnValue . retryableResult . duration ) . toBeGreaterThan ( 10_000 ) ;
628- expect ( returnValue . gotFatalError ) . toBe ( true ) ;
629- } ) ;
744+ test (
745+ 'RetryableError respects custom retryAfter delay' ,
746+ { timeout : 60_000 } ,
747+ async ( ) => {
748+ const run = await triggerWorkflow ( 'errorRetryCustomDelay' , [ ] ) ;
749+ const result = await getWorkflowReturnValue ( run . runId ) ;
630750
631- test (
632- 'maxRetriesZeroWorkflow - maxRetries=0 runs once without retrying' ,
633- { timeout : 60_000 } ,
634- async ( ) => {
635- const run = await triggerWorkflow ( 'maxRetriesZeroWorkflow' , [ ] ) ;
636- const returnValue = await getWorkflowReturnValue ( run . runId ) ;
751+ expect ( result . attempt ) . toBe ( 2 ) ;
752+ expect ( result . duration ) . toBeGreaterThan ( 10_000 ) ;
753+ }
754+ ) ;
637755
638- // The step with maxRetries=0 that succeeds should have run on attempt 1
639- expect ( returnValue . successResult ) . toEqual ( { attempt : 1 } ) ;
756+ test ( 'maxRetries=0 disables retries' , { timeout : 60_000 } , async ( ) => {
757+ const run = await triggerWorkflow ( 'errorRetryDisabled' , [ ] ) ;
758+ const result = await getWorkflowReturnValue ( run . runId ) ;
640759
641- // The step with maxRetries=0 that fails should have thrown an error
642- expect ( returnValue . gotError ) . toBe ( true ) ;
760+ expect ( result . failed ) . toBe ( true ) ;
761+ expect ( result . attempt ) . toBe ( 1 ) ;
762+ } ) ;
763+ } ) ;
643764
644- // The failing step should have only run once (attempt 1), not retried
645- expect ( returnValue . failedAttempt ) . toBe ( 1 ) ;
646- }
647- ) ;
765+ describe ( 'catchability' , ( ) => {
766+ test (
767+ 'FatalError can be caught and detected with FatalError.is()' ,
768+ { timeout : 60_000 } ,
769+ async ( ) => {
770+ const run = await triggerWorkflow ( 'errorFatalCatchable' , [ ] ) ;
771+ const result = await getWorkflowReturnValue ( run . runId ) ;
772+
773+ expect ( result . caught ) . toBe ( true ) ;
774+ expect ( result . isFatal ) . toBe ( true ) ;
775+
776+ // Verify workflow completed successfully (error was caught)
777+ const { json : runData } = await cliInspectJson ( `runs ${ run . runId } ` ) ;
778+ expect ( runData . status ) . toBe ( 'completed' ) ;
779+ }
780+ ) ;
781+ } ) ;
782+ } ) ;
783+ // ==================== END ERROR HANDLING TESTS ====================
648784
649785 test (
650786 'stepDirectCallWorkflow - calling step functions directly outside workflow context' ,
@@ -676,68 +812,6 @@ describe('e2e', () => {
676812 }
677813 ) ;
678814
679- test (
680- 'crossFileErrorWorkflow - stack traces work across imported modules' ,
681- { timeout : 60_000 } ,
682- async ( ) => {
683- // This workflow intentionally throws an error from an imported helper module
684- // to verify that stack traces correctly show cross-file call chains
685- const run = await triggerWorkflow ( 'crossFileErrorWorkflow' , [ ] ) ;
686- const returnValue = await getWorkflowReturnValue ( run . runId ) ;
687-
688- // The workflow should fail with error response containing both top-level and cause
689- expect ( returnValue ) . toHaveProperty ( 'name' ) ;
690- expect ( returnValue . name ) . toBe ( 'WorkflowRunFailedError' ) ;
691- expect ( returnValue ) . toHaveProperty ( 'message' ) ;
692-
693- // Verify the cause property contains the structured error
694- expect ( returnValue ) . toHaveProperty ( 'cause' ) ;
695- expect ( returnValue . cause ) . toBeTypeOf ( 'object' ) ;
696- expect ( returnValue . cause ) . toHaveProperty ( 'message' ) ;
697- expect ( returnValue . cause . message ) . toContain (
698- 'Error from imported helper module'
699- ) ;
700-
701- // Verify the stack trace is present in the cause
702- expect ( returnValue . cause ) . toHaveProperty ( 'stack' ) ;
703- expect ( typeof returnValue . cause . stack ) . toBe ( 'string' ) ;
704-
705- // Known issue: vite-based frameworks dev mode has incorrect source map mappings for bundled imports.
706- // esbuild with bundle:true inlines helpers.ts but source maps incorrectly map to 99_e2e.ts
707- // This works correctly in production and other frameworks.
708- // TODO: Investigate esbuild source map generation for bundled modules
709- const isViteBasedFrameworkDevMode =
710- ( process . env . APP_NAME === 'sveltekit' ||
711- process . env . APP_NAME === 'vite' ||
712- process . env . APP_NAME === 'astro' ) &&
713- isLocalDeployment ( ) ;
714-
715- if ( ! isViteBasedFrameworkDevMode ) {
716- // Stack trace should include frames from the helper module (helpers.ts)
717- expect ( returnValue . cause . stack ) . toContain ( 'helpers.ts' ) ;
718- }
719-
720- // These checks should work in all modes
721- expect ( returnValue . cause . stack ) . toContain ( 'throwError' ) ;
722- expect ( returnValue . cause . stack ) . toContain ( 'callThrower' ) ;
723-
724- // Stack trace should include frames from the workflow file (99_e2e.ts)
725- expect ( returnValue . cause . stack ) . toContain ( '99_e2e.ts' ) ;
726- expect ( returnValue . cause . stack ) . toContain ( 'crossFileErrorWorkflow' ) ;
727-
728- // Stack trace should NOT contain 'evalmachine' anywhere
729- expect ( returnValue . cause . stack ) . not . toContain ( 'evalmachine' ) ;
730-
731- // Verify the run failed with structured error
732- const { json : runData } = await cliInspectJson ( `runs ${ run . runId } ` ) ;
733- expect ( runData . status ) . toBe ( 'failed' ) ;
734- expect ( runData . error ) . toBeTypeOf ( 'object' ) ;
735- expect ( runData . error . message ) . toContain (
736- 'Error from imported helper module'
737- ) ;
738- }
739- ) ;
740-
741815 test (
742816 'hookCleanupTestWorkflow - hook token reuse after workflow completion' ,
743817 { timeout : 60_000 } ,
0 commit comments