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