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