@qualflare/playwright 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 +190 -0
- package/README.md +139 -0
- package/dist/index.cjs +139 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +79 -0
- package/dist/index.d.ts +79 -0
- package/dist/index.js +111 -0
- package/dist/index.js.map +1 -0
- package/dist/reporter/index.cjs +949 -0
- package/dist/reporter/index.cjs.map +1 -0
- package/dist/reporter/index.d.cts +66 -0
- package/dist/reporter/index.d.ts +66 -0
- package/dist/reporter/index.js +916 -0
- package/dist/reporter/index.js.map +1 -0
- package/dist/resolve-config-CSjXFl7v.d.cts +292 -0
- package/dist/resolve-config-CSjXFl7v.d.ts +292 -0
- package/package.json +81 -0
|
@@ -0,0 +1,916 @@
|
|
|
1
|
+
// src/reporter/reporter.ts
|
|
2
|
+
import { randomUUID as randomUUID2 } from "crypto";
|
|
3
|
+
import * as fs3 from "fs";
|
|
4
|
+
import * as path3 from "path";
|
|
5
|
+
|
|
6
|
+
// src/shared/constants.ts
|
|
7
|
+
var RESERVED_MESSAGE_MEDIA_TYPE = "application/vnd.qualflare.message+json";
|
|
8
|
+
var MAX_SUITES_PER_LAUNCH = 2e3;
|
|
9
|
+
var MAX_CASES_PER_SUITE = 5e3;
|
|
10
|
+
var MAX_ATTACHMENTS_PER_CASE = 50;
|
|
11
|
+
var MAX_LABELS_PER_CASE = 100;
|
|
12
|
+
var MAX_LINKS_PER_CASE = 20;
|
|
13
|
+
var MAX_TAGS_PER_CASE = 64;
|
|
14
|
+
var MAX_TAG_LENGTH = 255;
|
|
15
|
+
var MAX_VIDEO_UPLOAD_BYTES = 50 * 1024 * 1024;
|
|
16
|
+
var MAX_STEPS_PER_TEST_ATTEMPT = 300;
|
|
17
|
+
|
|
18
|
+
// src/config/ci-detect.ts
|
|
19
|
+
import * as ciInfo from "ci-info";
|
|
20
|
+
function parsePositiveInt(raw) {
|
|
21
|
+
if (!raw) {
|
|
22
|
+
return void 0;
|
|
23
|
+
}
|
|
24
|
+
const n = Number.parseInt(raw, 10);
|
|
25
|
+
return Number.isFinite(n) && n >= 1 ? n : void 0;
|
|
26
|
+
}
|
|
27
|
+
function nonEmpty(raw) {
|
|
28
|
+
return raw && raw.length > 0 ? raw : void 0;
|
|
29
|
+
}
|
|
30
|
+
var PROVIDERS = [
|
|
31
|
+
{
|
|
32
|
+
detect: (env) => env.GITHUB_ACTIONS === "true",
|
|
33
|
+
providerName: "GitHub Actions",
|
|
34
|
+
buildNumber: (env) => nonEmpty(env.GITHUB_RUN_NUMBER),
|
|
35
|
+
runUrl: (env) => env.GITHUB_SERVER_URL && env.GITHUB_REPOSITORY && env.GITHUB_RUN_ID ? `${env.GITHUB_SERVER_URL}/${env.GITHUB_REPOSITORY}/actions/runs/${env.GITHUB_RUN_ID}` : void 0,
|
|
36
|
+
prNumber: (env) => {
|
|
37
|
+
const match = /^refs\/pull\/(\d+)\/merge$/.exec(env.GITHUB_REF ?? "");
|
|
38
|
+
return match ? parsePositiveInt(match[1]) : void 0;
|
|
39
|
+
}
|
|
40
|
+
},
|
|
41
|
+
{
|
|
42
|
+
detect: (env) => env.GITLAB_CI === "true",
|
|
43
|
+
providerName: "GitLab CI",
|
|
44
|
+
buildNumber: (env) => nonEmpty(env.CI_PIPELINE_IID),
|
|
45
|
+
runUrl: (env) => nonEmpty(env.CI_PIPELINE_URL),
|
|
46
|
+
prNumber: (env) => parsePositiveInt(env.CI_MERGE_REQUEST_IID)
|
|
47
|
+
},
|
|
48
|
+
{
|
|
49
|
+
detect: (env) => env.CIRCLECI === "true",
|
|
50
|
+
providerName: "CircleCI",
|
|
51
|
+
buildNumber: (env) => nonEmpty(env.CIRCLE_BUILD_NUM),
|
|
52
|
+
runUrl: (env) => nonEmpty(env.CIRCLE_BUILD_URL),
|
|
53
|
+
prNumber: (env) => parsePositiveInt(env.CIRCLE_PR_NUMBER)
|
|
54
|
+
},
|
|
55
|
+
{
|
|
56
|
+
detect: (env) => env.BUILDKITE === "true",
|
|
57
|
+
providerName: "Buildkite",
|
|
58
|
+
buildNumber: (env) => nonEmpty(env.BUILDKITE_BUILD_NUMBER),
|
|
59
|
+
runUrl: (env) => nonEmpty(env.BUILDKITE_BUILD_URL),
|
|
60
|
+
prNumber: (env) => {
|
|
61
|
+
const raw = env.BUILDKITE_PULL_REQUEST;
|
|
62
|
+
if (!raw || raw === "false") {
|
|
63
|
+
return void 0;
|
|
64
|
+
}
|
|
65
|
+
return parsePositiveInt(raw);
|
|
66
|
+
}
|
|
67
|
+
},
|
|
68
|
+
{
|
|
69
|
+
// Jenkins has no simple `JENKINS=true`-style flag; JENKINS_URL is always
|
|
70
|
+
// set by the Jenkins agent and is the conventional detection signal.
|
|
71
|
+
detect: (env) => Boolean(env.JENKINS_URL),
|
|
72
|
+
providerName: "Jenkins",
|
|
73
|
+
buildNumber: (env) => nonEmpty(env.BUILD_NUMBER),
|
|
74
|
+
runUrl: (env) => nonEmpty(env.BUILD_URL)
|
|
75
|
+
// Jenkins has no standardized PR-number env var across its many PR
|
|
76
|
+
// plugins (Multibranch, GitHub Branch Source, etc.) — deliberately
|
|
77
|
+
// omitted rather than guessing at a plugin-specific variable.
|
|
78
|
+
},
|
|
79
|
+
{
|
|
80
|
+
detect: (env) => env.TF_BUILD === "True" || env.TF_BUILD === "true",
|
|
81
|
+
providerName: "Azure Pipelines",
|
|
82
|
+
buildNumber: (env) => nonEmpty(env.BUILD_BUILDID),
|
|
83
|
+
runUrl: (env) => {
|
|
84
|
+
const collectionUri = env.SYSTEM_TEAMFOUNDATIONCOLLECTIONURI;
|
|
85
|
+
const project = env.SYSTEM_TEAMPROJECT;
|
|
86
|
+
const buildId = env.BUILD_BUILDID;
|
|
87
|
+
if (!collectionUri || !project || !buildId) {
|
|
88
|
+
return void 0;
|
|
89
|
+
}
|
|
90
|
+
return `${collectionUri.replace(/\/+$/, "")}/${encodeURIComponent(project)}/_build/results?buildId=${buildId}`;
|
|
91
|
+
},
|
|
92
|
+
prNumber: (env) => parsePositiveInt(env.SYSTEM_PULLREQUEST_PULLREQUESTNUMBER)
|
|
93
|
+
},
|
|
94
|
+
{
|
|
95
|
+
detect: (env) => Boolean(env.BITBUCKET_BUILD_NUMBER),
|
|
96
|
+
providerName: "Bitbucket Pipelines",
|
|
97
|
+
buildNumber: (env) => nonEmpty(env.BITBUCKET_BUILD_NUMBER),
|
|
98
|
+
runUrl: (env) => {
|
|
99
|
+
const origin = env.BITBUCKET_GIT_HTTP_ORIGIN;
|
|
100
|
+
if (!origin) {
|
|
101
|
+
return void 0;
|
|
102
|
+
}
|
|
103
|
+
const resultsId = env.BITBUCKET_PIPELINE_UUID ?? env.BITBUCKET_BUILD_NUMBER;
|
|
104
|
+
return resultsId ? `${origin}/addon/pipelines/home#!/results/${resultsId}` : void 0;
|
|
105
|
+
},
|
|
106
|
+
prNumber: (env) => parsePositiveInt(env.BITBUCKET_PR_ID)
|
|
107
|
+
}
|
|
108
|
+
];
|
|
109
|
+
function detectCi(env = process.env) {
|
|
110
|
+
const provider = PROVIDERS.find((p) => p.detect(env));
|
|
111
|
+
if (provider) {
|
|
112
|
+
const result = { ciProvider: provider.providerName };
|
|
113
|
+
const buildNumber = provider.buildNumber?.(env);
|
|
114
|
+
if (buildNumber !== void 0) result.ciBuildNumber = buildNumber;
|
|
115
|
+
const runUrl = provider.runUrl?.(env);
|
|
116
|
+
if (runUrl !== void 0) result.ciRunUrl = runUrl;
|
|
117
|
+
const prNumber = provider.prNumber?.(env);
|
|
118
|
+
if (prNumber !== void 0) result.ciPrNumber = prNumber;
|
|
119
|
+
return result;
|
|
120
|
+
}
|
|
121
|
+
if (ciInfo.name) {
|
|
122
|
+
return { ciProvider: ciInfo.name };
|
|
123
|
+
}
|
|
124
|
+
return {};
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// src/config/git-detect.ts
|
|
128
|
+
import { execFileSync } from "child_process";
|
|
129
|
+
var defaultExecGit = (args, cwd) => execFileSync("git", args, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] });
|
|
130
|
+
function firstEnv(env, ...names) {
|
|
131
|
+
for (const name2 of names) {
|
|
132
|
+
const value = env[name2];
|
|
133
|
+
if (value) {
|
|
134
|
+
return value;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
return void 0;
|
|
138
|
+
}
|
|
139
|
+
function detectBranchFromGit(exec, cwd) {
|
|
140
|
+
try {
|
|
141
|
+
const out = exec(["symbolic-ref", "--short", "-q", "HEAD"], cwd).trim();
|
|
142
|
+
return out || void 0;
|
|
143
|
+
} catch {
|
|
144
|
+
return void 0;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
function detectCommitFromGit(exec, cwd) {
|
|
148
|
+
try {
|
|
149
|
+
const out = exec(["rev-parse", "HEAD"], cwd).trim();
|
|
150
|
+
return out || void 0;
|
|
151
|
+
} catch {
|
|
152
|
+
return void 0;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
function detectGit(env = process.env, cwd = process.cwd(), exec = defaultExecGit) {
|
|
156
|
+
const branch = firstEnv(env, "GIT_BRANCH", "GITHUB_REF_NAME", "CI_COMMIT_REF_NAME", "BITBUCKET_BRANCH") ?? detectBranchFromGit(exec, cwd);
|
|
157
|
+
const commit = firstEnv(env, "GIT_COMMIT", "GITHUB_SHA", "CI_COMMIT_SHA", "BITBUCKET_COMMIT") ?? detectCommitFromGit(exec, cwd);
|
|
158
|
+
const result = {};
|
|
159
|
+
if (branch !== void 0) result.branch = branch;
|
|
160
|
+
if (commit !== void 0) result.commit = commit;
|
|
161
|
+
return result;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// src/config/resolve-config.ts
|
|
165
|
+
function firstEnv2(...names) {
|
|
166
|
+
for (const name2 of names) {
|
|
167
|
+
const value = process.env[name2];
|
|
168
|
+
if (value !== void 0 && value !== "") {
|
|
169
|
+
return value;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
return void 0;
|
|
173
|
+
}
|
|
174
|
+
function envBool(...names) {
|
|
175
|
+
const raw = firstEnv2(...names);
|
|
176
|
+
if (raw === void 0) {
|
|
177
|
+
return void 0;
|
|
178
|
+
}
|
|
179
|
+
return raw === "true" || raw === "1";
|
|
180
|
+
}
|
|
181
|
+
function envInt(...names) {
|
|
182
|
+
const raw = firstEnv2(...names);
|
|
183
|
+
if (raw === void 0) {
|
|
184
|
+
return void 0;
|
|
185
|
+
}
|
|
186
|
+
const parsed = Number.parseInt(raw, 10);
|
|
187
|
+
return Number.isFinite(parsed) ? parsed : void 0;
|
|
188
|
+
}
|
|
189
|
+
function resolveConfig(options, deps = {}) {
|
|
190
|
+
const doDetectGit = deps.detectGit ?? detectGit;
|
|
191
|
+
const doDetectCi = deps.detectCi ?? detectCi;
|
|
192
|
+
const enabled = options.enabled ?? envBool("QUALFLARE_ENABLED") ?? true;
|
|
193
|
+
const outputDir = options.outputDir || firstEnv2("QUALFLARE_OUTPUT_DIR") || "./qualflare-results";
|
|
194
|
+
const shardIndex = options.shardIndex ?? envInt("QUALFLARE_SHARD_INDEX") ?? deps.detectedShardIndex;
|
|
195
|
+
const milestoneRaw = options.milestone !== void 0 ? options.milestone : envInt("QUALFLARE_MILESTONE", "QF_MILESTONE");
|
|
196
|
+
const milestone = milestoneRaw !== void 0 && milestoneRaw !== null && milestoneRaw >= 1 ? milestoneRaw : null;
|
|
197
|
+
const envBranch = firstEnv2("QUALFLARE_BRANCH", "QF_BRANCH");
|
|
198
|
+
const envCommit = firstEnv2("QUALFLARE_COMMIT", "QF_COMMIT");
|
|
199
|
+
const needsGitDetection = options.branch === void 0 && envBranch === void 0 || options.commit === void 0 && envCommit === void 0;
|
|
200
|
+
const detectedGit = needsGitDetection ? doDetectGit() : {};
|
|
201
|
+
const branch = options.branch !== void 0 ? options.branch : envBranch ?? detectedGit.branch ?? null;
|
|
202
|
+
const commit = options.commit !== void 0 ? options.commit : envCommit ?? detectedGit.commit ?? null;
|
|
203
|
+
const detectedCi = doDetectCi();
|
|
204
|
+
const ciProvider = options.ciProvider ?? detectedCi.ciProvider;
|
|
205
|
+
const ciBuildNumber = options.ciBuildNumber ?? detectedCi.ciBuildNumber;
|
|
206
|
+
const ciRunUrl = options.ciRunUrl ?? detectedCi.ciRunUrl;
|
|
207
|
+
const ciPrNumber = options.ciPrNumber ?? detectedCi.ciPrNumber;
|
|
208
|
+
return {
|
|
209
|
+
// `||` (truthy check), not `??`, for these three REQUIRED-non-empty wire
|
|
210
|
+
// fields — an explicit `''` option must not silently win over the
|
|
211
|
+
// default (the server rejects an empty `environment`). Ported verbatim from
|
|
212
|
+
// qualflare-cypress, where this was found via deep adversarial review.
|
|
213
|
+
environment: (options.environment || void 0) ?? firstEnv2("QUALFLARE_ENVIRONMENT", "QF_ENVIRONMENT") ?? "development",
|
|
214
|
+
language: (options.language || void 0) ?? firstEnv2("QUALFLARE_LANGUAGE", "QF_LANGUAGE") ?? "en-US",
|
|
215
|
+
milestone,
|
|
216
|
+
branch,
|
|
217
|
+
commit,
|
|
218
|
+
platform: options.platform ?? "web",
|
|
219
|
+
framework: options.framework || "playwright",
|
|
220
|
+
os: options.os,
|
|
221
|
+
browser: options.browser,
|
|
222
|
+
properties: options.properties,
|
|
223
|
+
ciProvider,
|
|
224
|
+
ciBuildNumber,
|
|
225
|
+
ciRunUrl,
|
|
226
|
+
ciPrNumber,
|
|
227
|
+
attachScreenshots: options.attachScreenshots ?? envBool("QUALFLARE_ATTACH_SCREENSHOTS") ?? true,
|
|
228
|
+
includeApiSteps: options.includeApiSteps ?? envBool("QUALFLARE_INCLUDE_API_STEPS") ?? false,
|
|
229
|
+
maxAttachmentBytes: options.maxAttachmentBytes ?? envInt("QUALFLARE_MAX_ATTACHMENT_BYTES") ?? 15e5,
|
|
230
|
+
maxTotalAttachmentBytes: options.maxTotalAttachmentBytes ?? envInt("QUALFLARE_MAX_TOTAL_ATTACHMENT_BYTES") ?? 75e4,
|
|
231
|
+
maxVideoBytes: options.maxVideoBytes ?? envInt("QUALFLARE_MAX_VIDEO_BYTES") ?? MAX_VIDEO_UPLOAD_BYTES,
|
|
232
|
+
debug: options.debug ?? envBool("QUALFLARE_DEBUG", "QF_DEBUG") ?? false,
|
|
233
|
+
enabled,
|
|
234
|
+
outputDir,
|
|
235
|
+
shardIndex
|
|
236
|
+
};
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// src/shared/logger.ts
|
|
240
|
+
var PREFIX = "[qualflare-playwright]";
|
|
241
|
+
var logger = {
|
|
242
|
+
debug(...args) {
|
|
243
|
+
console.debug(PREFIX, ...args);
|
|
244
|
+
},
|
|
245
|
+
info(...args) {
|
|
246
|
+
console.log(PREFIX, ...args);
|
|
247
|
+
},
|
|
248
|
+
warn(...args) {
|
|
249
|
+
console.warn(PREFIX, ...args);
|
|
250
|
+
},
|
|
251
|
+
error(...args) {
|
|
252
|
+
console.error(PREFIX, ...args);
|
|
253
|
+
}
|
|
254
|
+
};
|
|
255
|
+
|
|
256
|
+
// src/reporter/attachment-reader.ts
|
|
257
|
+
import * as fs2 from "fs";
|
|
258
|
+
|
|
259
|
+
// src/reporter/video-writer.ts
|
|
260
|
+
import * as fs from "fs";
|
|
261
|
+
import * as path from "path";
|
|
262
|
+
import { randomUUID } from "crypto";
|
|
263
|
+
var VIDEO_MIME_TYPES_BY_EXTENSION = {
|
|
264
|
+
".mp4": "video/mp4",
|
|
265
|
+
".webm": "video/webm",
|
|
266
|
+
".mov": "video/quicktime"
|
|
267
|
+
};
|
|
268
|
+
function copyVideoAttachment(filePath, outputDir, maxVideoBytes) {
|
|
269
|
+
const ext = path.extname(filePath).toLowerCase();
|
|
270
|
+
const mimeType = VIDEO_MIME_TYPES_BY_EXTENSION[ext];
|
|
271
|
+
if (!mimeType) {
|
|
272
|
+
logger.warn(`skipping video attachment "${filePath}": unsupported video format.`);
|
|
273
|
+
return void 0;
|
|
274
|
+
}
|
|
275
|
+
let fileSize;
|
|
276
|
+
try {
|
|
277
|
+
fileSize = fs.statSync(filePath).size;
|
|
278
|
+
} catch (err) {
|
|
279
|
+
logger.warn(`skipping video attachment "${filePath}": could not stat file: ${err.message}`);
|
|
280
|
+
return void 0;
|
|
281
|
+
}
|
|
282
|
+
if (fileSize > maxVideoBytes) {
|
|
283
|
+
logger.warn(
|
|
284
|
+
`skipping video attachment "${filePath}": ${fileSize} bytes exceeds the configured maxVideoBytes cap of ${maxVideoBytes} bytes.`
|
|
285
|
+
);
|
|
286
|
+
return void 0;
|
|
287
|
+
}
|
|
288
|
+
const localVideoPath = `${randomUUID()}${ext}`;
|
|
289
|
+
try {
|
|
290
|
+
fs.mkdirSync(outputDir, { recursive: true });
|
|
291
|
+
fs.copyFileSync(filePath, path.join(outputDir, localVideoPath));
|
|
292
|
+
} catch (err) {
|
|
293
|
+
logger.warn(`skipping video attachment "${filePath}": could not copy file: ${err.message}`);
|
|
294
|
+
return void 0;
|
|
295
|
+
}
|
|
296
|
+
return { localVideoPath, fileSize, mimeType };
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// src/reporter/attachment-reader.ts
|
|
300
|
+
var AttachmentBudget = class {
|
|
301
|
+
constructor(maxTotalBytes) {
|
|
302
|
+
this.maxTotalBytes = maxTotalBytes;
|
|
303
|
+
}
|
|
304
|
+
maxTotalBytes;
|
|
305
|
+
used = 0;
|
|
306
|
+
tryReserve(bytes) {
|
|
307
|
+
if (this.used + bytes > this.maxTotalBytes) {
|
|
308
|
+
return false;
|
|
309
|
+
}
|
|
310
|
+
this.used += bytes;
|
|
311
|
+
return true;
|
|
312
|
+
}
|
|
313
|
+
/** Returns bytes to the budget when the attachment they were reserved for
|
|
314
|
+
* turns out to be discarded — a retried test's superseded attempt. Without
|
|
315
|
+
* this, a flaky test consumes budget twice and a LATER test silently loses
|
|
316
|
+
* its screenshot to an attachment nobody will ever see. */
|
|
317
|
+
release(bytes) {
|
|
318
|
+
this.used = Math.max(0, this.used - bytes);
|
|
319
|
+
}
|
|
320
|
+
get usedBytes() {
|
|
321
|
+
return this.used;
|
|
322
|
+
}
|
|
323
|
+
};
|
|
324
|
+
var NAME_VIDEO = "video";
|
|
325
|
+
var NAME_TRACE = "trace";
|
|
326
|
+
function isVideo(a) {
|
|
327
|
+
return a.name === NAME_VIDEO || (a.contentType?.startsWith("video/") ?? false);
|
|
328
|
+
}
|
|
329
|
+
function resolveAttachments(result, config, budget) {
|
|
330
|
+
if (!config.attachScreenshots) {
|
|
331
|
+
return [];
|
|
332
|
+
}
|
|
333
|
+
const out = [];
|
|
334
|
+
let capWarned = false;
|
|
335
|
+
for (const a of result.attachments) {
|
|
336
|
+
if (a.contentType === RESERVED_MESSAGE_MEDIA_TYPE) {
|
|
337
|
+
continue;
|
|
338
|
+
}
|
|
339
|
+
if (out.length >= MAX_ATTACHMENTS_PER_CASE) {
|
|
340
|
+
if (!capWarned) {
|
|
341
|
+
capWarned = true;
|
|
342
|
+
logger.warn(`a test produced more than ${MAX_ATTACHMENTS_PER_CASE} attachments; the rest were dropped.`);
|
|
343
|
+
}
|
|
344
|
+
break;
|
|
345
|
+
}
|
|
346
|
+
if (a.name === NAME_TRACE || a.contentType === "application/zip") {
|
|
347
|
+
continue;
|
|
348
|
+
}
|
|
349
|
+
if (isVideo(a)) {
|
|
350
|
+
if (!a.path) {
|
|
351
|
+
logger.warn(`skipping in-memory video attachment "${a.name}": only file-backed videos are supported.`);
|
|
352
|
+
continue;
|
|
353
|
+
}
|
|
354
|
+
const copied = copyVideoAttachment(a.path, config.outputDir, config.maxVideoBytes);
|
|
355
|
+
if (copied) {
|
|
356
|
+
out.push({
|
|
357
|
+
name: a.name,
|
|
358
|
+
mimeType: copied.mimeType,
|
|
359
|
+
localVideoPath: copied.localVideoPath,
|
|
360
|
+
fileSize: copied.fileSize
|
|
361
|
+
});
|
|
362
|
+
}
|
|
363
|
+
continue;
|
|
364
|
+
}
|
|
365
|
+
const inlined = inlineAttachment(a, config, budget);
|
|
366
|
+
if (inlined) {
|
|
367
|
+
out.push(inlined);
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
return out;
|
|
371
|
+
}
|
|
372
|
+
function inlineFromBuffer(name2, bytes, mimeType, config, budget) {
|
|
373
|
+
if (bytes.byteLength > config.maxAttachmentBytes) {
|
|
374
|
+
logger.warn(
|
|
375
|
+
`skipping attachment "${name2}": ${bytes.byteLength} bytes exceeds the configured maxAttachmentBytes cap of ${config.maxAttachmentBytes} bytes.`
|
|
376
|
+
);
|
|
377
|
+
return void 0;
|
|
378
|
+
}
|
|
379
|
+
if (!budget.tryReserve(bytes.byteLength)) {
|
|
380
|
+
logger.warn(
|
|
381
|
+
`skipping attachment "${name2}": this run's total inline-attachment budget of ${config.maxTotalAttachmentBytes} bytes is exhausted.`
|
|
382
|
+
);
|
|
383
|
+
return void 0;
|
|
384
|
+
}
|
|
385
|
+
return {
|
|
386
|
+
name: name2,
|
|
387
|
+
...mimeType ? { mimeType } : {},
|
|
388
|
+
content: bytes.toString("base64"),
|
|
389
|
+
fileSize: bytes.byteLength
|
|
390
|
+
};
|
|
391
|
+
}
|
|
392
|
+
function inlineFromFile(name2, filePath, mimeType, config, budget) {
|
|
393
|
+
let size;
|
|
394
|
+
try {
|
|
395
|
+
size = fs2.statSync(filePath).size;
|
|
396
|
+
} catch (err) {
|
|
397
|
+
logger.warn(`skipping attachment "${name2}": could not stat ${filePath}: ${err.message}`);
|
|
398
|
+
return void 0;
|
|
399
|
+
}
|
|
400
|
+
if (size > config.maxAttachmentBytes) {
|
|
401
|
+
logger.warn(
|
|
402
|
+
`skipping attachment "${name2}": ${size} bytes exceeds the configured maxAttachmentBytes cap of ${config.maxAttachmentBytes} bytes.`
|
|
403
|
+
);
|
|
404
|
+
return void 0;
|
|
405
|
+
}
|
|
406
|
+
let bytes;
|
|
407
|
+
try {
|
|
408
|
+
bytes = fs2.readFileSync(filePath);
|
|
409
|
+
} catch (err) {
|
|
410
|
+
logger.warn(`skipping attachment "${name2}": could not read ${filePath}: ${err.message}`);
|
|
411
|
+
return void 0;
|
|
412
|
+
}
|
|
413
|
+
return inlineFromBuffer(name2, bytes, mimeType, config, budget);
|
|
414
|
+
}
|
|
415
|
+
function inlineAttachment(a, config, budget) {
|
|
416
|
+
if (a.body) {
|
|
417
|
+
return inlineFromBuffer(a.name, a.body, a.contentType, config, budget);
|
|
418
|
+
}
|
|
419
|
+
if (a.path) {
|
|
420
|
+
return inlineFromFile(a.name, a.path, a.contentType, config, budget);
|
|
421
|
+
}
|
|
422
|
+
return void 0;
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
// src/shared/duration.ts
|
|
426
|
+
var NS_PER_MS = 1e6;
|
|
427
|
+
function msToNs(ms) {
|
|
428
|
+
if (!Number.isFinite(ms) || ms <= 0) {
|
|
429
|
+
return 0;
|
|
430
|
+
}
|
|
431
|
+
return Math.round(ms * NS_PER_MS);
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
// src/reporter/step-mapper.ts
|
|
435
|
+
var CATEGORY_TEST_STEP = "test.step";
|
|
436
|
+
var CATEGORY_EXPECT = "expect";
|
|
437
|
+
var CATEGORY_HOOK = "hook";
|
|
438
|
+
var CATEGORY_FIXTURE = "fixture";
|
|
439
|
+
var CATEGORY_PW_API = "pw:api";
|
|
440
|
+
var MAX_STEP_DEPTH = 10;
|
|
441
|
+
function isReportable(step, includeApiSteps) {
|
|
442
|
+
if (step.error) {
|
|
443
|
+
return true;
|
|
444
|
+
}
|
|
445
|
+
switch (step.category) {
|
|
446
|
+
case CATEGORY_TEST_STEP:
|
|
447
|
+
case CATEGORY_EXPECT:
|
|
448
|
+
case CATEGORY_HOOK:
|
|
449
|
+
return true;
|
|
450
|
+
case CATEGORY_PW_API:
|
|
451
|
+
case CATEGORY_FIXTURE:
|
|
452
|
+
return includeApiSteps;
|
|
453
|
+
default:
|
|
454
|
+
return true;
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
function formatLocation(step) {
|
|
458
|
+
if (!step.location) {
|
|
459
|
+
return void 0;
|
|
460
|
+
}
|
|
461
|
+
return `${step.location.file}:${step.location.line}`;
|
|
462
|
+
}
|
|
463
|
+
function mapSteps(steps, includeApiSteps) {
|
|
464
|
+
const out = [];
|
|
465
|
+
let capWarned = false;
|
|
466
|
+
const walk = (nodes, parentIndex, depth) => {
|
|
467
|
+
for (const node of nodes) {
|
|
468
|
+
if (!isReportable(node, includeApiSteps)) {
|
|
469
|
+
walk(node.steps, parentIndex, depth);
|
|
470
|
+
continue;
|
|
471
|
+
}
|
|
472
|
+
if (out.length >= MAX_STEPS_PER_TEST_ATTEMPT) {
|
|
473
|
+
if (!capWarned) {
|
|
474
|
+
capWarned = true;
|
|
475
|
+
logger.warn(
|
|
476
|
+
`a test produced more than ${MAX_STEPS_PER_TEST_ATTEMPT} reportable steps; the rest were dropped. Set \`includeApiSteps: false\` (the default) or reduce step nesting if this is unexpected.`
|
|
477
|
+
);
|
|
478
|
+
}
|
|
479
|
+
return;
|
|
480
|
+
}
|
|
481
|
+
const index = out.length;
|
|
482
|
+
out.push({
|
|
483
|
+
name: node.title,
|
|
484
|
+
keyword: node.category,
|
|
485
|
+
status: node.error ? "failed" : "passed",
|
|
486
|
+
duration: msToNs(node.duration),
|
|
487
|
+
...node.error ? { error: formatStepError(node) } : {},
|
|
488
|
+
...formatLocation(node) ? { location: formatLocation(node) } : {},
|
|
489
|
+
...parentIndex !== void 0 ? { parentIndex } : {}
|
|
490
|
+
});
|
|
491
|
+
const nextParent = depth + 1 >= MAX_STEP_DEPTH ? parentIndex : index;
|
|
492
|
+
walk(node.steps, nextParent, depth + 1);
|
|
493
|
+
}
|
|
494
|
+
};
|
|
495
|
+
walk(steps, void 0, 0);
|
|
496
|
+
return out;
|
|
497
|
+
}
|
|
498
|
+
function formatStepError(step) {
|
|
499
|
+
const err = step.error;
|
|
500
|
+
if (!err) {
|
|
501
|
+
return "";
|
|
502
|
+
}
|
|
503
|
+
return err.message ?? err.value ?? "step failed";
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
// src/reporter/case-builder.ts
|
|
507
|
+
function mapStatus(status) {
|
|
508
|
+
switch (status) {
|
|
509
|
+
case "passed":
|
|
510
|
+
return "passed";
|
|
511
|
+
case "failed":
|
|
512
|
+
return "failed";
|
|
513
|
+
case "timedOut":
|
|
514
|
+
return "timeout";
|
|
515
|
+
case "interrupted":
|
|
516
|
+
return "aborted";
|
|
517
|
+
case "skipped":
|
|
518
|
+
return "skipped";
|
|
519
|
+
default:
|
|
520
|
+
return "error";
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
var ANSI_PATTERN = /\u001b\[[0-9;]*m/g;
|
|
524
|
+
function stripAnsi(text) {
|
|
525
|
+
return text.replace(ANSI_PATTERN, "");
|
|
526
|
+
}
|
|
527
|
+
function formatError(result) {
|
|
528
|
+
const err = result.error;
|
|
529
|
+
if (!err) {
|
|
530
|
+
return void 0;
|
|
531
|
+
}
|
|
532
|
+
const head = err.message ?? err.value ?? "test failed";
|
|
533
|
+
const parts = [stripAnsi(head)];
|
|
534
|
+
if (err.snippet) {
|
|
535
|
+
parts.push("", stripAnsi(err.snippet));
|
|
536
|
+
}
|
|
537
|
+
if (err.stack && !head.includes(err.stack)) {
|
|
538
|
+
parts.push("", stripAnsi(err.stack));
|
|
539
|
+
}
|
|
540
|
+
return parts.join("\n");
|
|
541
|
+
}
|
|
542
|
+
function replayMetadata(result, config, budget) {
|
|
543
|
+
const meta = {
|
|
544
|
+
labels: [],
|
|
545
|
+
links: [],
|
|
546
|
+
tags: [],
|
|
547
|
+
caseParameters: [],
|
|
548
|
+
stepParameters: /* @__PURE__ */ new Map(),
|
|
549
|
+
attachments: []
|
|
550
|
+
};
|
|
551
|
+
const openSteps = [];
|
|
552
|
+
for (const a of result.attachments) {
|
|
553
|
+
if (a.contentType !== RESERVED_MESSAGE_MEDIA_TYPE || !a.body) {
|
|
554
|
+
continue;
|
|
555
|
+
}
|
|
556
|
+
let message;
|
|
557
|
+
try {
|
|
558
|
+
message = JSON.parse(a.body.toString("utf8"));
|
|
559
|
+
} catch {
|
|
560
|
+
logger.warn("ignoring an unparseable qualflare runtime message.");
|
|
561
|
+
continue;
|
|
562
|
+
}
|
|
563
|
+
switch (message.type) {
|
|
564
|
+
case "label":
|
|
565
|
+
meta.labels.push({ name: message.name, value: message.value });
|
|
566
|
+
break;
|
|
567
|
+
case "link":
|
|
568
|
+
meta.links.push({
|
|
569
|
+
type: message.linkType ?? "custom",
|
|
570
|
+
...message.name ? { name: message.name } : {},
|
|
571
|
+
url: message.url
|
|
572
|
+
});
|
|
573
|
+
break;
|
|
574
|
+
case "tag":
|
|
575
|
+
meta.tags.push(...message.tags);
|
|
576
|
+
break;
|
|
577
|
+
case "description":
|
|
578
|
+
meta.description = message.text;
|
|
579
|
+
break;
|
|
580
|
+
case "priority":
|
|
581
|
+
meta.priority = message.value;
|
|
582
|
+
break;
|
|
583
|
+
case "parameter": {
|
|
584
|
+
const param = {
|
|
585
|
+
name: message.name,
|
|
586
|
+
...message.value !== void 0 ? { value: message.value } : {},
|
|
587
|
+
...message.masked ? { masked: true } : {}
|
|
588
|
+
};
|
|
589
|
+
const openStep = openSteps[openSteps.length - 1];
|
|
590
|
+
if (openStep === void 0) {
|
|
591
|
+
meta.caseParameters.push(param);
|
|
592
|
+
} else {
|
|
593
|
+
const existing = meta.stepParameters.get(openStep) ?? [];
|
|
594
|
+
existing.push(param);
|
|
595
|
+
meta.stepParameters.set(openStep, existing);
|
|
596
|
+
}
|
|
597
|
+
break;
|
|
598
|
+
}
|
|
599
|
+
case "attachment": {
|
|
600
|
+
const inlined = inlineFromBuffer(
|
|
601
|
+
message.name,
|
|
602
|
+
Buffer.from(message.contentBase64, "base64"),
|
|
603
|
+
message.mimeType,
|
|
604
|
+
config,
|
|
605
|
+
budget
|
|
606
|
+
);
|
|
607
|
+
if (inlined) {
|
|
608
|
+
meta.attachments.push(inlined);
|
|
609
|
+
}
|
|
610
|
+
break;
|
|
611
|
+
}
|
|
612
|
+
case "attachment_from_file": {
|
|
613
|
+
const fromFile = inlineFromFile(message.name, message.path, message.mimeType, config, budget);
|
|
614
|
+
if (fromFile) {
|
|
615
|
+
meta.attachments.push(fromFile);
|
|
616
|
+
}
|
|
617
|
+
break;
|
|
618
|
+
}
|
|
619
|
+
case "step_start":
|
|
620
|
+
openSteps.push(message.name);
|
|
621
|
+
break;
|
|
622
|
+
case "step_stop":
|
|
623
|
+
openSteps.pop();
|
|
624
|
+
break;
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
return meta;
|
|
628
|
+
}
|
|
629
|
+
function capTags(tags) {
|
|
630
|
+
const unique = [...new Set(tags.map((t) => t.slice(0, MAX_TAG_LENGTH)))];
|
|
631
|
+
return unique.slice(0, MAX_TAGS_PER_CASE);
|
|
632
|
+
}
|
|
633
|
+
function buildCase(test, config, attachmentsByResult, budget) {
|
|
634
|
+
const results = test.results.filter((r) => r.workerIndex !== -1);
|
|
635
|
+
if (results.length === 0) {
|
|
636
|
+
return void 0;
|
|
637
|
+
}
|
|
638
|
+
const final = results[results.length - 1];
|
|
639
|
+
const outcome = test.outcome();
|
|
640
|
+
const expectedFailure = outcome === "expected" && final.status === "failed";
|
|
641
|
+
const status = expectedFailure ? "passed" : mapStatus(final.status);
|
|
642
|
+
const meta = replayMetadata(final, config, budget);
|
|
643
|
+
const steps = mapSteps(final.steps, config.includeApiSteps);
|
|
644
|
+
for (const [stepName, params] of meta.stepParameters) {
|
|
645
|
+
for (let i = steps.length - 1; i >= 0; i -= 1) {
|
|
646
|
+
if (steps[i].name === stepName) {
|
|
647
|
+
steps[i].parameters = [...steps[i].parameters ?? [], ...params];
|
|
648
|
+
break;
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
const projectName = test.parent.project()?.name;
|
|
653
|
+
const properties = {
|
|
654
|
+
file: test.location.file,
|
|
655
|
+
...projectName ? { project: projectName } : {}
|
|
656
|
+
};
|
|
657
|
+
for (const p of meta.caseParameters) {
|
|
658
|
+
properties[p.name] = p.value ?? "";
|
|
659
|
+
}
|
|
660
|
+
const attachments = [...attachmentsByResult.get(`${test.id}:${final.retry}`) ?? [], ...meta.attachments];
|
|
661
|
+
const nativeTags = test.tags ?? [];
|
|
662
|
+
const tags = capTags([...nativeTags, ...meta.tags]);
|
|
663
|
+
const error = formatError(final);
|
|
664
|
+
return {
|
|
665
|
+
id: test.id,
|
|
666
|
+
name: test.title,
|
|
667
|
+
className: test.location.file,
|
|
668
|
+
status,
|
|
669
|
+
duration: msToNs(final.duration),
|
|
670
|
+
retryCount: results.length - 1,
|
|
671
|
+
isFlaky: outcome === "flaky",
|
|
672
|
+
...error ? { error } : {},
|
|
673
|
+
...meta.priority ? { priority: meta.priority } : {},
|
|
674
|
+
...meta.description ? { description: meta.description } : {},
|
|
675
|
+
...tags.length > 0 ? { tags } : {},
|
|
676
|
+
properties,
|
|
677
|
+
...attachments.length > 0 ? { attachments } : {},
|
|
678
|
+
...steps.length > 0 ? { steps } : {},
|
|
679
|
+
...meta.labels.length > 0 ? { labels: meta.labels.slice(0, MAX_LABELS_PER_CASE) } : {},
|
|
680
|
+
...meta.links.length > 0 ? { links: meta.links.slice(0, MAX_LINKS_PER_CASE) } : {},
|
|
681
|
+
startedAt: final.startTime.toISOString()
|
|
682
|
+
};
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
// src/reporter/collect-builder.ts
|
|
686
|
+
import * as os from "os";
|
|
687
|
+
|
|
688
|
+
// src/config/version.ts
|
|
689
|
+
var PACKAGE_VERSION = "0.1.0";
|
|
690
|
+
|
|
691
|
+
// src/reporter/collect-builder.ts
|
|
692
|
+
function resolveOs(config) {
|
|
693
|
+
if (config.os) {
|
|
694
|
+
return config.os;
|
|
695
|
+
}
|
|
696
|
+
return `${os.type()} ${os.release()}`;
|
|
697
|
+
}
|
|
698
|
+
function resolveBrowser(config, browsers) {
|
|
699
|
+
if (config.browser) {
|
|
700
|
+
return config.browser;
|
|
701
|
+
}
|
|
702
|
+
return [...new Set(browsers)].sort().join(", ");
|
|
703
|
+
}
|
|
704
|
+
function buildCollectPayload(suites, config, browsers = []) {
|
|
705
|
+
return {
|
|
706
|
+
framework: config.framework,
|
|
707
|
+
platform: config.platform,
|
|
708
|
+
os: resolveOs(config),
|
|
709
|
+
browser: resolveBrowser(config, browsers),
|
|
710
|
+
branch: config.branch,
|
|
711
|
+
commit: config.commit,
|
|
712
|
+
environment: config.environment,
|
|
713
|
+
language: config.language,
|
|
714
|
+
milestone: config.milestone,
|
|
715
|
+
metadata: {
|
|
716
|
+
version: PACKAGE_VERSION,
|
|
717
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
718
|
+
cliName: "qualflare-playwright"
|
|
719
|
+
},
|
|
720
|
+
properties: config.properties,
|
|
721
|
+
suites,
|
|
722
|
+
ciProvider: config.ciProvider,
|
|
723
|
+
ciBuildNumber: config.ciBuildNumber,
|
|
724
|
+
ciRunUrl: config.ciRunUrl,
|
|
725
|
+
ciPrNumber: config.ciPrNumber
|
|
726
|
+
};
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
// src/reporter/suite-builder.ts
|
|
730
|
+
import * as path2 from "path";
|
|
731
|
+
function relativizeFile(file, rootDir) {
|
|
732
|
+
const relative2 = path2.isAbsolute(file) ? path2.relative(rootDir, file) : file;
|
|
733
|
+
return relative2.split(path2.sep).join("/");
|
|
734
|
+
}
|
|
735
|
+
function groupIntoSuites(cases) {
|
|
736
|
+
const byFile = /* @__PURE__ */ new Map();
|
|
737
|
+
for (const entry of cases) {
|
|
738
|
+
const existing = byFile.get(entry.file);
|
|
739
|
+
if (existing) {
|
|
740
|
+
existing.push(entry);
|
|
741
|
+
} else {
|
|
742
|
+
byFile.set(entry.file, [entry]);
|
|
743
|
+
}
|
|
744
|
+
}
|
|
745
|
+
const suites = [];
|
|
746
|
+
for (const [file, entries] of byFile) {
|
|
747
|
+
let kept = entries;
|
|
748
|
+
if (kept.length > MAX_CASES_PER_SUITE) {
|
|
749
|
+
logger.warn(
|
|
750
|
+
`suite "${file}" produced ${kept.length} cases, over the server's limit of ${MAX_CASES_PER_SUITE}; the rest were dropped.`
|
|
751
|
+
);
|
|
752
|
+
kept = kept.slice(0, MAX_CASES_PER_SUITE);
|
|
753
|
+
}
|
|
754
|
+
const browsers = [...new Set(kept.map((e) => e.browser).filter((b) => Boolean(b)))].sort();
|
|
755
|
+
suites.push({
|
|
756
|
+
name: file,
|
|
757
|
+
category: "playwright",
|
|
758
|
+
duration: kept.reduce((sum, e) => sum + e.testCase.duration, 0),
|
|
759
|
+
...browsers.length > 0 ? { browser: browsers.join(", ") } : {},
|
|
760
|
+
cases: kept.map((e) => e.testCase)
|
|
761
|
+
});
|
|
762
|
+
}
|
|
763
|
+
if (suites.length > MAX_SUITES_PER_LAUNCH) {
|
|
764
|
+
logger.warn(
|
|
765
|
+
`this run produced ${suites.length} suites, over the server's limit of ${MAX_SUITES_PER_LAUNCH}; the rest were dropped.`
|
|
766
|
+
);
|
|
767
|
+
return suites.slice(0, MAX_SUITES_PER_LAUNCH);
|
|
768
|
+
}
|
|
769
|
+
return suites;
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
// src/reporter/reporter.ts
|
|
773
|
+
var QualflareReporter = class {
|
|
774
|
+
options;
|
|
775
|
+
config;
|
|
776
|
+
rootDir = process.cwd();
|
|
777
|
+
cases = [];
|
|
778
|
+
browsers = /* @__PURE__ */ new Set();
|
|
779
|
+
budget = new AttachmentBudget(0);
|
|
780
|
+
rootSuite;
|
|
781
|
+
attachmentsByResult = /* @__PURE__ */ new Map();
|
|
782
|
+
latestAttemptByTest = /* @__PURE__ */ new Map();
|
|
783
|
+
constructor(options = {}) {
|
|
784
|
+
this.options = options;
|
|
785
|
+
}
|
|
786
|
+
/** Returning false tells Playwright to auto-inject a terminal reporter
|
|
787
|
+
* (`line` locally, `dot` on CI) so a user who registers only this one is
|
|
788
|
+
* not left staring at a blank console. This reporter prints nothing but
|
|
789
|
+
* warnings and a single completion line. */
|
|
790
|
+
printsToStdio() {
|
|
791
|
+
return false;
|
|
792
|
+
}
|
|
793
|
+
onBegin(config, suite) {
|
|
794
|
+
this.guard("onBegin", () => {
|
|
795
|
+
this.rootSuite = suite;
|
|
796
|
+
this.rootDir = config.rootDir || process.cwd();
|
|
797
|
+
const detectedShardIndex = config.shard ? config.shard.current - 1 : void 0;
|
|
798
|
+
this.config = resolveConfig(this.options, { detectedShardIndex });
|
|
799
|
+
this.budget = new AttachmentBudget(this.config.maxTotalAttachmentBytes);
|
|
800
|
+
for (const project of config.projects) {
|
|
801
|
+
const browserName = project.use?.browserName;
|
|
802
|
+
if (browserName) {
|
|
803
|
+
this.browsers.add(browserName);
|
|
804
|
+
}
|
|
805
|
+
}
|
|
806
|
+
});
|
|
807
|
+
}
|
|
808
|
+
onTestEnd(test, result) {
|
|
809
|
+
this.guard("onTestEnd", () => {
|
|
810
|
+
const config = this.config;
|
|
811
|
+
if (!config || !config.enabled) {
|
|
812
|
+
return;
|
|
813
|
+
}
|
|
814
|
+
this.discardSupersededAttempt(test.id, result.retry, config.outputDir);
|
|
815
|
+
this.attachmentsByResult.set(`${test.id}:${result.retry}`, resolveAttachments(result, config, this.budget));
|
|
816
|
+
this.latestAttemptByTest.set(test.id, result.retry);
|
|
817
|
+
});
|
|
818
|
+
}
|
|
819
|
+
async onEnd(_result) {
|
|
820
|
+
await Promise.resolve();
|
|
821
|
+
this.guard("onEnd", () => {
|
|
822
|
+
const config = this.config;
|
|
823
|
+
if (!config || !config.enabled) {
|
|
824
|
+
return;
|
|
825
|
+
}
|
|
826
|
+
this.writeReport(config);
|
|
827
|
+
});
|
|
828
|
+
}
|
|
829
|
+
/** Collects every test from the (possibly nested) suite tree. */
|
|
830
|
+
collectCases(root, config) {
|
|
831
|
+
for (const test of root.allTests()) {
|
|
832
|
+
const built = buildCase(test, config, this.attachmentsByResult, this.budget);
|
|
833
|
+
if (!built) {
|
|
834
|
+
continue;
|
|
835
|
+
}
|
|
836
|
+
const file = relativizeFile(test.location.file, this.rootDir);
|
|
837
|
+
built.className = file;
|
|
838
|
+
if (built.properties) {
|
|
839
|
+
built.properties["file"] = file;
|
|
840
|
+
}
|
|
841
|
+
const browserName = test.parent.project()?.use?.browserName;
|
|
842
|
+
this.cases.push({
|
|
843
|
+
file,
|
|
844
|
+
...browserName ? { browser: browserName } : {},
|
|
845
|
+
testCase: built
|
|
846
|
+
});
|
|
847
|
+
}
|
|
848
|
+
}
|
|
849
|
+
writeReport(config) {
|
|
850
|
+
if (this.rootSuite) {
|
|
851
|
+
this.collectCases(this.rootSuite, config);
|
|
852
|
+
}
|
|
853
|
+
const suites = groupIntoSuites(this.cases);
|
|
854
|
+
if (suites.length === 0) {
|
|
855
|
+
logger.info("no test results were captured this run \u2014 skipping file write.");
|
|
856
|
+
return;
|
|
857
|
+
}
|
|
858
|
+
const collect = buildCollectPayload(suites, config, [...this.browsers]);
|
|
859
|
+
if (config.shardIndex !== void 0) {
|
|
860
|
+
for (const suite of collect.suites) {
|
|
861
|
+
for (const testCase of suite.cases) {
|
|
862
|
+
testCase.shardIndex = config.shardIndex;
|
|
863
|
+
}
|
|
864
|
+
}
|
|
865
|
+
}
|
|
866
|
+
const outputDir = this.resolveOutputDir(config.outputDir);
|
|
867
|
+
fs3.mkdirSync(outputDir, { recursive: true });
|
|
868
|
+
const outputPath = path3.join(outputDir, `${randomUUID2()}.json`);
|
|
869
|
+
fs3.writeFileSync(outputPath, JSON.stringify(collect));
|
|
870
|
+
logger.info(`wrote Collect payload to ${outputPath} \u2014 run \`qualflare-cli collect ${outputDir}\` to upload it.`);
|
|
871
|
+
}
|
|
872
|
+
/** Relative `outputDir` resolves against the Playwright config's own
|
|
873
|
+
* directory, not the shell's cwd — a user running `npx playwright test`
|
|
874
|
+
* from a monorepo root should still write next to their config. */
|
|
875
|
+
resolveOutputDir(outputDir) {
|
|
876
|
+
return path3.isAbsolute(outputDir) ? outputDir : path3.resolve(this.options.configDir ?? this.rootDir, outputDir);
|
|
877
|
+
}
|
|
878
|
+
/** Drops everything an earlier, now-superseded attempt produced: deletes the
|
|
879
|
+
* video copied into outputDir and refunds its bytes to the run budget. */
|
|
880
|
+
discardSupersededAttempt(testId, retry, outputDir) {
|
|
881
|
+
const previous = this.latestAttemptByTest.get(testId);
|
|
882
|
+
if (previous === void 0 || previous >= retry) {
|
|
883
|
+
return;
|
|
884
|
+
}
|
|
885
|
+
const key = `${testId}:${previous}`;
|
|
886
|
+
for (const attachment of this.attachmentsByResult.get(key) ?? []) {
|
|
887
|
+
if (attachment.localVideoPath) {
|
|
888
|
+
try {
|
|
889
|
+
fs3.rmSync(path3.join(this.resolveOutputDir(outputDir), attachment.localVideoPath), { force: true });
|
|
890
|
+
} catch {
|
|
891
|
+
}
|
|
892
|
+
}
|
|
893
|
+
if (attachment.fileSize && attachment.content) {
|
|
894
|
+
this.budget.release(attachment.fileSize);
|
|
895
|
+
}
|
|
896
|
+
}
|
|
897
|
+
this.attachmentsByResult.delete(key);
|
|
898
|
+
}
|
|
899
|
+
/**
|
|
900
|
+
* Playwright SWALLOWS anything a reporter throws (Multiplexer._wrap catches
|
|
901
|
+
* it and re-dispatches as onError), so an unguarded bug here vanishes
|
|
902
|
+
* silently and the user just gets no report. Every hook body runs through
|
|
903
|
+
* this instead, which at least says what broke and where.
|
|
904
|
+
*/
|
|
905
|
+
guard(hook, fn) {
|
|
906
|
+
try {
|
|
907
|
+
fn();
|
|
908
|
+
} catch (err) {
|
|
909
|
+
logger.error(`${hook} failed: ${err.message}`);
|
|
910
|
+
}
|
|
911
|
+
}
|
|
912
|
+
};
|
|
913
|
+
export {
|
|
914
|
+
QualflareReporter as default
|
|
915
|
+
};
|
|
916
|
+
//# sourceMappingURL=index.js.map
|