@wcag-checkr/ci 1.0.0-rc.13

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 (50) hide show
  1. package/README.md +135 -0
  2. package/dist/assets/ErrorBoundary-BPz4qckm.js +524 -0
  3. package/dist/assets/_commonjsHelpers-Cpj98o6Y.js +1 -0
  4. package/dist/assets/ai-usage-log-DFkwAfmW.js +1 -0
  5. package/dist/assets/content-script.ts-D7yXcBUr.js +181 -0
  6. package/dist/assets/content-script.ts-loader-Cn8Y9Xod.js +13 -0
  7. package/dist/assets/crash-reporter-wxu43qbG.js +4 -0
  8. package/dist/assets/devtools-panel-D2fL4guz.js +1 -0
  9. package/dist/assets/devtools.html-DQBohI9U.js +1 -0
  10. package/dist/assets/diff-D4sCAdXf.js +1 -0
  11. package/dist/assets/forensic-log-B3iX62mE.js +129 -0
  12. package/dist/assets/main-CqDdt0Iq.js +6 -0
  13. package/dist/assets/main-DyQfCbPM.js +1 -0
  14. package/dist/assets/modulepreload-polyfill-B5Qt9EMX.js +1 -0
  15. package/dist/assets/options.html-jfjpxZBp.js +1 -0
  16. package/dist/assets/preload-helper-D7HrI6pR.js +1 -0
  17. package/dist/assets/reflow-analyzer-DNgBX8N_.js +1 -0
  18. package/dist/assets/service-worker.ts-DaHvU8nE.js +715 -0
  19. package/dist/assets/side-panel.html-DW1tssqQ.js +1 -0
  20. package/dist/assets/site-report-renderer-JH44v2hK.js +147 -0
  21. package/dist/assets/state-DnzwwNxZ.js +1 -0
  22. package/dist/assets/styles-DP9v_aMy.css +1 -0
  23. package/dist/assets/styles-kHMb1Lda.js +84 -0
  24. package/dist/devtools/devtools.html +11 -0
  25. package/dist/devtools/panel.html +20 -0
  26. package/dist/fonts/mona-sans-variable.woff2 +0 -0
  27. package/dist/icons/icon-128.png +0 -0
  28. package/dist/icons/icon-16.png +0 -0
  29. package/dist/icons/icon-32.png +0 -0
  30. package/dist/icons/icon-48.png +0 -0
  31. package/dist/manifest.json +70 -0
  32. package/dist/options/options.html +19 -0
  33. package/dist/service-worker-loader.js +1 -0
  34. package/dist/side-panel/App.tsx +174 -0
  35. package/dist/side-panel/README.md +57 -0
  36. package/dist/side-panel/audit-launcher.test.ts +56 -0
  37. package/dist/side-panel/audit-launcher.ts +65 -0
  38. package/dist/side-panel/format-component-id.test.ts +89 -0
  39. package/dist/side-panel/format-component-id.ts +40 -0
  40. package/dist/side-panel/github-issue.test.ts +102 -0
  41. package/dist/side-panel/github-issue.ts +66 -0
  42. package/dist/side-panel/jira-issue.ts +64 -0
  43. package/dist/side-panel/main.tsx +19 -0
  44. package/dist/side-panel/side-panel.html +21 -0
  45. package/dist/side-panel/store.ts +264 -0
  46. package/dist/side-panel/styles.css +16 -0
  47. package/dist/side-panel/wire-messaging.test.ts +202 -0
  48. package/dist/side-panel/wire-messaging.ts +285 -0
  49. package/package.json +39 -0
  50. package/wcagcheckr-ci.mjs +559 -0
