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/push.ts
ADDED
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
// Remote API: upload/retrieve reports and run data from flow-walker hosted service
|
|
2
|
+
|
|
3
|
+
import { readFileSync, existsSync } from 'node:fs';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import { FlowWalkerError, ErrorCodes } from './errors.ts';
|
|
6
|
+
|
|
7
|
+
const DEFAULT_API_URL = 'https://flow-walker.beastoin.workers.dev';
|
|
8
|
+
|
|
9
|
+
function resolveApiUrl(apiUrl?: string): string {
|
|
10
|
+
return apiUrl || process.env.FLOW_WALKER_API_URL || DEFAULT_API_URL;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface PushResult {
|
|
14
|
+
id: string;
|
|
15
|
+
url: string;
|
|
16
|
+
htmlUrl: string;
|
|
17
|
+
expiresAt: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Upload a report to the hosted service */
|
|
21
|
+
export async function pushReport(
|
|
22
|
+
runDir: string,
|
|
23
|
+
options: { apiUrl?: string; runId?: string } = {},
|
|
24
|
+
): Promise<PushResult> {
|
|
25
|
+
const apiUrl = resolveApiUrl(options.apiUrl);
|
|
26
|
+
|
|
27
|
+
// Find report.html
|
|
28
|
+
const reportPath = join(runDir, 'report.html');
|
|
29
|
+
if (!existsSync(reportPath)) {
|
|
30
|
+
throw new FlowWalkerError(
|
|
31
|
+
ErrorCodes.FILE_NOT_FOUND,
|
|
32
|
+
'report.html not found in run directory',
|
|
33
|
+
'Generate it first: flow-walker report <run-dir>',
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Read metadata from run.json if available
|
|
38
|
+
let runId = options.runId;
|
|
39
|
+
let flowName: string | undefined;
|
|
40
|
+
let stepsTotal: number | undefined;
|
|
41
|
+
let stepsPass: number | undefined;
|
|
42
|
+
let duration: number | undefined;
|
|
43
|
+
let appName: string | undefined;
|
|
44
|
+
let appUrl: string | undefined;
|
|
45
|
+
let runJsonContent: string | undefined;
|
|
46
|
+
const runJsonPath = join(runDir, 'run.json');
|
|
47
|
+
if (existsSync(runJsonPath)) {
|
|
48
|
+
try {
|
|
49
|
+
const raw = readFileSync(runJsonPath, 'utf-8');
|
|
50
|
+
const runData = JSON.parse(raw);
|
|
51
|
+
if (!runId) runId = runData.id;
|
|
52
|
+
if (runData.flow) flowName = String(runData.flow);
|
|
53
|
+
if (typeof runData.duration === 'number') duration = runData.duration;
|
|
54
|
+
if (runData.app) appName = String(runData.app);
|
|
55
|
+
if (runData.appUrl || runData.app_url) appUrl = String(runData.appUrl || runData.app_url);
|
|
56
|
+
if (Array.isArray(runData.steps)) {
|
|
57
|
+
stepsTotal = runData.steps.length;
|
|
58
|
+
stepsPass = runData.steps.filter((s: { status?: string }) => s.status === 'pass').length;
|
|
59
|
+
}
|
|
60
|
+
// Prepare run.json for upload — strip local file paths
|
|
61
|
+
const uploadData = { ...runData };
|
|
62
|
+
delete uploadData.video;
|
|
63
|
+
delete uploadData.log;
|
|
64
|
+
if (Array.isArray(uploadData.steps)) {
|
|
65
|
+
uploadData.steps = uploadData.steps.map((s: Record<string, unknown>) => {
|
|
66
|
+
const { screenshot: _s, ...rest } = s;
|
|
67
|
+
return rest;
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
runJsonContent = JSON.stringify(uploadData);
|
|
71
|
+
} catch { /* ignore parse errors */ }
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Read report
|
|
75
|
+
const reportContent = readFileSync(reportPath);
|
|
76
|
+
|
|
77
|
+
// Upload report.html
|
|
78
|
+
const headers: Record<string, string> = {
|
|
79
|
+
'Content-Type': 'text/html',
|
|
80
|
+
'Content-Length': String(reportContent.byteLength),
|
|
81
|
+
};
|
|
82
|
+
if (runId) headers['X-Run-ID'] = runId;
|
|
83
|
+
if (flowName) headers['X-Flow-Name'] = flowName;
|
|
84
|
+
if (stepsTotal !== undefined) headers['X-Steps-Total'] = String(stepsTotal);
|
|
85
|
+
if (stepsPass !== undefined) headers['X-Steps-Pass'] = String(stepsPass);
|
|
86
|
+
if (duration !== undefined) headers['X-Duration'] = String(duration);
|
|
87
|
+
if (appName) headers['X-App-Name'] = appName;
|
|
88
|
+
if (appUrl) headers['X-App-URL'] = appUrl;
|
|
89
|
+
|
|
90
|
+
let response: Response;
|
|
91
|
+
try {
|
|
92
|
+
response = await fetch(`${apiUrl}/runs`, {
|
|
93
|
+
method: 'POST',
|
|
94
|
+
headers,
|
|
95
|
+
body: reportContent,
|
|
96
|
+
});
|
|
97
|
+
} catch (err) {
|
|
98
|
+
throw new FlowWalkerError(
|
|
99
|
+
ErrorCodes.COMMAND_FAILED,
|
|
100
|
+
`Failed to connect to ${apiUrl}: ${err instanceof Error ? err.message : String(err)}`,
|
|
101
|
+
`Check your network or set FLOW_WALKER_API_URL`,
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if (!response.ok) {
|
|
106
|
+
let errorMsg = `Upload failed (HTTP ${response.status})`;
|
|
107
|
+
try {
|
|
108
|
+
const body = await response.json() as { error?: { message?: string } };
|
|
109
|
+
if (body.error?.message) errorMsg = body.error.message;
|
|
110
|
+
} catch { /* ignore */ }
|
|
111
|
+
throw new FlowWalkerError(
|
|
112
|
+
ErrorCodes.COMMAND_FAILED,
|
|
113
|
+
errorMsg,
|
|
114
|
+
'Try again or check FLOW_WALKER_API_URL',
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const result = await response.json() as PushResult;
|
|
119
|
+
|
|
120
|
+
// Upload run.json (best-effort — don't fail push if this fails)
|
|
121
|
+
if (runJsonContent && result.id) {
|
|
122
|
+
try {
|
|
123
|
+
await fetch(`${apiUrl}/runs/${result.id}.json`, {
|
|
124
|
+
method: 'PUT',
|
|
125
|
+
headers: { 'Content-Type': 'application/json' },
|
|
126
|
+
body: runJsonContent,
|
|
127
|
+
});
|
|
128
|
+
} catch { /* best-effort */ }
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
return result;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Fetch run data from hosted service */
|
|
135
|
+
export async function getRunData(
|
|
136
|
+
runId: string,
|
|
137
|
+
options: { apiUrl?: string } = {},
|
|
138
|
+
): Promise<unknown> {
|
|
139
|
+
const apiUrl = resolveApiUrl(options.apiUrl);
|
|
140
|
+
|
|
141
|
+
let response: Response;
|
|
142
|
+
try {
|
|
143
|
+
response = await fetch(`${apiUrl}/runs/${runId}`, {
|
|
144
|
+
headers: { 'Accept': 'application/json' },
|
|
145
|
+
});
|
|
146
|
+
} catch (err) {
|
|
147
|
+
throw new FlowWalkerError(
|
|
148
|
+
ErrorCodes.COMMAND_FAILED,
|
|
149
|
+
`Failed to connect to ${apiUrl}: ${err instanceof Error ? err.message : String(err)}`,
|
|
150
|
+
'Check your network or set FLOW_WALKER_API_URL',
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
if (!response.ok) {
|
|
155
|
+
if (response.status === 404) {
|
|
156
|
+
throw new FlowWalkerError(
|
|
157
|
+
ErrorCodes.FILE_NOT_FOUND,
|
|
158
|
+
`Run ${runId} not found`,
|
|
159
|
+
'Check the run ID or push the run first: flow-walker push <run-dir>',
|
|
160
|
+
);
|
|
161
|
+
}
|
|
162
|
+
throw new FlowWalkerError(
|
|
163
|
+
ErrorCodes.COMMAND_FAILED,
|
|
164
|
+
`Failed to fetch run (HTTP ${response.status})`,
|
|
165
|
+
'Try again or check FLOW_WALKER_API_URL',
|
|
166
|
+
);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
return response.json();
|
|
170
|
+
}
|
package/src/reporter.ts
ADDED
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
// HTML report generator: run.json → self-contained HTML viewer
|
|
2
|
+
|
|
3
|
+
import { readFileSync, writeFileSync } from 'node:fs';
|
|
4
|
+
import { join, dirname } from 'node:path';
|
|
5
|
+
import type { RunResult, StepResult } from './run-schema.ts';
|
|
6
|
+
|
|
7
|
+
export interface ReportOptions {
|
|
8
|
+
noVideo?: boolean;
|
|
9
|
+
output?: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** Generate a self-contained HTML report from a RunResult */
|
|
13
|
+
export function generateReport(runResult: RunResult, runDir: string, options: ReportOptions = {}): string {
|
|
14
|
+
const outputPath = options.output ?? join(runDir, 'report.html');
|
|
15
|
+
|
|
16
|
+
// Embed video as base64 if available
|
|
17
|
+
let videoBase64 = '';
|
|
18
|
+
if (!options.noVideo && runResult.video) {
|
|
19
|
+
try {
|
|
20
|
+
const videoPath = join(runDir, runResult.video);
|
|
21
|
+
const videoData = readFileSync(videoPath);
|
|
22
|
+
videoBase64 = videoData.toString('base64');
|
|
23
|
+
} catch { /* video not available */ }
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Embed screenshots as base64
|
|
27
|
+
const screenshotData: Map<string, string> = new Map();
|
|
28
|
+
for (const step of runResult.steps) {
|
|
29
|
+
if (step.screenshot) {
|
|
30
|
+
try {
|
|
31
|
+
const imgPath = join(runDir, step.screenshot);
|
|
32
|
+
const imgData = readFileSync(imgPath);
|
|
33
|
+
screenshotData.set(step.screenshot, imgData.toString('base64'));
|
|
34
|
+
} catch { /* screenshot not available */ }
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const html = buildHtml(runResult, videoBase64, screenshotData);
|
|
39
|
+
writeFileSync(outputPath, html);
|
|
40
|
+
return outputPath;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Build the HTML string */
|
|
44
|
+
export function buildHtml(
|
|
45
|
+
run: RunResult,
|
|
46
|
+
videoBase64: string,
|
|
47
|
+
screenshots: Map<string, string>,
|
|
48
|
+
): string {
|
|
49
|
+
const passCount = run.steps.filter(s => s.status === 'pass').length;
|
|
50
|
+
const failCount = run.steps.filter(s => s.status === 'fail').length;
|
|
51
|
+
const durationSec = (run.duration / 1000).toFixed(1);
|
|
52
|
+
|
|
53
|
+
return `<!DOCTYPE html>
|
|
54
|
+
<html lang="en">
|
|
55
|
+
<head>
|
|
56
|
+
<meta charset="UTF-8">
|
|
57
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
58
|
+
<title>E2E Flow Viewer: ${escHtml(run.flow)}</title>
|
|
59
|
+
<style>
|
|
60
|
+
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
61
|
+
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #1a1a2e; color: #eee; padding: 20px; }
|
|
62
|
+
.header { text-align: center; margin-bottom: 20px; }
|
|
63
|
+
.header h1 { font-size: 1.5em; margin-bottom: 8px; }
|
|
64
|
+
.header .meta { font-size: 0.9em; color: #aaa; }
|
|
65
|
+
.legend { display: flex; gap: 16px; justify-content: center; margin: 12px 0; font-size: 0.85em; }
|
|
66
|
+
.dot { display: inline-block; width: 10px; height: 10px; border-radius: 50%; margin-right: 4px; }
|
|
67
|
+
.dot.pass { background: #00d26a; }
|
|
68
|
+
.dot.fail { background: #e94560; }
|
|
69
|
+
.dot.active { background: #ffa500; }
|
|
70
|
+
.container { display: flex; gap: 20px; max-width: 1200px; margin: 0 auto; }
|
|
71
|
+
.video-panel { flex: 1; position: sticky; top: 20px; align-self: flex-start; }
|
|
72
|
+
.video-panel video { width: 100%; max-width: 360px; border-radius: 12px; border: 2px solid #333; }
|
|
73
|
+
.steps-panel { flex: 1; display: flex; flex-direction: column; gap: 12px; }
|
|
74
|
+
.step { background: #16213e; border-radius: 10px; padding: 14px; cursor: pointer; border: 2px solid transparent; transition: border-color 0.2s; }
|
|
75
|
+
.step:hover { border-color: #555; }
|
|
76
|
+
.step.active { border-color: #ffa500; }
|
|
77
|
+
.step.pass { border-left: 4px solid #00d26a; }
|
|
78
|
+
.step.fail { border-left: 4px solid #e94560; }
|
|
79
|
+
.step-header { display: flex; align-items: center; gap: 10px; }
|
|
80
|
+
.step-num { font-weight: bold; font-size: 1.1em; min-width: 24px; }
|
|
81
|
+
.step-name { font-weight: 600; }
|
|
82
|
+
.step-detail { font-size: 0.85em; color: #aaa; margin-top: 4px; }
|
|
83
|
+
.step-time { font-size: 0.8em; color: #e94560; font-family: monospace; margin-top: 2px; }
|
|
84
|
+
.step-result { font-size: 0.85em; margin-top: 4px; }
|
|
85
|
+
.step-result.pass { color: #00d26a; }
|
|
86
|
+
.step-result.fail { color: #e94560; }
|
|
87
|
+
.step-thumb { max-width: 120px; border-radius: 6px; margin-top: 8px; display: none; }
|
|
88
|
+
.step.active .step-thumb { display: block; }
|
|
89
|
+
.no-video { display: flex; align-items: center; justify-content: center; width: 360px; height: 640px; background: #16213e; border-radius: 12px; border: 2px solid #333; color: #666; }
|
|
90
|
+
@media (max-width: 768px) {
|
|
91
|
+
.container { flex-direction: column; }
|
|
92
|
+
.video-panel { position: static; }
|
|
93
|
+
.video-panel video, .no-video { max-width: 100%; width: 100%; }
|
|
94
|
+
}
|
|
95
|
+
</style>
|
|
96
|
+
</head>
|
|
97
|
+
<body>
|
|
98
|
+
<div class="header">
|
|
99
|
+
<h1>${escHtml(run.flow)}</h1>
|
|
100
|
+
<div class="meta">${escHtml(run.device)} · ${durationSec}s · ${run.steps.length} steps</div>
|
|
101
|
+
<div class="legend">
|
|
102
|
+
<span><span class="dot pass"></span> PASS (${passCount})</span>
|
|
103
|
+
<span><span class="dot fail"></span> FAIL (${failCount})</span>
|
|
104
|
+
<span><span class="dot active"></span> Active step</span>
|
|
105
|
+
<span>Duration: ${durationSec}s | ${run.steps.length} steps | ${run.result === 'pass' ? 'All PASS' : failCount + ' FAIL'}</span>
|
|
106
|
+
</div>
|
|
107
|
+
</div>
|
|
108
|
+
<div class="container">
|
|
109
|
+
<div class="video-panel">
|
|
110
|
+
${videoBase64
|
|
111
|
+
? `<video id="video" controls><source src="data:video/mp4;base64,${videoBase64}" type="video/mp4"></video>`
|
|
112
|
+
: '<div class="no-video">No video</div>'}
|
|
113
|
+
</div>
|
|
114
|
+
<div class="steps-panel">
|
|
115
|
+
${run.steps.map((s, i) => renderStep(s, i, screenshots)).join('\n')}
|
|
116
|
+
</div>
|
|
117
|
+
</div>
|
|
118
|
+
<script>
|
|
119
|
+
const video = document.getElementById('video');
|
|
120
|
+
const steps = document.querySelectorAll('.step');
|
|
121
|
+
|
|
122
|
+
function jumpTo(time, el) {
|
|
123
|
+
if (video) { video.currentTime = time; video.play(); }
|
|
124
|
+
steps.forEach(s => s.classList.remove('active'));
|
|
125
|
+
if (el) el.classList.add('active');
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Keyboard shortcuts: 1-9 jump to step, Space play/pause
|
|
129
|
+
document.addEventListener('keydown', (e) => {
|
|
130
|
+
const num = parseInt(e.key);
|
|
131
|
+
if (num >= 1 && num <= steps.length) {
|
|
132
|
+
const step = steps[num - 1];
|
|
133
|
+
const time = parseFloat(step.getAttribute('data-time') || '0');
|
|
134
|
+
jumpTo(time, step);
|
|
135
|
+
}
|
|
136
|
+
if (e.key === ' ' && video) {
|
|
137
|
+
e.preventDefault();
|
|
138
|
+
video.paused ? video.play() : video.pause();
|
|
139
|
+
}
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
// Auto-highlight step based on video time
|
|
143
|
+
if (video) {
|
|
144
|
+
video.addEventListener('timeupdate', () => {
|
|
145
|
+
const t = video.currentTime;
|
|
146
|
+
let active = steps[0];
|
|
147
|
+
for (const step of steps) {
|
|
148
|
+
if (parseFloat(step.getAttribute('data-time') || '0') <= t) active = step;
|
|
149
|
+
}
|
|
150
|
+
steps.forEach(s => s.classList.remove('active'));
|
|
151
|
+
if (active) active.classList.add('active');
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
</script>
|
|
155
|
+
</body>
|
|
156
|
+
</html>`;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function renderStep(step: StepResult, index: number, screenshots: Map<string, string>): string {
|
|
160
|
+
const timeSec = (step.timestamp / 1000).toFixed(1);
|
|
161
|
+
const statusClass = step.status === 'pass' ? 'pass' : step.status === 'fail' ? 'fail' : '';
|
|
162
|
+
const statusIcon = step.status === 'pass' ? '✓' : step.status === 'fail' ? '✗' : '○';
|
|
163
|
+
|
|
164
|
+
let detail = step.action;
|
|
165
|
+
if (step.assertion?.interactive_count) {
|
|
166
|
+
detail = `assert: interactive_count ≥ ${step.assertion.interactive_count.min}`;
|
|
167
|
+
}
|
|
168
|
+
if (step.assertion?.bottom_nav_tabs) {
|
|
169
|
+
detail += (detail ? ', ' : 'assert: ') + `bottom_nav_tabs ≥ ${step.assertion.bottom_nav_tabs.min}`;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
let resultText = '';
|
|
173
|
+
if (step.assertion?.interactive_count) {
|
|
174
|
+
resultText = `${statusIcon} ${step.assertion.interactive_count.actual} elements`;
|
|
175
|
+
}
|
|
176
|
+
if (step.assertion?.bottom_nav_tabs) {
|
|
177
|
+
resultText += (resultText ? ', ' : `${statusIcon} `) + `${step.assertion.bottom_nav_tabs.actual} nav tabs`;
|
|
178
|
+
}
|
|
179
|
+
if (step.error) {
|
|
180
|
+
resultText = `${statusIcon} ${step.error}`;
|
|
181
|
+
}
|
|
182
|
+
if (!resultText && step.status === 'pass') {
|
|
183
|
+
resultText = `${statusIcon} ${step.elementCount} elements`;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
let thumbHtml = '';
|
|
187
|
+
if (step.screenshot && screenshots.has(step.screenshot)) {
|
|
188
|
+
thumbHtml = `<img class="step-thumb" src="data:image/png;base64,${screenshots.get(step.screenshot)}" alt="Step ${index + 1}">`;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
return ` <div class="step ${statusClass}" data-time="${timeSec}" onclick="jumpTo(${timeSec}, this)">
|
|
192
|
+
<div class="step-header">
|
|
193
|
+
<div class="step-num">${index + 1}</div>
|
|
194
|
+
<div>
|
|
195
|
+
<div class="step-name">${escHtml(step.name)}</div>
|
|
196
|
+
<div class="step-detail">${escHtml(detail)}</div>
|
|
197
|
+
<div class="step-time">⏱ 0:${timeSec.padStart(4, '0')}</div>
|
|
198
|
+
<div class="step-result ${statusClass}">${escHtml(resultText)}</div>
|
|
199
|
+
</div>
|
|
200
|
+
</div>
|
|
201
|
+
${thumbHtml}
|
|
202
|
+
</div>`;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function escHtml(str: string): string {
|
|
206
|
+
return str
|
|
207
|
+
.replace(/&/g, '&')
|
|
208
|
+
.replace(/</g, '<')
|
|
209
|
+
.replace(/>/g, '>')
|
|
210
|
+
.replace(/"/g, '"');
|
|
211
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
// Run result schema for flow-walker
|
|
2
|
+
|
|
3
|
+
import { randomBytes } from 'node:crypto';
|
|
4
|
+
|
|
5
|
+
/** Generate a short URL-safe run ID (10 chars, base64url) */
|
|
6
|
+
export function generateRunId(): string {
|
|
7
|
+
// 8 random bytes → 10 base64url chars (after trimming padding)
|
|
8
|
+
// Collision probability: ~1 in 2^64 — safe for any practical volume
|
|
9
|
+
return randomBytes(8)
|
|
10
|
+
.toString('base64url')
|
|
11
|
+
.slice(0, 10);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** Result of executing a single step */
|
|
15
|
+
export interface StepResult {
|
|
16
|
+
index: number; // 0-based step index
|
|
17
|
+
name: string;
|
|
18
|
+
action: string; // "press" | "scroll" | "fill" | "back" | "assert" | "screenshot"
|
|
19
|
+
status: 'pass' | 'fail' | 'skip';
|
|
20
|
+
timestamp: number; // ms since flow start
|
|
21
|
+
duration: number; // ms this step took
|
|
22
|
+
elementCount: number;
|
|
23
|
+
screenshot?: string; // path to screenshot file
|
|
24
|
+
assertion?: {
|
|
25
|
+
interactive_count?: { min: number; actual: number };
|
|
26
|
+
bottom_nav_tabs?: { min: number; actual: number };
|
|
27
|
+
has_type?: { type: string; min: number; actual: number };
|
|
28
|
+
text_visible?: { expected: string[]; found: string[]; missing: string[] };
|
|
29
|
+
text_not_visible?: { expected_absent: string[]; absent: string[]; unexpected: string[] };
|
|
30
|
+
};
|
|
31
|
+
error?: string; // error message if failed
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Complete run result */
|
|
35
|
+
export interface RunResult {
|
|
36
|
+
id: string; // unique run ID (10-char base64url)
|
|
37
|
+
flow: string; // flow name
|
|
38
|
+
app?: string; // app name (from flow YAML)
|
|
39
|
+
appUrl?: string; // app URL (from flow YAML)
|
|
40
|
+
device: string; // device model/serial
|
|
41
|
+
startedAt: string; // ISO 8601
|
|
42
|
+
duration: number; // total ms
|
|
43
|
+
result: 'pass' | 'fail';
|
|
44
|
+
steps: StepResult[];
|
|
45
|
+
video?: string; // path to video file
|
|
46
|
+
log?: string; // path to log file
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Validate a RunResult has all required fields */
|
|
50
|
+
export function validateRunResult(data: unknown): data is RunResult {
|
|
51
|
+
if (typeof data !== 'object' || data === null) return false;
|
|
52
|
+
const r = data as Record<string, unknown>;
|
|
53
|
+
if (typeof r.id !== 'string') return false;
|
|
54
|
+
if (typeof r.flow !== 'string') return false;
|
|
55
|
+
if (typeof r.device !== 'string') return false;
|
|
56
|
+
if (typeof r.startedAt !== 'string') return false;
|
|
57
|
+
if (typeof r.duration !== 'number') return false;
|
|
58
|
+
if (r.result !== 'pass' && r.result !== 'fail') return false;
|
|
59
|
+
if (!Array.isArray(r.steps)) return false;
|
|
60
|
+
for (const step of r.steps) {
|
|
61
|
+
if (typeof step !== 'object' || step === null) return false;
|
|
62
|
+
const s = step as Record<string, unknown>;
|
|
63
|
+
if (typeof s.name !== 'string') return false;
|
|
64
|
+
if (typeof s.action !== 'string') return false;
|
|
65
|
+
if (s.status !== 'pass' && s.status !== 'fail' && s.status !== 'skip') return false;
|
|
66
|
+
if (typeof s.timestamp !== 'number') return false;
|
|
67
|
+
if (typeof s.duration !== 'number') return false;
|
|
68
|
+
if (typeof s.elementCount !== 'number') return false;
|
|
69
|
+
}
|
|
70
|
+
return true;
|
|
71
|
+
}
|