@bugzy-ai/bugzy 1.15.0 → 1.16.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.
@@ -0,0 +1,329 @@
1
+ import { test, expect } from '@playwright/test';
2
+ import { mergeManifests } from '../bugzy-reporter';
3
+
4
+ function makeExecution(overrides: Partial<{
5
+ number: number;
6
+ status: string;
7
+ duration: number;
8
+ videoFile: string | null;
9
+ hasTrace: boolean;
10
+ hasScreenshots: boolean;
11
+ error: string | null;
12
+ }> = {}) {
13
+ return {
14
+ number: 1,
15
+ status: 'passed',
16
+ duration: 1000,
17
+ videoFile: 'video.webm',
18
+ hasTrace: false,
19
+ hasScreenshots: false,
20
+ error: null,
21
+ ...overrides,
22
+ };
23
+ }
24
+
25
+ function makeTestCase(id: string, executions: ReturnType<typeof makeExecution>[], finalStatus?: string) {
26
+ const lastExec = executions[executions.length - 1];
27
+ return {
28
+ id,
29
+ name: id.replace(/^TC-\d+-/, '').replace(/-/g, ' '),
30
+ totalExecutions: executions.length,
31
+ finalStatus: finalStatus ?? lastExec.status,
32
+ executions,
33
+ };
34
+ }
35
+
36
+ function makeManifest(overrides: Partial<{
37
+ bugzyExecutionId: string;
38
+ timestamp: string;
39
+ startTime: string;
40
+ endTime: string;
41
+ status: string;
42
+ stats: { totalTests: number; passed: number; failed: number; totalExecutions: number };
43
+ testCases: ReturnType<typeof makeTestCase>[];
44
+ }> = {}) {
45
+ const testCases = overrides.testCases ?? [];
46
+ const totalExecutions = testCases.reduce((sum, tc) => sum + tc.executions.length, 0);
47
+ const passed = testCases.filter(tc => tc.finalStatus === 'passed').length;
48
+ const failed = testCases.length - passed;
49
+
50
+ return {
51
+ bugzyExecutionId: 'local-20260127-060129',
52
+ timestamp: '20260127-060129',
53
+ startTime: '2026-01-27T06:01:29.000Z',
54
+ endTime: '2026-01-27T06:02:00.000Z',
55
+ status: 'passed',
56
+ stats: {
57
+ totalTests: testCases.length,
58
+ passed,
59
+ failed,
60
+ totalExecutions,
61
+ ...overrides.stats,
62
+ },
63
+ ...overrides,
64
+ testCases,
65
+ };
66
+ }
67
+
68
+ test.describe('mergeManifests', () => {
69
+ test('returns current manifest unchanged when existing is null', () => {
70
+ const current = makeManifest({
71
+ testCases: [makeTestCase('TC-001-login', [makeExecution()])],
72
+ });
73
+
74
+ const result = mergeManifests(null, current);
75
+
76
+ expect(result).toEqual(current);
77
+ });
78
+
79
+ test('merges test cases from both manifests', () => {
80
+ const existing = makeManifest({
81
+ testCases: [
82
+ makeTestCase('TC-001-login', [makeExecution({ number: 1 })]),
83
+ ],
84
+ });
85
+
86
+ const current = makeManifest({
87
+ startTime: '2026-01-27T06:05:00.000Z',
88
+ endTime: '2026-01-27T06:06:00.000Z',
89
+ testCases: [
90
+ makeTestCase('TC-002-checkout', [makeExecution({ number: 1 })]),
91
+ ],
92
+ });
93
+
94
+ const result = mergeManifests(existing, current);
95
+
96
+ expect(result.testCases).toHaveLength(2);
97
+ expect(result.testCases.map(tc => tc.id)).toContain('TC-001-login');
98
+ expect(result.testCases.map(tc => tc.id)).toContain('TC-002-checkout');
99
+ expect(result.stats.totalTests).toBe(2);
100
+ expect(result.stats.totalExecutions).toBe(2);
101
+ });
102
+
103
+ test('merges executions for the same test case across runs', () => {
104
+ const existing = makeManifest({
105
+ testCases: [
106
+ makeTestCase('TC-001-login', [
107
+ makeExecution({ number: 1, status: 'failed', error: 'timeout' }),
108
+ ], 'failed'),
109
+ ],
110
+ });
111
+
112
+ const current = makeManifest({
113
+ startTime: '2026-01-27T06:05:00.000Z',
114
+ endTime: '2026-01-27T06:06:00.000Z',
115
+ testCases: [
116
+ makeTestCase('TC-001-login', [
117
+ makeExecution({ number: 2, status: 'passed' }),
118
+ ]),
119
+ ],
120
+ });
121
+
122
+ const result = mergeManifests(existing, current);
123
+
124
+ expect(result.testCases).toHaveLength(1);
125
+ const tc = result.testCases[0];
126
+ expect(tc.executions).toHaveLength(2);
127
+ expect(tc.executions[0].number).toBe(1);
128
+ expect(tc.executions[0].status).toBe('failed');
129
+ expect(tc.executions[1].number).toBe(2);
130
+ expect(tc.executions[1].status).toBe('passed');
131
+ expect(tc.totalExecutions).toBe(2);
132
+ expect(tc.finalStatus).toBe('passed'); // Latest execution status
133
+ });
134
+
135
+ test('current run wins on execution number collision', () => {
136
+ const existing = makeManifest({
137
+ testCases: [
138
+ makeTestCase('TC-001-login', [
139
+ makeExecution({ number: 3, status: 'failed', duration: 500 }),
140
+ ], 'failed'),
141
+ ],
142
+ });
143
+
144
+ const current = makeManifest({
145
+ startTime: '2026-01-27T06:05:00.000Z',
146
+ endTime: '2026-01-27T06:06:00.000Z',
147
+ testCases: [
148
+ makeTestCase('TC-001-login', [
149
+ makeExecution({ number: 3, status: 'passed', duration: 1200 }),
150
+ ]),
151
+ ],
152
+ });
153
+
154
+ const result = mergeManifests(existing, current);
155
+
156
+ const tc = result.testCases[0];
157
+ expect(tc.executions).toHaveLength(1);
158
+ expect(tc.executions[0].status).toBe('passed');
159
+ expect(tc.executions[0].duration).toBe(1200);
160
+ });
161
+
162
+ test('preserves test cases that only exist in existing manifest', () => {
163
+ const existing = makeManifest({
164
+ testCases: [
165
+ makeTestCase('TC-001-login', [makeExecution({ number: 1 })]),
166
+ makeTestCase('TC-002-checkout', [makeExecution({ number: 1 })]),
167
+ ],
168
+ });
169
+
170
+ const current = makeManifest({
171
+ startTime: '2026-01-27T06:05:00.000Z',
172
+ endTime: '2026-01-27T06:06:00.000Z',
173
+ testCases: [
174
+ makeTestCase('TC-001-login', [makeExecution({ number: 2 })]),
175
+ ],
176
+ });
177
+
178
+ const result = mergeManifests(existing, current);
179
+
180
+ expect(result.testCases).toHaveLength(2);
181
+ const checkout = result.testCases.find(tc => tc.id === 'TC-002-checkout');
182
+ expect(checkout).toBeDefined();
183
+ expect(checkout!.executions).toHaveLength(1);
184
+ expect(checkout!.executions[0].number).toBe(1);
185
+ });
186
+
187
+ test('recalculates stats correctly from merged data', () => {
188
+ const existing = makeManifest({
189
+ testCases: [
190
+ makeTestCase('TC-001-login', [
191
+ makeExecution({ number: 1, status: 'failed' }),
192
+ ], 'failed'),
193
+ makeTestCase('TC-002-checkout', [
194
+ makeExecution({ number: 1, status: 'passed' }),
195
+ ]),
196
+ ],
197
+ });
198
+
199
+ const current = makeManifest({
200
+ startTime: '2026-01-27T06:05:00.000Z',
201
+ endTime: '2026-01-27T06:06:00.000Z',
202
+ testCases: [
203
+ makeTestCase('TC-001-login', [
204
+ makeExecution({ number: 2, status: 'passed' }),
205
+ ]),
206
+ makeTestCase('TC-003-profile', [
207
+ makeExecution({ number: 1, status: 'failed' }),
208
+ ], 'failed'),
209
+ ],
210
+ });
211
+
212
+ const result = mergeManifests(existing, current);
213
+
214
+ expect(result.stats.totalTests).toBe(3);
215
+ // TC-001: exec-1 (failed) + exec-2 (passed) = 2 execs, finalStatus=passed
216
+ // TC-002: exec-1 (passed) = 1 exec, finalStatus=passed
217
+ // TC-003: exec-1 (failed) = 1 exec, finalStatus=failed
218
+ expect(result.stats.totalExecutions).toBe(4);
219
+ expect(result.stats.passed).toBe(2); // TC-001 and TC-002
220
+ expect(result.stats.failed).toBe(1); // TC-003
221
+ });
222
+
223
+ test('uses earliest startTime and latest endTime', () => {
224
+ const existing = makeManifest({
225
+ startTime: '2026-01-27T06:01:00.000Z',
226
+ endTime: '2026-01-27T06:02:00.000Z',
227
+ testCases: [makeTestCase('TC-001-login', [makeExecution()])],
228
+ });
229
+
230
+ const current = makeManifest({
231
+ startTime: '2026-01-27T06:05:00.000Z',
232
+ endTime: '2026-01-27T06:06:00.000Z',
233
+ testCases: [makeTestCase('TC-001-login', [makeExecution({ number: 2 })])],
234
+ });
235
+
236
+ const result = mergeManifests(existing, current);
237
+
238
+ expect(result.startTime).toBe('2026-01-27T06:01:00.000Z');
239
+ expect(result.endTime).toBe('2026-01-27T06:06:00.000Z');
240
+ });
241
+
242
+ test('sets status to failed if any test case has failed finalStatus', () => {
243
+ const existing = makeManifest({
244
+ status: 'passed',
245
+ testCases: [
246
+ makeTestCase('TC-001-login', [makeExecution({ number: 1, status: 'passed' })]),
247
+ ],
248
+ });
249
+
250
+ const current = makeManifest({
251
+ status: 'passed',
252
+ startTime: '2026-01-27T06:05:00.000Z',
253
+ endTime: '2026-01-27T06:06:00.000Z',
254
+ testCases: [
255
+ makeTestCase('TC-002-checkout', [
256
+ makeExecution({ number: 1, status: 'failed' }),
257
+ ], 'failed'),
258
+ ],
259
+ });
260
+
261
+ const result = mergeManifests(existing, current);
262
+
263
+ expect(result.status).toBe('failed');
264
+ });
265
+
266
+ test('preserves original session timestamp from existing manifest', () => {
267
+ const existing = makeManifest({
268
+ timestamp: '20260127-060129',
269
+ testCases: [makeTestCase('TC-001-login', [makeExecution()])],
270
+ });
271
+
272
+ const current = makeManifest({
273
+ timestamp: '20260127-060500',
274
+ startTime: '2026-01-27T06:05:00.000Z',
275
+ endTime: '2026-01-27T06:06:00.000Z',
276
+ testCases: [makeTestCase('TC-001-login', [makeExecution({ number: 2 })])],
277
+ });
278
+
279
+ const result = mergeManifests(existing, current);
280
+
281
+ expect(result.timestamp).toBe('20260127-060129');
282
+ });
283
+
284
+ test('handles timedOut status as failure in merged status', () => {
285
+ const existing = makeManifest({
286
+ status: 'passed',
287
+ testCases: [
288
+ makeTestCase('TC-001-login', [
289
+ makeExecution({ number: 1, status: 'timedOut' }),
290
+ ], 'timedOut'),
291
+ ],
292
+ });
293
+
294
+ const current = makeManifest({
295
+ status: 'passed',
296
+ startTime: '2026-01-27T06:05:00.000Z',
297
+ endTime: '2026-01-27T06:06:00.000Z',
298
+ testCases: [
299
+ makeTestCase('TC-002-checkout', [makeExecution({ number: 1 })]),
300
+ ],
301
+ });
302
+
303
+ const result = mergeManifests(existing, current);
304
+
305
+ expect(result.status).toBe('failed');
306
+ });
307
+
308
+ test('does not mutate input manifests', () => {
309
+ const existingExec = makeExecution({ number: 1, status: 'failed' });
310
+ const existing = makeManifest({
311
+ testCases: [makeTestCase('TC-001-login', [existingExec], 'failed')],
312
+ });
313
+ const existingSnapshot = JSON.parse(JSON.stringify(existing));
314
+
315
+ const current = makeManifest({
316
+ startTime: '2026-01-27T06:05:00.000Z',
317
+ endTime: '2026-01-27T06:06:00.000Z',
318
+ testCases: [
319
+ makeTestCase('TC-001-login', [makeExecution({ number: 2, status: 'passed' })]),
320
+ ],
321
+ });
322
+ const currentSnapshot = JSON.parse(JSON.stringify(current));
323
+
324
+ mergeManifests(existing, current);
325
+
326
+ expect(existing).toEqual(existingSnapshot);
327
+ expect(current).toEqual(currentSnapshot);
328
+ });
329
+ });
@@ -0,0 +1,5 @@
1
+ import { defineConfig } from '@playwright/test';
2
+
3
+ export default defineConfig({
4
+ testDir: '.',
5
+ });
@@ -24,6 +24,142 @@ interface StepData {
24
24
  duration?: number;
25
25
  }
