@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 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
@@ -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
- // Report stale baseline entries.
243
- baseline.forEach((entry, idx) => {
244
- if (!matchedBaselineIndices.has(idx)) {
245
- testInfo.annotations.push({
246
- type: "Stale a11y baseline entry",
247
- description: `${entry.rule} on ${entry.targets.join(", ")} — no longer detected`,
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
- : [`**${report.totalFailed}** failing test(s)`];
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
@@ -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.0.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",
@@ -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(ctx: ScanContext, baseline: AccessibilityBaseline) {
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
- // Report stale baseline entries.
531
- baseline.forEach((entry, idx) => {
532
- if (!matchedBaselineIndices.has(idx)) {
533
- testInfo.annotations.push({
534
- type: "Stale a11y baseline entry",
535
- description: `${entry.rule} on ${entry.targets.join(", ")} — no longer detected`,
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
- : [`**${report.totalFailed}** failing test(s)`];
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
@@ -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
+ }