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
package/src/runner.ts
ADDED
|
@@ -0,0 +1,391 @@
|
|
|
1
|
+
// Flow executor: runs YAML flows via agent-flutter
|
|
2
|
+
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { writeFileSync } from 'node:fs';
|
|
5
|
+
import type { Flow, FlowStep, SnapshotElement } from './types.ts';
|
|
6
|
+
import { AgentBridge } from './agent-bridge.ts';
|
|
7
|
+
import { generateRunId, type RunResult, type StepResult } from './run-schema.ts';
|
|
8
|
+
import { screenshot as captureScreenshot, startRecording, stopRecording, startLogcat, stopLogcat, getDeviceName, ensureDir } from './capture.ts';
|
|
9
|
+
|
|
10
|
+
export interface RunOptions {
|
|
11
|
+
outputDir: string;
|
|
12
|
+
noVideo?: boolean;
|
|
13
|
+
noLogs?: boolean;
|
|
14
|
+
json?: boolean;
|
|
15
|
+
agentFlutterPath?: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Execute a flow and produce a RunResult */
|
|
19
|
+
export async function runFlow(flow: Flow, options: RunOptions): Promise<RunResult> {
|
|
20
|
+
const bridge = new AgentBridge(options.agentFlutterPath ?? 'agent-flutter');
|
|
21
|
+
const runId = generateRunId();
|
|
22
|
+
|
|
23
|
+
// Append run ID to output dir so multiple runs don't overwrite each other
|
|
24
|
+
const outputDir = join(options.outputDir, runId);
|
|
25
|
+
ensureDir(outputDir);
|
|
26
|
+
|
|
27
|
+
const device = getDeviceName();
|
|
28
|
+
const startedAt = new Date().toISOString();
|
|
29
|
+
const t0 = Date.now();
|
|
30
|
+
const steps: StepResult[] = [];
|
|
31
|
+
|
|
32
|
+
// Start video recording
|
|
33
|
+
let videoHandle: ReturnType<typeof startRecording> | null = null;
|
|
34
|
+
if (!options.noVideo) {
|
|
35
|
+
try {
|
|
36
|
+
videoHandle = startRecording();
|
|
37
|
+
} catch { /* warn but continue */ }
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Start logcat
|
|
41
|
+
let logHandle: ReturnType<typeof startLogcat> | null = null;
|
|
42
|
+
if (!options.noLogs) {
|
|
43
|
+
try {
|
|
44
|
+
logHandle = startLogcat();
|
|
45
|
+
} catch { /* warn but continue */ }
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Execute each step
|
|
49
|
+
for (let i = 0; i < flow.steps.length; i++) {
|
|
50
|
+
const step = flow.steps[i];
|
|
51
|
+
const stepStart = Date.now();
|
|
52
|
+
const timestamp = stepStart - t0;
|
|
53
|
+
|
|
54
|
+
try {
|
|
55
|
+
const result = await executeStep(step, bridge, outputDir, i + 1);
|
|
56
|
+
steps.push({
|
|
57
|
+
...result,
|
|
58
|
+
index: i,
|
|
59
|
+
timestamp,
|
|
60
|
+
duration: Date.now() - stepStart,
|
|
61
|
+
});
|
|
62
|
+
} catch (err) {
|
|
63
|
+
// Step failed — mark and continue
|
|
64
|
+
const snapshot = await safeSnapshot(bridge);
|
|
65
|
+
steps.push({
|
|
66
|
+
index: i,
|
|
67
|
+
name: step.name,
|
|
68
|
+
action: getStepAction(step),
|
|
69
|
+
status: 'fail',
|
|
70
|
+
timestamp,
|
|
71
|
+
duration: Date.now() - stepStart,
|
|
72
|
+
elementCount: snapshot.length,
|
|
73
|
+
error: String(err),
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (!options.json) {
|
|
78
|
+
const s = steps[steps.length - 1];
|
|
79
|
+
const icon = s.status === 'pass' ? '✓' : s.status === 'fail' ? '✗' : '○';
|
|
80
|
+
console.log(` ${icon} Step ${i + 1}: ${s.name} [${s.status}] (${s.duration}ms, ${s.elementCount} elements)`);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const duration = Date.now() - t0;
|
|
85
|
+
|
|
86
|
+
// Stop video
|
|
87
|
+
let videoPath: string | undefined;
|
|
88
|
+
if (videoHandle) {
|
|
89
|
+
const localVideo = join(outputDir, 'recording.mp4');
|
|
90
|
+
if (stopRecording(videoHandle, localVideo)) {
|
|
91
|
+
videoPath = 'recording.mp4';
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Stop logcat
|
|
96
|
+
let logPath: string | undefined;
|
|
97
|
+
if (logHandle) {
|
|
98
|
+
const logLines = stopLogcat(logHandle);
|
|
99
|
+
if (logLines.length > 0) {
|
|
100
|
+
const localLog = join(outputDir, 'device.log');
|
|
101
|
+
writeFileSync(localLog, logLines.join('\n'));
|
|
102
|
+
logPath = 'device.log';
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const overallResult = steps.every(s => s.status !== 'fail') ? 'pass' as const : 'fail' as const;
|
|
107
|
+
|
|
108
|
+
const runResult: RunResult = {
|
|
109
|
+
id: runId,
|
|
110
|
+
flow: flow.name,
|
|
111
|
+
...(flow.app ? { app: flow.app } : {}),
|
|
112
|
+
...(flow.appUrl ? { appUrl: flow.appUrl } : {}),
|
|
113
|
+
device,
|
|
114
|
+
startedAt,
|
|
115
|
+
duration,
|
|
116
|
+
result: overallResult,
|
|
117
|
+
steps,
|
|
118
|
+
video: videoPath,
|
|
119
|
+
log: logPath,
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
// Write run.json
|
|
123
|
+
const runJsonPath = join(outputDir, 'run.json');
|
|
124
|
+
writeFileSync(runJsonPath, JSON.stringify(runResult, null, 2));
|
|
125
|
+
|
|
126
|
+
return runResult;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** Execute a single step */
|
|
130
|
+
async function executeStep(
|
|
131
|
+
step: FlowStep,
|
|
132
|
+
bridge: AgentBridge,
|
|
133
|
+
outputDir: string,
|
|
134
|
+
stepNum: number,
|
|
135
|
+
): Promise<StepResult> {
|
|
136
|
+
const action = getStepAction(step);
|
|
137
|
+
let elements = await safeSnapshot(bridge);
|
|
138
|
+
|
|
139
|
+
// Execute the action
|
|
140
|
+
if (step.press) {
|
|
141
|
+
const target = resolvePress(step.press, elements);
|
|
142
|
+
if (target) {
|
|
143
|
+
await bridge.press(target.ref);
|
|
144
|
+
await delay(1500); // wait for transition
|
|
145
|
+
elements = await safeSnapshot(bridge);
|
|
146
|
+
} else {
|
|
147
|
+
return {
|
|
148
|
+
index: 0, // filled by caller
|
|
149
|
+
name: step.name,
|
|
150
|
+
action,
|
|
151
|
+
status: 'fail',
|
|
152
|
+
timestamp: 0,
|
|
153
|
+
duration: 0,
|
|
154
|
+
elementCount: elements.length,
|
|
155
|
+
error: `Could not resolve press target: ${JSON.stringify(step.press)}`,
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
} else if (step.scroll) {
|
|
159
|
+
await bridge.scroll(step.scroll);
|
|
160
|
+
await delay(1000);
|
|
161
|
+
elements = await safeSnapshot(bridge);
|
|
162
|
+
} else if (step.fill) {
|
|
163
|
+
const target = resolveFill(step.fill, elements);
|
|
164
|
+
if (target) {
|
|
165
|
+
await bridge.fill(target.ref, step.fill.value);
|
|
166
|
+
await delay(500);
|
|
167
|
+
elements = await safeSnapshot(bridge);
|
|
168
|
+
} else {
|
|
169
|
+
return {
|
|
170
|
+
index: 0, // filled by caller
|
|
171
|
+
name: step.name,
|
|
172
|
+
action,
|
|
173
|
+
status: 'fail',
|
|
174
|
+
timestamp: 0,
|
|
175
|
+
duration: 0,
|
|
176
|
+
elementCount: elements.length,
|
|
177
|
+
error: `Could not resolve fill target: ${JSON.stringify(step.fill)}`,
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
} else if (step.back) {
|
|
181
|
+
await bridge.back();
|
|
182
|
+
await delay(1500);
|
|
183
|
+
elements = await safeSnapshot(bridge);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// Take screenshot
|
|
187
|
+
let screenshotPath: string | undefined;
|
|
188
|
+
if (step.screenshot) {
|
|
189
|
+
const filename = `step-${stepNum}-${step.screenshot}.png`;
|
|
190
|
+
const fullPath = join(outputDir, filename);
|
|
191
|
+
if (captureScreenshot(fullPath)) {
|
|
192
|
+
screenshotPath = filename;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// Check assertions
|
|
197
|
+
let status: 'pass' | 'fail' = 'pass';
|
|
198
|
+
let assertion: StepResult['assertion'];
|
|
199
|
+
|
|
200
|
+
if (step.assert) {
|
|
201
|
+
assertion = {};
|
|
202
|
+
if (step.assert.interactive_count) {
|
|
203
|
+
const actual = elements.length;
|
|
204
|
+
const min = step.assert.interactive_count.min;
|
|
205
|
+
assertion.interactive_count = { min, actual };
|
|
206
|
+
if (actual < min) status = 'fail';
|
|
207
|
+
}
|
|
208
|
+
if (step.assert.bottom_nav_tabs) {
|
|
209
|
+
const navTabs = elements.filter(e =>
|
|
210
|
+
e.flutterType === 'InkWell' && e.bounds && e.bounds.y > 780
|
|
211
|
+
);
|
|
212
|
+
const actual = navTabs.length;
|
|
213
|
+
const min = step.assert.bottom_nav_tabs.min;
|
|
214
|
+
assertion.bottom_nav_tabs = { min, actual };
|
|
215
|
+
if (actual < min) status = 'fail';
|
|
216
|
+
}
|
|
217
|
+
if (step.assert.has_type) {
|
|
218
|
+
const searchType = step.assert.has_type.type.toLowerCase();
|
|
219
|
+
const matching = elements.filter(e =>
|
|
220
|
+
e.type === searchType || e.flutterType?.toLowerCase().includes(searchType)
|
|
221
|
+
);
|
|
222
|
+
const actual = matching.length;
|
|
223
|
+
const min = step.assert.has_type.min ?? 1;
|
|
224
|
+
assertion.has_type = { type: step.assert.has_type.type, min, actual };
|
|
225
|
+
if (actual < min) status = 'fail';
|
|
226
|
+
}
|
|
227
|
+
if (step.assert.text_visible || step.assert.text_not_visible) {
|
|
228
|
+
// Fetch all visible text from UIAutomator accessibility layer
|
|
229
|
+
const screenTexts = bridge.text();
|
|
230
|
+
|
|
231
|
+
if (step.assert.text_visible) {
|
|
232
|
+
const missing: string[] = [];
|
|
233
|
+
const found: string[] = [];
|
|
234
|
+
for (const query of step.assert.text_visible) {
|
|
235
|
+
const lowerQuery = query.toLowerCase();
|
|
236
|
+
const match = screenTexts.find(t => t.toLowerCase().includes(lowerQuery));
|
|
237
|
+
if (match) {
|
|
238
|
+
found.push(query);
|
|
239
|
+
} else {
|
|
240
|
+
missing.push(query);
|
|
241
|
+
status = 'fail';
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
assertion.text_visible = { expected: step.assert.text_visible, found, missing };
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
if (step.assert.text_not_visible) {
|
|
248
|
+
const unexpected: string[] = [];
|
|
249
|
+
const absent: string[] = [];
|
|
250
|
+
for (const query of step.assert.text_not_visible) {
|
|
251
|
+
const lowerQuery = query.toLowerCase();
|
|
252
|
+
const match = screenTexts.find(t => t.toLowerCase().includes(lowerQuery));
|
|
253
|
+
if (match) {
|
|
254
|
+
unexpected.push(query);
|
|
255
|
+
status = 'fail';
|
|
256
|
+
} else {
|
|
257
|
+
absent.push(query);
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
assertion.text_not_visible = { expected_absent: step.assert.text_not_visible, absent, unexpected };
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
return {
|
|
266
|
+
index: 0, // filled by caller
|
|
267
|
+
name: step.name,
|
|
268
|
+
action,
|
|
269
|
+
status,
|
|
270
|
+
timestamp: 0, // filled by caller
|
|
271
|
+
duration: 0, // filled by caller
|
|
272
|
+
elementCount: elements.length,
|
|
273
|
+
screenshot: screenshotPath,
|
|
274
|
+
assertion,
|
|
275
|
+
};
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/** Resolve a press target from the snapshot */
|
|
279
|
+
export function resolvePress(
|
|
280
|
+
press: NonNullable<FlowStep['press']>,
|
|
281
|
+
elements: SnapshotElement[],
|
|
282
|
+
): SnapshotElement | null {
|
|
283
|
+
if (press.ref) {
|
|
284
|
+
return elements.find(e => e.ref === press.ref) ?? null;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
if (press.bottom_nav_tab !== undefined) {
|
|
288
|
+
const navItems = elements
|
|
289
|
+
.filter(e => e.flutterType === 'InkWell' && e.bounds && e.bounds.y > 780)
|
|
290
|
+
.sort((a, b) => (a.bounds?.x ?? 0) - (b.bounds?.x ?? 0));
|
|
291
|
+
return navItems[press.bottom_nav_tab] ?? null;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
if (press.type) {
|
|
295
|
+
const typeMatches = elements.filter(e =>
|
|
296
|
+
e.type === press.type || e.flutterType?.toLowerCase().includes(press.type!.toLowerCase())
|
|
297
|
+
);
|
|
298
|
+
|
|
299
|
+
if (press.position === 'rightmost') {
|
|
300
|
+
return typeMatches.sort((a, b) => (b.bounds?.x ?? 0) - (a.bounds?.x ?? 0))[0] ?? null;
|
|
301
|
+
}
|
|
302
|
+
if (press.position === 'leftmost') {
|
|
303
|
+
return typeMatches.sort((a, b) => (a.bounds?.x ?? 0) - (b.bounds?.x ?? 0))[0] ?? null;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
return typeMatches[0] ?? null;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
return null;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/** Resolve a fill target from the snapshot */
|
|
313
|
+
export function resolveFill(
|
|
314
|
+
fill: NonNullable<FlowStep['fill']>,
|
|
315
|
+
elements: SnapshotElement[],
|
|
316
|
+
): SnapshotElement | null {
|
|
317
|
+
if (fill.type) {
|
|
318
|
+
return elements.find(e =>
|
|
319
|
+
e.type === fill.type || e.type === 'textfield' || e.flutterType === 'TextField'
|
|
320
|
+
) ?? null;
|
|
321
|
+
}
|
|
322
|
+
return elements.find(e => e.type === 'textfield' || e.flutterType === 'TextField') ?? null;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
export function getStepAction(step: FlowStep): string {
|
|
326
|
+
if (step.press) return 'press';
|
|
327
|
+
if (step.scroll) return 'scroll';
|
|
328
|
+
if (step.fill) return 'fill';
|
|
329
|
+
if (step.back) return 'back';
|
|
330
|
+
if (step.assert) return 'assert';
|
|
331
|
+
if (step.screenshot) return 'screenshot';
|
|
332
|
+
return 'unknown';
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/** Dry-run: parse flow and resolve step targets without executing */
|
|
336
|
+
export async function dryRunFlow(flow: Flow, agentFlutterPath: string = 'agent-flutter'): Promise<{
|
|
337
|
+
flow: string;
|
|
338
|
+
steps: { index: number; name: string; action: string; target: unknown; resolved: boolean; reason?: string }[];
|
|
339
|
+
dryRun: true;
|
|
340
|
+
}> {
|
|
341
|
+
const bridge = new AgentBridge(agentFlutterPath);
|
|
342
|
+
let elements: SnapshotElement[] = [];
|
|
343
|
+
try {
|
|
344
|
+
elements = await safeSnapshot(bridge);
|
|
345
|
+
} catch { /* no device = empty snapshot */ }
|
|
346
|
+
|
|
347
|
+
const hasDevice = elements.length > 0;
|
|
348
|
+
|
|
349
|
+
const steps = flow.steps.map((step, i) => {
|
|
350
|
+
const action = getStepAction(step);
|
|
351
|
+
let target: unknown = null;
|
|
352
|
+
let resolved = true;
|
|
353
|
+
let reason: string | undefined;
|
|
354
|
+
|
|
355
|
+
if (step.press) {
|
|
356
|
+
const el = resolvePress(step.press, elements);
|
|
357
|
+
target = el ? { ref: el.ref, type: el.type, text: el.text } : step.press;
|
|
358
|
+
resolved = el !== null;
|
|
359
|
+
if (!resolved) reason = hasDevice ? 'element not found in current snapshot' : 'no device connected for snapshot';
|
|
360
|
+
} else if (step.fill) {
|
|
361
|
+
const el = resolveFill(step.fill, elements);
|
|
362
|
+
target = el ? { ref: el.ref, type: el.type } : step.fill;
|
|
363
|
+
resolved = el !== null;
|
|
364
|
+
if (!resolved) reason = hasDevice ? 'textfield not found in current snapshot' : 'no device connected for snapshot';
|
|
365
|
+
} else if (step.scroll) {
|
|
366
|
+
target = { direction: step.scroll };
|
|
367
|
+
} else if (step.back) {
|
|
368
|
+
target = { back: true };
|
|
369
|
+
} else if (step.assert) {
|
|
370
|
+
target = step.assert;
|
|
371
|
+
// Assertions always "resolve" — they check conditions at runtime
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
return { index: i, name: step.name, action, target, resolved, ...(reason ? { reason } : {}) };
|
|
375
|
+
});
|
|
376
|
+
|
|
377
|
+
return { flow: flow.name, steps, dryRun: true };
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
async function safeSnapshot(bridge: AgentBridge): Promise<SnapshotElement[]> {
|
|
381
|
+
try {
|
|
382
|
+
const snapshot = await bridge.snapshot();
|
|
383
|
+
return snapshot.elements;
|
|
384
|
+
} catch {
|
|
385
|
+
return [];
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
function delay(ms: number): Promise<void> {
|
|
390
|
+
return new Promise(resolve => setTimeout(resolve, ms));
|
|
391
|
+
}
|
package/src/safety.ts
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import type { SnapshotElement } from './types.ts';
|
|
2
|
+
|
|
3
|
+
const DEFAULT_BLOCKLIST = [
|
|
4
|
+
'delete', 'sign out', 'remove', 'reset', 'unpair', 'logout', 'clear all',
|
|
5
|
+
'delete account', 'factory reset', 'erase', 'uninstall',
|
|
6
|
+
];
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Check if an element is safe to press.
|
|
10
|
+
* Returns { safe: true } or { safe: false, reason: string }.
|
|
11
|
+
*/
|
|
12
|
+
export function isSafe(
|
|
13
|
+
element: SnapshotElement,
|
|
14
|
+
nearbyElements: SnapshotElement[],
|
|
15
|
+
blocklist: string[] = DEFAULT_BLOCKLIST,
|
|
16
|
+
): { safe: boolean; reason?: string } {
|
|
17
|
+
// Skip disabled elements
|
|
18
|
+
if (element.enabled === false) {
|
|
19
|
+
return { safe: false, reason: 'element is disabled' };
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// Check element's own text against blocklist
|
|
23
|
+
const elementText = element.text.toLowerCase();
|
|
24
|
+
for (const keyword of blocklist) {
|
|
25
|
+
if (elementText.includes(keyword.toLowerCase())) {
|
|
26
|
+
return { safe: false, reason: `text matches blocklist: "${keyword}"` };
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Check nearby elements' text for context clues
|
|
31
|
+
// "nearby" = elements within a small vertical range
|
|
32
|
+
if (element.bounds) {
|
|
33
|
+
const nearbyTexts = nearbyElements
|
|
34
|
+
.filter(el => {
|
|
35
|
+
if (!el.bounds || el.ref === element.ref) return false;
|
|
36
|
+
const verticalDist = Math.abs(el.bounds.y - element.bounds!.y);
|
|
37
|
+
return verticalDist < 60; // within 60px vertically
|
|
38
|
+
})
|
|
39
|
+
.map(el => el.text.toLowerCase());
|
|
40
|
+
|
|
41
|
+
for (const text of nearbyTexts) {
|
|
42
|
+
for (const keyword of blocklist) {
|
|
43
|
+
if (text.includes(keyword.toLowerCase())) {
|
|
44
|
+
return { safe: false, reason: `nearby text matches blocklist: "${keyword}"` };
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return { safe: true };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Filter a list of elements to only safe-to-press ones.
|
|
55
|
+
* Returns [safeElements, skippedElements].
|
|
56
|
+
*/
|
|
57
|
+
export function filterSafe(
|
|
58
|
+
elements: SnapshotElement[],
|
|
59
|
+
blocklist: string[] = DEFAULT_BLOCKLIST,
|
|
60
|
+
): [SnapshotElement[], Array<{ element: SnapshotElement; reason: string }>] {
|
|
61
|
+
const safe: SnapshotElement[] = [];
|
|
62
|
+
const skipped: Array<{ element: SnapshotElement; reason: string }> = [];
|
|
63
|
+
|
|
64
|
+
for (const el of elements) {
|
|
65
|
+
const result = isSafe(el, elements, blocklist);
|
|
66
|
+
if (result.safe) {
|
|
67
|
+
safe.push(el);
|
|
68
|
+
} else {
|
|
69
|
+
skipped.push({ element: el, reason: result.reason! });
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return [safe, skipped];
|
|
74
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
// Shared types for flow-walker
|
|
2
|
+
|
|
3
|
+
/** An interactive element from agent-flutter snapshot */
|
|
4
|
+
export interface SnapshotElement {
|
|
5
|
+
ref: string; // e.g. "@e1"
|
|
6
|
+
type: string; // e.g. "button", "textfield", "gesture"
|
|
7
|
+
text: string; // label/text content
|
|
8
|
+
flutterType?: string; // e.g. "ElevatedButton", "InkWell"
|
|
9
|
+
enabled?: boolean;
|
|
10
|
+
bounds?: { x: number; y: number; width: number; height: number };
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** A snapshot of a screen's interactive elements */
|
|
14
|
+
export interface ScreenSnapshot {
|
|
15
|
+
elements: SnapshotElement[];
|
|
16
|
+
raw?: string; // raw agent-flutter output
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** A node in the navigation graph */
|
|
20
|
+
export interface ScreenNode {
|
|
21
|
+
id: string; // fingerprint hash
|
|
22
|
+
name: string; // human-readable name derived from elements
|
|
23
|
+
elementTypes: string[]; // sorted type list used for fingerprint
|
|
24
|
+
elementCount: number;
|
|
25
|
+
firstSeen: number; // timestamp
|
|
26
|
+
visits: number;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** An edge in the navigation graph */
|
|
30
|
+
export interface ScreenEdge {
|
|
31
|
+
source: string; // source screen fingerprint
|
|
32
|
+
target: string; // target screen fingerprint
|
|
33
|
+
element: {
|
|
34
|
+
ref: string;
|
|
35
|
+
type: string;
|
|
36
|
+
text: string;
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** A step in a YAML flow */
|
|
41
|
+
export interface FlowStep {
|
|
42
|
+
name: string;
|
|
43
|
+
press?: { type?: string; position?: string; hint?: string; bottom_nav_tab?: number; ref?: string };
|
|
44
|
+
scroll?: string;
|
|
45
|
+
fill?: { type?: string; value: string };
|
|
46
|
+
back?: boolean;
|
|
47
|
+
assert?: {
|
|
48
|
+
interactive_count?: { min: number; verified?: string };
|
|
49
|
+
bottom_nav_tabs?: { min: number };
|
|
50
|
+
has_type?: { type: string; min?: number };
|
|
51
|
+
text?: string;
|
|
52
|
+
text_visible?: string[];
|
|
53
|
+
text_not_visible?: string[];
|
|
54
|
+
};
|
|
55
|
+
screenshot?: string;
|
|
56
|
+
note?: string;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** A complete YAML flow */
|
|
60
|
+
export interface Flow {
|
|
61
|
+
name: string;
|
|
62
|
+
description: string;
|
|
63
|
+
app?: string;
|
|
64
|
+
appUrl?: string;
|
|
65
|
+
covers?: string[];
|
|
66
|
+
prerequisites?: string[];
|
|
67
|
+
setup: string;
|
|
68
|
+
steps: FlowStep[];
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Walker configuration */
|
|
72
|
+
export interface WalkerConfig {
|
|
73
|
+
appUri?: string;
|
|
74
|
+
bundleId?: string;
|
|
75
|
+
maxDepth: number;
|
|
76
|
+
outputDir: string;
|
|
77
|
+
blocklist: string[];
|
|
78
|
+
json: boolean;
|
|
79
|
+
dryRun: boolean;
|
|
80
|
+
agentFlutterPath: string;
|
|
81
|
+
skipConnect: boolean;
|
|
82
|
+
}
|
package/src/validate.ts
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
// Input validation for flow-walker
|
|
2
|
+
// "Agents hallucinate. Build like it." — validate all inputs before dispatch.
|
|
3
|
+
|
|
4
|
+
import { existsSync } from 'node:fs';
|
|
5
|
+
import { resolve } from 'node:path';
|
|
6
|
+
import { FlowWalkerError, ErrorCodes } from './errors.ts';
|
|
7
|
+
|
|
8
|
+
/** Reject strings containing ASCII control characters (except \n and \t) */
|
|
9
|
+
export function rejectControlChars(str: string, fieldName: string): void {
|
|
10
|
+
if (/[\x00-\x08\x0b\x0c\x0e-\x1f]/.test(str)) {
|
|
11
|
+
throw new FlowWalkerError(
|
|
12
|
+
ErrorCodes.INVALID_INPUT,
|
|
13
|
+
`${fieldName} contains invalid control characters`,
|
|
14
|
+
`Remove ASCII control characters (\\n and \\t are allowed)`,
|
|
15
|
+
);
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Reject path traversal attempts */
|
|
20
|
+
function rejectPathTraversal(path: string, fieldName: string): void {
|
|
21
|
+
const normalized = resolve(path);
|
|
22
|
+
if (path.includes('..')) {
|
|
23
|
+
throw new FlowWalkerError(
|
|
24
|
+
ErrorCodes.INVALID_INPUT,
|
|
25
|
+
`${fieldName} contains path traversal (..)`,
|
|
26
|
+
`Use an absolute path or a path relative to cwd without ..`,
|
|
27
|
+
);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Validate a YAML flow file path */
|
|
32
|
+
export function validateFlowPath(path: string): void {
|
|
33
|
+
rejectControlChars(path, 'Flow path');
|
|
34
|
+
rejectPathTraversal(path, 'Flow path');
|
|
35
|
+
|
|
36
|
+
if (!path.endsWith('.yaml') && !path.endsWith('.yml')) {
|
|
37
|
+
throw new FlowWalkerError(
|
|
38
|
+
ErrorCodes.INVALID_INPUT,
|
|
39
|
+
`Flow path must end in .yaml or .yml: ${path}`,
|
|
40
|
+
`Provide a YAML flow file. Run: flow-walker schema run`,
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
if (!existsSync(path)) {
|
|
45
|
+
throw new FlowWalkerError(
|
|
46
|
+
ErrorCodes.FILE_NOT_FOUND,
|
|
47
|
+
`Flow file not found: ${path}`,
|
|
48
|
+
`Check the path and try again. Run: ls ${path}`,
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Validate an output directory path */
|
|
54
|
+
export function validateOutputDir(dir: string): void {
|
|
55
|
+
rejectControlChars(dir, 'Output directory');
|
|
56
|
+
rejectPathTraversal(dir, 'Output directory');
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Validate a VM Service WebSocket URI */
|
|
60
|
+
export function validateUri(uri: string): void {
|
|
61
|
+
rejectControlChars(uri, 'URI');
|
|
62
|
+
|
|
63
|
+
if (!uri.startsWith('ws://') && !uri.startsWith('wss://')) {
|
|
64
|
+
throw new FlowWalkerError(
|
|
65
|
+
ErrorCodes.INVALID_INPUT,
|
|
66
|
+
`URI must start with ws:// or wss://: ${uri}`,
|
|
67
|
+
`Example: ws://127.0.0.1:38047/abc=/ws`,
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
try {
|
|
72
|
+
new URL(uri);
|
|
73
|
+
} catch {
|
|
74
|
+
throw new FlowWalkerError(
|
|
75
|
+
ErrorCodes.INVALID_INPUT,
|
|
76
|
+
`Invalid URI format: ${uri}`,
|
|
77
|
+
`Provide a valid WebSocket URI. Example: ws://127.0.0.1:38047/abc=/ws`,
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Validate a bundle ID (reverse-domain format) */
|
|
83
|
+
export function validateBundleId(id: string): void {
|
|
84
|
+
rejectControlChars(id, 'Bundle ID');
|
|
85
|
+
|
|
86
|
+
if (!/^[a-zA-Z][a-zA-Z0-9._-]*(\.[a-zA-Z][a-zA-Z0-9._-]*)+$/.test(id)) {
|
|
87
|
+
throw new FlowWalkerError(
|
|
88
|
+
ErrorCodes.INVALID_INPUT,
|
|
89
|
+
`Invalid bundle ID format: ${id}`,
|
|
90
|
+
`Bundle ID must be reverse-domain format. Example: com.example.app`,
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Validate a run directory exists and contains run.json */
|
|
96
|
+
export function validateRunDir(dir: string): void {
|
|
97
|
+
rejectControlChars(dir, 'Run directory');
|
|
98
|
+
rejectPathTraversal(dir, 'Run directory');
|
|
99
|
+
|
|
100
|
+
if (!existsSync(dir)) {
|
|
101
|
+
throw new FlowWalkerError(
|
|
102
|
+
ErrorCodes.FILE_NOT_FOUND,
|
|
103
|
+
`Run directory not found: ${dir}`,
|
|
104
|
+
`Provide the output directory from a previous flow-walker run`,
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
if (!existsSync(`${dir}/run.json`)) {
|
|
109
|
+
throw new FlowWalkerError(
|
|
110
|
+
ErrorCodes.FILE_NOT_FOUND,
|
|
111
|
+
`run.json not found in ${dir}`,
|
|
112
|
+
`Run a flow first: flow-walker run <flow.yaml> --output-dir ${dir}`,
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
}
|