reporting-labs 0.1.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/LICENSE +21 -0
- package/README.md +247 -0
- package/bin/cli.js +32 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.js +15 -0
- package/dist/mask.d.ts +9 -0
- package/dist/mask.js +51 -0
- package/dist/meta.d.ts +43 -0
- package/dist/meta.js +70 -0
- package/dist/reporter.d.ts +37 -0
- package/dist/reporter.js +504 -0
- package/dist/template.d.ts +4 -0
- package/dist/template.js +1383 -0
- package/dist/types.d.ts +281 -0
- package/dist/types.js +2 -0
- package/fonts/mono-400.woff2 +0 -0
- package/fonts/mono-500.woff2 +0 -0
- package/fonts/sans-400.woff2 +0 -0
- package/fonts/sans-500.woff2 +0 -0
- package/fonts/sans-600.woff2 +0 -0
- package/package.json +69 -0
package/dist/reporter.js
ADDED
|
@@ -0,0 +1,504 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
35
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
+
const fs = __importStar(require("fs"));
|
|
37
|
+
const path = __importStar(require("path"));
|
|
38
|
+
const os = __importStar(require("os"));
|
|
39
|
+
const child_process_1 = require("child_process");
|
|
40
|
+
const template_1 = require("./template");
|
|
41
|
+
const mask_1 = require("./mask");
|
|
42
|
+
const DEFAULT_EMBED_LIMIT = 2 * 1024 * 1024;
|
|
43
|
+
class ReportingLabsReporter {
|
|
44
|
+
constructor(options = {}) {
|
|
45
|
+
this.startTime = Date.now();
|
|
46
|
+
this.outDir = '';
|
|
47
|
+
this.assetsDir = '';
|
|
48
|
+
this.assetCounter = 0;
|
|
49
|
+
this.masker = (0, mask_1.makeMasker)();
|
|
50
|
+
this.globalErrors = [];
|
|
51
|
+
this.globalOutput = [];
|
|
52
|
+
this.options = options;
|
|
53
|
+
this.masker = (0, mask_1.makeMasker)(options.maskKeys ?? []);
|
|
54
|
+
}
|
|
55
|
+
printsToStdio() { return false; }
|
|
56
|
+
/** Errors outside tests: a spec that throws at load, global setup, a crashed worker. */
|
|
57
|
+
onError(error) { this.globalErrors.push(this.serializeError(error)); }
|
|
58
|
+
onStdOut(chunk, test) { if (!test)
|
|
59
|
+
this.pushOutput('out', chunk); }
|
|
60
|
+
onStdErr(chunk, test) { if (!test)
|
|
61
|
+
this.pushOutput('err', chunk); }
|
|
62
|
+
pushOutput(stream, chunk) {
|
|
63
|
+
if (this.globalOutput.length >= 200)
|
|
64
|
+
return;
|
|
65
|
+
const text = stripAnsi(chunk.toString()).slice(0, 2000);
|
|
66
|
+
if (text.trim())
|
|
67
|
+
this.globalOutput.push({ stream, text });
|
|
68
|
+
}
|
|
69
|
+
onBegin(config, suite) {
|
|
70
|
+
this.config = config;
|
|
71
|
+
this.suite = suite;
|
|
72
|
+
this.startTime = Date.now();
|
|
73
|
+
const base = config.configFile ? path.dirname(config.configFile) : process.cwd();
|
|
74
|
+
this.outDir = path.resolve(base, this.options.outputFolder ?? 'reporting-labs');
|
|
75
|
+
this.assetsDir = path.join(this.outDir, 'assets');
|
|
76
|
+
fs.rmSync(this.outDir, { recursive: true, force: true });
|
|
77
|
+
fs.mkdirSync(this.assetsDir, { recursive: true });
|
|
78
|
+
}
|
|
79
|
+
async onEnd(result) {
|
|
80
|
+
const tests = [];
|
|
81
|
+
const projects = new Set();
|
|
82
|
+
let workers = 0;
|
|
83
|
+
for (const test of this.suite.allTests()) {
|
|
84
|
+
const project = test.parent.project()?.name ?? '';
|
|
85
|
+
projects.add(project);
|
|
86
|
+
const results = test.results.map(r => {
|
|
87
|
+
workers = Math.max(workers, r.parallelIndex + 1);
|
|
88
|
+
return this.serializeResult(r, test);
|
|
89
|
+
});
|
|
90
|
+
const { outcome, expectedFailure, note } = this.outcome(test);
|
|
91
|
+
const titlePath = this.titlePath(test);
|
|
92
|
+
const file = this.rel(test.location.file);
|
|
93
|
+
const last = test.results[test.results.length - 1];
|
|
94
|
+
const resultAnn = (last?.annotations ?? []);
|
|
95
|
+
const annotations = [...test.annotations, ...resultAnn.filter(a => !test.annotations.some(b => b.type === a.type && b.description === a.description))];
|
|
96
|
+
tests.push({
|
|
97
|
+
id: test.id,
|
|
98
|
+
key: `${project}::${file}::${[...titlePath, test.title].join(' › ')}${test.repeatEachIndex ? ' #' + (test.repeatEachIndex + 1) : ''}`,
|
|
99
|
+
title: test.title,
|
|
100
|
+
path: titlePath,
|
|
101
|
+
file,
|
|
102
|
+
line: test.location.line,
|
|
103
|
+
column: test.location.column,
|
|
104
|
+
project,
|
|
105
|
+
tags: test.tags,
|
|
106
|
+
annotations,
|
|
107
|
+
meta: this.extractMeta(test),
|
|
108
|
+
outcome,
|
|
109
|
+
expectedFailure,
|
|
110
|
+
note,
|
|
111
|
+
expectedStatus: test.expectedStatus,
|
|
112
|
+
timeout: test.timeout,
|
|
113
|
+
retries: test.retries,
|
|
114
|
+
duration: results.reduce((a, r) => a + r.duration, 0),
|
|
115
|
+
results,
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
const stats = { passed: 0, failed: 0, skipped: 0, flaky: 0, timedOut: 0, interrupted: 0, total: tests.length };
|
|
119
|
+
for (const t of tests)
|
|
120
|
+
stats[t.outcome]++;
|
|
121
|
+
const base = this.config.configFile ? path.dirname(this.config.configFile) : process.cwd();
|
|
122
|
+
const histOn = this.options.history?.enabled ?? true;
|
|
123
|
+
const { file: histFile, entries } = histOn ? this.loadHistory(base) : { file: '', entries: [] };
|
|
124
|
+
const failedCount = stats.failed + stats.timedOut + stats.interrupted;
|
|
125
|
+
const code = (o) => o === 'passed' ? 'p' : o === 'flaky' ? 'k' : o === 'skipped' ? 's' : 'f';
|
|
126
|
+
const perTest = {};
|
|
127
|
+
for (const t of tests) {
|
|
128
|
+
const last = t.results[t.results.length - 1];
|
|
129
|
+
perTest[t.key] = [code(t.outcome), Math.round(last?.duration ?? t.duration)];
|
|
130
|
+
}
|
|
131
|
+
const current = { time: this.startTime, duration: result.duration ?? Date.now() - this.startTime, passed: stats.passed, failed: failedCount, flaky: stats.flaky, skipped: stats.skipped, total: stats.total, label: this.options.metadata?.build ?? ciRunLabel(process.env) ?? this.options.metadata?.branch, tests: perTest };
|
|
132
|
+
const history = [...entries, current].slice(-(this.options.history?.keep ?? 30));
|
|
133
|
+
if (histOn) {
|
|
134
|
+
try {
|
|
135
|
+
fs.writeFileSync(histFile, JSON.stringify(history, null, 1));
|
|
136
|
+
}
|
|
137
|
+
catch { /* read-only fs */ }
|
|
138
|
+
}
|
|
139
|
+
const bdd = this.options.bdd ?? tests.some(t => t.results.some(r => r.steps.some(st => /^(Given|When|Then|And|But)\b/.test(st.title))));
|
|
140
|
+
const data = {
|
|
141
|
+
title: this.options.title ?? 'Test report',
|
|
142
|
+
generatedAt: Date.now(),
|
|
143
|
+
startTime: this.startTime,
|
|
144
|
+
duration: result.duration ?? Date.now() - this.startTime,
|
|
145
|
+
metadata: this.options.metadata ?? {},
|
|
146
|
+
projects: [...projects],
|
|
147
|
+
workers,
|
|
148
|
+
stats,
|
|
149
|
+
tests,
|
|
150
|
+
history,
|
|
151
|
+
bdd,
|
|
152
|
+
rootDir: base,
|
|
153
|
+
env: this.collectEnv(base),
|
|
154
|
+
runStatus: result.status,
|
|
155
|
+
globalErrors: this.globalErrors,
|
|
156
|
+
globalOutput: this.globalOutput,
|
|
157
|
+
shard: this.config.shard ? { current: this.config.shard.current, total: this.config.shard.total } : undefined,
|
|
158
|
+
options: {
|
|
159
|
+
logo: this.options.logo,
|
|
160
|
+
accent: this.options.accent,
|
|
161
|
+
theme: this.options.theme ?? 'auto',
|
|
162
|
+
palette: this.options.palette ?? 'lab',
|
|
163
|
+
embedFonts: this.options.embedFonts ?? true,
|
|
164
|
+
sections: this.options.sections ?? [],
|
|
165
|
+
widgets: {
|
|
166
|
+
runStrip: this.options.widgets?.runStrip ?? true,
|
|
167
|
+
outcome: this.options.widgets?.outcome ?? true,
|
|
168
|
+
attention: this.options.widgets?.attention ?? true,
|
|
169
|
+
dimensions: this.options.widgets?.dimensions ?? true,
|
|
170
|
+
timeline: this.options.widgets?.timeline ?? true,
|
|
171
|
+
durations: this.options.widgets?.durations ?? true,
|
|
172
|
+
tags: this.options.widgets?.tags ?? true,
|
|
173
|
+
slowest: this.options.widgets?.slowest ?? true,
|
|
174
|
+
projects: this.options.widgets?.projects ?? true,
|
|
175
|
+
flaky: this.options.widgets?.flaky ?? true,
|
|
176
|
+
environment: this.options.widgets?.environment ?? true,
|
|
177
|
+
skipped: this.options.widgets?.skipped ?? true,
|
|
178
|
+
},
|
|
179
|
+
dimensions: this.dimensions(),
|
|
180
|
+
dimensionOrder: {
|
|
181
|
+
priority: ['P0', 'P1', 'P2', 'P3', 'P4'],
|
|
182
|
+
severity: ['blocker', 'critical', 'major', 'high', 'medium', 'normal', 'minor', 'low', 'trivial'],
|
|
183
|
+
...(this.options.dimensionOrder ?? {}),
|
|
184
|
+
},
|
|
185
|
+
project: this.options.project,
|
|
186
|
+
links: this.options.links ?? {},
|
|
187
|
+
customCss: this.options.customCss ?? '',
|
|
188
|
+
editorLinks: this.options.editorLinks ?? !process.env.CI,
|
|
189
|
+
},
|
|
190
|
+
};
|
|
191
|
+
const file = path.join(this.outDir, this.options.outputFile ?? 'index.html');
|
|
192
|
+
fs.writeFileSync(file, (0, template_1.renderHtml)(data), 'utf8');
|
|
193
|
+
if (this.options.announce !== false) {
|
|
194
|
+
const rel = path.relative(process.cwd(), file);
|
|
195
|
+
console.log(`\n reporting-labs: report written to ${rel}\n`);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
// ---- helpers -------------------------------------------------------------
|
|
199
|
+
/** Runtime facts for the Environment card: Playwright, Node, OS, browsers, CI job, git commit. */
|
|
200
|
+
collectEnv(base) {
|
|
201
|
+
const rows = [];
|
|
202
|
+
const env = process.env;
|
|
203
|
+
let pw = '';
|
|
204
|
+
try {
|
|
205
|
+
pw = require('@playwright/test/package.json').version;
|
|
206
|
+
}
|
|
207
|
+
catch { /* not resolvable */ }
|
|
208
|
+
if (pw)
|
|
209
|
+
rows.push({ k: 'Playwright', v: pw });
|
|
210
|
+
rows.push({ k: 'Node', v: process.version });
|
|
211
|
+
rows.push({ k: 'OS', v: `${os.type()} ${os.release()} (${os.arch()})` });
|
|
212
|
+
const browsers = [...new Set(this.config.projects.map(p => { const u = p.use ?? {}; return [u.browserName, u.channel].filter(Boolean).join('/') || (u.defaultBrowserType ?? ''); }).filter(Boolean))];
|
|
213
|
+
if (browsers.length)
|
|
214
|
+
rows.push({ k: 'Browsers', v: browsers.join(', ') });
|
|
215
|
+
if (this.config.shard)
|
|
216
|
+
rows.push({ k: 'Shard', v: `${this.config.shard.current} of ${this.config.shard.total}` });
|
|
217
|
+
if (this.config.workers)
|
|
218
|
+
rows.push({ k: 'Workers', v: String(this.config.workers) });
|
|
219
|
+
const ci = ciLink(env);
|
|
220
|
+
if (ci)
|
|
221
|
+
rows.push({ k: 'CI', v: ci.name, href: ci.url });
|
|
222
|
+
const git = gitInfo(base, env);
|
|
223
|
+
if (git.sha)
|
|
224
|
+
rows.push({ k: 'Commit', v: `${git.sha.slice(0, 7)}${git.author ? ' · ' + git.author : ''}${git.subject ? ' · ' + git.subject : ''}`, href: git.url });
|
|
225
|
+
if (git.branch && !this.options.metadata?.branch)
|
|
226
|
+
rows.push({ k: 'Branch', v: git.branch });
|
|
227
|
+
for (const [k, v] of Object.entries(this.options.env ?? {}))
|
|
228
|
+
rows.push({ k, v: String(v), href: /^https?:\/\//.test(String(v)) ? String(v) : undefined });
|
|
229
|
+
return rows;
|
|
230
|
+
}
|
|
231
|
+
toDataBlock(name, raw) {
|
|
232
|
+
let v;
|
|
233
|
+
try {
|
|
234
|
+
v = JSON.parse(raw);
|
|
235
|
+
}
|
|
236
|
+
catch {
|
|
237
|
+
return { name, kind: 'text', text: this.masker.maskStr(raw) };
|
|
238
|
+
}
|
|
239
|
+
if (v && typeof v === 'object' && typeof v.csv === 'string' && Object.keys(v).length === 1) {
|
|
240
|
+
const { columns, rows } = (0, mask_1.parseCsv)(v.csv);
|
|
241
|
+
const masked = rows.map(r => r.map((c, i) => this.masker.isSensitive(columns[i] ?? '') ? '****' : this.masker.maskStr(c)));
|
|
242
|
+
return { name, kind: 'table', columns, rows: masked };
|
|
243
|
+
}
|
|
244
|
+
v = this.masker.mask(v);
|
|
245
|
+
if (Array.isArray(v) && v.length && v.every(x => x && typeof x === 'object' && !Array.isArray(x))) {
|
|
246
|
+
const columns = [...new Set(v.flatMap((x) => Object.keys(x)))];
|
|
247
|
+
return { name, kind: 'table', columns, rows: v.map((x) => columns.map(c => fmt(x[c]))) };
|
|
248
|
+
}
|
|
249
|
+
if (v && typeof v === 'object' && !Array.isArray(v))
|
|
250
|
+
return { name, kind: 'kv', kv: Object.entries(v).map(([k, x]) => [k, fmt(x)]) };
|
|
251
|
+
return { name, kind: 'text', text: JSON.stringify(v, null, 2) };
|
|
252
|
+
}
|
|
253
|
+
loadHistory(base) {
|
|
254
|
+
const file = path.resolve(base, this.options.history?.file ?? 'reporting-labs.history.json');
|
|
255
|
+
let entries = [];
|
|
256
|
+
try {
|
|
257
|
+
entries = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
258
|
+
}
|
|
259
|
+
catch { /* first run */ }
|
|
260
|
+
return { file, entries };
|
|
261
|
+
}
|
|
262
|
+
dimensions() {
|
|
263
|
+
return (this.options.dimensions ?? ['priority', 'severity', 'feature', 'owner']).map(d => d.toLowerCase());
|
|
264
|
+
}
|
|
265
|
+
/** Pull dimension values from annotations and tags. */
|
|
266
|
+
extractMeta(test) {
|
|
267
|
+
const dims = this.dimensions();
|
|
268
|
+
const meta = {};
|
|
269
|
+
for (const a of test.annotations) {
|
|
270
|
+
const k = a.type.toLowerCase();
|
|
271
|
+
if (dims.includes(k) && a.description)
|
|
272
|
+
meta[k] = a.description;
|
|
273
|
+
}
|
|
274
|
+
for (const raw of test.tags) {
|
|
275
|
+
const tag = raw.replace(/^@/, '');
|
|
276
|
+
const m = tag.match(/^([a-z_-]+)[:=](.+)$/i);
|
|
277
|
+
if (m && dims.includes(m[1].toLowerCase())) {
|
|
278
|
+
meta[m[1].toLowerCase()] = m[2];
|
|
279
|
+
continue;
|
|
280
|
+
}
|
|
281
|
+
if (/^P[0-4]$/i.test(tag) && dims.includes('priority') && !meta.priority)
|
|
282
|
+
meta.priority = tag.toUpperCase();
|
|
283
|
+
if (/^(blocker|critical|major|minor|trivial)$/i.test(tag) && dims.includes('severity') && !meta.severity)
|
|
284
|
+
meta.severity = tag.toLowerCase();
|
|
285
|
+
}
|
|
286
|
+
return meta;
|
|
287
|
+
}
|
|
288
|
+
rel(file) {
|
|
289
|
+
const base = this.config.configFile ? path.dirname(this.config.configFile) : this.config.rootDir;
|
|
290
|
+
return path.relative(base, file).split(path.sep).join('/');
|
|
291
|
+
}
|
|
292
|
+
serializeError(e) {
|
|
293
|
+
const out = { message: stripAnsi(e.message ?? e.value ?? '') };
|
|
294
|
+
if (e.stack)
|
|
295
|
+
out.stack = stripAnsi(e.stack);
|
|
296
|
+
if (e.snippet)
|
|
297
|
+
out.snippet = stripAnsi(e.snippet);
|
|
298
|
+
if (e.location)
|
|
299
|
+
out.location = { file: this.rel(e.location.file), line: e.location.line, column: e.location.column };
|
|
300
|
+
return out;
|
|
301
|
+
}
|
|
302
|
+
outcome(test) {
|
|
303
|
+
const o = test.outcome();
|
|
304
|
+
const last = test.results[test.results.length - 1];
|
|
305
|
+
const xfail = test.expectedStatus === 'failed';
|
|
306
|
+
if (o === 'expected') {
|
|
307
|
+
if (test.results.some(r => r.status === 'skipped') && test.results.length === 1)
|
|
308
|
+
return { outcome: 'skipped' };
|
|
309
|
+
if (xfail)
|
|
310
|
+
return { outcome: 'passed', expectedFailure: true, note: 'Failed as expected: this test is marked test.fail(). The failure below is the known one.' };
|
|
311
|
+
return { outcome: 'passed' };
|
|
312
|
+
}
|
|
313
|
+
if (o === 'skipped')
|
|
314
|
+
return { outcome: 'skipped' };
|
|
315
|
+
if (o === 'flaky')
|
|
316
|
+
return { outcome: 'flaky' };
|
|
317
|
+
if (!last)
|
|
318
|
+
return { outcome: 'interrupted', note: 'This test never ran: the run was interrupted before it started.' };
|
|
319
|
+
if (last.status === 'timedOut')
|
|
320
|
+
return { outcome: 'timedOut', note: `Exceeded the ${test.timeout >= 1000 ? (test.timeout / 1000).toFixed(test.timeout % 1000 ? 1 : 0) + 's' : test.timeout + 'ms'} timeout.` };
|
|
321
|
+
if (last.status === 'interrupted')
|
|
322
|
+
return { outcome: 'interrupted', note: 'The run was interrupted while this test was executing.' };
|
|
323
|
+
if (xfail && last.status === 'passed')
|
|
324
|
+
return { outcome: 'failed', note: 'Passed, but the test is marked test.fail(). If the bug is fixed, remove the marker.' };
|
|
325
|
+
return { outcome: 'failed' };
|
|
326
|
+
}
|
|
327
|
+
titlePath(test) {
|
|
328
|
+
const parts = [];
|
|
329
|
+
let s = test.parent;
|
|
330
|
+
while (s) {
|
|
331
|
+
if (s.type === 'describe' && s.title)
|
|
332
|
+
parts.unshift(s.title);
|
|
333
|
+
s = s.parent;
|
|
334
|
+
}
|
|
335
|
+
return parts;
|
|
336
|
+
}
|
|
337
|
+
serializeResult(r, test) {
|
|
338
|
+
const logs = [], data = [], api = [];
|
|
339
|
+
const normal = [];
|
|
340
|
+
for (const a of r.attachments) {
|
|
341
|
+
const body = a.body ?? (a.path && fs.existsSync(a.path) ? fs.readFileSync(a.path) : undefined);
|
|
342
|
+
if (a.contentType === 'application/x-rl-log' && body) {
|
|
343
|
+
try {
|
|
344
|
+
const l = JSON.parse(body.toString());
|
|
345
|
+
logs.push({ t: l.t, msg: this.masker.maskStr(String(l.msg)) });
|
|
346
|
+
}
|
|
347
|
+
catch { /* ignore */ }
|
|
348
|
+
continue;
|
|
349
|
+
}
|
|
350
|
+
if (a.contentType === 'application/x-rl-api' && body) {
|
|
351
|
+
try {
|
|
352
|
+
api.push(this.masker.mask(JSON.parse(body.toString())));
|
|
353
|
+
}
|
|
354
|
+
catch { /* ignore */ }
|
|
355
|
+
continue;
|
|
356
|
+
}
|
|
357
|
+
if (a.contentType === 'application/x-rl-data' && body) {
|
|
358
|
+
data.push(this.toDataBlock(a.name, body.toString()));
|
|
359
|
+
continue;
|
|
360
|
+
}
|
|
361
|
+
if (body && (a.contentType === 'text/csv' || /\.csv$/i.test(a.name))) {
|
|
362
|
+
data.push(this.toDataBlock(a.name, JSON.stringify({ csv: body.toString() })));
|
|
363
|
+
continue;
|
|
364
|
+
}
|
|
365
|
+
normal.push(a);
|
|
366
|
+
}
|
|
367
|
+
return {
|
|
368
|
+
logs: logs.sort((x, y) => x.t - y.t), data, api,
|
|
369
|
+
retry: r.retry,
|
|
370
|
+
status: r.status,
|
|
371
|
+
duration: r.duration,
|
|
372
|
+
startTime: r.startTime.getTime(),
|
|
373
|
+
workerIndex: r.parallelIndex,
|
|
374
|
+
errors: r.errors.map(e => this.serializeError(e)),
|
|
375
|
+
steps: r.steps.map(s => this.serializeStep(s)),
|
|
376
|
+
attachments: normal.map(a => this.serializeAttachment(a, test)).filter(Boolean),
|
|
377
|
+
stdout: r.stdout.map(c => stripAnsi(c.toString())),
|
|
378
|
+
stderr: r.stderr.map(c => stripAnsi(c.toString())),
|
|
379
|
+
};
|
|
380
|
+
}
|
|
381
|
+
serializeStep(s) {
|
|
382
|
+
return {
|
|
383
|
+
title: s.title,
|
|
384
|
+
category: s.category,
|
|
385
|
+
duration: s.duration,
|
|
386
|
+
error: s.error?.message ? stripAnsi(s.error.message) : undefined,
|
|
387
|
+
steps: s.steps.map(c => this.serializeStep(c)),
|
|
388
|
+
};
|
|
389
|
+
}
|
|
390
|
+
serializeAttachment(a, test) {
|
|
391
|
+
const out = { name: a.name, contentType: a.contentType };
|
|
392
|
+
const embed = this.options.embedAttachments ?? true;
|
|
393
|
+
const limit = this.options.embedLimit ?? DEFAULT_EMBED_LIMIT;
|
|
394
|
+
const isImage = a.contentType.startsWith('image/');
|
|
395
|
+
const isVideo = a.contentType.startsWith('video/');
|
|
396
|
+
const isText = a.contentType.startsWith('text/') || a.contentType.includes('json');
|
|
397
|
+
const canEmbed = embed && (isImage || (isVideo && this.options.embedVideos));
|
|
398
|
+
let body = a.body;
|
|
399
|
+
if (!body && a.path && fs.existsSync(a.path)) {
|
|
400
|
+
const size = fs.statSync(a.path).size;
|
|
401
|
+
out.size = size;
|
|
402
|
+
if (size <= limit && (canEmbed || isText))
|
|
403
|
+
body = fs.readFileSync(a.path);
|
|
404
|
+
}
|
|
405
|
+
if (body) {
|
|
406
|
+
out.size = out.size ?? body.length;
|
|
407
|
+
if (isText) {
|
|
408
|
+
out.text = this.masker.maskStr(body.toString('utf8').slice(0, 20000));
|
|
409
|
+
return out;
|
|
410
|
+
}
|
|
411
|
+
if (canEmbed && body.length <= limit) {
|
|
412
|
+
out.src = `data:${a.contentType};base64,${body.toString('base64')}`;
|
|
413
|
+
return out;
|
|
414
|
+
}
|
|
415
|
+
if (!a.path) {
|
|
416
|
+
// body-only attachment that we don't want inline (large or binary): write it to assets
|
|
417
|
+
const name = `${sanitize(test.title)}-${a.name.replace(/[^a-z0-9.-]/gi, '_')}-${this.assetCounter++}${extFor(a.contentType)}`;
|
|
418
|
+
fs.writeFileSync(path.join(this.assetsDir, name), body);
|
|
419
|
+
out.src = `assets/${name}`;
|
|
420
|
+
return out;
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
if (a.path && fs.existsSync(a.path)) {
|
|
424
|
+
const ext = path.extname(a.path) || extFor(a.contentType);
|
|
425
|
+
const name = `${sanitize(test.title)}-${a.name.replace(/[^a-z0-9.-]/gi, '_')}-${this.assetCounter++}${ext}`;
|
|
426
|
+
fs.copyFileSync(a.path, path.join(this.assetsDir, name));
|
|
427
|
+
out.src = `assets/${name}`;
|
|
428
|
+
return out;
|
|
429
|
+
}
|
|
430
|
+
return null;
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
exports.default = ReportingLabsReporter;
|
|
434
|
+
/** Run number from the CI system, used to label history entries when metadata.build is not set. */
|
|
435
|
+
function ciRunLabel(env) {
|
|
436
|
+
const n = env.GITHUB_RUN_NUMBER || env.BUILD_NUMBER || env.CI_PIPELINE_IID || env.CIRCLE_BUILD_NUM || env.BUILD_BUILDNUMBER || env.BITBUCKET_BUILD_NUMBER;
|
|
437
|
+
return n ? `#${n}` : undefined;
|
|
438
|
+
}
|
|
439
|
+
function ciLink(env) {
|
|
440
|
+
if (env.GITHUB_ACTIONS && env.GITHUB_SERVER_URL && env.GITHUB_REPOSITORY && env.GITHUB_RUN_ID)
|
|
441
|
+
return { name: `GitHub Actions #${env.GITHUB_RUN_NUMBER ?? env.GITHUB_RUN_ID}`, url: `${env.GITHUB_SERVER_URL}/${env.GITHUB_REPOSITORY}/actions/runs/${env.GITHUB_RUN_ID}` };
|
|
442
|
+
if (env.GITLAB_CI && env.CI_JOB_URL)
|
|
443
|
+
return { name: `GitLab CI #${env.CI_PIPELINE_IID ?? env.CI_JOB_ID}`, url: env.CI_JOB_URL };
|
|
444
|
+
if (env.JENKINS_URL && env.BUILD_URL)
|
|
445
|
+
return { name: `Jenkins ${env.JOB_NAME ?? ''} #${env.BUILD_NUMBER ?? ''}`.trim(), url: env.BUILD_URL };
|
|
446
|
+
if (env.CIRCLECI && env.CIRCLE_BUILD_URL)
|
|
447
|
+
return { name: `CircleCI #${env.CIRCLE_BUILD_NUM ?? ''}`.trim(), url: env.CIRCLE_BUILD_URL };
|
|
448
|
+
if (env.TF_BUILD && env.SYSTEM_TEAMFOUNDATIONCOLLECTIONURI && env.SYSTEM_TEAMPROJECT && env.BUILD_BUILDID)
|
|
449
|
+
return { name: `Azure Pipelines #${env.BUILD_BUILDNUMBER ?? env.BUILD_BUILDID}`, url: `${env.SYSTEM_TEAMFOUNDATIONCOLLECTIONURI}${env.SYSTEM_TEAMPROJECT}/_build/results?buildId=${env.BUILD_BUILDID}` };
|
|
450
|
+
if (env.BITBUCKET_BUILD_NUMBER && env.BITBUCKET_GIT_HTTP_ORIGIN)
|
|
451
|
+
return { name: `Bitbucket Pipelines #${env.BITBUCKET_BUILD_NUMBER}`, url: `${env.BITBUCKET_GIT_HTTP_ORIGIN}/addon/pipelines/home#!/results/${env.BITBUCKET_BUILD_NUMBER}` };
|
|
452
|
+
if (env.CI)
|
|
453
|
+
return { name: 'CI' };
|
|
454
|
+
return null;
|
|
455
|
+
}
|
|
456
|
+
function gitInfo(cwd, env) {
|
|
457
|
+
const out = {};
|
|
458
|
+
const run = (cmd) => { try {
|
|
459
|
+
return (0, child_process_1.execSync)(cmd, { cwd, stdio: ['ignore', 'pipe', 'ignore'], timeout: 2000 }).toString().trim();
|
|
460
|
+
}
|
|
461
|
+
catch {
|
|
462
|
+
return '';
|
|
463
|
+
} };
|
|
464
|
+
const line = run('git log -1 --format=%H%x1f%an%x1f%s');
|
|
465
|
+
if (line) {
|
|
466
|
+
const [sha, author, subject] = line.split('\x1f');
|
|
467
|
+
out.sha = sha;
|
|
468
|
+
out.author = author;
|
|
469
|
+
out.subject = subject;
|
|
470
|
+
}
|
|
471
|
+
out.sha = out.sha || env.GITHUB_SHA || env.CI_COMMIT_SHA || env.GIT_COMMIT || env.CIRCLE_SHA1 || env.BUILD_SOURCEVERSION || undefined;
|
|
472
|
+
out.author = out.author || env.GITHUB_ACTOR || env.CI_COMMIT_AUTHOR || undefined;
|
|
473
|
+
const branch = run('git rev-parse --abbrev-ref HEAD');
|
|
474
|
+
out.branch = (branch && branch !== 'HEAD' ? branch : '') || env.GITHUB_REF_NAME || env.CI_COMMIT_REF_NAME || env.GIT_BRANCH || env.BUILD_SOURCEBRANCHNAME || undefined;
|
|
475
|
+
if (out.sha) {
|
|
476
|
+
if (env.GITHUB_SERVER_URL && env.GITHUB_REPOSITORY)
|
|
477
|
+
out.url = `${env.GITHUB_SERVER_URL}/${env.GITHUB_REPOSITORY}/commit/${out.sha}`;
|
|
478
|
+
else if (env.CI_PROJECT_URL)
|
|
479
|
+
out.url = `${env.CI_PROJECT_URL}/-/commit/${out.sha}`;
|
|
480
|
+
else {
|
|
481
|
+
const remote = run('git config --get remote.origin.url');
|
|
482
|
+
const m = remote.match(/github\.com[:/]([^/]+\/[^/.]+)/);
|
|
483
|
+
if (m)
|
|
484
|
+
out.url = `https://github.com/${m[1]}/commit/${out.sha}`;
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
return out;
|
|
488
|
+
}
|
|
489
|
+
function extFor(ct) {
|
|
490
|
+
const m = { 'image/png': '.png', 'image/jpeg': '.jpg', 'video/webm': '.webm', 'video/mp4': '.mp4', 'application/zip': '.zip', 'application/pdf': '.pdf' };
|
|
491
|
+
return m[ct] ?? '';
|
|
492
|
+
}
|
|
493
|
+
function fmt(v) {
|
|
494
|
+
if (v == null)
|
|
495
|
+
return '';
|
|
496
|
+
return typeof v === 'object' ? JSON.stringify(v) : String(v);
|
|
497
|
+
}
|
|
498
|
+
function stripAnsi(s) {
|
|
499
|
+
// eslint-disable-next-line no-control-regex
|
|
500
|
+
return s.replace(/\u001b\[[0-9;]*m/g, '');
|
|
501
|
+
}
|
|
502
|
+
function sanitize(s) {
|
|
503
|
+
return s.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '').slice(0, 40);
|
|
504
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import { ReportData } from './types';
|
|
2
|
+
/** reporting-labs mark: a lab flask holding report bars. Used as favicon and header logo when no custom logo is set. */
|
|
3
|
+
export declare const LOGO_SVG = "<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 64 64\"><defs><linearGradient id=\"rl-bg\" x1=\"0\" y1=\"0\" x2=\"1\" y2=\"1\"><stop offset=\"0\" stop-color=\"#1A56DB\"/><stop offset=\"1\" stop-color=\"#0A2A6B\"/></linearGradient><radialGradient id=\"rl-glow\" cx=\"0.8\" cy=\"0.1\" r=\"0.9\"><stop offset=\"0\" stop-color=\"#fff\" stop-opacity=\".22\"/><stop offset=\"1\" stop-color=\"#fff\" stop-opacity=\"0\"/></radialGradient><clipPath id=\"rl-flask\"><path d=\"M25 8h14v14l13.5 24.5c1.8 3.3-.6 7.5-4.4 7.5H15.9c-3.8 0-6.2-4.2-4.4-7.5L25 22z\"/></clipPath></defs><rect width=\"64\" height=\"64\" rx=\"15\" fill=\"url(#rl-bg)\"/><rect width=\"64\" height=\"64\" rx=\"15\" fill=\"url(#rl-glow)\"/><g clip-path=\"url(#rl-flask)\"><rect x=\"17\" y=\"40\" width=\"7\" height=\"20\" rx=\"2\" fill=\"#fff\" fill-opacity=\".55\"/><rect x=\"28.5\" y=\"28\" width=\"7\" height=\"32\" rx=\"2\" fill=\"#3DDC8C\"/><rect x=\"40\" y=\"35\" width=\"7\" height=\"25\" rx=\"2\" fill=\"#fff\" fill-opacity=\".85\"/></g><path d=\"M25 8h14v14l13.5 24.5c1.8 3.3-.6 7.5-4.4 7.5H15.9c-3.8 0-6.2-4.2-4.4-7.5L25 22z\" fill=\"none\" stroke=\"#fff\" stroke-width=\"3.2\" stroke-linejoin=\"round\"/><path d=\"M22 8h20\" stroke=\"#fff\" stroke-width=\"3.2\" stroke-linecap=\"round\"/><circle cx=\"24\" cy=\"30\" r=\"2.2\" fill=\"#fff\" fill-opacity=\".9\"/></svg>";
|
|
4
|
+
export declare function renderHtml(data: ReportData): string;
|