@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 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, and GitHub reporting.
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 reusable mocks.
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](https://github.com/Lullabot/playwright-drupal/blob/main/packages/playwright-testing/docs/accessibility.md)
51
- - [Stable screenshots and visual comparisons](https://github.com/Lullabot/playwright-drupal/blob/main/packages/playwright-testing/docs/screenshots-and-visual-comparisons.md)
52
- - [GitHub reporting](https://github.com/Lullabot/playwright-drupal/blob/main/packages/playwright-testing/docs/github-reporting.md)
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 the same way we use it as the
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
- * Reproduce the stem Playwright uses for auto-named snapshot files: the
82
- * title path (minus the spec file) joined with spaces, with every run of
83
- * control/punctuation characters collapsed to a single hyphen. Unlike
84
- * `slugifyTitle()` this preserves case and does not trim hyphens, so it
85
- * matches committed snapshot filenames exactly. Mirrors Playwright's
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 raw = segments.length > 0 ? segments.join(" ") : testInfo.title;
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
- // Playwright names snapshots `<stem>-<counter>[-<project>-<platform>].txt`.
150
- // Require the counter so a title that merely extends this one (e.g.
151
- // "Video" vs "Video Promo") is not mistaken for this test's snapshot.
152
- const prefix = `${playwrightSnapshotStem(testInfo)}-`;
153
- return entries.some((name) => {
154
- if (!name.startsWith(prefix) || !name.endsWith(".txt"))
155
- return false;
156
- let i = prefix.length;
157
- while (i < name.length &&
158
- name.charCodeAt(i) >= 48 &&
159
- name.charCodeAt(i) <= 57)
160
- i++;
161
- return i > prefix.length && (name[i] === "-" || name[i] === ".");
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
@@ -11,3 +11,4 @@ export * from "./pseudo-state.js";
11
11
  export * from "./videos.js";
12
12
  export * from "./visualdiff.js";
13
13
  export * from "./mock/index.js";
14
+ export * from "./webkit-autofocus.js";
package/lib/index.js CHANGED
@@ -27,3 +27,4 @@ __exportStar(require("./pseudo-state.js"), exports);
27
27
  __exportStar(require("./videos.js"), exports);
28
28
  __exportStar(require("./visualdiff.js"), exports);
29
29
  __exportStar(require("./mock/index.js"), exports);
30
+ __exportStar(require("./webkit-autofocus.js"), exports);
@@ -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.0",
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[]) => path.join(opts.dir, ...segs),
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({ dir: tmpDir, title: "Login Works" });
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({ dir: tmpDir, title: "Login Works" });
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({ dir: tmpDir, title: "Login Works" });
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({ dir: tmpDir, title: "Login Works" });
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({ dir: tmpDir, title: "CRUD tests - Video" });
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 the same way we use it as the
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
- * Reproduce the stem Playwright uses for auto-named snapshot files: the
84
- * title path (minus the spec file) joined with spaces, with every run of
85
- * control/punctuation characters collapsed to a single hyphen. Unlike
86
- * `slugifyTitle()` this preserves case and does not trim hyphens, so it
87
- * matches committed snapshot filenames exactly. Mirrors Playwright's
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 raw = segments.length > 0 ? segments.join(" ") : testInfo.title;
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
- // Playwright names snapshots `<stem>-<counter>[-<project>-<platform>].txt`.
160
- // Require the counter so a title that merely extends this one (e.g.
161
- // "Video" vs "Video Promo") is not mistaken for this test's snapshot.
162
- const prefix = `${playwrightSnapshotStem(testInfo)}-`;
163
- return entries.some((name) => {
164
- if (!name.startsWith(prefix) || !name.endsWith(".txt")) return false;
165
- let i = prefix.length;
166
- while (
167
- i < name.length &&
168
- name.charCodeAt(i) >= 48 &&
169
- name.charCodeAt(i) <= 57
170
- )
171
- i++;
172
- return i > prefix.length && (name[i] === "-" || name[i] === ".");
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(makeAxeResults({ violations: [] }));
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-chromium-linux.txt"),
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
@@ -11,3 +11,4 @@ export * from "./pseudo-state.js";
11
11
  export * from "./videos.js";
12
12
  export * from "./visualdiff.js";
13
13
  export * from "./mock/index.js";
14
+ export * from "./webkit-autofocus.js";
@@ -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
+ }