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,150 @@
1
+ import { describe, it } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { resolvePress, resolveFill, getStepAction } from '../src/runner.ts';
4
+ import type { SnapshotElement, FlowStep } from '../src/types.ts';
5
+
6
+ const makeElement = (overrides: Partial<SnapshotElement> = {}): SnapshotElement => ({
7
+ ref: '@e1',
8
+ type: 'button',
9
+ text: 'OK',
10
+ enabled: true,
11
+ bounds: { x: 100, y: 200, width: 80, height: 40 },
12
+ ...overrides,
13
+ });
14
+
15
+ describe('getStepAction', () => {
16
+ it('returns "press" for press step', () => {
17
+ assert.equal(getStepAction({ name: 'x', press: { type: 'button' } }), 'press');
18
+ });
19
+
20
+ it('returns "scroll" for scroll step', () => {
21
+ assert.equal(getStepAction({ name: 'x', scroll: 'down' }), 'scroll');
22
+ });
23
+
24
+ it('returns "fill" for fill step', () => {
25
+ assert.equal(getStepAction({ name: 'x', fill: { value: 'hello' } }), 'fill');
26
+ });
27
+
28
+ it('returns "back" for back step', () => {
29
+ assert.equal(getStepAction({ name: 'x', back: true }), 'back');
30
+ });
31
+
32
+ it('returns "assert" for assert step', () => {
33
+ assert.equal(getStepAction({ name: 'x', assert: { interactive_count: { min: 5 } } }), 'assert');
34
+ });
35
+
36
+ it('returns "screenshot" for screenshot-only step', () => {
37
+ assert.equal(getStepAction({ name: 'x', screenshot: 'home' }), 'screenshot');
38
+ });
39
+
40
+ it('returns "unknown" for empty step', () => {
41
+ assert.equal(getStepAction({ name: 'x' }), 'unknown');
42
+ });
43
+ });
44
+
45
+ describe('resolvePress', () => {
46
+ it('resolves by ref', () => {
47
+ const els = [makeElement({ ref: '@e1' }), makeElement({ ref: '@e2' })];
48
+ const result = resolvePress({ ref: '@e2' }, els);
49
+ assert.equal(result?.ref, '@e2');
50
+ });
51
+
52
+ it('returns null for missing ref', () => {
53
+ const els = [makeElement({ ref: '@e1' })];
54
+ const result = resolvePress({ ref: '@e99' }, els);
55
+ assert.equal(result, null);
56
+ });
57
+
58
+ it('resolves by bottom_nav_tab index', () => {
59
+ const els = [
60
+ makeElement({ ref: '@nav0', flutterType: 'InkWell', bounds: { x: 10, y: 800, width: 80, height: 50 } }),
61
+ makeElement({ ref: '@nav1', flutterType: 'InkWell', bounds: { x: 100, y: 800, width: 80, height: 50 } }),
62
+ makeElement({ ref: '@nav2', flutterType: 'InkWell', bounds: { x: 200, y: 800, width: 80, height: 50 } }),
63
+ ];
64
+ const result = resolvePress({ bottom_nav_tab: 1 }, els);
65
+ assert.equal(result?.ref, '@nav1');
66
+ });
67
+
68
+ it('returns null for out-of-range bottom_nav_tab', () => {
69
+ const els = [
70
+ makeElement({ ref: '@nav0', flutterType: 'InkWell', bounds: { x: 10, y: 800, width: 80, height: 50 } }),
71
+ ];
72
+ const result = resolvePress({ bottom_nav_tab: 5 }, els);
73
+ assert.equal(result, null);
74
+ });
75
+
76
+ it('resolves by type', () => {
77
+ const els = [
78
+ makeElement({ ref: '@e1', type: 'textfield' }),
79
+ makeElement({ ref: '@e2', type: 'button' }),
80
+ ];
81
+ const result = resolvePress({ type: 'button' }, els);
82
+ assert.equal(result?.ref, '@e2');
83
+ });
84
+
85
+ it('resolves by type with rightmost position', () => {
86
+ const els = [
87
+ makeElement({ ref: '@e1', type: 'button', bounds: { x: 10, y: 100, width: 40, height: 40 } }),
88
+ makeElement({ ref: '@e2', type: 'button', bounds: { x: 300, y: 100, width: 40, height: 40 } }),
89
+ makeElement({ ref: '@e3', type: 'button', bounds: { x: 150, y: 100, width: 40, height: 40 } }),
90
+ ];
91
+ const result = resolvePress({ type: 'button', position: 'rightmost' }, els);
92
+ assert.equal(result?.ref, '@e2');
93
+ });
94
+
95
+ it('resolves by type with leftmost position', () => {
96
+ const els = [
97
+ makeElement({ ref: '@e1', type: 'button', bounds: { x: 300, y: 100, width: 40, height: 40 } }),
98
+ makeElement({ ref: '@e2', type: 'button', bounds: { x: 10, y: 100, width: 40, height: 40 } }),
99
+ ];
100
+ const result = resolvePress({ type: 'button', position: 'leftmost' }, els);
101
+ assert.equal(result?.ref, '@e2');
102
+ });
103
+
104
+ it('resolves by flutterType partial match', () => {
105
+ const els = [
106
+ makeElement({ ref: '@e1', type: 'gesture', flutterType: 'ElevatedButton' }),
107
+ ];
108
+ const result = resolvePress({ type: 'elevatedbutton' }, els);
109
+ assert.equal(result?.ref, '@e1');
110
+ });
111
+
112
+ it('returns null when no match', () => {
113
+ const els = [makeElement({ ref: '@e1', type: 'textfield' })];
114
+ const result = resolvePress({ type: 'switch' }, els);
115
+ assert.equal(result, null);
116
+ });
117
+
118
+ it('returns null for empty press config', () => {
119
+ const els = [makeElement()];
120
+ const result = resolvePress({}, els);
121
+ assert.equal(result, null);
122
+ });
123
+ });
124
+
125
+ describe('resolveFill', () => {
126
+ it('resolves textfield by type', () => {
127
+ const els = [
128
+ makeElement({ ref: '@e1', type: 'button' }),
129
+ makeElement({ ref: '@e2', type: 'textfield' }),
130
+ ];
131
+ const result = resolveFill({ type: 'textfield', value: 'hi' }, els);
132
+ assert.equal(result?.ref, '@e2');
133
+ });
134
+
135
+ it('resolves TextField by flutterType when no type match', () => {
136
+ const els = [
137
+ makeElement({ ref: '@e1', type: 'gesture', flutterType: 'TextField' }),
138
+ ];
139
+ const result = resolveFill({ value: 'hello' }, els);
140
+ assert.equal(result?.ref, '@e1');
141
+ });
142
+
143
+ it('returns null when no textfield available', () => {
144
+ const els = [
145
+ makeElement({ ref: '@e1', type: 'button' }),
146
+ ];
147
+ const result = resolveFill({ value: 'hi' }, els);
148
+ assert.equal(result, null);
149
+ });
150
+ });
@@ -0,0 +1,115 @@
1
+ import { describe, it } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { isSafe, filterSafe } from '../src/safety.ts';
4
+ import type { SnapshotElement } from '../src/types.ts';
5
+
6
+ function el(ref: string, type: string, text: string, opts?: Partial<SnapshotElement>): SnapshotElement {
7
+ return { ref, type, text, enabled: true, ...opts };
8
+ }
9
+
10
+ describe('isSafe', () => {
11
+ it('returns safe for normal button', () => {
12
+ const element = el('@e1', 'button', 'Save');
13
+ const result = isSafe(element, []);
14
+ assert.equal(result.safe, true);
15
+ });
16
+
17
+ it('blocks element with "delete" in text', () => {
18
+ const element = el('@e1', 'button', 'Delete Account');
19
+ const result = isSafe(element, []);
20
+ assert.equal(result.safe, false);
21
+ assert.ok(result.reason?.includes('delete'));
22
+ });
23
+
24
+ it('blocks element with "sign out" in text', () => {
25
+ const element = el('@e1', 'button', 'Sign Out');
26
+ const result = isSafe(element, []);
27
+ assert.equal(result.safe, false);
28
+ assert.ok(result.reason?.includes('sign out'));
29
+ });
30
+
31
+ it('blocks element with "remove" in text', () => {
32
+ const element = el('@e1', 'button', 'Remove Device');
33
+ const result = isSafe(element, []);
34
+ assert.equal(result.safe, false);
35
+ });
36
+
37
+ it('blocks element with "reset" in text', () => {
38
+ const element = el('@e1', 'button', 'Reset All Settings');
39
+ const result = isSafe(element, []);
40
+ assert.equal(result.safe, false);
41
+ });
42
+
43
+ it('blocks element with "unpair" in text', () => {
44
+ const element = el('@e1', 'button', 'Unpair Device');
45
+ const result = isSafe(element, []);
46
+ assert.equal(result.safe, false);
47
+ });
48
+
49
+ it('blocks element with "logout" in text', () => {
50
+ const element = el('@e1', 'button', 'Logout');
51
+ const result = isSafe(element, []);
52
+ assert.equal(result.safe, false);
53
+ });
54
+
55
+ it('blocks disabled elements', () => {
56
+ const element = el('@e1', 'button', 'Save', { enabled: false });
57
+ const result = isSafe(element, []);
58
+ assert.equal(result.safe, false);
59
+ assert.ok(result.reason?.includes('disabled'));
60
+ });
61
+
62
+ it('is case-insensitive on blocklist matching', () => {
63
+ const element = el('@e1', 'button', 'DELETE');
64
+ const result = isSafe(element, []);
65
+ assert.equal(result.safe, false);
66
+ });
67
+
68
+ it('blocks based on nearby element text', () => {
69
+ const target = el('@e1', 'button', 'Confirm', { bounds: { x: 100, y: 200, width: 80, height: 40 } });
70
+ const nearby = el('@e2', 'label', 'Delete Account', { bounds: { x: 100, y: 180, width: 200, height: 20 } });
71
+ const result = isSafe(target, [target, nearby]);
72
+ assert.equal(result.safe, false);
73
+ assert.ok(result.reason?.includes('nearby'));
74
+ });
75
+
76
+ it('allows custom blocklist', () => {
77
+ const element = el('@e1', 'button', 'Explode');
78
+ const result = isSafe(element, [], ['explode']);
79
+ assert.equal(result.safe, false);
80
+ });
81
+
82
+ it('does not block when custom blocklist is empty', () => {
83
+ const element = el('@e1', 'button', 'Delete');
84
+ const result = isSafe(element, [], []);
85
+ assert.equal(result.safe, true);
86
+ });
87
+ });
88
+
89
+ describe('filterSafe', () => {
90
+ it('separates safe and unsafe elements', () => {
91
+ const elements = [
92
+ el('@e1', 'button', 'Save'),
93
+ el('@e2', 'button', 'Delete'),
94
+ el('@e3', 'button', 'Cancel'),
95
+ el('@e4', 'button', 'Sign Out'),
96
+ ];
97
+
98
+ const [safe, skipped] = filterSafe(elements);
99
+ assert.equal(safe.length, 2);
100
+ assert.equal(skipped.length, 2);
101
+ assert.deepEqual(safe.map(e => e.text), ['Save', 'Cancel']);
102
+ assert.deepEqual(skipped.map(s => s.element.text), ['Delete', 'Sign Out']);
103
+ });
104
+
105
+ it('returns all elements as safe when none match blocklist', () => {
106
+ const elements = [
107
+ el('@e1', 'button', 'Save'),
108
+ el('@e2', 'button', 'Next'),
109
+ ];
110
+
111
+ const [safe, skipped] = filterSafe(elements);
112
+ assert.equal(safe.length, 2);
113
+ assert.equal(skipped.length, 0);
114
+ });
115
+ });
@@ -0,0 +1,193 @@
1
+ import { describe, it } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { writeFileSync, unlinkSync, mkdirSync, rmdirSync } from 'node:fs';
4
+ import { join } from 'node:path';
5
+ import {
6
+ rejectControlChars,
7
+ validateFlowPath,
8
+ validateOutputDir,
9
+ validateUri,
10
+ validateBundleId,
11
+ validateRunDir,
12
+ } from '../src/validate.ts';
13
+ import { FlowWalkerError } from '../src/errors.ts';
14
+
15
+ const tmpDir = join(import.meta.dirname!, '..', '.test-tmp-validate');
16
+
17
+ describe('rejectControlChars', () => {
18
+ it('accepts normal strings', () => {
19
+ assert.doesNotThrow(() => rejectControlChars('hello world', 'test'));
20
+ });
21
+
22
+ it('accepts newlines and tabs', () => {
23
+ assert.doesNotThrow(() => rejectControlChars('line1\nline2\ttab', 'test'));
24
+ });
25
+
26
+ it('rejects null byte', () => {
27
+ assert.throws(() => rejectControlChars('bad\x00input', 'test'), FlowWalkerError);
28
+ });
29
+
30
+ it('rejects bell character', () => {
31
+ assert.throws(() => rejectControlChars('bad\x07input', 'test'), FlowWalkerError);
32
+ });
33
+
34
+ it('rejects backspace', () => {
35
+ assert.throws(() => rejectControlChars('bad\x08input', 'test'), FlowWalkerError);
36
+ });
37
+
38
+ it('error has INVALID_INPUT code', () => {
39
+ try {
40
+ rejectControlChars('bad\x00input', 'Path');
41
+ assert.fail('should throw');
42
+ } catch (err) {
43
+ assert.ok(err instanceof FlowWalkerError);
44
+ assert.equal(err.code, 'INVALID_INPUT');
45
+ assert.ok(err.message.includes('Path'));
46
+ }
47
+ });
48
+ });
49
+
50
+ describe('validateFlowPath', () => {
51
+ it('accepts valid .yaml file', () => {
52
+ mkdirSync(tmpDir, { recursive: true });
53
+ const p = join(tmpDir, 'test.yaml');
54
+ writeFileSync(p, 'name: test\nsteps:\n - name: s1\n');
55
+ assert.doesNotThrow(() => validateFlowPath(p));
56
+ unlinkSync(p);
57
+ rmdirSync(tmpDir);
58
+ });
59
+
60
+ it('accepts valid .yml file', () => {
61
+ mkdirSync(tmpDir, { recursive: true });
62
+ const p = join(tmpDir, 'test.yml');
63
+ writeFileSync(p, 'name: test\nsteps:\n - name: s1\n');
64
+ assert.doesNotThrow(() => validateFlowPath(p));
65
+ unlinkSync(p);
66
+ rmdirSync(tmpDir);
67
+ });
68
+
69
+ it('rejects non-yaml extension', () => {
70
+ assert.throws(() => validateFlowPath('/tmp/test.json'), FlowWalkerError);
71
+ });
72
+
73
+ it('rejects path traversal', () => {
74
+ assert.throws(() => validateFlowPath('../../../etc/passwd.yaml'), FlowWalkerError);
75
+ });
76
+
77
+ it('rejects nonexistent file', () => {
78
+ try {
79
+ validateFlowPath('/tmp/definitely-not-here-xyz.yaml');
80
+ assert.fail('should throw');
81
+ } catch (err) {
82
+ assert.ok(err instanceof FlowWalkerError);
83
+ assert.equal(err.code, 'FILE_NOT_FOUND');
84
+ }
85
+ });
86
+
87
+ it('rejects control characters', () => {
88
+ assert.throws(() => validateFlowPath('/tmp/bad\x00.yaml'), FlowWalkerError);
89
+ });
90
+ });
91
+
92
+ describe('validateOutputDir', () => {
93
+ it('accepts normal paths', () => {
94
+ assert.doesNotThrow(() => validateOutputDir('./output'));
95
+ assert.doesNotThrow(() => validateOutputDir('/tmp/results'));
96
+ });
97
+
98
+ it('rejects path traversal', () => {
99
+ assert.throws(() => validateOutputDir('../../etc'), FlowWalkerError);
100
+ });
101
+
102
+ it('rejects control characters', () => {
103
+ assert.throws(() => validateOutputDir('/tmp/bad\x00dir'), FlowWalkerError);
104
+ });
105
+ });
106
+
107
+ describe('validateUri', () => {
108
+ it('accepts valid ws:// URI', () => {
109
+ assert.doesNotThrow(() => validateUri('ws://127.0.0.1:38047/abc=/ws'));
110
+ });
111
+
112
+ it('accepts valid wss:// URI', () => {
113
+ assert.doesNotThrow(() => validateUri('wss://secure.host:443/path'));
114
+ });
115
+
116
+ it('rejects http:// URI', () => {
117
+ assert.throws(() => validateUri('http://127.0.0.1:38047'), FlowWalkerError);
118
+ });
119
+
120
+ it('rejects empty string', () => {
121
+ assert.throws(() => validateUri(''), FlowWalkerError);
122
+ });
123
+
124
+ it('rejects control characters', () => {
125
+ assert.throws(() => validateUri('ws://bad\x00host'), FlowWalkerError);
126
+ });
127
+
128
+ it('error has hint with example', () => {
129
+ try {
130
+ validateUri('http://wrong');
131
+ assert.fail('should throw');
132
+ } catch (err) {
133
+ assert.ok(err instanceof FlowWalkerError);
134
+ assert.ok(err.hint?.includes('ws://'));
135
+ }
136
+ });
137
+ });
138
+
139
+ describe('validateBundleId', () => {
140
+ it('accepts valid reverse-domain ID', () => {
141
+ assert.doesNotThrow(() => validateBundleId('com.example.app'));
142
+ assert.doesNotThrow(() => validateBundleId('com.friend.ios.dev'));
143
+ });
144
+
145
+ it('rejects single-segment ID', () => {
146
+ assert.throws(() => validateBundleId('myapp'), FlowWalkerError);
147
+ });
148
+
149
+ it('rejects control characters', () => {
150
+ assert.throws(() => validateBundleId('com.bad\x00.app'), FlowWalkerError);
151
+ });
152
+
153
+ it('rejects starting with number', () => {
154
+ assert.throws(() => validateBundleId('123.example.app'), FlowWalkerError);
155
+ });
156
+ });
157
+
158
+ describe('validateRunDir', () => {
159
+ it('accepts directory with run.json', () => {
160
+ mkdirSync(tmpDir, { recursive: true });
161
+ writeFileSync(join(tmpDir, 'run.json'), '{}');
162
+ assert.doesNotThrow(() => validateRunDir(tmpDir));
163
+ unlinkSync(join(tmpDir, 'run.json'));
164
+ rmdirSync(tmpDir);
165
+ });
166
+
167
+ it('rejects nonexistent directory', () => {
168
+ try {
169
+ validateRunDir('/tmp/no-such-dir-xyz');
170
+ assert.fail('should throw');
171
+ } catch (err) {
172
+ assert.ok(err instanceof FlowWalkerError);
173
+ assert.equal(err.code, 'FILE_NOT_FOUND');
174
+ }
175
+ });
176
+
177
+ it('rejects directory without run.json', () => {
178
+ mkdirSync(tmpDir, { recursive: true });
179
+ try {
180
+ validateRunDir(tmpDir);
181
+ assert.fail('should throw');
182
+ } catch (err) {
183
+ assert.ok(err instanceof FlowWalkerError);
184
+ assert.equal(err.code, 'FILE_NOT_FOUND');
185
+ assert.ok(err.message.includes('run.json'));
186
+ }
187
+ rmdirSync(tmpDir);
188
+ });
189
+
190
+ it('rejects path traversal', () => {
191
+ assert.throws(() => validateRunDir('../../etc'), FlowWalkerError);
192
+ });
193
+ });
@@ -0,0 +1,146 @@
1
+ import { describe, it } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { generateFlows, toYaml } from '../src/yaml-writer.ts';
4
+ import { NavigationGraph } from '../src/graph.ts';
5
+ import type { Flow } from '../src/types.ts';
6
+
7
+ describe('generateFlows', () => {
8
+ it('generates a single-screen flow for isolated nodes', () => {
9
+ const g = new NavigationGraph();
10
+ g.addScreen('abc', 'home', ['button'], 5);
11
+
12
+ const flows = generateFlows(g);
13
+ assert.equal(flows.length, 1);
14
+ assert.equal(flows[0].name, 'home');
15
+ assert.equal(flows[0].setup, 'normal');
16
+ assert.ok(flows[0].steps.length >= 1);
17
+ });
18
+
19
+ it('generates one flow per outgoing branch from root', () => {
20
+ const g = new NavigationGraph();
21
+ g.addScreen('root', 'home', ['button'], 5);
22
+ g.addScreen('a', 'settings', ['button'], 8);
23
+ g.addScreen('b', 'profile', ['button'], 4);
24
+
25
+ g.addEdge('root', 'a', { ref: '@e1', type: 'button', text: 'Settings' });
26
+ g.addEdge('root', 'b', { ref: '@e2', type: 'button', text: 'Profile' });
27
+
28
+ const flows = generateFlows(g);
29
+ assert.equal(flows.length, 2);
30
+
31
+ const names = flows.map(f => f.name).sort();
32
+ assert.deepEqual(names, ['profile', 'settings']);
33
+ });
34
+
35
+ it('includes press, assert, back, and screenshot steps', () => {
36
+ const g = new NavigationGraph();
37
+ g.addScreen('root', 'home', ['button'], 5);
38
+ g.addScreen('a', 'settings', ['button'], 8);
39
+ g.addEdge('root', 'a', { ref: '@e1', type: 'button', text: 'Settings' });
40
+
41
+ const flows = generateFlows(g);
42
+ const flow = flows[0];
43
+ const stepNames = flow.steps.map(s => s.name);
44
+
45
+ // Should have: verify root, press to settings, verify settings, back to root
46
+ assert.ok(stepNames.some(n => n.includes('Verify home')));
47
+ assert.ok(stepNames.some(n => n.includes('Press')));
48
+ assert.ok(stepNames.some(n => n.includes('Back')));
49
+
50
+ // Check assert exists on verify steps
51
+ const verifyStep = flow.steps.find(s => s.name.includes('Verify home'));
52
+ assert.ok(verifyStep?.assert?.interactive_count);
53
+
54
+ // Check screenshot exists
55
+ const screenshotSteps = flow.steps.filter(s => s.screenshot);
56
+ assert.ok(screenshotSteps.length >= 1);
57
+ });
58
+
59
+ it('generates valid flow with correct structure fields', () => {
60
+ const g = new NavigationGraph();
61
+ g.addScreen('r', 'home', ['button'], 3);
62
+ g.addScreen('s', 'settings', ['button'], 5);
63
+ g.addEdge('r', 's', { ref: '@e1', type: 'button', text: 'Go' });
64
+
65
+ const flows = generateFlows(g);
66
+ const flow = flows[0];
67
+
68
+ // All flows must have name, description, setup, steps
69
+ assert.ok(flow.name);
70
+ assert.ok(flow.description);
71
+ assert.equal(flow.setup, 'normal');
72
+ assert.ok(Array.isArray(flow.steps));
73
+ assert.ok(flow.steps.length > 0);
74
+
75
+ // Each step must have a name
76
+ for (const step of flow.steps) {
77
+ assert.ok(step.name, 'every step must have a name');
78
+ }
79
+ });
80
+ });
81
+
82
+ describe('toYaml', () => {
83
+ it('produces valid YAML with name: and steps: fields', () => {
84
+ const flow: Flow = {
85
+ name: 'settings-nav',
86
+ description: 'Home → Settings navigation',
87
+ setup: 'normal',
88
+ steps: [
89
+ { name: 'Verify home', assert: { interactive_count: { min: 3 } }, screenshot: 'home' },
90
+ { name: 'Press Settings', press: { type: 'button', hint: 'Settings gear' } },
91
+ { name: 'Back to home', back: true },
92
+ ],
93
+ };
94
+
95
+ const yaml = toYaml(flow);
96
+
97
+ // Check required YAML fields
98
+ assert.ok(yaml.includes('name: settings-nav'));
99
+ assert.ok(yaml.includes('description: Home → Settings navigation'));
100
+ assert.ok(yaml.includes('setup: normal'));
101
+ assert.ok(yaml.includes('steps:'));
102
+
103
+ // Check step structure
104
+ assert.ok(yaml.includes('- name: Verify home'));
105
+ assert.ok(yaml.includes('interactive_count: { min: 3 }'));
106
+ assert.ok(yaml.includes('screenshot: home'));
107
+ assert.ok(yaml.includes('press: { type: button, hint: "Settings gear" }'));
108
+ assert.ok(yaml.includes('back: true'));
109
+ });
110
+
111
+ it('outputs E2E Flow comment header', () => {
112
+ const flow: Flow = {
113
+ name: 'test',
114
+ description: 'A test flow',
115
+ setup: 'normal',
116
+ steps: [{ name: 'Step 1' }],
117
+ };
118
+
119
+ const yaml = toYaml(flow);
120
+ assert.ok(yaml.startsWith('# E2E Flow:'));
121
+ });
122
+
123
+ it('handles flow with assert text field', () => {
124
+ const flow: Flow = {
125
+ name: 'chat',
126
+ description: 'Chat flow',
127
+ setup: 'normal',
128
+ steps: [{ name: 'Verify', assert: { text: 'Conversations' } }],
129
+ };
130
+
131
+ const yaml = toYaml(flow);
132
+ assert.ok(yaml.includes('text: "Conversations"'));
133
+ });
134
+
135
+ it('handles scroll steps', () => {
136
+ const flow: Flow = {
137
+ name: 'scroll',
138
+ description: 'Scroll test',
139
+ setup: 'normal',
140
+ steps: [{ name: 'Scroll down', scroll: 'down' }],
141
+ };
142
+
143
+ const yaml = toYaml(flow);
144
+ assert.ok(yaml.includes('scroll: down'));
145
+ });
146
+ });
package/tsconfig.json ADDED
@@ -0,0 +1,15 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "module": "NodeNext",
5
+ "moduleResolution": "NodeNext",
6
+ "strict": true,
7
+ "noEmit": true,
8
+ "allowImportingTsExtensions": true,
9
+ "skipLibCheck": true,
10
+ "esModuleInterop": true,
11
+ "forceConsistentCasingInFileNames": true,
12
+ "resolveJsonModule": true
13
+ },
14
+ "include": ["src/**/*.ts", "tests/**/*.ts"]
15
+ }