@bugzy-ai/bugzy 1.9.3 → 1.9.4
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 -21
- package/README.md +273 -273
- package/dist/cli/index.cjs +25 -57
- package/dist/cli/index.cjs.map +1 -1
- package/dist/cli/index.js +24 -56
- package/dist/cli/index.js.map +1 -1
- package/dist/index.cjs +22 -53
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +22 -53
- package/dist/index.js.map +1 -1
- package/dist/subagents/index.cjs.map +1 -1
- package/dist/subagents/index.js.map +1 -1
- package/dist/subagents/metadata.cjs.map +1 -1
- package/dist/subagents/metadata.js.map +1 -1
- package/dist/tasks/index.cjs +20 -9
- package/dist/tasks/index.cjs.map +1 -1
- package/dist/tasks/index.js +20 -9
- package/dist/tasks/index.js.map +1 -1
- package/dist/templates/init/.bugzy/runtime/knowledge-base.md +61 -0
- package/dist/templates/init/.bugzy/runtime/knowledge-maintenance-guide.md +97 -0
- package/dist/templates/init/.bugzy/runtime/project-context.md +35 -0
- package/dist/templates/init/.bugzy/runtime/subagent-memory-guide.md +87 -0
- package/dist/templates/init/.bugzy/runtime/templates/test-plan-template.md +50 -0
- package/dist/templates/init/.bugzy/runtime/templates/test-result-schema.md +498 -0
- package/dist/templates/init/.bugzy/runtime/test-execution-strategy.md +535 -0
- package/dist/templates/init/.bugzy/runtime/testing-best-practices.md +632 -0
- package/dist/templates/init/.gitignore-template +25 -0
- package/package.json +95 -95
- package/templates/init/.bugzy/runtime/knowledge-base.md +61 -61
- package/templates/init/.bugzy/runtime/knowledge-maintenance-guide.md +97 -97
- package/templates/init/.bugzy/runtime/project-context.md +35 -35
- package/templates/init/.bugzy/runtime/subagent-memory-guide.md +87 -87
- package/templates/init/.bugzy/runtime/templates/test-plan-template.md +50 -50
- package/templates/init/.bugzy/runtime/templates/test-result-schema.md +498 -498
- package/templates/init/.bugzy/runtime/test-execution-strategy.md +535 -535
- package/templates/init/.bugzy/runtime/testing-best-practices.md +724 -724
- package/templates/init/.env.testdata +18 -18
- package/templates/init/.gitignore-template +24 -24
- package/templates/init/AGENTS.md +155 -155
- package/templates/init/CLAUDE.md +157 -157
- package/templates/init/test-runs/README.md +45 -45
- package/templates/playwright/BasePage.template.ts +190 -190
- package/templates/playwright/auth.setup.template.ts +89 -89
- package/templates/playwright/dataGenerators.helper.template.ts +148 -148
- package/templates/playwright/dateUtils.helper.template.ts +96 -96
- package/templates/playwright/pages.fixture.template.ts +50 -50
- package/templates/playwright/playwright.config.template.ts +97 -97
- package/templates/playwright/reporters/bugzy-reporter.ts +454 -454
|
@@ -1,454 +1,454 @@
|
|
|
1
|
-
import type {
|
|
2
|
-
Reporter,
|
|
3
|
-
FullConfig,
|
|
4
|
-
Suite,
|
|
5
|
-
TestCase,
|
|
6
|
-
TestResult,
|
|
7
|
-
FullResult,
|
|
8
|
-
TestStep,
|
|
9
|
-
} from '@playwright/test/reporter';
|
|
10
|
-
import * as fs from 'fs';
|
|
11
|
-
import * as path from 'path';
|
|
12
|
-
|
|
13
|
-
/**
|
|
14
|
-
* Step data for steps.json
|
|
15
|
-
*/
|
|
16
|
-
interface StepData {
|
|
17
|
-
index: number;
|
|
18
|
-
timestamp: string;
|
|
19
|
-
videoTimeSeconds: number;
|
|
20
|
-
action: string;
|
|
21
|
-
status: 'success' | 'failed' | 'skipped';
|
|
22
|
-
description: string;
|
|
23
|
-
technicalDetails: string;
|
|
24
|
-
duration?: number;
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
/**
|
|
28
|
-
* Bugzy Custom Playwright Reporter
|
|
29
|
-
*
|
|
30
|
-
* Records test executions in hierarchical structure:
|
|
31
|
-
* test-runs/YYYYMMDD-HHMMSS/TC-{id}/exec-{num}/
|
|
32
|
-
*
|
|
33
|
-
* Features:
|
|
34
|
-
* - Groups multiple test runs under same directory when BUGZY_EXECUTION_ID matches
|
|
35
|
-
* - Checks latest directory's manifest to reuse existing session directory
|
|
36
|
-
* - Tracks multiple execution attempts per test
|
|
37
|
-
* - Records videos for all tests
|
|
38
|
-
* - Captures traces/screenshots for failures only
|
|
39
|
-
* - Links to BUGZY_EXECUTION_ID for session tracking
|
|
40
|
-
* - Generates manifest.json with execution summary
|
|
41
|
-
* - Generates steps.json with video timestamps for test.step() calls
|
|
42
|
-
*/
|
|
43
|
-
class BugzyReporter implements Reporter {
|
|
44
|
-
private testRunDir!: string;
|
|
45
|
-
private timestamp!: string;
|
|
46
|
-
private bugzyExecutionId!: string;
|
|
47
|
-
private startTime!: Date;
|
|
48
|
-
private testResults: Map<string, Array<any>> = new Map();
|
|
49
|
-
private testSteps: Map<string, Array<StepData>> = new Map();
|
|
50
|
-
private testStartTimes: Map<string, number> = new Map();
|
|
51
|
-
|
|
52
|
-
constructor() {
|
|
53
|
-
// No longer need to read execution number from environment
|
|
54
|
-
// It will be auto-detected per test case
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
/**
|
|
58
|
-
* Called once before running tests
|
|
59
|
-
*/
|
|
60
|
-
onBegin(config: FullConfig, suite: Suite): void {
|
|
61
|
-
this.startTime = new Date();
|
|
62
|
-
|
|
63
|
-
// Generate timestamp in YYYYMMDD-HHMMSS format
|
|
64
|
-
this.timestamp = this.startTime
|
|
65
|
-
.toISOString()
|
|
66
|
-
.replace(/[-:]/g, '')
|
|
67
|
-
.replace(/T/, '-')
|
|
68
|
-
.slice(0, 15);
|
|
69
|
-
|
|
70
|
-
const testRunsRoot = path.join(process.cwd(), 'test-runs');
|
|
71
|
-
|
|
72
|
-
// Check if we should reuse an existing session
|
|
73
|
-
let reuseDir: string | null = null;
|
|
74
|
-
|
|
75
|
-
// If BUGZY_EXECUTION_ID is provided, use it directly
|
|
76
|
-
if (process.env.BUGZY_EXECUTION_ID) {
|
|
77
|
-
this.bugzyExecutionId = process.env.BUGZY_EXECUTION_ID;
|
|
78
|
-
} else {
|
|
79
|
-
// For local runs, check if we can reuse the latest session
|
|
80
|
-
// Reuse if the latest manifest is within 60 minutes
|
|
81
|
-
if (fs.existsSync(testRunsRoot)) {
|
|
82
|
-
const dirs = fs.readdirSync(testRunsRoot)
|
|
83
|
-
.filter(d => fs.statSync(path.join(testRunsRoot, d)).isDirectory())
|
|
84
|
-
.sort()
|
|
85
|
-
.reverse(); // Sort descending (latest first)
|
|
86
|
-
|
|
87
|
-
if (dirs.length > 0) {
|
|
88
|
-
const latestDir = dirs[0];
|
|
89
|
-
const manifestPath = path.join(testRunsRoot, latestDir, 'manifest.json');
|
|
90
|
-
|
|
91
|
-
if (fs.existsSync(manifestPath)) {
|
|
92
|
-
try {
|
|
93
|
-
const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf-8'));
|
|
94
|
-
const manifestTime = new Date(manifest.startTime).getTime();
|
|
95
|
-
const currentTime = this.startTime.getTime();
|
|
96
|
-
const minutesDiff = (currentTime - manifestTime) / (1000 * 60);
|
|
97
|
-
|
|
98
|
-
// Reuse if within 60 minutes and has a local execution ID
|
|
99
|
-
if (minutesDiff <= 60 && manifest.bugzyExecutionId?.startsWith('local-')) {
|
|
100
|
-
this.bugzyExecutionId = manifest.bugzyExecutionId;
|
|
101
|
-
reuseDir = latestDir;
|
|
102
|
-
}
|
|
103
|
-
} catch (err) {
|
|
104
|
-
// Ignore parsing errors
|
|
105
|
-
}
|
|
106
|
-
}
|
|
107
|
-
}
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
// If no session to reuse, generate new local ID
|
|
111
|
-
if (!this.bugzyExecutionId) {
|
|
112
|
-
this.bugzyExecutionId = 'local-' + this.timestamp;
|
|
113
|
-
}
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
// If we have a specific execution ID but haven't found a reuse dir yet, check for matching session
|
|
117
|
-
if (!reuseDir && fs.existsSync(testRunsRoot)) {
|
|
118
|
-
const dirs = fs.readdirSync(testRunsRoot)
|
|
119
|
-
.filter(d => fs.statSync(path.join(testRunsRoot, d)).isDirectory())
|
|
120
|
-
.sort()
|
|
121
|
-
.reverse();
|
|
122
|
-
|
|
123
|
-
for (const dir of dirs) {
|
|
124
|
-
const manifestPath = path.join(testRunsRoot, dir, 'manifest.json');
|
|
125
|
-
if (fs.existsSync(manifestPath)) {
|
|
126
|
-
try {
|
|
127
|
-
const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf-8'));
|
|
128
|
-
if (manifest.bugzyExecutionId === this.bugzyExecutionId) {
|
|
129
|
-
reuseDir = dir;
|
|
130
|
-
break;
|
|
131
|
-
}
|
|
132
|
-
} catch (err) {
|
|
133
|
-
// Ignore parsing errors
|
|
134
|
-
}
|
|
135
|
-
}
|
|
136
|
-
}
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
if (reuseDir) {
|
|
140
|
-
this.testRunDir = path.join(testRunsRoot, reuseDir);
|
|
141
|
-
console.log(`\n🔄 Continuing test run: ${reuseDir}`);
|
|
142
|
-
console.log(`📋 Execution ID: ${this.bugzyExecutionId}`);
|
|
143
|
-
console.log(`📁 Output directory: ${this.testRunDir}\n`);
|
|
144
|
-
} else {
|
|
145
|
-
this.testRunDir = path.join(testRunsRoot, this.timestamp);
|
|
146
|
-
fs.mkdirSync(this.testRunDir, { recursive: true });
|
|
147
|
-
console.log(`\n🆕 New test run: ${this.timestamp}`);
|
|
148
|
-
console.log(`📋 Execution ID: ${this.bugzyExecutionId}`);
|
|
149
|
-
console.log(`📁 Output directory: ${this.testRunDir}\n`);
|
|
150
|
-
}
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
/**
|
|
154
|
-
* Called after each test completes
|
|
155
|
-
*/
|
|
156
|
-
onTestEnd(test: TestCase, result: TestResult): void {
|
|
157
|
-
// Extract test ID from test title or file path
|
|
158
|
-
const testId = this.extractTestId(test);
|
|
159
|
-
|
|
160
|
-
// Create test case directory
|
|
161
|
-
const testCaseDir = path.join(this.testRunDir, testId);
|
|
162
|
-
fs.mkdirSync(testCaseDir, { recursive: true });
|
|
163
|
-
|
|
164
|
-
// Auto-detect execution number from existing folders
|
|
165
|
-
let executionNum = 1;
|
|
166
|
-
if (fs.existsSync(testCaseDir)) {
|
|
167
|
-
const existingExecs = fs.readdirSync(testCaseDir)
|
|
168
|
-
.filter(d => d.startsWith('exec-') && fs.statSync(path.join(testCaseDir, d)).isDirectory())
|
|
169
|
-
.map(d => parseInt(d.replace('exec-', ''), 10))
|
|
170
|
-
.filter(n => !isNaN(n));
|
|
171
|
-
|
|
172
|
-
if (existingExecs.length > 0) {
|
|
173
|
-
executionNum = Math.max(...existingExecs) + 1;
|
|
174
|
-
}
|
|
175
|
-
}
|
|
176
|
-
|
|
177
|
-
// Create execution directory
|
|
178
|
-
const execDir = path.join(testCaseDir, `exec-${executionNum}`);
|
|
179
|
-
fs.mkdirSync(execDir, { recursive: true });
|
|
180
|
-
|
|
181
|
-
// Prepare result data in Playwright format
|
|
182
|
-
const resultData = {
|
|
183
|
-
status: result.status,
|
|
184
|
-
duration: result.duration,
|
|
185
|
-
errors: result.errors,
|
|
186
|
-
retry: result.retry,
|
|
187
|
-
startTime: result.startTime.toISOString(),
|
|
188
|
-
attachments: [] as Array<{ name: string; path: string; contentType: string }>,
|
|
189
|
-
};
|
|
190
|
-
|
|
191
|
-
// Handle attachments (videos, traces, screenshots)
|
|
192
|
-
let hasVideo = false;
|
|
193
|
-
let hasTrace = false;
|
|
194
|
-
let hasScreenshots = false;
|
|
195
|
-
|
|
196
|
-
for (const attachment of result.attachments) {
|
|
197
|
-
if (attachment.name === 'video' && attachment.path) {
|
|
198
|
-
// Copy video file to execution directory
|
|
199
|
-
const videoFileName = 'video.webm';
|
|
200
|
-
const videoDestPath = path.join(execDir, videoFileName);
|
|
201
|
-
|
|
202
|
-
try {
|
|
203
|
-
fs.copyFileSync(attachment.path, videoDestPath);
|
|
204
|
-
resultData.attachments.push({
|
|
205
|
-
name: 'video',
|
|
206
|
-
path: videoFileName,
|
|
207
|
-
contentType: attachment.contentType || 'video/webm',
|
|
208
|
-
});
|
|
209
|
-
hasVideo = true;
|
|
210
|
-
} catch (err) {
|
|
211
|
-
console.error(`Failed to copy video: ${err}`);
|
|
212
|
-
}
|
|
213
|
-
} else if (attachment.name === 'trace' && attachment.path) {
|
|
214
|
-
// Copy trace file to execution directory (only for failures)
|
|
215
|
-
if (result.status === 'failed' || result.status === 'timedOut') {
|
|
216
|
-
const traceFileName = 'trace.zip';
|
|
217
|
-
const traceDestPath = path.join(execDir, traceFileName);
|
|
218
|
-
|
|
219
|
-
try {
|
|
220
|
-
fs.copyFileSync(attachment.path, traceDestPath);
|
|
221
|
-
resultData.attachments.push({
|
|
222
|
-
name: 'trace',
|
|
223
|
-
path: traceFileName,
|
|
224
|
-
contentType: attachment.contentType || 'application/zip',
|
|
225
|
-
});
|
|
226
|
-
hasTrace = true;
|
|
227
|
-
} catch (err) {
|
|
228
|
-
console.error(`Failed to copy trace: ${err}`);
|
|
229
|
-
}
|
|
230
|
-
}
|
|
231
|
-
} else if (attachment.name === 'screenshot' && attachment.path) {
|
|
232
|
-
// Copy screenshots to execution directory (only for failures)
|
|
233
|
-
if (result.status === 'failed' || result.status === 'timedOut') {
|
|
234
|
-
const screenshotsDir = path.join(execDir, 'screenshots');
|
|
235
|
-
fs.mkdirSync(screenshotsDir, { recursive: true });
|
|
236
|
-
|
|
237
|
-
const screenshotFileName = path.basename(attachment.path);
|
|
238
|
-
const screenshotDestPath = path.join(screenshotsDir, screenshotFileName);
|
|
239
|
-
|
|
240
|
-
try {
|
|
241
|
-
fs.copyFileSync(attachment.path, screenshotDestPath);
|
|
242
|
-
resultData.attachments.push({
|
|
243
|
-
name: 'screenshot',
|
|
244
|
-
path: path.join('screenshots', screenshotFileName),
|
|
245
|
-
contentType: attachment.contentType || 'image/png',
|
|
246
|
-
});
|
|
247
|
-
hasScreenshots = true;
|
|
248
|
-
} catch (err) {
|
|
249
|
-
console.error(`Failed to copy screenshot: ${err}`);
|
|
250
|
-
}
|
|
251
|
-
}
|
|
252
|
-
}
|
|
253
|
-
}
|
|
254
|
-
|
|
255
|
-
// Write result.json
|
|
256
|
-
const resultPath = path.join(execDir, 'result.json');
|
|
257
|
-
fs.writeFileSync(resultPath, JSON.stringify(resultData, null, 2));
|
|
258
|
-
|
|
259
|
-
// Store execution info for manifest
|
|
260
|
-
if (!this.testResults.has(testId)) {
|
|
261
|
-
this.testResults.set(testId, []);
|
|
262
|
-
}
|
|
263
|
-
|
|
264
|
-
this.testResults.get(testId)!.push({
|
|
265
|
-
number: executionNum,
|
|
266
|
-
status: result.status,
|
|
267
|
-
duration: result.duration,
|
|
268
|
-
videoFile: hasVideo ? 'video.webm' : null,
|
|
269
|
-
hasTrace,
|
|
270
|
-
hasScreenshots,
|
|
271
|
-
error: result.errors.length > 0 ? result.errors[0].message : null,
|
|
272
|
-
});
|
|
273
|
-
|
|
274
|
-
// Generate steps.json if test has steps
|
|
275
|
-
const testKey = this.getTestKey(test);
|
|
276
|
-
const steps = this.testSteps.get(testKey);
|
|
277
|
-
if (steps && steps.length > 0) {
|
|
278
|
-
const stepsData = {
|
|
279
|
-
steps,
|
|
280
|
-
summary: {
|
|
281
|
-
totalSteps: steps.length,
|
|
282
|
-
successfulSteps: steps.filter(s => s.status === 'success').length,
|
|
283
|
-
failedSteps: steps.filter(s => s.status === 'failed').length,
|
|
284
|
-
skippedSteps: steps.filter(s => s.status === 'skipped').length,
|
|
285
|
-
},
|
|
286
|
-
};
|
|
287
|
-
|
|
288
|
-
const stepsPath = path.join(execDir, 'steps.json');
|
|
289
|
-
fs.writeFileSync(stepsPath, JSON.stringify(stepsData, null, 2));
|
|
290
|
-
}
|
|
291
|
-
|
|
292
|
-
// Log execution result
|
|
293
|
-
const statusIcon = result.status === 'passed' ? '✅' : result.status === 'failed' ? '❌' : '⚠️';
|
|
294
|
-
console.log(`${statusIcon} ${testId} [exec-${executionNum}] - ${result.status} (${result.duration}ms)`);
|
|
295
|
-
}
|
|
296
|
-
|
|
297
|
-
/**
|
|
298
|
-
* Called when a test step begins
|
|
299
|
-
*/
|
|
300
|
-
onStepBegin(test: TestCase, _result: TestResult, step: TestStep): void {
|
|
301
|
-
// Only track test.step() calls (not hooks, fixtures, or expects)
|
|
302
|
-
if (step.category !== 'test.step') {
|
|
303
|
-
return;
|
|
304
|
-
}
|
|
305
|
-
|
|
306
|
-
const testKey = this.getTestKey(test);
|
|
307
|
-
|
|
308
|
-
// Record test start time on first step
|
|
309
|
-
if (!this.testStartTimes.has(testKey)) {
|
|
310
|
-
this.testStartTimes.set(testKey, step.startTime.getTime());
|
|
311
|
-
}
|
|
312
|
-
|
|
313
|
-
// Initialize steps array for this test
|
|
314
|
-
if (!this.testSteps.has(testKey)) {
|
|
315
|
-
this.testSteps.set(testKey, []);
|
|
316
|
-
}
|
|
317
|
-
|
|
318
|
-
const steps = this.testSteps.get(testKey)!;
|
|
319
|
-
const testStartTime = this.testStartTimes.get(testKey)!;
|
|
320
|
-
const videoTimeSeconds = Math.floor((step.startTime.getTime() - testStartTime) / 1000);
|
|
321
|
-
|
|
322
|
-
steps.push({
|
|
323
|
-
index: steps.length + 1,
|
|
324
|
-
timestamp: step.startTime.toISOString(),
|
|
325
|
-
videoTimeSeconds,
|
|
326
|
-
action: step.title,
|
|
327
|
-
status: 'success', // Will be updated in onStepEnd if it fails
|
|
328
|
-
description: `${step.title} - in progress`,
|
|
329
|
-
technicalDetails: 'test.step',
|
|
330
|
-
});
|
|
331
|
-
}
|
|
332
|
-
|
|
333
|
-
/**
|
|
334
|
-
* Called when a test step ends
|
|
335
|
-
*/
|
|
336
|
-
onStepEnd(test: TestCase, _result: TestResult, step: TestStep): void {
|
|
337
|
-
// Only track test.step() calls
|
|
338
|
-
if (step.category !== 'test.step') {
|
|
339
|
-
return;
|
|
340
|
-
}
|
|
341
|
-
|
|
342
|
-
const testKey = this.getTestKey(test);
|
|
343
|
-
const steps = this.testSteps.get(testKey);
|
|
344
|
-
|
|
345
|
-
if (!steps || steps.length === 0) {
|
|
346
|
-
return;
|
|
347
|
-
}
|
|
348
|
-
|
|
349
|
-
// Update the last step with final status and duration
|
|
350
|
-
const lastStep = steps[steps.length - 1];
|
|
351
|
-
lastStep.duration = step.duration;
|
|
352
|
-
|
|
353
|
-
if (step.error) {
|
|
354
|
-
lastStep.status = 'failed';
|
|
355
|
-
lastStep.description = `${step.title} - failed: ${step.error.message}`;
|
|
356
|
-
} else {
|
|
357
|
-
lastStep.status = 'success';
|
|
358
|
-
lastStep.description = `${step.title} - completed successfully`;
|
|
359
|
-
}
|
|
360
|
-
}
|
|
361
|
-
|
|
362
|
-
/**
|
|
363
|
-
* Called after all tests complete
|
|
364
|
-
*/
|
|
365
|
-
onEnd(result: FullResult): void {
|
|
366
|
-
const endTime = new Date();
|
|
367
|
-
|
|
368
|
-
// Calculate statistics
|
|
369
|
-
let totalTests = 0;
|
|
370
|
-
let totalExecutions = 0;
|
|
371
|
-
let passedTests = 0;
|
|
372
|
-
let failedTests = 0;
|
|
373
|
-
|
|
374
|
-
const testCases: Array<any> = [];
|
|
375
|
-
|
|
376
|
-
for (const [testId, executions] of this.testResults.entries()) {
|
|
377
|
-
totalTests++;
|
|
378
|
-
totalExecutions += executions.length;
|
|
379
|
-
|
|
380
|
-
const finalStatus = executions[executions.length - 1].status;
|
|
381
|
-
if (finalStatus === 'passed') {
|
|
382
|
-
passedTests++;
|
|
383
|
-
} else {
|
|
384
|
-
failedTests++;
|
|
385
|
-
}
|
|
386
|
-
|
|
387
|
-
testCases.push({
|
|
388
|
-
id: testId,
|
|
389
|
-
name: testId.replace(/^TC-\d+-/, '').replace(/-/g, ' '),
|
|
390
|
-
totalExecutions: executions.length,
|
|
391
|
-
finalStatus,
|
|
392
|
-
executions,
|
|
393
|
-
});
|
|
394
|
-
}
|
|
395
|
-
|
|
396
|
-
// Generate manifest.json
|
|
397
|
-
const manifest = {
|
|
398
|
-
bugzyExecutionId: this.bugzyExecutionId,
|
|
399
|
-
timestamp: this.timestamp,
|
|
400
|
-
startTime: this.startTime.toISOString(),
|
|
401
|
-
endTime: endTime.toISOString(),
|
|
402
|
-
status: result.status,
|
|
403
|
-
stats: {
|
|
404
|
-
totalTests,
|
|
405
|
-
passed: passedTests,
|
|
406
|
-
failed: failedTests,
|
|
407
|
-
totalExecutions,
|
|
408
|
-
},
|
|
409
|
-
testCases,
|
|
410
|
-
};
|
|
411
|
-
|
|
412
|
-
const manifestPath = path.join(this.testRunDir, 'manifest.json');
|
|
413
|
-
fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2));
|
|
414
|
-
|
|
415
|
-
console.log(`\n📊 Test Run Summary:`);
|
|
416
|
-
console.log(` Total tests: ${totalTests}`);
|
|
417
|
-
console.log(` Passed: ${passedTests}`);
|
|
418
|
-
console.log(` Failed: ${failedTests}`);
|
|
419
|
-
console.log(` Total executions: ${totalExecutions}`);
|
|
420
|
-
console.log(` Manifest: ${manifestPath}\n`);
|
|
421
|
-
}
|
|
422
|
-
|
|
423
|
-
/**
|
|
424
|
-
* Extract test ID from test case
|
|
425
|
-
* Generates TC-XXX-{test-name} format
|
|
426
|
-
*/
|
|
427
|
-
private extractTestId(test: TestCase): string {
|
|
428
|
-
// Try to extract from test title
|
|
429
|
-
const title = test.title.toLowerCase().replace(/\s+/g, '-');
|
|
430
|
-
|
|
431
|
-
// Get test file name without extension
|
|
432
|
-
const fileName = path.basename(test.location.file, path.extname(test.location.file));
|
|
433
|
-
|
|
434
|
-
// Extract number from filename if it follows TC-XXX pattern
|
|
435
|
-
const tcMatch = fileName.match(/TC-(\d+)/i);
|
|
436
|
-
if (tcMatch) {
|
|
437
|
-
return `TC-${tcMatch[1]}-${title}`;
|
|
438
|
-
}
|
|
439
|
-
|
|
440
|
-
// Otherwise generate from index
|
|
441
|
-
// This is a simple fallback - you may want to improve this
|
|
442
|
-
const testIndex = String(test.parent.tests.indexOf(test) + 1).padStart(3, '0');
|
|
443
|
-
return `TC-${testIndex}-${title}`;
|
|
444
|
-
}
|
|
445
|
-
|
|
446
|
-
/**
|
|
447
|
-
* Generate unique key for test to track steps across retries
|
|
448
|
-
*/
|
|
449
|
-
private getTestKey(test: TestCase): string {
|
|
450
|
-
return `${test.location.file}::${test.title}`;
|
|
451
|
-
}
|
|
452
|
-
}
|
|
453
|
-
|
|
454
|
-
export default BugzyReporter;
|
|
1
|
+
import type {
|
|
2
|
+
Reporter,
|
|
3
|
+
FullConfig,
|
|
4
|
+
Suite,
|
|
5
|
+
TestCase,
|
|
6
|
+
TestResult,
|
|
7
|
+
FullResult,
|
|
8
|
+
TestStep,
|
|
9
|
+
} from '@playwright/test/reporter';
|
|
10
|
+
import * as fs from 'fs';
|
|
11
|
+
import * as path from 'path';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Step data for steps.json
|
|
15
|
+
*/
|
|
16
|
+
interface StepData {
|
|
17
|
+
index: number;
|
|
18
|
+
timestamp: string;
|
|
19
|
+
videoTimeSeconds: number;
|
|
20
|
+
action: string;
|
|
21
|
+
status: 'success' | 'failed' | 'skipped';
|
|
22
|
+
description: string;
|
|
23
|
+
technicalDetails: string;
|
|
24
|
+
duration?: number;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Bugzy Custom Playwright Reporter
|
|
29
|
+
*
|
|
30
|
+
* Records test executions in hierarchical structure:
|
|
31
|
+
* test-runs/YYYYMMDD-HHMMSS/TC-{id}/exec-{num}/
|
|
32
|
+
*
|
|
33
|
+
* Features:
|
|
34
|
+
* - Groups multiple test runs under same directory when BUGZY_EXECUTION_ID matches
|
|
35
|
+
* - Checks latest directory's manifest to reuse existing session directory
|
|
36
|
+
* - Tracks multiple execution attempts per test
|
|
37
|
+
* - Records videos for all tests
|
|
38
|
+
* - Captures traces/screenshots for failures only
|
|
39
|
+
* - Links to BUGZY_EXECUTION_ID for session tracking
|
|
40
|
+
* - Generates manifest.json with execution summary
|
|
41
|
+
* - Generates steps.json with video timestamps for test.step() calls
|
|
42
|
+
*/
|
|
43
|
+
class BugzyReporter implements Reporter {
|
|
44
|
+
private testRunDir!: string;
|
|
45
|
+
private timestamp!: string;
|
|
46
|
+
private bugzyExecutionId!: string;
|
|
47
|
+
private startTime!: Date;
|
|
48
|
+
private testResults: Map<string, Array<any>> = new Map();
|
|
49
|
+
private testSteps: Map<string, Array<StepData>> = new Map();
|
|
50
|
+
private testStartTimes: Map<string, number> = new Map();
|
|
51
|
+
|
|
52
|
+
constructor() {
|
|
53
|
+
// No longer need to read execution number from environment
|
|
54
|
+
// It will be auto-detected per test case
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Called once before running tests
|
|
59
|
+
*/
|
|
60
|
+
onBegin(config: FullConfig, suite: Suite): void {
|
|
61
|
+
this.startTime = new Date();
|
|
62
|
+
|
|
63
|
+
// Generate timestamp in YYYYMMDD-HHMMSS format
|
|
64
|
+
this.timestamp = this.startTime
|
|
65
|
+
.toISOString()
|
|
66
|
+
.replace(/[-:]/g, '')
|
|
67
|
+
.replace(/T/, '-')
|
|
68
|
+
.slice(0, 15);
|
|
69
|
+
|
|
70
|
+
const testRunsRoot = path.join(process.cwd(), 'test-runs');
|
|
71
|
+
|
|
72
|
+
// Check if we should reuse an existing session
|
|
73
|
+
let reuseDir: string | null = null;
|
|
74
|
+
|
|
75
|
+
// If BUGZY_EXECUTION_ID is provided, use it directly
|
|
76
|
+
if (process.env.BUGZY_EXECUTION_ID) {
|
|
77
|
+
this.bugzyExecutionId = process.env.BUGZY_EXECUTION_ID;
|
|
78
|
+
} else {
|
|
79
|
+
// For local runs, check if we can reuse the latest session
|
|
80
|
+
// Reuse if the latest manifest is within 60 minutes
|
|
81
|
+
if (fs.existsSync(testRunsRoot)) {
|
|
82
|
+
const dirs = fs.readdirSync(testRunsRoot)
|
|
83
|
+
.filter(d => fs.statSync(path.join(testRunsRoot, d)).isDirectory())
|
|
84
|
+
.sort()
|
|
85
|
+
.reverse(); // Sort descending (latest first)
|
|
86
|
+
|
|
87
|
+
if (dirs.length > 0) {
|
|
88
|
+
const latestDir = dirs[0];
|
|
89
|
+
const manifestPath = path.join(testRunsRoot, latestDir, 'manifest.json');
|
|
90
|
+
|
|
91
|
+
if (fs.existsSync(manifestPath)) {
|
|
92
|
+
try {
|
|
93
|
+
const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf-8'));
|
|
94
|
+
const manifestTime = new Date(manifest.startTime).getTime();
|
|
95
|
+
const currentTime = this.startTime.getTime();
|
|
96
|
+
const minutesDiff = (currentTime - manifestTime) / (1000 * 60);
|
|
97
|
+
|
|
98
|
+
// Reuse if within 60 minutes and has a local execution ID
|
|
99
|
+
if (minutesDiff <= 60 && manifest.bugzyExecutionId?.startsWith('local-')) {
|
|
100
|
+
this.bugzyExecutionId = manifest.bugzyExecutionId;
|
|
101
|
+
reuseDir = latestDir;
|
|
102
|
+
}
|
|
103
|
+
} catch (err) {
|
|
104
|
+
// Ignore parsing errors
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// If no session to reuse, generate new local ID
|
|
111
|
+
if (!this.bugzyExecutionId) {
|
|
112
|
+
this.bugzyExecutionId = 'local-' + this.timestamp;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// If we have a specific execution ID but haven't found a reuse dir yet, check for matching session
|
|
117
|
+
if (!reuseDir && fs.existsSync(testRunsRoot)) {
|
|
118
|
+
const dirs = fs.readdirSync(testRunsRoot)
|
|
119
|
+
.filter(d => fs.statSync(path.join(testRunsRoot, d)).isDirectory())
|
|
120
|
+
.sort()
|
|
121
|
+
.reverse();
|
|
122
|
+
|
|
123
|
+
for (const dir of dirs) {
|
|
124
|
+
const manifestPath = path.join(testRunsRoot, dir, 'manifest.json');
|
|
125
|
+
if (fs.existsSync(manifestPath)) {
|
|
126
|
+
try {
|
|
127
|
+
const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf-8'));
|
|
128
|
+
if (manifest.bugzyExecutionId === this.bugzyExecutionId) {
|
|
129
|
+
reuseDir = dir;
|
|
130
|
+
break;
|
|
131
|
+
}
|
|
132
|
+
} catch (err) {
|
|
133
|
+
// Ignore parsing errors
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
if (reuseDir) {
|
|
140
|
+
this.testRunDir = path.join(testRunsRoot, reuseDir);
|
|
141
|
+
console.log(`\n🔄 Continuing test run: ${reuseDir}`);
|
|
142
|
+
console.log(`📋 Execution ID: ${this.bugzyExecutionId}`);
|
|
143
|
+
console.log(`📁 Output directory: ${this.testRunDir}\n`);
|
|
144
|
+
} else {
|
|
145
|
+
this.testRunDir = path.join(testRunsRoot, this.timestamp);
|
|
146
|
+
fs.mkdirSync(this.testRunDir, { recursive: true });
|
|
147
|
+
console.log(`\n🆕 New test run: ${this.timestamp}`);
|
|
148
|
+
console.log(`📋 Execution ID: ${this.bugzyExecutionId}`);
|
|
149
|
+
console.log(`📁 Output directory: ${this.testRunDir}\n`);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Called after each test completes
|
|
155
|
+
*/
|
|
156
|
+
onTestEnd(test: TestCase, result: TestResult): void {
|
|
157
|
+
// Extract test ID from test title or file path
|
|
158
|
+
const testId = this.extractTestId(test);
|
|
159
|
+
|
|
160
|
+
// Create test case directory
|
|
161
|
+
const testCaseDir = path.join(this.testRunDir, testId);
|
|
162
|
+
fs.mkdirSync(testCaseDir, { recursive: true });
|
|
163
|
+
|
|
164
|
+
// Auto-detect execution number from existing folders
|
|
165
|
+
let executionNum = 1;
|
|
166
|
+
if (fs.existsSync(testCaseDir)) {
|
|
167
|
+
const existingExecs = fs.readdirSync(testCaseDir)
|
|
168
|
+
.filter(d => d.startsWith('exec-') && fs.statSync(path.join(testCaseDir, d)).isDirectory())
|
|
169
|
+
.map(d => parseInt(d.replace('exec-', ''), 10))
|
|
170
|
+
.filter(n => !isNaN(n));
|
|
171
|
+
|
|
172
|
+
if (existingExecs.length > 0) {
|
|
173
|
+
executionNum = Math.max(...existingExecs) + 1;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// Create execution directory
|
|
178
|
+
const execDir = path.join(testCaseDir, `exec-${executionNum}`);
|
|
179
|
+
fs.mkdirSync(execDir, { recursive: true });
|
|
180
|
+
|
|
181
|
+
// Prepare result data in Playwright format
|
|
182
|
+
const resultData = {
|
|
183
|
+
status: result.status,
|
|
184
|
+
duration: result.duration,
|
|
185
|
+
errors: result.errors,
|
|
186
|
+
retry: result.retry,
|
|
187
|
+
startTime: result.startTime.toISOString(),
|
|
188
|
+
attachments: [] as Array<{ name: string; path: string; contentType: string }>,
|
|
189
|
+
};
|
|
190
|
+
|
|
191
|
+
// Handle attachments (videos, traces, screenshots)
|
|
192
|
+
let hasVideo = false;
|
|
193
|
+
let hasTrace = false;
|
|
194
|
+
let hasScreenshots = false;
|
|
195
|
+
|
|
196
|
+
for (const attachment of result.attachments) {
|
|
197
|
+
if (attachment.name === 'video' && attachment.path) {
|
|
198
|
+
// Copy video file to execution directory
|
|
199
|
+
const videoFileName = 'video.webm';
|
|
200
|
+
const videoDestPath = path.join(execDir, videoFileName);
|
|
201
|
+
|
|
202
|
+
try {
|
|
203
|
+
fs.copyFileSync(attachment.path, videoDestPath);
|
|
204
|
+
resultData.attachments.push({
|
|
205
|
+
name: 'video',
|
|
206
|
+
path: videoFileName,
|
|
207
|
+
contentType: attachment.contentType || 'video/webm',
|
|
208
|
+
});
|
|
209
|
+
hasVideo = true;
|
|
210
|
+
} catch (err) {
|
|
211
|
+
console.error(`Failed to copy video: ${err}`);
|
|
212
|
+
}
|
|
213
|
+
} else if (attachment.name === 'trace' && attachment.path) {
|
|
214
|
+
// Copy trace file to execution directory (only for failures)
|
|
215
|
+
if (result.status === 'failed' || result.status === 'timedOut') {
|
|
216
|
+
const traceFileName = 'trace.zip';
|
|
217
|
+
const traceDestPath = path.join(execDir, traceFileName);
|
|
218
|
+
|
|
219
|
+
try {
|
|
220
|
+
fs.copyFileSync(attachment.path, traceDestPath);
|
|
221
|
+
resultData.attachments.push({
|
|
222
|
+
name: 'trace',
|
|
223
|
+
path: traceFileName,
|
|
224
|
+
contentType: attachment.contentType || 'application/zip',
|
|
225
|
+
});
|
|
226
|
+
hasTrace = true;
|
|
227
|
+
} catch (err) {
|
|
228
|
+
console.error(`Failed to copy trace: ${err}`);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
} else if (attachment.name === 'screenshot' && attachment.path) {
|
|
232
|
+
// Copy screenshots to execution directory (only for failures)
|
|
233
|
+
if (result.status === 'failed' || result.status === 'timedOut') {
|
|
234
|
+
const screenshotsDir = path.join(execDir, 'screenshots');
|
|
235
|
+
fs.mkdirSync(screenshotsDir, { recursive: true });
|
|
236
|
+
|
|
237
|
+
const screenshotFileName = path.basename(attachment.path);
|
|
238
|
+
const screenshotDestPath = path.join(screenshotsDir, screenshotFileName);
|
|
239
|
+
|
|
240
|
+
try {
|
|
241
|
+
fs.copyFileSync(attachment.path, screenshotDestPath);
|
|
242
|
+
resultData.attachments.push({
|
|
243
|
+
name: 'screenshot',
|
|
244
|
+
path: path.join('screenshots', screenshotFileName),
|
|
245
|
+
contentType: attachment.contentType || 'image/png',
|
|
246
|
+
});
|
|
247
|
+
hasScreenshots = true;
|
|
248
|
+
} catch (err) {
|
|
249
|
+
console.error(`Failed to copy screenshot: ${err}`);
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// Write result.json
|
|
256
|
+
const resultPath = path.join(execDir, 'result.json');
|
|
257
|
+
fs.writeFileSync(resultPath, JSON.stringify(resultData, null, 2));
|
|
258
|
+
|
|
259
|
+
// Store execution info for manifest
|
|
260
|
+
if (!this.testResults.has(testId)) {
|
|
261
|
+
this.testResults.set(testId, []);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
this.testResults.get(testId)!.push({
|
|
265
|
+
number: executionNum,
|
|
266
|
+
status: result.status,
|
|
267
|
+
duration: result.duration,
|
|
268
|
+
videoFile: hasVideo ? 'video.webm' : null,
|
|
269
|
+
hasTrace,
|
|
270
|
+
hasScreenshots,
|
|
271
|
+
error: result.errors.length > 0 ? result.errors[0].message : null,
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
// Generate steps.json if test has steps
|
|
275
|
+
const testKey = this.getTestKey(test);
|
|
276
|
+
const steps = this.testSteps.get(testKey);
|
|
277
|
+
if (steps && steps.length > 0) {
|
|
278
|
+
const stepsData = {
|
|
279
|
+
steps,
|
|
280
|
+
summary: {
|
|
281
|
+
totalSteps: steps.length,
|
|
282
|
+
successfulSteps: steps.filter(s => s.status === 'success').length,
|
|
283
|
+
failedSteps: steps.filter(s => s.status === 'failed').length,
|
|
284
|
+
skippedSteps: steps.filter(s => s.status === 'skipped').length,
|
|
285
|
+
},
|
|
286
|
+
};
|
|
287
|
+
|
|
288
|
+
const stepsPath = path.join(execDir, 'steps.json');
|
|
289
|
+
fs.writeFileSync(stepsPath, JSON.stringify(stepsData, null, 2));
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// Log execution result
|
|
293
|
+
const statusIcon = result.status === 'passed' ? '✅' : result.status === 'failed' ? '❌' : '⚠️';
|
|
294
|
+
console.log(`${statusIcon} ${testId} [exec-${executionNum}] - ${result.status} (${result.duration}ms)`);
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* Called when a test step begins
|
|
299
|
+
*/
|
|
300
|
+
onStepBegin(test: TestCase, _result: TestResult, step: TestStep): void {
|
|
301
|
+
// Only track test.step() calls (not hooks, fixtures, or expects)
|
|
302
|
+
if (step.category !== 'test.step') {
|
|
303
|
+
return;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
const testKey = this.getTestKey(test);
|
|
307
|
+
|
|
308
|
+
// Record test start time on first step
|
|
309
|
+
if (!this.testStartTimes.has(testKey)) {
|
|
310
|
+
this.testStartTimes.set(testKey, step.startTime.getTime());
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
// Initialize steps array for this test
|
|
314
|
+
if (!this.testSteps.has(testKey)) {
|
|
315
|
+
this.testSteps.set(testKey, []);
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
const steps = this.testSteps.get(testKey)!;
|
|
319
|
+
const testStartTime = this.testStartTimes.get(testKey)!;
|
|
320
|
+
const videoTimeSeconds = Math.floor((step.startTime.getTime() - testStartTime) / 1000);
|
|
321
|
+
|
|
322
|
+
steps.push({
|
|
323
|
+
index: steps.length + 1,
|
|
324
|
+
timestamp: step.startTime.toISOString(),
|
|
325
|
+
videoTimeSeconds,
|
|
326
|
+
action: step.title,
|
|
327
|
+
status: 'success', // Will be updated in onStepEnd if it fails
|
|
328
|
+
description: `${step.title} - in progress`,
|
|
329
|
+
technicalDetails: 'test.step',
|
|
330
|
+
});
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/**
|
|
334
|
+
* Called when a test step ends
|
|
335
|
+
*/
|
|
336
|
+
onStepEnd(test: TestCase, _result: TestResult, step: TestStep): void {
|
|
337
|
+
// Only track test.step() calls
|
|
338
|
+
if (step.category !== 'test.step') {
|
|
339
|
+
return;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
const testKey = this.getTestKey(test);
|
|
343
|
+
const steps = this.testSteps.get(testKey);
|
|
344
|
+
|
|
345
|
+
if (!steps || steps.length === 0) {
|
|
346
|
+
return;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
// Update the last step with final status and duration
|
|
350
|
+
const lastStep = steps[steps.length - 1];
|
|
351
|
+
lastStep.duration = step.duration;
|
|
352
|
+
|
|
353
|
+
if (step.error) {
|
|
354
|
+
lastStep.status = 'failed';
|
|
355
|
+
lastStep.description = `${step.title} - failed: ${step.error.message}`;
|
|
356
|
+
} else {
|
|
357
|
+
lastStep.status = 'success';
|
|
358
|
+
lastStep.description = `${step.title} - completed successfully`;
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
/**
|
|
363
|
+
* Called after all tests complete
|
|
364
|
+
*/
|
|
365
|
+
onEnd(result: FullResult): void {
|
|
366
|
+
const endTime = new Date();
|
|
367
|
+
|
|
368
|
+
// Calculate statistics
|
|
369
|
+
let totalTests = 0;
|
|
370
|
+
let totalExecutions = 0;
|
|
371
|
+
let passedTests = 0;
|
|
372
|
+
let failedTests = 0;
|
|
373
|
+
|
|
374
|
+
const testCases: Array<any> = [];
|
|
375
|
+
|
|
376
|
+
for (const [testId, executions] of this.testResults.entries()) {
|
|
377
|
+
totalTests++;
|
|
378
|
+
totalExecutions += executions.length;
|
|
379
|
+
|
|
380
|
+
const finalStatus = executions[executions.length - 1].status;
|
|
381
|
+
if (finalStatus === 'passed') {
|
|
382
|
+
passedTests++;
|
|
383
|
+
} else {
|
|
384
|
+
failedTests++;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
testCases.push({
|
|
388
|
+
id: testId,
|
|
389
|
+
name: testId.replace(/^TC-\d+-/, '').replace(/-/g, ' '),
|
|
390
|
+
totalExecutions: executions.length,
|
|
391
|
+
finalStatus,
|
|
392
|
+
executions,
|
|
393
|
+
});
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
// Generate manifest.json
|
|
397
|
+
const manifest = {
|
|
398
|
+
bugzyExecutionId: this.bugzyExecutionId,
|
|
399
|
+
timestamp: this.timestamp,
|
|
400
|
+
startTime: this.startTime.toISOString(),
|
|
401
|
+
endTime: endTime.toISOString(),
|
|
402
|
+
status: result.status,
|
|
403
|
+
stats: {
|
|
404
|
+
totalTests,
|
|
405
|
+
passed: passedTests,
|
|
406
|
+
failed: failedTests,
|
|
407
|
+
totalExecutions,
|
|
408
|
+
},
|
|
409
|
+
testCases,
|
|
410
|
+
};
|
|
411
|
+
|
|
412
|
+
const manifestPath = path.join(this.testRunDir, 'manifest.json');
|
|
413
|
+
fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2));
|
|
414
|
+
|
|
415
|
+
console.log(`\n📊 Test Run Summary:`);
|
|
416
|
+
console.log(` Total tests: ${totalTests}`);
|
|
417
|
+
console.log(` Passed: ${passedTests}`);
|
|
418
|
+
console.log(` Failed: ${failedTests}`);
|
|
419
|
+
console.log(` Total executions: ${totalExecutions}`);
|
|
420
|
+
console.log(` Manifest: ${manifestPath}\n`);
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
/**
|
|
424
|
+
* Extract test ID from test case
|
|
425
|
+
* Generates TC-XXX-{test-name} format
|
|
426
|
+
*/
|
|
427
|
+
private extractTestId(test: TestCase): string {
|
|
428
|
+
// Try to extract from test title
|
|
429
|
+
const title = test.title.toLowerCase().replace(/\s+/g, '-');
|
|
430
|
+
|
|
431
|
+
// Get test file name without extension
|
|
432
|
+
const fileName = path.basename(test.location.file, path.extname(test.location.file));
|
|
433
|
+
|
|
434
|
+
// Extract number from filename if it follows TC-XXX pattern
|
|
435
|
+
const tcMatch = fileName.match(/TC-(\d+)/i);
|
|
436
|
+
if (tcMatch) {
|
|
437
|
+
return `TC-${tcMatch[1]}-${title}`;
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
// Otherwise generate from index
|
|
441
|
+
// This is a simple fallback - you may want to improve this
|
|
442
|
+
const testIndex = String(test.parent.tests.indexOf(test) + 1).padStart(3, '0');
|
|
443
|
+
return `TC-${testIndex}-${title}`;
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
/**
|
|
447
|
+
* Generate unique key for test to track steps across retries
|
|
448
|
+
*/
|
|
449
|
+
private getTestKey(test: TestCase): string {
|
|
450
|
+
return `${test.location.file}::${test.title}`;
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
export default BugzyReporter;
|