@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
package/lib/index.js ADDED
@@ -0,0 +1,29 @@
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 __exportStar = (this && this.__exportStar) || function(m, exports) {
14
+ for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
15
+ };
16
+ Object.defineProperty(exports, "__esModule", { value: true });
17
+ __exportStar(require("./focus.js"), exports);
18
+ __exportStar(require("./accessibility-baseline.js"), exports);
19
+ __exportStar(require("./accessibility-baseline-file.js"), exports);
20
+ __exportStar(require("./accessible-screenshot.js"), exports);
21
+ __exportStar(require("./fonts.js"), exports);
22
+ __exportStar(require("./frames.js"), exports);
23
+ __exportStar(require("./hover.js"), exports);
24
+ __exportStar(require("./images.js"), exports);
25
+ __exportStar(require("./interaction-states.js"), exports);
26
+ __exportStar(require("./pseudo-state.js"), exports);
27
+ __exportStar(require("./videos.js"), exports);
28
+ __exportStar(require("./visualdiff.js"), exports);
29
+ __exportStar(require("./mock/index.js"), exports);
@@ -0,0 +1,22 @@
1
+ import type { Locator } from '@playwright/test';
2
+ /** Real browser interaction states supported consistently by Playwright. */
3
+ export type InteractionState = 'hover' | 'focus';
4
+ /** A locator and the real browser interaction states to apply to it. */
5
+ export interface ScreenshotInteractionState {
6
+ locator: Locator;
7
+ states: InteractionState[];
8
+ }
9
+ export interface InteractionStateCleanupOptions {
10
+ /** Move the pointer away from the hovered locator during cleanup. */
11
+ clearHover?: () => Promise<void>;
12
+ }
13
+ /** Validate that the requested states are possible with real browser input. */
14
+ export declare function validateInteractionStates(interactionStates: ScreenshotInteractionState[]): void;
15
+ /**
16
+ * Apply real hover and focus, returning an idempotent cleanup function.
17
+ *
18
+ * Hover is applied before focus so focusing cannot disturb the pointer. If an
19
+ * interaction fails, any state already applied is cleaned before the original
20
+ * error is rethrown.
21
+ */
22
+ export declare function applyInteractionStates(interactionStates: ScreenshotInteractionState[], cleanupOptions?: InteractionStateCleanupOptions): Promise<() => Promise<void>>;
@@ -0,0 +1,75 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.validateInteractionStates = validateInteractionStates;
4
+ exports.applyInteractionStates = applyInteractionStates;
5
+ /** Validate that the requested states are possible with real browser input. */
6
+ function validateInteractionStates(interactionStates) {
7
+ const hoverTargets = interactionStates.filter(({ states }) => states.includes('hover'));
8
+ const focusTargets = interactionStates.filter(({ states }) => states.includes('focus'));
9
+ if (hoverTargets.length > 1) {
10
+ throw new Error('interactionStates can hover at most one locator at a time.');
11
+ }
12
+ if (focusTargets.length > 1) {
13
+ throw new Error('interactionStates can focus at most one locator at a time.');
14
+ }
15
+ for (const { states } of interactionStates) {
16
+ if (new Set(states).size !== states.length) {
17
+ throw new Error('interactionStates cannot repeat a state for the same locator.');
18
+ }
19
+ }
20
+ }
21
+ /**
22
+ * Apply real hover and focus, returning an idempotent cleanup function.
23
+ *
24
+ * Hover is applied before focus so focusing cannot disturb the pointer. If an
25
+ * interaction fails, any state already applied is cleaned before the original
26
+ * error is rethrown.
27
+ */
28
+ async function applyInteractionStates(interactionStates, cleanupOptions = {}) {
29
+ validateInteractionStates(interactionStates);
30
+ const hoverTarget = interactionStates.find(({ states }) => states.includes('hover'))?.locator;
31
+ const focusTarget = interactionStates.find(({ states }) => states.includes('focus'))?.locator;
32
+ let hoverApplied = false;
33
+ let focusApplied = false;
34
+ let cleaned = false;
35
+ const cleanup = async () => {
36
+ if (cleaned) {
37
+ return;
38
+ }
39
+ cleaned = true;
40
+ try {
41
+ if (focusApplied) {
42
+ await focusTarget?.blur();
43
+ }
44
+ }
45
+ finally {
46
+ if (hoverApplied) {
47
+ await cleanupOptions.clearHover?.();
48
+ }
49
+ }
50
+ };
51
+ try {
52
+ if (hoverTarget) {
53
+ // Mark the state first: Playwright can move the pointer and then fail
54
+ // while waiting for actionability, and that partial effect still needs
55
+ // cleanup.
56
+ hoverApplied = true;
57
+ await hoverTarget.hover();
58
+ }
59
+ if (focusTarget) {
60
+ focusApplied = true;
61
+ await focusTarget.focus();
62
+ }
63
+ return cleanup;
64
+ }
65
+ catch (error) {
66
+ try {
67
+ await cleanup();
68
+ }
69
+ catch {
70
+ // Preserve the interaction failure. Cleanup is best effort on this path;
71
+ // callers cannot act on a cleanup error if applying the state failed.
72
+ }
73
+ throw error;
74
+ }
75
+ }
@@ -0,0 +1 @@
1
+ export { YoutubeMock } from './youtube.js';
@@ -0,0 +1,5 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.YoutubeMock = void 0;
4
+ var youtube_js_1 = require("./youtube.js");
5
+ Object.defineProperty(exports, "YoutubeMock", { enumerable: true, get: function () { return youtube_js_1.YoutubeMock; } });
@@ -0,0 +1,5 @@
1
+ import type { Page } from '@playwright/test';
2
+ import type { Mockable } from "../visualdiff.js";
3
+ export declare class YoutubeMock implements Mockable {
4
+ mock(page: Page): Promise<void>;
5
+ }
@@ -0,0 +1,38 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.YoutubeMock = void 0;
4
+ class YoutubeMock {
5
+ async mock(page) {
6
+ await page.route(/www\.youtube\.com/i, async (route) => {
7
+ await route.fulfill({
8
+ contentType: 'text/html',
9
+ body: `
10
+ <html>
11
+ <head>
12
+ <title>Youtube Video Mock</title>
13
+ <style>
14
+ body {
15
+ color: white;
16
+ background-color: darkred;
17
+ }
18
+ div {
19
+ position: absolute;
20
+ top: 50%;
21
+ left: 50%;
22
+ margin: 0;
23
+ transform: translate(-50%, -50%);
24
+ font-size: xxx-large;
25
+ text-align: center;
26
+ }
27
+ </style>
28
+ </head>
29
+ <body>
30
+ <div>Youtube Video Mock</div>
31
+ </body>
32
+ </html>
33
+ `,
34
+ });
35
+ });
36
+ }
37
+ }
38
+ exports.YoutubeMock = YoutubeMock;
@@ -0,0 +1,17 @@
1
+ import { Page } from '@playwright/test';
2
+ /** Pseudo-classes supported by Chromium's CSS.forcePseudoState command. */
3
+ export type ForcedPseudoClass = 'active' | 'focus' | 'focus-visible' | 'focus-within' | 'hover' | 'target';
4
+ /** A CSS selector and the pseudo-classes to force on its first match. */
5
+ export interface ForcedPseudoState {
6
+ selector: string;
7
+ pseudoClasses: ForcedPseudoClass[];
8
+ }
9
+ /**
10
+ * Force CSS pseudo-classes on the first element matching a selector.
11
+ *
12
+ * This uses Chromium's DevTools Protocol and therefore only works in Chromium
13
+ * projects. The state is synthetic: moving the pointer or blurring the active
14
+ * element does not clear it. Call the returned idempotent cleanup function when
15
+ * the screenshot and accessibility scan are complete.
16
+ */
17
+ export declare function forcePseudoState(page: Page, selector: string, pseudoClasses: ForcedPseudoClass[]): Promise<() => Promise<void>>;
@@ -0,0 +1,50 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.forcePseudoState = forcePseudoState;
4
+ /**
5
+ * Force CSS pseudo-classes on the first element matching a selector.
6
+ *
7
+ * This uses Chromium's DevTools Protocol and therefore only works in Chromium
8
+ * projects. The state is synthetic: moving the pointer or blurring the active
9
+ * element does not clear it. Call the returned idempotent cleanup function when
10
+ * the screenshot and accessibility scan are complete.
11
+ */
12
+ async function forcePseudoState(page, selector, pseudoClasses) {
13
+ let session;
14
+ try {
15
+ session = await page.context().newCDPSession(page);
16
+ }
17
+ catch (error) {
18
+ throw new Error('forcePseudoState() requires a Chromium browser project.', { cause: error });
19
+ }
20
+ try {
21
+ await session.send('DOM.enable');
22
+ await session.send('CSS.enable');
23
+ const { root } = await session.send('DOM.getDocument');
24
+ const { nodeId } = await session.send('DOM.querySelector', {
25
+ nodeId: root.nodeId,
26
+ selector,
27
+ });
28
+ if (!nodeId) {
29
+ throw new Error(`forcePseudoState() could not find an element matching "${selector}".`);
30
+ }
31
+ await session.send('CSS.forcePseudoState', { nodeId, forcedPseudoClasses: pseudoClasses });
32
+ let cleared = false;
33
+ return async () => {
34
+ if (cleared) {
35
+ return;
36
+ }
37
+ cleared = true;
38
+ try {
39
+ await session.send('CSS.forcePseudoState', { nodeId, forcedPseudoClasses: [] });
40
+ }
41
+ finally {
42
+ await session.detach();
43
+ }
44
+ };
45
+ }
46
+ catch (error) {
47
+ await session.detach();
48
+ throw error;
49
+ }
50
+ }
@@ -0,0 +1,134 @@
1
+ import { Page } from "@playwright/test";
2
+ /**
3
+ * Settle every <video> on the page so a screenshot of it is reproducible.
4
+ *
5
+ * A `<video>` is the one replaced element nothing else in the screenshot path
6
+ * covers. waitForImages() only looks at `img`, and Playwright's
7
+ * `animations: 'disabled'` only fast-forwards CSS transitions and Web
8
+ * Animations -- it does not touch media playback. That leaves two separate ways
9
+ * for a video to break a visual comparison:
10
+ *
11
+ * 1. **The frame is captured before it exists.** A full-page screenshot rasters
12
+ * the whole document, including regions that were never in the viewport, so
13
+ * it will happily capture a video that is still loading. The region paints as
14
+ * an empty box on one capture and as the video on the next, which Playwright
15
+ * reports as "Failed to take two consecutive stable screenshots" -- the run
16
+ * burns its whole stability window and fails even though the image it ended
17
+ * up with was correct. When the video is slower still, the captures agree on
18
+ * the empty box, which is worse: it passes stability and bakes a blank
19
+ * rectangle into the baseline if snapshots are being regenerated.
20
+ * 2. **The frame is captured while the video is playing.** An `autoplay muted
21
+ * loop` video renders whatever moment the shutter caught. Chromium only
22
+ * autoplays a muted video while it is on screen, so whether such a baseline
23
+ * is reproducible at all currently rests on nothing more principled than
24
+ * whether an earlier scroll happened to bring the video into the viewport.
25
+ *
26
+ * Both are fixed together: pin playback first, bring the video on screen so it
27
+ * both loads and composites, wait for a decodable frame, and rewind.
28
+ *
29
+ * Note this is deliberately *not* built on `requestVideoFrameCallback()`, which
30
+ * looks like the right signal and is not: it only fires when a frame is
31
+ * presented, and a paused off-screen video never presents one. Measured against
32
+ * a real page it never fired at all -- every call sat there until its timeout,
33
+ * adding the full timeout to each capture and reporting nothing.
34
+ */
35
+ /**
36
+ * Settle every visible video, in the browser.
37
+ *
38
+ * This runs in the browser via `page.evaluate()`, which serializes the function
39
+ * source, so it must stay self-contained and reference nothing else in this
40
+ * module. It is exported separately from waitForVideos() so it can be tested
41
+ * directly.
42
+ *
43
+ * The order of operations matters and is not the obvious one:
44
+ *
45
+ * - Playback is pinned -- `autoplay` cleared and the element paused -- *before*
46
+ * the video is scrolled into view, because bringing a muted `autoplay` video
47
+ * on screen is exactly what starts it: measured on a real page, scrolling to
48
+ * it first and pausing after left `currentTime` at 0.6s and climbing, while
49
+ * pinning first held it at 0 through the same scroll. Pinning first turns the
50
+ * scroll into a pure repaint.
51
+ * - The video is scrolled into view *before* its readiness is polled, because
52
+ * for a lazily loaded video the scroll is what starts the load. Polling first
53
+ * would sit there watching a `preload="none"` element that has been told not
54
+ * to fetch anything, burn the whole timeout, and only then scroll it on
55
+ * screen -- leaving the capture racing a load that had just begun.
56
+ *
57
+ * Videos are handled in serial: each one is scrolled into view in turn, and
58
+ * doing that concurrently would just mean the last one wins. Every scroll is
59
+ * undone -- the window offset and any scrollable ancestor `scrollIntoView()`
60
+ * moved -- so this composes with callers that care where the page is left;
61
+ * waitForImages(), in particular, does its own careful scroll back to the top.
62
+ *
63
+ * Playback state is not restored here, because the whole point is to leave the
64
+ * videos pinned at their first frame for the capture. It is recorded on each
65
+ * element so restorePlayback() can put it back afterwards.
66
+ *
67
+ * A video that never produces a frame is not an error. A page that legitimately
68
+ * references a missing or undecodable video should still be screenshotted and
69
+ * still have its accessibility checked, and the comparison is what fails. The
70
+ * wait is not silent either: the sources that never became ready are returned
71
+ * so the caller can report them.
72
+ *
73
+ * @param options.timeoutMs How long to wait for each video to become ready.
74
+ * @param options.pollMs How long to sleep between readiness checks.
75
+ * @param options.paintTimeoutMs How long to wait on a frame or a seek that may
76
+ * never arrive.
77
+ * @returns The sources of the videos that never became ready, and whether the
78
+ * page was scrolled at all.
79
+ */
80
+ export declare function settleVideos(options: {
81
+ timeoutMs: number;
82
+ pollMs?: number;
83
+ paintTimeoutMs?: number;
84
+ }): Promise<{
85
+ notReady: string[];
86
+ scrolled: boolean;
87
+ }>;
88
+ /**
89
+ * Put back the playback state settleVideos() recorded, in the browser.
90
+ *
91
+ * Like settleVideos() this is serialized into the page by `page.evaluate()`, so
92
+ * it must stay self-contained, and it is exported separately from
93
+ * restoreVideoPlayback() so it can be tested directly.
94
+ *
95
+ * Videos that were not settled are left alone, and the recorded state is
96
+ * cleared as it is applied so a second call is a no-op rather than a rewind of
97
+ * whatever the test did in between.
98
+ */
99
+ export declare function restorePlayback(): void;
100
+ /**
101
+ * Wait for every visible video to be ready, composited, and paused at its
102
+ * first frame.
103
+ *
104
+ * See settleVideos() for what "ready" means, why playback is pinned before the
105
+ * video is scrolled into view, why the readiness poll comes after that scroll,
106
+ * and why this is not built on `requestVideoFrameCallback()`.
107
+ *
108
+ * This runs in every frame, not just the main one: media embeds commonly render
109
+ * inside iframes, and a
110
+ * `<video>` in one of those is no less able to break a comparison.
111
+ *
112
+ * @param page
113
+ * @param options A timeout or settling options.
114
+ * @returns The sources of the videos that never became ready. Empty when they
115
+ * all did.
116
+ */
117
+ export interface WaitForVideosOptions {
118
+ /** How long to wait for each video to produce a frame. */
119
+ timeoutMs?: number;
120
+ /** Optional adapter hook run after video scrolling has been restored. */
121
+ afterScroll?: (page: Page) => Promise<void>;
122
+ }
123
+ export declare function waitForVideos(page: Page, options?: number | WaitForVideosOptions): Promise<string[]>;
124
+ /**
125
+ * Restore the playback state waitForVideos() pinned.
126
+ *
127
+ * Settling a video means pausing it, clearing `autoplay` and rewinding it, and
128
+ * a test that takes a screenshot has not asked for any of that to outlive the
129
+ * capture -- it may well go on to assert that the video is playing. Call this
130
+ * once the screenshot is taken to hand the page back as it was found.
131
+ *
132
+ * @param page
133
+ */
134
+ export declare function restoreVideoPlayback(page: Page): Promise<void>;