fix(e2e): give axe.run() a dedicated 120s eval timeout (bookshelf-t4ftz) #1336
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "bd-bookshelf-t4ftz"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Root cause
axe.run()is a CPU-intensive JS Promise that serialises the full WCAG rule-set result. Under CI CPU contention it routinely exceeds the 60spageTimeout(which covers navigation + DOM stability), causingpage.Eval(runJS)to returncontext deadline exceeded— surfacing as[FAIL]ataxe_helpers_test.go:229.The inherited page context deadline (set by
refreshPageTimeoutfor navigation) was also being used for the axe.run() Promise eval. Navigation + DOM-stability typically takes <5s, leaving only the remainder of 60s for the actual axe scan — not enough under contention.Fix
Inside
runAxe, callpage.CancelTimeout().Timeout(axeEvalTimeout)(120s) beforepage.Eval(runJS)to give the scan a fresh, independent budget. This matches theCancelTimeout+Timeoutpattern used throughout the browser e2e suite (refreshPageTimeout, per the go-rod Ordered-journey gotcha documented in CLAUDE.md) and does not affect navigation/stability timeouts in callers.Confirmed not a real a11y violation
The
[FAIL]ataxe_helpers_test.go:229isExpect(err).NotTo(HaveOccurred(), "runAxe: axe.run() evaluation failed")— the error is a Go context error from the deadline expiring, not an axe violation report. A real violation would fail at theFail(msg.String())call later in the function with a descriptive message listing rule IDs and nodes.Test plan
go vet -tags e2e ./e2e/browser/cleango build ./...cleanCloses bead bookshelf-t4ftz on merge.
Code Review — bookshelf-t4ftz / PR #1336
CI status:
success(all required checks green).Mergeable:
True.Phase 1: Spec Compliance
The bead description says: determine whether the axe-core scan is genuinely slow (needs a bigger deadline / go-rod page.Timeout reset per It per CLAUDE.md go-rod gotcha) or a real violation. Fix root cause; do not just bump the timeout without understanding.
Root cause determination is evidenced in the bead completion comment: "context deadline exceeded during axe.run() Promise eval under CI CPU contention — the page's inherited navigation deadline was expiring before the CPU-intensive axe scan completed." The fix correctly addresses this by giving the eval its own dedicated deadline rather than bumping the shared navigation timeout. Scope is appropriate.
Phase 2: Code Quality
Correctness of go-rod timeout handling
page.CancelTimeout()returns a new*rod.Pageclone whose context has been cancelled (the deadline removed), and.Timeout(axeEvalTimeout)then attaches a fresh 120s absolute deadline to that clone. The originalpagevariable (the outer closure variable shared acrossItsteps) is NOT reassigned —axePageis a local. This is the correct pattern:runAxereceivespageby value, createsaxePagelocally, evaluates, and returns. The outerpagevariable retains whatever deadlinerefreshPageTimeoutset at the top of theIt(via theBeforeEach+ inline reassignments likepage = refreshPageTimeout(page)). SorunAxedoes NOT clobber the journey-level page deadline — subsequentItsteps start withrefreshPageTimeoutanyway per theBeforeEach. No state leak.Does 120s paper over a real a11y violation?
No. The timeout only gates the
Evalcall — i.e., whetheraxe.run()completes before Go's context deadline fires. If axe.run() finishes and reports violations, the result flows intorunAxe's violation parsing logic exactly as before. The stale-baseline check and the unexpected-violation assertion are both downstream of the eval; a genuine new violation still fails the test at theFail(msg.String())call inrunAxe. The 120s budget cannot mask a real a11y regression.Could this hide a genuine hang?
Under a genuine hang (e.g.,
axe.run()never resolving because the page is broken), the test would now wait 120s before failing instead of 60s. The cost is 60 additional seconds of CI time per hung spec, not a masked failure — it still fails. Acceptable.Page reuse across Ordered journey (CLAUDE.md go-rod gotcha)
The
BeforeEachin the calling spec (journey_a11y_guardrails_test.go:101) always callspage = refreshPageTimeout(page)at the start of everyIt, which resets the page's absolute deadline to a freshpageTimeout(60s). TheaxePagelocal inrunAxeis derived from the per-callpageargument and is never stored back. AfterrunAxereturns, subsequent calls inside the sameIt(e.g., nextMustNavigate, nextrunAxe) work against the samepagethat hadrefreshPageTimeoutcalled at the top of the step — not against the expiredaxePagecontext. The fix is correctly scoped.Convention checks
package browser_test: correct black-box test package."time"import: newly added, correctly placed.const axeEvalTimeoutdeclared at top-level with a clear doc comment: follows var-at-top spirit (constants are hoisted by nature). Naming is clear..golangci.ymlexclusions added.No findings.
REVIEW VERDICT: 0 blocker, 0 major, 0 minor
Security Review — PR #1336 (bookshelf-t4ftz)
Diff scope:
e2e/browser/axe_helpers_test.goonly — addsaxeEvalTimeout = 120 * time.Secondconstant and changesrunAxeto callpage.CancelTimeout().Timeout(axeEvalTimeout)beforeaxePage.Eval(runJS).Surface analysis
Is
runJSattacker-influenced? No.runJSis a package-levelconststring literal hardcoded in the source. It is never interpolated from test inputs, environment variables, or page content. No injection surface exists.Does
injectAxeexecute attacker-controlled code? No.loadAxeScript()readstestdata/axe.min.jsfrom a repo-committed path viaruntime.Caller(0)— a fixed relative path to a file checked into source control. The content is never user-supplied.Does
CancelTimeout().Timeout(120s)affect scope beyond the axe Promise? TheCancelTimeout()call resets the deadline on a clone of the page (rod'sTimeout/CancelTimeoutmethods return a new*rod.Pagevalue with a modified context — they do not mutate the original page pointer). The result is assigned to the localaxePagevariable; subsequent operations in the sameItstep that use the outerpagevariable are unaffected. This matches the existingrefreshPageTimeoutpattern inbrowser_suite_test.go.Does the A11Y journey gate any security-relevant assertion? The journey (
journey_a11y_guardrails_test.go) callsrunAxeexactly once in Step 1 (the WCAG axe scan). CSP checking is fully separate and lives inbrowser_csp_helpers_test.govia thecspViolationCollector/proto.AuditsEnablepath — it is not wired throughrunAxeand is unaffected by this timeout change. There is no scenario where a longer axe timeout could cause a security-relevant assertion (CSP, header check, etc.) to silently pass.Production code path? Zero. The file carries
//go:build e2eandpackage browser_test. It is never compiled into the application binary.Secrets / PII in test? None. The diff adds only a
time.Durationconstant and three lines rewiring the eval call. No tokens, credentials, or PII appear.No findings.
REVIEW VERDICT: 0 blocker, 0 major, 0 minor