@testivai/witness-playwright 1.7.1 → 1.9.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 +2 -20
- package/dist/capture/element-map.d.ts +6 -35
- package/dist/capture/element-map.js +9 -148
- package/dist/config/stabilize.d.ts +18 -0
- package/dist/config/stabilize.js +49 -0
- package/dist/reporter.d.ts +24 -0
- package/dist/reporter.js +52 -2
- package/dist/snapshot.js +5 -0
- package/package.json +10 -6
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
|
-
|
|
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
|
-
*
|
|
3
|
-
*
|
|
4
|
-
*
|
|
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
|
|
12
|
-
|
|
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
|
-
*
|
|
148
|
-
*
|
|
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
|
-
|
|
151
|
-
|
|
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; } });
|
|
@@ -33,3 +33,21 @@ export declare function resolveStabilize(projectRoot: string, projectConfig: Tes
|
|
|
33
33
|
* a missing FontFaceSet API just means no wait.
|
|
34
34
|
*/
|
|
35
35
|
export declare function waitForFonts(page: Page): Promise<void>;
|
|
36
|
+
/**
|
|
37
|
+
* Wait until the page has stopped changing: document complete, images
|
|
38
|
+
* finished, fonts loaded, and no DOM mutations for `quietMs`.
|
|
39
|
+
*
|
|
40
|
+
* Deliberately NOT `networkidle` — Playwright's own docs mark that DISCOURAGED
|
|
41
|
+
* for testing, and it is the wrong signal for a visual snapshot: a page with
|
|
42
|
+
* analytics beacons never goes quiet, while a network-idle page can still be
|
|
43
|
+
* animating. What matters is whether the rendered page settled.
|
|
44
|
+
*
|
|
45
|
+
* Always bounded; a page that never settles yields a capture, not a hang.
|
|
46
|
+
*/
|
|
47
|
+
export declare function waitForSettled(page: {
|
|
48
|
+
evaluate: (expr: string) => Promise<unknown>;
|
|
49
|
+
}, quietMs?: number, timeoutMs?: number): Promise<void>;
|
|
50
|
+
/** Detach the settle observer so it does not linger on the page under test. */
|
|
51
|
+
export declare function stopSettleObserver(page: {
|
|
52
|
+
evaluate: (expr: string) => Promise<unknown>;
|
|
53
|
+
}): Promise<void>;
|
package/dist/config/stabilize.js
CHANGED
|
@@ -52,6 +52,9 @@ exports.STABILIZE_CSS = void 0;
|
|
|
52
52
|
exports.readWitnessConfigStabilize = readWitnessConfigStabilize;
|
|
53
53
|
exports.resolveStabilize = resolveStabilize;
|
|
54
54
|
exports.waitForFonts = waitForFonts;
|
|
55
|
+
exports.waitForSettled = waitForSettled;
|
|
56
|
+
exports.stopSettleObserver = stopSettleObserver;
|
|
57
|
+
const witness_1 = require("@testivai/witness");
|
|
55
58
|
const fs = __importStar(require("fs"));
|
|
56
59
|
const path = __importStar(require("path"));
|
|
57
60
|
/**
|
|
@@ -110,3 +113,49 @@ async function waitForFonts(page) {
|
|
|
110
113
|
// Page navigated or evaluate failed — capture proceeds unstabilized fonts
|
|
111
114
|
}
|
|
112
115
|
}
|
|
116
|
+
/**
|
|
117
|
+
* Wait until the page has stopped changing: document complete, images
|
|
118
|
+
* finished, fonts loaded, and no DOM mutations for `quietMs`.
|
|
119
|
+
*
|
|
120
|
+
* Deliberately NOT `networkidle` — Playwright's own docs mark that DISCOURAGED
|
|
121
|
+
* for testing, and it is the wrong signal for a visual snapshot: a page with
|
|
122
|
+
* analytics beacons never goes quiet, while a network-idle page can still be
|
|
123
|
+
* animating. What matters is whether the rendered page settled.
|
|
124
|
+
*
|
|
125
|
+
* Always bounded; a page that never settles yields a capture, not a hang.
|
|
126
|
+
*/
|
|
127
|
+
async function waitForSettled(page, quietMs = witness_1.DEFAULT_QUIET_MS, timeoutMs = witness_1.DEFAULT_SETTLE_TIMEOUT_MS) {
|
|
128
|
+
const expr = (0, witness_1.buildSettleProbeExpression)(quietMs);
|
|
129
|
+
const deadline = Date.now() + timeoutMs;
|
|
130
|
+
for (;;) {
|
|
131
|
+
let state;
|
|
132
|
+
try {
|
|
133
|
+
state = (await page.evaluate(expr));
|
|
134
|
+
}
|
|
135
|
+
catch {
|
|
136
|
+
return; // probe unavailable — never block the capture
|
|
137
|
+
}
|
|
138
|
+
// An unusable answer means we cannot probe; polling to the timeout would
|
|
139
|
+
// add the full wait to every capture.
|
|
140
|
+
if (!state || typeof state.settled !== 'boolean')
|
|
141
|
+
return;
|
|
142
|
+
if (state.settled)
|
|
143
|
+
return;
|
|
144
|
+
if (Date.now() >= deadline) {
|
|
145
|
+
if (process.env.TESTIVAI_DEBUG === 'true') {
|
|
146
|
+
console.log(`[TestivAI] page did not settle within ${timeoutMs}ms — capturing anyway`);
|
|
147
|
+
}
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
/** Detach the settle observer so it does not linger on the page under test. */
|
|
154
|
+
async function stopSettleObserver(page) {
|
|
155
|
+
try {
|
|
156
|
+
await page.evaluate(witness_1.SETTLE_STOP_EXPRESSION);
|
|
157
|
+
}
|
|
158
|
+
catch {
|
|
159
|
+
// best-effort cleanup
|
|
160
|
+
}
|
|
161
|
+
}
|
package/dist/reporter.d.ts
CHANGED
|
@@ -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
|
@@ -44,6 +44,7 @@ const axios_1 = __importDefault(require("axios"));
|
|
|
44
44
|
const ci_1 = require("./ci");
|
|
45
45
|
const common_1 = require("@testivai/common");
|
|
46
46
|
const mode_1 = require("./mode");
|
|
47
|
+
const witness_1 = require("@testivai/witness");
|
|
47
48
|
class TestivAIPlaywrightReporter {
|
|
48
49
|
constructor(options = {}) {
|
|
49
50
|
this.gitInfo = null;
|
|
@@ -52,16 +53,32 @@ class TestivAIPlaywrightReporter {
|
|
|
52
53
|
this.ciInfo = null;
|
|
53
54
|
this.tempDir = path.join(process.cwd(), '.testivai', 'temp');
|
|
54
55
|
this.localMode = false;
|
|
56
|
+
this.captureOnly = false;
|
|
57
|
+
this.shard = null;
|
|
55
58
|
this.options = {
|
|
56
59
|
apiUrl: options.apiUrl || process.env.TESTIVAI_API_URL || 'https://core-api.testiv.ai',
|
|
57
60
|
apiKey: options.apiKey || process.env.TESTIVAI_API_KEY,
|
|
58
61
|
compression: options.compression || {},
|
|
59
62
|
debug: options.debug || process.env.TESTIVAI_DEBUG === 'true',
|
|
63
|
+
captureOnly: options.captureOnly,
|
|
60
64
|
};
|
|
61
65
|
// Initialize compression helper
|
|
62
66
|
this.compressionHelper = new common_1.CompressionHelper(this.options.compression);
|
|
63
67
|
}
|
|
68
|
+
/**
|
|
69
|
+
* Capture-only resolution, most explicit wins:
|
|
70
|
+
* 1. the reporter option (including an explicit `false` to opt out)
|
|
71
|
+
* 2. TESTIVAI_CAPTURE_ONLY — for CI that can't edit playwright.config
|
|
72
|
+
* 3. auto: Playwright says this is shard i/N with N > 1
|
|
73
|
+
*/
|
|
74
|
+
resolveCaptureOnly(_config) {
|
|
75
|
+
return (0, witness_1.resolveCaptureOnly)(this.options.captureOnly, this.shard);
|
|
76
|
+
}
|
|
64
77
|
async onBegin(config, suite) {
|
|
78
|
+
// Playwright's own --shard wins; TESTIVAI_SHARD covers CI that splits work
|
|
79
|
+
// some other way, and is the same contract the non-JS adapters honour.
|
|
80
|
+
this.shard = config.shard ?? (0, witness_1.parseShardEnv)(process.env.TESTIVAI_SHARD);
|
|
81
|
+
this.captureOnly = this.resolveCaptureOnly(config);
|
|
65
82
|
// Resolve mode. Local-first is the default: with no API key we capture
|
|
66
83
|
// and report locally rather than disabling the reporter. Cloud mode
|
|
67
84
|
// activates only when a key is present (or config/env force it).
|
|
@@ -114,6 +131,29 @@ class TestivAIPlaywrightReporter {
|
|
|
114
131
|
async onEnd(result) {
|
|
115
132
|
// Wrap entire reporter logic in try-catch to prevent crashes
|
|
116
133
|
try {
|
|
134
|
+
// ── Capture-only: leave the captures on disk and stop ─────────────────
|
|
135
|
+
// Comparing here would be wrong for a shard (it can only see its own
|
|
136
|
+
// slice) so we say what to do next instead of producing a misleading
|
|
137
|
+
// report and exit code.
|
|
138
|
+
if (this.captureOnly) {
|
|
139
|
+
// Drop a manifest naming this shard, so the merge step can prove every
|
|
140
|
+
// shard reported. Written HERE rather than at onBegin on purpose: a
|
|
141
|
+
// shard killed mid-run (OOM, runner loss) never reaches onEnd and so
|
|
142
|
+
// leaves no manifest, which is precisely the case worth catching.
|
|
143
|
+
// Without this a crashed shard silently reduces coverage whenever
|
|
144
|
+
// failOnMissing is off.
|
|
145
|
+
if (this.shard) {
|
|
146
|
+
(0, witness_1.writeShardManifest)(path.join(process.cwd(), '.testivai', 'temp'), this.shard, {
|
|
147
|
+
complete: true,
|
|
148
|
+
status: result.status,
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
const where = this.shard ? `shard ${this.shard.current}/${this.shard.total}` : 'capture-only mode';
|
|
152
|
+
console.log(`\n TestivAI: ${where} — captured to .testivai/temp/, comparison skipped.`);
|
|
153
|
+
console.log(' Collect every shard\'s .testivai/temp/, then compare once:');
|
|
154
|
+
console.log(' npx testivai merge-captures <dirs...> && npx testivai report --fail-on-diff');
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
117
157
|
// ── Local mode: generate HTML report instead of uploading ─────────────────
|
|
118
158
|
if (this.localMode) {
|
|
119
159
|
if (this.options.debug) {
|
|
@@ -134,10 +174,20 @@ class TestivAIPlaywrightReporter {
|
|
|
134
174
|
});
|
|
135
175
|
// Print summary
|
|
136
176
|
const { summary } = reportData;
|
|
177
|
+
const missing = reportData.missingBaselines?.length ?? 0;
|
|
137
178
|
console.log(`\n ═══ TestivAI Visual Report ═══`);
|
|
138
179
|
console.log(` Total: ${summary.total} | Passed: ${summary.passed} | Changed: ${summary.changed} | New: ${summary.newSnapshots}`);
|
|
139
|
-
if (
|
|
140
|
-
console.log(
|
|
180
|
+
if (missing > 0) {
|
|
181
|
+
console.log(` Missing baselines (no capture this run): ${missing}`);
|
|
182
|
+
}
|
|
183
|
+
// A reporter cannot change the process exit code — Playwright owns it,
|
|
184
|
+
// and it reflects test results, not visual results. Saying nothing here
|
|
185
|
+
// meant a run could print "Changed: 3" and still exit 0, which reads
|
|
186
|
+
// like a check that passed. State it plainly and name the gate.
|
|
187
|
+
if (summary.changed > 0 || summary.newSnapshots > 0 || missing > 0) {
|
|
188
|
+
console.log(`\n ⚠ This did NOT fail the build — a reporter cannot set the exit code.`);
|
|
189
|
+
console.log(` Gate it in CI: npx testivai report --fail-on-diff`);
|
|
190
|
+
console.log(` To approve: npx testivai approve --all`);
|
|
141
191
|
}
|
|
142
192
|
if (this.options.debug) {
|
|
143
193
|
console.log(`Testivai Reporter: [DEBUG] Report generated at ${path.join(process.cwd(), localConfig.reportDir || 'visual-report')}`);
|
package/dist/snapshot.js
CHANGED
|
@@ -152,6 +152,10 @@ async function snapshot(page, testInfo, name, config) {
|
|
|
152
152
|
// Style injection can fail on locked-down pages; capture proceeds
|
|
153
153
|
}
|
|
154
154
|
await (0, stabilize_1.waitForFonts)(page);
|
|
155
|
+
// Then wait for the page itself to stop changing — images finished, DOM
|
|
156
|
+
// quiet. This is the load question the DOM/style layer cannot answer:
|
|
157
|
+
// content that never arrived reads as a real change, not as noise.
|
|
158
|
+
await (0, stabilize_1.waitForSettled)(page);
|
|
155
159
|
}
|
|
156
160
|
// Check if scroll-and-stitch is explicitly requested (backup method)
|
|
157
161
|
if (effectiveConfig.useBrowserCapture === false) {
|
|
@@ -378,6 +382,7 @@ async function snapshot(page, testInfo, name, config) {
|
|
|
378
382
|
if (stabilizeStyleEl) {
|
|
379
383
|
await stabilizeStyleEl.evaluate((el) => el.remove()).catch(() => { });
|
|
380
384
|
stabilizeStyleEl = null;
|
|
385
|
+
await (0, stabilize_1.stopSettleObserver)(page);
|
|
381
386
|
}
|
|
382
387
|
// 1.5. Local mode: also place the screenshot in the layout expected by
|
|
383
388
|
// @testivai/witness/report (subdirectory keyed by snapshot name).
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@testivai/witness-playwright",
|
|
3
|
-
"version": "1.
|
|
4
|
-
"description": "Playwright
|
|
3
|
+
"version": "1.9.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
|
-
"
|
|
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.
|
|
51
|
-
"@testivai/witness": "1.
|
|
54
|
+
"@testivai/common": "0.2.3",
|
|
55
|
+
"@testivai/witness": "1.13.0"
|
|
52
56
|
},
|
|
53
57
|
"devDependencies": {
|
|
54
58
|
"@playwright/test": "^1.60.0",
|