flow-walker-cli 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,264 @@
1
+ import { describe, it } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { parseFlow } from '../src/flow-parser.ts';
4
+
5
+ describe('parseFlow', () => {
6
+ it('parses basic flow with name, description, setup', () => {
7
+ const yaml = `
8
+ name: test-flow
9
+ description: A test flow
10
+ setup: normal
11
+
12
+ steps:
13
+ - name: First step
14
+ screenshot: step1
15
+ `;
16
+ const flow = parseFlow(yaml);
17
+ assert.equal(flow.name, 'test-flow');
18
+ assert.equal(flow.description, 'A test flow');
19
+ assert.equal(flow.setup, 'normal');
20
+ assert.equal(flow.steps.length, 1);
21
+ });
22
+
23
+ it('parses covers and prerequisites arrays', () => {
24
+ const yaml = `
25
+ name: with-meta
26
+ description: Flow with metadata
27
+ covers:
28
+ - app/lib/pages/home.dart
29
+ - app/lib/pages/settings.dart
30
+ prerequisites:
31
+ - auth_ready
32
+ setup: normal
33
+
34
+ steps:
35
+ - name: Step one
36
+ screenshot: s1
37
+ `;
38
+ const flow = parseFlow(yaml);
39
+ assert.deepEqual(flow.covers, ['app/lib/pages/home.dart', 'app/lib/pages/settings.dart']);
40
+ assert.deepEqual(flow.prerequisites, ['auth_ready']);
41
+ });
42
+
43
+ it('parses press step with inline object', () => {
44
+ const yaml = `
45
+ name: press-test
46
+ description: Test press parsing
47
+ steps:
48
+ - name: Press button
49
+ press: { type: button, position: rightmost }
50
+ screenshot: pressed
51
+ `;
52
+ const flow = parseFlow(yaml);
53
+ const step = flow.steps[0];
54
+ assert.deepEqual(step.press, { type: 'button', position: 'rightmost' });
55
+ assert.equal(step.screenshot, 'pressed');
56
+ });
57
+
58
+ it('parses press with bottom_nav_tab as number', () => {
59
+ const yaml = `
60
+ name: nav-test
61
+ description: Nav tab test
62
+ steps:
63
+ - name: Go to tab 2
64
+ press: { bottom_nav_tab: 2 }
65
+ `;
66
+ const flow = parseFlow(yaml);
67
+ assert.equal(flow.steps[0].press?.bottom_nav_tab, 2);
68
+ });
69
+
70
+ it('parses scroll step', () => {
71
+ const yaml = `
72
+ name: scroll-test
73
+ description: Scroll test
74
+ steps:
75
+ - name: Scroll down
76
+ scroll: down
77
+ screenshot: scrolled
78
+ `;
79
+ const flow = parseFlow(yaml);
80
+ assert.equal(flow.steps[0].scroll, 'down');
81
+ });
82
+
83
+ it('parses fill step', () => {
84
+ const yaml = `
85
+ name: fill-test
86
+ description: Fill test
87
+ steps:
88
+ - name: Fill text
89
+ fill: { type: textfield, value: "Hello world" }
90
+ screenshot: filled
91
+ `;
92
+ const flow = parseFlow(yaml);
93
+ assert.equal(flow.steps[0].fill?.type, 'textfield');
94
+ assert.equal(flow.steps[0].fill?.value, 'Hello world');
95
+ });
96
+
97
+ it('parses back step', () => {
98
+ const yaml = `
99
+ name: back-test
100
+ description: Back test
101
+ steps:
102
+ - name: Go back
103
+ back: true
104
+ `;
105
+ const flow = parseFlow(yaml);
106
+ assert.equal(flow.steps[0].back, true);
107
+ });
108
+
109
+ it('parses assert with interactive_count', () => {
110
+ const yaml = `
111
+ name: assert-test
112
+ description: Assert test
113
+ steps:
114
+ - name: Check elements
115
+ assert:
116
+ interactive_count: { min: 20 }
117
+ screenshot: home
118
+ `;
119
+ const flow = parseFlow(yaml);
120
+ assert.equal(flow.steps[0].assert?.interactive_count?.min, 20);
121
+ });
122
+
123
+ it('parses assert with bottom_nav_tabs', () => {
124
+ const yaml = `
125
+ name: nav-assert
126
+ description: Nav tabs assert
127
+ steps:
128
+ - name: Check nav
129
+ assert:
130
+ bottom_nav_tabs: { min: 4 }
131
+ `;
132
+ const flow = parseFlow(yaml);
133
+ assert.equal(flow.steps[0].assert?.bottom_nav_tabs?.min, 4);
134
+ });
135
+
136
+ it('parses assert with has_type', () => {
137
+ const yaml = `
138
+ name: type-assert
139
+ description: Type assert
140
+ steps:
141
+ - name: Check switches
142
+ assert:
143
+ has_type: { type: switch, min: 2 }
144
+ `;
145
+ const flow = parseFlow(yaml);
146
+ assert.equal(flow.steps[0].assert?.has_type?.type, 'switch');
147
+ assert.equal(flow.steps[0].assert?.has_type?.min, 2);
148
+ });
149
+
150
+ it('parses note field (ignored by executor)', () => {
151
+ const yaml = `
152
+ name: note-test
153
+ description: Note test
154
+ steps:
155
+ - name: Step with note
156
+ note: "This is a human-readable note"
157
+ screenshot: noted
158
+ `;
159
+ const flow = parseFlow(yaml);
160
+ assert.equal(flow.steps[0].note, 'This is a human-readable note');
161
+ });
162
+
163
+ it('parses multiple steps in order', () => {
164
+ const yaml = `
165
+ name: multi-step
166
+ description: Multiple steps
167
+ steps:
168
+ - name: Step 1
169
+ screenshot: s1
170
+ - name: Step 2
171
+ press: { type: button }
172
+ - name: Step 3
173
+ back: true
174
+ `;
175
+ const flow = parseFlow(yaml);
176
+ assert.equal(flow.steps.length, 3);
177
+ assert.equal(flow.steps[0].name, 'Step 1');
178
+ assert.equal(flow.steps[1].name, 'Step 2');
179
+ assert.equal(flow.steps[2].name, 'Step 3');
180
+ });
181
+
182
+ it('strips inline YAML comments', () => {
183
+ const yaml = `
184
+ name: comment-test
185
+ description: Test with comments
186
+ prerequisites:
187
+ - auth_ready # User must be signed in
188
+ setup: normal
189
+
190
+ steps:
191
+ - name: Check home
192
+ screenshot: home
193
+ `;
194
+ const flow = parseFlow(yaml);
195
+ assert.equal(flow.prerequisites![0], 'auth_ready');
196
+ });
197
+
198
+ it('throws on missing name', () => {
199
+ const yaml = `
200
+ description: No name
201
+ steps:
202
+ - name: Step 1
203
+ `;
204
+ assert.throws(() => parseFlow(yaml), /missing required field: name/i);
205
+ });
206
+
207
+ it('throws on empty steps', () => {
208
+ const yaml = `
209
+ name: empty
210
+ description: Empty steps
211
+ steps:
212
+ `;
213
+ assert.throws(() => parseFlow(yaml), /no steps/i);
214
+ });
215
+
216
+ it('parses real-world flow format (home-navigation style)', () => {
217
+ const yaml = `# E2E Flow: Home screen navigation
218
+ # Tests: snapshot, settings button, scroll, back navigation
219
+
220
+ name: home-navigation
221
+ description: Home screen snapshot, settings gear press, scroll in settings, back to home
222
+ covers:
223
+ - app/lib/pages/home/page.dart
224
+ - app/lib/pages/settings/settings_drawer.dart
225
+ prerequisites:
226
+ - auth_ready # User completed real sign-in
227
+ setup: normal
228
+
229
+ steps:
230
+ - name: Snapshot home screen
231
+ assert:
232
+ interactive_count: { min: 20, verified: "flow-walker run10: 24 elements" }
233
+ screenshot: home
234
+
235
+ - name: Press settings gear (rightmost button in top bar)
236
+ press: { type: button, position: rightmost }
237
+ assert:
238
+ interactive_count: { min: 10 }
239
+ screenshot: settings
240
+
241
+ - name: Scroll down in settings
242
+ scroll: down
243
+ screenshot: settings-scrolled
244
+
245
+ - name: Back to home
246
+ back: true
247
+ assert:
248
+ interactive_count: { min: 5 }
249
+ screenshot: final
250
+ `;
251
+ const flow = parseFlow(yaml);
252
+ assert.equal(flow.name, 'home-navigation');
253
+ assert.equal(flow.steps.length, 4);
254
+ assert.equal(flow.steps[0].assert?.interactive_count?.min, 20);
255
+ assert.equal(flow.steps[1].press?.type, 'button');
256
+ assert.equal(flow.steps[1].press?.position, 'rightmost');
257
+ assert.equal(flow.steps[2].scroll, 'down');
258
+ assert.equal(flow.steps[3].back, true);
259
+ assert.deepEqual(flow.covers, [
260
+ 'app/lib/pages/home/page.dart',
261
+ 'app/lib/pages/settings/settings_drawer.dart'
262
+ ]);
263
+ });
264
+ });
@@ -0,0 +1,111 @@
1
+ import { describe, it } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { NavigationGraph } from '../src/graph.ts';
4
+
5
+ describe('NavigationGraph', () => {
6
+ it('adds screens and tracks visit count', () => {
7
+ const g = new NavigationGraph();
8
+ g.addScreen('abc123', 'home', ['button', 'textfield'], 5);
9
+ assert.equal(g.screenCount(), 1);
10
+ assert.equal(g.visitCount('abc123'), 1);
11
+
12
+ g.addScreen('abc123', 'home', ['button', 'textfield'], 5);
13
+ assert.equal(g.screenCount(), 1);
14
+ assert.equal(g.visitCount('abc123'), 2);
15
+ });
16
+
17
+ it('hasScreen returns correct state', () => {
18
+ const g = new NavigationGraph();
19
+ assert.equal(g.hasScreen('abc'), false);
20
+ g.addScreen('abc', 'home', [], 0);
21
+ assert.equal(g.hasScreen('abc'), true);
22
+ });
23
+
24
+ it('adds edges between screens', () => {
25
+ const g = new NavigationGraph();
26
+ g.addScreen('a', 'home', [], 3);
27
+ g.addScreen('b', 'settings', [], 5);
28
+ g.addEdge('a', 'b', { ref: '@e1', type: 'button', text: 'Settings' });
29
+
30
+ assert.equal(g.edges.length, 1);
31
+ assert.equal(g.edges[0].source, 'a');
32
+ assert.equal(g.edges[0].target, 'b');
33
+ });
34
+
35
+ it('deduplicates identical edges', () => {
36
+ const g = new NavigationGraph();
37
+ g.addScreen('a', 'home', [], 3);
38
+ g.addScreen('b', 'settings', [], 5);
39
+
40
+ const el = { ref: '@e1', type: 'button', text: 'Settings' };
41
+ g.addEdge('a', 'b', el);
42
+ g.addEdge('a', 'b', el); // duplicate
43
+ assert.equal(g.edges.length, 1);
44
+ });
45
+
46
+ it('allows different edges between same screens', () => {
47
+ const g = new NavigationGraph();
48
+ g.addScreen('a', 'home', [], 3);
49
+ g.addScreen('b', 'settings', [], 5);
50
+
51
+ g.addEdge('a', 'b', { ref: '@e1', type: 'button', text: 'Settings' });
52
+ g.addEdge('a', 'b', { ref: '@e2', type: 'gesture', text: 'Gear icon' });
53
+ assert.equal(g.edges.length, 2);
54
+ });
55
+
56
+ it('edgesFrom returns correct edges', () => {
57
+ const g = new NavigationGraph();
58
+ g.addScreen('a', 'home', [], 3);
59
+ g.addScreen('b', 'settings', [], 5);
60
+ g.addScreen('c', 'profile', [], 4);
61
+
62
+ g.addEdge('a', 'b', { ref: '@e1', type: 'button', text: 'Settings' });
63
+ g.addEdge('a', 'c', { ref: '@e2', type: 'button', text: 'Profile' });
64
+ g.addEdge('b', 'c', { ref: '@e3', type: 'button', text: 'Profile' });
65
+
66
+ assert.equal(g.edgesFrom('a').length, 2);
67
+ assert.equal(g.edgesFrom('b').length, 1);
68
+ assert.equal(g.edgesFrom('c').length, 0);
69
+ });
70
+
71
+ it('edgesTo returns correct edges', () => {
72
+ const g = new NavigationGraph();
73
+ g.addScreen('a', 'home', [], 3);
74
+ g.addScreen('b', 'settings', [], 5);
75
+
76
+ g.addEdge('a', 'b', { ref: '@e1', type: 'button', text: 'Settings' });
77
+ assert.equal(g.edgesTo('b').length, 1);
78
+ assert.equal(g.edgesTo('a').length, 0);
79
+ });
80
+
81
+ it('cycle detection: hasScreen prevents revisiting', () => {
82
+ const g = new NavigationGraph();
83
+ g.addScreen('a', 'home', [], 3);
84
+ g.addScreen('b', 'settings', [], 5);
85
+ g.addEdge('a', 'b', { ref: '@e1', type: 'button', text: 'Settings' });
86
+
87
+ // Simulate cycle: b → a
88
+ g.addEdge('b', 'a', { ref: '@e2', type: 'button', text: 'Back' });
89
+
90
+ // Walker would check hasScreen before recursing
91
+ assert.equal(g.hasScreen('a'), true);
92
+ assert.equal(g.visitCount('a'), 1);
93
+ // visitCount > 1 would trigger cycle skip in walker
94
+ });
95
+
96
+ it('toJSON produces serializable output', () => {
97
+ const g = new NavigationGraph();
98
+ g.addScreen('a', 'home', ['button'], 3);
99
+ g.addScreen('b', 'settings', ['button', 'switch'], 5);
100
+ g.addEdge('a', 'b', { ref: '@e1', type: 'button', text: 'Settings' });
101
+
102
+ const json = g.toJSON();
103
+ assert.equal(json.nodes.length, 2);
104
+ assert.equal(json.edges.length, 1);
105
+
106
+ // Verify it's JSON-serializable
107
+ const str = JSON.stringify(json);
108
+ const parsed = JSON.parse(str);
109
+ assert.equal(parsed.nodes.length, 2);
110
+ });
111
+ });
@@ -0,0 +1,188 @@
1
+ import { describe, it } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { buildHtml } from '../src/reporter.ts';
4
+ import type { RunResult } from '../src/run-schema.ts';
5
+
6
+ const makeRun = (overrides: Partial<RunResult> = {}): RunResult => ({
7
+ id: 'test_runId1',
8
+ flow: 'tab-navigation',
9
+ device: 'Pixel_7a',
10
+ startedAt: '2026-03-12T10:00:00Z',
11
+ duration: 17600,
12
+ result: 'pass',
13
+ steps: [
14
+ {
15
+ index: 0,
16
+ name: 'Verify home tab',
17
+ action: 'assert',
18
+ status: 'pass',
19
+ timestamp: 1000,
20
+ duration: 2300,
21
+ elementCount: 24,
22
+ screenshot: 'step-1-home.png',
23
+ assertion: { interactive_count: { min: 20, actual: 24 } },
24
+ },
25
+ {
26
+ index: 1,
27
+ name: 'Press settings',
28
+ action: 'press',
29
+ status: 'pass',
30
+ timestamp: 3300,
31
+ duration: 1500,
32
+ elementCount: 18,
33
+ },
34
+ ],
35
+ ...overrides,
36
+ });
37
+
38
+ describe('buildHtml', () => {
39
+ it('returns valid HTML document', () => {
40
+ const html = buildHtml(makeRun(), '', new Map());
41
+ assert.ok(html.startsWith('<!DOCTYPE html>'));
42
+ assert.ok(html.includes('</html>'));
43
+ });
44
+
45
+ it('includes flow name in title and header', () => {
46
+ const html = buildHtml(makeRun(), '', new Map());
47
+ assert.ok(html.includes('<title>E2E Flow Viewer: tab-navigation</title>'));
48
+ assert.ok(html.includes('tab-navigation'));
49
+ });
50
+
51
+ it('includes device name and duration in meta', () => {
52
+ const html = buildHtml(makeRun(), '', new Map());
53
+ assert.ok(html.includes('Pixel_7a'));
54
+ assert.ok(html.includes('17.6s'));
55
+ });
56
+
57
+ it('renders pass/fail counts in legend', () => {
58
+ const run = makeRun({
59
+ result: 'fail',
60
+ steps: [
61
+ { index: 0, name: 'S1', action: 'assert', status: 'pass', timestamp: 0, duration: 100, elementCount: 5 },
62
+ { index: 1, name: 'S2', action: 'press', status: 'fail', timestamp: 100, duration: 200, elementCount: 3, error: 'not found' },
63
+ ],
64
+ });
65
+ const html = buildHtml(run, '', new Map());
66
+ assert.ok(html.includes('PASS (1)'));
67
+ assert.ok(html.includes('FAIL (1)'));
68
+ assert.ok(html.includes('1 FAIL'));
69
+ });
70
+
71
+ it('embeds video when base64 provided', () => {
72
+ const videoB64 = 'AAAA'; // dummy base64
73
+ const html = buildHtml(makeRun(), videoB64, new Map());
74
+ assert.ok(html.includes('<video id="video"'));
75
+ assert.ok(html.includes('data:video/mp4;base64,AAAA'));
76
+ assert.ok(!html.includes('No video'));
77
+ });
78
+
79
+ it('shows no-video placeholder when no base64', () => {
80
+ const html = buildHtml(makeRun(), '', new Map());
81
+ assert.ok(html.includes('No video'));
82
+ assert.ok(!html.includes('data:video/mp4'));
83
+ });
84
+
85
+ it('renders step cards with data-time for seek', () => {
86
+ const html = buildHtml(makeRun(), '', new Map());
87
+ // Step 1 timestamp 1000ms → 1.0 sec
88
+ assert.ok(html.includes('data-time="1.0"'));
89
+ // Step 2 timestamp 3300ms → 3.3 sec
90
+ assert.ok(html.includes('data-time="3.3"'));
91
+ });
92
+
93
+ it('renders step cards with jumpTo onclick', () => {
94
+ const html = buildHtml(makeRun(), '', new Map());
95
+ assert.ok(html.includes('onclick="jumpTo('));
96
+ });
97
+
98
+ it('includes step names', () => {
99
+ const html = buildHtml(makeRun(), '', new Map());
100
+ assert.ok(html.includes('Verify home tab'));
101
+ assert.ok(html.includes('Press settings'));
102
+ });
103
+
104
+ it('renders pass/fail status classes on steps', () => {
105
+ const run = makeRun({
106
+ steps: [
107
+ { index: 0, name: 'Good', action: 'assert', status: 'pass', timestamp: 0, duration: 100, elementCount: 5 },
108
+ { index: 1, name: 'Bad', action: 'press', status: 'fail', timestamp: 100, duration: 200, elementCount: 0, error: 'fail' },
109
+ ],
110
+ });
111
+ const html = buildHtml(run, '', new Map());
112
+ assert.ok(html.includes('class="step pass"'));
113
+ assert.ok(html.includes('class="step fail"'));
114
+ });
115
+
116
+ it('embeds screenshot thumbnails as base64 images', () => {
117
+ const screenshots = new Map([['step-1-home.png', 'iVBOR']]);
118
+ const html = buildHtml(makeRun(), '', screenshots);
119
+ assert.ok(html.includes('data:image/png;base64,iVBOR'));
120
+ assert.ok(html.includes('class="step-thumb"'));
121
+ });
122
+
123
+ it('renders assertion results in step detail', () => {
124
+ const html = buildHtml(makeRun(), '', new Map());
125
+ assert.ok(html.includes('24 elements'));
126
+ });
127
+
128
+ it('renders error messages for failed steps', () => {
129
+ const run = makeRun({
130
+ steps: [
131
+ { index: 0, name: 'Broken', action: 'press', status: 'fail', timestamp: 0, duration: 100, elementCount: 0, error: 'element not found' },
132
+ ],
133
+ });
134
+ const html = buildHtml(run, '', new Map());
135
+ assert.ok(html.includes('element not found'));
136
+ });
137
+
138
+ it('includes keyboard shortcut script', () => {
139
+ const html = buildHtml(makeRun(), '', new Map());
140
+ assert.ok(html.includes("document.addEventListener('keydown'"));
141
+ assert.ok(html.includes('video.paused'));
142
+ });
143
+
144
+ it('includes timeupdate listener for auto-highlight', () => {
145
+ const html = buildHtml(makeRun(), '', new Map());
146
+ assert.ok(html.includes("video.addEventListener('timeupdate'"));
147
+ });
148
+
149
+ it('escapes HTML special characters in flow name', () => {
150
+ const run = makeRun({ flow: '<script>alert("xss")</script>' });
151
+ const html = buildHtml(run, '', new Map());
152
+ assert.ok(!html.includes('<script>alert'));
153
+ assert.ok(html.includes('&lt;script&gt;'));
154
+ });
155
+
156
+ it('renders step numbers starting from 1', () => {
157
+ const html = buildHtml(makeRun(), '', new Map());
158
+ assert.ok(html.includes('>1</div>'));
159
+ assert.ok(html.includes('>2</div>'));
160
+ });
161
+
162
+ it('includes responsive CSS media query', () => {
163
+ const html = buildHtml(makeRun(), '', new Map());
164
+ assert.ok(html.includes('@media (max-width: 768px)'));
165
+ });
166
+
167
+ it('renders all-pass summary when all steps pass', () => {
168
+ const html = buildHtml(makeRun(), '', new Map());
169
+ assert.ok(html.includes('All PASS'));
170
+ });
171
+
172
+ it('renders bottom_nav_tabs assertion result', () => {
173
+ const run = makeRun({
174
+ steps: [{
175
+ index: 0,
176
+ name: 'Check nav',
177
+ action: 'assert',
178
+ status: 'pass',
179
+ timestamp: 0,
180
+ duration: 100,
181
+ elementCount: 20,
182
+ assertion: { bottom_nav_tabs: { min: 4, actual: 5 } },
183
+ }],
184
+ });
185
+ const html = buildHtml(run, '', new Map());
186
+ assert.ok(html.includes('5 nav tabs'));
187
+ });
188
+ });
@@ -0,0 +1,138 @@
1
+ import { describe, it } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { validateRunResult, generateRunId } from '../src/run-schema.ts';
4
+ import type { RunResult } from '../src/run-schema.ts';
5
+
6
+ describe('validateRunResult', () => {
7
+ const validRun: RunResult = {
8
+ id: 'P-tnB_sgKA',
9
+ flow: 'tab-navigation',
10
+ device: 'Pixel_7a',
11
+ startedAt: '2026-03-12T10:00:00Z',
12
+ duration: 17600,
13
+ result: 'pass',
14
+ steps: [
15
+ {
16
+ index: 0,
17
+ name: 'Verify home tab',
18
+ action: 'assert',
19
+ status: 'pass',
20
+ timestamp: 1000,
21
+ duration: 2300,
22
+ elementCount: 24,
23
+ screenshot: 'step-1-home.png',
24
+ assertion: { interactive_count: { min: 20, actual: 24 } },
25
+ },
26
+ ],
27
+ video: 'recording.mp4',
28
+ log: 'device.log',
29
+ };
30
+
31
+ it('accepts valid run result', () => {
32
+ assert.equal(validateRunResult(validRun), true);
33
+ });
34
+
35
+ it('accepts run with fail status', () => {
36
+ const failRun = { ...validRun, result: 'fail' as const };
37
+ assert.equal(validateRunResult(failRun), true);
38
+ });
39
+
40
+ it('accepts run without video and log', () => {
41
+ const { video, log, ...minimal } = validRun;
42
+ assert.equal(validateRunResult(minimal), true);
43
+ });
44
+
45
+ it('accepts step with fail status', () => {
46
+ const run = {
47
+ ...validRun,
48
+ steps: [{ ...validRun.steps[0], status: 'fail' as const, error: 'element not found' }],
49
+ };
50
+ assert.equal(validateRunResult(run), true);
51
+ });
52
+
53
+ it('accepts step with skip status', () => {
54
+ const run = {
55
+ ...validRun,
56
+ steps: [{ ...validRun.steps[0], status: 'skip' as const }],
57
+ };
58
+ assert.equal(validateRunResult(run), true);
59
+ });
60
+
61
+ it('rejects null', () => {
62
+ assert.equal(validateRunResult(null), false);
63
+ });
64
+
65
+ it('rejects non-object', () => {
66
+ assert.equal(validateRunResult('string'), false);
67
+ });
68
+
69
+ it('rejects missing id field', () => {
70
+ const { id, ...noId } = validRun;
71
+ assert.equal(validateRunResult(noId), false);
72
+ });
73
+
74
+ it('rejects missing flow field', () => {
75
+ const { flow, ...noFlow } = validRun;
76
+ assert.equal(validateRunResult(noFlow), false);
77
+ });
78
+
79
+ it('rejects missing device field', () => {
80
+ const { device, ...noDevice } = validRun;
81
+ assert.equal(validateRunResult(noDevice), false);
82
+ });
83
+
84
+ it('rejects invalid result value', () => {
85
+ assert.equal(validateRunResult({ ...validRun, result: 'unknown' }), false);
86
+ });
87
+
88
+ it('rejects non-array steps', () => {
89
+ assert.equal(validateRunResult({ ...validRun, steps: 'not-array' }), false);
90
+ });
91
+
92
+ it('rejects step missing name', () => {
93
+ const { name, ...noName } = validRun.steps[0];
94
+ assert.equal(validateRunResult({ ...validRun, steps: [noName] }), false);
95
+ });
96
+
97
+ it('rejects step missing action', () => {
98
+ const { action, ...noAction } = validRun.steps[0];
99
+ assert.equal(validateRunResult({ ...validRun, steps: [noAction] }), false);
100
+ });
101
+
102
+ it('rejects step with invalid status', () => {
103
+ const badStep = { ...validRun.steps[0], status: 'maybe' };
104
+ assert.equal(validateRunResult({ ...validRun, steps: [badStep] }), false);
105
+ });
106
+
107
+ it('rejects step missing timestamp', () => {
108
+ const { timestamp, ...noTs } = validRun.steps[0];
109
+ assert.equal(validateRunResult({ ...validRun, steps: [noTs] }), false);
110
+ });
111
+
112
+ it('rejects step missing duration', () => {
113
+ const { duration, ...noDur } = validRun.steps[0];
114
+ assert.equal(validateRunResult({ ...validRun, steps: [noDur] }), false);
115
+ });
116
+
117
+ it('rejects step missing elementCount', () => {
118
+ const { elementCount, ...noCount } = validRun.steps[0];
119
+ assert.equal(validateRunResult({ ...validRun, steps: [noCount] }), false);
120
+ });
121
+ });
122
+
123
+ describe('generateRunId', () => {
124
+ it('returns a 10-character string', () => {
125
+ const id = generateRunId();
126
+ assert.equal(id.length, 10);
127
+ });
128
+
129
+ it('is URL-safe (base64url charset)', () => {
130
+ const id = generateRunId();
131
+ assert.match(id, /^[A-Za-z0-9_-]+$/);
132
+ });
133
+
134
+ it('generates unique IDs', () => {
135
+ const ids = new Set(Array.from({ length: 100 }, () => generateRunId()));
136
+ assert.equal(ids.size, 100);
137
+ });
138
+ });