@testivai/witness-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.
Files changed (61) hide show
  1. package/__tests__/.gitkeep +0 -0
  2. package/__tests__/config-integration.spec.ts +102 -0
  3. package/__tests__/snapshot.spec.d.ts +1 -0
  4. package/__tests__/snapshot.spec.js +81 -0
  5. package/__tests__/snapshot.spec.ts +58 -0
  6. package/__tests__/unit/ci.spec.d.ts +1 -0
  7. package/__tests__/unit/ci.spec.js +35 -0
  8. package/__tests__/unit/ci.spec.ts +40 -0
  9. package/__tests__/unit/reporter.spec.d.ts +1 -0
  10. package/__tests__/unit/reporter.spec.js +37 -0
  11. package/__tests__/unit/reporter.spec.ts +43 -0
  12. package/__tests__/unit/structureAnalyzer.spec.js +212 -0
  13. package/__tests__/unit/types.spec.ts +179 -0
  14. package/dist/__tests__/unit/ci.spec.d.ts +1 -0
  15. package/dist/__tests__/unit/ci.spec.js +226 -0
  16. package/dist/__tests__/unit/compression.spec.d.ts +4 -0
  17. package/dist/__tests__/unit/compression.spec.js +46 -0
  18. package/dist/ci.d.ts +30 -0
  19. package/dist/ci.js +117 -0
  20. package/dist/cli/index.d.ts +2 -0
  21. package/dist/cli/index.js +47 -0
  22. package/dist/cli/init.d.ts +3 -0
  23. package/dist/cli/init.js +158 -0
  24. package/dist/config/loader.d.ts +29 -0
  25. package/dist/config/loader.js +251 -0
  26. package/dist/domAnalyzer.d.ts +10 -0
  27. package/dist/domAnalyzer.js +285 -0
  28. package/dist/index.d.ts +7 -0
  29. package/dist/index.js +11 -0
  30. package/dist/reporter-entry.d.ts +2 -0
  31. package/dist/reporter-entry.js +5 -0
  32. package/dist/reporter-types.d.ts +2 -0
  33. package/dist/reporter-types.js +2 -0
  34. package/dist/reporter.d.ts +21 -0
  35. package/dist/reporter.js +249 -0
  36. package/dist/snapshot.d.ts +12 -0
  37. package/dist/snapshot.js +601 -0
  38. package/dist/structureAnalyzer.d.ts +12 -0
  39. package/dist/structureAnalyzer.js +288 -0
  40. package/dist/types.d.ts +368 -0
  41. package/dist/types.js +10 -0
  42. package/examples/structure-analysis-example.spec.ts +118 -0
  43. package/examples/structure-analysis.config.ts +159 -0
  44. package/jest.config.js +8 -0
  45. package/package.json +51 -0
  46. package/playwright.config.ts +11 -0
  47. package/src/__tests__/unit/ci.spec.ts +257 -0
  48. package/src/__tests__/unit/compression.spec.ts +52 -0
  49. package/src/ci.ts +140 -0
  50. package/src/cli/index.ts +49 -0
  51. package/src/cli/init.ts +131 -0
  52. package/src/config/loader.ts +238 -0
  53. package/src/index.ts +14 -0
  54. package/src/reporter-entry.ts +6 -0
  55. package/src/reporter-types.ts +5 -0
  56. package/src/reporter.ts +251 -0
  57. package/src/snapshot.ts +632 -0
  58. package/src/structureAnalyzer.ts +338 -0
  59. package/src/types.ts +388 -0
  60. package/tsconfig.jest.json +7 -0
  61. package/tsconfig.json +20 -0
