diff --git a/.github/actions/auth/package-lock.json b/.github/actions/auth/package-lock.json index 1c43de4f..eec4329d 100644 --- a/.github/actions/auth/package-lock.json +++ b/.github/actions/auth/package-lock.json @@ -13,7 +13,7 @@ "playwright": "^1.60.0" }, "devDependencies": { - "@types/node": "^25.7.0", + "@types/node": "^25.9.0", "typescript": "^6.0.3" } }, @@ -53,13 +53,13 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "25.7.0", - "resolved": "https://registry.npmjs.org/@types/node/-/node-25.7.0.tgz", - "integrity": "sha512-z+pdZyxE+RTQE9AcboAZCb4otwcrvgHD+GlBpPgn0emDVt0ohrTMhAwlr2Wd9nZ+nihhYFxO2pThz3C5qSu2Eg==", + "version": "25.9.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.0.tgz", + "integrity": "sha512-AOQwYUNolgy3VosiRqXrACUXTN8nJUtPl7FJXMqZVyxiiCLhQuG3jXKvCS1ALr+Y2OmZhzzLVlYPEqJaiqkaJQ==", "dev": true, "license": "MIT", "dependencies": { - "undici-types": "~7.21.0" + "undici-types": ">=7.24.0 <7.24.7" } }, "node_modules/fsevents": { @@ -139,9 +139,9 @@ } }, "node_modules/undici-types": { - "version": "7.21.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.21.0.tgz", - "integrity": "sha512-w9IMgQrz4O0YN1LtB7K5P63vhlIOvC7opSmouCJ+ZywlPAlO9gIkJ+otk6LvGpAs2wg4econaCz3TvQ9xPoyuQ==", + "version": "7.24.6", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz", + "integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==", "dev": true, "license": "MIT" } diff --git a/.github/actions/auth/package.json b/.github/actions/auth/package.json index 0e182856..3ab837d0 100644 --- a/.github/actions/auth/package.json +++ b/.github/actions/auth/package.json @@ -17,7 +17,7 @@ "playwright": "^1.60.0" }, "devDependencies": { - "@types/node": "^25.7.0", + "@types/node": "^25.9.0", "typescript": "^6.0.3" } } \ No newline at end of file diff --git a/.github/actions/file/README.md b/.github/actions/file/README.md index 7680cce4..fd956e46 100644 --- a/.github/actions/file/README.md +++ b/.github/actions/file/README.md @@ -11,6 +11,7 @@ Files GitHub issues to track potential accessibility gaps. **Required** Path to a JSON file containing the list of potential accessibility gaps. The path can be absolute or relative to the working directory (which defaults to `GITHUB_WORKSPACE`). For example: `findings.json`. The file should contain a JSON array of finding objects. For example: + ```json [] ``` @@ -28,15 +29,31 @@ The file should contain a JSON array of finding objects. For example: **Optional** Path to a JSON file containing cached filings from previous runs. The path can be absolute or relative to the working directory (which defaults to `GITHUB_WORKSPACE`). Without this, duplicate issues may be filed. For example: `cached-filings.json`. The file should contain a JSON array of filing objects. For example: + ```json [ { "findings": [], - "issue": {"id":1,"nodeId":"SXNzdWU6MQ==","url":"https://github.com/github/docs/issues/123","title":"Accessibility issue: 1"} + "issue": { + "id": 1, + "nodeId": "SXNzdWU6MQ==", + "url": "https://github.com/github/docs/issues/123", + "title": "Accessibility issue: 1" + } } ] ``` +#### `group_by` + +**Optional** How to consolidate findings into issues. One of: + +- `finding` (default): one issue per individual violation — current behavior, unchanged. +- `rule`: one issue per rule (`ruleId`/`scannerType`), aggregating every occurrence across all scanned URLs. +- `rule+url`: one issue per rule per scanned URL. + +When grouping, each additional occurrence is appended to the single "umbrella" issue body as a checklist item under an **Occurrences** section rather than spawning a new issue. This is the preferred mechanism for consolidating issues over `open_grouped_issues`. + ### Outputs #### `filings_file` @@ -44,11 +61,17 @@ The file should contain a JSON array of filing objects. For example: Absolute path to a JSON file containing the list of issues filed (and their associated finding(s)). The action writes this file to a temporary directory and returns the absolute path. For example: `$RUNNER_TEMP/filings-.json`. The file will contain a JSON array of filing objects. For example: + ```json [ { "findings": [], - "issue": {"id":1,"nodeId":"SXNzdWU6MQ==","url":"https://github.com/github/docs/issues/123","title":"Accessibility issue: 1"} + "issue": { + "id": 1, + "nodeId": "SXNzdWU6MQ==", + "url": "https://github.com/github/docs/issues/123", + "title": "Accessibility issue: 1" + } } ] ``` diff --git a/.github/actions/file/action.yml b/.github/actions/file/action.yml index 836e125d..a0f1a80e 100644 --- a/.github/actions/file/action.yml +++ b/.github/actions/file/action.yml @@ -1,21 +1,21 @@ -name: "File" -description: "Files GitHub issues to track potential accessibility gaps." +name: 'File' +description: 'Files GitHub issues to track potential accessibility gaps.' inputs: findings_file: - description: "Path to a JSON file containing the list of potential accessibility gaps" + description: 'Path to a JSON file containing the list of potential accessibility gaps' required: true repository: - description: "Repository (with owner) to file issues in" + description: 'Repository (with owner) to file issues in' required: true token: description: "Token with fine-grained permission 'issues: write'" required: true base_url: - description: "Optional base URL to pass into Octokit for the GitHub API (for example, `https://YOUR_HOSTNAME/api/v3` for GitHub Enterprise Server)" + description: 'Optional base URL to pass into Octokit for the GitHub API (for example, `https://YOUR_HOSTNAME/api/v3` for GitHub Enterprise Server)' required: false cached_filings_file: - description: "Path to a JSON file containing cached filings from previous runs. Without this, duplicate issues may be filed." + description: 'Path to a JSON file containing cached filings from previous runs. Without this, duplicate issues may be filed.' required: false screenshot_repository: description: "Repository (with owner) where screenshots are stored on the gh-cache branch. Defaults to the 'repository' input if not set. Required if issues are open in a different repo to construct proper screenshot URLs." @@ -23,16 +23,32 @@ inputs: open_grouped_issues: description: "In the 'file' step, also open grouped issues which link to all issues with the same root cause" required: false - default: "false" + default: 'false' + group_by: + description: "How to group findings into issues: 'finding' (one issue per violation, default), 'rule' (one issue per rule), or 'rule+url' (one issue per rule per scanned URL)." + required: false + default: 'finding' + dry_run: + description: 'When true, log the issues that would be filed without opening, closing, or reopening any issues.' + required: false + default: 'false' + file_best_practice_issues: + description: 'File issues for best-practice findings (accessibility recommendations that are not hard WCAG failures). Disabling only suppresses new issues; existing ones are left untouched.' + required: false + default: 'true' + file_experimental_issues: + description: 'File issues for experimental findings (checks that are not yet stable). Disabling only suppresses new issues; existing ones are left untouched.' + required: false + default: 'true' outputs: filings_file: - description: "Path to a JSON file containing the list of issues filed (and their associated finding(s))" + description: 'Path to a JSON file containing the list of issues filed (and their associated finding(s))' runs: - using: "node24" - main: "bootstrap.js" + using: 'node24' + main: 'bootstrap.js' branding: - icon: "compass" - color: "blue" + icon: 'compass' + color: 'blue' diff --git a/.github/actions/file/package-lock.json b/.github/actions/file/package-lock.json index f65b3c2d..9c103305 100644 --- a/.github/actions/file/package-lock.json +++ b/.github/actions/file/package-lock.json @@ -14,7 +14,7 @@ "@octokit/plugin-throttling": "^11.0.3" }, "devDependencies": { - "@types/node": "^25.7.0", + "@types/node": "^25.9.0", "typescript": "^6.0.3" } }, @@ -167,13 +167,13 @@ } }, "node_modules/@types/node": { - "version": "25.7.0", - "resolved": "https://registry.npmjs.org/@types/node/-/node-25.7.0.tgz", - "integrity": "sha512-z+pdZyxE+RTQE9AcboAZCb4otwcrvgHD+GlBpPgn0emDVt0ohrTMhAwlr2Wd9nZ+nihhYFxO2pThz3C5qSu2Eg==", + "version": "25.9.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.0.tgz", + "integrity": "sha512-AOQwYUNolgy3VosiRqXrACUXTN8nJUtPl7FJXMqZVyxiiCLhQuG3jXKvCS1ALr+Y2OmZhzzLVlYPEqJaiqkaJQ==", "dev": true, "license": "MIT", "dependencies": { - "undici-types": "~7.21.0" + "undici-types": ">=7.24.0 <7.24.7" } }, "node_modules/before-after-hook": { @@ -237,9 +237,9 @@ } }, "node_modules/undici-types": { - "version": "7.21.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.21.0.tgz", - "integrity": "sha512-w9IMgQrz4O0YN1LtB7K5P63vhlIOvC7opSmouCJ+ZywlPAlO9gIkJ+otk6LvGpAs2wg4econaCz3TvQ9xPoyuQ==", + "version": "7.24.6", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz", + "integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==", "dev": true, "license": "MIT" }, diff --git a/.github/actions/file/package.json b/.github/actions/file/package.json index 55022f9b..a5d7cbd2 100644 --- a/.github/actions/file/package.json +++ b/.github/actions/file/package.json @@ -18,7 +18,7 @@ "@octokit/plugin-throttling": "^11.0.3" }, "devDependencies": { - "@types/node": "^25.7.0", + "@types/node": "^25.9.0", "typescript": "^6.0.3" } } diff --git a/.github/actions/file/src/generateIssueBody.ts b/.github/actions/file/src/generateIssueBody.ts index 18e25d31..ad703d51 100644 --- a/.github/actions/file/src/generateIssueBody.ts +++ b/.github/actions/file/src/generateIssueBody.ts @@ -1,6 +1,9 @@ import type {Finding} from './types.d.js' -export function generateIssueBody(finding: Finding, screenshotRepo: string): string { +export function generateIssueBody(occurrences: Finding | Finding[], screenshotRepo: string): string { + const findings = Array.isArray(occurrences) ? occurrences : [occurrences] + const finding = findings[0] + const solutionLong = finding.solutionLong ?.split('\n') .map((line: string) => @@ -18,21 +21,64 @@ export function generateIssueBody(finding: Finding, screenshotRepo: string): str ` } + let occurrencesSection = '' + if (findings.length > 1) { + const items = findings.map(f => `- [ ] ${f.html ? `\`${f.html}\` on ${f.url}` : f.url}`).join('\n') + occurrencesSection = ` +## ${findings.length} Other Occurrences: + +${items} +` + } + + const categoryNotice = + finding.category && finding.category !== 'wcag' + ? `> [!NOTE]\n> This is ${ + finding.category === 'experimental' ? 'an experimental check' : 'a best-practice recommendation' + }, not a definite WCAG failure.\n\n` + : '' + + const standardsLine = + finding.category && finding.category !== 'wcag' + ? '- [ ] The fix MUST meet the accessibility standards specified by the repository or organization (WCAG 2.2 if applicable).' + : '- [ ] The fix MUST meet WCAG 2.2 guidelines OR the accessibility standards specified by the repository or organization.' + const acceptanceCriteria = `## Acceptance Criteria - [ ] The specific violation reported in this issue is no longer reproducible. -- [ ] The fix MUST meet WCAG 2.1 guidelines OR the accessibility standards specified by the repository or organization. +${standardsLine} - [ ] A test SHOULD be added to ensure this specific violation does not regress. - [ ] This PR MUST NOT introduce any new accessibility issues or regressions.` - const body = `## What -An accessibility scan ${finding.html ? `flagged the element \`${finding.html}\`` : `found an issue on ${finding.url}`} because ${finding.problemShort}. Learn more about why this was flagged by visiting ${finding.problemUrl}. + const body = `${categoryNotice}## What +${describeFinding(finding)} ${screenshotSection ?? ''} To fix this, ${finding.solutionShort}. ${solutionLong ? `\nSpecifically:\n\n${solutionLong}` : ''} - +${occurrencesSection} ${acceptanceCriteria} ` return body } + +function describeFinding(finding: Finding): string { + const reason = `because ${finding.problemShort}. Learn more about why this was flagged by visiting ${finding.problemUrl}.` + + // Axe carries every failing element; list them all, not just the first. + if (finding.nodes && finding.nodes.length > 0) { + const count = finding.nodes.length + const subject = count === 1 ? 'an element' : `${count} elements` + const elementList = finding.nodes + .map(node => `- \`${node.html}\`${node.target ? ` (selector: \`${node.target}\`)` : ''}`) + .join('\n') + const heading = count === 1 ? 'The following element needs' : 'The following elements need' + return `An accessibility scan flagged ${subject} on ${finding.url} ${reason}\n\n${heading} attention:\n\n${elementList}` + } + + if (finding.html) { + return `An accessibility scan flagged the element \`${finding.html}\` ${reason}` + } + + return `An accessibility scan found an issue on ${finding.url} ${reason}` +} diff --git a/.github/actions/file/src/groupBy.ts b/.github/actions/file/src/groupBy.ts new file mode 100644 index 00000000..35d2c7b5 --- /dev/null +++ b/.github/actions/file/src/groupBy.ts @@ -0,0 +1,7 @@ +export const GROUP_BY_VALUES = ['finding', 'rule', 'rule+url'] as const + +export type GroupBy = (typeof GROUP_BY_VALUES)[number] + +export function isGroupBy(value: string): value is GroupBy { + return (GROUP_BY_VALUES as readonly string[]).includes(value) +} diff --git a/.github/actions/file/src/index.ts b/.github/actions/file/src/index.ts index 86d14ec8..b4f989c9 100644 --- a/.github/actions/file/src/index.ts +++ b/.github/actions/file/src/index.ts @@ -10,12 +10,20 @@ import {closeIssue} from './closeIssue.js' import {isNewFiling} from './isNewFiling.js' import {isRepeatedFiling} from './isRepeatedFiling.js' import {isResolvedFiling} from './isResolvedFiling.js' +import {getWontfixIssueNumbers, shouldReopenIssue, WONTFIX_LABEL} from './shouldReopenIssue.js' import {openIssue} from './openIssue.js' import {reopenIssue} from './reopenIssue.js' import {updateFilingsWithNewFindings} from './updateFilingsWithNewFindings.js' +import {GROUP_BY_VALUES, isGroupBy} from './groupBy.js' import {OctokitResponse} from '@octokit/types' const OctokitWithThrottling = Octokit.plugin(throttling) +// core.getBooleanInput throws on unset inputs, so apply the default first. +function getBooleanInputWithDefault(name: string, defaultValue: boolean): boolean { + if (!core.getInput(name)) return defaultValue + return core.getBooleanInput(name) +} + export default async function () { core.info("Started 'file' action") const findingsFile = core.getInput('findings_file', {required: true}) @@ -29,12 +37,25 @@ export default async function () { ? JSON.parse(fs.readFileSync(cachedFilingsFile, 'utf8')) : [] const shouldOpenGroupedIssues = core.getBooleanInput('open_grouped_issues') + const groupByInput = core.getInput('group_by') || 'finding' + if (!isGroupBy(groupByInput)) { + core.setFailed(`Invalid 'group_by' value: '${groupByInput}'. Must be one of: ${GROUP_BY_VALUES.join(', ')}.`) + return + } + const groupBy = groupByInput + const dryRun = core.getBooleanInput('dry_run') + const fileBestPracticeIssues = getBooleanInputWithDefault('file_best_practice_issues', true) + const fileExperimentalIssues = getBooleanInputWithDefault('file_experimental_issues', true) core.debug(`Input: 'findings_file: ${findingsFile}'`) core.debug(`Input: 'repository: ${repoWithOwner}'`) core.debug(`Input: 'base_url: ${baseUrl ?? '(default)'}'`) core.debug(`Input: 'screenshot_repository: ${screenshotRepo}'`) core.debug(`Input: 'cached_filings_file: ${cachedFilingsFile}'`) core.debug(`Input: 'open_grouped_issues: ${shouldOpenGroupedIssues}'`) + core.debug(`Input: 'group_by: ${groupBy}'`) + core.debug(`Input: 'dry_run: ${dryRun}'`) + core.debug(`Input: 'file_best_practice_issues: ${fileBestPracticeIssues}'`) + core.debug(`Input: 'file_experimental_issues: ${fileExperimentalIssues}'`) const octokit = new OctokitWithThrottling({ auth: token, @@ -56,55 +77,103 @@ export default async function () { }, }, }) - const filings = updateFilingsWithNewFindings(cachedFilings, findings) + const filings = updateFilingsWithNewFindings(cachedFilings, findings, groupBy) + + // Suppressed new filings are kept out of the cache + const suppressedFilings = new Set() + + // Fetch closed wontfix issues once up front; a failed fetch reopens as usual + let wontfixIssueNumbers = new Set() + if (!dryRun) { + try { + const [owner, repository] = repoWithOwner.split('/') + wontfixIssueNumbers = await getWontfixIssueNumbers(octokit, {owner, repository}) + } catch (error) { + core.warning(`Could not fetch '${WONTFIX_LABEL}' issues; proceeding with reopen as usual: ${error}`) + } + } // Track new issues for grouping const newIssuesByProblemShort: Record = {} const trackingIssueUrls: Record = {} + const dryRunCounts = {open: 0, reopen: 0, close: 0} for (const filing of filings) { let response: OctokitResponse | undefined try { - if (isResolvedFiling(filing)) { - // Close the filing’s issue (if necessary) - response = await closeIssue(octokit, new Issue(filing.issue)) - filing.issue.state = 'closed' - } else if (isNewFiling(filing)) { - // Open a new issue for the filing - response = await openIssue(octokit, repoWithOwner, filing.findings[0], screenshotRepo) - ;(filing as Filing).issue = {state: 'open'} as Issue + // Category switches gate only new issues + if (isNewFiling(filing)) { + const category = filing.findings[0].category ?? 'wcag' + if ( + (category === 'best-practice' && !fileBestPracticeIssues) || + (category === 'experimental' && !fileExperimentalIssues) + ) { + core.info( + `Skipping new ${category} issue (filing disabled for this category): ${filing.findings[0].problemShort}`, + ) + suppressedFilings.add(filing) + continue + } + } + + if (dryRun) { + if (isResolvedFiling(filing)) { + dryRunCounts.close++ + filing.issue.state = 'closed' + core.info(`[dry run] Would CLOSE issue: ${filing.issue.url}`) + } else if (isNewFiling(filing)) { + dryRunCounts.open++ + ;(filing as Filing).issue = {state: 'open'} as Issue + core.info( + `[dry run] Would OPEN a new issue for: ${filing.findings[0].problemShort} (${filing.findings[0].url})`, + ) + } else if (isRepeatedFiling(filing)) { + dryRunCounts.reopen++ + filing.issue.state = 'reopened' + core.info(`[dry run] Would REOPEN issue: ${filing.issue.url}`) + } + } else { + if (isResolvedFiling(filing)) { + // Close the filing's issue (if necessary) + response = await closeIssue(octokit, new Issue(filing.issue)) + filing.issue.state = 'closed' + } else if (isNewFiling(filing)) { + // Open a new issue for the filing + response = await openIssue(octokit, repoWithOwner, filing.findings, screenshotRepo) + ;(filing as Filing).issue = {state: 'open'} as Issue - // Track for grouping - if (shouldOpenGroupedIssues) { - const problemShort: string = filing.findings[0].problemShort - if (!newIssuesByProblemShort[problemShort]) { - newIssuesByProblemShort[problemShort] = [] + // Track for grouping + if (shouldOpenGroupedIssues) { + const problemShort: string = filing.findings[0].problemShort + if (!newIssuesByProblemShort[problemShort]) { + newIssuesByProblemShort[problemShort] = [] + } + newIssuesByProblemShort[problemShort].push({ + url: response.data.html_url, + id: response.data.number, + }) + } + } else if (isRepeatedFiling(filing)) { + const issue = new Issue(filing.issue) + if (!shouldReopenIssue(issue, wontfixIssueNumbers)) { + // The developer intentionally closed this issue and labeled it 'wontfix', so leave it closed + core.info(`Skipping reopen of issue labeled '${WONTFIX_LABEL}': ${filing.issue.url}`) + } else { + // Reopen the filing's issue and update the body with the latest finding(s) + response = await reopenIssue(octokit, issue, filing.findings, repoWithOwner, screenshotRepo) + filing.issue.state = 'reopened' } - newIssuesByProblemShort[problemShort].push({ - url: response.data.html_url, - id: response.data.number, - }) } - } else if (isRepeatedFiling(filing)) { - // Reopen the filing's issue (if necessary) and update the body with the latest finding - response = await reopenIssue( - octokit, - new Issue(filing.issue), - filing.findings[0], - repoWithOwner, - screenshotRepo, - ) - filing.issue.state = 'reopened' - } - if (response?.data && filing.issue) { - // Update the filing with the latest issue data - filing.issue.id = response.data.id - filing.issue.nodeId = response.data.node_id - filing.issue.url = response.data.html_url - filing.issue.title = response.data.title - core.info( - `Set issue ${response.data.title} (${repoWithOwner}#${response.data.number}) state to ${filing.issue.state}`, - ) + if (response?.data && filing.issue) { + // Update the filing with the latest issue data + filing.issue.id = response.data.id + filing.issue.nodeId = response.data.node_id + filing.issue.url = response.data.html_url + filing.issue.title = response.data.title + core.info( + `Set issue ${response.data.title} (${repoWithOwner}#${response.data.number}) state to ${filing.issue.state}`, + ) + } } } catch (error) { core.setFailed(`Failed on filing: ${JSON.stringify(filing, null, 2)}\n${error}`) @@ -114,7 +183,7 @@ export default async function () { // Open tracking issues for groups with >1 new issue and link back from each // new issue - if (shouldOpenGroupedIssues) { + if (shouldOpenGroupedIssues && !dryRun) { for (const [problemShort, issues] of Object.entries(newIssuesByProblemShort)) { if (issues.length > 1) { const capitalizedProblemShort = problemShort[0].toUpperCase() + problemShort.slice(1) @@ -138,8 +207,19 @@ export default async function () { } } + if (dryRun) { + core.info('[dry run] Summary of actions that would be taken:') + console.table({ + open: dryRunCounts.open, + reopen: dryRunCounts.reopen, + close: dryRunCounts.close, + total: dryRunCounts.open + dryRunCounts.reopen + dryRunCounts.close, + }) + } + const filingsPath = path.join(process.env.RUNNER_TEMP || '/tmp', `filings-${crypto.randomUUID()}.json`) - fs.writeFileSync(filingsPath, JSON.stringify(filings)) + const outputFilings = suppressedFilings.size > 0 ? filings.filter(f => !suppressedFilings.has(f)) : filings + fs.writeFileSync(filingsPath, JSON.stringify(outputFilings)) core.setOutput('filings_file', filingsPath) core.debug(`Output: 'filings_file: ${filingsPath}'`) diff --git a/.github/actions/file/src/openIssue.ts b/.github/actions/file/src/openIssue.ts index 937f06cf..67128d08 100644 --- a/.github/actions/file/src/openIssue.ts +++ b/.github/actions/file/src/openIssue.ts @@ -17,22 +17,29 @@ function truncateWithEllipsis(text: string, maxLength: number): string { return text.length > maxLength ? text.slice(0, maxLength - 1) + '…' : text } -export async function openIssue(octokit: Octokit, repoWithOwner: string, finding: Finding, screenshotRepo?: string) { +export async function openIssue(octokit: Octokit, repoWithOwner: string, findings: Finding[], screenshotRepo?: string) { const owner = repoWithOwner.split('/')[0] const repo = repoWithOwner.split('/')[1] + const primary = findings[0] - const labels = [`${finding.scannerType}-scanning-issue`] + const labels = [`${primary.scannerType}-scanning-issue`] // Only include a ruleId label when it's defined - if (finding.ruleId) { - labels.push(`${finding.scannerType} rule: ${finding.ruleId}`) + if (primary.ruleId) { + labels.push(`${primary.scannerType} rule: ${primary.ruleId}`) + } + // Flag non-WCAG findings so they can be filtered or triaged separately + if (primary.category && primary.category !== 'wcag') { + labels.push(primary.category) } + const count = findings.length + const titleSuffix = count > 1 ? ` (${count} occurrences)` : ` on ${new URL(primary.url).pathname}` const title = truncateWithEllipsis( - `Accessibility issue: ${finding.problemShort[0].toUpperCase() + finding.problemShort.slice(1)} on ${new URL(finding.url).pathname}`, + `Accessibility issue: ${primary.problemShort[0].toUpperCase() + primary.problemShort.slice(1)}${titleSuffix}`, GITHUB_ISSUE_TITLE_MAX_LENGTH, ) - const body = generateIssueBody(finding, screenshotRepo ?? repoWithOwner) + const body = generateIssueBody(findings, screenshotRepo ?? repoWithOwner) return octokit.request(`POST /repos/${owner}/${repo}/issues`, { owner, diff --git a/.github/actions/file/src/reopenIssue.ts b/.github/actions/file/src/reopenIssue.ts index 329c6952..10705f30 100644 --- a/.github/actions/file/src/reopenIssue.ts +++ b/.github/actions/file/src/reopenIssue.ts @@ -6,13 +6,13 @@ import {generateIssueBody} from './generateIssueBody.js' export async function reopenIssue( octokit: Octokit, {owner, repository, issueNumber}: Issue, - finding?: Finding, + findings?: Finding[], repoWithOwner?: string, screenshotRepo?: string, ) { let body: string | undefined - if (finding && repoWithOwner) { - body = generateIssueBody(finding, screenshotRepo ?? repoWithOwner) + if (findings?.length && repoWithOwner) { + body = generateIssueBody(findings, screenshotRepo ?? repoWithOwner) } return octokit.request(`PATCH /repos/${owner}/${repository}/issues/${issueNumber}`, { diff --git a/.github/actions/file/src/shouldReopenIssue.ts b/.github/actions/file/src/shouldReopenIssue.ts new file mode 100644 index 00000000..d431ed9d --- /dev/null +++ b/.github/actions/file/src/shouldReopenIssue.ts @@ -0,0 +1,40 @@ +import type {Octokit} from '@octokit/core' +import type {Issue} from './Issue.js' + +/** Issues with this label are intentionally closed and should not be reopened. */ +export const WONTFIX_LABEL = 'wontfix' + +// Fetch every closed wontfix issue once so the per-filing check is a set lookup +export async function getWontfixIssueNumbers( + octokit: Octokit, + {owner, repository}: {owner: string; repository: string}, +): Promise> { + const wontfixIssueNumbers = new Set() + const perPage = 100 + for (let page = 1; ; page++) { + const response = await octokit.request(`GET /repos/${owner}/${repository}/issues`, { + owner, + repo: repository, + state: 'closed', + labels: WONTFIX_LABEL, + per_page: perPage, + page, + }) + const issues = (response.data as Array<{number: number; pull_request?: unknown}>) ?? [] + for (const issue of issues) { + // The issues endpoint also returns pull requests; skip them + if (!issue.pull_request) { + wontfixIssueNumbers.add(issue.number) + } + } + if (issues.length < perPage) { + break + } + } + return wontfixIssueNumbers +} + +// The single place to decide whether a repeated filing's issue should reopen +export function shouldReopenIssue(issue: Issue, wontfixIssueNumbers: Set): boolean { + return !wontfixIssueNumbers.has(issue.issueNumber) +} diff --git a/.github/actions/file/src/types.d.ts b/.github/actions/file/src/types.d.ts index ee91bc67..08d85737 100644 --- a/.github/actions/file/src/types.d.ts +++ b/.github/actions/file/src/types.d.ts @@ -1,8 +1,17 @@ +export type FindingNode = { + html: string + target?: string +} + +export type FindingCategory = 'wcag' | 'best-practice' | 'experimental' + export type Finding = { scannerType: string + category?: FindingCategory ruleId?: string url: string html?: string + nodes?: FindingNode[] problemShort: string problemUrl: string solutionShort: string diff --git a/.github/actions/file/src/updateFilingsWithNewFindings.ts b/.github/actions/file/src/updateFilingsWithNewFindings.ts index eee1c6ae..e322f4ce 100644 --- a/.github/actions/file/src/updateFilingsWithNewFindings.ts +++ b/.github/actions/file/src/updateFilingsWithNewFindings.ts @@ -1,25 +1,44 @@ import type {Finding, ResolvedFiling, NewFiling, RepeatedFiling, Filing} from './types.d.js' +import type {GroupBy} from './groupBy.js' function getFilingKey(filing: ResolvedFiling | RepeatedFiling): string { return filing.issue.url } -function getFindingKey(finding: Finding): string { - if (finding.ruleId && finding.html) { - return `${finding.url};${finding.ruleId};${finding.html}` +function getFindingKey(finding: Finding, groupBy: GroupBy): string { + const rule = finding.ruleId + ? `${finding.scannerType};${finding.ruleId}` + : `${finding.scannerType};${finding.problemUrl}` + + switch (groupBy) { + case 'rule': + return rule + case 'rule+url': + return `${finding.url};${rule}` + case 'finding': + default: + // Axe groups every failing element under one rule, so key on the rule, not the + // element's HTML, which shifts with DOM changes and re-files tracked issues. + if (finding.scannerType === 'axe' && finding.ruleId) { + return `${finding.url};axe;${finding.ruleId}` + } + if (finding.ruleId && finding.html) { + return `${finding.url};${finding.ruleId};${finding.html}` + } + return `${finding.url};${finding.scannerType};${finding.problemUrl}` } - return `${finding.url};${finding.scannerType};${finding.problemUrl}` } export function updateFilingsWithNewFindings( filings: (ResolvedFiling | RepeatedFiling)[], findings: Finding[], + groupBy: GroupBy = 'finding', ): Filing[] { const filingKeys: { [key: string]: ResolvedFiling | RepeatedFiling } = {} const findingKeys: {[key: string]: string} = {} - const newFilings: NewFiling[] = [] + const newFilingKeys: {[key: string]: NewFiling} = {} // Create maps for filing and finding data from previous runs, for quick lookups for (const filing of filings) { @@ -29,21 +48,23 @@ export function updateFilingsWithNewFindings( findings: [], } for (const finding of filing.findings) { - findingKeys[getFindingKey(finding)] = getFilingKey(filing) + findingKeys[getFindingKey(finding, groupBy)] = getFilingKey(filing) } } for (const finding of findings) { - const filingKey = findingKeys[getFindingKey(finding)] + const key = getFindingKey(finding, groupBy) + const filingKey = findingKeys[key] if (filingKey) { // This finding already has an associated filing; add it to that filing's findings ;(filingKeys[filingKey] as RepeatedFiling).findings.push(finding) + } else if (newFilingKeys[key]) { + newFilingKeys[key].findings.push(finding) } else { - // This finding is new; create a new entry with no associated issue yet - newFilings.push({findings: [finding]}) + newFilingKeys[key] = {findings: [finding]} } } const updatedFilings = Object.values(filingKeys) - return [...updatedFilings, ...newFilings] + return [...updatedFilings, ...Object.values(newFilingKeys)] } diff --git a/.github/actions/file/tests/dryRun.test.ts b/.github/actions/file/tests/dryRun.test.ts new file mode 100644 index 00000000..8afea3d6 --- /dev/null +++ b/.github/actions/file/tests/dryRun.test.ts @@ -0,0 +1,232 @@ +import {describe, it, expect, vi, beforeEach, afterEach} from 'vitest' + +// --- Mock the issue-mutating helpers so we can assert they are NEVER called in dry run --- +const openIssue = vi.fn() +const reopenIssue = vi.fn() +const closeIssue = vi.fn() +vi.mock('../src/openIssue.js', () => ({openIssue: (...args: unknown[]) => openIssue(...args)})) +vi.mock('../src/reopenIssue.js', () => ({reopenIssue: (...args: unknown[]) => reopenIssue(...args)})) +vi.mock('../src/closeIssue.js', () => ({closeIssue: (...args: unknown[]) => closeIssue(...args)})) + +// --- Mock @actions/core: control inputs, capture logs/outputs --- +const inputs: Record = {} +const infoLines: string[] = [] +const outputs: Record = {} +const failedMessages: string[] = [] +vi.mock('@actions/core', () => ({ + getInput: (name: string) => inputs[name] ?? '', + getBooleanInput: (name: string) => (inputs[name] ?? 'false') === 'true', + setOutput: (name: string, value: string) => { + outputs[name] = value + }, + info: (msg: string) => { + infoLines.push(msg) + }, + debug: () => {}, + warning: () => {}, + setFailed: (msg: string) => { + failedMessages.push(msg) + }, +})) + +// --- Mock fs: feed findings/cached filings in, swallow the output write --- +const files: Record = {} +vi.mock('node:fs', () => ({ + default: { + readFileSync: (p: string) => files[p], + writeFileSync: (p: string, data: string) => { + files[p] = data + }, + }, +})) + +// --- Stub Octokit so constructing it in index.ts doesn't do anything real --- +const octokitRequest = vi.fn() +vi.mock('@octokit/core', () => ({ + Octokit: { + plugin: () => + class { + request = octokitRequest + }, + }, +})) +vi.mock('@octokit/plugin-throttling', () => ({throttling: {}})) + +import runFileAction from '../src/index.ts' + +const finding = { + scannerType: 'axe', + ruleId: 'color-contrast', + url: 'https://example.com/page', + html: 'Low contrast', + problemShort: 'elements must meet minimum color contrast ratio thresholds', + problemUrl: 'https://dequeuniversity.com/rules/axe/4.10/color-contrast', + solutionShort: 'ensure sufficient contrast', +} + +// A second finding with no matching cached filing -> NEW (open) +const newFinding = {...finding, ruleId: 'heading-order', html: '

Skipped

'} + +// A cached filing whose finding matches `finding` -> REPEATED (reopen) +const repeatedCached = { + issue: {id: 1, nodeId: 'N1', url: 'https://github.com/org/repo/issues/1', title: 'repeat'}, + findings: [finding], +} + +// A cached filing with NO matching finding this run -> RESOLVED (close) +const resolvedCached = { + issue: {id: 2, nodeId: 'N2', url: 'https://github.com/org/repo/issues/2', title: 'resolved'}, + findings: [{...finding, ruleId: 'landmark-one-main', html: '
old
'}], +} + +function setup() { + // findings file: includes `finding` (matches repeatedCached) and `newFinding` (brand new) + files['/tmp/findings.json'] = JSON.stringify([finding, newFinding]) + // cached filings: one repeated, one resolved (its finding is absent from findings file) + files['/tmp/cached.json'] = JSON.stringify([repeatedCached, resolvedCached]) + inputs.findings_file = '/tmp/findings.json' + inputs.cached_filings_file = '/tmp/cached.json' + inputs.repository = 'org/repo' + inputs.token = 'fake-token' +} + +describe('file action — dry_run', () => { + beforeEach(() => { + vi.clearAllMocks() + infoLines.length = 0 + failedMessages.length = 0 + for (const k of Object.keys(inputs)) delete inputs[k] + for (const k of Object.keys(outputs)) delete outputs[k] + vi.spyOn(console, 'table').mockImplementation(() => {}) + }) + afterEach(() => { + vi.restoreAllMocks() + }) + + it('does not open, reopen, or close any issues when dry_run is true', async () => { + setup() + inputs.dry_run = 'true' + + await runFileAction() + + expect(openIssue).not.toHaveBeenCalled() + expect(reopenIssue).not.toHaveBeenCalled() + expect(closeIssue).not.toHaveBeenCalled() + expect(octokitRequest).not.toHaveBeenCalled() + }) + + it('logs the intended action for each filing type', async () => { + setup() + inputs.dry_run = 'true' + + await runFileAction() + + const log = infoLines.join('\n') + expect(log).toContain( + '[dry run] Would OPEN a new issue for: elements must meet minimum color contrast ratio thresholds (https://example.com/page)', + ) + expect(log).toContain('[dry run] Would REOPEN issue: https://github.com/org/repo/issues/1') + expect(log).toContain('[dry run] Would CLOSE issue: https://github.com/org/repo/issues/2') + }) + + it('logs a summary table with counts', async () => { + setup() + inputs.dry_run = 'true' + + await runFileAction() + + expect(vi.mocked(console.table)).toHaveBeenCalledWith( + expect.objectContaining({open: 1, reopen: 1, close: 1, total: 3}), + ) + }) + + it('still writes the filings_file output in dry run', async () => { + setup() + inputs.dry_run = 'true' + + await runFileAction() + + expect(outputs.filings_file).toBeDefined() + }) + + it('updates in-memory issue state for an accurate preview without mutating remotely', async () => { + setup() + inputs.dry_run = 'true' + + await runFileAction() + + // The path written is `${RUNNER_TEMP||'/tmp'}/filings-.json`; grab it from the output. + const writtenPath = outputs.filings_file + const writtenFilings = JSON.parse(files[writtenPath]) + + // Resolved cached filing (issues/2) -> would be CLOSED + const resolved = writtenFilings.find( + (f: {issue?: {url?: string}}) => f.issue?.url === 'https://github.com/org/repo/issues/2', + ) + expect(resolved?.issue.state).toBe('closed') + + // Repeated cached filing (issues/1) -> would be REOPENED + const repeated = writtenFilings.find( + (f: {issue?: {url?: string}}) => f.issue?.url === 'https://github.com/org/repo/issues/1', + ) + expect(repeated?.issue.state).toBe('reopened') + + // New filing -> issue object created with state 'open' + const opened = writtenFilings.find((f: {issue?: {state?: string}}) => f.issue?.state === 'open') + expect(opened).toBeDefined() + + // And confirm we still didn't actually mutate anything remotely + expect(openIssue).not.toHaveBeenCalled() + expect(reopenIssue).not.toHaveBeenCalled() + expect(closeIssue).not.toHaveBeenCalled() + }) + + it('does call the mutating helpers when dry_run is false (regression guard)', async () => { + setup() + inputs.dry_run = 'false' + // helpers return a minimal Octokit-style response so index.ts can read response.data + const resp = {data: {id: 9, node_id: 'N', number: 9, html_url: 'https://github.com/org/repo/issues/9', title: 't'}} + openIssue.mockResolvedValue(resp) + reopenIssue.mockResolvedValue(resp) + closeIssue.mockResolvedValue(resp) + // the wontfix-label check issues a GET before reopening; return no labels so the reopen proceeds + octokitRequest.mockResolvedValue({data: {labels: []}}) + + await runFileAction() + + expect(openIssue).toHaveBeenCalled() + expect(reopenIssue).toHaveBeenCalled() + expect(closeIssue).toHaveBeenCalled() + }) + + it("group_by 'rule' collapses multiple same-rule findings into a single OPEN", async () => { + // Three brand-new color-contrast findings across two URLs, no cached filings. + const ccA1 = {...finding, url: 'https://example.com/a', html: '1'} + const ccA2 = {...finding, url: 'https://example.com/a', html: '2'} + const ccB1 = {...finding, url: 'https://example.com/b', html: '3'} + files['/tmp/findings.json'] = JSON.stringify([ccA1, ccA2, ccB1]) + files['/tmp/cached.json'] = JSON.stringify([]) + inputs.findings_file = '/tmp/findings.json' + inputs.cached_filings_file = '/tmp/cached.json' + inputs.repository = 'org/repo' + inputs.token = 'fake-token' + inputs.dry_run = 'true' + inputs.group_by = 'rule' + + await runFileAction() + + expect(vi.mocked(console.table)).toHaveBeenCalledWith(expect.objectContaining({open: 1})) + }) + + it('fails fast on an invalid group_by value', async () => { + setup() + inputs.group_by = 'bogus' + + await runFileAction() + + expect(failedMessages.join('\n')).toContain("Invalid 'group_by' value: 'bogus'") + expect(openIssue).not.toHaveBeenCalled() + expect(reopenIssue).not.toHaveBeenCalled() + expect(closeIssue).not.toHaveBeenCalled() + }) +}) diff --git a/.github/actions/file/tests/generateIssueBody.test.ts b/.github/actions/file/tests/generateIssueBody.test.ts index 167ee5f8..60a06db7 100644 --- a/.github/actions/file/tests/generateIssueBody.test.ts +++ b/.github/actions/file/tests/generateIssueBody.test.ts @@ -26,6 +26,7 @@ describe('generateIssueBody', () => { expect(body).toContain('## What') expect(body).toContain('## Acceptance Criteria') expect(body).toContain('The specific violation reported in this issue is no longer reproducible.') + expect(body).toContain('The fix MUST meet WCAG 2.2 guidelines OR') expect(body).not.toContain('Specifically:') }) @@ -76,4 +77,65 @@ describe('generateIssueBody', () => { expect(body).toContain(`found an issue on ${findingWithEmptyOptionalFields.url}`) expect(body).not.toContain('flagged the element') }) + + it('lists every node when the finding carries multiple elements', () => { + const body = generateIssueBody( + { + ...baseFinding, + html: 'first', + nodes: [ + {html: 'first', target: 'span.first'}, + {html: 'link', target: 'a.link'}, + ], + }, + 'github/accessibility-scanner', + ) + + expect(body).toContain('flagged 2 elements') + expect(body).toContain('- `first` (selector: `span.first`)') + expect(body).toContain('- `link` (selector: `a.link`)') + expect(body).not.toContain('flagged the element') + }) + + it('omits the Occurrences section for a single finding', () => { + const body = generateIssueBody(baseFinding, 'github/accessibility-scanner') + + expect(body).not.toContain('Other Occurrences') + }) + + it('renders an Occurrences checklist when given multiple findings', () => { + const second = {...baseFinding, url: 'https://example.com/other', html: 'Link'} + const body = generateIssueBody([baseFinding, second], 'github/accessibility-scanner') + + expect(body).toContain('## 2 Other Occurrences:') + expect(body).toContain(`- [ ] \`${baseFinding.html}\` on ${baseFinding.url}`) + expect(body).toContain(`- [ ] \`${second.html}\` on ${second.url}`) + }) + + it('omits the category notice for WCAG findings', () => { + expect(generateIssueBody(baseFinding, 'github/accessibility-scanner')).not.toContain('> [!NOTE]') + expect(generateIssueBody({...baseFinding, category: 'wcag'}, 'github/accessibility-scanner')).not.toContain( + '> [!NOTE]', + ) + }) + + it('includes a best-practice notice for best-practice findings', () => { + const body = generateIssueBody({...baseFinding, category: 'best-practice'}, 'github/accessibility-scanner') + + expect(body).toContain('> [!NOTE]') + expect(body).toContain('best-practice recommendation') + expect(body).toContain('not a definite WCAG failure') + expect(body).toContain('WCAG 2.2 if applicable') + expect(body).not.toContain('The fix MUST meet WCAG 2.2 guidelines OR') + }) + + it('includes an experimental notice for experimental findings', () => { + const body = generateIssueBody({...baseFinding, category: 'experimental'}, 'github/accessibility-scanner') + + expect(body).toContain('> [!NOTE]') + expect(body).toContain('an experimental check') + expect(body).toContain('not a definite WCAG failure') + expect(body).toContain('WCAG 2.2 if applicable') + expect(body).not.toContain('The fix MUST meet WCAG 2.2 guidelines OR') + }) }) diff --git a/.github/actions/file/tests/openIssue.test.ts b/.github/actions/file/tests/openIssue.test.ts index 77a184c3..890f8438 100644 --- a/.github/actions/file/tests/openIssue.test.ts +++ b/.github/actions/file/tests/openIssue.test.ts @@ -28,21 +28,21 @@ function mockOctokit() { describe('openIssue', () => { it('passes screenshotRepo to generateIssueBody when provided', async () => { const octokit = mockOctokit() - await openIssue(octokit, 'org/filing-repo', baseFinding, 'org/workflow-repo') + await openIssue(octokit, 'org/filing-repo', [baseFinding], 'org/workflow-repo') - expect(generateIssueBody).toHaveBeenCalledWith(baseFinding, 'org/workflow-repo') + expect(generateIssueBody).toHaveBeenCalledWith([baseFinding], 'org/workflow-repo') }) it('falls back to repoWithOwner when screenshotRepo is not provided', async () => { const octokit = mockOctokit() - await openIssue(octokit, 'org/filing-repo', baseFinding) + await openIssue(octokit, 'org/filing-repo', [baseFinding]) - expect(generateIssueBody).toHaveBeenCalledWith(baseFinding, 'org/filing-repo') + expect(generateIssueBody).toHaveBeenCalledWith([baseFinding], 'org/filing-repo') }) it('posts to the correct filing repo, not the screenshot repo', async () => { const octokit = mockOctokit() - await openIssue(octokit, 'org/filing-repo', baseFinding, 'org/workflow-repo') + await openIssue(octokit, 'org/filing-repo', [baseFinding], 'org/workflow-repo') expect(octokit.request).toHaveBeenCalledWith( 'POST /repos/org/filing-repo/issues', @@ -55,7 +55,7 @@ describe('openIssue', () => { it('includes the correct labels based on the finding', async () => { const octokit = mockOctokit() - await openIssue(octokit, 'org/repo', baseFinding) + await openIssue(octokit, 'org/repo', [baseFinding]) expect(octokit.request).toHaveBeenCalledWith( expect.any(String), @@ -65,16 +65,48 @@ describe('openIssue', () => { ) }) + it('adds a category label for non-WCAG findings', async () => { + const octokit = mockOctokit() + await openIssue(octokit, 'org/repo', [{...baseFinding, category: 'best-practice'}]) + + expect(octokit.request).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + labels: ['axe-scanning-issue', 'axe rule: color-contrast', 'best-practice'], + }), + ) + }) + + it('does not add a category label for WCAG findings', async () => { + const octokit = mockOctokit() + await openIssue(octokit, 'org/repo', [{...baseFinding, category: 'wcag'}]) + + const labels = octokit.request.mock.calls[0][1].labels + expect(labels).not.toContain('wcag') + expect(labels).not.toContain('best-practice') + expect(labels).not.toContain('experimental') + }) + it('truncates long titles with ellipsis', async () => { const octokit = mockOctokit() const longFinding = { ...baseFinding, problemShort: 'a'.repeat(300), } - await openIssue(octokit, 'org/repo', longFinding) + await openIssue(octokit, 'org/repo', [longFinding]) const callArgs = octokit.request.mock.calls[0][1] expect(callArgs.title.length).toBeLessThanOrEqual(256) expect(callArgs.title).toMatch(/…$/) }) + + it('includes an occurrence count in the title when grouping multiple findings', async () => { + const octokit = mockOctokit() + const second = {...baseFinding, url: 'https://example.com/other', html: 'Another'} + await openIssue(octokit, 'org/repo', [baseFinding, second]) + + const callArgs = octokit.request.mock.calls[0][1] + expect(callArgs.title).toContain('(2 occurrences)') + expect(generateIssueBody).toHaveBeenCalledWith([baseFinding, second], 'org/repo') + }) }) diff --git a/.github/actions/file/tests/reopenIssue.test.ts b/.github/actions/file/tests/reopenIssue.test.ts index f5b34ef8..a739a3f2 100644 --- a/.github/actions/file/tests/reopenIssue.test.ts +++ b/.github/actions/file/tests/reopenIssue.test.ts @@ -41,16 +41,16 @@ describe('reopenIssue', () => { it('passes screenshotRepo to generateIssueBody when provided', async () => { const octokit = mockOctokit() - await reopenIssue(octokit, testIssue, baseFinding, 'org/filing-repo', 'org/workflow-repo') + await reopenIssue(octokit, testIssue, [baseFinding], 'org/filing-repo', 'org/workflow-repo') - expect(generateIssueBody).toHaveBeenCalledWith(baseFinding, 'org/workflow-repo') + expect(generateIssueBody).toHaveBeenCalledWith([baseFinding], 'org/workflow-repo') }) it('falls back to repoWithOwner when screenshotRepo is not provided', async () => { const octokit = mockOctokit() - await reopenIssue(octokit, testIssue, baseFinding, 'org/filing-repo') + await reopenIssue(octokit, testIssue, [baseFinding], 'org/filing-repo') - expect(generateIssueBody).toHaveBeenCalledWith(baseFinding, 'org/filing-repo') + expect(generateIssueBody).toHaveBeenCalledWith([baseFinding], 'org/filing-repo') }) it('does not generate a body when finding is not provided', async () => { @@ -66,14 +66,14 @@ describe('reopenIssue', () => { it('does not generate a body when repoWithOwner is not provided', async () => { const octokit = mockOctokit() - await reopenIssue(octokit, testIssue, baseFinding) + await reopenIssue(octokit, testIssue, [baseFinding]) expect(generateIssueBody).not.toHaveBeenCalled() }) it('sends PATCH to the correct issue URL with state open', async () => { const octokit = mockOctokit() - await reopenIssue(octokit, testIssue, baseFinding, 'org/filing-repo', 'org/workflow-repo') + await reopenIssue(octokit, testIssue, [baseFinding], 'org/filing-repo', 'org/workflow-repo') expect(octokit.request).toHaveBeenCalledWith( 'PATCH /repos/org/filing-repo/issues/7', @@ -86,7 +86,7 @@ describe('reopenIssue', () => { it('includes generated body when finding and repoWithOwner are provided', async () => { const octokit = mockOctokit() - await reopenIssue(octokit, testIssue, baseFinding, 'org/filing-repo', 'org/workflow-repo') + await reopenIssue(octokit, testIssue, [baseFinding], 'org/filing-repo', 'org/workflow-repo') expect(octokit.request).toHaveBeenCalledWith( expect.any(String), diff --git a/.github/actions/file/tests/shouldReopenIssue.test.ts b/.github/actions/file/tests/shouldReopenIssue.test.ts new file mode 100644 index 00000000..7eedc13c --- /dev/null +++ b/.github/actions/file/tests/shouldReopenIssue.test.ts @@ -0,0 +1,90 @@ +import {describe, it, expect, vi, beforeEach} from 'vitest' + +import {getWontfixIssueNumbers, shouldReopenIssue, WONTFIX_LABEL} from '../src/shouldReopenIssue.ts' +import {Issue} from '../src/Issue.ts' + +function issueAt(issueNumber: number): Issue { + return new Issue({ + id: issueNumber, + nodeId: `node-${issueNumber}`, + url: `https://github.com/org/filing-repo/issues/${issueNumber}`, + title: `Accessibility issue ${issueNumber}`, + state: 'closed', + }) +} + +// `pages` is consumed one response per request call, in order. +function mockOctokit(pages: Array>) { + const request = vi.fn() + for (const page of pages) { + request.mockResolvedValueOnce({data: page}) + } + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return {request} as any +} + +describe('getWontfixIssueNumbers', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('returns the numbers of closed wontfix issues as a set', async () => { + const octokit = mockOctokit([[{number: 1}, {number: 5}, {number: 9}]]) + + const result = await getWontfixIssueNumbers(octokit, {owner: 'org', repository: 'repo'}) + + expect(result).toEqual(new Set([1, 5, 9])) + }) + + it('requests closed issues filtered by the wontfix label', async () => { + const octokit = mockOctokit([[]]) + + await getWontfixIssueNumbers(octokit, {owner: 'org', repository: 'repo'}) + + expect(octokit.request).toHaveBeenCalledWith( + 'GET /repos/org/repo/issues', + expect.objectContaining({state: 'closed', labels: WONTFIX_LABEL}), + ) + }) + + it('returns an empty set when no issues are labeled wontfix', async () => { + const octokit = mockOctokit([[]]) + + const result = await getWontfixIssueNumbers(octokit, {owner: 'org', repository: 'repo'}) + + expect(result.size).toBe(0) + }) + + it('paginates until a short page is returned', async () => { + const firstPage = Array.from({length: 100}, (_, i) => ({number: i + 1})) + const octokit = mockOctokit([firstPage, [{number: 101}]]) + + const result = await getWontfixIssueNumbers(octokit, {owner: 'org', repository: 'repo'}) + + expect(octokit.request).toHaveBeenCalledTimes(2) + expect(result.has(1)).toBe(true) + expect(result.has(101)).toBe(true) + }) + + it('ignores pull requests returned by the issues endpoint', async () => { + const octokit = mockOctokit([[{number: 2}, {number: 3, pull_request: {url: 'https://example.com/pull/3'}}]]) + + const result = await getWontfixIssueNumbers(octokit, {owner: 'org', repository: 'repo'}) + + expect(result).toEqual(new Set([2])) + }) +}) + +describe('shouldReopenIssue', () => { + it('returns false when the issue is in the wontfix set', () => { + expect(shouldReopenIssue(issueAt(7), new Set([7]))).toBe(false) + }) + + it('returns true when the issue is not in the wontfix set', () => { + expect(shouldReopenIssue(issueAt(7), new Set([1, 2, 3]))).toBe(true) + }) + + it('returns true when the wontfix set is empty', () => { + expect(shouldReopenIssue(issueAt(7), new Set())).toBe(true) + }) +}) diff --git a/.github/actions/file/tests/updateFilingsWithNewFindings.test.ts b/.github/actions/file/tests/updateFilingsWithNewFindings.test.ts new file mode 100644 index 00000000..e5c2aef4 --- /dev/null +++ b/.github/actions/file/tests/updateFilingsWithNewFindings.test.ts @@ -0,0 +1,162 @@ +import {describe, it, expect} from 'vitest' +import {updateFilingsWithNewFindings} from '../src/updateFilingsWithNewFindings.ts' +import type {Finding, RepeatedFiling} from '../src/types.d.ts' + +const cachedFinding: Finding = { + scannerType: 'axe', + ruleId: 'color-contrast', + url: 'https://example.com/', + html: '', + nodes: [{html: '', target: 'span.post-meta'}], + problemShort: 'elements must meet minimum color contrast ratio thresholds', + problemUrl: 'https://dequeuniversity.com/rules/axe/4.10/color-contrast?application=playwright', + solutionShort: 'ensure the contrast meets WCAG thresholds', +} + +const cachedFiling: RepeatedFiling = { + issue: { + id: 1, + nodeId: 'node-1', + url: 'https://github.com/org/repo/issues/1', + title: 'Accessibility issue: color contrast on /', + }, + findings: [cachedFinding], +} + +describe('updateFilingsWithNewFindings', () => { + it('re-matches an axe finding to its existing issue after the element HTML shifts', () => { + // Same rule and page, but the element's markup shifted; should still map to issue #1. + const shiftedFinding: Finding = { + ...cachedFinding, + html: '', + nodes: [ + {html: '', target: 'div > span.post-meta'}, + ], + } + + const result = updateFilingsWithNewFindings([cachedFiling], [shiftedFinding]) + + expect(result).toHaveLength(1) + const filing = result[0] as RepeatedFiling + expect(filing.issue.url).toBe('https://github.com/org/repo/issues/1') + expect(filing.findings).toHaveLength(1) + expect(filing.findings[0].html).toContain('new container') + }) + + it('files a new issue when a different rule fails on the same page', () => { + const differentRule: Finding = { + ...cachedFinding, + ruleId: 'image-alt', + html: '', + nodes: [{html: '', target: 'img'}], + } + + const result = updateFilingsWithNewFindings([cachedFiling], [differentRule]) + + expect(result).toHaveLength(2) + const newFilings = result.filter(filing => filing.issue === undefined) + expect(newFilings).toHaveLength(1) + expect(newFilings[0].findings[0].ruleId).toBe('image-alt') + }) +}) + +const colorContrastFinding = (url: string, html: string) => ({ + scannerType: 'axe', + ruleId: 'color-contrast', + url, + html, + problemShort: 'elements must meet minimum color contrast ratio thresholds', + problemUrl: 'https://dequeuniversity.com/rules/axe/4.10/color-contrast', + solutionShort: 'ensure sufficient contrast', +}) + +describe('updateFilingsWithNewFindings — group_by', () => { + const findings = [ + colorContrastFinding('https://example.com/a', '1'), + colorContrastFinding('https://example.com/a', '2'), + colorContrastFinding('https://example.com/b', '3'), + ] + + it("defaults to 'finding': axe findings collapse by rule and URL", () => { + const result = updateFilingsWithNewFindings([], findings) + // /a color-contrast (x2) share one filing; /b color-contrast is its own. + expect(result).toHaveLength(2) + const counts = result.map(f => f.findings.length).sort() + expect(counts).toEqual([1, 2]) + }) + + it("'rule': collapses all occurrences of a rule into a single filing", () => { + const result = updateFilingsWithNewFindings([], findings, 'rule') + expect(result).toHaveLength(1) + expect(result[0].findings).toHaveLength(3) + }) + + it("'rule+url': one filing per rule per URL", () => { + const result = updateFilingsWithNewFindings([], findings, 'rule+url') + expect(result).toHaveLength(2) + const counts = result.map(f => f.findings.length).sort() + expect(counts).toEqual([1, 2]) // 2 on /a, 1 on /b + }) + + it("'rule': appends new occurrences to an existing cached filing instead of opening a new issue", () => { + const cached = [ + { + issue: { + id: 1, + nodeId: 'N1', + url: 'https://github.com/org/repo/issues/1', + title: 'color-contrast', + }, + findings: [colorContrastFinding('https://example.com/a', '1')], + }, + ] + const result = updateFilingsWithNewFindings(cached, findings, 'rule') + // No brand-new filing; all three findings attach to the cached issue. + expect(result).toHaveLength(1) + expect(result[0].issue?.url).toBe('https://github.com/org/repo/issues/1') + expect(result[0].findings).toHaveLength(3) + }) + + it("keeps distinct rules separate under 'rule'", () => { + const mixed = [ + colorContrastFinding('https://example.com/a', '1'), + {...colorContrastFinding('https://example.com/a', '

x

'), ruleId: 'heading-order'}, + ] + const result = updateFilingsWithNewFindings([], mixed, 'rule') + expect(result).toHaveLength(2) + }) + + it("'rule': does not merge findings from different scanners that share a ruleId", () => { + const a = { + ...colorContrastFinding('https://example.com/a', '1'), + scannerType: 'axe', + ruleId: 'duplicate-id', + } + const b = { + ...colorContrastFinding('https://example.com/a', '2'), + scannerType: 'reflow', + ruleId: 'duplicate-id', + } + const result = updateFilingsWithNewFindings([], [a, b], 'rule') + expect(result).toHaveLength(2) + }) + + it("'finding' (default) re-matches axe findings to a cached filing by rule and URL", () => { + const cached = [ + { + issue: { + id: 1, + nodeId: 'N1', + url: 'https://github.com/org/repo/issues/1', + title: 'color-contrast', + }, + findings: [colorContrastFinding('https://example.com/a', '1')], + }, + ] + const result = updateFilingsWithNewFindings(cached, findings) + // Both /a color-contrast findings attach to issues/1; /b opens one new filing. + expect(result).toHaveLength(2) + const repeated = result.find(f => f.issue?.url === 'https://github.com/org/repo/issues/1') + expect(repeated?.findings).toHaveLength(2) + }) +}) diff --git a/.github/actions/file/tests/wontfixReopen.test.ts b/.github/actions/file/tests/wontfixReopen.test.ts new file mode 100644 index 00000000..b938d2b3 --- /dev/null +++ b/.github/actions/file/tests/wontfixReopen.test.ts @@ -0,0 +1,135 @@ +import {describe, it, expect, vi, beforeEach, afterEach} from 'vitest' + +const openIssue = vi.fn() +const reopenIssue = vi.fn() +const closeIssue = vi.fn() +vi.mock('../src/openIssue.js', () => ({openIssue: (...args: unknown[]) => openIssue(...args)})) +vi.mock('../src/reopenIssue.js', () => ({reopenIssue: (...args: unknown[]) => reopenIssue(...args)})) +vi.mock('../src/closeIssue.js', () => ({closeIssue: (...args: unknown[]) => closeIssue(...args)})) + +const inputs: Record = {} +const infoLines: string[] = [] +const warnLines: string[] = [] +const outputs: Record = {} +vi.mock('@actions/core', () => ({ + getInput: (name: string) => inputs[name] ?? '', + getBooleanInput: (name: string) => (inputs[name] ?? 'false') === 'true', + setOutput: (name: string, value: string) => { + outputs[name] = value + }, + info: (msg: string) => { + infoLines.push(msg) + }, + debug: () => {}, + warning: (msg: string) => { + warnLines.push(msg) + }, + setFailed: () => {}, +})) + +// Feed findings/cached filings in +const files: Record = {} +vi.mock('node:fs', () => ({ + default: { + readFileSync: (p: string) => files[p], + writeFileSync: (p: string, data: string) => { + files[p] = data + }, + }, +})) + +// Stub Octokit: `request` serves the list of closed `wontfix` issues that +// getWontfixIssueNumbers fetches once up front. +const octokitRequest = vi.fn() +vi.mock('@octokit/core', () => ({ + Octokit: { + plugin: () => + class { + request = octokitRequest + }, + }, +})) +vi.mock('@octokit/plugin-throttling', () => ({throttling: {}})) + +import runFileAction from '../src/index.ts' + +const wontfixFinding = { + scannerType: 'axe', + ruleId: 'color-contrast', + url: 'https://example.com/page', + html: 'Low contrast', + problemShort: 'elements must meet minimum color contrast ratio thresholds', + problemUrl: 'https://dequeuniversity.com/rules/axe/4.10/color-contrast', + solutionShort: 'ensure sufficient contrast', +} +const normalFinding = {...wontfixFinding, ruleId: 'heading-order', html: '

Skipped

'} + +// Both cached filings' findings reappear this run, so both are repeated +const wontfixCached = { + issue: {id: 1, nodeId: 'N1', url: 'https://github.com/org/repo/issues/1', title: 'wontfix'}, + findings: [wontfixFinding], +} +const normalCached = { + issue: {id: 3, nodeId: 'N3', url: 'https://github.com/org/repo/issues/3', title: 'normal'}, + findings: [normalFinding], +} + +function setup() { + files['/tmp/findings.json'] = JSON.stringify([wontfixFinding, normalFinding]) + files['/tmp/cached.json'] = JSON.stringify([wontfixCached, normalCached]) + inputs.findings_file = '/tmp/findings.json' + inputs.cached_filings_file = '/tmp/cached.json' + inputs.repository = 'org/repo' + inputs.token = 'fake-token' + // Single up-front fetch: only issue 1 is closed and labeled wontfix + octokitRequest.mockImplementation((route: string) => + route.includes('GET /repos/org/repo/issues') ? Promise.resolve({data: [{number: 1}]}) : Promise.resolve({data: {}}), + ) +} + +describe('file action — wontfix label', () => { + beforeEach(() => { + vi.clearAllMocks() + infoLines.length = 0 + warnLines.length = 0 + for (const k of Object.keys(inputs)) delete inputs[k] + for (const k of Object.keys(outputs)) delete outputs[k] + }) + afterEach(() => { + vi.restoreAllMocks() + }) + + it('reopens the unlabeled issue but not the one labeled wontfix', async () => { + setup() + + await runFileAction() + + expect(reopenIssue).toHaveBeenCalledTimes(1) + const reopenedIssue = reopenIssue.mock.calls[0][1] as {url: string} + expect(reopenedIssue.url).toBe('https://github.com/org/repo/issues/3') + expect(openIssue).not.toHaveBeenCalled() + expect(closeIssue).not.toHaveBeenCalled() + }) + + it('logs that it skipped the wontfix issue', async () => { + setup() + + await runFileAction() + + expect(infoLines.join('\n')).toContain( + "Skipping reopen of issue labeled 'wontfix': https://github.com/org/repo/issues/1", + ) + }) + + it('reopens as usual (and warns) when the label check fails', async () => { + setup() + // The up-front wontfix fetch fails (e.g. transient API error) + octokitRequest.mockRejectedValue(new Error('boom')) + + await runFileAction() + + // Both repeated filings should still be reopened rather than aborting the run + expect(reopenIssue).toHaveBeenCalledTimes(2) + expect(warnLines.join('\n')).toContain("Could not fetch 'wontfix' issues") + }) +}) diff --git a/.github/actions/find/package-lock.json b/.github/actions/find/package-lock.json index 60a9751c..410fb747 100644 --- a/.github/actions/find/package-lock.json +++ b/.github/actions/find/package-lock.json @@ -15,7 +15,7 @@ "playwright": "^1.60.0" }, "devDependencies": { - "@types/node": "^25.7.0", + "@types/node": "^25.9.0", "typescript": "^6.0.3" } }, @@ -483,13 +483,13 @@ } }, "node_modules/@types/node": { - "version": "25.7.0", - "resolved": "https://registry.npmjs.org/@types/node/-/node-25.7.0.tgz", - "integrity": "sha512-z+pdZyxE+RTQE9AcboAZCb4otwcrvgHD+GlBpPgn0emDVt0ohrTMhAwlr2Wd9nZ+nihhYFxO2pThz3C5qSu2Eg==", + "version": "25.9.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.0.tgz", + "integrity": "sha512-AOQwYUNolgy3VosiRqXrACUXTN8nJUtPl7FJXMqZVyxiiCLhQuG3jXKvCS1ALr+Y2OmZhzzLVlYPEqJaiqkaJQ==", "dev": true, "license": "MIT", "dependencies": { - "undici-types": "~7.21.0" + "undici-types": ">=7.24.0 <7.24.7" } }, "node_modules/axe-core": { @@ -619,9 +619,9 @@ } }, "node_modules/undici-types": { - "version": "7.21.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.21.0.tgz", - "integrity": "sha512-w9IMgQrz4O0YN1LtB7K5P63vhlIOvC7opSmouCJ+ZywlPAlO9gIkJ+otk6LvGpAs2wg4econaCz3TvQ9xPoyuQ==", + "version": "7.24.6", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz", + "integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==", "dev": true, "license": "MIT" } diff --git a/.github/actions/find/package.json b/.github/actions/find/package.json index 92f464bd..7f3fd7ab 100644 --- a/.github/actions/find/package.json +++ b/.github/actions/find/package.json @@ -19,7 +19,7 @@ "playwright": "^1.60.0" }, "devDependencies": { - "@types/node": "^25.7.0", + "@types/node": "^25.9.0", "typescript": "^6.0.3" } } diff --git a/.github/actions/find/src/findForUrl.ts b/.github/actions/find/src/findForUrl.ts index d9f1ea87..a93cd6b9 100644 --- a/.github/actions/find/src/findForUrl.ts +++ b/.github/actions/find/src/findForUrl.ts @@ -1,4 +1,4 @@ -import type {ColorSchemePreference, Finding, ReducedMotionPreference, UrlConfig} from './types.d.js' +import type {ColorSchemePreference, Finding, FindingCategory, ReducedMotionPreference, UrlConfig} from './types.d.js' import {AxeBuilder} from '@axe-core/playwright' import playwright from 'playwright' import {AuthContext} from './AuthContext.js' @@ -85,10 +85,16 @@ async function runAxeScan({ if (rawFindings) { for (const violation of rawFindings.violations) { + // Capture every failing element, not just the first, so one issue covers the rule. await addFinding({ scannerType: 'axe', + category: categorizeAxeViolation(violation.tags), url, html: violation.nodes[0].html.replace(/'/g, '''), + nodes: violation.nodes.map(node => ({ + html: node.html.replace(/'/g, '''), + target: node.target.map(part => (Array.isArray(part) ? part.join(' ') : part)).join(' '), + })), problemShort: violation.help.toLowerCase().replace(/'/g, '''), problemUrl: violation.helpUrl.replace(/'/g, '''), ruleId: violation.id, @@ -98,3 +104,11 @@ async function runAxeScan({ } } } + +// Maps an Axe violation's tags to a conformance tier. Experimental is checked +// first because some experimental rules also carry a wcag* tag. +function categorizeAxeViolation(tags: string[]): FindingCategory { + if (tags.includes('experimental')) return 'experimental' + if (tags.includes('best-practice')) return 'best-practice' + return 'wcag' +} diff --git a/.github/actions/find/src/types.d.ts b/.github/actions/find/src/types.d.ts index dcbc8600..4102c324 100644 --- a/.github/actions/find/src/types.d.ts +++ b/.github/actions/find/src/types.d.ts @@ -1,7 +1,16 @@ +export type FindingNode = { + html: string + target?: string +} + +export type FindingCategory = 'wcag' | 'best-practice' | 'experimental' + export type Finding = { scannerType: string + category?: FindingCategory url: string html?: string + nodes?: FindingNode[] problemShort: string problemUrl: string solutionShort: string diff --git a/.github/actions/find/tests/findForUrl.test.ts b/.github/actions/find/tests/findForUrl.test.ts index 85299c5c..31a00b3f 100644 --- a/.github/actions/find/tests/findForUrl.test.ts +++ b/.github/actions/find/tests/findForUrl.test.ts @@ -117,4 +117,69 @@ describe('findForUrl', () => { expect(loadedPlugins[1].default).toHaveBeenCalledTimes(0) }) }) + + it('captures every failing element of an axe violation as nodes', async () => { + actionInput = '' + clearAll() + + const violation = { + id: 'color-contrast', + help: 'Elements must meet minimum color contrast ratio thresholds', + helpUrl: 'https://dequeuniversity.com/rules/axe/4.10/color-contrast', + description: 'Ensure contrast meets WCAG thresholds', + tags: ['wcag2aa', 'wcag143'], + nodes: [ + {html: 'one', target: ['span.one'], failureSummary: 'Fix any of the following:'}, + {html: 'two', target: ['div', 'span.two'], failureSummary: 'Fix any of the following:'}, + ], + } + vi.mocked(AxeBuilder.prototype.analyze).mockResolvedValueOnce({ + violations: [violation], + } as unknown as axe.AxeResults) + + const findings = await findForUrl('test.com') + + expect(findings).toHaveLength(1) + expect(findings[0].html).toBe('one') + expect(findings[0].nodes).toEqual([ + {html: 'one', target: 'span.one'}, + {html: 'two', target: 'div span.two'}, + ]) + }) + + describe('axe finding categorization', () => { + function axeViolation(tags: string[]) { + return { + id: 'some-rule', + help: 'Help', + helpUrl: 'https://example.com', + description: 'Description', + tags, + nodes: [{html: '
', target: ['div'], failureSummary: 'summary'}], + } + } + + async function categoryFor(tags: string[]) { + clearAll() + actionInput = JSON.stringify(['axe']) + vi.mocked(AxeBuilder.prototype.analyze).mockResolvedValueOnce({ + violations: [axeViolation(tags)], + } as unknown as axe.AxeResults) + + const findings = await findForUrl('test.com') + return findings[0].category + } + + it('categorizes a violation with only wcag tags as wcag', async () => { + expect(await categoryFor(['wcag2a', 'wcag111'])).toBe('wcag') + }) + + it('categorizes a violation with a best-practice tag as best-practice', async () => { + expect(await categoryFor(['cat.semantics', 'best-practice'])).toBe('best-practice') + }) + + it('categorizes a violation with an experimental tag as experimental, even alongside wcag tags', async () => { + expect(await categoryFor(['wcag2a', 'experimental'])).toBe('experimental') + }) + }) }) diff --git a/.github/actions/fix/package-lock.json b/.github/actions/fix/package-lock.json index 59790304..8b4ead61 100644 --- a/.github/actions/fix/package-lock.json +++ b/.github/actions/fix/package-lock.json @@ -14,7 +14,7 @@ "@octokit/plugin-throttling": "^11.0.3" }, "devDependencies": { - "@types/node": "^25.7.0", + "@types/node": "^25.9.0", "typescript": "^6.0.3" } }, @@ -167,13 +167,13 @@ } }, "node_modules/@types/node": { - "version": "25.7.0", - "resolved": "https://registry.npmjs.org/@types/node/-/node-25.7.0.tgz", - "integrity": "sha512-z+pdZyxE+RTQE9AcboAZCb4otwcrvgHD+GlBpPgn0emDVt0ohrTMhAwlr2Wd9nZ+nihhYFxO2pThz3C5qSu2Eg==", + "version": "25.9.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.0.tgz", + "integrity": "sha512-AOQwYUNolgy3VosiRqXrACUXTN8nJUtPl7FJXMqZVyxiiCLhQuG3jXKvCS1ALr+Y2OmZhzzLVlYPEqJaiqkaJQ==", "dev": true, "license": "MIT", "dependencies": { - "undici-types": "~7.21.0" + "undici-types": ">=7.24.0 <7.24.7" } }, "node_modules/before-after-hook": { @@ -237,9 +237,9 @@ } }, "node_modules/undici-types": { - "version": "7.21.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.21.0.tgz", - "integrity": "sha512-w9IMgQrz4O0YN1LtB7K5P63vhlIOvC7opSmouCJ+ZywlPAlO9gIkJ+otk6LvGpAs2wg4econaCz3TvQ9xPoyuQ==", + "version": "7.24.6", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz", + "integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==", "dev": true, "license": "MIT" }, diff --git a/.github/actions/fix/package.json b/.github/actions/fix/package.json index 769e9f2a..c6dc7916 100644 --- a/.github/actions/fix/package.json +++ b/.github/actions/fix/package.json @@ -18,7 +18,7 @@ "@octokit/plugin-throttling": "^11.0.3" }, "devDependencies": { - "@types/node": "^25.7.0", + "@types/node": "^25.9.0", "typescript": "^6.0.3" } } diff --git a/.github/actions/gh-cache/delete/action.yml b/.github/actions/gh-cache/delete/action.yml index 1f0242aa..e0a130b4 100644 --- a/.github/actions/gh-cache/delete/action.yml +++ b/.github/actions/gh-cache/delete/action.yml @@ -40,7 +40,7 @@ runs: fi - name: Checkout repository to temporary directory - uses: actions/checkout@v6 + uses: actions/checkout@v7 with: token: ${{ inputs.token }} fetch-depth: 0 diff --git a/.github/actions/gh-cache/restore/action.yml b/.github/actions/gh-cache/restore/action.yml index 26d0ecc7..acb340d5 100644 --- a/.github/actions/gh-cache/restore/action.yml +++ b/.github/actions/gh-cache/restore/action.yml @@ -44,7 +44,7 @@ runs: fi - name: Checkout repository to temporary directory - uses: actions/checkout@v6 + uses: actions/checkout@v7 with: token: ${{ inputs.token }} fetch-depth: 0 diff --git a/.github/actions/gh-cache/save/action.yml b/.github/actions/gh-cache/save/action.yml index ee5df6f6..d6d4893c 100644 --- a/.github/actions/gh-cache/save/action.yml +++ b/.github/actions/gh-cache/save/action.yml @@ -40,7 +40,7 @@ runs: fi - name: Checkout repository to temporary directory - uses: actions/checkout@v6 + uses: actions/checkout@v7 with: token: ${{ inputs.token }} fetch-depth: 0 diff --git a/.github/scanner-plugins/reflow-scan/index.ts b/.github/scanner-plugins/reflow-scan/index.ts index fa4fa5d1..7f6a5e31 100644 --- a/.github/scanner-plugins/reflow-scan/index.ts +++ b/.github/scanner-plugins/reflow-scan/index.ts @@ -1,9 +1,9 @@ -export default async function reflowScan({ page, addFinding } = {}) { +export default async function reflowScan({page, addFinding} = {}) { const originalViewport = page.viewportSize() const url = page.url() - // Check for horizontal scrolling at 320x256 viewport + // Check for horizontal scrolling at 320 viewport try { - await page.setViewportSize({ width: 320, height: 256 }) + await page.setViewportSize({width: 320, height: 256}) const scrollWidth = await page.evaluate(() => document.documentElement.scrollWidth) const clientWidth = await page.evaluate(() => document.documentElement.clientWidth) @@ -12,10 +12,11 @@ export default async function reflowScan({ page, addFinding } = {}) { await addFinding({ scannerType: 'reflow-scan', url, - problemShort: 'page requires horizontal scrolling at 320x256 viewport', - problemUrl: 'https://www.w3.org/WAI/WCAG21/Understanding/reflow.html', - solutionShort: 'ensure content is responsive and does not require horizontal scrolling at small viewport sizes', - solutionLong: `The page has a scroll width of ${scrollWidth}px but a client width of only ${clientWidth}px at a 320x256 viewport, requiring horizontal scrolling. This violates WCAG 2.1 Level AA Success Criterion 1.4.10 (Reflow).`, + problemShort: 'needs review: page presents a horizontal scrollbar at a 320px wide viewport', + problemUrl: 'https://www.w3.org/WAI/WCAG22/Understanding/reflow.html', + solutionShort: + 'verify if sections of content can be viewed within the 320px wide viewport without needing to scroll in two dimensions to read the content of an individual section', + solutionLong: `The page has a scroll width of ${scrollWidth}px but a client width of only ${clientWidth}px at a 320px wide viewport, resulting in a horizontal scrollbar. Ensure that multi-line text does not require scrolling in two-dimensions to read, as this would be a violation of WCAG Success Criterion 1.4.10 (Reflow).`, }) } } catch (e) { diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 7e44c7dc..663d3e7e 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -23,7 +23,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@v7 - name: Setup Node uses: actions/setup-node@v6 diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index d11d6096..22449730 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -31,10 +31,10 @@ jobs: site: ['sites/site-with-errors'] steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@v7 - name: Setup Ruby - uses: ruby/setup-ruby@6aaa311d81eba98ae12eaffbcb63296ace0efcde + uses: ruby/setup-ruby@9eb537ca036ebaed86729dcb9309076e4c5c3b74 with: ruby-version: '3.4' bundler-cache: true diff --git a/FAQ.md b/FAQ.md index 2fc40164..08a425e8 100644 --- a/FAQ.md +++ b/FAQ.md @@ -60,6 +60,17 @@ Just keep in mind that resetting the cache means the Action will "forget" what it's already seen, so it may reopen issues that were previously tracked or closed. +### How can I preview what the scanner would do without filing issues? + +Set the `dry_run` input to `true`. The scanner will run a normal scan and log the +issues it _would_ open, reopen, or close — but it won't actually mutate any data or write to the `gh-cache` branch +assign any issues, and it won't write to the `gh-cache` branch. + +This is handy for trying out a new configuration or seeing how many issues a scan +would file, without making any changes to your repository. Because dry runs don't +update the cache, your next real run behaves exactly as if the dry run never +happened. + ### Does this work with private repositories? Yes! The Action works with both public and private repositories. Since it runs diff --git a/README.md b/README.md index 88b68c11..0e34735d 100644 --- a/README.md +++ b/README.md @@ -55,6 +55,10 @@ jobs: # skip_copilot_assignment: false # Optional: Set to true to skip assigning issues to GitHub Copilot (or if you don't have GitHub Copilot) # include_screenshots: false # Optional: Set to true to capture screenshots and include links to them in filed issues # open_grouped_issues: false # Optional: Set to true to open an issue grouping individual issues per violation + # group_by: finding # Optional: 'finding' (default, one issue per violation), 'rule' (one per rule), or 'rule+url' (one per rule per URL) + # file_best_practice_issues: true # Optional: Set to false to stop filing new issues for best-practice findings (recommendations that are not hard WCAG failures) + # file_experimental_issues: true # Optional: Set to false to stop filing new issues for experimental findings (checks that are not yet stable) + # dry_run: false # Optional: Set to true to scan and log what would be filed without creating/closing issues or writing the cache # reduced_motion: no-preference # Optional: Playwright reduced motion configuration option # color_scheme: light # Optional: Playwright color scheme configuration option # scans: '["axe","reflow-scan"]' # Optional: An array of scans (or plugins) to be performed. If not provided, only Axe will be performed. @@ -114,24 +118,28 @@ Trigger the workflow manually or automatically based on your configuration. The ## Action inputs -| Input | Required | Description | Example | -| ------------------------- | -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------- | -| `urls` | No\* | Newline-delimited list of URLs to scan. Required unless `url_configs` is provided. | `https://primer.style`
`https://primer.style/octicons` | -| `repository` | Yes | Repository (with owner) for issues and PRs | `primer/primer-docs` | -| `token` | Yes | PAT with write permissions (see above) | `${{ secrets.GH_TOKEN }}` | -| `cache_key` | Yes | Key for caching results across runs
Allowed: `A-Za-z0-9._/-` | `cached_results-primer.style-main.json` | -| `base_url` | No | GitHub API base URL used by Octokit. Set this for GitHub Enterprise Server (format: `https://HOSTNAME/api/v3`). Defaults to `https://api.github.com` | `https://ghe.example.com/api/v3` | -| `login_url` | No | If scanned pages require authentication, the URL of the login page | `https://github.com/login` | -| `username` | No | If scanned pages require authentication, the username to use for login | `some-user` | -| `password` | No | If scanned pages require authentication, the password to use for login | `${{ secrets.PASSWORD }}` | -| `auth_context` | No | If scanned pages require authentication, a stringified JSON object containing username, password, cookies, and/or localStorage from an authenticated session | `{"username":"some-user","password":"***","cookies":[...]}` | -| `skip_copilot_assignment` | No | Whether to skip assigning filed issues to GitHub Copilot. Set to `true` if you don't have GitHub Copilot or prefer to handle issues manually | `true` | -| `include_screenshots` | No | Whether to capture screenshots of scanned pages and include links to them in filed issues. Screenshots are stored on the `gh-cache` branch of the repository running the workflow. Default: `false` | `true` | -| `open_grouped_issues` | No | Whether to create a tracking issue which groups filed issues together by violation type. Default: `false` | `true` | -| `reduced_motion` | No | Playwright `reducedMotion` setting for scan contexts. Allowed values: `reduce`, `no-preference` | `reduce` | -| `color_scheme` | No | Playwright `colorScheme` setting for scan contexts. Allowed values: `light`, `dark`, `no-preference` | `dark` | -| `scans` | No | An array of scans (or plugins) to be performed. If not provided, only Axe will be performed. | `'["axe", "reflow-scan", ...other plugins]'` | -| `url_configs` | No | A stringified JSON array of URL config objects. Each object must have a `url` field and may have an optional `excludeSelectors` field (array of CSS selectors to exclude from the Axe scan for that URL). When provided, takes precedence over the `urls` input. | `'[{"url":"https://example.com","excludeSelectors":["iframe","#widget"]}]'` | +| Input | Required | Description | Example | +| --------------------------- | -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------- | +| `urls` | No\* | Newline-delimited list of URLs to scan. Required unless `url_configs` is provided. | `https://primer.style`
`https://primer.style/octicons` | +| `repository` | Yes | Repository (with owner) for issues and PRs | `primer/primer-docs` | +| `token` | Yes | PAT with write permissions (see above) | `${{ secrets.GH_TOKEN }}` | +| `cache_key` | Yes | Key for caching results across runs
Allowed: `A-Za-z0-9._/-` | `cached_results-primer.style-main.json` | +| `base_url` | No | GitHub API base URL used by Octokit. Set this for GitHub Enterprise Server (format: `https://HOSTNAME/api/v3`). Defaults to `https://api.github.com` | `https://ghe.example.com/api/v3` | +| `login_url` | No | If scanned pages require authentication, the URL of the login page | `https://github.com/login` | +| `username` | No | If scanned pages require authentication, the username to use for login | `some-user` | +| `password` | No | If scanned pages require authentication, the password to use for login | `${{ secrets.PASSWORD }}` | +| `auth_context` | No | If scanned pages require authentication, a stringified JSON object containing username, password, cookies, and/or localStorage from an authenticated session | `{"username":"some-user","password":"***","cookies":[...]}` | +| `skip_copilot_assignment` | No | Whether to skip assigning filed issues to GitHub Copilot. Set to `true` if you don't have GitHub Copilot or prefer to handle issues manually | `true` | +| `include_screenshots` | No | Whether to capture screenshots of scanned pages and include links to them in filed issues. Screenshots are stored on the `gh-cache` branch of the repository running the workflow. Default: `false` | `true` | +| `open_grouped_issues` | No | Whether to create a tracking issue which groups filed issues together by violation type. Default: `false` | `true` | +| `group_by` | No | How to consolidate findings when filing issues: `finding` (one issue per individual violation), `rule` (one issue per rule, aggregating every occurrence across all scanned URLs), or `rule+url` (one issue per rule per scanned URL). Default: `finding` | `rule` | +| `file_best_practice_issues` | No | Whether to file issues for best-practice findings (accessibility recommendations that are not hard WCAG failures). Set to `false` to suppress new best-practice issues; existing ones are left untouched. Default: `true` | `false` | +| `file_experimental_issues` | No | Whether to file issues for experimental findings (checks that are not yet stable). Set to `false` to suppress new experimental issues; existing ones are left untouched. Default: `true` | `false` | +| `reduced_motion` | No | Playwright `reducedMotion` setting for scan contexts. Allowed values: `reduce`, `no-preference` | `reduce` | +| `color_scheme` | No | Playwright `colorScheme` setting for scan contexts. Allowed values: `light`, `dark`, `no-preference` | `dark` | +| `scans` | No | An array of scans (or plugins) to be performed. If not provided, only Axe will be performed. | `'["axe", "reflow-scan", ...other plugins]'` | +| `dry_run` | No | When `true`, scan and log the issues that _would_ be filed without opening, closing, reopening, or assigning any issues — and without writing to the `gh-cache` branch. Useful for safely previewing results. Default: `false` | `true` | +| `url_configs` | No | A stringified JSON array of URL config objects. Each object must have a `url` field and may have an optional `excludeSelectors` field (array of CSS selectors to exclude from the Axe scan for that URL). When provided, takes precedence over the `urls` input. | `'[{"url":"https://example.com","excludeSelectors":["iframe","#widget"]}]'` | --- @@ -146,6 +154,14 @@ If your login flow is more complex—if it requires two-factor authentication, s --- +## Keeping an issue closed with `wontfix` + +When the scanner files an issue for an accessibility finding and that same finding turns up again on a later run, it reopens closed issues so the problem doesn't get lost. Sometimes, though, you may want a closed issue to _stay_ closed -- for example, if you've decided not to act on a particular finding, or if you're already tracking the work outside of GitHub issues. + +To stop the scanner from reopening a closed issue, add the **`wontfix`** label to it. On its next run, the scanner sees the label and skips reopening the issue, leaving it closed. + +--- + ## Configuring GitHub Copilot The a11y scanner leverages GitHub Copilot coding agent, which can be configured with custom instructions: diff --git a/action.yml b/action.yml index b86a45e5..02cc58c0 100644 --- a/action.yml +++ b/action.yml @@ -45,6 +45,18 @@ inputs: description: "In the 'file' step, also open grouped issues which link to all issues with the same problem" required: false default: 'false' + group_by: + description: "How to group findings into issues: 'finding' (one issue per violation, default), 'rule' (one issue per rule), or 'rule+url' (one issue per rule per scanned URL)." + required: false + default: 'finding' + file_best_practice_issues: + description: 'File issues for best-practice findings (accessibility recommendations that are not hard WCAG failures). Disabling suppresses new issues while existing ones are left untouched.' + required: false + default: 'true' + file_experimental_issues: + description: 'File issues for experimental findings (checks that are not yet stable). Disabling suppresses new issues while existing ones are left untouched.' + required: false + default: 'true' reduced_motion: description: 'Playwright reducedMotion setting: https://playwright.dev/docs/api/class-browser#browser-new-page-option-reduced-motion' required: false @@ -54,6 +66,10 @@ inputs: scans: description: 'Stringified JSON array of scans to perform. If not provided, only Axe will be performed' required: false + dry_run: + description: 'When true, scan and log the issues that would be filed without opening, closing, reopening, or assigning any issues, and without writing to the cache.' + required: false + default: 'false' outputs: results: @@ -129,6 +145,10 @@ runs: cached_filings_file: ${{ steps.normalize_cache.outputs.cached_filings_file }} screenshot_repository: ${{ github.repository }} open_grouped_issues: ${{ inputs.open_grouped_issues }} + group_by: ${{ inputs.group_by }} + dry_run: ${{ inputs.dry_run }} + file_best_practice_issues: ${{ inputs.file_best_practice_issues }} + file_experimental_issues: ${{ inputs.file_experimental_issues }} - if: ${{ steps.file.outputs.filings_file }} name: Get issues from filings id: get_issues_from_filings @@ -137,7 +157,7 @@ runs: # Extract open issues from Filing objects and write to a file jq -c '[.[] | select(.issue.state == "open") | .issue]' "${{ steps.file.outputs.filings_file }}" > "$RUNNER_TEMP/issues.json" echo "issues_file=$RUNNER_TEMP/issues.json" >> "$GITHUB_OUTPUT" - - if: ${{ inputs.skip_copilot_assignment != 'true' }} + - if: ${{ inputs.skip_copilot_assignment != 'true' && inputs.dry_run != 'true' }} name: Fix id: fix uses: ./../../_actions/github/accessibility-scanner/current/.github/actions/fix @@ -185,19 +205,20 @@ runs: # Set results_file output echo "results_file=$RESULTS_FILE" >> "$GITHUB_OUTPUT" - - if: ${{ inputs.include_screenshots == 'true' }} + - if: ${{ inputs.include_screenshots == 'true' && inputs.dry_run != 'true' }} name: Save screenshots uses: ./../../_actions/github/accessibility-scanner/current/.github/actions/gh-cache/save with: path: .screenshots token: ${{ inputs.token }} - name: Copy results to cache path + if: ${{ inputs.dry_run != 'true' }} shell: bash run: | mkdir -p "$(dirname '${{ inputs.cache_key }}')" cp "$GITHUB_WORKSPACE/scanner-results.json" "${{ inputs.cache_key }}" - - name: Save cached results + if: ${{ inputs.dry_run != 'true' }} uses: ./../../_actions/github/accessibility-scanner/current/.github/actions/gh-cache/save with: path: ${{ inputs.cache_key }} diff --git a/package-lock.json b/package-lock.json index 0c6ee538..ac6f3f5f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,11 +14,11 @@ "@octokit/core": "^7.0.6", "@octokit/plugin-throttling": "^11.0.3", "@octokit/types": "^16.0.0", - "@types/node": "^25.7.0", - "eslint": "^10.3.0", + "@types/node": "^26.0.0", + "eslint": "^10.4.0", "eslint-config-prettier": "^10.1.8", "prettier": "^3.8.3", - "typescript-eslint": "^8.59.3", + "typescript-eslint": "^8.59.4", "vitest": "^4.1.6" } }, @@ -153,9 +153,9 @@ } }, "node_modules/@eslint/config-helpers": { - "version": "0.5.5", - "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.5.5.tgz", - "integrity": "sha512-eIJYKTCECbP/nsKaaruF6LW967mtbQbsw4JTtSVkUQc9MneSkbrgPJAbKl9nWr0ZeowV8BfsarBmPpBzGelA2w==", + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.6.0.tgz", + "integrity": "sha512-ii6Bw9jJ2zi2cWA2Z+9/QZ/+3DX6kwaV5Q986D/CdP3Lap3w/pgQZ373FV7byY/i7L4IRH/G43I5dz1ClsCbpA==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -269,14 +269,14 @@ "license": "MIT" }, "node_modules/@napi-rs/wasm-runtime": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz", - "integrity": "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.5.tgz", + "integrity": "sha512-AWPoBRJ9tsnVhor4sjO7rkni+7p+2IAEFj6cx06UgP10jkQHqay/36uRV/bFkgrh18D9vb4cr8Q0Pthskgzy+Q==", "dev": true, "license": "MIT", "optional": true, "dependencies": { - "@tybys/wasm-util": "^0.10.1" + "@tybys/wasm-util": "^0.10.2" }, "funding": { "type": "github", @@ -410,9 +410,9 @@ } }, "node_modules/@oxc-project/types": { - "version": "0.129.0", - "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.129.0.tgz", - "integrity": "sha512-3oz8m3FGdr2nDXVqmFUw7jolKliC4MoyXYIG2c7gpjBnzUWQpUGIYcXYKxTdTi+N2jusvt610ckTMkxdwHkYEg==", + "version": "0.133.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.133.0.tgz", + "integrity": "sha512-KzkdCd6Uxqnf6l3HOw1xfatAlUURA0g14cvBYFyJ5SaNOQbOUvBr9PKArcPcrNIeRsBdgcUzOGrhKveVpvOIGA==", "dev": true, "license": "MIT", "funding": { @@ -420,9 +420,9 @@ } }, "node_modules/@rolldown/binding-android-arm64": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0.tgz", - "integrity": "sha512-TWMZnRLMe63C2Lhyicviu7ZHaU4kxa6PS3rofvc9GmcvptzNN11BcfQ4Sl7MwTOsisQoa2keB/EBdNCAnUo8vA==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.3.tgz", + "integrity": "sha512-454rs7jHngixp/NMxd5srYD57OnzSlZ/eFTETjORQHLwJG1lRtmNOJcBerZlfu4GjKqeq8aCCIQrMdHyhI51Hw==", "cpu": [ "arm64" ], @@ -437,9 +437,9 @@ } }, "node_modules/@rolldown/binding-darwin-arm64": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0.tgz", - "integrity": "sha512-6XcD+8k0gPVItNagEw78/qqcBDwKcwDYS8V2hRmVsfUSIrd8cWe/CBvRDI5toqFyPfj+FJr6t8U6Xj2P2prEew==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.3.tgz", + "integrity": "sha512-PcAhP+ynjURNyy8SKGl5DQP94aGuB/7JrXJb/t7P+hanXvQVMWzUvRRhBAcg/lNRadBhoUPqSoP4xw5tR/KBEA==", "cpu": [ "arm64" ], @@ -454,9 +454,9 @@ } }, "node_modules/@rolldown/binding-darwin-x64": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0.tgz", - "integrity": "sha512-iN/tWVXRQDWvmZlKdceP1Dwug9GDpEymhb9p4xnEe6zvCg5lFmzVljl+1qR1NVx3yfGpr2Na+CuLmv5IU8uzfQ==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.3.tgz", + "integrity": "sha512-9YpfeUvSE2RS7wysJ81uOZkXJz7f7Q55H2Gvp3VEw/EsahqDtrphrZ0EwDLK5vvKOzaCrBsjF8JmnMLcUt78Gg==", "cpu": [ "x64" ], @@ -471,9 +471,9 @@ } }, "node_modules/@rolldown/binding-freebsd-x64": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0.tgz", - "integrity": "sha512-jjQMDvvwSOuhOwMszD/klSOjyWMM3zI64hWTj9KT5x4MxRbZAf+7vLQ6qouRhtsLVFHr3f0ILaJAfgENPiQdAQ==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.3.tgz", + "integrity": "sha512-yB1IlAsSNHncV6SCTL27/MVGR5htvQsoGxIv5KMGXALp+Ll1wYsn+x98M9MW7qa+NdSbvrrY7ANI4wLJ0n1e6g==", "cpu": [ "x64" ], @@ -488,9 +488,9 @@ } }, "node_modules/@rolldown/binding-linux-arm-gnueabihf": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0.tgz", - "integrity": "sha512-d//Dtg2x6/m3mbV64yUGNnDGNZaDGRpDLLNGerHQUVObuNaIQaaDp25yUiqGXtHEXX+NP2d0wAlmKgpYgIAJ2A==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.3.tgz", + "integrity": "sha512-Yi30IVAAfLUCy2MseFjbB1jAMDl1VMCAas5StnYp8da9+CKvMd2H2cbEjWcw5NPaPqzvYkVIaF1nNUG+b7u/sw==", "cpu": [ "arm" ], @@ -505,9 +505,9 @@ } }, "node_modules/@rolldown/binding-linux-arm64-gnu": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0.tgz", - "integrity": "sha512-n7Ofp0mx+aB2cC+Sdy5YtMnXtY9lchnHbY+3Yt0uq9JsWQExf4f5Whu0tK0R8Jdc9S6RchTHjIFY7uc92puOVQ==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.3.tgz", + "integrity": "sha512-jsO7R8To+AdlYgUmN5sHSCZbfhtMBkO0WUx8iORQnPcMMdgr7qM2DQmMwgabs3GhNztdmoKkMKQFHD6DTMCIQw==", "cpu": [ "arm64" ], @@ -522,9 +522,9 @@ } }, "node_modules/@rolldown/binding-linux-arm64-musl": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0.tgz", - "integrity": "sha512-EIVjy2cgd7uuMMo94FVkBp7F6DhcZAUwNURkSG3RwUmvAXR6s0ISxM81U+IydcZByPG0pZIHsf1b6kTxoFDgJA==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.3.tgz", + "integrity": "sha512-VWkUHwWriDciit80wleYwKILoR/KMvxh/IdwS/paX+ZgpuRpCrKLUdadJbc0NpBEiyhpYawsJ73j9aCvOH+f7Q==", "cpu": [ "arm64" ], @@ -539,9 +539,9 @@ } }, "node_modules/@rolldown/binding-linux-ppc64-gnu": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0.tgz", - "integrity": "sha512-JEwwOPcwTLAcpDQlqSmjEmfs63xJnSiUNIGvLcDLUHCWK4XowpS/7c7tUsUH6uT/ct6bMUTdXKfI8967FYj6mg==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.3.tgz", + "integrity": "sha512-5f1laC0SlIR0yDbFCd8acUhvJIag6N3zC5P7oUPN6wX0aOma+uKJ0wBDH5aq7I1PVI2ttTlhJwzwRIBnLiSGEg==", "cpu": [ "ppc64" ], @@ -556,9 +556,9 @@ } }, "node_modules/@rolldown/binding-linux-s390x-gnu": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0.tgz", - "integrity": "sha512-0wjCFhLrihtAubnT9iA0N++0pSV0z5Hg7tNGdNJ4RFaINceHadoF+kiFGyY1qSSNVIAZtLotG8Ju1bgDPkjnFA==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.3.tgz", + "integrity": "sha512-Iq4ko0r4XsgbrF/LunNgHtAGLRRVE2kXonAXQ/MV0mC6jQpMOhW1SvtZja2EhC/kd05++bP78dsqBeIQyYJ6Yg==", "cpu": [ "s390x" ], @@ -573,9 +573,9 @@ } }, "node_modules/@rolldown/binding-linux-x64-gnu": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0.tgz", - "integrity": "sha512-Dfn7iak9BcMMePxcoJfpSbWqnEyrp/dRF63/8qW/eHBdOZov6x5aShLLEYGYdIeSJ6vMLK/XCVB+lGIxm41bQA==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.3.tgz", + "integrity": "sha512-B8m6tD5+/N5FeNQFbKlLA/2yVq9ycQP1SeedyEYYKWBNR3ZQbkvIUcNnDNM03lO1l5F2roiiFJGgvoLLyZXtSg==", "cpu": [ "x64" ], @@ -590,9 +590,9 @@ } }, "node_modules/@rolldown/binding-linux-x64-musl": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0.tgz", - "integrity": "sha512-5/utzzDmD/pD/bmuaUcbTf/sZYy0aztwIVlfpoW1fTjCZ0BaPOMVWGZL1zvgxyi7ZIVYWlxKONHmSbHuiOh8Jw==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.3.tgz", + "integrity": "sha512-pSdpdUJHkuCxun9LE7jvgUB9qsRgaiyNNCX7m/AvHTcq67AiT/Yhoxvw5zPfhrM8k/BfP8ce/hMOpthKDpEUow==", "cpu": [ "x64" ], @@ -607,9 +607,9 @@ } }, "node_modules/@rolldown/binding-openharmony-arm64": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0.tgz", - "integrity": "sha512-ouJs8VcUomfLfpbUECqFMRqdV4x6aeAK3MA4m6vTrJJjKyWTV5KnxZx7Jd9G+GlDaQQxubcba00x16OyJ1meig==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.3.tgz", + "integrity": "sha512-OXXS3RKJgX2uLwM+gYyuH5omcH8fL1LJs96pZGgtetVCahON57+d4SJHzTgZiOjxgGkSnpXpOsWuPDGAKAigEg==", "cpu": [ "arm64" ], @@ -624,9 +624,9 @@ } }, "node_modules/@rolldown/binding-wasm32-wasi": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0.tgz", - "integrity": "sha512-E+oHKGiDA+lsKMmFtffDDw91EryDT7uJocrIuCHqhm6bCTM6xFK+3gaCkYOHfPwQr0cCNarSM2xaELoQDz9jJg==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.3.tgz", + "integrity": "sha512-JTtb8BWFynicNSoPrehsCzBtOKjZ6jhMiPFEmOiuXg1Fl8dn2KHQob+GuPSGR0dryQa1PQJbzjF3dqO/whhjLg==", "cpu": [ "wasm32" ], @@ -643,9 +643,9 @@ } }, "node_modules/@rolldown/binding-win32-arm64-msvc": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0.tgz", - "integrity": "sha512-yYK02n8Rngo+gbm1y6G0+7jk1sJ/2Wt7K0me0Y7k/ErBpyf+LJ2gFpqWVTcRV1rUepBlQRmpgWkTQCiiwrK0Ow==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.3.tgz", + "integrity": "sha512-gEdFFEN70A/jxb2svrWsN3aDL7OUtmvlOy+6fa2jxG8K0wQ1ZbdeLGnidov6Yu5/733dI5ySfzFlQ/cb0bSz1g==", "cpu": [ "arm64" ], @@ -660,9 +660,9 @@ } }, "node_modules/@rolldown/binding-win32-x64-msvc": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0.tgz", - "integrity": "sha512-14bpChMahXRRXiTwahSl+zzHPW6qQTXtkMuJBFlbo+pqSAews2d4BdCSHfrJ/MBsCZtpmTafsY+1QhBzitcmdg==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.3.tgz", + "integrity": "sha512-eXB7CHuaQdqmJcc3koCNtNPmT/bj2gc999kUFgBxG8Ac0NdgXc4rkCHhqrgrhN3zddvvvrgzj1e90SuSfmyIXA==", "cpu": [ "x64" ], @@ -677,9 +677,9 @@ } }, "node_modules/@rolldown/pluginutils": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0.tgz", - "integrity": "sha512-aKs/3GSWyV0mrhNmt/96/Z3yczC3yvrzYATCiCXQebBsGyYzjNdUphRVLeJQ67ySKVXRfMxt2lm12pmXvbPFQQ==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", "dev": true, "license": "MIT" }, @@ -741,27 +741,27 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "25.7.0", - "resolved": "https://registry.npmjs.org/@types/node/-/node-25.7.0.tgz", - "integrity": "sha512-z+pdZyxE+RTQE9AcboAZCb4otwcrvgHD+GlBpPgn0emDVt0ohrTMhAwlr2Wd9nZ+nihhYFxO2pThz3C5qSu2Eg==", + "version": "26.0.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.0.0.tgz", + "integrity": "sha512-vf2YFi1iY9lHGwNJMs01biZFbKJkrZR1T6/MlzjhJLPdntOHLhTrDSnSVcdtvjihi4VQNlrFRIxLsDBlQpAipA==", "dev": true, "license": "MIT", "dependencies": { - "undici-types": "~7.21.0" + "undici-types": "~8.3.0" } }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.59.3", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.59.3.tgz", - "integrity": "sha512-PwFvSKsXGShKGW6n5bZOhGHEcCZXM8HofLK9fNsEwZXzFRjoY+XT1Vsf1zgyXdwTr0ZYz1/2tkZ0DBTT9jZjhw==", + "version": "8.59.4", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.59.4.tgz", + "integrity": "sha512-PegsU+XfyJJNjd4+u/k6f9yTyp0lEXXiPopUNobZcIAUJFGICFLN+sP0Rb3JehVmiij1Ph0dFGYqODoRo/2+6A==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/regexpp": "^4.12.2", - "@typescript-eslint/scope-manager": "8.59.3", - "@typescript-eslint/type-utils": "8.59.3", - "@typescript-eslint/utils": "8.59.3", - "@typescript-eslint/visitor-keys": "8.59.3", + "@typescript-eslint/scope-manager": "8.59.4", + "@typescript-eslint/type-utils": "8.59.4", + "@typescript-eslint/utils": "8.59.4", + "@typescript-eslint/visitor-keys": "8.59.4", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.5.0" @@ -774,7 +774,7 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "@typescript-eslint/parser": "^8.59.3", + "@typescript-eslint/parser": "^8.59.4", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } @@ -790,16 +790,16 @@ } }, "node_modules/@typescript-eslint/parser": { - "version": "8.59.3", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.59.3.tgz", - "integrity": "sha512-HPwA+hVkfcriajbNvTmZv4VRauibay+cWArYUYq7u7W7PmGShMxbPxLvrwDme55a6d5alG3nrYfhyJ/G28XlLg==", + "version": "8.59.4", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.59.4.tgz", + "integrity": "sha512-zORHqO/tuhxY1zWuTvMUqddRxpiFJ72xVfcNoWpqdLjs6lfPbuQBJuW4pk+49/uBMy7Ssr4bzgjiKmmDB1UbZQ==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/scope-manager": "8.59.3", - "@typescript-eslint/types": "8.59.3", - "@typescript-eslint/typescript-estree": "8.59.3", - "@typescript-eslint/visitor-keys": "8.59.3", + "@typescript-eslint/scope-manager": "8.59.4", + "@typescript-eslint/types": "8.59.4", + "@typescript-eslint/typescript-estree": "8.59.4", + "@typescript-eslint/visitor-keys": "8.59.4", "debug": "^4.4.3" }, "engines": { @@ -815,14 +815,14 @@ } }, "node_modules/@typescript-eslint/project-service": { - "version": "8.59.3", - "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.59.3.tgz", - "integrity": "sha512-ECiUWa/KYRGDFUqTNehaRgzDshnJfkTABJxVemHk4ko22gcr0ukloKjWvyQ64g8YCV/UI47kN1dbmjf/GaQYng==", + "version": "8.59.4", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.59.4.tgz", + "integrity": "sha512-Ly00Vu4oAacfDeHp2Zg85ioNG6l8HG+tN1D7J+xTHSxu9y0awYKJ2zH1rFBn8ZSfuGK+7FxK3Cgl3uAz0aZZLg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/tsconfig-utils": "^8.59.3", - "@typescript-eslint/types": "^8.59.3", + "@typescript-eslint/tsconfig-utils": "^8.59.4", + "@typescript-eslint/types": "^8.59.4", "debug": "^4.4.3" }, "engines": { @@ -837,14 +837,14 @@ } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "8.59.3", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.59.3.tgz", - "integrity": "sha512-t2LvZnoEfzKtnPjgeEu41xw5gxq9mQVfYy4OoZ4Vlt0sk3JwxmhCca/AR7DwOiHrjWgjAj6as4AhRLKSDfvZIA==", + "version": "8.59.4", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.59.4.tgz", + "integrity": "sha512-mUeR/3H1WrTAddJrwut8OoPjfauaztMQmRwV5fQTUyNVJCLiUXXe4lGEyYIL2oFDpP7UtgbGJXCt72wT0z2S3Q==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.59.3", - "@typescript-eslint/visitor-keys": "8.59.3" + "@typescript-eslint/types": "8.59.4", + "@typescript-eslint/visitor-keys": "8.59.4" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -855,9 +855,9 @@ } }, "node_modules/@typescript-eslint/tsconfig-utils": { - "version": "8.59.3", - "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.59.3.tgz", - "integrity": "sha512-PcIJHjmaREXLgIAIzLnSY9VucEzz8FKXsRgFa1DmdGCK/5tJpW03TKJF01Q6VZd1lLdz2sIKPWaDUZN9dp//dw==", + "version": "8.59.4", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.59.4.tgz", + "integrity": "sha512-DLCpnKgD4alVxTBSKulK+gU1KCqOgUXfDRDXh2mZgzokQKa/70ax93I2uVO3m/LLvIAtWZIFoiifudmIqAxpMA==", "dev": true, "license": "MIT", "engines": { @@ -872,15 +872,15 @@ } }, "node_modules/@typescript-eslint/type-utils": { - "version": "8.59.3", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.59.3.tgz", - "integrity": "sha512-g71d8QD8UaiHGvrJwyIS1hCX5r63w6Jll+4VEYhEAHXTDIqX1JgxhTAbEHtKntL9kuc4jRo7/GWw5xfCepSccQ==", + "version": "8.59.4", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.59.4.tgz", + "integrity": "sha512-uonTuPAAKr9XaBGqJ3LjYTh72zy5DyGesljO9gtmk/eFW0W1fRHjnwVYKB35Lm8d5Q5CluEW3gPHjTvZTmgrfA==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.59.3", - "@typescript-eslint/typescript-estree": "8.59.3", - "@typescript-eslint/utils": "8.59.3", + "@typescript-eslint/types": "8.59.4", + "@typescript-eslint/typescript-estree": "8.59.4", + "@typescript-eslint/utils": "8.59.4", "debug": "^4.4.3", "ts-api-utils": "^2.5.0" }, @@ -897,9 +897,9 @@ } }, "node_modules/@typescript-eslint/types": { - "version": "8.59.3", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.59.3.tgz", - "integrity": "sha512-ePFoH0g4ludssdRFqqDxQePCxU4WQyRa9+XVwjm7yLn0FKhMeoetC+qBEEI1Eyb1pGSDveTIT09Bvw2WhlGayg==", + "version": "8.59.4", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.59.4.tgz", + "integrity": "sha512-F1o7WJcCq+bc8dwcO/YsSEOudAH8RDtaOhM6wcAQhcUsFhnWQl81JKy48q1hoxAU0qrzM89+31GYh1515Zde3Q==", "dev": true, "license": "MIT", "engines": { @@ -911,16 +911,16 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.59.3", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.59.3.tgz", - "integrity": "sha512-CbRjVRAf7Lr9Kr8RopKcbY45p2VfmmHrm0ygOCYFi7oU8q19m0Fs/6iHS7kNOmwpp+ob07ZVcAqlxUod9lYdmg==", + "version": "8.59.4", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.59.4.tgz", + "integrity": "sha512-F+RuOmcDXo4+TPdfd/TCLS3m2nw8gE9XXyZLrA3JBfaA5tz9TtdkyD3YJFmPxulyc2cKbEok/CvFE3MgSLWnag==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/project-service": "8.59.3", - "@typescript-eslint/tsconfig-utils": "8.59.3", - "@typescript-eslint/types": "8.59.3", - "@typescript-eslint/visitor-keys": "8.59.3", + "@typescript-eslint/project-service": "8.59.4", + "@typescript-eslint/tsconfig-utils": "8.59.4", + "@typescript-eslint/types": "8.59.4", + "@typescript-eslint/visitor-keys": "8.59.4", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", @@ -939,16 +939,16 @@ } }, "node_modules/@typescript-eslint/utils": { - "version": "8.59.3", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.59.3.tgz", - "integrity": "sha512-JAvT14goBzRzzzZyqq3P9BLArIxTtQURUtFgQ/V7FO+eU+Gg6ES+5ymOPP1wRxXcxAYeivCk4uS3jCKWI1K8Zg==", + "version": "8.59.4", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.59.4.tgz", + "integrity": "sha512-cYXeNAUsG4lJo5dbc1FcKm+JwIWrj1/UpTORsC6tGMjEZ81DYcvIr9/ueikhMa/Y/gDQYGp+YX9/xQrXje5BJw==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", - "@typescript-eslint/scope-manager": "8.59.3", - "@typescript-eslint/types": "8.59.3", - "@typescript-eslint/typescript-estree": "8.59.3" + "@typescript-eslint/scope-manager": "8.59.4", + "@typescript-eslint/types": "8.59.4", + "@typescript-eslint/typescript-estree": "8.59.4" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -963,13 +963,13 @@ } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.59.3", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.59.3.tgz", - "integrity": "sha512-f1UQF7ggd42YiwI5wGrRaPsa+P0CINBlrkLPmGfpq/u/I/oVtecoEIfFR9ag/oa1sLOsRNZ6xehf6qMZhQGBDg==", + "version": "8.59.4", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.59.4.tgz", + "integrity": "sha512-U3gxVaDVnuZKhSspW/MzMxE1kq7zOdc072FcSNoqA1I9p8HyKbBFfEHoWckBAMgNMph4MamwS5iTVzFmrnt8TQ==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.59.3", + "@typescript-eslint/types": "8.59.4", "eslint-visitor-keys": "^5.0.0" }, "engines": { @@ -1268,16 +1268,16 @@ } }, "node_modules/eslint": { - "version": "10.3.0", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.3.0.tgz", - "integrity": "sha512-XbEXaRva5cF0ZQB8w6MluHA0kZZfV2DuCMJ3ozyEOHLwDpZX2Lmm/7Pp0xdJmI0GL1W05VH5VwIFHEm1Vcw2gw==", + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.4.0.tgz", + "integrity": "sha512-loXy6bWOoP3EP6JA7jo6p5jMpBJmHmsNZM5SFRHLdh1MGOPurMnNBj4ZlAbaqUAaQWbCr7jHV4P7gzAyryZWkQ==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.2", "@eslint/config-array": "^0.23.5", - "@eslint/config-helpers": "^0.5.5", + "@eslint/config-helpers": "^0.6.0", "@eslint/core": "^1.2.1", "@eslint/plugin-kit": "^0.7.1", "@humanfs/node": "^0.16.6", @@ -2130,9 +2130,9 @@ } }, "node_modules/postcss": { - "version": "8.5.14", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.14.tgz", - "integrity": "sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==", + "version": "8.5.15", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", + "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", "dev": true, "funding": [ { @@ -2150,7 +2150,7 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.11", + "nanoid": "^3.3.12", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, @@ -2195,14 +2195,14 @@ } }, "node_modules/rolldown": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0.tgz", - "integrity": "sha512-yD986aXDESFGS95spT1LAv0jssywP4npMEjmMHyN2/5+eE8qQJUype2AaKkRiLgBgyD0LFlubwAht7VmY8rGoA==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.3.tgz", + "integrity": "sha512-i00lAJ2ks1BYr7rjNjKC7BcqAS7nVfiT3QX1SI5aY+AFHblCmaUf9OE9dbdzDvW6dJxbi2ZCZiy9v3CcwOiX3g==", "dev": true, "license": "MIT", "dependencies": { - "@oxc-project/types": "=0.129.0", - "@rolldown/pluginutils": "1.0.0" + "@oxc-project/types": "=0.133.0", + "@rolldown/pluginutils": "^1.0.0" }, "bin": { "rolldown": "bin/cli.mjs" @@ -2211,21 +2211,21 @@ "node": "^20.19.0 || >=22.12.0" }, "optionalDependencies": { - "@rolldown/binding-android-arm64": "1.0.0", - "@rolldown/binding-darwin-arm64": "1.0.0", - "@rolldown/binding-darwin-x64": "1.0.0", - "@rolldown/binding-freebsd-x64": "1.0.0", - "@rolldown/binding-linux-arm-gnueabihf": "1.0.0", - "@rolldown/binding-linux-arm64-gnu": "1.0.0", - "@rolldown/binding-linux-arm64-musl": "1.0.0", - "@rolldown/binding-linux-ppc64-gnu": "1.0.0", - "@rolldown/binding-linux-s390x-gnu": "1.0.0", - "@rolldown/binding-linux-x64-gnu": "1.0.0", - "@rolldown/binding-linux-x64-musl": "1.0.0", - "@rolldown/binding-openharmony-arm64": "1.0.0", - "@rolldown/binding-wasm32-wasi": "1.0.0", - "@rolldown/binding-win32-arm64-msvc": "1.0.0", - "@rolldown/binding-win32-x64-msvc": "1.0.0" + "@rolldown/binding-android-arm64": "1.0.3", + "@rolldown/binding-darwin-arm64": "1.0.3", + "@rolldown/binding-darwin-x64": "1.0.3", + "@rolldown/binding-freebsd-x64": "1.0.3", + "@rolldown/binding-linux-arm-gnueabihf": "1.0.3", + "@rolldown/binding-linux-arm64-gnu": "1.0.3", + "@rolldown/binding-linux-arm64-musl": "1.0.3", + "@rolldown/binding-linux-ppc64-gnu": "1.0.3", + "@rolldown/binding-linux-s390x-gnu": "1.0.3", + "@rolldown/binding-linux-x64-gnu": "1.0.3", + "@rolldown/binding-linux-x64-musl": "1.0.3", + "@rolldown/binding-openharmony-arm64": "1.0.3", + "@rolldown/binding-wasm32-wasi": "1.0.3", + "@rolldown/binding-win32-arm64-msvc": "1.0.3", + "@rolldown/binding-win32-x64-msvc": "1.0.3" } }, "node_modules/semver": { @@ -2313,9 +2313,9 @@ } }, "node_modules/tinyglobby": { - "version": "0.2.16", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", - "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", "dev": true, "license": "MIT", "dependencies": { @@ -2399,16 +2399,16 @@ } }, "node_modules/typescript-eslint": { - "version": "8.59.3", - "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.59.3.tgz", - "integrity": "sha512-KgusgyDgG4LI8Ih/sWaCtZ06tckLAS5CvT5A4D1Q7bYVoAAyzwiZvE4BmwDHkhRVkvhRBepKeASoFzQetha7Fg==", + "version": "8.59.4", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.59.4.tgz", + "integrity": "sha512-Rw6+44QNFaXtgHSjPy+Kw8hrJniMYzR85E9yLmOLcfZ91/rz+JXQbDTCmc6ccxMPY6K6PgAq26f0JCBfR7LIPQ==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/eslint-plugin": "8.59.3", - "@typescript-eslint/parser": "8.59.3", - "@typescript-eslint/typescript-estree": "8.59.3", - "@typescript-eslint/utils": "8.59.3" + "@typescript-eslint/eslint-plugin": "8.59.4", + "@typescript-eslint/parser": "8.59.4", + "@typescript-eslint/typescript-estree": "8.59.4", + "@typescript-eslint/utils": "8.59.4" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -2423,9 +2423,9 @@ } }, "node_modules/undici": { - "version": "6.24.1", - "resolved": "https://registry.npmjs.org/undici/-/undici-6.24.1.tgz", - "integrity": "sha512-sC+b0tB1whOCzbtlx20fx3WgCXwkW627p4EA9uM+/tNNPkSS+eSEld6pAs9nDv7WbY1UUljBMYPtu9BCOrCWKA==", + "version": "6.27.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-6.27.0.tgz", + "integrity": "sha512-YmfV3YnEDzXRC5lZ2jWtWWHKGUm1zIt8AhesR1tens+HTNv+YZlN/dp6G727LOvMJ8xjP9Be7Y2Sdr96LDm+pg==", "dev": true, "license": "MIT", "engines": { @@ -2433,9 +2433,9 @@ } }, "node_modules/undici-types": { - "version": "7.21.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.21.0.tgz", - "integrity": "sha512-w9IMgQrz4O0YN1LtB7K5P63vhlIOvC7opSmouCJ+ZywlPAlO9gIkJ+otk6LvGpAs2wg4econaCz3TvQ9xPoyuQ==", + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz", + "integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==", "dev": true, "license": "MIT" }, @@ -2457,17 +2457,17 @@ } }, "node_modules/vite": { - "version": "8.0.12", - "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.12.tgz", - "integrity": "sha512-w2dDofOWv2QB09ZITZBsvKTVAlYvPR4IAmrY/v0ir9KvLs0xybR7i48wxhM1/oyBWO34wPns+bPGw5ZrZqDpZg==", + "version": "8.0.16", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.16.tgz", + "integrity": "sha512-h9bXPmJichP5fLmVQo3PyaGSDE2n3aPuomeAlVRm0JLmt4rY6zmPKd59HYI4LNW8oTK7tlTsuC7l/m7awx9Jcw==", "dev": true, "license": "MIT", "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", - "postcss": "^8.5.14", - "rolldown": "1.0.0", - "tinyglobby": "^0.2.16" + "postcss": "^8.5.15", + "rolldown": "1.0.3", + "tinyglobby": "^0.2.17" }, "bin": { "vite": "bin/vite.js" diff --git a/package.json b/package.json index 593f92e0..f7cb1176 100644 --- a/package.json +++ b/package.json @@ -26,11 +26,11 @@ "@octokit/core": "^7.0.6", "@octokit/plugin-throttling": "^11.0.3", "@octokit/types": "^16.0.0", - "@types/node": "^25.7.0", - "eslint": "^10.3.0", + "@types/node": "^26.0.0", + "eslint": "^10.4.0", "eslint-config-prettier": "^10.1.8", "prettier": "^3.8.3", - "typescript-eslint": "^8.59.3", + "typescript-eslint": "^8.59.4", "vitest": "^4.1.6" } } diff --git a/sites/site-with-errors/Gemfile.lock b/sites/site-with-errors/Gemfile.lock index 4d6b3b84..57c5380e 100644 --- a/sites/site-with-errors/Gemfile.lock +++ b/sites/site-with-errors/Gemfile.lock @@ -6,7 +6,7 @@ GEM base64 (0.3.0) bigdecimal (3.2.2) colorator (1.1.0) - concurrent-ruby (1.3.5) + concurrent-ruby (1.3.7) csv (3.3.5) em-websocket (0.5.3) eventmachine (>= 0.12.9) @@ -99,7 +99,7 @@ GEM pathutil (0.16.2) forwardable-extended (~> 2.6) public_suffix (7.0.5) - puma (8.0.1) + puma (8.0.2) nio4r (~> 2.0) rack (3.2.6) rake (13.3.0) diff --git a/tests/site-with-errors.test.ts b/tests/site-with-errors.test.ts index 06ba78da..4a6b00ef 100644 --- a/tests/site-with-errors.test.ts +++ b/tests/site-with-errors.test.ts @@ -39,13 +39,16 @@ describe('site-with-errors', () => { it('cache has expected results', () => { const actual = results.map(({issue: {url: issueUrl}, findings}) => { - const {problemUrl, solutionLong, screenshotId, ...finding} = findings[0] + const {problemUrl, solutionLong, screenshotId, nodes, ...finding} = findings[0] // Check volatile fields for existence only expect(issueUrl).toBeDefined() expect(problemUrl).toBeDefined() // Axe-specific assertions if (finding.scannerType === 'axe') { expect(solutionLong).toBeDefined() + expect(nodes).toBeDefined() + expect(nodes!.length).toBeGreaterThan(0) + expect(nodes![0].html).toBe(finding.html) expect(problemUrl.startsWith('https://dequeuniversity.com/rules/axe/')).toBe(true) expect(problemUrl.endsWith(`/${finding.ruleId}?application=playwright`)).toBe(true) } @@ -58,6 +61,7 @@ describe('site-with-errors', () => { const expected = [ { scannerType: 'axe', + category: 'wcag', url: 'http://127.0.0.1:4000/', html: '', problemShort: 'elements must meet minimum color contrast ratio thresholds', @@ -67,6 +71,7 @@ describe('site-with-errors', () => { }, { scannerType: 'axe', + category: 'best-practice', url: 'http://127.0.0.1:4000/', html: '', problemShort: 'page should contain a level-one heading', @@ -75,6 +80,7 @@ describe('site-with-errors', () => { }, { scannerType: 'axe', + category: 'wcag', url: 'http://127.0.0.1:4000/jekyll/update/2025/07/30/welcome-to-jekyll.html', html: ``, @@ -85,6 +91,7 @@ describe('site-with-errors', () => { }, { scannerType: 'axe', + category: 'wcag', url: 'http://127.0.0.1:4000/about/', html: 'jekyllrb.com', problemShort: 'elements must meet minimum color contrast ratio thresholds', @@ -94,6 +101,7 @@ describe('site-with-errors', () => { }, { scannerType: 'axe', + category: 'wcag', url: 'http://127.0.0.1:4000/404.html', html: '
  • Accessibility Scanner Demo
  • ', problemShort: 'elements must meet minimum color contrast ratio thresholds', @@ -103,6 +111,7 @@ describe('site-with-errors', () => { }, { scannerType: 'axe', + category: 'best-practice', url: 'http://127.0.0.1:4000/404.html', html: '

    ', problemShort: 'headings should not be empty', @@ -112,8 +121,9 @@ describe('site-with-errors', () => { { scannerType: 'reflow-scan', url: 'http://127.0.0.1:4000/404.html', - problemShort: 'page requires horizontal scrolling at 320x256 viewport', - solutionShort: 'ensure content is responsive and does not require horizontal scrolling at small viewport sizes', + problemShort: 'needs review: page presents a horizontal scrollbar at a 320px wide viewport', + solutionShort: + 'verify if sections of content can be viewed within the 320px wide viewport without needing to scroll in two dimensions to read the content of an individual section', }, ] // Check that: @@ -161,7 +171,7 @@ describe('site-with-errors', () => { 'Accessibility issue: Headings should not be empty on /404.html', 'Accessibility issue: Elements must meet minimum color contrast ratio thresholds on /about/', 'Accessibility issue: Elements must meet minimum color contrast ratio thresholds on /jekyll/update/2025/07/30/welcome-to-jekyll.html', - 'Accessibility issue: Page requires horizontal scrolling at 320x256 viewport on /404.html', + 'Accessibility issue: Needs review: page presents a horizontal scrollbar at a 320px wide viewport on /404.html', ] expect(actualTitles).toHaveLength(expectedTitles.length) expect(actualTitles).toEqual(expect.arrayContaining(expectedTitles)) diff --git a/tests/types.d.ts b/tests/types.d.ts index b12077ae..ea72c54b 100644 --- a/tests/types.d.ts +++ b/tests/types.d.ts @@ -1,8 +1,14 @@ +export type FindingNode = { + html: string + target?: string +} + export type Finding = { scannerType: string ruleId?: string url: string html?: string + nodes?: FindingNode[] problemShort: string problemUrl: string solutionShort: string