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,178 @@
|
|
|
1
|
+
// Command schema for flow-walker — single source of truth
|
|
2
|
+
// Used by: schema subcommand, --help --json, agent introspection
|
|
3
|
+
|
|
4
|
+
export interface SchemaArg {
|
|
5
|
+
name: string;
|
|
6
|
+
required: boolean;
|
|
7
|
+
description: string;
|
|
8
|
+
type: 'string' | 'path' | 'integer';
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface SchemaFlag {
|
|
12
|
+
name: string;
|
|
13
|
+
description: string;
|
|
14
|
+
type: 'string' | 'boolean' | 'integer' | 'path';
|
|
15
|
+
default?: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface OutputField {
|
|
19
|
+
name: string;
|
|
20
|
+
type: string;
|
|
21
|
+
description: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface CommandSchema {
|
|
25
|
+
name: string;
|
|
26
|
+
description: string;
|
|
27
|
+
args: SchemaArg[];
|
|
28
|
+
flags: SchemaFlag[];
|
|
29
|
+
exitCodes: Record<string, string>;
|
|
30
|
+
examples: string[];
|
|
31
|
+
outputShape?: OutputField[];
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export const COMMAND_SCHEMAS: CommandSchema[] = [
|
|
35
|
+
{
|
|
36
|
+
name: 'walk',
|
|
37
|
+
description: 'Auto-explore app via BFS, discover screens, generate YAML flows',
|
|
38
|
+
args: [],
|
|
39
|
+
flags: [
|
|
40
|
+
{ name: '--app-uri', type: 'string', description: 'VM Service WebSocket URI (ws://...)' },
|
|
41
|
+
{ name: '--bundle-id', type: 'string', description: 'Connect by bundle ID' },
|
|
42
|
+
{ name: '--max-depth', type: 'integer', description: 'Max navigation depth', default: '5' },
|
|
43
|
+
{ name: '--output-dir', type: 'path', description: 'Output directory for YAML flows', default: './flows/' },
|
|
44
|
+
{ name: '--blocklist', type: 'string', description: 'Comma-separated destructive keywords to avoid', default: 'delete,sign out,remove,reset,unpair,logout,clear all' },
|
|
45
|
+
{ name: '--agent-flutter-path', type: 'path', description: 'Path to agent-flutter binary', default: 'agent-flutter' },
|
|
46
|
+
{ name: '--json', type: 'boolean', description: 'NDJSON output (one event per line)' },
|
|
47
|
+
{ name: '--no-json', type: 'boolean', description: 'Force human-readable output' },
|
|
48
|
+
{ name: '--dry-run', type: 'boolean', description: 'Snapshot and plan without pressing' },
|
|
49
|
+
{ name: '--skip-connect', type: 'boolean', description: 'Use existing agent-flutter session' },
|
|
50
|
+
],
|
|
51
|
+
exitCodes: { '0': 'success', '2': 'error' },
|
|
52
|
+
examples: [
|
|
53
|
+
'flow-walker walk --app-uri ws://127.0.0.1:38047/abc=/ws',
|
|
54
|
+
'flow-walker walk --skip-connect --max-depth 3',
|
|
55
|
+
'flow-walker walk --bundle-id com.example.app --json',
|
|
56
|
+
],
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
name: 'run',
|
|
60
|
+
description: 'Execute a YAML flow, produce run.json + video + screenshots',
|
|
61
|
+
args: [
|
|
62
|
+
{ name: 'flow', required: true, description: 'Path to YAML flow file', type: 'path' },
|
|
63
|
+
],
|
|
64
|
+
flags: [
|
|
65
|
+
{ name: '--output-dir', type: 'path', description: 'Output directory for results', default: './run-output/' },
|
|
66
|
+
{ name: '--agent-flutter-path', type: 'path', description: 'Path to agent-flutter binary', default: 'agent-flutter' },
|
|
67
|
+
{ name: '--no-video', type: 'boolean', description: 'Skip video recording' },
|
|
68
|
+
{ name: '--no-logs', type: 'boolean', description: 'Skip logcat capture' },
|
|
69
|
+
{ name: '--json', type: 'boolean', description: 'Machine-readable JSON output' },
|
|
70
|
+
{ name: '--no-json', type: 'boolean', description: 'Force human-readable output' },
|
|
71
|
+
{ name: '--dry-run', type: 'boolean', description: 'Parse and resolve without executing' },
|
|
72
|
+
],
|
|
73
|
+
exitCodes: { '0': 'all steps pass', '1': 'one or more steps fail', '2': 'error' },
|
|
74
|
+
examples: [
|
|
75
|
+
'flow-walker run flows/tab-navigation.yaml',
|
|
76
|
+
'flow-walker run flows/login.yaml --output-dir ./results/ --json',
|
|
77
|
+
'flow-walker run flows/settings.yaml --dry-run',
|
|
78
|
+
],
|
|
79
|
+
outputShape: [
|
|
80
|
+
{ name: 'id', type: 'string', description: 'Unique 10-char run ID' },
|
|
81
|
+
{ name: 'flow', type: 'string', description: 'Flow name' },
|
|
82
|
+
{ name: 'result', type: 'pass|fail', description: 'Overall result' },
|
|
83
|
+
{ name: 'duration', type: 'number', description: 'Total milliseconds' },
|
|
84
|
+
{ name: 'steps', type: 'StepResult[]', description: 'Per-step results with index, name, action, status, duration, elementCount, assertion' },
|
|
85
|
+
{ name: 'device', type: 'string', description: 'Device model' },
|
|
86
|
+
{ name: 'video', type: 'string?', description: 'Recording filename' },
|
|
87
|
+
],
|
|
88
|
+
},
|
|
89
|
+
{
|
|
90
|
+
name: 'report',
|
|
91
|
+
description: 'Generate self-contained HTML report from run results',
|
|
92
|
+
args: [
|
|
93
|
+
{ name: 'run-dir', required: true, description: 'Directory containing run.json', type: 'path' },
|
|
94
|
+
],
|
|
95
|
+
flags: [
|
|
96
|
+
{ name: '--output', type: 'path', description: 'Output HTML file path', default: '<run-dir>/report.html' },
|
|
97
|
+
{ name: '--no-video', type: 'boolean', description: 'Exclude video from report' },
|
|
98
|
+
],
|
|
99
|
+
exitCodes: { '0': 'success', '2': 'error' },
|
|
100
|
+
examples: [
|
|
101
|
+
'flow-walker report ./run-output/',
|
|
102
|
+
'flow-walker report ./results/ --output /tmp/report.html',
|
|
103
|
+
'flow-walker report ./results/ --no-video',
|
|
104
|
+
],
|
|
105
|
+
},
|
|
106
|
+
{
|
|
107
|
+
name: 'push',
|
|
108
|
+
description: 'Upload report to hosted service and return shareable URL',
|
|
109
|
+
args: [
|
|
110
|
+
{ name: 'run-dir', required: true, description: 'Directory containing run.json and report.html', type: 'path' },
|
|
111
|
+
],
|
|
112
|
+
flags: [
|
|
113
|
+
{ name: '--json', type: 'boolean', description: 'Machine-readable JSON output' },
|
|
114
|
+
{ name: '--no-json', type: 'boolean', description: 'Force human-readable output' },
|
|
115
|
+
],
|
|
116
|
+
exitCodes: { '0': 'success', '2': 'error' },
|
|
117
|
+
examples: [
|
|
118
|
+
'flow-walker push ./run-output/P-tnB_sgKA/',
|
|
119
|
+
'flow-walker push ./run-output/P-tnB_sgKA/ --json',
|
|
120
|
+
],
|
|
121
|
+
outputShape: [
|
|
122
|
+
{ name: 'id', type: 'string', description: 'Run ID' },
|
|
123
|
+
{ name: 'url', type: 'string', description: 'JSON URL (agent-first)' },
|
|
124
|
+
{ name: 'htmlUrl', type: 'string', description: 'HTML report URL (human)' },
|
|
125
|
+
{ name: 'expiresAt', type: 'string', description: 'ISO 8601 expiry (30 days)' },
|
|
126
|
+
],
|
|
127
|
+
},
|
|
128
|
+
{
|
|
129
|
+
name: 'get',
|
|
130
|
+
description: 'Fetch run data from hosted service',
|
|
131
|
+
args: [
|
|
132
|
+
{ name: 'run-id', required: true, description: 'Run ID (from push or run output)', type: 'string' },
|
|
133
|
+
],
|
|
134
|
+
flags: [
|
|
135
|
+
{ name: '--json', type: 'boolean', description: 'Compact JSON output (default: pretty-printed)' },
|
|
136
|
+
{ name: '--no-json', type: 'boolean', description: 'Force human-readable output' },
|
|
137
|
+
],
|
|
138
|
+
exitCodes: { '0': 'success', '2': 'error (not found, network)' },
|
|
139
|
+
examples: [
|
|
140
|
+
'flow-walker get 25h7afGwBK',
|
|
141
|
+
'flow-walker get 25h7afGwBK --json',
|
|
142
|
+
'flow-walker get 25h7afGwBK | jq \'.steps[] | select(.status=="fail")\'',
|
|
143
|
+
],
|
|
144
|
+
outputShape: [
|
|
145
|
+
{ name: 'id', type: 'string', description: 'Run ID' },
|
|
146
|
+
{ name: 'flow', type: 'string', description: 'Flow name' },
|
|
147
|
+
{ name: 'result', type: 'pass|fail', description: 'Overall result' },
|
|
148
|
+
{ name: 'duration', type: 'number', description: 'Total milliseconds' },
|
|
149
|
+
{ name: 'steps', type: 'StepResult[]', description: 'Per-step results' },
|
|
150
|
+
],
|
|
151
|
+
},
|
|
152
|
+
{
|
|
153
|
+
name: 'schema',
|
|
154
|
+
description: 'Show command schema for agent discovery (always JSON)',
|
|
155
|
+
args: [
|
|
156
|
+
{ name: 'command', required: false, description: 'Specific command to describe', type: 'string' },
|
|
157
|
+
],
|
|
158
|
+
flags: [],
|
|
159
|
+
exitCodes: { '0': 'success', '2': 'error (unknown command)' },
|
|
160
|
+
examples: [
|
|
161
|
+
'flow-walker schema',
|
|
162
|
+
'flow-walker schema run',
|
|
163
|
+
'flow-walker schema walk',
|
|
164
|
+
],
|
|
165
|
+
},
|
|
166
|
+
];
|
|
167
|
+
|
|
168
|
+
export const SCHEMA_VERSION = '0.2.0';
|
|
169
|
+
|
|
170
|
+
/** Get schema for a specific command */
|
|
171
|
+
export function getCommandSchema(name: string): CommandSchema | undefined {
|
|
172
|
+
return COMMAND_SCHEMAS.find(s => s.name === name);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** Get full schema envelope with version */
|
|
176
|
+
export function getSchemaEnvelope(): { version: string; commands: CommandSchema[] } {
|
|
177
|
+
return { version: SCHEMA_VERSION, commands: COMMAND_SCHEMAS };
|
|
178
|
+
}
|
package/src/errors.ts
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
// Structured error handling for flow-walker
|
|
2
|
+
// Every error has: code, message, hint, diagnosticId
|
|
3
|
+
|
|
4
|
+
import { randomUUID } from 'node:crypto';
|
|
5
|
+
|
|
6
|
+
export const ErrorCodes = {
|
|
7
|
+
INVALID_ARGS: 'INVALID_ARGS',
|
|
8
|
+
INVALID_INPUT: 'INVALID_INPUT',
|
|
9
|
+
FILE_NOT_FOUND: 'FILE_NOT_FOUND',
|
|
10
|
+
FLOW_PARSE_ERROR: 'FLOW_PARSE_ERROR',
|
|
11
|
+
STEP_FAILED: 'STEP_FAILED',
|
|
12
|
+
DEVICE_ERROR: 'DEVICE_ERROR',
|
|
13
|
+
COMMAND_FAILED: 'COMMAND_FAILED',
|
|
14
|
+
} as const;
|
|
15
|
+
|
|
16
|
+
export type ErrorCode = typeof ErrorCodes[keyof typeof ErrorCodes];
|
|
17
|
+
|
|
18
|
+
export class FlowWalkerError extends Error {
|
|
19
|
+
code: ErrorCode;
|
|
20
|
+
hint?: string;
|
|
21
|
+
diagnosticId: string;
|
|
22
|
+
|
|
23
|
+
constructor(code: ErrorCode, message: string, hint?: string) {
|
|
24
|
+
super(message);
|
|
25
|
+
this.name = 'FlowWalkerError';
|
|
26
|
+
this.code = code;
|
|
27
|
+
this.hint = hint;
|
|
28
|
+
this.diagnosticId = randomUUID().slice(0, 8);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
toJSON(): { error: { code: string; message: string; hint?: string; diagnosticId: string } } {
|
|
32
|
+
return {
|
|
33
|
+
error: {
|
|
34
|
+
code: this.code,
|
|
35
|
+
message: this.message,
|
|
36
|
+
...(this.hint ? { hint: this.hint } : {}),
|
|
37
|
+
diagnosticId: this.diagnosticId,
|
|
38
|
+
},
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Format any error as structured output */
|
|
44
|
+
export function formatError(err: unknown, json: boolean): string {
|
|
45
|
+
if (err instanceof FlowWalkerError) {
|
|
46
|
+
if (json) {
|
|
47
|
+
return JSON.stringify(err.toJSON());
|
|
48
|
+
}
|
|
49
|
+
const parts = [`Error [${err.code}:${err.diagnosticId}]: ${err.message}`];
|
|
50
|
+
if (err.hint) parts.push(`Hint: ${err.hint}`);
|
|
51
|
+
return parts.join('\n');
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Wrap unknown errors
|
|
55
|
+
const wrapped = new FlowWalkerError(
|
|
56
|
+
ErrorCodes.COMMAND_FAILED,
|
|
57
|
+
String(err),
|
|
58
|
+
);
|
|
59
|
+
if (json) {
|
|
60
|
+
return JSON.stringify(wrapped.toJSON());
|
|
61
|
+
}
|
|
62
|
+
return `Error [${wrapped.code}:${wrapped.diagnosticId}]: ${wrapped.message}`;
|
|
63
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import type { SnapshotElement } from './types.ts';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Compute a deterministic screen fingerprint from interactive elements.
|
|
6
|
+
* Uses element types and counts only — ignores text content (which is dynamic).
|
|
7
|
+
* Similar screens with minor count differences produce the same fingerprint
|
|
8
|
+
* via count bucketing.
|
|
9
|
+
*/
|
|
10
|
+
export function computeFingerprint(elements: SnapshotElement[]): string {
|
|
11
|
+
// Count elements by type
|
|
12
|
+
const typeCounts = new Map<string, number>();
|
|
13
|
+
for (const el of elements) {
|
|
14
|
+
const key = el.flutterType || el.type;
|
|
15
|
+
typeCounts.set(key, (typeCounts.get(key) || 0) + 1);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// Bucket counts to handle minor variations (e.g., list length differences)
|
|
19
|
+
// 0 → 0, 1 → 1, 2-3 → 2, 4-7 → 4, 8+ → 8
|
|
20
|
+
const bucketed = new Map<string, number>();
|
|
21
|
+
for (const [type, count] of typeCounts) {
|
|
22
|
+
bucketed.set(type, bucketCount(count));
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// Sort by type name for determinism
|
|
26
|
+
const sorted = [...bucketed.entries()].sort((a, b) => a[0].localeCompare(b[0]));
|
|
27
|
+
|
|
28
|
+
// Hash the type:count pairs
|
|
29
|
+
const input = sorted.map(([type, count]) => `${type}:${count}`).join('|');
|
|
30
|
+
return createHash('sha256').update(input).digest('hex').slice(0, 12);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Bucket a count to reduce sensitivity to minor variations */
|
|
34
|
+
function bucketCount(n: number): number {
|
|
35
|
+
if (n <= 1) return n;
|
|
36
|
+
if (n <= 3) return 2;
|
|
37
|
+
if (n <= 7) return 4;
|
|
38
|
+
return 8;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Derive a human-readable screen name from its elements.
|
|
43
|
+
* Picks the most descriptive text from the first few elements.
|
|
44
|
+
*/
|
|
45
|
+
export function deriveScreenName(elements: SnapshotElement[]): string {
|
|
46
|
+
// Look for text that looks like a title (short, at top of screen)
|
|
47
|
+
const candidates = elements
|
|
48
|
+
.filter(el => el.text && el.text.length > 0 && el.text.length < 40)
|
|
49
|
+
.map(el => el.text);
|
|
50
|
+
|
|
51
|
+
if (candidates.length > 0) {
|
|
52
|
+
return toKebabCase(candidates[0]);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Fallback: use dominant element type
|
|
56
|
+
const types = elements.map(el => el.type);
|
|
57
|
+
const dominant = mode(types) || 'unknown';
|
|
58
|
+
return `screen-${dominant}-${elements.length}`;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function toKebabCase(s: string): string {
|
|
62
|
+
return s
|
|
63
|
+
.toLowerCase()
|
|
64
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
65
|
+
.replace(/^-|-$/g, '');
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function mode(arr: string[]): string | undefined {
|
|
69
|
+
const counts = new Map<string, number>();
|
|
70
|
+
for (const item of arr) {
|
|
71
|
+
counts.set(item, (counts.get(item) || 0) + 1);
|
|
72
|
+
}
|
|
73
|
+
let best: string | undefined;
|
|
74
|
+
let bestCount = 0;
|
|
75
|
+
for (const [item, count] of counts) {
|
|
76
|
+
if (count > bestCount) {
|
|
77
|
+
best = item;
|
|
78
|
+
bestCount = count;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
return best;
|
|
82
|
+
}
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
// YAML flow parser for flow-walker
|
|
2
|
+
// Parses the flow format used in app/e2e/flows/*.yaml
|
|
3
|
+
|
|
4
|
+
import { readFileSync } from 'node:fs';
|
|
5
|
+
import type { Flow, FlowStep } from './types.ts';
|
|
6
|
+
import { FlowWalkerError, ErrorCodes } from './errors.ts';
|
|
7
|
+
|
|
8
|
+
/** Parse a YAML flow file into a Flow object */
|
|
9
|
+
export function parseFlow(yamlContent: string): Flow {
|
|
10
|
+
const lines = yamlContent.split('\n');
|
|
11
|
+
const flow: Partial<Flow> = { steps: [] };
|
|
12
|
+
let currentStep: Partial<FlowStep> | null = null;
|
|
13
|
+
let inSteps = false;
|
|
14
|
+
let inCovers = false;
|
|
15
|
+
let inPrerequisites = false;
|
|
16
|
+
|
|
17
|
+
for (const rawLine of lines) {
|
|
18
|
+
const line = rawLine.trimEnd();
|
|
19
|
+
|
|
20
|
+
// Skip comments and blank lines at top level
|
|
21
|
+
if (line.startsWith('#') || line.trim() === '') continue;
|
|
22
|
+
|
|
23
|
+
// Top-level fields
|
|
24
|
+
if (!line.startsWith(' ') && !line.startsWith('\t')) {
|
|
25
|
+
inCovers = false;
|
|
26
|
+
inPrerequisites = false;
|
|
27
|
+
|
|
28
|
+
if (line.startsWith('name:')) {
|
|
29
|
+
flow.name = parseScalarValue(line.slice(5));
|
|
30
|
+
} else if (line.startsWith('description:')) {
|
|
31
|
+
flow.description = parseScalarValue(line.slice(12));
|
|
32
|
+
} else if (line.startsWith('app:')) {
|
|
33
|
+
flow.app = parseScalarValue(line.slice(4));
|
|
34
|
+
} else if (line.startsWith('app_url:')) {
|
|
35
|
+
flow.appUrl = parseScalarValue(line.slice(8));
|
|
36
|
+
} else if (line.startsWith('setup:')) {
|
|
37
|
+
flow.setup = parseScalarValue(line.slice(6));
|
|
38
|
+
} else if (line.startsWith('covers:')) {
|
|
39
|
+
inCovers = true;
|
|
40
|
+
flow.covers = [];
|
|
41
|
+
} else if (line.startsWith('prerequisites:')) {
|
|
42
|
+
inPrerequisites = true;
|
|
43
|
+
flow.prerequisites = [];
|
|
44
|
+
} else if (line.startsWith('steps:')) {
|
|
45
|
+
inSteps = true;
|
|
46
|
+
}
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Array items under covers/prerequisites
|
|
51
|
+
const trimmed = line.trim();
|
|
52
|
+
if (inCovers && trimmed.startsWith('- ')) {
|
|
53
|
+
flow.covers!.push(parseScalarValue(trimmed.slice(2)));
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
if (inPrerequisites && trimmed.startsWith('- ')) {
|
|
57
|
+
flow.prerequisites!.push(parseScalarValue(trimmed.slice(2)));
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if (!inSteps) continue;
|
|
62
|
+
|
|
63
|
+
// Step list items
|
|
64
|
+
if (trimmed.startsWith('- name:')) {
|
|
65
|
+
if (currentStep && currentStep.name) {
|
|
66
|
+
flow.steps!.push(currentStep as FlowStep);
|
|
67
|
+
}
|
|
68
|
+
currentStep = { name: parseScalarValue(trimmed.slice(7)) };
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
if (!currentStep) continue;
|
|
73
|
+
|
|
74
|
+
// Step fields
|
|
75
|
+
if (trimmed.startsWith('press:')) {
|
|
76
|
+
currentStep.press = parseInlineObject(trimmed.slice(6).trim()) as FlowStep['press'];
|
|
77
|
+
} else if (trimmed.startsWith('scroll:')) {
|
|
78
|
+
currentStep.scroll = parseScalarValue(trimmed.slice(7));
|
|
79
|
+
} else if (trimmed.startsWith('fill:')) {
|
|
80
|
+
currentStep.fill = parseInlineObject(trimmed.slice(5).trim()) as FlowStep['fill'];
|
|
81
|
+
} else if (trimmed.startsWith('back:')) {
|
|
82
|
+
currentStep.back = parseScalarValue(trimmed.slice(5)) === 'true';
|
|
83
|
+
} else if (trimmed.startsWith('screenshot:')) {
|
|
84
|
+
currentStep.screenshot = parseScalarValue(trimmed.slice(11));
|
|
85
|
+
} else if (trimmed.startsWith('note:')) {
|
|
86
|
+
currentStep.note = parseScalarValue(trimmed.slice(5));
|
|
87
|
+
} else if (trimmed.startsWith('assert:')) {
|
|
88
|
+
const inlineVal = trimmed.slice(7).trim();
|
|
89
|
+
if (inlineVal) {
|
|
90
|
+
currentStep.assert = parseInlineObject(inlineVal) as FlowStep['assert'];
|
|
91
|
+
} else {
|
|
92
|
+
currentStep.assert = {};
|
|
93
|
+
}
|
|
94
|
+
} else if (trimmed.startsWith('interactive_count:')) {
|
|
95
|
+
if (!currentStep.assert) currentStep.assert = {};
|
|
96
|
+
currentStep.assert.interactive_count = parseInlineObject(trimmed.slice(18).trim()) as { min: number };
|
|
97
|
+
} else if (trimmed.startsWith('bottom_nav_tabs:')) {
|
|
98
|
+
if (!currentStep.assert) currentStep.assert = {};
|
|
99
|
+
currentStep.assert.bottom_nav_tabs = parseInlineObject(trimmed.slice(16).trim()) as { min: number };
|
|
100
|
+
} else if (trimmed.startsWith('has_type:')) {
|
|
101
|
+
if (!currentStep.assert) currentStep.assert = {};
|
|
102
|
+
currentStep.assert.has_type = parseInlineObject(trimmed.slice(9).trim()) as { type: string; min?: number };
|
|
103
|
+
} else if (trimmed.startsWith('text_visible:')) {
|
|
104
|
+
if (!currentStep.assert) currentStep.assert = {};
|
|
105
|
+
currentStep.assert.text_visible = parseInlineArray(trimmed.slice(13).trim());
|
|
106
|
+
} else if (trimmed.startsWith('text_not_visible:')) {
|
|
107
|
+
if (!currentStep.assert) currentStep.assert = {};
|
|
108
|
+
currentStep.assert.text_not_visible = parseInlineArray(trimmed.slice(17).trim());
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Push last step
|
|
113
|
+
if (currentStep && currentStep.name) {
|
|
114
|
+
flow.steps!.push(currentStep as FlowStep);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
if (!flow.name) throw new FlowWalkerError(ErrorCodes.FLOW_PARSE_ERROR, 'Flow missing required field: name', 'Add a name: field at the top of the YAML flow');
|
|
118
|
+
if (!flow.steps || flow.steps.length === 0) throw new FlowWalkerError(ErrorCodes.FLOW_PARSE_ERROR, 'Flow has no steps', 'Add steps: with at least one - name: entry');
|
|
119
|
+
|
|
120
|
+
return {
|
|
121
|
+
name: flow.name,
|
|
122
|
+
description: flow.description ?? '',
|
|
123
|
+
...(flow.app ? { app: flow.app } : {}),
|
|
124
|
+
...(flow.appUrl ? { appUrl: flow.appUrl } : {}),
|
|
125
|
+
covers: flow.covers,
|
|
126
|
+
prerequisites: flow.prerequisites,
|
|
127
|
+
setup: flow.setup ?? 'normal',
|
|
128
|
+
steps: flow.steps,
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** Load and parse a YAML flow from a file path */
|
|
133
|
+
export function parseFlowFile(filePath: string): Flow {
|
|
134
|
+
const content = readFileSync(filePath, 'utf-8');
|
|
135
|
+
return parseFlow(content);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** Parse a scalar YAML value (strip quotes, inline comments) */
|
|
139
|
+
function parseScalarValue(raw: string): string {
|
|
140
|
+
let val = raw.trim();
|
|
141
|
+
// Remove inline comments (but not # inside quotes)
|
|
142
|
+
const commentIdx = val.indexOf(' #');
|
|
143
|
+
if (commentIdx > 0) val = val.slice(0, commentIdx).trim();
|
|
144
|
+
// Strip surrounding quotes
|
|
145
|
+
if ((val.startsWith('"') && val.endsWith('"')) ||
|
|
146
|
+
(val.startsWith("'") && val.endsWith("'"))) {
|
|
147
|
+
val = val.slice(1, -1);
|
|
148
|
+
}
|
|
149
|
+
return val;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** Parse an inline YAML object like { type: button, position: rightmost } */
|
|
153
|
+
function parseInlineObject(raw: string): Record<string, unknown> {
|
|
154
|
+
const str = raw.trim();
|
|
155
|
+
if (!str.startsWith('{') || !str.endsWith('}')) {
|
|
156
|
+
// Simple scalar — return as-is in a value field
|
|
157
|
+
return { value: parseScalarValue(str) } as Record<string, unknown>;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const inner = str.slice(1, -1).trim();
|
|
161
|
+
const result: Record<string, unknown> = {};
|
|
162
|
+
|
|
163
|
+
// Split by comma, handling quoted strings
|
|
164
|
+
const pairs = splitCommas(inner);
|
|
165
|
+
for (const pair of pairs) {
|
|
166
|
+
const colonIdx = pair.indexOf(':');
|
|
167
|
+
if (colonIdx < 0) continue;
|
|
168
|
+
const key = pair.slice(0, colonIdx).trim();
|
|
169
|
+
const valRaw = pair.slice(colonIdx + 1).trim();
|
|
170
|
+
const val = parseScalarValue(valRaw);
|
|
171
|
+
|
|
172
|
+
// Type coercion
|
|
173
|
+
if (val === 'true') result[key] = true;
|
|
174
|
+
else if (val === 'false') result[key] = false;
|
|
175
|
+
else if (/^\d+$/.test(val)) result[key] = parseInt(val, 10);
|
|
176
|
+
else if (/^\d+\.\d+$/.test(val)) result[key] = parseFloat(val);
|
|
177
|
+
else result[key] = val;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
return result;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** Parse an inline YAML array like ["Featured", "Create Your Own App"] */
|
|
184
|
+
function parseInlineArray(raw: string): string[] {
|
|
185
|
+
const str = raw.trim();
|
|
186
|
+
if (!str.startsWith('[') || !str.endsWith(']')) {
|
|
187
|
+
// Single value — wrap in array
|
|
188
|
+
const val = parseScalarValue(str);
|
|
189
|
+
return val ? [val] : [];
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const inner = str.slice(1, -1).trim();
|
|
193
|
+
if (!inner) return [];
|
|
194
|
+
|
|
195
|
+
return splitCommas(inner).map(s => parseScalarValue(s)).filter(Boolean);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** Split string by commas, respecting quoted strings */
|
|
199
|
+
function splitCommas(str: string): string[] {
|
|
200
|
+
const parts: string[] = [];
|
|
201
|
+
let current = '';
|
|
202
|
+
let inQuote = false;
|
|
203
|
+
let quoteChar = '';
|
|
204
|
+
|
|
205
|
+
for (const ch of str) {
|
|
206
|
+
if (!inQuote && (ch === '"' || ch === "'")) {
|
|
207
|
+
inQuote = true;
|
|
208
|
+
quoteChar = ch;
|
|
209
|
+
current += ch;
|
|
210
|
+
} else if (inQuote && ch === quoteChar) {
|
|
211
|
+
inQuote = false;
|
|
212
|
+
current += ch;
|
|
213
|
+
} else if (!inQuote && ch === ',') {
|
|
214
|
+
parts.push(current.trim());
|
|
215
|
+
current = '';
|
|
216
|
+
} else {
|
|
217
|
+
current += ch;
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
if (current.trim()) parts.push(current.trim());
|
|
221
|
+
return parts;
|
|
222
|
+
}
|
package/src/graph.ts
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import type { ScreenNode, ScreenEdge } from './types.ts';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Directed navigation graph tracking screens and transitions.
|
|
5
|
+
*/
|
|
6
|
+
export class NavigationGraph {
|
|
7
|
+
nodes: Map<string, ScreenNode> = new Map();
|
|
8
|
+
edges: ScreenEdge[] = [];
|
|
9
|
+
|
|
10
|
+
/** Add or update a screen node */
|
|
11
|
+
addScreen(id: string, name: string, elementTypes: string[], elementCount: number): ScreenNode {
|
|
12
|
+
const existing = this.nodes.get(id);
|
|
13
|
+
if (existing) {
|
|
14
|
+
existing.visits += 1;
|
|
15
|
+
return existing;
|
|
16
|
+
}
|
|
17
|
+
const node: ScreenNode = {
|
|
18
|
+
id,
|
|
19
|
+
name,
|
|
20
|
+
elementTypes,
|
|
21
|
+
elementCount,
|
|
22
|
+
firstSeen: Date.now(),
|
|
23
|
+
visits: 1,
|
|
24
|
+
};
|
|
25
|
+
this.nodes.set(id, node);
|
|
26
|
+
return node;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Add an edge (transition) between two screens */
|
|
30
|
+
addEdge(source: string, target: string, element: { ref: string; type: string; text: string }): void {
|
|
31
|
+
// Avoid duplicate edges for same source→target via same element type+text
|
|
32
|
+
const exists = this.edges.some(
|
|
33
|
+
e => e.source === source && e.target === target &&
|
|
34
|
+
e.element.type === element.type && e.element.text === element.text,
|
|
35
|
+
);
|
|
36
|
+
if (!exists) {
|
|
37
|
+
this.edges.push({ source, target, element });
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Check if a screen has been visited */
|
|
42
|
+
hasScreen(id: string): boolean {
|
|
43
|
+
return this.nodes.has(id);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Get visit count for a screen */
|
|
47
|
+
visitCount(id: string): number {
|
|
48
|
+
return this.nodes.get(id)?.visits ?? 0;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Get all edges from a source screen */
|
|
52
|
+
edgesFrom(source: string): ScreenEdge[] {
|
|
53
|
+
return this.edges.filter(e => e.source === source);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Get all edges to a target screen */
|
|
57
|
+
edgesTo(target: string): ScreenEdge[] {
|
|
58
|
+
return this.edges.filter(e => e.target === target);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Get unique screen count */
|
|
62
|
+
screenCount(): number {
|
|
63
|
+
return this.nodes.size;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Export graph as JSON-serializable object */
|
|
67
|
+
toJSON(): { nodes: ScreenNode[]; edges: ScreenEdge[] } {
|
|
68
|
+
return {
|
|
69
|
+
nodes: [...this.nodes.values()],
|
|
70
|
+
edges: this.edges,
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
}
|