@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 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
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.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
@@ -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
+ }