@bugzy-ai/bugzy 1.7.0 → 1.9.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.
Files changed (52) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +273 -273
  3. package/dist/cli/index.cjs +465 -15
  4. package/dist/cli/index.cjs.map +1 -1
  5. package/dist/cli/index.js +464 -14
  6. package/dist/cli/index.js.map +1 -1
  7. package/dist/index.cjs +460 -12
  8. package/dist/index.cjs.map +1 -1
  9. package/dist/index.js +460 -12
  10. package/dist/index.js.map +1 -1
  11. package/dist/subagents/index.cjs +392 -6
  12. package/dist/subagents/index.cjs.map +1 -1
  13. package/dist/subagents/index.js +392 -6
  14. package/dist/subagents/index.js.map +1 -1
  15. package/dist/subagents/metadata.cjs +27 -0
  16. package/dist/subagents/metadata.cjs.map +1 -1
  17. package/dist/subagents/metadata.js +27 -0
  18. package/dist/subagents/metadata.js.map +1 -1
  19. package/dist/tasks/index.cjs +30 -1
  20. package/dist/tasks/index.cjs.map +1 -1
  21. package/dist/tasks/index.js +30 -1
  22. package/dist/tasks/index.js.map +1 -1
  23. package/package.json +95 -95
  24. package/templates/init/.bugzy/runtime/knowledge-base.md +61 -61
  25. package/templates/init/.bugzy/runtime/knowledge-maintenance-guide.md +97 -97
  26. package/templates/init/.bugzy/runtime/project-context.md +35 -35
  27. package/templates/init/.bugzy/runtime/subagent-memory-guide.md +87 -87
  28. package/templates/init/.bugzy/runtime/templates/test-plan-template.md +50 -50
  29. package/templates/init/.bugzy/runtime/templates/test-result-schema.md +498 -498
  30. package/templates/init/.bugzy/runtime/test-execution-strategy.md +535 -535
  31. package/templates/init/.bugzy/runtime/testing-best-practices.md +724 -632
  32. package/templates/init/.env.testdata +18 -18
  33. package/templates/init/.gitignore-template +24 -24
  34. package/templates/init/AGENTS.md +155 -155
  35. package/templates/init/CLAUDE.md +157 -157
  36. package/templates/init/test-runs/README.md +45 -45
  37. package/templates/playwright/BasePage.template.ts +190 -190
  38. package/templates/playwright/auth.setup.template.ts +89 -89
  39. package/templates/playwright/dataGenerators.helper.template.ts +148 -148
  40. package/templates/playwright/dateUtils.helper.template.ts +96 -96
  41. package/templates/playwright/pages.fixture.template.ts +50 -50
  42. package/templates/playwright/playwright.config.template.ts +97 -97
  43. package/templates/playwright/reporters/bugzy-reporter.ts +454 -454
  44. package/dist/templates/init/.bugzy/runtime/knowledge-base.md +0 -61
  45. package/dist/templates/init/.bugzy/runtime/knowledge-maintenance-guide.md +0 -97
  46. package/dist/templates/init/.bugzy/runtime/project-context.md +0 -35
  47. package/dist/templates/init/.bugzy/runtime/subagent-memory-guide.md +0 -87
  48. package/dist/templates/init/.bugzy/runtime/templates/test-plan-template.md +0 -50
  49. package/dist/templates/init/.bugzy/runtime/templates/test-result-schema.md +0 -498
  50. package/dist/templates/init/.bugzy/runtime/test-execution-strategy.md +0 -535
  51. package/dist/templates/init/.bugzy/runtime/testing-best-practices.md +0 -632
  52. package/dist/templates/init/.gitignore-template +0 -25
@@ -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;