@testivai/witness-playwright 1.7.1 → 1.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -25,7 +25,6 @@ npm install -D @testivai/witness-playwright
25
25
  reporter: [
26
26
  ['line'],
27
27
  ['@testivai/witness-playwright/reporter', {
28
- // No API key needed for local mode!
29
28
  }]
30
29
  ],
31
30
  });
@@ -57,25 +56,8 @@ npm install -D @testivai/witness-playwright
57
56
 
58
57
  ## Configuration
59
58
 
60
- ### Local Mode (Default)
61
-
62
- No configuration needed! Just run `npx testivai init` and select "Local Mode".
63
-
64
- ### Cloud Mode
65
-
66
- For team dashboards and collaboration:
67
-
68
- ```typescript
69
- // playwright.config.ts
70
- export default defineConfig({
71
- reporter: [
72
- ['line'],
73
- ['@testivai/witness-playwright/reporter', {
74
- apiKey: process.env.TESTIVAI_API_KEY,
75
- }]
76
- ],
77
- });
78
- ```
59
+ No configuration needed — `npx testivai init` scaffolds `.testivai/config.json`,
60
+ the baselines directory, and the `.gitignore` entries. Everything runs locally.
79
61
 
80
62
  ## API Reference
81
63
 
@@ -1,37 +1,8 @@
1
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.
2
+ * Re-export of the canonical element-map collector, which now lives in
3
+ * `@testivai/witness` so every adapter (Playwright, Selenium, WebdriverIO)
4
+ * injects the *same* page-side function. Keeping this shim means existing
5
+ * imports and unit tests continue to work unchanged.
10
6
  */
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, ignoreSelectors?: string[]): 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, ignoreSelectors?: string[]): string;
7
+ export { collectElementMap, buildElementMapExpression, DEFAULT_MAX_ELEMENTS, } from '@testivai/witness';
8
+ export type { CollectedElement } from '@testivai/witness';
@@ -1,152 +1,13 @@
1
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
2
  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, ignoreSelectors = []) {
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
- // 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
107
- var rect = el.getBoundingClientRect();
108
- if (rect.width >= 4 && rect.height >= 4) {
109
- var styleParts = [];
110
- var hidden = false;
111
- try {
112
- var cs = win.getComputedStyle(el);
113
- for (var p = 0; p < STYLE_PROPS.length; 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);
118
- }
119
- }
120
- catch (e) {
121
- // styleHash stays a digest of the empty string — still deterministic
122
- }
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
- }
136
- }
137
- // Push children in reverse so the walk stays document-ordered
138
- for (var c = el.children.length - 1; c >= 0; c--) {
139
- stack.push(el.children[c]);
140
- }
141
- }
142
- return out;
143
- }
144
- /** Default element cap — bounds capture time and elements.json size. */
145
- exports.DEFAULT_MAX_ELEMENTS = 3000;
3
+ exports.DEFAULT_MAX_ELEMENTS = exports.buildElementMapExpression = exports.collectElementMap = void 0;
146
4
  /**
147
- * The page-side expression evaluated by the adapter. Serializes the
148
- * collector so page code and unit-tested code are the same function.
5
+ * Re-export of the canonical element-map collector, which now lives in
6
+ * `@testivai/witness` so every adapter (Playwright, Selenium, WebdriverIO)
7
+ * injects the *same* page-side function. Keeping this shim means existing
8
+ * imports and unit tests continue to work unchanged.
149
9
  */
150
- function buildElementMapExpression(maxElements = exports.DEFAULT_MAX_ELEMENTS, ignoreSelectors = []) {
151
- return `(${collectElementMap.toString()})(document, window, ${maxElements}, ${JSON.stringify(ignoreSelectors)})`;
152
- }
10
+ var witness_1 = require("@testivai/witness");
11
+ Object.defineProperty(exports, "collectElementMap", { enumerable: true, get: function () { return witness_1.collectElementMap; } });
12
+ Object.defineProperty(exports, "buildElementMapExpression", { enumerable: true, get: function () { return witness_1.buildElementMapExpression; } });
13
+ Object.defineProperty(exports, "DEFAULT_MAX_ELEMENTS", { enumerable: true, get: function () { return witness_1.DEFAULT_MAX_ELEMENTS; } });
@@ -5,6 +5,21 @@ interface TestivaiReporterOptions {
5
5
  apiKey?: string;
6
6
  compression?: CompressionOptions;
7
7
  debug?: boolean;
8
+ /**
9
+ * Capture only — write `.testivai/temp/` and skip comparison and report
10
+ * generation entirely.
11
+ *
12
+ * This is what a sharded run needs. Each shard only executes a slice of the
13
+ * suite, so comparing inside a shard reports every baseline the *other*
14
+ * shards own as missing coverage: measured on an 8-shard run, every shard
15
+ * exited 3 with ~90% of the suite listed as missing. Capture in the shards,
16
+ * merge the captures, compare once.
17
+ *
18
+ * Left unset this auto-enables when Playwright reports a sharded run
19
+ * (`--shard=i/N` with N > 1). Set it explicitly to `false` to force
20
+ * per-shard reports anyway.
21
+ */
22
+ captureOnly?: boolean;
8
23
  }
