Skip to content

Commit e9e76c2

Browse files
committed
WIP scenarios refactor
1 parent a8ffb1a commit e9e76c2

File tree

21 files changed

+1080
-13
lines changed

21 files changed

+1080
-13
lines changed

README.md

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,79 @@ We recommend using [Camunda 8 Run](https://docs.camunda.io/docs/self-managed/qui
6060

6161
You can configure your Camunda 8 environment in the `demo/.env` file.
6262

63+
## Test Scenarios
64+
65+
The integration tests in `test/` are driven by **recorded fixture files** (`test/fixtures/scenarios/*.json`) rather than a live Camunda instance. Each fixture captures a full timeline of API responses from a real run, which is then replayed deterministically in CI.
66+
67+
### How it works
68+
69+
1. A *scenario* is a self-contained folder under `test/scenarios/` with its own BPMN diagram (`process.bpmn`) and a definition file (`scenario.mjs`).
70+
2. The headless runner (`test/scenarios/run.mjs`) deploys the BPMN to a live Camunda 8 instance, starts a process instance, activates any job workers, polls until the process reaches a terminal state, and writes the recorded timeline to `test/fixtures/scenarios/<name>.json`.
71+
3. The committed JSON fixtures are what the unit tests actually execute against — no live Camunda required in CI.
72+
4. Re-running the recorder and doing a `git diff` is an effective regression test against the Camunda engine: if the fixture changes unexpectedly, something in engine behaviour has changed.
73+
74+
### Folder structure
75+
76+
```
77+
test/scenarios/
78+
helpers.mjs # worker factory helpers (completeWorker, failWorker, …)
79+
run.mjs # headless recorder CLI
80+
service-task-success/
81+
process.bpmn # owned BPMN — do not share across scenarios
82+
scenario.mjs # scenario definition
83+
service-task-incident/
84+
...
85+
```
86+
87+
Each `scenario.mjs` exports a default object:
88+
89+
```js
90+
export default {
91+
name: 'service-task-success', // used as output filename
92+
description: '...',
93+
resources: [ './process.bpmn' ], // paths relative to this file
94+
processId: 'Process_ServiceTaskSuccess',
95+
elementId: 'ServiceTask_1', // element shown in the task-testing panel
96+
variables: { foo: 'bar' }, // process variables to pass on start
97+
workers: [
98+
completeWorker('task-testing:service-task-success', { result: 'done' })
99+
],
100+
maxPolls: 10, // optional; default: 20
101+
pollIntervalMs: 500 // optional; default: 500
102+
};
103+
```
104+
105+
Worker helpers (`helpers.mjs`):
106+
107+
| Helper | Description |
108+
|---|---|
109+
| `completeWorker(type, output?, delayMs?)` | Completes every job with optional output variables |
110+
| `failWorker(type, message, { errorCode?, retries?, delayMs? })` | Fails every job; `retries: 0` creates an incident |
111+
| `messageWorker(type, messageName, getCorrelationKey, options?)` | Completes job then correlates a message |
112+
| `customWorker(type, handler)` | Full control — `handler(job, context)` called for each job |
113+
114+
### Recording fixtures
115+
116+
Requires a live Camunda 8 instance configured in `demo/.env` (same as `npm start`).
117+
118+
```sh
119+
# Record all scenarios
120+
npm run scenarios:record
121+
122+
# Record a single scenario
123+
npm run scenarios:record:one test/scenarios/service-task-success/scenario.mjs
124+
125+
# Review what changed
126+
npm run scenarios:diff
127+
```
128+
129+
### Adding a new scenario
130+
131+
1. Create a new folder under `test/scenarios/` with a minimal `process.bpmn` and a `scenario.mjs` that exports the scenario definition. Use a unique `processId` and a scoped job type (e.g. `task-testing:<scenario-name>`) to avoid collisions on a shared cluster.
132+
2. Run `npm run scenarios:record:one test/scenarios/<name>/scenario.mjs` to produce the fixture JSON.
133+
3. Write a spec in `test/` that uses `replayScenario` / `createReplayApi` to exercise the component with the new fixture.
134+
4. Commit both the `process.bpmn` and the generated JSON fixture.
135+
63136
## Build
64137

65138
Run all tests and build the library:

demo/recorder.mjs

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,15 @@ const SCENARIOS_DIR = path.join(__dirname, '..', 'test', 'fixtures', 'scenarios'
2828

2929
export default class ApiRecorder {
3030

31-
constructor() {
31+
/**
32+
* @param {Object} [options]
33+
* @param {string} [options.outputDir] — directory to write scenario JSON files.
34+
* Defaults to `test/fixtures/scenarios/` relative to the project root.
35+
*/
36+
constructor({ outputDir } = {}) {
37+
38+
/** @type {string} */
39+
this._outputDir = outputDir || SCENARIOS_DIR;
3240

3341
/** @type {string|null} */
3442
this._name = null;
@@ -104,8 +112,8 @@ export default class ApiRecorder {
104112

105113
this._recording = false;
106114

107-
if (!fs.existsSync(SCENARIOS_DIR)) {
108-
fs.mkdirSync(SCENARIOS_DIR, { recursive: true });
115+
if (!fs.existsSync(this._outputDir)) {
116+
fs.mkdirSync(this._outputDir, { recursive: true });
109117
}
110118

111119
const scenario = {
@@ -117,7 +125,7 @@ export default class ApiRecorder {
117125
timeline: this._timeline
118126
};
119127

120-
const filePath = path.join(SCENARIOS_DIR, `${this._name}.json`);
128+
const filePath = path.join(this._outputDir, `${this._name}.json`);
121129

122130
fs.writeFileSync(filePath, JSON.stringify(scenario, null, 2));
123131

package.json

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,10 @@
1818
"test": "karma start karma.config.js",
1919
"lint": "eslint .",
2020
"types": "tsc --noEmit",
21-
"prepare": "npm run build"
21+
"prepare": "npm run build",
22+
"scenarios:record": "node test/scenarios/run.mjs --all",
23+
"scenarios:record:one": "node test/scenarios/run.mjs",
24+
"scenarios:diff": "git diff --stat test/fixtures/scenarios/"
2225
},
2326
"license": "MIT",
2427
"dependencies": {

test/ExecutionLog.scenarios.spec.js

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -42,14 +42,15 @@ describe('ExecutionLog - recorded scenarios', function() {
4242
const jobEntries1 = afterPoll1.entries.filter(e => e.type === 'job');
4343
expect(jobEntries1).to.have.length(1);
4444
expect(jobEntries1[0].data.state).to.equal('ACTIVATED');
45-
expect(jobEntries1[0].data.jobKey).to.equal('2251799813755930');
45+
const activatedJobKey = jobEntries1[0].data.jobKey;
46+
expect(activatedJobKey).to.be.a('string').and.not.empty;
4647

47-
// After second poll: same job should be COMPLETED (upserted in-place)
48+
// After second poll: same job should be COMPLETED (upserted in-place, same key)
4849
const afterPoll2 = pollSnapshots[1];
4950
const jobEntries2 = afterPoll2.entries.filter(e => e.type === 'job');
5051
expect(jobEntries2).to.have.length(1);
5152
expect(jobEntries2[0].data.state).to.equal('COMPLETED');
52-
expect(jobEntries2[0].data.jobKey).to.equal('2251799813755930');
53+
expect(jobEntries2[0].data.jobKey).to.equal(activatedJobKey);
5354
});
5455

5556
it('should drop executing from display entries when finished', function() {

test/TaskExecution.scenarios.spec.js

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -70,9 +70,9 @@ describe('TaskExecution - recorded scenarios', function() {
7070
// then — should have finished successfully
7171
expect(finishedSpy).to.have.been.calledOnce;
7272
expect(finishedSpy).to.have.been.calledWithMatch({
73-
success: true,
74-
processInstanceKey: '2251799813755922'
73+
success: true
7574
});
75+
expect(finishedSpy.firstCall.args[0].processInstanceKey).to.be.a('string').and.not.empty;
7676
}));
7777

7878

@@ -91,7 +91,7 @@ describe('TaskExecution - recorded scenarios', function() {
9191
expect(firstPollArgs).to.have.property('userTasksResult');
9292
expect(firstPollArgs).to.have.property('elementInstancesResult');
9393
expect(firstPollArgs).to.have.property('processInstanceKey');
94-
expect(firstPollArgs.processInstanceKey).to.equal('2251799813755922');
94+
expect(firstPollArgs.processInstanceKey).to.be.a('string').and.not.empty;
9595
}));
9696

9797

@@ -156,9 +156,9 @@ describe('TaskExecution - recorded scenarios', function() {
156156
expect(finishedSpy).to.have.been.calledOnce;
157157
expect(finishedSpy).to.have.been.calledWithMatch({
158158
success: false,
159-
reason: TASK_EXECUTION_REASON.INCIDENT,
160-
processInstanceKey: '2251799813756100'
159+
reason: TASK_EXECUTION_REASON.INCIDENT
161160
});
161+
expect(finishedSpy.firstCall.args[0].processInstanceKey).to.be.a('string').and.not.empty;
162162
}));
163163

164164

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
<?xml version="1.0" encoding="UTF-8"?>
2+
<bpmn:definitions
3+
xmlns:bpmn="http://www.omg.org/spec/BPMN/20100524/MODEL"
4+
xmlns:zeebe="http://camunda.org/schema/zeebe/1.0"
5+
xmlns:modeler="http://camunda.org/schema/modeler/1.0"
6+
id="Definitions_BoundaryEventTriggered"
7+
targetNamespace="http://bpmn.io/schema/bpmn"
8+
modeler:executionPlatform="Camunda Cloud"
9+
modeler:executionPlatformVersion="8.8.0">
10+
11+
<!--
12+
Task_1 is a service task with no worker registered.
13+
BoundaryEvent_Timer fires after PT5S (interrupting), cancelling Task_1
14+
and routing to EndEvent_Boundary.
15+
-->
16+
<bpmn:process id="Process_BoundaryEventTriggered" isExecutable="true">
17+
<bpmn:startEvent id="StartEvent_1">
18+
<bpmn:outgoing>Flow_1</bpmn:outgoing>
19+
</bpmn:startEvent>
20+
<bpmn:sequenceFlow id="Flow_1" sourceRef="StartEvent_1" targetRef="Task_1" />
21+
22+
<bpmn:serviceTask id="Task_1" name="Slow task (no worker)">
23+
<bpmn:extensionElements>
24+
<zeebe:taskDefinition type="task-testing:boundary-task" retries="3" />
25+
</bpmn:extensionElements>
26+
<bpmn:incoming>Flow_1</bpmn:incoming>
27+
<bpmn:outgoing>Flow_2</bpmn:outgoing>
28+
</bpmn:serviceTask>
29+
30+
<bpmn:boundaryEvent id="BoundaryEvent_Timer" attachedToRef="Task_1" cancelActivity="true">
31+
<bpmn:outgoing>Flow_3</bpmn:outgoing>
32+
<bpmn:timerEventDefinition id="TimerEventDefinition_1">
33+
<bpmn:timeDuration xsi:type="bpmn:tFormalExpression"
34+
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">PT5S</bpmn:timeDuration>
35+
</bpmn:timerEventDefinition>
36+
</bpmn:boundaryEvent>
37+
38+
<bpmn:sequenceFlow id="Flow_2" sourceRef="Task_1" targetRef="EndEvent_1" />
39+
<bpmn:sequenceFlow id="Flow_3" sourceRef="BoundaryEvent_Timer" targetRef="EndEvent_Boundary" />
40+
41+
<bpmn:endEvent id="EndEvent_1">
42+
<bpmn:incoming>Flow_2</bpmn:incoming>
43+
</bpmn:endEvent>
44+
<bpmn:endEvent id="EndEvent_Boundary" name="Timed out">
45+
<bpmn:incoming>Flow_3</bpmn:incoming>
46+
</bpmn:endEvent>
47+
</bpmn:process>
48+
49+
</bpmn:definitions>
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
/**
2+
* Scenario: boundary-event-triggered
3+
*
4+
* A service task with an interrupting boundary timer event (PT5S).
5+
* No worker — the task job stays ACTIVATED while the boundary timer fires,
6+
* routing to the timeout end event.
7+
*
8+
* Expected fixture: deploy → start → polls showing Task_1 ACTIVE +
9+
* BoundaryEvent_Timer appearing → process COMPLETED via boundary path.
10+
*/
11+
12+
export default {
13+
name: 'boundary-event-triggered',
14+
description: 'A service task with a boundary timer event that triggers during execution. The boundary event element instance appears in poll results.',
15+
resources: [ './process.bpmn' ],
16+
elementId: 'Task_1',
17+
processId: 'Process_BoundaryEventTriggered',
18+
variables: {},
19+
workers: [],
20+
21+
// Timer fires after PT5S; poll long enough for that to happen
22+
maxPolls: 15,
23+
pollIntervalMs: 1000
24+
};
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
<?xml version="1.0" encoding="UTF-8"?>
2+
<bpmn:definitions
3+
xmlns:bpmn="http://www.omg.org/spec/BPMN/20100524/MODEL"
4+
xmlns:modeler="http://camunda.org/schema/modeler/1.0"
5+
id="Definitions_DeployError"
6+
targetNamespace="http://bpmn.io/schema/bpmn"
7+
modeler:executionPlatform="Camunda Cloud"
8+
modeler:executionPlatformVersion="8.8.0">
9+
10+
<!--
11+
Intentionally invalid: process has no start event.
12+
Deploying this to Camunda 8 produces:
13+
"Must have at least one start event"
14+
-->
15+
<bpmn:process id="Process_DeployError" isExecutable="true">
16+
<bpmn:serviceTask id="ServiceTask_1" name="Orphan task" />
17+
</bpmn:process>
18+
19+
</bpmn:definitions>
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
/**
2+
* Scenario: deploy-error
3+
*
4+
* Deployment of an invalid BPMN (no start event) fails before a process
5+
* instance is ever created.
6+
*
7+
* Expected fixture: deploy → error (no poll events).
8+
*/
9+
10+
export default {
11+
name: 'deploy-error',
12+
description: "Deployment fails due to invalid BPMN (missing start event). No instance is started.",
13+
resources: [ './invalid.bpmn' ],
14+
15+
// elementId / processId are irrelevant — deployment never succeeds —
16+
// but are kept for structural consistency.
17+
elementId: 'ServiceTask_1',
18+
processId: 'Process_DeployError',
19+
variables: {},
20+
workers: []
21+
};
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
<?xml version="1.0" encoding="UTF-8"?>
2+
<bpmn:definitions
3+
xmlns:bpmn="http://www.omg.org/spec/BPMN/20100524/MODEL"
4+
xmlns:zeebe="http://camunda.org/schema/zeebe/1.0"
5+
xmlns:modeler="http://camunda.org/schema/modeler/1.0"
6+
id="Definitions_EventSubprocess"
7+
targetNamespace="http://bpmn.io/schema/bpmn"
8+
modeler:executionPlatform="Camunda Cloud"
9+
modeler:executionPlatformVersion="8.8.0">
10+
11+
<!--
12+
Task_1 is a service task with no worker — it stays ACTIVATED.
13+
After PT2S a non-interrupting event sub-process fires, executing
14+
EspTask_1 (FEEL script) and producing child element instances visible
15+
with depth:1.
16+
-->
17+
<bpmn:process id="Process_EventSubprocess" isExecutable="true">
18+
<bpmn:startEvent id="StartEvent_1">
19+
<bpmn:outgoing>Flow_1</bpmn:outgoing>
20+
</bpmn:startEvent>
21+
<bpmn:sequenceFlow id="Flow_1" sourceRef="StartEvent_1" targetRef="Task_1" />
22+
23+
<bpmn:serviceTask id="Task_1" name="Long-running task (no worker)">
24+
<bpmn:extensionElements>
25+
<zeebe:taskDefinition type="task-testing:esp-task" retries="3" />
26+
</bpmn:extensionElements>
27+
<bpmn:incoming>Flow_1</bpmn:incoming>
28+
<bpmn:outgoing>Flow_2</bpmn:outgoing>
29+
</bpmn:serviceTask>
30+
<bpmn:sequenceFlow id="Flow_2" sourceRef="Task_1" targetRef="EndEvent_1" />
31+
32+
<bpmn:endEvent id="EndEvent_1">
33+
<bpmn:incoming>Flow_2</bpmn:incoming>
34+
</bpmn:endEvent>
35+
36+
<!-- Non-interrupting event sub-process triggered by a timer after PT2S -->
37+
<bpmn:subProcess id="Activity_1dh97uw" triggeredByEvent="true">
38+
<bpmn:startEvent id="StartEvent_ESP" isInterrupting="false">
39+
<bpmn:outgoing>Flow_ESP1</bpmn:outgoing>
40+
<bpmn:timerEventDefinition id="TimerEventDefinition_ESP">
41+
<bpmn:timeDuration xsi:type="bpmn:tFormalExpression"
42+
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">PT2S</bpmn:timeDuration>
43+
</bpmn:timerEventDefinition>
44+
</bpmn:startEvent>
45+
<bpmn:sequenceFlow id="Flow_ESP1" sourceRef="StartEvent_ESP" targetRef="EspTask_1" />
46+
47+
<bpmn:scriptTask id="EspTask_1" name="ESP Script">
48+
<bpmn:extensionElements>
49+
<zeebe:script expression="=true" resultVariable="espResult" />
50+
</bpmn:extensionElements>
51+
<bpmn:incoming>Flow_ESP1</bpmn:incoming>
52+
<bpmn:outgoing>Flow_ESP2</bpmn:outgoing>
53+
</bpmn:scriptTask>
54+
<bpmn:sequenceFlow id="Flow_ESP2" sourceRef="EspTask_1" targetRef="EndEvent_ESP" />
55+
56+
<bpmn:endEvent id="EndEvent_ESP">
57+
<bpmn:incoming>Flow_ESP2</bpmn:incoming>
58+
</bpmn:endEvent>
59+
</bpmn:subProcess>
60+
</bpmn:process>
61+
62+
</bpmn:definitions>

0 commit comments

Comments
 (0)