Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
14a7ae9
Adds screenshots to issues
lindseywild Feb 18, 2026
177a78a
Updates to wait until DOMContentLoaded, moves Axe scan
lindseywild Feb 19, 2026
7b42fca
Fixes markdown
lindseywild Feb 19, 2026
ae45dcd
Fixes screenshot location
lindseywild Feb 19, 2026
c7fa1f5
Moves axe builder back
lindseywild Feb 19, 2026
e7ace49
Updates save action
lindseywild Feb 19, 2026
706e363
Ensures re-opened issues are updated
lindseywild Feb 19, 2026
dba8913
Updates action
lindseywild Feb 19, 2026
b3430a2
Changes screenshot to link
lindseywild Feb 19, 2026
4618a36
Removes duplicate save workflow
lindseywild Feb 19, 2026
6d230de
Attempt to fix matrix gh-cache push
lindseywild Feb 19, 2026
3420115
Removes gh-cache updates
lindseywild Feb 19, 2026
d462476
Adds screenshot repo to ensure we are targeting the correct gh-cache …
lindseywild Feb 19, 2026
c4fbd5b
Minor tweaks after initial code review
lindseywild Feb 19, 2026
a5b6a29
Adds additional tests
lindseywild Feb 19, 2026
b150539
Merge branch 'main' into adds-screenshots
lindseywild Feb 19, 2026
e28ed11
Updates site-with-errors test
lindseywild Feb 19, 2026
7cfce7c
Updates test
lindseywild Feb 19, 2026
e2bf89a
Adds crypto import
lindseywild Feb 19, 2026
4f3fa07
Initial updates from PR review
lindseywild Feb 20, 2026
4dd03c7
Removes page.waitForLoadState
lindseywild Feb 20, 2026
0fbb9d7
Extracts generareScreenshots into another function
lindseywild Feb 20, 2026
124d17c
Fixes naming
lindseywild Feb 20, 2026
dfc8c4b
Updates ternary
lindseywild Feb 20, 2026
7131561
Merge branch 'main' into adds-screenshots
lindseywild Feb 20, 2026
a0e058e
Formatting / linting
lindseywild Feb 20, 2026
f03c192
Changes include_screenshots from true to false
lindseywild Feb 20, 2026
51b0cc5
Updates README
lindseywild Feb 20, 2026
035975f
Update tests
lindseywild Feb 20, 2026
8dee19c
Fixes bad semi
lindseywild Feb 20, 2026
594d6b6
Removes extra tests
lindseywild Feb 20, 2026
0b1f891
Removes silly destructure
lindseywild Feb 20, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/actions/file/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,9 @@ inputs:
cached_filings:
description: "Cached filings from previous runs, as stringified JSON. 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."
required: false

outputs:
filings:
Expand Down
13 changes: 12 additions & 1 deletion .github/actions/file/src/generateIssueBody.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import type {Finding} from './types.d.js'

