@lullabot/playwright-testing 0.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 +61 -0
- package/bin/github-a11y-summary +5 -0
- package/bin/github-failure-summary +8 -0
- package/lib/accessibility-baseline-file.d.ts +47 -0
- package/lib/accessibility-baseline-file.js +205 -0
- package/lib/accessibility-baseline.d.ts +19 -0
- package/lib/accessibility-baseline.js +38 -0
- package/lib/accessible-screenshot.d.ts +181 -0
- package/lib/accessible-screenshot.js +519 -0
- package/lib/focus.d.ts +14 -0
- package/lib/focus.js +28 -0
- package/lib/fonts.d.ts +18 -0
- package/lib/fonts.js +24 -0
- package/lib/frames.d.ts +7 -0
- package/lib/frames.js +27 -0
- package/lib/github/a11y-summary.d.ts +55 -0
- package/lib/github/a11y-summary.js +383 -0
- package/lib/github/attachments.d.ts +98 -0
- package/lib/github/attachments.js +297 -0
- package/lib/github/failure-summary.d.ts +144 -0
- package/lib/github/failure-summary.js +567 -0
- package/lib/github/index.d.ts +6 -0
- package/lib/github/index.js +35 -0
- package/lib/github/report-paths.d.ts +38 -0
- package/lib/github/report-paths.js +200 -0
- package/lib/hover.d.ts +13 -0
- package/lib/hover.js +61 -0
- package/lib/images.d.ts +118 -0
- package/lib/images.js +260 -0
- package/lib/index.d.ts +13 -0
- package/lib/index.js +29 -0
- package/lib/interaction-states.d.ts +22 -0
- package/lib/interaction-states.js +75 -0
- package/lib/mock/index.d.ts +1 -0
- package/lib/mock/index.js +5 -0
- package/lib/mock/youtube.d.ts +5 -0
- package/lib/mock/youtube.js +38 -0
- package/lib/pseudo-state.d.ts +17 -0
- package/lib/pseudo-state.js +50 -0
- package/lib/videos.d.ts +134 -0
- package/lib/videos.js +349 -0
- package/lib/visualdiff.d.ts +154 -0
- package/lib/visualdiff.js +197 -0
- package/package.json +47 -0
- package/src/accessibility-baseline-file.test.ts +181 -0
- package/src/accessibility-baseline-file.ts +208 -0
- package/src/accessibility-baseline.test.ts +601 -0
- package/src/accessibility-baseline.ts +50 -0
- package/src/accessible-screenshot.test.ts +597 -0
- package/src/accessible-screenshot.ts +809 -0
- package/src/focus.test.ts +34 -0
- package/src/focus.ts +27 -0
- package/src/fonts.test.ts +17 -0
- package/src/fonts.ts +23 -0
- package/src/frames.test.ts +75 -0
- package/src/frames.ts +26 -0
- package/src/github/a11y-summary.test.ts +439 -0
- package/src/github/a11y-summary.ts +421 -0
- package/src/github/attachments.test.ts +248 -0
- package/src/github/attachments.ts +328 -0
- package/src/github/failure-summary.test.ts +636 -0
- package/src/github/failure-summary.ts +720 -0
- package/src/github/index.test.ts +24 -0
- package/src/github/index.ts +35 -0
- package/src/github/report-paths.test.ts +222 -0
- package/src/github/report-paths.ts +208 -0
- package/src/hover.test.ts +76 -0
- package/src/hover.ts +64 -0
- package/src/images.test.ts +355 -0
- package/src/images.ts +299 -0
- package/src/index.ts +13 -0
- package/src/interaction-states.test.ts +48 -0
- package/src/interaction-states.ts +94 -0
- package/src/mock/index.ts +1 -0
- package/src/mock/youtube.test.ts +38 -0
- package/src/mock/youtube.ts +39 -0
- package/src/pseudo-state.test.ts +83 -0
- package/src/pseudo-state.ts +69 -0
- package/src/videos.test.ts +637 -0
- package/src/videos.ts +389 -0
- package/src/visualdiff.test.ts +452 -0
- package/src/visualdiff.ts +381 -0
|
@@ -0,0 +1,200 @@
|
|
|
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.parsePathPrefix = parsePathPrefix;
|
|
37
|
+
exports.createPathResolver = createPathResolver;
|
|
38
|
+
const fs = __importStar(require("fs"));
|
|
39
|
+
const path = __importStar(require("path"));
|
|
40
|
+
/**
|
|
41
|
+
* Translate the file paths a Playwright JSON report records into paths the
|
|
42
|
+
* process reading that report can actually open.
|
|
43
|
+
*
|
|
44
|
+
* The two are not always the same file system. Running Playwright inside a
|
|
45
|
+
* container — which is the normal arrangement for this package, where the suite
|
|
46
|
+
* runs in DDEV's web container — records every attachment under the path it had
|
|
47
|
+
* *there*, `/var/www/html/…`. A workflow step reading the report on the runner
|
|
48
|
+
* is on the other side of that boundary, and `/var/www/html` means nothing to
|
|
49
|
+
* it. The report is readable, the attachments are not, and nothing about the
|
|
50
|
+
* failure says why.
|
|
51
|
+
*
|
|
52
|
+
* The bind mount that creates the problem also solves it: the same files are
|
|
53
|
+
* visible from both sides under different prefixes, so the paths need
|
|
54
|
+
* re-rooting rather than fetching. What the two views share is the tail of the
|
|
55
|
+
* path — the part below the mount point — so the mount point is found by
|
|
56
|
+
* hanging progressively longer tails of a recorded path off the directories
|
|
57
|
+
* around the report until one of them names a file that exists.
|
|
58
|
+
*
|
|
59
|
+
* Every mapping is confirmed against the disk before it is used, so a wrong
|
|
60
|
+
* guess resolves to nothing rather than to the wrong image. The report's own
|
|
61
|
+
* `config` is deliberately not consulted: `rootDir` is the *test* directory
|
|
62
|
+
* rather than the project root, and `outputDir` need not contain the report,
|
|
63
|
+
* so neither reliably shares a tail with the path the report was read from.
|
|
64
|
+
*
|
|
65
|
+
* Symlinking the container path on the runner instead looks like it should
|
|
66
|
+
* work and does not: the Ubuntu runner image ships Apache, so /var/www/html
|
|
67
|
+
* already exists as a directory and `ln -sfn` quietly creates the link inside
|
|
68
|
+
* it. The command exits 0 and changes nothing.
|
|
69
|
+
*/
|
|
70
|
+
/**
|
|
71
|
+
* Shortest tail worth trying, in path components.
|
|
72
|
+
*
|
|
73
|
+
* A bare file name is too little to identify a file by — some unrelated
|
|
74
|
+
* `fixture-diff.png` elsewhere in the tree would match. One directory of
|
|
75
|
+
* context makes a coincidence unlikely, and longer tails are tried first
|
|
76
|
+
* regardless, so the most specific match always wins.
|
|
77
|
+
*/
|
|
78
|
+
const MIN_TAIL_COMPONENTS = 2;
|
|
79
|
+
/**
|
|
80
|
+
* Parse a `FROM:TO` pair, as `--path-prefix` takes it.
|
|
81
|
+
*
|
|
82
|
+
* Split on the first colon: `TO` is a local absolute path and may itself
|
|
83
|
+
* contain one on Windows, whereas `FROM` comes from a container and will not.
|
|
84
|
+
*/
|
|
85
|
+
function parsePathPrefix(value) {
|
|
86
|
+
const separator = value.indexOf(':');
|
|
87
|
+
if (separator <= 0)
|
|
88
|
+
return null;
|
|
89
|
+
const from = value.slice(0, separator).trim();
|
|
90
|
+
const to = value.slice(separator + 1).trim();
|
|
91
|
+
if (!from || !to)
|
|
92
|
+
return null;
|
|
93
|
+
return { from, to };
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* Build the function that turns a recorded path into a readable one.
|
|
97
|
+
*
|
|
98
|
+
* A path that already resolves is left alone, so this costs one `stat` per
|
|
99
|
+
* attachment when the report and the files are on the same side. A mapping
|
|
100
|
+
* worked out for one attachment is kept and tried first for the rest, so the
|
|
101
|
+
* search runs once per run rather than once per image.
|
|
102
|
+
*/
|
|
103
|
+
function createPathResolver(options) {
|
|
104
|
+
const exists = options.exists ?? ((filePath) => fs.existsSync(filePath));
|
|
105
|
+
const explicit = options.prefixes ?? [];
|
|
106
|
+
const searchRoots = ancestors(path.dirname(path.resolve(options.reportPath)));
|
|
107
|
+
const learned = [];
|
|
108
|
+
/** Hang tails of the recorded path off each directory around the report. */
|
|
109
|
+
function search(filePath) {
|
|
110
|
+
const target = splitPath(filePath);
|
|
111
|
+
// Longest tail first, so the most specific match wins. Stop one short of
|
|
112
|
+
// the whole path: something has to be left to rewrite.
|
|
113
|
+
for (let length = target.components.length - 1; length >= MIN_TAIL_COMPONENTS; length--) {
|
|
114
|
+
const tail = target.components.slice(target.components.length - length).join('/');
|
|
115
|
+
for (const root of searchRoots) {
|
|
116
|
+
const candidate = `${trimTrailingSeparators(root)}/${tail}`;
|
|
117
|
+
if (!exists(candidate))
|
|
118
|
+
continue;
|
|
119
|
+
return {
|
|
120
|
+
path: candidate,
|
|
121
|
+
found: true,
|
|
122
|
+
prefix: { from: joinPath(target, target.components.length - length), to: root },
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
return null;
|
|
127
|
+
}
|
|
128
|
+
return (filePath) => {
|
|
129
|
+
if (exists(filePath))
|
|
130
|
+
return { path: filePath, found: true };
|
|
131
|
+
for (const prefix of [...explicit, ...learned]) {
|
|
132
|
+
const rewritten = applyPrefix(filePath, prefix);
|
|
133
|
+
if (rewritten && exists(rewritten))
|
|
134
|
+
return { path: rewritten, found: true, prefix };
|
|
135
|
+
}
|
|
136
|
+
const discovered = search(filePath);
|
|
137
|
+
if (!discovered)
|
|
138
|
+
return { path: filePath, found: false };
|
|
139
|
+
if (discovered.prefix)
|
|
140
|
+
learned.push(discovered.prefix);
|
|
141
|
+
return discovered;
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
/** Swap one prefix for another, or null when the path is not under it. */
|
|
145
|
+
function applyPrefix(filePath, prefix) {
|
|
146
|
+
const from = splitPath(prefix.from);
|
|
147
|
+
const target = splitPath(filePath);
|
|
148
|
+
if (target.components.length < from.components.length)
|
|
149
|
+
return null;
|
|
150
|
+
for (let index = 0; index < from.components.length; index++) {
|
|
151
|
+
if (target.components[index] !== from.components[index])
|
|
152
|
+
return null;
|
|
153
|
+
}
|
|
154
|
+
const rest = target.components.slice(from.components.length);
|
|
155
|
+
const to = trimTrailingSeparators(prefix.to);
|
|
156
|
+
return rest.length === 0 ? to : `${to}/${rest.join('/')}`;
|
|
157
|
+
}
|
|
158
|
+
/**
|
|
159
|
+
* Trim trailing separators.
|
|
160
|
+
*
|
|
161
|
+
* An index scan rather than `/[\\/]+$/`, because that pattern backtracks
|
|
162
|
+
* quadratically over a long run of separators — and these strings are paths
|
|
163
|
+
* out of a report this package did not write.
|
|
164
|
+
*/
|
|
165
|
+
function trimTrailingSeparators(value) {
|
|
166
|
+
let end = value.length;
|
|
167
|
+
while (end > 0 && (value[end - 1] === '/' || value[end - 1] === '\\'))
|
|
168
|
+
end--;
|
|
169
|
+
return value.slice(0, end);
|
|
170
|
+
}
|
|
171
|
+
/**
|
|
172
|
+
* Split a path into a root and its components, accepting either separator.
|
|
173
|
+
*
|
|
174
|
+
* The two sides of the boundary need not agree on the separator, and the
|
|
175
|
+
* recorded paths come from a report rather than from this platform.
|
|
176
|
+
*/
|
|
177
|
+
function splitPath(value) {
|
|
178
|
+
const match = /^([A-Za-z]:[\\/]|[\\/])?/.exec(value);
|
|
179
|
+
const prefix = match?.[1] ?? '';
|
|
180
|
+
return {
|
|
181
|
+
root: prefix.replace(/\\/g, '/'),
|
|
182
|
+
components: value.slice(prefix.length).split(/[\\/]+/).filter(Boolean),
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
/** Rebuild a path from its first `count` components. */
|
|
186
|
+
function joinPath(split, count) {
|
|
187
|
+
return split.root + split.components.slice(0, count).join('/');
|
|
188
|
+
}
|
|
189
|
+
/** A directory and every directory above it. */
|
|
190
|
+
function ancestors(directory) {
|
|
191
|
+
const out = [];
|
|
192
|
+
let current = directory;
|
|
193
|
+
for (;;) {
|
|
194
|
+
out.push(current);
|
|
195
|
+
const parent = path.dirname(current);
|
|
196
|
+
if (parent === current)
|
|
197
|
+
return out;
|
|
198
|
+
current = parent;
|
|
199
|
+
}
|
|
200
|
+
}
|
package/lib/hover.d.ts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { Page } from "@playwright/test";
|
|
2
|
+
/**
|
|
3
|
+
* Move the pointer away from page content for a deterministic screenshot.
|
|
4
|
+
*
|
|
5
|
+
* Pointer position survives clicks, navigation, and layout changes. That can
|
|
6
|
+
* leave an unrelated element under the pointer and activate its `:hover`
|
|
7
|
+
* styles. A transparent shield gives the pointer a neutral target regardless
|
|
8
|
+
* of what the page renders at the chosen coordinate.
|
|
9
|
+
*
|
|
10
|
+
* The returned cleanup function removes the shield. Call it after the
|
|
11
|
+
* screenshot so normal pointer hit testing is restored.
|
|
12
|
+
*/
|
|
13
|
+
export declare function clearHover(page: Page): Promise<() => Promise<void>>;
|
package/lib/hover.js
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.clearHover = clearHover;
|
|
4
|
+
const HOVER_SHIELD_ATTRIBUTE = 'data-playwright-testing-hover-shield';
|
|
5
|
+
/**
|
|
6
|
+
* Move the pointer away from page content for a deterministic screenshot.
|
|
7
|
+
*
|
|
8
|
+
* Pointer position survives clicks, navigation, and layout changes. That can
|
|
9
|
+
* leave an unrelated element under the pointer and activate its `:hover`
|
|
10
|
+
* styles. A transparent shield gives the pointer a neutral target regardless
|
|
11
|
+
* of what the page renders at the chosen coordinate.
|
|
12
|
+
*
|
|
13
|
+
* The returned cleanup function removes the shield. Call it after the
|
|
14
|
+
* screenshot so normal pointer hit testing is restored.
|
|
15
|
+
*/
|
|
16
|
+
async function clearHover(page) {
|
|
17
|
+
await page.evaluate((attribute) => {
|
|
18
|
+
document.querySelector(`[${attribute}]`)?.remove();
|
|
19
|
+
const shield = document.createElement('playwright-testing-hover-shield');
|
|
20
|
+
shield.setAttribute(attribute, '');
|
|
21
|
+
shield.setAttribute('aria-hidden', 'true');
|
|
22
|
+
shield.style.cssText = [
|
|
23
|
+
'all: initial !important',
|
|
24
|
+
'position: fixed !important',
|
|
25
|
+
'inset: 0 !important',
|
|
26
|
+
'display: block !important',
|
|
27
|
+
'width: 100vw !important',
|
|
28
|
+
'height: 100vh !important',
|
|
29
|
+
'margin: 0 !important',
|
|
30
|
+
'padding: 0 !important',
|
|
31
|
+
'border: 0 !important',
|
|
32
|
+
'opacity: 0 !important',
|
|
33
|
+
'pointer-events: auto !important',
|
|
34
|
+
'z-index: 2147483647 !important',
|
|
35
|
+
].join(';');
|
|
36
|
+
document.documentElement.appendChild(shield);
|
|
37
|
+
}, HOVER_SHIELD_ATTRIBUTE);
|
|
38
|
+
try {
|
|
39
|
+
// Playwright emits a mousemove even when the pointer was already at this
|
|
40
|
+
// coordinate, ensuring mouseleave/pointerleave handlers get a chance to
|
|
41
|
+
// respond to the new hit target.
|
|
42
|
+
await page.mouse.move(0, 0);
|
|
43
|
+
}
|
|
44
|
+
catch (error) {
|
|
45
|
+
await removeHoverShield(page);
|
|
46
|
+
throw error;
|
|
47
|
+
}
|
|
48
|
+
let removed = false;
|
|
49
|
+
return async () => {
|
|
50
|
+
if (removed) {
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
removed = true;
|
|
54
|
+
await removeHoverShield(page);
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
async function removeHoverShield(page) {
|
|
58
|
+
await page.evaluate((attribute) => {
|
|
59
|
+
document.querySelector(`[${attribute}]`)?.remove();
|
|
60
|
+
}, HOVER_SHIELD_ATTRIBUTE);
|
|
61
|
+
}
|
package/lib/images.d.ts
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { Page } from "@playwright/test";
|
|
2
|
+
export interface WaitForImagesOptions {
|
|
3
|
+
/** How long to wait for visible images to decode. */
|
|
4
|
+
decodeTimeoutMs?: number;
|
|
5
|
+
/**
|
|
6
|
+
* Re-request broken images with a cache-busting URL. This mutates `src`,
|
|
7
|
+
* `srcset`, and enclosing `<picture>` sources, so it is disabled by default.
|
|
8
|
+
*/
|
|
9
|
+
recoverErroredImages?: boolean;
|
|
10
|
+
/** Optional adapter hook run after the viewport has returned to the top. */
|
|
11
|
+
afterScroll?: (page: Page) => Promise<void>;
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Wait for images specified by a selector to load.
|
|
15
|
+
*
|
|
16
|
+
* The function must scroll the page to handle lazy-loading images. After all
|
|
17
|
+
* images have loaded, the page is scrolled back to the top.
|
|
18
|
+
*
|
|
19
|
+
* See https://github.com/microsoft/playwright/issues/14388 for further details.
|
|
20
|
+
*
|
|
21
|
+
* @param page
|
|
22
|
+
* @param selector
|
|
23
|
+
*/
|
|
24
|
+
export declare function waitForImages(page: Page, selector: string, options?: WaitForImagesOptions): Promise<void>;
|
|
25
|
+
/**
|
|
26
|
+
* Wait for a single image to stop being in flight.
|
|
27
|
+
*
|
|
28
|
+
* Returns without a promise for an image that needs no waiting at all: one that
|
|
29
|
+
* has already settled, or a 1x1 image that is visually hidden for accessibility
|
|
30
|
+
* (a common visually-hidden accessibility technique), which would otherwise
|
|
31
|
+
* hang forever -- even
|
|
32
|
+
* though such images are :visible, Chrome doesn't load them at desktop widths. See
|
|
33
|
+
* https://www.tpgi.com/the-anatomy-of-visually-hidden/ for how .visually-hidden
|
|
34
|
+
* works.
|
|
35
|
+
*
|
|
36
|
+
* Otherwise it waits for the request to finish, whether it succeeds or fails.
|
|
37
|
+
* Waiting on `load` alone would hang until the test timed out on any image that
|
|
38
|
+
* errors after the wait begins -- a 404, or an image proxy that fails while it
|
|
39
|
+
* fetches the original on demand -- because such an image only ever
|
|
40
|
+
* fires `error`. That hang is worse than useless here: it happens before
|
|
41
|
+
* waitForImagesToDecode() runs, so it also denies the one piece of code that
|
|
42
|
+
* knows how to re-request a broken image the chance to recover it. Settling on
|
|
43
|
+
* `error` hands the image on to that recovery instead.
|
|
44
|
+
*
|
|
45
|
+
* Listeners are added rather than assigned to `onload`/`onerror` so that any
|
|
46
|
+
* handler the page itself installed keeps working.
|
|
47
|
+
*
|
|
48
|
+
* This runs in the browser via `evaluate()`, which serializes the function
|
|
49
|
+
* source, so it must stay self-contained and reference nothing else in this
|
|
50
|
+
* module. It is exported so the waiting can be tested directly.
|
|
51
|
+
*
|
|
52
|
+
* @param image
|
|
53
|
+
*/
|
|
54
|
+
export declare function settleImage(image: HTMLImageElement): void | Promise<void>;
|
|
55
|
+
/**
|
|
56
|
+
* Wait for all image tags on the page to load.
|
|
57
|
+
*
|
|
58
|
+
* @param page
|
|
59
|
+
*/
|
|
60
|
+
export declare function waitForAllImages(page: Page, options?: WaitForImagesOptions): Promise<void>;
|
|
61
|
+
/**
|
|
62
|
+
* Poll every visible image until it decodes, optionally re-requesting failures.
|
|
63
|
+
*
|
|
64
|
+
* A loaded image is `complete` with a `naturalWidth` greater than zero. An
|
|
65
|
+
* errored request (a 404, or an image proxy that fails while it
|
|
66
|
+
* fetches the original on demand) is `complete` with a `naturalWidth` of 0 and
|
|
67
|
+
* would otherwise be screenshotted as a broken image. A zero `naturalWidth` is
|
|
68
|
+
* ambiguous, though -- a valid but dimensionless image such as an SVG without an
|
|
69
|
+
* intrinsic size reports it too -- so `decode()` disambiguates: it rejects only
|
|
70
|
+
* for a genuine failure. When recovery is explicitly enabled, a broken image
|
|
71
|
+
* is re-requested with a cache-busting query parameter. The retry reuses
|
|
72
|
+
* `currentSrc` (the URL already
|
|
73
|
+
* chosen from `srcset`) and drops the responsive sources so that exact image
|
|
74
|
+
* loads, keeping the render identical to a clean first load. 1x1
|
|
75
|
+
* visually-hidden images are skipped.
|
|
76
|
+
*
|
|
77
|
+
* This runs in the browser via `page.evaluate()`, which serializes the function
|
|
78
|
+
* source, so it must stay self-contained and reference nothing else in this
|
|
79
|
+
* module. It is exported separately from waitForImagesToDecode() so the polling
|
|
80
|
+
* can be tested directly.
|
|
81
|
+
*
|
|
82
|
+
* Every image is checked at least once, even with a timeout of zero, and the
|
|
83
|
+
* images that never settled are returned rather than swallowed so the caller
|
|
84
|
+
* can report them.
|
|
85
|
+
*
|
|
86
|
+
* @param options.timeoutMs How long to keep polling before giving up.
|
|
87
|
+
* @param options.pollMs How long to sleep between polls.
|
|
88
|
+
* @param options.reloadIntervalMs How long to leave a re-requested image to
|
|
89
|
+
* resolve before re-requesting it again.
|
|
90
|
+
* @param options.recoverErroredImages Whether broken image sources may be
|
|
91
|
+
* rewritten and re-requested. Defaults to false.
|
|
92
|
+
* @returns The URLs of the images that never decoded. Empty when they all did.
|
|
93
|
+
*/
|
|
94
|
+
export declare function decodeVisibleImages(options: {
|
|
95
|
+
timeoutMs: number;
|
|
96
|
+
pollMs?: number;
|
|
97
|
+
reloadIntervalMs?: number;
|
|
98
|
+
recoverErroredImages?: boolean;
|
|
99
|
+
}): Promise<string[]>;
|
|
100
|
+
/**
|
|
101
|
+
* Wait for every visible image to finish decoding.
|
|
102
|
+
*
|
|
103
|
+
* See decodeVisibleImages() for how an image is judged loaded, broken, or
|
|
104
|
+
* merely dimensionless, and how broken ones are re-requested.
|
|
105
|
+
*
|
|
106
|
+
* Giving up is not an error: a page that legitimately references a missing
|
|
107
|
+
* image should still be screenshotted and still have its accessibility checked,
|
|
108
|
+
* and the screenshot comparison is what fails. But the wait is not silent
|
|
109
|
+
* either -- the images that never decoded are warned about, so a mysterious
|
|
110
|
+
* pixel diff (and the timeout's worth of delay before it) has an explanation --
|
|
111
|
+
* and they are returned so a caller can assert on them.
|
|
112
|
+
*
|
|
113
|
+
* @param page
|
|
114
|
+
* @param timeoutMs How long to wait for images to decode before giving up.
|
|
115
|
+
* @param recoverErroredImages Whether broken image sources may be rewritten.
|
|
116
|
+
* @returns The URLs of the images that never decoded. Empty when they all did.
|
|
117
|
+
*/
|
|
118
|
+
export declare function waitForImagesToDecode(page: Page, timeoutMs?: number, recoverErroredImages?: boolean): Promise<string[]>;
|
package/lib/images.js
ADDED
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.waitForImages = waitForImages;
|
|
4
|
+
exports.settleImage = settleImage;
|
|
5
|
+
exports.waitForAllImages = waitForAllImages;
|
|
6
|
+
exports.decodeVisibleImages = decodeVisibleImages;
|
|
7
|
+
exports.waitForImagesToDecode = waitForImagesToDecode;
|
|
8
|
+
/**
|
|
9
|
+
* Wait for images specified by a selector to load.
|
|
10
|
+
*
|
|
11
|
+
* The function must scroll the page to handle lazy-loading images. After all
|
|
12
|
+
* images have loaded, the page is scrolled back to the top.
|
|
13
|
+
*
|
|
14
|
+
* See https://github.com/microsoft/playwright/issues/14388 for further details.
|
|
15
|
+
*
|
|
16
|
+
* @param page
|
|
17
|
+
* @param selector
|
|
18
|
+
*/
|
|
19
|
+
async function waitForImages(page, selector, options = {}) {
|
|
20
|
+
const locators = page.locator(selector);
|
|
21
|
+
try {
|
|
22
|
+
// Trigger lazy-loading images. Since this should be fast, and we don't want to
|
|
23
|
+
// have to deal with concurrency bugs, we do this in serial.
|
|
24
|
+
for (const l of await locators.all()) {
|
|
25
|
+
// Ensure images are connected to the DOM before trying to scroll to them.
|
|
26
|
+
// https://github.com/microsoft/playwright/issues/23758
|
|
27
|
+
if (await l.evaluate(image => image.isConnected)) {
|
|
28
|
+
await l.scrollIntoViewIfNeeded();
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
// Make sure all images have loaded.
|
|
32
|
+
const promises = (await locators.all()).map(locator => locator.evaluate(settleImage));
|
|
33
|
+
await Promise.all(promises);
|
|
34
|
+
// The wait above treats an errored image as "loaded". Decode each visible
|
|
35
|
+
// image as well; source rewriting is available only when explicitly enabled.
|
|
36
|
+
await waitForImagesToDecode(page, options.decodeTimeoutMs ?? 15000, options.recoverErroredImages ?? false);
|
|
37
|
+
}
|
|
38
|
+
finally {
|
|
39
|
+
// Lazy-loading scrolls the document. Always put it back, including when an
|
|
40
|
+
// image listener, decode poll, or adapter hook fails.
|
|
41
|
+
await page.evaluate(() => window.scroll({
|
|
42
|
+
top: 0,
|
|
43
|
+
left: 0,
|
|
44
|
+
behavior: 'instant',
|
|
45
|
+
}));
|
|
46
|
+
// window.scroll is async and doesn't return a promise, so wait until the
|
|
47
|
+
// browser confirms we are at the top again.
|
|
48
|
+
const scrollState = { forced: false };
|
|
49
|
+
await page.waitForFunction(state => {
|
|
50
|
+
if (window.scrollY !== 0 && !state.forced) {
|
|
51
|
+
window.scroll({ top: 0, left: 0, behavior: 'instant' });
|
|
52
|
+
state.forced = true;
|
|
53
|
+
}
|
|
54
|
+
return window.scrollY === 0;
|
|
55
|
+
}, scrollState);
|
|
56
|
+
await options.afterScroll?.(page);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Wait for a single image to stop being in flight.
|
|
61
|
+
*
|
|
62
|
+
* Returns without a promise for an image that needs no waiting at all: one that
|
|
63
|
+
* has already settled, or a 1x1 image that is visually hidden for accessibility
|
|
64
|
+
* (a common visually-hidden accessibility technique), which would otherwise
|
|
65
|
+
* hang forever -- even
|
|
66
|
+
* though such images are :visible, Chrome doesn't load them at desktop widths. See
|
|
67
|
+
* https://www.tpgi.com/the-anatomy-of-visually-hidden/ for how .visually-hidden
|
|
68
|
+
* works.
|
|
69
|
+
*
|
|
70
|
+
* Otherwise it waits for the request to finish, whether it succeeds or fails.
|
|
71
|
+
* Waiting on `load` alone would hang until the test timed out on any image that
|
|
72
|
+
* errors after the wait begins -- a 404, or an image proxy that fails while it
|
|
73
|
+
* fetches the original on demand -- because such an image only ever
|
|
74
|
+
* fires `error`. That hang is worse than useless here: it happens before
|
|
75
|
+
* waitForImagesToDecode() runs, so it also denies the one piece of code that
|
|
76
|
+
* knows how to re-request a broken image the chance to recover it. Settling on
|
|
77
|
+
* `error` hands the image on to that recovery instead.
|
|
78
|
+
*
|
|
79
|
+
* Listeners are added rather than assigned to `onload`/`onerror` so that any
|
|
80
|
+
* handler the page itself installed keeps working.
|
|
81
|
+
*
|
|
82
|
+
* This runs in the browser via `evaluate()`, which serializes the function
|
|
83
|
+
* source, so it must stay self-contained and reference nothing else in this
|
|
84
|
+
* module. It is exported so the waiting can be tested directly.
|
|
85
|
+
*
|
|
86
|
+
* @param image
|
|
87
|
+
*/
|
|
88
|
+
function settleImage(image) {
|
|
89
|
+
if ((image.width <= 1 && image.height <= 1) || image.complete) {
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
return new Promise(resolve => {
|
|
93
|
+
const settled = () => {
|
|
94
|
+
image.removeEventListener("load", settled);
|
|
95
|
+
image.removeEventListener("error", settled);
|
|
96
|
+
resolve();
|
|
97
|
+
};
|
|
98
|
+
image.addEventListener("load", settled);
|
|
99
|
+
image.addEventListener("error", settled);
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* Wait for all image tags on the page to load.
|
|
104
|
+
*
|
|
105
|
+
* @param page
|
|
106
|
+
*/
|
|
107
|
+
async function waitForAllImages(page, options = {}) {
|
|
108
|
+
await waitForImages(page, 'img:visible', options);
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* Poll every visible image until it decodes, optionally re-requesting failures.
|
|
112
|
+
*
|
|
113
|
+
* A loaded image is `complete` with a `naturalWidth` greater than zero. An
|
|
114
|
+
* errored request (a 404, or an image proxy that fails while it
|
|
115
|
+
* fetches the original on demand) is `complete` with a `naturalWidth` of 0 and
|
|
116
|
+
* would otherwise be screenshotted as a broken image. A zero `naturalWidth` is
|
|
117
|
+
* ambiguous, though -- a valid but dimensionless image such as an SVG without an
|
|
118
|
+
* intrinsic size reports it too -- so `decode()` disambiguates: it rejects only
|
|
119
|
+
* for a genuine failure. When recovery is explicitly enabled, a broken image
|
|
120
|
+
* is re-requested with a cache-busting query parameter. The retry reuses
|
|
121
|
+
* `currentSrc` (the URL already
|
|
122
|
+
* chosen from `srcset`) and drops the responsive sources so that exact image
|
|
123
|
+
* loads, keeping the render identical to a clean first load. 1x1
|
|
124
|
+
* visually-hidden images are skipped.
|
|
125
|
+
*
|
|
126
|
+
* This runs in the browser via `page.evaluate()`, which serializes the function
|
|
127
|
+
* source, so it must stay self-contained and reference nothing else in this
|
|
128
|
+
* module. It is exported separately from waitForImagesToDecode() so the polling
|
|
129
|
+
* can be tested directly.
|
|
130
|
+
*
|
|
131
|
+
* Every image is checked at least once, even with a timeout of zero, and the
|
|
132
|
+
* images that never settled are returned rather than swallowed so the caller
|
|
133
|
+
* can report them.
|
|
134
|
+
*
|
|
135
|
+
* @param options.timeoutMs How long to keep polling before giving up.
|
|
136
|
+
* @param options.pollMs How long to sleep between polls.
|
|
137
|
+
* @param options.reloadIntervalMs How long to leave a re-requested image to
|
|
138
|
+
* resolve before re-requesting it again.
|
|
139
|
+
* @param options.recoverErroredImages Whether broken image sources may be
|
|
140
|
+
* rewritten and re-requested. Defaults to false.
|
|
141
|
+
* @returns The URLs of the images that never decoded. Empty when they all did.
|
|
142
|
+
*/
|
|
143
|
+
async function decodeVisibleImages(options) {
|
|
144
|
+
const timeoutMs = options.timeoutMs;
|
|
145
|
+
const pollMs = options.pollMs ?? 250;
|
|
146
|
+
const reloadIntervalMs = options.reloadIntervalMs ?? 2000;
|
|
147
|
+
const recoverErroredImages = options.recoverErroredImages ?? false;
|
|
148
|
+
const deadline = Date.now() + timeoutMs;
|
|
149
|
+
const isCandidate = (img) => {
|
|
150
|
+
// Skip 1x1 visually-hidden images (see waitForImages above).
|
|
151
|
+
if (img.width <= 1 && img.height <= 1) {
|
|
152
|
+
return false;
|
|
153
|
+
}
|
|
154
|
+
const rect = img.getBoundingClientRect();
|
|
155
|
+
if (rect.width === 0 || rect.height === 0) {
|
|
156
|
+
return false;
|
|
157
|
+
}
|
|
158
|
+
const style = getComputedStyle(img);
|
|
159
|
+
return style.visibility !== "hidden" && style.display !== "none";
|
|
160
|
+
};
|
|
161
|
+
const reload = (img) => {
|
|
162
|
+
const src = img.currentSrc || img.src;
|
|
163
|
+
if (!src || src.startsWith("data:")) {
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
try {
|
|
167
|
+
const url = new URL(src, location.href);
|
|
168
|
+
url.searchParams.set("playwrightReload", String(Date.now()));
|
|
169
|
+
// Drop the responsive sources so the resolved URL we just loaded is the
|
|
170
|
+
// one fetched, instead of re-running srcset selection.
|
|
171
|
+
const picture = img.closest("picture");
|
|
172
|
+
if (picture) {
|
|
173
|
+
picture.querySelectorAll("source").forEach(source => source.remove());
|
|
174
|
+
}
|
|
175
|
+
img.removeAttribute("srcset");
|
|
176
|
+
img.src = url.href;
|
|
177
|
+
}
|
|
178
|
+
catch {
|
|
179
|
+
// Ignore anything that is not a reloadable URL.
|
|
180
|
+
}
|
|
181
|
+
};
|
|
182
|
+
let lastReload = 0;
|
|
183
|
+
// Checking before testing the deadline means a zero or already-elapsed
|
|
184
|
+
// timeout still reports on the images instead of claiming success.
|
|
185
|
+
for (;;) {
|
|
186
|
+
const candidates = Array.from(document.images).filter(isCandidate);
|
|
187
|
+
// Collect the verdicts positionally rather than pushing as each check
|
|
188
|
+
// settles, so anything reported below stays in document order.
|
|
189
|
+
const verdicts = await Promise.all(candidates.map(async (img) => {
|
|
190
|
+
if (img.complete && img.naturalWidth > 0) {
|
|
191
|
+
return "loaded"; // Loaded with intrinsic dimensions.
|
|
192
|
+
}
|
|
193
|
+
if (!img.complete) {
|
|
194
|
+
return "loading";
|
|
195
|
+
}
|
|
196
|
+
// Complete with a zero naturalWidth is ambiguous: a genuine load error
|
|
197
|
+
// (404/503) rejects decode(), while a valid but dimensionless image
|
|
198
|
+
// (such as an SVG with no intrinsic size) resolves it. Only the former
|
|
199
|
+
// should be re-requested; the latter is already settled.
|
|
200
|
+
try {
|
|
201
|
+
await img.decode();
|
|
202
|
+
return "loaded";
|
|
203
|
+
}
|
|
204
|
+
catch {
|
|
205
|
+
return "errored";
|
|
206
|
+
}
|
|
207
|
+
}));
|
|
208
|
+
const pending = candidates.filter((img, index) => verdicts[index] !== "loaded");
|
|
209
|
+
const errored = candidates.filter((img, index) => verdicts[index] === "errored");
|
|
210
|
+
if (pending.length === 0) {
|
|
211
|
+
return [];
|
|
212
|
+
}
|
|
213
|
+
if (Date.now() >= deadline) {
|
|
214
|
+
return pending.map(img => {
|
|
215
|
+
const src = img.currentSrc || img.src;
|
|
216
|
+
try {
|
|
217
|
+
// Report the URL as the page authored it, without the cache-busting
|
|
218
|
+
// parameter a retry added, so the warning is greppable.
|
|
219
|
+
const url = new URL(src, location.href);
|
|
220
|
+
url.searchParams.delete("playwrightReload");
|
|
221
|
+
return url.href;
|
|
222
|
+
}
|
|
223
|
+
catch {
|
|
224
|
+
return src;
|
|
225
|
+
}
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
// Re-request errored images, throttled so each retry has time to resolve.
|
|
229
|
+
if (recoverErroredImages && Date.now() - lastReload > reloadIntervalMs) {
|
|
230
|
+
lastReload = Date.now();
|
|
231
|
+
errored.forEach(reload);
|
|
232
|
+
}
|
|
233
|
+
await new Promise(resolve => setTimeout(resolve, pollMs));
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
/**
|
|
237
|
+
* Wait for every visible image to finish decoding.
|
|
238
|
+
*
|
|
239
|
+
* See decodeVisibleImages() for how an image is judged loaded, broken, or
|
|
240
|
+
* merely dimensionless, and how broken ones are re-requested.
|
|
241
|
+
*
|
|
242
|
+
* Giving up is not an error: a page that legitimately references a missing
|
|
243
|
+
* image should still be screenshotted and still have its accessibility checked,
|
|
244
|
+
* and the screenshot comparison is what fails. But the wait is not silent
|
|
245
|
+
* either -- the images that never decoded are warned about, so a mysterious
|
|
246
|
+
* pixel diff (and the timeout's worth of delay before it) has an explanation --
|
|
247
|
+
* and they are returned so a caller can assert on them.
|
|
248
|
+
*
|
|
249
|
+
* @param page
|
|
250
|
+
* @param timeoutMs How long to wait for images to decode before giving up.
|
|
251
|
+
* @param recoverErroredImages Whether broken image sources may be rewritten.
|
|
252
|
+
* @returns The URLs of the images that never decoded. Empty when they all did.
|
|
253
|
+
*/
|
|
254
|
+
async function waitForImagesToDecode(page, timeoutMs = 15000, recoverErroredImages = false) {
|
|
255
|
+
const undecoded = await page.evaluate(decodeVisibleImages, { timeoutMs, recoverErroredImages });
|
|
256
|
+
if (undecoded.length > 0) {
|
|
257
|
+
console.warn(`waitForImagesToDecode: ${undecoded.length} image(s) did not finish loading within ${timeoutMs}ms and may be captured as broken: ${undecoded.join(', ')}`);
|
|
258
|
+
}
|
|
259
|
+
return undecoded;
|
|
260
|
+
}
|
package/lib/index.d.ts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export * from './focus.js';
|
|
2
|
+
export * from './accessibility-baseline.js';
|
|
3
|
+
export * from './accessibility-baseline-file.js';
|
|
4
|
+
export * from './accessible-screenshot.js';
|
|
5
|
+
export * from './fonts.js';
|
|
6
|
+
export * from './frames.js';
|
|
7
|
+
export * from './hover.js';
|
|
8
|
+
export * from './images.js';
|
|
9
|
+
export * from './interaction-states.js';
|
|
10
|
+
export * from './pseudo-state.js';
|
|
11
|
+
export * from './videos.js';
|
|
12
|
+
export * from './visualdiff.js';
|
|
13
|
+
export * from './mock/index.js';
|