@wcag-checkr/ci 1.0.0-rc.366 → 1.0.0-rc.367
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/dist/assets/{ErrorBoundary-BH1wbHb3.js → ErrorBoundary-Ck0XMUiQ.js} +22 -22
- package/dist/assets/{copy-ai-fixer-prompt-FO0N3IMf.js → copy-ai-fixer-prompt-CKp52Td1.js} +2 -2
- package/dist/assets/{devtools-panel-BAHfgRsc.js → devtools-panel-Dw0aFhIc.js} +1 -1
- package/dist/assets/{parallel-tab-flow-C4bjrDqL.js → parallel-tab-flow-BuZZIdRb.js} +1 -1
- package/dist/assets/{scheduled-audit-runner-XCDyFhY6.js → scheduled-audit-runner-9R-aGzwO.js} +137 -137
- package/dist/assets/{service-worker.ts-57FwiS_-.js → service-worker.ts-CyL2aQvI.js} +2 -2
- package/dist/assets/{side-panel-Dl247dJv.js → side-panel-CuEUwRBD.js} +3 -3
- package/dist/assets/{site-report-renderer-CNWZjbOd.js → site-report-renderer-46_JyAkB.js} +1 -1
- package/dist/devtools/panel.html +3 -3
- package/dist/manifest.json +3 -4
- package/dist/service-worker-loader.js +1 -1
- package/dist/side-panel/side-panel.html +4 -4
- package/package.json +1 -1
- package/dist/side-panel/App.tsx +0 -308
- package/dist/side-panel/README.md +0 -57
- package/dist/side-panel/audit-launcher.test.ts +0 -56
- package/dist/side-panel/audit-launcher.ts +0 -98
- package/dist/side-panel/azure-devops-issue.test.ts +0 -68
- package/dist/side-panel/azure-devops-issue.ts +0 -89
- package/dist/side-panel/format-component-id.test.ts +0 -89
- package/dist/side-panel/format-component-id.ts +0 -40
- package/dist/side-panel/github-issue.test.ts +0 -102
- package/dist/side-panel/github-issue.ts +0 -66
- package/dist/side-panel/gitlab-issue.test.ts +0 -53
- package/dist/side-panel/gitlab-issue.ts +0 -78
- package/dist/side-panel/jira-issue.ts +0 -64
- package/dist/side-panel/main.tsx +0 -86
- package/dist/side-panel/store.ts +0 -435
- package/dist/side-panel/styles.css +0 -55
- package/dist/side-panel/use-audit-view-model.ts +0 -116
- package/dist/side-panel/wire-messaging.test.ts +0 -234
- package/dist/side-panel/wire-messaging.ts +0 -521
|
@@ -1,89 +0,0 @@
|
|
|
1
|
-
// rc.139 — G9 — Azure DevOps work-item pre-fill URL builder.
|
|
2
|
-
//
|
|
3
|
-
// Mirrors the GitHub + Jira filer pattern: take a base URL (Azure
|
|
4
|
-
// DevOps org+project), the audited component id, and the list of NEW
|
|
5
|
-
// violations; return a clickable URL pre-filled with a structured
|
|
6
|
-
// title + description so the user lands on a ready-to-save work item.
|
|
7
|
-
//
|
|
8
|
-
// Azure DevOps create-work-item URL pattern (Cloud + Server):
|
|
9
|
-
// https://dev.azure.com/{org}/{project}/_workitems/create/{type}
|
|
10
|
-
// ?[Title]=...&[Description]=...
|
|
11
|
-
//
|
|
12
|
-
// Description is markdown — Azure DevOps' work-item editor renders
|
|
13
|
-
// markdown in the description field as of 2023. Falls back to plain
|
|
14
|
-
// text for older Server installs.
|
|
15
|
-
|
|
16
|
-
import type { Violation } from '../types/audit';
|
|
17
|
-
|
|
18
|
-
export type AzureDevOpsIssueBuild = { url: string; title: string; description: string };
|
|
19
|
-
|
|
20
|
-
const URL_LIMIT = 7800;
|
|
21
|
-
|
|
22
|
-
export function buildAzureDevOpsIssueUrl(
|
|
23
|
-
/** Base URL — e.g. `https://dev.azure.com/myorg/myproject`. */
|
|
24
|
-
baseUrl: string,
|
|
25
|
-
componentId: string | null,
|
|
26
|
-
newViolations: Violation[],
|
|
27
|
-
/** Work-item type. Default 'Bug' fits accessibility regressions.
|
|
28
|
-
* Users with custom types ('Accessibility Defect', 'Issue', etc.)
|
|
29
|
-
* can pass the exact label. */
|
|
30
|
-
workItemType: string = 'Bug',
|
|
31
|
-
): AzureDevOpsIssueBuild {
|
|
32
|
-
const cleaned = baseUrl.replace(/\/$/, '');
|
|
33
|
-
|
|
34
|
-
const groups: Array<Violation & { _states: string[] }> = [];
|
|
35
|
-
for (const v of newViolations) {
|
|
36
|
-
const key = `${v.ruleId}::${v.target.selector}`;
|
|
37
|
-
const stateLabel = `:${v.currentState.pseudoState} · ${v.currentState.theme} · ${v.currentState.direction}`;
|
|
38
|
-
const existing = groups.find((g) => `${g.ruleId}::${g.target.selector}` === key);
|
|
39
|
-
if (existing) {
|
|
40
|
-
if (!existing._states.includes(stateLabel)) existing._states.push(stateLabel);
|
|
41
|
-
continue;
|
|
42
|
-
}
|
|
43
|
-
groups.push({ ...v, _states: [stateLabel] });
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
const title = `a11y: ${groups.length} new violation${groups.length === 1 ? '' : 's'} in ${componentId ?? 'audited component'}`;
|
|
47
|
-
|
|
48
|
-
const lines: string[] = [];
|
|
49
|
-
lines.push(`**Component:** \`${componentId ?? 'unknown'}\``);
|
|
50
|
-
lines.push('');
|
|
51
|
-
lines.push(
|
|
52
|
-
'Detected by WCAG Component Auditor as **new** vs the saved baseline — these are violations introduced since the last accepted baseline.',
|
|
53
|
-
);
|
|
54
|
-
lines.push('');
|
|
55
|
-
lines.push('---');
|
|
56
|
-
lines.push('');
|
|
57
|
-
for (const v of groups) {
|
|
58
|
-
lines.push(`### \`${v.ruleId}\` — ${v.impact}`);
|
|
59
|
-
lines.push('');
|
|
60
|
-
lines.push(v.description);
|
|
61
|
-
lines.push('');
|
|
62
|
-
lines.push(`- **WCAG:** ${v.wcagCriterion} (${v.wcagLevel})`);
|
|
63
|
-
lines.push(`- **Selector:** \`${v.target.selector}\``);
|
|
64
|
-
lines.push(`- **Found in state(s):** ${v._states.join(', ')}`);
|
|
65
|
-
if (v.helpUrl) lines.push(`- **More info:** ${v.helpUrl}`);
|
|
66
|
-
lines.push('');
|
|
67
|
-
lines.push('```html');
|
|
68
|
-
lines.push(v.target.outerHTML);
|
|
69
|
-
lines.push('```');
|
|
70
|
-
lines.push('');
|
|
71
|
-
}
|
|
72
|
-
lines.push('---');
|
|
73
|
-
lines.push('');
|
|
74
|
-
lines.push('_Filed via WCAG Component Auditor (delta-only — inherited debt not included)._');
|
|
75
|
-
|
|
76
|
-
const description = lines.join('\n');
|
|
77
|
-
// Azure DevOps work-item URL params:
|
|
78
|
-
// ?[Title]=... → title field
|
|
79
|
-
// ?[Description]=... → description (rendered as markdown)
|
|
80
|
-
// Square brackets are part of the param name (a reference syntax).
|
|
81
|
-
const params = new URLSearchParams({
|
|
82
|
-
'[Title]': title,
|
|
83
|
-
'[Description]': description,
|
|
84
|
-
});
|
|
85
|
-
let url = `${cleaned}/_workitems/create/${encodeURIComponent(workItemType)}?${params.toString()}`;
|
|
86
|
-
if (url.length > URL_LIMIT) url = url.slice(0, URL_LIMIT);
|
|
87
|
-
|
|
88
|
-
return { url, title, description };
|
|
89
|
-
}
|
|
@@ -1,89 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect } from 'vitest';
|
|
2
|
-
import { displayComponentId } from './format-component-id';
|
|
3
|
-
|
|
4
|
-
describe('displayComponentId', () => {
|
|
5
|
-
it('formats Storybook story id', () => {
|
|
6
|
-
const r = displayComponentId('::story:atoms-button--primary');
|
|
7
|
-
expect(r.primary).toBe('Atoms Button / Primary');
|
|
8
|
-
expect(r.secondary).toBe('storybook');
|
|
9
|
-
});
|
|
10
|
-
|
|
11
|
-
it('formats testid with URL scope', () => {
|
|
12
|
-
const r = displayComponentId('https://example.com/login::testid:email-input');
|
|
13
|
-
expect(r.primary).toBe('email-input');
|
|
14
|
-
expect(r.secondary).toBe('https://example.com/login');
|
|
15
|
-
});
|
|
16
|
-
|
|
17
|
-
it('formats explicit data-componentid', () => {
|
|
18
|
-
const r = displayComponentId('https://example.com::explicit:my-card');
|
|
19
|
-
expect(r.primary).toBe('my-card');
|
|
20
|
-
expect(r.secondary).toBe('https://example.com');
|
|
21
|
-
});
|
|
22
|
-
|
|
23
|
-
it('formats custom element', () => {
|
|
24
|
-
const r = displayComponentId('https://example.com::customelement:my-button');
|
|
25
|
-
expect(r.primary).toBe('my-button');
|
|
26
|
-
expect(r.secondary).toBe('https://example.com');
|
|
27
|
-
});
|
|
28
|
-
|
|
29
|
-
it('formats aria id with role:name', () => {
|
|
30
|
-
const r = displayComponentId('https://example.com::aria:button:Submit form');
|
|
31
|
-
// value contains a colon; we keep everything after the first colon as the value
|
|
32
|
-
expect(r.primary).toBe('button:Submit form');
|
|
33
|
-
expect(r.secondary).toBe('https://example.com');
|
|
34
|
-
});
|
|
35
|
-
|
|
36
|
-
it('falls back gracefully when no separator', () => {
|
|
37
|
-
const r = displayComponentId('weird-id');
|
|
38
|
-
expect(r.primary).toBe('weird-id');
|
|
39
|
-
});
|
|
40
|
-
|
|
41
|
-
it('handles aria value with multiple colons (kept verbatim after first colon)', () => {
|
|
42
|
-
const r = displayComponentId('https://example.com::aria:button:Foo: bar: baz');
|
|
43
|
-
// Strategy is "aria"; value is "button:Foo: bar: baz" (preserves colons after the first).
|
|
44
|
-
expect(r.primary).toBe('button:Foo: bar: baz');
|
|
45
|
-
expect(r.secondary).toBe('https://example.com');
|
|
46
|
-
});
|
|
47
|
-
|
|
48
|
-
it('handles unicode story names', () => {
|
|
49
|
-
const r = displayComponentId('::story:atoms-bütton--primary');
|
|
50
|
-
expect(r.primary).toBe('Atoms Bütton / Primary');
|
|
51
|
-
});
|
|
52
|
-
|
|
53
|
-
it('handles emoji in story names', () => {
|
|
54
|
-
const r = displayComponentId('::story:atoms-button--🚀-primary');
|
|
55
|
-
expect(r.primary).toContain('🚀');
|
|
56
|
-
});
|
|
57
|
-
|
|
58
|
-
it('handles very long story id without crashing', () => {
|
|
59
|
-
const longSegment = 'a'.repeat(200);
|
|
60
|
-
const r = displayComponentId(`::story:${longSegment}--variant`);
|
|
61
|
-
expect(r.primary.length).toBeGreaterThan(0);
|
|
62
|
-
expect(r.primary).toContain('Variant');
|
|
63
|
-
});
|
|
64
|
-
|
|
65
|
-
it('handles single-segment story id without "--" separator', () => {
|
|
66
|
-
const r = displayComponentId('::story:single-segment-only');
|
|
67
|
-
// No "--" separator → entire value is one segment, gets capitalized.
|
|
68
|
-
expect(r.primary).toBe('Single Segment Only');
|
|
69
|
-
});
|
|
70
|
-
|
|
71
|
-
it('handles testid with special characters', () => {
|
|
72
|
-
const r = displayComponentId('https://example.com::testid:btn-#$@-special');
|
|
73
|
-
expect(r.primary).toBe('btn-#$@-special');
|
|
74
|
-
expect(r.secondary).toBe('https://example.com');
|
|
75
|
-
});
|
|
76
|
-
|
|
77
|
-
it('handles empty url scope (storybook-style separator with no URL)', () => {
|
|
78
|
-
const r = displayComponentId('::testid:button-x');
|
|
79
|
-
expect(r.primary).toBe('button-x');
|
|
80
|
-
// Empty URL scope falls back to strategy as the secondary label.
|
|
81
|
-
expect(r.secondary).toBe('testid');
|
|
82
|
-
});
|
|
83
|
-
|
|
84
|
-
it('handles trailing :: with empty rest', () => {
|
|
85
|
-
const r = displayComponentId('https://example.com::');
|
|
86
|
-
// No strategy:value content after the separator; should not crash.
|
|
87
|
-
expect(r.primary).toBe('');
|
|
88
|
-
});
|
|
89
|
-
});
|
|
@@ -1,40 +0,0 @@
|
|
|
1
|
-
// Pretty-print a canonical componentId for display in the UI.
|
|
2
|
-
// Spec: phase-1-specs.md §2 (componentId format).
|
|
3
|
-
|
|
4
|
-
export type ComponentIdDisplay = {
|
|
5
|
-
primary: string; // Headline label
|
|
6
|
-
secondary?: string; // Smaller subtext (URL or strategy)
|
|
7
|
-
};
|
|
8
|
-
|
|
9
|
-
export function displayComponentId(id: string): ComponentIdDisplay {
|
|
10
|
-
// Format: {urlScope}::{strategy}:{value} (urlScope may be empty for storybook)
|
|
11
|
-
const sepIdx = id.indexOf('::');
|
|
12
|
-
if (sepIdx === -1) {
|
|
13
|
-
return { primary: id };
|
|
14
|
-
}
|
|
15
|
-
const url = id.slice(0, sepIdx);
|
|
16
|
-
const rest = id.slice(sepIdx + 2);
|
|
17
|
-
|
|
18
|
-
// rest is "{strategy}:{value}". Strategy is the first segment up to the FIRST colon.
|
|
19
|
-
const colonIdx = rest.indexOf(':');
|
|
20
|
-
if (colonIdx === -1) {
|
|
21
|
-
return { primary: rest };
|
|
22
|
-
}
|
|
23
|
-
const strategy = rest.slice(0, colonIdx);
|
|
24
|
-
const value = rest.slice(colonIdx + 1);
|
|
25
|
-
|
|
26
|
-
if (strategy === 'story') {
|
|
27
|
-
// storyId like "atoms-button--primary" → "Atoms / Button / Primary"
|
|
28
|
-
const niceStory = value
|
|
29
|
-
.split('--')
|
|
30
|
-
.map((seg) => seg.split('-').map(cap).join(' '))
|
|
31
|
-
.join(' / ');
|
|
32
|
-
return { primary: niceStory, secondary: 'storybook' };
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
return { primary: value, secondary: url || strategy };
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
function cap(word: string): string {
|
|
39
|
-
return word.length === 0 ? word : word[0]!.toUpperCase() + word.slice(1);
|
|
40
|
-
}
|
|
@@ -1,102 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect } from 'vitest';
|
|
2
|
-
import { buildGithubIssueUrl } from './github-issue';
|
|
3
|
-
import { DEFAULT_BREAKPOINT_PRESETS } from '../types/state';
|
|
4
|
-
import type { Violation } from '../types/audit';
|
|
5
|
-
|
|
6
|
-
const desktop = DEFAULT_BREAKPOINT_PRESETS.find((p) => p.id === 'desktop')!;
|
|
7
|
-
|
|
8
|
-
function v(overrides: Partial<Violation> = {}): Violation {
|
|
9
|
-
return {
|
|
10
|
-
ruleId: 'color-contrast',
|
|
11
|
-
wcagCriterion: 'wcag143',
|
|
12
|
-
wcagLevel: 'AA',
|
|
13
|
-
impact: 'serious',
|
|
14
|
-
description: 'Insufficient contrast',
|
|
15
|
-
helpUrl: 'https://example.com/r',
|
|
16
|
-
target: {
|
|
17
|
-
selector: '#btn',
|
|
18
|
-
outerHTML: '<button id="btn">Go</button>',
|
|
19
|
-
failureSummary: '',
|
|
20
|
-
tagName: 'BUTTON',
|
|
21
|
-
role: null,
|
|
22
|
-
accessibleName: 'Go',
|
|
23
|
-
textSnippet: 'Go',
|
|
24
|
-
attrId: 'btn',
|
|
25
|
-
attrTestid: null,
|
|
26
|
-
},
|
|
27
|
-
componentId: '::cmp::btn',
|
|
28
|
-
currentState: {
|
|
29
|
-
pseudoState: 'default',
|
|
30
|
-
ariaVariation: null,
|
|
31
|
-
theme: 'light',
|
|
32
|
-
direction: 'ltr',
|
|
33
|
-
breakpoint: desktop,
|
|
34
|
-
},
|
|
35
|
-
axeVersion: '4.11.4',
|
|
36
|
-
detectedAt: '2026-05-04T00:00:00Z',
|
|
37
|
-
matchKey: 'mk-1',
|
|
38
|
-
...overrides,
|
|
39
|
-
};
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
describe('buildGithubIssueUrl', () => {
|
|
43
|
-
it('produces a /issues/new URL pointing at the configured repo', () => {
|
|
44
|
-
const r = buildGithubIssueUrl('https://github.com/foo/bar', '::cmp::x', [v()]);
|
|
45
|
-
expect(r.url.startsWith('https://github.com/foo/bar/issues/new?')).toBe(true);
|
|
46
|
-
});
|
|
47
|
-
|
|
48
|
-
it('strips trailing slash from repo URL', () => {
|
|
49
|
-
const r = buildGithubIssueUrl('https://github.com/foo/bar/', '::x', [v()]);
|
|
50
|
-
expect(r.url.startsWith('https://github.com/foo/bar/issues/new?')).toBe(true);
|
|
51
|
-
});
|
|
52
|
-
|
|
53
|
-
it('puts violation count in the title', () => {
|
|
54
|
-
const r = buildGithubIssueUrl('https://github.com/foo/bar', '::x', [
|
|
55
|
-
v({ matchKey: 'm1' }),
|
|
56
|
-
v({ matchKey: 'm2', ruleId: 'image-alt' }),
|
|
57
|
-
]);
|
|
58
|
-
expect(r.title).toMatch(/2 new violations/);
|
|
59
|
-
});
|
|
60
|
-
|
|
61
|
-
it('singular vs plural in title', () => {
|
|
62
|
-
const r = buildGithubIssueUrl('https://github.com/foo/bar', '::x', [v()]);
|
|
63
|
-
expect(r.title).toMatch(/1 new violation in/);
|
|
64
|
-
expect(r.title).not.toMatch(/violations/);
|
|
65
|
-
});
|
|
66
|
-
|
|
67
|
-
it('dedupes violations sharing the same logical key (rule + selector)', () => {
|
|
68
|
-
const a = v({ matchKey: 'm1', currentState: { ...v().currentState, pseudoState: 'hover' } });
|
|
69
|
-
const b = v({ matchKey: 'm2', currentState: { ...v().currentState, pseudoState: 'focus' } });
|
|
70
|
-
const r = buildGithubIssueUrl('https://github.com/foo/bar', '::x', [a, b]);
|
|
71
|
-
// Title says 1 violation (deduped), body lists both states.
|
|
72
|
-
expect(r.title).toMatch(/1 new violation/);
|
|
73
|
-
expect(r.body).toMatch(/:hover/);
|
|
74
|
-
expect(r.body).toMatch(/:focus/);
|
|
75
|
-
});
|
|
76
|
-
|
|
77
|
-
it('includes selector, helpUrl, and outerHTML in the body', () => {
|
|
78
|
-
const r = buildGithubIssueUrl('https://github.com/foo/bar', '::x', [
|
|
79
|
-
v({
|
|
80
|
-
target: {
|
|
81
|
-
...v().target,
|
|
82
|
-
selector: '.my-thing',
|
|
83
|
-
outerHTML: '<button class="my-thing">x</button>',
|
|
84
|
-
},
|
|
85
|
-
helpUrl: 'https://example.com/help-link',
|
|
86
|
-
}),
|
|
87
|
-
]);
|
|
88
|
-
expect(r.body).toContain('.my-thing');
|
|
89
|
-
expect(r.body).toContain('https://example.com/help-link');
|
|
90
|
-
expect(r.body).toContain('<button class="my-thing">x</button>');
|
|
91
|
-
});
|
|
92
|
-
|
|
93
|
-
it('truncates the URL when it gets absurdly long', () => {
|
|
94
|
-
const huge = 'x'.repeat(20000);
|
|
95
|
-
const r = buildGithubIssueUrl('https://github.com/foo/bar', '::x', [
|
|
96
|
-
v({
|
|
97
|
-
target: { ...v().target, outerHTML: huge },
|
|
98
|
-
}),
|
|
99
|
-
]);
|
|
100
|
-
expect(r.url.length).toBeLessThanOrEqual(7800);
|
|
101
|
-
});
|
|
102
|
-
});
|
|
@@ -1,66 +0,0 @@
|
|
|
1
|
-
// Builds a pre-filled GitHub new-issue URL from a set of NEW violations.
|
|
2
|
-
// Pure function — extracted from DeltaView so the URL/body shape is testable.
|
|
3
|
-
|
|
4
|
-
import type { Violation } from '../types/audit';
|
|
5
|
-
|
|
6
|
-
export type IssueBuild = { url: string; title: string; body: string };
|
|
7
|
-
|
|
8
|
-
const URL_LIMIT = 7800;
|
|
9
|
-
|
|
10
|
-
export function buildGithubIssueUrl(
|
|
11
|
-
repoUrl: string,
|
|
12
|
-
componentId: string | null,
|
|
13
|
-
newViolations: Violation[]
|
|
14
|
-
): IssueBuild {
|
|
15
|
-
const cleaned = repoUrl.replace(/\/$/, '');
|
|
16
|
-
|
|
17
|
-
// Dedup violations by logical key (rule + selector); aggregate states.
|
|
18
|
-
const groups: Array<Violation & { _states: string[] }> = [];
|
|
19
|
-
for (const v of newViolations) {
|
|
20
|
-
const key = `${v.ruleId}::${v.target.selector}`;
|
|
21
|
-
const stateLabel = `:${v.currentState.pseudoState} · ${v.currentState.theme} · ${v.currentState.direction}`;
|
|
22
|
-
const existing = groups.find((g) => `${g.ruleId}::${g.target.selector}` === key);
|
|
23
|
-
if (existing) {
|
|
24
|
-
if (!existing._states.includes(stateLabel)) existing._states.push(stateLabel);
|
|
25
|
-
continue;
|
|
26
|
-
}
|
|
27
|
-
groups.push({ ...v, _states: [stateLabel] });
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
const title = `a11y: ${groups.length} new violation${groups.length === 1 ? '' : 's'} in ${componentId ?? 'audited component'}`;
|
|
31
|
-
|
|
32
|
-
const lines: string[] = [];
|
|
33
|
-
lines.push(`**Component:** \`${componentId ?? 'unknown'}\``);
|
|
34
|
-
lines.push('');
|
|
35
|
-
lines.push(
|
|
36
|
-
`Detected by WCAG Component Auditor as **new** vs the saved baseline — these are violations introduced since the last accepted baseline.`
|
|
37
|
-
);
|
|
38
|
-
lines.push('');
|
|
39
|
-
lines.push('---');
|
|
40
|
-
lines.push('');
|
|
41
|
-
for (const v of groups) {
|
|
42
|
-
lines.push(`### \`${v.ruleId}\` — ${v.impact}`);
|
|
43
|
-
lines.push('');
|
|
44
|
-
lines.push(v.description);
|
|
45
|
-
lines.push('');
|
|
46
|
-
lines.push(`- **WCAG:** ${v.wcagCriterion} (${v.wcagLevel})`);
|
|
47
|
-
lines.push(`- **Selector:** \`${v.target.selector}\``);
|
|
48
|
-
lines.push(`- **Found in state(s):** ${v._states.join(', ')}`);
|
|
49
|
-
if (v.helpUrl) lines.push(`- **More info:** ${v.helpUrl}`);
|
|
50
|
-
lines.push('');
|
|
51
|
-
lines.push('```html');
|
|
52
|
-
lines.push(v.target.outerHTML);
|
|
53
|
-
lines.push('```');
|
|
54
|
-
lines.push('');
|
|
55
|
-
}
|
|
56
|
-
lines.push('---');
|
|
57
|
-
lines.push('');
|
|
58
|
-
lines.push('_Filed via WCAG Component Auditor (delta-only — inherited debt not included)._');
|
|
59
|
-
|
|
60
|
-
const body = lines.join('\n');
|
|
61
|
-
const params = new URLSearchParams({ title, body });
|
|
62
|
-
let url = `${cleaned}/issues/new?${params.toString()}`;
|
|
63
|
-
if (url.length > URL_LIMIT) url = url.slice(0, URL_LIMIT);
|
|
64
|
-
|
|
65
|
-
return { url, title, body };
|
|
66
|
-
}
|
|
@@ -1,53 +0,0 @@
|
|
|
1
|
-
// rc.139 — G9 — Unit tests for the GitLab issue URL builder.
|
|
2
|
-
|
|
3
|
-
import { describe, it, expect } from 'vitest';
|
|
4
|
-
import { buildGitLabIssueUrl } from './gitlab-issue';
|
|
5
|
-
import type { Violation } from '../types/audit';
|
|
6
|
-
|
|
7
|
-
function v(over: Partial<Violation> = {}): Violation {
|
|
8
|
-
return {
|
|
9
|
-
ruleId: 'image-alt', wcagCriterion: '1.1.1', wcagLevel: 'A', impact: 'serious',
|
|
10
|
-
description: 'Images must have alt', helpUrl: 'https://x',
|
|
11
|
-
target: { selector: 'img.hero', outerHTML: '<img>', failureSummary: '', tagName: 'IMG', role: null, accessibleName: null, textSnippet: null, attrId: null, attrTestid: null },
|
|
12
|
-
componentId: '::c', currentState: { pseudoState: 'default', theme: 'light', direction: 'ltr', viewport: 'desktop' } as unknown as Violation['currentState'],
|
|
13
|
-
axeVersion: '4.11.4', detectedAt: '2026-05-22T00:00:00Z', matchKey: 'mk',
|
|
14
|
-
...over,
|
|
15
|
-
};
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
describe('buildGitLabIssueUrl (rc.139)', () => {
|
|
19
|
-
it('builds a URL targeting GitLab.com new-issue endpoint', () => {
|
|
20
|
-
const r = buildGitLabIssueUrl('https://gitlab.com/mygroup/myproject', '::c', [v()]);
|
|
21
|
-
expect(r.url.startsWith('https://gitlab.com/mygroup/myproject/-/issues/new?')).toBe(true);
|
|
22
|
-
});
|
|
23
|
-
|
|
24
|
-
it('handles self-hosted GitLab instances with the same pattern', () => {
|
|
25
|
-
const r = buildGitLabIssueUrl('https://gitlab.my-org.com/team/repo', '::c', [v()]);
|
|
26
|
-
expect(r.url.startsWith('https://gitlab.my-org.com/team/repo/-/issues/new?')).toBe(true);
|
|
27
|
-
});
|
|
28
|
-
|
|
29
|
-
it('embeds title + description as issue[*] params', () => {
|
|
30
|
-
const r = buildGitLabIssueUrl('https://gitlab.com/g/p', '::c', [v()]);
|
|
31
|
-
expect(r.url).toMatch(/issue%5Btitle%5D=/);
|
|
32
|
-
expect(r.url).toMatch(/issue%5Bdescription%5D=/);
|
|
33
|
-
expect(r.title).toContain('1 new violation');
|
|
34
|
-
expect(r.description).toContain('image-alt');
|
|
35
|
-
expect(r.description).toContain('1.1.1');
|
|
36
|
-
});
|
|
37
|
-
|
|
38
|
-
it('caps URL length at 7800', () => {
|
|
39
|
-
const many = Array.from({ length: 200 }, (_, i) =>
|
|
40
|
-
v({
|
|
41
|
-
ruleId: `rule-${i}`,
|
|
42
|
-
target: { ...v().target, selector: `.really.long.selector.number-${i}` },
|
|
43
|
-
}),
|
|
44
|
-
);
|
|
45
|
-
const r = buildGitLabIssueUrl('https://gitlab.com/g/p', '::c', many);
|
|
46
|
-
expect(r.url.length).toBeLessThanOrEqual(7800);
|
|
47
|
-
});
|
|
48
|
-
|
|
49
|
-
it('strips the trailing slash from the project URL', () => {
|
|
50
|
-
const r = buildGitLabIssueUrl('https://gitlab.com/g/p/', '::c', [v()]);
|
|
51
|
-
expect(r.url.startsWith('https://gitlab.com/g/p/-/issues/new?')).toBe(true);
|
|
52
|
-
});
|
|
53
|
-
});
|
|
@@ -1,78 +0,0 @@
|
|
|
1
|
-
// rc.139 — G9 — GitLab issue pre-fill URL builder.
|
|
2
|
-
//
|
|
3
|
-
// Mirrors the GitHub flow but targets GitLab's new-issue endpoint.
|
|
4
|
-
// GitLab's URL pattern is /-/issues/new (vs GitHub's /issues/new).
|
|
5
|
-
// Description is markdown; renders natively.
|
|
6
|
-
//
|
|
7
|
-
// https://gitlab.com/{group}/{project}/-/issues/new
|
|
8
|
-
// ?issue[title]=...&issue[description]=...
|
|
9
|
-
|
|
10
|
-
import type { Violation } from '../types/audit';
|
|
11
|
-
|
|
12
|
-
export type GitLabIssueBuild = { url: string; title: string; description: string };
|
|
13
|
-
|
|
14
|
-
const URL_LIMIT = 7800;
|
|
15
|
-
|
|
16
|
-
export function buildGitLabIssueUrl(
|
|
17
|
-
/** Project URL — e.g. `https://gitlab.com/group/project`.
|
|
18
|
-
* Self-hosted instances work the same: pass `https://gitlab.your-org.com/group/project`. */
|
|
19
|
-
projectUrl: string,
|
|
20
|
-
componentId: string | null,
|
|
21
|
-
newViolations: Violation[],
|
|
22
|
-
): GitLabIssueBuild {
|
|
23
|
-
const cleaned = projectUrl.replace(/\/$/, '');
|
|
24
|
-
|
|
25
|
-
const groups: Array<Violation & { _states: string[] }> = [];
|
|
26
|
-
for (const v of newViolations) {
|
|
27
|
-
const key = `${v.ruleId}::${v.target.selector}`;
|
|
28
|
-
const stateLabel = `:${v.currentState.pseudoState} · ${v.currentState.theme} · ${v.currentState.direction}`;
|
|
29
|
-
const existing = groups.find((g) => `${g.ruleId}::${g.target.selector}` === key);
|
|
30
|
-
if (existing) {
|
|
31
|
-
if (!existing._states.includes(stateLabel)) existing._states.push(stateLabel);
|
|
32
|
-
continue;
|
|
33
|
-
}
|
|
34
|
-
groups.push({ ...v, _states: [stateLabel] });
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
const title = `a11y: ${groups.length} new violation${groups.length === 1 ? '' : 's'} in ${componentId ?? 'audited component'}`;
|
|
38
|
-
|
|
39
|
-
const lines: string[] = [];
|
|
40
|
-
lines.push(`**Component:** \`${componentId ?? 'unknown'}\``);
|
|
41
|
-
lines.push('');
|
|
42
|
-
lines.push(
|
|
43
|
-
'Detected by WCAG Component Auditor as **new** vs the saved baseline — these are violations introduced since the last accepted baseline.',
|
|
44
|
-
);
|
|
45
|
-
lines.push('');
|
|
46
|
-
lines.push('---');
|
|
47
|
-
lines.push('');
|
|
48
|
-
for (const v of groups) {
|
|
49
|
-
lines.push(`### \`${v.ruleId}\` — ${v.impact}`);
|
|
50
|
-
lines.push('');
|
|
51
|
-
lines.push(v.description);
|
|
52
|
-
lines.push('');
|
|
53
|
-
lines.push(`- **WCAG:** ${v.wcagCriterion} (${v.wcagLevel})`);
|
|
54
|
-
lines.push(`- **Selector:** \`${v.target.selector}\``);
|
|
55
|
-
lines.push(`- **Found in state(s):** ${v._states.join(', ')}`);
|
|
56
|
-
if (v.helpUrl) lines.push(`- **More info:** ${v.helpUrl}`);
|
|
57
|
-
lines.push('');
|
|
58
|
-
lines.push('```html');
|
|
59
|
-
lines.push(v.target.outerHTML);
|
|
60
|
-
lines.push('```');
|
|
61
|
-
lines.push('');
|
|
62
|
-
}
|
|
63
|
-
lines.push('---');
|
|
64
|
-
lines.push('');
|
|
65
|
-
lines.push('_Filed via WCAG Component Auditor (delta-only — inherited debt not included)._');
|
|
66
|
-
|
|
67
|
-
const description = lines.join('\n');
|
|
68
|
-
// GitLab's new-issue endpoint uses bracketed param names that
|
|
69
|
-
// express a nested form payload.
|
|
70
|
-
const params = new URLSearchParams({
|
|
71
|
-
'issue[title]': title,
|
|
72
|
-
'issue[description]': description,
|
|
73
|
-
});
|
|
74
|
-
let url = `${cleaned}/-/issues/new?${params.toString()}`;
|
|
75
|
-
if (url.length > URL_LIMIT) url = url.slice(0, URL_LIMIT);
|
|
76
|
-
|
|
77
|
-
return { url, title, description };
|
|
78
|
-
}
|
|
@@ -1,64 +0,0 @@
|
|
|
1
|
-
// Builds a pre-filled Jira create-issue URL from a set of NEW violations.
|
|
2
|
-
// Mirrors the GitHub flow but targets Jira Cloud's CreateIssue URL params.
|
|
3
|
-
// Description field is plain text; we keep formatting minimal so it reads
|
|
4
|
-
// cleanly in Jira's default text view (markdown won't render natively).
|
|
5
|
-
|
|
6
|
-
import type { Violation } from '../types/audit';
|
|
7
|
-
|
|
8
|
-
export type JiraIssueBuild = { url: string; summary: string; description: string };
|
|
9
|
-
|
|
10
|
-
const URL_LIMIT = 7800;
|
|
11
|
-
|
|
12
|
-
export function buildJiraIssueUrl(
|
|
13
|
-
instanceUrl: string,
|
|
14
|
-
componentId: string | null,
|
|
15
|
-
newViolations: Violation[]
|
|
16
|
-
): JiraIssueBuild {
|
|
17
|
-
const cleaned = instanceUrl.replace(/\/$/, '');
|
|
18
|
-
|
|
19
|
-
// Dedup violations by logical key (rule + selector); aggregate states.
|
|
20
|
-
const groups: Array<Violation & { _states: string[] }> = [];
|
|
21
|
-
for (const v of newViolations) {
|
|
22
|
-
const key = `${v.ruleId}::${v.target.selector}`;
|
|
23
|
-
const stateLabel = `:${v.currentState.pseudoState} · ${v.currentState.theme} · ${v.currentState.direction}`;
|
|
24
|
-
const existing = groups.find((g) => `${g.ruleId}::${g.target.selector}` === key);
|
|
25
|
-
if (existing) {
|
|
26
|
-
if (!existing._states.includes(stateLabel)) existing._states.push(stateLabel);
|
|
27
|
-
continue;
|
|
28
|
-
}
|
|
29
|
-
groups.push({ ...v, _states: [stateLabel] });
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
const summary = `a11y: ${groups.length} new violation${groups.length === 1 ? '' : 's'} in ${componentId ?? 'audited component'}`;
|
|
33
|
-
|
|
34
|
-
const lines: string[] = [];
|
|
35
|
-
lines.push(`Component: ${componentId ?? 'unknown'}`);
|
|
36
|
-
lines.push('');
|
|
37
|
-
lines.push(
|
|
38
|
-
`Detected by WCAG Component Auditor as NEW vs the saved baseline — these are violations introduced since the last accepted baseline.`
|
|
39
|
-
);
|
|
40
|
-
lines.push('');
|
|
41
|
-
lines.push('---');
|
|
42
|
-
lines.push('');
|
|
43
|
-
for (const v of groups) {
|
|
44
|
-
lines.push(`Rule: ${v.ruleId} (${v.impact})`);
|
|
45
|
-
lines.push(`WCAG: ${v.wcagCriterion} ${v.wcagLevel}`);
|
|
46
|
-
lines.push(`Description: ${v.description}`);
|
|
47
|
-
lines.push(`Selector: ${v.target.selector}`);
|
|
48
|
-
lines.push(`Found in state(s): ${v._states.join(', ')}`);
|
|
49
|
-
if (v.helpUrl) lines.push(`More info: ${v.helpUrl}`);
|
|
50
|
-
lines.push('');
|
|
51
|
-
lines.push(`HTML: ${v.target.outerHTML}`);
|
|
52
|
-
lines.push('');
|
|
53
|
-
lines.push('---');
|
|
54
|
-
lines.push('');
|
|
55
|
-
}
|
|
56
|
-
lines.push('Filed via WCAG Component Auditor (delta-only — inherited debt not included).');
|
|
57
|
-
|
|
58
|
-
const description = lines.join('\n');
|
|
59
|
-
const params = new URLSearchParams({ summary, description });
|
|
60
|
-
let url = `${cleaned}/secure/CreateIssue!default.jspa?${params.toString()}`;
|
|
61
|
-
if (url.length > URL_LIMIT) url = url.slice(0, URL_LIMIT);
|
|
62
|
-
|
|
63
|
-
return { url, summary, description };
|
|
64
|
-
}
|
package/dist/side-panel/main.tsx
DELETED
|
@@ -1,86 +0,0 @@
|
|
|
1
|
-
import React, { useEffect, useState } from 'react';
|
|
2
|
-
import { createRoot } from 'react-dom/client';
|
|
3
|
-
import { App } from './App';
|
|
4
|
-
import { V2App } from './v2/App';
|
|
5
|
-
import { ErrorBoundary } from './components/ErrorBoundary';
|
|
6
|
-
import { installCrashReporter } from '../modules/crash-reporter';
|
|
7
|
-
import type { UserMode } from './store';
|
|
8
|
-
import './styles.css';
|
|
9
|
-
|
|
10
|
-
installCrashReporter('side-panel');
|
|
11
|
-
|
|
12
|
-
/** rc.276 → rc.333 — Persona-aware root.
|
|
13
|
-
*
|
|
14
|
-
* rc.276 introduced a v1/v2 toggle backed by `v2UiEnabled`. rc.333
|
|
15
|
-
* collapses that flag plus the older `userMode` ('owner' | 'dev')
|
|
16
|
-
* into a single three-persona setting: 'owner' | 'power' | 'dev'.
|
|
17
|
-
* Migration on first read: if `userMode` is missing, default to
|
|
18
|
-
* 'owner' (the simpler default — Cliff 2026-06-04). If `userMode`
|
|
19
|
-
* is 'dev' AND legacy `v2UiEnabled` is true, treat as 'power'.
|
|
20
|
-
*
|
|
21
|
-
* The legacy `v2UiEnabled` storage key is kept in sync by the
|
|
22
|
-
* PersonaSwitcher write path for backward compat, but is no longer
|
|
23
|
-
* the source of truth. A future rc removes it entirely.
|
|
24
|
-
*/
|
|
25
|
-
function Root() {
|
|
26
|
-
const [mode, setMode] = useState<UserMode | null>(null);
|
|
27
|
-
|
|
28
|
-
useEffect(() => {
|
|
29
|
-
let cancelled = false;
|
|
30
|
-
|
|
31
|
-
function applyStorage(values: { userMode?: unknown; v2UiEnabled?: unknown }): void {
|
|
32
|
-
const stored = values.userMode;
|
|
33
|
-
const legacyV2 = Boolean(values.v2UiEnabled);
|
|
34
|
-
let resolved: UserMode;
|
|
35
|
-
if (stored === 'owner' || stored === 'power' || stored === 'dev') {
|
|
36
|
-
resolved = stored;
|
|
37
|
-
} else if (stored === 'dev' && legacyV2) {
|
|
38
|
-
// (Unreachable given the typeguard above but kept for clarity:
|
|
39
|
-
// legacy combo where userMode='dev' + v2UiEnabled=true now
|
|
40
|
-
// maps to 'power'.)
|
|
41
|
-
resolved = 'power';
|
|
42
|
-
} else {
|
|
43
|
-
// No persisted persona (first launch) → owner default.
|
|
44
|
-
// Or stored was a stale value — recover to owner.
|
|
45
|
-
// If the user previously opted into v2 without userMode set,
|
|
46
|
-
// honor that as 'power'.
|
|
47
|
-
resolved = legacyV2 ? 'power' : 'owner';
|
|
48
|
-
}
|
|
49
|
-
if (!cancelled) setMode(resolved);
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
void chrome.storage.local
|
|
53
|
-
.get(['userMode', 'v2UiEnabled'])
|
|
54
|
-
.then(applyStorage)
|
|
55
|
-
.catch(() => setMode('owner'));
|
|
56
|
-
|
|
57
|
-
const onChange = (
|
|
58
|
-
changes: Record<string, chrome.storage.StorageChange>,
|
|
59
|
-
area: string,
|
|
60
|
-
) => {
|
|
61
|
-
if (area !== 'local') return;
|
|
62
|
-
if (!('userMode' in changes) && !('v2UiEnabled' in changes)) return;
|
|
63
|
-
void chrome.storage.local.get(['userMode', 'v2UiEnabled']).then(applyStorage);
|
|
64
|
-
};
|
|
65
|
-
chrome.storage.onChanged.addListener(onChange);
|
|
66
|
-
|
|
67
|
-
return () => {
|
|
68
|
-
cancelled = true;
|
|
69
|
-
chrome.storage.onChanged.removeListener(onChange);
|
|
70
|
-
};
|
|
71
|
-
}, []);
|
|
72
|
-
|
|
73
|
-
if (mode === null) return null; // brief tick before first paint
|
|
74
|
-
return mode === 'power' ? <V2App /> : <App />;
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
const root = document.getElementById('root');
|
|
78
|
-
if (!root) throw new Error('side-panel: #root not found');
|
|
79
|
-
|
|
80
|
-
createRoot(root).render(
|
|
81
|
-
<React.StrictMode>
|
|
82
|
-
<ErrorBoundary>
|
|
83
|
-
<Root />
|
|
84
|
-
</ErrorBoundary>
|
|
85
|
-
</React.StrictMode>
|
|
86
|
-
);
|