@supatest/playwright-reporter 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.cjs ADDED
@@ -0,0 +1,900 @@
1
+ "use strict";
2
+ var __create = Object.create;
3
+ var __defProp = Object.defineProperty;
4
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
5
+ var __getOwnPropNames = Object.getOwnPropertyNames;
6
+ var __getProtoOf = Object.getPrototypeOf;
7
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
8
+ var __export = (target, all) => {
9
+ for (var name in all)
10
+ __defProp(target, name, { get: all[name], enumerable: true });
11
+ };
12
+ var __copyProps = (to, from, except, desc) => {
13
+ if (from && typeof from === "object" || typeof from === "function") {
14
+ for (let key of __getOwnPropNames(from))
15
+ if (!__hasOwnProp.call(to, key) && key !== except)
16
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
17
+ }
18
+ return to;
19
+ };
20
+ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
21
+ // If the importer is in node compatibility mode or this is not an ESM
22
+ // file that has been converted to a CommonJS file using a Babel-
23
+ // compatible transform (i.e. "__esModule" has not been set), then set
24
+ // "default" to the CommonJS "module.exports" for node compatibility.
25
+ isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
26
+ mod
27
+ ));
28
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
29
+
30
+ // src/index.ts
31
+ var index_exports = {};
32
+ __export(index_exports, {
33
+ default: () => SupatestPlaywrightReporter
34
+ });
35
+ module.exports = __toCommonJS(index_exports);
36
+ var import_node_crypto = require("crypto");
37
+ var import_node_fs2 = __toESM(require("fs"), 1);
38
+ var import_node_os = __toESM(require("os"), 1);
39
+ var import_node_path = __toESM(require("path"), 1);
40
+
41
+ // src/retry.ts
42
+ var defaultRetryConfig = {
43
+ maxAttempts: 3,
44
+ baseDelayMs: 1e3,
45
+ maxDelayMs: 1e4,
46
+ retryOnStatusCodes: [408, 429, 500, 502, 503, 504]
47
+ };
48
+ function sleep(ms) {
49
+ return new Promise((resolve) => setTimeout(resolve, ms));
50
+ }
51
+ function shouldRetry(error, config) {
52
+ if (error instanceof Error && "statusCode" in error) {
53
+ const statusCode = error.statusCode;
54
+ return config.retryOnStatusCodes.includes(statusCode);
55
+ }
56
+ if (error instanceof Error) {
57
+ const networkErrors = ["ECONNRESET", "ETIMEDOUT", "ECONNREFUSED", "ENOTFOUND"];
58
+ return networkErrors.some((e) => error.message.includes(e));
59
+ }
60
+ return false;
61
+ }
62
+ async function withRetry(fn, config = defaultRetryConfig) {
63
+ let lastError;
64
+ for (let attempt = 1; attempt <= config.maxAttempts; attempt++) {
65
+ try {
66
+ return await fn();
67
+ } catch (error) {
68
+ lastError = error;
69
+ if (attempt === config.maxAttempts) {
70
+ throw error;
71
+ }
72
+ if (!shouldRetry(error, config)) {
73
+ throw error;
74
+ }
75
+ const delay = Math.min(
76
+ config.baseDelayMs * Math.pow(2, attempt - 1),
77
+ config.maxDelayMs
78
+ );
79
+ await sleep(delay);
80
+ }
81
+ }
82
+ throw lastError;
83
+ }
84
+
85
+ // src/api-client.ts
86
+ var SupatestApiClient = class {
87
+ options;
88
+ retryConfig;
89
+ constructor(options) {
90
+ this.options = options;
91
+ this.retryConfig = {
92
+ ...defaultRetryConfig,
93
+ maxAttempts: options.retryAttempts
94
+ };
95
+ }
96
+ async createRun(data) {
97
+ if (this.options.dryRun) {
98
+ this.logPayload("POST /v1/runs", data);
99
+ return {
100
+ runId: `mock_run_${Date.now()}`,
101
+ status: "running"
102
+ };
103
+ }
104
+ return this.request("POST", "/v1/runs", data);
105
+ }
106
+ async submitTest(runId, data) {
107
+ if (this.options.dryRun) {
108
+ this.logPayload(`POST /v1/runs/${runId}/tests`, data);
109
+ return;
110
+ }
111
+ await this.request("POST", `/v1/runs/${runId}/tests`, data);
112
+ }
113
+ async signAttachments(runId, data) {
114
+ if (this.options.dryRun) {
115
+ this.logPayload(`POST /v1/runs/${runId}/attachments/sign`, data);
116
+ return {
117
+ uploads: data.attachments.map((att, i) => ({
118
+ attachmentId: `mock_att_${i}_${Date.now()}`,
119
+ signedUrl: `https://mock-s3.example.com/uploads/${att.filename}`,
120
+ expiresAt: new Date(Date.now() + 15 * 60 * 1e3).toISOString()
121
+ }))
122
+ };
123
+ }
124
+ return this.request(
125
+ "POST",
126
+ `/v1/runs/${runId}/attachments/sign`,
127
+ data
128
+ );
129
+ }
130
+ async completeRun(runId, data) {
131
+ if (this.options.dryRun) {
132
+ this.logPayload(`POST /v1/runs/${runId}/complete`, data);
133
+ return;
134
+ }
135
+ await this.request("POST", `/v1/runs/${runId}/complete`, data);
136
+ }
137
+ async request(method, path2, body) {
138
+ const url = `${this.options.apiUrl}${path2}`;
139
+ return withRetry(async () => {
140
+ const controller = new AbortController();
141
+ const timeoutId = setTimeout(
142
+ () => controller.abort(),
143
+ this.options.timeoutMs
144
+ );
145
+ try {
146
+ const response = await fetch(url, {
147
+ method,
148
+ headers: {
149
+ "Content-Type": "application/json",
150
+ Authorization: `Bearer ${this.options.apiKey}`
151
+ },
152
+ body: body ? JSON.stringify(body) : void 0,
153
+ signal: controller.signal
154
+ });
155
+ if (!response.ok) {
156
+ const error = new Error(
157
+ `API request failed: ${response.status} ${response.statusText}`
158
+ );
159
+ error.statusCode = response.status;
160
+ throw error;
161
+ }
162
+ const text = await response.text();
163
+ return text ? JSON.parse(text) : {};
164
+ } finally {
165
+ clearTimeout(timeoutId);
166
+ }
167
+ }, this.retryConfig);
168
+ }
169
+ logPayload(endpoint, data) {
170
+ console.log(`
171
+ [supatest][dry-run] ${endpoint}`);
172
+ console.log(JSON.stringify(data, null, 2));
173
+ }
174
+ };
175
+
176
+ // src/git-utils.ts
177
+ var import_simple_git = require("simple-git");
178
+ async function getLocalGitInfo(rootDir) {
179
+ try {
180
+ const git = (0, import_simple_git.simpleGit)(rootDir || process.cwd());
181
+ const isRepo = await git.checkIsRepo();
182
+ if (!isRepo) {
183
+ return {};
184
+ }
185
+ const [branchResult, commitResult, status, remotes] = await Promise.all([
186
+ git.revparse(["--abbrev-ref", "HEAD"]).catch(() => void 0),
187
+ git.revparse(["HEAD"]).catch(() => void 0),
188
+ git.status().catch(() => void 0),
189
+ git.getRemotes(true).catch(() => [])
190
+ ]);
191
+ const branch = typeof branchResult === "string" ? branchResult.trim() : void 0;
192
+ const commit = typeof commitResult === "string" ? commitResult.trim() : void 0;
193
+ let commitMessage;
194
+ try {
195
+ const logOutput = await git.raw(["log", "-1", "--format=%s"]);
196
+ commitMessage = typeof logOutput === "string" ? logOutput.trim() : void 0;
197
+ } catch {
198
+ }
199
+ let author;
200
+ let authorEmail;
201
+ try {
202
+ const authorConfig = await git.getConfig("user.name").catch(() => void 0);
203
+ const emailConfig = await git.getConfig("user.email").catch(() => void 0);
204
+ author = authorConfig && typeof authorConfig.value === "string" ? authorConfig.value.trim() : void 0;
205
+ authorEmail = emailConfig && typeof emailConfig.value === "string" ? emailConfig.value.trim() : void 0;
206
+ } catch {
207
+ }
208
+ let tag;
209
+ try {
210
+ const tagResult = await git.raw(["describe", "--tags", "--exact-match"]).catch(() => void 0);
211
+ tag = typeof tagResult === "string" ? tagResult.trim() : void 0;
212
+ } catch {
213
+ }
214
+ let repo;
215
+ const remote = remotes && remotes.length > 0 ? remotes[0] : null;
216
+ if (remote && remote.refs && remote.refs.fetch) {
217
+ repo = remote.refs.fetch;
218
+ }
219
+ const dirty = status ? status.modified.length > 0 || status.created.length > 0 || status.deleted.length > 0 : false;
220
+ return {
221
+ branch,
222
+ commit,
223
+ commitMessage,
224
+ repo,
225
+ author,
226
+ authorEmail,
227
+ tag,
228
+ dirty
229
+ };
230
+ } catch (error) {
231
+ return {};
232
+ }
233
+ }
234
+
235
+ // src/uploader.ts
236
+ var import_node_fs = __toESM(require("fs"), 1);
237
+ var AttachmentUploader = class {
238
+ options;
239
+ constructor(options) {
240
+ this.options = options;
241
+ }
242
+ async upload(signedUrl, filePath, contentType) {
243
+ if (this.options.dryRun) {
244
+ const stats = import_node_fs.default.statSync(filePath);
245
+ console.log(
246
+ `[supatest][dry-run] Would upload ${filePath} (${stats.size} bytes) to S3`
247
+ );
248
+ return;
249
+ }
250
+ const fileBuffer = await import_node_fs.default.promises.readFile(filePath);
251
+ await withRetry(async () => {
252
+ const controller = new AbortController();
253
+ const timeoutId = setTimeout(
254
+ () => controller.abort(),
255
+ this.options.timeoutMs
256
+ );
257
+ try {
258
+ const response = await fetch(signedUrl, {
259
+ method: "PUT",
260
+ headers: {
261
+ "Content-Type": contentType,
262
+ "Content-Length": String(fileBuffer.length)
263
+ },
264
+ body: fileBuffer,
265
+ signal: controller.signal
266
+ });
267
+ if (!response.ok) {
268
+ const error = new Error(
269
+ `S3 upload failed: ${response.status} ${response.statusText}`
270
+ );
271
+ error.statusCode = response.status;
272
+ throw error;
273
+ }
274
+ } finally {
275
+ clearTimeout(timeoutId);
276
+ }
277
+ }, defaultRetryConfig);
278
+ }
279
+ async uploadBatch(items, signedUploads) {
280
+ const pLimit = (await import("p-limit")).default;
281
+ const limit = pLimit(this.options.maxConcurrent);
282
+ const results = await Promise.allSettled(
283
+ items.map(
284
+ (item, index) => limit(async () => {
285
+ await this.upload(item.signedUrl, item.filePath, item.contentType);
286
+ return {
287
+ success: true,
288
+ attachmentId: signedUploads[index]?.attachmentId
289
+ };
290
+ })
291
+ )
292
+ );
293
+ return results.map((result, index) => {
294
+ if (result.status === "fulfilled") {
295
+ return result.value;
296
+ }
297
+ return {
298
+ success: false,
299
+ attachmentId: signedUploads[index]?.attachmentId,
300
+ error: result.reason instanceof Error ? result.reason.message : String(result.reason)
301
+ };
302
+ });
303
+ }
304
+ };
305
+
306
+ // src/index.ts
307
+ var DEFAULT_API_URL = "https://api.supatest.dev";
308
+ var DEFAULT_MAX_CONCURRENT_UPLOADS = 5;
309
+ var DEFAULT_RETRY_ATTEMPTS = 3;
310
+ var DEFAULT_TIMEOUT_MS = 3e4;
311
+ var MAX_STDOUT_LINES = 100;
312
+ var MAX_STDERR_LINES = 100;
313
+ var SupatestPlaywrightReporter = class {
314
+ options;
315
+ client;
316
+ uploader;
317
+ runId;
318
+ uploadQueue = [];
319
+ uploadLimit;
320
+ startedAt;
321
+ firstTestStartTime;
322
+ firstFailureTime;
323
+ testsProcessed = /* @__PURE__ */ new Map();
324
+ disabled = false;
325
+ config;
326
+ rootDir;
327
+ stepIdCounter = 0;
328
+ constructor(options = {}) {
329
+ this.options = {
330
+ apiUrl: DEFAULT_API_URL,
331
+ uploadAssets: true,
332
+ maxConcurrentUploads: DEFAULT_MAX_CONCURRENT_UPLOADS,
333
+ retryAttempts: DEFAULT_RETRY_ATTEMPTS,
334
+ timeoutMs: DEFAULT_TIMEOUT_MS,
335
+ dryRun: false,
336
+ ...options
337
+ };
338
+ }
339
+ async onBegin(config, suite) {
340
+ this.config = config;
341
+ this.rootDir = config.rootDir;
342
+ if (!this.options.projectId) {
343
+ this.options.projectId = process.env.SUPATEST_PROJECT_ID ?? "";
344
+ }
345
+ if (!this.options.apiKey) {
346
+ this.options.apiKey = process.env.SUPATEST_API_KEY ?? "";
347
+ }
348
+ if (process.env.SUPATEST_DRY_RUN === "true") {
349
+ this.options.dryRun = true;
350
+ }
351
+ if (!this.options.projectId || !this.options.apiKey) {
352
+ if (!this.options.dryRun) {
353
+ this.logWarn(
354
+ "Missing projectId or apiKey. Set SUPATEST_PROJECT_ID and SUPATEST_API_KEY env vars, or pass them as options."
355
+ );
356
+ this.disabled = true;
357
+ return;
358
+ }
359
+ }
360
+ this.client = new SupatestApiClient({
361
+ apiKey: this.options.apiKey,
362
+ apiUrl: this.options.apiUrl,
363
+ timeoutMs: this.options.timeoutMs,
364
+ retryAttempts: this.options.retryAttempts,
365
+ dryRun: this.options.dryRun
366
+ });
367
+ this.uploader = new AttachmentUploader({
368
+ maxConcurrent: this.options.maxConcurrentUploads,
369
+ timeoutMs: this.options.timeoutMs,
370
+ dryRun: this.options.dryRun
371
+ });
372
+ const pLimit = (await import("p-limit")).default;
373
+ this.uploadLimit = pLimit(this.options.maxConcurrentUploads);
374
+ this.startedAt = (/* @__PURE__ */ new Date()).toISOString();
375
+ const allTests = suite.allTests();
376
+ const projects = this.buildProjectConfigs(config);
377
+ const testFiles = new Set(allTests.map((t) => t.location.file));
378
+ try {
379
+ const runRequest = {
380
+ projectId: this.options.projectId,
381
+ startedAt: this.startedAt,
382
+ playwright: {
383
+ version: config.version,
384
+ workers: config.workers,
385
+ retries: config.projects?.[0]?.retries ?? 0,
386
+ fullyParallel: config.fullyParallel,
387
+ forbidOnly: config.forbidOnly,
388
+ globalTimeout: config.globalTimeout,
389
+ reportSlowTests: config.reportSlowTests ? {
390
+ max: config.reportSlowTests.max,
391
+ threshold: config.reportSlowTests.threshold
392
+ } : void 0
393
+ },
394
+ projects,
395
+ testStats: {
396
+ totalFiles: testFiles.size,
397
+ totalTests: allTests.length,
398
+ totalProjects: config.projects?.length ?? 0
399
+ },
400
+ shard: config.shard ? {
401
+ current: config.shard.current,
402
+ total: config.shard.total
403
+ } : void 0,
404
+ environment: this.getEnvironmentInfo(config),
405
+ git: await this.getGitInfo(),
406
+ configPath: config.configFile,
407
+ rootDir: config.rootDir,
408
+ testDir: config.projects?.[0]?.testDir,
409
+ outputDir: config.projects?.[0]?.outputDir
410
+ };
411
+ const response = await this.client.createRun(runRequest);
412
+ this.runId = response.runId;
413
+ this.logInfo(
414
+ `Run ${this.runId} started (${allTests.length} tests across ${testFiles.size} files, ${projects.length} projects)`
415
+ );
416
+ } catch (error) {
417
+ this.logWarn(`Failed to create run: ${this.getErrorMessage(error)}`);
418
+ this.disabled = true;
419
+ }
420
+ }
421
+ onTestEnd(test, result) {
422
+ if (this.disabled || !this.runId) return;
423
+ if (!this.firstTestStartTime && result.startTime) {
424
+ this.firstTestStartTime = result.startTime.getTime();
425
+ }
426
+ if (!this.firstFailureTime && (result.status === "failed" || result.status === "timedOut")) {
427
+ this.firstFailureTime = Date.now();
428
+ }
429
+ const promise = this.uploadLimit(
430
+ () => this.processTestResult(test, result)
431
+ );
432
+ this.uploadQueue.push(promise);
433
+ }
434
+ async onEnd(result) {
435
+ if (this.disabled || !this.runId) {
436
+ this.logInfo("Reporter disabled, skipping onEnd");
437
+ return;
438
+ }
439
+ this.logInfo(`Waiting for ${this.uploadQueue.length} pending uploads...`);
440
+ const results = await Promise.allSettled(this.uploadQueue);
441
+ const failures = results.filter((r) => r.status === "rejected");
442
+ if (failures.length > 0) {
443
+ this.logWarn(`${failures.length} uploads failed`);
444
+ }
445
+ const summary = this.computeSummary(result);
446
+ const endedAt = (/* @__PURE__ */ new Date()).toISOString();
447
+ const startTime = this.startedAt ? new Date(this.startedAt).getTime() : 0;
448
+ try {
449
+ await this.client.completeRun(this.runId, {
450
+ status: this.mapRunStatus(result.status),
451
+ endedAt,
452
+ summary,
453
+ timing: {
454
+ totalDurationMs: Math.round(result.duration),
455
+ timeToFirstTest: this.firstTestStartTime ? Math.round(this.firstTestStartTime - startTime) : void 0,
456
+ timeToFirstFailure: this.firstFailureTime ? Math.round(this.firstFailureTime - startTime) : void 0
457
+ }
458
+ });
459
+ this.logInfo(`Run ${this.runId} completed: ${result.status}`);
460
+ } catch (error) {
461
+ this.logWarn(`Failed to complete run: ${this.getErrorMessage(error)}`);
462
+ }
463
+ }
464
+ async processTestResult(test, result) {
465
+ const testId = this.getTestId(test);
466
+ const resultId = this.getResultId(testId, result.retry);
467
+ try {
468
+ const payload = this.buildTestPayload(test, result);
469
+ await this.client.submitTest(this.runId, payload);
470
+ if (this.options.uploadAssets) {
471
+ await this.uploadAttachments(result, resultId);
472
+ }
473
+ const outcome = this.getTestOutcome(test, result);
474
+ const existing = this.testsProcessed.get(testId);
475
+ this.testsProcessed.set(testId, {
476
+ status: result.status,
477
+ outcome,
478
+ retries: (existing?.retries ?? 0) + (result.retry > 0 ? 1 : 0)
479
+ });
480
+ } catch (error) {
481
+ this.logWarn(
482
+ `Failed to submit test ${test.title}: ${this.getErrorMessage(error)}`
483
+ );
484
+ throw error;
485
+ }
486
+ }
487
+ buildTestPayload(test, result) {
488
+ const testId = this.getTestId(test);
489
+ const tags = test.tags ?? [];
490
+ const annotations = this.serializeAnnotations(test.annotations ?? []);
491
+ const resultEntry = {
492
+ resultId: this.getResultId(testId, result.retry),
493
+ retry: result.retry,
494
+ status: result.status,
495
+ startTime: result.startTime?.toISOString?.() ?? void 0,
496
+ durationMs: result.duration,
497
+ workerIndex: result.workerIndex,
498
+ parallelIndex: result.parallelIndex,
499
+ errors: this.serializeErrors(result.errors ?? []),
500
+ steps: this.serializeSteps(result.steps ?? []),
501
+ annotations: this.serializeAnnotations(result.annotations ?? []),
502
+ attachments: this.serializeAttachmentMeta(result.attachments ?? []),
503
+ stdout: this.serializeOutput(result.stdout ?? [], MAX_STDOUT_LINES),
504
+ stderr: this.serializeOutput(result.stderr ?? [], MAX_STDERR_LINES)
505
+ };
506
+ const projectName = test.parent?.project()?.name ?? "unknown";
507
+ return {
508
+ testId,
509
+ playwrightId: test.id,
510
+ file: this.relativePath(test.location.file),
511
+ location: {
512
+ file: this.relativePath(test.location.file),
513
+ line: test.location.line,
514
+ column: test.location.column
515
+ },
516
+ title: test.title,
517
+ titlePath: test.titlePath(),
518
+ tags,
519
+ annotations,
520
+ expectedStatus: test.expectedStatus,
521
+ outcome: this.getTestOutcome(test, result),
522
+ timeout: test.timeout,
523
+ retries: test.retries,
524
+ repeatEachIndex: test.repeatEachIndex > 0 ? test.repeatEachIndex : void 0,
525
+ status: result.status,
526
+ durationMs: result.duration,
527
+ retryCount: result.retry,
528
+ results: [resultEntry],
529
+ projectName
530
+ };
531
+ }
532
+ serializeSteps(steps) {
533
+ return steps.map((step) => this.serializeStep(step));
534
+ }
535
+ serializeStep(step) {
536
+ const stepId = `step_${++this.stepIdCounter}`;
537
+ return {
538
+ stepId,
539
+ title: step.title,
540
+ category: step.category,
541
+ startTime: step.startTime?.toISOString?.() ?? void 0,
542
+ durationMs: step.duration,
543
+ location: step.location ? {
544
+ file: this.relativePath(step.location.file),
545
+ line: step.location.line,
546
+ column: step.location.column
547
+ } : void 0,
548
+ error: step.error ? this.serializeError(step.error) : void 0,
549
+ // Recursively serialize nested steps
550
+ steps: step.steps && step.steps.length > 0 ? this.serializeSteps(step.steps) : void 0,
551
+ // Step attachments (from step.attachments if available)
552
+ attachments: void 0
553
+ // Playwright doesn't expose step.attachments directly
554
+ };
555
+ }
556
+ serializeErrors(errors) {
557
+ return errors.map((e) => this.serializeError(e));
558
+ }
559
+ serializeError(error) {
560
+ return {
561
+ message: error.message,
562
+ stack: error.stack,
563
+ value: error.value !== void 0 ? String(error.value) : void 0,
564
+ location: error.location ? {
565
+ file: this.relativePath(error.location.file),
566
+ line: error.location.line,
567
+ column: error.location.column
568
+ } : void 0,
569
+ // Could extract code snippet from file here if needed
570
+ snippet: void 0
571
+ };
572
+ }
573
+ serializeAnnotations(annotations) {
574
+ return annotations.map((a) => ({
575
+ type: a.type,
576
+ description: a.description
577
+ }));
578
+ }
579
+ serializeAttachmentMeta(attachments) {
580
+ return attachments.filter((a) => a.path || a.body).map((a) => ({
581
+ name: a.name,
582
+ filename: a.path ? import_node_path.default.basename(a.path) : a.name,
583
+ contentType: a.contentType,
584
+ sizeBytes: this.getAttachmentSize(a),
585
+ kind: this.getAttachmentKind(a.name, a.contentType)
586
+ }));
587
+ }
588
+ serializeOutput(output, maxLines) {
589
+ const lines = [];
590
+ for (const chunk of output) {
591
+ const str = chunk.toString();
592
+ lines.push(...str.split("\n"));
593
+ if (lines.length >= maxLines) break;
594
+ }
595
+ return lines.slice(0, maxLines);
596
+ }
597
+ getTestOutcome(test, result) {
598
+ if (typeof test.outcome === "function") {
599
+ return test.outcome();
600
+ }
601
+ if (result.status === "skipped") return "skipped";
602
+ if (result.status === test.expectedStatus) return "expected";
603
+ if (result.status === "passed" && result.retry > 0) return "flaky";
604
+ return "unexpected";
605
+ }
606
+ async uploadAttachments(result, testResultId) {
607
+ const attachments = (result.attachments ?? []).filter((a) => a.path);
608
+ if (attachments.length === 0) return;
609
+ const meta = attachments.map((a) => ({
610
+ name: a.name,
611
+ filename: import_node_path.default.basename(a.path),
612
+ contentType: a.contentType,
613
+ sizeBytes: this.getFileSize(a.path),
614
+ kind: this.getAttachmentKind(a.name, a.contentType)
615
+ }));
616
+ try {
617
+ const { uploads } = await this.client.signAttachments(this.runId, {
618
+ testResultId,
619
+ attachments: meta
620
+ });
621
+ const uploadItems = uploads.map((u, i) => ({
622
+ signedUrl: u.signedUrl,
623
+ filePath: attachments[i].path,
624
+ contentType: attachments[i].contentType
625
+ }));
626
+ const results = await this.uploader.uploadBatch(uploadItems, uploads);
627
+ const failedCount = results.filter((r) => !r.success).length;
628
+ if (failedCount > 0) {
629
+ this.logWarn(`${failedCount} attachment uploads failed`);
630
+ }
631
+ } catch (error) {
632
+ this.logWarn(
633
+ `Failed to upload attachments: ${this.getErrorMessage(error)}`
634
+ );
635
+ }
636
+ }
637
+ buildProjectConfigs(config) {
638
+ return (config.projects ?? []).map((project) => {
639
+ const use = project.use ?? {};
640
+ return {
641
+ name: project.name,
642
+ browserName: use.browserName,
643
+ channel: use.channel,
644
+ deviceName: use.deviceName,
645
+ viewport: use.viewport ? { width: use.viewport.width, height: use.viewport.height } : void 0,
646
+ locale: use.locale,
647
+ timezone: use.timezoneId,
648
+ colorScheme: use.colorScheme,
649
+ isMobile: use.isMobile,
650
+ hasTouch: use.hasTouch,
651
+ testDir: project.testDir,
652
+ timeout: project.timeout
653
+ };
654
+ });
655
+ }
656
+ getEnvironmentInfo(config) {
657
+ const ciInfo = this.getCIInfo();
658
+ return {
659
+ os: {
660
+ platform: import_node_os.default.platform(),
661
+ release: import_node_os.default.release(),
662
+ arch: import_node_os.default.arch()
663
+ },
664
+ node: {
665
+ version: process.version
666
+ },
667
+ machine: {
668
+ cpus: import_node_os.default.cpus().length,
669
+ memory: import_node_os.default.totalmem(),
670
+ hostname: import_node_os.default.hostname()
671
+ },
672
+ playwright: {
673
+ version: config.version
674
+ },
675
+ ci: ciInfo
676
+ };
677
+ }
678
+ computeSummary(result) {
679
+ let passed = 0;
680
+ let failed = 0;
681
+ let flaky = 0;
682
+ let skipped = 0;
683
+ let timedOut = 0;
684
+ let interrupted = 0;
685
+ let expected = 0;
686
+ let unexpected = 0;
687
+ for (const [, testInfo] of this.testsProcessed) {
688
+ switch (testInfo.status) {
689
+ case "passed":
690
+ passed += 1;
691
+ if (testInfo.retries > 0) flaky += 1;
692
+ break;
693
+ case "failed":
694
+ failed += 1;
695
+ break;
696
+ case "skipped":
697
+ skipped += 1;
698
+ break;
699
+ case "timedOut":
700
+ timedOut += 1;
701
+ break;
702
+ case "interrupted":
703
+ interrupted += 1;
704
+ break;
705
+ }
706
+ if (testInfo.outcome === "expected") expected += 1;
707
+ else if (testInfo.outcome === "unexpected") unexpected += 1;
708
+ }
709
+ return {
710
+ total: this.testsProcessed.size,
711
+ passed,
712
+ failed,
713
+ flaky,
714
+ skipped,
715
+ timedOut,
716
+ interrupted,
717
+ durationMs: Math.round(result.duration),
718
+ expected,
719
+ unexpected
720
+ };
721
+ }
722
+ mapRunStatus(status) {
723
+ switch (status) {
724
+ case "passed":
725
+ return "complete";
726
+ case "failed":
727
+ case "timedout":
728
+ return "errored";
729
+ case "interrupted":
730
+ return "interrupted";
731
+ default:
732
+ return "complete";
733
+ }
734
+ }
735
+ getTestId(test) {
736
+ const key = `${test.location.file}:${test.location.line}:${test.title}`;
737
+ return this.hashKey(key);
738
+ }
739
+ getResultId(testId, retry) {
740
+ return this.hashKey(`${testId}:${retry}`);
741
+ }
742
+ hashKey(value) {
743
+ return (0, import_node_crypto.createHash)("sha256").update(value).digest("hex").slice(0, 12);
744
+ }
745
+ relativePath(filePath) {
746
+ if (this.rootDir && filePath.startsWith(this.rootDir)) {
747
+ return filePath.slice(this.rootDir.length + 1);
748
+ }
749
+ return filePath;
750
+ }
751
+ getFileSize(filePath) {
752
+ try {
753
+ return import_node_fs2.default.statSync(filePath).size;
754
+ } catch {
755
+ return 0;
756
+ }
757
+ }
758
+ getAttachmentSize(attachment) {
759
+ if (attachment.path) {
760
+ return this.getFileSize(attachment.path);
761
+ }
762
+ if (attachment.body) {
763
+ return attachment.body.length;
764
+ }
765
+ return 0;
766
+ }
767
+ getAttachmentKind(name, contentType) {
768
+ if (name === "video" || contentType.startsWith("video/")) return "video";
769
+ if (name === "trace" || name.endsWith(".zip")) return "trace";
770
+ if (name.includes("screenshot") || contentType === "image/png")
771
+ return "screenshot";
772
+ if (name === "stdout") return "stdout";
773
+ if (name === "stderr") return "stderr";
774
+ return "other";
775
+ }
776
+ async getGitInfo() {
777
+ const ciGitInfo = {
778
+ branch: process.env.GITHUB_REF_NAME ?? process.env.GITHUB_HEAD_REF ?? process.env.CI_COMMIT_BRANCH ?? process.env.GITLAB_CI_COMMIT_BRANCH ?? process.env.GIT_BRANCH,
779
+ commit: process.env.GITHUB_SHA ?? process.env.CI_COMMIT_SHA ?? process.env.GITLAB_CI_COMMIT_SHA ?? process.env.GIT_COMMIT,
780
+ commitMessage: process.env.CI_COMMIT_MESSAGE ?? process.env.GITLAB_CI_COMMIT_MESSAGE,
781
+ repo: process.env.GITHUB_REPOSITORY ?? process.env.CI_PROJECT_PATH ?? process.env.GITLAB_CI_PROJECT_PATH ?? process.env.GIT_REPO,
782
+ author: process.env.GITHUB_ACTOR ?? process.env.GITLAB_USER_NAME,
783
+ authorEmail: process.env.GITLAB_USER_EMAIL,
784
+ tag: process.env.GITHUB_REF_TYPE === "tag" ? process.env.GITHUB_REF_NAME : void 0
785
+ };
786
+ if (ciGitInfo.branch || ciGitInfo.commit) {
787
+ return ciGitInfo;
788
+ }
789
+ const localGitInfo = await getLocalGitInfo(this.rootDir);
790
+ return {
791
+ branch: ciGitInfo.branch ?? localGitInfo.branch,
792
+ commit: ciGitInfo.commit ?? localGitInfo.commit,
793
+ commitMessage: ciGitInfo.commitMessage ?? localGitInfo.commitMessage,
794
+ repo: ciGitInfo.repo ?? localGitInfo.repo,
795
+ author: ciGitInfo.author ?? localGitInfo.author,
796
+ authorEmail: ciGitInfo.authorEmail ?? localGitInfo.authorEmail,
797
+ tag: ciGitInfo.tag ?? localGitInfo.tag,
798
+ dirty: localGitInfo.dirty
799
+ };
800
+ }
801
+ getCIInfo() {
802
+ if (process.env.GITHUB_ACTIONS) {
803
+ return {
804
+ provider: "github-actions",
805
+ runId: process.env.GITHUB_RUN_ID,
806
+ jobId: process.env.GITHUB_JOB,
807
+ jobUrl: `${process.env.GITHUB_SERVER_URL}/${process.env.GITHUB_REPOSITORY}/actions/runs/${process.env.GITHUB_RUN_ID}`,
808
+ buildNumber: process.env.GITHUB_RUN_NUMBER,
809
+ branch: process.env.GITHUB_REF_NAME,
810
+ pullRequest: process.env.GITHUB_EVENT_NAME === "pull_request" ? {
811
+ number: process.env.GITHUB_PR_NUMBER ?? "",
812
+ url: `${process.env.GITHUB_SERVER_URL}/${process.env.GITHUB_REPOSITORY}/pull/${process.env.GITHUB_PR_NUMBER}`
813
+ } : void 0
814
+ };
815
+ }
816
+ if (process.env.GITLAB_CI) {
817
+ return {
818
+ provider: "gitlab-ci",
819
+ runId: process.env.CI_PIPELINE_ID,
820
+ jobId: process.env.CI_JOB_ID,
821
+ jobUrl: process.env.CI_JOB_URL,
822
+ buildNumber: process.env.CI_PIPELINE_IID,
823
+ branch: process.env.CI_COMMIT_BRANCH,
824
+ pullRequest: process.env.CI_MERGE_REQUEST_IID ? {
825
+ number: process.env.CI_MERGE_REQUEST_IID,
826
+ url: process.env.CI_MERGE_REQUEST_PROJECT_URL + "/-/merge_requests/" + process.env.CI_MERGE_REQUEST_IID,
827
+ title: process.env.CI_MERGE_REQUEST_TITLE
828
+ } : void 0
829
+ };
830
+ }
831
+ if (process.env.JENKINS_URL) {
832
+ return {
833
+ provider: "jenkins",
834
+ runId: process.env.BUILD_ID,
835
+ jobUrl: process.env.BUILD_URL,
836
+ buildNumber: process.env.BUILD_NUMBER,
837
+ branch: process.env.GIT_BRANCH
838
+ };
839
+ }
840
+ if (process.env.CIRCLECI) {
841
+ return {
842
+ provider: "circleci",
843
+ runId: process.env.CIRCLE_WORKFLOW_ID,
844
+ jobId: process.env.CIRCLE_JOB,
845
+ jobUrl: process.env.CIRCLE_BUILD_URL,
846
+ buildNumber: process.env.CIRCLE_BUILD_NUM,
847
+ branch: process.env.CIRCLE_BRANCH,
848
+ pullRequest: process.env.CIRCLE_PULL_REQUEST ? {
849
+ number: process.env.CIRCLE_PR_NUMBER ?? "",
850
+ url: process.env.CIRCLE_PULL_REQUEST
851
+ } : void 0
852
+ };
853
+ }
854
+ if (process.env.TRAVIS) {
855
+ return {
856
+ provider: "travis",
857
+ runId: process.env.TRAVIS_BUILD_ID,
858
+ jobId: process.env.TRAVIS_JOB_ID,
859
+ jobUrl: process.env.TRAVIS_JOB_WEB_URL,
860
+ buildNumber: process.env.TRAVIS_BUILD_NUMBER,
861
+ branch: process.env.TRAVIS_BRANCH,
862
+ pullRequest: process.env.TRAVIS_PULL_REQUEST !== "false" ? {
863
+ number: process.env.TRAVIS_PULL_REQUEST ?? "",
864
+ url: `https://github.com/${process.env.TRAVIS_REPO_SLUG}/pull/${process.env.TRAVIS_PULL_REQUEST}`
865
+ } : void 0
866
+ };
867
+ }
868
+ if (process.env.BUILDKITE) {
869
+ return {
870
+ provider: "buildkite",
871
+ runId: process.env.BUILDKITE_BUILD_ID,
872
+ jobId: process.env.BUILDKITE_JOB_ID,
873
+ jobUrl: process.env.BUILDKITE_BUILD_URL,
874
+ buildNumber: process.env.BUILDKITE_BUILD_NUMBER,
875
+ branch: process.env.BUILDKITE_BRANCH
876
+ };
877
+ }
878
+ if (process.env.AZURE_PIPELINES || process.env.TF_BUILD) {
879
+ return {
880
+ provider: "azure-pipelines",
881
+ runId: process.env.BUILD_BUILDID,
882
+ jobUrl: `${process.env.SYSTEM_COLLECTIONURI}${process.env.SYSTEM_TEAMPROJECT}/_build/results?buildId=${process.env.BUILD_BUILDID}`,
883
+ buildNumber: process.env.BUILD_BUILDNUMBER,
884
+ branch: process.env.BUILD_SOURCEBRANCH
885
+ };
886
+ }
887
+ return void 0;
888
+ }
889
+ getErrorMessage(error) {
890
+ if (error instanceof Error) return error.message;
891
+ return String(error);
892
+ }
893
+ logInfo(message) {
894
+ console.log(`[supatest] ${message}`);
895
+ }
896
+ logWarn(message) {
897
+ console.warn(`[supatest] ${message}`);
898
+ }
899
+ };
900
+ //# sourceMappingURL=index.cjs.map