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.
- package/AGENTS.md +299 -0
- package/CLAUDE.md +81 -0
- package/LICENSE +21 -0
- package/README.md +247 -0
- package/package.json +21 -0
- package/src/agent-bridge.ts +189 -0
- package/src/capture.ts +102 -0
- package/src/cli.ts +352 -0
- package/src/command-schema.ts +178 -0
- package/src/errors.ts +63 -0
- package/src/fingerprint.ts +82 -0
- package/src/flow-parser.ts +222 -0
- package/src/graph.ts +73 -0
- package/src/push.ts +170 -0
- package/src/reporter.ts +211 -0
- package/src/run-schema.ts +71 -0
- package/src/runner.ts +391 -0
- package/src/safety.ts +74 -0
- package/src/types.ts +82 -0
- package/src/validate.ts +115 -0
- package/src/walker.ts +656 -0
- package/src/yaml-writer.ts +194 -0
- package/tests/capture.test.ts +75 -0
- package/tests/command-schema.test.ts +133 -0
- package/tests/errors.test.ts +93 -0
- package/tests/fingerprint.test.ts +85 -0
- package/tests/flow-parser.test.ts +264 -0
- package/tests/graph.test.ts +111 -0
- package/tests/reporter.test.ts +188 -0
- package/tests/run-schema.test.ts +138 -0
- package/tests/runner.test.ts +150 -0
- package/tests/safety.test.ts +115 -0
- package/tests/validate.test.ts +193 -0
- package/tests/yaml-writer.test.ts +146 -0
- package/tsconfig.json +15 -0
|
@@ -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('<script>'));
|
|
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
|
+
});
|