@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.
- package/README.md +61 -0
- package/bin/github-a11y-summary +5 -0
- package/bin/github-failure-summary +8 -0
- package/lib/accessibility-baseline-file.d.ts +47 -0
- package/lib/accessibility-baseline-file.js +205 -0
- package/lib/accessibility-baseline.d.ts +19 -0
- package/lib/accessibility-baseline.js +38 -0
- package/lib/accessible-screenshot.d.ts +181 -0
- package/lib/accessible-screenshot.js +519 -0
- package/lib/focus.d.ts +14 -0
- package/lib/focus.js +28 -0
- package/lib/fonts.d.ts +18 -0
- package/lib/fonts.js +24 -0
- package/lib/frames.d.ts +7 -0
- package/lib/frames.js +27 -0
- package/lib/github/a11y-summary.d.ts +55 -0
- package/lib/github/a11y-summary.js +383 -0
- package/lib/github/attachments.d.ts +98 -0
- package/lib/github/attachments.js +297 -0
- package/lib/github/failure-summary.d.ts +144 -0
- package/lib/github/failure-summary.js +567 -0
- package/lib/github/index.d.ts +6 -0
- package/lib/github/index.js +35 -0
- package/lib/github/report-paths.d.ts +38 -0
- package/lib/github/report-paths.js +200 -0
- package/lib/hover.d.ts +13 -0
- package/lib/hover.js +61 -0
- package/lib/images.d.ts +118 -0
- package/lib/images.js +260 -0
- package/lib/index.d.ts +13 -0
- package/lib/index.js +29 -0
- package/lib/interaction-states.d.ts +22 -0
- package/lib/interaction-states.js +75 -0
- package/lib/mock/index.d.ts +1 -0
- package/lib/mock/index.js +5 -0
- package/lib/mock/youtube.d.ts +5 -0
- package/lib/mock/youtube.js +38 -0
- package/lib/pseudo-state.d.ts +17 -0
- package/lib/pseudo-state.js +50 -0
- package/lib/videos.d.ts +134 -0
- package/lib/videos.js +349 -0
- package/lib/visualdiff.d.ts +154 -0
- package/lib/visualdiff.js +197 -0
- package/package.json +47 -0
- package/src/accessibility-baseline-file.test.ts +181 -0
- package/src/accessibility-baseline-file.ts +208 -0
- package/src/accessibility-baseline.test.ts +601 -0
- package/src/accessibility-baseline.ts +50 -0
- package/src/accessible-screenshot.test.ts +597 -0
- package/src/accessible-screenshot.ts +809 -0
- package/src/focus.test.ts +34 -0
- package/src/focus.ts +27 -0
- package/src/fonts.test.ts +17 -0
- package/src/fonts.ts +23 -0
- package/src/frames.test.ts +75 -0
- package/src/frames.ts +26 -0
- package/src/github/a11y-summary.test.ts +439 -0
- package/src/github/a11y-summary.ts +421 -0
- package/src/github/attachments.test.ts +248 -0
- package/src/github/attachments.ts +328 -0
- package/src/github/failure-summary.test.ts +636 -0
- package/src/github/failure-summary.ts +720 -0
- package/src/github/index.test.ts +24 -0
- package/src/github/index.ts +35 -0
- package/src/github/report-paths.test.ts +222 -0
- package/src/github/report-paths.ts +208 -0
- package/src/hover.test.ts +76 -0
- package/src/hover.ts +64 -0
- package/src/images.test.ts +355 -0
- package/src/images.ts +299 -0
- package/src/index.ts +13 -0
- package/src/interaction-states.test.ts +48 -0
- package/src/interaction-states.ts +94 -0
- package/src/mock/index.ts +1 -0
- package/src/mock/youtube.test.ts +38 -0
- package/src/mock/youtube.ts +39 -0
- package/src/pseudo-state.test.ts +83 -0
- package/src/pseudo-state.ts +69 -0
- package/src/videos.test.ts +637 -0
- package/src/videos.ts +389 -0
- package/src/visualdiff.test.ts +452 -0
- 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
|
+
}
|