@testivai/witness-playwright 1.5.1 → 1.6.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/dist/capture/element-map.d.ts +2 -2
- package/dist/capture/element-map.js +41 -12
- package/dist/config/ignore-selectors.d.ts +33 -14
- package/dist/config/ignore-selectors.js +68 -19
- package/dist/index.d.ts +1 -0
- package/dist/index.js +2 -1
- package/dist/mode.d.ts +21 -0
- package/dist/mode.js +84 -0
- package/dist/reporter.js +9 -27
- package/dist/snapshot.js +49 -32
- package/dist/types.d.ts +22 -1
- package/package.json +2 -2
|
@@ -27,11 +27,11 @@ export interface CollectedElement {
|
|
|
27
27
|
* - `styleHash` digests a fixed, documented list of computed properties
|
|
28
28
|
* (FNV-1a 32-bit, hex) — deterministic for identical rendering.
|
|
29
29
|
*/
|
|
30
|
-
export declare function collectElementMap(doc: Document, win: Window & typeof globalThis, maxElements: number): CollectedElement[];
|
|
30
|
+
export declare function collectElementMap(doc: Document, win: Window & typeof globalThis, maxElements: number, ignoreSelectors?: string[]): CollectedElement[];
|
|
31
31
|
/** Default element cap — bounds capture time and elements.json size. */
|
|
32
32
|
export declare const DEFAULT_MAX_ELEMENTS = 3000;
|
|
33
33
|
/**
|
|
34
34
|
* The page-side expression evaluated by the adapter. Serializes the
|
|
35
35
|
* collector so page code and unit-tested code are the same function.
|
|
36
36
|
*/
|
|
37
|
-
export declare function buildElementMapExpression(maxElements?: number): string;
|
|
37
|
+
export declare function buildElementMapExpression(maxElements?: number, ignoreSelectors?: string[]): string;
|
|
@@ -24,7 +24,7 @@ exports.buildElementMapExpression = buildElementMapExpression;
|
|
|
24
24
|
* - `styleHash` digests a fixed, documented list of computed properties
|
|
25
25
|
* (FNV-1a 32-bit, hex) — deterministic for identical rendering.
|
|
26
26
|
*/
|
|
27
|
-
function collectElementMap(doc, win, maxElements) {
|
|
27
|
+
function collectElementMap(doc, win, maxElements, ignoreSelectors = []) {
|
|
28
28
|
var STYLE_PROPS = [
|
|
29
29
|
'color', 'background-color', 'background-image',
|
|
30
30
|
'border-top-color', 'border-right-color', 'border-bottom-color', 'border-left-color',
|
|
@@ -84,26 +84,55 @@ function collectElementMap(doc, win, maxElements) {
|
|
|
84
84
|
var stack = [body];
|
|
85
85
|
while (stack.length > 0 && out.length < maxElements) {
|
|
86
86
|
var el = stack.pop();
|
|
87
|
+
// The consistency rule: elements covered by ignoreSelectors are
|
|
88
|
+
// excluded from pixels and the DOM snapshot — they must be excluded
|
|
89
|
+
// from the element map too (subtree included), or their dynamic
|
|
90
|
+
// styles would trip the style fingerprint they were meant to escape.
|
|
91
|
+
var ignored = false;
|
|
92
|
+
if (ignoreSelectors.length > 0 && typeof el.matches === 'function') {
|
|
93
|
+
for (var g = 0; g < ignoreSelectors.length; g++) {
|
|
94
|
+
try {
|
|
95
|
+
if (el.matches(ignoreSelectors[g])) {
|
|
96
|
+
ignored = true;
|
|
97
|
+
break;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
catch (e) {
|
|
101
|
+
// invalid selector — never breaks the walk
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
if (ignored)
|
|
106
|
+
continue; // skip element AND subtree
|
|
87
107
|
var rect = el.getBoundingClientRect();
|
|
88
108
|
if (rect.width >= 4 && rect.height >= 4) {
|
|
89
109
|
var styleParts = [];
|
|
110
|
+
var hidden = false;
|
|
90
111
|
try {
|
|
91
112
|
var cs = win.getComputedStyle(el);
|
|
92
113
|
for (var p = 0; p < STYLE_PROPS.length; p++) {
|
|
93
|
-
|
|
114
|
+
var value = cs.getPropertyValue(STYLE_PROPS[p]);
|
|
115
|
+
if (STYLE_PROPS[p] === 'visibility' && value === 'hidden')
|
|
116
|
+
hidden = true;
|
|
117
|
+
styleParts.push(STYLE_PROPS[p] + ':' + value);
|
|
94
118
|
}
|
|
95
119
|
}
|
|
96
120
|
catch (e) {
|
|
97
121
|
// styleHash stays a digest of the empty string — still deterministic
|
|
98
122
|
}
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
123
|
+
// visibility:hidden elements paint no pixels, so their style changes
|
|
124
|
+
// can never explain a pixel diff — keep them out of the map. Their
|
|
125
|
+
// CHILDREN may override visibility, so the subtree still walks.
|
|
126
|
+
if (!hidden) {
|
|
127
|
+
out.push({
|
|
128
|
+
path: pathOf(el, body),
|
|
129
|
+
x: Math.round((rect.x + scrollX) * dpr),
|
|
130
|
+
y: Math.round((rect.y + scrollY) * dpr),
|
|
131
|
+
width: Math.round(rect.width * dpr),
|
|
132
|
+
height: Math.round(rect.height * dpr),
|
|
133
|
+
styleHash: fnv1a(styleParts.join(';')),
|
|
134
|
+
});
|
|
135
|
+
}
|
|
107
136
|
}
|
|
108
137
|
// Push children in reverse so the walk stays document-ordered
|
|
109
138
|
for (var c = el.children.length - 1; c >= 0; c--) {
|
|
@@ -118,6 +147,6 @@ exports.DEFAULT_MAX_ELEMENTS = 3000;
|
|
|
118
147
|
* The page-side expression evaluated by the adapter. Serializes the
|
|
119
148
|
* collector so page code and unit-tested code are the same function.
|
|
120
149
|
*/
|
|
121
|
-
function buildElementMapExpression(maxElements = exports.DEFAULT_MAX_ELEMENTS) {
|
|
122
|
-
return `(${collectElementMap.toString()})(document, window, ${maxElements})`;
|
|
150
|
+
function buildElementMapExpression(maxElements = exports.DEFAULT_MAX_ELEMENTS, ignoreSelectors = []) {
|
|
151
|
+
return `(${collectElementMap.toString()})(document, window, ${maxElements}, ${JSON.stringify(ignoreSelectors)})`;
|
|
123
152
|
}
|
|
@@ -1,38 +1,57 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Helpers for the ignoreSelectors feature.
|
|
3
3
|
*
|
|
4
|
-
* Three sources, merged with deduplication (
|
|
4
|
+
* Three sources, merged with deduplication (first-write-wins via selector key):
|
|
5
5
|
* 1. .testivai/config.json — global OSS config (`ignoreSelectors` array)
|
|
6
6
|
* 2. testivai.config.ts/js — power-user project config (`ignoreSelectors`)
|
|
7
7
|
* 3. testivai.witness() call — per-snapshot override (`ignoreSelectors`)
|
|
8
8
|
*
|
|
9
|
+
* Each entry is a bare CSS selector string (defaults to `mask` mode) or an
|
|
10
|
+
* object `{ selector, mode }` where mode is:
|
|
11
|
+
* - `mask` (default) → visibility:hidden — blank the box, keep layout
|
|
12
|
+
* - `collapse` → display:none — remove layout influence
|
|
13
|
+
*
|
|
9
14
|
* These functions are pure (no Playwright dependency) so they can be
|
|
10
15
|
* exercised in plain Jest unit tests without a browser.
|
|
11
16
|
*/
|
|
12
|
-
import { TestivAIConfig, TestivAIProjectConfig } from '../types';
|
|
17
|
+
import { TestivAIConfig, TestivAIProjectConfig, IgnoreMode, IgnoreSelectorInput } from '../types';
|
|
18
|
+
/** A fully-resolved ignore entry with an explicit mode. */
|
|
19
|
+
export interface IgnoreRule {
|
|
20
|
+
selector: string;
|
|
21
|
+
mode: IgnoreMode;
|
|
22
|
+
}
|
|
13
23
|
/**
|
|
14
|
-
* Read ignoreSelectors from `.testivai/config.json`.
|
|
15
|
-
* Returns `[]` when the file doesn't exist or is
|
|
24
|
+
* Read raw ignoreSelectors from `.testivai/config.json`.
|
|
25
|
+
* Returns `[]` when the file doesn't exist, is malformed, or the field is not
|
|
26
|
+
* an array. Entries are returned as-authored (strings and/or objects).
|
|
16
27
|
*
|
|
17
28
|
* @param projectRoot Directory that contains the `.testivai/` folder
|
|
18
29
|
*/
|
|
19
|
-
export declare function readWitnessConfigSelectors(projectRoot: string):
|
|
30
|
+
export declare function readWitnessConfigSelectors(projectRoot: string): IgnoreSelectorInput[];
|
|
20
31
|
/**
|
|
21
|
-
* Collect and deduplicate
|
|
22
|
-
*
|
|
23
|
-
* Order of precedence for deduplication: earlier sources win (Set preserves
|
|
24
|
-
* insertion order, so the first occurrence of any selector survives).
|
|
32
|
+
* Collect and deduplicate ignore rules (with modes) from all three sources.
|
|
33
|
+
* Earlier sources win on duplicate selectors (insertion order preserved).
|
|
25
34
|
*
|
|
26
35
|
* @param projectRoot Path to the project root (where `.testivai/` lives)
|
|
27
36
|
* @param projectConfig Loaded `testivai.config.ts` project config
|
|
28
37
|
* @param effectiveConfig Merged per-snapshot TestivAIConfig
|
|
29
38
|
*/
|
|
39
|
+
export declare function collectIgnoreRules(projectRoot: string, projectConfig: TestivAIProjectConfig, effectiveConfig: TestivAIConfig): IgnoreRule[];
|
|
40
|
+
/**
|
|
41
|
+
* Collect and deduplicate ignore selector strings from all three sources.
|
|
42
|
+
*
|
|
43
|
+
* Mode-agnostic: returns just the selectors, used where only the "which
|
|
44
|
+
* elements" question matters (DOM/text signal and the element map exclude
|
|
45
|
+
* ignored elements regardless of mask vs collapse).
|
|
46
|
+
*/
|
|
30
47
|
export declare function collectIgnoreSelectors(projectRoot: string, projectConfig: TestivAIProjectConfig, effectiveConfig: TestivAIConfig): string[];
|
|
31
48
|
/**
|
|
32
|
-
* Build
|
|
33
|
-
* `visibility: hidden !important
|
|
49
|
+
* Build `<style>` content that neutralizes every entry before capture.
|
|
50
|
+
* `mask` → `visibility: hidden !important` (layout preserved);
|
|
51
|
+
* `collapse` → `display: none !important` (layout removed).
|
|
34
52
|
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
53
|
+
* Accepts bare strings (treated as `mask`) or resolved rules, so both the raw
|
|
54
|
+
* config shape and `collectIgnoreRules()` output work. Returns an empty string
|
|
55
|
+
* for an empty list so callers can guard with `if (css)`.
|
|
37
56
|
*/
|
|
38
|
-
export declare function buildIgnoreSelectorsCSS(
|
|
57
|
+
export declare function buildIgnoreSelectorsCSS(items: Array<IgnoreSelectorInput | IgnoreRule>): string;
|
|
@@ -2,11 +2,16 @@
|
|
|
2
2
|
/**
|
|
3
3
|
* Helpers for the ignoreSelectors feature.
|
|
4
4
|
*
|
|
5
|
-
* Three sources, merged with deduplication (
|
|
5
|
+
* Three sources, merged with deduplication (first-write-wins via selector key):
|
|
6
6
|
* 1. .testivai/config.json — global OSS config (`ignoreSelectors` array)
|
|
7
7
|
* 2. testivai.config.ts/js — power-user project config (`ignoreSelectors`)
|
|
8
8
|
* 3. testivai.witness() call — per-snapshot override (`ignoreSelectors`)
|
|
9
9
|
*
|
|
10
|
+
* Each entry is a bare CSS selector string (defaults to `mask` mode) or an
|
|
11
|
+
* object `{ selector, mode }` where mode is:
|
|
12
|
+
* - `mask` (default) → visibility:hidden — blank the box, keep layout
|
|
13
|
+
* - `collapse` → display:none — remove layout influence
|
|
14
|
+
*
|
|
10
15
|
* These functions are pure (no Playwright dependency) so they can be
|
|
11
16
|
* exercised in plain Jest unit tests without a browser.
|
|
12
17
|
*/
|
|
@@ -45,13 +50,28 @@ var __importStar = (this && this.__importStar) || (function () {
|
|
|
45
50
|
})();
|
|
46
51
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
47
52
|
exports.readWitnessConfigSelectors = readWitnessConfigSelectors;
|
|
53
|
+
exports.collectIgnoreRules = collectIgnoreRules;
|
|
48
54
|
exports.collectIgnoreSelectors = collectIgnoreSelectors;
|
|
49
55
|
exports.buildIgnoreSelectorsCSS = buildIgnoreSelectorsCSS;
|
|
50
56
|
const fs = __importStar(require("fs"));
|
|
51
57
|
const path = __importStar(require("path"));
|
|
58
|
+
/** Normalize one raw entry to a rule, or null when it's unusable. */
|
|
59
|
+
function toRule(input) {
|
|
60
|
+
if (typeof input === 'string') {
|
|
61
|
+
return input ? { selector: input, mode: 'mask' } : null;
|
|
62
|
+
}
|
|
63
|
+
if (input && typeof input.selector === 'string' && input.selector) {
|
|
64
|
+
return {
|
|
65
|
+
selector: input.selector,
|
|
66
|
+
mode: input.mode === 'collapse' ? 'collapse' : 'mask',
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
52
71
|
/**
|
|
53
|
-
* Read ignoreSelectors from `.testivai/config.json`.
|
|
54
|
-
* Returns `[]` when the file doesn't exist or is
|
|
72
|
+
* Read raw ignoreSelectors from `.testivai/config.json`.
|
|
73
|
+
* Returns `[]` when the file doesn't exist, is malformed, or the field is not
|
|
74
|
+
* an array. Entries are returned as-authored (strings and/or objects).
|
|
55
75
|
*
|
|
56
76
|
* @param projectRoot Directory that contains the `.testivai/` folder
|
|
57
77
|
*/
|
|
@@ -68,30 +88,59 @@ function readWitnessConfigSelectors(projectRoot) {
|
|
|
68
88
|
}
|
|
69
89
|
}
|
|
70
90
|
/**
|
|
71
|
-
* Collect and deduplicate
|
|
72
|
-
*
|
|
73
|
-
* Order of precedence for deduplication: earlier sources win (Set preserves
|
|
74
|
-
* insertion order, so the first occurrence of any selector survives).
|
|
91
|
+
* Collect and deduplicate ignore rules (with modes) from all three sources.
|
|
92
|
+
* Earlier sources win on duplicate selectors (insertion order preserved).
|
|
75
93
|
*
|
|
76
94
|
* @param projectRoot Path to the project root (where `.testivai/` lives)
|
|
77
95
|
* @param projectConfig Loaded `testivai.config.ts` project config
|
|
78
96
|
* @param effectiveConfig Merged per-snapshot TestivAIConfig
|
|
79
97
|
*/
|
|
98
|
+
function collectIgnoreRules(projectRoot, projectConfig, effectiveConfig) {
|
|
99
|
+
const merged = [
|
|
100
|
+
...readWitnessConfigSelectors(projectRoot),
|
|
101
|
+
...(projectConfig.ignoreSelectors ?? []),
|
|
102
|
+
...(effectiveConfig.ignoreSelectors ?? []),
|
|
103
|
+
];
|
|
104
|
+
const seen = new Set();
|
|
105
|
+
const rules = [];
|
|
106
|
+
for (const item of merged) {
|
|
107
|
+
const rule = toRule(item);
|
|
108
|
+
if (rule && !seen.has(rule.selector)) {
|
|
109
|
+
seen.add(rule.selector);
|
|
110
|
+
rules.push(rule);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
return rules;
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* Collect and deduplicate ignore selector strings from all three sources.
|
|
117
|
+
*
|
|
118
|
+
* Mode-agnostic: returns just the selectors, used where only the "which
|
|
119
|
+
* elements" question matters (DOM/text signal and the element map exclude
|
|
120
|
+
* ignored elements regardless of mask vs collapse).
|
|
121
|
+
*/
|
|
80
122
|
function collectIgnoreSelectors(projectRoot, projectConfig, effectiveConfig) {
|
|
81
|
-
|
|
82
|
-
const fromProjectConfig = projectConfig.ignoreSelectors ?? [];
|
|
83
|
-
const fromSnapshot = effectiveConfig.ignoreSelectors ?? [];
|
|
84
|
-
return [...new Set([...fromWitnessConfig, ...fromProjectConfig, ...fromSnapshot])];
|
|
123
|
+
return collectIgnoreRules(projectRoot, projectConfig, effectiveConfig).map((r) => r.selector);
|
|
85
124
|
}
|
|
86
125
|
/**
|
|
87
|
-
* Build
|
|
88
|
-
* `visibility: hidden !important
|
|
126
|
+
* Build `<style>` content that neutralizes every entry before capture.
|
|
127
|
+
* `mask` → `visibility: hidden !important` (layout preserved);
|
|
128
|
+
* `collapse` → `display: none !important` (layout removed).
|
|
89
129
|
*
|
|
90
|
-
*
|
|
91
|
-
*
|
|
130
|
+
* Accepts bare strings (treated as `mask`) or resolved rules, so both the raw
|
|
131
|
+
* config shape and `collectIgnoreRules()` output work. Returns an empty string
|
|
132
|
+
* for an empty list so callers can guard with `if (css)`.
|
|
92
133
|
*/
|
|
93
|
-
function buildIgnoreSelectorsCSS(
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
134
|
+
function buildIgnoreSelectorsCSS(items) {
|
|
135
|
+
const lines = [];
|
|
136
|
+
for (const item of items) {
|
|
137
|
+
const rule = toRule(item);
|
|
138
|
+
if (!rule)
|
|
139
|
+
continue;
|
|
140
|
+
const decl = rule.mode === 'collapse'
|
|
141
|
+
? 'display: none !important;'
|
|
142
|
+
: 'visibility: hidden !important;';
|
|
143
|
+
lines.push(`${rule.selector} { ${decl} }`);
|
|
144
|
+
}
|
|
145
|
+
return lines.join('\n');
|
|
97
146
|
}
|
package/dist/index.d.ts
CHANGED
package/dist/index.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.testivai = void 0;
|
|
3
|
+
exports.testivai = exports.snapshot = void 0;
|
|
4
4
|
const snapshot_1 = require("./snapshot");
|
|
5
|
+
Object.defineProperty(exports, "snapshot", { enumerable: true, get: function () { return snapshot_1.snapshot; } });
|
|
5
6
|
const ci_1 = require("./ci");
|
|
6
7
|
exports.testivai = {
|
|
7
8
|
witness: snapshot_1.snapshot,
|
package/dist/mode.d.ts
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resolve whether the adapter should run in local mode.
|
|
3
|
+
*
|
|
4
|
+
* Local-first is the zero-config default: with no API key, the adapter
|
|
5
|
+
* captures to `.testivai/temp/<name>/` and writes an HTML report — no cloud,
|
|
6
|
+
* no account, nothing scary. Cloud mode activates only when a key is present.
|
|
7
|
+
*
|
|
8
|
+
* Precedence (first match wins):
|
|
9
|
+
* 1. TESTIVAI_MODE env — explicit override ('local' | 'cloud')
|
|
10
|
+
* 2. .testivai/config.json `mode` — project default
|
|
11
|
+
* 3. TESTIVAI_API_KEY presence — key ⇒ cloud, no key ⇒ local
|
|
12
|
+
*
|
|
13
|
+
* This is deliberately derivable from the shell environment and the config
|
|
14
|
+
* file alone, so it resolves identically in the Playwright main process (the
|
|
15
|
+
* reporter) and in worker processes (the `snapshot()` capture) — env vars set
|
|
16
|
+
* at runtime by the reporter never propagate to already-spawned workers.
|
|
17
|
+
*/
|
|
18
|
+
export declare function resolveLocalMode(opts?: {
|
|
19
|
+
apiKey?: string;
|
|
20
|
+
projectRoot?: string;
|
|
21
|
+
}): boolean;
|
package/dist/mode.js
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
35
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
+
exports.resolveLocalMode = resolveLocalMode;
|
|
37
|
+
const fs = __importStar(require("fs-extra"));
|
|
38
|
+
const path = __importStar(require("path"));
|
|
39
|
+
/**
|
|
40
|
+
* Read `mode` from .testivai/config.json. Returns null when the file is
|
|
41
|
+
* absent, malformed, or has no recognized mode field.
|
|
42
|
+
*/
|
|
43
|
+
function configMode(projectRoot) {
|
|
44
|
+
try {
|
|
45
|
+
const cfg = fs.readJsonSync(path.join(projectRoot, '.testivai', 'config.json'));
|
|
46
|
+
if (cfg?.mode === 'local')
|
|
47
|
+
return 'local';
|
|
48
|
+
if (cfg?.mode === 'cloud')
|
|
49
|
+
return 'cloud';
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Resolve whether the adapter should run in local mode.
|
|
58
|
+
*
|
|
59
|
+
* Local-first is the zero-config default: with no API key, the adapter
|
|
60
|
+
* captures to `.testivai/temp/<name>/` and writes an HTML report — no cloud,
|
|
61
|
+
* no account, nothing scary. Cloud mode activates only when a key is present.
|
|
62
|
+
*
|
|
63
|
+
* Precedence (first match wins):
|
|
64
|
+
* 1. TESTIVAI_MODE env — explicit override ('local' | 'cloud')
|
|
65
|
+
* 2. .testivai/config.json `mode` — project default
|
|
66
|
+
* 3. TESTIVAI_API_KEY presence — key ⇒ cloud, no key ⇒ local
|
|
67
|
+
*
|
|
68
|
+
* This is deliberately derivable from the shell environment and the config
|
|
69
|
+
* file alone, so it resolves identically in the Playwright main process (the
|
|
70
|
+
* reporter) and in worker processes (the `snapshot()` capture) — env vars set
|
|
71
|
+
* at runtime by the reporter never propagate to already-spawned workers.
|
|
72
|
+
*/
|
|
73
|
+
function resolveLocalMode(opts = {}) {
|
|
74
|
+
const envMode = process.env.TESTIVAI_MODE;
|
|
75
|
+
if (envMode === 'local')
|
|
76
|
+
return true;
|
|
77
|
+
if (envMode === 'cloud')
|
|
78
|
+
return false;
|
|
79
|
+
const cfg = configMode(opts.projectRoot ?? process.cwd());
|
|
80
|
+
if (cfg)
|
|
81
|
+
return cfg === 'local';
|
|
82
|
+
const apiKey = opts.apiKey ?? process.env.TESTIVAI_API_KEY;
|
|
83
|
+
return !apiKey;
|
|
84
|
+
}
|
package/dist/reporter.js
CHANGED
|
@@ -43,22 +43,7 @@ const simple_git_1 = __importDefault(require("simple-git"));
|
|
|
43
43
|
const axios_1 = __importDefault(require("axios"));
|
|
44
44
|
const ci_1 = require("./ci");
|
|
45
45
|
const common_1 = require("@testivai/common");
|
|
46
|
-
|
|
47
|
-
* Check if local mode is configured by looking for .testivai/config.json with mode: 'local'
|
|
48
|
-
*/
|
|
49
|
-
function isLocalMode() {
|
|
50
|
-
try {
|
|
51
|
-
const configPath = path.join(process.cwd(), '.testivai', 'config.json');
|
|
52
|
-
if (!fs.existsSync(configPath)) {
|
|
53
|
-
return false;
|
|
54
|
-
}
|
|
55
|
-
const config = fs.readJsonSync(configPath);
|
|
56
|
-
return config.mode === 'local';
|
|
57
|
-
}
|
|
58
|
-
catch {
|
|
59
|
-
return false;
|
|
60
|
-
}
|
|
61
|
-
}
|
|
46
|
+
const mode_1 = require("./mode");
|
|
62
47
|
class TestivAIPlaywrightReporter {
|
|
63
48
|
constructor(options = {}) {
|
|
64
49
|
this.gitInfo = null;
|
|
@@ -77,21 +62,18 @@ class TestivAIPlaywrightReporter {
|
|
|
77
62
|
this.compressionHelper = new common_1.CompressionHelper(this.options.compression);
|
|
78
63
|
}
|
|
79
64
|
async onBegin(config, suite) {
|
|
80
|
-
//
|
|
81
|
-
|
|
65
|
+
// Resolve mode. Local-first is the default: with no API key we capture
|
|
66
|
+
// and report locally rather than disabling the reporter. Cloud mode
|
|
67
|
+
// activates only when a key is present (or config/env force it).
|
|
68
|
+
this.localMode = (0, mode_1.resolveLocalMode)({ apiKey: this.options.apiKey });
|
|
82
69
|
if (this.localMode) {
|
|
83
70
|
process.env.TESTIVAI_MODE = 'local';
|
|
84
|
-
|
|
85
|
-
|
|
71
|
+
// One quiet, friendly line — not a scary "disabling reporter" error.
|
|
72
|
+
if (!this.options.apiKey && !process.env.TESTIVAI_MODE_ANNOUNCED) {
|
|
73
|
+
console.log('TestivAI: no API key set — running in local mode (report → visual-report/index.html).');
|
|
74
|
+
process.env.TESTIVAI_MODE_ANNOUNCED = '1';
|
|
86
75
|
}
|
|
87
76
|
}
|
|
88
|
-
// API key is only required in cloud mode
|
|
89
|
-
if (!this.localMode && !this.options.apiKey) {
|
|
90
|
-
console.error('Testivai Reporter: API Key is not configured. Disabling reporter.');
|
|
91
|
-
console.error('Set TESTIVAI_API_KEY environment variable or pass apiKey in reporter options.');
|
|
92
|
-
this.options.apiUrl = undefined; // Disable reporter
|
|
93
|
-
return;
|
|
94
|
-
}
|
|
95
77
|
// 1. Clean temp directory
|
|
96
78
|
await fs.emptyDir(this.tempDir);
|
|
97
79
|
// 2. Capture Git metadata
|
package/dist/snapshot.js
CHANGED
|
@@ -46,6 +46,7 @@ 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");
|
|
49
50
|
/**
|
|
50
51
|
* Generates a safe filename from a URL.
|
|
51
52
|
* @param pageUrl The URL of the page.
|
|
@@ -97,8 +98,11 @@ function effectiveSnapshotName(baseName, testInfo) {
|
|
|
97
98
|
* @param config Optional TestivAI configuration for this snapshot (overrides project defaults).
|
|
98
99
|
*/
|
|
99
100
|
async function snapshot(page, testInfo, name, config) {
|
|
100
|
-
// Check for local mode - only capture screenshots, skip heavy data
|
|
101
|
-
|
|
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)();
|
|
102
106
|
// Load project configuration and merge with test-specific overrides
|
|
103
107
|
const projectConfig = await (0, loader_1.loadConfig)();
|
|
104
108
|
const effectiveConfig = (0, loader_1.mergeTestConfig)(projectConfig, config);
|
|
@@ -127,8 +131,10 @@ async function snapshot(page, testInfo, name, config) {
|
|
|
127
131
|
// 3. testivai.witness(...) → { ignoreSelectors } (per-snapshot override)
|
|
128
132
|
let ignoreStyleEl = null;
|
|
129
133
|
if (isLocalMode) {
|
|
130
|
-
|
|
131
|
-
|
|
134
|
+
// Rules carry per-selector mode: mask (visibility:hidden, layout kept) or
|
|
135
|
+
// collapse (display:none, layout removed — fixes variable-height shift).
|
|
136
|
+
const ignoreRules = (0, ignore_selectors_1.collectIgnoreRules)(process.cwd(), projectConfig, effectiveConfig);
|
|
137
|
+
const css = (0, ignore_selectors_1.buildIgnoreSelectorsCSS)(ignoreRules);
|
|
132
138
|
if (css) {
|
|
133
139
|
ignoreStyleEl = await page.addStyleTag({ content: css });
|
|
134
140
|
}
|
|
@@ -385,7 +391,12 @@ async function snapshot(page, testInfo, name, config) {
|
|
|
385
391
|
if (isLocalMode) {
|
|
386
392
|
const localSnapshotDir = path.join(outputDir, snapshotName);
|
|
387
393
|
await fs.ensureDir(localSnapshotDir);
|
|
388
|
-
|
|
394
|
+
// Move (not copy) the flat capture into the canonical <name>/ layout so
|
|
395
|
+
// local mode leaves exactly one on-disk representation — no stray
|
|
396
|
+
// <timestamp>_<name>.png alongside it. The flat metadata .json is also
|
|
397
|
+
// skipped in local mode (see below); the local report reads the <name>/
|
|
398
|
+
// directory, not the flat files.
|
|
399
|
+
await fs.move(screenshotPath, path.join(localSnapshotDir, 'screenshot.png'), { overwrite: true });
|
|
389
400
|
try {
|
|
390
401
|
const domIgnoreSelectors = (0, ignore_selectors_1.collectIgnoreSelectors)(process.cwd(), projectConfig, effectiveConfig);
|
|
391
402
|
const domHtml = await page.evaluate((selectors) => {
|
|
@@ -469,7 +480,8 @@ async function snapshot(page, testInfo, name, config) {
|
|
|
469
480
|
// shift classification, and the style fingerprint on the compare
|
|
470
481
|
// side. Best-effort — the screenshot path never depends on it.
|
|
471
482
|
try {
|
|
472
|
-
const
|
|
483
|
+
const mapIgnores = (0, ignore_selectors_1.collectIgnoreSelectors)(process.cwd(), projectConfig, effectiveConfig);
|
|
484
|
+
const elementMap = await page.evaluate((0, element_map_1.buildElementMapExpression)(undefined, mapIgnores));
|
|
473
485
|
if (Array.isArray(elementMap) && elementMap.length > 0) {
|
|
474
486
|
await fs.writeJson(path.join(localSnapshotDir, 'elements.json'), elementMap);
|
|
475
487
|
}
|
|
@@ -751,32 +763,37 @@ async function snapshot(page, testInfo, name, config) {
|
|
|
751
763
|
// The SDK just captures the HTML and sends it with the configuration
|
|
752
764
|
// @renamed: domAnalysis → structureAnalysis (IP protection)
|
|
753
765
|
const structureAnalysis = isLocalMode ? undefined : undefined; // Will be populated by backend
|
|
754
|
-
// 6. Save metadata with configuration and performance data
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
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
|
+
}
|
|
780
797
|
}
|
|
781
798
|
/**
|
|
782
799
|
* Read the global `mask` list from .testivai/config.json (selector strings
|
package/dist/types.d.ts
CHANGED
|
@@ -6,6 +6,23 @@
|
|
|
6
6
|
* - BatchPayload: Git + Browser info + collection of snapshots
|
|
7
7
|
* - TestivAIConfig: Configuration for visual analysis behavior
|
|
8
8
|
*/
|
|
9
|
+
/**
|
|
10
|
+
* How an ignored selector is neutralized before capture:
|
|
11
|
+
* - `mask` — `visibility: hidden` (default): the box is blanked but keeps
|
|
12
|
+
* its layout, so surrounding elements don't shift.
|
|
13
|
+
* - `collapse` — `display: none`: the element is removed from layout entirely,
|
|
14
|
+
* so variable-height ignored content (e.g. a dynamic footer)
|
|
15
|
+
* stops shifting everything below it.
|
|
16
|
+
*/
|
|
17
|
+
export type IgnoreMode = 'mask' | 'collapse';
|
|
18
|
+
/**
|
|
19
|
+
* An ignoreSelectors entry: a plain CSS selector string (defaults to `mask`
|
|
20
|
+
* mode), or an object choosing the mode per selector.
|
|
21
|
+
*/
|
|
22
|
+
export type IgnoreSelectorInput = string | {
|
|
23
|
+
selector: string;
|
|
24
|
+
mode?: IgnoreMode;
|
|
25
|
+
};
|
|
9
26
|
/**
|
|
10
27
|
* Layout configuration for visual analysis
|
|
11
28
|
*/
|
|
@@ -228,8 +245,12 @@ export interface TestivAIConfig {
|
|
|
228
245
|
* Per-snapshot values are merged with (not replaced by) the global list.
|
|
229
246
|
*
|
|
230
247
|
* Example: ["[data-testivai-ignore]", ".version-badge", "#live-chat-widget"]
|
|
248
|
+
*
|
|
249
|
+
* Entries may also be objects to choose a per-selector mode, e.g.
|
|
250
|
+
* [{ selector: "#footer", mode: "collapse" }] to remove layout influence.
|
|
251
|
+
* A bare string defaults to "mask" (visibility:hidden, layout preserved).
|
|
231
252
|
*/
|
|
232
|
-
ignoreSelectors?:
|
|
253
|
+
ignoreSelectors?: IgnoreSelectorInput[];
|
|
233
254
|
/**
|
|
234
255
|
* Stabilize the page before capture: disable CSS animations/transitions,
|
|
235
256
|
* hide the text caret, and wait for web fonts to finish loading — the top
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@testivai/witness-playwright",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.6.0",
|
|
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.7.0"
|
|
52
52
|
},
|
|
53
53
|
"devDependencies": {
|
|
54
54
|
"@playwright/test": "^1.60.0",
|