@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.
Files changed (82) hide show
  1. package/README.md +61 -0
  2. package/bin/github-a11y-summary +5 -0
  3. package/bin/github-failure-summary +8 -0
  4. package/lib/accessibility-baseline-file.d.ts +47 -0
  5. package/lib/accessibility-baseline-file.js +205 -0
  6. package/lib/accessibility-baseline.d.ts +19 -0
  7. package/lib/accessibility-baseline.js +38 -0
  8. package/lib/accessible-screenshot.d.ts +181 -0
  9. package/lib/accessible-screenshot.js +519 -0
  10. package/lib/focus.d.ts +14 -0
  11. package/lib/focus.js +28 -0
  12. package/lib/fonts.d.ts +18 -0
  13. package/lib/fonts.js +24 -0
  14. package/lib/frames.d.ts +7 -0
  15. package/lib/frames.js +27 -0
  16. package/lib/github/a11y-summary.d.ts +55 -0
  17. package/lib/github/a11y-summary.js +383 -0
  18. package/lib/github/attachments.d.ts +98 -0
  19. package/lib/github/attachments.js +297 -0
  20. package/lib/github/failure-summary.d.ts +144 -0
  21. package/lib/github/failure-summary.js +567 -0
  22. package/lib/github/index.d.ts +6 -0
  23. package/lib/github/index.js +35 -0
  24. package/lib/github/report-paths.d.ts +38 -0
  25. package/lib/github/report-paths.js +200 -0
  26. package/lib/hover.d.ts +13 -0
  27. package/lib/hover.js +61 -0
  28. package/lib/images.d.ts +118 -0
  29. package/lib/images.js +260 -0
  30. package/lib/index.d.ts +13 -0
  31. package/lib/index.js +29 -0
  32. package/lib/interaction-states.d.ts +22 -0
  33. package/lib/interaction-states.js +75 -0
  34. package/lib/mock/index.d.ts +1 -0
  35. package/lib/mock/index.js +5 -0
  36. package/lib/mock/youtube.d.ts +5 -0
  37. package/lib/mock/youtube.js +38 -0
  38. package/lib/pseudo-state.d.ts +17 -0
  39. package/lib/pseudo-state.js +50 -0
  40. package/lib/videos.d.ts +134 -0
  41. package/lib/videos.js +349 -0
  42. package/lib/visualdiff.d.ts +154 -0
  43. package/lib/visualdiff.js +197 -0
  44. package/package.json +47 -0
  45. package/src/accessibility-baseline-file.test.ts +181 -0
  46. package/src/accessibility-baseline-file.ts +208 -0
  47. package/src/accessibility-baseline.test.ts +601 -0
  48. package/src/accessibility-baseline.ts +50 -0
  49. package/src/accessible-screenshot.test.ts +597 -0
  50. package/src/accessible-screenshot.ts +809 -0
  51. package/src/focus.test.ts +34 -0
  52. package/src/focus.ts +27 -0
  53. package/src/fonts.test.ts +17 -0
  54. package/src/fonts.ts +23 -0
  55. package/src/frames.test.ts +75 -0
  56. package/src/frames.ts +26 -0
  57. package/src/github/a11y-summary.test.ts +439 -0
  58. package/src/github/a11y-summary.ts +421 -0
  59. package/src/github/attachments.test.ts +248 -0
  60. package/src/github/attachments.ts +328 -0
  61. package/src/github/failure-summary.test.ts +636 -0
  62. package/src/github/failure-summary.ts +720 -0
  63. package/src/github/index.test.ts +24 -0
  64. package/src/github/index.ts +35 -0
  65. package/src/github/report-paths.test.ts +222 -0
  66. package/src/github/report-paths.ts +208 -0
  67. package/src/hover.test.ts +76 -0
  68. package/src/hover.ts +64 -0
  69. package/src/images.test.ts +355 -0
  70. package/src/images.ts +299 -0
  71. package/src/index.ts +13 -0
  72. package/src/interaction-states.test.ts +48 -0
  73. package/src/interaction-states.ts +94 -0
  74. package/src/mock/index.ts +1 -0
  75. package/src/mock/youtube.test.ts +38 -0
  76. package/src/mock/youtube.ts +39 -0
  77. package/src/pseudo-state.test.ts +83 -0
  78. package/src/pseudo-state.ts +69 -0
  79. package/src/videos.test.ts +637 -0
  80. package/src/videos.ts +389 -0
  81. package/src/visualdiff.test.ts +452 -0
  82. 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
+ }
@@ -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';