@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.
@@ -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
- styleParts.push(STYLE_PROPS[p] + ':' + cs.getPropertyValue(STYLE_PROPS[p]));
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
- 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
- });
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 (last-write-wins dedup via Set):
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 malformed.
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): string[];
30
+ export declare function readWitnessConfigSelectors(projectRoot: string): IgnoreSelectorInput[];
20
31
  /**
21
- * Collect and deduplicate ignoreSelectors from all three sources.
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 a `<style>` tag content that hides every given selector via
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
- * Returns an empty string when the selectors list is empty so callers can
36
- * guard with `if (css)` before calling `page.addStyleTag`.
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(selectors: string[]): string;
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 (last-write-wins dedup via Set):
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 malformed.
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 ignoreSelectors from all three sources.
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
- const fromWitnessConfig = readWitnessConfigSelectors(projectRoot);
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 a `<style>` tag content that hides every given selector via
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
- * Returns an empty string when the selectors list is empty so callers can
91
- * guard with `if (css)` before calling `page.addStyleTag`.
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(selectors) {
94
- if (selectors.length === 0)
95
- return '';
96
- return selectors.map(s => `${s} { visibility: hidden !important; }`).join('\n');
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
@@ -1,5 +1,6 @@
1
1
  import { snapshot } from './snapshot';
2
2
  import { getCiRunId } from './ci';
3
+ export { snapshot };
3
4
  export declare const testivai: {
4
5
  witness: typeof snapshot;
5
6
  ci: typeof getCiRunId;
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
- // Check for local mode first
81
- this.localMode = isLocalMode();
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
- if (this.options.debug) {
85
- console.log('Testivai Reporter: [DEBUG] Local mode detected, skipping API key validation');
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
- const isLocalMode = process.env.TESTIVAI_MODE === 'local';
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
- const allSelectors = (0, ignore_selectors_1.collectIgnoreSelectors)(process.cwd(), projectConfig, effectiveConfig);
131
- const css = (0, ignore_selectors_1.buildIgnoreSelectorsCSS)(allSelectors);
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
- await fs.copyFile(screenshotPath, path.join(localSnapshotDir, 'screenshot.png'));
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 elementMap = await page.evaluate((0, element_map_1.buildElementMapExpression)());
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
- const metadataPath = path.join(outputDir, `${baseFilename}.json`);
756
- const metadata = {
757
- snapshotName,
758
- testName: testInfo.title,
759
- timestamp,
760
- url: page.url(),
761
- viewport: page.viewportSize() || undefined,
762
- };
763
- await fs.writeJson(metadataPath, {
764
- ...metadata,
765
- files: {
766
- screenshot: screenshotPath,
767
- // @renamed: dom → structure, css → styles (IP protection)
768
- structure: structurePath,
769
- styles: stylesPath,
770
- },
771
- layout,
772
- // Store the effective configuration for the reporter
773
- testivaiConfig: effectiveConfig,
774
- // Store unified performance metrics if captured
775
- performanceMetrics,
776
- // Store structure analysis if captured
777
- // @renamed: domAnalysis → structureAnalysis (IP protection)
778
- structureAnalysis
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?: string[];
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.5.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.6.0"
51
+ "@testivai/witness": "1.7.0"
52
52
  },
53
53
  "devDependencies": {
54
54
  "@playwright/test": "^1.60.0",