Skip to content

Commit 0281c22

Browse files
authored
Merge pull request #154 from mgifford/copilot/add-unique-identifier-to-reports
Add stable unique identifiers (DAP-xxxxxxxx) to axe accessibility findings
2 parents 231d1b8 + 2cbaceb commit 0281c22

File tree

2 files changed

+97
-8
lines changed

2 files changed

+97
-8
lines changed

src/publish/render-pages.js

Lines changed: 52 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { createHash } from 'node:crypto';
12
import { AXE_TO_FPC, FPC_LABELS, FPC_SVGS, FPC_DESCRIPTIONS } from '../data/axe-fpc-mapping.js';
23
import { getFpcPrevalenceRates, CENSUS_DISABILITY_STATS } from '../data/census-disability-stats.js';
34
import { getPolicyNarrative, getHeuristicsForAxeRule } from '../data/axe-impact-loader.js';
@@ -23,6 +24,30 @@ function formatCompact(n) {
2324
return String(n);
2425
}
2526

27+
/**
28+
* Generates a stable unique identifier for an accessibility violation.
29+
* The ID is based on the normalized URL, rule ID, and element selector so that
30+
* the same violation on the same element produces the same ID across scans,
31+
* enabling tracking of how long an issue has persisted.
32+
*
33+
* @param {string} pageUrl - The URL of the scanned page
34+
* @param {string} ruleId - The axe rule ID (e.g. "color-contrast")
35+
* @param {string} [selector] - The CSS selector of the affected element (empty string for finding-level ID)
36+
* @returns {string} A short stable identifier, e.g. "DAP-a1b2c3d4"
37+
*/
38+
export function generateViolationId(pageUrl, ruleId, selector = '') {
39+
const safeUrl = typeof pageUrl === 'string' ? pageUrl : '';
40+
const safeRuleId = typeof ruleId === 'string' ? ruleId : '';
41+
const safeSelector = typeof selector === 'string' ? selector : '';
42+
const normalizedUrl = safeUrl
43+
.replace(/^https?:\/\//i, '')
44+
.replace(/\/+$/, '')
45+
.toLowerCase();
46+
const seed = `${normalizedUrl}|${safeRuleId}|${safeSelector}`;
47+
const hash = createHash('sha256').update(seed).digest('hex').slice(0, 8);
48+
return `DAP-${hash}`;
49+
}
50+
2651
/**
2752
* Format a byte count as a human-readable string (B / KB / MB / GB).
2853
*
@@ -606,6 +631,17 @@ function renderSharedStyles() {
606631
overflow-x: auto;
607632
font-size: 0.85em;
608633
}
634+
.violation-id {
635+
font-size: 0.75em;
636+
font-family: monospace;
637+
color: var(--color-muted, #6b7280);
638+
background: var(--color-code-bg, #f3f4f6);
639+
border: 1px solid var(--color-border, #d1d5db);
640+
border-radius: 3px;
641+
padding: 0.1em 0.35em;
642+
vertical-align: middle;
643+
user-select: all;
644+
}
609645
.finding-detail {
610646
padding: 0.5rem 1rem;
611647
border: 1px solid var(--color-finding-border);
@@ -1794,21 +1830,26 @@ function renderWcagTags(tags = []) {
17941830
return `<p class="wcag-tags"><strong>WCAG criteria:</strong> ${wcagLabels.map((l) => escapeHtml(l)).join(', ')}</p>`;
17951831
}
17961832

1797-
function renderAxeFindingItems(items = []) {
1833+
function renderAxeFindingItems(items = [], pageUrl = '', ruleId = '') {
17981834
if (items.length === 0) {
17991835
return '<p><em>No specific element details available.</em></p>';
18001836
}
18011837

18021838
return items
18031839
.map(
1804-
(item, index) => `
1840+
(item, index) => {
1841+
const elementId = typeof pageUrl === 'string' && pageUrl.length > 0 && typeof ruleId === 'string' && ruleId.length > 0
1842+
? generateViolationId(pageUrl, ruleId, item.selector ?? '')
1843+
: '';
1844+
return `
18051845
<div class="axe-item">
1806-
<p><strong>Element ${index + 1}</strong></p>
1846+
<p><strong>Element ${index + 1}</strong>${elementId ? ` <code class="violation-id" title="Stable identifier for this accessibility violation">${escapeHtml(elementId)}</code>` : ''}</p>
18071847
${item.selector ? `<p><strong>Element path:</strong> <code>${escapeHtml(item.selector)}</code></p>` : ''}
18081848
${item.snippet ? `<p><strong>Snippet:</strong></p><pre><code>${escapeHtml(item.snippet)}</code></pre>` : ''}
18091849
${item.node_label && item.node_label !== item.selector ? `<p><strong>Label:</strong> ${escapeHtml(item.node_label)}</p>` : ''}
18101850
${renderExplanationHtml(item.explanation)}
1811-
</div>`
1851+
</div>`;
1852+
}
18121853
)
18131854
.join('\n');
18141855
}
@@ -1853,10 +1894,12 @@ function formatImpactList(items) {
18531894
export function buildFindingCopyText(pageUrl, finding, pageLoadCount = 0, scanDate = '') {
18541895
const wcagLabels = (finding.tags ?? []).map(formatWcagTag).filter(Boolean);
18551896
const fpcCodes = AXE_TO_FPC.get(finding.id) ?? [];
1897+
const findingId = generateViolationId(pageUrl, finding.id, '');
18561898
const lines = [
18571899
`**URL:** ${pageUrl}`,
18581900
'',
18591901
`**${finding.title}** (rule: \`${finding.id}\`)`,
1902+
`**Violation ID:** ${findingId}`,
18601903
'',
18611904
plainTextDescription(finding.description ?? ''),
18621905
];
@@ -1891,7 +1934,8 @@ export function buildFindingCopyText(pageUrl, finding, pageLoadCount = 0, scanDa
18911934
lines.push('', `**Affected elements (${items.length}):**`);
18921935

18931936
items.forEach((item, index) => {
1894-
lines.push('', `**Element ${index + 1}**`);
1937+
const elementId = generateViolationId(pageUrl, finding.id, item.selector ?? '');
1938+
lines.push('', `**Element ${index + 1}** (ID: ${elementId})`);
18951939
if (item.selector) {
18961940
lines.push(`Element path: \`${item.selector}\``);
18971941
}
@@ -1922,15 +1966,16 @@ function renderAxeFindingsList(axeFindings = [], pageUrl = '', pageLoadCount = 0
19221966
fpcCodes && fpcCodes.length > 0
19231967
? `<p><strong>Disabilities affected:</strong> ${renderFpcCodes(finding.id, pageLoadCount, prevalenceRates)}</p>`
19241968
: '';
1969+
const findingId = generateViolationId(pageUrl, finding.id, '');
19251970
return `
19261971
<details>
1927-
<summary><strong>${escapeHtml(finding.title)}</strong> (rule: <code>${escapeHtml(finding.id)}</code>)</summary>
1972+
<summary><strong>${escapeHtml(finding.title)}</strong> (rule: <code>${escapeHtml(finding.id)}</code>) <code class="violation-id" title="Stable identifier for this accessibility finding">${escapeHtml(findingId)}</code></summary>
19281973
<div class="finding-detail">
19291974
<p>${renderDescriptionHtml(finding.description)}</p>
19301975
${renderWcagTags(finding.tags)}
19311976
${fpcHtml}
19321977
<p><strong>Affected elements (${finding.items.length}):</strong></p>
1933-
${renderAxeFindingItems(finding.items)}
1978+
${renderAxeFindingItems(finding.items, pageUrl, finding.id)}
19341979
<button class="copy-finding-btn" data-copy-text="${escapeHtml(buildFindingCopyText(pageUrl, finding, pageLoadCount, scanDate))}" aria-label="Copy finding to clipboard">Copy finding</button>
19351980
</div>
19361981
</details>`;

tests/unit/render-pages.test.js

Lines changed: 45 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import test from 'node:test';
22
import assert from 'node:assert/strict';
3-
import { renderDailyReportPage, renderDashboardPage, renderArchiveIndexPage, renderArchiveRedirectStub, render404Page, renderCodeQualityPage, buildFindingCopyText, plainTextDescription, buildUsabilityHeuristicsCounts } from '../../src/publish/render-pages.js';
3+
import { renderDailyReportPage, renderDashboardPage, renderArchiveIndexPage, renderArchiveRedirectStub, render404Page, renderCodeQualityPage, buildFindingCopyText, plainTextDescription, buildUsabilityHeuristicsCounts, generateViolationId } from '../../src/publish/render-pages.js';
44
import { renderFailurePage } from '../../src/publish/failure-report.js';
55

66
test('renderDailyReportPage filters out zero-score history entries', () => {
@@ -741,6 +741,48 @@ test('plainTextDescription converts markdown links to plain text', () => {
741741
assert.ok(!result.includes('[Learn more]'), 'Should not contain raw markdown link syntax');
742742
});
743743

744+
test('generateViolationId returns a stable DAP- prefixed identifier', () => {
745+
const id = generateViolationId('https://fdic.gov', 'tabindex', 'body.acquia-cms-toolbar > a.skipheader');
746+
assert.ok(id.startsWith('DAP-'), 'Should have DAP- prefix');
747+
assert.match(id, /^DAP-[0-9a-f]{8}$/, 'Should be DAP- followed by 8 hex characters');
748+
});
749+
750+
test('generateViolationId produces the same ID for the same inputs', () => {
751+
const id1 = generateViolationId('https://fdic.gov', 'tabindex', 'body > a.skip');
752+
const id2 = generateViolationId('https://fdic.gov', 'tabindex', 'body > a.skip');
753+
assert.equal(id1, id2, 'Same inputs should produce the same ID');
754+
});
755+
756+
test('generateViolationId produces different IDs for different selectors', () => {
757+
const id1 = generateViolationId('https://fdic.gov', 'tabindex', 'body > a.skip');
758+
const id2 = generateViolationId('https://fdic.gov', 'tabindex', 'div > button#other');
759+
assert.notEqual(id1, id2, 'Different selectors should produce different IDs');
760+
});
761+
762+
test('generateViolationId produces different IDs for different rule IDs', () => {
763+
const id1 = generateViolationId('https://fdic.gov', 'tabindex', 'body > a.skip');
764+
const id2 = generateViolationId('https://fdic.gov', 'color-contrast', 'body > a.skip');
765+
assert.notEqual(id1, id2, 'Different rule IDs should produce different IDs');
766+
});
767+
768+
test('generateViolationId normalizes URL protocol and trailing slash', () => {
769+
const id1 = generateViolationId('https://fdic.gov', 'tabindex', 'a.skip');
770+
const id2 = generateViolationId('http://fdic.gov/', 'tabindex', 'a.skip');
771+
assert.equal(id1, id2, 'http/https and trailing slash differences should not affect the ID');
772+
});
773+
774+
test('generateViolationId produces finding-level ID when selector is empty', () => {
775+
const id = generateViolationId('https://fdic.gov', 'tabindex', '');
776+
assert.ok(id.startsWith('DAP-'), 'Finding-level ID should have DAP- prefix');
777+
assert.match(id, /^DAP-[0-9a-f]{8}$/, 'Should be DAP- followed by 8 hex characters');
778+
});
779+
780+
test('generateViolationId handles non-string inputs gracefully', () => {
781+
const id = generateViolationId(null, undefined, 42);
782+
assert.ok(id.startsWith('DAP-'), 'Should still return a DAP-prefixed ID with non-string inputs');
783+
assert.match(id, /^DAP-[0-9a-f]{8}$/, 'Should be DAP- followed by 8 hex characters');
784+
});
785+
744786
test('buildFindingCopyText includes page URL and finding details', () => {
745787
const pageUrl = 'https://informeddelivery.usps.com';
746788
const finding = {
@@ -769,6 +811,8 @@ test('buildFindingCopyText includes page URL and finding details', () => {
769811
assert.ok(text.includes('<h4>'), 'Should include element snippet');
770812
assert.ok(text.includes('What is Informed Delivery?'), 'Should include node label');
771813
assert.ok(text.includes('Heading order invalid'), 'Should include how-to-fix text');
814+
assert.ok(text.includes('**Violation ID:** DAP-'), 'Should include a finding-level violation ID');
815+
assert.ok(text.includes('(ID: DAP-'), 'Should include an element-level violation ID');
772816
});
773817

774818
test('buildFindingCopyText handles finding with no items', () => {

0 commit comments

Comments
 (0)