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