Repository navigation
Expand file tree
/
Copy pathaxe-scan.mjs
More file actions
926 lines (866 loc) · 39.8 KB
/
Copy pathaxe-scan.mjs
File metadata and controls
926 lines (866 loc) · 39.8 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
// Shared definition of "the accessibility scan".
//
// Three consumers:
//
// * scripts/check_a11y.mjs -- the production gate (check.bat, CI)
// * scripts/check_a11y_fingerprint.mjs -- the correctness gate: does a
// candidate configuration still see everything production sees?
// * perf/ab-axe.mjs -- the cost-attribution rig
//
// All three have to agree on the page list, the viewports, the themes, the
// blocked requests and the axe run options. If they drift, the fingerprint
// gate stops gating what production actually runs, which is the one failure
// mode the gate exists to prevent. So everything that defines the scan lives
// here and nowhere else.
//
// See builder/PLAN-axe-perf.md for why each knob below is a knob.
import { readdirSync, readFileSync } from "node:fs";
import { join, relative, sep } from "node:path";
import { pathToFileURL } from "node:url";
import { REPO_ROOT } from "../../lib/repo-paths.mjs";
// Anchoring the built tree and the axe bundle to the repository root rather
// than to process.cwd() lets perf/ab-axe.mjs run from perf/ (as ab-css.mjs
// does) while check_a11y.mjs runs from the repo root, without either needing
// to know where the other is. An explicit --root-dir still resolves against
// the caller's cwd, which is what someone typing a relative path expects.
// pick_a11y_sample.mjs and sweep_a11y.mjs take the root from here.
export { REPO_ROOT };
export const DEFAULT_ROOT_DIR = join(REPO_ROOT, "docs/_site-offline");
// The pages the gate audits. Not a hand-picked list any more: it is derived,
// and scripts/pick_a11y_sample.mjs --check fails the build when the site grows
// a construct no page here covers.
//
// The first six are the original sample. They were chosen once, by hand, and
// by the time anyone measured, the site had moved underneath them: all six sat
// between 2,175 and 2,694 elements against a site maximum of 5,231, and not one
// had a table, an image, a disclosure widget or a video card. Rules for those
// constructs ran against nothing and reported a pass. A full-site sweep
// (scripts/sweep_a11y.mjs, 3,476 audits) found six violation classes on 54
// pages, every one of them in a construct the sample could not see.
//
// The rest close that gap, each earning its place:
//
// Pipeline-Stages the site's largest page (5,231 elements) and its
// table-heaviest (35 tables, 109 <th>, 1,168 <code>).
// Audit cost is super-linear in element count -- k = 2.73,
// see builder/PLAN-axe-perf.md -- so this one page is 30 %
// of the scan's audit time and is the only guard against
// anything that degrades with page size.
// Features/index callouts, footnotes, <sup>. The footnote back-link is
// what link-in-text-block caught.
// Menu/Window images, <details>/<summary>, <kbd>.
// Videos/tB the vendored video cards.
// Procedures-and-Functions a bare index page: 1,157 list items, no prose.
//
// The site's 290 redirect stubs are deliberately absent, and cannot be added:
// each carries `<script>location=...</script>`, which navigates before the
// audit runs, so what axe walks is the redirect *target*. A stub in this list
// would silently be a duplicate audit of another page (measured: the stub for
// /CustomControls.html and /Tutorials/CustomControls/index.html both report
// 2,221 elements). A page nobody sees for longer than 0 ms is not a page to
// audit.
//
// Cost, measured across the full matrix: 18.7 s of audit against 6.3 s for the
// original six -- 2.96x for thirteen pages instead of six, because cost tracks
// element count super-linearly rather than page count. Pipeline-Stages alone
// is 26 % of it.
export const SAMPLE_PAGES = [
"/index.html",
"/tB/Core/Dim.html",
"/tB/Modules/Interaction/index.html",
"/Documentation/Development/BuildInfo.html",
"/tB/Core/Select-Case.html",
"/404.html",
"/Documentation/Development/Pipeline-Stages.html",
"/Features/index.html",
"/tB/IDE/Reference/Menu/Window.html",
"/Videos/tB.html",
"/Reference/Procedures-and-Functions.html",
// The only page in the site carrying both an IMPORTANT and a TIP
// admonition, so it closes both gaps for one page load. --propose names
// two cheaper pages instead (Unicode-Support + Anchoring-Docking, 1,591 ms
// against 1,736); the 145 ms is 0.9 % of the scan and buys a smaller
// matrix. Measured: 485/454/398/399 ms over the four theme x viewport
// combinations.
"/Features/Language/Generics.html",
// The site's only page that stacks disclosures -- 30 against
// Menu/Window's 4 -- and the only one where the target-size defect
// class on <summary> is reachable. `.main-content summary`'s
// min-height was guarded by nothing: reverted, this page reports
// target-size x16 at the mobile viewport and no other page in the
// site reports anything at all. Measured 272/236/229/215 ms over the
// four combinations.
"/FAQ.html",
// The only diagram whose boxes link: its figure is a group of 78 links
// rather than an image (builder/inline-diagram.mjs). Inlined as
// role="img", as every other diagram is, it reports nested-interactive.
"/Documentation/Development/Tools.html",
// A callout with a label, `> [!NOTE] 1.0.0.29`: the cheapest page with one.
// The tag keeps its title's colour only while it out-ranks the stylesheets'
// bare `span { color }`, which differs between the themes. A label goes when
// its defect is fixed, so --check names another page when this one loses it.
"/tB/Packages/Contributed/TBMANLIB/ToolsCN.html",
// The site's only download cards and its only theme `.btn` links: the
// buttons' colour against the card in each theme, and target-size between
// a card's buttons, which wrap onto two rows at the mobile viewport.
"/Downloads.html",
];
// ---------------------------------------------------------------------------
// Page states
// ---------------------------------------------------------------------------
//
// axe walks the DOM as it finds it, and the subtree of a closed <details> is
// `notRendered`: absent from the accessibility tree, and absent from the
// audit. That property is exactly what makes the per-page section-links
// disclosure cheap -- one tab stop, nothing in the links list -- and it is
// also a hole in this scan. The construct is on most pages, and the state a
// reader sees after one click is not audited without a state audit; a
// `target-size` violation on every link inside it (69.6x14 against a 24px
// floor) would leave check.bat reporting a clean pass.
//
// A state is a DOM mutation applied after gotoPage and before the audit. The
// function is serialised into the page, so it must not close over anything in
// this module.
//
// Each one MUST assert that it found what it expected. A state that silently
// no-ops degrades into a second audit of the default page -- the scan gets
// slower, reports a pass, and covers nothing extra, which is precisely the
// failure this mechanism exists to prevent.
export const PAGE_STATES = {
"section-links-open": () => {
const found = document.querySelectorAll("details.section-links");
if (found.length !== 1) {
throw new Error(`section-links-open: expected exactly 1 details.section-links, found ${found.length}`);
}
found[0].open = true;
const links = found[0].querySelectorAll("li > a");
if (links.length < 2) {
throw new Error(`section-links-open: disclosure holds ${links.length} link(s); nothing was added to the audit`);
}
return links.length;
},
// The section-links disclosure is one construct; the ones the docs
// themselves use are another, and PAGE_STATES covers only the first.
// Their content stays closed during a page's own state audit, so the
// content a reader sees after clicking a disclosure would be audited
// nowhere. At the mobile viewport this state takes colour-contrast from
// 0 incomplete nodes to 118, which is the measure of what is not looked
// at otherwise.
"content-details-open": () => {
const found = [...document.querySelectorAll("details")].filter((d) => !d.classList.contains("section-links"));
if (found.length === 0) {
throw new Error(
"content-details-open: this page carries no content disclosure; " +
"the audit would be a second run of the default page",
);
}
let revealed = 0;
for (const d of found) {
d.open = true;
// The <summary> is rendered either way; what this state adds to the
// audit is everything after it.
for (const el of d.children) {
if (el.tagName !== "SUMMARY") revealed += 1 + el.getElementsByTagName("*").length;
}
}
if (revealed === 0) {
throw new Error(
`content-details-open: opened ${found.length} disclosure(s) and ` +
"revealed no elements; nothing was added to the audit",
);
}
return revealed;
},
};
// Extra audits layered onto the page x theme x viewport matrix: each entry is
// run at every theme and viewport, like an ordinary page, but with a state
// applied first.
//
// Menu/Window is the host because it is already the sample's disclosure-widget
// page, and because item count buys nothing here. The obvious candidate was
// Pipeline-Stages -- 72 items against this page's 14 -- but the defect class
// is per-link geometry, which is identical for every item: the spacing between
// two consecutive items does not change with how many follow them, and the
// long labels that make Pipeline-Stages look like the stress case only *wrap*
// at the mobile viewport, which makes a target taller and easier to pass. It
// is also the most expensive page in the sample. Measured: both hosts catch
// a reverted fix at both viewports (14 nodes here, 70 there), at 0.41 s per
// audit against 1.28 s.
//
// One page is enough for the same reason -- the disclosure is structurally
// identical on all 707 pages carrying it -- and if this page ever stops
// carrying one, the state applier throws rather than quietly auditing nothing.
//
// Cost: 4 audits on top of 44, +7 % of the sample's audit time (2.64 s
// against 39.8 s). That is a within-run ratio deliberately -- absolute wall
// times on this box moved between 18 s and 40 s for the same 44 audits across
// runs, so a before/after difference of two runs measures the box, not the
// change. Same caveat as perf/ab-axe.mjs and its ~20 % resolution floor.
//
// content-details-open is hosted on FAQ.html, which the sample now
// carries anyway, and it audits a DIFFERENT thing from the closed pass --
// not a stronger version of it. Measured with `.main-content summary`'s
// min-height reverted: FAQ reports target-size x16 CLOSED at the mobile
// viewport and zero OPEN, because opening the disclosures pushes the
// summaries apart until the spacing allowance rescues them. So the
// closed audit is what guards that fix, and this state is what audits the
// content behind the summaries -- 118 colour-contrast nodes at mobile
// that nothing looked at before. Neither substitutes for the other.
export const STATE_AUDITS = [
{
filePath: "/tB/IDE/Reference/Menu/Window.html",
state: "section-links-open",
},
{
filePath: "/FAQ.html",
state: "content-details-open",
},
];
/**
* Throws when a STATE_AUDITS entry names a page outside SAMPLE_PAGES or a
* state PAGE_STATES does not have. A function rather than a loop at module
* level, so that the tool importing this module decides where the throw ends.
*/
export function assertStateAudits() {
for (const { filePath, state } of STATE_AUDITS) {
if (!SAMPLE_PAGES.includes(filePath)) {
throw new Error(`STATE_AUDITS: ${filePath} is not in SAMPLE_PAGES, so --pages narrowing would drop it silently`);
}
if (!Object.hasOwn(PAGE_STATES, state)) {
throw new Error(`STATE_AUDITS: unknown state "${state}"`);
}
}
}
// Fixed sizes keep the media queries -- and therefore which elements are laid
// out and visible to axe -- the same from run to run.
export const VIEWPORTS = {
desktop: { width: 1280, height: 900 },
mobile: { width: 375, height: 812 },
};
export const THEMES = ["light", "dark"];
// A --theme or --viewport value: "both", or one of `allowed`. Validated, not
// trusted. An unrecognised value would sail through: `--theme drak` sets
// data-theme="drak", which renders light, and labels every line of the
// report `[drak, ...]` -- a full run of the light theme presented as a run of
// something else. `--viewport tiny` passes undefined to setViewport, which
// Puppeteer accepts, so the run goes ahead at a size nobody chose, labelled
// `tiny`.
export function pick(name, value, allowed) {
if (value === "both") return allowed;
if (allowed.includes(value)) return [value];
console.error(`unknown --${name} "${value}"; expected one of ${allowed.join(", ")} or both`);
process.exit(2);
}
// Requests aborted for the duration of the scan.
//
// Every page in the offline tree pulls in the ~3.2 MB search index
// (assets/js/search-data.js) plus lunr. Loading and parsing it dominated the
// run -- 18.9 s of a 27.1 s scan, across what was then a 24-audit matrix --
// and contributes nothing to the audit: it populates window.store for the
// search box, it does not alter the DOM axe walks.
// Aborting both cuts the scan to ~9.1 s (-66 %) with byte-identical results;
// every rule id and node count, violations and incomplete alike, matched the
// unblocked scan on every combination.
//
// just-the-docs.js is deliberately NOT blocked. It installs the search
// combobox ARIA (role=listbox, aria-activedescendant) added by Phase 2.1 of
// builder/PLAN-a11y.md, and blocking it makes axe see *less* -- the
// colour-contrast node count on Select-Case drops 54 -> 2 -- which would
// silently mask coverage for ~130 ms.
export const BLOCKED_REQUESTS = [/search-data\.js/, /lunr\.min\.js/];
// The production axe.run options.
//
// runOnly: WCAG 2.2 AA is a superset of 2.1 AA, which is a superset of 2.0 AA
// -- but axe's matchTags() is a literal tag test with no version rollup, so a
// rule tagged only wcag21aa does NOT match "wcag22aa". All five tags have to
// be listed or the 2.1 additions never run. They never did until this was
// fixed: autocomplete-valid, avoid-inline-spacing (WCAG 1.4.12 text spacing),
// css-orientation-lock and label-content-name-mismatch were all silently
// skipped. There is no "wcag22a" tag in axe-core 4.13.
//
// rules: heading-order is tagged best-practice rather than WCAG, so the tag
// filter above excludes it -- and nothing else guards heading structure:
// check_links.mjs has no notion of headings, and render.mjs's
// headingLevelNormalizePlugin repairs the legacy h1->h3 house style at build
// time without reporting it. ruleShouldRun() tests an explicit
// rules[id].enabled BEFORE the tag filter, so this re-admits the one rule
// without dragging in the rest of best-practice.
export const AXE_RUN_OPTIONS = Object.freeze({
runOnly: {
type: "tag",
values: ["wcag2a", "wcag2aa", "wcag21a", "wcag21aa", "wcag22aa"],
},
rules: { "heading-order": { enabled: true } },
});
/** Shallow-merge extra rule entries onto the production `rules` map. */
export function withRules(extra) {
return { ...AXE_RUN_OPTIONS.rules, ...extra };
}
// ---------------------------------------------------------------------------
// Schemes
// ---------------------------------------------------------------------------
//
// A scheme is one way of configuring the scan: an optional axe.configure()
// spec, the axe.run() options, and the source patches applied to the bundle.
// `production` is the scheme check_a11y.mjs and sweep_a11y.mjs run -- they
// read it from here rather than declaring their own copy, so a lever added to
// the scheme reaches the shipped scan, and the fingerprint gate's A/A control
// compares the bundle that actually ships against itself. The rest are the
// levers builder/PLAN-axe-perf.md lists under "Landing options", named so the
// fingerprint gate and the ablation rig can refer to the same thing.
//
// `gates: false` marks a scheme that is expected to change the findings -- it
// exists to attribute cost, not to ship. The fingerprint harness still runs
// it, it just says up front that a diff is the point.
//
// Every scheme inherits DEFAULT_PATCHES unless it names its own. The other
// schemes are variations OF production, so comparing one against production
// has to hold the bundle fixed -- otherwise the diff mixes the config change
// with a patch change and attributes both to the config.
export const DEFAULT_PATCHES = ["plain-color-fields"];
export const SCHEMES = {
production: {
describe: "what check_a11y.mjs and sweep_a11y.mjs run",
configure: null,
runOptions: AXE_RUN_OPTIONS,
},
// D3 -- DqElement's constructor calls outerHTML on every result node, pass
// or fail, then throws the string away above 300 chars. noHtml is a
// configure option, not a run option, and resultTypes cannot reach it.
"no-html": {
describe: "D3: axe.configure({ noHtml: true })",
configure: { noHtml: true },
runOptions: AXE_RUN_OPTIONS,
},
// D4 -- generateSelector climbs the ancestor chain running a document query
// per level until the selector is unique. selectors:false skips `target`
// generation outright.
"no-selectors": {
describe: "D4: selectors: false",
configure: null,
runOptions: { ...AXE_RUN_OPTIONS, selectors: false },
},
// D5 -- the correct lever. no-autoplay-audio is the only preload:true rule
// our tag set admits, so disabling it empties runLaterRules and removes the
// preload round trip without the blunt `preload: false`.
"no-autoplay-audio": {
describe: "D5: disable the one preload:true rule in our tag set",
configure: null,
runOptions: {
...AXE_RUN_OPTIONS,
rules: withRules({ "no-autoplay-audio": { enabled: false } }),
},
},
// D5, blunt version -- kept for the A/B against the targeted lever above.
"no-preload": {
describe: "D5 (blunt): preload: false",
configure: null,
runOptions: { ...AXE_RUN_OPTIONS, preload: false },
},
// Needs the relaxed `incomplete` half of the gate: resultTypes truncates
// each excluded group's nodes to [nodes[0]] rather than dropping the group.
"violations-only": {
describe: "resultTypes: ['violations']",
configure: null,
runOptions: { ...AXE_RUN_OPTIONS, resultTypes: ["violations"] },
},
// All four config-only levers at once -- the "Landing option 1" candidate.
"config-only": {
describe: "D3 + D4 + D5 together (landing option 1, minus resultTypes)",
configure: { noHtml: true },
runOptions: {
...AXE_RUN_OPTIONS,
selectors: false,
rules: withRules({ "no-autoplay-audio": { enabled: false } }),
},
},
// Ablation reference. Disabling colour-contrast plausibly removes the whole
// _createGrid build (D1) along with the contrast maths, which is why the
// 364 -> 168 ms probe reads as dramatic. It is not a landing candidate.
"no-contrast": {
describe: "D1 ablation: colour-contrast disabled (NOT a landing candidate)",
gates: false,
configure: null,
runOptions: {
...AXE_RUN_OPTIONS,
rules: withRules({ "color-contrast": { enabled: false } }),
},
},
};
export function getScheme(label) {
const scheme = SCHEMES[label];
if (!scheme) {
throw new Error(`unknown scheme "${label}"; known: ${Object.keys(SCHEMES).join(", ")}`);
}
return { label, patches: DEFAULT_PATCHES, ...scheme };
}
// ---------------------------------------------------------------------------
// axe source
// ---------------------------------------------------------------------------
// ---------------------------------------------------------------------------
// Source patches
// ---------------------------------------------------------------------------
//
// axe-core ships ONLY a Babel-downleveled bundle -- its package.json `files:`
// lists axe.js and axe.min.js and nothing else, so there is no modern build to
// switch to. Phase 2 of builder/PLAN-axe-perf.md found the consequence: the
// Color2 class (axe.js:18186) has six `#private` fields plus a brand check,
// emulated with six WeakMaps and a WeakSet, so every `new Color2()` runs
// fourteen weak-collection operations before any colour maths -- and
// `color-contrast` constructs enormous numbers of them.
//
// These patches exist so that cost can be MEASURED before anyone commits to
// maintaining a vendored fork. They are text substitutions against the
// injected source; nothing on disk is modified.
//
// Every substitution asserts an exact occurrence count, so an axe-core upgrade
// that moves the code -- or rebinds a duplicated private-field name to another
// class -- fails loudly instead of silently measuring nothing. "At least one"
// is not enough: a bump that made a target match somewhere else as well would
// rewrite that site too and still pass. Targets are single lines on purpose --
// a multi-line target would be one reformatting away from breaking, and harder
// to re-derive by eye.
function substitute(src, name, from, to, expectedCount = null) {
const parts = src.split(from);
const found = parts.length - 1;
if (found === 0) {
throw new Error(
`patch "${name}": target not found in axe.js:\n ${from}\n` +
`The bundle has changed (axe-core upgrade?); re-derive before trusting it.`,
);
}
if (expectedCount !== null && found !== expectedCount) {
throw new Error(
`patch "${name}": expected ${expectedCount} occurrence(s) of\n ${from}\n` +
`but found ${found}. The bundle has changed; re-derive before trusting it.`,
);
}
return parts.join(to);
}
// Color2's six private fields, in constructor-initialisation order. The
// backing names are parameters of the bundle's top-level IIFE (axe.js:506) and
// appear nowhere outside the class body, which is what makes a global
// substitution safe -- verified by enumerating every occurrence.
const COLOR2_FIELDS = ["_r", "_g", "_b", "_red", "_green", "_blue"];
export const SOURCE_PATCHES = {
// Babel emits a redeclaration guard on every private-field initialisation
// and a brand assertion on every private-field access. Both exist to throw
// on misuse of the compiled output, which cannot happen here: the classes
// are internal to a generated bundle. Removing them drops one WeakMap or
// WeakSet `has` per field init and one per access -- on Color2 that is seven
// fewer `has` calls per construction, plus two per colour-channel write.
//
// Behaviour-preserving by construction: the only observable difference is
// which TypeError is thrown by code that is already broken.
"cheap-private-fields": {
describe: "drop Babel's redeclaration guard and brand assert (Color2 hot path)",
apply(src) {
src = substitute(
src,
"cheap-private-fields/field-init",
"_checkPrivateRedeclaration(e, t), t.set(e, a);",
"t.set(e, a);",
);
src = substitute(
src,
"cheap-private-fields/method-init",
"_checkPrivateRedeclaration(e, a), a.add(e);",
"a.add(e);",
);
src = substitute(
src,
"cheap-private-fields/brand",
"if ('function' == typeof e ? e === t : e.has(t)) {",
"if (true) {",
);
return src;
},
},
// The full version of `cheap-private-fields`, scoped to the one class that
// matters: replace Color2's WeakMap-emulated `#private` fields with plain own
// properties.
//
// Phase 2 measured `_classPrivateFieldInitSpec` at 23.6 % of self-time on a
// 9,475-element page, with the sibling helpers taking it to ~34 %.
// `cheap-private-fields` only removes the guard calls and measured within
// noise, because the cost that remains is the `WeakMap.set` it keeps. This
// removes all of it: six `WeakMap.set` plus a `WeakSet.add` per construction,
// and a `WeakMap.get`/`set` on every colour-channel access.
//
// Safety, established by enumerating all 26 call sites (axe.js:18190-18356):
//
// * The backing bindings (_r, _g, _b, _red, _green, _blue, _Class3_brand)
// are IIFE parameters at axe.js:506 and are referenced nowhere outside
// the Color2 body, so a global substitution cannot reach another class.
// * `__r`-style names do not occur anywhere in the bundle.
// * Color2 defines an explicit `toJSON` returning {red, green, blue,
// alpha}, so serialisation does not see the new own properties.
// * All six are initialised in the constructor before either return path,
// ahead of `alpha`, so every instance keeps one hidden class.
// * `_classPrivateFieldSet` returns the assigned value; every call site is
// a statement, and `(this.__x = v)` has the same value anyway.
//
// The residual risk is enumeration: the private fields were invisible to
// `Object.keys` / spread and the own properties are not. Nothing in the
// bundle enumerates a Color, and the fingerprint gate covers the one
// consumer that would notice -- `color-contrast` results across the matrix.
"plain-color-fields": {
describe: "Color2's six #private fields as plain own properties (no WeakMaps)",
apply(src) {
const n = "plain-color-fields";
// The brand only gates one private method call; drop both halves.
src = substitute(src, n + "/brand-init", "_classPrivateMethodInitSpec(this, _Class3_brand);", "", 1);
src = substitute(src, n + "/brand-call", "_assertClassBrand(_Class3_brand, this, _add)", "_add", 1);
for (const f of COLOR2_FIELDS) {
src = substitute(
src,
n + "/init" + f,
`_classPrivateFieldInitSpec(this, ${f}, void 0);`,
`this._${f} = void 0;`,
1,
);
// Counts are measured, not guessed: every field is read once (the
// getter) and written twice (the constructor and the setter) in
// axe-core 4.13.0. Asserting them is what stops a bump that rebinds
// Babel's duplicate private names to a different class from rewriting
// that class instead -- consistently, silently, and past
// check_axe_patch_equiv.mjs, which only reads Color2 values.
src = substitute(src, n + "/get" + f, `_classPrivateFieldGet(${f}, this)`, `this._${f}`, 1);
// Prefix-only substitution: the original call's closing paren becomes
// the closing paren of the assignment expression, so an arbitrary
// nested argument expression is carried across untouched.
src = substitute(src, n + "/set" + f, `_classPrivateFieldSet(${f}, this, `, `(this._${f} = `, 2);
}
return src;
},
},
};
export function getPatches(names) {
return names.map((n) => {
const p = SOURCE_PATCHES[n];
if (!p) {
throw new Error(`unknown patch "${n}"; known: ${Object.keys(SOURCE_PATCHES).join(", ")}`);
}
return { name: n, ...p };
});
}
/**
* Read the axe-core bundle to inject.
*
* Production uses the minified build -- it is the same code and parses
* faster. Anything that CPU-profiles the scan must pass `minified: false`:
* in axe.min.js every profile frame is a mangled single letter, so the
* bottom-up table is unreadable and find-callers.mjs has nothing to match on.
*/
export function readAxeSource({ minified = true, patches = [] } = {}) {
if (patches.length && minified) {
throw new Error("source patches target the unminified bundle; pass minified: false");
}
let src = readFileSync(join(REPO_ROOT, "node_modules/axe-core", minified ? "axe.min.js" : "axe.js"), "utf-8");
for (const p of getPatches(patches)) src = p.apply(src);
return src;
}
/** Whatever axe-core version is installed; line citations in the plan pin 4.13.0. */
export function axeVersion() {
return JSON.parse(readFileSync(join(REPO_ROOT, "node_modules/axe-core/package.json"), "utf-8")).version;
}
// ---------------------------------------------------------------------------
// Browser / page plumbing
// ---------------------------------------------------------------------------
// The launch lives in browser.mjs; perf/'s rigs still import it from here.
export { launchBrowser } from "./browser.mjs";
/** A page with the scan's request blocking installed. */
export async function newAuditPage(browser) {
const page = await browser.newPage();
await page.setRequestInterception(true);
page.on("request", (req) => {
if (BLOCKED_REQUESTS.some((re) => re.test(req.url()))) {
req.abort().catch(() => {});
} else {
req.continue().catch(() => {});
}
});
return page;
}
/**
* Navigate to one page of the offline tree and apply the theme.
*
* theme-toggle.js reads localStorage, which is unavailable on file:// origins.
* Set the data-theme attribute it would have set instead (an explicit
* override; its declarations are identical to the no-JS prefers-color-scheme
* path, so this exercises the same dark palette).
*
* Note the ordering hazard recorded in PLAN-axe-perf.md H4: theme-toggle.js is
* `defer` and its apply("system") path calls removeAttribute("data-theme").
* Deferred scripts run before DOMContentLoaded and we wait on
* `domcontentloaded`, so this assignment lands last -- but change either side
* and every "dark" audit silently becomes a light one.
*/
export async function gotoPage(page, { rootDir, filePath, theme }) {
const url = pathToFileURL(join(rootDir, filePath)).href;
await page.goto(url, { waitUntil: "domcontentloaded" });
await page.evaluate((t) => {
document.documentElement.setAttribute("data-theme", t);
}, theme);
// domcontentloaded does not wait for images, and no <img> in the tree
// carries width/height -- so an undecoded one lays out at 0x0 and the
// video card's target-size box depends on decode timing that nothing
// waits for. The A/A control is 48/48 identical today, so this is a
// latent flake rather than a live one; wait it out rather than find out.
// Giving the images intrinsic dimensions in the markup would be better
// still, and would help readers as well.
//
// A picture in two themes is two lazy images, one hidden (builder/theme-pictures.mjs).
// The hidden one never loads, so its decode() never settles and the wait outlasts the
// protocol timeout: only images that are laid out are waited for, a lazy one among them
// made eager, since one below the fold would otherwise wait for a scroll.
await page.evaluate(async () => {
const imgs = [...document.images].filter((i) => !i.complete && i.getClientRects().length);
if (!imgs.length) return;
for (const i of imgs) if (i.loading === "lazy") i.loading = "eager";
await Promise.all(imgs.map((i) => i.decode().catch(() => {})));
});
}
/**
* Inject axe and run one audit.
*
* `performanceTimer` output does not appear in the return value --
* _logGatherPerformance and performanceTimer.logMeasures go to console.log.
* Relaying ~100 rules x 5 measures per audit through CDP would be overhead
* on the thing being measured, so the measures are read directly out of the
* performance timeline instead; only the marks are cleared.
*
* @returns {{results: object, measures: Array<{name: string, dur: number}>|null,
* timings: {inject: number, run: number}}}
*/
export async function runAxe(
page,
{ axeSource, configure = null, runOptions = AXE_RUN_OPTIONS, performanceTimer = false },
) {
const tInject = Date.now();
await page.evaluate(axeSource);
const injectMs = Date.now() - tInject;
const tRun = Date.now();
const { results, measures } = await page.evaluate(
async (cfg, opts, wantMeasures) => {
if (cfg) axe.configure(cfg);
const results = await axe.run(document, wantMeasures ? { ...opts, performanceTimer: true } : opts);
const measures = wantMeasures
? performance.getEntriesByType("measure").map((e) => ({ name: e.name, dur: e.duration }))
: null;
return { results, measures };
},
configure,
runOptions,
performanceTimer,
);
const runMs = Date.now() - tRun;
return { results, measures, timings: { inject: injectMs, run: runMs } };
}
/**
* The full page x theme x viewport matrix, in the order check_a11y.mjs walks
* it (viewport outermost -- setViewport is the expensive one).
*
* @returns {Array<{filePath: string, theme: string, viewport: string, label: string}>}
*/
export function buildMatrix({
pages = SAMPLE_PAGES,
themes = THEMES,
viewports = Object.keys(VIEWPORTS),
stateAudits = STATE_AUDITS,
} = {}) {
assertStateAudits();
// The backstop behind pick(), for a caller that builds a matrix without it.
for (const theme of themes) {
if (!THEMES.includes(theme)) {
throw new Error(`buildMatrix: unknown theme "${theme}"`);
}
}
for (const viewport of viewports) {
if (!Object.hasOwn(VIEWPORTS, viewport)) {
throw new Error(`buildMatrix: unknown viewport "${viewport}"`);
}
}
const seen = new Set();
const uniquePages = pages.filter((p) => {
if (seen.has(p)) return false;
seen.add(p);
return true;
});
// Follow whatever narrowing the caller applied to `pages`, so --pages does
// not leave a state audit running on a page the caller excluded -- and say
// when that happens. The load-time assertion below SAMPLE_PAGES exists to
// stop a state audit being dropped silently; a --pages narrowing walked
// straight past it, which is the same hole from the other side.
const states = stateAudits.filter((s) => seen.has(s.filePath));
for (const s of stateAudits) {
if (!seen.has(s.filePath)) {
console.warn(
`[axe-scan] page narrowing dropped the "${s.state}" state audit ` +
`(${s.filePath} is not in the page list); that construct is not ` +
`being checked in this run`,
);
}
}
const out = [];
for (const viewport of viewports) {
for (const theme of themes) {
for (const filePath of uniquePages) {
out.push({
filePath,
theme,
viewport,
state: null,
label: `${filePath} [${theme}, ${viewport}]`,
});
}
for (const { filePath, state } of states) {
out.push({
filePath,
theme,
viewport,
state,
label: `${filePath} [${theme}, ${viewport}, ${state}]`,
});
}
}
}
return out;
}
/**
* Walk the matrix, auditing each combination. `onAudit` is called with
* `{ ...entry, results, measures, timings }` after each one.
*/
export async function runMatrix(
page,
{
rootDir = DEFAULT_ROOT_DIR,
matrix = buildMatrix(),
axeSource,
configure = null,
runOptions = AXE_RUN_OPTIONS,
performanceTimer = false,
onAudit = null,
},
) {
const audits = [];
let currentViewport = null;
for (const entry of matrix) {
if (entry.viewport !== currentViewport) {
await page.setViewport(VIEWPORTS[entry.viewport]);
currentViewport = entry.viewport;
}
await gotoPage(page, { rootDir, filePath: entry.filePath, theme: entry.theme });
// Applied after navigation, before the audit. PAGE_STATES entries throw
// in-page if the construct they expect is not there, and page.evaluate
// propagates that here rather than letting the audit quietly proceed
// against the default state.
// Whatever the applier returns is carried on the record as `stateResult`
// so a front end can report how much the state actually exposed. "The
// state ran" and "the state added something" are different claims, and
// only the second one is worth anything.
const stateResult = entry.state ? await page.evaluate(PAGE_STATES[entry.state]) : null;
const audit = await runAxe(page, {
axeSource,
configure,
runOptions,
performanceTimer,
});
const record = { ...entry, ...audit, stateResult };
audits.push(record);
if (onAudit) onAudit(record);
}
return audits;
}
// ---------------------------------------------------------------------------
// Fingerprints -- the correctness gate
// ---------------------------------------------------------------------------
//
// axe is the correctness oracle, so a change can silently make it see *less*
// while still reporting a pass. This nearly happened once: blocking
// just-the-docs.js looked like a 130 ms win and quietly dropped color-contrast
// nodes on Select-Case from 54 to 2.
//
// The gate, per builder/PLAN-axe-perf.md:
//
// * violations -- identical sorted `ruleId:nodeCount`;
// * incomplete -- identical sorted `ruleId` SET, node counts excluded.
//
// The relaxation on `incomplete` is required, not sloppiness: resultTypes
// truncates each excluded group's `nodes` to `[nodes[0]]` rather than dropping
// the group, so a ruleId:nodeCount fingerprint would read `:1` everywhere and
// silently stop discriminating.
//
// KNOWN BLIND SPOT: the gate compares a candidate against a baseline produced
// by that same scheme's element set. It therefore cannot detect a change that
// stops auditing elements entirely -- a rule that never runs simply produces
// no entry. Any change touching *which DOM is walked* (viewport, visibility,
// request blocking) must be argued from source, not from this gate.
export const fmtViolations = (groups) =>
groups
.map((g) => `${g.id}:${g.nodes.length}`)
.sort()
.join(",");
export const fmtIncomplete = (groups) => [...new Set(groups.map((g) => g.id))].sort().join(",");
export function fingerprint({ filePath, theme, viewport, state, results }) {
return (
`${filePath}|${theme}|${viewport}|${state ?? "default"}` +
`|V[${fmtViolations(results.violations)}]` +
`|I[${fmtIncomplete(results.incomplete)}]`
);
}
// ---------------------------------------------------------------------------
// Page discovery -- the pages pick_a11y_sample.mjs and sweep_a11y.mjs see
// ---------------------------------------------------------------------------
// Pages below this static start-tag count are redirect stubs -- a canonical
// link, a meta refresh, `<script>location=...</script>` and a one-line body.
// There are ~290 of them, and neither tool audits them, because they cannot be
// audited: the inline script navigates before the audit runs, so what axe
// walks is the redirect *target*. The stub for /CustomControls.html reports
// the same 2,221 elements as /Tutorials/CustomControls/index.html. One
// ceiling serves both tools, since pick_a11y_sample.mjs --propose reads the
// records the sweep writes.
export const STUB_TAG_CEILING = 100;
// Static start-tag count. Not the element count axe sees -- it misses what
// scripts add and counts what parsing drops -- but it ranks pages correctly
// and costs no browser, which is all it is used for: separating stubs from
// content before any audit starts.
export function staticTagCount(html) {
const m = html.match(/<[a-zA-Z][a-zA-Z0-9-]*(\s|>|\/)/g);
return m ? m.length : 0;
}
/**
* Every .html page under the built tree `dir`, sorted by path, as
* `{ filePath, tags, ...fields(html) }`: `filePath` is root-relative with a
* leading slash, and `fields`, when given, adds what the caller counts in the
* same read of the page.
*/
export function discoverPages(dir, fields = () => ({})) {
const out = [];
(function walk(d) {
for (const e of readdirSync(d, { withFileTypes: true })) {
const p = join(d, e.name);
if (e.isDirectory()) walk(p);
else if (e.name.endsWith(".html")) out.push(p);
}
})(dir);
return out
.map((p) => {
const html = readFileSync(p, "utf8");
return { filePath: "/" + relative(dir, p).split(sep).join("/"), tags: staticTagCount(html), ...fields(html) };
})
.sort((a, b) => a.filePath.localeCompare(b.filePath));
}
/** `pages` split at the stub ceiling: `{ content, stubs }`, each in order. */
export function splitStubs(pages) {
return {
content: pages.filter((p) => p.tags >= STUB_TAG_CEILING),
stubs: pages.filter((p) => p.tags < STUB_TAG_CEILING),
};
}
// The two tools' report helpers.
export const pad = (s, w) => String(s).padStart(w);
export const median = (xs) => {
const s = [...xs].sort((a, b) => a - b);
return s.length % 2 ? s[(s.length - 1) / 2] : (s[s.length / 2 - 1] + s[s.length / 2]) / 2;
};