@lullabot/playwright-testing 1.2.0 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,8 +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, WebKit autofocus stabilization, and
5
- GitHub reporting.
4
+ baselines, URL-driven visual comparisons, CKEditor 5 editing, WebKit autofocus
5
+ stabilization, and GitHub reporting.
6
6
 
7
7
  This package is developed in the
8
8
  [`playwright-drupal` monorepo](https://github.com/Lullabot/playwright-drupal),
@@ -25,8 +25,9 @@ version.
25
25
 
26
26
  - `@lullabot/playwright-testing` exports screenshot stabilization,
27
27
  accessibility checks and baselines, visual-diff definitions, interaction and
28
- pseudo-state helpers, reusable mocks, and the `suppressWebKitAutofocus()` init
29
- script (installed explicitly in WebKit contexts).
28
+ pseudo-state helpers, `openAllDetails()`, CKEditor 5 editing, reusable mocks,
29
+ and the `suppressWebKitAutofocus()` init script (installed explicitly in WebKit
30
+ contexts).
30
31
  - `@lullabot/playwright-testing/github` exports the optional GitHub report,
31
32
  attachment-upload, and path-remapping APIs.
32
33
  - `playwright-testing-a11y-summary` and
@@ -51,6 +52,8 @@ test('home page', async ({ page }, testInfo) => {
51
52
 
52
53
  - [Accessibility testing][testing-accessibility]
53
54
  - [Stable screenshots and visual comparisons][testing-screenshots]
55
+ - [Page readiness and browser state][testing-page-readiness]
56
+ - [CKEditor 5 editing][testing-ckeditor5]
54
57
  - [WebKit native autofocus workaround][testing-webkit-autofocus]
55
58
  - [GitHub reporting][testing-github-reporting]
56
59
 
@@ -67,3 +70,7 @@ package.
67
70
  [testing-screenshots]: https://github.com/Lullabot/playwright-drupal/blob/main/packages/playwright-testing/docs/screenshots-and-visual-comparisons.md
68
71
  [testing-github-reporting]: https://github.com/Lullabot/playwright-drupal/blob/main/packages/playwright-testing/docs/github-reporting.md
69
72
  [testing-webkit-autofocus]: https://github.com/Lullabot/playwright-drupal/blob/main/packages/playwright-testing/docs/webkit-autofocus.md
73
+
74
+ [testing-page-readiness]: https://github.com/Lullabot/playwright-drupal/blob/main/packages/playwright-testing/docs/page-readiness.md
75
+
76
+ [testing-ckeditor5]: https://github.com/Lullabot/playwright-drupal/blob/main/packages/playwright-testing/docs/ckeditor5.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;
@@ -0,0 +1,58 @@
1
+ import { FrameLocator, Page } from "@playwright/test";
2
+ /**
3
+ * Return the select-all modifier key for a given platform.
4
+ *
5
+ * Exported so unit tests can cover the platform branch without mocking
6
+ * `process.platform`. Defaults to the current platform.
7
+ */
8
+ export declare function selectAllModifier(platform?: NodeJS.Platform): "Meta" | "Control";
9
+ /**
10
+ * Drive a CKEditor **5** field from a Playwright test.
11
+ *
12
+ * This class is specifically for CKEditor 5. It does **not** work with
13
+ * CKEditor 4.
14
+ *
15
+ * CKEditor 5 keeps a virtual-DOM model that it synchronises with the visible
16
+ * contenteditable element. Setting the DOM directly (e.g. via Playwright's
17
+ * `locator.fill()`) can be silently dropped because the editor's next
18
+ * re-render overwrites it; it also bypasses any input handlers CKEditor
19
+ * plugins register. `fill()` here dispatches real keyboard events through
20
+ * `page.keyboard`, which CKEditor's event pipeline processes as normal
21
+ * edits.
22
+ *
23
+ * The `selector` targets the widget **wrapper** (e.g.
24
+ * `#body-editor` or `[data-testid="body-editor"]`),
25
+ * not the contenteditable itself — the class drills into
26
+ * `.ck-editor__editable` internally so callers don't have to memorise
27
+ * CKEditor 5's markup.
28
+ *
29
+ * For editors rendered inside an iframe, pass the `FrameLocator` as `root`
30
+ * while keeping `page` as the owning page so keyboard events still reach the
31
+ * right window.
32
+ */
33
+ export declare class Ckeditor5 {
34
+ page: Page;
35
+ root: Page | FrameLocator;
36
+ protected selector: string;
37
+ /**
38
+ * @param page
39
+ * The page the CKEditor 5 instance lives on. Keyboard events are sent to
40
+ * this page.
41
+ * @param selector
42
+ * A selector that resolves to the widget wrapper containing the editor
43
+ * (e.g. `#body-editor`). The class finds the
44
+ * `.ck-editor__editable` element inside.
45
+ * @param root
46
+ * Optional frame locator if the editor is inside an iframe. Defaults to
47
+ * `page`.
48
+ */
49
+ constructor(page: Page, selector: string, root?: Page | FrameLocator);
50
+ /**
51
+ * Replace the editor's contents with the given text.
52
+ *
53
+ * Clears existing content (select-all + Backspace) so the call has
54
+ * Playwright-style `fill()` semantics: the final value is exactly `text`,
55
+ * regardless of whether the field was empty.
56
+ */
57
+ fill(text: string): Promise<void>;
58
+ }
@@ -0,0 +1,82 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.Ckeditor5 = void 0;
4
+ exports.selectAllModifier = selectAllModifier;
5
+ /**
6
+ * Return the select-all modifier key for a given platform.
7
+ *
8
+ * Exported so unit tests can cover the platform branch without mocking
9
+ * `process.platform`. Defaults to the current platform.
10
+ */
11
+ function selectAllModifier(platform = process.platform) {
12
+ return platform === "darwin" ? "Meta" : "Control";
13
+ }
14
+ /**
15
+ * Drive a CKEditor **5** field from a Playwright test.
16
+ *
17
+ * This class is specifically for CKEditor 5. It does **not** work with
18
+ * CKEditor 4.
19
+ *
20
+ * CKEditor 5 keeps a virtual-DOM model that it synchronises with the visible
21
+ * contenteditable element. Setting the DOM directly (e.g. via Playwright's
22
+ * `locator.fill()`) can be silently dropped because the editor's next
23
+ * re-render overwrites it; it also bypasses any input handlers CKEditor
24
+ * plugins register. `fill()` here dispatches real keyboard events through
25
+ * `page.keyboard`, which CKEditor's event pipeline processes as normal
26
+ * edits.
27
+ *
28
+ * The `selector` targets the widget **wrapper** (e.g.
29
+ * `#body-editor` or `[data-testid="body-editor"]`),
30
+ * not the contenteditable itself — the class drills into
31
+ * `.ck-editor__editable` internally so callers don't have to memorise
32
+ * CKEditor 5's markup.
33
+ *
34
+ * For editors rendered inside an iframe, pass the `FrameLocator` as `root`
35
+ * while keeping `page` as the owning page so keyboard events still reach the
36
+ * right window.
37
+ */
38
+ class Ckeditor5 {
39
+ page;
40
+ root;
41
+ selector;
42
+ /**
43
+ * @param page
44
+ * The page the CKEditor 5 instance lives on. Keyboard events are sent to
45
+ * this page.
46
+ * @param selector
47
+ * A selector that resolves to the widget wrapper containing the editor
48
+ * (e.g. `#body-editor`). The class finds the
49
+ * `.ck-editor__editable` element inside.
50
+ * @param root
51
+ * Optional frame locator if the editor is inside an iframe. Defaults to
52
+ * `page`.
53
+ */
54
+ constructor(page, selector, root) {
55
+ this.page = page;
56
+ this.selector = selector;
57
+ this.root = root ?? page;
58
+ }
59
+ /**
60
+ * Replace the editor's contents with the given text.
61
+ *
62
+ * Clears existing content (select-all + Backspace) so the call has
63
+ * Playwright-style `fill()` semantics: the final value is exactly `text`,
64
+ * regardless of whether the field was empty.
65
+ */
66
+ async fill(text) {
67
+ const editable = this.root
68
+ .locator(this.selector)
69
+ .locator(".ck-editor__editable");
70
+ await editable.waitFor({ state: "visible", timeout: 15000 });
71
+ // Click places the caret inside the editable so the keyboard events
72
+ // below land in CKEditor rather than the outer document.
73
+ await editable.click();
74
+ await this.page.keyboard.press(`${selectAllModifier()}+A`);
75
+ await this.page.keyboard.press("Backspace");
76
+ // keyboard.type fires keydown/keypress/input events that CKEditor 5's
77
+ // event pipeline processes. locator.fill() would set the DOM directly
78
+ // and can be silently dropped on the next model re-render.
79
+ await this.page.keyboard.type(text);
80
+ }
81
+ }
82
+ exports.Ckeditor5 = Ckeditor5;
@@ -0,0 +1,6 @@
1
+ import { Page } from "@playwright/test";
2
+ /**
3
+ * Expand every collapsed `<details>` element on the page so nested controls
4
+ * become interactable. Call after navigation and before filling nested fields.
5
+ */
6
+ export declare function openAllDetails(page: Page): Promise<void>;
package/lib/details.js ADDED
@@ -0,0 +1,14 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.openAllDetails = openAllDetails;
4
+ /**
5
+ * Expand every collapsed `<details>` element on the page so nested controls
6
+ * become interactable. Call after navigation and before filling nested fields.
7
+ */
8
+ async function openAllDetails(page) {
9
+ await page.evaluate(() => {
10
+ document.querySelectorAll("details:not([open])").forEach((d) => {
11
+ d.open = true;
12
+ });
13
+ });
14
+ }
package/lib/index.d.ts CHANGED
@@ -1,3 +1,4 @@
1
+ export * from "./details.js";
1
2
  export * from "./focus.js";
2
3
  export * from "./accessibility-baseline.js";
3
4
  export * from "./accessibility-baseline-file.js";
@@ -12,3 +13,4 @@ export * from "./videos.js";
12
13
  export * from "./visualdiff.js";
13
14
  export * from "./mock/index.js";
14
15
  export * from "./webkit-autofocus.js";
16
+ export * from "./ckeditor5.js";
package/lib/index.js CHANGED
@@ -14,6 +14,7 @@ var __exportStar = (this && this.__exportStar) || function(m, exports) {
14
14
  for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
15
15
  };
16
16
  Object.defineProperty(exports, "__esModule", { value: true });
17
+ __exportStar(require("./details.js"), exports);
17
18
  __exportStar(require("./focus.js"), exports);
18
19
  __exportStar(require("./accessibility-baseline.js"), exports);
19
20
  __exportStar(require("./accessibility-baseline-file.js"), exports);
@@ -28,3 +29,4 @@ __exportStar(require("./videos.js"), exports);
28
29
  __exportStar(require("./visualdiff.js"), exports);
29
30
  __exportStar(require("./mock/index.js"), exports);
30
31
  __exportStar(require("./webkit-autofocus.js"), exports);
32
+ __exportStar(require("./ckeditor5.js"), exports);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lullabot/playwright-testing",
3
- "version": "1.2.0",
3
+ "version": "1.3.0",
4
4
  "description": "Framework-neutral Playwright testing utilities",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
@@ -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
  });
@@ -0,0 +1,20 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { selectAllModifier } from "./ckeditor5";
3
+
4
+ describe("selectAllModifier", () => {
5
+ it("returns Meta on darwin", () => {
6
+ expect(selectAllModifier("darwin")).toBe("Meta");
7
+ });
8
+
9
+ it("returns Control on linux", () => {
10
+ expect(selectAllModifier("linux")).toBe("Control");
11
+ });
12
+
13
+ it("returns Control on win32", () => {
14
+ expect(selectAllModifier("win32")).toBe("Control");
15
+ });
16
+
17
+ it("returns Control on freebsd / other", () => {
18
+ expect(selectAllModifier("freebsd")).toBe("Control");
19
+ });
20
+ });
@@ -0,0 +1,84 @@
1
+ import { FrameLocator, Page } from "@playwright/test";
2
+
3
+ /**
4
+ * Return the select-all modifier key for a given platform.
5
+ *
6
+ * Exported so unit tests can cover the platform branch without mocking
7
+ * `process.platform`. Defaults to the current platform.
8
+ */
9
+ export function selectAllModifier(
10
+ platform: NodeJS.Platform = process.platform,
11
+ ): "Meta" | "Control" {
12
+ return platform === "darwin" ? "Meta" : "Control";
13
+ }
14
+
15
+ /**
16
+ * Drive a CKEditor **5** field from a Playwright test.
17
+ *
18
+ * This class is specifically for CKEditor 5. It does **not** work with
19
+ * CKEditor 4.
20
+ *
21
+ * CKEditor 5 keeps a virtual-DOM model that it synchronises with the visible
22
+ * contenteditable element. Setting the DOM directly (e.g. via Playwright's
23
+ * `locator.fill()`) can be silently dropped because the editor's next
24
+ * re-render overwrites it; it also bypasses any input handlers CKEditor
25
+ * plugins register. `fill()` here dispatches real keyboard events through
26
+ * `page.keyboard`, which CKEditor's event pipeline processes as normal
27
+ * edits.
28
+ *
29
+ * The `selector` targets the widget **wrapper** (e.g.
30
+ * `#body-editor` or `[data-testid="body-editor"]`),
31
+ * not the contenteditable itself — the class drills into
32
+ * `.ck-editor__editable` internally so callers don't have to memorise
33
+ * CKEditor 5's markup.
34
+ *
35
+ * For editors rendered inside an iframe, pass the `FrameLocator` as `root`
36
+ * while keeping `page` as the owning page so keyboard events still reach the
37
+ * right window.
38
+ */
39
+ export class Ckeditor5 {
40
+ public page: Page;
41
+ public root: Page | FrameLocator;
42
+ protected selector: string;
43
+
44
+ /**
45
+ * @param page
46
+ * The page the CKEditor 5 instance lives on. Keyboard events are sent to
47
+ * this page.
48
+ * @param selector
49
+ * A selector that resolves to the widget wrapper containing the editor
50
+ * (e.g. `#body-editor`). The class finds the
51
+ * `.ck-editor__editable` element inside.
52
+ * @param root
53
+ * Optional frame locator if the editor is inside an iframe. Defaults to
54
+ * `page`.
55
+ */
56
+ public constructor(page: Page, selector: string, root?: Page | FrameLocator) {
57
+ this.page = page;
58
+ this.selector = selector;
59
+ this.root = root ?? page;
60
+ }
61
+
62
+ /**
63
+ * Replace the editor's contents with the given text.
64
+ *
65
+ * Clears existing content (select-all + Backspace) so the call has
66
+ * Playwright-style `fill()` semantics: the final value is exactly `text`,
67
+ * regardless of whether the field was empty.
68
+ */
69
+ public async fill(text: string): Promise<void> {
70
+ const editable = this.root
71
+ .locator(this.selector)
72
+ .locator(".ck-editor__editable");
73
+ await editable.waitFor({ state: "visible", timeout: 15000 });
74
+ // Click places the caret inside the editable so the keyboard events
75
+ // below land in CKEditor rather than the outer document.
76
+ await editable.click();
77
+ await this.page.keyboard.press(`${selectAllModifier()}+A`);
78
+ await this.page.keyboard.press("Backspace");
79
+ // keyboard.type fires keydown/keypress/input events that CKEditor 5's
80
+ // event pipeline processes. locator.fill() would set the DOM directly
81
+ // and can be silently dropped on the next model re-render.
82
+ await this.page.keyboard.type(text);
83
+ }
84
+ }
package/src/details.ts ADDED
@@ -0,0 +1,13 @@
1
+ import { Page } from "@playwright/test";
2
+
3
+ /**
4
+ * Expand every collapsed `<details>` element on the page so nested controls
5
+ * become interactable. Call after navigation and before filling nested fields.
6
+ */
7
+ export async function openAllDetails(page: Page): Promise<void> {
8
+ await page.evaluate(() => {
9
+ document.querySelectorAll("details:not([open])").forEach((d) => {
10
+ (d as HTMLDetailsElement).open = true;
11
+ });
12
+ });
13
+ }
package/src/index.ts CHANGED
@@ -1,3 +1,4 @@
1
+ export * from "./details.js";
1
2
  export * from "./focus.js";
2
3
  export * from "./accessibility-baseline.js";
3
4
  export * from "./accessibility-baseline-file.js";
@@ -12,3 +13,4 @@ export * from "./videos.js";
12
13
  export * from "./visualdiff.js";
13
14
  export * from "./mock/index.js";
14
15
  export * from "./webkit-autofocus.js";
16
+ export * from "./ckeditor5.js";