@lullabot/playwright-testing 0.1.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.
Files changed (82) hide show
  1. package/README.md +61 -0
  2. package/bin/github-a11y-summary +5 -0
  3. package/bin/github-failure-summary +8 -0
  4. package/lib/accessibility-baseline-file.d.ts +47 -0
  5. package/lib/accessibility-baseline-file.js +205 -0
  6. package/lib/accessibility-baseline.d.ts +19 -0
  7. package/lib/accessibility-baseline.js +38 -0
  8. package/lib/accessible-screenshot.d.ts +181 -0
  9. package/lib/accessible-screenshot.js +519 -0
  10. package/lib/focus.d.ts +14 -0
  11. package/lib/focus.js +28 -0
  12. package/lib/fonts.d.ts +18 -0
  13. package/lib/fonts.js +24 -0
  14. package/lib/frames.d.ts +7 -0
  15. package/lib/frames.js +27 -0
  16. package/lib/github/a11y-summary.d.ts +55 -0
  17. package/lib/github/a11y-summary.js +383 -0
  18. package/lib/github/attachments.d.ts +98 -0
  19. package/lib/github/attachments.js +297 -0
  20. package/lib/github/failure-summary.d.ts +144 -0
  21. package/lib/github/failure-summary.js +567 -0
  22. package/lib/github/index.d.ts +6 -0
  23. package/lib/github/index.js +35 -0
  24. package/lib/github/report-paths.d.ts +38 -0
  25. package/lib/github/report-paths.js +200 -0
  26. package/lib/hover.d.ts +13 -0
  27. package/lib/hover.js +61 -0
  28. package/lib/images.d.ts +118 -0
  29. package/lib/images.js +260 -0
  30. package/lib/index.d.ts +13 -0
  31. package/lib/index.js +29 -0
  32. package/lib/interaction-states.d.ts +22 -0
  33. package/lib/interaction-states.js +75 -0
  34. package/lib/mock/index.d.ts +1 -0
  35. package/lib/mock/index.js +5 -0
  36. package/lib/mock/youtube.d.ts +5 -0
  37. package/lib/mock/youtube.js +38 -0
  38. package/lib/pseudo-state.d.ts +17 -0
  39. package/lib/pseudo-state.js +50 -0
  40. package/lib/videos.d.ts +134 -0
  41. package/lib/videos.js +349 -0
  42. package/lib/visualdiff.d.ts +154 -0
  43. package/lib/visualdiff.js +197 -0
  44. package/package.json +47 -0
  45. package/src/accessibility-baseline-file.test.ts +181 -0
  46. package/src/accessibility-baseline-file.ts +208 -0
  47. package/src/accessibility-baseline.test.ts +601 -0
  48. package/src/accessibility-baseline.ts +50 -0
  49. package/src/accessible-screenshot.test.ts +597 -0
  50. package/src/accessible-screenshot.ts +809 -0
  51. package/src/focus.test.ts +34 -0
  52. package/src/focus.ts +27 -0
  53. package/src/fonts.test.ts +17 -0
  54. package/src/fonts.ts +23 -0
  55. package/src/frames.test.ts +75 -0
  56. package/src/frames.ts +26 -0
  57. package/src/github/a11y-summary.test.ts +439 -0
  58. package/src/github/a11y-summary.ts +421 -0
  59. package/src/github/attachments.test.ts +248 -0
  60. package/src/github/attachments.ts +328 -0
  61. package/src/github/failure-summary.test.ts +636 -0
  62. package/src/github/failure-summary.ts +720 -0
  63. package/src/github/index.test.ts +24 -0
  64. package/src/github/index.ts +35 -0
  65. package/src/github/report-paths.test.ts +222 -0
  66. package/src/github/report-paths.ts +208 -0
  67. package/src/hover.test.ts +76 -0
  68. package/src/hover.ts +64 -0
  69. package/src/images.test.ts +355 -0
  70. package/src/images.ts +299 -0
  71. package/src/index.ts +13 -0
  72. package/src/interaction-states.test.ts +48 -0
  73. package/src/interaction-states.ts +94 -0
  74. package/src/mock/index.ts +1 -0
  75. package/src/mock/youtube.test.ts +38 -0
  76. package/src/mock/youtube.ts +39 -0
  77. package/src/pseudo-state.test.ts +83 -0
  78. package/src/pseudo-state.ts +69 -0
  79. package/src/videos.test.ts +637 -0
  80. package/src/videos.ts +389 -0
  81. package/src/visualdiff.test.ts +452 -0
  82. package/src/visualdiff.ts +381 -0