@@ -0,0 +1,632 @@
1
+ import { Page, TestInfo } from '@playwright/test';
2
+ import * as fs from 'fs-extra';
3
+ import * as path from 'path';
4
+ import { URL } from 'url';
5
+ import sharp from 'sharp';
6
+ import { SnapshotPayload, LayoutData, TestivAIConfig, StructureAnalysis, StructureAnalysisConfig } from './types';
7
+ import { loadConfig, mergeTestConfig } from './config/loader';
8
+
9
+ /**
10
+ * Generates a safe filename from a URL.
11
+ * @param pageUrl The URL of the page.
12
+ * @returns A sanitized string suitable for a filename.
13
+ */
14
+ function getSnapshotNameFromUrl(pageUrl: string): string {
15
+ // Handle data URIs, which are common in test environments
16
+ if (pageUrl.startsWith('data:')) {
17
+ return 'snapshot';
18
+ }
19
+
20
+ try {
21
+ const url = new URL(pageUrl);
22
+ const pathName = url.pathname.substring(1).replace(/\//g, '_'); // remove leading slash and replace others
23
+ return pathName || 'home';
24
+ } catch (error) {
25
+ // Fallback for invalid URLs
26
+ return 'snapshot';
27
+ }
28
+ }
29
+
30
+ /**
31
+ * Captures a snapshot of the page, including a screenshot, DOM, and layout data.
32
+ * The evidence is stored in a temporary directory for the reporter to process later.
33
+ *
34
+ * @param page The Playwright Page object.
35
+ * @param testInfo The Playwright TestInfo object, passed from the test.
36
+ * @param name An optional unique name for the snapshot. If not provided, a name is generated from the URL.
37
+ * @param config Optional TestivAI configuration for this snapshot (overrides project defaults).
38
+ */
39
+ export async function snapshot(
40
+ page: Page,
41
+ testInfo: TestInfo,
42
+ name?: string,
43
+ config?: TestivAIConfig
44
+ ): Promise<void> {
45
+ // Load project configuration and merge with test-specific overrides
46
+ const projectConfig = await loadConfig();
47
+ const effectiveConfig = mergeTestConfig(projectConfig, config);
48
+
49
+ // Debug: Log config
50
+ if (process.env.TESTIVAI_DEBUG === 'true') {
51
+ console.log('[TestivAI] Config:', {
52
+ projectConfig,
53
+ testConfig: config,
54
+ effectiveConfig
55
+ });
56
+ }
57
+
58
+ const outputDir = path.join(process.cwd(), '.testivai', 'temp');
59
+ await fs.ensureDir(outputDir);
60
+
61
+ const snapshotName = name || getSnapshotNameFromUrl(page.url());
62
+ const timestamp = Date.now();
63
+ const safeName = snapshotName.replace(/[^a-z0-9]/gi, '_').toLowerCase();
64
+ const baseFilename = `${timestamp}_${safeName}`;
65
+
66
+ // 1. Capture full-page screenshot
67
+ const screenshotPath = path.join(outputDir, `${baseFilename}.png`);
68
+
69
+ // Check if scroll-and-stitch is explicitly requested (backup method)
70
+ if (effectiveConfig.useCDP === false) {
71
+ // Use scroll-and-stitch approach (backup method)
72
+ if (process.env.TESTIVAI_DEBUG === 'true') {
73
+ console.log('[TestivAI] Using scroll-and-stitch approach (backup method)');
74
+ }
75
+
76
+ // Get viewport dimensions
77
+ const viewport = page.viewportSize();
78
+ const viewportWidth = viewport?.width || 1280;
79
+ const viewportHeight = viewport?.height || 720;
80
+
81
+ // Find the main scrollable container and get its dimensions
82
+ const scrollableInfo = await page.evaluate(`
83
+ (function() {
84
+ var mainScrollable = null;
85
+ var maxScrollHeight = 0;
86
+
87
+ // Find the element with the most scrollable content
88
+ document.querySelectorAll('*').forEach(function(el) {
89
+ var computed = window.getComputedStyle(el);
90
+ var isScrollable = (
91
+ computed.overflowY === 'auto' ||
92
+ computed.overflowY === 'scroll'
93
+ );
94
+
95
+ if (isScrollable && el.scrollHeight > el.clientHeight) {
96
+ if (el.scrollHeight > maxScrollHeight) {
97
+ maxScrollHeight = el.scrollHeight;
98
+ mainScrollable = el;
99
+ }
100
+ }
101
+ });
102
+
103
+ // If we found a scrollable container, add a temporary ID
104
+ if (mainScrollable) {
105
+ if (!mainScrollable.id) {
106
+ mainScrollable.id = '__testivai_scrollable_' + Date.now();
107
+ }
108
+ return {
109
+ hasScrollable: true,
110
+ scrollableId: mainScrollable.id,
111
+ scrollHeight: mainScrollable.scrollHeight,
112
+ clientHeight: mainScrollable.clientHeight,
113
+ scrollTop: mainScrollable.scrollTop
114
+ };
115
+ }
116
+
117
+ // Fallback to document scroll
118
+ return {
119
+ hasScrollable: false,
120
+ scrollableId: null,
121
+ scrollHeight: document.documentElement.scrollHeight,
122
+ clientHeight: window.innerHeight,
123
+ scrollTop: window.scrollY
124
+ };
125
+ })()
126
+ `) as {
127
+ hasScrollable: boolean;
128
+ scrollableId: string | null;
129
+ scrollHeight: number;
130
+ clientHeight: number;
131
+ scrollTop: number;
132
+ };
133
+
134
+ // Calculate number of screenshots needed
135
+ const totalHeight = scrollableInfo.scrollHeight;
136
+ const captureHeight = scrollableInfo.clientHeight;
137
+ const numCaptures = Math.ceil(totalHeight / captureHeight);
138
+
139
+ // Debug logging (only when TESTIVAI_DEBUG is enabled)
140
+ if (process.env.TESTIVAI_DEBUG === 'true') {
141
+ console.log(`[TestivAI] Scroll-and-stitch info:`, {
142
+ hasScrollable: scrollableInfo.hasScrollable,
143
+ scrollableId: scrollableInfo.scrollableId,
144
+ totalHeight,
145
+ captureHeight,
146
+ numCaptures,
147
+ viewportWidth,
148
+ viewportHeight
149
+ });
150
+ }
151
+
152
+ // If only one capture needed, just take a regular screenshot
153
+ if (numCaptures <= 1) {
154
+ await page.screenshot({ path: screenshotPath, fullPage: true });
155
+ } else {
156
+ // Scroll-and-stitch approach
157
+ const screenshots: Buffer[] = [];
158
+
159
+ for (let i = 0; i < numCaptures; i++) {
160
+ const scrollPosition = i * captureHeight;
161
+
162
+ // Scroll to position
163
+ if (scrollableInfo.hasScrollable && scrollableInfo.scrollableId) {
164
+ await page.evaluate(`
165
+ document.getElementById('${scrollableInfo.scrollableId}').scrollTop = ${scrollPosition};
166
+ `);
167
+ } else {
168
+ await page.evaluate(`window.scrollTo(0, ${scrollPosition})`);
169
+ }
170
+
171
+ // Wait for scroll and any lazy-loaded content
172
+ await page.waitForTimeout(100);
173
+
174
+ // Capture this viewport
175
+ const screenshotBuffer = await page.screenshot({ fullPage: false });
176
+ screenshots.push(screenshotBuffer);
177
+ }
178
+
179
+ // Stitch screenshots together using sharp
180
+ // Calculate the actual height of the last capture (may be partial)
181
+ const lastCaptureHeight = totalHeight - (captureHeight * (numCaptures - 1));
182
+
183
+ // Create composite image
184
+ const compositeInputs = screenshots.map((buffer, index) => {
185
+ const isLast = index === screenshots.length - 1;
186
+ const yOffset = index * captureHeight;
187
+
188
+ // For the last screenshot, we need to crop from the bottom
189
+ if (isLast && lastCaptureHeight < captureHeight) {
190
+ return {
191
+ input: buffer,
192
+ top: yOffset,
193
+ left: 0,
194
+ // We'll handle the cropping separately
195
+ };
196
+ }
197
+
198
+ return {
199
+ input: buffer,
200
+ top: yOffset,
201
+ left: 0,
202
+ };
203
+ });
204
+
205
+ // Create the final stitched image
206
+ const finalImage = sharp({
207
+ create: {
208
+ width: viewportWidth,
209
+ height: totalHeight,
210
+ channels: 4,
211
+ background: { r: 255, g: 255, b: 255, alpha: 1 }
212
+ }
213
+ });
214
+
215
+ // Composite all screenshots
216
+ const stitchedImage = await finalImage
217
+ .composite(compositeInputs)
218
+ .png()
219
+ .toBuffer();
220
+
221
+ await fs.writeFile(screenshotPath, stitchedImage);
222
+
223
+ // Restore original scroll position
224
+ if (scrollableInfo.hasScrollable && scrollableInfo.scrollableId) {
225
+ await page.evaluate(`
226
+ document.getElementById('${scrollableInfo.scrollableId}').scrollTop = ${scrollableInfo.scrollTop};
227
+ `);
228
+ } else {
229
+ await page.evaluate(`window.scrollTo(0, ${scrollableInfo.scrollTop})`);
230
+ }
231
+ }
232
+ } else {
233
+ // Use CDP approach (default)
234
+ if (process.env.TESTIVAI_DEBUG === 'true') {
235
+ console.log('[TestivAI] Using CDP approach (default) for full-page screenshot');
236
+ }
237
+
238
+ try {
239
+ // Create a CDP session
240
+ const client = await page.context().newCDPSession(page);
241
+
242
+ // Enable Page domain
243
+ await client.send('Page.enable');
244
+
245
+ // Temporarily remove height constraints to get the full scrollable content
246
+ await page.addStyleTag({
247
+ content: `
248
+ html, body {
249
+ height: auto !important;
250
+ min-height: auto !important;
251
+ max-height: none !important;
252
+ }
253
+ #testivai-layout-root, [class*="h-screen"] {
254
+ height: auto !important;
255
+ min-height: auto !important;
256
+ max-height: none !important;
257
+ overflow: visible !important;
258
+ }
259
+ `
260
+ });
261
+
262
+ // Wait a bit for styles to apply
263
+ await page.waitForTimeout(100);
264
+
265
+ // Get layout metrics to determine full page size
266
+ const layoutMetrics = await client.send('Page.getLayoutMetrics');
267
+
268
+ // Calculate full page dimensions
269
+ const pageWidth = Math.ceil(layoutMetrics.contentSize.width);
270
+ const pageHeight = Math.ceil(layoutMetrics.contentSize.height);
271
+
272
+ if (process.env.TESTIVAI_DEBUG === 'true') {
273
+ console.log('[TestivAI] CDP Layout metrics:', {
274
+ pageWidth,
275
+ pageHeight,
276
+ viewportWidth: layoutMetrics.layoutViewport.clientWidth,
277
+ viewportHeight: layoutMetrics.layoutViewport.clientHeight
278
+ });
279
+ }
280
+
281
+ // Capture screenshot with captureBeyondViewport: true
282
+ const screenshot = await client.send('Page.captureScreenshot', {
283
+ format: 'png',
284
+ captureBeyondViewport: true,
285
+ clip: {
286
+ x: 0,
287
+ y: 0,
288
+ width: pageWidth,
289
+ height: pageHeight,
290
+ scale: 1
291
+ }
292
+ });
293
+
294
+ // Save the screenshot
295
+ await fs.writeFile(screenshotPath, Buffer.from(screenshot.data, 'base64'));
296
+
297
+ // Remove the temporary style tag
298
+ await page.evaluate(`
299
+ const styleTags = document.querySelectorAll('style');
300
+ // Remove the last added style tag (our temporary one)
301
+ if (styleTags.length > 0) {
302
+ styleTags[styleTags.length - 1].remove();
303
+ }
304
+ `);
305
+
306
+ // Close CDP session
307
+ await client.detach();
308
+
309
+ } catch (error: any) {
310
+ console.error('[TestivAI] CDP screenshot failed:', error.message);
311
+ // Fallback to regular screenshot
312
+ await page.screenshot({ path: screenshotPath, fullPage: true });
313
+ }
314
+ }
315
+
316
+ // 2. Dump page structure (HTML)
317
+ // @renamed: domPath → structurePath (IP protection)
318
+ const structurePath = path.join(outputDir, `${baseFilename}.html`);
319
+ const htmlContent = await page.content();
320
+ await fs.writeFile(structurePath, htmlContent);
321
+
322
+ // 2.5. Capture computed styles using CDP
323
+ // @renamed: cssPath → stylesPath (IP protection)
324
+ const stylesPath = path.join(outputDir, `${baseFilename}.css.json`);
325
+ try {
326
+ const cdpSession = await page.context().newCDPSession(page);
327
+
328
+ // Enable DOM and CSS domains
329
+ await cdpSession.send('DOM.enable');
330
+ await cdpSession.send('CSS.enable');
331
+
332
+ // Get all elements and their computed styles
333
+ const computedStyles: Record<string, Record<string, string>> = {};
334
+
335
+ // Visual properties we care about
336
+ const visualProperties = [
337
+ 'color', 'background-color', 'background-image',
338
+ 'font-size', 'font-weight', 'font-family',
339
+ 'width', 'height', 'padding', 'margin',
340
+ 'border', 'border-radius', 'box-shadow',
341
+ 'display', 'position', 'top', 'left', 'right', 'bottom',
342
+ 'transform', 'opacity', 'visibility', 'z-index'
343
+ ];
344
+
345
+ // Execute script to get all elements with unique identifiers
346
+ const elementsData = await cdpSession.send('Runtime.evaluate', {
347
+ expression: `
348
+ (function() {
349
+ // Helper to get stable CSS selector path for an element
350
+ function getElementPath(element) {
351
+ if (element.id) {
352
+ return '#' + element.id;
353
+ }
354
+
355
+ const path = [];
356
+ let current = element;
357
+
358
+ while (current && current !== document.body) {
359
+ let selector = current.tagName.toLowerCase();
360
+
361
+ // Add up to 3 CSS classes for better uniqueness
362
+ // e.g., button.button.primary-button instead of just button.button
363
+ if (current.className && typeof current.className === 'string') {
364
+ const classes = current.className.trim().split(/\\s+/).filter(Boolean);
365
+ const maxClasses = Math.min(classes.length, 3);
366
+ for (let c = 0; c < maxClasses; c++) {
367
+ selector += '.' + classes[c];
368
+ }
369
+ }
370
+
371
+ // Get nth-child position for uniqueness
372
+ if (current.parentNode) {
373
+ const siblings = Array.from(current.parentNode.children);
374
+ const sameTagSiblings = siblings.filter(s => s.tagName === current.tagName);
375
+ if (sameTagSiblings.length > 1) {
376
+ const index = sameTagSiblings.indexOf(current) + 1;
377
+ selector += \`:nth-of-type(\${index})\`;
378
+ }
379
+ }
380
+
381
+ path.unshift(selector);
382
+ current = current.parentElement;
383
+ }
384
+
385
+ return path.join(' > ');
386
+ }
387
+
388
+ const elements = document.querySelectorAll('*');
389
+ const result = [];
390
+ elements.forEach((el, index) => {
391
+ const selectorPath = getElementPath(el);
392
+ const tagName = el.tagName.toLowerCase();
393
+ const className = el.className || '';
394
+ result.push({
395
+ selectorPath,
396
+ tagName,
397
+ className,
398
+ index
399
+ });
400
+ });
401
+ return result;
402
+ })()
403
+ `,
404
+ returnByValue: true
405
+ });
406
+
407
+ if (elementsData.result.value) {
408
+ const elements = elementsData.result.value as Array<{selectorPath: string, tagName: string, className: string, index: number}>;
409
+
410
+ // Get computed styles for each element (sample first 100 to avoid performance issues)
411
+ const sampleSize = Math.min(elements.length, 100);
412
+ for (let i = 0; i < sampleSize; i++) {
413
+ const element = elements[i];
414
+ try {
415
+ const styleResult = await cdpSession.send('Runtime.evaluate', {
416
+ expression: `
417
+ (function() {
418
+ const el = document.querySelectorAll('*')[${element.index}];
419
+ if (!el) return null;
420
+ const styles = window.getComputedStyle(el);
421
+ const result = {};
422
+ ${JSON.stringify(visualProperties)}.forEach(prop => {
423
+ result[prop] = styles.getPropertyValue(prop);
424
+ });
425
+ return result;
426
+ })()
427
+ `,
428
+ returnByValue: true
429
+ });
430
+
431
+ if (styleResult.result.value) {
432
+ // Use stable selector path as element ID instead of unstable index
433
+ // Deduplicate: if key already exists, append numeric suffix to prevent overwriting
434
+ let uniqueKey = element.selectorPath;
435
+ if (computedStyles[uniqueKey]) {
436
+ let suffix = 2;
437
+ while (computedStyles[`${element.selectorPath}[${suffix}]`]) {
438
+ suffix++;
439
+ }
440
+ uniqueKey = `${element.selectorPath}[${suffix}]`;
441
+ }
442
+ computedStyles[uniqueKey] = styleResult.result.value as Record<string, string>;
443
+ }
444
+ } catch (err) {
445
+ // Skip elements that fail
446
+ continue;
447
+ }
448
+ }
449
+ }
450
+
451
+ // Disable domains and close session
452
+ await cdpSession.send('CSS.disable');
453
+ await cdpSession.send('DOM.disable');
454
+ await cdpSession.detach();
455
+
456
+ // Save computed styles to file
457
+ await fs.writeJson(stylesPath, {
458
+ computed_styles: computedStyles,
459
+ timestamp: Date.now(),
460
+ sample_size: Object.keys(computedStyles).length
461
+ });
462
+
463
+ if (process.env.TESTIVAI_DEBUG === 'true') {
464
+ console.log(`[TestivAI] Captured ${Object.keys(computedStyles).length} element styles`);
465
+ }
466
+ } catch (error) {
467
+ console.warn('[TestivAI] Failed to capture CSS via CDP:', error);
468
+ // Continue without CSS data
469
+ }
470
+
471
+ // 3. Extract bounding boxes for requested selectors
472
+ const selectors = effectiveConfig.selectors ?? ['body'];
473
+ const layout: Record<string, LayoutData> = {};
474
+
475
+ for (const selector of selectors) {
476
+ const element = page.locator(selector).first();
477
+ const boundingBox = await element.boundingBox();
478
+ if (boundingBox) {
479
+ layout[selector] = {
480
+ ...boundingBox,
481
+ top: boundingBox.y,
482
+ left: boundingBox.x,
483
+ right: boundingBox.x + boundingBox.width,
484
+ bottom: boundingBox.y + boundingBox.height,
485
+ };
486
+ }
487
+ }
488
+
489
+ // 4. Capture performance metrics using CDP (if enabled)
490
+ let performanceMetrics: any = undefined;
491
+
492
+ const metricsEnabled = effectiveConfig.performanceMetrics?.enabled ?? true; // Default: enabled
493
+
494
+ if (metricsEnabled) {
495
+ try {
496
+ // Get CDP session from Playwright page
497
+ const cdpSession = await page.context().newCDPSession(page);
498
+
499
+ // Enable Performance domain
500
+ await cdpSession.send('Performance.enable');
501
+
502
+ // Get CDP performance metrics
503
+ const cdpMetrics = await cdpSession.send('Performance.getMetrics');
504
+
505
+ // Convert metrics array to object
506
+ const cdpMetricsObj: any = {};
507
+ cdpMetrics.metrics.forEach((metric: any) => {
508
+ cdpMetricsObj[metric.name] = metric.value;
509
+ });
510
+
511
+ // Get navigation timing and Web Vitals via page.evaluate
512
+ const timingData = await page.evaluate(() => {
513
+ const timing = window.performance.timing;
514
+ const navigation = window.performance.navigation;
515
+
516
+ // Get paint entries
517
+ const paintEntries = window.performance.getEntriesByType('paint');
518
+ const fcp = paintEntries.find(e => e.name === 'first-contentful-paint')?.startTime;
519
+
520
+ // Get LCP
521
+ const lcpEntries = window.performance.getEntriesByType('largest-contentful-paint');
522
+ const lcp = lcpEntries[lcpEntries.length - 1]?.startTime;
523
+
524
+ // Get CLS
525
+ let cls = 0;
526
+ try {
527
+ const clsEntries = window.performance.getEntriesByType('layout-shift');
528
+ clsEntries.forEach((entry: any) => {
529
+ if (!entry.hadRecentInput) {
530
+ cls += entry.value;
531
+ }
532
+ });
533
+ } catch (e) {
534
+ // CLS might not be available
535
+ }
536
+
537
+ // Get FID (requires PerformanceObserver)
538
+ let fid = null;
539
+ try {
540
+ const fidEntries = window.performance.getEntriesByType('first-input');
541
+ if (fidEntries.length > 0) {
542
+ fid = (fidEntries[0] as any).processingStart - (fidEntries[0] as any).startTime;
543
+ }
544
+ } catch (e) {
545
+ // FID might not be available
546
+ }
547
+
548
+ return {
549
+ navigation: {
550
+ type: navigation.type,
551
+ redirectCount: navigation.redirectCount
552
+ },
553
+ timing: {
554
+ navigationStart: timing.navigationStart,
555
+ unloadEventStart: timing.unloadEventStart,
556
+ unloadEventEnd: timing.unloadEventEnd,
557
+ redirectStart: timing.redirectStart,
558
+ redirectEnd: timing.redirectEnd,
559
+ fetchStart: timing.fetchStart,
560
+ domainLookupStart: timing.domainLookupStart,
561
+ domainLookupEnd: timing.domainLookupEnd,
562
+ connectStart: timing.connectStart,
563
+ connectEnd: timing.connectEnd,
564
+ secureConnectionStart: timing.secureConnectionStart,
565
+ requestStart: timing.requestStart,
566
+ responseStart: timing.responseStart,
567
+ responseEnd: timing.responseEnd,
568
+ domLoading: timing.domLoading,
569
+ domInteractive: timing.domInteractive,
570
+ domContentLoadedEventStart: timing.domContentLoadedEventStart,
571
+ domContentLoadedEventEnd: timing.domContentLoadedEventEnd,
572
+ domComplete: timing.domComplete,
573
+ loadEventStart: timing.loadEventStart,
574
+ loadEventEnd: timing.loadEventEnd
575
+ },
576
+ webVitals: {
577
+ firstContentfulPaint: fcp,
578
+ largestContentfulPaint: lcp,
579
+ cumulativeLayoutShift: cls,
580
+ firstInputDelay: fid
581
+ }
582
+ };
583
+ });
584
+
585
+ // Disable Performance domain
586
+ await cdpSession.send('Performance.disable');
587
+ await cdpSession.detach();
588
+
589
+ // Structure identical to CDP SDK
590
+ performanceMetrics = {
591
+ cdp: cdpMetricsObj,
592
+ timing: timingData,
593
+ timestamp: Date.now()
594
+ };
595
+ } catch (err) {
596
+ console.warn('Failed to capture performance metrics:', err);
597
+ }
598
+ }
599
+
600
+ // 5. Structure analysis is now handled on the backend
601
+ // The SDK just captures the HTML and sends it with the configuration
602
+ // @renamed: domAnalysis → structureAnalysis (IP protection)
603
+ const structureAnalysis = undefined; // Will be populated by backend
604
+
605
+ // 6. Save metadata with configuration and performance data
606
+ const metadataPath = path.join(outputDir, `${baseFilename}.json`);
607
+ const metadata: Partial<SnapshotPayload> = {
608
+ snapshotName,
609
+ testName: testInfo.title,
610
+ timestamp,
611
+ url: page.url(),
612
+ viewport: page.viewportSize() || undefined,
613
+ };
614
+
615
+ await fs.writeJson(metadataPath, {
616
+ ...metadata,
617
+ files: {
618
+ screenshot: screenshotPath,
619
+ // @renamed: dom → structure, css → styles (IP protection)
620
+ structure: structurePath,
621
+ styles: stylesPath,
622
+ },
623
+ layout,
624
+ // Store the effective configuration for the reporter
625
+ testivaiConfig: effectiveConfig,
626
+ // Store unified performance metrics if captured
627
+ performanceMetrics,
628
+ // Store structure analysis if captured
629
+ // @renamed: domAnalysis → structureAnalysis (IP protection)
630
+ structureAnalysis
631
+ });
632
+ }