@testivai/witness-playwright 1.3.3 → 1.5.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/dist/capture/element-map.d.ts +37 -0
- package/dist/capture/element-map.js +123 -0
- package/dist/config/loader.js +2 -1
- package/dist/snapshot.js +83 -0
- package/dist/types.d.ts +12 -0
- package/package.json +2 -2
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Element-map capture — the layout + computed-style digest that powers
|
|
3
|
+
* element attribution, shift classification, and the style fingerprint
|
|
4
|
+
* on the comparison side (@testivai/witness).
|
|
5
|
+
*
|
|
6
|
+
* `collectElementMap` is a SELF-CONTAINED function (no imports, no
|
|
7
|
+
* closures) so it can be serialized into the page with
|
|
8
|
+
* `(${collectElementMap.toString()})(document, window, max)` — and unit
|
|
9
|
+
* tested in Node against a duck-typed DOM. Keep it dependency-free.
|
|
10
|
+
*/
|
|
11
|
+
export interface CollectedElement {
|
|
12
|
+
path: string;
|
|
13
|
+
x: number;
|
|
14
|
+
y: number;
|
|
15
|
+
width: number;
|
|
16
|
+
height: number;
|
|
17
|
+
styleHash: string;
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Walk the document and produce [{path, rect, styleHash}] in document
|
|
21
|
+
* coordinates (DPR-scaled to match full-page screenshot pixels).
|
|
22
|
+
*
|
|
23
|
+
* - `path` is deterministic: tag + first class + :nth-of-type when the
|
|
24
|
+
* element has same-tag element siblings.
|
|
25
|
+
* - Elements smaller than 4×4 px and invisible elements are skipped.
|
|
26
|
+
* - The walk is depth-first and capped at `maxElements`.
|
|
27
|
+
* - `styleHash` digests a fixed, documented list of computed properties
|
|
28
|
+
* (FNV-1a 32-bit, hex) — deterministic for identical rendering.
|
|
29
|
+
*/
|
|
30
|
+
export declare function collectElementMap(doc: Document, win: Window & typeof globalThis, maxElements: number): CollectedElement[];
|
|
31
|
+
/** Default element cap — bounds capture time and elements.json size. */
|
|
32
|
+
export declare const DEFAULT_MAX_ELEMENTS = 3000;
|
|
33
|
+
/**
|
|
34
|
+
* The page-side expression evaluated by the adapter. Serializes the
|
|
35
|
+
* collector so page code and unit-tested code are the same function.
|
|
36
|
+
*/
|
|
37
|
+
export declare function buildElementMapExpression(maxElements?: number): string;
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Element-map capture — the layout + computed-style digest that powers
|
|
4
|
+
* element attribution, shift classification, and the style fingerprint
|
|
5
|
+
* on the comparison side (@testivai/witness).
|
|
6
|
+
*
|
|
7
|
+
* `collectElementMap` is a SELF-CONTAINED function (no imports, no
|
|
8
|
+
* closures) so it can be serialized into the page with
|
|
9
|
+
* `(${collectElementMap.toString()})(document, window, max)` — and unit
|
|
10
|
+
* tested in Node against a duck-typed DOM. Keep it dependency-free.
|
|
11
|
+
*/
|
|
12
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
13
|
+
exports.DEFAULT_MAX_ELEMENTS = void 0;
|
|
14
|
+
exports.collectElementMap = collectElementMap;
|
|
15
|
+
exports.buildElementMapExpression = buildElementMapExpression;
|
|
16
|
+
/**
|
|
17
|
+
* Walk the document and produce [{path, rect, styleHash}] in document
|
|
18
|
+
* coordinates (DPR-scaled to match full-page screenshot pixels).
|
|
19
|
+
*
|
|
20
|
+
* - `path` is deterministic: tag + first class + :nth-of-type when the
|
|
21
|
+
* element has same-tag element siblings.
|
|
22
|
+
* - Elements smaller than 4×4 px and invisible elements are skipped.
|
|
23
|
+
* - The walk is depth-first and capped at `maxElements`.
|
|
24
|
+
* - `styleHash` digests a fixed, documented list of computed properties
|
|
25
|
+
* (FNV-1a 32-bit, hex) — deterministic for identical rendering.
|
|
26
|
+
*/
|
|
27
|
+
function collectElementMap(doc, win, maxElements) {
|
|
28
|
+
var STYLE_PROPS = [
|
|
29
|
+
'color', 'background-color', 'background-image',
|
|
30
|
+
'border-top-color', 'border-right-color', 'border-bottom-color', 'border-left-color',
|
|
31
|
+
'border-top-width', 'border-right-width', 'border-bottom-width', 'border-left-width',
|
|
32
|
+
'border-top-style', 'border-bottom-style', 'border-radius',
|
|
33
|
+
'font-family', 'font-size', 'font-weight', 'font-style', 'line-height',
|
|
34
|
+
'text-align', 'text-transform', 'letter-spacing',
|
|
35
|
+
'padding-top', 'padding-right', 'padding-bottom', 'padding-left',
|
|
36
|
+
'margin-top', 'margin-right', 'margin-bottom', 'margin-left',
|
|
37
|
+
'display', 'opacity', 'visibility', 'box-shadow',
|
|
38
|
+
];
|
|
39
|
+
function fnv1a(str) {
|
|
40
|
+
var h = 0x811c9dc5;
|
|
41
|
+
for (var i = 0; i < str.length; i++) {
|
|
42
|
+
h ^= str.charCodeAt(i);
|
|
43
|
+
h = (h + ((h << 1) + (h << 4) + (h << 7) + (h << 8) + (h << 24))) >>> 0;
|
|
44
|
+
}
|
|
45
|
+
return ('0000000' + h.toString(16)).slice(-8);
|
|
46
|
+
}
|
|
47
|
+
function segment(el) {
|
|
48
|
+
var tag = el.tagName.toLowerCase();
|
|
49
|
+
var cls = '';
|
|
50
|
+
if (el.classList && el.classList.length > 0) {
|
|
51
|
+
cls = '.' + el.classList[0];
|
|
52
|
+
}
|
|
53
|
+
var parent = el.parentElement;
|
|
54
|
+
if (!parent)
|
|
55
|
+
return tag + cls;
|
|
56
|
+
var sameTag = 0;
|
|
57
|
+
var index = 0;
|
|
58
|
+
for (var i = 0; i < parent.children.length; i++) {
|
|
59
|
+
var sib = parent.children[i];
|
|
60
|
+
if (sib.tagName === el.tagName) {
|
|
61
|
+
sameTag++;
|
|
62
|
+
if (sib === el)
|
|
63
|
+
index = sameTag;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return sameTag > 1 ? tag + cls + ':nth-of-type(' + index + ')' : tag + cls;
|
|
67
|
+
}
|
|
68
|
+
function pathOf(el, stopAt) {
|
|
69
|
+
var parts = [];
|
|
70
|
+
var node = el;
|
|
71
|
+
while (node && node !== stopAt.parentElement) {
|
|
72
|
+
parts.unshift(segment(node));
|
|
73
|
+
node = node.parentElement;
|
|
74
|
+
}
|
|
75
|
+
return parts.join(' > ');
|
|
76
|
+
}
|
|
77
|
+
var dpr = win.devicePixelRatio || 1;
|
|
78
|
+
var scrollX = win.scrollX || 0;
|
|
79
|
+
var scrollY = win.scrollY || 0;
|
|
80
|
+
var out = [];
|
|
81
|
+
var body = doc.body;
|
|
82
|
+
if (!body)
|
|
83
|
+
return out;
|
|
84
|
+
var stack = [body];
|
|
85
|
+
while (stack.length > 0 && out.length < maxElements) {
|
|
86
|
+
var el = stack.pop();
|
|
87
|
+
var rect = el.getBoundingClientRect();
|
|
88
|
+
if (rect.width >= 4 && rect.height >= 4) {
|
|
89
|
+
var styleParts = [];
|
|
90
|
+
try {
|
|
91
|
+
var cs = win.getComputedStyle(el);
|
|
92
|
+
for (var p = 0; p < STYLE_PROPS.length; p++) {
|
|
93
|
+
styleParts.push(STYLE_PROPS[p] + ':' + cs.getPropertyValue(STYLE_PROPS[p]));
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
catch (e) {
|
|
97
|
+
// styleHash stays a digest of the empty string — still deterministic
|
|
98
|
+
}
|
|
99
|
+
out.push({
|
|
100
|
+
path: pathOf(el, body),
|
|
101
|
+
x: Math.round((rect.x + scrollX) * dpr),
|
|
102
|
+
y: Math.round((rect.y + scrollY) * dpr),
|
|
103
|
+
width: Math.round(rect.width * dpr),
|
|
104
|
+
height: Math.round(rect.height * dpr),
|
|
105
|
+
styleHash: fnv1a(styleParts.join(';')),
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
// Push children in reverse so the walk stays document-ordered
|
|
109
|
+
for (var c = el.children.length - 1; c >= 0; c--) {
|
|
110
|
+
stack.push(el.children[c]);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
return out;
|
|
114
|
+
}
|
|
115
|
+
/** Default element cap — bounds capture time and elements.json size. */
|
|
116
|
+
exports.DEFAULT_MAX_ELEMENTS = 3000;
|
|
117
|
+
/**
|
|
118
|
+
* The page-side expression evaluated by the adapter. Serializes the
|
|
119
|
+
* collector so page code and unit-tested code are the same function.
|
|
120
|
+
*/
|
|
121
|
+
function buildElementMapExpression(maxElements = exports.DEFAULT_MAX_ELEMENTS) {
|
|
122
|
+
return `(${collectElementMap.toString()})(document, window, ${maxElements})`;
|
|
123
|
+
}
|
package/dist/config/loader.js
CHANGED
|
@@ -138,7 +138,8 @@ function mergeTestConfig(projectConfig, testConfig) {
|
|
|
138
138
|
// the selectors (masked for a long time by the diff engine's cumulated
|
|
139
139
|
// threshold absorbing the tiny leaked pixels).
|
|
140
140
|
ignoreSelectors: testConfig.ignoreSelectors,
|
|
141
|
-
stabilize: testConfig.stabilize
|
|
141
|
+
stabilize: testConfig.stabilize,
|
|
142
|
+
mask: testConfig.mask
|
|
142
143
|
};
|
|
143
144
|
}
|
|
144
145
|
/**
|
package/dist/snapshot.js
CHANGED
|
@@ -44,6 +44,7 @@ const url_1 = require("url");
|
|
|
44
44
|
const sharp_1 = __importDefault(require("sharp"));
|
|
45
45
|
const loader_1 = require("./config/loader");
|
|
46
46
|
const ignore_selectors_1 = require("./config/ignore-selectors");
|
|
47
|
+
const element_map_1 = require("./capture/element-map");
|
|
47
48
|
const stabilize_1 = require("./config/stabilize");
|
|
48
49
|
/**
|
|
49
50
|
* Generates a safe filename from a URL.
|
|
@@ -408,6 +409,74 @@ async function snapshot(page, testInfo, name, config) {
|
|
|
408
409
|
console.warn('[TestivAI] DOM capture failed (noise-hint will be unavailable):', err);
|
|
409
410
|
}
|
|
410
411
|
}
|
|
412
|
+
// Mask support: record per-call mask specs and capture geometry for
|
|
413
|
+
// every selector mask (config + per-call) via getBoundingClientRect.
|
|
414
|
+
// The DOM snapshot carries no layout, so comparison-time selector
|
|
415
|
+
// masks depend on these rects; when absent, the compare degrades to
|
|
416
|
+
// a visible warning. Best-effort — never breaks the capture.
|
|
417
|
+
try {
|
|
418
|
+
const perCallMasks = effectiveConfig.mask ?? [];
|
|
419
|
+
const configMaskEntries = readConfigMasks(process.cwd());
|
|
420
|
+
const selectorMasks = [
|
|
421
|
+
...configMaskEntries.filter((m) => typeof m === 'string'),
|
|
422
|
+
...perCallMasks.filter((m) => typeof m === 'string'),
|
|
423
|
+
];
|
|
424
|
+
const uniqueSelectors = [...new Set(selectorMasks)];
|
|
425
|
+
let maskRects = [];
|
|
426
|
+
if (uniqueSelectors.length > 0) {
|
|
427
|
+
maskRects = await page.evaluate((selectors) => {
|
|
428
|
+
const dpr = window.devicePixelRatio || 1;
|
|
429
|
+
const out = [];
|
|
430
|
+
for (const sel of selectors) {
|
|
431
|
+
try {
|
|
432
|
+
document.querySelectorAll(sel).forEach((el) => {
|
|
433
|
+
const r = el.getBoundingClientRect();
|
|
434
|
+
if (r.width > 0 && r.height > 0) {
|
|
435
|
+
out.push({
|
|
436
|
+
selector: sel,
|
|
437
|
+
x: Math.round((r.x + window.scrollX) * dpr),
|
|
438
|
+
y: Math.round((r.y + window.scrollY) * dpr),
|
|
439
|
+
width: Math.round(r.width * dpr),
|
|
440
|
+
height: Math.round(r.height * dpr),
|
|
441
|
+
});
|
|
442
|
+
}
|
|
443
|
+
});
|
|
444
|
+
}
|
|
445
|
+
catch {
|
|
446
|
+
// invalid selector — the compare side will warn
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
return out;
|
|
450
|
+
}, uniqueSelectors);
|
|
451
|
+
}
|
|
452
|
+
const geometricCallMasks = perCallMasks.filter((m) => typeof m !== 'string');
|
|
453
|
+
const callSelectorMasks = perCallMasks.filter((m) => typeof m === 'string');
|
|
454
|
+
if (geometricCallMasks.length > 0 || callSelectorMasks.length > 0 || maskRects.length > 0) {
|
|
455
|
+
await fs.writeJson(path.join(localSnapshotDir, 'metadata.json'), {
|
|
456
|
+
name: snapshotName,
|
|
457
|
+
timestamp: new Date(timestamp).toISOString(),
|
|
458
|
+
...(geometricCallMasks.length > 0 ? { masks: geometricCallMasks } : {}),
|
|
459
|
+
...(callSelectorMasks.length > 0 ? { maskSelectors: callSelectorMasks } : {}),
|
|
460
|
+
...(maskRects.length > 0 ? { maskRects } : {}),
|
|
461
|
+
});
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
catch {
|
|
465
|
+
// metadata is an enhancement; the screenshot path never depends on it
|
|
466
|
+
}
|
|
467
|
+
// Element map: layout + computed-style digest for every visible
|
|
468
|
+
// element, powering attribution ("WHICH element changed"), exact
|
|
469
|
+
// shift classification, and the style fingerprint on the compare
|
|
470
|
+
// side. Best-effort — the screenshot path never depends on it.
|
|
471
|
+
try {
|
|
472
|
+
const elementMap = await page.evaluate((0, element_map_1.buildElementMapExpression)());
|
|
473
|
+
if (Array.isArray(elementMap) && elementMap.length > 0) {
|
|
474
|
+
await fs.writeJson(path.join(localSnapshotDir, 'elements.json'), elementMap);
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
catch {
|
|
478
|
+
// missing elements.json only disables attribution for this capture
|
|
479
|
+
}
|
|
411
480
|
}
|
|
412
481
|
// 2. Dump page structure (HTML) - skip in local mode
|
|
413
482
|
// @renamed: domPath → structurePath (IP protection)
|
|
@@ -709,3 +778,17 @@ async function snapshot(page, testInfo, name, config) {
|
|
|
709
778
|
structureAnalysis
|
|
710
779
|
});
|
|
711
780
|
}
|
|
781
|
+
/**
|
|
782
|
+
* Read the global `mask` list from .testivai/config.json (selector strings
|
|
783
|
+
* and geometric objects). Returns [] when absent or malformed.
|
|
784
|
+
*/
|
|
785
|
+
function readConfigMasks(projectRoot) {
|
|
786
|
+
try {
|
|
787
|
+
const raw = fs.readFileSync(path.join(projectRoot, '.testivai', 'config.json'), 'utf-8');
|
|
788
|
+
const parsed = JSON.parse(raw);
|
|
789
|
+
return Array.isArray(parsed?.mask) ? parsed.mask : [];
|
|
790
|
+
}
|
|
791
|
+
catch {
|
|
792
|
+
return [];
|
|
793
|
+
}
|
|
794
|
+
}
|
package/dist/types.d.ts
CHANGED
|
@@ -239,6 +239,18 @@ export interface TestivAIConfig {
|
|
|
239
239
|
* or in `testivai.config.ts`. Per-snapshot values win.
|
|
240
240
|
*/
|
|
241
241
|
stabilize?: boolean;
|
|
242
|
+
/**
|
|
243
|
+
* Masks for this snapshot: areas excluded from the pixel diff and
|
|
244
|
+
* hatched in the report (auditable — never silent). Each entry is a
|
|
245
|
+
* CSS selector string (geometry recorded at capture time) or a
|
|
246
|
+
* geometric region — px numbers, 0–1 ratios, "NN%" strings, or a
|
|
247
|
+
* single-edge shorthand like { top: 24 }.
|
|
248
|
+
*
|
|
249
|
+
* Merged with the global `mask` list from `.testivai/config.json`.
|
|
250
|
+
* Unlike `ignoreSelectors` (which hides elements during capture),
|
|
251
|
+
* masks act at comparison time and stay visible in the diff output.
|
|
252
|
+
*/
|
|
253
|
+
mask?: Array<string | Record<string, number | string>>;
|
|
242
254
|
}
|
|
243
255
|
/**
|
|
244
256
|
* Layout/Bounding box data for an element
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@testivai/witness-playwright",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.5.1",
|
|
4
4
|
"description": "Playwright sensor for Testivai Visual Regression Test system",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"types": "dist/index.d.ts",
|
|
@@ -48,7 +48,7 @@
|
|
|
48
48
|
"sharp": "^0.34.5",
|
|
49
49
|
"simple-git": "^3.21.0",
|
|
50
50
|
"@testivai/common": "0.2.2",
|
|
51
|
-
"@testivai/witness": "1.
|
|
51
|
+
"@testivai/witness": "1.6.0"
|
|
52
52
|
},
|
|
53
53
|
"devDependencies": {
|
|
54
54
|
"@playwright/test": "^1.60.0",
|