@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.
@@ -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
+ }
@@ -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.3",
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.3.1"
51
+ "@testivai/witness": "1.6.0"
52
52
  },
53
53
  "devDependencies": {
54
54
  "@playwright/test": "^1.60.0",