@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,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
+ })