@@ -0,0 +1,197 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.VisualDiffTestCases = void 0;
4
+ exports.defineVisualDiffConfig = defineVisualDiffConfig;
5
+ exports.defaultTestFunction = defaultTestFunction;
6
+ const test_1 = require("@playwright/test");
7
+ const accessible_screenshot_js_1 = require("./accessible-screenshot.js");
8
+ const pseudo_state_js_1 = require("./pseudo-state.js");
9
+ function defineVisualDiffConfig(cases, preset) {
10
+ return new VisualDiffTestCases(cases, preset);
11
+ }
12
+ function defaultTestFunction(testCase, group, config, preset) {
13
+ return async ({ page, context }, testInfo) => {
14
+ const MockClass = testCase.mockClass ?? group.mockClass ?? config?.mockClass;
15
+ if (MockClass) {
16
+ const mock = new MockClass();
17
+ await mock.mock(page);
18
+ }
19
+ // Log any errors to the Playwright console too.
20
+ context.on('weberror', (webError) => console.log(webError.error()));
21
+ testInfo.annotations.push({
22
+ type: 'Description',
23
+ description: testCase.description,
24
+ });
25
+ const representativeUrl = testCase.representativeUrl
26
+ ?? group.representativeUrl
27
+ ?? config?.representativeUrl;
28
+ if (representativeUrl) {
29
+ testInfo.annotations.push({
30
+ type: 'Representative URL',
31
+ description: representativeUrl,
32
+ });
33
+ }
34
+ const path = `${config?.pathPrefix ?? ''}${group.pathPrefix ?? ''}${testCase.path}`;
35
+ await page.goto(path);
36
+ const configuredScreenshotOptions = typeof preset?.screenshotOptions === 'function'
37
+ ? await preset.screenshotOptions({ page, testInfo, testCase, group, config })
38
+ : preset?.screenshotOptions ?? {};
39
+ // Merge masks from all three levels: config, group, and testCase.
40
+ const maskSelectors = [
41
+ ...(config?.mask ?? []),
42
+ ...(group.mask ?? []),
43
+ ...(testCase.mask ?? []),
44
+ ];
45
+ const maskLocators = [
46
+ ...(configuredScreenshotOptions.mask ?? []),
47
+ ...maskSelectors.map(selector => page.locator(selector)),
48
+ ];
49
+ // Most-specific-wins for maskColor: testCase > group > config.
50
+ const maskColor = testCase.maskColor
51
+ ?? group.maskColor
52
+ ?? config?.maskColor
53
+ ?? configuredScreenshotOptions.maskColor;
54
+ const screenshotOptions = {
55
+ ...configuredScreenshotOptions,
56
+ fullPage: true,
57
+ };
58
+ if (maskLocators.length > 0) {
59
+ screenshotOptions.mask = maskLocators;
60
+ }
61
+ if (maskColor) {
62
+ screenshotOptions.maskColor = maskColor;
63
+ }
64
+ const a11yBaseline = testCase.a11yBaseline
65
+ ?? group.a11yBaseline
66
+ ?? config?.a11yBaseline
67
+ ?? configuredScreenshotOptions.accessibility?.baseline;
68
+ if (a11yBaseline || configuredScreenshotOptions.accessibility) {
69
+ screenshotOptions.accessibility = {
70
+ ...configuredScreenshotOptions.accessibility,
71
+ ...(a11yBaseline ? { baseline: a11yBaseline } : {}),
72
+ };
73
+ }
74
+ const interactionStates = [
75
+ ...(config?.interactionStates ?? []),
76
+ ...(group.interactionStates ?? []),
77
+ ...(testCase.interactionStates ?? []),
78
+ ];
79
+ if (interactionStates.length > 0 || configuredScreenshotOptions.interactionStates) {
80
+ screenshotOptions.interactionStates = [
81
+ ...(configuredScreenshotOptions.interactionStates ?? []),
82
+ ...interactionStates.map(({ selector, states }) => ({
83
+ locator: page.locator(selector),
84
+ states,
85
+ })),
86
+ ];
87
+ }
88
+ // Force declared pseudo-states after navigation and keep them active
89
+ // through both the screenshot and the accessibility scan. These synthetic
90
+ // states are unaffected by takeAccessibleScreenshot() clearing incidental
91
+ // pointer hover and DOM focus.
92
+ const pseudoStates = [
93
+ ...(config?.pseudoStates ?? []),
94
+ ...(group.pseudoStates ?? []),
95
+ ...(testCase.pseudoStates ?? []),
96
+ ];
97
+ const clearPseudoStates = [];
98
+ let operationFailed = false;
99
+ let operationError;
100
+ try {
101
+ for (const pseudoState of pseudoStates) {
102
+ clearPseudoStates.push(await (0, pseudo_state_js_1.forcePseudoState)(page, pseudoState.selector, pseudoState.pseudoClasses));
103
+ }
104
+ await (0, accessible_screenshot_js_1.takeAccessibleScreenshot)(page, testInfo, screenshotOptions);
105
+ }
106
+ catch (error) {
107
+ operationFailed = true;
108
+ operationError = error;
109
+ }
110
+ const cleanupErrors = [];
111
+ for (const clearPseudoState of clearPseudoStates.reverse()) {
112
+ try {
113
+ await clearPseudoState();
114
+ }
115
+ catch (error) {
116
+ cleanupErrors.push(error);
117
+ }
118
+ }
119
+ if (operationFailed) {
120
+ if (cleanupErrors.length > 0) {
121
+ throw new AggregateError([operationError, ...cleanupErrors], 'Visual diff capture and pseudo-state cleanup failed.');
122
+ }
123
+ throw operationError;
124
+ }
125
+ if (cleanupErrors.length === 1) {
126
+ throw cleanupErrors[0];
127
+ }
128
+ if (cleanupErrors.length > 1) {
129
+ throw new AggregateError(cleanupErrors, 'Multiple pseudo-state cleanup operations failed.');
130
+ }
131
+ };
132
+ }
133
+ /**
134
+ * Execute a set of visual diffs against groups of test cases.
135
+ */
136
+ class VisualDiffTestCases {
137
+ /**
138
+ * The configuration object containing all visual diff test cases.
139
+ * @private
140
+ */
141
+ config;
142
+ preset;
143
+ /**
144
+ * Construct a new set of VisualDiffTestCases
145
+ *
146
+ * @param config The config that has been imported via "import ..."
147
+ */
148
+ constructor(config, preset) {
149
+ this.config = config;
150
+ this.preset = preset;
151
+ }
152
+ /**
153
+ * Describe, execute, and skip test cases
154
+ *
155
+ * @param overriddenTestFunction An optional custom test function. Note: when
156
+ * using a custom test function, automatic mask, interaction-state, and
157
+ * pseudo-state handling is bypassed. You must apply them yourself.
158
+ */
159
+ describe(overriddenTestFunction) {
160
+ // Handle skipping of test cases, either based on a simple boolean or a callback.
161
+ function shouldSkip(testCase) {
162
+ return testCase.skip !== undefined
163
+ && (testCase.skip.callback === undefined || testCase.skip.callback(testCase));
164
+ }
165
+ function registerSkip(testCase) {
166
+ const skip = testCase.skip;
167
+ test_1.test.skip(`${testCase.name}: ${skip.reason} <${skip.willBeFixedIn}>`, async () => { });
168
+ }
169
+ this.config.groups.forEach((group) => {
170
+ // Allow skipping of entire groups of tests.
171
+ if (shouldSkip(group)) {
172
+ registerSkip(group);
173
+ return;
174
+ }
175
+ // Actually describe the group.
176
+ test_1.test.describe(group.name, () => {
177
+ group.testCases.forEach((testCase => {
178
+ // Allow skipping of individual test cases.
179
+ if (shouldSkip(testCase)) {
180
+ registerSkip(testCase);
181
+ return;
182
+ }
183
+ // Define a default function for test cases.
184
+ let testFunction;
185
+ if (typeof overriddenTestFunction != 'function') {
186
+ testFunction = defaultTestFunction(testCase, group, this.config, this.preset);
187
+ }
188
+ else {
189
+ testFunction = overriddenTestFunction(testCase, group);
190
+ }
191
+ (0, test_1.test)(`${testCase.name}: ${testCase.path}`, testFunction);
192
+ }));
193
+ });
194
+ });
195
+ }
196
+ }
197
+ exports.VisualDiffTestCases = VisualDiffTestCases;
package/package.json ADDED
@@ -0,0 +1,47 @@
1
+ {
2
+ "name": "@lullabot/playwright-testing",
3
+ "version": "0.1.0",
4
+ "description": "Framework-neutral Playwright testing utilities",
5
+ "license": "Apache-2.0",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/Lullabot/playwright-drupal.git",
9
+ "directory": "packages/playwright-testing"
10
+ },
11
+ "main": "lib/index.js",
12
+ "types": "lib/index.d.ts",
13
+ "files": [
14
+ "bin/",
15
+ "lib/",
16
+ "src/",
17
+ "README.md"
18
+ ],
19
+ "bin": {
20
+ "playwright-testing-a11y-summary": "./bin/github-a11y-summary",
21
+ "playwright-testing-failure-summary": "./bin/github-failure-summary"
22
+ },
23
+ "exports": {
24
+ ".": {
25
+ "types": "./lib/index.d.ts",
26
+ "default": "./lib/index.js"
27
+ },
28
+ "./github": {
29
+ "types": "./lib/github/index.d.ts",
30
+ "default": "./lib/github/index.js"
31
+ }
32
+ },
33
+ "scripts": {
34
+ "build": "tsc",
35
+ "prepack": "npm run build",
36
+ "test": "vitest run --passWithNoTests"
37
+ },
38
+ "dependencies": {
39
+ "@axe-core/playwright": "^4.8.5",
40
+ "@playwright/test": "^1.39.0"
41
+ },
42
+ "devDependencies": {
43
+ "@types/node": "^24.0.0",
44
+ "typescript": "^5.2.2 || ^6.0.0 || ^7.0.0",
45
+ "vitest": "^5.0.0"
46
+ }
47
+ }
@@ -0,0 +1,181 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest'
2
+ import { promises as fs } from 'fs'
3
+ import os from 'os'
4
+ import path from 'path'
5
+ import {
6
+ baselineFilePath,
7
+ buildSeed,
8
+ nextAccessibilityScanCount,
9
+ readBaselineFile,
10
+ resetAccessibilityScanCounts,
11
+ snapshotExists,
12
+ writeBaselineFile,
13
+ } from './accessibility-baseline-file'
14
+
15
+ function makeTestInfo(opts: { dir: string; title?: string; titlePath?: string[] }) {
16
+ const title = opts.title ?? 'standalone accessibility check works'
17
+ const titlePath = opts.titlePath ?? ['file.spec.ts', title]
18
+ return {
19
+ testId: `${title}-${Math.random()}`,
20
+ title,
21
+ titlePath,
22
+ snapshotPath: (...segs: string[]) => path.join(opts.dir, ...segs),
23
+ }
24
+ }
25
+
26
+ describe('accessibility-baseline-file', () => {
27
+ let tmpDir: string
28
+
29
+ beforeEach(async () => {
30
+ tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'a11y-baseline-'))
31
+ })
32
+
33
+ afterEach(async () => {
34
+ await fs.rm(tmpDir, { recursive: true, force: true })
35
+ })
36
+
37
+ describe('baselineFilePath', () => {
38
+ it('builds a deterministic projectless path with counter and scan-kind suffix', () => {
39
+ const ti = makeTestInfo({ dir: tmpDir })
40
+ const wcag = baselineFilePath(ti, 'wcag', 1)
41
+ const bp = baselineFilePath(ti, 'best-practice', 1)
42
+ expect(wcag).toBe(path.join(tmpDir, 'standalone-accessibility-check-works-1.a11y-baseline.json'))
43
+ expect(bp).toBe(path.join(tmpDir, 'standalone-accessibility-check-works-1.a11y-baseline-best-practice.json'))
44
+ })
45
+
46
+ it('uses an incremented counter for multi-call tests', () => {
47
+ const ti = makeTestInfo({ dir: tmpDir })
48
+ const path2 = baselineFilePath(ti, 'wcag', 2)
49
+ expect(path2).toContain('-2.a11y-baseline.json')
50
+ })
51
+ })
52
+
53
+ describe('nextAccessibilityScanCount', () => {
54
+ it('returns sequential numbers per (testInfo, scan)', () => {
55
+ const ti = {} as any
56
+ try {
57
+ expect(nextAccessibilityScanCount(ti, 'wcag')).toBe(1)
58
+ expect(nextAccessibilityScanCount(ti, 'wcag')).toBe(2)
59
+ expect(nextAccessibilityScanCount(ti, 'best-practice')).toBe(1)
60
+ expect(nextAccessibilityScanCount(ti, 'wcag')).toBe(3)
61
+ } finally {
62
+ resetAccessibilityScanCounts(ti)
63
+ }
64
+ })
65
+ })
66
+
67
+ describe('snapshotExists', () => {
68
+ it('returns false when the snapshots dir is missing', async () => {
69
+ const ti = makeTestInfo({ dir: path.join(tmpDir, 'does-not-exist') })
70
+ expect(await snapshotExists(ti)).toBe(false)
71
+ })
72
+
73
+ it('returns false when no snapshot file matches the test slug', async () => {
74
+ await fs.writeFile(path.join(tmpDir, 'other-test-1-chromium-linux.txt'), 'data')
75
+ const ti = makeTestInfo({ dir: tmpDir })
76
+ expect(await snapshotExists(ti)).toBe(false)
77
+ })
78
+
79
+ it('returns true when a snapshot file matching the test slug exists', async () => {
80
+ await fs.writeFile(
81
+ path.join(tmpDir, 'standalone-accessibility-check-works-1-chromium-linux.txt'),
82
+ 'data',
83
+ )
84
+ const ti = makeTestInfo({ dir: tmpDir })
85
+ expect(await snapshotExists(ti)).toBe(true)
86
+ })
87
+ })
88
+
89
+ describe('snapshotExists with Playwright-named snapshots', () => {
90
+ const touch = (name: string) => fs.writeFile(path.join(tmpDir, name), 'data')
91
+
92
+ it('matches a capitalised title with punctuation and project suffix', async () => {
93
+ await touch('Test-error-pages-ID-3303-2-desktop-chrome-linux.txt')
94
+ const ti = makeTestInfo({ dir: tmpDir, title: 'Test error pages: ID 3303' })
95
+ expect(await snapshotExists(ti)).toBe(true)
96
+ })
97
+
98
+ it('matches with different project suffixes', async () => {
99
+ await touch('Login-Works-1-mobile-safari-darwin.txt')
100
+ const ti = makeTestInfo({ dir: tmpDir, title: 'Login Works' })
101
+ expect(await snapshotExists(ti)).toBe(true)
102
+ })
103
+
104
+ it('does not match a title that differs only by case', async () => {
105
+ await touch('login-works-1-desktop-chrome-linux.txt')
106
+ const ti = makeTestInfo({ dir: tmpDir, title: 'Login Works' })
107
+ expect(await snapshotExists(ti)).toBe(false)
108
+ })
109
+
110
+ it('returns false when nothing matches', async () => {
111
+ await touch('Other-Test-1-desktop-chrome-linux.txt')
112
+ const ti = makeTestInfo({ dir: tmpDir, title: 'Login Works' })
113
+ expect(await snapshotExists(ti)).toBe(false)
114
+ })
115
+
116
+ it('ignores a matching .png without a .txt', async () => {
117
+ await touch('Login-Works-1-desktop-chrome-linux.png')
118
+ const ti = makeTestInfo({ dir: tmpDir, title: 'Login Works' })
119
+ expect(await snapshotExists(ti)).toBe(false)
120
+ })
121
+
122
+ it('does not match a different test whose title extends this one', async () => {
123
+ await touch('CRUD-tests-Video-Promo-1-desktop-chrome-linux.txt')
124
+ const ti = makeTestInfo({ dir: tmpDir, title: 'CRUD tests - Video' })
125
+ expect(await snapshotExists(ti)).toBe(false)
126
+ })
127
+ })
128
+
129
+ describe('readBaselineFile / writeBaselineFile', () => {
130
+ it('round-trips object schema with note and violations', async () => {
131
+ const file = path.join(tmpDir, 'rt.a11y-baseline.json')
132
+ await writeBaselineFile(file, {
133
+ note: 'No accessibility violations found',
134
+ violations: [],
135
+ })
136
+ const back = await readBaselineFile(file)
137
+ expect(back).toEqual({ note: 'No accessibility violations found', violations: [] })
138
+ })
139
+
140
+ it('returns null when file is missing', async () => {
141
+ const file = path.join(tmpDir, 'missing.json')
142
+ expect(await readBaselineFile(file)).toBeNull()
143
+ })
144
+
145
+ it('does not throw on EEXIST (idempotent first-write)', async () => {
146
+ const file = path.join(tmpDir, 'idem.json')
147
+ await writeBaselineFile(file, { note: 'first', violations: [] })
148
+ await writeBaselineFile(file, { note: 'second', violations: [] })
149
+ const back = await readBaselineFile(file)
150
+ expect(back?.note).toBe('first')
151
+ })
152
+
153
+ it('throws a clear error on malformed JSON', async () => {
154
+ const file = path.join(tmpDir, 'bad.json')
155
+ await fs.writeFile(file, '{not json')
156
+ await expect(readBaselineFile(file)).rejects.toThrow(/Malformed baseline file/)
157
+ })
158
+
159
+ it('creates parent directories on write', async () => {
160
+ const file = path.join(tmpDir, 'nested', 'sub', 'baseline.json')
161
+ await writeBaselineFile(file, { note: 'x', violations: [] })
162
+ expect((await readBaselineFile(file))?.note).toBe('x')
163
+ })
164
+ })
165
+
166
+ describe('buildSeed', () => {
167
+ it('produces a clean-state payload for zero violations', () => {
168
+ expect(buildSeed([])).toEqual({
169
+ note: 'No accessibility violations found',
170
+ violations: [],
171
+ })
172
+ })
173
+
174
+ it('produces a TODO-prompted payload for non-zero violations', () => {
175
+ const v = [{ rule: 'color-contrast', targets: ['#x'], reason: 'TODO', willBeFixedIn: 'TODO' }]
176
+ const seed = buildSeed(v)
177
+ expect(seed.note).toMatch(/TODO/)
178
+ expect(seed.violations).toEqual(v)
179
+ })
180
+ })
181
+ })
@@ -0,0 +1,208 @@
1
+ import { promises as fs } from 'fs'
2
+ import path from 'path'
3
+ import type { TestInfo } from '@playwright/test'
4
+ import type { AccessibilityBaselineEntry } from './accessibility-baseline.js'
5
+
6
+ export type ScanKind = 'wcag' | 'best-practice'
7
+
8
+ export interface OnDiskBaselineFile {
9
+ note: string
10
+ violations: AccessibilityBaselineEntry[]
11
+ }
12
+
13
+ const scanInvocationCounters = new WeakMap<object, Map<ScanKind, number>>()
14
+
15
+ /**
16
+ * Increment and return the 1-indexed invocation number for this scan kind
17
+ * within the current test. Every call to `checkAccessibility()` runs up to
18
+ * two scans (WCAG + best-practice); each scan gets its own counter so that
19
+ * a test calling `checkAccessibility()` twice produces distinct baseline
20
+ * file names (`…-1.a11y-baseline.json`, `…-2.a11y-baseline.json`) even when
21
+ * both scans share a single `checkAccessibility()` call.
22
+ *
23
+ * Counter state is keyed by the TestInfo object (held in a WeakMap so it
24
+ * is discarded with the test). Use `resetAccessibilityScanCounts()` to
25
+ * clear state explicitly — for example, between tests in a unit-test
26
+ * suite that reuses a mocked TestInfo.
27
+ */
28
+ export function nextAccessibilityScanCount(
29
+ testInfo: Pick<TestInfo, 'testId'> | object,
30
+ scan: ScanKind,
31
+ ): number {
32
+ const key = testInfo as object
33
+ let counts = scanInvocationCounters.get(key)
34
+ if (!counts) {
35
+ counts = new Map()
36
+ scanInvocationCounters.set(key, counts)
37
+ }
38
+ const n = (counts.get(scan) ?? 0) + 1
39
+ counts.set(scan, n)
40
+ return n
41
+ }
42
+
43
+ export function resetAccessibilityScanCounts(testInfo: object): void {
44
+ scanInvocationCounters.delete(testInfo)
45
+ }
46
+
47
+ /**
48
+ * Slugify a test's fully qualified title the same way we use it as the
49
+ * stem of both on-disk baseline filenames and (for existence checks) the
50
+ * prefix of Playwright's auto-generated snapshot filenames.
51
+ *
52
+ * Implemented as a single-pass character scan to avoid regex-based
53
+ * polynomial backtracking on library-supplied input (CodeQL
54
+ * js/polynomial-redos).
55
+ */
56
+ function slugifyTitle(testInfo: Pick<TestInfo, 'titlePath' | 'title'>): string {
57
+ const segments = testInfo.titlePath?.slice(1) ?? []
58
+ const raw = segments.length > 0 ? segments.join(' ') : testInfo.title
59
+ let out = ''
60
+ let lastWasHyphen = true // suppresses leading hyphen
61
+ for (let i = 0; i < raw.length; i++) {
62
+ const code = raw.charCodeAt(i)
63
+ const isLowerAlpha = code >= 97 && code <= 122 // a-z
64
+ const isUpperAlpha = code >= 65 && code <= 90 // A-Z
65
+ const isDigit = code >= 48 && code <= 57 // 0-9
66
+ if (isLowerAlpha || isDigit) {
67
+ out += raw[i]
68
+ lastWasHyphen = false
69
+ } else if (isUpperAlpha) {
70
+ out += String.fromCharCode(code + 32)
71
+ lastWasHyphen = false
72
+ } else if (!lastWasHyphen) {
73
+ out += '-'
74
+ lastWasHyphen = true
75
+ }
76
+ }
77
+ // Trim trailing hyphen.
78
+ if (out.endsWith('-')) out = out.slice(0, -1)
79
+ return out
80
+ }
81
+
82
+ /**
83
+ * Reproduce the stem Playwright uses for auto-named snapshot files: the
84
+ * title path (minus the spec file) joined with spaces, with every run of
85
+ * control/punctuation characters collapsed to a single hyphen. Unlike
86
+ * `slugifyTitle()` this preserves case and does not trim hyphens, so it
87
+ * matches committed snapshot filenames exactly. Mirrors Playwright's
88
+ * `sanitizeForFilePath()`. Very long titles, which Playwright truncates
89
+ * and hashes, are not handled.
90
+ */
91
+ function playwrightSnapshotStem(testInfo: Pick<TestInfo, 'titlePath' | 'title'>): string {
92
+ const segments = testInfo.titlePath?.slice(1) ?? []
93
+ const raw = segments.length > 0 ? segments.join(' ') : testInfo.title
94
+ let out = ''
95
+ let lastWasReplaced = false
96
+ for (let i = 0; i < raw.length; i++) {
97
+ const code = raw.charCodeAt(i)
98
+ const replaced =
99
+ code <= 0x2c ||
100
+ (code >= 0x2e && code <= 0x2f) ||
101
+ (code >= 0x3a && code <= 0x40) ||
102
+ (code >= 0x5b && code <= 0x60) ||
103
+ (code >= 0x7b && code <= 0x7f)
104
+ if (replaced) {
105
+ if (!lastWasReplaced) out += '-'
106
+ lastWasReplaced = true
107
+ } else {
108
+ out += raw[i]
109
+ lastWasReplaced = false
110
+ }
111
+ }
112
+ return out
113
+ }
114
+
115
+ /**
116
+ * Build the on-disk baseline file path for a single scan call.
117
+ *
118
+ * Playwright exposes `testInfo.snapshotPath(...name)` as the public way to
119
+ * resolve a file under the test's snapshots directory (honoring any
120
+ * user-configured `snapshotPathTemplate`). We pass a filename that mixes
121
+ * the slugified test title + call counter + a scan-specific suffix, so two
122
+ * different tests in the same spec file don't collide and each scan has
123
+ * its own file.
124
+ *
125
+ * There is no reusable Playwright API that bakes the test title into an
126
+ * explicit-name snapshot path — that logic is private to `toMatchSnapshot()`
127
+ * for its auto-counter naming — so we build the stem ourselves.
128
+ */
129
+ export function baselineFilePath(
130
+ testInfo: Pick<TestInfo, 'snapshotPath' | 'titlePath' | 'title'>,
131
+ scan: ScanKind,
132
+ callCount: number,
133
+ ): string {
134
+ const slug = slugifyTitle(testInfo)
135
+ const suffix = scan === 'wcag' ? 'a11y-baseline' : 'a11y-baseline-best-practice'
136
+ return testInfo.snapshotPath(`${slug}-${callCount}.${suffix}.json`)
137
+ }
138
+
139
+ /**
140
+ * Return true if a Playwright snapshot file already exists on disk for
141
+ * this test — i.e. the test has been previously committed under snapshot
142
+ * mode. Used to preserve snapshot-mode behaviour for existing tests while
143
+ * defaulting new (snapshotless) tests into on-disk baseline mode.
144
+ */
145
+ export async function snapshotExists(
146
+ testInfo: Pick<TestInfo, 'snapshotPath' | 'titlePath' | 'title'>,
147
+ ): Promise<boolean> {
148
+ const dir = path.dirname(testInfo.snapshotPath('a11y-baseline-probe'))
149
+ let entries: string[]
150
+ try {
151
+ entries = await fs.readdir(dir)
152
+ } catch (err: any) {
153
+ if (err?.code === 'ENOENT') return false
154
+ throw err
155
+ }
156
+ // Playwright names snapshots `<stem>-<counter>[-<project>-<platform>].txt`.
157
+ // Require the counter so a title that merely extends this one (e.g.
158
+ // "Video" vs "Video Promo") is not mistaken for this test's snapshot.
159
+ const prefix = `${playwrightSnapshotStem(testInfo)}-`
160
+ return entries.some(name => {
161
+ if (!name.startsWith(prefix) || !name.endsWith('.txt')) return false
162
+ let i = prefix.length
163
+ while (i < name.length && name.charCodeAt(i) >= 48 && name.charCodeAt(i) <= 57) i++
164
+ return i > prefix.length && (name[i] === '-' || name[i] === '.')
165
+ })
166
+ }
167
+
168
+ export async function readBaselineFile(filePath: string): Promise<OnDiskBaselineFile | null> {
169
+ let raw: string
170
+ try {
171
+ raw = await fs.readFile(filePath, 'utf8')
172
+ } catch (err: any) {
173
+ if (err?.code === 'ENOENT') return null
174
+ throw err
175
+ }
176
+ try {
177
+ const parsed = JSON.parse(raw)
178
+ if (!parsed || typeof parsed !== 'object' || !Array.isArray(parsed.violations)) {
179
+ throw new Error('expected an object with a "violations" array')
180
+ }
181
+ return {
182
+ note: typeof parsed.note === 'string' ? parsed.note : '',
183
+ violations: parsed.violations as AccessibilityBaselineEntry[],
184
+ }
185
+ } catch (err) {
186
+ throw new Error(`Malformed baseline file at ${filePath}: ${(err as Error).message}`)
187
+ }
188
+ }
189
+
190
+ export async function writeBaselineFile(filePath: string, data: OnDiskBaselineFile): Promise<void> {
191
+ await fs.mkdir(path.dirname(filePath), { recursive: true })
192
+ try {
193
+ await fs.writeFile(filePath, JSON.stringify(data, null, 2) + '\n', { flag: 'wx' })
194
+ } catch (err: any) {
195
+ if (err?.code === 'EEXIST') return
196
+ throw err
197
+ }
198
+ }
199
+
200
+ export function buildSeed(violations: AccessibilityBaselineEntry[]): OnDiskBaselineFile {
201
+ if (violations.length === 0) {
202
+ return { note: 'No accessibility violations found', violations: [] }
203
+ }
204
+ return {
205
+ note: 'TODO: fill in reason and willBeFixedIn for each entry before committing.',
206
+ violations,
207
+ }
208
+ }