@testivai/witness-playwright 1.8.0 → 2.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.
package/dist/snapshot.js CHANGED
@@ -46,7 +46,6 @@ const loader_1 = require("./config/loader");
46
46
  const ignore_selectors_1 = require("./config/ignore-selectors");
47
47
  const element_map_1 = require("./capture/element-map");
48
48
  const stabilize_1 = require("./config/stabilize");
49
- const mode_1 = require("./mode");
50
49
  /**
51
50
  * Generates a safe filename from a URL.
52
51
  * @param pageUrl The URL of the page.
@@ -62,7 +61,7 @@ function getSnapshotNameFromUrl(pageUrl) {
62
61
  const pathName = url.pathname.substring(1).replace(/\//g, '_'); // remove leading slash and replace others
63
62
  return pathName || 'home';
64
63
  }
65
- catch (error) {
64
+ catch (_error) {
66
65
  // Fallback for invalid URLs
67
66
  return 'snapshot';
68
67
  }
@@ -98,11 +97,6 @@ function effectiveSnapshotName(baseName, testInfo) {
98
97
  * @param config Optional TestivAI configuration for this snapshot (overrides project defaults).
99
98
  */
100
99
  async function snapshot(page, testInfo, name, config) {
101
- // Check for local mode - only capture screenshots, skip heavy data.
102
- // Resolved from the shell env + config so it agrees with the reporter even
103
- // though this runs in a Playwright worker (the reporter's runtime env writes
104
- // never reach workers). Default: local unless a TESTIVAI_API_KEY is present.
105
- const isLocalMode = (0, mode_1.resolveLocalMode)();
106
100
  // Load project configuration and merge with test-specific overrides
107
101
  const projectConfig = await (0, loader_1.loadConfig)();
108
102
  const effectiveConfig = (0, loader_1.mergeTestConfig)(projectConfig, config);
@@ -111,8 +105,7 @@ async function snapshot(page, testInfo, name, config) {
111
105
  console.log('[TestivAI] Config:', {
112
106
  projectConfig,
113
107
  testConfig: config,
114
- effectiveConfig,
115
- isLocalMode
108
+ effectiveConfig
116
109
  });
117
110
  }
118
111
  const outputDir = path.join(process.cwd(), '.testivai', 'temp');
@@ -123,14 +116,14 @@ async function snapshot(page, testInfo, name, config) {
123
116
  const baseFilename = `${timestamp}_${safeName}`;
124
117
  // 1. Capture full-page screenshot
125
118
  const screenshotPath = path.join(outputDir, `${baseFilename}.png`);
126
- // In local mode: hide elements matching ignoreSelectors before the screenshot
127
- // so dynamic content (version badges, timestamps, ads) doesn't cause false diffs.
119
+ // Hide elements matching ignoreSelectors before the screenshot so dynamic
120
+ // content (version badges, timestamps, ads) doesn't cause false diffs.
128
121
  // Sources (merged, deduped) — see config/ignore-selectors.ts:
129
- // 1. .testivai/config.json → ignoreSelectors (global, OSS config)
122
+ // 1. .testivai/config.json → ignoreSelectors (global config)
130
123
  // 2. testivai.config.ts → ignoreSelectors (global, power users)
131
124
  // 3. testivai.witness(...) → { ignoreSelectors } (per-snapshot override)
132
125
  let ignoreStyleEl = null;
133
- if (isLocalMode) {
126
+ {
134
127
  // Rules carry per-selector mode: mask (visibility:hidden, layout kept) or
135
128
  // collapse (display:none, layout removed — fixes variable-height shift).
136
129
  const ignoreRules = (0, ignore_selectors_1.collectIgnoreRules)(process.cwd(), projectConfig, effectiveConfig);
@@ -139,7 +132,7 @@ async function snapshot(page, testInfo, name, config) {
139
132
  ignoreStyleEl = await page.addStyleTag({ content: css });
140
133
  }
141
134
  }
142
- // Stabilize the page before capture (all modes): freeze CSS animations and
135
+ // Stabilize the page before capture: freeze CSS animations and
143
136
  // transitions, hide the caret, and wait for web fonts — the top sources of
144
137
  // flaky pixel diffs. Injected as CSS so every capture path (native
145
138
  // screenshot, CDP captureScreenshot, scroll-and-stitch) is covered.
@@ -152,6 +145,10 @@ async function snapshot(page, testInfo, name, config) {
152
145
  // Style injection can fail on locked-down pages; capture proceeds
153
146
  }
154
147
  await (0, stabilize_1.waitForFonts)(page);
148
+ // Then wait for the page itself to stop changing — images finished, DOM
149
+ // quiet. This is the load question the DOM/style layer cannot answer:
150
+ // content that never arrived reads as a real change, not as noise.
151
+ await (0, stabilize_1.waitForSettled)(page);
155
152
  }
156
153
  // Check if scroll-and-stitch is explicitly requested (backup method)
157
154
  if (effectiveConfig.useBrowserCapture === false) {
@@ -378,8 +375,9 @@ async function snapshot(page, testInfo, name, config) {
378
375
  if (stabilizeStyleEl) {
379
376
  await stabilizeStyleEl.evaluate((el) => el.remove()).catch(() => { });
380
377
  stabilizeStyleEl = null;
378
+ await (0, stabilize_1.stopSettleObserver)(page);
381
379
  }
382
- // 1.5. Local mode: also place the screenshot in the layout expected by
380
+ // 1.5. Place the screenshot in the layout expected by
383
381
  // @testivai/witness/report (subdirectory keyed by snapshot name).
384
382
  // This is what `BaselineStore.listTemp()` and `compareAll()` enumerate.
385
383
  //
@@ -388,7 +386,7 @@ async function snapshot(page, testInfo, name, config) {
388
386
  // ("pixels differ but DOM is unchanged → likely render noise"). DOM
389
387
  // capture is wrapped in try/catch so a flaky page never breaks the
390
388
  // screenshot path; missing dom.html simply suppresses the hint.
391
- if (isLocalMode) {
389
+ {
392
390
  const localSnapshotDir = path.join(outputDir, snapshotName);
393
391
  await fs.ensureDir(localSnapshotDir);
394
392
  // Move (not copy) the flat capture into the canonical <name>/ layout so
@@ -405,7 +403,7 @@ async function snapshot(page, testInfo, name, config) {
405
403
  const clone = document.documentElement.cloneNode(true);
406
404
  for (const sel of selectors) {
407
405
  try {
408
- clone.querySelectorAll(sel).forEach((el) => el.remove());
406
+ clone.querySelectorAll(sel).forEach((el) => { el.remove(); });
409
407
  }
410
408
  catch { }
411
409
  }
@@ -490,310 +488,6 @@ async function snapshot(page, testInfo, name, config) {
490
488
  // missing elements.json only disables attribution for this capture
491
489
  }
492
490
  }
493
- // 2. Dump page structure (HTML) - skip in local mode
494
- // @renamed: domPath → structurePath (IP protection)
495
- let structurePath = '';
496
- if (!isLocalMode) {
497
- structurePath = path.join(outputDir, `${baseFilename}.html`);
498
- const htmlContent = await page.content();
499
- await fs.writeFile(structurePath, htmlContent);
500
- }
501
- // 2.5. Capture computed styles using browser session - skip in local mode
502
- // @renamed: cssPath → stylesPath (IP protection)
503
- let stylesPath = '';
504
- if (!isLocalMode) {
505
- stylesPath = path.join(outputDir, `${baseFilename}.css.json`);
506
- try {
507
- const browserSession = await page.context().newCDPSession(page);
508
- // Enable DOM and CSS domains
509
- await browserSession.send('DOM.enable');
510
- await browserSession.send('CSS.enable');
511
- // Get all elements and their computed styles
512
- const computedStyles = {};
513
- // Visual properties we care about
514
- const visualProperties = [
515
- 'color', 'background-color', 'background-image',
516
- 'font-size', 'font-weight', 'font-family',
517
- 'width', 'height', 'padding', 'margin',
518
- 'border', 'border-radius', 'box-shadow',
519
- 'display', 'position', 'top', 'left', 'right', 'bottom',
520
- 'transform', 'opacity', 'visibility', 'z-index'
521
- ];
522
- // Execute script to get all elements with unique identifiers
523
- const elementsData = await browserSession.send('Runtime.evaluate', {
524
- expression: `
525
- (function() {
526
- // Helper to get stable CSS selector path for an element
527
- function getElementPath(element) {
528
- if (element.id) {
529
- return '#' + element.id;
530
- }
531
-
532
- const path = [];
533
- let current = element;
534
-
535
- while (current && current !== document.body) {
536
- let selector = current.tagName.toLowerCase();
537
-
538
- // Add up to 3 CSS classes for better uniqueness
539
- // e.g., button.button.primary-button instead of just button.button
540
- if (current.className && typeof current.className === 'string') {
541
- const classes = current.className.trim().split(/\\s+/).filter(Boolean);
542
- const maxClasses = Math.min(classes.length, 3);
543
- for (let c = 0; c < maxClasses; c++) {
544
- selector += '.' + classes[c];
545
- }
546
- }
547
-
548
- // Get nth-child position for uniqueness
549
- if (current.parentNode) {
550
- const siblings = Array.from(current.parentNode.children);
551
- const sameTagSiblings = siblings.filter(s => s.tagName === current.tagName);
552
- if (sameTagSiblings.length > 1) {
553
- const index = sameTagSiblings.indexOf(current) + 1;
554
- selector += \`:nth-of-type(\${index})\`;
555
- }
556
- }
557
-
558
- path.unshift(selector);
559
- current = current.parentElement;
560
- }
561
-
562
- return path.join(' > ');
563
- }
564
-
565
- const elements = document.querySelectorAll('*');
566
- const result = [];
567
- elements.forEach((el, index) => {
568
- const selectorPath = getElementPath(el);
569
- const tagName = el.tagName.toLowerCase();
570
- const className = el.className || '';
571
- result.push({
572
- selectorPath,
573
- tagName,
574
- className,
575
- index
576
- });
577
- });
578
- return result;
579
- })()
580
- `,
581
- returnByValue: true
582
- });
583
- if (elementsData.result.value) {
584
- const elements = elementsData.result.value;
585
- // Get computed styles for each element (sample first 100 to avoid performance issues)
586
- const sampleSize = Math.min(elements.length, 100);
587
- for (let i = 0; i < sampleSize; i++) {
588
- const element = elements[i];
589
- try {
590
- const styleResult = await browserSession.send('Runtime.evaluate', {
591
- expression: `
592
- (function() {
593
- const el = document.querySelectorAll('*')[${element.index}];
594
- if (!el) return null;
595
- const styles = window.getComputedStyle(el);
596
- const result = {};
597
- ${JSON.stringify(visualProperties)}.forEach(prop => {
598
- result[prop] = styles.getPropertyValue(prop);
599
- });
600
- return result;
601
- })()
602
- `,
603
- returnByValue: true
604
- });
605
- if (styleResult.result.value) {
606
- // Use stable selector path as element ID instead of unstable index
607
- // Deduplicate: if key already exists, append numeric suffix to prevent overwriting
608
- let uniqueKey = element.selectorPath;
609
- if (computedStyles[uniqueKey]) {
610
- let suffix = 2;
611
- while (computedStyles[`${element.selectorPath}[${suffix}]`]) {
612
- suffix++;
613
- }
614
- uniqueKey = `${element.selectorPath}[${suffix}]`;
615
- }
616
- computedStyles[uniqueKey] = styleResult.result.value;
617
- }
618
- }
619
- catch (err) {
620
- // Skip elements that fail
621
- continue;
622
- }
623
- }
624
- }
625
- // Disable domains and close session
626
- await browserSession.send('CSS.disable');
627
- await browserSession.send('DOM.disable');
628
- await browserSession.detach();
629
- // Save computed styles to file
630
- await fs.writeJson(stylesPath, {
631
- computed_styles: computedStyles,
632
- timestamp: Date.now(),
633
- sample_size: Object.keys(computedStyles).length
634
- });
635
- if (process.env.TESTIVAI_DEBUG === 'true') {
636
- console.log(`[TestivAI] Captured ${Object.keys(computedStyles).length} element styles`);
637
- }
638
- }
639
- catch (error) {
640
- console.warn('[TestivAI] Failed to capture CSS via browser session:', error);
641
- // Continue without CSS data
642
- }
643
- }
644
- // 3. Extract bounding boxes for requested selectors - skip in local mode
645
- const selectors = effectiveConfig.selectors ?? ['body'];
646
- const layout = {};
647
- if (!isLocalMode) {
648
- for (const selector of selectors) {
649
- const element = page.locator(selector).first();
650
- const boundingBox = await element.boundingBox();
651
- if (boundingBox) {
652
- layout[selector] = {
653
- ...boundingBox,
654
- top: boundingBox.y,
655
- left: boundingBox.x,
656
- right: boundingBox.x + boundingBox.width,
657
- bottom: boundingBox.y + boundingBox.height,
658
- };
659
- }
660
- }
661
- }
662
- // 4. Capture performance metrics using browser session (if enabled) - skip in local mode
663
- let performanceMetrics = undefined;
664
- const metricsEnabled = effectiveConfig.performanceMetrics?.enabled ?? true; // Default: enabled
665
- if (metricsEnabled && !isLocalMode) {
666
- try {
667
- // Get browser session from Playwright page
668
- const browserSession = await page.context().newCDPSession(page);
669
- // Enable Performance domain
670
- await browserSession.send('Performance.enable');
671
- // Get browser performance metrics
672
- const browserMetrics = await browserSession.send('Performance.getMetrics');
673
- // Convert metrics array to object
674
- const browserMetricsObj = {};
675
- browserMetrics.metrics.forEach((metric) => {
676
- browserMetricsObj[metric.name] = metric.value;
677
- });
678
- // Get navigation timing and Web Vitals via page.evaluate
679
- const timingData = await page.evaluate(() => {
680
- const timing = window.performance.timing;
681
- const navigation = window.performance.navigation;
682
- // Get paint entries
683
- const paintEntries = window.performance.getEntriesByType('paint');
684
- const fcp = paintEntries.find(e => e.name === 'first-contentful-paint')?.startTime;
685
- // Get LCP
686
- const lcpEntries = window.performance.getEntriesByType('largest-contentful-paint');
687
- const lcp = lcpEntries[lcpEntries.length - 1]?.startTime;
688
- // Get CLS
689
- let cls = 0;
690
- try {
691
- const clsEntries = window.performance.getEntriesByType('layout-shift');
692
- clsEntries.forEach((entry) => {
693
- if (!entry.hadRecentInput) {
694
- cls += entry.value;
695
- }
696
- });
697
- }
698
- catch (e) {
699
- // CLS might not be available
700
- }
701
- // Get FID (requires PerformanceObserver)
702
- let fid = null;
703
- try {
704
- const fidEntries = window.performance.getEntriesByType('first-input');
705
- if (fidEntries.length > 0) {
706
- fid = fidEntries[0].processingStart - fidEntries[0].startTime;
707
- }
708
- }
709
- catch (e) {
710
- // FID might not be available
711
- }
712
- return {
713
- navigation: {
714
- type: navigation.type,
715
- redirectCount: navigation.redirectCount
716
- },
717
- timing: {
718
- navigationStart: timing.navigationStart,
719
- unloadEventStart: timing.unloadEventStart,
720
- unloadEventEnd: timing.unloadEventEnd,
721
- redirectStart: timing.redirectStart,
722
- redirectEnd: timing.redirectEnd,
723
- fetchStart: timing.fetchStart,
724
- domainLookupStart: timing.domainLookupStart,
725
- domainLookupEnd: timing.domainLookupEnd,
726
- connectStart: timing.connectStart,
727
- connectEnd: timing.connectEnd,
728
- secureConnectionStart: timing.secureConnectionStart,
729
- requestStart: timing.requestStart,
730
- responseStart: timing.responseStart,
731
- responseEnd: timing.responseEnd,
732
- domLoading: timing.domLoading,
733
- domInteractive: timing.domInteractive,
734
- domContentLoadedEventStart: timing.domContentLoadedEventStart,
735
- domContentLoadedEventEnd: timing.domContentLoadedEventEnd,
736
- domComplete: timing.domComplete,
737
- loadEventStart: timing.loadEventStart,
738
- loadEventEnd: timing.loadEventEnd
739
- },
740
- webVitals: {
741
- firstContentfulPaint: fcp,
742
- largestContentfulPaint: lcp,
743
- cumulativeLayoutShift: cls,
744
- firstInputDelay: fid
745
- }
746
- };
747
- });
748
- // Disable Performance domain
749
- await browserSession.send('Performance.disable');
750
- await browserSession.detach();
751
- // Structure identical to Witness SDK
752
- performanceMetrics = {
753
- cdp: browserMetricsObj,
754
- timing: timingData,
755
- timestamp: Date.now()
756
- };
757
- }
758
- catch (err) {
759
- console.warn('Failed to capture performance metrics:', err);
760
- }
761
- }
762
- // 5. Structure analysis is now handled on the backend - skip in local mode
763
- // The SDK just captures the HTML and sends it with the configuration
764
- // @renamed: domAnalysis → structureAnalysis (IP protection)
765
- const structureAnalysis = isLocalMode ? undefined : undefined; // Will be populated by backend
766
- // 6. Save metadata with configuration and performance data.
767
- // Cloud mode only: the flat <timestamp>_<name>.json is the upload
768
- // manifest the reporter reads. Local mode has no flat files — its report
769
- // is generated from the canonical .testivai/temp/<name>/ directory.
770
- if (!isLocalMode) {
771
- const metadataPath = path.join(outputDir, `${baseFilename}.json`);
772
- const metadata = {
773
- snapshotName,
774
- testName: testInfo.title,
775
- timestamp,
776
- url: page.url(),
777
- viewport: page.viewportSize() || undefined,
778
- };
779
- await fs.writeJson(metadataPath, {
780
- ...metadata,
781
- files: {
782
- screenshot: screenshotPath,
783
- // @renamed: dom → structure, css → styles (IP protection)
784
- structure: structurePath,
785
- styles: stylesPath,
786
- },
787
- layout,
788
- // Store the effective configuration for the reporter
789
- testivaiConfig: effectiveConfig,
790
- // Store unified performance metrics if captured
791
- performanceMetrics,
792
- // Store structure analysis if captured
793
- // @renamed: domAnalysis → structureAnalysis (IP protection)
794
- structureAnalysis
795
- });
796
- }
797
491
  }
798
492
  /**
799
493
  * Read the global `mask` list from .testivai/config.json (selector strings