@testivai/witness-playwright 2.0.9 → 2.1.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 +4 -2
- package/dist/capture/element-map.d.ts +1 -1
- package/dist/capture/element-map.js +2 -1
- package/dist/capture/emulation.d.ts +41 -0
- package/dist/capture/emulation.js +79 -0
- package/dist/capture/environment.d.ts +36 -0
- package/dist/capture/environment.js +56 -0
- package/dist/capture/health.d.ts +24 -0
- package/dist/capture/health.js +40 -0
- package/dist/reporter.js +4 -0
- package/dist/snapshot.js +92 -3
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -100,8 +100,10 @@ jobs:
|
|
|
100
100
|
- run: npm ci
|
|
101
101
|
- run: npx playwright install chromium
|
|
102
102
|
- run: npx playwright test
|
|
103
|
-
|
|
104
|
-
|
|
103
|
+
# After a failing test too: the reporter writes the report whatever
|
|
104
|
+
# the tests did, and the action posts it.
|
|
105
|
+
- uses: testivai/testivai-oss@v1
|
|
106
|
+
if: ${{ !cancelled() }}
|
|
105
107
|
with:
|
|
106
108
|
github-token: ${{ secrets.GITHUB_TOKEN }}
|
|
107
109
|
report-dir: visual-report
|
|
@@ -4,5 +4,5 @@
|
|
|
4
4
|
* injects the *same* page-side function. Keeping this shim means existing
|
|
5
5
|
* imports and unit tests continue to work unchanged.
|
|
6
6
|
*/
|
|
7
|
-
export { collectElementMap, buildElementMapExpression, DEFAULT_MAX_ELEMENTS, } from '@testivai/witness';
|
|
7
|
+
export { collectElementMap, buildElementMapExpression, DEFAULT_MAX_ELEMENTS, normalizeElementIdAttributes, } from '@testivai/witness';
|
|
8
8
|
export type { CollectedElement } from '@testivai/witness';
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.DEFAULT_MAX_ELEMENTS = exports.buildElementMapExpression = exports.collectElementMap = void 0;
|
|
3
|
+
exports.normalizeElementIdAttributes = exports.DEFAULT_MAX_ELEMENTS = exports.buildElementMapExpression = exports.collectElementMap = void 0;
|
|
4
4
|
/**
|
|
5
5
|
* Re-export of the canonical element-map collector, which now lives in
|
|
6
6
|
* `@testivai/witness` so every adapter (Playwright, Selenium, WebdriverIO)
|
|
@@ -11,3 +11,4 @@ var witness_1 = require("@testivai/witness");
|
|
|
11
11
|
Object.defineProperty(exports, "collectElementMap", { enumerable: true, get: function () { return witness_1.collectElementMap; } });
|
|
12
12
|
Object.defineProperty(exports, "buildElementMapExpression", { enumerable: true, get: function () { return witness_1.buildElementMapExpression; } });
|
|
13
13
|
Object.defineProperty(exports, "DEFAULT_MAX_ELEMENTS", { enumerable: true, get: function () { return witness_1.DEFAULT_MAX_ELEMENTS; } });
|
|
14
|
+
Object.defineProperty(exports, "normalizeElementIdAttributes", { enumerable: true, get: function () { return witness_1.normalizeElementIdAttributes; } });
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import type { Page } from '@playwright/test';
|
|
2
|
+
/**
|
|
3
|
+
* Device emulation around the default full-page capture.
|
|
4
|
+
*
|
|
5
|
+
* The capture runs `Page.captureScreenshot` with `captureBeyondViewport` over
|
|
6
|
+
* a CDP session of our own. Chrome resizes the page for it and afterwards
|
|
7
|
+
* restores that session's emulation, which is none: the device emulation
|
|
8
|
+
* Playwright set on its own session is gone. Measured on the Pixel 7 profile:
|
|
9
|
+
* devicePixelRatio 2.625 → 1 and the touch pointer gone (`pointer: coarse`
|
|
10
|
+
* and `hover: none` false; maxTouchPoints 1 → 0 on Chrome 153, kept on
|
|
11
|
+
* Chrome 156) for the rest of the test. The image itself is
|
|
12
|
+
* rendered at a pixel ratio of 1 (412 px wide for a 412 px viewport).
|
|
13
|
+
*
|
|
14
|
+
* witness() reads the emulation before and right after the capture, records
|
|
15
|
+
* the pixel ratio the image was rendered at, and restores what the capture
|
|
16
|
+
* dropped once it has measured everything else in the page (the element map
|
|
17
|
+
* must stay aligned with the 1× image).
|
|
18
|
+
*/
|
|
19
|
+
export interface EmulationState {
|
|
20
|
+
dpr: number;
|
|
21
|
+
maxTouchPoints: number;
|
|
22
|
+
/** `(pointer: coarse)`: what touch emulation means to the page's CSS. */
|
|
23
|
+
coarsePointer: boolean;
|
|
24
|
+
}
|
|
25
|
+
/** Evaluated in the page; a self-contained expression. */
|
|
26
|
+
export declare const EMULATION_PROBE = "({ dpr: window.devicePixelRatio, maxTouchPoints: navigator.maxTouchPoints, coarsePointer: window.matchMedia('(pointer: coarse)').matches })";
|
|
27
|
+
/** The page's pixel ratio and touch points, or null when the page cannot say. */
|
|
28
|
+
export declare function readEmulation(page: Pick<Page, 'evaluate'>): Promise<EmulationState | null>;
|
|
29
|
+
/**
|
|
30
|
+
* Put back what the capture dropped: the pixel ratio (Playwright re-applies
|
|
31
|
+
* its device metrics, mobile flag included, on setViewportSize) and touch
|
|
32
|
+
* emulation. The page sees two resize events, after a capture that resized
|
|
33
|
+
* it already. Nothing is done when nothing changed, so a profile the capture
|
|
34
|
+
* leaves alone is never touched. Best effort: never fails the capture.
|
|
35
|
+
*/
|
|
36
|
+
export declare function restoreEmulation(page: Page, before: EmulationState | null): Promise<void>;
|
|
37
|
+
/**
|
|
38
|
+
* The render probe with the pixel ratio the image was rendered at: the CDP
|
|
39
|
+
* capture renders at 1 whatever the profile says.
|
|
40
|
+
*/
|
|
41
|
+
export declare function withCapturedScale(probe: unknown, atCapture: EmulationState | null): unknown;
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.EMULATION_PROBE = void 0;
|
|
4
|
+
exports.readEmulation = readEmulation;
|
|
5
|
+
exports.restoreEmulation = restoreEmulation;
|
|
6
|
+
exports.withCapturedScale = withCapturedScale;
|
|
7
|
+
/** Evaluated in the page; a self-contained expression. */
|
|
8
|
+
exports.EMULATION_PROBE = "({ dpr: window.devicePixelRatio, maxTouchPoints: navigator.maxTouchPoints, coarsePointer: window.matchMedia('(pointer: coarse)').matches })";
|
|
9
|
+
/** The page's pixel ratio and touch points, or null when the page cannot say. */
|
|
10
|
+
async function readEmulation(page) {
|
|
11
|
+
try {
|
|
12
|
+
const raw = (await page.evaluate(exports.EMULATION_PROBE));
|
|
13
|
+
if (raw && typeof raw.dpr === 'number' && typeof raw.maxTouchPoints === 'number') {
|
|
14
|
+
return { dpr: raw.dpr, maxTouchPoints: raw.maxTouchPoints, coarsePointer: raw.coarsePointer === true };
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
catch {
|
|
18
|
+
// a page that cannot be evaluated has nothing to restore
|
|
19
|
+
}
|
|
20
|
+
return null;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Touch emulation lives on the CDP session that set it, and goes when that
|
|
24
|
+
* session detaches, so the session that restores it stays attached for the
|
|
25
|
+
* page's lifetime, one per page.
|
|
26
|
+
*/
|
|
27
|
+
const touchSessions = new WeakMap();
|
|
28
|
+
/**
|
|
29
|
+
* Put back what the capture dropped: the pixel ratio (Playwright re-applies
|
|
30
|
+
* its device metrics, mobile flag included, on setViewportSize) and touch
|
|
31
|
+
* emulation. The page sees two resize events, after a capture that resized
|
|
32
|
+
* it already. Nothing is done when nothing changed, so a profile the capture
|
|
33
|
+
* leaves alone is never touched. Best effort: never fails the capture.
|
|
34
|
+
*/
|
|
35
|
+
async function restoreEmulation(page, before) {
|
|
36
|
+
if (!before)
|
|
37
|
+
return;
|
|
38
|
+
const now = await readEmulation(page);
|
|
39
|
+
if (!now)
|
|
40
|
+
return;
|
|
41
|
+
if (now.dpr === before.dpr && now.maxTouchPoints === before.maxTouchPoints && now.coarsePointer === before.coarsePointer)
|
|
42
|
+
return;
|
|
43
|
+
try {
|
|
44
|
+
// Touch emulation, whenever the page had touch and its touch points or
|
|
45
|
+
// pointer media changed. What the capture drops depends on Chrome:
|
|
46
|
+
// Chrome 153 drops the touch points and `pointer: coarse`; Chrome 156
|
|
47
|
+
// keeps the touch points and drops `pointer: coarse` and `hover: none`.
|
|
48
|
+
// Sending it again brings back all three; device metrics alone do not.
|
|
49
|
+
if (before.maxTouchPoints > 0 &&
|
|
50
|
+
(now.maxTouchPoints !== before.maxTouchPoints || now.coarsePointer !== before.coarsePointer)) {
|
|
51
|
+
let session = touchSessions.get(page);
|
|
52
|
+
if (!session) {
|
|
53
|
+
session = await page.context().newCDPSession(page);
|
|
54
|
+
touchSessions.set(page, session);
|
|
55
|
+
}
|
|
56
|
+
await session.send('Emulation.setTouchEmulationEnabled', { enabled: true, maxTouchPoints: before.maxTouchPoints });
|
|
57
|
+
}
|
|
58
|
+
// The device metrics (pixel ratio, mobile). Playwright does not send a viewport equal to
|
|
59
|
+
// the last one it set, so re-applying the same size does nothing: step
|
|
60
|
+
// 1 px away and back.
|
|
61
|
+
const viewport = page.viewportSize();
|
|
62
|
+
if (viewport) {
|
|
63
|
+
await page.setViewportSize({ width: viewport.width, height: viewport.height + 1 });
|
|
64
|
+
await page.setViewportSize(viewport);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
catch {
|
|
68
|
+
// the capture is written; a page we cannot restore stays as Chrome left it
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* The render probe with the pixel ratio the image was rendered at: the CDP
|
|
73
|
+
* capture renders at 1 whatever the profile says.
|
|
74
|
+
*/
|
|
75
|
+
function withCapturedScale(probe, atCapture) {
|
|
76
|
+
if (!atCapture || !probe || typeof probe !== 'object' || Array.isArray(probe))
|
|
77
|
+
return probe;
|
|
78
|
+
return { ...probe, deviceScaleFactor: atCapture.dpr };
|
|
79
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Capture provenance for the Playwright adapter: the `environment` block
|
|
3
|
+
* witness() records in the capture's metadata.json (see capture/environment.ts
|
|
4
|
+
* in @testivai/witness for the shape and why it matters).
|
|
5
|
+
*
|
|
6
|
+
* Sources, in order of trust: the project's own `use` settings and Playwright's
|
|
7
|
+
* browser.version(); the in-page render probe (what the page is actually
|
|
8
|
+
* rendered with); the user agent as the fallback for a browser that cannot be
|
|
9
|
+
* asked (a persistent context has no browser()). Nothing is guessed: what none
|
|
10
|
+
* of them says is left out, or null for the version.
|
|
11
|
+
*/
|
|
12
|
+
import { type CaptureEnvironment, type HostEnvironment } from '@testivai/witness';
|
|
13
|
+
/** The parts of Playwright's TestInfo this reads (structural, so tests need no runner). */
|
|
14
|
+
export interface TestInfoLike {
|
|
15
|
+
config?: {
|
|
16
|
+
version?: string;
|
|
17
|
+
};
|
|
18
|
+
project?: {
|
|
19
|
+
name?: string;
|
|
20
|
+
use?: Record<string, unknown>;
|
|
21
|
+
};
|
|
22
|
+
file?: string;
|
|
23
|
+
}
|
|
24
|
+
/** The engine witness() runs in: the project's browserName, else the user agent, else Playwright's default. */
|
|
25
|
+
export declare function playwrightBrowserName(testInfo: TestInfoLike, userAgent: string | undefined): string;
|
|
26
|
+
export interface PlaywrightEnvironmentInput {
|
|
27
|
+
testInfo: TestInfoLike;
|
|
28
|
+
/** page.context().browser()?.version(), or null when there is no browser to ask. */
|
|
29
|
+
browserVersion: string | null;
|
|
30
|
+
/** The raw RENDER_PROBE_EXPRESSION result (null when the probe failed). */
|
|
31
|
+
probe: unknown;
|
|
32
|
+
captureMethod: string;
|
|
33
|
+
/** Injected in tests; collected from this machine otherwise. */
|
|
34
|
+
host?: HostEnvironment;
|
|
35
|
+
}
|
|
36
|
+
export declare function playwrightEnvironment(input: PlaywrightEnvironmentInput): CaptureEnvironment;
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.playwrightBrowserName = playwrightBrowserName;
|
|
4
|
+
exports.playwrightEnvironment = playwrightEnvironment;
|
|
5
|
+
/**
|
|
6
|
+
* Capture provenance for the Playwright adapter: the `environment` block
|
|
7
|
+
* witness() records in the capture's metadata.json (see capture/environment.ts
|
|
8
|
+
* in @testivai/witness for the shape and why it matters).
|
|
9
|
+
*
|
|
10
|
+
* Sources, in order of trust: the project's own `use` settings and Playwright's
|
|
11
|
+
* browser.version(); the in-page render probe (what the page is actually
|
|
12
|
+
* rendered with); the user agent as the fallback for a browser that cannot be
|
|
13
|
+
* asked (a persistent context has no browser()). Nothing is guessed: what none
|
|
14
|
+
* of them says is left out, or null for the version.
|
|
15
|
+
*/
|
|
16
|
+
const witness_1 = require("@testivai/witness");
|
|
17
|
+
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
|
18
|
+
const ADAPTER_VERSION = require('../../package.json').version;
|
|
19
|
+
/** The engine witness() runs in: the project's browserName, else the user agent, else Playwright's default. */
|
|
20
|
+
function playwrightBrowserName(testInfo, userAgent) {
|
|
21
|
+
const configured = testInfo.project?.use?.browserName;
|
|
22
|
+
if (typeof configured === 'string' && configured.length > 0)
|
|
23
|
+
return configured;
|
|
24
|
+
return (0, witness_1.browserFromUserAgent)(userAgent ?? '')?.name ?? 'chromium';
|
|
25
|
+
}
|
|
26
|
+
function playwrightEnvironment(input) {
|
|
27
|
+
const { testInfo } = input;
|
|
28
|
+
const use = testInfo.project?.use ?? {};
|
|
29
|
+
const probed = (0, witness_1.parseRenderProbe)(input.probe);
|
|
30
|
+
const fromUa = probed?.userAgent ? (0, witness_1.browserFromUserAgent)(probed.userAgent) : null;
|
|
31
|
+
const browser = {
|
|
32
|
+
name: playwrightBrowserName(testInfo, probed?.userAgent),
|
|
33
|
+
version: input.browserVersion ?? fromUa?.version ?? null,
|
|
34
|
+
};
|
|
35
|
+
if (typeof use.channel === 'string' && use.channel.length > 0)
|
|
36
|
+
browser.channel = use.channel;
|
|
37
|
+
if (typeof use.headless === 'boolean')
|
|
38
|
+
browser.headless = use.headless;
|
|
39
|
+
else if (fromUa?.headless)
|
|
40
|
+
browser.headless = true;
|
|
41
|
+
const file = testInfo.file ?? '';
|
|
42
|
+
const projectName = testInfo.project?.name;
|
|
43
|
+
return (0, witness_1.buildCaptureEnvironment)({
|
|
44
|
+
host: input.host ?? (0, witness_1.collectHostEnvironment)(),
|
|
45
|
+
browser,
|
|
46
|
+
framework: {
|
|
47
|
+
name: 'playwright',
|
|
48
|
+
version: testInfo.config?.version ?? null,
|
|
49
|
+
language: /\.[cm]?tsx?$/.test(file) ? 'typescript' : 'javascript',
|
|
50
|
+
runner: '@playwright/test',
|
|
51
|
+
},
|
|
52
|
+
adapter: { name: '@testivai/witness-playwright', version: ADAPTER_VERSION, captureMethod: input.captureMethod },
|
|
53
|
+
render: probed?.render,
|
|
54
|
+
project: typeof projectName === 'string' && projectName.length > 0 ? projectName : undefined,
|
|
55
|
+
});
|
|
56
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Capture health for the Playwright adapter: what failed to load (web fonts,
|
|
3
|
+
* stylesheets, images, requests that answered 400 or more), read by the
|
|
4
|
+
* shared probe in the page right AFTER the screenshot, so it sees what loaded,
|
|
5
|
+
* and BEFORE the ignoreSelectors and stabilize styles come off: removing them
|
|
6
|
+
* shows elements that were hidden in the capture, and a web font only such an
|
|
7
|
+
* element uses starts loading then, for glyphs that were never in the image.
|
|
8
|
+
* Merged into the capture's metadata.json as `health`; approving freezes it
|
|
9
|
+
* into the baseline's. See capture/health.ts in @testivai/witness for the
|
|
10
|
+
* shape and report/health.ts for when a capture is unhealthy.
|
|
11
|
+
*/
|
|
12
|
+
import { type BaselineStore, type CaptureHealth } from '@testivai/witness';
|
|
13
|
+
/** The part of a Playwright Page this needs (structural, so tests need no browser). */
|
|
14
|
+
export interface EvaluatesExpressions {
|
|
15
|
+
evaluate(expression: string): Promise<unknown>;
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Run the probe. Best-effort, like the rest of the metadata: a probe that
|
|
19
|
+
* throws or returns something malformed gives null, which reads as "not
|
|
20
|
+
* checked", never as healthy.
|
|
21
|
+
*/
|
|
22
|
+
export declare function probeCaptureHealth(page: EvaluatesExpressions): Promise<CaptureHealth | null>;
|
|
23
|
+
/** Record a probe's result in the capture's metadata.json; nothing for null (not checked). */
|
|
24
|
+
export declare function recordCaptureHealth(store: BaselineStore, name: string, health: CaptureHealth | null): void;
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.probeCaptureHealth = probeCaptureHealth;
|
|
4
|
+
exports.recordCaptureHealth = recordCaptureHealth;
|
|
5
|
+
/**
|
|
6
|
+
* Capture health for the Playwright adapter: what failed to load (web fonts,
|
|
7
|
+
* stylesheets, images, requests that answered 400 or more), read by the
|
|
8
|
+
* shared probe in the page right AFTER the screenshot, so it sees what loaded,
|
|
9
|
+
* and BEFORE the ignoreSelectors and stabilize styles come off: removing them
|
|
10
|
+
* shows elements that were hidden in the capture, and a web font only such an
|
|
11
|
+
* element uses starts loading then, for glyphs that were never in the image.
|
|
12
|
+
* Merged into the capture's metadata.json as `health`; approving freezes it
|
|
13
|
+
* into the baseline's. See capture/health.ts in @testivai/witness for the
|
|
14
|
+
* shape and report/health.ts for when a capture is unhealthy.
|
|
15
|
+
*/
|
|
16
|
+
const witness_1 = require("@testivai/witness");
|
|
17
|
+
/**
|
|
18
|
+
* Run the probe. Best-effort, like the rest of the metadata: a probe that
|
|
19
|
+
* throws or returns something malformed gives null, which reads as "not
|
|
20
|
+
* checked", never as healthy.
|
|
21
|
+
*/
|
|
22
|
+
async function probeCaptureHealth(page) {
|
|
23
|
+
try {
|
|
24
|
+
return (0, witness_1.parseHealth)(await page.evaluate(witness_1.HEALTH_PROBE_EXPRESSION));
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
/** Record a probe's result in the capture's metadata.json; nothing for null (not checked). */
|
|
31
|
+
function recordCaptureHealth(store, name, health) {
|
|
32
|
+
if (!health)
|
|
33
|
+
return;
|
|
34
|
+
try {
|
|
35
|
+
store.writeTempMetadata(name, { health });
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
// not checked: the screenshot path never depends on it
|
|
39
|
+
}
|
|
40
|
+
}
|
package/dist/reporter.js
CHANGED
|
@@ -88,6 +88,10 @@ class TestivAIPlaywrightReporter {
|
|
|
88
88
|
console.log(`\n TestivAI: ${where} — captured to .testivai/temp/, comparison skipped.`);
|
|
89
89
|
console.log(' Collect every shard\'s .testivai/temp/, then compare once:');
|
|
90
90
|
console.log(' npx testivai merge-captures <dirs...> && npx testivai report --fail-on-diff');
|
|
91
|
+
// A CI step runs only on success by default, so one failing test would
|
|
92
|
+
// skip the comparison and leave the pull request without a report.
|
|
93
|
+
console.log(' Run that step even when a test fails: in GitHub Actions, `if: ${{ !cancelled() }}`');
|
|
94
|
+
console.log(' (and on a job that `needs` the shards, too).');
|
|
91
95
|
return;
|
|
92
96
|
}
|
|
93
97
|
// ── Generate the HTML report ──────────────────────────────────────────
|
package/dist/snapshot.js
CHANGED
|
@@ -46,7 +46,11 @@ 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 witness_1 = require("@testivai/witness");
|
|
50
|
+
const environment_1 = require("./capture/environment");
|
|
51
|
+
const health_1 = require("./capture/health");
|
|
49
52
|
const full_page_1 = require("./capture/full-page");
|
|
53
|
+
const emulation_1 = require("./capture/emulation");
|
|
50
54
|
/**
|
|
51
55
|
* Generates a safe filename from a URL.
|
|
52
56
|
* @param pageUrl The URL of the page.
|
|
@@ -111,7 +115,19 @@ async function snapshot(page, testInfo, name, config) {
|
|
|
111
115
|
}
|
|
112
116
|
const outputDir = path.join(process.cwd(), '.testivai', 'temp');
|
|
113
117
|
await fs.ensureDir(outputDir);
|
|
114
|
-
|
|
118
|
+
// What the page is rendered with, measured before anything is injected:
|
|
119
|
+
// recorded as capture provenance, and its user agent names the engine for
|
|
120
|
+
// environmentPolicy "separate" when the project does not.
|
|
121
|
+
const renderProbe = await page.evaluate(witness_1.RENDER_PROBE_EXPRESSION).catch(() => null);
|
|
122
|
+
// The device emulation before the capture, which the CDP capture drops
|
|
123
|
+
// (capture/emulation.ts); restored once everything is measured.
|
|
124
|
+
const emulationBefore = await (0, emulation_1.readEmulation)(page);
|
|
125
|
+
const localConfig = (0, witness_1.loadLocalConfig)(process.cwd());
|
|
126
|
+
const environmentPolicy = localConfig.environmentPolicy;
|
|
127
|
+
const snapshotName = (0, witness_1.applyEnvironmentPolicyToName)(effectiveSnapshotName(name || getSnapshotNameFromUrl(page.url()), testInfo), { browser: { name: (0, environment_1.playwrightBrowserName)(testInfo ?? {}, (0, witness_1.parseRenderProbe)(renderProbe)?.userAgent) }, os: { platform: process.platform } }, environmentPolicy);
|
|
128
|
+
// Which path took the screenshot (set below): part of the provenance, since
|
|
129
|
+
// another method changes the image and often its size.
|
|
130
|
+
let captureMethod = witness_1.CAPTURE_METHODS.CDP_FULL_PAGE;
|
|
115
131
|
const timestamp = Date.now();
|
|
116
132
|
const safeName = snapshotName.replace(/[^a-z0-9]/gi, '_').toLowerCase();
|
|
117
133
|
const baseFilename = `${timestamp}_${safeName}`;
|
|
@@ -225,9 +241,11 @@ async function snapshot(page, testInfo, name, config) {
|
|
|
225
241
|
}
|
|
226
242
|
// If only one capture needed, just take a regular screenshot
|
|
227
243
|
if (numCaptures <= 1) {
|
|
244
|
+
captureMethod = witness_1.CAPTURE_METHODS.PLAYWRIGHT_FULL_PAGE;
|
|
228
245
|
await page.screenshot({ path: screenshotPath, fullPage: true });
|
|
229
246
|
}
|
|
230
247
|
else {
|
|
248
|
+
captureMethod = witness_1.CAPTURE_METHODS.STITCHED_FULL_PAGE;
|
|
231
249
|
// Scroll-and-stitch approach
|
|
232
250
|
const screenshots = [];
|
|
233
251
|
for (let i = 0; i < numCaptures; i++) {
|
|
@@ -295,6 +313,12 @@ async function snapshot(page, testInfo, name, config) {
|
|
|
295
313
|
}
|
|
296
314
|
}
|
|
297
315
|
}
|
|
316
|
+
else if (!hasCdp(page)) {
|
|
317
|
+
// WebKit and Firefox have no CDP: Playwright's own full-page screenshot
|
|
318
|
+
// is the capture there, not a fallback from a failure.
|
|
319
|
+
captureMethod = witness_1.CAPTURE_METHODS.PLAYWRIGHT_FULL_PAGE;
|
|
320
|
+
await page.screenshot({ path: screenshotPath, fullPage: true });
|
|
321
|
+
}
|
|
298
322
|
else {
|
|
299
323
|
// Use browser capture approach (default)
|
|
300
324
|
if (process.env.TESTIVAI_DEBUG === 'true') {
|
|
@@ -344,11 +368,28 @@ async function snapshot(page, testInfo, name, config) {
|
|
|
344
368
|
await client.detach();
|
|
345
369
|
}
|
|
346
370
|
catch (error) {
|
|
347
|
-
|
|
371
|
+
// An engine we could not name (a persistent context has no browser())
|
|
372
|
+
// answers that CDP is Chromium-only: the expected path, not a failure.
|
|
373
|
+
if (!/only available in Chromium/i.test(String(error?.message))) {
|
|
374
|
+
console.error('[TestivAI] Browser screenshot failed:', error.message);
|
|
375
|
+
}
|
|
348
376
|
// Fallback to regular screenshot
|
|
377
|
+
captureMethod = witness_1.CAPTURE_METHODS.PLAYWRIGHT_FULL_PAGE;
|
|
349
378
|
await page.screenshot({ path: screenshotPath, fullPage: true });
|
|
350
379
|
}
|
|
351
380
|
}
|
|
381
|
+
// Capture health: what failed to load (web fonts, stylesheets, images,
|
|
382
|
+
// requests that answered 400+), read right after the screenshot (every
|
|
383
|
+
// capture path above ends here) so it sees what loaded, and before the
|
|
384
|
+
// ignoreSelectors and stabilize styles come off: removing them shows
|
|
385
|
+
// elements that were hidden in the capture, and a web font only they use
|
|
386
|
+
// starts loading then, for glyphs that were never in the image. A font that
|
|
387
|
+
// fell back leaves the DOM and the declared styles the same, so only this
|
|
388
|
+
// tells an unhealthy capture from render noise. Recorded with the metadata.
|
|
389
|
+
const captureHealth = await (0, health_1.probeCaptureHealth)(page);
|
|
390
|
+
// The pixel ratio the image was rendered at (1 after a CDP capture,
|
|
391
|
+
// whatever the profile says): recorded with the capture's provenance.
|
|
392
|
+
const emulationAtCapture = await (0, emulation_1.readEmulation)(page);
|
|
352
393
|
// Restore any elements hidden for ignoreSelectors
|
|
353
394
|
if (ignoreStyleEl) {
|
|
354
395
|
await ignoreStyleEl.evaluate((el) => el.remove()).catch(() => { });
|
|
@@ -456,21 +497,69 @@ async function snapshot(page, testInfo, name, config) {
|
|
|
456
497
|
catch {
|
|
457
498
|
// metadata is an enhancement; the screenshot path never depends on it
|
|
458
499
|
}
|
|
500
|
+
// Capture provenance: the environment this capture was taken in, merged
|
|
501
|
+
// into metadata.json (approving freezes it into the baseline). Best-effort
|
|
502
|
+
// like the rest: the screenshot path never depends on it.
|
|
503
|
+
try {
|
|
504
|
+
let browserVersion = null;
|
|
505
|
+
try {
|
|
506
|
+
browserVersion = page.context().browser()?.version() ?? null;
|
|
507
|
+
}
|
|
508
|
+
catch {
|
|
509
|
+
// a persistent context has no browser(): the probe's user agent is the fallback
|
|
510
|
+
}
|
|
511
|
+
const environment = (0, environment_1.playwrightEnvironment)({
|
|
512
|
+
testInfo: testInfo ?? {},
|
|
513
|
+
browserVersion,
|
|
514
|
+
probe: (0, emulation_1.withCapturedScale)(renderProbe, emulationAtCapture),
|
|
515
|
+
captureMethod,
|
|
516
|
+
});
|
|
517
|
+
new witness_1.BaselineStore(process.cwd()).writeTempMetadata(snapshotName, { name: snapshotName, environment });
|
|
518
|
+
}
|
|
519
|
+
catch {
|
|
520
|
+
// no environment only means the compare cannot tell drift from noise for this capture
|
|
521
|
+
}
|
|
522
|
+
// Capture health, probed right after the screenshot (above).
|
|
523
|
+
(0, health_1.recordCaptureHealth)(new witness_1.BaselineStore(process.cwd()), snapshotName, captureHealth);
|
|
459
524
|
// Element map: layout + computed-style digest for every visible
|
|
460
525
|
// element, powering attribution ("WHICH element changed"), exact
|
|
461
526
|
// shift classification, and the style fingerprint on the compare
|
|
462
527
|
// side. Best-effort — the screenshot path never depends on it.
|
|
463
528
|
try {
|
|
464
529
|
const mapIgnores = (0, ignore_selectors_1.collectIgnoreSelectors)(process.cwd(), projectConfig, effectiveConfig);
|
|
465
|
-
|
|
530
|
+
// Opt-in: paths anchored on attributes such as data-testid. Recorded
|
|
531
|
+
// with the capture only when set, so the compare can tell paths built
|
|
532
|
+
// another way; unset, nothing written changes.
|
|
533
|
+
const idAttributes = (0, witness_1.normalizeElementIdAttributes)(localConfig.elementIdAttributes);
|
|
534
|
+
const elementMap = await page.evaluate((0, element_map_1.buildElementMapExpression)(undefined, mapIgnores, idAttributes));
|
|
466
535
|
if (Array.isArray(elementMap) && elementMap.length > 0) {
|
|
467
536
|
await fs.writeJson(path.join(localSnapshotDir, 'elements.json'), elementMap);
|
|
537
|
+
if (idAttributes.length > 0) {
|
|
538
|
+
new witness_1.BaselineStore(process.cwd()).writeTempMetadata(snapshotName, { elementIdAttributes: idAttributes });
|
|
539
|
+
}
|
|
468
540
|
}
|
|
469
541
|
}
|
|
470
542
|
catch {
|
|
471
543
|
// missing elements.json only disables attribution for this capture
|
|
472
544
|
}
|
|
473
545
|
}
|
|
546
|
+
// Last, after everything measured in the page at the capture's scale:
|
|
547
|
+
// put back the device emulation the CDP capture dropped.
|
|
548
|
+
await (0, emulation_1.restoreEmulation)(page, emulationBefore);
|
|
549
|
+
}
|
|
550
|
+
/**
|
|
551
|
+
* Whether the page's engine can have a CDP session. False only when the
|
|
552
|
+
* browser names an engine other than Chromium; unknown (no browser(), as in a
|
|
553
|
+
* persistent context) is tried, and its answer handled where it fails.
|
|
554
|
+
*/
|
|
555
|
+
function hasCdp(page) {
|
|
556
|
+
try {
|
|
557
|
+
const name = page.context().browser()?.browserType().name();
|
|
558
|
+
return name === undefined || name === 'chromium';
|
|
559
|
+
}
|
|
560
|
+
catch {
|
|
561
|
+
return true;
|
|
562
|
+
}
|
|
474
563
|
}
|
|
475
564
|
/**
|
|
476
565
|
* Read the global `mask` list from .testivai/config.json (selector strings
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@testivai/witness-playwright",
|
|
3
|
-
"version": "2.0
|
|
3
|
+
"version": "2.1.0",
|
|
4
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",
|
|
@@ -46,7 +46,7 @@
|
|
|
46
46
|
"dependencies": {
|
|
47
47
|
"fs-extra": "^11.4.0",
|
|
48
48
|
"sharp": "^0.35.4",
|
|
49
|
-
"@testivai/witness": "2.
|
|
49
|
+
"@testivai/witness": "2.6.0"
|
|
50
50
|
},
|
|
51
51
|
"devDependencies": {
|
|
52
52
|
"@playwright/test": "^1.63.0",
|