@empiricalrun/playwright-utils 0.6.3 → 0.7.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.
@@ -0,0 +1,699 @@
1
+ "use strict";
2
+ /**
3
+ * Copyright (c) Microsoft Corporation.
4
+ *
5
+ * Licensed under the Apache License, Version 2.0 (the "License");
6
+ * you may not use this file except in compliance with the License.
7
+ * You may obtain a copy of the License at
8
+ *
9
+ * http://www.apache.org/licenses/LICENSE-2.0
10
+ *
11
+ * Unless required by applicable law or agreed to in writing, software
12
+ * distributed under the License is distributed on an "AS IS" BASIS,
13
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ * See the License for the specific language governing permissions and
15
+ * limitations under the License.
16
+ */
17
+ var __importDefault = (this && this.__importDefault) || function (mod) {
18
+ return (mod && mod.__esModule) ? mod : { "default": mod };
19
+ };
20
+ Object.defineProperty(exports, "__esModule", { value: true });
21
+ exports.startHtmlReportServer = exports.showHTMLReport = void 0;
22
+ const code_frame_1 = require("@babel/code-frame");
23
+ const fs_1 = __importDefault(require("fs"));
24
+ const path_1 = __importDefault(require("path"));
25
+ const utils_1 = require("playwright-core/lib/utils");
26
+ // @ts-ignore
27
+ const utilsBundle_1 = require("playwright-core/lib/utilsBundle");
28
+ // @ts-ignore
29
+ const zipBundle_1 = require("playwright-core/lib/zipBundle");
30
+ const stream_1 = require("stream");
31
+ const base_1 = require("./base");
32
+ const upload_1 = require("./upload");
33
+ const util_1 = require("./util");
34
+ const htmlReportOptions = ["always", "never", "on-failure"];
35
+ const isHtmlReportOption = (type) => {
36
+ return htmlReportOptions.includes(type);
37
+ };
38
+ class HtmlReporter {
39
+ config;
40
+ suite;
41
+ _options;
42
+ _outputFolder;
43
+ _attachmentsBaseURL;
44
+ _open;
45
+ _port;
46
+ _host;
47
+ _buildResult;
48
+ _topLevelErrors = [];
49
+ constructor(options) {
50
+ this._options = options;
51
+ }
52
+ printsToStdio() {
53
+ return false;
54
+ }
55
+ onStdOut() { }
56
+ onStdErr() { }
57
+ onStepBegin() { }
58
+ onStepEnd() { }
59
+ version() {
60
+ return "v2";
61
+ }
62
+ onTestBegin() { }
63
+ onTestEnd(test, result) {
64
+ // upload folder to R2
65
+ console.log(`Finished test ${test.title}: ${result.status}`); // comment this out before merging - we have list reporter to report on ci
66
+ // console.log("result.attachments", result.attachments);
67
+ // iterate over attachments
68
+ result.attachments.forEach(async (attachment) => {
69
+ if (!attachment.path)
70
+ return;
71
+ const folder = path_1.default.relative(this._outputFolder + "/data", attachment.path);
72
+ const folderName = folder.split("/")[0];
73
+ console.log("uploading to R2: ", folderName);
74
+ // on test gen run, we don't have PROJECT_NAME or GITHUB_RUN_ID so we skip uploading since we anyways handle uploads for that, this is a stopgap
75
+ if (!process.env.PROJECT_NAME || !"10632667227") {
76
+ console.log("Skipping upload of trace directory since PROJECT_NAME or GITHUB_RUN_ID is not set");
77
+ return;
78
+ }
79
+ await (0, upload_1.uploadDirectory)({
80
+ sourceDir: this._outputFolder + "/data/" + folderName,
81
+ destinationDir: process.env.PROJECT_NAME +
82
+ "/" +
83
+ "10632667227" +
84
+ "/data/" +
85
+ folderName,
86
+ uploadBucket: "test-report",
87
+ });
88
+ });
89
+ }
90
+ onConfigure(config) {
91
+ this.config = config;
92
+ }
93
+ onBegin(suite) {
94
+ console.log("Using @empiricalrun/playwright-utils custom reporter");
95
+ const { outputFolder, open, attachmentsBaseURL, host, port } = this._resolveOptions();
96
+ this._outputFolder = outputFolder;
97
+ this._open = open;
98
+ this._host = host;
99
+ this._port = port;
100
+ this._attachmentsBaseURL = attachmentsBaseURL;
101
+ this.suite = suite;
102
+ }
103
+ _resolveOptions() {
104
+ const outputFolder = reportFolderFromEnv() ??
105
+ (0, util_1.resolveReporterOutputPath)("playwright-report", this._options.configDir, this._options.outputFolder);
106
+ return {
107
+ outputFolder,
108
+ open: getHtmlReportOptionProcessEnv() || this._options.open || "on-failure",
109
+ attachmentsBaseURL:
110
+ /* process.env.PLAYWRIGHT_HTML_ATTACHMENTS_BASE_URL || */
111
+ this._options.attachmentsBaseURL || "data/",
112
+ host: /* process.env.PLAYWRIGHT_HTML_HOST || */ this._options.host,
113
+ port: /* process.env.PLAYWRIGHT_HTML_PORT
114
+ ? +process.env.PLAYWRIGHT_HTML_PORT
115
+ :*/ this._options.port,
116
+ };
117
+ }
118
+ // override onTestBegin(test: TestCasePublic) {
119
+ // console.log("onTestBegin, test: ", test.title);
120
+ // }
121
+ // override onTestEnd(test: TestCasePublic, result: TestResultPublic) {
122
+ // console.log("onTestEnd, test: ", test.title, result.status);
123
+ // // check path and start upload
124
+ // result.attachments.forEach((attachment) => {
125
+ // console.log("attachment: ", attachment);
126
+ // });
127
+ // }
128
+ _isSubdirectory(parentDir, dir) {
129
+ const relativePath = path_1.default.relative(parentDir, dir);
130
+ return (!!relativePath &&
131
+ !relativePath.startsWith("..") &&
132
+ !path_1.default.isAbsolute(relativePath));
133
+ }
134
+ onError(error) {
135
+ this._topLevelErrors.push(error);
136
+ }
137
+ async onEnd(result) {
138
+ const projectSuites = this.suite.suites;
139
+ // Commenting out this line causes the outputs to not get deleted
140
+ // await removeFolders([this._outputFolder]);
141
+ const builder = new HtmlBuilder(this.config, this._outputFolder, this._attachmentsBaseURL);
142
+ this._buildResult = await builder.build(this.config.metadata, projectSuites, result, this._topLevelErrors);
143
+ // upload trace directory
144
+ if (!process.env.PROJECT_NAME || !"10632667227") {
145
+ console.log("Skipping upload of trace directory since PROJECT_NAME or GITHUB_RUN_ID is not set");
146
+ return;
147
+ }
148
+ const startTime = new Date().getTime();
149
+ console.log("Starting final report upload at: ", new Intl.DateTimeFormat("en-US", {
150
+ hour: "2-digit",
151
+ minute: "2-digit",
152
+ second: "2-digit",
153
+ }).format(startTime));
154
+ await Promise.allSettled([
155
+ (0, upload_1.uploadDirectory)({
156
+ sourceDir: this._outputFolder + "/trace",
157
+ destinationDir: process.env.PROJECT_NAME + "/" + "10632667227" + "/trace",
158
+ uploadBucket: "test-report",
159
+ }),
160
+ // upload summary.json
161
+ (0, upload_1.uploadDirectory)({
162
+ sourceDir: this._outputFolder,
163
+ fileList: [
164
+ path_1.default.join(this._outputFolder, "summary.json"),
165
+ // path.join(this._outputFolder, "data", ".last-run.json"),
166
+ ],
167
+ destinationDir: process.env.PROJECT_NAME + "/" + "10632667227",
168
+ uploadBucket: "test-report",
169
+ }),
170
+ // upload index.html
171
+ (0, upload_1.uploadDirectory)({
172
+ sourceDir: this._outputFolder,
173
+ fileList: [path_1.default.join(this._outputFolder, "index.html")],
174
+ destinationDir: process.env.PROJECT_NAME + "/" + "10632667227",
175
+ uploadBucket: "test-report",
176
+ }),
177
+ ]);
178
+ const endTime = new Date().getTime();
179
+ console.log("Finished final report upload at: ", new Intl.DateTimeFormat("en-US", {
180
+ hour: "2-digit",
181
+ minute: "2-digit",
182
+ second: "2-digit",
183
+ }).format(endTime));
184
+ // time difference
185
+ const timeDiff = endTime - startTime;
186
+ console.log("Time taken to upload after tests ended: ", timeDiff, "ms");
187
+ }
188
+ async onExit() {
189
+ if ("true" || !this._buildResult)
190
+ return;
191
+ const { ok, singleTestId } = this._buildResult;
192
+ const shouldOpen = !this._options._isTestServer &&
193
+ (this._open === "always" || (!ok && this._open === "on-failure"));
194
+ if (shouldOpen) {
195
+ await showHTMLReport(this._outputFolder, this._host, this._port, singleTestId);
196
+ }
197
+ else if (this._options._mode === "test") {
198
+ const packageManagerCommand = (0, utils_1.getPackageManagerExecCommand)();
199
+ const relativeReportPath = this._outputFolder === standaloneDefaultFolder()
200
+ ? ""
201
+ : " " + path_1.default.relative(process.cwd(), this._outputFolder);
202
+ const hostArg = this._host ? ` --host ${this._host}` : "";
203
+ const portArg = this._port ? ` --port ${this._port}` : "";
204
+ console.log("");
205
+ console.log("To open last HTML report run:");
206
+ console.log(base_1.colors.cyan(`
207
+ ${packageManagerCommand} playwright show-report${relativeReportPath}${hostArg}${portArg}
208
+ `));
209
+ }
210
+ }
211
+ }
212
+ function reportFolderFromEnv() {
213
+ // Note: PLAYWRIGHT_HTML_REPORT is for backwards compatibility.
214
+ const envValue = false;
215
+ // process.env.PLAYWRIGHT_HTML_OUTPUT_DIR ||
216
+ // process.env.PLAYWRIGHT_HTML_REPORT;
217
+ return envValue ? path_1.default.resolve(envValue) : undefined;
218
+ }
219
+ function getHtmlReportOptionProcessEnv() {
220
+ // Note: PW_TEST_HTML_REPORT_OPEN is for backwards compatibility.
221
+ // const htmlOpenEnv = process.env.PLAYWRIGHT_HTML_OPEN || process.env.PW_TEST_HTML_REPORT_OPEN;
222
+ const htmlOpenEnv = false;
223
+ if (!htmlOpenEnv)
224
+ return undefined;
225
+ if (!isHtmlReportOption(htmlOpenEnv)) {
226
+ console.log(base_1.colors.red(`Configuration Error: HTML reporter Invalid value for PLAYWRIGHT_HTML_OPEN: ${htmlOpenEnv}. Valid values are: ${htmlReportOptions.join(", ")}`));
227
+ return undefined;
228
+ }
229
+ return htmlOpenEnv;
230
+ }
231
+ function standaloneDefaultFolder() {
232
+ return (reportFolderFromEnv() ??
233
+ // cleanup this "test-results" value since this is should not be used anyways
234
+ (0, util_1.resolveReporterOutputPath)("test-results", process.cwd(), undefined));
235
+ }
236
+ async function showHTMLReport(reportFolder, host = "localhost", port, testId) {
237
+ const folder = reportFolder ?? standaloneDefaultFolder();
238
+ try {
239
+ (0, utils_1.assert)(fs_1.default.statSync(folder).isDirectory());
240
+ }
241
+ catch (e) {
242
+ console.log(base_1.colors.red(`No report found at "${folder}"`));
243
+ (0, utils_1.gracefullyProcessExitDoNotHang)(1);
244
+ return;
245
+ }
246
+ const server = startHtmlReportServer(folder);
247
+ await server.start({ port, host, preferredPort: port ? undefined : 9323 });
248
+ let url = server.urlPrefix("human-readable");
249
+ console.log("");
250
+ console.log(base_1.colors.cyan(` Serving HTML report at ${url}. Press Ctrl+C to quit.`));
251
+ if (testId)
252
+ url += `#?testId=${testId}`;
253
+ url = url.replace("0.0.0.0", "localhost");
254
+ await (0, utilsBundle_1.open)(url, { wait: true }).catch(() => { });
255
+ await new Promise(() => { });
256
+ }
257
+ exports.showHTMLReport = showHTMLReport;
258
+ function startHtmlReportServer(folder) {
259
+ const server = new utils_1.HttpServer();
260
+ server.routePrefix("/", (request, response) => {
261
+ let relativePath = new URL("http://localhost" + request.url).pathname;
262
+ if (relativePath.startsWith("/trace/file")) {
263
+ const url = new URL("http://localhost" + request.url);
264
+ try {
265
+ return server.serveFile(request, response, url.searchParams.get("path"));
266
+ }
267
+ catch (e) {
268
+ return false;
269
+ }
270
+ }
271
+ if (relativePath.endsWith("/stall.js"))
272
+ return true;
273
+ if (relativePath === "/")
274
+ relativePath = "/index.html";
275
+ const absolutePath = path_1.default.join(folder, ...relativePath.split("/"));
276
+ return server.serveFile(request, response, absolutePath);
277
+ });
278
+ return server;
279
+ }
280
+ exports.startHtmlReportServer = startHtmlReportServer;
281
+ class HtmlBuilder {
282
+ _config;
283
+ _reportFolder;
284
+ _stepsInFile = new utils_1.MultiMap();
285
+ _dataZipFile;
286
+ _hasTraces = false;
287
+ _attachmentsBaseURL;
288
+ constructor(config, outputDir, attachmentsBaseURL) {
289
+ this._config = config;
290
+ this._reportFolder = outputDir;
291
+ fs_1.default.mkdirSync(this._reportFolder, { recursive: true });
292
+ this._dataZipFile = new zipBundle_1.yazl.ZipFile();
293
+ this._attachmentsBaseURL = attachmentsBaseURL;
294
+ }
295
+ async build(metadata, projectSuites, result, topLevelErrors) {
296
+ const data = new Map();
297
+ for (const projectSuite of projectSuites) {
298
+ const testDir = projectSuite.project().testDir;
299
+ for (const fileSuite of projectSuite.suites) {
300
+ const fileName = this._relativeLocation(fileSuite.location).file;
301
+ // Preserve file ids computed off the testDir.
302
+ const relativeFile = path_1.default.relative(testDir, fileSuite.location.file);
303
+ const fileId = (0, utils_1.calculateSha1)((0, utils_1.toPosixPath)(relativeFile)).slice(0, 20);
304
+ let fileEntry = data.get(fileId);
305
+ if (!fileEntry) {
306
+ fileEntry = {
307
+ testFile: { fileId, fileName, tests: [] },
308
+ testFileSummary: {
309
+ fileId,
310
+ fileName,
311
+ tests: [],
312
+ stats: emptyStats(),
313
+ },
314
+ };
315
+ data.set(fileId, fileEntry);
316
+ }
317
+ const { testFile, testFileSummary } = fileEntry;
318
+ const testEntries = [];
319
+ this._processSuite(fileSuite, projectSuite.project().name, [], testEntries);
320
+ for (const test of testEntries) {
321
+ testFile.tests.push(test.testCase);
322
+ testFileSummary.tests.push(test.testCaseSummary);
323
+ }
324
+ }
325
+ }
326
+ createSnippets(this._stepsInFile);
327
+ let ok = true;
328
+ for (const [fileId, { testFile, testFileSummary }] of data) {
329
+ const stats = testFileSummary.stats;
330
+ for (const test of testFileSummary.tests) {
331
+ if (test.outcome === "expected")
332
+ ++stats.expected;
333
+ if (test.outcome === "skipped")
334
+ ++stats.skipped;
335
+ if (test.outcome === "unexpected")
336
+ ++stats.unexpected;
337
+ if (test.outcome === "flaky")
338
+ ++stats.flaky;
339
+ ++stats.total;
340
+ }
341
+ stats.ok = stats.unexpected + stats.flaky === 0;
342
+ if (!stats.ok)
343
+ ok = false;
344
+ const testCaseSummaryComparator = (t1, t2) => {
345
+ const w1 = (t1.outcome === "unexpected" ? 1000 : 0) +
346
+ (t1.outcome === "flaky" ? 1 : 0);
347
+ const w2 = (t2.outcome === "unexpected" ? 1000 : 0) +
348
+ (t2.outcome === "flaky" ? 1 : 0);
349
+ return w2 - w1;
350
+ };
351
+ testFileSummary.tests.sort(testCaseSummaryComparator);
352
+ this._addDataFile(fileId + ".json", testFile);
353
+ }
354
+ const htmlReport = {
355
+ metadata,
356
+ startTime: result.startTime.getTime(),
357
+ duration: result.duration,
358
+ files: [...data.values()].map((e) => e.testFileSummary),
359
+ projectNames: projectSuites.map((r) => r.project().name),
360
+ stats: {
361
+ ...[...data.values()].reduce((a, e) => addStats(a, e.testFileSummary.stats), emptyStats()),
362
+ },
363
+ errors: topLevelErrors.map((error) => (0, base_1.formatError)(error, true).message),
364
+ };
365
+ htmlReport.files.sort((f1, f2) => {
366
+ const w1 = f1.stats.unexpected * 1000 + f1.stats.flaky;
367
+ const w2 = f2.stats.unexpected * 1000 + f2.stats.flaky;
368
+ return w2 - w1;
369
+ });
370
+ this._addDataFile("report.json", htmlReport);
371
+ // Copy app.
372
+ const appFolder = path_1.default.join(require.resolve("playwright-core"), "..", "lib", "vite", "htmlReport");
373
+ await (0, utils_1.copyFileAndMakeWritable)(path_1.default.join(appFolder, "index.html"), path_1.default.join(this._reportFolder, "index.html"));
374
+ // Copy trace viewer.
375
+ if (this._hasTraces) {
376
+ const traceViewerFolder = path_1.default.join(require.resolve("playwright-core"), "..", "lib", "vite", "traceViewer");
377
+ const traceViewerTargetFolder = path_1.default.join(this._reportFolder, "trace");
378
+ const traceViewerAssetsTargetFolder = path_1.default.join(traceViewerTargetFolder, "assets");
379
+ fs_1.default.mkdirSync(traceViewerAssetsTargetFolder, { recursive: true });
380
+ for (const file of fs_1.default.readdirSync(traceViewerFolder)) {
381
+ if (file.endsWith(".map") ||
382
+ file.includes("watch") ||
383
+ file.includes("assets"))
384
+ continue;
385
+ await (0, utils_1.copyFileAndMakeWritable)(path_1.default.join(traceViewerFolder, file), path_1.default.join(traceViewerTargetFolder, file));
386
+ }
387
+ for (const file of fs_1.default.readdirSync(path_1.default.join(traceViewerFolder, "assets"))) {
388
+ if (file.endsWith(".map") || file.includes("xtermModule"))
389
+ continue;
390
+ await (0, utils_1.copyFileAndMakeWritable)(path_1.default.join(traceViewerFolder, "assets", file), path_1.default.join(traceViewerAssetsTargetFolder, file));
391
+ }
392
+ }
393
+ // Inline report data.
394
+ const indexFile = path_1.default.join(this._reportFolder, "index.html");
395
+ fs_1.default.appendFileSync(indexFile, '<script>\nwindow.playwrightReportBase64 = "data:application/zip;base64,');
396
+ await new Promise((f) => {
397
+ this._dataZipFile.end(undefined, () => {
398
+ this._dataZipFile.outputStream.pipe(new Base64Encoder())
399
+ .pipe(fs_1.default.createWriteStream(indexFile, { flags: "a" }))
400
+ .on("close", f);
401
+ });
402
+ });
403
+ fs_1.default.appendFileSync(indexFile, '";</script>');
404
+ let singleTestId;
405
+ if (htmlReport.stats.total === 1) {
406
+ const testFile = data.values().next().value?.testFile;
407
+ singleTestId = testFile?.tests[0]?.testId;
408
+ }
409
+ return { ok, singleTestId };
410
+ }
411
+ _addDataFile(fileName, data) {
412
+ this._dataZipFile.addBuffer(Buffer.from(JSON.stringify(data)), fileName);
413
+ }
414
+ _processSuite(suite, projectName, path, outTests) {
415
+ const newPath = [...path, suite.title];
416
+ suite.entries().forEach((e) => {
417
+ if (e.type === "test")
418
+ outTests.push(this._createTestEntry(e, projectName, newPath));
419
+ else
420
+ this._processSuite(e, projectName, newPath, outTests);
421
+ });
422
+ }
423
+ _createTestEntry(test, projectName, path) {
424
+ const duration = test.results.reduce((a, r) => a + r.duration, 0);
425
+ const location = this._relativeLocation(test.location);
426
+ path = path.slice(1).filter((path) => path.length > 0);
427
+ const results = test.results.map((r) => this._createTestResult(test, r));
428
+ return {
429
+ testCase: {
430
+ testId: test.id,
431
+ title: test.title,
432
+ projectName,
433
+ location,
434
+ duration,
435
+ // Annotations can be pushed directly, with a wrong type.
436
+ annotations: test.annotations.map((a) => ({
437
+ type: a.type,
438
+ description: a.description ? String(a.description) : a.description,
439
+ })),
440
+ tags: test.tags,
441
+ outcome: test.outcome(),
442
+ path,
443
+ results,
444
+ ok: test.outcome() === "expected" || test.outcome() === "flaky",
445
+ },
446
+ testCaseSummary: {
447
+ testId: test.id,
448
+ title: test.title,
449
+ projectName,
450
+ location,
451
+ duration,
452
+ // Annotations can be pushed directly, with a wrong type.
453
+ annotations: test.annotations.map((a) => ({
454
+ type: a.type,
455
+ description: a.description ? String(a.description) : a.description,
456
+ })),
457
+ tags: test.tags,
458
+ outcome: test.outcome(),
459
+ path,
460
+ ok: test.outcome() === "expected" || test.outcome() === "flaky",
461
+ results: results.map((result) => {
462
+ return {
463
+ attachments: result.attachments.map((a) => ({
464
+ name: a.name,
465
+ contentType: a.contentType,
466
+ path: a.path,
467
+ })),
468
+ };
469
+ }),
470
+ },
471
+ };
472
+ }
473
+ _serializeAttachments(attachments) {
474
+ let lastAttachment;
475
+ return attachments
476
+ .map((a) => {
477
+ if (a.name === "trace")
478
+ this._hasTraces = true;
479
+ if ((a.name === "stdout" || a.name === "stderr") &&
480
+ a.contentType === "text/plain") {
481
+ if (lastAttachment &&
482
+ lastAttachment.name === a.name &&
483
+ lastAttachment.contentType === a.contentType) {
484
+ lastAttachment.body += (0, base_1.stripAnsiEscapes)(a.body);
485
+ return null;
486
+ }
487
+ a.body = (0, base_1.stripAnsiEscapes)(a.body);
488
+ lastAttachment = a;
489
+ return a;
490
+ }
491
+ if (a.path) {
492
+ // in our custom reporter, we do not delete the original files onEnd
493
+ const relativePath = path_1.default.relative(this._reportFolder, a.path);
494
+ // removed logic which renamed original files to different versions with SHAs
495
+ return {
496
+ name: a.name,
497
+ contentType: a.contentType,
498
+ path: relativePath,
499
+ body: a.body,
500
+ };
501
+ }
502
+ if (a.body instanceof Buffer) {
503
+ if (isTextContentType(a.contentType)) {
504
+ // Content type is like this: "text/html; charset=UTF-8"
505
+ const charset = a.contentType.match(/charset=(.*)/)?.[1];
506
+ try {
507
+ const body = a.body.toString(charset || "utf-8");
508
+ return {
509
+ name: a.name,
510
+ contentType: a.contentType,
511
+ body,
512
+ };
513
+ }
514
+ catch (e) {
515
+ // Invalid encoding, fall through and save to file.
516
+ }
517
+ }
518
+ fs_1.default.mkdirSync(path_1.default.join(this._reportFolder, "data"), {
519
+ recursive: true,
520
+ });
521
+ const extension = (0, utils_1.sanitizeForFilePath)(path_1.default.extname(a.name).replace(/^\./, "")) ||
522
+ utilsBundle_1.mime.getExtension(a.contentType) ||
523
+ "dat";
524
+ const sha1 = (0, utils_1.calculateSha1)(a.body) + "." + extension;
525
+ fs_1.default.writeFileSync(path_1.default.join(this._reportFolder, "data", sha1), a.body);
526
+ return {
527
+ name: a.name,
528
+ contentType: a.contentType,
529
+ path: this._attachmentsBaseURL + sha1,
530
+ };
531
+ }
532
+ // string
533
+ return {
534
+ name: a.name,
535
+ contentType: a.contentType,
536
+ body: a.body,
537
+ };
538
+ })
539
+ .filter(Boolean);
540
+ }
541
+ _createTestResult(test, result) {
542
+ return {
543
+ duration: result.duration,
544
+ startTime: result.startTime.toISOString(),
545
+ retry: result.retry,
546
+ steps: dedupeSteps(result.steps).map((s) => this._createTestStep(s)),
547
+ errors: (0, base_1.formatResultFailure)(test, result, "", true).map((error) => error.message),
548
+ status: result.status,
549
+ attachments: this._serializeAttachments([
550
+ ...result.attachments,
551
+ ...result.stdout.map((m) => stdioAttachment(m, "stdout")),
552
+ ...result.stderr.map((m) => stdioAttachment(m, "stderr")),
553
+ ]),
554
+ };
555
+ }
556
+ _createTestStep(dedupedStep) {
557
+ const { step, duration, count } = dedupedStep;
558
+ const result = {
559
+ title: step.title,
560
+ startTime: step.startTime.toISOString(),
561
+ duration,
562
+ steps: dedupeSteps(step.steps).map((s) => this._createTestStep(s)),
563
+ location: this._relativeLocation(step.location),
564
+ error: step.error?.message,
565
+ count,
566
+ };
567
+ if (result.location)
568
+ this._stepsInFile.set(result.location.file, result);
569
+ return result;
570
+ }
571
+ _relativeLocation(location) {
572
+ if (!location)
573
+ return undefined;
574
+ const file = (0, utils_1.toPosixPath)(path_1.default.relative(this._config.rootDir, location.file));
575
+ return {
576
+ file,
577
+ line: location.line,
578
+ column: location.column,
579
+ };
580
+ }
581
+ }
582
+ const emptyStats = () => {
583
+ return {
584
+ total: 0,
585
+ expected: 0,
586
+ unexpected: 0,
587
+ flaky: 0,
588
+ skipped: 0,
589
+ ok: true,
590
+ };
591
+ };
592
+ const addStats = (stats, delta) => {
593
+ stats.total += delta.total;
594
+ stats.skipped += delta.skipped;
595
+ stats.expected += delta.expected;
596
+ stats.unexpected += delta.unexpected;
597
+ stats.flaky += delta.flaky;
598
+ stats.ok = stats.ok && delta.ok;
599
+ return stats;
600
+ };
601
+ class Base64Encoder extends stream_1.Transform {
602
+ _remainder;
603
+ _transform(chunk, encoding, callback) {
604
+ if (this._remainder) {
605
+ chunk = Buffer.concat([this._remainder, chunk]);
606
+ this._remainder = undefined;
607
+ }
608
+ const remaining = chunk.length % 3;
609
+ if (remaining) {
610
+ this._remainder = chunk.slice(chunk.length - remaining);
611
+ chunk = chunk.slice(0, chunk.length - remaining);
612
+ }
613
+ chunk = chunk.toString("base64");
614
+ this.push(Buffer.from(chunk));
615
+ callback();
616
+ }
617
+ _flush(callback) {
618
+ if (this._remainder)
619
+ this.push(Buffer.from(this._remainder.toString("base64")));
620
+ callback();
621
+ }
622
+ }
623
+ function isTextContentType(contentType) {
624
+ return (contentType.startsWith("text/") ||
625
+ contentType.startsWith("application/json"));
626
+ }
627
+ function stdioAttachment(chunk, type) {
628
+ if (typeof chunk === "string") {
629
+ return {
630
+ name: type,
631
+ contentType: "text/plain",
632
+ body: chunk,
633
+ };
634
+ }
635
+ return {
636
+ name: type,
637
+ contentType: "application/octet-stream",
638
+ body: chunk,
639
+ };
640
+ }
641
+ function dedupeSteps(steps) {
642
+ const result = [];
643
+ let lastResult = undefined;
644
+ for (const step of steps) {
645
+ const canDedupe = !step.error &&
646
+ step.duration >= 0 &&
647
+ step.location?.file &&
648
+ !step.steps.length;
649
+ const lastStep = lastResult?.step;
650
+ if (canDedupe &&
651
+ lastResult &&
652
+ lastStep &&
653
+ step.category === lastStep.category &&
654
+ step.title === lastStep.title &&
655
+ step.location?.file === lastStep.location?.file &&
656
+ step.location?.line === lastStep.location?.line &&
657
+ step.location?.column === lastStep.location?.column) {
658
+ ++lastResult.count;
659
+ lastResult.duration += step.duration;
660
+ continue;
661
+ }
662
+ lastResult = { step, count: 1, duration: step.duration };
663
+ result.push(lastResult);
664
+ if (!canDedupe)
665
+ lastResult = undefined;
666
+ }
667
+ return result;
668
+ }
669
+ function createSnippets(stepsInFile) {
670
+ for (const file of stepsInFile.keys()) {
671
+ let source;
672
+ try {
673
+ source = fs_1.default.readFileSync(file, "utf-8") + "\n//";
674
+ }
675
+ catch (e) {
676
+ continue;
677
+ }
678
+ const lines = source.split("\n").length;
679
+ const highlighted = (0, code_frame_1.codeFrameColumns)(source, { start: { line: lines, column: 1 } }, { highlightCode: true, linesAbove: lines, linesBelow: 0 });
680
+ const highlightedLines = highlighted.split("\n");
681
+ const lineWithArrow = highlightedLines[highlightedLines.length - 1];
682
+ for (const step of stepsInFile.get(file)) {
683
+ // Don't bother with snippets that have less than 3 lines.
684
+ if (step.location.line < 2 || step.location.line >= lines)
685
+ continue;
686
+ // Cut out snippet.
687
+ const snippetLines = highlightedLines.slice(step.location.line - 2, step.location.line + 1);
688
+ // Relocate arrow.
689
+ const index = lineWithArrow?.indexOf("^");
690
+ const shiftedArrow = lineWithArrow?.slice(0, index) +
691
+ " ".repeat(step.location.column - 1) +
692
+ lineWithArrow?.slice(index);
693
+ // Insert arrow line.
694
+ snippetLines.splice(2, 0, shiftedArrow);
695
+ step.snippet = snippetLines.join("\n");
696
+ }
697
+ }
698
+ }
699
+ exports.default = HtmlReporter;