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/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
+ }
@@ -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)} &middot; ${durationSec}s &middot; ${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, '&amp;')
208
+ .replace(/</g, '&lt;')
209
+ .replace(/>/g, '&gt;')
210
+ .replace(/"/g, '&quot;');
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
+ }