Skip to content

perf: only load axe-core when accessibility is asserted - #290

Merged
MrPunyapal merged 4 commits into
pestphp:5.xfrom
dommer1:perf/lazy-axe
Oct 27, 2026
Merged

MrPunyapal merged 4 commits into
pestphp:5.xfrom
dommer1:perf/lazy-axe

Conversation

@dommer1

@dommer1 dommer1 commented Oct 18, 2026 •

Copy link
Copy Markdown
Contributor

Problem

InitScript::get() embeds the whole axe.min.js (553 KB) into the init script that every visit() registers on its browser context. Chromium therefore parses and executes axe-core in about:blank when the page is created, and again in every document the page navigates to — although only assertNoAccessibilityIssues() ever uses window.axe.

Timing the steps of PendingAwaitablePage::buildAwaitablePage() in a real app:

Step With axe in the init script Without
addInitScript() ~35 ms (553 KB over the websocket) ~0 ms
newPage() 240–310 ms 51–63 ms
navigation (load → content visible) ~40 ms more —

Change

  • InitScript keeps only the console log / JS error capture.
  • New Page::injectAxe() evaluates axe-core into the page right before axe.run(), only if window.axe is not there yet, and recursively into its iframes, so iframe content is still audited exactly like before (the init script used to load axe into every frame).
  • assertNoAccessibilityIssues() calls it after the existing load-state waits.

The only observable difference: window.axe is no longer present on pages until assertNoAccessibilityIssues() has been called.

Tests

There were no tests for assertNoAccessibilityIssues(), so the first commit adds them against the current behaviour (all pass on 5.x):

  • passes on an accessible page, fails with image-alt on an inaccessible one,
  • fails on a violation inside an iframe (verified that this test goes red if the iframes are not injected),
  • fails on a violation after navigating to another page.

The second commit adds the change and a test that axe is not loaded until the assertion runs (red on 5.x). Full suite: 351 passed, 27 skipped (onlyOnMac); type coverage 100 %; PHPStan, Pint and Rector clean.

Benchmark

Linux, 4 vCPU, Playwright 1.63, chromium-headless-shell.

This repository: trivial page, median of 20 warm visit('/')->assertSee(...), alternating runs:

Run 5.x this PR
1 461 ms 205 ms
2 427 ms 201 ms

A real Laravel + Inertia + React app (Mantine UI, ~3 MB Vite build):

5.x this PR
one warm visit() until content is visible ~950 ms ~650 ms
3 browser test files, 21 tests, sequential (A/B/A) 49.0 s / 50.0 s 42.5 s

The saving is roughly constant per page load (~250–300 ms), so it matters most for suites with many short browser tests.

dommer1 and others added 4 commits October 18, 2026 14:26
The init script embedded axe.min.js (553 KB) into every page, so Chromium
parsed and executed it on about:blank in newPage() and again on every
document, although only assertNoAccessibilityIssues() uses it.

axe-core is now injected right before axe.run() into the page and,
recursively, into its iframes, so iframe content is still audited.
Both branches added tests/Browser/Webpage/AssertNoAccessibilityIssuesTest.php.
The timeout test from pestphp#289 patched window.axe.run on the page's load event,
which no longer runs before axe-core is injected, so it would pass without
waiting. Its page now brings its own slow window.axe instead, which the
assertion uses rather than injecting axe-core; the test still fails with
the old fixed 5 second cap. The duplicate passing case from pestphp#289 is covered
by the existing one.
@MrPunyapal
MrPunyapal merged commit c5c04ab into pestphp:5.x Oct 27, 2026
2 checks passed
@MrPunyapal

Copy link
Copy Markdown
Member

Thanks

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants