@lullabot/playwright-testing 1.0.1 → 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/accessible-screenshot.js +24 -14
- package/lib/github/failure-summary.js +1 -1
- 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/accessibility-baseline.test.ts +236 -0
- package/src/accessible-screenshot.ts +29 -11
- package/src/github/failure-summary.test.ts +2 -1
- package/src/github/failure-summary.ts +1 -1
- 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
|
|
@@ -38,6 +38,9 @@ async function checkAccessibility(page, testInfo, options) {
|
|
|
38
38
|
if (!testInfo.annotations.some((a) => a.type === "@a11y")) {
|
|
39
39
|
testInfo.annotations.push({ type: "@a11y" });
|
|
40
40
|
}
|
|
41
|
+
// An in-code baseline is shared by both scans. Track matches across this
|
|
42
|
+
// check so that a rule detected by either scan is not reported as stale.
|
|
43
|
+
const sharedBaselineMatches = new Set();
|
|
41
44
|
if (bestPracticeMode !== "off") {
|
|
42
45
|
const bpResults = await runBestPracticeScan(page, testInfo, {
|
|
43
46
|
exclude: [...exclude, ...bestPracticeExclude],
|
|
@@ -52,7 +55,7 @@ async function checkAccessibility(page, testInfo, options) {
|
|
|
52
55
|
scan: "best-practice",
|
|
53
56
|
expectFn: test_1.expect.soft,
|
|
54
57
|
scanLabel: "Best-practice scan",
|
|
55
|
-
}, baseline);
|
|
58
|
+
}, baseline, sharedBaselineMatches);
|
|
56
59
|
}
|
|
57
60
|
const wcagScanResults = await runWcagScan(page, testInfo, {
|
|
58
61
|
wcagTags,
|
|
@@ -68,7 +71,7 @@ async function checkAccessibility(page, testInfo, options) {
|
|
|
68
71
|
scan: "wcag",
|
|
69
72
|
expectFn: test_1.expect,
|
|
70
73
|
scanLabel: "WCAG scan",
|
|
71
|
-
}, baseline);
|
|
74
|
+
}, baseline, sharedBaselineMatches);
|
|
72
75
|
}
|
|
73
76
|
/**
|
|
74
77
|
* Dispatch the assertion for one scan to the correct mode:
|
|
@@ -85,9 +88,9 @@ async function checkAccessibility(page, testInfo, options) {
|
|
|
85
88
|
* test (matching Playwright's missing-snapshot behaviour); locally,
|
|
86
89
|
* seeding passes so the first run is green.
|
|
87
90
|
*/
|
|
88
|
-
async function dispatchAssertion(ctx, inCodeBaseline) {
|
|
91
|
+
async function dispatchAssertion(ctx, inCodeBaseline, sharedBaselineMatches) {
|
|
89
92
|
if (inCodeBaseline) {
|
|
90
|
-
return assertBaseline(ctx, inCodeBaseline);
|
|
93
|
+
return assertBaseline(ctx, inCodeBaseline, sharedBaselineMatches);
|
|
91
94
|
}
|
|
92
95
|
if (await (0, accessibility_baseline_file_js_1.snapshotExists)(ctx.testInfo)) {
|
|
93
96
|
return assertSnapshot(ctx);
|
|
@@ -214,7 +217,7 @@ async function screenshotViolatingElements(page, testInfo, results) {
|
|
|
214
217
|
/**
|
|
215
218
|
* Assert violations against a baseline allowlist (in-code or on-disk).
|
|
216
219
|
*/
|
|
217
|
-
function assertBaseline(ctx, baseline) {
|
|
220
|
+
function assertBaseline(ctx, baseline, sharedBaselineMatches) {
|
|
218
221
|
(0, accessibility_baseline_js_1.validateAccessibilityBaseline)(baseline);
|
|
219
222
|
const { testInfo, results, scanLabel, expectFn } = ctx;
|
|
220
223
|
const allViolations = extractNormalizedViolations(results);
|
|
@@ -229,6 +232,7 @@ function assertBaseline(ctx, baseline) {
|
|
|
229
232
|
});
|
|
230
233
|
if (baselineIndex >= 0) {
|
|
231
234
|
matchedBaselineIndices.add(baselineIndex);
|
|
235
|
+
sharedBaselineMatches?.add(baselineIndex);
|
|
232
236
|
const entry = baseline[baselineIndex];
|
|
233
237
|
testInfo.annotations.push({
|
|
234
238
|
type: "Baselined a11y violation",
|
|
@@ -239,15 +243,21 @@ function assertBaseline(ctx, baseline) {
|
|
|
239
243
|
unmatchedViolations.push(violation);
|
|
240
244
|
}
|
|
241
245
|
}
|
|
242
|
-
//
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
246
|
+
// The WCAG scan always runs last, even when best-practice is disabled.
|
|
247
|
+
// Report shared entries once after both scans have contributed matches,
|
|
248
|
+
// before the hard assertion can throw. On-disk baselines belong to one
|
|
249
|
+
// scan and continue to report stale entries independently.
|
|
250
|
+
if (!sharedBaselineMatches || ctx.scan === "wcag") {
|
|
251
|
+
const matches = sharedBaselineMatches ?? matchedBaselineIndices;
|
|
252
|
+
baseline.forEach((entry, idx) => {
|
|
253
|
+
if (!matches.has(idx)) {
|
|
254
|
+
testInfo.annotations.push({
|
|
255
|
+
type: "Stale a11y baseline entry",
|
|
256
|
+
description: `${entry.rule} on ${entry.targets.join(", ")} — no longer detected`,
|
|
257
|
+
});
|
|
258
|
+
}
|
|
259
|
+
});
|
|
260
|
+
}
|
|
251
261
|
// Summary annotation for baseline mode.
|
|
252
262
|
const baselinedCount = matchedBaselineIndices.size;
|
|
253
263
|
testInfo.annotations.push({
|
|
@@ -292,7 +292,7 @@ function headline(report) {
|
|
|
292
292
|
// seeing — a baselined violation passes and is still screenshotted.
|
|
293
293
|
const parts = report.totalFailed === 0
|
|
294
294
|
? [":white_check_mark: No failing tests"]
|
|
295
|
-
: [
|
|
295
|
+
: [`:x: **${report.totalFailed}** failing test(s)`];
|
|
296
296
|
if (report.totalFlaky > 0)
|
|
297
297
|
parts.push(`:warning: **${report.totalFlaky}** flaky test(s)`);
|
|
298
298
|
if (report.totalImages > 0)
|
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.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",
|
|
@@ -305,6 +305,175 @@ describe("accessibility baseline", () => {
|
|
|
305
305
|
expect(staleAnnotations[0].description).toContain("#old-element");
|
|
306
306
|
expect(staleAnnotations[0].description).toContain("no longer detected");
|
|
307
307
|
});
|
|
308
|
+
|
|
309
|
+
it.each([
|
|
310
|
+
{ name: "best-practice", bp: ["page-has-heading-one"], wcag: [] },
|
|
311
|
+
{ name: "WCAG", bp: [], wcag: ["image-alt"] },
|
|
312
|
+
{ name: "both scans", bp: ["page-has-heading-one"], wcag: ["image-alt"] },
|
|
313
|
+
])(
|
|
314
|
+
"does not mark entries matched by $name as stale",
|
|
315
|
+
async ({ bp, wcag }) => {
|
|
316
|
+
const rules = [...bp, ...wcag];
|
|
317
|
+
const baseline: AccessibilityBaseline = rules.map((rule) => ({
|
|
318
|
+
rule,
|
|
319
|
+
targets: ["#known"],
|
|
320
|
+
reason: "Known issue",
|
|
321
|
+
willBeFixedIn: "PROJ-353",
|
|
322
|
+
}));
|
|
323
|
+
mockAnalyze
|
|
324
|
+
.mockResolvedValueOnce(
|
|
325
|
+
makeAxeResults({
|
|
326
|
+
violations: bp.map((rule) => makeViolation(rule, [["#known"]])),
|
|
327
|
+
}),
|
|
328
|
+
)
|
|
329
|
+
.mockResolvedValueOnce(
|
|
330
|
+
makeAxeResults({
|
|
331
|
+
violations: wcag.map((rule) => makeViolation(rule, [["#known"]])),
|
|
332
|
+
}),
|
|
333
|
+
);
|
|
334
|
+
|
|
335
|
+
const testInfo = makeTestInfo();
|
|
336
|
+
await checkAccessibility(makePage() as any, testInfo as any, {
|
|
337
|
+
baseline,
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
expect(
|
|
341
|
+
testInfo.annotations.filter(
|
|
342
|
+
(a) => a.type === "Stale a11y baseline entry",
|
|
343
|
+
),
|
|
344
|
+
).toEqual([]);
|
|
345
|
+
expect(
|
|
346
|
+
testInfo.annotations.filter(
|
|
347
|
+
(a) => a.type === "Baselined a11y violation",
|
|
348
|
+
),
|
|
349
|
+
).toHaveLength(rules.length);
|
|
350
|
+
expect(
|
|
351
|
+
testInfo.annotations.filter((a) =>
|
|
352
|
+
a.description?.includes("new violations"),
|
|
353
|
+
),
|
|
354
|
+
).toEqual([
|
|
355
|
+
{
|
|
356
|
+
type: "Accessibility",
|
|
357
|
+
description: `Best-practice scan: 0 new violations (${bp.length} baselined)`,
|
|
358
|
+
},
|
|
359
|
+
{
|
|
360
|
+
type: "Accessibility",
|
|
361
|
+
description: `WCAG scan: 0 new violations (${wcag.length} baselined)`,
|
|
362
|
+
},
|
|
363
|
+
]);
|
|
364
|
+
expect(mockToBe).not.toHaveBeenCalled();
|
|
365
|
+
},
|
|
366
|
+
);
|
|
367
|
+
|
|
368
|
+
it("reports entries unmatched by either scan only once", async () => {
|
|
369
|
+
const testInfo = makeTestInfo();
|
|
370
|
+
await checkAccessibility(makePage() as any, testInfo as any, {
|
|
371
|
+
baseline: [
|
|
372
|
+
{
|
|
373
|
+
rule: "page-has-heading-one",
|
|
374
|
+
targets: ["html"],
|
|
375
|
+
reason: "Was broken",
|
|
376
|
+
willBeFixedIn: "PROJ-353",
|
|
377
|
+
},
|
|
378
|
+
],
|
|
379
|
+
});
|
|
380
|
+
|
|
381
|
+
expect(
|
|
382
|
+
testInfo.annotations.filter(
|
|
383
|
+
(a) => a.type === "Stale a11y baseline entry",
|
|
384
|
+
),
|
|
385
|
+
).toEqual([
|
|
386
|
+
{
|
|
387
|
+
type: "Stale a11y baseline entry",
|
|
388
|
+
description: "page-has-heading-one on html — no longer detected",
|
|
389
|
+
},
|
|
390
|
+
]);
|
|
391
|
+
});
|
|
392
|
+
|
|
393
|
+
it("does not carry baseline matches into later checks in the same test", async () => {
|
|
394
|
+
const baseline: AccessibilityBaseline = [
|
|
395
|
+
{
|
|
396
|
+
rule: "page-has-heading-one",
|
|
397
|
+
targets: ["html"],
|
|
398
|
+
reason: "Known issue",
|
|
399
|
+
willBeFixedIn: "PROJ-353",
|
|
400
|
+
},
|
|
401
|
+
];
|
|
402
|
+
mockAnalyze.mockResolvedValueOnce(
|
|
403
|
+
makeAxeResults({
|
|
404
|
+
violations: [makeViolation("page-has-heading-one", [["html"]])],
|
|
405
|
+
}),
|
|
406
|
+
);
|
|
407
|
+
const testInfo = makeTestInfo();
|
|
408
|
+
await checkAccessibility(makePage() as any, testInfo as any, {
|
|
409
|
+
baseline,
|
|
410
|
+
});
|
|
411
|
+
expect(
|
|
412
|
+
testInfo.annotations.filter(
|
|
413
|
+
(a) => a.type === "Stale a11y baseline entry",
|
|
414
|
+
),
|
|
415
|
+
).toEqual([]);
|
|
416
|
+
|
|
417
|
+
await checkAccessibility(makePage() as any, testInfo as any, {
|
|
418
|
+
baseline,
|
|
419
|
+
});
|
|
420
|
+
expect(
|
|
421
|
+
testInfo.annotations.filter(
|
|
422
|
+
(a) => a.type === "Stale a11y baseline entry",
|
|
423
|
+
),
|
|
424
|
+
).toHaveLength(1);
|
|
425
|
+
});
|
|
426
|
+
|
|
427
|
+
it("reports stale entries before an unmatched WCAG violation throws", async () => {
|
|
428
|
+
const baseline: AccessibilityBaseline = [
|
|
429
|
+
{
|
|
430
|
+
rule: "page-has-heading-one",
|
|
431
|
+
targets: ["html"],
|
|
432
|
+
reason: "Known issue",
|
|
433
|
+
willBeFixedIn: "PROJ-353",
|
|
434
|
+
},
|
|
435
|
+
{
|
|
436
|
+
rule: "heading-order",
|
|
437
|
+
targets: ["h3"],
|
|
438
|
+
reason: "Was broken",
|
|
439
|
+
willBeFixedIn: "PROJ-354",
|
|
440
|
+
},
|
|
441
|
+
];
|
|
442
|
+
mockAnalyze
|
|
443
|
+
.mockResolvedValueOnce(
|
|
444
|
+
makeAxeResults({
|
|
445
|
+
violations: [makeViolation("page-has-heading-one", [["html"]])],
|
|
446
|
+
}),
|
|
447
|
+
)
|
|
448
|
+
.mockResolvedValueOnce(
|
|
449
|
+
makeAxeResults({
|
|
450
|
+
violations: [makeViolation("image-alt", [["img"]])],
|
|
451
|
+
}),
|
|
452
|
+
);
|
|
453
|
+
mockToBe.mockImplementationOnce(() => {
|
|
454
|
+
throw new Error("new WCAG violation");
|
|
455
|
+
});
|
|
456
|
+
const testInfo = makeTestInfo();
|
|
457
|
+
|
|
458
|
+
await expect(
|
|
459
|
+
checkAccessibility(makePage() as any, testInfo as any, { baseline }),
|
|
460
|
+
).rejects.toThrow("new WCAG violation");
|
|
461
|
+
|
|
462
|
+
expect(
|
|
463
|
+
testInfo.annotations.filter(
|
|
464
|
+
(a) => a.type === "Stale a11y baseline entry",
|
|
465
|
+
),
|
|
466
|
+
).toEqual([
|
|
467
|
+
{
|
|
468
|
+
type: "Stale a11y baseline entry",
|
|
469
|
+
description: "heading-order on h3 — no longer detected",
|
|
470
|
+
},
|
|
471
|
+
]);
|
|
472
|
+
expect(mockExpectHard).toHaveBeenCalledWith(
|
|
473
|
+
null,
|
|
474
|
+
expect.stringContaining("image-alt"),
|
|
475
|
+
);
|
|
476
|
+
});
|
|
308
477
|
});
|
|
309
478
|
|
|
310
479
|
describe("snapshot bypass", () => {
|
|
@@ -699,6 +868,73 @@ describe("accessibility baseline", () => {
|
|
|
699
868
|
).rejects.toThrow("requires a reason");
|
|
700
869
|
});
|
|
701
870
|
|
|
871
|
+
it("keeps stale detection independent for each scan-specific on-disk baseline", async () => {
|
|
872
|
+
const fs = await import("fs");
|
|
873
|
+
const path = await import("path");
|
|
874
|
+
await fs.promises.writeFile(
|
|
875
|
+
path.join(tmpDir, "separate-scans-1.a11y-baseline-best-practice.json"),
|
|
876
|
+
JSON.stringify({
|
|
877
|
+
note: "Known best-practice violation.",
|
|
878
|
+
violations: [
|
|
879
|
+
{
|
|
880
|
+
rule: "page-has-heading-one",
|
|
881
|
+
targets: ["html"],
|
|
882
|
+
reason: "Known",
|
|
883
|
+
willBeFixedIn: "PROJ-353",
|
|
884
|
+
},
|
|
885
|
+
],
|
|
886
|
+
}),
|
|
887
|
+
);
|
|
888
|
+
await fs.promises.writeFile(
|
|
889
|
+
path.join(tmpDir, "separate-scans-1.a11y-baseline.json"),
|
|
890
|
+
JSON.stringify({
|
|
891
|
+
note: "Previously broken image.",
|
|
892
|
+
violations: [
|
|
893
|
+
{
|
|
894
|
+
rule: "image-alt",
|
|
895
|
+
targets: ["img"],
|
|
896
|
+
reason: "Was broken",
|
|
897
|
+
willBeFixedIn: "PROJ-354",
|
|
898
|
+
},
|
|
899
|
+
],
|
|
900
|
+
}),
|
|
901
|
+
);
|
|
902
|
+
mockAnalyze.mockResolvedValueOnce(
|
|
903
|
+
makeAxeResults({
|
|
904
|
+
violations: [makeViolation("page-has-heading-one", [["html"]])],
|
|
905
|
+
}),
|
|
906
|
+
);
|
|
907
|
+
|
|
908
|
+
const testInfo = makeTestInfo({
|
|
909
|
+
updateSnapshots: "none",
|
|
910
|
+
snapshotsDir: tmpDir,
|
|
911
|
+
title: "separate scans",
|
|
912
|
+
});
|
|
913
|
+
await checkAccessibility(makePage() as any, testInfo as any);
|
|
914
|
+
|
|
915
|
+
expect(
|
|
916
|
+
testInfo.annotations.filter(
|
|
917
|
+
(a) => a.type === "Baselined a11y violation",
|
|
918
|
+
),
|
|
919
|
+
).toEqual([
|
|
920
|
+
{
|
|
921
|
+
type: "Baselined a11y violation",
|
|
922
|
+
description: "page-has-heading-one: Known — PROJ-353",
|
|
923
|
+
},
|
|
924
|
+
]);
|
|
925
|
+
expect(
|
|
926
|
+
testInfo.annotations.filter(
|
|
927
|
+
(a) => a.type === "Stale a11y baseline entry",
|
|
928
|
+
),
|
|
929
|
+
).toEqual([
|
|
930
|
+
{
|
|
931
|
+
type: "Stale a11y baseline entry",
|
|
932
|
+
description: "image-alt on img — no longer detected",
|
|
933
|
+
},
|
|
934
|
+
]);
|
|
935
|
+
expect(mockToBe).not.toHaveBeenCalled();
|
|
936
|
+
});
|
|
937
|
+
|
|
702
938
|
it("produces per-call counter files for multi-call tests", async () => {
|
|
703
939
|
mockAnalyze.mockResolvedValue(makeAxeResults({ violations: [] }));
|
|
704
940
|
|
|
@@ -253,6 +253,10 @@ export async function checkAccessibility(
|
|
|
253
253
|
testInfo.annotations.push({ type: "@a11y" });
|
|
254
254
|
}
|
|
255
255
|
|
|
256
|
+
// An in-code baseline is shared by both scans. Track matches across this
|
|
257
|
+
// check so that a rule detected by either scan is not reported as stale.
|
|
258
|
+
const sharedBaselineMatches = new Set<number>();
|
|
259
|
+
|
|
256
260
|
if (bestPracticeMode !== "off") {
|
|
257
261
|
const bpResults = await runBestPracticeScan(page, testInfo, {
|
|
258
262
|
exclude: [...exclude, ...bestPracticeExclude],
|
|
@@ -270,6 +274,7 @@ export async function checkAccessibility(
|
|
|
270
274
|
scanLabel: "Best-practice scan",
|
|
271
275
|
},
|
|
272
276
|
baseline,
|
|
277
|
+
sharedBaselineMatches,
|
|
273
278
|
);
|
|
274
279
|
}
|
|
275
280
|
|
|
@@ -292,6 +297,7 @@ export async function checkAccessibility(
|
|
|
292
297
|
scanLabel: "WCAG scan",
|
|
293
298
|
},
|
|
294
299
|
baseline,
|
|
300
|
+
sharedBaselineMatches,
|
|
295
301
|
);
|
|
296
302
|
}
|
|
297
303
|
|
|
@@ -321,9 +327,10 @@ interface ScanContext {
|
|
|
321
327
|
async function dispatchAssertion(
|
|
322
328
|
ctx: ScanContext,
|
|
323
329
|
inCodeBaseline?: AccessibilityBaseline,
|
|
330
|
+
sharedBaselineMatches?: Set<number>,
|
|
324
331
|
): Promise<void> {
|
|
325
332
|
if (inCodeBaseline) {
|
|
326
|
-
return assertBaseline(ctx, inCodeBaseline);
|
|
333
|
+
return assertBaseline(ctx, inCodeBaseline, sharedBaselineMatches);
|
|
327
334
|
}
|
|
328
335
|
|
|
329
336
|
if (await snapshotExists(ctx.testInfo)) {
|
|
@@ -497,7 +504,11 @@ async function screenshotViolatingElements(
|
|
|
497
504
|
/**
|
|
498
505
|
* Assert violations against a baseline allowlist (in-code or on-disk).
|
|
499
506
|
*/
|
|
500
|
-
function assertBaseline(
|
|
507
|
+
function assertBaseline(
|
|
508
|
+
ctx: ScanContext,
|
|
509
|
+
baseline: AccessibilityBaseline,
|
|
510
|
+
sharedBaselineMatches?: Set<number>,
|
|
511
|
+
) {
|
|
501
512
|
validateAccessibilityBaseline(baseline);
|
|
502
513
|
const { testInfo, results, scanLabel, expectFn } = ctx;
|
|
503
514
|
const allViolations = extractNormalizedViolations(results);
|
|
@@ -517,6 +528,7 @@ function assertBaseline(ctx: ScanContext, baseline: AccessibilityBaseline) {
|
|
|
517
528
|
|
|
518
529
|
if (baselineIndex >= 0) {
|
|
519
530
|
matchedBaselineIndices.add(baselineIndex);
|
|
531
|
+
sharedBaselineMatches?.add(baselineIndex);
|
|
520
532
|
const entry = baseline[baselineIndex];
|
|
521
533
|
testInfo.annotations.push({
|
|
522
534
|
type: "Baselined a11y violation",
|
|
@@ -527,15 +539,21 @@ function assertBaseline(ctx: ScanContext, baseline: AccessibilityBaseline) {
|
|
|
527
539
|
}
|
|
528
540
|
}
|
|
529
541
|
|
|
530
|
-
//
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
542
|
+
// The WCAG scan always runs last, even when best-practice is disabled.
|
|
543
|
+
// Report shared entries once after both scans have contributed matches,
|
|
544
|
+
// before the hard assertion can throw. On-disk baselines belong to one
|
|
545
|
+
// scan and continue to report stale entries independently.
|
|
546
|
+
if (!sharedBaselineMatches || ctx.scan === "wcag") {
|
|
547
|
+
const matches = sharedBaselineMatches ?? matchedBaselineIndices;
|
|
548
|
+
baseline.forEach((entry, idx) => {
|
|
549
|
+
if (!matches.has(idx)) {
|
|
550
|
+
testInfo.annotations.push({
|
|
551
|
+
type: "Stale a11y baseline entry",
|
|
552
|
+
description: `${entry.rule} on ${entry.targets.join(", ")} — no longer detected`,
|
|
553
|
+
});
|
|
554
|
+
}
|
|
555
|
+
});
|
|
556
|
+
}
|
|
539
557
|
|
|
540
558
|
// Summary annotation for baseline mode.
|
|
541
559
|
const baselinedCount = matchedBaselineIndices.size;
|
|
@@ -245,6 +245,7 @@ describe("generateSummary", () => {
|
|
|
245
245
|
// attachment resolves, and that dead render is cached for good.
|
|
246
246
|
const summary = generateSummary(reportWith("https://example.test/a"));
|
|
247
247
|
|
|
248
|
+
expect(summary).toContain(":x: **1** failing test(s)");
|
|
248
249
|
expect(summary).not.toContain("<img");
|
|
249
250
|
expect(summary).toContain(
|
|
250
251
|
"1 screenshot(s) uploaded — see the pull request comment.",
|
|
@@ -396,7 +397,7 @@ describe("generateComment", () => {
|
|
|
396
397
|
summaryUrl: "https://example.test/run/1",
|
|
397
398
|
});
|
|
398
399
|
|
|
399
|
-
expect(comment).toContain("**1** failing test(s)");
|
|
400
|
+
expect(comment).toContain(":x: **1** failing test(s)");
|
|
400
401
|
expect(comment).toContain("homepage matches");
|
|
401
402
|
expect(comment).toContain("(https://example.test/run/1)");
|
|
402
403
|
});
|
|
@@ -382,7 +382,7 @@ function headline(report: FailureReport): string {
|
|
|
382
382
|
const parts =
|
|
383
383
|
report.totalFailed === 0
|
|
384
384
|
? [":white_check_mark: No failing tests"]
|
|
385
|
-
: [
|
|
385
|
+
: [`:x: **${report.totalFailed}** failing test(s)`];
|
|
386
386
|
|
|
387
387
|
if (report.totalFlaky > 0)
|
|
388
388
|
parts.push(`:warning: **${report.totalFlaky}** flaky test(s)`);
|
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
|
+
}
|