@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 +11 -4
- package/lib/accessibility-baseline-file.js +33 -27
- package/lib/ckeditor5.d.ts +58 -0
- package/lib/ckeditor5.js +82 -0
- package/lib/details.d.ts +6 -0
- package/lib/details.js +14 -0
- package/lib/index.d.ts +2 -0
- package/lib/index.js +2 -0
- package/package.json +1 -1
- 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/ckeditor5.test.ts +20 -0
- package/src/ckeditor5.ts +84 -0
- package/src/details.ts +13 -0
- package/src/index.ts +2 -0
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,
|
|
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,
|
|
29
|
-
script (installed explicitly in WebKit
|
|
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
|
|
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;
|
|
@@ -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
|
+
}
|
package/lib/ckeditor5.js
ADDED
|
@@ -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;
|
package/lib/details.d.ts
ADDED
|
@@ -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,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
|
});
|
|
@@ -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
|
+
});
|
package/src/ckeditor5.ts
ADDED
|
@@ -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";
|