@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/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 +19 -1
- package/dist/config/stabilize.js +49 -0
- package/dist/index.d.ts +1 -3
- package/dist/index.js +0 -4
- package/dist/reporter.d.ts +2 -12
- package/dist/reporter.js +44 -257
- package/dist/snapshot.d.ts +2 -2
- package/dist/snapshot.js +15 -321
- package/dist/types.d.ts +10 -357
- package/dist/types.js +1 -6
- package/package.json +5 -11
- 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.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.
|
|
@@ -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.
|
|
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
|
-
|
|
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
|