fair-playwright 1.0.0
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 +21 -0
- package/README.md +262 -0
- package/dist/index.cjs +1422 -0
- package/dist/index.d.cts +429 -0
- package/dist/index.d.ts +429 -0
- package/dist/index.js +1387 -0
- package/dist/mcp-cli.js +504 -0
- package/package.json +88 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,1387 @@
|
|
|
1
|
+
// src/reporter/StepTracker.ts
|
|
2
|
+
var StepTracker = class {
|
|
3
|
+
tests = /* @__PURE__ */ new Map();
|
|
4
|
+
steps = /* @__PURE__ */ new Map();
|
|
5
|
+
durationThreshold;
|
|
6
|
+
autoDetect;
|
|
7
|
+
constructor(options) {
|
|
8
|
+
this.durationThreshold = options?.durationThreshold ?? 1e3;
|
|
9
|
+
this.autoDetect = options?.autoDetect ?? true;
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* Start tracking a test
|
|
13
|
+
*/
|
|
14
|
+
startTest(testCase, _result) {
|
|
15
|
+
const testId = this.getTestId(testCase);
|
|
16
|
+
const test2 = {
|
|
17
|
+
id: testId,
|
|
18
|
+
title: testCase.title,
|
|
19
|
+
file: testCase.location.file,
|
|
20
|
+
status: "running",
|
|
21
|
+
startTime: Date.now(),
|
|
22
|
+
steps: [],
|
|
23
|
+
attachments: [],
|
|
24
|
+
consoleErrors: []
|
|
25
|
+
};
|
|
26
|
+
this.tests.set(testId, test2);
|
|
27
|
+
return testId;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Start tracking a step
|
|
31
|
+
*/
|
|
32
|
+
startStep(testCase, _result, step, parentStepId) {
|
|
33
|
+
const testId = this.getTestId(testCase);
|
|
34
|
+
const stepId = this.getStepId(testCase, step);
|
|
35
|
+
const stepMetadata = {
|
|
36
|
+
id: stepId,
|
|
37
|
+
title: this.cleanStepTitle(step.title),
|
|
38
|
+
level: this.classifyStep(step, parentStepId),
|
|
39
|
+
status: "running",
|
|
40
|
+
startTime: Date.now(),
|
|
41
|
+
parentId: parentStepId,
|
|
42
|
+
childIds: []
|
|
43
|
+
};
|
|
44
|
+
this.steps.set(stepId, stepMetadata);
|
|
45
|
+
if (parentStepId) {
|
|
46
|
+
const parent = this.steps.get(parentStepId);
|
|
47
|
+
if (parent) {
|
|
48
|
+
parent.childIds.push(stepId);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
const test2 = this.tests.get(testId);
|
|
52
|
+
if (test2) {
|
|
53
|
+
test2.steps.push(stepMetadata);
|
|
54
|
+
}
|
|
55
|
+
return stepId;
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* End tracking a step
|
|
59
|
+
*/
|
|
60
|
+
endStep(_testCase, _result, step, stepId) {
|
|
61
|
+
const stepMetadata = this.steps.get(stepId);
|
|
62
|
+
if (!stepMetadata) return;
|
|
63
|
+
stepMetadata.endTime = Date.now();
|
|
64
|
+
stepMetadata.duration = stepMetadata.endTime - stepMetadata.startTime;
|
|
65
|
+
stepMetadata.status = step.error ? "failed" : "passed";
|
|
66
|
+
if (step.error) {
|
|
67
|
+
stepMetadata.error = {
|
|
68
|
+
message: step.error.message || "Unknown error",
|
|
69
|
+
stack: step.error.stack
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
if (this.autoDetect && stepMetadata.duration > this.durationThreshold) {
|
|
73
|
+
stepMetadata.level = "major";
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* End tracking a test
|
|
78
|
+
*/
|
|
79
|
+
endTest(testCase, result) {
|
|
80
|
+
const testId = this.getTestId(testCase);
|
|
81
|
+
const test2 = this.tests.get(testId);
|
|
82
|
+
if (!test2) return;
|
|
83
|
+
test2.endTime = Date.now();
|
|
84
|
+
test2.duration = result.duration;
|
|
85
|
+
test2.status = this.mapTestStatus(result.status);
|
|
86
|
+
if (result.error) {
|
|
87
|
+
test2.error = {
|
|
88
|
+
message: result.error.message || "Unknown error",
|
|
89
|
+
stack: result.error.stack,
|
|
90
|
+
location: `${testCase.location.file}:${testCase.location.line}:${testCase.location.column}`
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
test2.attachments = result.attachments.map((att) => ({
|
|
94
|
+
name: att.name,
|
|
95
|
+
path: att.path,
|
|
96
|
+
contentType: att.contentType
|
|
97
|
+
}));
|
|
98
|
+
this.extractConsoleErrors(test2, result);
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* Extract console errors from test result output
|
|
102
|
+
*/
|
|
103
|
+
extractConsoleErrors(test2, result) {
|
|
104
|
+
if (!test2.consoleErrors) {
|
|
105
|
+
test2.consoleErrors = [];
|
|
106
|
+
}
|
|
107
|
+
if (!result.stdout || !result.stderr) {
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
const output = result.stdout.join("\n") + "\n" + result.stderr.join("\n");
|
|
111
|
+
const errorPatterns = [
|
|
112
|
+
/console\.error:\s*(.+)/gi,
|
|
113
|
+
/\[error\]\s*(.+)/gi,
|
|
114
|
+
/ERROR:\s*(.+)/gi
|
|
115
|
+
];
|
|
116
|
+
errorPatterns.forEach((pattern) => {
|
|
117
|
+
const matches = output.matchAll(pattern);
|
|
118
|
+
for (const match of matches) {
|
|
119
|
+
if (match[1]) {
|
|
120
|
+
test2.consoleErrors.push({
|
|
121
|
+
type: "error",
|
|
122
|
+
message: match[1].trim(),
|
|
123
|
+
timestamp: Date.now()
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* Get test metadata
|
|
131
|
+
*/
|
|
132
|
+
getTest(testId) {
|
|
133
|
+
return this.tests.get(testId);
|
|
134
|
+
}
|
|
135
|
+
/**
|
|
136
|
+
* Get all tests
|
|
137
|
+
*/
|
|
138
|
+
getAllTests() {
|
|
139
|
+
return Array.from(this.tests.values());
|
|
140
|
+
}
|
|
141
|
+
/**
|
|
142
|
+
* Get step metadata
|
|
143
|
+
*/
|
|
144
|
+
getStep(stepId) {
|
|
145
|
+
return this.steps.get(stepId);
|
|
146
|
+
}
|
|
147
|
+
/**
|
|
148
|
+
* Clear all tracking data
|
|
149
|
+
*/
|
|
150
|
+
clear() {
|
|
151
|
+
this.tests.clear();
|
|
152
|
+
this.steps.clear();
|
|
153
|
+
}
|
|
154
|
+
/**
|
|
155
|
+
* Generate unique test ID
|
|
156
|
+
*/
|
|
157
|
+
getTestId(testCase) {
|
|
158
|
+
return `${testCase.location.file}::${testCase.title}`;
|
|
159
|
+
}
|
|
160
|
+
/**
|
|
161
|
+
* Generate unique step ID
|
|
162
|
+
*/
|
|
163
|
+
getStepId(testCase, step) {
|
|
164
|
+
return `${this.getTestId(testCase)}::${step.title}::${Date.now()}`;
|
|
165
|
+
}
|
|
166
|
+
/**
|
|
167
|
+
* Classify step as MAJOR or MINOR
|
|
168
|
+
*/
|
|
169
|
+
classifyStep(step, parentStepId) {
|
|
170
|
+
const prefixMatch = step.title.match(/^\[(MAJOR|MINOR)\]\s*/);
|
|
171
|
+
if (prefixMatch) {
|
|
172
|
+
return prefixMatch[1].toLowerCase();
|
|
173
|
+
}
|
|
174
|
+
if (parentStepId) {
|
|
175
|
+
return "minor";
|
|
176
|
+
}
|
|
177
|
+
const majorKeywords = ["login", "checkout", "payment", "register", "setup", "flow"];
|
|
178
|
+
const titleLower = step.title.toLowerCase();
|
|
179
|
+
if (majorKeywords.some((keyword) => titleLower.includes(keyword))) {
|
|
180
|
+
return "major";
|
|
181
|
+
}
|
|
182
|
+
return "minor";
|
|
183
|
+
}
|
|
184
|
+
/**
|
|
185
|
+
* Clean step title by removing [MAJOR]/[MINOR] prefix
|
|
186
|
+
*/
|
|
187
|
+
cleanStepTitle(title) {
|
|
188
|
+
return title.replace(/^\[(MAJOR|MINOR)\]\s*/, "");
|
|
189
|
+
}
|
|
190
|
+
/**
|
|
191
|
+
* Map Playwright test status to our status
|
|
192
|
+
*/
|
|
193
|
+
mapTestStatus(status) {
|
|
194
|
+
switch (status) {
|
|
195
|
+
case "passed":
|
|
196
|
+
return "passed";
|
|
197
|
+
case "failed":
|
|
198
|
+
return "failed";
|
|
199
|
+
case "skipped":
|
|
200
|
+
return "skipped";
|
|
201
|
+
case "timedOut":
|
|
202
|
+
return "failed";
|
|
203
|
+
default:
|
|
204
|
+
return "failed";
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
};
|
|
208
|
+
|
|
209
|
+
// src/formatters/ConsoleFormatter.ts
|
|
210
|
+
import pc from "picocolors";
|
|
211
|
+
import logUpdate from "log-update";
|
|
212
|
+
var ConsoleFormatter = class {
|
|
213
|
+
config;
|
|
214
|
+
totalTests = 0;
|
|
215
|
+
completedTests = 0;
|
|
216
|
+
passedTests = 0;
|
|
217
|
+
failedTests = 0;
|
|
218
|
+
skippedTests = 0;
|
|
219
|
+
runningSteps = /* @__PURE__ */ new Map();
|
|
220
|
+
isCI;
|
|
221
|
+
useProgressiveMode;
|
|
222
|
+
updateTimer;
|
|
223
|
+
constructor(config) {
|
|
224
|
+
this.config = config;
|
|
225
|
+
this.isCI = this.detectCI();
|
|
226
|
+
this.useProgressiveMode = config.mode === "progressive" && !this.isCI && process.stdout.isTTY === true;
|
|
227
|
+
}
|
|
228
|
+
/**
|
|
229
|
+
* Detect if running in CI environment
|
|
230
|
+
*/
|
|
231
|
+
detectCI() {
|
|
232
|
+
return !!(process.env.CI || process.env.CONTINUOUS_INTEGRATION || process.env.GITHUB_ACTIONS || process.env.GITLAB_CI || process.env.CIRCLECI || process.env.TRAVIS || process.env.JENKINS_URL);
|
|
233
|
+
}
|
|
234
|
+
onBegin(totalTests) {
|
|
235
|
+
this.totalTests = totalTests;
|
|
236
|
+
const header = pc.bold(pc.blue("\u{1F3AD} Fair Playwright Reporter"));
|
|
237
|
+
const subheader = pc.dim(`Running ${totalTests} test(s)...`);
|
|
238
|
+
if (this.useProgressiveMode) {
|
|
239
|
+
console.log(`
|
|
240
|
+
${header}
|
|
241
|
+
${subheader}
|
|
242
|
+
`);
|
|
243
|
+
} else {
|
|
244
|
+
console.log(`
|
|
245
|
+
${header}
|
|
246
|
+
${subheader}
|
|
247
|
+
`);
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
onTestBegin(test2) {
|
|
251
|
+
if (!this.useProgressiveMode && this.config.mode !== "minimal") {
|
|
252
|
+
console.log(pc.dim(`\u23F3 ${test2.title}`));
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
onStepBegin(step) {
|
|
256
|
+
this.runningSteps.set(step.id, step);
|
|
257
|
+
if (this.useProgressiveMode) {
|
|
258
|
+
this.scheduleUpdate();
|
|
259
|
+
} else if (this.config.mode === "full") {
|
|
260
|
+
const indent = step.parentId ? " " : " ";
|
|
261
|
+
const icon = step.level === "major" ? "\u25B6" : "\u25B8";
|
|
262
|
+
console.log(pc.dim(`${indent}${icon} ${step.title}...`));
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
onStepEnd(step) {
|
|
266
|
+
this.runningSteps.delete(step.id);
|
|
267
|
+
if (this.useProgressiveMode) {
|
|
268
|
+
this.scheduleUpdate();
|
|
269
|
+
} else if (this.config.mode === "full") {
|
|
270
|
+
const indent = step.parentId ? " " : " ";
|
|
271
|
+
const icon = step.status === "passed" ? pc.green("\u2713") : pc.red("\u2717");
|
|
272
|
+
const duration = step.duration ? pc.dim(` (${step.duration}ms)`) : "";
|
|
273
|
+
const levelBadge = step.level === "major" ? pc.blue("[MAJOR]") : pc.dim("[minor]");
|
|
274
|
+
console.log(`${indent}${icon} ${levelBadge} ${step.title}${duration}`);
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
onTestEnd(test2) {
|
|
278
|
+
this.completedTests++;
|
|
279
|
+
if (test2.status === "passed") {
|
|
280
|
+
this.passedTests++;
|
|
281
|
+
} else if (test2.status === "failed") {
|
|
282
|
+
this.failedTests++;
|
|
283
|
+
} else if (test2.status === "skipped") {
|
|
284
|
+
this.skippedTests++;
|
|
285
|
+
}
|
|
286
|
+
if (this.useProgressiveMode) {
|
|
287
|
+
if (test2.status === "failed") {
|
|
288
|
+
this.clearUpdate();
|
|
289
|
+
this.printFailedTest(test2);
|
|
290
|
+
}
|
|
291
|
+
this.scheduleUpdate();
|
|
292
|
+
} else {
|
|
293
|
+
if (test2.status === "passed") {
|
|
294
|
+
if (this.config.compression.passedTests !== "hide") {
|
|
295
|
+
const icon = pc.green("\u2713");
|
|
296
|
+
const duration = pc.dim(` (${test2.duration}ms)`);
|
|
297
|
+
console.log(`${icon} ${pc.dim(test2.title)}${duration}`);
|
|
298
|
+
}
|
|
299
|
+
} else if (test2.status === "failed") {
|
|
300
|
+
this.printFailedTest(test2);
|
|
301
|
+
} else if (test2.status === "skipped") {
|
|
302
|
+
console.log(pc.yellow(`\u2298 ${test2.title} (skipped)`));
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
onEnd(allTests, _result) {
|
|
307
|
+
if (this.useProgressiveMode) {
|
|
308
|
+
this.clearUpdate();
|
|
309
|
+
if (this.updateTimer) {
|
|
310
|
+
clearTimeout(this.updateTimer);
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
console.log("");
|
|
314
|
+
console.log(pc.bold("\u2500".repeat(60)));
|
|
315
|
+
console.log("");
|
|
316
|
+
const passed = allTests.filter((t) => t.status === "passed").length;
|
|
317
|
+
const failed = allTests.filter((t) => t.status === "failed").length;
|
|
318
|
+
const skipped = allTests.filter((t) => t.status === "skipped").length;
|
|
319
|
+
const totalDuration = allTests.reduce((sum, t) => sum + (t.duration || 0), 0);
|
|
320
|
+
if (failed > 0) {
|
|
321
|
+
console.log(pc.red(pc.bold(`\u2717 ${failed} failed`)));
|
|
322
|
+
}
|
|
323
|
+
if (passed > 0) {
|
|
324
|
+
console.log(pc.green(`\u2713 ${passed} passed`));
|
|
325
|
+
}
|
|
326
|
+
if (skipped > 0) {
|
|
327
|
+
console.log(pc.yellow(`\u2298 ${skipped} skipped`));
|
|
328
|
+
}
|
|
329
|
+
console.log(pc.dim(`
|
|
330
|
+
Total: ${allTests.length} test(s)`));
|
|
331
|
+
console.log(pc.dim(`Duration: ${(totalDuration / 1e3).toFixed(2)}s`));
|
|
332
|
+
if (this.config.output.ai) {
|
|
333
|
+
const aiPath = typeof this.config.output.ai === "string" ? this.config.output.ai : "./test-results/ai-summary.md";
|
|
334
|
+
console.log(pc.dim(`
|
|
335
|
+
\u{1F4DD} AI Summary: ${aiPath}`));
|
|
336
|
+
}
|
|
337
|
+
console.log("");
|
|
338
|
+
}
|
|
339
|
+
onError(error) {
|
|
340
|
+
if (this.useProgressiveMode) {
|
|
341
|
+
this.clearUpdate();
|
|
342
|
+
}
|
|
343
|
+
console.error(pc.red(`
|
|
344
|
+
\u274C Reporter Error: ${error.message}`));
|
|
345
|
+
if (error.stack) {
|
|
346
|
+
console.error(pc.dim(error.stack));
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
/**
|
|
350
|
+
* Schedule a progressive update
|
|
351
|
+
*/
|
|
352
|
+
scheduleUpdate() {
|
|
353
|
+
if (!this.useProgressiveMode) return;
|
|
354
|
+
if (this.updateTimer) {
|
|
355
|
+
clearTimeout(this.updateTimer);
|
|
356
|
+
}
|
|
357
|
+
this.updateTimer = setTimeout(() => {
|
|
358
|
+
this.render();
|
|
359
|
+
}, this.config.progressive.updateInterval);
|
|
360
|
+
}
|
|
361
|
+
/**
|
|
362
|
+
* Render progressive output
|
|
363
|
+
*/
|
|
364
|
+
render() {
|
|
365
|
+
if (!this.useProgressiveMode) return;
|
|
366
|
+
const lines = [];
|
|
367
|
+
const progress = this.totalTests > 0 ? Math.floor(this.completedTests / this.totalTests * 100) : 0;
|
|
368
|
+
lines.push(
|
|
369
|
+
pc.dim(`Progress: ${this.completedTests}/${this.totalTests} tests `) + pc.green(`(${progress}%)`)
|
|
370
|
+
);
|
|
371
|
+
const statusParts = [];
|
|
372
|
+
if (this.passedTests > 0) statusParts.push(pc.green(`\u2713 ${this.passedTests}`));
|
|
373
|
+
if (this.failedTests > 0) statusParts.push(pc.red(`\u2717 ${this.failedTests}`));
|
|
374
|
+
if (this.skippedTests > 0) statusParts.push(pc.yellow(`\u2298 ${this.skippedTests}`));
|
|
375
|
+
if (statusParts.length > 0) {
|
|
376
|
+
lines.push(statusParts.join(" "));
|
|
377
|
+
}
|
|
378
|
+
if (this.runningSteps.size > 0) {
|
|
379
|
+
lines.push("");
|
|
380
|
+
lines.push(pc.dim("Running:"));
|
|
381
|
+
const runningStepsArray = Array.from(this.runningSteps.values());
|
|
382
|
+
runningStepsArray.slice(0, 5).forEach((step) => {
|
|
383
|
+
const indent = step.parentId ? " " : " ";
|
|
384
|
+
const icon = step.level === "major" ? "\u25B6" : "\u25B8";
|
|
385
|
+
const levelBadge = step.level === "major" ? pc.blue("[MAJOR]") : pc.dim("[minor]");
|
|
386
|
+
const elapsed = Date.now() - step.startTime;
|
|
387
|
+
lines.push(`${indent}${icon} ${levelBadge} ${step.title} ${pc.dim(`(${elapsed}ms)`)}`);
|
|
388
|
+
});
|
|
389
|
+
if (runningStepsArray.length > 5) {
|
|
390
|
+
lines.push(pc.dim(` ... and ${runningStepsArray.length - 5} more`));
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
logUpdate(lines.join("\n"));
|
|
394
|
+
}
|
|
395
|
+
/**
|
|
396
|
+
* Clear progressive update
|
|
397
|
+
*/
|
|
398
|
+
clearUpdate() {
|
|
399
|
+
if (this.useProgressiveMode) {
|
|
400
|
+
logUpdate.clear();
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
/**
|
|
404
|
+
* Print failed test details with progressive MAJOR step display
|
|
405
|
+
*/
|
|
406
|
+
printFailedTest(test2) {
|
|
407
|
+
console.log(pc.red(`\u2717 ${test2.title}`));
|
|
408
|
+
console.log("");
|
|
409
|
+
const majorSteps = test2.steps.filter((s) => s.level === "major" && !s.parentId);
|
|
410
|
+
if (majorSteps.length > 0) {
|
|
411
|
+
majorSteps.forEach((majorStep, index) => {
|
|
412
|
+
const stepNumber = index + 1;
|
|
413
|
+
if (majorStep.status === "passed") {
|
|
414
|
+
const duration = majorStep.duration ? pc.dim(` (${majorStep.duration}ms)`) : "";
|
|
415
|
+
const successMsg = majorStep.successMessage ? pc.dim(` - ${majorStep.successMessage}`) : "";
|
|
416
|
+
console.log(pc.green(` ${stepNumber}. \u2713 [MAJOR] ${majorStep.title}${successMsg}${duration}`));
|
|
417
|
+
} else if (majorStep.status === "failed") {
|
|
418
|
+
console.log(pc.red(` ${stepNumber}. \u2717 [MAJOR] ${majorStep.title}`));
|
|
419
|
+
const minorSteps = test2.steps.filter((s) => s.parentId === majorStep.id);
|
|
420
|
+
if (minorSteps.length > 0) {
|
|
421
|
+
minorSteps.forEach((minorStep, minorIndex) => {
|
|
422
|
+
const minorNumber = minorIndex + 1;
|
|
423
|
+
const duration = minorStep.duration ? pc.dim(` (${minorStep.duration}ms)`) : "";
|
|
424
|
+
if (minorStep.status === "passed") {
|
|
425
|
+
console.log(pc.green(` ${minorNumber}. \u2713 [minor] ${minorStep.title}${duration}`));
|
|
426
|
+
} else if (minorStep.status === "failed") {
|
|
427
|
+
console.log(pc.red(` ${minorNumber}. \u2717 [minor] ${minorStep.title}${duration}`));
|
|
428
|
+
if (minorStep.error) {
|
|
429
|
+
console.log(pc.dim(` ${minorStep.error.message}`));
|
|
430
|
+
}
|
|
431
|
+
} else {
|
|
432
|
+
console.log(pc.dim(` ${minorNumber}. \u2298 [minor] ${minorStep.title}`));
|
|
433
|
+
}
|
|
434
|
+
});
|
|
435
|
+
}
|
|
436
|
+
if (majorStep.error) {
|
|
437
|
+
console.log("");
|
|
438
|
+
console.log(pc.red(` Error: ${majorStep.error.message}`));
|
|
439
|
+
}
|
|
440
|
+
} else if (majorStep.status === "skipped") {
|
|
441
|
+
console.log(pc.yellow(` ${stepNumber}. \u2298 [MAJOR] ${majorStep.title} (skipped)`));
|
|
442
|
+
}
|
|
443
|
+
});
|
|
444
|
+
}
|
|
445
|
+
if (test2.error) {
|
|
446
|
+
console.log("");
|
|
447
|
+
console.log(pc.red(` Error Message: ${test2.error.message}`));
|
|
448
|
+
if (test2.error.stack) {
|
|
449
|
+
console.log("");
|
|
450
|
+
console.log(pc.dim(" Stack Trace:"));
|
|
451
|
+
const stackLines = test2.error.stack.split("\n").slice(0, 5);
|
|
452
|
+
stackLines.forEach((line) => {
|
|
453
|
+
console.log(pc.dim(` ${line}`));
|
|
454
|
+
});
|
|
455
|
+
}
|
|
456
|
+
if (test2.error.location) {
|
|
457
|
+
console.log(pc.dim(` Location: ${test2.error.location}`));
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
if (test2.consoleErrors && test2.consoleErrors.length > 0) {
|
|
461
|
+
console.log("");
|
|
462
|
+
console.log(pc.yellow(` Browser Console Errors (${test2.consoleErrors.length}):`));
|
|
463
|
+
test2.consoleErrors.forEach((consoleError, index) => {
|
|
464
|
+
console.log(pc.yellow(` ${index + 1}. [${consoleError.type}] ${consoleError.message}`));
|
|
465
|
+
if (consoleError.location) {
|
|
466
|
+
console.log(pc.dim(` at ${consoleError.location}`));
|
|
467
|
+
}
|
|
468
|
+
});
|
|
469
|
+
}
|
|
470
|
+
console.log("");
|
|
471
|
+
}
|
|
472
|
+
};
|
|
473
|
+
|
|
474
|
+
// src/formatters/AIFormatter.ts
|
|
475
|
+
import { writeFile, mkdir } from "fs/promises";
|
|
476
|
+
import { dirname } from "path";
|
|
477
|
+
var AIFormatter = class {
|
|
478
|
+
config;
|
|
479
|
+
outputPath;
|
|
480
|
+
constructor(config, outputPath) {
|
|
481
|
+
this.config = config;
|
|
482
|
+
this.outputPath = outputPath;
|
|
483
|
+
}
|
|
484
|
+
async write(allTests, _result) {
|
|
485
|
+
const markdown = this.generateMarkdown(allTests, _result);
|
|
486
|
+
try {
|
|
487
|
+
await mkdir(dirname(this.outputPath), { recursive: true });
|
|
488
|
+
await writeFile(this.outputPath, markdown, "utf-8");
|
|
489
|
+
} catch (error) {
|
|
490
|
+
console.error(`Failed to write AI summary: ${error}`);
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
generateMarkdown(allTests, _result) {
|
|
494
|
+
const passed = allTests.filter((t) => t.status === "passed").length;
|
|
495
|
+
const failed = allTests.filter((t) => t.status === "failed").length;
|
|
496
|
+
const skipped = allTests.filter((t) => t.status === "skipped").length;
|
|
497
|
+
const totalDuration = allTests.reduce((sum, t) => sum + (t.duration || 0), 0);
|
|
498
|
+
const overallStatus = failed > 0 ? "\u274C FAILED" : "\u2705 PASSED";
|
|
499
|
+
let md = `# Test Results
|
|
500
|
+
|
|
501
|
+
`;
|
|
502
|
+
md += `**Status**: ${overallStatus} (${passed}/${allTests.length} tests passed)
|
|
503
|
+
`;
|
|
504
|
+
md += `**Duration**: ${(totalDuration / 1e3).toFixed(2)}s
|
|
505
|
+
`;
|
|
506
|
+
md += `**Date**: ${(/* @__PURE__ */ new Date()).toISOString()}
|
|
507
|
+
|
|
508
|
+
`;
|
|
509
|
+
md += `## Summary
|
|
510
|
+
|
|
511
|
+
`;
|
|
512
|
+
md += `- \u2705 Passed: ${passed}
|
|
513
|
+
`;
|
|
514
|
+
md += `- \u274C Failed: ${failed}
|
|
515
|
+
`;
|
|
516
|
+
md += `- \u2298 Skipped: ${skipped}
|
|
517
|
+
`;
|
|
518
|
+
md += `- \u{1F4CA} Total: ${allTests.length}
|
|
519
|
+
|
|
520
|
+
`;
|
|
521
|
+
const failedTests = allTests.filter((t) => t.status === "failed");
|
|
522
|
+
if (failedTests.length > 0) {
|
|
523
|
+
md += `## \u274C Failed Tests
|
|
524
|
+
|
|
525
|
+
`;
|
|
526
|
+
failedTests.forEach((test2) => {
|
|
527
|
+
md += `### ${test2.title}
|
|
528
|
+
|
|
529
|
+
`;
|
|
530
|
+
md += `**File**: \`${test2.file}\`
|
|
531
|
+
`;
|
|
532
|
+
md += `**Duration**: ${test2.duration}ms
|
|
533
|
+
|
|
534
|
+
`;
|
|
535
|
+
if (test2.error) {
|
|
536
|
+
md += `**Error**: ${test2.error.message}
|
|
537
|
+
|
|
538
|
+
`;
|
|
539
|
+
if (test2.error.location) {
|
|
540
|
+
md += `**Location**: \`${test2.error.location}\`
|
|
541
|
+
|
|
542
|
+
`;
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
if (test2.steps.length > 0) {
|
|
546
|
+
md += `**Steps Executed**:
|
|
547
|
+
|
|
548
|
+
`;
|
|
549
|
+
test2.steps.forEach((step, index) => {
|
|
550
|
+
const icon = step.status === "passed" ? "\u2705" : step.status === "failed" ? "\u274C" : "\u2298";
|
|
551
|
+
const level = step.level === "major" ? "**MAJOR**" : "minor";
|
|
552
|
+
const duration = step.duration ? ` (${step.duration}ms)` : "";
|
|
553
|
+
md += `${index + 1}. ${icon} [${level}] ${step.title}${duration}
|
|
554
|
+
`;
|
|
555
|
+
if (step.status === "failed" && step.error) {
|
|
556
|
+
md += ` - Error: ${step.error.message}
|
|
557
|
+
`;
|
|
558
|
+
}
|
|
559
|
+
});
|
|
560
|
+
md += `
|
|
561
|
+
`;
|
|
562
|
+
}
|
|
563
|
+
if (test2.consoleErrors && test2.consoleErrors.length > 0) {
|
|
564
|
+
md += `**Browser Console Errors** (${test2.consoleErrors.length}):
|
|
565
|
+
|
|
566
|
+
`;
|
|
567
|
+
test2.consoleErrors.forEach((consoleError, index) => {
|
|
568
|
+
md += `${index + 1}. **[${consoleError.type}]** ${consoleError.message}
|
|
569
|
+
`;
|
|
570
|
+
if (consoleError.location) {
|
|
571
|
+
md += ` - Location: \`${consoleError.location}\`
|
|
572
|
+
`;
|
|
573
|
+
}
|
|
574
|
+
});
|
|
575
|
+
md += `
|
|
576
|
+
`;
|
|
577
|
+
}
|
|
578
|
+
if (test2.attachments.length > 0) {
|
|
579
|
+
md += `**Artifacts**:
|
|
580
|
+
|
|
581
|
+
`;
|
|
582
|
+
test2.attachments.forEach((att) => {
|
|
583
|
+
if (att.path) {
|
|
584
|
+
md += `- ${att.name}: \`${att.path}\`
|
|
585
|
+
`;
|
|
586
|
+
}
|
|
587
|
+
});
|
|
588
|
+
md += `
|
|
589
|
+
`;
|
|
590
|
+
}
|
|
591
|
+
md += `---
|
|
592
|
+
|
|
593
|
+
`;
|
|
594
|
+
});
|
|
595
|
+
}
|
|
596
|
+
if (this.config.compression.passedTests !== "hide") {
|
|
597
|
+
const passedTests = allTests.filter((t) => t.status === "passed");
|
|
598
|
+
if (passedTests.length > 0) {
|
|
599
|
+
md += `## \u2705 Passed Tests
|
|
600
|
+
|
|
601
|
+
`;
|
|
602
|
+
if (this.config.compression.passedTests === "summary") {
|
|
603
|
+
md += `All ${passedTests.length} test(s) passed:
|
|
604
|
+
|
|
605
|
+
`;
|
|
606
|
+
passedTests.forEach((test2) => {
|
|
607
|
+
md += `- ${test2.title} (${test2.duration}ms)
|
|
608
|
+
`;
|
|
609
|
+
});
|
|
610
|
+
md += `
|
|
611
|
+
`;
|
|
612
|
+
} else {
|
|
613
|
+
passedTests.forEach((test2) => {
|
|
614
|
+
md += `### ${test2.title}
|
|
615
|
+
|
|
616
|
+
`;
|
|
617
|
+
md += `**Duration**: ${test2.duration}ms
|
|
618
|
+
|
|
619
|
+
`;
|
|
620
|
+
if (test2.steps.length > 0) {
|
|
621
|
+
md += `**Steps**:
|
|
622
|
+
|
|
623
|
+
`;
|
|
624
|
+
test2.steps.forEach((step, index) => {
|
|
625
|
+
md += `${index + 1}. \u2705 ${step.title} (${step.duration}ms)
|
|
626
|
+
`;
|
|
627
|
+
});
|
|
628
|
+
}
|
|
629
|
+
md += `
|
|
630
|
+
`;
|
|
631
|
+
});
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
const skippedTests = allTests.filter((t) => t.status === "skipped");
|
|
636
|
+
if (skippedTests.length > 0) {
|
|
637
|
+
md += `## \u2298 Skipped Tests
|
|
638
|
+
|
|
639
|
+
`;
|
|
640
|
+
skippedTests.forEach((test2) => {
|
|
641
|
+
md += `- ${test2.title}
|
|
642
|
+
`;
|
|
643
|
+
});
|
|
644
|
+
md += `
|
|
645
|
+
`;
|
|
646
|
+
}
|
|
647
|
+
md += `---
|
|
648
|
+
|
|
649
|
+
`;
|
|
650
|
+
md += `*Generated by [fair-playwright](https://github.com/baranaytas/fair-playwright)*
|
|
651
|
+
`;
|
|
652
|
+
return md;
|
|
653
|
+
}
|
|
654
|
+
};
|
|
655
|
+
|
|
656
|
+
// src/formatters/JSONFormatter.ts
|
|
657
|
+
import { writeFile as writeFile2, mkdir as mkdir2 } from "fs/promises";
|
|
658
|
+
import { dirname as dirname2 } from "path";
|
|
659
|
+
var JSONFormatter = class {
|
|
660
|
+
outputPath;
|
|
661
|
+
constructor(_config, outputPath) {
|
|
662
|
+
this.outputPath = outputPath;
|
|
663
|
+
}
|
|
664
|
+
async write(allTests, _result) {
|
|
665
|
+
const passed = allTests.filter((t) => t.status === "passed").length;
|
|
666
|
+
const failed = allTests.filter((t) => t.status === "failed").length;
|
|
667
|
+
const skipped = allTests.filter((t) => t.status === "skipped").length;
|
|
668
|
+
const totalDuration = allTests.reduce((sum, t) => sum + (t.duration || 0), 0);
|
|
669
|
+
const output = {
|
|
670
|
+
status: failed > 0 ? "failed" : "passed",
|
|
671
|
+
summary: {
|
|
672
|
+
total: allTests.length,
|
|
673
|
+
passed,
|
|
674
|
+
failed,
|
|
675
|
+
skipped,
|
|
676
|
+
duration: totalDuration
|
|
677
|
+
},
|
|
678
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
679
|
+
tests: allTests.map((test2) => ({
|
|
680
|
+
id: test2.id,
|
|
681
|
+
title: test2.title,
|
|
682
|
+
file: test2.file,
|
|
683
|
+
status: test2.status,
|
|
684
|
+
duration: test2.duration,
|
|
685
|
+
startTime: test2.startTime,
|
|
686
|
+
endTime: test2.endTime,
|
|
687
|
+
error: test2.error ? {
|
|
688
|
+
message: test2.error.message,
|
|
689
|
+
stack: test2.error.stack,
|
|
690
|
+
location: test2.error.location
|
|
691
|
+
} : void 0,
|
|
692
|
+
steps: test2.steps.map((step) => ({
|
|
693
|
+
id: step.id,
|
|
694
|
+
title: step.title,
|
|
695
|
+
level: step.level,
|
|
696
|
+
status: step.status,
|
|
697
|
+
duration: step.duration,
|
|
698
|
+
error: step.error ? {
|
|
699
|
+
message: step.error.message,
|
|
700
|
+
stack: step.error.stack
|
|
701
|
+
} : void 0
|
|
702
|
+
})),
|
|
703
|
+
attachments: test2.attachments
|
|
704
|
+
}))
|
|
705
|
+
};
|
|
706
|
+
try {
|
|
707
|
+
await mkdir2(dirname2(this.outputPath), { recursive: true });
|
|
708
|
+
await writeFile2(this.outputPath, JSON.stringify(output, null, 2), "utf-8");
|
|
709
|
+
} catch (error) {
|
|
710
|
+
console.error(`Failed to write JSON output: ${error}`);
|
|
711
|
+
}
|
|
712
|
+
}
|
|
713
|
+
};
|
|
714
|
+
|
|
715
|
+
// src/reporter/FairReporter.ts
|
|
716
|
+
var FairReporter = class {
|
|
717
|
+
config;
|
|
718
|
+
stepTracker;
|
|
719
|
+
consoleFormatter;
|
|
720
|
+
aiFormatter;
|
|
721
|
+
jsonFormatter;
|
|
722
|
+
stepIdMap = /* @__PURE__ */ new Map();
|
|
723
|
+
constructor(config) {
|
|
724
|
+
this.config = {
|
|
725
|
+
mode: config?.mode ?? "progressive",
|
|
726
|
+
aiOptimized: config?.aiOptimized ?? true,
|
|
727
|
+
output: {
|
|
728
|
+
console: config?.output?.console ?? true,
|
|
729
|
+
ai: config?.output?.ai ?? false,
|
|
730
|
+
json: config?.output?.json ?? false
|
|
731
|
+
},
|
|
732
|
+
stepClassification: {
|
|
733
|
+
durationThreshold: config?.stepClassification?.durationThreshold ?? 1e3,
|
|
734
|
+
autoDetect: config?.stepClassification?.autoDetect ?? true
|
|
735
|
+
},
|
|
736
|
+
progressive: {
|
|
737
|
+
clearCompleted: config?.progressive?.clearCompleted ?? true,
|
|
738
|
+
updateInterval: config?.progressive?.updateInterval ?? 100
|
|
739
|
+
},
|
|
740
|
+
compression: {
|
|
741
|
+
passedTests: config?.compression?.passedTests ?? "summary",
|
|
742
|
+
failureContext: {
|
|
743
|
+
steps: config?.compression?.failureContext?.steps ?? 3,
|
|
744
|
+
screenshot: config?.compression?.failureContext?.screenshot ?? true,
|
|
745
|
+
trace: config?.compression?.failureContext?.trace ?? true,
|
|
746
|
+
logs: config?.compression?.failureContext?.logs ?? true
|
|
747
|
+
}
|
|
748
|
+
}
|
|
749
|
+
};
|
|
750
|
+
this.stepTracker = new StepTracker({
|
|
751
|
+
durationThreshold: this.config.stepClassification.durationThreshold,
|
|
752
|
+
autoDetect: this.config.stepClassification.autoDetect
|
|
753
|
+
});
|
|
754
|
+
if (this.config.output.console) {
|
|
755
|
+
this.consoleFormatter = new ConsoleFormatter(this.config);
|
|
756
|
+
}
|
|
757
|
+
if (this.config.output.ai) {
|
|
758
|
+
const outputPath = typeof this.config.output.ai === "string" ? this.config.output.ai : "./test-results/ai-summary.md";
|
|
759
|
+
this.aiFormatter = new AIFormatter(this.config, outputPath);
|
|
760
|
+
}
|
|
761
|
+
if (this.config.output.json) {
|
|
762
|
+
const outputPath = typeof this.config.output.json === "string" ? this.config.output.json : "./test-results/results.json";
|
|
763
|
+
this.jsonFormatter = new JSONFormatter(this.config, outputPath);
|
|
764
|
+
}
|
|
765
|
+
}
|
|
766
|
+
/**
|
|
767
|
+
* Called once before running tests
|
|
768
|
+
*/
|
|
769
|
+
onBegin(_config, suite) {
|
|
770
|
+
const totalTests = suite.allTests().length;
|
|
771
|
+
if (this.consoleFormatter) {
|
|
772
|
+
this.consoleFormatter.onBegin(totalTests);
|
|
773
|
+
}
|
|
774
|
+
}
|
|
775
|
+
/**
|
|
776
|
+
* Called when a test begins
|
|
777
|
+
*/
|
|
778
|
+
onTestBegin(test2, result) {
|
|
779
|
+
this.stepTracker.startTest(test2, result);
|
|
780
|
+
if (this.consoleFormatter) {
|
|
781
|
+
this.consoleFormatter.onTestBegin(test2);
|
|
782
|
+
}
|
|
783
|
+
}
|
|
784
|
+
/**
|
|
785
|
+
* Called when a test step begins
|
|
786
|
+
*/
|
|
787
|
+
onStepBegin(test2, result, step) {
|
|
788
|
+
let parentStepId;
|
|
789
|
+
if (step.parent) {
|
|
790
|
+
parentStepId = this.stepIdMap.get(step.parent);
|
|
791
|
+
}
|
|
792
|
+
const stepId = this.stepTracker.startStep(test2, result, step, parentStepId);
|
|
793
|
+
this.stepIdMap.set(step, stepId);
|
|
794
|
+
if (this.consoleFormatter) {
|
|
795
|
+
const stepMetadata = this.stepTracker.getStep(stepId);
|
|
796
|
+
if (stepMetadata) {
|
|
797
|
+
this.consoleFormatter.onStepBegin(stepMetadata);
|
|
798
|
+
}
|
|
799
|
+
}
|
|
800
|
+
}
|
|
801
|
+
/**
|
|
802
|
+
* Called when a test step ends
|
|
803
|
+
*/
|
|
804
|
+
onStepEnd(test2, result, step) {
|
|
805
|
+
const stepId = this.stepIdMap.get(step);
|
|
806
|
+
if (!stepId) return;
|
|
807
|
+
this.stepTracker.endStep(test2, result, step, stepId);
|
|
808
|
+
if (this.consoleFormatter) {
|
|
809
|
+
const stepMetadata = this.stepTracker.getStep(stepId);
|
|
810
|
+
if (stepMetadata) {
|
|
811
|
+
this.consoleFormatter.onStepEnd(stepMetadata);
|
|
812
|
+
}
|
|
813
|
+
}
|
|
814
|
+
}
|
|
815
|
+
/**
|
|
816
|
+
* Called when a test ends
|
|
817
|
+
*/
|
|
818
|
+
onTestEnd(test2, result) {
|
|
819
|
+
this.stepTracker.endTest(test2, result);
|
|
820
|
+
if (this.consoleFormatter) {
|
|
821
|
+
const testId = `${test2.location.file}::${test2.title}`;
|
|
822
|
+
const testMetadata = this.stepTracker.getTest(testId);
|
|
823
|
+
if (testMetadata) {
|
|
824
|
+
this.consoleFormatter.onTestEnd(testMetadata);
|
|
825
|
+
}
|
|
826
|
+
}
|
|
827
|
+
}
|
|
828
|
+
/**
|
|
829
|
+
* Called once after all tests have finished
|
|
830
|
+
*/
|
|
831
|
+
async onEnd(result) {
|
|
832
|
+
const allTests = this.stepTracker.getAllTests();
|
|
833
|
+
if (this.consoleFormatter) {
|
|
834
|
+
this.consoleFormatter.onEnd(allTests, result);
|
|
835
|
+
}
|
|
836
|
+
if (this.aiFormatter) {
|
|
837
|
+
await this.aiFormatter.write(allTests, result);
|
|
838
|
+
}
|
|
839
|
+
if (this.jsonFormatter) {
|
|
840
|
+
await this.jsonFormatter.write(allTests, result);
|
|
841
|
+
}
|
|
842
|
+
this.stepIdMap.clear();
|
|
843
|
+
}
|
|
844
|
+
/**
|
|
845
|
+
* Optional: Called on error
|
|
846
|
+
*/
|
|
847
|
+
onError(error) {
|
|
848
|
+
if (this.consoleFormatter) {
|
|
849
|
+
this.consoleFormatter.onError(error);
|
|
850
|
+
}
|
|
851
|
+
}
|
|
852
|
+
};
|
|
853
|
+
|
|
854
|
+
// src/e2e.ts
|
|
855
|
+
import { test } from "@playwright/test";
|
|
856
|
+
var E2EHelperImpl = class {
|
|
857
|
+
/**
|
|
858
|
+
* Execute a major step
|
|
859
|
+
* Supports both inline and declarative modes
|
|
860
|
+
*/
|
|
861
|
+
async major(title, actionOrOptions, options) {
|
|
862
|
+
if (typeof actionOrOptions === "object" && "steps" in actionOrOptions) {
|
|
863
|
+
return this.majorDeclarative(title, actionOrOptions);
|
|
864
|
+
}
|
|
865
|
+
if (typeof actionOrOptions === "function") {
|
|
866
|
+
return this.majorInline(title, actionOrOptions, options);
|
|
867
|
+
}
|
|
868
|
+
throw new Error("Invalid arguments for e2e.major()");
|
|
869
|
+
}
|
|
870
|
+
/**
|
|
871
|
+
* Execute a minor step (inline mode only)
|
|
872
|
+
*/
|
|
873
|
+
async minor(title, action, options) {
|
|
874
|
+
const stepTitle = this.formatStepTitle(title, "minor", options);
|
|
875
|
+
return test.step(stepTitle, async () => {
|
|
876
|
+
try {
|
|
877
|
+
await action();
|
|
878
|
+
} catch (error) {
|
|
879
|
+
if (options?.failure) {
|
|
880
|
+
const enhancedError = new Error(`${options.failure}: ${error.message}`);
|
|
881
|
+
enhancedError.stack = error.stack;
|
|
882
|
+
throw enhancedError;
|
|
883
|
+
}
|
|
884
|
+
throw error;
|
|
885
|
+
}
|
|
886
|
+
});
|
|
887
|
+
}
|
|
888
|
+
/**
|
|
889
|
+
* Inline mode for major steps
|
|
890
|
+
*/
|
|
891
|
+
async majorInline(title, action, options) {
|
|
892
|
+
const stepTitle = this.formatStepTitle(title, "major", options);
|
|
893
|
+
return test.step(stepTitle, async () => {
|
|
894
|
+
try {
|
|
895
|
+
await action();
|
|
896
|
+
} catch (error) {
|
|
897
|
+
if (options?.failure) {
|
|
898
|
+
const enhancedError = new Error(`${options.failure}: ${error.message}`);
|
|
899
|
+
enhancedError.stack = error.stack;
|
|
900
|
+
throw enhancedError;
|
|
901
|
+
}
|
|
902
|
+
throw error;
|
|
903
|
+
}
|
|
904
|
+
});
|
|
905
|
+
}
|
|
906
|
+
/**
|
|
907
|
+
* Declarative mode for major steps with child steps
|
|
908
|
+
*/
|
|
909
|
+
async majorDeclarative(title, options) {
|
|
910
|
+
const stepTitle = this.formatStepTitle(title, "major", options);
|
|
911
|
+
return test.step(stepTitle, async () => {
|
|
912
|
+
if (!options.steps || options.steps.length === 0) {
|
|
913
|
+
return;
|
|
914
|
+
}
|
|
915
|
+
for (const childStep of options.steps) {
|
|
916
|
+
const childTitle = this.formatStepTitle(childStep.title, "minor", childStep);
|
|
917
|
+
await test.step(childTitle, async () => {
|
|
918
|
+
try {
|
|
919
|
+
await childStep.action();
|
|
920
|
+
} catch (error) {
|
|
921
|
+
if (childStep.failure) {
|
|
922
|
+
const enhancedError = new Error(
|
|
923
|
+
`${childStep.failure}: ${error.message}`
|
|
924
|
+
);
|
|
925
|
+
enhancedError.stack = error.stack;
|
|
926
|
+
throw enhancedError;
|
|
927
|
+
}
|
|
928
|
+
throw error;
|
|
929
|
+
}
|
|
930
|
+
});
|
|
931
|
+
}
|
|
932
|
+
});
|
|
933
|
+
}
|
|
934
|
+
/**
|
|
935
|
+
* Format step title with metadata for the reporter
|
|
936
|
+
* The reporter will parse this metadata to classify steps
|
|
937
|
+
*/
|
|
938
|
+
formatStepTitle(title, level, _options) {
|
|
939
|
+
const prefix = level === "major" ? "[MAJOR]" : "[MINOR]";
|
|
940
|
+
return `${prefix} ${title}`;
|
|
941
|
+
}
|
|
942
|
+
};
|
|
943
|
+
var e2e = new E2EHelperImpl();
|
|
944
|
+
|
|
945
|
+
// src/mcp/server.ts
|
|
946
|
+
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
947
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
948
|
+
import {
|
|
949
|
+
CallToolRequestSchema,
|
|
950
|
+
ListResourcesRequestSchema,
|
|
951
|
+
ListToolsRequestSchema,
|
|
952
|
+
ReadResourceRequestSchema
|
|
953
|
+
} from "@modelcontextprotocol/sdk/types.js";
|
|
954
|
+
import { readFile } from "fs/promises";
|
|
955
|
+
import { existsSync } from "fs";
|
|
956
|
+
var MCPServer = class {
|
|
957
|
+
server;
|
|
958
|
+
config;
|
|
959
|
+
testResults = [];
|
|
960
|
+
constructor(config = {}) {
|
|
961
|
+
this.config = {
|
|
962
|
+
resultsPath: config.resultsPath ?? "./test-results/results.json",
|
|
963
|
+
name: config.name ?? "fair-playwright",
|
|
964
|
+
version: config.version ?? "0.1.0",
|
|
965
|
+
verbose: config.verbose ?? false
|
|
966
|
+
};
|
|
967
|
+
this.server = new Server(
|
|
968
|
+
{
|
|
969
|
+
name: this.config.name,
|
|
970
|
+
version: this.config.version
|
|
971
|
+
},
|
|
972
|
+
{
|
|
973
|
+
capabilities: {
|
|
974
|
+
resources: {},
|
|
975
|
+
tools: {}
|
|
976
|
+
}
|
|
977
|
+
}
|
|
978
|
+
);
|
|
979
|
+
this.setupHandlers();
|
|
980
|
+
}
|
|
981
|
+
/**
|
|
982
|
+
* Setup MCP protocol handlers
|
|
983
|
+
*/
|
|
984
|
+
setupHandlers() {
|
|
985
|
+
this.server.setRequestHandler(ListResourcesRequestSchema, async () => ({
|
|
986
|
+
resources: [
|
|
987
|
+
{
|
|
988
|
+
uri: "fair-playwright://test-results",
|
|
989
|
+
name: "Test Results",
|
|
990
|
+
description: "Current Playwright test execution results",
|
|
991
|
+
mimeType: "application/json"
|
|
992
|
+
},
|
|
993
|
+
{
|
|
994
|
+
uri: "fair-playwright://test-summary",
|
|
995
|
+
name: "Test Summary",
|
|
996
|
+
description: "AI-optimized summary of test results",
|
|
997
|
+
mimeType: "text/markdown"
|
|
998
|
+
},
|
|
999
|
+
{
|
|
1000
|
+
uri: "fair-playwright://failures",
|
|
1001
|
+
name: "Failed Tests",
|
|
1002
|
+
description: "Detailed information about failed tests",
|
|
1003
|
+
mimeType: "text/markdown"
|
|
1004
|
+
}
|
|
1005
|
+
]
|
|
1006
|
+
}));
|
|
1007
|
+
this.server.setRequestHandler(ReadResourceRequestSchema, async (request) => {
|
|
1008
|
+
const uri = request.params.uri.toString();
|
|
1009
|
+
if (this.testResults.length === 0) {
|
|
1010
|
+
await this.loadTestResults();
|
|
1011
|
+
}
|
|
1012
|
+
switch (uri) {
|
|
1013
|
+
case "fair-playwright://test-results":
|
|
1014
|
+
return {
|
|
1015
|
+
contents: [
|
|
1016
|
+
{
|
|
1017
|
+
uri,
|
|
1018
|
+
mimeType: "application/json",
|
|
1019
|
+
text: JSON.stringify(this.getTestResults(), null, 2)
|
|
1020
|
+
}
|
|
1021
|
+
]
|
|
1022
|
+
};
|
|
1023
|
+
case "fair-playwright://test-summary":
|
|
1024
|
+
return {
|
|
1025
|
+
contents: [
|
|
1026
|
+
{
|
|
1027
|
+
uri,
|
|
1028
|
+
mimeType: "text/markdown",
|
|
1029
|
+
text: this.getTestSummary()
|
|
1030
|
+
}
|
|
1031
|
+
]
|
|
1032
|
+
};
|
|
1033
|
+
case "fair-playwright://failures":
|
|
1034
|
+
return {
|
|
1035
|
+
contents: [
|
|
1036
|
+
{
|
|
1037
|
+
uri,
|
|
1038
|
+
mimeType: "text/markdown",
|
|
1039
|
+
text: this.getFailureSummary()
|
|
1040
|
+
}
|
|
1041
|
+
]
|
|
1042
|
+
};
|
|
1043
|
+
default:
|
|
1044
|
+
throw new Error(`Unknown resource: ${uri}`);
|
|
1045
|
+
}
|
|
1046
|
+
});
|
|
1047
|
+
this.server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
1048
|
+
tools: [
|
|
1049
|
+
{
|
|
1050
|
+
name: "get_test_results",
|
|
1051
|
+
description: "Get complete test execution results with all details",
|
|
1052
|
+
inputSchema: {
|
|
1053
|
+
type: "object",
|
|
1054
|
+
properties: {}
|
|
1055
|
+
}
|
|
1056
|
+
},
|
|
1057
|
+
{
|
|
1058
|
+
name: "get_failure_summary",
|
|
1059
|
+
description: "Get AI-optimized summary of failed tests",
|
|
1060
|
+
inputSchema: {
|
|
1061
|
+
type: "object",
|
|
1062
|
+
properties: {}
|
|
1063
|
+
}
|
|
1064
|
+
},
|
|
1065
|
+
{
|
|
1066
|
+
name: "query_test",
|
|
1067
|
+
description: "Search for a specific test by title",
|
|
1068
|
+
inputSchema: {
|
|
1069
|
+
type: "object",
|
|
1070
|
+
properties: {
|
|
1071
|
+
title: {
|
|
1072
|
+
type: "string",
|
|
1073
|
+
description: "Test title to search for (case-insensitive partial match)"
|
|
1074
|
+
}
|
|
1075
|
+
},
|
|
1076
|
+
required: ["title"]
|
|
1077
|
+
}
|
|
1078
|
+
},
|
|
1079
|
+
{
|
|
1080
|
+
name: "get_tests_by_status",
|
|
1081
|
+
description: "Get all tests filtered by status",
|
|
1082
|
+
inputSchema: {
|
|
1083
|
+
type: "object",
|
|
1084
|
+
properties: {
|
|
1085
|
+
status: {
|
|
1086
|
+
type: "string",
|
|
1087
|
+
enum: ["passed", "failed", "skipped"],
|
|
1088
|
+
description: "Test status to filter by"
|
|
1089
|
+
}
|
|
1090
|
+
},
|
|
1091
|
+
required: ["status"]
|
|
1092
|
+
}
|
|
1093
|
+
},
|
|
1094
|
+
{
|
|
1095
|
+
name: "get_step_details",
|
|
1096
|
+
description: "Get detailed information about test steps",
|
|
1097
|
+
inputSchema: {
|
|
1098
|
+
type: "object",
|
|
1099
|
+
properties: {
|
|
1100
|
+
testTitle: {
|
|
1101
|
+
type: "string",
|
|
1102
|
+
description: "Title of the test to get step details for"
|
|
1103
|
+
},
|
|
1104
|
+
level: {
|
|
1105
|
+
type: "string",
|
|
1106
|
+
enum: ["major", "minor", "all"],
|
|
1107
|
+
description: "Filter steps by level (default: all)"
|
|
1108
|
+
}
|
|
1109
|
+
},
|
|
1110
|
+
required: ["testTitle"]
|
|
1111
|
+
}
|
|
1112
|
+
}
|
|
1113
|
+
]
|
|
1114
|
+
}));
|
|
1115
|
+
this.server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
1116
|
+
const { name, arguments: args } = request.params;
|
|
1117
|
+
if (this.testResults.length === 0) {
|
|
1118
|
+
await this.loadTestResults();
|
|
1119
|
+
}
|
|
1120
|
+
switch (name) {
|
|
1121
|
+
case "get_test_results":
|
|
1122
|
+
return {
|
|
1123
|
+
content: [
|
|
1124
|
+
{
|
|
1125
|
+
type: "text",
|
|
1126
|
+
text: JSON.stringify(this.getTestResults(), null, 2)
|
|
1127
|
+
}
|
|
1128
|
+
]
|
|
1129
|
+
};
|
|
1130
|
+
case "get_failure_summary":
|
|
1131
|
+
return {
|
|
1132
|
+
content: [
|
|
1133
|
+
{
|
|
1134
|
+
type: "text",
|
|
1135
|
+
text: this.getFailureSummary()
|
|
1136
|
+
}
|
|
1137
|
+
]
|
|
1138
|
+
};
|
|
1139
|
+
case "query_test": {
|
|
1140
|
+
const title = args.title;
|
|
1141
|
+
const test2 = this.queryTest(title);
|
|
1142
|
+
return {
|
|
1143
|
+
content: [
|
|
1144
|
+
{
|
|
1145
|
+
type: "text",
|
|
1146
|
+
text: test2 ? JSON.stringify(test2, null, 2) : `No test found matching: ${title}`
|
|
1147
|
+
}
|
|
1148
|
+
]
|
|
1149
|
+
};
|
|
1150
|
+
}
|
|
1151
|
+
case "get_tests_by_status": {
|
|
1152
|
+
const status = args.status;
|
|
1153
|
+
const tests = this.getTestsByStatus(status);
|
|
1154
|
+
return {
|
|
1155
|
+
content: [
|
|
1156
|
+
{
|
|
1157
|
+
type: "text",
|
|
1158
|
+
text: JSON.stringify(tests, null, 2)
|
|
1159
|
+
}
|
|
1160
|
+
]
|
|
1161
|
+
};
|
|
1162
|
+
}
|
|
1163
|
+
case "get_step_details": {
|
|
1164
|
+
const { testTitle, level } = args;
|
|
1165
|
+
const test2 = this.queryTest(testTitle);
|
|
1166
|
+
if (!test2) {
|
|
1167
|
+
return {
|
|
1168
|
+
content: [
|
|
1169
|
+
{
|
|
1170
|
+
type: "text",
|
|
1171
|
+
text: `No test found matching: ${testTitle}`
|
|
1172
|
+
}
|
|
1173
|
+
]
|
|
1174
|
+
};
|
|
1175
|
+
}
|
|
1176
|
+
let steps = test2.steps;
|
|
1177
|
+
if (level && level !== "all") {
|
|
1178
|
+
steps = steps.filter((s) => s.level === level);
|
|
1179
|
+
}
|
|
1180
|
+
return {
|
|
1181
|
+
content: [
|
|
1182
|
+
{
|
|
1183
|
+
type: "text",
|
|
1184
|
+
text: JSON.stringify(steps, null, 2)
|
|
1185
|
+
}
|
|
1186
|
+
]
|
|
1187
|
+
};
|
|
1188
|
+
}
|
|
1189
|
+
default:
|
|
1190
|
+
throw new Error(`Unknown tool: ${name}`);
|
|
1191
|
+
}
|
|
1192
|
+
});
|
|
1193
|
+
}
|
|
1194
|
+
/**
|
|
1195
|
+
* Load test results from JSON file
|
|
1196
|
+
*/
|
|
1197
|
+
async loadTestResults() {
|
|
1198
|
+
try {
|
|
1199
|
+
if (!existsSync(this.config.resultsPath)) {
|
|
1200
|
+
if (this.config.verbose) {
|
|
1201
|
+
console.error(`[MCP] Results file not found: ${this.config.resultsPath}`);
|
|
1202
|
+
}
|
|
1203
|
+
return;
|
|
1204
|
+
}
|
|
1205
|
+
const content = await readFile(this.config.resultsPath, "utf-8");
|
|
1206
|
+
const data = JSON.parse(content);
|
|
1207
|
+
this.testResults = Array.isArray(data) ? data : data.tests || [];
|
|
1208
|
+
if (this.config.verbose) {
|
|
1209
|
+
console.error(`[MCP] Loaded ${this.testResults.length} test results`);
|
|
1210
|
+
}
|
|
1211
|
+
} catch (error) {
|
|
1212
|
+
if (this.config.verbose) {
|
|
1213
|
+
console.error(`[MCP] Error loading test results:`, error);
|
|
1214
|
+
}
|
|
1215
|
+
}
|
|
1216
|
+
}
|
|
1217
|
+
/**
|
|
1218
|
+
* Start the MCP server with stdio transport
|
|
1219
|
+
*/
|
|
1220
|
+
async start() {
|
|
1221
|
+
const transport = new StdioServerTransport();
|
|
1222
|
+
await this.server.connect(transport);
|
|
1223
|
+
if (this.config.verbose) {
|
|
1224
|
+
console.error("[MCP] Server started and connected via stdio");
|
|
1225
|
+
}
|
|
1226
|
+
}
|
|
1227
|
+
/**
|
|
1228
|
+
* Get current test results
|
|
1229
|
+
*/
|
|
1230
|
+
getTestResults() {
|
|
1231
|
+
const passed = this.testResults.filter((t) => t.status === "passed").length;
|
|
1232
|
+
const failed = this.testResults.filter((t) => t.status === "failed").length;
|
|
1233
|
+
const skipped = this.testResults.filter((t) => t.status === "skipped").length;
|
|
1234
|
+
const totalDuration = this.testResults.reduce((sum, t) => sum + (t.duration || 0), 0);
|
|
1235
|
+
return {
|
|
1236
|
+
status: failed > 0 ? "failed" : this.testResults.length > 0 ? "passed" : "unknown",
|
|
1237
|
+
summary: {
|
|
1238
|
+
total: this.testResults.length,
|
|
1239
|
+
passed,
|
|
1240
|
+
failed,
|
|
1241
|
+
skipped,
|
|
1242
|
+
duration: totalDuration
|
|
1243
|
+
},
|
|
1244
|
+
tests: this.testResults
|
|
1245
|
+
};
|
|
1246
|
+
}
|
|
1247
|
+
/**
|
|
1248
|
+
* Get test summary in markdown format
|
|
1249
|
+
*/
|
|
1250
|
+
getTestSummary() {
|
|
1251
|
+
const results = this.getTestResults();
|
|
1252
|
+
const { summary } = results;
|
|
1253
|
+
let md = "# Playwright Test Results\n\n";
|
|
1254
|
+
md += `**Status**: ${results.status === "failed" ? "\u274C FAILED" : "\u2705 PASSED"}
|
|
1255
|
+
`;
|
|
1256
|
+
md += `**Total Tests**: ${summary.total}
|
|
1257
|
+
`;
|
|
1258
|
+
md += `**Duration**: ${(summary.duration / 1e3).toFixed(2)}s
|
|
1259
|
+
|
|
1260
|
+
`;
|
|
1261
|
+
md += "## Summary\n\n";
|
|
1262
|
+
md += `- \u2705 Passed: ${summary.passed}
|
|
1263
|
+
`;
|
|
1264
|
+
md += `- \u274C Failed: ${summary.failed}
|
|
1265
|
+
`;
|
|
1266
|
+
md += `- \u2298 Skipped: ${summary.skipped}
|
|
1267
|
+
|
|
1268
|
+
`;
|
|
1269
|
+
if (summary.failed > 0) {
|
|
1270
|
+
md += "## Failed Tests\n\n";
|
|
1271
|
+
const failedTests = this.testResults.filter((t) => t.status === "failed");
|
|
1272
|
+
failedTests.forEach((test2, index) => {
|
|
1273
|
+
md += `${index + 1}. **${test2.title}** (${test2.duration}ms)
|
|
1274
|
+
`;
|
|
1275
|
+
md += ` - File: \`${test2.file}\`
|
|
1276
|
+
`;
|
|
1277
|
+
if (test2.error) {
|
|
1278
|
+
md += ` - Error: ${test2.error.message}
|
|
1279
|
+
`;
|
|
1280
|
+
}
|
|
1281
|
+
});
|
|
1282
|
+
}
|
|
1283
|
+
return md;
|
|
1284
|
+
}
|
|
1285
|
+
/**
|
|
1286
|
+
* Get AI-optimized summary of failures
|
|
1287
|
+
*/
|
|
1288
|
+
getFailureSummary() {
|
|
1289
|
+
const failedTests = this.testResults.filter((t) => t.status === "failed");
|
|
1290
|
+
if (failedTests.length === 0) {
|
|
1291
|
+
return "# No Test Failures\n\nAll tests passed! \u2705";
|
|
1292
|
+
}
|
|
1293
|
+
let summary = `# Test Failures Summary
|
|
1294
|
+
|
|
1295
|
+
`;
|
|
1296
|
+
summary += `${failedTests.length} test(s) failed:
|
|
1297
|
+
|
|
1298
|
+
`;
|
|
1299
|
+
failedTests.forEach((test2, index) => {
|
|
1300
|
+
summary += `## ${index + 1}. ${test2.title}
|
|
1301
|
+
|
|
1302
|
+
`;
|
|
1303
|
+
summary += `**File**: \`${test2.file}\`
|
|
1304
|
+
`;
|
|
1305
|
+
summary += `**Duration**: ${test2.duration}ms
|
|
1306
|
+
|
|
1307
|
+
`;
|
|
1308
|
+
if (test2.error) {
|
|
1309
|
+
summary += `**Error**: ${test2.error.message}
|
|
1310
|
+
|
|
1311
|
+
`;
|
|
1312
|
+
if (test2.error.location) {
|
|
1313
|
+
summary += `**Location**: \`${test2.error.location}\`
|
|
1314
|
+
|
|
1315
|
+
`;
|
|
1316
|
+
}
|
|
1317
|
+
}
|
|
1318
|
+
const majorSteps = test2.steps.filter((s) => s.level === "major" && !s.parentId);
|
|
1319
|
+
if (majorSteps.length > 0) {
|
|
1320
|
+
summary += `**Test Flow**:
|
|
1321
|
+
|
|
1322
|
+
`;
|
|
1323
|
+
majorSteps.forEach((majorStep, idx) => {
|
|
1324
|
+
const icon = majorStep.status === "passed" ? "\u2705" : "\u274C";
|
|
1325
|
+
summary += `${idx + 1}. ${icon} [MAJOR] ${majorStep.title}
|
|
1326
|
+
`;
|
|
1327
|
+
if (majorStep.status === "failed") {
|
|
1328
|
+
const minorSteps = test2.steps.filter((s) => s.parentId === majorStep.id);
|
|
1329
|
+
minorSteps.forEach((minorStep) => {
|
|
1330
|
+
const minorIcon = minorStep.status === "passed" ? "\u2705" : "\u274C";
|
|
1331
|
+
summary += ` - ${minorIcon} [minor] ${minorStep.title}
|
|
1332
|
+
`;
|
|
1333
|
+
if (minorStep.error) {
|
|
1334
|
+
summary += ` Error: ${minorStep.error.message}
|
|
1335
|
+
`;
|
|
1336
|
+
}
|
|
1337
|
+
});
|
|
1338
|
+
}
|
|
1339
|
+
});
|
|
1340
|
+
summary += `
|
|
1341
|
+
`;
|
|
1342
|
+
}
|
|
1343
|
+
if (test2.consoleErrors && test2.consoleErrors.length > 0) {
|
|
1344
|
+
summary += `**Browser Console Errors** (${test2.consoleErrors.length}):
|
|
1345
|
+
|
|
1346
|
+
`;
|
|
1347
|
+
test2.consoleErrors.forEach((consoleError, idx) => {
|
|
1348
|
+
summary += `${idx + 1}. [${consoleError.type}] ${consoleError.message}
|
|
1349
|
+
`;
|
|
1350
|
+
});
|
|
1351
|
+
summary += `
|
|
1352
|
+
`;
|
|
1353
|
+
}
|
|
1354
|
+
summary += `---
|
|
1355
|
+
|
|
1356
|
+
`;
|
|
1357
|
+
});
|
|
1358
|
+
return summary;
|
|
1359
|
+
}
|
|
1360
|
+
/**
|
|
1361
|
+
* Query specific test by title
|
|
1362
|
+
*/
|
|
1363
|
+
queryTest(title) {
|
|
1364
|
+
const test2 = this.testResults.find(
|
|
1365
|
+
(t) => t.title.toLowerCase().includes(title.toLowerCase())
|
|
1366
|
+
);
|
|
1367
|
+
return test2 || null;
|
|
1368
|
+
}
|
|
1369
|
+
/**
|
|
1370
|
+
* Get tests by status
|
|
1371
|
+
*/
|
|
1372
|
+
getTestsByStatus(status) {
|
|
1373
|
+
return this.testResults.filter((t) => t.status === status);
|
|
1374
|
+
}
|
|
1375
|
+
};
|
|
1376
|
+
async function createMCPServer(config) {
|
|
1377
|
+
const server = new MCPServer(config);
|
|
1378
|
+
await server.start();
|
|
1379
|
+
return server;
|
|
1380
|
+
}
|
|
1381
|
+
export {
|
|
1382
|
+
FairReporter,
|
|
1383
|
+
MCPServer,
|
|
1384
|
+
createMCPServer,
|
|
1385
|
+
FairReporter as default,
|
|
1386
|
+
e2e
|
|
1387
|
+
};
|