@piwitests/reporter 0.4.2
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 +234 -0
- package/dist/compression.d.ts +5 -0
- package/dist/compression.js +39 -0
- package/dist/config-wrapper.d.ts +21 -0
- package/dist/config-wrapper.js +64 -0
- package/dist/config.d.ts +104 -0
- package/dist/config.js +127 -0
- package/dist/crash-recovery.d.ts +23 -0
- package/dist/crash-recovery.js +105 -0
- package/dist/file-handler.d.ts +38 -0
- package/dist/file-handler.js +166 -0
- package/dist/fixtures.d.ts +25 -0
- package/dist/fixtures.js +156 -0
- package/dist/global-setup-module.d.ts +2 -0
- package/dist/global-setup-module.js +4 -0
- package/dist/helpers.d.ts +44 -0
- package/dist/helpers.js +288 -0
- package/dist/http-client.d.ts +42 -0
- package/dist/http-client.js +154 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.js +6 -0
- package/dist/logger.d.ts +26 -0
- package/dist/logger.js +43 -0
- package/dist/metadata-collector.d.ts +32 -0
- package/dist/metadata-collector.js +243 -0
- package/dist/reporter.d.ts +65 -0
- package/dist/reporter.js +341 -0
- package/dist/run-submitter.d.ts +66 -0
- package/dist/run-submitter.js +184 -0
- package/dist/serializer.d.ts +45 -0
- package/dist/serializer.js +104 -0
- package/dist/skip-classify.d.ts +27 -0
- package/dist/skip-classify.js +40 -0
- package/dist/step-analyzer.d.ts +97 -0
- package/dist/step-analyzer.js +216 -0
- package/dist/stream-buffer.d.ts +17 -0
- package/dist/stream-buffer.js +102 -0
- package/dist/stream-manager.d.ts +74 -0
- package/dist/stream-manager.js +338 -0
- package/dist/types.d.ts +251 -0
- package/dist/types.js +14 -0
- package/dist/uploader.d.ts +86 -0
- package/dist/uploader.js +191 -0
- package/package.json +62 -0
|
@@ -0,0 +1,105 @@
|
|
|
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
|
+
exports.CrashRecovery = void 0;
|
|
37
|
+
const path = __importStar(require("path"));
|
|
38
|
+
const os = __importStar(require("os"));
|
|
39
|
+
const fs = __importStar(require("fs"));
|
|
40
|
+
const helpers_js_1 = require("./helpers.js");
|
|
41
|
+
const logger_js_1 = require("./logger.js");
|
|
42
|
+
/**
|
|
43
|
+
* Persists a test-run payload to disk when all upload strategies fail, so the
|
|
44
|
+
* data can be retried on the next run.
|
|
45
|
+
*/
|
|
46
|
+
class CrashRecovery {
|
|
47
|
+
/**
|
|
48
|
+
* @param projectName Used to derive the temp-file name so recovery data is project-scoped.
|
|
49
|
+
* @param logger Prefixed logger.
|
|
50
|
+
*/
|
|
51
|
+
constructor(projectName, logger = new logger_js_1.Logger()) {
|
|
52
|
+
this.logger = logger;
|
|
53
|
+
this.filePath = path.join(os.tmpdir(), `piwi-dashboard-recovery-${(0, helpers_js_1.hashForProject)(projectName)}.json`);
|
|
54
|
+
}
|
|
55
|
+
/** Serialise the payload to a temp file for later retry */
|
|
56
|
+
save(data) {
|
|
57
|
+
try {
|
|
58
|
+
fs.writeFileSync(this.filePath, JSON.stringify(data), 'utf8');
|
|
59
|
+
this.logger.info('Saved recovery data for later upload');
|
|
60
|
+
}
|
|
61
|
+
catch (error) {
|
|
62
|
+
this.logger.error(`Failed to save recovery data: ${error.message}`);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
/** Read the saved payload from disk, or return `null` if none exists */
|
|
66
|
+
load() {
|
|
67
|
+
try {
|
|
68
|
+
if (fs.existsSync(this.filePath))
|
|
69
|
+
return JSON.parse(fs.readFileSync(this.filePath, 'utf8'));
|
|
70
|
+
}
|
|
71
|
+
catch {
|
|
72
|
+
// Non-fatal
|
|
73
|
+
}
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
/** Delete the recovery file from disk */
|
|
77
|
+
clear() {
|
|
78
|
+
try {
|
|
79
|
+
if (fs.existsSync(this.filePath))
|
|
80
|
+
fs.unlinkSync(this.filePath);
|
|
81
|
+
}
|
|
82
|
+
catch {
|
|
83
|
+
// Non-fatal
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
/** Attempt to submit a previously saved payload. Clears the recovery file on success. */
|
|
87
|
+
async tryUpload(httpClient, auth) {
|
|
88
|
+
const data = this.load();
|
|
89
|
+
if (!data)
|
|
90
|
+
return;
|
|
91
|
+
this.logger.info('Found saved test data from a previous run, uploading...');
|
|
92
|
+
// Strip instanceId so /submit doesn't cancel the current run (which shares
|
|
93
|
+
// the same instanceId for the same project + hostname combination).
|
|
94
|
+
delete data.instanceId;
|
|
95
|
+
try {
|
|
96
|
+
await httpClient.postJSON('/api/test-runs/submit', data, auth);
|
|
97
|
+
this.logger.info('Successfully uploaded saved test data');
|
|
98
|
+
this.clear();
|
|
99
|
+
}
|
|
100
|
+
catch (error) {
|
|
101
|
+
this.logger.warn(`Could not upload saved test data: ${error.message}`);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
exports.CrashRecovery = CrashRecovery;
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { Logger } from './logger.js';
|
|
2
|
+
import type { CollectedTestCase, RawAttachment, TraceHashInfo } from './types.js';
|
|
3
|
+
/**
|
|
4
|
+
* File-system helpers for discovering report directories, trace files, and
|
|
5
|
+
* test attachments, as well as compressing report directories and computing
|
|
6
|
+
* trace-file hashes for deduplication.
|
|
7
|
+
*/
|
|
8
|
+
export declare class FileHandler {
|
|
9
|
+
private readonly logger;
|
|
10
|
+
constructor(logger?: Logger);
|
|
11
|
+
/** Locate a Playwright HTML report directory containing `index.html`. Optionally override the search path. */
|
|
12
|
+
findHTMLReportDirectory(customDir?: string): string | null;
|
|
13
|
+
/** Check if `dir` exists and is a directory; also checks `path.join(process.cwd(), dir)` */
|
|
14
|
+
findReportDirectory(dir: string): string | null;
|
|
15
|
+
/** Gzip-compress an entire report directory into a single buffer. Returns `null` on failure. */
|
|
16
|
+
compressReportDirectory(reportDir: string): Promise<Buffer | null>;
|
|
17
|
+
/** Return resolved paths for all trace attachments on a test case */
|
|
18
|
+
findTraceFiles(testCase: CollectedTestCase): string[];
|
|
19
|
+
/** Return all non-trace, non-internal attachments from a test case. Skips `trace` and `piwi-dashboard-*` attachments. */
|
|
20
|
+
findAllAttachments(testCase: CollectedTestCase): Array<{
|
|
21
|
+
name: string;
|
|
22
|
+
path: string;
|
|
23
|
+
contentType: string;
|
|
24
|
+
originalName: string;
|
|
25
|
+
}>;
|
|
26
|
+
/** Mapping of well-known report type names to their default output directories */
|
|
27
|
+
getDefaultReportDirs(): Record<string, string>;
|
|
28
|
+
/** Parse Piwi-internal attachment bodies (`piwi-dashboard-network`, `-web-vitals`, `-console`, `-aria-snapshot`) into structured fields on the test case */
|
|
29
|
+
parsePerformanceAttachments(testCase: CollectedTestCase, attachments: RawAttachment[]): void;
|
|
30
|
+
/** Compute SHA-256 hash and size for a single test case's trace file. Returns `null` when the case has no trace on disk. */
|
|
31
|
+
computeSingleTraceHash(testCase: CollectedTestCase): Promise<TraceHashInfo | null>;
|
|
32
|
+
/** Ask the server which trace hashes it already has, returning the set of hashes that are missing */
|
|
33
|
+
checkMissingTraces(httpClient: {
|
|
34
|
+
postJSON(path: string, payload: any, auth?: string | null): Promise<any>;
|
|
35
|
+
}, projectName: string, traceHashMap: Map<number, {
|
|
36
|
+
hash: string;
|
|
37
|
+
}>, auth: string | null): Promise<Set<string>>;
|
|
38
|
+
}
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.FileHandler = void 0;
|
|
7
|
+
const fs_1 = __importDefault(require("fs"));
|
|
8
|
+
const path_1 = __importDefault(require("path"));
|
|
9
|
+
const crypto_1 = __importDefault(require("crypto"));
|
|
10
|
+
const compression_js_1 = require("./compression.js");
|
|
11
|
+
const logger_js_1 = require("./logger.js");
|
|
12
|
+
/**
|
|
13
|
+
* File-system helpers for discovering report directories, trace files, and
|
|
14
|
+
* test attachments, as well as compressing report directories and computing
|
|
15
|
+
* trace-file hashes for deduplication.
|
|
16
|
+
*/
|
|
17
|
+
class FileHandler {
|
|
18
|
+
constructor(logger = new logger_js_1.Logger()) {
|
|
19
|
+
this.logger = logger;
|
|
20
|
+
}
|
|
21
|
+
/** Locate a Playwright HTML report directory containing `index.html`. Optionally override the search path. */
|
|
22
|
+
findHTMLReportDirectory(customDir) {
|
|
23
|
+
const possibleDirs = customDir
|
|
24
|
+
? [customDir, path_1.default.join(process.cwd(), customDir)]
|
|
25
|
+
: ['playwright-report', './playwright-report', path_1.default.join(process.cwd(), 'playwright-report')];
|
|
26
|
+
for (const reportDir of possibleDirs) {
|
|
27
|
+
if (fs_1.default.existsSync(reportDir) && fs_1.default.statSync(reportDir).isDirectory()) {
|
|
28
|
+
if (fs_1.default.existsSync(path_1.default.join(reportDir, 'index.html')))
|
|
29
|
+
return reportDir;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
/** Check if `dir` exists and is a directory; also checks `path.join(process.cwd(), dir)` */
|
|
35
|
+
findReportDirectory(dir) {
|
|
36
|
+
const candidates = [dir, path_1.default.join(process.cwd(), dir)];
|
|
37
|
+
for (const candidate of candidates) {
|
|
38
|
+
if (fs_1.default.existsSync(candidate) && fs_1.default.statSync(candidate).isDirectory())
|
|
39
|
+
return candidate;
|
|
40
|
+
}
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
/** Gzip-compress an entire report directory into a single buffer. Returns `null` on failure. */
|
|
44
|
+
async compressReportDirectory(reportDir) {
|
|
45
|
+
try {
|
|
46
|
+
return (await (0, compression_js_1.compressDirectory)(reportDir)) || null;
|
|
47
|
+
}
|
|
48
|
+
catch (error) {
|
|
49
|
+
this.logger.warn(`Failed to compress report directory ${reportDir}: ${error.message}`);
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
/** Return resolved paths for all trace attachments on a test case */
|
|
54
|
+
findTraceFiles(testCase) {
|
|
55
|
+
const set = new Set();
|
|
56
|
+
if (testCase.attachments) {
|
|
57
|
+
for (const a of testCase.attachments) {
|
|
58
|
+
if (a.name === 'trace' && a.path)
|
|
59
|
+
set.add(path_1.default.resolve(a.path));
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return Array.from(set);
|
|
63
|
+
}
|
|
64
|
+
/** Return all non-trace, non-internal attachments from a test case. Skips `trace` and `piwi-dashboard-*` attachments. */
|
|
65
|
+
findAllAttachments(testCase) {
|
|
66
|
+
const result = [];
|
|
67
|
+
if (testCase.attachments) {
|
|
68
|
+
for (const a of testCase.attachments) {
|
|
69
|
+
if (a.name === 'trace')
|
|
70
|
+
continue;
|
|
71
|
+
if (a.name?.startsWith('piwi-dashboard-'))
|
|
72
|
+
continue;
|
|
73
|
+
if (a.path && fs_1.default.existsSync(a.path)) {
|
|
74
|
+
result.push({
|
|
75
|
+
name: a.name || 'attachment',
|
|
76
|
+
path: path_1.default.resolve(a.path),
|
|
77
|
+
contentType: a.contentType || 'application/octet-stream',
|
|
78
|
+
originalName: path_1.default.basename(a.path),
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
return result;
|
|
84
|
+
}
|
|
85
|
+
/** Mapping of well-known report type names to their default output directories */
|
|
86
|
+
getDefaultReportDirs() {
|
|
87
|
+
return {
|
|
88
|
+
html: 'playwright-report',
|
|
89
|
+
monocart: 'monocart-report',
|
|
90
|
+
allure: 'allure-report',
|
|
91
|
+
blob: 'blob-report',
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
/** Parse Piwi-internal attachment bodies (`piwi-dashboard-network`, `-web-vitals`, `-console`, `-aria-snapshot`) into structured fields on the test case */
|
|
95
|
+
parsePerformanceAttachments(testCase, attachments) {
|
|
96
|
+
const find = (name) => attachments.find((a) => a.name === name);
|
|
97
|
+
const net = find('piwi-dashboard-network');
|
|
98
|
+
if (net?.body) {
|
|
99
|
+
try {
|
|
100
|
+
testCase.networkRequests = JSON.parse(net.body.toString());
|
|
101
|
+
}
|
|
102
|
+
catch {
|
|
103
|
+
/* ignore */
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
const vitals = find('piwi-dashboard-web-vitals');
|
|
107
|
+
if (vitals?.body) {
|
|
108
|
+
try {
|
|
109
|
+
testCase.webVitals = JSON.parse(vitals.body.toString());
|
|
110
|
+
}
|
|
111
|
+
catch {
|
|
112
|
+
/* ignore */
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
const consoleLog = find('piwi-dashboard-console');
|
|
116
|
+
if (consoleLog?.body) {
|
|
117
|
+
try {
|
|
118
|
+
testCase.consoleLogs = JSON.parse(consoleLog.body.toString());
|
|
119
|
+
}
|
|
120
|
+
catch {
|
|
121
|
+
/* ignore */
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
const aria = find('piwi-dashboard-aria-snapshot');
|
|
125
|
+
if (aria?.body)
|
|
126
|
+
testCase.ariaSnapshot = aria.body.toString();
|
|
127
|
+
}
|
|
128
|
+
/** Compute SHA-256 hash and size for a single test case's trace file. Returns `null` when the case has no trace on disk. */
|
|
129
|
+
async computeSingleTraceHash(testCase) {
|
|
130
|
+
const tracePaths = this.findTraceFiles(testCase);
|
|
131
|
+
let lastPath = null;
|
|
132
|
+
for (const tp of tracePaths) {
|
|
133
|
+
if (fs_1.default.existsSync(tp))
|
|
134
|
+
lastPath = tp;
|
|
135
|
+
}
|
|
136
|
+
if (!lastPath)
|
|
137
|
+
return null;
|
|
138
|
+
const hash = crypto_1.default.createHash('sha256');
|
|
139
|
+
await new Promise((resolve, reject) => {
|
|
140
|
+
fs_1.default.createReadStream(lastPath)
|
|
141
|
+
.on('data', (chunk) => hash.update(chunk))
|
|
142
|
+
.on('end', resolve)
|
|
143
|
+
.on('error', reject);
|
|
144
|
+
});
|
|
145
|
+
return {
|
|
146
|
+
tracePath: lastPath,
|
|
147
|
+
hash: hash.digest('hex'),
|
|
148
|
+
size: fs_1.default.statSync(lastPath).size,
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
/** Ask the server which trace hashes it already has, returning the set of hashes that are missing */
|
|
152
|
+
async checkMissingTraces(httpClient, projectName, traceHashMap, auth) {
|
|
153
|
+
if (traceHashMap.size === 0)
|
|
154
|
+
return new Set();
|
|
155
|
+
const hashes = [...traceHashMap.values()].map((h) => h.hash);
|
|
156
|
+
try {
|
|
157
|
+
const response = await httpClient.postJSON('/api/traces/check', { projectName, hashes }, auth);
|
|
158
|
+
const missing = Array.isArray(response.missing) ? response.missing : hashes;
|
|
159
|
+
return new Set(missing);
|
|
160
|
+
}
|
|
161
|
+
catch {
|
|
162
|
+
return new Set(hashes);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
exports.FileHandler = FileHandler;
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { Fixtures } from '@playwright/test';
|
|
2
|
+
/**
|
|
3
|
+
* Playwright fixtures that collect network requests, console entries,
|
|
4
|
+
* web vitals, and ARIA snapshots during a test.
|
|
5
|
+
*
|
|
6
|
+
* Attaches collected data as `piwi-dashboard-*` test-info attachments
|
|
7
|
+
* which the Piwi Dashboard reporter parses on `onTestEnd`.
|
|
8
|
+
*/
|
|
9
|
+
export declare const dashboardFixtures: Fixtures;
|
|
10
|
+
/**
|
|
11
|
+
* Extend a Playwright `test` object with Piwi Dashboard fixtures.
|
|
12
|
+
*
|
|
13
|
+
* Use this instead of importing `@playwright/test` directly from this package
|
|
14
|
+
* to avoid the "Requiring @playwright/test second time" error caused by
|
|
15
|
+
* duplicate module resolution.
|
|
16
|
+
*
|
|
17
|
+
* @example
|
|
18
|
+
* ```ts
|
|
19
|
+
* import { test as base } from '@playwright/test';
|
|
20
|
+
* import { extendDashboardFixtures } from '@piwitests/reporter/fixtures';
|
|
21
|
+
*
|
|
22
|
+
* export const test = extendDashboardFixtures(base);
|
|
23
|
+
* ```
|
|
24
|
+
*/
|
|
25
|
+
export declare function extendDashboardFixtures<T>(test: T): T;
|
package/dist/fixtures.js
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.dashboardFixtures = void 0;
|
|
4
|
+
exports.extendDashboardFixtures = extendDashboardFixtures;
|
|
5
|
+
const zlib_1 = require("zlib");
|
|
6
|
+
/**
|
|
7
|
+
* Playwright fixtures that collect network requests, console entries,
|
|
8
|
+
* web vitals, and ARIA snapshots during a test.
|
|
9
|
+
*
|
|
10
|
+
* Attaches collected data as `piwi-dashboard-*` test-info attachments
|
|
11
|
+
* which the Piwi Dashboard reporter parses on `onTestEnd`.
|
|
12
|
+
*/
|
|
13
|
+
exports.dashboardFixtures = {
|
|
14
|
+
page: async ({ page }, use, testInfo) => {
|
|
15
|
+
const networkRequests = [];
|
|
16
|
+
const consoleEntries = [];
|
|
17
|
+
const pendingHandlers = [];
|
|
18
|
+
page.on('console', (msg) => {
|
|
19
|
+
const type = msg.type();
|
|
20
|
+
if (['warning', 'error', 'assert'].includes(type)) {
|
|
21
|
+
consoleEntries.push({
|
|
22
|
+
type,
|
|
23
|
+
text: msg.text(),
|
|
24
|
+
timestamp: Date.now(),
|
|
25
|
+
location: msg.location()
|
|
26
|
+
? `${msg.location().url}:${msg.location().lineNumber}:${msg.location().columnNumber}`
|
|
27
|
+
: null,
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
});
|
|
31
|
+
page.on('requestfinished', (request) => {
|
|
32
|
+
const p = (async () => {
|
|
33
|
+
try {
|
|
34
|
+
const url = request.url();
|
|
35
|
+
if (url.startsWith('data:') || url.startsWith('blob:'))
|
|
36
|
+
return;
|
|
37
|
+
const timing = request.timing();
|
|
38
|
+
const response = await request.response();
|
|
39
|
+
const resourceType = request.resourceType();
|
|
40
|
+
// Only keep API/document requests; skip static assets (scripts, styles, fonts, images, media)
|
|
41
|
+
if (!['fetch', 'xhr', 'document', 'other'].includes(resourceType))
|
|
42
|
+
return;
|
|
43
|
+
const entry = {
|
|
44
|
+
method: request.method(),
|
|
45
|
+
url,
|
|
46
|
+
status: response ? response.status() : 0,
|
|
47
|
+
duration: timing.responseEnd > 0 ? Math.round(timing.responseEnd - timing.requestStart) : 0,
|
|
48
|
+
startTime: timing.startTime,
|
|
49
|
+
resourceType,
|
|
50
|
+
};
|
|
51
|
+
if (response) {
|
|
52
|
+
const logHeader = response.headers()['x-piwi-logs'];
|
|
53
|
+
if (logHeader) {
|
|
54
|
+
try {
|
|
55
|
+
entry.serverLogs = JSON.parse((0, zlib_1.gunzipSync)(Buffer.from(logHeader, 'base64')).toString('utf-8'));
|
|
56
|
+
}
|
|
57
|
+
catch {
|
|
58
|
+
/* ignore malformed header */
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
networkRequests.push(entry);
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
/* ignore */
|
|
66
|
+
}
|
|
67
|
+
})();
|
|
68
|
+
pendingHandlers.push(p);
|
|
69
|
+
});
|
|
70
|
+
await use(page);
|
|
71
|
+
// Wait for all in-flight requestfinished handlers before snapshotting
|
|
72
|
+
// networkRequests — the last request (often the one that failed the test)
|
|
73
|
+
// races with fixture teardown and its serverLogs would otherwise be lost.
|
|
74
|
+
await Promise.allSettled(pendingHandlers);
|
|
75
|
+
if (testInfo.status !== 'passed' && testInfo.status !== 'skipped') {
|
|
76
|
+
try {
|
|
77
|
+
const snapshot = await page.locator(':root').ariaSnapshot();
|
|
78
|
+
if (snapshot) {
|
|
79
|
+
await testInfo.attach('piwi-dashboard-aria-snapshot', {
|
|
80
|
+
contentType: 'text/plain',
|
|
81
|
+
body: snapshot,
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
catch {
|
|
86
|
+
/* ignore */
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
if (consoleEntries.length > 0) {
|
|
90
|
+
await testInfo.attach('piwi-dashboard-console', {
|
|
91
|
+
contentType: 'application/json',
|
|
92
|
+
body: Buffer.from(JSON.stringify(consoleEntries)),
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
if (networkRequests.length > 0) {
|
|
96
|
+
await testInfo.attach('piwi-dashboard-network', {
|
|
97
|
+
contentType: 'application/json',
|
|
98
|
+
body: Buffer.from(JSON.stringify(networkRequests)),
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
try {
|
|
102
|
+
const webVitals = await page.evaluate(() => {
|
|
103
|
+
const navEntries = performance.getEntriesByType('navigation');
|
|
104
|
+
const paintEntries = performance.getEntriesByType('paint');
|
|
105
|
+
const nav = navEntries[0];
|
|
106
|
+
const navigation = nav
|
|
107
|
+
? {
|
|
108
|
+
url: nav.name,
|
|
109
|
+
ttfb: Math.round(nav.responseStart - nav.fetchStart),
|
|
110
|
+
domInteractive: Math.round(nav.domInteractive - nav.fetchStart),
|
|
111
|
+
domContentLoaded: Math.round(nav.domContentLoadedEventEnd - nav.fetchStart),
|
|
112
|
+
loadComplete: Math.round(nav.loadEventEnd - nav.fetchStart),
|
|
113
|
+
transferSize: nav.transferSize || 0,
|
|
114
|
+
encodedBodySize: nav.encodedBodySize || 0,
|
|
115
|
+
decodedBodySize: nav.decodedBodySize || 0,
|
|
116
|
+
}
|
|
117
|
+
: null;
|
|
118
|
+
const paint = {};
|
|
119
|
+
for (const entry of paintEntries) {
|
|
120
|
+
const key = entry.name.replace(/-([a-z])/g, (_, l) => l.toUpperCase());
|
|
121
|
+
paint[key] = Math.round(entry.startTime);
|
|
122
|
+
}
|
|
123
|
+
if (!navigation && Object.keys(paint).length === 0)
|
|
124
|
+
return null;
|
|
125
|
+
return { navigation, paint };
|
|
126
|
+
});
|
|
127
|
+
if (webVitals) {
|
|
128
|
+
await testInfo.attach('piwi-dashboard-web-vitals', {
|
|
129
|
+
contentType: 'application/json',
|
|
130
|
+
body: Buffer.from(JSON.stringify(webVitals)),
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
catch {
|
|
135
|
+
/* ignore */
|
|
136
|
+
}
|
|
137
|
+
},
|
|
138
|
+
};
|
|
139
|
+
/**
|
|
140
|
+
* Extend a Playwright `test` object with Piwi Dashboard fixtures.
|
|
141
|
+
*
|
|
142
|
+
* Use this instead of importing `@playwright/test` directly from this package
|
|
143
|
+
* to avoid the "Requiring @playwright/test second time" error caused by
|
|
144
|
+
* duplicate module resolution.
|
|
145
|
+
*
|
|
146
|
+
* @example
|
|
147
|
+
* ```ts
|
|
148
|
+
* import { test as base } from '@playwright/test';
|
|
149
|
+
* import { extendDashboardFixtures } from '@piwitests/reporter/fixtures';
|
|
150
|
+
*
|
|
151
|
+
* export const test = extendDashboardFixtures(base);
|
|
152
|
+
* ```
|
|
153
|
+
*/
|
|
154
|
+
function extendDashboardFixtures(test) {
|
|
155
|
+
return test.extend(exports.dashboardFixtures);
|
|
156
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import type { PiwiDashboardOptions } from './config.js';
|
|
2
|
+
/** Produce a deterministic short SHA-1 hex hash for a project name (used in temp file names) */
|
|
3
|
+
export declare function hashForProject(projectName: string): string;
|
|
4
|
+
/**
|
|
5
|
+
* Extract a worker index from a Playwright `TestResult`, falling back to
|
|
6
|
+
* `parallelIndex` when `workerIndex` is absent. Returns `null` when neither is
|
|
7
|
+
* set. Localizes the `as any` reach into Playwright's result object so the
|
|
8
|
+
* cast lives in exactly one place.
|
|
9
|
+
*/
|
|
10
|
+
export declare function workerIndexOf(result: any): number | null;
|
|
11
|
+
export declare function detectCliFileFilters(argv?: string[]): string[];
|
|
12
|
+
/** Return the temp-file path used to exchange setup info between globalSetup and the reporter instance */
|
|
13
|
+
export declare function getSetupFilePath(projectName: string): string;
|
|
14
|
+
/** Derive a unique instance identifier by hashing hostname + projectName (or runLabel + projectName when sharding) */
|
|
15
|
+
export declare function computeInstanceId(projectName: string, runLabel?: string | null): string;
|
|
16
|
+
/** Detect a stable CI run label from well-known environment variables. Returns null outside CI. */
|
|
17
|
+
export declare function detectCiRunLabel(): string | null;
|
|
18
|
+
/** Information saved by the global setup for the reporter instance to consume */
|
|
19
|
+
export interface SetupInfo {
|
|
20
|
+
/** Server-assigned run ID */
|
|
21
|
+
runId: number;
|
|
22
|
+
/** One-time token used to authenticate the /begin call */
|
|
23
|
+
setupToken: string;
|
|
24
|
+
/** Project name this run belongs to */
|
|
25
|
+
projectName: string;
|
|
26
|
+
}
|
|
27
|
+
/** Read and delete the setup info file for the given project. Returns `null` when no file exists. */
|
|
28
|
+
export declare function readSetupInfo(projectName: string): SetupInfo | null;
|
|
29
|
+
/** Read a snippet of source code surrounding the given line, returning a formatted string with line numbers. Returns `null` on error. */
|
|
30
|
+
export declare function readSourceSnippet(file: string, line: number, context: number): string | null;
|
|
31
|
+
/** Create a concurrency limiter that ensures at most `maxConcurrent` async operations run simultaneously */
|
|
32
|
+
export declare function createLimiter(maxConcurrent: number): <T>(fn: () => Promise<T>) => Promise<T>;
|
|
33
|
+
/**
|
|
34
|
+
* Create a Playwright `globalSetup` function that registers a test run on the
|
|
35
|
+
* Piwi Dashboard server at the start of the run. An optional `userSetup` is
|
|
36
|
+
* called afterward so existing setup logic is preserved.
|
|
37
|
+
*
|
|
38
|
+
* The server-run ID and a one-time token are written to a temp file so the
|
|
39
|
+
* reporter instance can pick them up during the streaming handshake.
|
|
40
|
+
*
|
|
41
|
+
* @param options Piwi Dashboard options (uses `serverUrl`, `projectName`, …).
|
|
42
|
+
* @param userSetup An existing global setup to chain after the Piwi registration.
|
|
43
|
+
*/
|
|
44
|
+
export declare function createGlobalSetup(options?: PiwiDashboardOptions, userSetup?: (config: any) => any): (config: any) => Promise<any>;
|