@testivai/witness-playwright 1.9.0 → 2.0.1

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.
@@ -1,5 +1,5 @@
1
- import { Page, TestInfo } from '@playwright/test';
2
- import { TestivAIConfig } from './types';
1
+ import type { Page, TestInfo } from '@playwright/test';
2
+ import type { TestivAIConfig } from './types';
3
3
  /**
4
4
  * Compute the effective snapshot name, folding in a project variant when the
5
5
  * Playwright config runs multiple projects. Without this, two projects (e.g.
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.
@@ -384,7 +377,7 @@ async function snapshot(page, testInfo, name, config) {
384
377
  stabilizeStyleEl = null;
385
378
  await (0, stabilize_1.stopSettleObserver)(page);
386
379
  }
387
- // 1.5. Local mode: also place the screenshot in the layout expected by
380
+ // 1.5. Place the screenshot in the layout expected by
388
381
  // @testivai/witness/report (subdirectory keyed by snapshot name).
389
382
  // This is what `BaselineStore.listTemp()` and `compareAll()` enumerate.
390
383
  //
@@ -393,7 +386,7 @@ async function snapshot(page, testInfo, name, config) {
393
386
  // ("pixels differ but DOM is unchanged → likely render noise"). DOM
394
387
  // capture is wrapped in try/catch so a flaky page never breaks the
395
388
  // screenshot path; missing dom.html simply suppresses the hint.
396
- if (isLocalMode) {
389
+ {
397
390
  const localSnapshotDir = path.join(outputDir, snapshotName);
398
391
  await fs.ensureDir(localSnapshotDir);
399
392
  // Move (not copy) the flat capture into the canonical <name>/ layout so
@@ -410,7 +403,7 @@ async function snapshot(page, testInfo, name, config) {
410
403
  const clone = document.documentElement.cloneNode(true);
411
404
  for (const sel of selectors) {
412
405
  try {
413
- clone.querySelectorAll(sel).forEach((el) => el.remove());
406
+ clone.querySelectorAll(sel).forEach((el) => { el.remove(); });
414
407
  }
415
408
  catch { }
416
409
  }
@@ -495,310 +488,6 @@ async function snapshot(page, testInfo, name, config) {
495
488
  // missing elements.json only disables attribution for this capture
496
489
  }
497
490
  }
498
- // 2. Dump page structure (HTML) - skip in local mode
499
- // @renamed: domPath → structurePath (IP protection)
500
- let structurePath = '';
501
- if (!isLocalMode) {
502
- structurePath = path.join(outputDir, `${baseFilename}.html`);
503
- const htmlContent = await page.content();
504
- await fs.writeFile(structurePath, htmlContent);
505
- }
506
- // 2.5. Capture computed styles using browser session - skip in local mode
507
- // @renamed: cssPath → stylesPath (IP protection)
508
- let stylesPath = '';
509
- if (!isLocalMode) {
510
- stylesPath = path.join(outputDir, `${baseFilename}.css.json`);
511
- try {
512
- const browserSession = await page.context().newCDPSession(page);
513
- // Enable DOM and CSS domains
514
- await browserSession.send('DOM.enable');
515
- await browserSession.send('CSS.enable');
516
- // Get all elements and their computed styles
517
- const computedStyles = {};
518
- // Visual properties we care about
519
- const visualProperties = [
520
- 'color', 'background-color', 'background-image',
521
- 'font-size', 'font-weight', 'font-family',
522
- 'width', 'height', 'padding', 'margin',
523
- 'border', 'border-radius', 'box-shadow',
524
- 'display', 'position', 'top', 'left', 'right', 'bottom',
525
- 'transform', 'opacity', 'visibility', 'z-index'
526
- ];
527
- // Execute script to get all elements with unique identifiers
528
- const elementsData = await browserSession.send('Runtime.evaluate', {
529
- expression: `
530
- (function() {
531
- // Helper to get stable CSS selector path for an element
532
- function getElementPath(element) {
533
- if (element.id) {
534
- return '#' + element.id;
535
- }
536
-
537
- const path = [];
538
- let current = element;
539
-
540
- while (current && current !== document.body) {
541
- let selector = current.tagName.toLowerCase();
542
-
543
- // Add up to 3 CSS classes for better uniqueness
544
- // e.g., button.button.primary-button instead of just button.button
545
- if (current.className && typeof current.className === 'string') {
546
- const classes = current.className.trim().split(/\\s+/).filter(Boolean);
547
- const maxClasses = Math.min(classes.length, 3);
548
- for (let c = 0; c < maxClasses; c++) {
549
- selector += '.' + classes[c];
550
- }
551
- }
552
-
553
- // Get nth-child position for uniqueness
554
- if (current.parentNode) {
555
- const siblings = Array.from(current.parentNode.children);
556
- const sameTagSiblings = siblings.filter(s => s.tagName === current.tagName);
557
- if (sameTagSiblings.length > 1) {
558
- const index = sameTagSiblings.indexOf(current) + 1;
559
- selector += \`:nth-of-type(\${index})\`;
560
- }
561
- }
562
-
563
- path.unshift(selector);
564
- current = current.parentElement;
565
- }
566
-
567
- return path.join(' > ');
568
- }
569
-
570
- const elements = document.querySelectorAll('*');
571
- const result = [];
572
- elements.forEach((el, index) => {
573
- const selectorPath = getElementPath(el);
574
- const tagName = el.tagName.toLowerCase();
575
- const className = el.className || '';
576
- result.push({
577
- selectorPath,
578
- tagName,
579
- className,
580
- index
581
- });
582
- });
583
- return result;
584
- })()
585
- `,
586
- returnByValue: true
587
- });
588
- if (elementsData.result.value) {
589
- const elements = elementsData.result.value;
590
- // Get computed styles for each element (sample first 100 to avoid performance issues)
591
- const sampleSize = Math.min(elements.length, 100);
592
- for (let i = 0; i < sampleSize; i++) {
593
- const element = elements[i];
594
- try {
595
- const styleResult = await browserSession.send('Runtime.evaluate', {
596
- expression: `
597
- (function() {
598
- const el = document.querySelectorAll('*')[${element.index}];
599
- if (!el) return null;
600
- const styles = window.getComputedStyle(el);
601
- const result = {};
602
- ${JSON.stringify(visualProperties)}.forEach(prop => {
603
- result[prop] = styles.getPropertyValue(prop);
604
- });
605
- return result;
606
- })()
607
- `,
608
- returnByValue: true
609
- });
610
- if (styleResult.result.value) {
611
- // Use stable selector path as element ID instead of unstable index
612
- // Deduplicate: if key already exists, append numeric suffix to prevent overwriting
613
- let uniqueKey = element.selectorPath;
614
- if (computedStyles[uniqueKey]) {
615
- let suffix = 2;
616
- while (computedStyles[`${element.selectorPath}[${suffix}]`]) {
617
- suffix++;
618
- }
619
- uniqueKey = `${element.selectorPath}[${suffix}]`;
620
- }
621
- computedStyles[uniqueKey] = styleResult.result.value;
622
- }
623
- }
624
- catch (err) {
625
- // Skip elements that fail
626
- continue;
627
- }
628
- }
629
- }
630
- // Disable domains and close session
631
- await browserSession.send('CSS.disable');
632
- await browserSession.send('DOM.disable');
633
- await browserSession.detach();
634
- // Save computed styles to file
635
- await fs.writeJson(stylesPath, {
636
- computed_styles: computedStyles,
637
- timestamp: Date.now(),
638
- sample_size: Object.keys(computedStyles).length
639
- });
640
- if (process.env.TESTIVAI_DEBUG === 'true') {
641
- console.log(`[TestivAI] Captured ${Object.keys(computedStyles).length} element styles`);
642
- }
643
- }
644
- catch (error) {
645
- console.warn('[TestivAI] Failed to capture CSS via browser session:', error);
646
- // Continue without CSS data
647
- }
648
- }
649
- // 3. Extract bounding boxes for requested selectors - skip in local mode
650
- const selectors = effectiveConfig.selectors ?? ['body'];
651
- const layout = {};
652
- if (!isLocalMode) {
653
- for (const selector of selectors) {
654
- const element = page.locator(selector).first();
655
- const boundingBox = await element.boundingBox();
656
- if (boundingBox) {
657
- layout[selector] = {
658
- ...boundingBox,
659
- top: boundingBox.y,
660
- left: boundingBox.x,
661
- right: boundingBox.x + boundingBox.width,
662
- bottom: boundingBox.y + boundingBox.height,
663
- };
664
- }
665
- }
666
- }
667
- // 4. Capture performance metrics using browser session (if enabled) - skip in local mode
668
- let performanceMetrics = undefined;
669
- const metricsEnabled = effectiveConfig.performanceMetrics?.enabled ?? true; // Default: enabled
670
- if (metricsEnabled && !isLocalMode) {
671
- try {
672
- // Get browser session from Playwright page
673
- const browserSession = await page.context().newCDPSession(page);
674
- // Enable Performance domain
675
- await browserSession.send('Performance.enable');
676
- // Get browser performance metrics
677
- const browserMetrics = await browserSession.send('Performance.getMetrics');
678
- // Convert metrics array to object
679
- const browserMetricsObj = {};
680
- browserMetrics.metrics.forEach((metric) => {
681
- browserMetricsObj[metric.name] = metric.value;
682
- });
683
- // Get navigation timing and Web Vitals via page.evaluate
684
- const timingData = await page.evaluate(() => {
685
- const timing = window.performance.timing;
686
- const navigation = window.performance.navigation;
687
- // Get paint entries
688
- const paintEntries = window.performance.getEntriesByType('paint');
689
- const fcp = paintEntries.find(e => e.name === 'first-contentful-paint')?.startTime;
690
- // Get LCP
691
- const lcpEntries = window.performance.getEntriesByType('largest-contentful-paint');
692
- const lcp = lcpEntries[lcpEntries.length - 1]?.startTime;
693
- // Get CLS
694
- let cls = 0;
695
- try {
696
- const clsEntries = window.performance.getEntriesByType('layout-shift');
697
- clsEntries.forEach((entry) => {
698
- if (!entry.hadRecentInput) {
699
- cls += entry.value;
700
- }
701
- });
702
- }
703
- catch (e) {
704
- // CLS might not be available
705
- }
706
- // Get FID (requires PerformanceObserver)
707
- let fid = null;
708
- try {
709
- const fidEntries = window.performance.getEntriesByType('first-input');
710
- if (fidEntries.length > 0) {
711
- fid = fidEntries[0].processingStart - fidEntries[0].startTime;
712
- }
713
- }
714
- catch (e) {
715
- // FID might not be available
716
- }
717
- return {
718
- navigation: {
719
- type: navigation.type,
720
- redirectCount: navigation.redirectCount
721
- },
722
- timing: {
723
- navigationStart: timing.navigationStart,
724
- unloadEventStart: timing.unloadEventStart,
725
- unloadEventEnd: timing.unloadEventEnd,
726
- redirectStart: timing.redirectStart,
727
- redirectEnd: timing.redirectEnd,
728
- fetchStart: timing.fetchStart,
729
- domainLookupStart: timing.domainLookupStart,
730
- domainLookupEnd: timing.domainLookupEnd,
731
- connectStart: timing.connectStart,
732
- connectEnd: timing.connectEnd,
733
- secureConnectionStart: timing.secureConnectionStart,
734
- requestStart: timing.requestStart,
735
- responseStart: timing.responseStart,
736
- responseEnd: timing.responseEnd,
737
- domLoading: timing.domLoading,
738
- domInteractive: timing.domInteractive,
739
- domContentLoadedEventStart: timing.domContentLoadedEventStart,
740
- domContentLoadedEventEnd: timing.domContentLoadedEventEnd,
741
- domComplete: timing.domComplete,
742
- loadEventStart: timing.loadEventStart,
743
- loadEventEnd: timing.loadEventEnd
744
- },
745
- webVitals: {
746
- firstContentfulPaint: fcp,
747
- largestContentfulPaint: lcp,
748
- cumulativeLayoutShift: cls,
749
- firstInputDelay: fid
750
- }
751
- };
752
- });
753
- // Disable Performance domain
754
- await browserSession.send('Performance.disable');
755
- await browserSession.detach();
756
- // Structure identical to Witness SDK
757
- performanceMetrics = {
758
- cdp: browserMetricsObj,
759
- timing: timingData,
760
- timestamp: Date.now()
761
- };
762
- }
763
- catch (err) {
764
- console.warn('Failed to capture performance metrics:', err);
765
- }
766
- }
767
- // 5. Structure analysis is now handled on the backend - skip in local mode
768
- // The SDK just captures the HTML and sends it with the configuration
769
- // @renamed: domAnalysis → structureAnalysis (IP protection)
770
- const structureAnalysis = isLocalMode ? undefined : undefined; // Will be populated by backend
771
- // 6. Save metadata with configuration and performance data.
772
- // Cloud mode only: the flat <timestamp>_<name>.json is the upload
773
- // manifest the reporter reads. Local mode has no flat files — its report
774
- // is generated from the canonical .testivai/temp/<name>/ directory.
775
- if (!isLocalMode) {
776
- const metadataPath = path.join(outputDir, `${baseFilename}.json`);
777
- const metadata = {
778
- snapshotName,
779
- testName: testInfo.title,
780
- timestamp,
781
- url: page.url(),
782
- viewport: page.viewportSize() || undefined,
783
- };
784
- await fs.writeJson(metadataPath, {
785
- ...metadata,
786
- files: {
787
- screenshot: screenshotPath,
788
- // @renamed: dom → structure, css → styles (IP protection)
789
- structure: structurePath,
790
- styles: stylesPath,
791
- },
792
- layout,
793
- // Store the effective configuration for the reporter
794
- testivaiConfig: effectiveConfig,
795
- // Store unified performance metrics if captured
796
- performanceMetrics,
797
- // Store structure analysis if captured
798
- // @renamed: domAnalysis → structureAnalysis (IP protection)
799
- structureAnalysis
800
- });
801
- }
802
491
  }
803
492
  /**
804
493
  * Read the global `mask` list from .testivai/config.json (selector strings