@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.
- package/README.md +2 -2
- package/dist/config/ignore-selectors.d.ts +1 -1
- package/dist/config/loader.d.ts +5 -18
- package/dist/config/loader.js +18 -160
- package/dist/config/stabilize.d.ts +1 -1
- package/dist/index.d.ts +1 -3
- package/dist/index.js +0 -4
- package/dist/reporter.d.ts +2 -12
- package/dist/reporter.js +34 -231
- package/dist/snapshot.d.ts +2 -2
- package/dist/snapshot.js +10 -321
- package/dist/types.d.ts +10 -357
- package/dist/types.js +1 -6
- package/package.json +9 -15
- package/dist/ci.d.ts +0 -30
- package/dist/ci.js +0 -117
- package/dist/mode.d.ts +0 -21
- package/dist/mode.js +0 -84
- package/dist/structureAnalyzer.d.ts +0 -12
- package/dist/structureAnalyzer.js +0 -288
package/dist/snapshot.d.ts
CHANGED
|
@@ -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 (
|
|
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
|
-
//
|
|
127
|
-
//
|
|
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
|
|
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
|
-
|
|
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
|
|
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.
|
|
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
|
-
|
|
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
|