@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,34 @@
|
|
|
1
|
+
import {afterEach, describe, expect, it, vi} from 'vitest'
|
|
2
|
+
import {blurActiveElement} from './focus'
|
|
3
|
+
|
|
4
|
+
describe('blurActiveElement', () => {
|
|
5
|
+
afterEach(() => vi.unstubAllGlobals())
|
|
6
|
+
|
|
7
|
+
it('blurs a genuinely focused element', async () => {
|
|
8
|
+
const activeElement = {blur: vi.fn()}
|
|
9
|
+
const document = {activeElement, body: {}, documentElement: {}}
|
|
10
|
+
const page = {
|
|
11
|
+
evaluate: vi.fn((callback: () => boolean) => {
|
|
12
|
+
vi.stubGlobal('document', document)
|
|
13
|
+
return callback()
|
|
14
|
+
}),
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
await expect(blurActiveElement(page as never)).resolves.toBe(true)
|
|
18
|
+
expect(activeElement.blur).toHaveBeenCalledOnce()
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
it('does not blur the document body fallback', async () => {
|
|
22
|
+
const body = {blur: vi.fn()}
|
|
23
|
+
const document = {activeElement: body, body, documentElement: {}}
|
|
24
|
+
const page = {
|
|
25
|
+
evaluate: vi.fn((callback: () => boolean) => {
|
|
26
|
+
vi.stubGlobal('document', document)
|
|
27
|
+
return callback()
|
|
28
|
+
}),
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
await expect(blurActiveElement(page as never)).resolves.toBe(false)
|
|
32
|
+
expect(body.blur).not.toHaveBeenCalled()
|
|
33
|
+
})
|
|
34
|
+
})
|
package/src/focus.ts
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import {Page} from "@playwright/test";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Remove keyboard focus from the active element.
|
|
5
|
+
*
|
|
6
|
+
* A focused form control paints a focus outline/ring. Whether an element still
|
|
7
|
+
* has focus when the screenshot is taken depends on test timing, so that ring
|
|
8
|
+
* appears in some runs and not others, producing a visual diff that is invisible
|
|
9
|
+
* to a human but trips the strict pixel budget. Blurring the active element
|
|
10
|
+
* first makes the focus state deterministic.
|
|
11
|
+
*
|
|
12
|
+
* @param page
|
|
13
|
+
* @returns `true` if an element was blurred, `false` if nothing was focused.
|
|
14
|
+
*/
|
|
15
|
+
export async function blurActiveElement(page: Page): Promise<boolean> {
|
|
16
|
+
return page.evaluate(() => {
|
|
17
|
+
const el = document.activeElement as HTMLElement | null;
|
|
18
|
+
// `document.activeElement` falls back to `<body>`/`<html>` when nothing is
|
|
19
|
+
// focused. Blurring that paints no focus ring but can still fire `focusout`
|
|
20
|
+
// handlers, so skip it and only blur a genuinely focused control.
|
|
21
|
+
if (!el || el === document.body || el === document.documentElement || typeof el.blur !== "function") {
|
|
22
|
+
return false;
|
|
23
|
+
}
|
|
24
|
+
el.blur();
|
|
25
|
+
return true;
|
|
26
|
+
});
|
|
27
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import {describe, expect, it, vi} from 'vitest'
|
|
2
|
+
import {waitForFonts} from './fonts'
|
|
3
|
+
|
|
4
|
+
describe('waitForFonts', () => {
|
|
5
|
+
it('waits for the browser font set to become ready', async () => {
|
|
6
|
+
const evaluate = vi.fn(async (callback: () => Promise<void>) => {
|
|
7
|
+
const ready = Promise.resolve()
|
|
8
|
+
vi.stubGlobal('document', {fonts: {ready}})
|
|
9
|
+
await callback()
|
|
10
|
+
})
|
|
11
|
+
|
|
12
|
+
await waitForFonts({evaluate} as never)
|
|
13
|
+
|
|
14
|
+
expect(evaluate).toHaveBeenCalledOnce()
|
|
15
|
+
vi.unstubAllGlobals()
|
|
16
|
+
})
|
|
17
|
+
})
|
package/src/fonts.ts
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import {Page} from "@playwright/test";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Wait for all web fonts to finish loading.
|
|
5
|
+
*
|
|
6
|
+
* Visual snapshots can flake when text is first painted with fallback font
|
|
7
|
+
* metrics before the web fonts finish loading. The fallback glyphs have
|
|
8
|
+
* different widths, so text wraps onto a different number of lines and the page
|
|
9
|
+
* renders at a slightly different height than it does once the real fonts are
|
|
10
|
+
* applied. By the time Playwright retries the layout has settled, which is why
|
|
11
|
+
* these failures usually only show up on the first attempt.
|
|
12
|
+
*
|
|
13
|
+
* Awaiting `document.fonts.ready` blocks until every font face the page has
|
|
14
|
+
* requested has finished loading (or failed), so the screenshot always captures
|
|
15
|
+
* the settled, web-font layout.
|
|
16
|
+
*
|
|
17
|
+
* @param page
|
|
18
|
+
*/
|
|
19
|
+
export async function waitForFonts(page: Page): Promise<void> {
|
|
20
|
+
await page.evaluate(async () => {
|
|
21
|
+
await document.fonts.ready;
|
|
22
|
+
});
|
|
23
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
2
|
+
|
|
3
|
+
import { waitForFrames } from './frames';
|
|
4
|
+
|
|
5
|
+
function makeLocator({
|
|
6
|
+
connected = true,
|
|
7
|
+
visible = true,
|
|
8
|
+
frame = makeFrame(),
|
|
9
|
+
}: {
|
|
10
|
+
connected?: boolean;
|
|
11
|
+
visible?: boolean;
|
|
12
|
+
frame?: ReturnType<typeof makeFrame> | null;
|
|
13
|
+
} = {}) {
|
|
14
|
+
const element = {
|
|
15
|
+
contentFrame: vi.fn().mockResolvedValue(frame),
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
return {
|
|
19
|
+
evaluate: vi.fn().mockResolvedValue(connected),
|
|
20
|
+
isVisible: vi.fn().mockResolvedValue(visible),
|
|
21
|
+
scrollIntoViewIfNeeded: vi.fn().mockResolvedValue(undefined),
|
|
22
|
+
elementHandle: vi.fn().mockResolvedValue(element),
|
|
23
|
+
element,
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function makeFrame() {
|
|
28
|
+
return {
|
|
29
|
+
waitForURL: vi.fn().mockResolvedValue(undefined),
|
|
30
|
+
waitForLoadState: vi.fn().mockResolvedValue(undefined),
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function makePage(locators: ReturnType<typeof makeLocator>[]) {
|
|
35
|
+
return {
|
|
36
|
+
locator: vi.fn().mockReturnValue({
|
|
37
|
+
all: vi.fn().mockResolvedValue(locators),
|
|
38
|
+
}),
|
|
39
|
+
} as never;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
describe('waitForFrames', () => {
|
|
43
|
+
it('waits for each visible frame URL and load state', async () => {
|
|
44
|
+
const first = makeLocator();
|
|
45
|
+
const second = makeLocator();
|
|
46
|
+
|
|
47
|
+
await waitForFrames(makePage([first, second]));
|
|
48
|
+
|
|
49
|
+
for (const locator of [first, second]) {
|
|
50
|
+
expect(locator.scrollIntoViewIfNeeded).toHaveBeenCalledTimes(2);
|
|
51
|
+
expect(locator.element.contentFrame).toHaveBeenCalledOnce();
|
|
52
|
+
expect(locator.element.contentFrame.mock.results[0].value).toBeInstanceOf(Promise);
|
|
53
|
+
const frame = await locator.element.contentFrame.mock.results[0].value;
|
|
54
|
+
expect(frame?.waitForURL).toHaveBeenCalledOnce();
|
|
55
|
+
expect(frame?.waitForLoadState).toHaveBeenCalledWith('load');
|
|
56
|
+
}
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('ignores frames that are detached or hidden', async () => {
|
|
60
|
+
const detached = makeLocator({ connected: false });
|
|
61
|
+
const hidden = makeLocator({ visible: false });
|
|
62
|
+
|
|
63
|
+
await waitForFrames(makePage([detached, hidden]));
|
|
64
|
+
|
|
65
|
+
expect(detached.isVisible).not.toHaveBeenCalled();
|
|
66
|
+
expect(detached.elementHandle).not.toHaveBeenCalled();
|
|
67
|
+
expect(hidden.elementHandle).not.toHaveBeenCalled();
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('tolerates an iframe that detaches before its content frame is resolved', async () => {
|
|
71
|
+
const locator = makeLocator({ frame: null });
|
|
72
|
+
|
|
73
|
+
await expect(waitForFrames(makePage([locator]))).resolves.toBeUndefined();
|
|
74
|
+
});
|
|
75
|
+
});
|
package/src/frames.ts
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import {Page} from "@playwright/test";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Wait for all frames to load.
|
|
5
|
+
*
|
|
6
|
+
* @param page
|
|
7
|
+
*/
|
|
8
|
+
export async function waitForFrames(page: Page): Promise<void> {
|
|
9
|
+
const locators = await page.locator('iframe');
|
|
10
|
+
// Trigger lazy-loading iframes. Since this should be fast, and we don't want to
|
|
11
|
+
// have to deal with concurrency bugs, we do this in serial.
|
|
12
|
+
for (const locator of await locators.all()) {
|
|
13
|
+
// Ensure iframes are connected to the DOM before trying to scroll to them.
|
|
14
|
+
// https://github.com/microsoft/playwright/issues/23758
|
|
15
|
+
if (await locator.evaluate(iframe => iframe.isConnected)) {
|
|
16
|
+
if (await locator.isVisible()) {
|
|
17
|
+
await locator.scrollIntoViewIfNeeded();
|
|
18
|
+
await locator.scrollIntoViewIfNeeded();
|
|
19
|
+
const element = await locator.elementHandle();
|
|
20
|
+
const frame = await element?.contentFrame();
|
|
21
|
+
await frame?.waitForURL(new RegExp('.*/.*', 'i'));
|
|
22
|
+
await frame?.waitForLoadState('load');
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
}
|
|
@@ -0,0 +1,439 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
|
|
2
|
+
import * as fs from 'fs'
|
|
3
|
+
import * as os from 'os'
|
|
4
|
+
import * as path from 'path'
|
|
5
|
+
import { parseA11yResults, generateSummary, generateAnnotations } from './a11y-summary'
|
|
6
|
+
|
|
7
|
+
// A minimal Playwright JSON report with a11y data.
|
|
8
|
+
function makeReport(overrides: {
|
|
9
|
+
violations?: any[]
|
|
10
|
+
annotations?: any[]
|
|
11
|
+
attachments?: any[]
|
|
12
|
+
specTitle?: string
|
|
13
|
+
file?: string
|
|
14
|
+
line?: number
|
|
15
|
+
} = {}) {
|
|
16
|
+
const {
|
|
17
|
+
violations = [],
|
|
18
|
+
annotations = [{ type: '@a11y' }],
|
|
19
|
+
attachments = [],
|
|
20
|
+
specTitle = 'homepage accessibility check',
|
|
21
|
+
file = 'tests/a11y.spec.ts',
|
|
22
|
+
line = 10,
|
|
23
|
+
} = overrides
|
|
24
|
+
|
|
25
|
+
return {
|
|
26
|
+
suites: [{
|
|
27
|
+
title: '',
|
|
28
|
+
file,
|
|
29
|
+
specs: [{
|
|
30
|
+
title: specTitle,
|
|
31
|
+
line,
|
|
32
|
+
tests: [{
|
|
33
|
+
annotations: [],
|
|
34
|
+
results: [{
|
|
35
|
+
// Playwright stores runtime annotations (from testInfo.annotations.push())
|
|
36
|
+
// at the result level, not the test level.
|
|
37
|
+
annotations,
|
|
38
|
+
attachments,
|
|
39
|
+
}],
|
|
40
|
+
}],
|
|
41
|
+
}],
|
|
42
|
+
}],
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function makeWcagAttachment(violations: any[]) {
|
|
47
|
+
const body = Buffer.from(JSON.stringify({
|
|
48
|
+
violations: violations.map(v => ({
|
|
49
|
+
id: v.rule ?? 'color-contrast',
|
|
50
|
+
impact: v.impact ?? 'serious',
|
|
51
|
+
description: v.description ?? 'Elements must have sufficient color contrast',
|
|
52
|
+
helpUrl: v.helpUrl ?? 'https://dequeuniversity.com/rules/axe/4.7/color-contrast',
|
|
53
|
+
nodes: (v.targets ?? ['#main']).map((t: string) => ({ target: [t] })),
|
|
54
|
+
})),
|
|
55
|
+
})).toString('base64')
|
|
56
|
+
|
|
57
|
+
return {
|
|
58
|
+
name: 'a11y-wcag-scan-results',
|
|
59
|
+
body,
|
|
60
|
+
contentType: 'application/json',
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
describe('parseA11yResults', () => {
|
|
65
|
+
let tmpDir: string
|
|
66
|
+
let reportPath: string
|
|
67
|
+
|
|
68
|
+
beforeEach(() => {
|
|
69
|
+
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'a11y-summary-'))
|
|
70
|
+
reportPath = path.join(tmpDir, 'test-report.json')
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
afterEach(() => {
|
|
74
|
+
fs.rmSync(tmpDir, { recursive: true, force: true })
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
it('throws when report file does not exist', () => {
|
|
78
|
+
expect(() => parseA11yResults('/nonexistent/report.json')).toThrow(
|
|
79
|
+
'Playwright JSON report not found',
|
|
80
|
+
)
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
it('returns empty report when no a11y tests exist', () => {
|
|
84
|
+
const report = { suites: [{ specs: [{ tests: [{ annotations: [] }] }] }] }
|
|
85
|
+
fs.writeFileSync(reportPath, JSON.stringify(report))
|
|
86
|
+
const result = parseA11yResults(reportPath)
|
|
87
|
+
expect(result.tests).toHaveLength(0)
|
|
88
|
+
expect(result.totalViolations).toBe(0)
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
it('extracts tests with a11y annotations', () => {
|
|
92
|
+
const report = makeReport({
|
|
93
|
+
annotations: [
|
|
94
|
+
{ type: '@a11y' },
|
|
95
|
+
{ type: 'Accessibility', description: 'WCAG scan: 1 violations (50 rules passed)' },
|
|
96
|
+
],
|
|
97
|
+
attachments: [makeWcagAttachment([{ rule: 'color-contrast', targets: ['#header'] }])],
|
|
98
|
+
})
|
|
99
|
+
fs.writeFileSync(reportPath, JSON.stringify(report))
|
|
100
|
+
const result = parseA11yResults(reportPath)
|
|
101
|
+
expect(result.tests).toHaveLength(1)
|
|
102
|
+
expect(result.tests[0].violations).toHaveLength(1)
|
|
103
|
+
expect(result.tests[0].violations[0].rule).toBe('color-contrast')
|
|
104
|
+
expect(result.totalViolations).toBe(1)
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
it('counts baselined and stale annotations', () => {
|
|
108
|
+
const report = makeReport({
|
|
109
|
+
annotations: [
|
|
110
|
+
{ type: '@a11y' },
|
|
111
|
+
{ type: 'Baselined a11y violation', description: 'color-contrast: known issue' },
|
|
112
|
+
{ type: 'Baselined a11y violation', description: 'link-name: known issue' },
|
|
113
|
+
{ type: 'Stale a11y baseline entry', description: 'image-alt on .hero — no longer detected' },
|
|
114
|
+
],
|
|
115
|
+
})
|
|
116
|
+
fs.writeFileSync(reportPath, JSON.stringify(report))
|
|
117
|
+
const result = parseA11yResults(reportPath)
|
|
118
|
+
expect(result.totalBaselined).toBe(2)
|
|
119
|
+
expect(result.totalStale).toBe(1)
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
it('handles nested suites', () => {
|
|
123
|
+
const report = {
|
|
124
|
+
suites: [{
|
|
125
|
+
title: 'outer',
|
|
126
|
+
file: 'tests/a11y.spec.ts',
|
|
127
|
+
specs: [],
|
|
128
|
+
suites: [{
|
|
129
|
+
title: 'inner',
|
|
130
|
+
specs: [{
|
|
131
|
+
title: 'nested test',
|
|
132
|
+
line: 5,
|
|
133
|
+
tests: [{
|
|
134
|
+
annotations: [{ type: '@a11y' }, { type: 'Accessibility', description: 'passed' }],
|
|
135
|
+
results: [{ attachments: [] }],
|
|
136
|
+
}],
|
|
137
|
+
}],
|
|
138
|
+
}],
|
|
139
|
+
}],
|
|
140
|
+
}
|
|
141
|
+
fs.writeFileSync(reportPath, JSON.stringify(report))
|
|
142
|
+
const result = parseA11yResults(reportPath)
|
|
143
|
+
expect(result.tests).toHaveLength(1)
|
|
144
|
+
expect(result.tests[0].file).toBe('tests/a11y.spec.ts')
|
|
145
|
+
})
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
describe('generateSummary', () => {
|
|
149
|
+
it('shows green check when no violations', () => {
|
|
150
|
+
const md = generateSummary({
|
|
151
|
+
tests: [{
|
|
152
|
+
title: 'passes a11y',
|
|
153
|
+
file: 'tests/a11y.spec.ts',
|
|
154
|
+
line: 10,
|
|
155
|
+
annotations: [{ type: 'Accessibility', description: 'WCAG scan: 0 violations (50 rules passed)' }],
|
|
156
|
+
violations: [],
|
|
157
|
+
}],
|
|
158
|
+
totalViolations: 0,
|
|
159
|
+
totalBaselined: 0,
|
|
160
|
+
totalStale: 0,
|
|
161
|
+
})
|
|
162
|
+
expect(md).toContain(':white_check_mark:')
|
|
163
|
+
expect(md).toContain('All accessibility checks passed')
|
|
164
|
+
})
|
|
165
|
+
|
|
166
|
+
it('shows violation table when violations exist', () => {
|
|
167
|
+
const md = generateSummary({
|
|
168
|
+
tests: [{
|
|
169
|
+
title: 'homepage check',
|
|
170
|
+
file: 'tests/a11y.spec.ts',
|
|
171
|
+
line: 10,
|
|
172
|
+
annotations: [{ type: 'Accessibility', description: 'WCAG scan: 1 violations' }],
|
|
173
|
+
violations: [{
|
|
174
|
+
rule: 'color-contrast',
|
|
175
|
+
impact: 'serious',
|
|
176
|
+
description: 'Elements must have sufficient color contrast',
|
|
177
|
+
helpUrl: 'https://dequeuniversity.com/rules/axe/4.7/color-contrast',
|
|
178
|
+
targets: ['#header'],
|
|
179
|
+
}],
|
|
180
|
+
}],
|
|
181
|
+
totalViolations: 1,
|
|
182
|
+
totalBaselined: 0,
|
|
183
|
+
totalStale: 0,
|
|
184
|
+
})
|
|
185
|
+
expect(md).toContain('**1** violation(s)')
|
|
186
|
+
expect(md).toContain('color-contrast')
|
|
187
|
+
expect(md).toContain('| Rule |')
|
|
188
|
+
expect(md).toContain('`#header`')
|
|
189
|
+
})
|
|
190
|
+
|
|
191
|
+
it('returns message when no tests found', () => {
|
|
192
|
+
const md = generateSummary({ tests: [], totalViolations: 0, totalBaselined: 0, totalStale: 0 })
|
|
193
|
+
expect(md).toContain('No accessibility tests were found')
|
|
194
|
+
})
|
|
195
|
+
|
|
196
|
+
it('shows baselined and stale counts', () => {
|
|
197
|
+
const md = generateSummary({
|
|
198
|
+
tests: [{
|
|
199
|
+
title: 'baseline test',
|
|
200
|
+
file: 'test.ts',
|
|
201
|
+
line: 1,
|
|
202
|
+
annotations: [
|
|
203
|
+
{ type: 'Baselined a11y violation', description: 'known issue' },
|
|
204
|
+
{ type: 'Stale a11y baseline entry', description: 'no longer detected' },
|
|
205
|
+
],
|
|
206
|
+
violations: [],
|
|
207
|
+
}],
|
|
208
|
+
totalViolations: 0,
|
|
209
|
+
totalBaselined: 1,
|
|
210
|
+
totalStale: 1,
|
|
211
|
+
})
|
|
212
|
+
expect(md).toContain('**1** baselined')
|
|
213
|
+
expect(md).toContain('**1** stale baseline entries')
|
|
214
|
+
})
|
|
215
|
+
|
|
216
|
+
})
|
|
217
|
+
|
|
218
|
+
describe('generateAnnotations', () => {
|
|
219
|
+
it('emits ::error for violations', () => {
|
|
220
|
+
const output = generateAnnotations({
|
|
221
|
+
tests: [{
|
|
222
|
+
title: 'test',
|
|
223
|
+
file: 'tests/a11y.spec.ts',
|
|
224
|
+
line: 15,
|
|
225
|
+
annotations: [],
|
|
226
|
+
violations: [{
|
|
227
|
+
rule: 'color-contrast',
|
|
228
|
+
impact: 'serious',
|
|
229
|
+
description: 'Elements must have sufficient color contrast',
|
|
230
|
+
helpUrl: 'https://example.com',
|
|
231
|
+
targets: ['#main'],
|
|
232
|
+
}],
|
|
233
|
+
}],
|
|
234
|
+
totalViolations: 1,
|
|
235
|
+
totalBaselined: 0,
|
|
236
|
+
totalStale: 0,
|
|
237
|
+
})
|
|
238
|
+
expect(output).toContain('::error file=tests/a11y.spec.ts,line=15,title=color-contrast (serious)')
|
|
239
|
+
expect(output).toContain('#main')
|
|
240
|
+
})
|
|
241
|
+
|
|
242
|
+
it('emits ::warning for stale baselines', () => {
|
|
243
|
+
const output = generateAnnotations({
|
|
244
|
+
tests: [{
|
|
245
|
+
title: 'test',
|
|
246
|
+
file: 'tests/a11y.spec.ts',
|
|
247
|
+
line: 10,
|
|
248
|
+
annotations: [{ type: 'Stale a11y baseline entry', description: 'no longer detected' }],
|
|
249
|
+
violations: [],
|
|
250
|
+
}],
|
|
251
|
+
totalViolations: 0,
|
|
252
|
+
totalBaselined: 0,
|
|
253
|
+
totalStale: 1,
|
|
254
|
+
})
|
|
255
|
+
expect(output).toContain('::warning file=tests/a11y.spec.ts,line=10,title=Stale a11y baseline')
|
|
256
|
+
})
|
|
257
|
+
|
|
258
|
+
it('returns empty string when no violations or stale entries', () => {
|
|
259
|
+
const output = generateAnnotations({
|
|
260
|
+
tests: [{
|
|
261
|
+
title: 'test',
|
|
262
|
+
file: 'test.ts',
|
|
263
|
+
line: 1,
|
|
264
|
+
annotations: [{ type: 'Accessibility', description: 'passed' }],
|
|
265
|
+
violations: [],
|
|
266
|
+
}],
|
|
267
|
+
totalViolations: 0,
|
|
268
|
+
totalBaselined: 0,
|
|
269
|
+
totalStale: 0,
|
|
270
|
+
})
|
|
271
|
+
expect(output).toBe('')
|
|
272
|
+
})
|
|
273
|
+
})
|
|
274
|
+
|
|
275
|
+
describe('workflow-command injection hardening', () => {
|
|
276
|
+
// Defence in depth: CSS target selectors are extracted by axe-core from
|
|
277
|
+
// page DOM, so a crafted page could inject workflow-command syntax.
|
|
278
|
+
it('escapes newlines and % in ::error output', () => {
|
|
279
|
+
const output = generateAnnotations({
|
|
280
|
+
tests: [{
|
|
281
|
+
title: 'test',
|
|
282
|
+
file: 'tests/a11y.spec.ts',
|
|
283
|
+
line: 15,
|
|
284
|
+
annotations: [],
|
|
285
|
+
violations: [{
|
|
286
|
+
rule: 'color-contrast',
|
|
287
|
+
impact: 'serious',
|
|
288
|
+
description: 'bad\ndescription with 100%',
|
|
289
|
+
helpUrl: 'https://example.com',
|
|
290
|
+
targets: ['#evil\n::error title=pwned::gotcha'],
|
|
291
|
+
}],
|
|
292
|
+
}],
|
|
293
|
+
totalViolations: 1,
|
|
294
|
+
totalBaselined: 0,
|
|
295
|
+
totalStale: 0,
|
|
296
|
+
})
|
|
297
|
+
// No raw newlines or literal '%' characters escape into the message body.
|
|
298
|
+
expect(output.split('\n')).toHaveLength(1)
|
|
299
|
+
expect(output).toContain('%0A')
|
|
300
|
+
expect(output).toContain('%25')
|
|
301
|
+
// The forged `::error` must not appear as a standalone command.
|
|
302
|
+
expect(output).not.toMatch(/\n::error title=pwned/)
|
|
303
|
+
})
|
|
304
|
+
|
|
305
|
+
it('escapes : and , in property values', () => {
|
|
306
|
+
const output = generateAnnotations({
|
|
307
|
+
tests: [{
|
|
308
|
+
title: 'test',
|
|
309
|
+
file: 'tests/with,comma:colon.spec.ts',
|
|
310
|
+
line: 1,
|
|
311
|
+
annotations: [],
|
|
312
|
+
violations: [{
|
|
313
|
+
rule: 'color-contrast',
|
|
314
|
+
impact: 'serious',
|
|
315
|
+
description: 'ok',
|
|
316
|
+
helpUrl: 'https://example.com',
|
|
317
|
+
targets: ['#main'],
|
|
318
|
+
}],
|
|
319
|
+
}],
|
|
320
|
+
totalViolations: 1,
|
|
321
|
+
totalBaselined: 0,
|
|
322
|
+
totalStale: 0,
|
|
323
|
+
})
|
|
324
|
+
expect(output).toContain('file=tests/with%2Ccomma%3Acolon.spec.ts')
|
|
325
|
+
})
|
|
326
|
+
|
|
327
|
+
it('escapes stale baseline warning content', () => {
|
|
328
|
+
const output = generateAnnotations({
|
|
329
|
+
tests: [{
|
|
330
|
+
title: 'test',
|
|
331
|
+
file: 'tests/a11y.spec.ts',
|
|
332
|
+
line: 10,
|
|
333
|
+
annotations: [{ type: 'Stale a11y baseline entry', description: 'stale\n::error::forged' }],
|
|
334
|
+
violations: [],
|
|
335
|
+
}],
|
|
336
|
+
totalViolations: 0,
|
|
337
|
+
totalBaselined: 0,
|
|
338
|
+
totalStale: 1,
|
|
339
|
+
})
|
|
340
|
+
expect(output.split('\n')).toHaveLength(1)
|
|
341
|
+
expect(output).toContain('%0A')
|
|
342
|
+
})
|
|
343
|
+
})
|
|
344
|
+
|
|
345
|
+
describe('markdown escaping in the summary table', () => {
|
|
346
|
+
it('escapes pipes and newlines in description cells', () => {
|
|
347
|
+
const md = generateSummary({
|
|
348
|
+
tests: [{
|
|
349
|
+
title: 'homepage',
|
|
350
|
+
file: 'tests/a11y.spec.ts',
|
|
351
|
+
line: 10,
|
|
352
|
+
annotations: [],
|
|
353
|
+
violations: [{
|
|
354
|
+
rule: 'color-contrast',
|
|
355
|
+
impact: 'serious',
|
|
356
|
+
description: 'pipe | in | description\nand a newline',
|
|
357
|
+
helpUrl: 'https://example.com',
|
|
358
|
+
targets: ['#main'],
|
|
359
|
+
}],
|
|
360
|
+
}],
|
|
361
|
+
totalViolations: 1,
|
|
362
|
+
totalBaselined: 0,
|
|
363
|
+
totalStale: 0,
|
|
364
|
+
})
|
|
365
|
+
// Find the row line — the one that starts with `| [color-contrast]`.
|
|
366
|
+
const row = md.split('\n').find(l => l.startsWith('| [color-contrast]'))
|
|
367
|
+
expect(row).toBeDefined()
|
|
368
|
+
// Row should still have exactly 5 pipe-separated segments (4 columns + trailing).
|
|
369
|
+
expect(row!.split(' | ').length).toBe(4)
|
|
370
|
+
expect(row).toContain('\\|')
|
|
371
|
+
// No literal newline inside the cell.
|
|
372
|
+
expect(row).not.toMatch(/\n/)
|
|
373
|
+
})
|
|
374
|
+
|
|
375
|
+
it('escapes backticks in target code spans', () => {
|
|
376
|
+
const md = generateSummary({
|
|
377
|
+
tests: [{
|
|
378
|
+
title: 'homepage',
|
|
379
|
+
file: 'tests/a11y.spec.ts',
|
|
380
|
+
line: 10,
|
|
381
|
+
annotations: [],
|
|
382
|
+
violations: [{
|
|
383
|
+
rule: 'color-contrast',
|
|
384
|
+
impact: 'serious',
|
|
385
|
+
description: 'bad',
|
|
386
|
+
helpUrl: 'https://example.com',
|
|
387
|
+
// A target containing a backtick — would escape a naive `${t}` code span.
|
|
388
|
+
targets: ['#foo`span'],
|
|
389
|
+
}],
|
|
390
|
+
}],
|
|
391
|
+
totalViolations: 1,
|
|
392
|
+
totalBaselined: 0,
|
|
393
|
+
totalStale: 0,
|
|
394
|
+
})
|
|
395
|
+
// The raw selector must not appear wrapped in single backticks
|
|
396
|
+
// (which would be the unescaped form).
|
|
397
|
+
expect(md).not.toMatch(/`#foo`span`/)
|
|
398
|
+
// It should be wrapped in double backticks with padding instead.
|
|
399
|
+
expect(md).toContain('`` #foo`span ``')
|
|
400
|
+
})
|
|
401
|
+
})
|
|
402
|
+
|
|
403
|
+
describe('main() error handling', () => {
|
|
404
|
+
let tmpDir: string
|
|
405
|
+
let origCwd: string
|
|
406
|
+
let origSummary: string | undefined
|
|
407
|
+
|
|
408
|
+
beforeEach(() => {
|
|
409
|
+
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'a11y-main-'))
|
|
410
|
+
origCwd = process.cwd()
|
|
411
|
+
origSummary = process.env.GITHUB_STEP_SUMMARY
|
|
412
|
+
delete process.env.GITHUB_STEP_SUMMARY
|
|
413
|
+
process.chdir(tmpDir)
|
|
414
|
+
})
|
|
415
|
+
|
|
416
|
+
afterEach(() => {
|
|
417
|
+
process.chdir(origCwd)
|
|
418
|
+
if (origSummary !== undefined) {
|
|
419
|
+
process.env.GITHUB_STEP_SUMMARY = origSummary
|
|
420
|
+
}
|
|
421
|
+
fs.rmSync(tmpDir, { recursive: true, force: true })
|
|
422
|
+
})
|
|
423
|
+
|
|
424
|
+
it('returns without throwing when the report is missing', async () => {
|
|
425
|
+
const { main } = await import('./a11y-summary')
|
|
426
|
+
expect(() =>
|
|
427
|
+
main(['--mode=summary', '--report-path=nonexistent.json']),
|
|
428
|
+
).not.toThrow()
|
|
429
|
+
})
|
|
430
|
+
|
|
431
|
+
it('propagates parse errors so CI surfaces real problems', async () => {
|
|
432
|
+
const bad = path.join(tmpDir, 'bad.json')
|
|
433
|
+
fs.writeFileSync(bad, '{ this is not json')
|
|
434
|
+
const { main } = await import('./a11y-summary')
|
|
435
|
+
expect(() =>
|
|
436
|
+
main(['--mode=summary', `--report-path=${bad}`]),
|
|
437
|
+
).toThrow()
|
|
438
|
+
})
|
|
439
|
+
})
|