testronaut 1.0.17 → 1.0.19
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/bin/cli.js +143 -1
- package/package.json +1 -1
- package/tools/generateHtmlReport.js +171 -97
package/bin/cli.js
CHANGED
|
@@ -8,6 +8,12 @@ import { generateHtmlReport } from '../tools/generateHtmlReport.js';
|
|
|
8
8
|
import inquirer from 'inquirer';
|
|
9
9
|
import fetch from 'node-fetch';
|
|
10
10
|
import crypto from 'crypto';
|
|
11
|
+
import http from 'http';
|
|
12
|
+
import { exec as execCmd } from 'child_process';
|
|
13
|
+
import { promisify } from 'util';
|
|
14
|
+
const exec = promisify(execCmd);
|
|
15
|
+
import url from 'url';
|
|
16
|
+
|
|
11
17
|
|
|
12
18
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
13
19
|
|
|
@@ -30,6 +36,8 @@ Usage:
|
|
|
30
36
|
npx testronaut <file> Run a specific mission file (e.g., login.mission.js)
|
|
31
37
|
npx testronaut login Log in and store session token
|
|
32
38
|
npx testronaut upload Upload the most recent report JSON
|
|
39
|
+
npx testronaut serve Serve & open the most recent HTML report (read-only)
|
|
40
|
+
npx testronaut view Alias of 'serve'
|
|
33
41
|
|
|
34
42
|
Options:
|
|
35
43
|
--init Scaffold project folders and a welcome mission
|
|
@@ -39,6 +47,7 @@ Examples:
|
|
|
39
47
|
npx testronaut
|
|
40
48
|
npx testronaut login
|
|
41
49
|
npx testronaut upload
|
|
50
|
+
npx testronaut serve
|
|
42
51
|
npx testronaut --init
|
|
43
52
|
`;
|
|
44
53
|
|
|
@@ -65,6 +74,14 @@ if (args.includes('upload')) {
|
|
|
65
74
|
process.exit(0);
|
|
66
75
|
}
|
|
67
76
|
|
|
77
|
+
// Handle the serve/view command
|
|
78
|
+
if (args.includes('serve') || args.includes('view')) {
|
|
79
|
+
await serveLatestReport();
|
|
80
|
+
// Keep process alive until user stops it
|
|
81
|
+
console.log('Press Ctrl+C to stop the server.');
|
|
82
|
+
await new Promise(() => {}); // ✅ never resolves; Ctrl+C will terminate
|
|
83
|
+
}
|
|
84
|
+
|
|
68
85
|
if (!fs.existsSync(missionsDir)) {
|
|
69
86
|
console.error('❌ No `missions` directory found.');
|
|
70
87
|
process.exit(1);
|
|
@@ -178,7 +195,7 @@ async function handleLogin() {
|
|
|
178
195
|
const response = await fetch(`${apiBase}/api/user/cli`, { // Replace with your actual URL
|
|
179
196
|
method: 'POST',
|
|
180
197
|
headers: {
|
|
181
|
-
'Content-Type': 'application/
|
|
198
|
+
'Content-Type': 'application/x-www-form-urlencoded',
|
|
182
199
|
},
|
|
183
200
|
body: formData.toString(),
|
|
184
201
|
});
|
|
@@ -371,3 +388,128 @@ async function uploadReport() {
|
|
|
371
388
|
console.log('✅ All screenshots uploaded.');
|
|
372
389
|
}
|
|
373
390
|
}
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
function guessMimeType(p) {
|
|
394
|
+
const ext = path.extname(p).toLowerCase();
|
|
395
|
+
if (ext === '.html' || ext === '.htm') return 'text/html; charset=utf-8';
|
|
396
|
+
if (ext === '.json') return 'application/json; charset=utf-8';
|
|
397
|
+
if (ext === '.js') return 'text/javascript; charset=utf-8';
|
|
398
|
+
if (ext === '.css') return 'text/css; charset=utf-8';
|
|
399
|
+
if (ext === '.png') return 'image/png';
|
|
400
|
+
if (ext === '.jpg' || ext === '.jpeg') return 'image/jpeg';
|
|
401
|
+
if (ext === '.webp') return 'image/webp';
|
|
402
|
+
if (ext === '.gif') return 'image/gif';
|
|
403
|
+
if (ext === '.svg') return 'image/svg+xml';
|
|
404
|
+
return 'application/octet-stream';
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
function safeJoin(root, relUrlPath) {
|
|
408
|
+
// Prevent path traversal and keep within root
|
|
409
|
+
const decoded = decodeURIComponent(relUrlPath);
|
|
410
|
+
const clean = decoded.replace(/^\/+/, ''); // strip leading slash
|
|
411
|
+
const resolved = path.resolve(root, clean);
|
|
412
|
+
if (!resolved.startsWith(path.resolve(root))) {
|
|
413
|
+
return null; // attempted escape
|
|
414
|
+
}
|
|
415
|
+
return resolved;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
async function openInBrowser(targetUrl) {
|
|
419
|
+
const platform = process.platform;
|
|
420
|
+
const quoted = `"${targetUrl}"`;
|
|
421
|
+
if (platform === 'darwin') return exec(`open ${quoted}`);
|
|
422
|
+
if (platform === 'win32') return exec(`start "" ${quoted}`);
|
|
423
|
+
return exec(`xdg-open ${quoted}`).catch(() => {}); // best-effort on Linux
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
function findLatestReportPair(reportDir) {
|
|
427
|
+
if (!fs.existsSync(reportDir)) return null;
|
|
428
|
+
const files = fs.readdirSync(reportDir).filter(f => f.endsWith('.html'));
|
|
429
|
+
if (!files.length) return null;
|
|
430
|
+
|
|
431
|
+
// Expecting run_<timestamp>.html — sort by numeric timestamp descending
|
|
432
|
+
files.sort((a, b) => {
|
|
433
|
+
const ta = parseInt(a.split('_')[1]) || 0;
|
|
434
|
+
const tb = parseInt(b.split('_')[1]) || 0;
|
|
435
|
+
return tb - ta;
|
|
436
|
+
});
|
|
437
|
+
|
|
438
|
+
const latestHtml = files[0];
|
|
439
|
+
const latestBase = path.basename(latestHtml, '.html');
|
|
440
|
+
const jsonCandidate = `${latestBase}.json`;
|
|
441
|
+
|
|
442
|
+
return {
|
|
443
|
+
htmlFile: latestHtml,
|
|
444
|
+
jsonFile: fs.existsSync(path.join(reportDir, jsonCandidate)) ? jsonCandidate : null,
|
|
445
|
+
};
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
async function serveLatestReport() {
|
|
449
|
+
const reportDir = path.resolve(process.cwd(), 'missions/mission_reports');
|
|
450
|
+
|
|
451
|
+
const latest = findLatestReportPair(reportDir);
|
|
452
|
+
if (!latest) {
|
|
453
|
+
console.error('❌ No HTML reports found in missions/mission_reports.');
|
|
454
|
+
process.exit(1);
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
const server = http.createServer((req, res) => {
|
|
458
|
+
try {
|
|
459
|
+
// Default route -> redirect to latest report
|
|
460
|
+
const parsed = url.parse(req.url || '/');
|
|
461
|
+
let pathname = parsed.pathname || '/';
|
|
462
|
+
|
|
463
|
+
if (pathname === '/' || pathname === '') {
|
|
464
|
+
res.statusCode = 302;
|
|
465
|
+
res.setHeader('Location', `/${latest.htmlFile}`);
|
|
466
|
+
res.end();
|
|
467
|
+
return;
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
const targetPath = safeJoin(reportDir, pathname);
|
|
471
|
+
if (!targetPath) {
|
|
472
|
+
res.statusCode = 400;
|
|
473
|
+
res.end('Bad Request');
|
|
474
|
+
return;
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
// If the path is a directory, try index.html (not expected, but harmless)
|
|
478
|
+
let toServe = targetPath;
|
|
479
|
+
if (fs.existsSync(toServe) && fs.statSync(toServe).isDirectory()) {
|
|
480
|
+
toServe = path.join(toServe, 'index.html');
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
if (!fs.existsSync(toServe) || !fs.statSync(toServe).isFile()) {
|
|
484
|
+
res.statusCode = 404;
|
|
485
|
+
res.end('Not Found');
|
|
486
|
+
return;
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
res.statusCode = 200;
|
|
490
|
+
res.setHeader('Content-Type', guessMimeType(toServe));
|
|
491
|
+
fs.createReadStream(toServe).pipe(res);
|
|
492
|
+
} catch (err) {
|
|
493
|
+
res.statusCode = 500;
|
|
494
|
+
res.end('Internal Server Error');
|
|
495
|
+
}
|
|
496
|
+
});
|
|
497
|
+
|
|
498
|
+
// Listen on a random free port
|
|
499
|
+
await new Promise((resolve, reject) => {
|
|
500
|
+
server.once('error', reject);
|
|
501
|
+
server.listen(0, '127.0.0.1', resolve);
|
|
502
|
+
});
|
|
503
|
+
|
|
504
|
+
const address = server.address();
|
|
505
|
+
const port = typeof address === 'object' && address ? address.port : 0;
|
|
506
|
+
const baseUrl = `http://127.0.0.1:${port}`;
|
|
507
|
+
const reportUrl = `${baseUrl}/${latest.htmlFile}`;
|
|
508
|
+
|
|
509
|
+
console.log(`📄 Serving reports from: ${path.relative(process.cwd(), reportDir)}`);
|
|
510
|
+
console.log(`🔗 Opening latest: ${latest.htmlFile}`);
|
|
511
|
+
console.log(`🌐 ${reportUrl}`);
|
|
512
|
+
|
|
513
|
+
// Best-effort auto-open
|
|
514
|
+
await openInBrowser(reportUrl).catch(() => {});
|
|
515
|
+
}
|
package/package.json
CHANGED
|
@@ -1,11 +1,15 @@
|
|
|
1
|
+
// generateHtmlReport.js
|
|
1
2
|
import fs from 'fs';
|
|
2
3
|
import path from 'path';
|
|
3
4
|
|
|
4
5
|
export function generateHtmlReport(report, outputPath) {
|
|
5
6
|
const { runId, startTime, endTime, missions = [], summary = {} } = report;
|
|
6
|
-
const durationSec =
|
|
7
|
+
const durationSec =
|
|
8
|
+
(startTime && endTime)
|
|
9
|
+
? ((new Date(endTime) - new Date(startTime)) / 1000).toFixed(2)
|
|
10
|
+
: '—';
|
|
7
11
|
|
|
8
|
-
//
|
|
12
|
+
// helpers
|
|
9
13
|
const esc = (s) => String(s ?? '')
|
|
10
14
|
.replace(/&/g, '&').replace(/</g, '<')
|
|
11
15
|
.replace(/>/g, '>');
|
|
@@ -13,24 +17,25 @@ export function generateHtmlReport(report, outputPath) {
|
|
|
13
17
|
status === 'passed' ? '✅ Passed' :
|
|
14
18
|
status === 'failed' ? '❌ Failed' : (status || '—');
|
|
15
19
|
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
: '—';
|
|
20
|
+
const submissionBlock = (m) => {
|
|
21
|
+
const mDurationSec =
|
|
22
|
+
m.endTime && m.startTime
|
|
23
|
+
? ((m.endTime - m.startTime) / 1000).toFixed(2)
|
|
24
|
+
: (typeof m.durationSeconds === 'number' ? m.durationSeconds.toFixed(2) : '—');
|
|
22
25
|
|
|
23
26
|
const steps = Array.isArray(m.steps) ? m.steps : [];
|
|
24
|
-
const stepItems = steps.map(step => {
|
|
27
|
+
const stepItems = steps.map((step, idx) => {
|
|
25
28
|
const events = Array.isArray(step.events) ? step.events : [];
|
|
29
|
+
const ok = /✅|Passed|Mission Success/i.test(step.result || '');
|
|
26
30
|
const imgTag = step.screenshotPath
|
|
27
|
-
? `<img src="${esc(step.screenshotPath)}" alt="screenshot turn ${esc(step.turn)}">`
|
|
31
|
+
? `<img src="${esc(step.screenshotPath)}" alt="screenshot turn ${esc(step.turn ?? idx)}">`
|
|
28
32
|
: '';
|
|
33
|
+
|
|
29
34
|
return `
|
|
30
|
-
<details class="step" ${
|
|
35
|
+
<details class="step" ${ok ? '' : 'open'}>
|
|
31
36
|
<summary>
|
|
32
|
-
<span class="turn">Turn ${esc((step.turn ??
|
|
33
|
-
<span class="step-result ${
|
|
37
|
+
<span class="turn">Turn ${esc((step.turn ?? idx) + 1)}</span>
|
|
38
|
+
<span class="step-result ${ok ? 'ok' : 'bad'}">${esc(step.result || '—')}</span>
|
|
34
39
|
<span class="tokens">tokens: ${esc(step.tokensUsed ?? '—')} / total: ${esc(step.totalTokensUsed ?? '—')}</span>
|
|
35
40
|
</summary>
|
|
36
41
|
<pre class="events">${esc(events.join('\n')) || '(no events)'}</pre>
|
|
@@ -39,21 +44,18 @@ export function generateHtmlReport(report, outputPath) {
|
|
|
39
44
|
`;
|
|
40
45
|
}).join('');
|
|
41
46
|
|
|
42
|
-
|
|
43
|
-
const
|
|
44
|
-
const typeLabel =
|
|
45
|
-
type === 'premission' ? 'premission' :
|
|
46
|
-
type === 'postmission' ? 'postmission' :
|
|
47
|
-
'mission';
|
|
47
|
+
const type = String(m.submissionType || 'mission').toLowerCase();
|
|
48
|
+
const typeLabel = (type === 'premission' || type === 'postmission') ? type : 'mission';
|
|
48
49
|
const prettyTitle = `${esc(m.missionName || 'Mission')} — ${typeLabel}${m.submissionName ? `: ${esc(m.submissionName)}` : ''}`;
|
|
49
|
-
|
|
50
|
+
const statusClass = m.status === 'failed' ? 'bad' : (m.status === 'passed' ? 'ok' : '');
|
|
50
51
|
|
|
51
52
|
return `
|
|
52
53
|
<details class="mission-submission">
|
|
53
54
|
<summary>
|
|
54
55
|
<span class="name">${prettyTitle}</span>
|
|
55
|
-
<span class="status ${
|
|
56
|
+
<span class="status ${statusClass}">${badge(m.status)}</span>
|
|
56
57
|
<span class="meta">steps: ${steps.length} • duration: ${mDurationSec}s</span>
|
|
58
|
+
</summary>
|
|
57
59
|
<div class="steps">
|
|
58
60
|
${stepItems || '<div class="empty">No steps recorded.</div>'}
|
|
59
61
|
</div>
|
|
@@ -61,17 +63,15 @@ export function generateHtmlReport(report, outputPath) {
|
|
|
61
63
|
`;
|
|
62
64
|
};
|
|
63
65
|
|
|
64
|
-
//
|
|
66
|
+
// group submissions by mission
|
|
65
67
|
const grouped = missions.reduce((acc, m) => {
|
|
66
68
|
const key = m.missionName || '(unnamed mission)';
|
|
67
69
|
(acc[key] ||= []).push(m);
|
|
68
70
|
return acc;
|
|
69
71
|
}, {});
|
|
70
72
|
|
|
71
|
-
// Compute overall status for a group (any failure => failed)
|
|
72
73
|
const groupStatus = (subs) => subs.some(s => s.status === 'failed') ? 'failed' : 'passed';
|
|
73
74
|
|
|
74
|
-
// Render a mission group with its submissions collapsed inside
|
|
75
75
|
const missionGroupBlock = (missionName, subs) => {
|
|
76
76
|
const status = groupStatus(subs);
|
|
77
77
|
const totalSteps = subs.reduce((n, s) => n + (Array.isArray(s.steps) ? s.steps.length : 0), 0);
|
|
@@ -79,15 +79,18 @@ export function generateHtmlReport(report, outputPath) {
|
|
|
79
79
|
const lastEnd = Math.max(...subs.map(s => s.endTime || 0).filter(Boolean));
|
|
80
80
|
const groupDur = (firstStart && lastEnd) ? ((lastEnd - firstStart) / 1000).toFixed(2) : '—';
|
|
81
81
|
|
|
82
|
-
//
|
|
82
|
+
// pre → mission → post
|
|
83
83
|
const order = { premission: 0, mission: 1, postmission: 2 };
|
|
84
|
-
subs.sort((a, b) =>
|
|
84
|
+
subs.sort((a, b) =>
|
|
85
|
+
(order[(a.submissionType || 'mission')] ?? 1) -
|
|
86
|
+
(order[(b.submissionType || 'mission')] ?? 1)
|
|
87
|
+
);
|
|
85
88
|
|
|
86
89
|
return `
|
|
87
90
|
<details class="mission-group">
|
|
88
91
|
<summary>
|
|
89
92
|
<span class="name">${esc(missionName)}</span>
|
|
90
|
-
<span class="status ${status === '
|
|
93
|
+
<span class="status ${status === 'failed' ? 'bad' : 'ok'}">${badge(status)}</span>
|
|
91
94
|
<span class="meta">submissions: ${subs.length} • steps: ${totalSteps} • duration: ${groupDur}s</span>
|
|
92
95
|
</summary>
|
|
93
96
|
<div class="group-body">
|
|
@@ -97,92 +100,163 @@ export function generateHtmlReport(report, outputPath) {
|
|
|
97
100
|
`;
|
|
98
101
|
};
|
|
99
102
|
|
|
100
|
-
const
|
|
101
|
-
|
|
103
|
+
const groupsHtml = Object.entries(grouped).map(([name, subs]) => missionGroupBlock(name, subs)).join('');
|
|
104
|
+
|
|
105
|
+
// compute totals for chips if not provided
|
|
106
|
+
const totals = Object.keys(grouped).reduce((acc, name) => {
|
|
107
|
+
const st = groupStatus(grouped[name]);
|
|
108
|
+
acc.total += 1;
|
|
109
|
+
acc.passed += st === 'passed' ? 1 : 0;
|
|
110
|
+
acc.failed += st === 'failed' ? 1 : 0;
|
|
111
|
+
return acc;
|
|
112
|
+
}, { total: 0, passed: 0, failed: 0 });
|
|
113
|
+
|
|
114
|
+
const html = `<!DOCTYPE html>
|
|
102
115
|
<html>
|
|
103
116
|
<head>
|
|
104
117
|
<meta charset="UTF-8" />
|
|
105
118
|
<title>Testronaut Report – ${esc(runId)}</title>
|
|
119
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
106
120
|
<style>
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
--
|
|
110
|
-
--
|
|
111
|
-
--bg
|
|
112
|
-
|
|
113
|
-
--
|
|
114
|
-
--
|
|
121
|
+
/* ===== Brand + surface tokens (match splash/app) ===== */
|
|
122
|
+
:root{
|
|
123
|
+
--bg-top:#0b1022;
|
|
124
|
+
--bg-mid:#0f172a;
|
|
125
|
+
--bg-btm:#0b1022;
|
|
126
|
+
|
|
127
|
+
--hairline:rgba(255,255,255,.12);
|
|
128
|
+
--hairline-strong:rgba(255,255,255,.18);
|
|
129
|
+
|
|
130
|
+
--text:rgba(255,255,255,.92);
|
|
131
|
+
--text-muted:rgba(255,255,255,.65);
|
|
132
|
+
|
|
133
|
+
--ok:#22c55e; /* green-500 */
|
|
134
|
+
--bad:#ef4444; /* red-500 */
|
|
135
|
+
|
|
136
|
+
--chip-ok-bg:rgba(34,197,94,.14);
|
|
137
|
+
--chip-bad-bg:rgba(239,68,68,.16);
|
|
138
|
+
--chip-border:rgba(255,255,255,.22);
|
|
139
|
+
--chip-ok-border:rgba(34,197,94,.40);
|
|
140
|
+
--chip-bad-border:rgba(239,68,68,.45);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/* ===== Base ===== */
|
|
144
|
+
html,body{height:100%;}
|
|
145
|
+
body{
|
|
146
|
+
margin:0; padding:24px;
|
|
147
|
+
font-family: ui-sans-serif,system-ui,-apple-system,Segoe UI,Roboto,Ubuntu,Helvetica,Arial;
|
|
148
|
+
background: linear-gradient(180deg,var(--bg-top) 0%,var(--bg-mid) 60%,var(--bg-btm) 100%);
|
|
149
|
+
color: var(--text);
|
|
150
|
+
-webkit-font-smoothing: antialiased;
|
|
151
|
+
-moz-osx-font-smoothing: grayscale;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/* ===== Utilities ===== */
|
|
155
|
+
.gradient-text{
|
|
156
|
+
background: linear-gradient(90deg,#60a5fa,#a78bfa,#34d399);
|
|
157
|
+
-webkit-background-clip:text; background-clip:text; color:transparent;
|
|
158
|
+
}
|
|
159
|
+
.glass{
|
|
160
|
+
background: rgba(255,255,255,.05);
|
|
161
|
+
border: 1px solid var(--hairline);
|
|
162
|
+
border-radius:16px;
|
|
163
|
+
backdrop-filter: blur(8px); -webkit-backdrop-filter: blur(8px);
|
|
164
|
+
box-shadow: 0 10px 30px rgba(59,130,246,.20);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/* ===== Header ===== */
|
|
168
|
+
.header{ max-width:960px; margin:0 auto 16px; padding:16px 18px; }
|
|
169
|
+
h1{ margin:0 0 6px; font-size:22px; font-weight:800; color:#fff; }
|
|
170
|
+
.run-meta{ color:var(--text-muted); display:grid; gap:4px; }
|
|
171
|
+
|
|
172
|
+
/* ===== Chips ===== */
|
|
173
|
+
.summary{ display:flex; flex-wrap:wrap; gap:12px; margin:16px auto 20px; max-width:960px; }
|
|
174
|
+
.pill{
|
|
175
|
+
border:1px solid var(--chip-border);
|
|
176
|
+
background: rgba(255,255,255,.06);
|
|
177
|
+
padding:6px 12px; border-radius:999px; font-size:12px; color:var(--text);
|
|
178
|
+
letter-spacing:.2px;
|
|
179
|
+
}
|
|
180
|
+
.pill.ok{ background: var(--chip-ok-bg); color: var(--ok); border-color: var(--chip-ok-border); font-weight:700; }
|
|
181
|
+
.pill.bad{ background: var(--chip-bad-bg); color: var(--bad); border-color: var(--chip-bad-border); font-weight:700; }
|
|
182
|
+
|
|
183
|
+
/* ===== Disclosure blocks ===== */
|
|
184
|
+
details{ background: rgba(255,255,255,.05); border:1px solid var(--hairline); border-radius:16px; margin:10px 0; overflow:hidden; }
|
|
185
|
+
summary{
|
|
186
|
+
cursor:pointer; padding:14px 16px; display:flex; align-items:center; gap:12px;
|
|
187
|
+
color:#fff; background: rgba(255,255,255,.04);
|
|
188
|
+
}
|
|
189
|
+
.mission-group > summary{ font-weight:800; font-size:15px; letter-spacing:.2px; }
|
|
190
|
+
.mission-group > summary:hover{ background: rgba(255,255,255,.08); }
|
|
191
|
+
|
|
192
|
+
.group-body{ padding:12px; display:grid; gap:10px; }
|
|
193
|
+
|
|
194
|
+
.mission-submission > summary{
|
|
195
|
+
font-weight:700; background: rgba(255,255,255,.06); border-top:1px solid var(--hairline);
|
|
115
196
|
}
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
.
|
|
119
|
-
.
|
|
120
|
-
.
|
|
121
|
-
.
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
.
|
|
130
|
-
|
|
131
|
-
.
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
.
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
197
|
+
.mission-submission > summary:hover{ background: rgba(255,255,255,.08); }
|
|
198
|
+
|
|
199
|
+
.name{ flex:1; }
|
|
200
|
+
.status.ok{ color: var(--ok); font-weight:700; }
|
|
201
|
+
.status.bad{ color: var(--bad); font-weight:700; }
|
|
202
|
+
.meta{ color: var(--text-muted); font-size:12px; }
|
|
203
|
+
|
|
204
|
+
.steps{ padding: 0 12px 12px; }
|
|
205
|
+
.step summary{
|
|
206
|
+
background: rgba(255,255,255,.04);
|
|
207
|
+
border:1px solid var(--hairline);
|
|
208
|
+
border-radius:12px;
|
|
209
|
+
}
|
|
210
|
+
.step summary:hover{ background: rgba(255,255,255,.08); }
|
|
211
|
+
|
|
212
|
+
.turn{ font-weight:700; }
|
|
213
|
+
.step-result.ok{ color: var(--ok); font-weight:700; }
|
|
214
|
+
.step-result.bad{ color: var(--bad); font-weight:700; }
|
|
215
|
+
.tokens{ color: var(--text-muted); font-size:12px; margin-left:auto; font-variant-numeric: tabular-nums; }
|
|
216
|
+
|
|
217
|
+
.events{
|
|
218
|
+
background: rgba(2,6,23,.9); /* near #020617 */
|
|
219
|
+
color: rgba(255,255,255,.92);
|
|
220
|
+
padding:12px; border-radius:12px; margin:12px 0 0;
|
|
221
|
+
font:12px/1.45 ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New";
|
|
222
|
+
max-height:320px; overflow:auto; white-space:pre-wrap;
|
|
223
|
+
border: 1px solid var(--hairline-strong);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
img{
|
|
227
|
+
display:block; max-width:100%;
|
|
228
|
+
border:1px solid var(--hairline); border-radius:12px; margin-top:10px;
|
|
229
|
+
background: rgba(0,0,0,.2);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/* container width */
|
|
233
|
+
.container{ max-width:960px; margin:0 auto; }
|
|
234
|
+
.empty{ color: var(--text-muted); padding:12px; }
|
|
151
235
|
</style>
|
|
152
236
|
</head>
|
|
153
237
|
<body>
|
|
154
|
-
<
|
|
155
|
-
|
|
156
|
-
<div
|
|
157
|
-
|
|
158
|
-
|
|
238
|
+
<div class="header glass">
|
|
239
|
+
<h1>🧑🚀 <span class="gradient-text">Testronaut Report</span></h1>
|
|
240
|
+
<div class="run-meta">
|
|
241
|
+
<div><strong>Run ID:</strong> ${esc(runId ?? '—')}</div>
|
|
242
|
+
<div><strong>Start:</strong> ${esc(startTime ?? '—')}</div>
|
|
243
|
+
<div><strong>End:</strong> ${esc(endTime ?? '—')} • <strong>Duration:</strong> ${durationSec}s</div>
|
|
244
|
+
</div>
|
|
159
245
|
</div>
|
|
160
246
|
|
|
161
247
|
<div class="summary">
|
|
162
|
-
${(
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
const st = groupStatus(grouped[name]);
|
|
166
|
-
acc.total += 1;
|
|
167
|
-
acc.passed += (st === 'passed') ? 1 : 0;
|
|
168
|
-
acc.failed += (st === 'failed') ? 1 : 0;
|
|
169
|
-
return acc;
|
|
170
|
-
}, { total: 0, passed: 0, failed: 0 });
|
|
171
|
-
return `
|
|
172
|
-
<div class="pill">Missions: ${esc(summary.totalMissions ?? totals.total)}</div>
|
|
173
|
-
<div class="pill ok">Passed: ${esc(summary.passed ?? totals.passed)}</div>
|
|
174
|
-
<div class="pill bad">Failed: ${esc(summary.failed ?? totals.failed)}</div>
|
|
175
|
-
`;
|
|
176
|
-
})()}
|
|
248
|
+
<div class="pill">Missions: ${esc(summary.totalMissions ?? totals.total)}</div>
|
|
249
|
+
<div class="pill ok">Passed: ${esc(summary.passed ?? totals.passed)}</div>
|
|
250
|
+
<div class="pill bad">Failed: ${esc(summary.failed ?? totals.failed)}</div>
|
|
177
251
|
</div>
|
|
178
252
|
|
|
179
|
-
|
|
180
|
-
|
|
253
|
+
<div class="container">
|
|
254
|
+
${groupsHtml || '<div class="glass empty">No missions recorded.</div>'}
|
|
255
|
+
</div>
|
|
181
256
|
</body>
|
|
182
|
-
</html
|
|
183
|
-
`;
|
|
257
|
+
</html>`;
|
|
184
258
|
|
|
185
|
-
const out = outputPath ?? path.resolve('missions/mission_reports', `${runId}.html`);
|
|
259
|
+
const out = outputPath ?? path.resolve('missions/mission_reports', `${runId || 'report'}.html`);
|
|
186
260
|
fs.mkdirSync(path.dirname(out), { recursive: true });
|
|
187
261
|
fs.writeFileSync(out, html);
|
|
188
262
|
console.log(`📝 Report generated at: ${out}`);
|