@lullabot/playwright-testing 1.1.0 → 1.2.1
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 +13 -5
- package/lib/accessibility-baseline-file.js +33 -27
- package/lib/index.d.ts +1 -0
- package/lib/index.js +1 -0
- package/lib/webkit-autofocus.d.ts +16 -0
- package/lib/webkit-autofocus.js +136 -0
- package/package.json +3 -2
- package/src/accessibility-baseline-file.test.ts +165 -6
- package/src/accessibility-baseline-file.ts +32 -27
- package/src/accessibility-baseline.test.ts +19 -2
- package/src/index.ts +1 -0
- package/src/webkit-autofocus.ts +154 -0
package/README.md
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
# `@lullabot/playwright-testing`
|
|
2
2
|
|
|
3
3
|
Framework-neutral utilities for stable Playwright screenshots, accessibility
|
|
4
|
-
baselines, URL-driven visual comparisons,
|
|
4
|
+
baselines, URL-driven visual comparisons, WebKit autofocus stabilization, and
|
|
5
|
+
GitHub reporting.
|
|
5
6
|
|
|
6
7
|
This package is developed in the
|
|
7
8
|
[`playwright-drupal` monorepo](https://github.com/Lullabot/playwright-drupal),
|
|
@@ -24,7 +25,8 @@ version.
|
|
|
24
25
|
|
|
25
26
|
- `@lullabot/playwright-testing` exports screenshot stabilization,
|
|
26
27
|
accessibility checks and baselines, visual-diff definitions, interaction and
|
|
27
|
-
pseudo-state helpers, and
|
|
28
|
+
pseudo-state helpers, reusable mocks, and the `suppressWebKitAutofocus()` init
|
|
29
|
+
script (installed explicitly in WebKit contexts).
|
|
28
30
|
- `@lullabot/playwright-testing/github` exports the optional GitHub report,
|
|
29
31
|
attachment-upload, and path-remapping APIs.
|
|
30
32
|
- `playwright-testing-a11y-summary` and
|
|
@@ -47,9 +49,10 @@ test('home page', async ({ page }, testInfo) => {
|
|
|
47
49
|
|
|
48
50
|
## Guides
|
|
49
51
|
|
|
50
|
-
- [Accessibility testing]
|
|
51
|
-
- [Stable screenshots and visual comparisons]
|
|
52
|
-
- [
|
|
52
|
+
- [Accessibility testing][testing-accessibility]
|
|
53
|
+
- [Stable screenshots and visual comparisons][testing-screenshots]
|
|
54
|
+
- [WebKit native autofocus workaround][testing-webkit-autofocus]
|
|
55
|
+
- [GitHub reporting][testing-github-reporting]
|
|
53
56
|
|
|
54
57
|
## Drupal compatibility
|
|
55
58
|
|
|
@@ -59,3 +62,8 @@ visual-diff calls with its Drupal preset. New framework-neutral code can import
|
|
|
59
62
|
this package directly; Drupal tests that depend on database isolation, Drush,
|
|
60
63
|
login helpers, or the `a11y` fixture should continue importing the Drupal
|
|
61
64
|
package.
|
|
65
|
+
|
|
66
|
+
[testing-accessibility]: https://github.com/Lullabot/playwright-drupal/blob/main/packages/playwright-testing/docs/accessibility.md
|
|
67
|
+
[testing-screenshots]: https://github.com/Lullabot/playwright-drupal/blob/main/packages/playwright-testing/docs/screenshots-and-visual-comparisons.md
|
|
68
|
+
[testing-github-reporting]: https://github.com/Lullabot/playwright-drupal/blob/main/packages/playwright-testing/docs/github-reporting.md
|
|
69
|
+
[testing-webkit-autofocus]: https://github.com/Lullabot/playwright-drupal/blob/main/packages/playwright-testing/docs/webkit-autofocus.md
|
|
@@ -12,6 +12,7 @@ exports.writeBaselineFile = writeBaselineFile;
|
|
|
12
12
|
exports.buildSeed = buildSeed;
|
|
13
13
|
const fs_1 = require("fs");
|
|
14
14
|
const path_1 = __importDefault(require("path"));
|
|
15
|
+
const crypto_1 = require("crypto");
|
|
15
16
|
const scanInvocationCounters = new WeakMap();
|
|
16
17
|
/**
|
|
17
18
|
* Increment and return the 1-indexed invocation number for this scan kind
|
|
@@ -41,9 +42,7 @@ function resetAccessibilityScanCounts(testInfo) {
|
|
|
41
42
|
scanInvocationCounters.delete(testInfo);
|
|
42
43
|
}
|
|
43
44
|
/**
|
|
44
|
-
* Slugify a test's fully qualified title
|
|
45
|
-
* stem of both on-disk baseline filenames and (for existence checks) the
|
|
46
|
-
* prefix of Playwright's auto-generated snapshot filenames.
|
|
45
|
+
* Slugify a test's fully qualified title for on-disk JSON baseline filenames.
|
|
47
46
|
*
|
|
48
47
|
* Implemented as a single-pass character scan to avoid regex-based
|
|
49
48
|
* polynomial backtracking on library-supplied input (CodeQL
|
|
@@ -78,17 +77,20 @@ function slugifyTitle(testInfo) {
|
|
|
78
77
|
return out;
|
|
79
78
|
}
|
|
80
79
|
/**
|
|
81
|
-
*
|
|
82
|
-
*
|
|
83
|
-
*
|
|
84
|
-
*
|
|
85
|
-
*
|
|
86
|
-
* `sanitizeForFilePath()`. Very long titles, which Playwright truncates
|
|
87
|
-
* and hashes, are not handled.
|
|
80
|
+
* Playwright's anonymous snapshot argument: trim the full title INCLUDING
|
|
81
|
+
* the counter to 100 UTF-16 code units, inserting five SHA-1 hex characters
|
|
82
|
+
* in the middle, then sanitize it. There is no public API for generating an
|
|
83
|
+
* arbitrary anonymous counter; snapshotPath() resolves this explicit argument.
|
|
84
|
+
* Keep this compatibility implementation covered by real runner tests.
|
|
88
85
|
*/
|
|
89
|
-
function playwrightSnapshotStem(testInfo) {
|
|
86
|
+
function playwrightSnapshotStem(testInfo, counter) {
|
|
90
87
|
const segments = testInfo.titlePath?.slice(1) ?? [];
|
|
91
|
-
const
|
|
88
|
+
const title = segments.length > 0 ? segments.join(" ") : testInfo.title;
|
|
89
|
+
let raw = `${title} ${counter}`;
|
|
90
|
+
if (raw.length > 100) {
|
|
91
|
+
const hash = (0, crypto_1.createHash)("sha1").update(raw).digest("hex").slice(0, 5);
|
|
92
|
+
raw = `${raw.slice(0, 46)}-${hash}-${raw.slice(-47)}`;
|
|
93
|
+
}
|
|
92
94
|
let out = "";
|
|
93
95
|
let lastWasReplaced = false;
|
|
94
96
|
for (let i = 0; i < raw.length; i++) {
|
|
@@ -136,7 +138,7 @@ function baselineFilePath(testInfo, scan, callCount) {
|
|
|
136
138
|
* defaulting new (snapshotless) tests into on-disk baseline mode.
|
|
137
139
|
*/
|
|
138
140
|
async function snapshotExists(testInfo) {
|
|
139
|
-
const dir = path_1.default.dirname(testInfo.snapshotPath("a11y-baseline-probe"));
|
|
141
|
+
const dir = path_1.default.dirname(testInfo.snapshotPath("a11y-baseline-probe.txt"));
|
|
140
142
|
let entries;
|
|
141
143
|
try {
|
|
142
144
|
entries = await fs_1.promises.readdir(dir);
|
|
@@ -146,20 +148,24 @@ async function snapshotExists(testInfo) {
|
|
|
146
148
|
return false;
|
|
147
149
|
throw err;
|
|
148
150
|
}
|
|
149
|
-
//
|
|
150
|
-
//
|
|
151
|
-
//
|
|
152
|
-
const
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
151
|
+
// Extract possible counters, then compare the entire resolved path. A
|
|
152
|
+
// prefix match can accidentally select another test (e.g. "Video 2").
|
|
153
|
+
// Resolving via the public API honors project, suffix, and path templates.
|
|
154
|
+
for (const name of entries) {
|
|
155
|
+
if (!name.endsWith(".txt"))
|
|
156
|
+
continue;
|
|
157
|
+
for (const match of name.matchAll(/[0-9]+/g)) {
|
|
158
|
+
const counter = match[0];
|
|
159
|
+
if (counter.startsWith("0"))
|
|
160
|
+
continue;
|
|
161
|
+
const expected = testInfo.snapshotPath(`${playwrightSnapshotStem(testInfo, counter)}.txt`);
|
|
162
|
+
if (path_1.default.resolve(expected) === path_1.default.resolve(dir, name)) {
|
|
163
|
+
if ((await fs_1.promises.stat(expected)).isFile())
|
|
164
|
+
return true;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
return false;
|
|
163
169
|
}
|
|
164
170
|
async function readBaselineFile(filePath) {
|
|
165
171
|
let raw;
|
package/lib/index.d.ts
CHANGED
package/lib/index.js
CHANGED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Runs in each document via BrowserContext.addInitScript(). Keep self-contained.
|
|
3
|
+
*
|
|
4
|
+
* WebKit queues autofocus on insertion, without rechecking the attribute later.
|
|
5
|
+
* Make candidates unfocusable until flushAutofocusCandidates has discarded them.
|
|
6
|
+
* Page::updateRendering flushes autofocus BEFORE animation-frame callbacks; a
|
|
7
|
+
* parser waiting for stylesheets can defer that flush, so wait for the top
|
|
8
|
+
* document's DOMContentLoaded first. This intentionally suppresses document
|
|
9
|
+
* autofocus, rather than replacing Playwright's focus/selection/input behavior.
|
|
10
|
+
*
|
|
11
|
+
* See docs/webkit-autofocus.md for scope and tradeoffs.
|
|
12
|
+
*
|
|
13
|
+
* Install only in WebKit contexts, before pages navigate:
|
|
14
|
+
* `await context.addInitScript(suppressWebKitAutofocus)`.
|
|
15
|
+
*/
|
|
16
|
+
export declare function suppressWebKitAutofocus(): void;
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.suppressWebKitAutofocus = suppressWebKitAutofocus;
|
|
4
|
+
/**
|
|
5
|
+
* Runs in each document via BrowserContext.addInitScript(). Keep self-contained.
|
|
6
|
+
*
|
|
7
|
+
* WebKit queues autofocus on insertion, without rechecking the attribute later.
|
|
8
|
+
* Make candidates unfocusable until flushAutofocusCandidates has discarded them.
|
|
9
|
+
* Page::updateRendering flushes autofocus BEFORE animation-frame callbacks; a
|
|
10
|
+
* parser waiting for stylesheets can defer that flush, so wait for the top
|
|
11
|
+
* document's DOMContentLoaded first. This intentionally suppresses document
|
|
12
|
+
* autofocus, rather than replacing Playwright's focus/selection/input behavior.
|
|
13
|
+
*
|
|
14
|
+
* See docs/webkit-autofocus.md for scope and tradeoffs.
|
|
15
|
+
*
|
|
16
|
+
* Install only in WebKit contexts, before pages navigate:
|
|
17
|
+
* `await context.addInitScript(suppressWebKitAutofocus)`.
|
|
18
|
+
*/
|
|
19
|
+
function suppressWebKitAutofocus() {
|
|
20
|
+
let topDocument;
|
|
21
|
+
try {
|
|
22
|
+
topDocument = window.top.document;
|
|
23
|
+
}
|
|
24
|
+
catch {
|
|
25
|
+
// WebKit already rejects native autofocus candidates in cross-origin frames.
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
const pending = new Map();
|
|
29
|
+
const candidates = new WeakSet();
|
|
30
|
+
let scheduled = false;
|
|
31
|
+
function restoreAfterFlush() {
|
|
32
|
+
requestAnimationFrame(() => {
|
|
33
|
+
scheduled = false;
|
|
34
|
+
for (const [element, original] of pending) {
|
|
35
|
+
if (element.style.getPropertyValue(original.property) ===
|
|
36
|
+
original.temporary &&
|
|
37
|
+
element.style.getPropertyPriority(original.property) === "important") {
|
|
38
|
+
if (original.value) {
|
|
39
|
+
element.style.setProperty(original.property, original.value, original.priority);
|
|
40
|
+
}
|
|
41
|
+
else {
|
|
42
|
+
element.style.removeProperty(original.property);
|
|
43
|
+
}
|
|
44
|
+
// Synchronize WebKit's serialized style attribute before removing it.
|
|
45
|
+
if (!original.hadStyle && !element.getAttribute("style")) {
|
|
46
|
+
element.removeAttribute("style");
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
pending.clear();
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
function quarantine(element) {
|
|
54
|
+
if (element.namespaceURI !== "http://www.w3.org/1999/xhtml")
|
|
55
|
+
return;
|
|
56
|
+
// Remember detached candidates too: the parser may still be blocking their
|
|
57
|
+
// queued opportunity when they are reinserted without the attribute.
|
|
58
|
+
candidates.add(element);
|
|
59
|
+
if (!element.isConnected)
|
|
60
|
+
return;
|
|
61
|
+
const candidate = element;
|
|
62
|
+
if (!pending.has(candidate)) {
|
|
63
|
+
// Keep normal form controls in layout. Hide other candidates' subtrees
|
|
64
|
+
// entirely, including hosts whose shadow roots might delegate focus.
|
|
65
|
+
const isControl = /^(input|textarea|select|button)$/.test(candidate.localName);
|
|
66
|
+
const property = isControl ? "visibility" : "display";
|
|
67
|
+
const temporary = isControl ? "hidden" : "none";
|
|
68
|
+
// Capture attribute presence before changing its CSSStyleDeclaration.
|
|
69
|
+
const hadStyle = candidate.hasAttribute("style");
|
|
70
|
+
pending.set(candidate, {
|
|
71
|
+
property,
|
|
72
|
+
temporary,
|
|
73
|
+
value: candidate.style.getPropertyValue(property),
|
|
74
|
+
priority: candidate.style.getPropertyPriority(property),
|
|
75
|
+
hadStyle,
|
|
76
|
+
});
|
|
77
|
+
candidate.style.setProperty(property, temporary, "important");
|
|
78
|
+
}
|
|
79
|
+
if (scheduled)
|
|
80
|
+
return;
|
|
81
|
+
scheduled = true;
|
|
82
|
+
if (topDocument.readyState === "loading") {
|
|
83
|
+
topDocument.addEventListener("DOMContentLoaded", restoreAfterFlush, {
|
|
84
|
+
once: true,
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
else {
|
|
88
|
+
restoreAfterFlush();
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
function scan(node) {
|
|
92
|
+
if (node.nodeType !== Node.ELEMENT_NODE)
|
|
93
|
+
return;
|
|
94
|
+
const element = node;
|
|
95
|
+
if (element.hasAttribute("autofocus") || candidates.has(element))
|
|
96
|
+
quarantine(element);
|
|
97
|
+
element.querySelectorAll("*").forEach((descendant) => {
|
|
98
|
+
if (descendant.hasAttribute("autofocus") || candidates.has(descendant))
|
|
99
|
+
quarantine(descendant);
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
new MutationObserver((records) => {
|
|
103
|
+
for (const record of records) {
|
|
104
|
+
if (record.type === "childList") {
|
|
105
|
+
record.addedNodes.forEach(scan);
|
|
106
|
+
}
|
|
107
|
+
else if (record.attributeName === "style") {
|
|
108
|
+
const element = record.target;
|
|
109
|
+
const original = pending.get(element);
|
|
110
|
+
if (original &&
|
|
111
|
+
(element.style.getPropertyValue(original.property) !==
|
|
112
|
+
original.temporary ||
|
|
113
|
+
element.style.getPropertyPriority(original.property) !==
|
|
114
|
+
"important")) {
|
|
115
|
+
// Keep the candidate hidden through the flush even if application
|
|
116
|
+
// code changes its styles in the meantime. Restore the latest value.
|
|
117
|
+
original.value = element.style.getPropertyValue(original.property);
|
|
118
|
+
original.priority = element.style.getPropertyPriority(original.property);
|
|
119
|
+
original.hadStyle = element.hasAttribute("style");
|
|
120
|
+
element.style.setProperty(original.property, original.temporary, "important");
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
else {
|
|
124
|
+
// Also catch an attribute removed synchronously after insertion, before
|
|
125
|
+
// observer delivery: its native candidate is still in WebKit's queue.
|
|
126
|
+
quarantine(record.target);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}).observe(document, {
|
|
130
|
+
childList: true,
|
|
131
|
+
subtree: true,
|
|
132
|
+
attributes: true,
|
|
133
|
+
attributeFilter: ["autofocus", "style"],
|
|
134
|
+
});
|
|
135
|
+
document.querySelectorAll("[autofocus]").forEach(quarantine);
|
|
136
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lullabot/playwright-testing",
|
|
3
|
-
"version": "1.1
|
|
3
|
+
"version": "1.2.1",
|
|
4
4
|
"description": "Framework-neutral Playwright testing utilities",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"repository": {
|
|
@@ -33,7 +33,8 @@
|
|
|
33
33
|
"scripts": {
|
|
34
34
|
"build": "tsc",
|
|
35
35
|
"prepack": "npm run build",
|
|
36
|
-
"test": "vitest run --passWithNoTests"
|
|
36
|
+
"test": "vitest run --passWithNoTests",
|
|
37
|
+
"test:autofocus": "playwright test --config tests/webkit-autofocus/playwright.config.ts"
|
|
37
38
|
},
|
|
38
39
|
"dependencies": {
|
|
39
40
|
"@axe-core/playwright": "^4.8.5",
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
|
2
2
|
import { promises as fs } from "fs";
|
|
3
3
|
import os from "os";
|
|
4
|
+
import { execFileSync } from "child_process";
|
|
5
|
+
import { createRequire } from "module";
|
|
4
6
|
import path from "path";
|
|
5
7
|
import {
|
|
6
8
|
baselineFilePath,
|
|
@@ -16,6 +18,7 @@ function makeTestInfo(opts: {
|
|
|
16
18
|
dir: string;
|
|
17
19
|
title?: string;
|
|
18
20
|
titlePath?: string[];
|
|
21
|
+
suffix?: string;
|
|
19
22
|
}) {
|
|
20
23
|
const title = opts.title ?? "standalone accessibility check works";
|
|
21
24
|
const titlePath = opts.titlePath ?? ["file.spec.ts", title];
|
|
@@ -23,7 +26,16 @@ function makeTestInfo(opts: {
|
|
|
23
26
|
testId: `${title}-${Math.random()}`,
|
|
24
27
|
title,
|
|
25
28
|
titlePath,
|
|
26
|
-
snapshotPath: (...segs: string[]) =>
|
|
29
|
+
snapshotPath: (...segs: string[]) => {
|
|
30
|
+
const name = segs.join(path.sep);
|
|
31
|
+
const ext = path.extname(name);
|
|
32
|
+
return path.join(
|
|
33
|
+
opts.dir,
|
|
34
|
+
name.slice(0, name.length - ext.length) +
|
|
35
|
+
(ext === ".txt" ? (opts.suffix ?? "-chromium-linux") : "") +
|
|
36
|
+
ext,
|
|
37
|
+
);
|
|
38
|
+
},
|
|
27
39
|
};
|
|
28
40
|
}
|
|
29
41
|
|
|
@@ -115,37 +127,184 @@ describe("accessibility-baseline-file", () => {
|
|
|
115
127
|
const ti = makeTestInfo({
|
|
116
128
|
dir: tmpDir,
|
|
117
129
|
title: "Test error pages: ID 3303",
|
|
130
|
+
suffix: "-desktop-chrome-linux",
|
|
118
131
|
});
|
|
119
132
|
expect(await snapshotExists(ti)).toBe(true);
|
|
120
133
|
});
|
|
121
134
|
|
|
122
135
|
it("matches with different project suffixes", async () => {
|
|
123
136
|
await touch("Login-Works-1-mobile-safari-darwin.txt");
|
|
124
|
-
const ti = makeTestInfo({
|
|
137
|
+
const ti = makeTestInfo({
|
|
138
|
+
dir: tmpDir,
|
|
139
|
+
title: "Login Works",
|
|
140
|
+
suffix: "-mobile-safari-darwin",
|
|
141
|
+
});
|
|
125
142
|
expect(await snapshotExists(ti)).toBe(true);
|
|
126
143
|
});
|
|
127
144
|
|
|
145
|
+
it("does not match a numeric extension of the title or another project", async () => {
|
|
146
|
+
await touch("Login-Works-2-1-chromium-linux.txt");
|
|
147
|
+
await touch("Login-Works-1-other-project-linux.txt");
|
|
148
|
+
expect(
|
|
149
|
+
await snapshotExists(
|
|
150
|
+
makeTestInfo({ dir: tmpDir, title: "Login Works" }),
|
|
151
|
+
),
|
|
152
|
+
).toBe(false);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it("matches punctuation adjoining literal hyphens and trailing separators", async () => {
|
|
156
|
+
await touch(
|
|
157
|
+
"Landing-Page-Content-of-type-Landing-Page-2-bureau-of-sorcery-tablet-linux.txt",
|
|
158
|
+
);
|
|
159
|
+
await touch(
|
|
160
|
+
"admin-toolbar-hover-expansion-When-I-mouse-over-an-admin-menu-item-it-expands-consistently-3-desktop-chrome-linux.txt",
|
|
161
|
+
);
|
|
162
|
+
expect(
|
|
163
|
+
await snapshotExists(
|
|
164
|
+
makeTestInfo({
|
|
165
|
+
dir: tmpDir,
|
|
166
|
+
titlePath: [
|
|
167
|
+
"file.spec.ts",
|
|
168
|
+
"Landing Page",
|
|
169
|
+
"Content of type Landing Page!",
|
|
170
|
+
],
|
|
171
|
+
suffix: "-bureau-of-sorcery-tablet-linux",
|
|
172
|
+
}),
|
|
173
|
+
),
|
|
174
|
+
).toBe(true);
|
|
175
|
+
expect(
|
|
176
|
+
await snapshotExists(
|
|
177
|
+
makeTestInfo({
|
|
178
|
+
dir: tmpDir,
|
|
179
|
+
titlePath: [
|
|
180
|
+
"file.spec.ts",
|
|
181
|
+
"admin toolbar hover expansion",
|
|
182
|
+
"When I mouse over an admin menu item it expands consistently!",
|
|
183
|
+
],
|
|
184
|
+
suffix: "-desktop-chrome-linux",
|
|
185
|
+
}),
|
|
186
|
+
),
|
|
187
|
+
).toBe(true);
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it.each([
|
|
191
|
+
undefined,
|
|
192
|
+
"{testDir}/snapshots/{projectName}/{arg}-{platform}{ext}",
|
|
193
|
+
])(
|
|
194
|
+
"detects real Playwright snapshots with template %s",
|
|
195
|
+
async (template) => {
|
|
196
|
+
const require = createRequire(import.meta.url);
|
|
197
|
+
const runner = require.resolve("@playwright/test/cli");
|
|
198
|
+
const api = require.resolve("@playwright/test");
|
|
199
|
+
const helper = require.resolve("./accessibility-baseline-file.ts");
|
|
200
|
+
const cases = [
|
|
201
|
+
[
|
|
202
|
+
"News",
|
|
203
|
+
"The new Defense Against the Dark Arts teacher",
|
|
204
|
+
"4/07/26 new defense against dark arts teacher!",
|
|
205
|
+
],
|
|
206
|
+
["Landing Page", "Content of type Landing Page!"],
|
|
207
|
+
[
|
|
208
|
+
"admin toolbar hover expansion",
|
|
209
|
+
"When I mouse over an admin menu item it expands consistently!",
|
|
210
|
+
],
|
|
211
|
+
["Nested: suite-", "Inner / describe", "Punctuation!? - trailing-"],
|
|
212
|
+
["Long " + "title!? - ".repeat(20), "Same ending"],
|
|
213
|
+
["Long " + "title!? - ".repeat(20) + "different", "Same ending"],
|
|
214
|
+
];
|
|
215
|
+
await fs.writeFile(
|
|
216
|
+
path.join(tmpDir, "playwright.config.cjs"),
|
|
217
|
+
`module.exports = {
|
|
218
|
+
testDir: '.', outputDir: 'output', reporter: 'line',
|
|
219
|
+
projects: [{name: 'bureau-of-sorcery-chrome'}, {name: 'tablet'}],
|
|
220
|
+
snapshotPathTemplate: ${JSON.stringify(template)}
|
|
221
|
+
};`,
|
|
222
|
+
);
|
|
223
|
+
await fs.writeFile(
|
|
224
|
+
path.join(tmpDir, "generated.spec.cjs"),
|
|
225
|
+
`
|
|
226
|
+
const {test, expect} = require(${JSON.stringify(api)});
|
|
227
|
+
const {snapshotExists} = require(${JSON.stringify(helper)});
|
|
228
|
+
const fs = require('fs');
|
|
229
|
+
const path = require('path');
|
|
230
|
+
for (const [outer, inner, title = inner] of ${JSON.stringify(cases)}) {
|
|
231
|
+
test.describe(outer, () => test.describe(inner, () => {
|
|
232
|
+
test(title, async ({}, info) => {
|
|
233
|
+
expect(await snapshotExists(info)).toBe(false);
|
|
234
|
+
for (let i = 0; i < 12; i++) expect('[]').toMatchSnapshot();
|
|
235
|
+
const dir = path.dirname(info.snapshotPath('probe.txt'));
|
|
236
|
+
const files = fs.readdirSync(dir).filter(name => name.endsWith('.txt'));
|
|
237
|
+
// Leave only the twelfth snapshot for this test: detection cannot
|
|
238
|
+
// assume that the first accessibility scan has counter one.
|
|
239
|
+
const own = files.filter(name => name.includes('-12-'));
|
|
240
|
+
for (const name of files) if (!own.includes(name)) fs.unlinkSync(path.join(dir, name));
|
|
241
|
+
expect(await snapshotExists(info)).toBe(true);
|
|
242
|
+
expect('[]').toMatchSnapshot();
|
|
243
|
+
const next = fs.readdirSync(dir).find(name => name.includes('-13-'));
|
|
244
|
+
expect(next).toBeTruthy(); // detection did not consume counters
|
|
245
|
+
fs.unlinkSync(path.join(dir, next));
|
|
246
|
+
expect(await snapshotExists({...info, titlePath: [...info.titlePath.slice(0, -1), title + ' 2'], snapshotPath: info.snapshotPath.bind(info)})).toBe(false);
|
|
247
|
+
expect(await snapshotExists({...info, titlePath: [info.titlePath[0], outer + 'different', ...info.titlePath.slice(2)], snapshotPath: info.snapshotPath.bind(info)})).toBe(false);
|
|
248
|
+
for (const name of own) fs.renameSync(path.join(dir, name), path.join(dir, name.slice(0, -4) + '.png'));
|
|
249
|
+
expect(await snapshotExists(info)).toBe(false);
|
|
250
|
+
});
|
|
251
|
+
}));
|
|
252
|
+
}
|
|
253
|
+
`,
|
|
254
|
+
);
|
|
255
|
+
execFileSync(
|
|
256
|
+
process.execPath,
|
|
257
|
+
[
|
|
258
|
+
runner,
|
|
259
|
+
"test",
|
|
260
|
+
"--config",
|
|
261
|
+
path.join(tmpDir, "playwright.config.cjs"),
|
|
262
|
+
"--update-snapshots",
|
|
263
|
+
"--workers=1",
|
|
264
|
+
],
|
|
265
|
+
{ encoding: "utf8", timeout: 30000 },
|
|
266
|
+
);
|
|
267
|
+
},
|
|
268
|
+
40000,
|
|
269
|
+
);
|
|
270
|
+
|
|
128
271
|
it("does not match a title that differs only by case", async () => {
|
|
129
272
|
await touch("login-works-1-desktop-chrome-linux.txt");
|
|
130
|
-
const ti = makeTestInfo({
|
|
273
|
+
const ti = makeTestInfo({
|
|
274
|
+
dir: tmpDir,
|
|
275
|
+
title: "Login Works",
|
|
276
|
+
suffix: "-desktop-chrome-linux",
|
|
277
|
+
});
|
|
131
278
|
expect(await snapshotExists(ti)).toBe(false);
|
|
132
279
|
});
|
|
133
280
|
|
|
134
281
|
it("returns false when nothing matches", async () => {
|
|
135
282
|
await touch("Other-Test-1-desktop-chrome-linux.txt");
|
|
136
|
-
const ti = makeTestInfo({
|
|
283
|
+
const ti = makeTestInfo({
|
|
284
|
+
dir: tmpDir,
|
|
285
|
+
title: "Login Works",
|
|
286
|
+
suffix: "-desktop-chrome-linux",
|
|
287
|
+
});
|
|
137
288
|
expect(await snapshotExists(ti)).toBe(false);
|
|
138
289
|
});
|
|
139
290
|
|
|
140
291
|
it("ignores a matching .png without a .txt", async () => {
|
|
141
292
|
await touch("Login-Works-1-desktop-chrome-linux.png");
|
|
142
|
-
const ti = makeTestInfo({
|
|
293
|
+
const ti = makeTestInfo({
|
|
294
|
+
dir: tmpDir,
|
|
295
|
+
title: "Login Works",
|
|
296
|
+
suffix: "-desktop-chrome-linux",
|
|
297
|
+
});
|
|
143
298
|
expect(await snapshotExists(ti)).toBe(false);
|
|
144
299
|
});
|
|
145
300
|
|
|
146
301
|
it("does not match a different test whose title extends this one", async () => {
|
|
147
302
|
await touch("CRUD-tests-Video-Promo-1-desktop-chrome-linux.txt");
|
|
148
|
-
const ti = makeTestInfo({
|
|
303
|
+
const ti = makeTestInfo({
|
|
304
|
+
dir: tmpDir,
|
|
305
|
+
title: "CRUD tests - Video",
|
|
306
|
+
suffix: "-desktop-chrome-linux",
|
|
307
|
+
});
|
|
149
308
|
expect(await snapshotExists(ti)).toBe(false);
|
|
150
309
|
});
|
|
151
310
|
});
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { promises as fs } from "fs";
|
|
2
2
|
import path from "path";
|
|
3
|
+
import { createHash } from "crypto";
|
|
3
4
|
import type { TestInfo } from "@playwright/test";
|
|
4
5
|
import type { AccessibilityBaselineEntry } from "./accessibility-baseline.js";
|
|
5
6
|
|
|
@@ -45,9 +46,7 @@ export function resetAccessibilityScanCounts(testInfo: object): void {
|
|
|
45
46
|
}
|
|
46
47
|
|
|
47
48
|
/**
|
|
48
|
-
* Slugify a test's fully qualified title
|
|
49
|
-
* stem of both on-disk baseline filenames and (for existence checks) the
|
|
50
|
-
* prefix of Playwright's auto-generated snapshot filenames.
|
|
49
|
+
* Slugify a test's fully qualified title for on-disk JSON baseline filenames.
|
|
51
50
|
*
|
|
52
51
|
* Implemented as a single-pass character scan to avoid regex-based
|
|
53
52
|
* polynomial backtracking on library-supplied input (CodeQL
|
|
@@ -80,19 +79,23 @@ function slugifyTitle(testInfo: Pick<TestInfo, "titlePath" | "title">): string {
|
|
|
80
79
|
}
|
|
81
80
|
|
|
82
81
|
/**
|
|
83
|
-
*
|
|
84
|
-
*
|
|
85
|
-
*
|
|
86
|
-
*
|
|
87
|
-
*
|
|
88
|
-
* `sanitizeForFilePath()`. Very long titles, which Playwright truncates
|
|
89
|
-
* and hashes, are not handled.
|
|
82
|
+
* Playwright's anonymous snapshot argument: trim the full title INCLUDING
|
|
83
|
+
* the counter to 100 UTF-16 code units, inserting five SHA-1 hex characters
|
|
84
|
+
* in the middle, then sanitize it. There is no public API for generating an
|
|
85
|
+
* arbitrary anonymous counter; snapshotPath() resolves this explicit argument.
|
|
86
|
+
* Keep this compatibility implementation covered by real runner tests.
|
|
90
87
|
*/
|
|
91
88
|
function playwrightSnapshotStem(
|
|
92
89
|
testInfo: Pick<TestInfo, "titlePath" | "title">,
|
|
90
|
+
counter: string,
|
|
93
91
|
): string {
|
|
94
92
|
const segments = testInfo.titlePath?.slice(1) ?? [];
|
|
95
|
-
const
|
|
93
|
+
const title = segments.length > 0 ? segments.join(" ") : testInfo.title;
|
|
94
|
+
let raw = `${title} ${counter}`;
|
|
95
|
+
if (raw.length > 100) {
|
|
96
|
+
const hash = createHash("sha1").update(raw).digest("hex").slice(0, 5);
|
|
97
|
+
raw = `${raw.slice(0, 46)}-${hash}-${raw.slice(-47)}`;
|
|
98
|
+
}
|
|
96
99
|
let out = "";
|
|
97
100
|
let lastWasReplaced = false;
|
|
98
101
|
for (let i = 0; i < raw.length; i++) {
|
|
@@ -148,7 +151,7 @@ export function baselineFilePath(
|
|
|
148
151
|
export async function snapshotExists(
|
|
149
152
|
testInfo: Pick<TestInfo, "snapshotPath" | "titlePath" | "title">,
|
|
150
153
|
): Promise<boolean> {
|
|
151
|
-
const dir = path.dirname(testInfo.snapshotPath("a11y-baseline-probe"));
|
|
154
|
+
const dir = path.dirname(testInfo.snapshotPath("a11y-baseline-probe.txt"));
|
|
152
155
|
let entries: string[];
|
|
153
156
|
try {
|
|
154
157
|
entries = await fs.readdir(dir);
|
|
@@ -156,21 +159,23 @@ export async function snapshotExists(
|
|
|
156
159
|
if (err?.code === "ENOENT") return false;
|
|
157
160
|
throw err;
|
|
158
161
|
}
|
|
159
|
-
//
|
|
160
|
-
//
|
|
161
|
-
//
|
|
162
|
-
const
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
162
|
+
// Extract possible counters, then compare the entire resolved path. A
|
|
163
|
+
// prefix match can accidentally select another test (e.g. "Video 2").
|
|
164
|
+
// Resolving via the public API honors project, suffix, and path templates.
|
|
165
|
+
for (const name of entries) {
|
|
166
|
+
if (!name.endsWith(".txt")) continue;
|
|
167
|
+
for (const match of name.matchAll(/[0-9]+/g)) {
|
|
168
|
+
const counter = match[0];
|
|
169
|
+
if (counter.startsWith("0")) continue;
|
|
170
|
+
const expected = testInfo.snapshotPath(
|
|
171
|
+
`${playwrightSnapshotStem(testInfo, counter)}.txt`,
|
|
172
|
+
);
|
|
173
|
+
if (path.resolve(expected) === path.resolve(dir, name)) {
|
|
174
|
+
if ((await fs.stat(expected)).isFile()) return true;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
return false;
|
|
174
179
|
}
|
|
175
180
|
|
|
176
181
|
export async function readBaselineFile(
|
|
@@ -959,13 +959,15 @@ describe("accessibility baseline", () => {
|
|
|
959
959
|
});
|
|
960
960
|
|
|
961
961
|
it("still uses snapshot mode when a legacy snapshot file exists for the test", async () => {
|
|
962
|
-
mockAnalyze.mockResolvedValue(
|
|
962
|
+
mockAnalyze.mockResolvedValue(
|
|
963
|
+
makeAxeResults({ violations: [makeViolation("image-alt", [["img"]])] }),
|
|
964
|
+
);
|
|
963
965
|
|
|
964
966
|
// Pre-create a Playwright-style snapshot file in the snapshots dir.
|
|
965
967
|
const fs = await import("fs");
|
|
966
968
|
const path = await import("path");
|
|
967
969
|
await fs.promises.writeFile(
|
|
968
|
-
path.join(tmpDir, "legacy-test-1
|
|
970
|
+
path.join(tmpDir, "legacy-test-1.txt"),
|
|
969
971
|
"[]\n",
|
|
970
972
|
);
|
|
971
973
|
|
|
@@ -978,6 +980,21 @@ describe("accessibility baseline", () => {
|
|
|
978
980
|
bestPracticeMode: "off",
|
|
979
981
|
});
|
|
980
982
|
|
|
983
|
+
// A retry must stay in snapshot mode without creating TODO metadata.
|
|
984
|
+
await checkAccessibility(
|
|
985
|
+
makePage() as any,
|
|
986
|
+
makeTestInfo({
|
|
987
|
+
updateSnapshots: "none",
|
|
988
|
+
snapshotsDir: tmpDir,
|
|
989
|
+
title: "legacy test",
|
|
990
|
+
}) as any,
|
|
991
|
+
{ bestPracticeMode: "off" },
|
|
992
|
+
);
|
|
993
|
+
expect(
|
|
994
|
+
(await fs.promises.readdir(tmpDir)).filter((name) =>
|
|
995
|
+
name.endsWith(".json"),
|
|
996
|
+
),
|
|
997
|
+
).toEqual([]);
|
|
981
998
|
// Snapshot mode -> toMatchSnapshot was invoked.
|
|
982
999
|
expect(mockToMatchSnapshot).toHaveBeenCalled();
|
|
983
1000
|
});
|
package/src/index.ts
CHANGED
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Runs in each document via BrowserContext.addInitScript(). Keep self-contained.
|
|
3
|
+
*
|
|
4
|
+
* WebKit queues autofocus on insertion, without rechecking the attribute later.
|
|
5
|
+
* Make candidates unfocusable until flushAutofocusCandidates has discarded them.
|
|
6
|
+
* Page::updateRendering flushes autofocus BEFORE animation-frame callbacks; a
|
|
7
|
+
* parser waiting for stylesheets can defer that flush, so wait for the top
|
|
8
|
+
* document's DOMContentLoaded first. This intentionally suppresses document
|
|
9
|
+
* autofocus, rather than replacing Playwright's focus/selection/input behavior.
|
|
10
|
+
*
|
|
11
|
+
* See docs/webkit-autofocus.md for scope and tradeoffs.
|
|
12
|
+
*
|
|
13
|
+
* Install only in WebKit contexts, before pages navigate:
|
|
14
|
+
* `await context.addInitScript(suppressWebKitAutofocus)`.
|
|
15
|
+
*/
|
|
16
|
+
export function suppressWebKitAutofocus(): void {
|
|
17
|
+
let topDocument: Document;
|
|
18
|
+
try {
|
|
19
|
+
topDocument = window.top!.document;
|
|
20
|
+
} catch {
|
|
21
|
+
// WebKit already rejects native autofocus candidates in cross-origin frames.
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const pending = new Map<
|
|
26
|
+
HTMLElement,
|
|
27
|
+
{
|
|
28
|
+
property: "visibility" | "display";
|
|
29
|
+
temporary: "hidden" | "none";
|
|
30
|
+
value: string;
|
|
31
|
+
priority: string;
|
|
32
|
+
hadStyle: boolean;
|
|
33
|
+
}
|
|
34
|
+
>();
|
|
35
|
+
const candidates = new WeakSet<Element>();
|
|
36
|
+
let scheduled = false;
|
|
37
|
+
|
|
38
|
+
function restoreAfterFlush(): void {
|
|
39
|
+
requestAnimationFrame(() => {
|
|
40
|
+
scheduled = false;
|
|
41
|
+
for (const [element, original] of pending) {
|
|
42
|
+
if (
|
|
43
|
+
element.style.getPropertyValue(original.property) ===
|
|
44
|
+
original.temporary &&
|
|
45
|
+
element.style.getPropertyPriority(original.property) === "important"
|
|
46
|
+
) {
|
|
47
|
+
if (original.value) {
|
|
48
|
+
element.style.setProperty(
|
|
49
|
+
original.property,
|
|
50
|
+
original.value,
|
|
51
|
+
original.priority,
|
|
52
|
+
);
|
|
53
|
+
} else {
|
|
54
|
+
element.style.removeProperty(original.property);
|
|
55
|
+
}
|
|
56
|
+
// Synchronize WebKit's serialized style attribute before removing it.
|
|
57
|
+
if (!original.hadStyle && !element.getAttribute("style")) {
|
|
58
|
+
element.removeAttribute("style");
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
pending.clear();
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function quarantine(element: Element): void {
|
|
67
|
+
if (element.namespaceURI !== "http://www.w3.org/1999/xhtml") return;
|
|
68
|
+
// Remember detached candidates too: the parser may still be blocking their
|
|
69
|
+
// queued opportunity when they are reinserted without the attribute.
|
|
70
|
+
candidates.add(element);
|
|
71
|
+
if (!element.isConnected) return;
|
|
72
|
+
const candidate = element as HTMLElement;
|
|
73
|
+
if (!pending.has(candidate)) {
|
|
74
|
+
// Keep normal form controls in layout. Hide other candidates' subtrees
|
|
75
|
+
// entirely, including hosts whose shadow roots might delegate focus.
|
|
76
|
+
const isControl = /^(input|textarea|select|button)$/.test(
|
|
77
|
+
candidate.localName,
|
|
78
|
+
);
|
|
79
|
+
const property = isControl ? "visibility" : "display";
|
|
80
|
+
const temporary = isControl ? "hidden" : "none";
|
|
81
|
+
// Capture attribute presence before changing its CSSStyleDeclaration.
|
|
82
|
+
const hadStyle = candidate.hasAttribute("style");
|
|
83
|
+
pending.set(candidate, {
|
|
84
|
+
property,
|
|
85
|
+
temporary,
|
|
86
|
+
value: candidate.style.getPropertyValue(property),
|
|
87
|
+
priority: candidate.style.getPropertyPriority(property),
|
|
88
|
+
hadStyle,
|
|
89
|
+
});
|
|
90
|
+
candidate.style.setProperty(property, temporary, "important");
|
|
91
|
+
}
|
|
92
|
+
if (scheduled) return;
|
|
93
|
+
scheduled = true;
|
|
94
|
+
if (topDocument.readyState === "loading") {
|
|
95
|
+
topDocument.addEventListener("DOMContentLoaded", restoreAfterFlush, {
|
|
96
|
+
once: true,
|
|
97
|
+
});
|
|
98
|
+
} else {
|
|
99
|
+
restoreAfterFlush();
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function scan(node: Node): void {
|
|
104
|
+
if (node.nodeType !== Node.ELEMENT_NODE) return;
|
|
105
|
+
const element = node as Element;
|
|
106
|
+
if (element.hasAttribute("autofocus") || candidates.has(element))
|
|
107
|
+
quarantine(element);
|
|
108
|
+
element.querySelectorAll("*").forEach((descendant) => {
|
|
109
|
+
if (descendant.hasAttribute("autofocus") || candidates.has(descendant))
|
|
110
|
+
quarantine(descendant);
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
new MutationObserver((records) => {
|
|
115
|
+
for (const record of records) {
|
|
116
|
+
if (record.type === "childList") {
|
|
117
|
+
record.addedNodes.forEach(scan);
|
|
118
|
+
} else if (record.attributeName === "style") {
|
|
119
|
+
const element = record.target as HTMLElement;
|
|
120
|
+
const original = pending.get(element);
|
|
121
|
+
if (
|
|
122
|
+
original &&
|
|
123
|
+
(element.style.getPropertyValue(original.property) !==
|
|
124
|
+
original.temporary ||
|
|
125
|
+
element.style.getPropertyPriority(original.property) !==
|
|
126
|
+
"important")
|
|
127
|
+
) {
|
|
128
|
+
// Keep the candidate hidden through the flush even if application
|
|
129
|
+
// code changes its styles in the meantime. Restore the latest value.
|
|
130
|
+
original.value = element.style.getPropertyValue(original.property);
|
|
131
|
+
original.priority = element.style.getPropertyPriority(
|
|
132
|
+
original.property,
|
|
133
|
+
);
|
|
134
|
+
original.hadStyle = element.hasAttribute("style");
|
|
135
|
+
element.style.setProperty(
|
|
136
|
+
original.property,
|
|
137
|
+
original.temporary,
|
|
138
|
+
"important",
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
} else {
|
|
142
|
+
// Also catch an attribute removed synchronously after insertion, before
|
|
143
|
+
// observer delivery: its native candidate is still in WebKit's queue.
|
|
144
|
+
quarantine(record.target as Element);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
}).observe(document, {
|
|
148
|
+
childList: true,
|
|
149
|
+
subtree: true,
|
|
150
|
+
attributes: true,
|
|
151
|
+
attributeFilter: ["autofocus", "style"],
|
|
152
|
+
});
|
|
153
|
+
document.querySelectorAll("[autofocus]").forEach(quarantine);
|
|
154
|
+
}
|