export function generateIssueBody(finding: Finding): string {
export function generateIssueBody(finding: Finding, screenshotRepo: string): string {
const solutionLong = finding.solutionLong
?.split('\n')
.map((line: string) =>
Expand All @@ -9,15 +9,26 @@ export function generateIssueBody(finding: Finding): string {
: line,
)
.join('\n')

let screenshotSection
if (finding.screenshotId) {
const screenshotUrl = `https://github.com/${screenshotRepo}/blob/gh-cache/.screenshots/${finding.screenshotId}.png`
screenshotSection = `
[View screenshot](${screenshotUrl})
`
}

const acceptanceCriteria = `## Acceptance Criteria
- [ ] The specific axe 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.
- [ ] A test SHOULD be added to ensure this specific axe violation does not regress.
- [ ] This PR MUST NOT introduce any new accessibility issues or regressions.
`

const body = `## What
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Prettier / eslint cleanup -- I have it installed locally on VSCode so you may see some unrelated changes that are purely linting. Once we add eslint/Prettier in this repo it will be updated if it doesn't follow suit with the guidelines anyway.

An accessibility scan flagged the element \`${finding.html}\` on ${finding.url} because ${finding.problemShort}. Learn more about why this was flagged by visiting ${finding.problemUrl}.

${screenshotSection ?? ''}
To fix this, ${finding.solutionShort}.
${solutionLong ? `\nSpecifically:\n\n${solutionLong}` : ''}

Expand Down
14 changes: 11 additions & 3 deletions .github/actions/file/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,11 +18,13 @@ export default async function () {
const findings: Finding[] = JSON.parse(core.getInput('findings', {required: true}))
const repoWithOwner = core.getInput('repository', {required: true})
const token = core.getInput('token', {required: true})
const screenshotRepo = core.getInput('screenshot_repository', {required: false}) || repoWithOwner
const cachedFilings: (ResolvedFiling | RepeatedFiling)[] = JSON.parse(
core.getInput('cached_filings', {required: false}) || '[]',
)
core.debug(`Input: 'findings: ${JSON.stringify(findings)}'`)
core.debug(`Input: 'repository: ${repoWithOwner}'`)
core.debug(`Input: 'screenshot_repository: ${screenshotRepo}'`)
core.debug(`Input: 'cached_filings: ${JSON.stringify(cachedFilings)}'`)

const octokit = new OctokitWithThrottling({
Expand Down Expand Up @@ -55,12 +57,18 @@ export default async function () {
filing.issue.state = 'closed'
} else if (isNewFiling(filing)) {
// Open a new issue for the filing
response = await openIssue(octokit, repoWithOwner, filing.findings[0])
response = await openIssue(octokit, repoWithOwner, filing.findings[0], screenshotRepo)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
;(filing as any).issue = {state: 'open'} as Issue
} else if (isRepeatedFiling(filing)) {
// Reopen the filing’s issue (if necessary)
response = await reopenIssue(octokit, new Issue(filing.issue))
// 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) {
Expand Down
4 changes: 2 additions & 2 deletions .github/actions/file/src/openIssue.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ 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) {
export async function openIssue(octokit: Octokit, repoWithOwner: string, finding: Finding, screenshotRepo?: string) {
const owner = repoWithOwner.split('/')[0]
const repo = repoWithOwner.split('/')[1]

Expand All @@ -27,7 +27,7 @@ export async function openIssue(octokit: Octokit, repoWithOwner: string, finding
GITHUB_ISSUE_TITLE_MAX_LENGTH,
)

const body = generateIssueBody(finding)
const body = generateIssueBody(finding, screenshotRepo ?? repoWithOwner)

return octokit.request(`POST /repos/${owner}/${repo}/issues`, {
owner,
Expand Down
16 changes: 15 additions & 1 deletion .github/actions/file/src/reopenIssue.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,25 @@
import type {Octokit} from '@octokit/core'
import type {Issue} from './Issue.js'
import type {Finding} from './types.d.js'
import {generateIssueBody} from './generateIssueBody.js'

export async function reopenIssue(
octokit: Octokit,
{owner, repository, issueNumber}: Issue,
finding?: Finding,
repoWithOwner?: string,
screenshotRepo?: string,
) {
let body: string | undefined
if (finding && repoWithOwner) {
body = generateIssueBody(finding, screenshotRepo ?? repoWithOwner)
}

export async function reopenIssue(octokit: Octokit, {owner, repository, issueNumber}: Issue) {
return octokit.request(`PATCH /repos/${owner}/${repository}/issues/${issueNumber}`, {
owner,
repository,
issue_number: issueNumber,
state: 'open',
body,
})
}
1 change: 1 addition & 0 deletions .github/actions/file/src/types.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ export type Finding = {
problemUrl: string
solutionShort: string
solutionLong?: string
screenshotId?: string
}

export type Issue = {
Expand Down
14 changes: 14 additions & 0 deletions .github/actions/file/tests/generateIssueBody.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,4 +47,18 @@ describe('generateIssueBody', () => {
expect(body).not.toContain('- Fix any of the following:')
expect(body).not.toContain('- Fix all of the following:')
})

it('uses the screenshotRepo for the screenshot URL, not the filing repo', () => {
const body = generateIssueBody({...baseFinding, screenshotId: 'abc123'}, 'github/my-workflow-repo')

expect(body).toContain('github/my-workflow-repo/blob/gh-cache/.screenshots/abc123.png')
expect(body).not.toContain('github/accessibility-scanner')
})

it('omits screenshot section when screenshotId is not present', () => {
const body = generateIssueBody(baseFinding, 'github/accessibility-scanner')

expect(body).not.toContain('View screenshot')
expect(body).not.toContain('.screenshots')
})
})
80 changes: 80 additions & 0 deletions .github/actions/file/tests/openIssue.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import {describe, it, expect, vi} from 'vitest'

// Mock generateIssueBody so we can inspect what screenshotRepo is passed
vi.mock('../src/generateIssueBody.js', () => ({
generateIssueBody: vi.fn((_finding, screenshotRepo: string) => `body with screenshotRepo=${screenshotRepo}`),
}))

import {openIssue} from '../src/openIssue.ts'
import {generateIssueBody} from '../src/generateIssueBody.ts'

const baseFinding = {
scannerType: 'axe',
ruleId: 'color-contrast',
url: 'https://example.com/page',
html: '<span>Low contrast</span>',
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 between foreground and background colors meets WCAG thresholds',
}

function mockOctokit() {
return {
request: vi.fn().mockResolvedValue({data: {id: 1, html_url: 'https://github.com/org/repo/issues/1'}}),
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any
}

describe('openIssue', () => {
it('passes screenshotRepo to generateIssueBody when provided', async () => {
const octokit = mockOctokit()
await openIssue(octokit, 'org/filing-repo', 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)

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')

expect(octokit.request).toHaveBeenCalledWith(
'POST /repos/org/filing-repo/issues',
expect.objectContaining({
owner: 'org',
repo: 'filing-repo',
}),
)
})

it('includes the correct labels based on the finding', async () => {
const octokit = mockOctokit()
await openIssue(octokit, 'org/repo', baseFinding)

expect(octokit.request).toHaveBeenCalledWith(
expect.any(String),
expect.objectContaining({
labels: ['axe rule: color-contrast', 'axe-scanning-issue'],
}),
)
})

it('truncates long titles with ellipsis', async () => {
const octokit = mockOctokit()
const longFinding = {
...baseFinding,
problemShort: 'a'.repeat(300),
}
await openIssue(octokit, 'org/repo', longFinding)

const callArgs = octokit.request.mock.calls[0][1]
expect(callArgs.title.length).toBeLessThanOrEqual(256)
expect(callArgs.title).toMatch(/…$/)
})
})
98 changes: 98 additions & 0 deletions .github/actions/file/tests/reopenIssue.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import {describe, it, expect, vi, beforeEach} from 'vitest'

// Mock generateIssueBody so we can inspect what screenshotRepo is passed
vi.mock('../src/generateIssueBody.js', () => ({
generateIssueBody: vi.fn((_finding, screenshotRepo: string) => `body with screenshotRepo=${screenshotRepo}`),
}))

import {reopenIssue} from '../src/reopenIssue.ts'
import {generateIssueBody} from '../src/generateIssueBody.ts'
import {Issue} from '../src/Issue.ts'

const baseFinding = {
scannerType: 'axe',
ruleId: 'color-contrast',
url: 'https://example.com/page',
html: '<span>Low contrast</span>',
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 between foreground and background colors meets WCAG thresholds',
}

const testIssue = new Issue({
id: 42,
nodeId: 'MDU6SXNzdWU0Mg==',
url: 'https://github.com/org/filing-repo/issues/7',
title: 'Accessibility issue: test',
state: 'closed',
})

function mockOctokit() {
return {
request: vi.fn().mockResolvedValue({data: {id: 42, html_url: 'https://github.com/org/filing-repo/issues/7'}}),
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any
}

describe('reopenIssue', () => {
beforeEach(() => {
vi.clearAllMocks()
})

it('passes screenshotRepo to generateIssueBody when provided', async () => {
const octokit = mockOctokit()
await reopenIssue(octokit, testIssue, baseFinding, 'org/filing-repo', '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')

expect(generateIssueBody).toHaveBeenCalledWith(baseFinding, 'org/filing-repo')
})

it('does not generate a body when finding is not provided', async () => {
const octokit = mockOctokit()
await reopenIssue(octokit, testIssue)

expect(generateIssueBody).not.toHaveBeenCalled()
expect(octokit.request).toHaveBeenCalledWith(
expect.any(String),
expect.not.objectContaining({body: expect.anything()}),
)
})

it('does not generate a body when repoWithOwner is not provided', async () => {
const octokit = mockOctokit()
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')

expect(octokit.request).toHaveBeenCalledWith(
'PATCH /repos/org/filing-repo/issues/7',
expect.objectContaining({
state: 'open',
issue_number: 7,
}),
)
})

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')

expect(octokit.request).toHaveBeenCalledWith(
expect.any(String),
expect.objectContaining({
body: 'body with screenshotRepo=org/workflow-repo',
}),
)
})
})
4 changes: 4 additions & 0 deletions .github/actions/find/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,10 @@ inputs:
auth_context:
description: "Stringified JSON object containing 'username', 'password', 'cookies', and/or 'localStorage' from an authenticated session"
required: false
include_screenshots:
description: "Whether to capture screenshots of scanned pages and include links to them in the issue"
required: false
default: "false"

outputs:
findings:
Expand Down
18 changes: 15 additions & 3 deletions .github/actions/find/src/findForUrl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,13 @@ import type {Finding} from './types.d.js'
import AxeBuilder from '@axe-core/playwright'
import playwright from 'playwright'
import {AuthContext} from './AuthContext.js'
import {generateScreenshots} from './generateScreenshots.js'

export async function findForUrl(url: string, authContext?: AuthContext): Promise<Finding[]> {
export async function findForUrl(
url: string,
authContext?: AuthContext,
includeScreenshots: boolean = false,
): Promise<Finding[]> {
const browser = await playwright.chromium.launch({
headless: true,
executablePath: process.env.CI ? '/usr/bin/google-chrome' : undefined,
Expand All @@ -17,6 +22,12 @@ export async function findForUrl(url: string, authContext?: AuthContext): Promis
let findings: Finding[] = []
try {
const rawFindings = await new AxeBuilder({page}).analyze()

let screenshotId: string | undefined
if (includeScreenshots) {
screenshotId = await generateScreenshots(page)
}

findings = rawFindings.violations.map(violation => ({
scannerType: 'axe',
url,
Expand All @@ -26,9 +37,10 @@ export async function findForUrl(url: string, authContext?: AuthContext): Promis
ruleId: violation.id,
solutionShort: violation.description.toLowerCase().replace(/'/g, '&apos;'),
solutionLong: violation.nodes[0].failureSummary?.replace(/'/g, '&apos;'),
screenshotId,
}))
} catch (_e) {
// do something with the error
} catch (e) {
console.error('Error during accessibility scan:', e)
}
await context.close()
await browser.close()
Expand Down
Loading