From 52e121fccd9d92acb032bf0a6c3967e058f5a43c Mon Sep 17 00:00:00 2001 From: Christophe Dervieux Date: Mon, 20 Jul 2026 18:09:15 +0200 Subject: [PATCH 1/4] test(axe): add regression guard against reintroducing a CDN dependency (#14677) PR #14677 vendored axe-core to drop a Skypack CDN dependency so axe checking works offline and never phones home, but nothing guarded against a future revert. Adds two complementary tests: a Playwright behavioral guard that fails if an axe-enabled page makes any request to a known package-CDN host, and a Deno static guard that scans axe-check.js and format-html-axe.ts for hard-coded CDN host references (catching a CDN fallback path the behavioral test can't exercise, since the vendored file always returns 200 in tests). --- .../tests/axe-accessibility.spec.ts | 38 +++++++++++++++++ tests/unit/axe-no-cdn.test.ts | 41 +++++++++++++++++++ 2 files changed, 79 insertions(+) create mode 100644 tests/unit/axe-no-cdn.test.ts diff --git a/tests/integration/playwright/tests/axe-accessibility.spec.ts b/tests/integration/playwright/tests/axe-accessibility.spec.ts index 6747ff694a..e3e2f80348 100644 --- a/tests/integration/playwright/tests/axe-accessibility.spec.ts +++ b/tests/integration/playwright/tests/axe-accessibility.spec.ts @@ -680,3 +680,41 @@ test.describe('Axe — standard and best-practice scoping (#14607)', () => { .not.toContain('heading'); }); }); + +test.describe('Axe — no third-party/CDN dependency (regression guard for #14677)', () => { + const CDN_DENYLIST = [ + 'skypack.dev', + 'unpkg.com', + 'jsdelivr.net', + 'esm.sh', + 'cdnjs.cloudflare.com', + ]; + + function cdnHost(url: string): string | null { + let hostname: string; + try { + hostname = new URL(url).hostname; + } catch { + return null; + } + return CDN_DENYLIST.find( + (base) => hostname === base || hostname.endsWith('.' + base), + ) ?? null; + } + + test('html — axe-enabled page makes no request to a package CDN', async ({ page }) => { + const cdnRequests: string[] = []; + page.on('request', (req) => { + if (cdnHost(req.url())) cdnRequests.push(req.url()); + }); + + await page.goto('/html/axe-accessibility.html', { waitUntil: 'networkidle' }); + await waitForAxeCompletion(page); + + expect( + cdnRequests, + `axe-enabled page must not load resources from a package CDN. ` + + `Offending request(s): ${cdnRequests.join(', ')}`, + ).toEqual([]); + }); +}); diff --git a/tests/unit/axe-no-cdn.test.ts b/tests/unit/axe-no-cdn.test.ts new file mode 100644 index 0000000000..f4281fd600 --- /dev/null +++ b/tests/unit/axe-no-cdn.test.ts @@ -0,0 +1,41 @@ +/* + * axe-no-cdn.test.ts + * + * PR #14677 vendored axe-core so accessibility checking works offline and no + * request goes to a CDN at runtime. The Playwright behavioral guard cannot + * catch a *fallback* CDN load that only fires when the vendored file 404s + * (the file returns 200 in tests). This static check does: it fails if either + * the checker module or its dependency-wiring hard-codes a package-CDN host. + * + * Copyright (C) 2020-2025 Posit Software, PBC + */ + +import { unitTest } from "../test.ts"; +import { assertEquals } from "testing/asserts"; + +const CDN_DENYLIST = [ + "skypack.dev", + "unpkg.com", + "jsdelivr.net", + "esm.sh", + "cdnjs.cloudflare.com", +]; + +const SCANNED_FILES = [ + "../../src/resources/formats/html/axe/axe-check.js", + "../../src/format/html/format-html-axe.ts", +]; + +// deno-lint-ignore require-await +unitTest("axe dependency wiring references no package CDN (regression guard #14677)", async () => { + for (const relPath of SCANNED_FILES) { + const src = Deno.readTextFileSync(new URL(relPath, import.meta.url)); + const offenders = CDN_DENYLIST.filter((host) => src.includes(host)); + assertEquals( + offenders, + [], + `${relPath} must not reference a package CDN (found: ${offenders.join(", ")}). ` + + `axe-core is vendored (#14677); load it same-origin, not from a CDN.`, + ); + } +}); From a90db4145165329c3768e7192ba0b1bb209b40ba Mon Sep 17 00:00:00 2001 From: Christophe Dervieux Date: Mon, 20 Jul 2026 18:25:39 +0200 Subject: [PATCH 2/4] test(axe): guard that the report overlay scrolls by keyboard (#14680) Commit 4a528a2e3 made the fixed HTML overlay a focusable native scroll region (overflow-y: auto + tabindex=0, no keydown handler). Coverage was static only: attribute checks plus the axe self-scan. Neither catches the overlay silently losing keyboard scroll while the attributes stay intact (overflow-y flipped away from auto, or a future keydown handler swallowing the keys). Add a Playwright test on the existing axe-overlay-scroll fixture that focuses the overlay, presses PageDown and ArrowDown, and asserts the overlay's scrollTop actually changes. --- .../tests/axe-accessibility.spec.ts | 45 +++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/tests/integration/playwright/tests/axe-accessibility.spec.ts b/tests/integration/playwright/tests/axe-accessibility.spec.ts index e3e2f80348..3281116f41 100644 --- a/tests/integration/playwright/tests/axe-accessibility.spec.ts +++ b/tests/integration/playwright/tests/axe-accessibility.spec.ts @@ -718,3 +718,48 @@ test.describe('Axe — no third-party/CDN dependency (regression guard for #1467 ).toEqual([]); }); }); + +test.describe('HTML axe — the report overlay scrolls by keyboard (#14680)', () => { + // Commit 4a528a2e3 made the fixed overlay a focusable scroll region so a + // report taller than max-height: 50vh can be scrolled from the keyboard. + // Keyboard scrolling here is *native* browser behavior (overflow-y: auto + + // tabindex=0, no keydown handler), so the regression this guards is the + // scroll behavior breaking — overflow-y flipped away from auto, or a future + // keydown handler swallowing the keys — while tabindex/role/aria-label stay + // intact. Those cases pass the static attribute checks (line ~172) AND the + // overlay self-scan (scrollable-region-focusable, line ~388), so only an + // actual key press that moves scrollTop catches them. + for (const key of ['PageDown', 'ArrowDown']) { + test(`html — focused overlay scrolls on ${key}`, async ({ page }) => { + await page.goto('/html/axe-overlay-scroll.html', { waitUntil: 'networkidle' }); + + const axeReport = page.locator('.quarto-axe-report'); + await expect(axeReport).toBeVisible({ timeout: 10000 }); + + // Precondition: the fixture must inflate the report past its max-height, + // or there is nothing to scroll and a green result would be meaningless. + const overflow = await axeReport.evaluate( + (el) => ({ scrollHeight: el.scrollHeight, clientHeight: el.clientHeight }), + ); + expect( + overflow.scrollHeight, + `overlay must overflow to be scrollable (scrollHeight ${overflow.scrollHeight} ` + + `must exceed clientHeight ${overflow.clientHeight}); check axe-overlay-scroll.qmd`, + ).toBeGreaterThan(overflow.clientHeight); + + // Start from the top and put focus on the overlay itself. toBeFocused is + // test hygiene: it proves the key goes to the overlay, not the document, + // so a scrollTop change can only mean the overlay scrolled. + await axeReport.evaluate((el) => { el.scrollTop = 0; }); + await axeReport.focus(); + await expect(axeReport).toBeFocused(); + + await page.keyboard.press(key); + + // Native key scroll is instant, but poll to avoid any timing race. + await expect + .poll(() => axeReport.evaluate((el) => el.scrollTop), { timeout: 3000 }) + .toBeGreaterThan(0); + }); + } +}); From 1af72efa9efa6e5d8c27303f1c3d35fa31a880ff Mon Sep 17 00:00:00 2001 From: Christophe Dervieux Date: Mon, 20 Jul 2026 20:25:50 +0200 Subject: [PATCH 3/4] test(axe): self-scan the reveal report slide and dashboard offcanvas The report chrome is injected after the page scan runs, so like the HTML overlay it never audits itself. Scan .quarto-axe-report-slide (navigated present, since it is otherwise a hidden future slide) and #quarto-axe-offcanvas plus its sibling .quarto-axe-toggle, asserting zero violations. Guards against a template refactor dropping a button aria-label, alt text, or aria wiring. The dashboard offcanvas body already overflows on this fixture and axe flags scrollable-region-focusable on it (quarto-dev/quarto-cli#14710) -- filtered out of this test's assertion rather than using test.fail(), so the test still catches any other regression (e.g. a dropped aria-label) while that issue is open. The reveal report slide has the same latent gap once it overflows (confirmed manually with a throwaway long fixture, not committed here since the rule doesn't fire on this short fixture) but is out of scope for a permanent guard test per #14710 pending a fix decision. --- .../tests/axe-accessibility.spec.ts | 80 +++++++++++++++++++ 1 file changed, 80 insertions(+) diff --git a/tests/integration/playwright/tests/axe-accessibility.spec.ts b/tests/integration/playwright/tests/axe-accessibility.spec.ts index 3281116f41..2c0c77853e 100644 --- a/tests/integration/playwright/tests/axe-accessibility.spec.ts +++ b/tests/integration/playwright/tests/axe-accessibility.spec.ts @@ -408,6 +408,86 @@ test.describe('HTML axe — the report overlay passes its own scan', () => { }); }); +test.describe('RevealJS axe — the report slide passes its own scan', () => { + test('report slide has no axe-core violations', async ({ page }) => { + // The report slide is injected after the page scan runs, so it never + // audits itself (same gap as the HTML overlay). Scan it here with the + // same vendored axe-core build the deck already loaded (window.axe). + // + // createReportSlide() navigates back to the original slide, leaving the + // report slide as a hidden future slide (hidden + aria-hidden). axe skips + // hidden elements, so we MUST navigate to the report slide and wait for + // `.present` first — otherwise the scan is a vacuous, permanent PASS. + await page.goto('/revealjs/axe-accessibility.html', { waitUntil: 'networkidle' }); + + const reportSlide = page.locator('section.quarto-axe-report-slide'); + await expect(reportSlide).toBeAttached({ timeout: 10000 }); + await waitForAxeCompletion(page); + + await page.evaluate(() => Reveal.slide(Reveal.getTotalSlides() - 1)); + await expect(reportSlide).toHaveClass(/present/); + + // Non-empty precondition: guards against the region silently becoming + // empty in a future refactor, which would make violations == [] a + // vacuous pass rather than a real guard. + await expect( + reportSlide.locator('.quarto-axe-violation-description'), + ).not.toHaveCount(0); + + const violations = await page.evaluate(async () => { + const axe = (window as any).axe; + const result = await axe.run(document.querySelector('.quarto-axe-report-slide')); + return result.violations.map((v: { id: string; nodes: { target: string[] }[] }) => ({ + id: v.id, + targets: v.nodes.map((n) => n.target), + })); + }); + expect(violations).toEqual([]); + }); +}); + +test.describe('Dashboard axe — the offcanvas passes its own scan', () => { + test('offcanvas and toggle have no axe-core violations', async ({ page }) => { + // The offcanvas and its toggle button are injected after the page scan + // runs, so they never audit themselves on initial load. Scan both here. + // The toggle (.quarto-axe-toggle) is a SIBLING of #quarto-axe-offcanvas + // (both appended to ), so it must be an explicit include — scanning + // the offcanvas alone would miss a dropped aria-label on the toggle. + await page.goto('/dashboard/axe-accessibility.html', { waitUntil: 'networkidle' }); + + const offcanvas = page.locator('#quarto-axe-offcanvas'); + await expect(offcanvas).toBeVisible({ timeout: 10000 }); + + // Non-empty precondition — see reveal test above for rationale. + await expect( + offcanvas.locator('.quarto-axe-violation-description'), + ).not.toHaveCount(0); + + const violations = await page.evaluate(async () => { + const axe = (window as any).axe; + const result = await axe.run({ + include: [['#quarto-axe-offcanvas'], ['.quarto-axe-toggle']], + }); + return result.violations.map((v: { id: string; nodes: { target: string[] }[] }) => ({ + id: v.id, + targets: v.nodes.map((n) => n.target), + })); + }); + + // Known issue quarto-dev/quarto-cli#14710: .offcanvas-body is a scrolling + // region with no focusable descendant and isn't itself focusable, so it + // trips scrollable-region-focusable even on this short fixture (its fixed + // height already overflows). Filtered out (not test.fail()'d) so this + // test still catches any other regression here — e.g. a dropped + // aria-label on .btn-close or .quarto-axe-toggle — while #14710 is open. + const unexpected = violations.filter( + (v) => !(v.id === 'scrollable-region-focusable' && + v.targets.some((t) => t.includes('.offcanvas-body'))), + ); + expect(unexpected).toEqual([]); + }); +}); + test.describe('Dashboard axe — re-scan on visibility change', () => { const pagesUrl = '/dashboard/axe-accessibility-pages.html'; From cc609fb1f199ef5455d8a0cdd5170fdc94f624b6 Mon Sep 17 00:00:00 2001 From: Christophe Dervieux Date: Tue, 21 Jul 2026 11:56:53 +0200 Subject: [PATCH 4/4] test(axe): filter dashboard scroll-focus known-issue at node level Whole-violation filtering hid a hypothetical second, unrelated scrollable-region-focusable node in the same scan scope as long as the known .offcanvas-body node remained. Filter targets instead so any other node under the rule still fails the test while #14710 is open. --- .../playwright/tests/axe-accessibility.spec.ts | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/tests/integration/playwright/tests/axe-accessibility.spec.ts b/tests/integration/playwright/tests/axe-accessibility.spec.ts index 2c0c77853e..547df4d7d2 100644 --- a/tests/integration/playwright/tests/axe-accessibility.spec.ts +++ b/tests/integration/playwright/tests/axe-accessibility.spec.ts @@ -477,13 +477,17 @@ test.describe('Dashboard axe — the offcanvas passes its own scan', () => { // Known issue quarto-dev/quarto-cli#14710: .offcanvas-body is a scrolling // region with no focusable descendant and isn't itself focusable, so it // trips scrollable-region-focusable even on this short fixture (its fixed - // height already overflows). Filtered out (not test.fail()'d) so this - // test still catches any other regression here — e.g. a dropped - // aria-label on .btn-close or .quarto-axe-toggle — while #14710 is open. - const unexpected = violations.filter( - (v) => !(v.id === 'scrollable-region-focusable' && - v.targets.some((t) => t.includes('.offcanvas-body'))), - ); + // height already overflows). That one node is filtered out (not + // test.fail()'d) at the node level — not the whole violation — so this + // test still catches any other regression here, including a second, + // unrelated scrollable-region-focusable node — while #14710 is open. + const unexpected = violations + .map((v) => + v.id === 'scrollable-region-focusable' + ? { ...v, targets: v.targets.filter((t) => !t.includes('.offcanvas-body')) } + : v, + ) + .filter((v) => v.targets.length > 0); expect(unexpected).toEqual([]); }); });