donobu 5.1.0 → 5.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/dist/cli/donobu-cli.js +61 -3
- package/dist/cli/donobu-cli.js.map +1 -1
- package/dist/cli/playwright-json-to-html.d.ts +17 -0
- package/dist/cli/playwright-json-to-html.d.ts.map +1 -0
- package/dist/cli/playwright-json-to-html.js +1278 -0
- package/dist/cli/playwright-json-to-html.js.map +1 -0
- package/dist/esm/cli/donobu-cli.js +61 -3
- package/dist/esm/cli/donobu-cli.js.map +1 -1
- package/dist/esm/cli/playwright-json-to-html.d.ts +17 -0
- package/dist/esm/cli/playwright-json-to-html.d.ts.map +1 -0
- package/dist/esm/cli/playwright-json-to-html.js +1278 -0
- package/dist/esm/cli/playwright-json-to-html.js.map +1 -0
- package/dist/esm/lib/test/testExtension.d.ts.map +1 -1
- package/dist/esm/lib/test/testExtension.js +59 -0
- package/dist/esm/lib/test/testExtension.js.map +1 -1
- package/dist/esm/lib/test/utils/triageTestFailure.d.ts +1 -0
- package/dist/esm/lib/test/utils/triageTestFailure.d.ts.map +1 -1
- package/dist/esm/lib/test/utils/triageTestFailure.js +13 -0
- package/dist/esm/lib/test/utils/triageTestFailure.js.map +1 -1
- package/dist/esm/persistence/env/EnvPersistenceRegistry.d.ts +8 -0
- package/dist/esm/persistence/env/EnvPersistenceRegistry.d.ts.map +1 -1
- package/dist/esm/persistence/env/EnvPersistenceRegistry.js +16 -0
- package/dist/esm/persistence/env/EnvPersistenceRegistry.js.map +1 -1
- package/dist/lib/test/testExtension.d.ts.map +1 -1
- package/dist/lib/test/testExtension.js +59 -0
- package/dist/lib/test/testExtension.js.map +1 -1
- package/dist/lib/test/utils/triageTestFailure.d.ts +1 -0
- package/dist/lib/test/utils/triageTestFailure.d.ts.map +1 -1
- package/dist/lib/test/utils/triageTestFailure.js +13 -0
- package/dist/lib/test/utils/triageTestFailure.js.map +1 -1
- package/dist/persistence/env/EnvPersistenceRegistry.d.ts +8 -0
- package/dist/persistence/env/EnvPersistenceRegistry.d.ts.map +1 -1
- package/dist/persistence/env/EnvPersistenceRegistry.js +16 -0
- package/dist/persistence/env/EnvPersistenceRegistry.js.map +1 -1
- package/package.json +3 -2
|
@@ -0,0 +1,1278 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
"use strict";
|
|
3
|
+
/**
|
|
4
|
+
* @fileoverview Playwright JSON Report to HTML Report Converter
|
|
5
|
+
*
|
|
6
|
+
* Converts Playwright JSON test reports (optionally enriched with Donobu triage
|
|
7
|
+
* data) into a polished, self-contained HTML report for test writers, maintainers,
|
|
8
|
+
* and debuggers.
|
|
9
|
+
*
|
|
10
|
+
* @usage
|
|
11
|
+
* ```bash
|
|
12
|
+
* npm exec playwright-json-to-html report.json -o report.html
|
|
13
|
+
* npm exec playwright-json-to-html report.json --triage-dir ./donobu-triage/run-id/ -o report.html
|
|
14
|
+
* cat merged-report.json | npx playwright-json-to-html --triage-dir ./triage/ -o report.html
|
|
15
|
+
* ```
|
|
16
|
+
*/
|
|
17
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
18
|
+
const fs_1 = require("fs");
|
|
19
|
+
const path_1 = require("path");
|
|
20
|
+
// ---------------------------------------------------------------------------
|
|
21
|
+
// Helpers
|
|
22
|
+
// ---------------------------------------------------------------------------
|
|
23
|
+
function stripAnsi(str) {
|
|
24
|
+
return str.replace(/[\u001b\u009b][[()#;?]*(?:[0-9]{1,4}(?:;[0-9]{0,4})*)?[0-9A-ORZcf-nqry=><]/g, '');
|
|
25
|
+
}
|
|
26
|
+
function esc(str) {
|
|
27
|
+
return str
|
|
28
|
+
.replace(/&/g, '&')
|
|
29
|
+
.replace(/</g, '<')
|
|
30
|
+
.replace(/>/g, '>')
|
|
31
|
+
.replace(/"/g, '"')
|
|
32
|
+
.replace(/'/g, ''');
|
|
33
|
+
}
|
|
34
|
+
function fmtDuration(ms) {
|
|
35
|
+
if (ms < 1000) {
|
|
36
|
+
return `${ms}ms`;
|
|
37
|
+
}
|
|
38
|
+
const s = Math.floor(ms / 1000);
|
|
39
|
+
if (s < 60) {
|
|
40
|
+
return `${s}s`;
|
|
41
|
+
}
|
|
42
|
+
return `${Math.floor(s / 60)}m ${s % 60}s`;
|
|
43
|
+
}
|
|
44
|
+
function fmtPercent(n) {
|
|
45
|
+
return `${Math.round(n * 100)}%`;
|
|
46
|
+
}
|
|
47
|
+
function uid() {
|
|
48
|
+
return Math.random().toString(36).slice(2, 10);
|
|
49
|
+
}
|
|
50
|
+
/** Normalize a file path to just the basename for matching purposes. */
|
|
51
|
+
function normalizeTestFile(filePath) {
|
|
52
|
+
if (!filePath) {
|
|
53
|
+
return '';
|
|
54
|
+
}
|
|
55
|
+
// Strip to just the relative-style path: "tests/foo.test.ts"
|
|
56
|
+
// Handle both absolute and relative paths
|
|
57
|
+
const match = filePath.match(/(?:^|[/\\])(tests[/\\].+)$/);
|
|
58
|
+
return match ? match[1].replace(/\\/g, '/') : (0, path_1.basename)(filePath);
|
|
59
|
+
}
|
|
60
|
+
// ---------------------------------------------------------------------------
|
|
61
|
+
// CLI argument parsing
|
|
62
|
+
// ---------------------------------------------------------------------------
|
|
63
|
+
function parseArgs() {
|
|
64
|
+
const args = process.argv.slice(2);
|
|
65
|
+
let outputFile = null;
|
|
66
|
+
let triageDir = null;
|
|
67
|
+
let inputFile = null;
|
|
68
|
+
for (let i = 0; i < args.length; i++) {
|
|
69
|
+
if ((args[i] === '-o' || args[i] === '--output') && i + 1 < args.length) {
|
|
70
|
+
outputFile = args[i + 1];
|
|
71
|
+
i++;
|
|
72
|
+
}
|
|
73
|
+
else if (args[i] === '--triage-dir' && i + 1 < args.length) {
|
|
74
|
+
triageDir = args[i + 1];
|
|
75
|
+
i++;
|
|
76
|
+
}
|
|
77
|
+
else if (!args[i].startsWith('-')) {
|
|
78
|
+
inputFile = args[i];
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
const jsonData = inputFile
|
|
82
|
+
? JSON.parse((0, fs_1.readFileSync)(inputFile, 'utf8'))
|
|
83
|
+
: JSON.parse((0, fs_1.readFileSync)(0, 'utf8'));
|
|
84
|
+
// Default output to a sibling of the input JSON so asset relative paths work
|
|
85
|
+
if (!outputFile && inputFile) {
|
|
86
|
+
outputFile = (0, path_1.join)((0, path_1.dirname)((0, path_1.resolve)(inputFile)), 'donobu-report.html');
|
|
87
|
+
}
|
|
88
|
+
return {
|
|
89
|
+
jsonData,
|
|
90
|
+
outputFile,
|
|
91
|
+
triageDir: triageDir ? (0, path_1.resolve)(triageDir) : null,
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
function loadTriageData(triageDir) {
|
|
95
|
+
const plans = [];
|
|
96
|
+
const evidence = [];
|
|
97
|
+
if (!(0, fs_1.existsSync)(triageDir)) {
|
|
98
|
+
return { plans, evidence };
|
|
99
|
+
}
|
|
100
|
+
try {
|
|
101
|
+
const files = (0, fs_1.readdirSync)(triageDir);
|
|
102
|
+
for (const f of files) {
|
|
103
|
+
const full = (0, path_1.resolve)(triageDir, f);
|
|
104
|
+
try {
|
|
105
|
+
const raw = JSON.parse((0, fs_1.readFileSync)(full, 'utf8'));
|
|
106
|
+
if (f.startsWith('treatment-plan-') && f.endsWith('.json')) {
|
|
107
|
+
plans.push(raw);
|
|
108
|
+
}
|
|
109
|
+
else if (f.startsWith('failure-evidence-') && f.endsWith('.json')) {
|
|
110
|
+
evidence.push(raw);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
catch {
|
|
114
|
+
// Skip unparseable files
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
catch {
|
|
119
|
+
// Directory unreadable
|
|
120
|
+
}
|
|
121
|
+
return { plans, evidence };
|
|
122
|
+
}
|
|
123
|
+
/**
|
|
124
|
+
* Build a lookup key from test metadata for matching triage files to tests.
|
|
125
|
+
* Normalizes file paths to handle absolute vs relative mismatches.
|
|
126
|
+
*/
|
|
127
|
+
function triageKey(file, projectName, title) {
|
|
128
|
+
return [normalizeTestFile(file), projectName ?? '', title]
|
|
129
|
+
.join('::')
|
|
130
|
+
.toLowerCase();
|
|
131
|
+
}
|
|
132
|
+
function buildTriageLookups(triage) {
|
|
133
|
+
const plansByKey = new Map();
|
|
134
|
+
const evidenceByKey = new Map();
|
|
135
|
+
for (const plan of triage.plans) {
|
|
136
|
+
const tc = plan.failure?.testCase;
|
|
137
|
+
if (tc) {
|
|
138
|
+
plansByKey.set(triageKey(tc.file, tc.projectName, tc.title), plan);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
for (const ev of triage.evidence) {
|
|
142
|
+
const tc = ev.failureContext?.testCase;
|
|
143
|
+
if (tc) {
|
|
144
|
+
evidenceByKey.set(triageKey(tc.file, tc.projectName, tc.title), ev);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
return { plansByKey, evidenceByKey };
|
|
148
|
+
}
|
|
149
|
+
function parseStderrSteps(stderrEntries) {
|
|
150
|
+
const steps = [];
|
|
151
|
+
for (const entry of stderrEntries) {
|
|
152
|
+
const raw = entry.text?.trim();
|
|
153
|
+
if (!raw) {
|
|
154
|
+
continue;
|
|
155
|
+
}
|
|
156
|
+
// Parse timestamp: "HH:MM:SS.mmm [uuid] LEVEL message"
|
|
157
|
+
const m = raw.match(/^(\d{2}:\d{2}:\d{2}\.\d{3})\s+\[[^\]]+\]\s+\w+\s+(.+)$/);
|
|
158
|
+
if (!m) {
|
|
159
|
+
continue;
|
|
160
|
+
}
|
|
161
|
+
const time = m[1];
|
|
162
|
+
const msg = m[2];
|
|
163
|
+
// Categorize the log line
|
|
164
|
+
if (msg.startsWith('Taking action:')) {
|
|
165
|
+
steps.push({
|
|
166
|
+
time,
|
|
167
|
+
text: msg.replace('Taking action: ', ''),
|
|
168
|
+
type: 'action',
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
else if (msg.startsWith('The ') && msg.includes(' tool completed in ')) {
|
|
172
|
+
// "The assertPage tool completed in 5054ms with outcome {...}"
|
|
173
|
+
const toolMatch = msg.match(/^The (\S+) tool completed in (\d+)ms with outcome (.+)$/);
|
|
174
|
+
if (toolMatch) {
|
|
175
|
+
const outcome = toolMatch[3];
|
|
176
|
+
let success = false;
|
|
177
|
+
let summary = '';
|
|
178
|
+
try {
|
|
179
|
+
const parsed = JSON.parse(outcome);
|
|
180
|
+
success = parsed.isSuccessful === true;
|
|
181
|
+
summary = parsed.forLlm ?? '';
|
|
182
|
+
}
|
|
183
|
+
catch {
|
|
184
|
+
summary = outcome;
|
|
185
|
+
}
|
|
186
|
+
steps.push({
|
|
187
|
+
time,
|
|
188
|
+
text: `${toolMatch[1]} (${toolMatch[2]}ms) ${success ? '✓' : '✗'} ${esc(summary)}`,
|
|
189
|
+
type: 'result',
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
else if (msg.startsWith('Transitioned flow state from ')) {
|
|
194
|
+
const stateMatch = msg.match(/Transitioned flow state from (\S+) to (\S+)/);
|
|
195
|
+
if (stateMatch) {
|
|
196
|
+
steps.push({
|
|
197
|
+
time,
|
|
198
|
+
text: `${stateMatch[1]} → ${stateMatch[2]}`,
|
|
199
|
+
type: 'state',
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
else if (msg.startsWith('Completed flow with state:')) {
|
|
204
|
+
steps.push({ time, text: msg, type: 'state' });
|
|
205
|
+
}
|
|
206
|
+
else if (msg.includes('Persisted Donobu triage') ||
|
|
207
|
+
msg.includes('Set up DONOBU client')) {
|
|
208
|
+
steps.push({ time, text: msg, type: 'info' });
|
|
209
|
+
}
|
|
210
|
+
// Skip noisy plugin loading lines, etc.
|
|
211
|
+
}
|
|
212
|
+
return steps;
|
|
213
|
+
}
|
|
214
|
+
function extractTests(jsonData) {
|
|
215
|
+
const tests = [];
|
|
216
|
+
for (const suite of jsonData.suites ?? []) {
|
|
217
|
+
for (const spec of suite.specs ?? []) {
|
|
218
|
+
for (const test of spec.tests ?? []) {
|
|
219
|
+
const annotations = test.annotations ?? [];
|
|
220
|
+
const hasHealAnnotation = annotations.some((a) => a.type === 'self-healed');
|
|
221
|
+
const isSelfHealed = hasHealAnnotation || test.donobuStatus === 'healed';
|
|
222
|
+
let status;
|
|
223
|
+
const lastResult = test.results?.at(-1);
|
|
224
|
+
if (test.status === 'skipped' ||
|
|
225
|
+
(!lastResult && test.status === undefined)) {
|
|
226
|
+
status = 'skipped';
|
|
227
|
+
}
|
|
228
|
+
else if (isSelfHealed) {
|
|
229
|
+
status = 'healed';
|
|
230
|
+
}
|
|
231
|
+
else {
|
|
232
|
+
status = lastResult?.status ?? 'unknown';
|
|
233
|
+
}
|
|
234
|
+
const objectiveAnnotation = annotations.find((a) => a.type === 'objective');
|
|
235
|
+
const results = (test.results ?? []).map((r, i) => {
|
|
236
|
+
const attachments = r.attachments ?? [];
|
|
237
|
+
// Parse step screenshot data from donobu-step-summary attachment
|
|
238
|
+
let stepScreenshots = [];
|
|
239
|
+
const summaryAtt = attachments.find((a) => a.name === 'donobu-step-summary');
|
|
240
|
+
if (summaryAtt?.body) {
|
|
241
|
+
try {
|
|
242
|
+
const raw = summaryAtt.body;
|
|
243
|
+
const decoded = Buffer.from(raw, 'base64').toString('utf8');
|
|
244
|
+
const parsed = JSON.parse(decoded);
|
|
245
|
+
stepScreenshots = parsed.map((s) => {
|
|
246
|
+
// Find the corresponding screenshot attachment
|
|
247
|
+
const imgAtt = attachments.find((a) => a.name === `donobu-step-${s.index}-${s.toolName}`);
|
|
248
|
+
return {
|
|
249
|
+
index: s.index,
|
|
250
|
+
toolName: s.toolName,
|
|
251
|
+
page: s.page,
|
|
252
|
+
startedAt: s.startedAt,
|
|
253
|
+
completedAt: s.completedAt,
|
|
254
|
+
success: s.success,
|
|
255
|
+
summary: s.summary,
|
|
256
|
+
imagePath: imgAtt?.path ?? null,
|
|
257
|
+
imageBody: imgAtt?.body ?? null,
|
|
258
|
+
imageContentType: imgAtt?.contentType ?? null,
|
|
259
|
+
};
|
|
260
|
+
});
|
|
261
|
+
}
|
|
262
|
+
catch {
|
|
263
|
+
// Ignore parse failures
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
return {
|
|
267
|
+
index: i,
|
|
268
|
+
status: r.status,
|
|
269
|
+
duration: r.duration ?? 0,
|
|
270
|
+
retry: r.retry ?? 0,
|
|
271
|
+
startTime: r.startTime ?? null,
|
|
272
|
+
errors: (r.errors ?? (r.error ? [r.error] : [])).map((e) => ({
|
|
273
|
+
message: e.message,
|
|
274
|
+
stack: e.stack,
|
|
275
|
+
snippet: e.snippet ? stripAnsi(e.snippet) : undefined,
|
|
276
|
+
actual: e.actual,
|
|
277
|
+
expected: e.expected,
|
|
278
|
+
})),
|
|
279
|
+
attachments,
|
|
280
|
+
steps: parseStderrSteps(r.stderr ?? []),
|
|
281
|
+
stepScreenshots,
|
|
282
|
+
};
|
|
283
|
+
});
|
|
284
|
+
tests.push({
|
|
285
|
+
file: suite.file,
|
|
286
|
+
specTitle: spec.title,
|
|
287
|
+
status,
|
|
288
|
+
isSelfHealed,
|
|
289
|
+
objective: objectiveAnnotation?.description ?? null,
|
|
290
|
+
annotations,
|
|
291
|
+
results,
|
|
292
|
+
projectName: test.projectName ?? '',
|
|
293
|
+
plan: null,
|
|
294
|
+
evidence: null,
|
|
295
|
+
});
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
return tests;
|
|
300
|
+
}
|
|
301
|
+
// ---------------------------------------------------------------------------
|
|
302
|
+
// HTML generation
|
|
303
|
+
// ---------------------------------------------------------------------------
|
|
304
|
+
const STATUS_CFG = {
|
|
305
|
+
passed: {
|
|
306
|
+
label: 'Passed',
|
|
307
|
+
color: '#10b981',
|
|
308
|
+
bg: 'rgba(16,185,129,0.08)',
|
|
309
|
+
icon: '✓',
|
|
310
|
+
},
|
|
311
|
+
failed: {
|
|
312
|
+
label: 'Failed',
|
|
313
|
+
color: '#ef4444',
|
|
314
|
+
bg: 'rgba(239,68,68,0.08)',
|
|
315
|
+
icon: '✗',
|
|
316
|
+
},
|
|
317
|
+
healed: {
|
|
318
|
+
label: 'Healed',
|
|
319
|
+
color: '#8b5cf6',
|
|
320
|
+
bg: 'rgba(139,92,246,0.08)',
|
|
321
|
+
icon: '♥',
|
|
322
|
+
},
|
|
323
|
+
timedOut: {
|
|
324
|
+
label: 'Timed Out',
|
|
325
|
+
color: '#f59e0b',
|
|
326
|
+
bg: 'rgba(245,158,11,0.08)',
|
|
327
|
+
icon: '⏲',
|
|
328
|
+
},
|
|
329
|
+
skipped: {
|
|
330
|
+
label: 'Skipped',
|
|
331
|
+
color: '#6b7280',
|
|
332
|
+
bg: 'rgba(107,114,128,0.08)',
|
|
333
|
+
icon: '▶',
|
|
334
|
+
},
|
|
335
|
+
interrupted: {
|
|
336
|
+
label: 'Interrupted',
|
|
337
|
+
color: '#f97316',
|
|
338
|
+
bg: 'rgba(249,115,22,0.08)',
|
|
339
|
+
icon: '⚡',
|
|
340
|
+
},
|
|
341
|
+
unknown: {
|
|
342
|
+
label: 'Unknown',
|
|
343
|
+
color: '#6b7280',
|
|
344
|
+
bg: 'rgba(107,114,128,0.08)',
|
|
345
|
+
icon: '?',
|
|
346
|
+
},
|
|
347
|
+
};
|
|
348
|
+
function cfg(status) {
|
|
349
|
+
return STATUS_CFG[status] ?? STATUS_CFG['unknown'];
|
|
350
|
+
}
|
|
351
|
+
const REASON_LABELS = {
|
|
352
|
+
SELECTOR_REGRESSION: { label: 'Selector Regression', color: '#f97316' },
|
|
353
|
+
TIMING_OR_SYNCHRONISATION: { label: 'Timing Issue', color: '#f59e0b' },
|
|
354
|
+
ASSERTION_DRIFT: { label: 'Assertion Drift', color: '#eab308' },
|
|
355
|
+
APPLICATION_DEFECT: { label: 'App Defect', color: '#ef4444' },
|
|
356
|
+
AUTOMATION_SCRIPT_ISSUE: { label: 'Script Issue', color: '#f97316' },
|
|
357
|
+
AUTHENTICATION_FAILURE: { label: 'Auth Failure', color: '#ec4899' },
|
|
358
|
+
ENVIRONMENT_CONFIGURATION: { label: 'Env Config', color: '#6366f1' },
|
|
359
|
+
TEST_DATA_UNAVAILABLE: { label: 'Test Data', color: '#8b5cf6' },
|
|
360
|
+
NETWORK_OR_DEPENDENCY: { label: 'Network/Deps', color: '#06b6d4' },
|
|
361
|
+
UNKNOWN: { label: 'Unknown', color: '#6b7280' },
|
|
362
|
+
};
|
|
363
|
+
function reasonCfg(reason) {
|
|
364
|
+
return REASON_LABELS[reason] ?? REASON_LABELS['UNKNOWN'];
|
|
365
|
+
}
|
|
366
|
+
function renderAttachments(attachments, outputDir) {
|
|
367
|
+
const rendered = [];
|
|
368
|
+
for (const att of attachments) {
|
|
369
|
+
if (!att.path && !att.body) {
|
|
370
|
+
continue;
|
|
371
|
+
}
|
|
372
|
+
if (att.contentType === 'application/json') {
|
|
373
|
+
continue;
|
|
374
|
+
}
|
|
375
|
+
// Step screenshots are rendered in the filmstrip, not here
|
|
376
|
+
if (att.name.startsWith('donobu-step-')) {
|
|
377
|
+
continue;
|
|
378
|
+
}
|
|
379
|
+
const isImage = att.contentType?.startsWith('image/');
|
|
380
|
+
const isVideo = att.contentType?.startsWith('video/');
|
|
381
|
+
if (att.path) {
|
|
382
|
+
// Check if file actually exists before rendering
|
|
383
|
+
const absPath = (0, path_1.resolve)(att.path);
|
|
384
|
+
const fileExists = (0, fs_1.existsSync)(absPath);
|
|
385
|
+
if (!fileExists) {
|
|
386
|
+
rendered.push(`<span class="attachment-missing">${esc(att.name)} (file not available)</span>`);
|
|
387
|
+
continue;
|
|
388
|
+
}
|
|
389
|
+
const assetHref = outputDir ? (0, path_1.relative)(outputDir, att.path) : att.path;
|
|
390
|
+
if (isImage) {
|
|
391
|
+
const imgLabel = att.name === 'screenshot'
|
|
392
|
+
? 'Screenshot at test completion'
|
|
393
|
+
: att.name;
|
|
394
|
+
rendered.push(`<div class="img-wrapper"><a href="${esc(assetHref)}" target="_blank" class="attachment-link img-link" title="${esc(imgLabel)}"><img src="${esc(assetHref)}" alt="${esc(imgLabel)}" loading="lazy" class="screenshot" /></a><span class="img-label">${esc(imgLabel)}</span></div>`);
|
|
395
|
+
}
|
|
396
|
+
else if (isVideo) {
|
|
397
|
+
rendered.push(`<a href="${esc(assetHref)}" target="_blank" class="attachment-link video-link" title="${esc(att.name)}">▶ ${esc(att.name)}</a>`);
|
|
398
|
+
}
|
|
399
|
+
else if (att.contentType === 'text/markdown') {
|
|
400
|
+
// Read and render markdown content inline (e.g., error-context.md page snapshots)
|
|
401
|
+
try {
|
|
402
|
+
const mdContent = (0, fs_1.readFileSync)(absPath, 'utf8');
|
|
403
|
+
rendered.push(`<details class="page-snapshot"><summary>${esc(att.name)}</summary><pre class="snapshot-block">${esc(mdContent)}</pre></details>`);
|
|
404
|
+
}
|
|
405
|
+
catch {
|
|
406
|
+
rendered.push(`<a href="${esc(assetHref)}" target="_blank" class="attachment-link" title="${esc(att.name)}">${esc(att.name)}</a>`);
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
else {
|
|
410
|
+
rendered.push(`<a href="${esc(assetHref)}" target="_blank" class="attachment-link" title="${esc(att.name)}">${esc(att.name)}</a>`);
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
else if (att.body && isImage) {
|
|
414
|
+
rendered.push(`<img src="data:${att.contentType};base64,${att.body}" alt="${esc(att.name)}" loading="lazy" class="screenshot" />`);
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
if (!rendered.length) {
|
|
418
|
+
return '';
|
|
419
|
+
}
|
|
420
|
+
// Separate images from other attachments for better layout
|
|
421
|
+
const images = [];
|
|
422
|
+
const others = [];
|
|
423
|
+
for (const r of rendered) {
|
|
424
|
+
if (r.includes('class="screenshot"')) {
|
|
425
|
+
images.push(r);
|
|
426
|
+
}
|
|
427
|
+
else {
|
|
428
|
+
others.push(r);
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
let html = '<div class="attachments-group">';
|
|
432
|
+
if (images.length) {
|
|
433
|
+
html += `<div class="attachments-images">${images.join('')}</div>`;
|
|
434
|
+
}
|
|
435
|
+
if (others.length) {
|
|
436
|
+
html += `<div class="attachments-meta">${others.join('')}</div>`;
|
|
437
|
+
}
|
|
438
|
+
html += '</div>';
|
|
439
|
+
return html;
|
|
440
|
+
}
|
|
441
|
+
function renderErrors(errors) {
|
|
442
|
+
if (!errors.length) {
|
|
443
|
+
return '';
|
|
444
|
+
}
|
|
445
|
+
let html = '';
|
|
446
|
+
for (const err of errors) {
|
|
447
|
+
if (err.message) {
|
|
448
|
+
html += `<pre class="error-block">${esc(stripAnsi(err.message))}</pre>`;
|
|
449
|
+
}
|
|
450
|
+
if (err.snippet) {
|
|
451
|
+
html += `<div class="detail-label">Code Snippet</div><pre class="snippet-block">${esc(err.snippet)}</pre>`;
|
|
452
|
+
}
|
|
453
|
+
if (err.actual !== null &&
|
|
454
|
+
err.actual !== undefined &&
|
|
455
|
+
err.expected !== null &&
|
|
456
|
+
err.expected !== undefined) {
|
|
457
|
+
html += `<div class="expect-actual"><span class="expect-label">Expected:</span> <code>${esc(err.expected)}</code><br/><span class="expect-label">Actual:</span> <code>${esc(err.actual)}</code></div>`;
|
|
458
|
+
}
|
|
459
|
+
if (err.stack && err.stack !== err.message) {
|
|
460
|
+
html += `<details class="stack-details"><summary>Stack trace</summary><pre class="stack-block">${esc(stripAnsi(err.stack))}</pre></details>`;
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
return html;
|
|
464
|
+
}
|
|
465
|
+
function renderSteps(steps, stepScreenshots, outputDir) {
|
|
466
|
+
const hasSteps = steps.filter((s) => s.type === 'action' || s.type === 'result').length > 0;
|
|
467
|
+
const hasScreenshots = stepScreenshots.length > 0;
|
|
468
|
+
if (!hasSteps && !hasScreenshots) {
|
|
469
|
+
return '';
|
|
470
|
+
}
|
|
471
|
+
const meaningful = steps.filter((s) => s.type === 'action' || s.type === 'result');
|
|
472
|
+
const totalItems = meaningful.length + stepScreenshots.length;
|
|
473
|
+
let html = '<details class="steps-section"><summary>Steps (' +
|
|
474
|
+
totalItems +
|
|
475
|
+
')</summary>';
|
|
476
|
+
// Render AI agent tool call steps with optional screenshots
|
|
477
|
+
if (hasScreenshots) {
|
|
478
|
+
html += '<div class="step-filmstrip">';
|
|
479
|
+
for (const ss of stepScreenshots) {
|
|
480
|
+
const duration = ss.completedAt - ss.startedAt;
|
|
481
|
+
const durationStr = duration >= 1000 ? `${(duration / 1000).toFixed(1)}s` : `${duration}ms`;
|
|
482
|
+
const statusIcon = ss.success
|
|
483
|
+
? '<span class="step-status-ok">✓</span>'
|
|
484
|
+
: '<span class="step-status-fail">✗</span>';
|
|
485
|
+
const stepId = `step-img-${uid()}`;
|
|
486
|
+
const hasImg = !!(ss.imagePath || ss.imageBody);
|
|
487
|
+
html += `<div class="filmstrip-step">`;
|
|
488
|
+
html += `<div class="filmstrip-header">`;
|
|
489
|
+
html += `${statusIcon}`;
|
|
490
|
+
html += `<span class="filmstrip-tool">${esc(ss.toolName)}</span>`;
|
|
491
|
+
html += `<span class="filmstrip-duration">${durationStr}</span>`;
|
|
492
|
+
if (hasImg) {
|
|
493
|
+
html += `<button class="filmstrip-toggle" onclick="event.stopPropagation();var el=document.getElementById('${stepId}');el.classList.toggle('open');this.textContent=el.classList.contains('open')?'Hide':'Screenshot'">Screenshot</button>`;
|
|
494
|
+
}
|
|
495
|
+
html += `</div>`;
|
|
496
|
+
// Summary line
|
|
497
|
+
if (ss.summary) {
|
|
498
|
+
const shortSummary = ss.summary.length > 120
|
|
499
|
+
? ss.summary.slice(0, 120) + '...'
|
|
500
|
+
: ss.summary;
|
|
501
|
+
html += `<div class="filmstrip-summary">${esc(shortSummary)}</div>`;
|
|
502
|
+
}
|
|
503
|
+
// Expandable screenshot (hidden by default)
|
|
504
|
+
let imgSrc = null;
|
|
505
|
+
if (ss.imagePath && (0, fs_1.existsSync)(ss.imagePath)) {
|
|
506
|
+
imgSrc = outputDir ? (0, path_1.relative)(outputDir, ss.imagePath) : ss.imagePath;
|
|
507
|
+
}
|
|
508
|
+
else if (ss.imageBody && ss.imageContentType) {
|
|
509
|
+
imgSrc = `data:${ss.imageContentType};base64,${ss.imageBody}`;
|
|
510
|
+
}
|
|
511
|
+
if (imgSrc) {
|
|
512
|
+
html += `<div class="filmstrip-img" id="${stepId}">`;
|
|
513
|
+
html += `<img src="${esc(imgSrc)}" alt="Step ${ss.index}: ${esc(ss.toolName)}" loading="lazy" class="step-screenshot" />`;
|
|
514
|
+
html += `</div>`;
|
|
515
|
+
}
|
|
516
|
+
html += `</div>`;
|
|
517
|
+
}
|
|
518
|
+
html += '</div>';
|
|
519
|
+
}
|
|
520
|
+
// Render log-based steps (stderr parsing)
|
|
521
|
+
if (meaningful.length > 0) {
|
|
522
|
+
html += '<div class="steps-list">';
|
|
523
|
+
for (const step of meaningful) {
|
|
524
|
+
const typeClass = step.type === 'action' ? 'step-action' : 'step-result';
|
|
525
|
+
html += `<div class="step-entry ${typeClass}">`;
|
|
526
|
+
html += `<span class="step-time">${esc(step.time)}</span>`;
|
|
527
|
+
html += `<span class="step-text">${step.text}</span>`;
|
|
528
|
+
html += '</div>';
|
|
529
|
+
}
|
|
530
|
+
html += '</div>';
|
|
531
|
+
}
|
|
532
|
+
html += '</details>';
|
|
533
|
+
return html;
|
|
534
|
+
}
|
|
535
|
+
function renderFailureSummary(plan) {
|
|
536
|
+
const p = plan.plan;
|
|
537
|
+
const rc = reasonCfg(p.failureReason);
|
|
538
|
+
return `
|
|
539
|
+
<div class="failure-summary-banner">
|
|
540
|
+
<div class="failure-summary-header">
|
|
541
|
+
<span class="failure-reason-badge" style="background:${rc.color}">${esc(rc.label)}</span>
|
|
542
|
+
<span class="failure-confidence">${fmtPercent(p.confidence)} confidence</span>
|
|
543
|
+
<span class="source-hint">(AI analysis)</span>
|
|
544
|
+
</div>
|
|
545
|
+
<div class="failure-summary-text">${esc(p.failureSummary)}</div>
|
|
546
|
+
</div>`;
|
|
547
|
+
}
|
|
548
|
+
function renderTriageCard(plan) {
|
|
549
|
+
const p = plan.plan;
|
|
550
|
+
let flags = '';
|
|
551
|
+
if (p.shouldRetryAutomation) {
|
|
552
|
+
flags += '<span class="triage-flag retry">Retryable</span>';
|
|
553
|
+
}
|
|
554
|
+
if (p.requiresCodeChange) {
|
|
555
|
+
flags += '<span class="triage-flag code">Needs Code Change</span>';
|
|
556
|
+
}
|
|
557
|
+
if (p.requiresProductFix) {
|
|
558
|
+
flags += '<span class="triage-flag product">Needs Product Fix</span>';
|
|
559
|
+
}
|
|
560
|
+
let indicators = '';
|
|
561
|
+
if (p.observedIndicators?.length) {
|
|
562
|
+
indicators =
|
|
563
|
+
'<div class="triage-indicators">' +
|
|
564
|
+
p.observedIndicators
|
|
565
|
+
.map((i) => `<span class="indicator-tag">${esc(i)}</span>`)
|
|
566
|
+
.join('') +
|
|
567
|
+
'</div>';
|
|
568
|
+
}
|
|
569
|
+
let steps = '';
|
|
570
|
+
if (p.remediationSteps?.length) {
|
|
571
|
+
steps =
|
|
572
|
+
'<div class="remediation-steps"><div class="detail-label">Remediation Steps</div><ol>';
|
|
573
|
+
for (const step of p.remediationSteps) {
|
|
574
|
+
steps += `<li><span class="step-category">${esc(step.category)}</span> <strong>${esc(step.summary)}</strong><br/><span class="step-details">${esc(step.details)}</span></li>`;
|
|
575
|
+
}
|
|
576
|
+
steps += '</ol></div>';
|
|
577
|
+
}
|
|
578
|
+
let notes = '';
|
|
579
|
+
if (p.notes) {
|
|
580
|
+
notes = `<div class="triage-notes"><div class="detail-label">Notes</div><p>${esc(p.notes)}</p></div>`;
|
|
581
|
+
}
|
|
582
|
+
return `
|
|
583
|
+
<details class="triage-card">
|
|
584
|
+
<summary>Triage Details ${flags}</summary>
|
|
585
|
+
<div class="triage-card-body">
|
|
586
|
+
${indicators}
|
|
587
|
+
${steps}
|
|
588
|
+
${notes}
|
|
589
|
+
</div>
|
|
590
|
+
</details>`;
|
|
591
|
+
}
|
|
592
|
+
function renderQuickActions(test) {
|
|
593
|
+
const cmds = [];
|
|
594
|
+
const title = test.specTitle;
|
|
595
|
+
const project = test.projectName;
|
|
596
|
+
const file = test.file;
|
|
597
|
+
// Re-run this specific test
|
|
598
|
+
const grepArg = title.includes("'") ? `"${title}"` : `'${title}'`;
|
|
599
|
+
const projectArg = project ? ` --project=${project}` : '';
|
|
600
|
+
cmds.push({
|
|
601
|
+
label: 'Re-run this test',
|
|
602
|
+
cmd: `npx donobu test -g ${grepArg}${projectArg}`,
|
|
603
|
+
});
|
|
604
|
+
// Only show treatment-plan actions for tests that are still failing
|
|
605
|
+
// (not for healed tests where the plan was already applied successfully)
|
|
606
|
+
if (test.plan && !test.isSelfHealed) {
|
|
607
|
+
const plan = test.plan.plan;
|
|
608
|
+
const directives = plan.automationDirectives;
|
|
609
|
+
if (directives?.clearPageAiCache) {
|
|
610
|
+
cmds.push({
|
|
611
|
+
label: 'Re-run with fresh cache',
|
|
612
|
+
cmd: `npx donobu test --clear-ai-cache -g ${grepArg}${projectArg}`,
|
|
613
|
+
});
|
|
614
|
+
}
|
|
615
|
+
// Only offer "apply treatment plan" if the plan says retry is viable
|
|
616
|
+
if (plan.shouldRetryAutomation) {
|
|
617
|
+
const planPath = test.plan.failure.runDirectory && test.plan.failure.evidencePath
|
|
618
|
+
? test.plan.failure.evidencePath.replace(/failure-evidence-/, 'treatment-plan-')
|
|
619
|
+
: null;
|
|
620
|
+
if (planPath) {
|
|
621
|
+
cmds.push({
|
|
622
|
+
label: 'Apply treatment plan',
|
|
623
|
+
cmd: `npx donobu heal --plan ${planPath}`,
|
|
624
|
+
});
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
// Source file link
|
|
629
|
+
const sourceHtml = file
|
|
630
|
+
? `<div class="qa-source"><span class="qa-source-label">Source:</span> <code>${esc(file)}</code></div>`
|
|
631
|
+
: '';
|
|
632
|
+
let html = '<div class="quick-actions">';
|
|
633
|
+
html += '<div class="qa-header">Quick Actions</div>';
|
|
634
|
+
for (const { label, cmd } of cmds) {
|
|
635
|
+
const cmdId = `cmd-${uid()}`;
|
|
636
|
+
html += `<div class="qa-row">`;
|
|
637
|
+
html += `<span class="qa-label">${esc(label)}</span>`;
|
|
638
|
+
html += `<div class="qa-cmd-wrapper">`;
|
|
639
|
+
html += `<code class="qa-cmd" id="${cmdId}">${esc(cmd)}</code>`;
|
|
640
|
+
html += `<button class="qa-copy" onclick="navigator.clipboard.writeText(document.getElementById('${cmdId}').textContent);this.textContent='Copied!';setTimeout(()=>this.textContent='Copy',1500)" title="Copy to clipboard">Copy</button>`;
|
|
641
|
+
html += `</div></div>`;
|
|
642
|
+
}
|
|
643
|
+
html += sourceHtml;
|
|
644
|
+
html += '</div>';
|
|
645
|
+
return html;
|
|
646
|
+
}
|
|
647
|
+
function renderEvidenceDeepDive(evidence) {
|
|
648
|
+
const ctx = evidence.failureContext;
|
|
649
|
+
const h = ctx.heuristics;
|
|
650
|
+
const id = uid();
|
|
651
|
+
let content = '';
|
|
652
|
+
if (h) {
|
|
653
|
+
const staleCacheEntries = Object.entries(h.staleCacheIndicators ?? {});
|
|
654
|
+
const activeIndicators = staleCacheEntries.filter(([, v]) => v);
|
|
655
|
+
content += '<div class="evidence-section">';
|
|
656
|
+
content +=
|
|
657
|
+
'<div class="detail-label">Heuristic Assessment <span class="source-hint">(fast, rule-based — may differ from AI analysis above)</span></div>';
|
|
658
|
+
content += '<div class="heuristic-grid">';
|
|
659
|
+
content += `<div class="h-item"><span class="h-label">Reason</span><span class="h-value">${esc(h.failureReason)}</span></div>`;
|
|
660
|
+
content += `<div class="h-item"><span class="h-label">Confidence</span><span class="h-value">${fmtPercent(h.confidence)}</span></div>`;
|
|
661
|
+
content += `<div class="h-item"><span class="h-label">During page.ai</span><span class="h-value">${h.occurredDuringPageAi ? 'Yes' : 'No'}</span></div>`;
|
|
662
|
+
if (activeIndicators.length > 0) {
|
|
663
|
+
content += `<div class="h-item full-width"><span class="h-label">Stale Cache Indicators</span><span class="h-value">${activeIndicators.map(([k]) => `<span class="indicator-tag warn">${esc(k)}</span>`).join('')}</span></div>`;
|
|
664
|
+
}
|
|
665
|
+
content += '</div>';
|
|
666
|
+
if (h.evidence?.length) {
|
|
667
|
+
content += '<div class="h-evidence">';
|
|
668
|
+
for (const e of h.evidence) {
|
|
669
|
+
content += `<div class="h-evidence-item">${esc(e)}</div>`;
|
|
670
|
+
}
|
|
671
|
+
content += '</div>';
|
|
672
|
+
}
|
|
673
|
+
content += '</div>';
|
|
674
|
+
}
|
|
675
|
+
const toolCalls = ctx.donobuFlow?.recentToolCalls;
|
|
676
|
+
if (toolCalls?.length) {
|
|
677
|
+
content += '<div class="evidence-section">';
|
|
678
|
+
content += '<div class="detail-label">Recent Donobu Tool Calls</div>';
|
|
679
|
+
content += '<div class="tool-calls">';
|
|
680
|
+
for (const tc of toolCalls) {
|
|
681
|
+
const name = tc.toolName ?? tc.name ?? 'unknown';
|
|
682
|
+
const dur = tc.durationMs !== null && tc.durationMs !== undefined
|
|
683
|
+
? ` (${fmtDuration(tc.durationMs)})`
|
|
684
|
+
: '';
|
|
685
|
+
const success = tc.outcome?.isSuccessful;
|
|
686
|
+
const statusIcon = success === true
|
|
687
|
+
? '<span class="tc-ok">✓</span>'
|
|
688
|
+
: success === false
|
|
689
|
+
? '<span class="tc-fail">✗</span>'
|
|
690
|
+
: '';
|
|
691
|
+
content += `<div class="tool-call">${statusIcon}<span class="tc-name">${esc(name)}</span>${dur}</div>`;
|
|
692
|
+
}
|
|
693
|
+
content += '</div></div>';
|
|
694
|
+
}
|
|
695
|
+
if (ctx.testSnippet) {
|
|
696
|
+
content += '<div class="evidence-section">';
|
|
697
|
+
content += '<div class="detail-label">Test Source</div>';
|
|
698
|
+
content += `<pre class="snippet-block">${esc(ctx.testSnippet)}</pre>`;
|
|
699
|
+
content += '</div>';
|
|
700
|
+
}
|
|
701
|
+
if (!content) {
|
|
702
|
+
return '';
|
|
703
|
+
}
|
|
704
|
+
return `
|
|
705
|
+
<details class="evidence-deep-dive" id="ev-${id}">
|
|
706
|
+
<summary>Evidence Deep-Dive</summary>
|
|
707
|
+
${content}
|
|
708
|
+
</details>`;
|
|
709
|
+
}
|
|
710
|
+
function renderResultTimeline(results, outputDir) {
|
|
711
|
+
if (results.length === 0) {
|
|
712
|
+
return '';
|
|
713
|
+
}
|
|
714
|
+
let html = '<div class="result-timeline">';
|
|
715
|
+
for (let i = 0; i < results.length; i++) {
|
|
716
|
+
const r = results[i];
|
|
717
|
+
const sc = cfg(r.status);
|
|
718
|
+
const label = i === 0 && results.length > 1
|
|
719
|
+
? 'Initial Run'
|
|
720
|
+
: i === results.length - 1 && results.length > 1
|
|
721
|
+
? 'Heal Run'
|
|
722
|
+
: `Run ${i + 1}`;
|
|
723
|
+
const isLast = i === results.length - 1;
|
|
724
|
+
html += `<div class="timeline-entry ${isLast ? 'last' : ''}">`;
|
|
725
|
+
html += `<div class="timeline-marker" style="background:${sc.color}"></div>`;
|
|
726
|
+
html += '<div class="timeline-content">';
|
|
727
|
+
html += '<div class="timeline-header">';
|
|
728
|
+
html += `<span class="timeline-label">${label}</span>`;
|
|
729
|
+
html += `<span class="timeline-status" style="color:${sc.color}">${sc.label}</span>`;
|
|
730
|
+
html += `<span class="timeline-duration">${fmtDuration(r.duration)}</span>`;
|
|
731
|
+
html += '</div>';
|
|
732
|
+
if (r.errors.length) {
|
|
733
|
+
html += `<div class="timeline-errors">${renderErrors(r.errors)}</div>`;
|
|
734
|
+
}
|
|
735
|
+
html += renderAttachments(r.attachments, outputDir);
|
|
736
|
+
html += renderSteps(r.steps, r.stepScreenshots, outputDir);
|
|
737
|
+
html += '</div></div>';
|
|
738
|
+
}
|
|
739
|
+
html += '</div>';
|
|
740
|
+
return html;
|
|
741
|
+
}
|
|
742
|
+
function generateHtml(jsonData, triage, outputDir) {
|
|
743
|
+
const tests = extractTests(jsonData);
|
|
744
|
+
const isMergedReport = !!jsonData.metadata?.donobuMergedReport;
|
|
745
|
+
const healedTestNames = jsonData.metadata?.donobuHealedTests ?? [];
|
|
746
|
+
// Match triage data to tests
|
|
747
|
+
const { plansByKey, evidenceByKey } = buildTriageLookups(triage);
|
|
748
|
+
for (const test of tests) {
|
|
749
|
+
const key = triageKey(test.file, test.projectName, test.specTitle);
|
|
750
|
+
test.plan = plansByKey.get(key) ?? null;
|
|
751
|
+
test.evidence = evidenceByKey.get(key) ?? null;
|
|
752
|
+
}
|
|
753
|
+
// Counts
|
|
754
|
+
const counts = {
|
|
755
|
+
passed: 0,
|
|
756
|
+
failed: 0,
|
|
757
|
+
healed: 0,
|
|
758
|
+
timedOut: 0,
|
|
759
|
+
skipped: 0,
|
|
760
|
+
interrupted: 0,
|
|
761
|
+
};
|
|
762
|
+
for (const t of tests) {
|
|
763
|
+
counts[t.status] = (counts[t.status] ?? 0) + 1;
|
|
764
|
+
}
|
|
765
|
+
const total = tests.length;
|
|
766
|
+
const totalDuration = tests.reduce((s, t) => s + t.results.reduce((rs, r) => rs + r.duration, 0), 0);
|
|
767
|
+
const allPassed = !counts['failed'] && !counts['timedOut'] && !counts['interrupted'];
|
|
768
|
+
// Group by file
|
|
769
|
+
const fileGroups = new Map();
|
|
770
|
+
for (const test of tests) {
|
|
771
|
+
const group = fileGroups.get(test.file) ?? [];
|
|
772
|
+
group.push(test);
|
|
773
|
+
fileGroups.set(test.file, group);
|
|
774
|
+
}
|
|
775
|
+
// Aggregate triage root causes
|
|
776
|
+
const reasonCounts = new Map();
|
|
777
|
+
for (const t of tests) {
|
|
778
|
+
if (t.plan) {
|
|
779
|
+
const r = t.plan.plan.failureReason;
|
|
780
|
+
reasonCounts.set(r, (reasonCounts.get(r) ?? 0) + 1);
|
|
781
|
+
}
|
|
782
|
+
}
|
|
783
|
+
// --- Build HTML sections ---
|
|
784
|
+
const statCardEntries = [
|
|
785
|
+
{ key: 'passed', label: 'Passed' },
|
|
786
|
+
{ key: 'healed', label: 'Healed' },
|
|
787
|
+
{ key: 'failed', label: 'Failed' },
|
|
788
|
+
{ key: 'timedOut', label: 'Timed Out' },
|
|
789
|
+
{ key: 'skipped', label: 'Skipped' },
|
|
790
|
+
{ key: 'interrupted', label: 'Interrupted' },
|
|
791
|
+
];
|
|
792
|
+
let statCardsHtml = '';
|
|
793
|
+
for (const card of statCardEntries) {
|
|
794
|
+
const count = counts[card.key] ?? 0;
|
|
795
|
+
if (count === 0 && card.key !== 'passed' && card.key !== 'failed') {
|
|
796
|
+
continue;
|
|
797
|
+
}
|
|
798
|
+
const sc = cfg(card.key);
|
|
799
|
+
statCardsHtml += `<button class="stat-card" data-filter="${card.key}" onclick="filterByStatus('${card.key}')" title="Click to filter"><div class="stat-count" style="color:${sc.color}">${count}</div><div class="stat-label">${card.label}</div></button>`;
|
|
800
|
+
}
|
|
801
|
+
// Triage summary bar
|
|
802
|
+
let triageSummaryHtml = '';
|
|
803
|
+
if (reasonCounts.size > 0) {
|
|
804
|
+
let badges = '';
|
|
805
|
+
reasonCounts.forEach((count, reason) => {
|
|
806
|
+
const rc = reasonCfg(reason);
|
|
807
|
+
badges += `<span class="reason-summary-badge" style="background:${rc.color}">${count} ${esc(rc.label)}</span>`;
|
|
808
|
+
});
|
|
809
|
+
triageSummaryHtml = `<div class="triage-summary-bar"><span class="triage-summary-title">Root Causes</span>${badges}</div>`;
|
|
810
|
+
}
|
|
811
|
+
// File groups + test cards
|
|
812
|
+
let testSectionsHtml = '';
|
|
813
|
+
fileGroups.forEach((fileTests, file) => {
|
|
814
|
+
const fileCounts = {};
|
|
815
|
+
for (const t of fileTests) {
|
|
816
|
+
fileCounts[t.status] = (fileCounts[t.status] ?? 0) + 1;
|
|
817
|
+
}
|
|
818
|
+
const fileDuration = fileTests.reduce((s, t) => s + t.results.reduce((rs, r) => rs + r.duration, 0), 0);
|
|
819
|
+
const fileHasFailure = (fileCounts['failed'] ?? 0) > 0 ||
|
|
820
|
+
(fileCounts['timedOut'] ?? 0) > 0 ||
|
|
821
|
+
(fileCounts['interrupted'] ?? 0) > 0;
|
|
822
|
+
let fileBadgesHtml = '';
|
|
823
|
+
for (const key of Object.keys(STATUS_CFG)) {
|
|
824
|
+
const c = fileCounts[key] ?? 0;
|
|
825
|
+
if (c > 0) {
|
|
826
|
+
fileBadgesHtml += `<span class="file-badge" style="background:${cfg(key).color}">${c}</span>`;
|
|
827
|
+
}
|
|
828
|
+
}
|
|
829
|
+
let testsHtml = '';
|
|
830
|
+
for (const test of fileTests) {
|
|
831
|
+
const sc = cfg(test.status);
|
|
832
|
+
const testId = `t-${uid()}`;
|
|
833
|
+
const hasMultipleResults = test.results.length > 1;
|
|
834
|
+
const lastResult = test.results.at(-1);
|
|
835
|
+
let detailsHtml = '';
|
|
836
|
+
// 1. AI analysis — why it failed (the headline)
|
|
837
|
+
if (test.plan) {
|
|
838
|
+
detailsHtml += renderFailureSummary(test.plan);
|
|
839
|
+
}
|
|
840
|
+
// 2. Quick actions — what to do about it
|
|
841
|
+
if (test.status === 'failed' || test.status === 'healed') {
|
|
842
|
+
detailsHtml += renderQuickActions(test);
|
|
843
|
+
}
|
|
844
|
+
// 3. Errors + screenshots — raw evidence
|
|
845
|
+
if (hasMultipleResults) {
|
|
846
|
+
detailsHtml += renderResultTimeline(test.results, outputDir);
|
|
847
|
+
}
|
|
848
|
+
else if (lastResult) {
|
|
849
|
+
if (lastResult.errors.length) {
|
|
850
|
+
detailsHtml += `<div class="detail-section">${renderErrors(lastResult.errors)}</div>`;
|
|
851
|
+
}
|
|
852
|
+
detailsHtml += renderAttachments(lastResult.attachments, outputDir);
|
|
853
|
+
}
|
|
854
|
+
// 4. Self-healed banner
|
|
855
|
+
if (test.isSelfHealed) {
|
|
856
|
+
detailsHtml += `<div class="healed-banner"><span class="healed-icon">♥</span> This test was automatically healed by re-running with Donobu treatment plan directives.</div>`;
|
|
857
|
+
}
|
|
858
|
+
// 5. Objective — what the test was trying to do
|
|
859
|
+
if (test.objective) {
|
|
860
|
+
detailsHtml += `<div class="detail-section"><div class="detail-label">Objective</div><div class="detail-objective">${esc(test.objective)}</div></div>`;
|
|
861
|
+
}
|
|
862
|
+
// 6. Steps — detailed forensics
|
|
863
|
+
if (!hasMultipleResults && lastResult) {
|
|
864
|
+
detailsHtml += renderSteps(lastResult.steps, lastResult.stepScreenshots, outputDir);
|
|
865
|
+
}
|
|
866
|
+
// 7. Triage details — remediation steps (expandable)
|
|
867
|
+
if (test.plan) {
|
|
868
|
+
detailsHtml += renderTriageCard(test.plan);
|
|
869
|
+
}
|
|
870
|
+
// 8. Evidence deep-dive — heuristics, tool calls (expandable)
|
|
871
|
+
if (test.evidence) {
|
|
872
|
+
detailsHtml += renderEvidenceDeepDive(test.evidence);
|
|
873
|
+
}
|
|
874
|
+
const hasDetails = detailsHtml.length > 0;
|
|
875
|
+
const expandableClass = hasDetails ? 'expandable' : '';
|
|
876
|
+
const chevron = hasDetails
|
|
877
|
+
? '<span class="chevron">▸</span>'
|
|
878
|
+
: '<span class="chevron-spacer"></span>';
|
|
879
|
+
const totalTestDuration = test.results.reduce((s, r) => s + r.duration, 0);
|
|
880
|
+
testsHtml += `
|
|
881
|
+
<div class="test-row ${sc.label.toLowerCase().replace(/ /g, '')} ${expandableClass}" data-status="${test.status}" ${hasDetails ? `onclick="toggleDetail('${testId}',event)"` : ''}>
|
|
882
|
+
<div class="test-summary">
|
|
883
|
+
${chevron}
|
|
884
|
+
<span class="status-dot" style="background:${sc.color}" title="${sc.label}"></span>
|
|
885
|
+
<span class="test-name">${esc(test.specTitle)}</span>
|
|
886
|
+
${test.plan ? `<span class="inline-reason" style="color:${reasonCfg(test.plan.plan.failureReason).color}" title="${esc(test.plan.plan.failureReason)}">${esc(reasonCfg(test.plan.plan.failureReason).label)}</span>` : ''}
|
|
887
|
+
<span class="test-duration">${fmtDuration(totalTestDuration)}</span>
|
|
888
|
+
</div>
|
|
889
|
+
${hasDetails ? `<div class="test-detail" id="${testId}">${detailsHtml}</div>` : ''}
|
|
890
|
+
</div>`;
|
|
891
|
+
}
|
|
892
|
+
testSectionsHtml += `
|
|
893
|
+
<div class="file-group ${fileHasFailure ? 'has-failure' : ''}" data-file="${esc(file)}">
|
|
894
|
+
<div class="file-header" onclick="toggleFileGroup(this)">
|
|
895
|
+
<span class="file-chevron">▾</span>
|
|
896
|
+
<span class="file-name">${esc(file)}</span>
|
|
897
|
+
<div class="file-badges">${fileBadgesHtml}</div>
|
|
898
|
+
<span class="file-duration">${fmtDuration(fileDuration)}</span>
|
|
899
|
+
</div>
|
|
900
|
+
<div class="file-tests">${testsHtml}</div>
|
|
901
|
+
</div>`;
|
|
902
|
+
});
|
|
903
|
+
// Healed tests list
|
|
904
|
+
let healedListHtml = '';
|
|
905
|
+
if (healedTestNames.length > 0) {
|
|
906
|
+
let items = '';
|
|
907
|
+
for (const name of healedTestNames) {
|
|
908
|
+
items += `<li>${esc(name)}</li>`;
|
|
909
|
+
}
|
|
910
|
+
healedListHtml = `<div class="healed-summary-section"><h3>Auto-Healed Tests</h3><ul class="healed-list">${items}</ul></div>`;
|
|
911
|
+
}
|
|
912
|
+
const overallClass = allPassed ? 'overall-pass' : 'overall-fail';
|
|
913
|
+
const overallText = allPassed ? 'All Tests Passed' : 'Some Tests Failed';
|
|
914
|
+
const mergedBanner = isMergedReport
|
|
915
|
+
? '<div class="merged-banner">Auto-heal summary generated by Donobu (merged initial and retry runs).</div>'
|
|
916
|
+
: '';
|
|
917
|
+
// -----------------------------------------------------------------------
|
|
918
|
+
// Full HTML document
|
|
919
|
+
// -----------------------------------------------------------------------
|
|
920
|
+
return `<!DOCTYPE html>
|
|
921
|
+
<html lang="en">
|
|
922
|
+
<head>
|
|
923
|
+
<meta charset="UTF-8">
|
|
924
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
925
|
+
<title>Donobu Test Report</title>
|
|
926
|
+
<style>
|
|
927
|
+
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
|
|
928
|
+
:root{
|
|
929
|
+
--bg:#0f1117;--surface:#161922;--surface-raised:#1e2130;
|
|
930
|
+
--border:#2a2d3a;--border-subtle:#222536;
|
|
931
|
+
--text:#e4e5ea;--text-muted:#8b8d98;--text-dim:#5f6170;
|
|
932
|
+
--green:#10b981;--red:#ef4444;--purple:#8b5cf6;
|
|
933
|
+
--amber:#f59e0b;--orange:#f97316;--gray:#6b7280;
|
|
934
|
+
--radius:8px;--radius-lg:12px;
|
|
935
|
+
--mono:'SF Mono','Fira Code','Fira Mono',Menlo,Consolas,monospace;
|
|
936
|
+
}
|
|
937
|
+
body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,'Helvetica Neue',Arial,sans-serif;background:var(--bg);color:var(--text);line-height:1.5;padding:0}
|
|
938
|
+
.container{max-width:1100px;margin:0 auto;padding:32px 24px}
|
|
939
|
+
|
|
940
|
+
/* Header */
|
|
941
|
+
.report-header{display:flex;align-items:center;justify-content:space-between;margin-bottom:8px}
|
|
942
|
+
.report-title{font-size:22px;font-weight:700;display:flex;align-items:center;gap:10px}
|
|
943
|
+
.logo{width:28px;height:28px;border-radius:6px;background:linear-gradient(135deg,var(--purple),#6d28d9);display:flex;align-items:center;justify-content:center;font-size:15px;color:#fff;flex-shrink:0}
|
|
944
|
+
.report-meta{font-size:13px;color:var(--text-muted)}
|
|
945
|
+
.merged-banner{background:var(--surface-raised);border-left:3px solid var(--purple);padding:10px 14px;margin:16px 0;border-radius:0 var(--radius) var(--radius) 0;font-size:13px;color:var(--text-muted)}
|
|
946
|
+
|
|
947
|
+
/* Overall status */
|
|
948
|
+
.overall-status{text-align:center;padding:24px;border-radius:var(--radius-lg);margin:20px 0 24px;font-size:18px;font-weight:600}
|
|
949
|
+
.overall-pass{background:linear-gradient(135deg,rgba(16,185,129,.12),rgba(16,185,129,.04));border:1px solid rgba(16,185,129,.25);color:var(--green)}
|
|
950
|
+
.overall-fail{background:linear-gradient(135deg,rgba(239,68,68,.12),rgba(239,68,68,.04));border:1px solid rgba(239,68,68,.25);color:var(--red)}
|
|
951
|
+
.overall-status .overall-sub{font-size:13px;font-weight:400;color:var(--text-muted);margin-top:4px}
|
|
952
|
+
|
|
953
|
+
/* Stat cards */
|
|
954
|
+
.stat-cards{display:flex;gap:10px;margin-bottom:16px;flex-wrap:wrap}
|
|
955
|
+
.stat-card{flex:1;min-width:90px;background:var(--surface);border:1px solid var(--border-subtle);border-radius:var(--radius-lg);padding:16px;text-align:center;cursor:pointer;transition:border-color .15s,background .15s;font-family:inherit;color:inherit}
|
|
956
|
+
.stat-card:hover{border-color:var(--border);background:var(--surface-raised)}
|
|
957
|
+
.stat-card.active-filter{border-color:var(--purple);background:rgba(139,92,246,.08)}
|
|
958
|
+
.stat-count{font-size:28px;font-weight:700;line-height:1.1}
|
|
959
|
+
.stat-label{font-size:12px;color:var(--text-muted);margin-top:4px;text-transform:uppercase;letter-spacing:.5px;font-weight:500}
|
|
960
|
+
|
|
961
|
+
/* Triage summary bar */
|
|
962
|
+
.triage-summary-bar{display:flex;align-items:center;gap:8px;flex-wrap:wrap;padding:12px 16px;background:var(--surface);border:1px solid var(--border-subtle);border-radius:var(--radius-lg);margin-bottom:16px}
|
|
963
|
+
.triage-summary-title{font-size:12px;font-weight:600;text-transform:uppercase;letter-spacing:.5px;color:var(--text-muted);margin-right:4px}
|
|
964
|
+
.reason-summary-badge{font-size:12px;font-weight:600;color:#fff;padding:2px 10px;border-radius:10px}
|
|
965
|
+
|
|
966
|
+
/* Filter bar */
|
|
967
|
+
.filter-bar{display:none;align-items:center;gap:8px;margin-bottom:16px;padding:8px 12px;background:var(--surface);border-radius:var(--radius);font-size:13px;color:var(--text-muted)}
|
|
968
|
+
.filter-bar.visible{display:flex}
|
|
969
|
+
.clear-filter{margin-left:auto;background:none;border:1px solid var(--border);color:var(--text-muted);padding:3px 10px;border-radius:4px;cursor:pointer;font-size:12px;font-family:inherit}
|
|
970
|
+
.clear-filter:hover{color:var(--text);border-color:var(--text-muted)}
|
|
971
|
+
|
|
972
|
+
/* File groups */
|
|
973
|
+
.file-group{background:var(--surface);border:1px solid var(--border-subtle);border-radius:var(--radius-lg);margin-bottom:10px;overflow:hidden}
|
|
974
|
+
.file-group.has-failure{border-color:rgba(239,68,68,.2)}
|
|
975
|
+
.file-group.hidden{display:none}
|
|
976
|
+
.file-header{display:flex;align-items:center;gap:10px;padding:14px 16px;cursor:pointer;user-select:none;transition:background .1s}
|
|
977
|
+
.file-header:hover{background:var(--surface-raised)}
|
|
978
|
+
.file-chevron{font-size:12px;color:var(--text-dim);transition:transform .15s;flex-shrink:0;width:14px;text-align:center}
|
|
979
|
+
.file-group.collapsed .file-chevron{transform:rotate(-90deg)}
|
|
980
|
+
.file-group.collapsed .file-tests{display:none}
|
|
981
|
+
.file-name{font-size:14px;font-weight:600;font-family:var(--mono)}
|
|
982
|
+
.file-badges{display:flex;gap:4px;margin-left:auto}
|
|
983
|
+
.file-badge{font-size:11px;font-weight:600;color:#fff;padding:1px 7px;border-radius:10px;min-width:22px;text-align:center}
|
|
984
|
+
.file-duration{font-size:12px;color:var(--text-dim);flex-shrink:0;min-width:50px;text-align:right}
|
|
985
|
+
|
|
986
|
+
/* Test rows */
|
|
987
|
+
.file-tests{border-top:1px solid var(--border-subtle)}
|
|
988
|
+
.test-row{border-bottom:1px solid var(--border-subtle)}
|
|
989
|
+
.test-row:last-child{border-bottom:none}
|
|
990
|
+
.test-row.hidden-by-filter{display:none}
|
|
991
|
+
.test-summary{display:flex;align-items:center;gap:10px;padding:10px 16px 10px 20px;transition:background .1s}
|
|
992
|
+
.test-row.expandable .test-summary{cursor:pointer}
|
|
993
|
+
.test-row.expandable .test-summary:hover{background:rgba(255,255,255,.02)}
|
|
994
|
+
.chevron{font-size:11px;color:var(--text-dim);transition:transform .15s;flex-shrink:0;width:12px}
|
|
995
|
+
.chevron-spacer{width:12px;flex-shrink:0}
|
|
996
|
+
.test-row.expanded .chevron{transform:rotate(90deg)}
|
|
997
|
+
.status-dot{width:8px;height:8px;border-radius:50%;flex-shrink:0}
|
|
998
|
+
.test-name{font-size:13px;flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
999
|
+
.inline-reason{font-size:11px;font-weight:500;flex-shrink:0;padding:1px 8px;border-radius:4px;background:rgba(255,255,255,.04)}
|
|
1000
|
+
.test-duration{font-size:12px;color:var(--text-dim);flex-shrink:0;font-family:var(--mono)}
|
|
1001
|
+
|
|
1002
|
+
/* Test detail */
|
|
1003
|
+
.test-detail{padding:0 16px 14px 50px;display:none;flex-direction:column;gap:10px}
|
|
1004
|
+
.test-row.expanded .test-detail{display:flex}
|
|
1005
|
+
.detail-label{font-size:11px;font-weight:600;text-transform:uppercase;letter-spacing:.5px;color:var(--text-muted);margin-bottom:6px}
|
|
1006
|
+
.detail-objective{font-size:13px;padding:10px 14px;background:var(--bg);border-radius:var(--radius);border:1px solid var(--border-subtle);white-space:pre-wrap;word-break:break-word}
|
|
1007
|
+
|
|
1008
|
+
/* Failure summary banner — the prominent "why did this fail?" block */
|
|
1009
|
+
.failure-summary-banner{background:rgba(239,68,68,.06);border:1px solid rgba(239,68,68,.18);border-radius:var(--radius-lg);padding:14px 16px}
|
|
1010
|
+
.failure-summary-header{display:flex;align-items:center;gap:8px;margin-bottom:6px}
|
|
1011
|
+
.failure-reason-badge{font-size:12px;font-weight:700;color:#fff;padding:3px 10px;border-radius:6px}
|
|
1012
|
+
.failure-confidence{font-size:12px;color:var(--text-muted)}
|
|
1013
|
+
.source-hint{font-size:11px;color:var(--text-muted);font-weight:400;font-style:italic}
|
|
1014
|
+
.failure-summary-text{font-size:14px;color:var(--text);line-height:1.6}
|
|
1015
|
+
|
|
1016
|
+
/* Healed banner */
|
|
1017
|
+
.healed-banner{display:flex;align-items:center;gap:8px;background:rgba(139,92,246,.08);border:1px solid rgba(139,92,246,.2);border-radius:var(--radius);padding:10px 14px;font-size:13px;color:var(--purple)}
|
|
1018
|
+
.healed-icon{font-size:16px}
|
|
1019
|
+
|
|
1020
|
+
/* Error / snippet blocks */
|
|
1021
|
+
.error-block,.snippet-block,.stack-block,.snapshot-block{font-size:12px;font-family:var(--mono);padding:12px 14px;border-radius:var(--radius);overflow-x:auto;white-space:pre-wrap;word-break:break-word;line-height:1.6;max-height:400px;overflow-y:auto}
|
|
1022
|
+
.error-block{background:rgba(239,68,68,.06);border:1px solid rgba(239,68,68,.15);color:#fca5a5}
|
|
1023
|
+
.snippet-block{background:var(--bg);border:1px solid var(--border-subtle);color:var(--text)}
|
|
1024
|
+
.stack-block{background:var(--bg);border:1px solid var(--border-subtle);color:var(--text-muted);font-size:11px}
|
|
1025
|
+
.snapshot-block{background:var(--bg);border:1px solid var(--border-subtle);color:var(--text-muted);font-size:11px;max-height:300px}
|
|
1026
|
+
.stack-details,.page-snapshot{margin-top:6px}
|
|
1027
|
+
.stack-details summary,.page-snapshot summary{font-size:12px;color:var(--text-dim);cursor:pointer;user-select:none;padding:6px 0}
|
|
1028
|
+
.stack-details summary:hover,.page-snapshot summary:hover{color:var(--text-muted)}
|
|
1029
|
+
.expect-actual{font-size:13px;padding:8px 12px;background:var(--bg);border:1px solid var(--border-subtle);border-radius:var(--radius);margin-top:6px}
|
|
1030
|
+
.expect-actual code{font-family:var(--mono);font-size:12px;color:var(--text)}
|
|
1031
|
+
.expect-label{color:var(--text-muted);font-size:11px;font-weight:600;text-transform:uppercase;letter-spacing:.3px}
|
|
1032
|
+
|
|
1033
|
+
/* Steps */
|
|
1034
|
+
.steps-section{margin:8px 0;border:1px solid var(--border-subtle);border-radius:var(--radius);overflow:hidden}
|
|
1035
|
+
.steps-section summary{font-size:12px;font-weight:600;color:var(--text-muted);padding:8px 12px;cursor:pointer;user-select:none;background:var(--surface-raised)}
|
|
1036
|
+
.steps-section summary:hover{background:var(--surface)}
|
|
1037
|
+
.steps-section[open] summary{border-bottom:1px solid var(--border-subtle)}
|
|
1038
|
+
.steps-list{padding:4px 0}
|
|
1039
|
+
.step-entry{display:flex;gap:8px;padding:4px 12px;font-size:12px;align-items:baseline}
|
|
1040
|
+
.step-time{color:var(--text-dim);font-family:var(--mono);font-size:11px;flex-shrink:0;width:85px}
|
|
1041
|
+
.step-text{color:var(--text-muted);min-width:0}
|
|
1042
|
+
.step-action .step-text{color:var(--text);font-weight:500}
|
|
1043
|
+
.step-result .step-text{color:var(--text-muted)}
|
|
1044
|
+
|
|
1045
|
+
/* Step filmstrip — AI agent tool call screenshots */
|
|
1046
|
+
.step-filmstrip{display:flex;flex-direction:column}
|
|
1047
|
+
.filmstrip-step{border-bottom:1px solid var(--border-subtle);padding:6px 12px}
|
|
1048
|
+
.filmstrip-step:last-child{border-bottom:none}
|
|
1049
|
+
.filmstrip-header{display:flex;align-items:center;gap:8px;user-select:none}
|
|
1050
|
+
.filmstrip-tool{font-size:12px;font-weight:600;color:var(--text);font-family:var(--mono)}
|
|
1051
|
+
.filmstrip-duration{font-size:11px;color:var(--text-dim);font-family:var(--mono)}
|
|
1052
|
+
.filmstrip-toggle{margin-left:auto;font-size:11px;padding:2px 8px;border:1px solid var(--border);border-radius:4px;background:var(--bg-card);color:var(--accent);cursor:pointer;transition:all .15s;font-family:var(--mono)}
|
|
1053
|
+
.filmstrip-toggle:hover{background:var(--accent);color:#fff;border-color:var(--accent)}
|
|
1054
|
+
.filmstrip-summary{font-size:11px;color:var(--text-dim);margin-top:2px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;padding-left:22px}
|
|
1055
|
+
.step-status-ok{color:var(--green);font-size:12px;font-weight:bold}
|
|
1056
|
+
.step-status-fail{color:var(--red);font-size:12px;font-weight:bold}
|
|
1057
|
+
.filmstrip-img{display:none;padding:8px 0 4px 22px}
|
|
1058
|
+
.filmstrip-img.open{display:block}
|
|
1059
|
+
.step-screenshot{max-width:100%;max-height:250px;border-radius:var(--radius);border:1px solid var(--border);cursor:pointer}
|
|
1060
|
+
|
|
1061
|
+
/* Result timeline */
|
|
1062
|
+
.result-timeline{position:relative;padding-left:20px}
|
|
1063
|
+
.timeline-entry{position:relative;padding-bottom:16px;padding-left:16px;border-left:2px solid var(--border)}
|
|
1064
|
+
.timeline-entry.last{border-left-color:transparent}
|
|
1065
|
+
.timeline-marker{position:absolute;left:-6px;top:4px;width:10px;height:10px;border-radius:50%;border:2px solid var(--surface)}
|
|
1066
|
+
.timeline-content{min-width:0}
|
|
1067
|
+
.timeline-header{display:flex;align-items:center;gap:10px;margin-bottom:6px}
|
|
1068
|
+
.timeline-label{font-size:12px;font-weight:600;color:var(--text-muted);text-transform:uppercase;letter-spacing:.3px}
|
|
1069
|
+
.timeline-status{font-size:13px;font-weight:600}
|
|
1070
|
+
.timeline-duration{font-size:12px;color:var(--text-dim);font-family:var(--mono)}
|
|
1071
|
+
.timeline-errors{margin-bottom:8px}
|
|
1072
|
+
|
|
1073
|
+
/* Attachments */
|
|
1074
|
+
.attachments-group{display:flex;flex-direction:column;gap:8px}
|
|
1075
|
+
.attachments-images{display:flex;gap:8px;flex-wrap:wrap}
|
|
1076
|
+
.attachments-meta{display:flex;gap:8px;flex-wrap:wrap;align-items:flex-start}
|
|
1077
|
+
.img-wrapper{display:inline-flex;flex-direction:column;gap:4px}
|
|
1078
|
+
.img-label{font-size:11px;color:var(--text-dim);font-style:italic}
|
|
1079
|
+
.screenshot{max-width:100%;max-height:300px;border-radius:var(--radius);border:1px solid var(--border);cursor:pointer;transition:opacity .15s}
|
|
1080
|
+
.screenshot:hover{opacity:.9}
|
|
1081
|
+
.page-snapshot{width:100%}
|
|
1082
|
+
.img-link{display:block}
|
|
1083
|
+
.attachment-link{display:inline-flex;align-items:center;gap:4px;font-size:12px;color:var(--purple);text-decoration:none;padding:4px 10px;background:var(--surface-raised);border:1px solid var(--border-subtle);border-radius:var(--radius)}
|
|
1084
|
+
.attachment-link:hover{border-color:var(--purple);background:rgba(139,92,246,.06)}
|
|
1085
|
+
.video-link{color:var(--amber)}
|
|
1086
|
+
.video-link:hover{border-color:var(--amber)}
|
|
1087
|
+
.attachment-missing{font-size:12px;color:var(--text-dim);font-style:italic;padding:4px 10px;background:var(--surface-raised);border:1px solid var(--border-subtle);border-radius:var(--radius)}
|
|
1088
|
+
|
|
1089
|
+
/* Triage card (expandable) */
|
|
1090
|
+
.triage-card{margin:8px 0;border:1px solid var(--border);border-radius:var(--radius-lg);overflow:hidden}
|
|
1091
|
+
.triage-card summary{font-size:12px;font-weight:600;color:var(--text-muted);padding:10px 14px;cursor:pointer;user-select:none;background:var(--bg);display:flex;align-items:center;gap:8px;flex-wrap:wrap}
|
|
1092
|
+
.triage-card summary:hover{background:var(--surface-raised)}
|
|
1093
|
+
.triage-card[open] summary{border-bottom:1px solid var(--border-subtle)}
|
|
1094
|
+
.triage-card-body{padding:12px 16px}
|
|
1095
|
+
.triage-flags{display:flex;gap:4px}
|
|
1096
|
+
.triage-flag{font-size:10px;font-weight:600;padding:2px 8px;border-radius:4px;text-transform:uppercase;letter-spacing:.3px}
|
|
1097
|
+
.triage-flag.retry{background:rgba(16,185,129,.1);color:var(--green);border:1px solid rgba(16,185,129,.2)}
|
|
1098
|
+
.triage-flag.code{background:rgba(245,158,11,.1);color:var(--amber);border:1px solid rgba(245,158,11,.2)}
|
|
1099
|
+
.triage-flag.product{background:rgba(239,68,68,.1);color:var(--red);border:1px solid rgba(239,68,68,.2)}
|
|
1100
|
+
.triage-indicators{display:flex;gap:4px;flex-wrap:wrap;margin-bottom:8px}
|
|
1101
|
+
.indicator-tag{font-size:11px;padding:2px 8px;border-radius:4px;background:var(--surface-raised);color:var(--text-muted);border:1px solid var(--border-subtle)}
|
|
1102
|
+
.indicator-tag.warn{background:rgba(245,158,11,.08);color:var(--amber);border-color:rgba(245,158,11,.2)}
|
|
1103
|
+
.remediation-steps{margin-top:8px}
|
|
1104
|
+
.remediation-steps ol{padding-left:18px;font-size:13px;color:var(--text)}
|
|
1105
|
+
.remediation-steps li{margin-bottom:8px;line-height:1.5}
|
|
1106
|
+
.step-category{font-size:10px;font-weight:600;padding:1px 6px;border-radius:3px;background:var(--surface-raised);color:var(--text-muted);text-transform:uppercase;letter-spacing:.3px;vertical-align:middle}
|
|
1107
|
+
.step-details{font-size:12px;color:var(--text-muted);display:block;margin-top:2px}
|
|
1108
|
+
.triage-notes{margin-top:8px}
|
|
1109
|
+
.triage-notes p{font-size:13px;color:var(--text-muted)}
|
|
1110
|
+
|
|
1111
|
+
/* Evidence deep-dive */
|
|
1112
|
+
.evidence-deep-dive{margin:8px 0;border:1px solid var(--border-subtle);border-radius:var(--radius);overflow:hidden}
|
|
1113
|
+
.evidence-deep-dive summary{font-size:12px;font-weight:600;color:var(--text-muted);padding:10px 14px;cursor:pointer;user-select:none;background:var(--surface-raised);transition:background .1s}
|
|
1114
|
+
.evidence-deep-dive summary:hover{background:var(--surface)}
|
|
1115
|
+
.evidence-deep-dive[open] summary{border-bottom:1px solid var(--border-subtle)}
|
|
1116
|
+
.evidence-section{padding:12px 14px}
|
|
1117
|
+
.evidence-section+.evidence-section{border-top:1px solid var(--border-subtle)}
|
|
1118
|
+
.heuristic-grid{display:flex;flex-wrap:wrap;gap:8px}
|
|
1119
|
+
.h-item{padding:8px 10px;background:var(--bg);border-radius:var(--radius);border:1px solid var(--border-subtle);min-width:120px}
|
|
1120
|
+
.h-item.full-width{width:100%}
|
|
1121
|
+
.h-label{font-size:10px;font-weight:600;text-transform:uppercase;letter-spacing:.5px;color:var(--text-dim);display:block;margin-bottom:2px}
|
|
1122
|
+
.h-value{font-size:13px;color:var(--text)}
|
|
1123
|
+
.h-evidence{margin-top:8px}
|
|
1124
|
+
.h-evidence-item{font-size:12px;color:var(--text-muted);padding:3px 0;padding-left:12px;position:relative}
|
|
1125
|
+
.h-evidence-item::before{content:'\\2022';position:absolute;left:0;color:var(--text-dim)}
|
|
1126
|
+
.tool-calls{display:flex;flex-direction:column;gap:3px}
|
|
1127
|
+
.tool-call{font-size:12px;font-family:var(--mono);color:var(--text-muted);padding:4px 8px;background:var(--bg);border-radius:4px;display:flex;align-items:center;gap:6px}
|
|
1128
|
+
.tc-name{color:var(--text)}
|
|
1129
|
+
.tc-ok{color:var(--green);font-weight:bold}
|
|
1130
|
+
.tc-fail{color:var(--red);font-weight:bold}
|
|
1131
|
+
|
|
1132
|
+
/* Healed summary */
|
|
1133
|
+
.healed-summary-section{background:var(--surface);border:1px solid rgba(139,92,246,.2);border-radius:var(--radius-lg);padding:18px 20px;margin-top:24px}
|
|
1134
|
+
.healed-summary-section h3{font-size:14px;font-weight:600;color:var(--purple);margin-bottom:10px}
|
|
1135
|
+
.healed-list{list-style:none}
|
|
1136
|
+
.healed-list li{font-size:13px;padding:4px 0 4px 18px;position:relative}
|
|
1137
|
+
.healed-list li::before{content:'\\2764';position:absolute;left:0;color:var(--purple);font-size:11px}
|
|
1138
|
+
|
|
1139
|
+
/* Quick Actions */
|
|
1140
|
+
.quick-actions{padding:12px;background:var(--bg);border:1px solid var(--border-subtle);border-radius:var(--radius)}
|
|
1141
|
+
.qa-header{font-size:12px;font-weight:700;text-transform:uppercase;letter-spacing:.5px;color:var(--text-dim);margin-bottom:8px}
|
|
1142
|
+
.qa-row{display:flex;align-items:center;gap:8px;margin-bottom:6px}
|
|
1143
|
+
.qa-label{font-size:12px;color:var(--text-muted);white-space:nowrap;min-width:140px}
|
|
1144
|
+
.qa-cmd-wrapper{display:flex;align-items:center;gap:6px;flex:1;min-width:0}
|
|
1145
|
+
.qa-cmd{font-size:12px;font-family:var(--mono);background:var(--bg-card);padding:5px 10px;border-radius:4px;border:1px solid var(--border-subtle);color:var(--text);white-space:nowrap;overflow-x:auto;flex:1;min-width:0}
|
|
1146
|
+
.qa-copy{font-size:11px;padding:3px 8px;border:1px solid var(--border);border-radius:4px;background:var(--bg-card);color:var(--text-muted);cursor:pointer;white-space:nowrap;transition:all .15s}
|
|
1147
|
+
.qa-copy:hover{background:var(--accent);color:#fff;border-color:var(--accent)}
|
|
1148
|
+
.qa-source{font-size:12px;color:var(--text-dim);margin-top:6px;padding-top:6px;border-top:1px solid var(--border-subtle)}
|
|
1149
|
+
.qa-source-label{font-weight:600}
|
|
1150
|
+
|
|
1151
|
+
/* Footer */
|
|
1152
|
+
.report-footer{text-align:center;font-size:12px;color:var(--text-dim);margin-top:32px;padding-top:16px;border-top:1px solid var(--border-subtle)}
|
|
1153
|
+
|
|
1154
|
+
/* Lightbox */
|
|
1155
|
+
.lightbox{display:none;position:fixed;inset:0;background:rgba(0,0,0,.85);z-index:1000;align-items:center;justify-content:center;cursor:zoom-out}
|
|
1156
|
+
.lightbox.open{display:flex}
|
|
1157
|
+
.lightbox img{max-width:95vw;max-height:95vh;border-radius:var(--radius)}
|
|
1158
|
+
|
|
1159
|
+
/* Print */
|
|
1160
|
+
@media print{
|
|
1161
|
+
body{background:#fff;color:#111}
|
|
1162
|
+
.stat-card,.file-group{border:1px solid #ddd}
|
|
1163
|
+
.test-detail{display:block!important}
|
|
1164
|
+
.screenshot{max-height:200px}
|
|
1165
|
+
.lightbox{display:none!important}
|
|
1166
|
+
}
|
|
1167
|
+
</style>
|
|
1168
|
+
</head>
|
|
1169
|
+
<body>
|
|
1170
|
+
<div class="container">
|
|
1171
|
+
<div class="report-header">
|
|
1172
|
+
<div class="report-title"><div class="logo">D</div>Donobu Test Report</div>
|
|
1173
|
+
<div class="report-meta">${total} test${total !== 1 ? 's' : ''} · ${fmtDuration(totalDuration)}</div>
|
|
1174
|
+
</div>
|
|
1175
|
+
|
|
1176
|
+
${mergedBanner}
|
|
1177
|
+
|
|
1178
|
+
<div class="overall-status ${overallClass}">
|
|
1179
|
+
${overallText}
|
|
1180
|
+
<div class="overall-sub">${total} test${total !== 1 ? 's' : ''} across ${fileGroups.size} file${fileGroups.size !== 1 ? 's' : ''}</div>
|
|
1181
|
+
</div>
|
|
1182
|
+
|
|
1183
|
+
<div class="stat-cards">${statCardsHtml}</div>
|
|
1184
|
+
|
|
1185
|
+
${triageSummaryHtml}
|
|
1186
|
+
|
|
1187
|
+
<div class="filter-bar" id="filterBar">
|
|
1188
|
+
<span>Showing: <strong id="filterLabel"></strong></span>
|
|
1189
|
+
<button class="clear-filter" onclick="clearFilter()">Clear filter</button>
|
|
1190
|
+
</div>
|
|
1191
|
+
|
|
1192
|
+
${testSectionsHtml}
|
|
1193
|
+
${healedListHtml}
|
|
1194
|
+
|
|
1195
|
+
<div class="report-footer">Report generated by Donobu</div>
|
|
1196
|
+
</div>
|
|
1197
|
+
|
|
1198
|
+
<div class="lightbox" id="lightbox" onclick="closeLightbox()">
|
|
1199
|
+
<img id="lightboxImg" src="" alt="Screenshot" />
|
|
1200
|
+
</div>
|
|
1201
|
+
|
|
1202
|
+
<script>
|
|
1203
|
+
function toggleDetail(id,e){if(e&&e.target.closest('.test-detail'))return;var el=document.getElementById(id);if(el){var row=el.closest('.test-row');if(row)row.classList.toggle('expanded')}}
|
|
1204
|
+
function toggleFileGroup(h){h.closest('.file-group').classList.toggle('collapsed')}
|
|
1205
|
+
|
|
1206
|
+
var activeFilter=null;
|
|
1207
|
+
function filterByStatus(s){
|
|
1208
|
+
if(activeFilter===s){clearFilter();return}
|
|
1209
|
+
activeFilter=s;
|
|
1210
|
+
document.querySelectorAll('.stat-card').forEach(function(c){c.classList.toggle('active-filter',c.getAttribute('data-filter')===s)});
|
|
1211
|
+
document.getElementById('filterBar').classList.add('visible');
|
|
1212
|
+
document.getElementById('filterLabel').textContent=s.charAt(0).toUpperCase()+s.slice(1);
|
|
1213
|
+
document.querySelectorAll('.test-row').forEach(function(r){r.classList.toggle('hidden-by-filter',r.getAttribute('data-status')!==s)});
|
|
1214
|
+
document.querySelectorAll('.file-group').forEach(function(g){
|
|
1215
|
+
var vis=g.querySelectorAll('.test-row:not(.hidden-by-filter)');
|
|
1216
|
+
g.classList.toggle('hidden',vis.length===0);
|
|
1217
|
+
g.classList.remove('collapsed');
|
|
1218
|
+
});
|
|
1219
|
+
}
|
|
1220
|
+
function clearFilter(){
|
|
1221
|
+
activeFilter=null;
|
|
1222
|
+
document.querySelectorAll('.stat-card').forEach(function(c){c.classList.remove('active-filter')});
|
|
1223
|
+
document.getElementById('filterBar').classList.remove('visible');
|
|
1224
|
+
document.querySelectorAll('.test-row').forEach(function(r){r.classList.remove('hidden-by-filter')});
|
|
1225
|
+
document.querySelectorAll('.file-group').forEach(function(g){g.classList.remove('hidden')});
|
|
1226
|
+
}
|
|
1227
|
+
|
|
1228
|
+
// Screenshot lightbox
|
|
1229
|
+
document.addEventListener('click',function(e){
|
|
1230
|
+
var img=e.target.closest('.screenshot');
|
|
1231
|
+
if(img){
|
|
1232
|
+
e.preventDefault();e.stopPropagation();
|
|
1233
|
+
var src=img.closest('a')?img.closest('a').href:img.src;
|
|
1234
|
+
document.getElementById('lightboxImg').src=src;
|
|
1235
|
+
document.getElementById('lightbox').classList.add('open');
|
|
1236
|
+
}
|
|
1237
|
+
});
|
|
1238
|
+
function closeLightbox(){document.getElementById('lightbox').classList.remove('open')}
|
|
1239
|
+
document.addEventListener('keydown',function(e){if(e.key==='Escape'){closeLightbox();clearFilter()}});
|
|
1240
|
+
|
|
1241
|
+
// Auto-expand failed/timedout/interrupted/healed tests
|
|
1242
|
+
document.querySelectorAll('.test-row.failed,.test-row.timedout,.test-row.interrupted,.test-row.healed').forEach(function(r){r.classList.add('expanded')});
|
|
1243
|
+
</script>
|
|
1244
|
+
</body>
|
|
1245
|
+
</html>`;
|
|
1246
|
+
}
|
|
1247
|
+
// ---------------------------------------------------------------------------
|
|
1248
|
+
// Main
|
|
1249
|
+
// ---------------------------------------------------------------------------
|
|
1250
|
+
try {
|
|
1251
|
+
const { jsonData, outputFile, triageDir } = parseArgs();
|
|
1252
|
+
// Auto-discover triage dir from merged report metadata if not explicitly given
|
|
1253
|
+
let resolvedTriageDir = triageDir;
|
|
1254
|
+
if (!resolvedTriageDir && jsonData.metadata?.sources?.initial) {
|
|
1255
|
+
const initialPath = jsonData.metadata.sources.initial;
|
|
1256
|
+
const candidate = (0, path_1.dirname)(initialPath);
|
|
1257
|
+
if ((0, fs_1.existsSync)(candidate) && (0, path_1.basename)(candidate) !== '.') {
|
|
1258
|
+
resolvedTriageDir = candidate;
|
|
1259
|
+
}
|
|
1260
|
+
}
|
|
1261
|
+
const triage = resolvedTriageDir
|
|
1262
|
+
? loadTriageData(resolvedTriageDir)
|
|
1263
|
+
: { plans: [], evidence: [] };
|
|
1264
|
+
const outputDir = outputFile ? (0, path_1.dirname)((0, path_1.resolve)(outputFile)) : null;
|
|
1265
|
+
const html = generateHtml(jsonData, triage, outputDir);
|
|
1266
|
+
if (outputFile) {
|
|
1267
|
+
(0, fs_1.writeFileSync)(outputFile, html, 'utf8');
|
|
1268
|
+
console.error(`Report written to ${outputFile}`);
|
|
1269
|
+
}
|
|
1270
|
+
else {
|
|
1271
|
+
console.log(html);
|
|
1272
|
+
}
|
|
1273
|
+
}
|
|
1274
|
+
catch (error) {
|
|
1275
|
+
console.error('Error processing JSON:', error.message);
|
|
1276
|
+
process.exit(1);
|
|
1277
|
+
}
|
|
1278
|
+
//# sourceMappingURL=playwright-json-to-html.js.map
|