Skip to content

Commit b826e26

Browse files
authored
Adds site-with-errors test back, only checks PRs daily (#156)
Adds back the site-with-errors tests to run on every PR, but will only check issues. PR creation validation will be run at a daily cadence so we can add enough of a timeout to ensure the Copilot PRs are created (sometimes they take several minutes). Also adds a check in the workflow if the test _does_ fail, so we can address it. Current [site-with-errors test added back passes](https://github.com/github/accessibility-scanner/actions/runs/23054537722/job/66964182473?pr=156), excluding the PR check 🎉 I'll have to run the `test-copilot-pr-creation.yml` workflow once this PR is merged but can make any follow-up changes necessary!
2 parents 9e2af13 + a3ed935 commit b826e26

File tree

3 files changed

+239
-43
lines changed

3 files changed

+239
-43
lines changed
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
name: Report failure
2+
3+
on: workflow_call
4+
5+
permissions:
6+
issues: write
7+
8+
jobs:
9+
report-failure:
10+
runs-on: ubuntu-latest
11+
name: Report failure
12+
steps:
13+
- uses: actions/github-script@v8
14+
with:
15+
script: |
16+
github.rest.issues.create({ owner: context.repo.owner, repo: context.repo.repo, title: "`${{ github.workflow }}` workflow failed", body: "The workflow, `${{ github.workflow }}`, failed to run. See [Action run output](https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}) for more information. cc: @github/accessibility-reviewers." })
Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
name: Test Copilot PR creation
2+
on:
3+
schedule:
4+
- cron: "0 6 * * *" # Every day at 06:00 UTC
5+
workflow_dispatch:
6+
7+
permissions:
8+
contents: write
9+
issues: write
10+
pull-requests: write
11+
12+
concurrency:
13+
group: ${{ github.workflow }}
14+
cancel-in-progress: false
15+
16+
env:
17+
TESTING_REPOSITORY: github/accessibility-scanner-testing
18+
19+
jobs:
20+
test_pull_request_creation:
21+
name: Test (with PR creation)
22+
runs-on: ubuntu-latest
23+
strategy:
24+
matrix:
25+
site: ["sites/site-with-errors"]
26+
steps:
27+
- name: Checkout
28+
uses: actions/checkout@v6
29+
30+
- name: Setup Ruby
31+
uses: ruby/setup-ruby@6ca151fd1bfcfd6fe0c4eb6837eb0584d0134a0c
32+
with:
33+
ruby-version: "3.4"
34+
bundler-cache: true
35+
working-directory: ${{ matrix.site }}
36+
37+
- name: Build Jekyll site (${{ matrix.site }})
38+
shell: bash
39+
working-directory: ${{ matrix.site }}
40+
env:
41+
JEKYLL_ENV: production
42+
run: bundle exec jekyll build
43+
44+
- name: Start Puma (${{ matrix.site }})
45+
shell: bash
46+
working-directory: ${{ matrix.site }}
47+
env:
48+
TEST_USERNAME: ${{ secrets.TEST_USERNAME }}
49+
TEST_PASSWORD: ${{ secrets.TEST_PASSWORD }}
50+
run: |
51+
set -euo pipefail
52+
bundle exec puma -b tcp://127.0.0.1:4000 &
53+
echo "Starting Puma on port 4000"
54+
curl -fsS --retry 25 --retry-delay 1 --retry-all-errors -u "${TEST_USERNAME}:${TEST_PASSWORD}" "http://127.0.0.1:4000/" > /dev/null
55+
echo "Puma has started"
56+
57+
- name: Generate cache key
58+
id: cache_key
59+
shell: bash
60+
run: |
61+
echo "cache_key=$(printf 'cached_results-%s-%s.json' "${{ matrix.site }}" "daily" | tr -cs 'A-Za-z0-9._-' '_')" >> $GITHUB_OUTPUT
62+
63+
- name: Scan site (${{ matrix.site }})
64+
uses: ./
65+
with:
66+
urls: |
67+
http://127.0.0.1:4000/
68+
http://127.0.0.1:4000/jekyll/update/2025/07/30/welcome-to-jekyll.html
69+
http://127.0.0.1:4000/about/
70+
http://127.0.0.1:4000/404.html
71+
login_url: http://127.0.0.1:4000/
72+
username: ${{ secrets.TEST_USERNAME }}
73+
password: ${{ secrets.TEST_PASSWORD }}
74+
repository: ${{ env.TESTING_REPOSITORY }}
75+
token: ${{ secrets.GH_TOKEN }}
76+
cache_key: ${{ steps.cache_key.outputs.cache_key }}
77+
78+
- name: Retrieve cached results
79+
uses: ./.github/actions/gh-cache/restore
80+
with:
81+
path: ${{ steps.cache_key.outputs.cache_key }}
82+
token: ${{ secrets.GITHUB_TOKEN }}
83+
84+
- name: Validate scan results (${{ matrix.site }})
85+
run: |
86+
npm ci
87+
npm run test
88+
env:
89+
GITHUB_TOKEN: ${{ secrets.GH_TOKEN }}
90+
CACHE_PATH: ${{ steps.cache_key.outputs.cache_key }}
91+
WAIT_FOR_PULL_REQUESTS: "true"
92+
93+
- name: Clean up issues and pull requests
94+
if: ${{ always() }}
95+
shell: bash
96+
run: |
97+
set -euo pipefail
98+
if [[ ! -f "${{ steps.cache_key.outputs.cache_key }}" ]]; then
99+
echo "Skipping 'Clean up issues and pull requests' (no cached results)."
100+
exit 0
101+
fi
102+
jq -r '
103+
(if type=="string" then fromjson else . end)
104+
| .[] | .issue.url, .pullRequest.url
105+
| select(. != null)
106+
' "${{ steps.cache_key.outputs.cache_key }}" \
107+
| while read -r URL; do
108+
if [[ "$URL" == *"/pull/"* ]]; then
109+
echo "Closing pull request: $URL"
110+
gh pr close "$URL" || echo "Failed to close pull request: $URL"
111+
branch="$(gh pr view "$URL" --json headRefName -q .headRefName || true)"
112+
if [[ -n "$branch" ]]; then
113+
echo "Deleting branch: $branch"
114+
gh api -X DELETE "repos/${{ env.TESTING_REPOSITORY }}/git/refs/heads/$branch" || echo "Failed to delete branch: $branch"
115+
fi
116+
elif [[ "$URL" == *"/issues/"* ]]; then
117+
echo "Closing issue: $URL"
118+
gh issue close "$URL" || echo "Failed to close issue: $URL"
119+
else
120+
echo "Skipping unrecognized url: $URL"
121+
fi
122+
done
123+
env:
124+
GITHUB_TOKEN: ${{ secrets.GH_TOKEN }}
125+
126+
- name: Clean up cached results
127+
if: ${{ always() }}
128+
uses: ./.github/actions/gh-cache/delete
129+
with:
130+
path: ${{ steps.cache_key.outputs.cache_key }}
131+
token: ${{ secrets.GITHUB_TOKEN }}
132+
133+
notify-of-failures:
134+
if: always() && github.ref_name == 'main' && needs.test_pull_request_creation.result == 'failure'
135+
needs: [test_pull_request_creation]
136+
uses: github/accessibility-scorecard/.github/workflows/report-failure.yml@main

tests/site-with-errors.test.ts

Lines changed: 87 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,58 @@ import {Octokit} from '@octokit/core'
66
import {throttling} from '@octokit/plugin-throttling'
77
const OctokitWithThrottling = Octokit.plugin(throttling)
88

9-
describe.skip('site-with-errors', () => {
9+
const WAIT_FOR_PULL_REQUESTS = !!process.env.WAIT_FOR_PULL_REQUESTS
10+
const POLL_INTERVAL_MS = 30_000 // 30 seconds
11+
const POLL_TIMEOUT_MS = 15 * 60 * 1000 // 15 minutes
12+
13+
/**
14+
* Repeatedly calls `fn` until `predicate(result)` returns `true`, or until the timeout is exceeded.
15+
* Errors thrown by `fn` (e.g. HTTP 404) are swallowed while polling continues.
16+
*/
17+
async function pollUntil<T>(
18+
fn: () => Promise<T>,
19+
predicate: (result: T) => boolean,
20+
{intervalMs, timeoutMs}: {intervalMs: number; timeoutMs: number},
21+
): Promise<T> {
22+
const deadline = Date.now() + timeoutMs
23+
let lastError: unknown
24+
while (true) {
25+
try {
26+
const result = await fn()
27+
if (predicate(result)) return result
28+
} catch (error) {
29+
lastError = error
30+
}
31+
if (Date.now() >= deadline) {
32+
throw lastError ?? new Error(`Timed out after ${timeoutMs}ms waiting for condition`)
33+
}
34+
await new Promise(resolve => setTimeout(resolve, intervalMs))
35+
}
36+
}
37+
38+
function createOctokit(): InstanceType<typeof OctokitWithThrottling> {
39+
return new OctokitWithThrottling({
40+
auth: process.env.GITHUB_TOKEN,
41+
throttle: {
42+
onRateLimit: (retryAfter, options, octokit, retryCount) => {
43+
octokit.log.warn(`Request quota exhausted for request ${options.method} ${options.url}`)
44+
if (retryCount < 3) {
45+
octokit.log.info(`Retrying after ${retryAfter} seconds!`)
46+
return true
47+
}
48+
},
49+
onSecondaryRateLimit: (retryAfter, options, octokit, retryCount) => {
50+
octokit.log.warn(`Secondary rate limit hit for request ${options.method} ${options.url}`)
51+
if (retryCount < 3) {
52+
octokit.log.info(`Retrying after ${retryAfter} seconds!`)
53+
return true
54+
}
55+
},
56+
},
57+
})
58+
}
59+
60+
describe('site-with-errors', () => {
1061
let results: Result[]
1162

1263
beforeAll(() => {
@@ -16,11 +67,13 @@ describe.skip('site-with-errors', () => {
1667
})
1768

1869
it('cache has expected results', () => {
19-
const actual = results.map(({issue: {url: issueUrl}, pullRequest: {url: pullRequestUrl}, findings}) => {
70+
const actual = results.map(({issue: {url: issueUrl}, pullRequest, findings}) => {
2071
const {problemUrl, solutionLong, screenshotId, ...finding} = findings[0]
2172
// Check volatile fields for existence only
2273
expect(issueUrl).toBeDefined()
23-
expect(pullRequestUrl).toBeDefined()
74+
if (WAIT_FOR_PULL_REQUESTS) {
75+
expect(pullRequest?.url).toBeDefined()
76+
}
2477
expect(problemUrl).toBeDefined()
2578
expect(solutionLong).toBeDefined()
2679
// Check `problemUrl`, ignoring axe version
@@ -100,32 +153,11 @@ describe.skip('site-with-errors', () => {
100153
expect(process.env.GITHUB_TOKEN).toBeDefined()
101154
})
102155

103-
describe.runIf(!!process.env.GITHUB_TOKEN)('—', () => {
104-
let octokit: Octokit
156+
describe.runIf(!!process.env.GITHUB_TOKEN)('issues', () => {
105157
let issues: Endpoints['GET /repos/{owner}/{repo}/issues/{issue_number}']['response']['data'][]
106-
let pullRequests: Endpoints['GET /repos/{owner}/{repo}/pulls/{pull_number}']['response']['data'][]
107158

108159
beforeAll(async () => {
109-
octokit = new OctokitWithThrottling({
110-
auth: process.env.GITHUB_TOKEN,
111-
throttle: {
112-
onRateLimit: (retryAfter, options, octokit, retryCount) => {
113-
octokit.log.warn(`Request quota exhausted for request ${options.method} ${options.url}`)
114-
if (retryCount < 3) {
115-
octokit.log.info(`Retrying after ${retryAfter} seconds!`)
116-
return true
117-
}
118-
},
119-
onSecondaryRateLimit: (retryAfter, options, octokit, retryCount) => {
120-
octokit.log.warn(`Secondary rate limit hit for request ${options.method} ${options.url}`)
121-
if (retryCount < 3) {
122-
octokit.log.info(`Retrying after ${retryAfter} seconds!`)
123-
return true
124-
}
125-
},
126-
},
127-
})
128-
// Fetch issues referenced in the cache file
160+
const octokit = createOctokit()
129161
issues = await Promise.all(
130162
results.map(async ({issue: {url: issueUrl}}) => {
131163
expect(issueUrl).toBeDefined()
@@ -142,23 +174,6 @@ describe.skip('site-with-errors', () => {
142174
return issue
143175
}),
144176
)
145-
// Fetch pull requests referenced in the findings file
146-
pullRequests = await Promise.all(
147-
results.map(async ({pullRequest: {url: pullRequestUrl}}) => {
148-
expect(pullRequestUrl).toBeDefined()
149-
const {owner, repo, pullNumber} =
150-
/https:\/\/github\.com\/(?<owner>[^/]+)\/(?<repo>[^/]+)\/pull\/(?<pullNumber>\d+)/.exec(
151-
pullRequestUrl!,
152-
)!.groups!
153-
const {data: pullRequest} = await octokit.request('GET /repos/{owner}/{repo}/pulls/{pull_number}', {
154-
owner,
155-
repo,
156-
pull_number: parseInt(pullNumber, 10),
157-
})
158-
expect(pullRequest).toBeDefined()
159-
return pullRequest
160-
}),
161-
)
162177
})
163178

164179
it('issues exist and have expected title, state, and assignee', async () => {
@@ -179,6 +194,35 @@ describe.skip('site-with-errors', () => {
179194
expect(issue.assignees!.some(a => a.login === 'Copilot')).toBe(true)
180195
}
181196
})
197+
})
198+
199+
describe.runIf(!!process.env.GITHUB_TOKEN && WAIT_FOR_PULL_REQUESTS)('pull requests', () => {
200+
let pullRequests: Endpoints['GET /repos/{owner}/{repo}/pulls/{pull_number}']['response']['data'][]
201+
202+
beforeAll(async () => {
203+
const octokit = createOctokit()
204+
pullRequests = await Promise.all(
205+
results.map(async ({pullRequest: {url: pullRequestUrl}}) => {
206+
expect(pullRequestUrl).toBeDefined()
207+
const {owner, repo, pullNumber} =
208+
/https:\/\/github\.com\/(?<owner>[^/]+)\/(?<repo>[^/]+)\/pull\/(?<pullNumber>\d+)/.exec(
209+
pullRequestUrl!,
210+
)!.groups!
211+
return pollUntil(
212+
async () => {
213+
const {data} = await octokit.request('GET /repos/{owner}/{repo}/pulls/{pull_number}', {
214+
owner,
215+
repo,
216+
pull_number: parseInt(pullNumber, 10),
217+
})
218+
return data
219+
},
220+
pr => pr.state === 'open',
221+
{intervalMs: POLL_INTERVAL_MS, timeoutMs: POLL_TIMEOUT_MS},
222+
)
223+
}),
224+
)
225+
}, POLL_TIMEOUT_MS + 60_000)
182226

183227
it('pull requests exist and have expected author, state, and assignee', async () => {
184228
for (const pullRequest of pullRequests) {

0 commit comments

Comments
 (0)