@testdino/playwright 1.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.mjs ADDED
@@ -0,0 +1,2688 @@
1
+ import { randomUUID } from 'crypto';
2
+ import { existsSync, readFileSync, statSync, createReadStream } from 'fs';
3
+ import WebSocket from 'ws';
4
+ import axios from 'axios';
5
+ import { readFile } from 'fs/promises';
6
+ import { execa } from 'execa';
7
+ import { type, release, platform, cpus, totalmem, hostname } from 'os';
8
+ import { version } from 'process';
9
+ import { basename, extname } from 'path';
10
+
11
+ var __require = /* @__PURE__ */ ((x) => typeof require !== "undefined" ? require : typeof Proxy !== "undefined" ? new Proxy(x, {
12
+ get: (a, b) => (typeof require !== "undefined" ? require : a)[b]
13
+ }) : x)(function(x) {
14
+ if (typeof require !== "undefined") return require.apply(this, arguments);
15
+ throw Error('Dynamic require of "' + x + '" is not supported');
16
+ });
17
+
18
+ // src/reporter/errors.ts
19
+ var TestDinoServerError = class extends Error {
20
+ code;
21
+ constructor(code, message) {
22
+ super(message);
23
+ this.name = "TestDinoServerError";
24
+ this.code = code;
25
+ Error.captureStackTrace(this, this.constructor);
26
+ }
27
+ };
28
+ var QuotaExhaustedError = class extends TestDinoServerError {
29
+ details;
30
+ constructor(message, details) {
31
+ super("QUOTA_EXHAUSTED", message);
32
+ this.name = "QuotaExhaustedError";
33
+ this.details = details;
34
+ }
35
+ };
36
+ var QuotaExceededError = class extends TestDinoServerError {
37
+ details;
38
+ constructor(message, details) {
39
+ super("QUOTA_EXCEEDED", message);
40
+ this.name = "QuotaExceededError";
41
+ this.details = details;
42
+ }
43
+ };
44
+ function isServerError(error) {
45
+ return error instanceof TestDinoServerError;
46
+ }
47
+ function isQuotaError(error) {
48
+ return error instanceof QuotaExhaustedError || error instanceof QuotaExceededError;
49
+ }
50
+
51
+ // src/streaming/websocket.ts
52
+ var HANDSHAKE_TIMEOUT_MS = 1e4;
53
+ var WebSocketClient = class {
54
+ ws = null;
55
+ options;
56
+ reconnectAttempts = 0;
57
+ reconnectTimer = null;
58
+ isConnecting = false;
59
+ isClosed = false;
60
+ pingInterval = null;
61
+ constructor(options) {
62
+ this.options = {
63
+ sessionId: "",
64
+ maxRetries: 5,
65
+ retryDelay: 1e3,
66
+ onConnected: () => {
67
+ },
68
+ onDisconnected: () => {
69
+ },
70
+ onError: () => {
71
+ },
72
+ ...options
73
+ };
74
+ }
75
+ /**
76
+ * Establish WebSocket connection.
77
+ * Resolves only after the server sends its 'connected' handshake message,
78
+ * guaranteeing the server's message handler is registered before events are sent.
79
+ * Passes sessionId from HTTP auth so the server reuses the existing session.
80
+ */
81
+ async connect() {
82
+ if (this.isConnecting || this.ws?.readyState === WebSocket.OPEN) {
83
+ return;
84
+ }
85
+ this.isConnecting = true;
86
+ return new Promise((resolve, reject) => {
87
+ try {
88
+ let wsUrl = `${this.options.serverUrl}/stream?token=${this.options.token}`;
89
+ if (this.options.sessionId) {
90
+ wsUrl += `&sessionId=${this.options.sessionId}`;
91
+ }
92
+ this.ws = new WebSocket(wsUrl);
93
+ let serverReady = false;
94
+ const handshakeTimeout = setTimeout(() => {
95
+ if (!serverReady) {
96
+ console.warn(
97
+ `\u26A0\uFE0F TestDino: WebSocket handshake timeout \u2014 server did not send 'connected' within ${HANDSHAKE_TIMEOUT_MS}ms. Resolving anyway.`
98
+ );
99
+ serverReady = true;
100
+ this.isConnecting = false;
101
+ this.options.onConnected();
102
+ resolve();
103
+ }
104
+ }, HANDSHAKE_TIMEOUT_MS);
105
+ this.ws.on("open", () => {
106
+ this.reconnectAttempts = 0;
107
+ this.startPing();
108
+ });
109
+ this.ws.on("message", (data) => {
110
+ const raw = data.toString();
111
+ if (!serverReady) {
112
+ try {
113
+ const msg = JSON.parse(raw);
114
+ if (msg.type === "connected") {
115
+ serverReady = true;
116
+ clearTimeout(handshakeTimeout);
117
+ this.isConnecting = false;
118
+ this.options.onConnected();
119
+ resolve();
120
+ return;
121
+ }
122
+ } catch {
123
+ }
124
+ }
125
+ this.handleMessage(raw);
126
+ });
127
+ this.ws.on("close", (code, reason) => {
128
+ clearTimeout(handshakeTimeout);
129
+ if (!serverReady) {
130
+ this.isConnecting = false;
131
+ reject(new Error(`WebSocket closed before server ready: code=${code} reason=${reason.toString()}`));
132
+ return;
133
+ }
134
+ this.handleClose(code, reason.toString());
135
+ });
136
+ this.ws.on("error", (error) => {
137
+ clearTimeout(handshakeTimeout);
138
+ this.isConnecting = false;
139
+ this.options.onError(error);
140
+ reject(error);
141
+ });
142
+ this.ws.on("pong", () => {
143
+ });
144
+ } catch (error) {
145
+ this.isConnecting = false;
146
+ reject(error);
147
+ }
148
+ });
149
+ }
150
+ /**
151
+ * Send event through WebSocket
152
+ */
153
+ async send(event) {
154
+ if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
155
+ throw new Error("WebSocket is not connected");
156
+ }
157
+ return new Promise((resolve, reject) => {
158
+ this.ws.send(JSON.stringify(event), (error) => {
159
+ if (error) {
160
+ reject(error);
161
+ } else {
162
+ resolve();
163
+ }
164
+ });
165
+ });
166
+ }
167
+ /**
168
+ * Send multiple events in batch (parallel for speed)
169
+ */
170
+ async sendBatch(events) {
171
+ if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
172
+ throw new Error("WebSocket is not connected");
173
+ }
174
+ await Promise.all(events.map((event) => this.send(event)));
175
+ }
176
+ /**
177
+ * Check if WebSocket is connected
178
+ */
179
+ isConnected() {
180
+ return this.ws?.readyState === WebSocket.OPEN;
181
+ }
182
+ /**
183
+ * Close WebSocket connection
184
+ */
185
+ close() {
186
+ this.isClosed = true;
187
+ this.stopPing();
188
+ this.clearReconnectTimer();
189
+ if (this.ws) {
190
+ this.ws.close();
191
+ this.ws = null;
192
+ }
193
+ }
194
+ /**
195
+ * Handle incoming messages
196
+ */
197
+ handleMessage(data) {
198
+ try {
199
+ const message = JSON.parse(data);
200
+ if (message.type === "connected") {
201
+ } else if (message.type === "ack") {
202
+ } else if (message.type === "nack") {
203
+ const nack = message;
204
+ if (isServerError(nack.error)) {
205
+ this.options.onError(nack.error);
206
+ } else if (typeof nack.error === "object" && nack.error !== null) {
207
+ const errorObj = nack.error;
208
+ const errorCode = errorObj.code || errorObj.error;
209
+ if (errorCode === "QUOTA_EXCEEDED" && errorObj.details && typeof errorObj.details === "object") {
210
+ const details = errorObj.details;
211
+ this.options.onError(
212
+ new QuotaExceededError(errorObj.message?.toString() || "Quota exceeded", {
213
+ planName: details.planName?.toString() || "Unknown",
214
+ totalTests: Number(details.totalTests) || 0,
215
+ remaining: Number(details.remaining) || 0,
216
+ used: Number(details.used) || 0,
217
+ total: Number(details.total) || 0,
218
+ resetDate: details.resetDate?.toString(),
219
+ canPartialSubmit: Boolean(details.canPartialSubmit),
220
+ allowedCount: Number(details.allowedCount) || 0
221
+ })
222
+ );
223
+ } else if (errorCode === "QUOTA_EXHAUSTED" && errorObj.details && typeof errorObj.details === "object") {
224
+ const details = errorObj.details;
225
+ this.options.onError(
226
+ new QuotaExhaustedError(errorObj.message?.toString() || "Quota exhausted", {
227
+ planName: details.planName?.toString() || "Unknown",
228
+ totalLimit: Number(details.totalLimit) || 0,
229
+ used: Number(details.used) || 0,
230
+ resetDate: details.resetDate?.toString()
231
+ })
232
+ );
233
+ } else {
234
+ const errorMessage = errorObj.message?.toString() || errorObj.error?.toString() || JSON.stringify(nack.error);
235
+ this.options.onError(new Error(`Event rejected: ${errorMessage}`));
236
+ }
237
+ } else {
238
+ const errorMessage = typeof nack.error === "string" ? nack.error : String(nack.error);
239
+ this.options.onError(new Error(`Event rejected: ${errorMessage}`));
240
+ }
241
+ }
242
+ } catch (error) {
243
+ console.error("Failed to parse WebSocket message:", error);
244
+ }
245
+ }
246
+ /**
247
+ * Handle connection close
248
+ */
249
+ handleClose(_code, _reason) {
250
+ this.stopPing();
251
+ this.options.onDisconnected();
252
+ if (this.isClosed) {
253
+ return;
254
+ }
255
+ if (this.reconnectAttempts < this.options.maxRetries) {
256
+ this.scheduleReconnect();
257
+ } else {
258
+ this.options.onError(new Error(`WebSocket connection failed after ${this.options.maxRetries} attempts`));
259
+ }
260
+ }
261
+ /**
262
+ * Schedule reconnection with exponential backoff
263
+ */
264
+ scheduleReconnect() {
265
+ this.clearReconnectTimer();
266
+ const delay = this.options.retryDelay * Math.pow(2, this.reconnectAttempts);
267
+ this.reconnectAttempts++;
268
+ this.reconnectTimer = setTimeout(() => {
269
+ this.connect().catch((error) => {
270
+ console.error("Reconnection failed:", error);
271
+ });
272
+ }, delay);
273
+ }
274
+ /**
275
+ * Clear reconnection timer
276
+ */
277
+ clearReconnectTimer() {
278
+ if (this.reconnectTimer) {
279
+ clearTimeout(this.reconnectTimer);
280
+ this.reconnectTimer = null;
281
+ }
282
+ }
283
+ /**
284
+ * Start ping interval for keep-alive
285
+ */
286
+ startPing() {
287
+ this.stopPing();
288
+ this.pingInterval = setInterval(() => {
289
+ if (this.ws?.readyState === WebSocket.OPEN) {
290
+ this.ws.ping();
291
+ }
292
+ }, 3e4);
293
+ }
294
+ /**
295
+ * Stop ping interval
296
+ */
297
+ stopPing() {
298
+ if (this.pingInterval) {
299
+ clearInterval(this.pingInterval);
300
+ this.pingInterval = null;
301
+ }
302
+ }
303
+ };
304
+ var HttpClient = class {
305
+ client;
306
+ options;
307
+ constructor(options) {
308
+ this.options = {
309
+ maxRetries: 3,
310
+ retryDelay: 1e3,
311
+ ...options
312
+ };
313
+ this.client = axios.create({
314
+ baseURL: this.options.serverUrl,
315
+ headers: {
316
+ "Content-Type": "application/json",
317
+ Authorization: `Bearer ${this.options.token}`
318
+ },
319
+ timeout: 1e4
320
+ });
321
+ }
322
+ /**
323
+ * Authenticate with server
324
+ */
325
+ async authenticate() {
326
+ try {
327
+ const response = await this.client.post("/auth");
328
+ return response.data;
329
+ } catch (error) {
330
+ if (axios.isAxiosError(error) && error.response?.status === 402) {
331
+ const quotaError = error.response.data;
332
+ throw new QuotaExhaustedError(quotaError.message, quotaError.details);
333
+ }
334
+ throw new Error(`Authentication failed: ${this.getErrorMessage(error)}`);
335
+ }
336
+ }
337
+ /**
338
+ * Send events via HTTP (fallback)
339
+ */
340
+ async sendEvents(events) {
341
+ let lastError = null;
342
+ for (let attempt = 0; attempt < this.options.maxRetries; attempt++) {
343
+ try {
344
+ await this.client.post("/events", { events });
345
+ return;
346
+ } catch (error) {
347
+ lastError = new Error(this.getErrorMessage(error));
348
+ if (attempt < this.options.maxRetries - 1) {
349
+ const delay = this.options.retryDelay * Math.pow(2, attempt);
350
+ await this.sleep(delay);
351
+ }
352
+ }
353
+ }
354
+ throw lastError || new Error("Failed to send events via HTTP");
355
+ }
356
+ /**
357
+ * Send single event via HTTP
358
+ */
359
+ async sendEvent(event) {
360
+ await this.sendEvents([event]);
361
+ }
362
+ /**
363
+ * Extract error message from various error types
364
+ */
365
+ getErrorMessage(error) {
366
+ if (axios.isAxiosError(error)) {
367
+ return error.response?.data?.message || error.message;
368
+ }
369
+ if (error instanceof Error) {
370
+ return error.message;
371
+ }
372
+ return String(error);
373
+ }
374
+ /**
375
+ * Sleep utility for retry delays
376
+ */
377
+ sleep(ms) {
378
+ return new Promise((resolve) => setTimeout(resolve, ms));
379
+ }
380
+ };
381
+
382
+ // src/streaming/buffer.ts
383
+ var EventBuffer = class {
384
+ events = [];
385
+ maxSize;
386
+ onFlush;
387
+ isFlushing = false;
388
+ constructor(options) {
389
+ this.maxSize = options.maxSize || 10;
390
+ this.onFlush = options.onFlush || (async () => {
391
+ });
392
+ }
393
+ /**
394
+ * Add event to buffer
395
+ * Automatically flushes if buffer reaches max size
396
+ */
397
+ async add(event) {
398
+ this.events.push(event);
399
+ if (this.events.length >= this.maxSize) {
400
+ await this.flush();
401
+ }
402
+ }
403
+ /**
404
+ * Flush all buffered events
405
+ */
406
+ async flush() {
407
+ if (this.isFlushing || this.events.length === 0) {
408
+ return;
409
+ }
410
+ this.isFlushing = true;
411
+ try {
412
+ const eventsToFlush = [...this.events];
413
+ this.events = [];
414
+ await this.onFlush(eventsToFlush);
415
+ } catch (error) {
416
+ console.error("Failed to flush events:", error);
417
+ throw error;
418
+ } finally {
419
+ this.isFlushing = false;
420
+ }
421
+ }
422
+ /**
423
+ * Get current buffer size
424
+ */
425
+ size() {
426
+ return this.events.length;
427
+ }
428
+ /**
429
+ * Check if buffer is empty
430
+ */
431
+ isEmpty() {
432
+ return this.events.length === 0;
433
+ }
434
+ /**
435
+ * Clear buffer without flushing
436
+ */
437
+ clear() {
438
+ this.events = [];
439
+ }
440
+ /**
441
+ * Get all events without removing them
442
+ */
443
+ getEvents() {
444
+ return [...this.events];
445
+ }
446
+ };
447
+
448
+ // src/metadata/base.ts
449
+ var BaseMetadataCollector = class {
450
+ name;
451
+ constructor(name) {
452
+ this.name = name;
453
+ }
454
+ /**
455
+ * Get collector name
456
+ */
457
+ getName() {
458
+ return this.name;
459
+ }
460
+ /**
461
+ * Collect metadata with error handling and timing
462
+ */
463
+ async collect() {
464
+ try {
465
+ const data = await this.collectMetadata();
466
+ return data;
467
+ } catch (error) {
468
+ console.warn(
469
+ `\u26A0\uFE0F TestDino: ${this.name} metadata collection failed:`,
470
+ error instanceof Error ? error.message : String(error)
471
+ );
472
+ return this.getEmptyMetadata();
473
+ }
474
+ }
475
+ /**
476
+ * Collect metadata with result tracking
477
+ */
478
+ async collectWithResult() {
479
+ const startTime = Date.now();
480
+ const collector = this.getName();
481
+ try {
482
+ const data = await this.collectMetadata();
483
+ const duration = Date.now() - startTime;
484
+ return {
485
+ data,
486
+ success: true,
487
+ duration,
488
+ collector
489
+ };
490
+ } catch (error) {
491
+ const duration = Date.now() - startTime;
492
+ const errorMessage = error instanceof Error ? error.message : String(error);
493
+ console.warn(`\u26A0\uFE0F TestDino: ${collector} metadata collection failed:`, errorMessage);
494
+ return {
495
+ data: this.getEmptyMetadata(),
496
+ success: false,
497
+ error: errorMessage,
498
+ duration,
499
+ collector
500
+ };
501
+ }
502
+ }
503
+ /**
504
+ * Utility method to run command with timeout
505
+ */
506
+ async withTimeout(promise, timeoutMs, operation) {
507
+ const timeoutPromise = new Promise((_, reject) => {
508
+ setTimeout(() => {
509
+ reject(new Error(`${operation} timed out after ${timeoutMs}ms`));
510
+ }, timeoutMs);
511
+ });
512
+ return Promise.race([promise, timeoutPromise]);
513
+ }
514
+ /**
515
+ * Utility method to safely parse JSON
516
+ */
517
+ safeJsonParse(jsonString, fallback) {
518
+ try {
519
+ return JSON.parse(jsonString);
520
+ } catch {
521
+ return fallback;
522
+ }
523
+ }
524
+ /**
525
+ * Utility method to check if a value is non-empty string
526
+ */
527
+ isNonEmptyString(value) {
528
+ return typeof value === "string" && value.trim().length > 0;
529
+ }
530
+ };
531
+
532
+ // src/metadata/git.ts
533
+ var GitMetadataCollector = class extends BaseMetadataCollector {
534
+ options;
535
+ constructor(options = {}) {
536
+ super("git");
537
+ this.options = {
538
+ timeout: options.timeout || 3e3,
539
+ cwd: options.cwd || process.cwd()
540
+ };
541
+ }
542
+ /**
543
+ * Collect git metadata
544
+ */
545
+ async collectMetadata() {
546
+ await this.configureGitForCI();
547
+ const isGitRepo = await this.isGitRepository();
548
+ if (!isGitRepo) {
549
+ return this.getEmptyMetadata();
550
+ }
551
+ const results = await Promise.all([
552
+ this.getBranch(),
553
+ this.getCommitHash(),
554
+ this.getCommitMessage(),
555
+ this.getAuthorName(),
556
+ this.getAuthorEmail(),
557
+ this.getCommitTimestamp(),
558
+ this.getRepoUrl(),
559
+ this.isDirtyWorkingTree()
560
+ ]);
561
+ let [branch, hash, message, author, email, timestamp] = results;
562
+ const repoUrl = results[6];
563
+ const isDirty = results[7];
564
+ let prMetadata;
565
+ if (process.env.GITHUB_EVENT_NAME === "pull_request") {
566
+ const eventData = await this.readGitHubEventFile();
567
+ if (eventData?.pull_request) {
568
+ prMetadata = this.extractPRMetadata(eventData);
569
+ const headRef = process.env.GITHUB_HEAD_REF;
570
+ if (this.isNonEmptyString(headRef)) {
571
+ branch = headRef;
572
+ }
573
+ const headSha = eventData.pull_request.head?.sha;
574
+ if (this.isNonEmptyString(headSha)) {
575
+ hash = headSha;
576
+ const realCommit = await this.getCommitInfoFromSha(headSha);
577
+ if (realCommit) {
578
+ message = realCommit.message ?? message;
579
+ author = realCommit.author ?? author;
580
+ email = realCommit.email ?? email;
581
+ timestamp = realCommit.timestamp ?? timestamp;
582
+ }
583
+ }
584
+ }
585
+ }
586
+ const githubAuthor = await this.resolveGitHubAuthor(hash);
587
+ return {
588
+ branch,
589
+ commit: {
590
+ hash,
591
+ message,
592
+ author: githubAuthor.authorLogin || author,
593
+ authorId: githubAuthor.authorId,
594
+ email,
595
+ timestamp,
596
+ isDirty
597
+ },
598
+ repository: {
599
+ name: this.extractRepoName(repoUrl),
600
+ url: repoUrl
601
+ },
602
+ ...prMetadata ? { pr: prMetadata } : {}
603
+ };
604
+ }
605
+ /**
606
+ * Get empty metadata
607
+ */
608
+ getEmptyMetadata() {
609
+ return {};
610
+ }
611
+ /**
612
+ * Configure git for CI environments
613
+ * Fixes "dubious ownership" errors when workspace is mounted with different ownership
614
+ */
615
+ async configureGitForCI() {
616
+ const isCI = process.env.CI === "true" || process.env.GITHUB_ACTIONS === "true";
617
+ if (!isCI) return;
618
+ try {
619
+ await execa("git", ["config", "--global", "--add", "safe.directory", this.options.cwd], {
620
+ timeout: this.options.timeout,
621
+ reject: true
622
+ });
623
+ } catch {
624
+ }
625
+ }
626
+ /**
627
+ * Read and parse the GitHub Actions event file
628
+ */
629
+ async readGitHubEventFile() {
630
+ const eventPath = process.env.GITHUB_EVENT_PATH;
631
+ if (!eventPath) return void 0;
632
+ try {
633
+ const content = await this.withTimeout(
634
+ readFile(eventPath, "utf-8"),
635
+ this.options.timeout,
636
+ "GitHub event file read"
637
+ );
638
+ return this.safeJsonParse(content, {});
639
+ } catch (error) {
640
+ console.warn(
641
+ "\u26A0\uFE0F TestDino: Failed to read GitHub event data:",
642
+ error instanceof Error ? error.message : String(error)
643
+ );
644
+ return void 0;
645
+ }
646
+ }
647
+ /**
648
+ * Extract PR metadata from GitHub event data
649
+ */
650
+ extractPRMetadata(eventData) {
651
+ const pullRequest = eventData?.pull_request;
652
+ if (!pullRequest) return void 0;
653
+ const prMetadata = {};
654
+ if (this.isNonEmptyString(pullRequest.title)) {
655
+ prMetadata.title = pullRequest.title;
656
+ }
657
+ if (typeof pullRequest.number === "number") {
658
+ prMetadata.number = pullRequest.number;
659
+ }
660
+ if (this.isNonEmptyString(pullRequest.state)) {
661
+ prMetadata.status = pullRequest.state;
662
+ }
663
+ const serverUrl = process.env.GITHUB_SERVER_URL;
664
+ const repository = process.env.GITHUB_REPOSITORY;
665
+ if (prMetadata.number && serverUrl && repository) {
666
+ prMetadata.url = `${serverUrl}/${repository}/pull/${prMetadata.number}`;
667
+ }
668
+ if (pullRequest.head?.ref && this.isNonEmptyString(pullRequest.head.ref)) {
669
+ prMetadata.branch = pullRequest.head.ref;
670
+ }
671
+ if (pullRequest.base?.ref && this.isNonEmptyString(pullRequest.base.ref)) {
672
+ prMetadata.targetBranch = pullRequest.base.ref;
673
+ }
674
+ if (pullRequest.user?.login && this.isNonEmptyString(pullRequest.user.login)) {
675
+ prMetadata.author = pullRequest.user.login;
676
+ }
677
+ if (Array.isArray(pullRequest.labels) && pullRequest.labels.length > 0) {
678
+ const labels = pullRequest.labels.map((label) => label?.name).filter((name) => this.isNonEmptyString(name));
679
+ if (labels.length > 0) {
680
+ prMetadata.labels = labels;
681
+ }
682
+ }
683
+ if (typeof pullRequest.merged === "boolean") {
684
+ prMetadata.merged = pullRequest.merged;
685
+ }
686
+ if (typeof pullRequest.mergeable === "boolean") {
687
+ prMetadata.mergeable = pullRequest.mergeable;
688
+ }
689
+ if (this.isNonEmptyString(pullRequest.merge_commit_sha)) {
690
+ prMetadata.mergeCommitSha = pullRequest.merge_commit_sha;
691
+ }
692
+ const hasData = Object.keys(prMetadata).length > 0;
693
+ return hasData ? prMetadata : void 0;
694
+ }
695
+ /**
696
+ * Get commit details for a specific SHA using git show
697
+ * Used to resolve real commit data in PR context (instead of merge commit)
698
+ */
699
+ async getCommitInfoFromSha(sha) {
700
+ try {
701
+ const result = await this.execGit(["show", "-s", "--format=%s%n%an%n%ae%n%aI", sha]);
702
+ const lines = result.split("\n");
703
+ if (lines.length < 4) return void 0;
704
+ return {
705
+ message: this.isNonEmptyString(lines[0]) ? lines[0] : void 0,
706
+ author: this.isNonEmptyString(lines[1]) ? lines[1] : void 0,
707
+ email: this.isNonEmptyString(lines[2]) ? lines[2] : void 0,
708
+ timestamp: this.isNonEmptyString(lines[3]) ? lines[3] : void 0
709
+ };
710
+ } catch (error) {
711
+ console.warn(
712
+ "\u26A0\uFE0F TestDino: Failed to get commit info from SHA:",
713
+ error instanceof Error ? error.message : String(error)
714
+ );
715
+ return void 0;
716
+ }
717
+ }
718
+ /**
719
+ * Resolve GitHub author info via the Commits API
720
+ * Only runs on GitHub Actions where GITHUB_REPOSITORY is available
721
+ */
722
+ async resolveGitHubAuthor(commitHash) {
723
+ const empty = { authorId: "" };
724
+ if (process.env.GITHUB_ACTIONS !== "true") {
725
+ return empty;
726
+ }
727
+ if (!commitHash) {
728
+ return empty;
729
+ }
730
+ const repository = process.env.GITHUB_REPOSITORY;
731
+ if (!repository) {
732
+ return empty;
733
+ }
734
+ const url = `https://api.github.com/repos/${repository}/commits/${commitHash}`;
735
+ try {
736
+ const headers = {
737
+ Accept: "application/vnd.github.v3+json",
738
+ "User-Agent": "testdino-playwright"
739
+ };
740
+ const token = process.env.GITHUB_TOKEN;
741
+ if (token) {
742
+ headers.Authorization = `token ${token}`;
743
+ }
744
+ const response = await this.withTimeout(fetch(url, { headers }), this.options.timeout, "GitHub Commits API");
745
+ if (!response.ok) {
746
+ return empty;
747
+ }
748
+ const data = await response.json();
749
+ if (data?.author?.id) {
750
+ const authorId = String(data.author.id);
751
+ const authorLogin = this.isNonEmptyString(data.author.login) ? data.author.login : void 0;
752
+ return { authorId, authorLogin };
753
+ }
754
+ return this.resolveGitHubAuthorFromActor();
755
+ } catch (error) {
756
+ console.warn(
757
+ "\u26A0\uFE0F TestDino: Failed to resolve GitHub author from Commits API:",
758
+ error instanceof Error ? error.message : String(error)
759
+ );
760
+ return this.resolveGitHubAuthorFromActor();
761
+ }
762
+ }
763
+ /**
764
+ * Fallback: resolve GitHub author info from GITHUB_ACTOR via the Users API
765
+ */
766
+ async resolveGitHubAuthorFromActor() {
767
+ const actor = process.env.GITHUB_ACTOR;
768
+ if (!actor) {
769
+ return { authorId: "" };
770
+ }
771
+ const url = `https://api.github.com/users/${actor}`;
772
+ try {
773
+ const response = await this.withTimeout(
774
+ fetch(url, {
775
+ headers: {
776
+ Accept: "application/vnd.github.v3+json",
777
+ "User-Agent": "testdino-playwright"
778
+ }
779
+ }),
780
+ this.options.timeout,
781
+ "GitHub Users API"
782
+ );
783
+ if (!response.ok) {
784
+ return { authorId: "" };
785
+ }
786
+ const data = await response.json();
787
+ const authorId = data?.id ? String(data.id) : "";
788
+ const authorLogin = this.isNonEmptyString(data?.login) ? data.login : actor;
789
+ return { authorId, authorLogin };
790
+ } catch (error) {
791
+ console.warn(
792
+ "\u26A0\uFE0F TestDino: Failed to resolve GitHub author from GITHUB_ACTOR:",
793
+ error instanceof Error ? error.message : String(error)
794
+ );
795
+ return { authorId: "" };
796
+ }
797
+ }
798
+ /**
799
+ * Check if current directory is a git repository
800
+ */
801
+ async isGitRepository() {
802
+ try {
803
+ await this.execGit(["rev-parse", "--git-dir"]);
804
+ return true;
805
+ } catch {
806
+ return false;
807
+ }
808
+ }
809
+ /**
810
+ * Get current branch name
811
+ * Uses: git rev-parse --abbrev-ref HEAD
812
+ */
813
+ async getBranch() {
814
+ try {
815
+ const result = await this.execGit(["rev-parse", "--abbrev-ref", "HEAD"]);
816
+ return this.isNonEmptyString(result) ? result : void 0;
817
+ } catch {
818
+ return void 0;
819
+ }
820
+ }
821
+ /**
822
+ * Get current commit hash (full SHA)
823
+ * Uses: git rev-parse HEAD
824
+ */
825
+ async getCommitHash() {
826
+ try {
827
+ const result = await this.execGit(["rev-parse", "HEAD"]);
828
+ return this.isNonEmptyString(result) ? result : void 0;
829
+ } catch {
830
+ return void 0;
831
+ }
832
+ }
833
+ /**
834
+ * Get commit message of current HEAD
835
+ * Uses: git log -1 --pretty=format:%s
836
+ */
837
+ async getCommitMessage() {
838
+ try {
839
+ const result = await this.execGit(["log", "-1", "--pretty=format:%s"]);
840
+ return this.isNonEmptyString(result) ? result : void 0;
841
+ } catch {
842
+ return void 0;
843
+ }
844
+ }
845
+ /**
846
+ * Get commit author name
847
+ * Uses: git log -1 --pretty=format:%an
848
+ */
849
+ async getAuthorName() {
850
+ try {
851
+ const result = await this.execGit(["log", "-1", "--pretty=format:%an"]);
852
+ return this.isNonEmptyString(result) ? result : void 0;
853
+ } catch {
854
+ return void 0;
855
+ }
856
+ }
857
+ /**
858
+ * Get commit author email
859
+ * Uses: git log -1 --pretty=format:%ae
860
+ */
861
+ async getAuthorEmail() {
862
+ try {
863
+ const result = await this.execGit(["log", "-1", "--pretty=format:%ae"]);
864
+ return this.isNonEmptyString(result) ? result : void 0;
865
+ } catch {
866
+ return void 0;
867
+ }
868
+ }
869
+ /**
870
+ * Get commit timestamp (ISO format)
871
+ * Uses: git log -1 --pretty=format:%aI
872
+ */
873
+ async getCommitTimestamp() {
874
+ try {
875
+ const result = await this.execGit(["log", "-1", "--pretty=format:%aI"]);
876
+ return this.isNonEmptyString(result) ? result : void 0;
877
+ } catch {
878
+ return void 0;
879
+ }
880
+ }
881
+ /**
882
+ * Get remote origin URL
883
+ * Uses: git config --get remote.origin.url
884
+ */
885
+ async getRepoUrl() {
886
+ try {
887
+ const result = await this.execGit(["config", "--get", "remote.origin.url"]);
888
+ return this.isNonEmptyString(result) ? result : void 0;
889
+ } catch {
890
+ return void 0;
891
+ }
892
+ }
893
+ /**
894
+ * Check if working tree has uncommitted changes
895
+ * Uses: git status --porcelain
896
+ * Returns true if there are any changes (staged, unstaged, or untracked)
897
+ */
898
+ async isDirtyWorkingTree() {
899
+ try {
900
+ const result = await this.execGit(["status", "--porcelain"]);
901
+ return result.trim().length > 0;
902
+ } catch {
903
+ return void 0;
904
+ }
905
+ }
906
+ /**
907
+ * Extract repository name from remote URL
908
+ * e.g., "https://github.com/user/repo.git" → "repo"
909
+ */
910
+ extractRepoName(repoUrl) {
911
+ if (!repoUrl) return void 0;
912
+ return repoUrl.split("/").pop()?.replace(".git", "") || void 0;
913
+ }
914
+ /**
915
+ * Execute git command with timeout
916
+ */
917
+ async execGit(args) {
918
+ const { stdout } = await this.withTimeout(
919
+ execa("git", args, {
920
+ cwd: this.options.cwd,
921
+ timeout: this.options.timeout,
922
+ reject: true
923
+ }),
924
+ this.options.timeout,
925
+ `git ${args.join(" ")}`
926
+ );
927
+ return stdout.trim();
928
+ }
929
+ };
930
+ var CIMetadataCollector = class extends BaseMetadataCollector {
931
+ constructor(_options = {}) {
932
+ super("ci");
933
+ }
934
+ /**
935
+ * Collect CI metadata
936
+ */
937
+ async collectMetadata() {
938
+ const provider = this.detectCIProvider();
939
+ if (!provider) {
940
+ return {
941
+ provider: "local",
942
+ environment: this.collectEnvironment()
943
+ };
944
+ }
945
+ if (provider === "github-actions") {
946
+ return this.collectGitHubActionsMetadata();
947
+ }
948
+ return {
949
+ provider,
950
+ environment: this.collectEnvironment()
951
+ };
952
+ }
953
+ /**
954
+ * Get empty metadata
955
+ */
956
+ getEmptyMetadata() {
957
+ return {};
958
+ }
959
+ /**
960
+ * Detect CI provider from environment variables
961
+ */
962
+ detectCIProvider() {
963
+ const { env } = process;
964
+ if (env.GITHUB_ACTIONS === "true") {
965
+ return "github-actions";
966
+ }
967
+ return void 0;
968
+ }
969
+ /**
970
+ * Collect GitHub Actions specific metadata
971
+ */
972
+ collectGitHubActionsMetadata() {
973
+ const { env } = process;
974
+ return {
975
+ provider: "github-actions",
976
+ pipeline: {
977
+ id: env.GITHUB_RUN_ID,
978
+ name: env.GITHUB_WORKFLOW,
979
+ url: this.buildPipelineUrl()
980
+ },
981
+ build: {
982
+ number: env.GITHUB_RUN_NUMBER,
983
+ trigger: env.GITHUB_EVENT_NAME
984
+ },
985
+ environment: this.collectEnvironment()
986
+ };
987
+ }
988
+ /**
989
+ * Build the pipeline URL from GitHub Actions environment variables
990
+ */
991
+ buildPipelineUrl() {
992
+ const { env } = process;
993
+ const serverUrl = env.GITHUB_SERVER_URL;
994
+ const repository = env.GITHUB_REPOSITORY;
995
+ const runId = env.GITHUB_RUN_ID;
996
+ if (serverUrl && repository && runId) {
997
+ return `${serverUrl}/${repository}/actions/runs/${runId}`;
998
+ }
999
+ return void 0;
1000
+ }
1001
+ /**
1002
+ * Collect runner environment information
1003
+ */
1004
+ collectEnvironment() {
1005
+ try {
1006
+ return {
1007
+ name: type(),
1008
+ type: process.platform,
1009
+ os: `${type()} ${release()}`,
1010
+ node: process.version
1011
+ };
1012
+ } catch {
1013
+ return {};
1014
+ }
1015
+ }
1016
+ };
1017
+ var SystemMetadataCollector = class extends BaseMetadataCollector {
1018
+ constructor(_options = {}) {
1019
+ super("system");
1020
+ }
1021
+ /**
1022
+ * Collect system metadata
1023
+ */
1024
+ async collectMetadata() {
1025
+ return new Promise((resolve) => {
1026
+ const metadata = {
1027
+ os: this.getOperatingSystem(),
1028
+ cpu: this.getCpuInfo(),
1029
+ memory: this.getMemoryInfo(),
1030
+ nodeVersion: this.getNodeVersion(),
1031
+ platform: this.getPlatform(),
1032
+ hostname: this.getHostname()
1033
+ };
1034
+ resolve(metadata);
1035
+ });
1036
+ }
1037
+ /**
1038
+ * Get empty metadata
1039
+ */
1040
+ getEmptyMetadata() {
1041
+ return {};
1042
+ }
1043
+ /**
1044
+ * Get operating system information
1045
+ * Format: "platform release" (e.g., "darwin 23.1.0", "linux 5.4.0")
1046
+ */
1047
+ getOperatingSystem() {
1048
+ let platformName = "unknown";
1049
+ let releaseVersion = "unknown";
1050
+ try {
1051
+ platformName = platform();
1052
+ } catch {
1053
+ }
1054
+ try {
1055
+ releaseVersion = release();
1056
+ } catch {
1057
+ }
1058
+ return `${platformName} ${releaseVersion}`;
1059
+ }
1060
+ /**
1061
+ * Get CPU information
1062
+ * Format: "model (X cores)" (e.g., "Intel(R) Core(TM) i7-9750H CPU @ 2.60GHz (12 cores)")
1063
+ */
1064
+ getCpuInfo() {
1065
+ try {
1066
+ const cpuList = cpus();
1067
+ if (cpuList.length === 0) {
1068
+ return "unknown";
1069
+ }
1070
+ const model = cpuList[0].model.trim();
1071
+ const coreCount = cpuList.length;
1072
+ return `${model} (${coreCount} cores)`;
1073
+ } catch {
1074
+ return "unknown";
1075
+ }
1076
+ }
1077
+ /**
1078
+ * Get memory information
1079
+ * Format: "X.X GB" (e.g., "16.0 GB", "8.5 GB")
1080
+ */
1081
+ getMemoryInfo() {
1082
+ try {
1083
+ const totalBytes = totalmem();
1084
+ const totalGB = totalBytes / (1024 * 1024 * 1024);
1085
+ return `${totalGB.toFixed(1)} GB`;
1086
+ } catch {
1087
+ return "unknown";
1088
+ }
1089
+ }
1090
+ /**
1091
+ * Get Node.js version
1092
+ * Returns process.version (e.g., "v18.17.0")
1093
+ */
1094
+ getNodeVersion() {
1095
+ try {
1096
+ return version;
1097
+ } catch {
1098
+ return "unknown";
1099
+ }
1100
+ }
1101
+ /**
1102
+ * Get platform identifier
1103
+ * Returns os.platform() (e.g., "darwin", "linux", "win32")
1104
+ */
1105
+ getPlatform() {
1106
+ try {
1107
+ return platform();
1108
+ } catch {
1109
+ return "unknown";
1110
+ }
1111
+ }
1112
+ /**
1113
+ * Get hostname
1114
+ * Returns os.hostname() (e.g., "MacBook-Pro.local")
1115
+ */
1116
+ getHostname() {
1117
+ try {
1118
+ return hostname();
1119
+ } catch {
1120
+ return "unknown";
1121
+ }
1122
+ }
1123
+ };
1124
+ var PlaywrightMetadataCollector = class extends BaseMetadataCollector {
1125
+ options;
1126
+ constructor(options = {}) {
1127
+ super("playwright");
1128
+ this.options = {
1129
+ timeout: options.timeout || 3e3,
1130
+ config: options.config,
1131
+ suite: options.suite,
1132
+ packageJsonPath: options.packageJsonPath
1133
+ };
1134
+ }
1135
+ /**
1136
+ * Collect Playwright metadata
1137
+ */
1138
+ async collectMetadata() {
1139
+ const metadata = {};
1140
+ const version2 = await this.getPlaywrightVersion();
1141
+ if (version2) {
1142
+ metadata.version = version2;
1143
+ }
1144
+ if (this.options.config) {
1145
+ const configMetadata = this.extractConfigMetadata(this.options.config);
1146
+ Object.assign(metadata, configMetadata);
1147
+ }
1148
+ return metadata;
1149
+ }
1150
+ /**
1151
+ * Build skeleton from Suite and include it in CompleteMetadata
1152
+ */
1153
+ buildSkeletonMetadata(suite) {
1154
+ const skeleton = this.buildSkeleton(suite);
1155
+ return { skeleton };
1156
+ }
1157
+ /**
1158
+ * Get empty metadata
1159
+ */
1160
+ getEmptyMetadata() {
1161
+ return {};
1162
+ }
1163
+ /**
1164
+ * Get Playwright version from @playwright/test package.json
1165
+ */
1166
+ async getPlaywrightVersion() {
1167
+ try {
1168
+ const packageJsonPath = this.options.packageJsonPath || this.resolvePlaywrightPackageJson();
1169
+ if (!packageJsonPath) {
1170
+ return void 0;
1171
+ }
1172
+ const packageJsonContent = await this.withTimeout(
1173
+ readFile(packageJsonPath, "utf-8"),
1174
+ this.options.timeout,
1175
+ "Playwright package.json read"
1176
+ );
1177
+ const packageJson = this.safeJsonParse(packageJsonContent, {});
1178
+ return this.isNonEmptyString(packageJson.version) ? packageJson.version : void 0;
1179
+ } catch (error) {
1180
+ console.warn(
1181
+ "\u26A0\uFE0F TestDino: Failed to read Playwright version:",
1182
+ error instanceof Error ? error.message : String(error)
1183
+ );
1184
+ return void 0;
1185
+ }
1186
+ }
1187
+ /**
1188
+ * Resolve @playwright/test package.json path
1189
+ */
1190
+ resolvePlaywrightPackageJson() {
1191
+ try {
1192
+ return __require.resolve("@playwright/test/package.json");
1193
+ } catch {
1194
+ return void 0;
1195
+ }
1196
+ }
1197
+ /**
1198
+ * Extract metadata from Playwright FullConfig
1199
+ */
1200
+ extractConfigMetadata(config) {
1201
+ const metadata = {};
1202
+ if (this.isNonEmptyString(config.configFile)) {
1203
+ metadata.configFile = config.configFile;
1204
+ }
1205
+ if (typeof config.forbidOnly === "boolean") {
1206
+ metadata.forbidOnly = config.forbidOnly;
1207
+ }
1208
+ if (typeof config.fullyParallel === "boolean") {
1209
+ metadata.fullyParallel = config.fullyParallel;
1210
+ metadata.parallel = config.fullyParallel;
1211
+ }
1212
+ if (typeof config.globalTimeout === "number") {
1213
+ metadata.globalTimeout = config.globalTimeout;
1214
+ }
1215
+ if (config.grep) {
1216
+ const patterns = Array.isArray(config.grep) ? config.grep : [config.grep];
1217
+ metadata.grep = patterns.map((p) => p.source);
1218
+ }
1219
+ if (typeof config.maxFailures === "number") {
1220
+ metadata.maxFailures = config.maxFailures;
1221
+ }
1222
+ if (config.metadata && typeof config.metadata === "object") {
1223
+ metadata.metadata = config.metadata;
1224
+ }
1225
+ if (typeof config.workers === "number") {
1226
+ metadata.workers = config.workers;
1227
+ }
1228
+ if (Array.isArray(config.projects) && config.projects.length > 0) {
1229
+ const projectNames = config.projects.map((project) => project.name).filter((name) => this.isNonEmptyString(name));
1230
+ if (projectNames.length > 0) {
1231
+ metadata.projects = projectNames;
1232
+ }
1233
+ }
1234
+ if (config.reportSlowTests && typeof config.reportSlowTests === "object") {
1235
+ metadata.reportSlowTests = {
1236
+ max: config.reportSlowTests.max,
1237
+ threshold: config.reportSlowTests.threshold
1238
+ };
1239
+ }
1240
+ if (this.isNonEmptyString(config.rootDir)) {
1241
+ metadata.rootDir = config.rootDir;
1242
+ }
1243
+ if (config.shard && typeof config.shard.current === "number" && typeof config.shard.total === "number") {
1244
+ metadata.shard = {
1245
+ current: config.shard.current,
1246
+ total: config.shard.total
1247
+ };
1248
+ }
1249
+ if (Array.isArray(config.tags) && config.tags.length > 0) {
1250
+ metadata.tags = config.tags;
1251
+ }
1252
+ if (config.webServer && typeof config.webServer === "object") {
1253
+ metadata.webServer = config.webServer;
1254
+ }
1255
+ return metadata;
1256
+ }
1257
+ /**
1258
+ * Build skeleton from Suite
1259
+ */
1260
+ buildSkeleton(suite) {
1261
+ const totalTests = suite.allTests().length;
1262
+ const suites = this.buildSuiteTree(suite);
1263
+ return {
1264
+ totalTests,
1265
+ suites
1266
+ };
1267
+ }
1268
+ /**
1269
+ * Recursively build suite tree
1270
+ */
1271
+ buildSuiteTree(suite) {
1272
+ const suites = [];
1273
+ for (const childSuite of suite.suites) {
1274
+ const skeletonSuite = {
1275
+ title: childSuite.title,
1276
+ type: childSuite.type === "file" ? "file" : "describe",
1277
+ tests: childSuite.tests.map((test) => this.buildSkeletonTest(test))
1278
+ };
1279
+ if (childSuite.type === "file" && childSuite.location) {
1280
+ skeletonSuite.file = childSuite.location.file;
1281
+ }
1282
+ if (childSuite.location) {
1283
+ skeletonSuite.location = {
1284
+ file: childSuite.location.file,
1285
+ line: childSuite.location.line,
1286
+ column: childSuite.location.column
1287
+ };
1288
+ }
1289
+ if (childSuite.suites.length > 0) {
1290
+ skeletonSuite.suites = this.buildSuiteTree(childSuite);
1291
+ }
1292
+ suites.push(skeletonSuite);
1293
+ }
1294
+ return suites;
1295
+ }
1296
+ /**
1297
+ * Build skeleton test from TestCase
1298
+ */
1299
+ buildSkeletonTest(test) {
1300
+ const skeletonTest = {
1301
+ testId: test.id,
1302
+ title: test.title,
1303
+ location: {
1304
+ file: test.location.file,
1305
+ line: test.location.line,
1306
+ column: test.location.column
1307
+ }
1308
+ };
1309
+ if (test.tags && test.tags.length > 0) {
1310
+ skeletonTest.tags = test.tags;
1311
+ }
1312
+ if (test.expectedStatus) {
1313
+ skeletonTest.expectedStatus = test.expectedStatus;
1314
+ }
1315
+ if (test.annotations && test.annotations.length > 0) {
1316
+ skeletonTest.annotations = test.annotations.map((ann) => ({
1317
+ type: ann.type,
1318
+ description: ann.description
1319
+ }));
1320
+ }
1321
+ return skeletonTest;
1322
+ }
1323
+ };
1324
+
1325
+ // src/metadata/index.ts
1326
+ var DEFAULT_METADATA_OPTIONS = {
1327
+ timeout: 5e3,
1328
+ debug: false
1329
+ };
1330
+ var MetadataAggregator = class {
1331
+ options;
1332
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
1333
+ collectors = [];
1334
+ playwrightCollector;
1335
+ constructor(options = {}) {
1336
+ this.options = { ...DEFAULT_METADATA_OPTIONS, ...options };
1337
+ }
1338
+ /**
1339
+ * Register a metadata collector
1340
+ */
1341
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
1342
+ registerCollector(collector) {
1343
+ this.collectors.push(collector);
1344
+ if (collector instanceof PlaywrightMetadataCollector) {
1345
+ this.playwrightCollector = collector;
1346
+ }
1347
+ }
1348
+ /**
1349
+ * Build skeleton from Suite (if PlaywrightMetadataCollector is registered with a suite)
1350
+ */
1351
+ buildSkeleton(suite) {
1352
+ if (!this.playwrightCollector) {
1353
+ return void 0;
1354
+ }
1355
+ return this.playwrightCollector.buildSkeleton(suite);
1356
+ }
1357
+ /**
1358
+ * Collect metadata from all registered collectors
1359
+ * Uses Promise.allSettled for error isolation
1360
+ */
1361
+ async collectAll() {
1362
+ const startTime = Date.now();
1363
+ if (this.options.debug) {
1364
+ console.log(`\u{1F50D} TestDino: Starting metadata collection with ${this.collectors.length} collectors`);
1365
+ }
1366
+ const settledResults = await Promise.allSettled(
1367
+ this.collectors.map(
1368
+ (collector) => this.withTimeout(collector.collectWithResult(), this.options.timeout, "Metadata collection")
1369
+ )
1370
+ );
1371
+ const results = [];
1372
+ const metadata = {};
1373
+ for (const settledResult of settledResults) {
1374
+ if (settledResult.status === "fulfilled") {
1375
+ const result = settledResult.value;
1376
+ results.push(result);
1377
+ this.aggregateMetadata(metadata, result);
1378
+ } else {
1379
+ const error = settledResult.reason;
1380
+ console.warn("\u26A0\uFE0F TestDino: Metadata collector promise rejected:", error);
1381
+ results.push({
1382
+ data: {},
1383
+ success: false,
1384
+ error: error instanceof Error ? error.message : String(error),
1385
+ duration: 0,
1386
+ collector: "unknown"
1387
+ });
1388
+ }
1389
+ }
1390
+ const totalDuration = Date.now() - startTime;
1391
+ const successCount = results.filter((r) => r.success).length;
1392
+ const failureCount = results.length - successCount;
1393
+ if (this.options.debug) {
1394
+ console.log(
1395
+ `\u2705 TestDino: Metadata collection completed in ${totalDuration}ms (${successCount}/${results.length} successful)`
1396
+ );
1397
+ }
1398
+ return {
1399
+ metadata,
1400
+ results,
1401
+ totalDuration,
1402
+ successCount,
1403
+ failureCount
1404
+ };
1405
+ }
1406
+ /**
1407
+ * Aggregate individual collector results into complete metadata
1408
+ */
1409
+ aggregateMetadata(metadata, result) {
1410
+ const { collector, data } = result;
1411
+ switch (collector) {
1412
+ case "git":
1413
+ metadata.git = data;
1414
+ break;
1415
+ case "ci":
1416
+ metadata.ci = data;
1417
+ break;
1418
+ case "system":
1419
+ metadata.system = data;
1420
+ break;
1421
+ case "playwright":
1422
+ metadata.playwright = data;
1423
+ break;
1424
+ default:
1425
+ if (this.options.debug) {
1426
+ console.warn(`\u26A0\uFE0F TestDino: Unknown metadata collector: ${collector}`);
1427
+ }
1428
+ }
1429
+ }
1430
+ /**
1431
+ * Utility method to run operation with timeout
1432
+ */
1433
+ async withTimeout(promise, timeoutMs, operation) {
1434
+ const timeoutPromise = new Promise((_, reject) => {
1435
+ setTimeout(() => {
1436
+ reject(new Error(`${operation} timed out after ${timeoutMs}ms`));
1437
+ }, timeoutMs);
1438
+ });
1439
+ return Promise.race([promise, timeoutPromise]);
1440
+ }
1441
+ };
1442
+ function createMetadataCollector(playwrightConfig, playwrightSuite) {
1443
+ const aggregator = new MetadataAggregator();
1444
+ aggregator.registerCollector(new GitMetadataCollector());
1445
+ aggregator.registerCollector(new CIMetadataCollector());
1446
+ aggregator.registerCollector(new SystemMetadataCollector());
1447
+ aggregator.registerCollector(
1448
+ new PlaywrightMetadataCollector({
1449
+ config: playwrightConfig,
1450
+ suite: playwrightSuite
1451
+ })
1452
+ );
1453
+ return aggregator;
1454
+ }
1455
+ var SASTokenClient = class {
1456
+ client;
1457
+ options;
1458
+ constructor(options) {
1459
+ this.options = {
1460
+ maxRetries: 2,
1461
+ retryDelay: 1e3,
1462
+ ...options
1463
+ };
1464
+ this.client = axios.create({
1465
+ baseURL: this.options.serverUrl,
1466
+ headers: {
1467
+ "Content-Type": "application/json",
1468
+ "x-api-key": this.options.token
1469
+ },
1470
+ timeout: 1e4
1471
+ });
1472
+ }
1473
+ /**
1474
+ * Request SAS token for artifact uploads
1475
+ * @param expiryHours - Token validity duration (1-48 hours, default 48)
1476
+ * @returns SAS token response with upload instructions
1477
+ */
1478
+ async requestToken(expiryHours = 48) {
1479
+ let lastError = null;
1480
+ for (let attempt = 0; attempt < this.options.maxRetries; attempt++) {
1481
+ try {
1482
+ const response = await this.client.post("/api/storage/token", void 0, {
1483
+ params: {
1484
+ expiryHours,
1485
+ permissions: "write,create"
1486
+ }
1487
+ });
1488
+ if (!response.data.success) {
1489
+ throw new Error(response.data.message || "SAS token request failed");
1490
+ }
1491
+ return response.data.data;
1492
+ } catch (error) {
1493
+ lastError = new Error(this.getErrorMessage(error));
1494
+ if (attempt < this.options.maxRetries - 1) {
1495
+ const delay = this.options.retryDelay * Math.pow(2, attempt);
1496
+ await this.sleep(delay);
1497
+ }
1498
+ }
1499
+ }
1500
+ throw lastError || new Error("Failed to request SAS token");
1501
+ }
1502
+ /**
1503
+ * Extract error message from various error types
1504
+ */
1505
+ getErrorMessage(error) {
1506
+ if (axios.isAxiosError(error)) {
1507
+ if (error.response?.status === 401) {
1508
+ return "Invalid API key for artifact uploads";
1509
+ }
1510
+ if (error.response?.status === 403) {
1511
+ return "API key does not have write permission for uploads";
1512
+ }
1513
+ if (error.response?.status === 429) {
1514
+ return "Rate limit exceeded for SAS token requests";
1515
+ }
1516
+ return error.response?.data?.message || error.message;
1517
+ }
1518
+ if (error instanceof Error) {
1519
+ return error.message;
1520
+ }
1521
+ return String(error);
1522
+ }
1523
+ /**
1524
+ * Sleep utility for retry delays
1525
+ */
1526
+ sleep(ms) {
1527
+ return new Promise((resolve) => setTimeout(resolve, ms));
1528
+ }
1529
+ };
1530
+ var ArtifactUploader = class {
1531
+ sasToken;
1532
+ options;
1533
+ constructor(sasToken, options = {}) {
1534
+ this.sasToken = sasToken;
1535
+ this.options = {
1536
+ timeout: 6e4,
1537
+ maxRetries: 2,
1538
+ debug: false,
1539
+ ...options
1540
+ };
1541
+ }
1542
+ /**
1543
+ * Upload a single file to Azure Blob Storage
1544
+ * @param attachment - Attachment with path and content type
1545
+ * @param testId - Test identifier for organizing uploads
1546
+ * @returns Upload result with Azure URL or error
1547
+ */
1548
+ async uploadFile(attachment, testId) {
1549
+ const startTime = Date.now();
1550
+ const fileName = basename(attachment.path);
1551
+ try {
1552
+ const stats = statSync(attachment.path);
1553
+ const fileSize = stats.size;
1554
+ if (fileSize > this.sasToken.maxSize) {
1555
+ return {
1556
+ name: attachment.name,
1557
+ success: false,
1558
+ error: `File size ${fileSize} bytes exceeds maximum ${this.sasToken.maxSize} bytes`,
1559
+ fileSize,
1560
+ duration: Date.now() - startTime
1561
+ };
1562
+ }
1563
+ const extension = extname(fileName).slice(1).toLowerCase();
1564
+ const allowedTypes = this.sasToken.allowedFileTypes;
1565
+ if (allowedTypes.length > 0 && !allowedTypes.includes(extension)) {
1566
+ return {
1567
+ name: attachment.name,
1568
+ success: false,
1569
+ error: `File extension '.${extension}' not in allowed types: ${allowedTypes.join(", ")}`,
1570
+ fileSize,
1571
+ duration: Date.now() - startTime
1572
+ };
1573
+ }
1574
+ const uploadUrl = this.buildUploadUrl(testId, fileName);
1575
+ await this.uploadWithRetry(attachment.path, uploadUrl, attachment.contentType);
1576
+ const publicUrl = this.buildPublicUrl(testId, fileName);
1577
+ if (this.options.debug) {
1578
+ console.log(`\u{1F4E4} Uploaded: ${attachment.name} \u2192 ${publicUrl}`);
1579
+ }
1580
+ return {
1581
+ name: attachment.name,
1582
+ success: true,
1583
+ uploadUrl: publicUrl,
1584
+ fileSize,
1585
+ duration: Date.now() - startTime
1586
+ };
1587
+ } catch (error) {
1588
+ const errorMessage = error instanceof Error ? error.message : String(error);
1589
+ if (this.options.debug) {
1590
+ console.warn(`\u26A0\uFE0F Upload failed: ${attachment.name} - ${errorMessage}`);
1591
+ }
1592
+ return {
1593
+ name: attachment.name,
1594
+ success: false,
1595
+ error: errorMessage,
1596
+ duration: Date.now() - startTime
1597
+ };
1598
+ }
1599
+ }
1600
+ /**
1601
+ * Upload multiple attachments in parallel
1602
+ * @param attachments - Array of attachments to upload
1603
+ * @param testId - Test identifier for organizing uploads
1604
+ * @returns Array of upload results
1605
+ */
1606
+ async uploadAll(attachments, testId) {
1607
+ if (attachments.length === 0) {
1608
+ return [];
1609
+ }
1610
+ const validAttachments = attachments.filter((a) => a.path);
1611
+ if (validAttachments.length === 0) {
1612
+ return [];
1613
+ }
1614
+ const results = await Promise.allSettled(validAttachments.map((attachment) => this.uploadFile(attachment, testId)));
1615
+ return results.map((result, index) => {
1616
+ if (result.status === "fulfilled") {
1617
+ return result.value;
1618
+ }
1619
+ return {
1620
+ name: validAttachments[index].name,
1621
+ success: false,
1622
+ error: result.reason?.message || "Upload failed"
1623
+ };
1624
+ });
1625
+ }
1626
+ /**
1627
+ * Build the full upload URL with SAS token
1628
+ * Format: containerUrl/blobPath/uniqueId/testId/fileName?sasToken
1629
+ */
1630
+ buildUploadUrl(testId, fileName) {
1631
+ const { containerUrl, blobPath, uniqueId, sasToken } = this.sasToken;
1632
+ const path = `${blobPath}/${uniqueId}/${this.sanitizeTestId(testId)}/${fileName}`;
1633
+ return `${containerUrl}/${path}?${sasToken}`;
1634
+ }
1635
+ /**
1636
+ * Build public URL without SAS token (for storage in events)
1637
+ * Format: containerUrl/blobPath/uniqueId/testId/fileName
1638
+ */
1639
+ buildPublicUrl(testId, fileName) {
1640
+ const { containerUrl, blobPath, uniqueId } = this.sasToken;
1641
+ const path = `${blobPath}/${uniqueId}/${this.sanitizeTestId(testId)}/${fileName}`;
1642
+ return `${containerUrl}/${path}`;
1643
+ }
1644
+ /**
1645
+ * Sanitize test ID for use in URL path
1646
+ */
1647
+ sanitizeTestId(testId) {
1648
+ return testId.replace(/[^a-zA-Z0-9-_]/g, "_");
1649
+ }
1650
+ /**
1651
+ * Upload file with retry logic
1652
+ */
1653
+ async uploadWithRetry(filePath, uploadUrl, contentType) {
1654
+ let lastError = null;
1655
+ for (let attempt = 0; attempt < this.options.maxRetries; attempt++) {
1656
+ try {
1657
+ await this.doUpload(filePath, uploadUrl, contentType);
1658
+ return;
1659
+ } catch (error) {
1660
+ lastError = error instanceof Error ? error : new Error(String(error));
1661
+ if (attempt < this.options.maxRetries - 1) {
1662
+ const delay = 1e3 * Math.pow(2, attempt);
1663
+ await this.sleep(delay);
1664
+ }
1665
+ }
1666
+ }
1667
+ throw lastError || new Error("Upload failed after retries");
1668
+ }
1669
+ /**
1670
+ * Perform actual file upload to Azure
1671
+ */
1672
+ async doUpload(filePath, uploadUrl, contentType) {
1673
+ const fileStream = createReadStream(filePath);
1674
+ const stats = statSync(filePath);
1675
+ await axios.put(uploadUrl, fileStream, {
1676
+ headers: {
1677
+ "Content-Type": contentType,
1678
+ "Content-Length": stats.size,
1679
+ "x-ms-blob-type": "BlockBlob"
1680
+ },
1681
+ timeout: this.options.timeout,
1682
+ maxContentLength: Infinity,
1683
+ maxBodyLength: Infinity
1684
+ });
1685
+ }
1686
+ /**
1687
+ * Sleep utility for retry delays
1688
+ */
1689
+ sleep(ms) {
1690
+ return new Promise((resolve) => setTimeout(resolve, ms));
1691
+ }
1692
+ /**
1693
+ * Check if SAS token is still valid
1694
+ */
1695
+ isTokenValid() {
1696
+ const expiresAt = new Date(this.sasToken.expiresAt).getTime();
1697
+ const now = Date.now();
1698
+ return expiresAt > now + 5 * 60 * 1e3;
1699
+ }
1700
+ /**
1701
+ * Get the unique ID for this upload session
1702
+ */
1703
+ getSessionId() {
1704
+ return this.sasToken.uniqueId;
1705
+ }
1706
+ };
1707
+
1708
+ // src/reporter/index.ts
1709
+ var MAX_CONSOLE_CHUNK_SIZE = 1e4;
1710
+ var MAX_BUFFER_SIZE = 10;
1711
+ var TestdinoReporter = class {
1712
+ config;
1713
+ wsClient = null;
1714
+ httpClient = null;
1715
+ buffer = null;
1716
+ runId;
1717
+ useHttpFallback = false;
1718
+ sequenceNumber = 0;
1719
+ // Shard and timing info for interruption handling
1720
+ shardInfo;
1721
+ runStartTime;
1722
+ // Signal handler management
1723
+ sigintHandler;
1724
+ sigtermHandler;
1725
+ isShuttingDown = false;
1726
+ // Quota tracking
1727
+ quotaExceeded = false;
1728
+ // Session ID from HTTP auth, passed to WebSocket for session reuse
1729
+ sessionId = null;
1730
+ // Artifact upload
1731
+ artifactUploader = null;
1732
+ artifactsEnabled = true;
1733
+ // Default: enabled
1734
+ // Deferred initialization - resolves true on success, false on failure
1735
+ initPromise = null;
1736
+ initFailed = false;
1737
+ constructor(config = {}) {
1738
+ const cliConfig = this.loadCliConfig();
1739
+ this.config = { ...config, ...cliConfig };
1740
+ this.runId = randomUUID();
1741
+ this.buffer = new EventBuffer({
1742
+ maxSize: MAX_BUFFER_SIZE,
1743
+ onFlush: async (events) => {
1744
+ if (this.initPromise) {
1745
+ const success = await this.initPromise;
1746
+ if (!success) return;
1747
+ }
1748
+ await this.sendEvents(events);
1749
+ }
1750
+ });
1751
+ }
1752
+ /**
1753
+ * Load configuration from CLI temp file if available
1754
+ */
1755
+ loadCliConfig() {
1756
+ const cliConfigPath = process.env.TESTDINO_CLI_CONFIG_PATH;
1757
+ if (!cliConfigPath) {
1758
+ return {};
1759
+ }
1760
+ try {
1761
+ if (!existsSync(cliConfigPath)) {
1762
+ return {};
1763
+ }
1764
+ const configContent = readFileSync(cliConfigPath, "utf-8");
1765
+ const cliConfig = JSON.parse(configContent);
1766
+ const mappedConfig = {};
1767
+ if (cliConfig.token !== void 0 && typeof cliConfig.token === "string") {
1768
+ mappedConfig.token = cliConfig.token;
1769
+ }
1770
+ if (cliConfig.serverUrl !== void 0 && typeof cliConfig.serverUrl === "string") {
1771
+ mappedConfig.serverUrl = cliConfig.serverUrl;
1772
+ }
1773
+ if (cliConfig.debug !== void 0 && typeof cliConfig.debug === "boolean") {
1774
+ mappedConfig.debug = cliConfig.debug;
1775
+ }
1776
+ if (cliConfig.ciRunId !== void 0 && typeof cliConfig.ciRunId === "string") {
1777
+ mappedConfig.ciBuildId = cliConfig.ciRunId;
1778
+ }
1779
+ if (cliConfig.artifacts !== void 0 && typeof cliConfig.artifacts === "boolean") {
1780
+ mappedConfig.artifacts = cliConfig.artifacts;
1781
+ }
1782
+ return mappedConfig;
1783
+ } catch (error) {
1784
+ if (process.env.TESTDINO_DEBUG === "true" || process.env.TESTDINO_DEBUG === "1") {
1785
+ console.warn(
1786
+ "\u26A0\uFE0F TestDino: Failed to load CLI config:",
1787
+ error instanceof Error ? error.message : String(error)
1788
+ );
1789
+ }
1790
+ return {};
1791
+ }
1792
+ }
1793
+ /**
1794
+ * Called once before running tests
1795
+ */
1796
+ async onBegin(config, suite) {
1797
+ if (config && this.isDuplicateInstance(config.reporter)) {
1798
+ if (this.config.debug) {
1799
+ console.log("\u26A0\uFE0F TestDino: Reporter already configured in playwright.config, skipping duplicate instance");
1800
+ }
1801
+ return;
1802
+ }
1803
+ const token = this.getToken();
1804
+ if (!token) {
1805
+ this.printConfigurationError("Token is required but not provided", [
1806
+ "Set environment variable: export TESTDINO_TOKEN=your-token",
1807
+ 'Add to playwright.config.ts: token: "your-token"',
1808
+ "Use CLI wrapper: npx tdpw test --token your-token"
1809
+ ]);
1810
+ return;
1811
+ }
1812
+ if (config?.shard) {
1813
+ this.shardInfo = {
1814
+ current: config.shard.current,
1815
+ total: config.shard.total
1816
+ };
1817
+ }
1818
+ this.runStartTime = Date.now();
1819
+ this.initPromise = this.performAsyncInit(config, suite, token);
1820
+ }
1821
+ /**
1822
+ * Perform all async initialization: metadata, auth, WebSocket, artifacts, run:begin
1823
+ *
1824
+ * Buffer's onFlush callback awaits this promise before transmitting events,
1825
+ * ensuring no data is sent before authentication and connection are established.
1826
+ *
1827
+ * @param config - Playwright FullConfig for metadata collection
1828
+ * @param suite - Playwright Suite for skeleton building
1829
+ * @param token - Authentication token
1830
+ * @returns true on success, false on failure
1831
+ */
1832
+ async performAsyncInit(config, suite, token) {
1833
+ const serverUrl = this.getServerUrl();
1834
+ try {
1835
+ const metadata = await this.collectMetadata(config, suite);
1836
+ this.httpClient = new HttpClient({ token, serverUrl });
1837
+ const auth = await this.httpClient.authenticate();
1838
+ this.sessionId = auth.sessionId;
1839
+ console.log("\u2705 TestDino: Authenticated successfully");
1840
+ if (this.config.debug) {
1841
+ console.log(`\u{1F50C} TestDino: Session ${this.sessionId} \u2014 reusing for WebSocket`);
1842
+ }
1843
+ this.wsClient = new WebSocketClient({
1844
+ token,
1845
+ sessionId: this.sessionId ?? void 0,
1846
+ serverUrl: this.getWebSocketUrl(),
1847
+ onConnected: () => {
1848
+ console.log("\u{1F50C} TestDino: WebSocket connected");
1849
+ },
1850
+ onDisconnected: () => {
1851
+ console.log("\u{1F50C} TestDino: WebSocket disconnected");
1852
+ },
1853
+ onError: (error) => {
1854
+ if (isQuotaError(error)) {
1855
+ if (!this.quotaExceeded) {
1856
+ this.quotaExceeded = true;
1857
+ this.initFailed = true;
1858
+ this.printQuotaError(error);
1859
+ }
1860
+ } else {
1861
+ console.error("\u274C TestDino: WebSocket error:", error.message);
1862
+ }
1863
+ }
1864
+ });
1865
+ try {
1866
+ await this.wsClient.connect();
1867
+ } catch {
1868
+ console.warn("\u26A0\uFE0F TestDino: WebSocket connection failed, using HTTP fallback");
1869
+ this.useHttpFallback = true;
1870
+ }
1871
+ this.artifactsEnabled = this.config.artifacts !== false;
1872
+ if (this.artifactsEnabled) {
1873
+ await this.initializeArtifactUploader(token, serverUrl);
1874
+ }
1875
+ const beginEvent = {
1876
+ type: "run:begin",
1877
+ runId: this.runId,
1878
+ metadata,
1879
+ ...this.getEventMetadata()
1880
+ };
1881
+ await this.sendEvents([beginEvent]);
1882
+ this.registerSignalHandlers();
1883
+ return true;
1884
+ } catch (error) {
1885
+ const errorMessage = error instanceof Error ? error.message : String(error);
1886
+ this.initFailed = true;
1887
+ if (error instanceof Error && "code" in error && isQuotaError(error)) {
1888
+ this.quotaExceeded = true;
1889
+ this.printQuotaError(error);
1890
+ } else if (errorMessage.includes("Authentication failed") || errorMessage.includes("401") || errorMessage.includes("Unauthorized")) {
1891
+ this.printConfigurationError("Authentication failed - Invalid or expired token", [
1892
+ "Verify your token is correct",
1893
+ "Check if the token has expired",
1894
+ "Generate a new token from TestDino dashboard",
1895
+ `Server URL: ${serverUrl}`
1896
+ ]);
1897
+ } else {
1898
+ this.printConfigurationError(`Failed to initialize TestDino reporter: ${errorMessage}`, [
1899
+ "Check if TestDino server is running and accessible",
1900
+ `Verify server URL is correct: ${serverUrl}`,
1901
+ "Check network connectivity",
1902
+ "Review server logs for details"
1903
+ ]);
1904
+ }
1905
+ return false;
1906
+ }
1907
+ }
1908
+ /**
1909
+ * Called for each test before it starts
1910
+ */
1911
+ async onTestBegin(test, result) {
1912
+ if (!this.initPromise || this.initFailed) return;
1913
+ const event = {
1914
+ type: "test:begin",
1915
+ runId: this.runId,
1916
+ ...this.getEventMetadata(),
1917
+ // Test identification
1918
+ testId: test.id,
1919
+ title: test.title,
1920
+ titlePath: test.titlePath(),
1921
+ // Location information
1922
+ location: {
1923
+ file: test.location.file,
1924
+ line: test.location.line,
1925
+ column: test.location.column
1926
+ },
1927
+ // Test configuration
1928
+ tags: test.tags,
1929
+ expectedStatus: test.expectedStatus,
1930
+ timeout: test.timeout,
1931
+ retries: test.retries,
1932
+ annotations: test.annotations.map((a) => ({
1933
+ type: a.type,
1934
+ description: a.description
1935
+ })),
1936
+ // Execution context
1937
+ retry: result.retry,
1938
+ workerIndex: result.workerIndex,
1939
+ parallelIndex: result.parallelIndex,
1940
+ repeatEachIndex: test.repeatEachIndex,
1941
+ // Hierarchy information
1942
+ parentSuite: this.extractParentSuite(test.parent),
1943
+ // Timing
1944
+ startTime: result.startTime.getTime()
1945
+ };
1946
+ await this.buffer.add(event);
1947
+ }
1948
+ /**
1949
+ * Called when a test step begins
1950
+ */
1951
+ async onStepBegin(test, result, step) {
1952
+ if (!this.initPromise || this.initFailed) return;
1953
+ const event = {
1954
+ type: "step:begin",
1955
+ runId: this.runId,
1956
+ ...this.getEventMetadata(),
1957
+ // Step Identification
1958
+ testId: test.id,
1959
+ stepId: `${test.id}-${step.titlePath().join("-")}`,
1960
+ title: step.title,
1961
+ titlePath: step.titlePath(),
1962
+ // Step Classification
1963
+ category: step.category,
1964
+ // Location Information
1965
+ location: step.location ? {
1966
+ file: step.location.file,
1967
+ line: step.location.line,
1968
+ column: step.location.column
1969
+ } : void 0,
1970
+ // Hierarchy Information
1971
+ parentStep: step.parent ? this.extractParentStep(step.parent) : void 0,
1972
+ // Timing
1973
+ startTime: step.startTime.getTime(),
1974
+ // Retry Information
1975
+ retry: result.retry,
1976
+ // Worker Information
1977
+ workerIndex: result.workerIndex,
1978
+ parallelIndex: result.parallelIndex
1979
+ };
1980
+ await this.buffer.add(event);
1981
+ }
1982
+ /**
1983
+ * Called when a test step ends
1984
+ */
1985
+ async onStepEnd(test, result, step) {
1986
+ if (!this.initPromise || this.initFailed) return;
1987
+ const status = step.error ? "failed" : "passed";
1988
+ const event = {
1989
+ type: "step:end",
1990
+ runId: this.runId,
1991
+ ...this.getEventMetadata(),
1992
+ // Step Identification
1993
+ testId: test.id,
1994
+ stepId: `${test.id}-${step.titlePath().join("-")}`,
1995
+ title: step.title,
1996
+ titlePath: step.titlePath(),
1997
+ // Timing
1998
+ duration: step.duration,
1999
+ // Error Information
2000
+ error: this.extractError(step.error),
2001
+ // Status Information
2002
+ status,
2003
+ // Child Steps Summary
2004
+ childSteps: this.extractChildSteps(step),
2005
+ // Attachments Metadata
2006
+ attachments: this.extractAttachments(step),
2007
+ // Annotations
2008
+ annotations: test.annotations.map((a) => ({
2009
+ type: a.type,
2010
+ description: a.description
2011
+ })),
2012
+ // Retry Information
2013
+ retry: result.retry,
2014
+ // Worker Information
2015
+ workerIndex: result.workerIndex,
2016
+ parallelIndex: result.parallelIndex
2017
+ };
2018
+ await this.buffer.add(event);
2019
+ }
2020
+ /**
2021
+ * Called after each test completes
2022
+ */
2023
+ async onTestEnd(test, result) {
2024
+ if (!this.initPromise || this.initFailed) return;
2025
+ const attachmentsWithUrls = await this.uploadAttachments(result.attachments, test.id);
2026
+ const event = {
2027
+ type: "test:end",
2028
+ runId: this.runId,
2029
+ ...this.getEventMetadata(),
2030
+ // Test Identification
2031
+ testId: test.id,
2032
+ // Status Information
2033
+ status: result.status,
2034
+ outcome: test.outcome(),
2035
+ // Timing
2036
+ duration: result.duration,
2037
+ // Execution Context
2038
+ retry: result.retry,
2039
+ // Worker Information
2040
+ workerIndex: result.workerIndex,
2041
+ parallelIndex: result.parallelIndex,
2042
+ // Test Metadata
2043
+ annotations: test.annotations.map((a) => ({
2044
+ type: a.type,
2045
+ description: a.description
2046
+ })),
2047
+ // Error Information
2048
+ errors: result.errors.map((e) => this.extractError(e)).filter((e) => e !== void 0),
2049
+ // Step Summary
2050
+ steps: this.extractTestStepsSummary(result),
2051
+ // Attachments Metadata (with Azure URLs when uploaded)
2052
+ attachments: attachmentsWithUrls,
2053
+ // Console Output
2054
+ stdout: result.stdout.length > 0 ? this.extractConsoleOutput(result.stdout) : void 0,
2055
+ stderr: result.stderr.length > 0 ? this.extractConsoleOutput(result.stderr) : void 0
2056
+ };
2057
+ await this.buffer.add(event);
2058
+ }
2059
+ /**
2060
+ * Called after all tests complete
2061
+ */
2062
+ async onEnd(result) {
2063
+ if (this.quotaExceeded) {
2064
+ console.log("\u2705 TestDino: Tests completed (quota limit reached; not streamed to TestDino)");
2065
+ this.wsClient?.close();
2066
+ this.removeSignalHandlers();
2067
+ return;
2068
+ }
2069
+ if (!this.initPromise) return;
2070
+ const success = await this.initPromise;
2071
+ if (!success) {
2072
+ this.buffer?.clear();
2073
+ this.wsClient?.close();
2074
+ this.removeSignalHandlers();
2075
+ return;
2076
+ }
2077
+ const event = {
2078
+ type: "run:end",
2079
+ runId: this.runId,
2080
+ ...this.getEventMetadata(),
2081
+ // Run Status
2082
+ status: result.status,
2083
+ // Timing
2084
+ duration: result.duration,
2085
+ startTime: result.startTime.getTime(),
2086
+ // Shard information
2087
+ shard: this.shardInfo
2088
+ };
2089
+ await this.buffer.add(event);
2090
+ try {
2091
+ await this.buffer.flush();
2092
+ console.log("\u2705 TestDino: All events sent successfully");
2093
+ } catch (error) {
2094
+ console.error("\u274C TestDino: Failed to flush final events:", error);
2095
+ }
2096
+ this.wsClient?.close();
2097
+ this.removeSignalHandlers();
2098
+ }
2099
+ /**
2100
+ * Called on global errors
2101
+ */
2102
+ async onError(error) {
2103
+ if (!this.initPromise || this.initFailed) return;
2104
+ const event = {
2105
+ type: "run:error",
2106
+ runId: this.runId,
2107
+ ...this.getEventMetadata(),
2108
+ // Error Information (with cause support)
2109
+ error: this.extractGlobalError(error)
2110
+ };
2111
+ await this.buffer.add(event);
2112
+ }
2113
+ /**
2114
+ * Called when standard output is produced in worker process
2115
+ */
2116
+ async onStdOut(chunk, test, result) {
2117
+ if (!this.initPromise || this.initFailed) return;
2118
+ const { text, truncated } = this.truncateChunk(chunk);
2119
+ const event = {
2120
+ type: "console:out",
2121
+ runId: this.runId,
2122
+ ...this.getEventMetadata(),
2123
+ // Console Output
2124
+ text,
2125
+ // Test Association (optional)
2126
+ testId: test?.id,
2127
+ retry: result?.retry,
2128
+ // Truncation Indicator
2129
+ truncated
2130
+ };
2131
+ await this.buffer.add(event);
2132
+ }
2133
+ /**
2134
+ * Called when standard error is produced in worker process
2135
+ */
2136
+ async onStdErr(chunk, test, result) {
2137
+ if (!this.initPromise || this.initFailed) return;
2138
+ const { text, truncated } = this.truncateChunk(chunk);
2139
+ const event = {
2140
+ type: "console:err",
2141
+ runId: this.runId,
2142
+ ...this.getEventMetadata(),
2143
+ // Console Error Output
2144
+ text,
2145
+ // Test Association (optional)
2146
+ testId: test?.id,
2147
+ retry: result?.retry,
2148
+ // Truncation Indicator
2149
+ truncated
2150
+ };
2151
+ await this.buffer.add(event);
2152
+ }
2153
+ /**
2154
+ * Indicates whether this reporter outputs to stdout/stderr
2155
+ * Returns false to allow Playwright to add its own terminal output
2156
+ */
2157
+ printsToStdio() {
2158
+ return false;
2159
+ }
2160
+ /**
2161
+ * Send events via WebSocket or HTTP fallback
2162
+ */
2163
+ async sendEvents(events) {
2164
+ if (events.length === 0) return;
2165
+ if (this.config.debug) {
2166
+ for (const event of events) {
2167
+ if (event.type === "test:begin") {
2168
+ const testBeginEvent = event;
2169
+ console.log(
2170
+ `\u{1F50D} TestDino: Sending event type=${event.type} sequence=${event.sequence} runId=${event.runId} testId=${testBeginEvent.testId} retry=${testBeginEvent.retry} parallelIndex=${testBeginEvent.parallelIndex} title=${testBeginEvent.title}`
2171
+ );
2172
+ } else if (event.type === "test:end") {
2173
+ const testEndEvent = event;
2174
+ console.log(
2175
+ `\u{1F50D} TestDino: Sending event type=${event.type} sequence=${event.sequence} runId=${event.runId} testId=${testEndEvent.testId} retry=${testEndEvent.retry} parallelIndex=${testEndEvent.parallelIndex}`
2176
+ );
2177
+ } else {
2178
+ console.log(`\u{1F50D} TestDino: Sending event type=${event.type} sequence=${event.sequence} runId=${event.runId}`);
2179
+ }
2180
+ }
2181
+ }
2182
+ if (!this.useHttpFallback && this.wsClient?.isConnected()) {
2183
+ try {
2184
+ await this.wsClient.sendBatch(events);
2185
+ return;
2186
+ } catch {
2187
+ console.warn("\u26A0\uFE0F TestDino: WebSocket send failed, switching to HTTP fallback");
2188
+ this.useHttpFallback = true;
2189
+ }
2190
+ }
2191
+ if (this.httpClient) {
2192
+ try {
2193
+ await this.httpClient.sendEvents(events);
2194
+ } catch (error) {
2195
+ console.error("\u274C TestDino: Failed to send events via HTTP:", error);
2196
+ throw error;
2197
+ }
2198
+ }
2199
+ }
2200
+ /**
2201
+ * Get token from config or environment
2202
+ */
2203
+ getToken() {
2204
+ return this.config.token || process.env.TESTDINO_TOKEN;
2205
+ }
2206
+ /**
2207
+ * Get server URL from config or environment
2208
+ */
2209
+ getServerUrl() {
2210
+ const baseUrl = this.config.serverUrl || process.env.TESTDINO_SERVER_URL || "https://api.testdino.com";
2211
+ return baseUrl.endsWith("/api/reporter") ? baseUrl : `${baseUrl}/api/reporter`;
2212
+ }
2213
+ getWebSocketUrl() {
2214
+ const baseUrl = this.config.serverUrl || process.env.TESTDINO_SERVER_URL || "https://api.testdino.com";
2215
+ const wsBaseUrl = baseUrl.replace("/api/reporter", "");
2216
+ return wsBaseUrl.replace("http", "ws");
2217
+ }
2218
+ /**
2219
+ * Collect metadata for the test run
2220
+ */
2221
+ async collectMetadata(playwrightConfig, playwrightSuite) {
2222
+ try {
2223
+ const metadataCollector = createMetadataCollector(playwrightConfig, playwrightSuite);
2224
+ const result = await metadataCollector.collectAll();
2225
+ if (result.failureCount > 0) {
2226
+ console.warn(`\u26A0\uFE0F TestDino: ${result.failureCount}/${result.results.length} metadata collectors failed`);
2227
+ }
2228
+ const skeleton = metadataCollector.buildSkeleton(playwrightSuite);
2229
+ return {
2230
+ ...result.metadata,
2231
+ skeleton
2232
+ };
2233
+ } catch (error) {
2234
+ console.warn("\u26A0\uFE0F TestDino: Metadata collection failed:", error instanceof Error ? error.message : String(error));
2235
+ return {};
2236
+ }
2237
+ }
2238
+ /**
2239
+ * Get next sequence number and current timestamp
2240
+ */
2241
+ getEventMetadata() {
2242
+ return {
2243
+ timestamp: Date.now(),
2244
+ sequence: ++this.sequenceNumber
2245
+ };
2246
+ }
2247
+ /**
2248
+ * Extract parent suite information for skeleton mapping
2249
+ */
2250
+ extractParentSuite(parent) {
2251
+ return {
2252
+ title: parent.title,
2253
+ type: parent.type,
2254
+ location: parent.location ? {
2255
+ file: parent.location.file,
2256
+ line: parent.location.line,
2257
+ column: parent.location.column
2258
+ } : void 0
2259
+ };
2260
+ }
2261
+ /**
2262
+ * Extract parent step information for step hierarchy mapping
2263
+ */
2264
+ extractParentStep(parent) {
2265
+ return {
2266
+ title: parent.title,
2267
+ category: parent.category,
2268
+ location: parent.location ? {
2269
+ file: parent.location.file,
2270
+ line: parent.location.line,
2271
+ column: parent.location.column
2272
+ } : void 0
2273
+ };
2274
+ }
2275
+ /**
2276
+ * Extract child steps summary for step:end event
2277
+ */
2278
+ extractChildSteps(step) {
2279
+ return {
2280
+ count: step.steps.length,
2281
+ steps: step.steps.map((child) => ({
2282
+ title: child.title,
2283
+ status: child.error ? "failed" : "passed"
2284
+ }))
2285
+ };
2286
+ }
2287
+ /**
2288
+ * Extract error details from TestError (shared by step:end, test:end, and error events)
2289
+ */
2290
+ extractError(error) {
2291
+ if (!error) return void 0;
2292
+ return {
2293
+ message: error.message || String(error),
2294
+ stack: error.stack,
2295
+ snippet: error.snippet,
2296
+ value: error.value,
2297
+ location: error.location ? {
2298
+ file: error.location.file,
2299
+ line: error.location.line,
2300
+ column: error.location.column
2301
+ } : void 0
2302
+ };
2303
+ }
2304
+ /**
2305
+ * Extract global error details with cause support (for error events)
2306
+ */
2307
+ extractGlobalError(error) {
2308
+ return {
2309
+ message: error.message,
2310
+ stack: error.stack,
2311
+ snippet: error.snippet,
2312
+ value: error.value,
2313
+ location: error.location ? {
2314
+ file: error.location.file,
2315
+ line: error.location.line,
2316
+ column: error.location.column
2317
+ } : void 0,
2318
+ // Handle nested error cause (v1.49+)
2319
+ cause: error.cause ? {
2320
+ message: error.cause.message,
2321
+ stack: error.cause.stack,
2322
+ snippet: error.cause.snippet,
2323
+ value: error.cause.value,
2324
+ location: error.cause.location ? {
2325
+ file: error.cause.location.file,
2326
+ line: error.cause.location.line,
2327
+ column: error.cause.location.column
2328
+ } : void 0
2329
+ } : void 0
2330
+ };
2331
+ }
2332
+ /**
2333
+ * Print a prominent configuration error banner
2334
+ */
2335
+ printConfigurationError(message, solutions) {
2336
+ const border = "\u2550".repeat(70);
2337
+ console.error("");
2338
+ console.error(border);
2339
+ console.error(" \u274C TestDino Reporter Configuration Error");
2340
+ console.error(border);
2341
+ console.error(` ${message}`);
2342
+ console.error("");
2343
+ console.error(" Solutions:");
2344
+ solutions.forEach((solution, index) => {
2345
+ console.error(` ${index + 1}. ${solution}`);
2346
+ });
2347
+ console.error(border);
2348
+ console.error("");
2349
+ }
2350
+ /**
2351
+ * Print quota error with plan details and upgrade information
2352
+ * @param error - Quota error (QUOTA_EXHAUSTED or QUOTA_EXCEEDED)
2353
+ */
2354
+ printQuotaError(error) {
2355
+ const border = "\u2550".repeat(70);
2356
+ const errorData = error;
2357
+ const details = errorData.details;
2358
+ const planName = details?.planName || "Unknown";
2359
+ const resetDate = details?.resetDate;
2360
+ let message = "Execution quota exceeded";
2361
+ message += `
2362
+
2363
+ Current Plan: ${planName}`;
2364
+ if (errorData.code === "QUOTA_EXHAUSTED") {
2365
+ message += `
2366
+ Monthly Limit: ${details.totalLimit || "Unknown"} executions`;
2367
+ message += `
2368
+ Used: ${details.used || "Unknown"} executions`;
2369
+ if (resetDate) {
2370
+ message += `
2371
+ Limit Resets: ${new Date(resetDate).toLocaleDateString()}`;
2372
+ }
2373
+ } else if (errorData.code === "QUOTA_EXCEEDED") {
2374
+ const exceeded = details;
2375
+ message += `
2376
+ Monthly Limit: ${exceeded.total || "Unknown"} executions`;
2377
+ message += `
2378
+ Used: ${exceeded.used || "Unknown"} executions`;
2379
+ const remaining = (exceeded.total ?? 0) - (exceeded.used ?? 0);
2380
+ message += `
2381
+ Remaining: ${remaining} executions`;
2382
+ message += `
2383
+ Tests in this run: ${exceeded.totalTests || "Unknown"}`;
2384
+ if (resetDate) {
2385
+ message += `
2386
+ Limit Resets: ${new Date(resetDate).toLocaleDateString()}`;
2387
+ }
2388
+ }
2389
+ console.error("");
2390
+ console.error(border);
2391
+ console.error(" \u274C TestDino Execution Limit Reached");
2392
+ console.error(border);
2393
+ console.error(` ${message}`);
2394
+ console.error("");
2395
+ console.error(" Solutions:");
2396
+ console.error(" 1. Upgrade your plan to increase monthly limit");
2397
+ console.error(" 2. Wait for monthly limit reset");
2398
+ console.error(" 3. Visit https://testdino.com/pricing for plan options");
2399
+ console.error(border);
2400
+ console.error("");
2401
+ }
2402
+ /**
2403
+ * Truncate console chunk to max size and convert Buffer to string
2404
+ */
2405
+ truncateChunk(chunk) {
2406
+ let convertedText;
2407
+ if (Buffer.isBuffer(chunk)) {
2408
+ convertedText = chunk.toString("utf-8");
2409
+ } else {
2410
+ convertedText = chunk;
2411
+ }
2412
+ if (convertedText.length > MAX_CONSOLE_CHUNK_SIZE) {
2413
+ return {
2414
+ text: convertedText.substring(0, MAX_CONSOLE_CHUNK_SIZE) + "\n[truncated]",
2415
+ truncated: true
2416
+ };
2417
+ }
2418
+ return { text: convertedText };
2419
+ }
2420
+ /**
2421
+ * Extract attachment metadata for step:end event
2422
+ */
2423
+ extractAttachments(step) {
2424
+ return step.attachments.map((a) => ({
2425
+ name: a.name,
2426
+ contentType: a.contentType,
2427
+ path: a.path
2428
+ // undefined for in-memory attachments
2429
+ }));
2430
+ }
2431
+ /**
2432
+ * Extract steps summary for test:end event
2433
+ */
2434
+ extractTestStepsSummary(result) {
2435
+ return {
2436
+ total: result.steps.length,
2437
+ passed: result.steps.filter((s) => !s.error).length,
2438
+ failed: result.steps.filter((s) => s.error).length
2439
+ };
2440
+ }
2441
+ /**
2442
+ * Extract console output for test:end event
2443
+ */
2444
+ extractConsoleOutput(output) {
2445
+ return output.map((item) => typeof item === "string" ? item : item.toString());
2446
+ }
2447
+ /**
2448
+ * Check if this is a duplicate instance of TestdinoReporter
2449
+ * This happens when the CLI injects our reporter via --reporter flag,
2450
+ * but the user already has TestdinoReporter configured in playwright.config
2451
+ *
2452
+ * @param reporters - The resolved reporters array from FullConfig
2453
+ * @returns true if there are multiple TestdinoReporter instances, false otherwise
2454
+ */
2455
+ isDuplicateInstance(reporters) {
2456
+ const count = this.countTestdinoReporters(reporters);
2457
+ return count > 1;
2458
+ }
2459
+ /**
2460
+ * Count how many TestdinoReporter instances are in the reporters array
2461
+ *
2462
+ * @param reporters - The resolved reporters array from FullConfig
2463
+ * @returns Number of TestdinoReporter instances found
2464
+ */
2465
+ countTestdinoReporters(reporters) {
2466
+ if (!reporters || !Array.isArray(reporters)) {
2467
+ return 0;
2468
+ }
2469
+ let count = 0;
2470
+ for (const reporter of reporters) {
2471
+ if (Array.isArray(reporter) && reporter.length > 0) {
2472
+ const reporterName = reporter[0];
2473
+ if (this.isTestdinoReporter(reporterName)) {
2474
+ count++;
2475
+ }
2476
+ }
2477
+ }
2478
+ return count;
2479
+ }
2480
+ /**
2481
+ * Check if a reporter name/path matches TestdinoReporter
2482
+ *
2483
+ * @param value - Reporter name, path, or class reference
2484
+ * @returns true if this is our reporter, false otherwise
2485
+ */
2486
+ isTestdinoReporter(value) {
2487
+ if (typeof value !== "string") {
2488
+ return false;
2489
+ }
2490
+ return value.includes("@testdino/playwright") || value.includes("TestdinoReporter") || value.endsWith("testdino-playwright");
2491
+ }
2492
+ /**
2493
+ * Register signal handlers for graceful shutdown on interruption
2494
+ */
2495
+ registerSignalHandlers() {
2496
+ this.sigintHandler = () => {
2497
+ if (this.sigintHandler) {
2498
+ process.removeListener("SIGINT", this.sigintHandler);
2499
+ }
2500
+ if (this.sigtermHandler) {
2501
+ process.removeListener("SIGTERM", this.sigtermHandler);
2502
+ }
2503
+ this.handleInterruption("SIGINT", 130);
2504
+ };
2505
+ this.sigtermHandler = () => {
2506
+ if (this.sigintHandler) {
2507
+ process.removeListener("SIGINT", this.sigintHandler);
2508
+ }
2509
+ if (this.sigtermHandler) {
2510
+ process.removeListener("SIGTERM", this.sigtermHandler);
2511
+ }
2512
+ this.handleInterruption("SIGTERM", 143);
2513
+ };
2514
+ process.on("SIGINT", this.sigintHandler);
2515
+ process.on("SIGTERM", this.sigtermHandler);
2516
+ }
2517
+ /**
2518
+ * Remove signal handlers (called on normal completion or after handling interruption)
2519
+ */
2520
+ removeSignalHandlers() {
2521
+ if (this.sigintHandler) {
2522
+ process.removeListener("SIGINT", this.sigintHandler);
2523
+ }
2524
+ if (this.sigtermHandler) {
2525
+ process.removeListener("SIGTERM", this.sigtermHandler);
2526
+ }
2527
+ }
2528
+ /**
2529
+ * Handle process interruption by sending run:end event with interrupted status
2530
+ * @param signal - The signal that triggered the interruption (SIGINT or SIGTERM)
2531
+ * @param exitCode - The exit code to use when exiting
2532
+ */
2533
+ handleInterruption(signal, exitCode) {
2534
+ if (this.isShuttingDown) return;
2535
+ this.isShuttingDown = true;
2536
+ console.log(`
2537
+ \u26A0\uFE0F TestDino: Received ${signal}, sending interruption event...`);
2538
+ if (!this.initPromise) {
2539
+ process.exit(exitCode);
2540
+ }
2541
+ const event = {
2542
+ type: "run:end",
2543
+ runId: this.runId,
2544
+ ...this.getEventMetadata(),
2545
+ status: "interrupted",
2546
+ duration: this.runStartTime ? Date.now() - this.runStartTime : 0,
2547
+ startTime: this.runStartTime ?? Date.now(),
2548
+ shard: this.shardInfo
2549
+ };
2550
+ const keepAlive = setInterval(() => {
2551
+ }, 100);
2552
+ const forceExitTimer = setTimeout(() => {
2553
+ clearInterval(keepAlive);
2554
+ console.error("\u274C TestDino: Force exit - send timeout exceeded");
2555
+ this.wsClient?.close();
2556
+ process.exit(exitCode);
2557
+ }, 3e3);
2558
+ const sendAndExit = async () => {
2559
+ try {
2560
+ await Promise.race([this.sendInterruptionEvent(event), this.timeoutPromise(2500, "Send timeout")]);
2561
+ console.log("\u2705 TestDino: Interruption event sent");
2562
+ } catch (error) {
2563
+ const errorMsg = error instanceof Error ? error.message : String(error);
2564
+ console.error(`\u274C TestDino: Failed to send interruption event: ${errorMsg}`);
2565
+ } finally {
2566
+ clearTimeout(forceExitTimer);
2567
+ clearInterval(keepAlive);
2568
+ this.wsClient?.close();
2569
+ process.exit(exitCode);
2570
+ }
2571
+ };
2572
+ sendAndExit().catch(() => {
2573
+ clearTimeout(forceExitTimer);
2574
+ clearInterval(keepAlive);
2575
+ process.exit(exitCode);
2576
+ });
2577
+ }
2578
+ /**
2579
+ * Send interruption event directly without buffering
2580
+ * Uses optimized path for speed during shutdown
2581
+ */
2582
+ async sendInterruptionEvent(event) {
2583
+ if (!this.useHttpFallback && this.wsClient?.isConnected()) {
2584
+ try {
2585
+ await this.wsClient.send(event);
2586
+ return;
2587
+ } catch {
2588
+ console.warn("\u26A0\uFE0F WebSocket send failed, trying HTTP");
2589
+ }
2590
+ }
2591
+ if (this.httpClient) {
2592
+ try {
2593
+ await this.httpClient.sendEvent(event);
2594
+ } catch (error) {
2595
+ throw new Error(`HTTP send failed: ${error}`);
2596
+ }
2597
+ } else {
2598
+ throw new Error("No client available");
2599
+ }
2600
+ }
2601
+ /**
2602
+ * Create a promise that rejects after a timeout
2603
+ * @param ms - Timeout in milliseconds
2604
+ * @param message - Error message for timeout
2605
+ */
2606
+ timeoutPromise(ms, message) {
2607
+ return new Promise((_, reject) => setTimeout(() => reject(new Error(message)), ms));
2608
+ }
2609
+ /**
2610
+ * Initialize artifact uploader by requesting SAS token
2611
+ * Gracefully handles failures - uploads disabled if SAS token request fails
2612
+ */
2613
+ async initializeArtifactUploader(token, serverUrl) {
2614
+ try {
2615
+ const baseServerUrl = this.getBaseServerUrl(serverUrl);
2616
+ const sasTokenClient = new SASTokenClient({
2617
+ token,
2618
+ serverUrl: baseServerUrl
2619
+ });
2620
+ const sasToken = await sasTokenClient.requestToken();
2621
+ this.artifactUploader = new ArtifactUploader(sasToken, {
2622
+ debug: this.config.debug
2623
+ });
2624
+ if (this.config.debug) {
2625
+ console.log("\u{1F4E4} TestDino: Artifact uploads enabled");
2626
+ }
2627
+ } catch (error) {
2628
+ console.warn("\u26A0\uFE0F TestDino: Artifact uploads disabled -", error instanceof Error ? error.message : String(error));
2629
+ this.artifactsEnabled = false;
2630
+ this.artifactUploader = null;
2631
+ }
2632
+ }
2633
+ /**
2634
+ * Upload attachments and return with Azure URLs
2635
+ * If uploads disabled or failed, returns attachments with local paths
2636
+ */
2637
+ async uploadAttachments(attachments, testId) {
2638
+ if (!this.artifactsEnabled || !this.artifactUploader) {
2639
+ return attachments.map((a) => ({
2640
+ name: a.name,
2641
+ contentType: a.contentType,
2642
+ path: a.path
2643
+ }));
2644
+ }
2645
+ const fileAttachments = attachments.filter((a) => !!a.path).map((a) => ({
2646
+ name: a.name,
2647
+ contentType: a.contentType,
2648
+ path: a.path
2649
+ }));
2650
+ if (fileAttachments.length === 0) {
2651
+ return attachments.map((a) => ({
2652
+ name: a.name,
2653
+ contentType: a.contentType,
2654
+ path: a.path
2655
+ }));
2656
+ }
2657
+ const uploadResults = await this.artifactUploader.uploadAll(fileAttachments, testId);
2658
+ const uploadMap = /* @__PURE__ */ new Map();
2659
+ for (const result of uploadResults) {
2660
+ uploadMap.set(result.name, result);
2661
+ }
2662
+ return attachments.map((a) => {
2663
+ const uploadResult = uploadMap.get(a.name);
2664
+ if (uploadResult?.success && uploadResult.uploadUrl) {
2665
+ return {
2666
+ name: a.name,
2667
+ contentType: a.contentType,
2668
+ path: uploadResult.uploadUrl
2669
+ };
2670
+ }
2671
+ return {
2672
+ name: a.name,
2673
+ contentType: a.contentType,
2674
+ path: a.path
2675
+ };
2676
+ });
2677
+ }
2678
+ /**
2679
+ * Get base server URL without /api/reporter suffix
2680
+ */
2681
+ getBaseServerUrl(serverUrl) {
2682
+ return serverUrl.replace(/\/api\/reporter$/, "");
2683
+ }
2684
+ };
2685
+
2686
+ export { TestdinoReporter as default };
2687
+ //# sourceMappingURL=index.mjs.map
2688
+ //# sourceMappingURL=index.mjs.map