@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/LICENSE +71 -0
- package/README.md +307 -0
- package/bin/tdpw.js +12 -0
- package/dist/cli/index.d.mts +1 -0
- package/dist/cli/index.d.ts +1 -0
- package/dist/cli/index.js +759 -0
- package/dist/cli/index.js.map +1 -0
- package/dist/cli/index.mjs +751 -0
- package/dist/cli/index.mjs.map +1 -0
- package/dist/index.d.mts +779 -0
- package/dist/index.d.ts +779 -0
- package/dist/index.js +2695 -0
- package/dist/index.js.map +1 -0
- package/dist/index.mjs +2688 -0
- package/dist/index.mjs.map +1 -0
- package/package.json +77 -0
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
|