Skip to content

Commit 1bab22c

Browse files
committed
Refactor e2e tests for errors
1 parent 75630fe commit 1bab22c

File tree

3 files changed

+372
-260
lines changed

3 files changed

+372
-260
lines changed

packages/core/e2e/e2e.test.ts

Lines changed: 186 additions & 112 deletions
Original file line numberDiff line numberDiff line change
@@ -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

Comments
 (0)