Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion packages/temporal/test/error-boundary.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ import {
type WorkflowExecutionInput,
executionWorkflowId,
} from '../src/index';
import { type RecordingStore, createRecordingStore } from './fixtures/graph';
import { type RecordingStore, createRecordingStore } from './fixtures/recording-store';

type BoundaryNode = (BaseNode & { type: 'test/step' }) | (BaseNode & { type: 'test/fail' });

Expand Down
54 changes: 0 additions & 54 deletions packages/temporal/test/fixtures/graph.ts

This file was deleted.

22 changes: 22 additions & 0 deletions packages/temporal/test/fixtures/recording-store.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import type { ExecutionStore } from '../../src/index';

export type RecordingStore = ExecutionStore & {
events: { sequence: number; type: string; nodeId?: string; payload?: unknown }[];
statuses: { status: string; errorMessage?: string }[];
};

export function createRecordingStore(): RecordingStore {
const events: RecordingStore['events'] = [];
const statuses: RecordingStore['statuses'] = [];

return {
events,
statuses,
async emitExecutionEvent(_executionId, sequence, type, payload, nodeId) {
events.push({ sequence, type, nodeId, payload });
},
async updateExecutionStatus(_executionId, status, errorMessage) {
statuses.push({ status, errorMessage });
},
};
}
205 changes: 205 additions & 0 deletions packages/temporal/test/fixtures/replay-scenarios.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,205 @@
// One scenario per path through the sandbox code; each gets its own committed history
// under ../replay/histories/. ../replay/README.md says what each protects.
import { WorkflowFailedError, type WorkflowHandle } from '@temporalio/client';

import {
type BaseNode,
type NodeExecutorRegistry,
PermanentNodeExecutionError,
type WorkflowDefinition,
} from '../../src/index';

export type ReplayScenarioNode =
| (BaseNode & { type: 'test/step' })
| (BaseNode & { type: 'test/fail' })
| (BaseNode & { type: 'test/route' })
| (BaseNode & { type: 'test/block' });

type ActivityCounts = { executeNode: number; emitEvent: number; updateStatus: number };

type WorkflowCloseAttributes =
| 'workflowExecutionCompletedEventAttributes'
| 'workflowExecutionFailedEventAttributes'
| 'workflowExecutionCanceledEventAttributes';

export type ReplayScenario = {
name: string;
graph: WorkflowDefinition<ReplayScenarioNode>;
terminalEvent: string;
terminalStatus: string;
terminalErrorMessage?: string;
closeAttributes: WorkflowCloseAttributes;
expectedActivities: ActivityCounts;
// Keyed by node id, so the assertion does not depend on the order siblings finish in.
// Every node in the graph needs an entry; one that never ran gets an empty list.
nodeEvents: Record<string, string[]>;
// Executors and the driver are built together, per run, so a scenario can share
// run-local state between them (the blocked node the cancel scenario releases).
stage(): {
executors: NodeExecutorRegistry<ReplayScenarioNode>;
drive(handle: WorkflowHandle): Promise<void>;
};
};

const UNWIRED_PORT = 'no';

const executors: NodeExecutorRegistry<ReplayScenarioNode> = {
'test/step': (node, context) => ({ output: { visited: node.id, after: Object.keys(context.nodeOutputs).sort() } }),
'test/fail': () => {
throw new PermanentNodeExecutionError('replay_fixture_failure', 'fails on purpose');
},
'test/route': () => ({ output: null, nextPort: UNWIRED_PORT }),
'test/block': () => {
throw new Error('test/block is staged by the cancel-mid-run scenario only');
},
};

// How a run ended is asserted from the store and the history, not from the result — but
// only the run's own failure is swallowed, so a client or connection fault surfaces here
// instead of as a puzzling assertion later. A cancelled run also arrives as this class.
async function settle(handle: WorkflowHandle): Promise<void> {
try {
await handle.result();
} catch (error) {
if (!(error instanceof WorkflowFailedError)) throw error;
}
}