26
26
 
27
+ /**
28
+ * Manifest execution entry
29
+ */
30
+ interface ManifestExecution {
31
+ number: number;
32
+ status: string;
33
+ duration: number;
34
+ videoFile: string | null;
35
+ hasTrace: boolean;
36
+ hasScreenshots: boolean;
37
+ error: string | null;
38
+ }
39
+
40
+ /**
41
+ * Manifest test case entry
42
+ */
43
+ interface ManifestTestCase {
44
+ id: string;
45
+ name: string;
46
+ totalExecutions: number;
47
+ finalStatus: string;
48
+ executions: ManifestExecution[];
49
+ }
50
+
51
+ /**
52
+ * Manifest structure for test run sessions
53
+ */
54
+ interface Manifest {
55
+ bugzyExecutionId: string;
56
+ timestamp: string;
57
+ startTime: string;
58
+ endTime: string;
59
+ status: string;
60
+ stats: {
61
+ totalTests: number;
62
+ passed: number;
63
+ failed: number;
64
+ totalExecutions: number;
65
+ };
66
+ testCases: ManifestTestCase[];
67
+ }
68
+
69
+ /**
70
+ * Merge an existing manifest with the current run's manifest.
71
+ * If existing is null, returns current as-is.
72
+ * Deduplicates executions by number (current run wins on collision).
73
+ * Recalculates stats from the merged data.
74
+ */
75
+ export function mergeManifests(existing: Manifest | null, current: Manifest): Manifest {
76
+ if (!existing) {
77
+ return current;
78
+ }
79
+
80
+ // Build map of test cases by id from existing manifest
81
+ const testCaseMap = new Map<string, ManifestTestCase>();
82
+ for (const tc of existing.testCases) {
83
+ testCaseMap.set(tc.id, { ...tc, executions: [...tc.executions] });
84
+ }
85
+
86
+ // Merge current run's test cases
87
+ for (const tc of current.testCases) {
88
+ const existingTc = testCaseMap.get(tc.id);
89
+ if (existingTc) {
90
+ // Merge executions: build a map keyed by execution number
91
+ const execMap = new Map<number, ManifestExecution>();
92
+ for (const exec of existingTc.executions) {
93
+ execMap.set(exec.number, exec);
94
+ }
95
+ // Current run's executions overwrite on collision
96
+ for (const exec of tc.executions) {
97
+ execMap.set(exec.number, exec);
98
+ }
99
+ // Sort by execution number
100
+ const mergedExecs = Array.from(execMap.values()).sort((a, b) => a.number - b.number);
101
+ const finalStatus = mergedExecs[mergedExecs.length - 1].status;
102
+
103
+ testCaseMap.set(tc.id, {
104
+ id: tc.id,
105
+ name: tc.name,
106
+ totalExecutions: mergedExecs.length,
107
+ finalStatus,
108
+ executions: mergedExecs,
109
+ });
110
+ } else {
111
+ // New test case from current run
112
+ testCaseMap.set(tc.id, { ...tc, executions: [...tc.executions] });
113
+ }
114
+ }
115
+
116
+ // Build merged test cases array
117
+ const mergedTestCases = Array.from(testCaseMap.values());
118
+
119
+ // Recalculate stats
120
+ let totalTests = 0;
121
+ let totalExecutions = 0;
122
+ let passedTests = 0;
123
+ let failedTests = 0;
124
+
125
+ for (const tc of mergedTestCases) {
126
+ totalTests++;
127
+ totalExecutions += tc.executions.length;
128
+ if (tc.finalStatus === 'passed') {
129
+ passedTests++;
130
+ } else {
131
+ failedTests++;
132
+ }
133
+ }
134
+
135
+ // Use earliest startTime, latest endTime
136
+ const startTime = new Date(existing.startTime) < new Date(current.startTime)
137
+ ? existing.startTime
138
+ : current.startTime;
139
+ const endTime = new Date(existing.endTime) > new Date(current.endTime)
140
+ ? existing.endTime
141
+ : current.endTime;
142
+
143
+ // Status: if any test case failed, overall is failed
144
+ const hasFailure = mergedTestCases.some(tc => tc.finalStatus === 'failed' || tc.finalStatus === 'timedOut');
145
+ const status = hasFailure ? 'failed' : current.status;
146
+
147
+ return {
148
+ bugzyExecutionId: current.bugzyExecutionId,
149
+ timestamp: existing.timestamp, // Keep original session timestamp
150
+ startTime,
151
+ endTime,
152
+ status,
153
+ stats: {
154
+ totalTests,
155
+ passed: passedTests,
156
+ failed: failedTests,
157
+ totalExecutions,
158
+ },
159
+ testCases: mergedTestCases,
160
+ };
161
+ }
162
+
27
163
  /**
28
164
  * Bugzy Custom Playwright Reporter
29
165
  *
@@ -393,8 +529,8 @@ class BugzyReporter implements Reporter {
393
529
  });
394
530
  }
395
531
 
396
- // Generate manifest.json
397
- const manifest = {
532
+ // Build current run's manifest
533
+ const currentManifest: Manifest = {
398
534
  bugzyExecutionId: this.bugzyExecutionId,
399
535
  timestamp: this.timestamp,
400
536
  startTime: this.startTime.toISOString(),
@@ -409,14 +545,37 @@ class BugzyReporter implements Reporter {
409
545
  testCases,
410
546
  };
411
547
 
548
+ // Read existing manifest for merge (if session is being reused)
412
549
  const manifestPath = path.join(this.testRunDir, 'manifest.json');
413
- fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2));
550
+ let existingManifest: Manifest | null = null;
551
+ if (fs.existsSync(manifestPath)) {
552
+ try {
553
+ existingManifest = JSON.parse(fs.readFileSync(manifestPath, 'utf-8'));
554
+ } catch (err) {
555
+ console.warn(`⚠️ Could not parse existing manifest, will overwrite: ${err}`);
556
+ }
557
+ }
414
558
 
415
- console.log(`\n📊 Test Run Summary:`);
559
+ // Merge with existing manifest data
560
+ const merged = mergeManifests(existingManifest, currentManifest);
561
+
562
+ // Write atomically (temp file + rename)
563
+ const tmpPath = manifestPath + '.tmp';
564
+ fs.writeFileSync(tmpPath, JSON.stringify(merged, null, 2));
565
+ fs.renameSync(tmpPath, manifestPath);
566
+
567
+ console.log(`\n📊 Test Run Summary (this run):`);
416
568
  console.log(` Total tests: ${totalTests}`);
417
569
  console.log(` Passed: ${passedTests}`);
418
570
  console.log(` Failed: ${failedTests}`);
419
571
  console.log(` Total executions: ${totalExecutions}`);
572
+
573
+ if (existingManifest) {
574
+ console.log(`\n🔗 Merged with previous session data:`);
575
+ console.log(` Session total tests: ${merged.stats.totalTests}`);
576
+ console.log(` Session total executions: ${merged.stats.totalExecutions}`);
577
+ }
578
+
420
579
  console.log(` Manifest: ${manifestPath}\n`);
421
580
  }
422
581