@@ -0,0 +1,202 @@
1
+ import { describe, it, expect, beforeEach } from 'vitest';
2
+ import { installChromeShim, type ChromeShim } from '../shared/test-helpers/chrome-shim';
3
+
4
+ const shim: ChromeShim = installChromeShim();
5
+
6
+ import {
7
+ loadLastAudit,
8
+ clearLastAudit,
9
+ refreshBaselineList,
10
+ refreshLicenseTier,
11
+ startKeepalive,
12
+ wireMessaging,
13
+ } from './wire-messaging';
14
+ import { useStore } from './store';
15
+ import { on } from '../shared/messaging';
16
+ import type { AuditResult, DeltaResult } from '../types/audit';
17
+
18
+ const sampleResult: AuditResult = {
19
+ componentId: '::story:atoms-button--primary',
20
+ scope: '#root',
21
+ state: {
22
+ pseudoState: 'default',
23
+ ariaVariation: null,
24
+ theme: 'light',
25
+ direction: 'ltr',
26
+ breakpoint: {
27
+ id: 'desktop',
28
+ label: 'Desktop',
29
+ width: 1280,
30
+ height: 800,
31
+ deviceScaleFactor: 1,
32
+ mobile: false,
33
+ },
34
+ },
35
+ violations: [],
36
+ passes: 1,
37
+ incomplete: 0,
38
+ inapplicable: 0,
39
+ axeVersion: '4.11.4',
40
+ startedAt: '2026-04-30T00:00:00Z',
41
+ durationMs: 100,
42
+ };
43
+
44
+ const sampleDelta: DeltaResult = {
45
+ new: [],
46
+ persistent: [],
47
+ fixed: [],
48
+ newCount: 0,
49
+ persistentCount: 0,
50
+ fixedCount: 0,
51
+ baselineSnapshotMeta: null,
52
+ comparedAt: '2026-04-30T00:00:00Z',
53
+ };
54
+
55
+ describe('wire-messaging', () => {
56
+ beforeEach(() => {
57
+ shim.reset();
58
+ useStore.setState({
59
+ status: 'idle',
60
+ progress: null,
61
+ results: [],
62
+ delta: null,
63
+ componentId: null,
64
+ errorMessage: null,
65
+ baselineList: [],
66
+ tier: 'trial',
67
+ view: 'matrix',
68
+ });
69
+ });
70
+
71
+ it('AUDIT_PROGRESS_EVENT updates store progress + status', () => {
72
+ const off = wireMessaging();
73
+ // Simulate event broadcast.
74
+ shim.listeners.forEach((fn) =>
75
+ fn(
76
+ {
77
+ type: 'AUDIT_PROGRESS_EVENT',
78
+ current: 3,
79
+ total: 8,
80
+ currentState: sampleResult.state,
81
+ },
82
+ {},
83
+ () => {}
84
+ )
85
+ );
86
+ const s = useStore.getState();
87
+ expect(s.status).toBe('running');
88
+ expect(s.progress).toEqual({
89
+ current: 3,
90
+ total: 8,
91
+ currentState: sampleResult.state,
92
+ });
93
+ off();
94
+ });
95
+
96
+ it('AUDIT_COMPLETE_EVENT writes results + persists to chrome.storage', async () => {
97
+ const off = wireMessaging();
98
+ shim.listeners.forEach((fn) =>
99
+ fn(
100
+ {
101
+ type: 'AUDIT_COMPLETE_EVENT',
102
+ componentId: '::story:atoms-button--primary',
103
+ results: [sampleResult],
104
+ delta: sampleDelta,
105
+ },
106
+ {},
107
+ () => {}
108
+ )
109
+ );
110
+ const s = useStore.getState();
111
+ expect(s.status).toBe('complete');
112
+ expect(s.componentId).toBe('::story:atoms-button--primary');
113
+ // Wait one microtask for the async persist call.
114
+ await new Promise((r) => setTimeout(r, 0));
115
+ expect(shim.storage.has('sidePanel:lastAudit')).toBe(true);
116
+ off();
117
+ });
118
+
119
+ it('AUDIT_FAILED_EVENT sets error state', () => {
120
+ const off = wireMessaging();
121
+ shim.listeners.forEach((fn) =>
122
+ fn(
123
+ {
124
+ type: 'AUDIT_FAILED_EVENT',
125
+ error: { code: 'AXE_FAILED', message: 'boom', recoverable: false },
126
+ },
127
+ {},
128
+ () => {}
129
+ )
130
+ );
131
+ const s = useStore.getState();
132
+ expect(s.status).toBe('failed');
133
+ expect(s.errorMessage).toBe('boom');
134
+ off();
135
+ });
136
+
137
+ it('LICENSE_CHANGED_EVENT triggers refreshLicenseTier', async () => {
138
+ // Register a handler that responds to TIER_GET so refreshLicenseTier resolves.
139
+ on('TIER_GET', () => ({
140
+ type: 'TIER_GET_RESPONSE',
141
+ tier: 'solo',
142
+ }));
143
+
144
+ const off = wireMessaging();
145
+ shim.listeners.forEach((fn) =>
146
+ fn({ type: 'LICENSE_CHANGED_EVENT', tier: 'solo' }, {}, () => {})
147
+ );
148
+ // Allow the chained promise to resolve.
149
+ await new Promise((r) => setTimeout(r, 5));
150
+ expect(useStore.getState().tier).toBe('solo');
151
+ off();
152
+ });
153
+
154
+ it('loadLastAudit hydrates the store from chrome.storage', async () => {
155
+ shim.storage.set('sidePanel:lastAudit', {
156
+ results: [sampleResult],
157
+ delta: sampleDelta,
158
+ componentId: '::story:atoms-button--primary',
159
+ });
160
+ await loadLastAudit();
161
+ const s = useStore.getState();
162
+ expect(s.status).toBe('complete');
163
+ expect(s.componentId).toBe('::story:atoms-button--primary');
164
+ expect(s.results).toHaveLength(1);
165
+ });
166
+
167
+ it('loadLastAudit is a no-op when no marker exists', async () => {
168
+ await loadLastAudit();
169
+ expect(useStore.getState().status).toBe('idle');
170
+ expect(useStore.getState().results).toEqual([]);
171
+ });
172
+
173
+ it('clearLastAudit removes the persisted marker', async () => {
174
+ shim.storage.set('sidePanel:lastAudit', { results: [], delta: null, componentId: null });
175
+ await clearLastAudit();
176
+ expect(shim.storage.has('sidePanel:lastAudit')).toBe(false);
177
+ });
178
+
179
+ it('startKeepalive returns a port and registers it with the shim', () => {
180
+ const port = startKeepalive();
181
+ expect(port.name).toBe('audit-keepalive');
182
+ expect(shim.ports.find((p) => p.name === 'audit-keepalive')).toBeTruthy();
183
+ });
184
+
185
+ it('refreshBaselineList writes items into the store', async () => {
186
+ on('BASELINE_LIST', () => ({
187
+ type: 'BASELINE_LIST_RESPONSE',
188
+ items: [
189
+ { componentId: '::story:a', violationCount: 2, lastUpdated: '2026-04-30T00:00:00Z' },
190
+ ],
191
+ }));
192
+ await refreshBaselineList();
193
+ expect(useStore.getState().baselineList).toHaveLength(1);
194
+ expect(useStore.getState().baselineList[0]?.componentId).toBe('::story:a');
195
+ });
196
+
197
+ it('refreshLicenseTier writes tier from TIER_GET response', async () => {
198
+ on('TIER_GET', () => ({ type: 'TIER_GET_RESPONSE', tier: 'team' }));
199
+ await refreshLicenseTier();
200
+ expect(useStore.getState().tier).toBe('team');
201
+ });
202
+ });
@@ -0,0 +1,285 @@
1
+ // Subscribe Zustand store to messaging events. Open keepalive port to SW.
2
+ // Also handles last-audit persistence so closing/reopening the side panel preserves results.
3
+
4
+ import { on, request } from '../shared/messaging';
5
+ import { useStore } from './store';
6
+ import { announcer } from '../shared/announcer';
7
+ import { getAuditTargetTabId } from '../shared/active-tab';
8
+ import type { BaselineListResponse, SettingsResponse, TierGetResponse } from '../shared/messages';
9
+ import type { UserMode } from './store';
10
+ import type { AuditResult, DeltaResult } from '../types/audit';
11
+
12
+ const LAST_AUDIT_KEY = 'sidePanel:lastAudit';
13
+
14
+ type PersistedAudit = {
15
+ results: AuditResult[];
16
+ delta: DeltaResult | null;
17
+ componentId: string | null;
18
+ };
19
+
20
+ export function wireMessaging(): () => void {
21
+ const offs: Array<() => void> = [];
22
+
23
+ offs.push(
24
+ on('AUDIT_PROGRESS_EVENT', (msg) => {
25
+ const wasRunning = useStore.getState().status === 'running';
26
+ useStore.getState().setProgress({
27
+ current: msg.current,
28
+ total: msg.total,
29
+ currentState: msg.currentState,
30
+ });
31
+ useStore.getState().setStatus('running');
32
+ // Announce only at the start, not on every state — otherwise SR users
33
+ // get spammed once per matrix iteration.
34
+ if (!wasRunning) {
35
+ announcer.polite(`Audit running, scanning ${msg.total} state${msg.total === 1 ? '' : 's'}.`);
36
+ }
37
+ })
38
+ );
39
+
40
+ offs.push(
41
+ on('AUDIT_COMPLETE_EVENT', (msg) => {
42
+ useStore.getState().setResults(msg.results, msg.delta, msg.componentId);
43
+ const violationCount = msg.results.reduce((acc, r) => acc + r.violations.length, 0);
44
+ const newCount = msg.delta?.newCount ?? 0;
45
+ const summary = msg.delta
46
+ ? `Audit complete. ${newCount} new violation${newCount === 1 ? '' : 's'} versus baseline.`
47
+ : `Audit complete. ${violationCount} violation${violationCount === 1 ? '' : 's'} found across ${msg.results.length} state${msg.results.length === 1 ? '' : 's'}.`;
48
+ announcer.polite(summary);
49
+ void persistLastAudit({
50
+ results: msg.results,
51
+ delta: msg.delta,
52
+ componentId: msg.componentId,
53
+ });
54
+ })
55
+ );
56
+
57
+ offs.push(
58
+ on('AUDIT_FAILED_EVENT', (msg) => {
59
+ // ErrorBanner has role="alert" (implicit assertive live region) so the SR
60
+ // announcement fires when the banner mounts. No need for an extra
61
+ // announcer.assertive — that would double-speak.
62
+ useStore.getState().setError(msg.error.message);
63
+ })
64
+ );
65
+
66
+ offs.push(
67
+ on('AI_AUGMENTATION_PROGRESS_EVENT', (msg) => {
68
+ const prev = useStore.getState().aiProgress;
69
+ useStore.getState().setAiProgress({
70
+ currentCheckLabel: msg.currentCheckLabel,
71
+ current: msg.current,
72
+ total: msg.total,
73
+ });
74
+ // Announce only on the first AI check so SR users hear the phase
75
+ // transition once, not 8 times.
76
+ if (!prev) {
77
+ announcer.polite(`AI augmentation running ${msg.total} check${msg.total === 1 ? '' : 's'}.`);
78
+ }
79
+ })
80
+ );
81
+
82
+ offs.push(
83
+ on('AI_AUGMENTATION_FAILED_EVENT', (msg) => {
84
+ useStore.getState().setAiFailure({
85
+ severity: msg.severity,
86
+ reason: msg.reason,
87
+ checksAttempted: msg.checksAttempted,
88
+ checksSucceeded: msg.checksSucceeded,
89
+ checksErrored: msg.checksErrored,
90
+ errorDetails: msg.errorDetails,
91
+ });
92
+ // The banner has role="alert" — the SR announcement fires on mount.
93
+ // Don't also call announcer.assertive (would double-speak).
94
+ })
95
+ );
96
+
97
+ offs.push(
98
+ on('SCORECARD_UPDATED_EVENT', () => {
99
+ void refreshBaselineList();
100
+ })
101
+ );
102
+
103
+ // The license tier can change when the user activates a new token in another context;
104
+ // refresh on broadcast so the tier badge is current.
105
+ offs.push(
106
+ on('LICENSE_CHANGED_EVENT', () => {
107
+ void refreshLicenseTier();
108
+ })
109
+ );
110
+
111
+ // ─── Site crawl events ─────────────────────────────────────────────────
112
+ offs.push(
113
+ on('SITE_CRAWL_PROGRESS_EVENT', (msg) => {
114
+ const s = useStore.getState();
115
+ s.setSiteCrawlStatus('running');
116
+ s.setSiteCrawlProgress({
117
+ current: msg.current,
118
+ total: msg.total,
119
+ url: msg.url,
120
+ lastViolations: msg.violations,
121
+ });
122
+ if (msg.status === 'auditing' && msg.current === 1) {
123
+ announcer.polite(`Site crawl started, scanning up to ${msg.total} pages.`);
124
+ }
125
+ })
126
+ );
127
+
128
+ offs.push(
129
+ on('SITE_CRAWL_COMPLETE_EVENT', (msg) => {
130
+ const s = useStore.getState();
131
+ s.setSiteCrawlStatus('complete');
132
+ s.setSiteCrawlReport(msg.report);
133
+ s.setSiteCrawlProgress(null);
134
+ announcer.polite(
135
+ `Site crawl complete. Grade ${msg.report.siteGrade}, ${msg.report.totalUniqueViolations} unique violation${msg.report.totalUniqueViolations === 1 ? '' : 's'}.`
136
+ );
137
+ })
138
+ );
139
+
140
+ offs.push(
141
+ on('SITE_CRAWL_FAILED_EVENT', (msg) => {
142
+ const s = useStore.getState();
143
+ s.setSiteCrawlStatus('failed');
144
+ s.setSiteCrawlError(msg.error.message);
145
+ s.setSiteCrawlProgress(null);
146
+ announcer.assertive(`Site crawl failed: ${msg.error.message}`);
147
+ })
148
+ );
149
+
150
+ return () => offs.forEach((off) => off());
151
+ }
152
+
153
+ async function persistLastAudit(payload: PersistedAudit): Promise<void> {
154
+ // Strip screenshotDataUrl from each result before persisting. With a full
155
+ // state matrix (up to 144 states), 144 base64 JPEGs at ~50-200KB each blow
156
+ // chrome.storage.local's 10MB MV3 quota — manifestly observed in panel
157
+ // unhandled rejections with "Resource::kQuotaBytes quota exceeded".
158
+ //
159
+ // Trade-off: screenshots remain in-memory for the current panel session
160
+ // (export bundles run in this session still have them). After a panel
161
+ // reopen / SW restart, violations + delta + componentId are restored but
162
+ // screenshots aren't — the user would need to re-audit to regenerate them.
163
+ const stripped: PersistedAudit = {
164
+ ...payload,
165
+ results: payload.results.map(({ screenshotDataUrl: _drop, ...rest }) => rest),
166
+ };
167
+ try {
168
+ await chrome.storage.local.set({ [LAST_AUDIT_KEY]: stripped });
169
+ } catch (err) {
170
+ // Even stripped, a very-large violation set could still exceed quota.
171
+ // Better to leave the in-memory results intact than crash the panel on
172
+ // a non-essential persistence step.
173
+ console.warn('[wire-messaging] persistLastAudit failed; results stay in-memory only', err);
174
+ }
175
+ }
176
+
177
+ export async function loadLastAudit(): Promise<void> {
178
+ const r = await chrome.storage.local.get(LAST_AUDIT_KEY);
179
+ const last = r[LAST_AUDIT_KEY] as PersistedAudit | undefined;
180
+ if (!last) return;
181
+ useStore.setState({
182
+ results: last.results,
183
+ delta: last.delta,
184
+ componentId: last.componentId,
185
+ status: 'complete',
186
+ });
187
+ }
188
+
189
+ export async function clearLastAudit(): Promise<void> {
190
+ await chrome.storage.local.remove(LAST_AUDIT_KEY);
191
+ }
192
+
193
+ /** Wipe results from store AND from the persisted last-audit cache. Single
194
+ * helper so both the user-triggered Clear button and the navigation auto-
195
+ * clear path stay in sync. */
196
+ export async function clearAudit(): Promise<void> {
197
+ useStore.getState().clearResults();
198
+ await clearLastAudit();
199
+ }
200
+
201
+ /** Strip the URL fragment so anchor-only changes (#section) don't read as
202
+ * navigation. Path + querystring + host all still count. */
203
+ function normalizeUrl(u: string): string {
204
+ try {
205
+ const url = new URL(u);
206
+ url.hash = '';
207
+ return url.toString();
208
+ } catch {
209
+ return u;
210
+ }
211
+ }
212
+
213
+ /** If the currently-loaded audit's pageUrl no longer matches the audit target
214
+ * tab's URL, clear the audit. Called on side-panel mount AND whenever the
215
+ * target tab navigates or the active tab changes (see App.tsx). */
216
+ export async function clearIfTargetUrlChanged(): Promise<void> {
217
+ const { results } = useStore.getState();
218
+ if (results.length === 0) return;
219
+ const audited = results[0]?.pageUrl;
220
+ if (!audited) return; // legacy result without pageUrl — leave alone
221
+
222
+ const tabId = await getAuditTargetTabId();
223
+ if (!tabId) {
224
+ // No usable target tab anymore (e.g. only chrome:// pages open) — treat
225
+ // as a navigation away from the audited page.
226
+ await clearAudit();
227
+ return;
228
+ }
229
+ try {
230
+ const tab = await chrome.tabs.get(tabId);
231
+ if (normalizeUrl(tab.url ?? '') !== normalizeUrl(audited)) {
232
+ await clearAudit();
233
+ }
234
+ } catch {
235
+ // tab gone — also stale
236
+ await clearAudit();
237
+ }
238
+ }
239
+
240
+ export async function refreshBaselineList(): Promise<void> {
241
+ const res = await request<BaselineListResponse>({ type: 'BASELINE_LIST' });
242
+ useStore.getState().setBaselineList(res.items);
243
+ }
244
+
245
+ export async function refreshLicenseTier(opts?: { forceRefresh?: boolean }): Promise<void> {
246
+ const res = await request<TierGetResponse>({
247
+ type: 'TIER_GET',
248
+ forceRefresh: opts?.forceRefresh === true,
249
+ });
250
+ useStore.getState().setTier(res.tier, {
251
+ trialDaysRemaining: res.trialDaysRemaining,
252
+ seatsUsed: res.seatsUsed,
253
+ seatsTotal: res.seatsTotal,
254
+ planCode: res.planCode,
255
+ licenseDaysRemaining: res.licenseDaysRemaining,
256
+ pastDue: res.pastDue,
257
+ });
258
+ // Fold the in-app messages refresh into the same wake-up so the bell
259
+ // stays in sync with the license-validate cadence. Failures are silent
260
+ // — the bell just stays in its last-known state.
261
+ void refreshInAppMessages();
262
+ }
263
+
264
+ export async function refreshInAppMessages(): Promise<void> {
265
+ // Dynamic import keeps the messages-client out of the initial chunk —
266
+ // bell content is only ever painted after the panel mounts.
267
+ const { fetchMessages } = await import('../modules/messages-client');
268
+ const mode = useStore.getState().userMode;
269
+ const res = await fetchMessages(mode);
270
+ if (!res) return;
271
+ useStore.getState().setMessages(res.messages, res.unreadCount, res.criticalUnacked);
272
+ }
273
+
274
+ /** Load the user's persona setting. Returns null when not yet set
275
+ * (first-launch — App will render the wizard). */
276
+ export async function refreshUserMode(): Promise<void> {
277
+ const res = await request<SettingsResponse>({ type: 'SETTINGS_GET', key: 'userMode' });
278
+ const v = res.data;
279
+ const mode: UserMode | null = v === 'owner' || v === 'dev' ? v : null;
280
+ useStore.getState().setUserMode(mode);
281
+ }
282
+
283
+ export function startKeepalive(): chrome.runtime.Port {
284
+ return chrome.runtime.connect({ name: 'audit-keepalive' });
285
+ }
package/package.json ADDED
@@ -0,0 +1,39 @@
1
+ {
2
+ "name": "@wcag-checkr/ci",
3
+ "version": "1.0.0-rc.13",
4
+ "private": false,
5
+ "description": "Headless wcagcheckr accessibility audit runner for CI/CD pipelines. Drives the wcagcheckr Chrome extension via Playwright, runs full-page audits across the state matrix (108 combinations: hover, focus, dark mode, RTL, breakpoints), outputs JSON / SARIF / JUnit, exits with severity-aware codes.",
6
+ "license": "UNLICENSED",
7
+ "type": "module",
8
+ "main": "wcagcheckr-ci.mjs",
9
+ "bin": {
10
+ "wcagcheckr-ci": "wcagcheckr-ci.mjs"
11
+ },
12
+ "files": [
13
+ "wcagcheckr-ci.mjs",
14
+ "README.md",
15
+ "dist/**"
16
+ ],
17
+ "scripts": {
18
+ "prepack": "node ../scripts/prepare-cli-publish.mjs"
19
+ },
20
+ "engines": {
21
+ "node": ">=20.18"
22
+ },
23
+ "dependencies": {
24
+ "playwright": "^1.59.1"
25
+ },
26
+ "homepage": "https://wcagcheckr.com",
27
+ "keywords": [
28
+ "accessibility",
29
+ "a11y",
30
+ "wcag",
31
+ "audit",
32
+ "ci",
33
+ "axe",
34
+ "chrome-extension",
35
+ "playwright",
36
+ "sarif",
37
+ "junit"
38
+ ]
39
+ }