export const REPLAY_SCENARIOS: ReplayScenario[] = [
{
// start ─┬─▶ left ──┬─▶ join The fan-out is the point: it is the only shape that
// └─▶ right ─┘ puts two commands in one workflow task.
name: 'parallel-wave',
graph: {
workflowId: 'replay-test-workflow',
nodes: [
{ id: 'start', type: 'test/step', role: 'start', config: { label: 'start' } },
{ id: 'left', type: 'test/step', config: { label: 'left' } },
{ id: 'right', type: 'test/step', config: { label: 'right' } },
{ id: 'join', type: 'test/step', config: { label: 'join' } },
],
edges: [
{ id: 'e-start-left', sourceNodeId: 'start', targetNodeId: 'left' },
{ id: 'e-start-right', sourceNodeId: 'start', targetNodeId: 'right' },
{ id: 'e-left-join', sourceNodeId: 'left', targetNodeId: 'join' },
{ id: 'e-right-join', sourceNodeId: 'right', targetNodeId: 'join' },
],
},
terminalEvent: 'execution_completed',
terminalStatus: 'completed',
closeAttributes: 'workflowExecutionCompletedEventAttributes',
expectedActivities: { executeNode: 4, emitEvent: 10, updateStatus: 1 },
nodeEvents: {
start: ['node_started', 'node_completed'],
left: ['node_started', 'node_completed'],
right: ['node_started', 'node_completed'],
join: ['node_started', 'node_completed'],
},
stage: () => ({ executors, drive: settle }),
},
{
// start ─┬─▶ fail ────┬─▶ join fail throws under the default policy: the wave
// └─▶ sibling ─┘ still finishes, join is never reached, no skips.
name: 'fail-policy',
graph: {
workflowId: 'replay-fail-policy',
nodes: [
{ id: 'start', type: 'test/step', role: 'start', config: {} },
{ id: 'fail', type: 'test/fail', config: {} },
{ id: 'sibling', type: 'test/step', config: {} },
{ id: 'join', type: 'test/step', config: {} },
],
edges: [
{ id: 'e-start-fail', sourceNodeId: 'start', targetNodeId: 'fail' },
{ id: 'e-start-sibling', sourceNodeId: 'start', targetNodeId: 'sibling' },
{ id: 'e-fail-join', sourceNodeId: 'fail', targetNodeId: 'join' },
{ id: 'e-sibling-join', sourceNodeId: 'sibling', targetNodeId: 'join' },
],
},
terminalEvent: 'execution_failed',
terminalStatus: 'failed',
terminalErrorMessage: 'fails on purpose',
closeAttributes: 'workflowExecutionFailedEventAttributes',
expectedActivities: { executeNode: 3, emitEvent: 8, updateStatus: 1 },
// join is never reached under the fail policy, so it owes no event at all.
nodeEvents: {
start: ['node_started', 'node_completed'],
fail: ['node_started', 'node_failed'],
sibling: ['node_started', 'node_completed'],
join: [],
},
stage: () => ({ executors, drive: settle }),
},
{
// start ─▶ route ─[yes]─▶ taken route names the 'no' port, which has no edge:
// taken is skipped and the run closes incomplete.
name: 'incomplete-branch',
graph: {
workflowId: 'replay-incomplete-branch',
nodes: [
{ id: 'start', type: 'test/step', role: 'start', config: {} },
{ id: 'route', type: 'test/route', config: {} },
{ id: 'taken', type: 'test/step', config: {} },
],
edges: [
{ id: 'e-start-route', sourceNodeId: 'start', targetNodeId: 'route' },
{ id: 'e-route-taken', sourceNodeId: 'route', targetNodeId: 'taken', sourceHandle: 'yes' },
],
},
terminalEvent: 'execution_incomplete',
terminalStatus: 'incomplete',
closeAttributes: 'workflowExecutionCompletedEventAttributes',
expectedActivities: { executeNode: 2, emitEvent: 7, updateStatus: 1 },
nodeEvents: {
start: ['node_started', 'node_completed'],
route: ['node_started', 'node_completed'],
taken: ['node_skipped'],
},
stage: () => ({ executors, drive: settle }),
},
{
// start ─▶ block block parks until released; the driver cancels the run while it
// is in flight, then lets it finish so the worker can drain.
name: 'cancel-mid-run',
graph: {
workflowId: 'replay-cancel-mid-run',
nodes: [
{ id: 'start', type: 'test/step', role: 'start', config: {} },
{ id: 'block', type: 'test/block', config: {} },
],
edges: [{ id: 'e-start-block', sourceNodeId: 'start', targetNodeId: 'block' }],
},
terminalEvent: 'execution_cancelled',
terminalStatus: 'cancelled',
closeAttributes: 'workflowExecutionCanceledEventAttributes',
expectedActivities: { executeNode: 2, emitEvent: 5, updateStatus: 1 },
// block is cancelled in flight, so it starts and never completes.
nodeEvents: { start: ['node_started', 'node_completed'], block: ['node_started'] },
stage: () => {
let reached!: () => void;
let release!: () => void;
const blockReached = new Promise<void>((resolve) => (reached = resolve));
const released = new Promise<void>((resolve) => (release = resolve));

return {
executors: {
...executors,
'test/block': async () => {
reached();
await released;
return { output: null };
},
},
drive: async (handle) => {
await blockReached;
await handle.cancel();
await settle(handle);
// Released only now, so the cancel is recorded with the activity still open. The
// late completion then meets a closed run; Temporal core logs that as a single
// "Activity not found on completion" warning, which is expected here.
release();
},
};
},
},
];
Loading
Loading