@lullabot/playwright-testing 1.1.0 → 1.2.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 +13 -5
- package/lib/index.d.ts +1 -0
- package/lib/index.js +1 -0
- package/lib/webkit-autofocus.d.ts +16 -0
- package/lib/webkit-autofocus.js +136 -0
- package/package.json +3 -2
- package/src/index.ts +1 -0
- package/src/webkit-autofocus.ts +154 -0
package/README.md
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
# `@lullabot/playwright-testing`
|
|
2
2
|
|
|
3
3
|
Framework-neutral utilities for stable Playwright screenshots, accessibility
|
|
4
|
-
baselines, URL-driven visual comparisons,
|
|
4
|
+
baselines, URL-driven visual comparisons, WebKit autofocus stabilization, and
|
|
5
|
+
GitHub reporting.
|
|
5
6
|
|
|
6
7
|
This package is developed in the
|
|
7
8
|
[`playwright-drupal` monorepo](https://github.com/Lullabot/playwright-drupal),
|
|
@@ -24,7 +25,8 @@ version.
|
|
|
24
25
|
|
|
25
26
|
- `@lullabot/playwright-testing` exports screenshot stabilization,
|
|
26
27
|
accessibility checks and baselines, visual-diff definitions, interaction and
|
|
27
|
-
pseudo-state helpers, and
|
|
28
|
+
pseudo-state helpers, reusable mocks, and the `suppressWebKitAutofocus()` init
|
|
29
|
+
script (installed explicitly in WebKit contexts).
|
|
28
30
|
- `@lullabot/playwright-testing/github` exports the optional GitHub report,
|
|
29
31
|
attachment-upload, and path-remapping APIs.
|
|
30
32
|
- `playwright-testing-a11y-summary` and
|
|
@@ -47,9 +49,10 @@ test('home page', async ({ page }, testInfo) => {
|
|
|
47
49
|
|
|
48
50
|
## Guides
|
|
49
51
|
|
|
50
|
-
- [Accessibility testing]
|
|
51
|
-
- [Stable screenshots and visual comparisons]
|
|
52
|
-
- [
|
|
52
|
+
- [Accessibility testing][testing-accessibility]
|
|
53
|
+
- [Stable screenshots and visual comparisons][testing-screenshots]
|
|
54
|
+
- [WebKit native autofocus workaround][testing-webkit-autofocus]
|
|
55
|
+
- [GitHub reporting][testing-github-reporting]
|
|
53
56
|
|
|
54
57
|
## Drupal compatibility
|
|
55
58
|
|
|
@@ -59,3 +62,8 @@ visual-diff calls with its Drupal preset. New framework-neutral code can import
|
|
|
59
62
|
this package directly; Drupal tests that depend on database isolation, Drush,
|
|
60
63
|
login helpers, or the `a11y` fixture should continue importing the Drupal
|
|
61
64
|
package.
|
|
65
|
+
|
|
66
|
+
[testing-accessibility]: https://github.com/Lullabot/playwright-drupal/blob/main/packages/playwright-testing/docs/accessibility.md
|
|
67
|
+
[testing-screenshots]: https://github.com/Lullabot/playwright-drupal/blob/main/packages/playwright-testing/docs/screenshots-and-visual-comparisons.md
|
|
68
|
+
[testing-github-reporting]: https://github.com/Lullabot/playwright-drupal/blob/main/packages/playwright-testing/docs/github-reporting.md
|
|
69
|
+
[testing-webkit-autofocus]: https://github.com/Lullabot/playwright-drupal/blob/main/packages/playwright-testing/docs/webkit-autofocus.md
|
package/lib/index.d.ts
CHANGED
package/lib/index.js
CHANGED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Runs in each document via BrowserContext.addInitScript(). Keep self-contained.
|
|
3
|
+
*
|
|
4
|
+
* WebKit queues autofocus on insertion, without rechecking the attribute later.
|
|
5
|
+
* Make candidates unfocusable until flushAutofocusCandidates has discarded them.
|
|
6
|
+
* Page::updateRendering flushes autofocus BEFORE animation-frame callbacks; a
|
|
7
|
+
* parser waiting for stylesheets can defer that flush, so wait for the top
|
|
8
|
+
* document's DOMContentLoaded first. This intentionally suppresses document
|
|
9
|
+
* autofocus, rather than replacing Playwright's focus/selection/input behavior.
|
|
10
|
+
*
|
|
11
|
+
* See docs/webkit-autofocus.md for scope and tradeoffs.
|
|
12
|
+
*
|
|
13
|
+
* Install only in WebKit contexts, before pages navigate:
|
|
14
|
+
* `await context.addInitScript(suppressWebKitAutofocus)`.
|
|
15
|
+
*/
|
|
16
|
+
export declare function suppressWebKitAutofocus(): void;
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.suppressWebKitAutofocus = suppressWebKitAutofocus;
|
|
4
|
+
/**
|
|
5
|
+
* Runs in each document via BrowserContext.addInitScript(). Keep self-contained.
|
|
6
|
+
*
|
|
7
|
+
* WebKit queues autofocus on insertion, without rechecking the attribute later.
|
|
8
|
+
* Make candidates unfocusable until flushAutofocusCandidates has discarded them.
|
|
9
|
+
* Page::updateRendering flushes autofocus BEFORE animation-frame callbacks; a
|
|
10
|
+
* parser waiting for stylesheets can defer that flush, so wait for the top
|
|
11
|
+
* document's DOMContentLoaded first. This intentionally suppresses document
|
|
12
|
+
* autofocus, rather than replacing Playwright's focus/selection/input behavior.
|
|
13
|
+
*
|
|
14
|
+
* See docs/webkit-autofocus.md for scope and tradeoffs.
|
|
15
|
+
*
|
|
16
|
+
* Install only in WebKit contexts, before pages navigate:
|
|
17
|
+
* `await context.addInitScript(suppressWebKitAutofocus)`.
|
|
18
|
+
*/
|
|
19
|
+
function suppressWebKitAutofocus() {
|
|
20
|
+
let topDocument;
|
|
21
|
+
try {
|
|
22
|
+
topDocument = window.top.document;
|
|
23
|
+
}
|
|
24
|
+
catch {
|
|
25
|
+
// WebKit already rejects native autofocus candidates in cross-origin frames.
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
const pending = new Map();
|
|
29
|
+
const candidates = new WeakSet();
|
|
30
|
+
let scheduled = false;
|
|
31
|
+
function restoreAfterFlush() {
|
|
32
|
+
requestAnimationFrame(() => {
|
|
33
|
+
scheduled = false;
|
|
34
|
+
for (const [element, original] of pending) {
|
|
35
|
+
if (element.style.getPropertyValue(original.property) ===
|
|
36
|
+
original.temporary &&
|
|
37
|
+
element.style.getPropertyPriority(original.property) === "important") {
|
|
38
|
+
if (original.value) {
|
|
39
|
+
element.style.setProperty(original.property, original.value, original.priority);
|
|
40
|
+
}
|
|
41
|
+
else {
|
|
42
|
+
element.style.removeProperty(original.property);
|
|
43
|
+
}
|
|
44
|
+
// Synchronize WebKit's serialized style attribute before removing it.
|
|
45
|
+
if (!original.hadStyle && !element.getAttribute("style")) {
|
|
46
|
+
element.removeAttribute("style");
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
pending.clear();
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
function quarantine(element) {
|
|
54
|
+
if (element.namespaceURI !== "http://www.w3.org/1999/xhtml")
|
|
55
|
+
return;
|
|
56
|
+
// Remember detached candidates too: the parser may still be blocking their
|
|
57
|
+
// queued opportunity when they are reinserted without the attribute.
|
|
58
|
+
candidates.add(element);
|
|
59
|
+
if (!element.isConnected)
|
|
60
|
+
return;
|
|
61
|
+
const candidate = element;
|
|
62
|
+
if (!pending.has(candidate)) {
|
|
63
|
+
// Keep normal form controls in layout. Hide other candidates' subtrees
|
|
64
|
+
// entirely, including hosts whose shadow roots might delegate focus.
|
|
65
|
+
const isControl = /^(input|textarea|select|button)$/.test(candidate.localName);
|
|
66
|
+
const property = isControl ? "visibility" : "display";
|
|
67
|
+
const temporary = isControl ? "hidden" : "none";
|
|
68
|
+
// Capture attribute presence before changing its CSSStyleDeclaration.
|
|
69
|
+
const hadStyle = candidate.hasAttribute("style");
|
|
70
|
+
pending.set(candidate, {
|
|
71
|
+
property,
|
|
72
|
+
temporary,
|
|
73
|
+
value: candidate.style.getPropertyValue(property),
|
|
74
|
+
priority: candidate.style.getPropertyPriority(property),
|
|
75
|
+
hadStyle,
|
|
76
|
+
});
|
|
77
|
+
candidate.style.setProperty(property, temporary, "important");
|
|
78
|
+
}
|
|
79
|
+
if (scheduled)
|
|
80
|
+
return;
|
|
81
|
+
scheduled = true;
|
|
82
|
+
if (topDocument.readyState === "loading") {
|
|
83
|
+
topDocument.addEventListener("DOMContentLoaded", restoreAfterFlush, {
|
|
84
|
+
once: true,
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
else {
|
|
88
|
+
restoreAfterFlush();
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
function scan(node) {
|
|
92
|
+
if (node.nodeType !== Node.ELEMENT_NODE)
|
|
93
|
+
return;
|
|
94
|
+
const element = node;
|
|
95
|
+
if (element.hasAttribute("autofocus") || candidates.has(element))
|
|
96
|
+
quarantine(element);
|
|
97
|
+
element.querySelectorAll("*").forEach((descendant) => {
|
|
98
|
+
if (descendant.hasAttribute("autofocus") || candidates.has(descendant))
|
|
99
|
+
quarantine(descendant);
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
new MutationObserver((records) => {
|
|
103
|
+
for (const record of records) {
|
|
104
|
+
if (record.type === "childList") {
|
|
105
|
+
record.addedNodes.forEach(scan);
|
|
106
|
+
}
|
|
107
|
+
else if (record.attributeName === "style") {
|
|
108
|
+
const element = record.target;
|
|
109
|
+
const original = pending.get(element);
|
|
110
|
+
if (original &&
|
|
111
|
+
(element.style.getPropertyValue(original.property) !==
|
|
112
|
+
original.temporary ||
|
|
113
|
+
element.style.getPropertyPriority(original.property) !==
|
|
114
|
+
"important")) {
|
|
115
|
+
// Keep the candidate hidden through the flush even if application
|
|
116
|
+
// code changes its styles in the meantime. Restore the latest value.
|
|
117
|
+
original.value = element.style.getPropertyValue(original.property);
|
|
118
|
+
original.priority = element.style.getPropertyPriority(original.property);
|
|
119
|
+
original.hadStyle = element.hasAttribute("style");
|
|
120
|
+
element.style.setProperty(original.property, original.temporary, "important");
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
else {
|
|
124
|
+
// Also catch an attribute removed synchronously after insertion, before
|
|
125
|
+
// observer delivery: its native candidate is still in WebKit's queue.
|
|
126
|
+
quarantine(record.target);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}).observe(document, {
|
|
130
|
+
childList: true,
|
|
131
|
+
subtree: true,
|
|
132
|
+
attributes: true,
|
|
133
|
+
attributeFilter: ["autofocus", "style"],
|
|
134
|
+
});
|
|
135
|
+
document.querySelectorAll("[autofocus]").forEach(quarantine);
|
|
136
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lullabot/playwright-testing",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.2.0",
|
|
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",
|
package/src/index.ts
CHANGED
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Runs in each document via BrowserContext.addInitScript(). Keep self-contained.
|
|
3
|
+
*
|
|
4
|
+
* WebKit queues autofocus on insertion, without rechecking the attribute later.
|
|
5
|
+
* Make candidates unfocusable until flushAutofocusCandidates has discarded them.
|
|
6
|
+
* Page::updateRendering flushes autofocus BEFORE animation-frame callbacks; a
|
|
7
|
+
* parser waiting for stylesheets can defer that flush, so wait for the top
|
|
8
|
+
* document's DOMContentLoaded first. This intentionally suppresses document
|
|
9
|
+
* autofocus, rather than replacing Playwright's focus/selection/input behavior.
|
|
10
|
+
*
|
|
11
|
+
* See docs/webkit-autofocus.md for scope and tradeoffs.
|
|
12
|
+
*
|
|
13
|
+
* Install only in WebKit contexts, before pages navigate:
|
|
14
|
+
* `await context.addInitScript(suppressWebKitAutofocus)`.
|
|
15
|
+
*/
|
|
16
|
+
export function suppressWebKitAutofocus(): void {
|
|
17
|
+
let topDocument: Document;
|
|
18
|
+
try {
|
|
19
|
+
topDocument = window.top!.document;
|
|
20
|
+
} catch {
|
|
21
|
+
// WebKit already rejects native autofocus candidates in cross-origin frames.
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const pending = new Map<
|
|
26
|
+
HTMLElement,
|
|
27
|
+
{
|
|
28
|
+
property: "visibility" | "display";
|
|
29
|
+
temporary: "hidden" | "none";
|
|
30
|
+
value: string;
|
|
31
|
+
priority: string;
|
|
32
|
+
hadStyle: boolean;
|
|
33
|
+
}
|
|
34
|
+
>();
|
|
35
|
+
const candidates = new WeakSet<Element>();
|
|
36
|
+
let scheduled = false;
|
|
37
|
+
|
|
38
|
+
function restoreAfterFlush(): void {
|
|
39
|
+
requestAnimationFrame(() => {
|
|
40
|
+
scheduled = false;
|
|
41
|
+
for (const [element, original] of pending) {
|
|
42
|
+
if (
|
|
43
|
+
element.style.getPropertyValue(original.property) ===
|
|
44
|
+
original.temporary &&
|
|
45
|
+
element.style.getPropertyPriority(original.property) === "important"
|
|
46
|
+
) {
|
|
47
|
+
if (original.value) {
|
|
48
|
+
element.style.setProperty(
|
|
49
|
+
original.property,
|
|
50
|
+
original.value,
|
|
51
|
+
original.priority,
|
|
52
|
+
);
|
|
53
|
+
} else {
|
|
54
|
+
element.style.removeProperty(original.property);
|
|
55
|
+
}
|
|
56
|
+
// Synchronize WebKit's serialized style attribute before removing it.
|
|
57
|
+
if (!original.hadStyle && !element.getAttribute("style")) {
|
|
58
|
+
element.removeAttribute("style");
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
pending.clear();
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function quarantine(element: Element): void {
|
|
67
|
+
if (element.namespaceURI !== "http://www.w3.org/1999/xhtml") return;
|
|
68
|
+
// Remember detached candidates too: the parser may still be blocking their
|
|
69
|
+
// queued opportunity when they are reinserted without the attribute.
|
|
70
|
+
candidates.add(element);
|
|
71
|
+
if (!element.isConnected) return;
|
|
72
|
+
const candidate = element as HTMLElement;
|
|
73
|
+
if (!pending.has(candidate)) {
|
|
74
|
+
// Keep normal form controls in layout. Hide other candidates' subtrees
|
|
75
|
+
// entirely, including hosts whose shadow roots might delegate focus.
|
|
76
|
+
const isControl = /^(input|textarea|select|button)$/.test(
|
|
77
|
+
candidate.localName,
|
|
78
|
+
);
|
|
79
|
+
const property = isControl ? "visibility" : "display";
|
|
80
|
+
const temporary = isControl ? "hidden" : "none";
|
|
81
|
+
// Capture attribute presence before changing its CSSStyleDeclaration.
|
|
82
|
+
const hadStyle = candidate.hasAttribute("style");
|
|
83
|
+
pending.set(candidate, {
|
|
84
|
+
property,
|
|
85
|
+
temporary,
|
|
86
|
+
value: candidate.style.getPropertyValue(property),
|
|
87
|
+
priority: candidate.style.getPropertyPriority(property),
|
|
88
|
+
hadStyle,
|
|
89
|
+
});
|
|
90
|
+
candidate.style.setProperty(property, temporary, "important");
|
|
91
|
+
}
|
|
92
|
+
if (scheduled) return;
|
|
93
|
+
scheduled = true;
|
|
94
|
+
if (topDocument.readyState === "loading") {
|
|
95
|
+
topDocument.addEventListener("DOMContentLoaded", restoreAfterFlush, {
|
|
96
|
+
once: true,
|
|
97
|
+
});
|
|
98
|
+
} else {
|
|
99
|
+
restoreAfterFlush();
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function scan(node: Node): void {
|
|
104
|
+
if (node.nodeType !== Node.ELEMENT_NODE) return;
|
|
105
|
+
const element = node as Element;
|
|
106
|
+
if (element.hasAttribute("autofocus") || candidates.has(element))
|
|
107
|
+
quarantine(element);
|
|
108
|
+
element.querySelectorAll("*").forEach((descendant) => {
|
|
109
|
+
if (descendant.hasAttribute("autofocus") || candidates.has(descendant))
|
|
110
|
+
quarantine(descendant);
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
new MutationObserver((records) => {
|
|
115
|
+
for (const record of records) {
|
|
116
|
+
if (record.type === "childList") {
|
|
117
|
+
record.addedNodes.forEach(scan);
|
|
118
|
+
} else if (record.attributeName === "style") {
|
|
119
|
+
const element = record.target as HTMLElement;
|
|
120
|
+
const original = pending.get(element);
|
|
121
|
+
if (
|
|
122
|
+
original &&
|
|
123
|
+
(element.style.getPropertyValue(original.property) !==
|
|
124
|
+
original.temporary ||
|
|
125
|
+
element.style.getPropertyPriority(original.property) !==
|
|
126
|
+
"important")
|
|
127
|
+
) {
|
|
128
|
+
// Keep the candidate hidden through the flush even if application
|
|
129
|
+
// code changes its styles in the meantime. Restore the latest value.
|
|
130
|
+
original.value = element.style.getPropertyValue(original.property);
|
|
131
|
+
original.priority = element.style.getPropertyPriority(
|
|
132
|
+
original.property,
|
|
133
|
+
);
|
|
134
|
+
original.hadStyle = element.hasAttribute("style");
|
|
135
|
+
element.style.setProperty(
|
|
136
|
+
original.property,
|
|
137
|
+
original.temporary,
|
|
138
|
+
"important",
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
} else {
|
|
142
|
+
// Also catch an attribute removed synchronously after insertion, before
|
|
143
|
+
// observer delivery: its native candidate is still in WebKit's queue.
|
|
144
|
+
quarantine(record.target as Element);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
}).observe(document, {
|
|
148
|
+
childList: true,
|
|
149
|
+
subtree: true,
|
|
150
|
+
attributes: true,
|
|
151
|
+
attributeFilter: ["autofocus", "style"],
|
|
152
|
+
});
|
|
153
|
+
document.querySelectorAll("[autofocus]").forEach(quarantine);
|
|
154
|
+
}
|