9
24
  export declare class TestivAIPlaywrightReporter implements Reporter {
10
25
  private options;
@@ -15,7 +30,16 @@ export declare class TestivAIPlaywrightReporter implements Reporter {
15
30
  private tempDir;
16
31
  private compressionHelper;
17
32
  private localMode;
33
+ private captureOnly;
34
+ private shard;
18
35
  constructor(options?: TestivaiReporterOptions);
36
+ /**
37
+ * Capture-only resolution, most explicit wins:
38
+ * 1. the reporter option (including an explicit `false` to opt out)
39
+ * 2. TESTIVAI_CAPTURE_ONLY — for CI that can't edit playwright.config
40
+ * 3. auto: Playwright says this is shard i/N with N > 1
41
+ */
42
+ private resolveCaptureOnly;
19
43
  onBegin(config: FullConfig, suite: Suite): Promise<void>;
20
44
  onEnd(result: FullResult): Promise<void>;
21
45
  }
package/dist/reporter.js CHANGED
@@ -52,16 +52,35 @@ class TestivAIPlaywrightReporter {
52
52
  this.ciInfo = null;
53
53
  this.tempDir = path.join(process.cwd(), '.testivai', 'temp');
54
54
  this.localMode = false;
55
+ this.captureOnly = false;
56
+ this.shard = null;
55
57
  this.options = {
56
58
  apiUrl: options.apiUrl || process.env.TESTIVAI_API_URL || 'https://core-api.testiv.ai',
57
59
  apiKey: options.apiKey || process.env.TESTIVAI_API_KEY,
58
60
  compression: options.compression || {},
59
61
  debug: options.debug || process.env.TESTIVAI_DEBUG === 'true',
62
+ captureOnly: options.captureOnly,
60
63
  };
61
64
  // Initialize compression helper
62
65
  this.compressionHelper = new common_1.CompressionHelper(this.options.compression);
63
66
  }
67
+ /**
68
+ * Capture-only resolution, most explicit wins:
69
+ * 1. the reporter option (including an explicit `false` to opt out)
70
+ * 2. TESTIVAI_CAPTURE_ONLY — for CI that can't edit playwright.config
71
+ * 3. auto: Playwright says this is shard i/N with N > 1
72
+ */
73
+ resolveCaptureOnly(config) {
74
+ if (typeof this.options.captureOnly === 'boolean')
75
+ return this.options.captureOnly;
76
+ const env = process.env.TESTIVAI_CAPTURE_ONLY;
77
+ if (env !== undefined && env !== '')
78
+ return env !== '0' && env.toLowerCase() !== 'false';
79
+ return !!(this.shard && this.shard.total > 1);
80
+ }
64
81
  async onBegin(config, suite) {
82
+ this.shard = config.shard ?? null;
83
+ this.captureOnly = this.resolveCaptureOnly(config);
65
84
  // Resolve mode. Local-first is the default: with no API key we capture
66
85
  // and report locally rather than disabling the reporter. Cloud mode
67
86
  // activates only when a key is present (or config/env force it).
@@ -114,6 +133,43 @@ class TestivAIPlaywrightReporter {
114
133
  async onEnd(result) {
115
134
  // Wrap entire reporter logic in try-catch to prevent crashes
116
135
  try {
136
+ // ── Capture-only: leave the captures on disk and stop ─────────────────
137
+ // Comparing here would be wrong for a shard (it can only see its own
138
+ // slice) so we say what to do next instead of producing a misleading
139
+ // report and exit code.
140
+ if (this.captureOnly) {
141
+ // Drop a manifest naming this shard, so the merge step can prove every
142
+ // shard reported. Written HERE rather than at onBegin on purpose: a
143
+ // shard killed mid-run (OOM, runner loss) never reaches onEnd and so
144
+ // leaves no manifest, which is precisely the case worth catching.
145
+ // Without this a crashed shard silently reduces coverage whenever
146
+ // failOnMissing is off.
147
+ if (this.shard) {
148
+ try {
149
+ const tempRoot = path.join(process.cwd(), '.testivai', 'temp');
150
+ fs.ensureDirSync(tempRoot);
151
+ const captures = fs
152
+ .readdirSync(tempRoot, { withFileTypes: true })
153
+ .filter((e) => e.isDirectory())
154
+ .map((e) => e.name);
155
+ fs.writeJsonSync(path.join(tempRoot, 'testivai-shard.json'), {
156
+ shard: { current: this.shard.current, total: this.shard.total },
157
+ captures,
158
+ status: result.status,
159
+ timestamp: new Date().toISOString(),
160
+ });
161
+ }
162
+ catch {
163
+ // A manifest we cannot write must not fail the test run; the merge
164
+ // step degrades to "no completeness check" and says so.
165
+ }
166
+ }
167
+ const where = this.shard ? `shard ${this.shard.current}/${this.shard.total}` : 'capture-only mode';
168
+ console.log(`\n TestivAI: ${where} — captured to .testivai/temp/, comparison skipped.`);
169
+ console.log(' Collect every shard\'s .testivai/temp/, then compare once:');
170
+ console.log(' npx testivai merge-captures <dirs...> && npx testivai report --fail-on-diff');
171
+ return;
172
+ }
117
173
  // ── Local mode: generate HTML report instead of uploading ─────────────────
118
174
  if (this.localMode) {
119
175
  if (this.options.debug) {
@@ -134,10 +190,20 @@ class TestivAIPlaywrightReporter {
134
190
  });
135
191
  // Print summary
136
192
  const { summary } = reportData;
193
+ const missing = reportData.missingBaselines?.length ?? 0;
137
194
  console.log(`\n ═══ TestivAI Visual Report ═══`);
138
195
  console.log(` Total: ${summary.total} | Passed: ${summary.passed} | Changed: ${summary.changed} | New: ${summary.newSnapshots}`);
139
- if (summary.changed > 0 || summary.newSnapshots > 0) {
140
- console.log(`\n To approve: npx testivai approve --all`);
196
+ if (missing > 0) {
197
+ console.log(` Missing baselines (no capture this run): ${missing}`);
198
+ }
199
+ // A reporter cannot change the process exit code — Playwright owns it,
200
+ // and it reflects test results, not visual results. Saying nothing here
201
+ // meant a run could print "Changed: 3" and still exit 0, which reads
202
+ // like a check that passed. State it plainly and name the gate.
203
+ if (summary.changed > 0 || summary.newSnapshots > 0 || missing > 0) {
204
+ console.log(`\n ⚠ This did NOT fail the build — a reporter cannot set the exit code.`);
205
+ console.log(` Gate it in CI: npx testivai report --fail-on-diff`);
206
+ console.log(` To approve: npx testivai approve --all`);
141
207
  }
142
208
  if (this.options.debug) {
143
209
  console.log(`Testivai Reporter: [DEBUG] Report generated at ${path.join(process.cwd(), localConfig.reportDir || 'visual-report')}`);
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@testivai/witness-playwright",
3
- "version": "1.7.1",
4
- "description": "Playwright sensor for Testivai Visual Regression Test system",
3
+ "version": "1.8.0",
4
+ "description": "Playwright adapter for TestivAI Witness — local-first visual regression with pixel + DOM comparison",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
7
7
  "exports": {
@@ -11,8 +11,12 @@
11
11
  "keywords": [
12
12
  "playwright",
13
13
  "visual-regression",
14
- "testing",
15
- "testivai"
14
+ "visual-testing",
15
+ "screenshot-testing",
16
+ "regression-testing",
17
+ "testivai",
18
+ "playwright-reporter",
19
+ "test-automation"
16
20
  ],
17
21
  "author": "TestivAI",
18
22
  "license": "MIT",
@@ -47,8 +51,8 @@
47
51
  "fs-extra": "^11.3.5",
48
52
  "sharp": "^0.34.5",
49
53
  "simple-git": "^3.21.0",
50
- "@testivai/common": "0.2.2",
51
- "@testivai/witness": "1.11.1"
54
+ "@testivai/common": "0.2.3",
55
+ "@testivai/witness": "1.12.0"
52
56
  },
53
57
  "devDependencies": {
54
58
  "@playwright/test": "^1.60.0",