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
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
1271
1272
1273
1274
1275
1276
1277
1278
1279
1280
1281
1282
1283
1284
1285
1286
1287
1288
1289
1290
1291
1292
1293
1294
1295
1296
1297
1298
1299
1300
1301
1302
1303
1304
1305
1306
1307
1308
1309
1310
1311
1312
1313
1314
1315
1316
1317
1318
1319
1320
1321
1322
1323
1324
1325
1326
1327
1328
1329
1330
1331
1332
1333
1334
1335
1336
1337
1338
1339
1340
1341
1342
1343
1344
1345
1346
1347
1348
1349
1350
1351
1352
1353
1354
1355
1356
1357
1358
1359
1360
1361
1362
1363
1364
1365
1366
1367
1368
1369
1370
1371
1372
1373
1374
1375
1376
1377
1378
1379
1380
1381
1382
1383
1384
1385
1386
1387
1388
1389
1390
1391
1392
1393
1394
1395
1396
1397
1398
1399
1400
1401
1402
1403
1404
1405
1406
1407
1408
1409
1410
1411
1412
1413
1414
1415
1416
1417
1418
1419
1420
1421
1422
1423
1424
1425
1426
1427
1428
1429
1430
1431
1432
1433
1434
1435
1436
1437
1438
1439
1440
1441
1442
1443
1444
1445
1446
1447
1448
1449
1450
1451
1452
1453
1454
1455
1456
1457
1458
1459
1460
1461
1462
1463
1464
1465
1466
1467
1468
1469
1470
1471
1472
1473
1474
1475
1476
1477
1478
1479
1480
1481
1482
1483
1484
1485
1486
1487
1488
1489
1490
1491
1492
1493
1494
1495
1496
1497
1498
1499
1500
1501
1502
1503
1504
1505
1506
1507
1508
1509
1510
1511
1512
1513
1514
1515
1516
1517
1518
1519
1520
1521
1522
1523
1524
1525
1526
1527
1528
1529
1530
1531
1532
1533
1534
1535
1536
1537
1538
1539
1540
1541
1542
1543
1544
1545
1546
1547
1548
1549
1550
1551
1552
1553
1554
1555
1556
1557
1558
1559
1560
1561
1562
1563
1564
1565
1566
1567
1568
1569
1570
1571
1572
1573
1574
1575
1576
1577
1578
1579
1580
1581
1582
1583
1584
1585
1586
1587
1588
1589
1590
1591
1592
1593
1594
1595
1596
1597
1598
1599
1600
1601
1602
1603
1604
1605
1606
1607
1608
1609
1610
1611
1612
1613
1614
1615
1616
1617
1618
1619
1620
1621
1622
1623
1624
1625
1626
1627
1628
1629
1630
1631
1632
1633
1634
1635
1636
1637
1638
1639
1640
1641
1642
1643
1644
1645
1646
1647
1648
1649
1650
1651
1652
1653
1654
1655
1656
1657
1658
1659
1660
1661
1662
1663
1664
1665
1666
1667
1668
1669
1670
1671
1672
1673
1674
1675
1676
1677
1678
1679
1680
1681
1682
1683
1684
1685import { test } from "node:test";
import assert from "node:assert/strict";
import { JSDOM } from "jsdom";
import { assembleBody, assembleBodyWithReport, runAssembly, wrapDocument } from "../src/pipeline/assembly.ts";
import { runAxe } from "../src/pipeline/lint.ts";
import { ATTR_SEP, sourceIds, sourceRefs } from "../src/pipeline/anchors.ts";
import type { Fragment } from "../src/pipeline/fragment.ts";
import type { PipelineContext } from "../src/pipeline/context.ts";
// Assembly is where per-page output becomes one document, and ids are the thing that
// cannot survive that join untouched. Each page is extracted alone and concurrently,
// so a page numbering its first footnote "1" has no way to know another page did the
// same β and the page prompt asks for exactly those ids by name (`id="fn-N"`,
// `href="#fn-N"`, "preserve the original numbering"). A multi-page scan where each
// page carries a footnote 1 is the ordinary case.
//
// That defect is invisible in every way that usually catches things: both notes are
// present, both are announced, the link works, and the axe gate passes it (WCAG 2.2
// dropped 4.1.1, so `duplicate-id` is tagged obsolete and filtered out). The only
// symptom is that a screen-reader user following the reference on page 3 arrives at
// page 1's note.
//
// The fix has a narrow correct scope, and both edges are pinned here. Renaming every
// id β the first attempt β fixed collisions and broke the references that legitimately
// span a page break: a `<label for>` whose input is on the next page, or endnotes with
// continuous numbering. Those resolved correctly BEFORE assembly touched them, and
// trading a wrong-target reference for a no-target one is not a fix; for
// `for`/`headers`/`aria-*` it is a real 1.3.1/4.1.2 failure introduced by the
// assembler. So only ids that more than one page claims are renamed.
function frag(order: number, innerHtml: string): Fragment {
return {
image: `page-00${order}.png`,
order,
agent: "page.md",
region: "page",
innerHtml,
edges: [],
log: "",
};
}
// The shape the page prompt asks for, with the numbering the source shows.
const footnotePage = (n: number) =>
`<p>Body text<sup><a href="#fn-${n}" id="fnref-${n}">${n}</a></sup></p>\n` +
`<ol><li id="fn-${n}">Note ${n} on this page. <a href="#fnref-${n}">β©</a></li></ol>`;
const idsOf = (html: string) => [...html.matchAll(/\sid="([^"]+)"/g)].map((m) => m[1]);
const fragHrefs = (html: string) => [...html.matchAll(/href="#([^"]+)"/g)].map((m) => m[1]);
test("two pages that both numbered a footnote 1 do not collide in the document", () => {
const body = assembleBody([frag(1, footnotePage(1)), frag(2, footnotePage(1))]);
const ids = idsOf(body);
assert.equal(new Set(ids).size, ids.length, `duplicate ids survived assembly: ${ids.join(", ")}`);
// Sanity: the ids are still there to be unique. A namespacing bug that dropped
// them, or a test fixture that stopped emitting them, would otherwise pass.
assert.equal(ids.length, 4, `expected 2 ids per page, got: ${ids.join(", ")}`);
});
test("each footnote reference still resolves to its own page's note", () => {
const body = assembleBody([frag(1, footnotePage(1)), frag(2, footnotePage(1)), frag(3, footnotePage(1))]);
// Uniqueness alone is not the property that matters β ids could be made unique
// while every href still pointed at page 1. Check the pairing: within each page,
// the marker's href must name that page's own note id.
for (const page of [1, 2, 3]) {
assert.match(body, new RegExp(`href="#p${page}-fn-1" id="p${page}-fnref-1"`), `page ${page}'s marker does not link to its own note`);
assert.match(body, new RegExp(`<li id="p${page}-fn-1">Note 1`), `page ${page}'s note id was not namespaced`);
assert.match(body, new RegExp(`<a href="#p${page}-fnref-1">β©`), `page ${page}'s back-reference was not rewritten`);
}
// Every fragment link resolves to an id that exists β no href was rewritten to a
// target that is not there, and none was left pointing at another page's note.
const ids = new Set(idsOf(body));
for (const href of fragHrefs(body)) assert.ok(ids.has(href), `href="#${href}" resolves to nothing`);
});
test("the prefix follows the page's order, not its position in the array", () => {
// Fragments arrive in whatever order the concurrent extractions finished;
// `assembleBody` sorts by `.order`. If the prefix came from the array index, the
// ids in a delivered document would depend on scheduling β different on a re-run
// of the same input, and not matching the page numbers the Reader attributes
// issues to or the unresolved comment cites.
const body = assembleBody([frag(3, footnotePage(1)), frag(1, footnotePage(1))]);
assert.deepEqual(idsOf(body), ["p1-fnref-1", "p1-fn-1", "p3-fnref-1", "p3-fn-1"]);
});
// The other edge, and the reason renaming is scoped to collisions. These two shapes
// worked before any namespacing existed, so breaking them would mean the assembler
// introducing a defect into content that was correct when the page produced it.
test("a form split across a page break keeps its label associated", () => {
// `<label for="q1">` on page 1, `<input id="q1">` on page 2. No collision, so
// nothing is renamed and the reference still resolves. Prefixing the input's id
// (and not the label's `for`, which the label's page does not own) is what an
// unconditional rename does, and it costs the field its accessible name.
const body = assembleBody([
frag(1, `<h1>Form</h1><label for="q1">Your name</label>`),
frag(2, `<input id="q1" type="text"><p>rest</p>`),
]);
assert.match(body, /<label for="q1">/, "the label's `for` was rewritten");
assert.match(body, /id="q1"/, "the input's id was rewritten away from its label");
assert.doesNotMatch(body, /id="p2-q1"/, "the input's id was namespaced despite no collision");
});
test("a form split across a page break is still axe-clean", async () => {
// The assertion above is about the markup; this is about the consequence. A
// dangling `for` is not a stylistic matter β axe reports it as a `label`
// violation, so the review loop would spend iterations on a defect assembly
// created.
const body = assembleBody([
frag(1, `<h1>Form</h1><label for="q1">Your name</label>`),
frag(2, `<input id="q1" type="text"><p>rest</p>`),
]);
const lint = await runAxe(wrapDocument(body));
if (!lint.violations) return; // no verdict: axe could not run here (see LintResult)
assert.equal(
lint.violations.map((v) => v.id).join(", "),
"",
"assembly introduced a lint violation into a document that was clean",
);
});
test("endnotes collected on a later page still round-trip", () => {
// Continuous numbering with the notes at the back β the normal shape for a scanned
// report, and one where no id collides. Both directions have to survive: the
// marker's link forward to the note, and the note's back-reference to the marker.
const body = assembleBody([
frag(1, `<p>Claim<sup><a href="#fn-1" id="fnref-1">1</a></sup></p>`),
frag(2, `<p>More<sup><a href="#fn-2" id="fnref-2">2</a></sup></p>`),
frag(3, `<ol><li id="fn-1">First <a href="#fnref-1">β©</a></li><li id="fn-2">Second <a href="#fnref-2">β©</a></li></ol>`),
]);
const ids = new Set(idsOf(body));
for (const href of fragHrefs(body)) assert.ok(ids.has(href), `href="#${href}" no longer resolves`);
assert.equal(ids.size, 4, `expected 4 ids, got: ${[...ids].join(", ")}`);
assert.doesNotMatch(body, /id="p\d+-/, "ids were namespaced although nothing collided");
});
test("a cross-page reference survives in a document that DOES have a collision", () => {
// The case that matters, and the one the two tests above cannot reach. They contain
// no colliding id at all, so they are protected by the document-level "nothing
// collides, change nothing" short-circuit β they would still pass if every id on a
// rewritten page were renamed. A real document mixes the two: per-page footnotes
// numbered 1 (colliding, must be renamed) alongside a form or an endnote reference
// that spans a page break (not colliding, must be left alone). Renaming per PAGE
// rather than per ID breaks the second while fixing the first.
const body = assembleBody([
frag(1, `${footnotePage(1)}<label for="q1">Your name</label>`),
frag(2, `${footnotePage(1)}<input id="q1" type="text">`),
]);
// The colliding footnote ids are namespaced and each marker points at its own note.
assert.match(body, /href="#p1-fn-1" id="p1-fnref-1"/);
assert.match(body, /href="#p2-fn-1" id="p2-fnref-1"/);
// The non-colliding pair, on the same rewritten pages, is untouched.
assert.match(body, /<label for="q1">/, "a non-colliding `for` was rewritten on a page that had a collision");
assert.match(body, /<input id="q1"/, "a non-colliding id was rewritten because its page had a collision");
assert.doesNotMatch(body, /"p\d+-q1"/, "the label/input pair was namespaced and no longer matches");
const ids = new Set(idsOf(body));
for (const href of fragHrefs(body)) assert.ok(ids.has(href), `href="#${href}" resolves to nothing`);
});
test("a document with nothing colliding is passed through untouched", () => {
// The overwhelmingly common case. Nothing is parsed and reserialized, so each page
// is delivered exactly as its agent wrote it. The fixture is markup jsdom rewrites
// on a round-trip β bare `required` becomes `required=""`, a `<table>` gains a
// `<tbody>` β because already-canonical HTML would come back identical either way
// and assert nothing.
const p1 = `<h1 id="title">Title</h1>\n<label>Name <input type="text" required></label>`;
const p2 = `<table><tr><td>1994</td></tr></table>`;
const { body, anchors } = assembleBodyWithReport([frag(1, p1), frag(2, p2)]);
assert.equal(body, `${p1}\n\n${p2}`);
assert.deepEqual(anchors.collisions, []);
});
test("references that are not ids at all are untouched", () => {
const body = assembleBody([
frag(1, `<p id="top">Top</p><p><a href="#">Nowhere</a> <a href="#top">Up</a> <a href="https://example.org/#top">Out</a></p>`),
frag(2, `<p id="top">Also top</p>`), // forces `top` to collide, so page 1 is rewritten
]);
assert.match(body, /href="#"/, "a bare #href was prefixed into an id reference");
assert.match(body, /href="#p1-top"/, "an in-page reference to a colliding id was not rewritten");
assert.match(body, /href="https:\/\/example\.org\/#top"/, "an external URL's fragment was rewritten");
});
// Namespacing ids without namespacing what points at them would be worse than the
// collision it fixes. `for`, `headers` and the aria-* references are how a field
// gets its accessible name and how a data cell is attributed to its headers.
test("every kind of id reference is rewritten with the id, not just href", () => {
const page = (suffix: string) =>
`<label for="q1">Name</label><input id="q1" aria-describedby="h1 h2">` +
`<p id="h1">Hint one${suffix}</p><p id="h2">Hint two</p>` +
`<table><tr><th id="c1">Year</th><td headers="c1">1994</td></tr></table>` +
`<div aria-labelledby="h1"></div>`;
// Two pages of the same form, so every id collides and both pages are rewritten.
const body = assembleBody([frag(1, page("")), frag(2, page(" again"))]);
assert.match(body, /<label for="p2-q1">/, "label/for lost its target β the field has no accessible name");
assert.match(body, /aria-describedby="p2-h1 p2-h2"/, "a multi-token idref list was not fully rewritten");
assert.match(body, /headers="p2-c1"/, "a data cell lost its header association");
assert.match(body, /aria-labelledby="p2-h1"/, "aria-labelledby was not rewritten");
const ids = new Set(idsOf(body));
for (const ref of [...body.matchAll(/(?:for|headers|aria-describedby|aria-labelledby)="([^"]+)"/g)].flatMap((m) => m[1].split(" "))) {
assert.ok(ids.has(ref), `"${ref}" is referenced but no element has that id`);
}
});
test("a reference to a colliding id the page does not own goes to the first owner, and is reported", () => {
// The genuinely ambiguous case: `fn-1` exists on pages 1 and 2, and page 3 links to
// it. No page can say which copy was meant. Leaving it as written was the first
// answer and it was the wrong one β every owner gets renamed, so the reference then
// resolves to nothing at all, and the assembler has turned a wrong-target link into
// a dead one. It is repointed at the FIRST owner in document order instead: exactly
// where a browser sent the bare reference before any of this ran, so nothing is made
// worse, and the association survives. Reported either way, because a reference
// disambiguated by document order rather than by the agent that wrote it deserves an
// eye.
//
// Page 3 owns a colliding id of its own (`fn-9`) as well as referencing `fn-1`.
// Without that it would exit before the reference-rewriting loop is even reached,
// and this test would be asserting the has-nothing-to-rename shortcut rather than
// the ownership rule β a version that rewrote every reference to a colliding id
// would still pass.
const { body, anchors } = assembleBodyWithReport([
frag(1, `<ol><li id="fn-1">One</li></ol>`),
frag(2, `<ol><li id="fn-1">Two</li></ol><p id="fn-9">Nine here too</p>`),
frag(3, `<p>See<sup><a href="#fn-1">1</a></sup> and<sup><a href="#fn-9">9</a></sup></p><ol start="9"><li id="fn-9">Nine</li></ol>`),
]);
assert.match(body, /href="#p1-fn-1"/, "an ambiguous reference was not sent to the first owner");
assert.doesNotMatch(body, /href="#fn-1"/, "an ambiguous reference was left pointing at an id nothing has");
// The reference page 3 DOES own goes to page 3's own copy, not to page 2's β which
// is the first owner of `fn-9`. Ownership wins over document order, and this also
// proves the page reached the rewriting loop rather than being skipped.
assert.match(body, /href="#p3-fn-9"/, "page 3's own colliding reference was not rewritten to its own copy");
assert.deepEqual(anchors.collisions, ["fn-1", "fn-9"]);
assert.deepEqual(anchors.ambiguous, [{ page: 3, ref: "fn-1" }]);
const ids = new Set(idsOf(body));
for (const href of fragHrefs(body)) assert.ok(ids.has(href), `href="#${href}" resolves to nothing`);
});
test("a page with no ids of its own has its ambiguous references repointed and reported", () => {
// The marker page: it links `#fn-1` and `#fn-2` and emits no id at all, while the
// notes are duplicated across two later pages. That shape is the one whose links go
// dead, and it is also the one the ownership pass skips β a page with no ids cannot
// contribute a collision, so it is never parsed for ownership and its references are
// invisible unless they are looked for separately. Fixing only the pages that own a
// colliding id would move the dangling-reference bug here rather than remove it.
const { body, anchors } = assembleBodyWithReport([
frag(1, `<p>See<sup><a href="#fn-1">1</a></sup> and<sup><a href="#fn-2">2</a></sup></p>`),
frag(2, `<ol><li id="fn-1">One</li><li id="fn-2">Two</li></ol>`),
frag(3, `<ol><li id="fn-1">One again</li><li id="fn-2">Two again</li></ol>`),
]);
assert.deepEqual(anchors.collisions, ["fn-1", "fn-2"]);
assert.deepEqual(anchors.ambiguous, [
{ page: 1, ref: "fn-1" },
{ page: 1, ref: "fn-2" },
]);
assert.match(body, /href="#p2-fn-1"/, "the marker page's reference was left dangling");
assert.match(body, /href="#p2-fn-2"/, "the marker page's reference was left dangling");
const ids = new Set(idsOf(body));
for (const href of fragHrefs(body)) assert.ok(ids.has(href), `href="#${href}" resolves to nothing`);
});
test("a link is not aimed at a note that already has its own marker", () => {
// #233, measured: in one bench round 18 of 63 footnote markers in the delivered
// documents pointed at another page's note. The page agent transcribed a superscript
// marker and not the note it refers to, so the page emitted `href="#fn-1"` with no
// `id="fn-1"` of its own β and first-owner repointing sent it to page 1, whose note
// already belongs to page 1's own marker. The reader following footnote 1 on page 3
// arrives at page 1's note and its back-reference returns them to page 1.
//
// The link resolves, so nothing caught it: axe has no rule for a link that lands on the
// wrong element, and namespacing removed the duplicate id that might have hinted at it.
// A reciprocal pair is the evidence β page 1 links to its own `fn-1`, so that note is
// spoken for β and the reference is left bare instead, which lands nowhere and is
// counted on the delivered bytes by links.ts (#234).
const { body, anchors } = assembleBodyWithReport([
frag(1, `<p>A<sup><a id="fnref-1" href="#fn-1">1</a></sup></p><ol><li id="fn-1">One<a href="#fnref-1">back</a></li></ol>`),
frag(2, `<p>B<sup><a id="fnref-1" href="#fn-1">1</a></sup></p><ol><li id="fn-1">Two<a href="#fnref-1">back</a></li></ol>`),
frag(3, `<p>C<sup><a id="fnref-9" href="#fn-1">1</a></sup></p><p id="fn-9">Nine</p>`),
]);
// Each page that owns both halves keeps its own pair β ownership still wins, and that
// is the case this rule must not touch.
assert.match(body, /<a id="p1-fnref-1" href="#p1-fn-1">/, "page 1's own pair was broken");
assert.match(body, /<a id="p2-fnref-1" href="#p2-fn-1">/, "page 2's own pair was broken");
// Page 3's marker is left bare rather than sent to page 1's note.
assert.match(body, /<a id="fnref-9" href="#fn-1">/, "page 3's marker was repointed at a note that is spoken for");
assert.doesNotMatch(body, /id="fnref-9" href="#p1-fn-1"/, "page 3's marker was aimed at page 1's note");
assert.deepEqual(anchors.ambiguous, [{ page: 3, ref: "fn-1" }]);
// A subset of `ambiguous`, and the field that says this one was given up on rather
// than resolved by document order.
assert.deepEqual(anchors.unrepointed, [{ page: 3, ref: "fn-1" }]);
});
test("a page linking one spoken-for note three times reports the fact once", () => {
// The report is read by a person deciding which page to look at, so one page and one id
// is one fact however many markers carry it β and the round that produced #233 had a
// page with six. The number of links a reader can follow to nothing is measured
// elsewhere, on the delivered bytes (links.ts, #234).
//
// Both owners link to their own copy, so page 3's markers are aimed at neither. With only
// page 1 spoken for they would all be repointed at page 2's free note, which is the
// separate test above.
const { body, anchors } = assembleBodyWithReport([
frag(1, `<p>A<sup><a id="fnref-1" href="#fn-1">1</a></sup></p><ol><li id="fn-1">One</li></ol>`),
frag(2, `<p>B<sup><a id="fnref-1" href="#fn-1">1</a></sup></p><ol><li id="fn-1">Two</li></ol>`),
frag(3, `<p>a<a href="#fn-1">1</a> b<a href="#fn-1">1</a> c<a href="#fn-1">1</a></p><p id="fn-9">Nine</p>`),
]);
assert.deepEqual(anchors.unrepointed, [{ page: 3, ref: "fn-1" }]);
assert.equal([...body.matchAll(/href="#fn-1"/g)].length, 3, "all three markers should be left bare");
});
test("a note nobody else has claimed still adopts an outside marker", () => {
// The other half of the same rule, and the reason it is not "never repoint a link". A
// footnote continued from an earlier page is a real shape in this corpus: the note is
// transcribed on the page where it appears and the marker was on the page before. Page 1
// owns `fn-1` and does NOT link to it, so nothing else claims that note and page 3's
// marker is the only reference there is β repointing it is right, and this is the
// behaviour that existed before #233.
const { body, anchors } = assembleBodyWithReport([
frag(1, `<ol><li id="fn-1">Continued from the previous page</li></ol>`),
frag(2, `<ol><li id="fn-1">Another one</li></ol>`),
frag(3, `<p>C<sup><a href="#fn-1">1</a></sup></p><p id="fn-9">Nine</p>`),
]);
assert.match(body, /href="#p1-fn-1"/, "a note no page had claimed did not adopt the outside marker");
assert.deepEqual(anchors.ambiguous, [{ page: 3, ref: "fn-1" }]);
assert.deepEqual(anchors.unrepointed, [], "a reference that resolved was reported as given up on");
});
test("a spoken-for first owner is passed over, not given up on", () => {
// Why the test is "the first FREE owner" and not "the first owner, is it free?". Page 1's
// `fn-1` has its own marker, so it is spoken for. Page 2's is the note continued onto that
// page and nothing claims it. Page 3 carries the marker for page 2's note β the only claim
// on it β so the link belongs there, and a version of this rule that consulted only the
// first owner left it bare with page 2's note unreachable from anywhere.
const { body, anchors } = assembleBodyWithReport([
frag(1, `<p>A<sup><a id="fnref-1" href="#fn-1">1</a></sup></p><ol><li id="fn-1">One</li></ol>`),
frag(2, `<ol><li id="fn-1">β¦continued from the previous page</li></ol>`),
frag(3, `<p>C<sup><a href="#fn-1">1</a></sup></p>`),
]);
// `fnref-1` is page 1's alone, so it does not collide and is not renamed; only the note it
// points at is.
assert.match(body, /<a id="fnref-1" href="#p1-fn-1">/, "page 1's own reciprocal pair was broken");
// Positionally, because page 1's own marker legitimately points at `#p1-fn-1` and a bare
// match would pass on that alone: the SECOND link in the document is page 3's.
const hrefs = [...new JSDOM(`<body>${body}</body>`).window.document.querySelectorAll("a[href^='#']")].map((a) =>
a.getAttribute("href"),
);
assert.deepEqual(hrefs, ["#p1-fn-1", "#p2-fn-1"], "page 3's link did not skip past the spoken-for owner");
assert.deepEqual(anchors.ambiguous, [{ page: 3, ref: "fn-1" }]);
assert.deepEqual(anchors.unrepointed, [], "a link that found a free owner was reported as given up on");
});
test("an owner left as written is a target like any other, and the bare link finds it", () => {
// Where the rule meets the reserialization guard. Page 2 owns the free copy of `fn-1` and
// is delivered as written (an orphan `<tr>` would be dropped by a rewrite), so it keeps the
// BARE id β which means page 3's link, left bare, resolves to it. Page 1's spoken-for copy
// is renamed out of the way, so there is no earlier bare `fn-1` for the link to land on
// first. Nothing is `unrepointed` here: the link was aimed at an owner, and that owner's id
// simply did not move.
const { body, anchors } = assembleBodyWithReport([
frag(1, `<p>A<sup><a href="#fn-1">1</a></sup></p><ol><li id="fn-1">One</li></ol>`),
frag(2, `<ol><li id="fn-1">β¦continued</li></ol><tr><td>IMPORTANT DATA</td></tr>`),
frag(3, `<p>C<sup><a href="#fn-1">1</a></sup></p>`),
]);
assert.deepEqual(anchors.skipped_pages, [2]);
assert.match(body, /<li id="fn-1">β¦continued<\/li>/, "the skipped page was rewritten after all");
assert.match(body, /<li id="p1-fn-1">One<\/li>/, "the spoken-for owner was not moved out of the way");
assert.deepEqual(anchors.unrepointed, [], "a link that lands on a delivered-as-written owner reads as given up on");
// Page 3's link is bare and page 2's is the only bare `fn-1` in the document, so it
// resolves β to the continued note, which is the one nothing else claimed.
const doc = new JSDOM(`<body>${body}</body>`).window.document;
const hrefs = [...doc.querySelectorAll("a[href^='#']")].map((a) => a.getAttribute("href"));
assert.deepEqual(hrefs, ["#p1-fn-1", "#fn-1"]);
assert.equal(doc.querySelectorAll("#fn-1").length, 1, "the bare target the link needs is not unique");
});
test("an owner too deep to rewrite still speaks for its own note", () => {
// A page delivered as written because of its nesting keeps its ids, so it is still an owner
// a link can be aimed at β and its own marker still speaks for its own note. Read from its
// tree, which such a page keeps. Without that, page 1 looks free, page 3's link is
// repointed at the note page 1's own marker already claims, and page 2's free note is
// unreachable from anywhere: the #233 defect, reintroduced through the one route that
// reports nothing.
const deep = "<div>".repeat(600);
const { body, anchors } = assembleBodyWithReport([
frag(1, `${deep}<p>A<sup><a href="#fn-1">1</a></sup></p><ol><li id="fn-1">One</li></ol>`),
frag(2, `<ol><li id="fn-1">β¦continued</li></ol>`),
frag(3, `<p>C<sup><a href="#fn-1">1</a></sup></p>`),
]);
assert.deepEqual(anchors.skipped_pages, [1], "the too-deep page was rewritten after all");
assert.match(body, /<li id="fn-1">One<\/li>/, "the page delivered as written did not keep its bare id");
assert.match(body, /<li id="p2-fn-1">β¦continued<\/li>/, "the free owner was not renamed");
assert.deepEqual(anchors.unrepointed, []);
// Page 1's own marker is frozen bare and finds its own note; page 3's is repointed past it.
const hrefs = [...new JSDOM(`<body>${body}</body>`).window.document.querySelectorAll("a[href^='#']")].map((a) =>
a.getAttribute("href"),
);
assert.deepEqual(hrefs, ["#fn-1", "#p2-fn-1"], "page 3's link was aimed at the deep page's already-marked note");
});
test("a link left bare does not fight a pin for the id it is not taking", () => {
// The other crossing: `fn-1` is pinned, because page 4 is delivered as written and its
// frozen `href="#fn-1"` can only find a bare id. Pages 1 and 2 both own a self-linked copy,
// so page 3's link is aimed at no owner and reported. The pin is unaffected β it is about
// the FIRST owner keeping its id, which page 4's reference still resolves to β and page 2's
// copy is still renamed, so the collision is still narrowed rather than abandoned.
const { body, anchors } = assembleBodyWithReport([
frag(1, `<p>A<sup><a href="#fn-1">1</a></sup></p><ol><li id="fn-1">One</li></ol>`),
frag(2, `<p>B<sup><a href="#fn-1">1</a></sup></p><ol><li id="fn-1">Two</li></ol>`),
frag(3, `<p>C<sup><a href="#fn-1">1</a></sup></p>`),
frag(4, `<p>D<sup><a href="#fn-1">1</a></sup></p><tr><td>IMPORTANT DATA</td></tr>`),
]);
assert.deepEqual(anchors.skipped_pages, [4]);
assert.deepEqual(anchors.pinned_ids, ["fn-1"], "the frozen reference did not pin its first owner");
assert.deepEqual(anchors.unrepointed, [{ page: 3, ref: "fn-1" }]);
assert.match(body, /<li id="fn-1">One<\/li>/, "the pinned first owner was renamed");
assert.match(body, /<li id="p2-fn-1">Two<\/li>/, "the collision was abandoned rather than narrowed");
// Page 1's own link stays bare because its target is pinned; page 2's follows its rename;
// pages 3 and 4 are both bare, and land on page 1's note β for page 4 that is the pin doing
// its job, and for page 3 it is the honest outcome of a marker whose note is not here.
const hrefs = [...new JSDOM(`<body>${body}</body>`).window.document.querySelectorAll("a[href^='#']")].map((a) =>
a.getAttribute("href"),
);
assert.deepEqual(hrefs, ["#fn-1", "#p2-fn-1", "#fn-1", "#fn-1"]);
});
test("the rule is about links, not about the attributes a name depends on", () => {
// The line this file's header draws, and where #233 stops. Page 2 owns `q1` and also
// references it β `<label for="q1">` beside its own input β so under the link rule page
// 1's orphaned label would be left bare, the field would lose its accessible name, and
// assembly would introduce a 1.3.1/4.1.2 failure into a document a plain concatenation
// passed. A no-target `for` is worse than a wrong-target one; a no-target LINK is not.
// So `for`/`headers`/`aria-*` keep first-owner repointing unconditionally.
const { body, anchors } = assembleBodyWithReport([
frag(1, `<h1>Form</h1><label for="q1">Your name</label>`),
frag(2, `<form><label for="q1">Your name</label><input id="q1" type="text"></form>`),
frag(3, `<form><label for="q1">Your name</label><input id="q1" type="text"></form>`),
]);
// Page 1's label leads the document, so its own `for` is the first one in the body β
// matched positionally, because pages 1 and 2 both end up naming `p2-q1` and a bare
// search for that string would pass on page 2's label alone.
assert.match(body, /^<h1>Form<\/h1><label for="p2-q1">/, "the orphaned label was not repointed");
assert.match(body, /<label for="p3-q1">Your name<\/label><input id="p3-q1"/, "page 3's own pair was broken");
assert.deepEqual(anchors.unrepointed, [], "an IDREF attribute was left dangling by the link rule");
const ids = new Set(idsOf(body));
for (const value of body.matchAll(/for="([^"]*)"/g)) {
assert.ok(ids.has(value[1]), `for="${value[1]}" resolves to nothing`);
}
});
test("a third page claiming an id does not cost a label its field", async () => {
// Where leaving an ambiguous reference alone showed its cost as a real violation,
// not just a dead anchor. A form whose `<label for="q1">` is on page 1 and whose
// `<input id="q1">` is on page 2 is the split-form case above and works untouched β
// until a THIRD page carries a repeat of the same field. Now `q1` collides, every
// owner is renamed, and page 1's label, which named the right control before
// assembly existed, points at nothing: the field loses its accessible name and axe
// reports `label` on a document that was clean under a plain concatenation.
const frags = [
frag(1, `<h1>Form</h1><label for="q1">Your name</label>`),
frag(2, `<form><input id="q1" type="text"></form>`),
frag(3, `<form><label for="q1">Your name</label><input id="q1" type="text"></form>`),
];
const { body, anchors } = assembleBodyWithReport(frags);
assert.deepEqual(anchors.collisions, ["q1"]);
assert.deepEqual(anchors.ambiguous, [{ page: 1, ref: "q1" }]);
// Page 1's `for` follows document order to page 2's input; page 3's label and input
// were written together, so they stay paired with each other.
assert.match(body, /<label for="p2-q1">Your name<\/label>/, "the orphaned label was not repointed");
assert.match(body, /<label for="p3-q1">Your name<\/label><input id="p3-q1"/, "page 3's own pair was broken");
const lint = await runAxe(wrapDocument(body));
if (!lint.violations) return; // no verdict: axe could not run here (see LintResult)
assert.equal(
lint.violations.map((v) => v.id).join(", "),
"",
"assembly introduced a violation into a document a plain concatenation passed",
);
});
test("runAssembly logs what the join did, and only when it did something", async () => {
// The report's whole purpose is that a reference resolved by document order rather
// than by the agent that wrote it is findable. It reaches a human through this log
// line, so an unlogged report is the same as no report β and a line on every ordinary
// run is noise that gets ignored, which comes to the same thing.
const events: { type: string; data: Record<string, unknown> }[] = [];
const ctx = {
log: { event: (type: string, data: Record<string, unknown> = {}) => events.push({ type, data }) },
} as unknown as PipelineContext;
await runAssembly(ctx, [frag(1, `<p id="intro">Clean</p>`), frag(2, `<p>Nothing to collide</p>`)]);
assert.deepEqual(events.filter((e) => e.type === "assembly_anchors"), [], "an ordinary document logged an anchor line");
events.length = 0;
await runAssembly(ctx, [
frag(1, `<ol><li id="fn-1">One</li></ol>`),
frag(2, `<ol><li id="fn-1">Two</li></ol>`),
frag(3, `<p>See<sup><a href="#fn-1">1</a></sup></p>`),
]);
const logged = events.filter((e) => e.type === "assembly_anchors");
assert.equal(logged.length, 1, "the namespacing was not reported");
assert.deepEqual(logged[0].data.collisions, ["fn-1"]);
assert.deepEqual(logged[0].data.ambiguous, ["page 3: #fn-1"], "the ambiguous reference was not named");
});
test("a lint gate that could not run says so in the log", async () => {
// `lint_ok: true` used to mean two different things, and the log collapsed them: when axe
// could not run, `runAxe` reported `ok: true, violations: []` with `error` set rather than
// failing the session, so a document axe never examined was recorded exactly like one it
// cleared. The pair is no longer produced at all (#164, asserted at the end of this test),
// and this line is what made the collapse visible while it was.
//
// Reachable on the route this module creates: a page too deeply nested to rewrite is
// delivered as written, its nesting reaches the linted document, and axe overflows on it
// from a few thousand levels. That is precisely the document that may still carry the
// duplicate ids the join could not fix β reported as clean, with the reason surviving only
// in the Reader's prompt. Same argument as `pinned_ids`: a gate that passed for a reason
// has to be distinguishable from a gate that found nothing.
const events: { type: string; data: Record<string, unknown> }[] = [];
const ctx = {
log: { event: (type: string, data: Record<string, unknown> = {}) => events.push({ type, data }) },
} as unknown as PipelineContext;
// An ordinary document: no `lint_error` key at all, so the common run gains no noise.
await runAssembly(ctx, [frag(1, `<h1>Report</h1><p id="intro">Clean</p>`)]);
const clean = events.find((e) => e.type === "assembly")!;
assert.ok(!("lint_error" in clean.data), `an ordinary run logged a lint error: ${JSON.stringify(clean.data)}`);
// Deep enough that axe overflows. If it manages to run here the degradation never
// happened, so there is nothing to disclose and the assertion would be measuring the
// wrong thing β every threshold here moves with the stack the caller already spent.
events.length = 0;
const deep = "<div>".repeat(6000);
const { lint } = await runAssembly(ctx, [frag(1, `${deep}<p id="fn-1">A</p>`), frag(2, `<p id="fn-1">B</p>`)]);
const logged = events.find((e) => e.type === "assembly")!;
if (lint.error === undefined) {
assert.ok(!("lint_error" in logged.data), "a gate that ran logged an error anyway");
return;
}
assert.equal(logged.data.lint_error, lint.error, "the gate could not run and the log did not say so");
// And the pairing that made this worth fixing, now the other way round: a gate that did
// not run is not a gate that passed, and it reports no violation count at all β a `0` here
// was read as "axe found nothing" by everything downstream, including the tally that
// divides by it.
assert.equal(logged.data.lint_ok, false, "a lint that threw is still logged as a pass");
assert.ok(
!("violations" in logged.data),
`a check that did not happen reported a violation count: ${JSON.stringify(logged.data.violations)}`,
);
});
test("a page that would lose markup on reserialization is left as the agent wrote it", () => {
// A stray `<tr>` outside a `<table>` β a plausible emission for a table continuing
// across a page break β is foster-parented by the HTML parser: the row and cell
// vanish and only their text survives, without any error. Reserializing that would
// silently discard structure. So the rewrite is abandoned for that page: the
// collision survives (and lint's `duplicate-id` reports it), which is strictly
// better than losing a table row from the deliverable.
const stray = `<p id="fn-1">A</p><tr><td>IMPORTANT DATA</td></tr>`;
const { body, anchors } = assembleBodyWithReport([frag(1, `<p id="fn-1">B</p>`), frag(2, stray)]);
assert.match(body, /<tr><td>IMPORTANT DATA<\/td><\/tr>/, "a table row was dropped by reserialization");
assert.deepEqual(anchors.skipped_pages, [2]);
assert.match(body, /<p id="fn-1">A<\/p>/, "page 2 was rewritten despite the markup risk");
});
test("the prefix cannot land on an id a page already claims", async () => {
// The rename must not manufacture the collision it exists to remove. `p1-total`,
// `p2-name` and the like are what a paginated form or worksheet emits, and the page
// agent has no idea the assembler reserves that shape.
//
// Here `x` collides across pages 1 and 2, and page 2 also carries a working
// `<label for="p1-x">`/`<input id="p1-x">` pair. A prefix applied without checking
// turns page 1's `x` into `p1-x`, so two elements own it and page 2's label β correct
// before assembly touched anything β resolves to page 1's `<p>`, which is not
// labelable. The field loses its accessible name, and nothing in the report says so:
// page 2 owns `p1-x`, so the reference is not ambiguous, and no page was skipped.
const frags = [
frag(1, `<p id="x">page one x</p>`),
frag(2, `<p id="x">two</p><label for="p1-x">Field</label><input id="p1-x">`),
];
const { body } = assembleBodyWithReport(frags);
const ids = idsOf(body);
assert.equal(new Set(ids).size, ids.length, `the rename created a duplicate id: ${ids.join(", ")}`);
// The pre-existing pair is what it was, and still points at its own field.
assert.match(body, /<label for="p1-x">Field<\/label><input id="p1-x">/, "an id the page wrote itself was disturbed");
const lint = await runAxe(wrapDocument(body));
if (!lint.violations) return; // no verdict: axe could not run here (see LintResult)
assert.equal(
lint.violations.map((v) => v.id).join(", "),
"",
"assembly introduced a duplicate id into a document that was clean",
);
});
test("the reservation covers every page's prefix, not just the first", () => {
// Same defect, one page over. `x` collides on pages 1 and 2, and it is page 2's own
// rename that lands on an id already in the document β page 3 wrote `p2-x` itself.
// A reservation that only checked `p1-` would pass every other test in this file
// while still shipping the duplicate, since each of those fixtures happens to put the
// occupied prefix on page 1.
const { body } = assembleBodyWithReport([
frag(1, `<p id="x">one</p>`),
frag(2, `<p id="x">two</p>`),
frag(3, `<label for="p2-x">Field</label><input id="p2-x">`),
]);
const ids = idsOf(body);
assert.equal(new Set(ids).size, ids.length, `the rename created a duplicate id: ${ids.join(", ")}`);
assert.match(body, /<label for="p2-x">Field<\/label><input id="p2-x">/, "page 3's own pair was disturbed");
});
test("a document that has taken the escalated prefix too is stepped past", () => {
// The separator grows until nothing claims it, so one occupied candidate is not
// enough to prove the loop rather than a single hard-coded fallback. Pages 1 and 2
// collide on `x` while page 2 also owns `p1-x` AND `p1--x`, so the first two
// candidates are both taken. Termination is by construction: `claims` is finite and
// each round is strictly longer.
const { body } = assembleBodyWithReport([
frag(1, `<p id="x">one</p>`),
frag(2, `<p id="x">two</p><p id="p1-x">a</p><p id="p1--x">b</p>`),
]);
const ids = idsOf(body);
assert.equal(new Set(ids).size, ids.length, `duplicate ids survived: ${ids.join(", ")}`);
assert.match(body, /id="p1---x"/, "the separator did not grow past both occupied candidates");
});
test("an ordinary document keeps the short prefix", () => {
// The escalation is driven by what the document claims, not applied defensively. A
// scan whose pages simply numbered their footnotes 1 gets `p1-fn-1`, so the ids in a
// delivered document stay legible and the pages 1 and 2 case is not penalised by an
// unrelated `p9-` id elsewhere.
const { body } = assembleBodyWithReport([
frag(1, `<p id="x">one</p><p id="p9-y">y</p>`),
frag(2, `<p id="x">two</p>`),
]);
assert.match(body, /id="p1-x"/, "an id under a page number no page has took the short prefix away");
assert.match(body, /id="p2-x"/);
assert.match(body, /id="p9-y"/, "an id that looked like a prefix was rewritten");
});
test("a slash-separated id is still seen as a claim on that id", () => {
// Pass 1's sniff decides whether a page is parsed for the ids it OWNS, and a page it
// skips contributes nothing to the claims map β so a collision is not merely
// unrepointed but never detected, and the whole join no-ops. `<p/id="fn-1">` is an
// element with that id as far as the parser is concerned (`/` is the only character
// besides whitespace the tokenizer accepts before an attribute name), so requiring
// whitespace shipped two `id="fn-1"` with an empty report.
//
// Pass 2's reference sniff was fixed for exactly this and pass 1 was not, which is why
// both directions are pinned rather than just the one that was reported.
const { body, anchors } = assembleBodyWithReport([
frag(1, `<p/id="fn-1">one</p>`),
frag(2, `<p id="fn-1">two</p><a href="#fn-1">r</a>`),
]);
assert.deepEqual(anchors.collisions, ["fn-1"], "the collision was never detected");
const ids = idsOf(body);
assert.equal(new Set(ids).size, ids.length, `duplicate ids survived assembly: ${ids.join(", ")}`);
// Page 2 owns its `fn-1`, so its own reference follows its own copy.
assert.match(body, /href="#p2-fn-1"/, "page 2's reference did not follow its own note");
});
test("a quote-adjacent id is still seen as a claim on that id", () => {
// The third and fourth separators, and the ones the header comment used to deny existed:
// `<p class="note"id="fn-1">` omits the space between two attributes, which is a parse
// error the spec recovers from by reading the next attribute name anyway. Both quote
// styles, since a page agent writing one writes the other.
//
// Same consequence as the slash case above and the reason it is a separate test: pass 1
// skipping the page means the id is never CLAIMED, so the collision is not detected, the
// report comes back empty, and two `id="fn-1"` ship. This is the third separator bug in
// this pair of sniffs, which is why they now share one ATTR_SEP constant.
for (const first of [`<p class="note"id="fn-1">one</p>`, `<p class='note'id='fn-1'>one</p>`]) {
const { body, anchors } = assembleBodyWithReport([
frag(1, first),
frag(2, `<p id="fn-1">two</p><a href="#fn-1">r</a>`),
]);
assert.deepEqual(anchors.collisions, ["fn-1"], `collision not detected for ${first}`);
const ids = idsOf(body).concat([...body.matchAll(/\sid='([^']+)'/g)].map((m) => m[1]));
assert.equal(new Set(ids).size, ids.length, `duplicate ids survived assembly: ${ids.join(", ")}`);
assert.match(body, /href="#p2-fn-1"/, "page 2's reference did not follow its own note");
}
});
test("the separator class covers every character jsdom accepts before an attribute name", () => {
// ATTR_SEP is a claim about the parser, and the last two versions of that claim were
// wrong because they were reasoned about instead of measured. So this measures it, and
// will fail if a jsdom upgrade widens the set rather than letting the sniffs silently
// start missing attributes.
//
// Every way a preceding attribute can end, crossed with every gap that could follow it;
// for each source jsdom parses as carrying a real `id`, the character sitting directly
// before the `id` in the SOURCE is what the sniff's class has to match.
const prev = ["", `a="b"`, `a='b'`, `a=b`, "a", `a=""`, `a=''`, `a="b/"`, `data-x="1"`];
const gaps = ["", " ", " ", "\t", "\n", "\r", "\f", "/", "//", " / ", "\t/"];
const seen = new Set<string>();
for (const p of prev) {
for (const g of gaps) {
const src = `<p ${p}${g}id="fn-1">t</p>`;
const el = new JSDOM(`<body>${src}`).window.document.body.querySelector("*");
if (el?.getAttribute("id") !== "fn-1") continue;
seen.add(src[src.lastIndexOf(`id="fn-1"`) - 1]);
}
}
// The eight the enumeration finds today. Asserted as a set equality, not a superset: a
// character DISAPPEARING would mean the probe stopped exercising a case it thinks it
// covers, which is how the false-negative fixtures above go stale.
assert.deepEqual([...seen].sort(), ["\t", "\n", "\f", "\r", " ", '"', "'", "/"].sort());
// The real constant, imported rather than restated: a copy of the class here would keep
// passing while the one the sniffs use drifted, which is the failure this pins.
const attrSep = new RegExp(ATTR_SEP);
for (const ch of seen) {
assert.ok(attrSep.test(ch), `ATTR_SEP does not match ${JSON.stringify(ch)}`);
}
});
test("a page the parser would REORDER is left as the agent wrote it", () => {
// The skip guard used to compare per-tag COUNTS, which cannot see a move. Foster
// parenting moves things: a `<p>` inside a `<table>` is hoisted out to before the
// table, so this page reserializes with the note ahead of the table it followed β
// every count identical, so the guard called it safe and rewrote it.
//
// A reading-order change is the worst thing this file could do, and it lands on
// precisely the shape that produces the collisions in the first place: content inside a
// table that continues across a page break. Skipping the page costs a duplicate id
// lint reports, which is the trade the header's rule demands.
const source = `<table><caption>Q1</caption><tbody><tr><td>1</td></tr></tbody><p id="fn-1">note after table</p></table><p>tail</p>`;
const { body, anchors } = assembleBodyWithReport([frag(1, source), frag(2, `<p id="fn-1">two</p>`)]);
assert.deepEqual(anchors.skipped_pages, [1], "the reordering page was rewritten instead of skipped");
assert.ok(body.startsWith(source), "page 1 was not delivered exactly as written");
// The note still follows the table, which is the property at stake.
assert.ok(
body.indexOf("<table>") < body.indexOf(`<p id="fn-1">note after table`),
"the note was hoisted ahead of the table it followed",
);
// And the skip is reported, so the surviving duplicate is not silent.
assert.deepEqual(anchors.collisions, ["fn-1"]);
});
test("a page whose bare TEXT the parser would move is left as written too", () => {
// The same defect one level down, and the reason the guard compares text and not only
// tags. Wrapping that content in a `<p>` was caught (the test above); leaving it as bare
// prose was not β foster parenting hoists the text run out past the whole table with
// every tag still present and in order, so a tag-only sequence saw nothing to object to.
//
// Bare prose inside a table is a plausible page-agent emission for a table continued
// across a break ("Continued from page 1"), which is the same shape that produces the
// duplicate `<caption>` id in the first place β so the two arrive together.
const source = `<table><caption id="c1">Cap</caption>Continued from page 1<tr><td>x</td></tr></table>`;
const { body, anchors } = assembleBodyWithReport([
frag(1, source),
frag(2, `<table><caption id="c1">Cap2</caption><tr><td>y</td></tr></table>`),
]);
assert.deepEqual(anchors.skipped_pages, [1], "the text-moving page was rewritten instead of skipped");
assert.ok(body.startsWith(source), "page 1 was not delivered exactly as written");
assert.ok(
body.indexOf("Continued from page 1") < body.indexOf("</table>"),
"the continuation text was moved out past the table",
);
assert.deepEqual(anchors.collisions, ["c1"]);
});
test("the skip guard does not fire on markup the parser only ADDS to", () => {
// The guard's cost is over-skipping: a page it skips keeps its duplicate id, so a guard
// that fires on ordinary markup quietly stops fixing the thing this module exists for.
// Both directions are pinned together because every past version of this check erred in
// one of them β counts under-fired, and a first text-aware draft over-fired on
// `title="a > b"`, where a `>` inside a quoted attribute value split one tag into a tag
// plus a phantom text run.
const rewritten = [
["a well-formed table gains a tbody", `<table><tr><td id="x">c</td></tr></table>`],
["the adoption agency duplicates a tag", `<b>1<p id="x">2</b>3</p>`],
["entities decode", `<p id="x">Tom & Jerry <br> ok</p>`],
["nbsp decodes", `<p id="x">a b</p>`],
["a quoted attribute value contains >", `<p id="x" title="a > b">t</p>`],
["prose contains escaped angle brackets", `<p id="x">5 < 6 and a > b</p>`],
["a comment sits between blocks", `<p id="x">a</p><!-- note --><p>b</p>`],
["void elements", `<p id="x">a<br>b<hr></p>`],
["source newlines and indentation", `<p id="x">\n spaced\n out\n</p>\n<p>next</p>`],
["nested inline markup", `<p id="x">a <em>b <strong>c</strong></em> d</p>`],
["a figure with a caption", `<figure><img src="a.png" alt="a"><figcaption id="x">c</figcaption></figure>`],
["an explicit thead/tbody table", `<table><thead><tr><th id="x">h</th></tr></thead><tbody><tr><td>c</td></tr></tbody></table>`],
] as const;
for (const [what, source] of rewritten) {
const { anchors } = assembleBodyWithReport([frag(1, source), frag(2, `<p id="x">two</p>`)]);
assert.deepEqual(anchors.skipped_pages, [], `over-skipped when ${what}: ${source}`);
assert.deepEqual(anchors.collisions, ["x"], `collision not namespaced when ${what}`);
}
// And the moves that must still fire, so this test cannot pass by never firing at all.
const skipped = [
["a <p> inside a table is hoisted", `<table><tbody><tr><td>1</td></tr></tbody><p id="x">note</p></table>`],
["bare text inside a table is hoisted", `<table><caption id="x">Cap</caption>Continued<tr><td>c</td></tr></table>`],
["a <tr> outside a table is dropped", `<p id="x">A</p><tr><td>DATA</td></tr>`],
] as const;
for (const [what, source] of skipped) {
const { anchors } = assembleBodyWithReport([frag(1, source), frag(2, `<p id="x">two</p>`)]);
assert.deepEqual(anchors.skipped_pages, [1], `did not skip when ${what}: ${source}`);
}
});
test("an unquoted href on a page that owns no id is still repointed", () => {
// The pre-parse sniff in pass 2 decides whether a page that owns no `id=` gets parsed
// for its references at all, and a page it skips is never repointed. It used to
// require a QUOTE after `href=`, so this page β whose only reference is
// `href=#fn-1` β was skipped while pages 1 and 3 were renamed out from under it.
//
// That is worse than the defect the file exists to fix. Before assembly the link
// resolved to page 1's note: the wrong note, but a target. After, it resolved to
// nothing β and silently, since the page lands in neither `ambiguous` nor
// `skipped_pages`, so no `assembly_anchors` line mentions it and axe has no rule for a
// broken same-document anchor.
//
// Unquoted attributes are valid HTML and a model writes them occasionally; the sniff
// is the only thing between that and a dangling reference.
const { body, anchors } = assembleBodyWithReport([
frag(1, `<ol><li id="fn-1">Note one</li></ol>`),
frag(2, `<p>see <a href=#fn-1>1</a></p>`),
frag(3, `<ol><li id="fn-1">Note three</li></ol>`),
]);
assert.deepEqual(anchors.collisions, ["fn-1"]);
assert.deepEqual(anchors.ambiguous, [{ page: 2, ref: "fn-1" }], "the page was skipped, so nothing reported it");
// Repointed at the first owner, which is where the bare reference went before.
assert.match(body, /href="#p1-fn-1"/, "the unquoted reference was left dangling");
assert.doesNotMatch(body, /href="?#fn-1/, "a bare reference to a renamed id survived");
});
test("a slash-separated reference attribute is not missed either", () => {
// The same sniff's other alternative required whitespace before the attribute name.
// jsdom parses `<label/for="q1">` β so a page whose sole reference is written that way
// was skipped for the same reason and with the same consequence, a `for` that names no
// element being a 1.3.1/4.1.2 failure rather than just a dead link.
const { body, anchors } = assembleBodyWithReport([
frag(1, `<input id="q1" type="text">`),
frag(2, `<p>text</p><label/for="q1">Your name</label>`),
frag(3, `<input id="q1" type="text">`),
]);
assert.deepEqual(anchors.ambiguous, [{ page: 2, ref: "q1" }]);
assert.match(body, /for="p1-q1"/, "the slash-separated `for` was left dangling");
});
test("a quote-adjacent reference attribute is not missed either", () => {
// `/` was not the last separator, and this is the case that says so. A quoted attribute
// value can be followed immediately by the next attribute name, so `<label class="l"for=
// "q1">` carries a real `for` β and the sniff skipped the page, leaving that `for`
// naming an id both owners had been renamed away from.
//
// Worse than the pass-1 direction because nothing reports it: page 2 appears in neither
// `ambiguous` nor `skipped_pages`, axe has no rule for a dangling `for` (the input still
// has an id, the label still has text), and `duplicate-id` no longer fires because the
// duplicate really was resolved. A working label association became a broken one and the
// document looked cleaner afterwards.
for (const label of [`<label class="l"for="q1">Your name</label>`, `<label class='l'for='q1'>Your name</label>`]) {
const { body, anchors } = assembleBodyWithReport([
frag(1, `<input id="q1" type="text">`),
frag(2, `<p>text</p>${label}`),
frag(3, `<input id="q1" type="text">`),
]);
assert.deepEqual(anchors.ambiguous, [{ page: 2, ref: "q1" }], `page 2 was not visited for ${label}`);
assert.match(body, /for="p1-q1"/, `the quote-adjacent \`for\` was left dangling: ${label}`);
}
});
test("no reference a page can write escapes the pre-parse sniff", async () => {
// The sniff is asserted to be unfailable in the false-negative direction, so the
// spellings are enumerated rather than sampled: quoted, unquoted and slash-separated
// `href`, an `aria-*` reference with each separator, and an unquoted `headers` token
// list. Every page owns no id of its own, so each depends entirely on the sniff to be
// parsed at all.
//
// One spelling per page, which is the part that makes this a test rather than a
// demonstration. A first version put three `href` forms on one page, and the quoted one
// pulled the page in and repointed all three β so the case survived a sabotage that
// reinstated the mandatory quote. A page is the unit the sniff decides on, so a spelling
// is only covered when it is the sole reason its page gets parsed.
//
// Checked by resolution rather than by matching prefixes: the property is that every
// href and every IDREF names something in the document, which is what a dangling
// reference violates and what a prefix assertion would only approximate.
const referrers = [
`<p>quoted <a href="#t">a</a></p>`,
`<p>unquoted <a href=#t>b</a></p>`,
`<p>slash <a/href="#t">c</a></p>`,
`<p aria-describedby="t">space-separated aria</p>`,
`<p/aria-labelledby="t">slash-separated aria</p>`,
// Quote-adjacent, the two spellings this test asserted coverage of before the code
// had it: a quoted attribute value can be followed immediately by the next attribute
// name, and the tokenizer recovers rather than giving up on the rest of the tag.
`<p class="note"aria-details="t">double-quote-adjacent aria</p>`,
`<p class='note'aria-controls='t'>single-quote-adjacent aria</p>`,
];
const target = `<ol><li id="t">Target</li></ol><table><tr><th id="h">H</th></tr></table>`;
const frags = [
frag(1, target),
...referrers.map((html, i) => frag(i + 2, html)),
// `headers` last, so the pages above stay free of the cross-table `headers` shape.
frag(referrers.length + 2, `<table><tr><td headers=h>cell</td></tr></table>`),
frag(referrers.length + 3, `<ol><li id="t">Other target</li></ol><table><tr><th id="h">H2</th></tr></table>`),
];
const { body, anchors } = assembleBodyWithReport(frags);
assert.deepEqual(anchors.collisions, ["h", "t"], "the fixture stopped producing collisions");
const ids = new Set(idsOf(body));
for (const href of fragHrefs(body)) assert.ok(ids.has(href), `href="#${href}" resolves to nothing`);
for (const attr of ["aria-describedby", "aria-labelledby", "aria-details", "aria-controls", "headers"]) {
for (const m of body.matchAll(new RegExp(`\\s${attr}="([^"]+)"`, "g"))) {
for (const token of m[1].split(/\s+/)) assert.ok(ids.has(token), `${attr}="${token}" resolves to nothing`);
}
}
// Every referring page had to be repointed, so a sniff that dropped any one of them
// shows up here as a missing entry β naming the page, and so the spelling β rather than
// only as a dangling reference somewhere in the body.
assert.deepEqual(
anchors.ambiguous,
[
{ page: 2, ref: "t" },
{ page: 3, ref: "t" },
{ page: 4, ref: "t" },
{ page: 5, ref: "t" },
{ page: 6, ref: "t" },
{ page: 7, ref: "t" },
{ page: 8, ref: "t" },
{ page: 9, ref: "h" },
],
"a page holding only references was not visited",
);
const lint = await runAxe(wrapDocument(body));
if (!lint.violations) return; // no verdict: axe could not run here (see LintResult)
// `td-headers-attr` is excluded, and only it: axe requires a `headers` token to name a
// cell in the SAME table, so a `headers` reference spanning a page break violates that
// rule whatever assembly does β verified by running the same fixture through a plain
// concatenation with no namespacing at all, which reports it too. Excluding the rule by
// name rather than dropping the lint check keeps the rest of the gate on this document,
// including the `duplicate-id` family that would catch a prefix collision here.
const remaining = lint.violations.map((v) => v.id).filter((id) => id !== "td-headers-attr");
assert.equal(remaining.join(", "), "", "assembly left the document with a lint violation");
});
test("two fragments sharing an order still get distinct prefixes", () => {
// `order` is an input, and it is the one input that can silently defeat the whole
// function: if the prefix were `p${order}` outright, two fragments numbered 1 would
// both become `p1-x` and the collision the rename exists to remove would survive it β
// with a report claiming `x` was namespaced. The current pipeline takes `order` from
// the image index so it does not happen today, but nothing in the type says so, and
// the failure is invisible (unique-looking report, duplicate ids in the document).
//
// Ownership is tracked per array position and the page LABEL is deduplicated, so the
// second page numbered 1 becomes `p1_2-`. The label still leads with the page number,
// because that is what the Reader and the `assembly_anchors` log cite.
const { body, anchors } = assembleBodyWithReport([frag(1, `<p id="x">one</p>`), frag(1, `<p id="x">two</p>`)]);
const ids = idsOf(body);
assert.equal(new Set(ids).size, ids.length, `two pages with the same order shared a prefix: ${ids.join(", ")}`);
assert.deepEqual(anchors.collisions, ["x"], "the collision was not even detected");
assert.deepEqual(ids, ["p1-x", "p1_2-x"]);
});
test("a reference from a page sharing an order goes to its own copy, not the other's", () => {
// The deduplication has to line up with ownership, not just produce two strings. Both
// pages are numbered 1 and each owns an `fn-1` it references itself. If the reference
// resolved by `order` it would find the first page claiming 1 and both markers would
// land on the first note β the original wrong-note defect, arriving through the
// duplicate-order path rather than through concatenation.
const { body } = assembleBodyWithReport([frag(1, footnotePage(1)), frag(1, footnotePage(1))]);
const ids = new Set(idsOf(body));
for (const href of fragHrefs(body)) assert.ok(ids.has(href), `href="#${href}" resolves to nothing`);
assert.deepEqual(idsOf(body), ["p1-fnref-1", "p1-fn-1", "p1_2-fnref-1", "p1_2-fn-1"]);
assert.deepEqual(fragHrefs(body), ["p1-fn-1", "p1-fnref-1", "p1_2-fn-1", "p1_2-fnref-1"]);
});
test("the reservation covers a deduplicated label too", () => {
// The prefix reservation reads the page LABELS, not `p${order}`, so an id shaped like
// a deduplicated label is protected the same way `p1-x` is. Two pages numbered 1
// collide on `x` while page 2 owns a working `<label for="p1_2-x">` pair, which the
// second page-1's rename would otherwise land on.
const { body } = assembleBodyWithReport([
frag(1, `<p id="x">one</p>`),
frag(1, `<p id="x">two</p>`),
frag(2, `<label for="p1_2-x">Field</label><input id="p1_2-x">`),
]);
const ids = idsOf(body);
assert.equal(new Set(ids).size, ids.length, `the rename created a duplicate id: ${ids.join(", ")}`);
assert.match(body, /<label for="p1_2-x">Field<\/label><input id="p1_2-x">/, "page 2's own pair was disturbed");
});
test("a reference whose first owner was left as written stays bare, so it still resolves", () => {
// The two mechanisms meeting. Page 1 owns `fn-1` and cannot be rewritten (the stray
// `<tr>` would be foster-parented away), so it keeps the BARE id. Page 2 owns `fn-1`
// too and is renamed. Page 3 references `fn-1` and owns neither copy, so it follows
// document order to page 1 β which means the reference has to stay bare as well.
// Prefixing it unconditionally would point it at `p1-fn-1`, an id that page 1 never
// got, and the reference would resolve to nothing: exactly the dangling-reference
// defect, reintroduced through the skip path.
const { body, anchors } = assembleBodyWithReport([
frag(1, `<p id="fn-1">A</p><tr><td>IMPORTANT DATA</td></tr>`),
frag(2, `<p id="fn-1">B</p>`),
frag(3, `<p>See<sup><a href="#fn-1">1</a></sup></p>`),
]);
assert.deepEqual(anchors.skipped_pages, [1]);
assert.deepEqual(anchors.ambiguous, [{ page: 3, ref: "fn-1" }]);
assert.match(body, /href="#fn-1"/, "the reference was prefixed to an id its owner never received");
assert.match(body, /id="p2-fn-1"/, "page 2 was not renamed, so this asserts nothing about the skip");
const ids = new Set(idsOf(body));
for (const href of fragHrefs(body)) assert.ok(ids.has(href), `href="#${href}" resolves to nothing`);
});
test("a REFERRING page left as written keeps its first owner bare, so its idref still resolves", async () => {
// The mirror of the test above, and the direction that was missing. There the skipped
// page OWNED the id; here it holds the reference. Its `for="q1"` is frozen in bare form
// by the skip, so renaming every owner leaves it naming nothing β and an unnamed field
// is a 1.3.1/4.1.2 failure, where the wrong-target name it had at least named something.
// A plain concatenation gave page 1's input its name from page 3's label; the assembler
// must not take that away.
//
// So the FIRST owner keeps the bare id β what a browser resolved that reference to
// before any of this ran β while the other owners are still renamed. Pinning the whole
// id instead would abandon the collision on account of one unrewritable page.
// Page 2 carries its own label, so the only unnamed field this document can produce is
// the one whose name comes across the page break β which is what the axe check below is
// then actually testing. (Without it, page 2's input is unlabelled on its own page and
// `label` fires either way.)
const { body, anchors } = assembleBodyWithReport([
frag(1, `<input id="q1" type="text">`),
frag(2, `<label for="q1">Second</label><input id="q1" type="text">`),
frag(3, `<label for="q1">Name</label><tr><td>IMPORTANT DATA</td></tr>`),
]);
assert.deepEqual(anchors.skipped_pages, [3]);
assert.deepEqual(anchors.ambiguous, [{ page: 3, ref: "q1" }]);
// And the pin is disclosed. `collisions` on its own says `q1` was claimed twice and would
// be read as "so it was renamed" β which the pin makes false for the first owner on
// purpose. Without this field, the bare `q1` two assertions down is indistinguishable in
// the run log from the namespacing having silently failed. `q1` appears in BOTH lists:
// it collided, and one of its owners was deliberately left alone.
assert.deepEqual(anchors.collisions, ["q1"]);
assert.deepEqual(anchors.pinned_ids, ["q1"], "the pin was not disclosed");
assert.match(body, /<label for="q1">Name<\/label>/, "the skipped page was rewritten after all");
// Page 2 owns its `q1`, so its own label follows its own renamed copy.
assert.match(body, /<label for="p2-q1">Second<\/label>/, "page 2's own label did not follow its own input");
// Page 1 keeps the bare id the label needs; page 2 is still de-duplicated.
assert.match(body, /<input id="q1"/, "the first owner was renamed out from under the frozen reference");
assert.match(body, /<input id="p2-q1"/, "the collision was abandoned instead of narrowed to the first owner");
const ids = idsOf(body);
assert.equal(new Set(ids).size, ids.length, `duplicate ids survived: ${ids.join(", ")}`);
// The property underneath both assertions: every idref in the delivered document names
// something in it.
const idSet = new Set(ids);
for (const m of body.matchAll(/\sfor="([^"]+)"/g)) {
assert.ok(idSet.has(m[1]), `for="${m[1]}" resolves to nothing`);
}
// A NORMAL page referencing the same pinned id is the other half of the fix, and it is
// the half the id rename alone does not cover: page 4 IS rewritten, so it goes through
// `resolve`, resolves to the first owner by document order, and that owner is the one
// holding the bare id. Prefixing here would break page 4's link instead of page 3's
// label β the defect moved rather than fixed.
const withNormalReferrer = assembleBodyWithReport([
frag(1, `<input id="q1" type="text">`),
frag(2, `<label for="q1">Second</label><input id="q1" type="text">`),
frag(3, `<label for="q1">Name</label><tr><td>IMPORTANT DATA</td></tr>`),
frag(4, `<p>See <a href="#q1">the field</a></p>`),
]);
assert.match(withNormalReferrer.body, /href="#q1"/, "a rewritten page's reference was pointed at a renamed owner");
const laterIds = new Set(idsOf(withNormalReferrer.body));
for (const href of fragHrefs(withNormalReferrer.body)) {
assert.ok(laterIds.has(href), `href="#${href}" resolves to nothing`);
}
// And the field really is named, which is the accessibility claim rather than a proxy
// for it β axe's `label` rule is what fires when this regresses.
const lint = await runAxe(wrapDocument(body));
if (!lint.violations) return; // no verdict: axe could not run here (see LintResult)
assert.deepEqual(
lint.violations.filter((v) => v.id === "label").map((v) => v.id),
[],
"the input lost its accessible name",
);
});
test("pinning is per-id and only for the first owner", () => {
// The narrowness is the point, so it is asserted rather than assumed. Two colliding ids,
// each referenced by a different skipped page: each id pins its own first owner, and
// nothing else in the document stops being namespaced.
const { body, anchors } = assembleBodyWithReport([
frag(1, `<input id="a" type="text"><input id="b" type="text">`),
frag(2, `<input id="a" type="text"><input id="b" type="text">`),
frag(3, `<label for="a">A</label><tr><td>x</td></tr>`),
frag(4, `<label for="b">B</label><tr><td>y</td></tr>`),
]);
assert.deepEqual(anchors.skipped_pages, [3, 4]);
assert.deepEqual(anchors.collisions, ["a", "b"]);
const ids = idsOf(body);
assert.deepEqual(ids, ["a", "b", "p2-a", "p2-b"]);
assert.equal(new Set(ids).size, ids.length, `duplicate ids survived: ${ids.join(", ")}`);
});
test("nothing is pinned when an OWNER of the id was itself skipped", async () => {
// The case the tests above could not reach, because in every one of them the skipped
// pages are pure referrers that own no colliding id. Here a skipped page OWNS `q1` while
// a different skipped page REFERS to it, and pinning unconditionally shipped two bare
// `id="q1"` β a duplicate id, which is the defect this whole module exists to remove.
//
// The premise the pin rests on is that a frozen reference can only ever find the bare id,
// so some owner has to keep it. That premise is already satisfied here: page 2 is
// delivered byte-for-byte as written, so its `q1` IS the bare one page 3's `for` finds.
// Pinning page 1 on top of that adds a second copy and fixes nothing.
//
// The shape is an ordinary one β a form continued across a page break. Two pages number
// their first field `q1`, and the page carrying the continued table's orphaned `<tr>`s is
// one the reserialization guard will not rewrite.
const { body, anchors } = assembleBodyWithReport([
frag(1, `<input id="q1" type="text">`),
frag(2, `<input id="q1" type="text"><tr><td>IMPORTANT DATA</td></tr>`),
frag(3, `<label for="q1">Name</label><tr><td>MORE DATA</td></tr>`),
]);
assert.deepEqual(anchors.skipped_pages, [2, 3]);
assert.deepEqual(anchors.collisions, ["q1"]);
// Nothing was pinned, so the report says so β the bare `q1` in the output is page 2's own,
// not a pin.
assert.deepEqual(anchors.pinned_ids, []);
const ids = idsOf(body);
assert.equal(new Set(ids).size, ids.length, `duplicate ids survived assembly: ${ids.join(", ")}`);
// Page 1 is the only owner that CAN be renamed, so it is β the skipped owner keeps the
// bare id, which is what the frozen reference needs.
assert.deepEqual(ids, ["p1-q1", "q1"]);
assert.match(body, /<label for="q1">/, "the skipped referrer was rewritten after all");
// And the duplicate really is gone as far as the gate is concerned. `for="q1"` makes the
// id ARIA-referenced, so the live rule lint.ts promotes out of `incomplete` is the one
// that fired when this regressed.
const lint = await runAxe(wrapDocument(body));
if (!lint.violations) return; // no verdict: axe could not run here (see LintResult)
assert.deepEqual(
lint.violations.filter((v) => v.id.startsWith("duplicate-id")).map((v) => v.id),
[],
"assembly delivered a duplicate id",
);
});
test("a skipped owner does not suppress pinning for a DIFFERENT id", () => {
// The condition above is per-id, not per-document: one id having a skipped owner must not
// switch the pin off for an unrelated id whose owners are all rewritable. Page 3 owns `a`
// (and is skipped) while referring to `b`, whose owners are pages 1 and 2 β so `b` still
// pins its first owner and `a` does not.
const { body, anchors } = assembleBodyWithReport([
frag(1, `<input id="b" type="text">`),
frag(2, `<input id="b" type="text">`),
frag(3, `<input id="a" type="text"><label for="b">B</label><tr><td>x</td></tr>`),
frag(4, `<input id="a" type="text">`),
]);
assert.deepEqual(anchors.skipped_pages, [3]);
assert.deepEqual(anchors.collisions, ["a", "b"]);
const ids = idsOf(body);
assert.equal(new Set(ids).size, ids.length, `duplicate ids survived: ${ids.join(", ")}`);
// `b` pinned its first owner (page 1) so page 3's frozen `for="b"` resolves; `a` did not
// pin, so page 3 keeps its bare `a` and page 4's copy is renamed.
assert.deepEqual(ids, ["b", "p2-b", "a", "p4-a"]);
const idSet = new Set(ids);
for (const m of body.matchAll(/\sfor="([^"]+)"/g)) {
assert.ok(idSet.has(m[1]), `for="${m[1]}" resolves to nothing`);
}
});
// A fragment `parseFragment` refuses to give a DOM: it nests past `MAX_NESTING` (500), so
// the page is delivered exactly as written and everything it contributes is read from its
// source. That is the live route to a null DOM; the parser's own `RangeError` is a backstop
// behind it. See `MAX_NESTING` for why the guard exists rather than letting the parse fail:
// between roughly 4,000 and 10,000 levels jsdom PARSES and then overflows in
// serialization or `window.close()`, throwing out of assembly and failing the session, so
// the depth at which the parse itself gives up was never a boundary worth relying on.
//
// Real input, not a stub. Stubbing was tried and abandoned: `anchors.ts` imports `JSDOM`
// directly and ESM namespace objects are read-only, so a test that reassigned it would have
// exercised nothing while appearing to pass.
const tooDeepToParse = "<div>".repeat(600);
test("an unparseable OWNER suppresses the pin, so no duplicate ships", () => {
// The pin leaves a colliding id's first owner bare for the sake of a frozen reference. An
// unparseable owner is ALREADY keeping that bare id, so pinning on top of it ships two
// copies β the duplicate the pin's own condition exists to rule out, reached by the one
// route that used to report nothing. Page 3 is guard-skipped and refers to `q1`; page 4
// cannot be parsed and owns `q1`.
const { body, anchors } = assembleBodyWithReport([
frag(1, `<input id="q1" type="text">`),
frag(2, `<input id="q1" type="text">`),
frag(3, `<label for="q1">Name</label><tr><td>DATA</td></tr>`),
frag(4, `${tooDeepToParse}<input id="q1" type="text">`),
]);
assert.deepEqual(anchors.pinned_ids, [], "the pin fired even though an owner was unparseable");
assert.deepEqual(anchors.skipped_pages, [3, 4]);
const ids = idsOf(body);
assert.equal(new Set(ids).size, ids.length, `duplicate ids survived: ${ids.join(", ")}`);
// Page 4's bare copy is the one page 3's frozen `for="q1"` finds, so the reference still
// resolves without any pin.
assert.deepEqual(ids, ["p1-q1", "p2-q1", "q1"]);
const idSet = new Set(ids);
for (const m of body.matchAll(/\sfor="([^"]+)"/g)) {
assert.ok(idSet.has(m[1]), `for="${m[1]}" resolves to nothing`);
}
});
test("an unparseable page's REFERENCES still pin their first owner", () => {
// The mirror, and the direction with an accessibility cost rather than a lint one. An
// unparseable page's `for="q1"` is frozen in bare form exactly like a guard-skipped page's,
// so if every owner is renamed the field loses its accessible name (1.3.1/4.1.2). Its
// references have to be read from the source for the same reason its ids are: there is no
// DOM to read them from and there never will be.
const { body, anchors } = assembleBodyWithReport([
frag(1, `<input id="q1" type="text">`),
frag(2, `<label for="q1">Second</label><input id="q1" type="text">`),
frag(3, `${tooDeepToParse}<label for="q1">Name</label>`),
]);
assert.deepEqual(anchors.pinned_ids, ["q1"], "the frozen reference did not pin its first owner");
assert.deepEqual(anchors.ambiguous, [{ page: 3, ref: "q1" }]);
assert.deepEqual(anchors.skipped_pages, [3]);
const ids = idsOf(body);
assert.deepEqual(ids, ["q1", "p2-q1"]);
const idSet = new Set(ids);
for (const m of body.matchAll(/\sfor="([^"]+)"/g)) {
assert.ok(idSet.has(m[1]), `for="${m[1]}" resolves to nothing`);
}
});
test("an unparseable page with nothing at stake is not named in the report", () => {
// `skipped_pages` is documented as "may still carry a collision or a stranded reference"
// and a human is asked to act on it. A page that fails to parse while owning no colliding
// id and referring to none carries neither, so naming it would be noise.
const { anchors } = assembleBodyWithReport([
frag(1, `<p id="fn-1">one</p>`),
frag(2, `<p id="fn-1">two</p>`),
frag(3, `${tooDeepToParse}<a href="#elsewhere">x</a>`),
]);
assert.deepEqual(anchors.collisions, ["fn-1"]);
assert.deepEqual(anchors.skipped_pages, []);
assert.deepEqual(anchors.ambiguous, []);
});
test("an id-shaped string that is not an attribute does not make an unparseable page an owner", () => {
// The source scan that reads an unparseable page's ids must not be over-inclusive, which
// is the opposite of the rule everywhere else here β a PHANTOM owner is worse than a
// missed one.
//
// Both readers of `skipped` rest on "a skipped owner is ALREADY keeping the bare id", and
// a phantom owner never had one. A first version scanned for `id\s*=` anywhere in the
// source, so `id=x` in prose made page 2 an owner of `x`: `x` became a collision that no
// element had twice, the pin declined to fire because an owner was skipped, page 3's real
// id was renamed to `p3-x`, and page 1's `<label for="x">` pointed at nothing. An unnamed
// field β 1.3.1/4.1.2 β manufactured out of prose.
//
// One end-to-end proof, with the delivered document as the evidence. The exhaustive
// position-by-position check is the test below, which does not pay for a parse per shape.
const { body, anchors } = assembleBodyWithReport([
frag(1, `<label for="x">Name</label>`),
frag(2, `${tooDeepToParse}<p>the field labelled id=x on the form</p>`),
frag(3, `<input id="x" type="text">`),
]);
assert.deepEqual(anchors.collisions, [], "`id=x` in prose was read as a claim on `x`");
assert.deepEqual(anchors.skipped_pages, []);
// Nothing collided, so nothing is renamed and the label keeps the field it named before
// assembly ran.
assert.match(body, /<input id="x"/, "the input's id was renamed");
const idSet = new Set(idsOf(body));
for (const m of body.matchAll(/\sfor="([^"]+)"/g)) {
assert.ok(idSet.has(m[1]), `for="${m[1]}" resolves to nothing`);
}
});
test("a tag inside a <textarea> is text, so it does not make a skipped page an owner", () => {
// The same manufactured-dangling-reference defect as the test above, reached through markup
// rather than prose: the tag-aware scan walked a `<p id="x">` sitting INSIDE a `<textarea>`,
// which the parser treats as text. A page agent transcribing a filled-in form field emits
// exactly this. `x` became a collision no element had twice, the phantom owner suppressed
// the pin, page 3's real id was renamed, and page 1's label named nothing.
const { body, anchors } = assembleBodyWithReport([
frag(1, `<label for="x">Name</label>`),
frag(2, `${tooDeepToParse}<textarea><p id="x"></textarea>`),
frag(3, `<input id="x" type="text">`),
]);
assert.deepEqual(anchors.collisions, [], "a tag inside <textarea> was read as an id claim");
assert.deepEqual(anchors.skipped_pages, []);
assert.match(body, /<input id="x"/, "the input's id was renamed");
const idSet = new Set(idsOf(body));
for (const m of body.matchAll(/\sfor="([^"]+)"/g)) {
assert.ok(idSet.has(m[1]), `for="${m[1]}" resolves to nothing`);
}
});
test("a character-referenced for= on a skipped page still pins its owner", () => {
// The mirror direction, on the reference side. `for="q1"` IS a reference to `q1` β the
// parser decodes attribute values β but the scan read it literally, so the pin never learned
// the reference existed. Both owners of `q1` were renamed, page 3's frozen `for` named
// nothing, and the report was empty: the defect the pin was added to fix, reached through
// the route that reports least.
const { body, anchors } = assembleBodyWithReport([
frag(1, `<input id="q1" type="text">`),
frag(2, `<label for="q1">Second</label><input id="q1" type="text">`),
frag(3, `${tooDeepToParse}<label for="q1">Name</label>`),
]);
assert.deepEqual(anchors.collisions, ["q1"]);
assert.deepEqual(anchors.pinned_ids, ["q1"], "the decoded reference did not pin its owner");
assert.deepEqual(anchors.skipped_pages, [3]);
const ids = idsOf(body);
assert.equal(new Set(ids).size, ids.length, `duplicate ids survived: ${ids.join(", ")}`);
// Page 1 keeps `q1` bare so page 3's frozen reference finds it; page 2's copy is renamed.
assert.deepEqual(ids, ["q1", "p2-q1"]);
});
test("a too-deep page's ids come from its tree, not from a scan of its source", () => {
// The phantom class the source scan could not close. `sourceIds` reads attribute positions,
// but the parser DROPS whole elements during tree construction β an orphan `<tr>`/`<td>`, a
// stray `<caption>`/`<col>`/`<thead>`/`<tbody>`/`<colgroup>`, and everything after
// `<plaintext>`. A page-break transcription that starts mid-table emits the orphan-row shape
// directly, so this was reachable on ordinary input.
//
// Each dropped id made the too-deep page a phantom `claims` owner: the collision it
// manufactured was on an id no delivered element had, the phantom owner suppressed the pin,
// the REAL owner was renamed, and page 1's `<label for>` named nothing β the 1.3.1/4.1.2
// failure the pin exists to prevent, on an id that never collided.
//
// The fix is not more parser rules: `parseFragment` keeps the DOM of a page too deep to
// REWRITE, because `querySelectorAll` is iterative and reads it exactly. So these shapes are
// asserted end to end, where the phantom used to do its damage.
const dropped = [
`<tr><td id="x">cell</td></tr>`, // orphan row: no table, so both elements are dropped
`<caption id="x">Totals`,
`<col id="x">`,
`<thead id="x">`,
`<tbody id="x">`,
`<colgroup id="x">`,
`<p>text<plaintext><p id="x">`, // everything after <plaintext> is text
];
for (const shape of dropped) {
const { body, anchors } = assembleBodyWithReport([
frag(1, `<label for="x">Name</label>`),
frag(2, `${tooDeepToParse}${shape}`),
frag(3, `<input id="x" type="text">`),
]);
const label = JSON.stringify(shape);
assert.deepEqual(anchors.collisions, [], `a dropped element was read as an id claim: ${label}`);
assert.deepEqual(anchors.skipped_pages, [], label);
assert.match(body, /<input id="x"/, `the real owner was renamed for a phantom: ${label}`);
const idSet = new Set(idsOf(body));
for (const m of body.matchAll(/\sfor="([^"]+)"/g)) {
assert.ok(idSet.has(m[1]), `for="${m[1]}" resolves to nothing: ${label}`);
}
}
// The other direction, on the same route: an id the parser DOES keep on a too-deep page is
// still an owner, so reading the tree must not have traded the phantoms for misses. If this
// page stopped claiming `x`, the pin would not fire, both copies would be renamed, and its
// frozen bare `id="x"` would collide with nothing β or worse, be left duplicated.
const kept = assembleBodyWithReport([
frag(1, `${tooDeepToParse}<select><option id="x">Platform</option></select>`),
frag(2, `<input id="x" type="text">`),
]);
assert.deepEqual(kept.anchors.collisions, ["x"], "a real id on a too-deep page was missed");
assert.deepEqual(kept.anchors.skipped_pages, [1]);
const keptIds = idsOf(kept.body);
assert.equal(new Set(keptIds).size, keptIds.length, `duplicate ids survived: ${keptIds.join(", ")}`);
});
test("a too-deep page's references come from its tree as well", () => {
// The mirror of the test above on the reference side, and it fails for a shape the id side
// does not: `sourceRefs` skips a `<select>` whole, because the parser drops most tags inside
// one β but `<option>`, `<optgroup>` and `<hr>` ARE kept, along with their attributes. So a
// real `aria-describedby` on an option is a reference the scan cannot see.
//
// Missing a reference is `sourceRefs`' dangerous direction: the pin never learns the frozen
// reference exists, every owner of `q1` is renamed, and the reference on the page delivered
// as-written names nothing β the 1.3.1/4.1.2 dangling reference the pin was added to prevent.
// Reading the tree instead closes it, since `querySelectorAll` works at any parseable depth.
const { body, anchors } = assembleBodyWithReport([
frag(1, `<input id="q1" type="text">`),
frag(2, `<input id="q1" type="text">`),
frag(3, `${tooDeepToParse}<select><option aria-describedby="q1">A</option></select>`),
]);
assert.deepEqual(anchors.collisions, ["q1"]);
assert.deepEqual(anchors.pinned_ids, ["q1"], "a reference inside <select> did not pin its owner");
assert.deepEqual(anchors.skipped_pages, [3]);
assert.deepEqual(anchors.ambiguous, [{ page: 3, ref: "q1" }]);
const ids = idsOf(body);
assert.equal(new Set(ids).size, ids.length, `duplicate ids survived: ${ids.join(", ")}`);
// Page 1 keeps `q1` bare so page 3's frozen reference resolves; page 2's copy is renamed.
assert.deepEqual(ids, ["q1", "p2-q1"]);
});
test("a page too deep to rewrite is delivered as written rather than throwing", async () => {
// Every step of the rewrite recurses per level of nesting, and they do not all give up at
// the same depth: measured, jsdom's serializer and `window.close()` overflow from about
// 4,000 levels while the parse survives past 10,000. So the band in between used to
// PARSE and then throw `RangeError` out of the rewrite β while a DEEPER page, whose parse
// failed cleanly, was delivered fine. Non-monotonic in depth, and the worse outcome was
// the shallower one.
//
// `MAX_NESTING` (500) makes the whole band one behaviour. The depths here span it: just
// over the limit, the old serializer/close band, and past the old parse threshold.
//
// Carried through `runAxe`, not stopped at assembly, because assembly is not where the
// session ends. A page too deep to rewrite is delivered as written, so its nesting reaches
// the linted document β and `runAxe` closes its own jsdom in a `finally`, which is one of
// the recursive steps. It threw from there and replaced the graceful degradation in the
// `catch` above it, so the band still failed the session one module later. A test that
// stopped at `assembleBodyWithReport` could not see that, and did not.
for (const depth of [600, 5000, 9000, 12000]) {
const deep = "<div>".repeat(depth);
const { body, anchors } = assembleBodyWithReport([
frag(1, `<input id="q1" type="text">`),
frag(2, `<input id="q1" type="text">`),
frag(3, `${deep}<label for="q1">Name</label>`),
]);
assert.deepEqual(anchors.skipped_pages, [3], `depth ${depth}`);
assert.deepEqual(anchors.pinned_ids, ["q1"], `depth ${depth}`);
const ids = idsOf(body);
assert.equal(new Set(ids).size, ids.length, `duplicate ids survived at depth ${depth}`);
// The page is delivered byte-for-byte, so its nesting is still in the output.
assert.ok(body.includes(deep), `depth ${depth}: the page was not delivered as written`);
const idSet = new Set(ids);
for (const m of body.matchAll(/\sfor="([^"]+)"/g)) {
assert.ok(idSet.has(m[1]), `depth ${depth}: for="${m[1]}" resolves to nothing`);
}
// The gate must return a verdict rather than throw. Whether axe can run at this depth is
// not asserted β it overflows too, and degrades to `ok: true` with an `error`, which is
// this gate's documented behaviour for an environment it cannot run in.
const lint = await runAxe(wrapDocument(body));
assert.equal(typeof lint.ok, "boolean", `depth ${depth}: lint did not return a verdict`);
}
});
test("nesting is measured as depth, not as a count of unclosed tags", () => {
// The first version of the depth guard counted start tags that had not been closed, which
// is not a depth: void elements and implied end tags never bring the count down. So two
// shapes that page agents emit constantly were refused a DOM and delivered as written,
// shipping the duplicate id this module exists to remove.
//
// A table continued across a page break is named throughout this file as the scenario that
// PRODUCES these collisions, and `</td>`/`</tr>` are exactly the tags a transcription
// omits β so this was reachable on ordinary input, not a corner case. Real depths here are
// 4 and 1; the old guard saw 601 and 601.
const table = (id: string) =>
`<table><caption id="${id}">Totals</caption>` +
Array.from({ length: 120 }, () => `<tr><td>a<td>b<td>c<td>d<td>e`).join("") +
`</table>`;
const wide = assembleBodyWithReport([frag(1, table("t1")), frag(2, table("t1"))]);
assert.deepEqual(wide.anchors.skipped_pages, [], "a table with implied end tags was refused a DOM");
assert.deepEqual(idsOf(wide.body), ["p1-t1", "p2-t1"], "the table's colliding id was not namespaced");
const brs = (id: string) => `<p id="${id}">note</p>${"<br>".repeat(600)}`;
const voids = assembleBodyWithReport([frag(1, brs("fn-1")), frag(2, brs("fn-1"))]);
assert.deepEqual(voids.anchors.skipped_pages, [], "a page of void elements was refused a DOM");
assert.deepEqual(idsOf(voids.body), ["p1-fn-1", "p2-fn-1"], "the colliding id was not namespaced");
});
test("the source id scan agrees with the parser on where an attribute is", () => {
// Both directions of the scan `sourceIds` uses, measured against jsdom rather than
// asserted from a list, since the whole question is what the parser considers an
// attribute. Every shape is checked in both directions at once: the ids the parser found
// must be exactly the ids the scan found.
//
// `sourceIds` directly rather than through `assembleBodyWithReport`: the end-to-end route
// needs a collision and a deep page per shape, and what is being measured here is the scan
// against the parser, one shape at a time, with the disagreement named in the failure. The
// route itself is covered end to end by the unparseable-page tests above. And it is the
// real function, not a copy of its regex: a copy would keep passing while the original
// drifted.
//
// The over-inclusive direction is the one with teeth (see the test above), but a MISS is
// the pin's premise failing too, so both are errors here.
const shapes = [
// Ids the parser does see, in every position it accepts one.
`<p id="x">t</p>`,
`<p id='x'>t</p>`,
`<p id=x>t</p>`,
`<p class="note"id="x">t</p>`, // quote-adjacent β what ATTR_SEP exists for
`<p/id="x">t</p>`, // slash-separated
`<p\tid="x">t</p>`,
`<p\nid="x">t</p>`,
`<p ID="x">t</p>`, // attribute names are case-insensitive
`<p id = "x">t</p>`,
`<img id="x"/>`,
`<p title="a > b" id="x">t</p>`, // a `>` inside a quoted value does not end the tag
`<p id="fn-1">t</p>`, // a character reference: the parser decodes, so this must too
`<p id="a&b">t</p>`,
`<p id="a&b">t</p>`, // NOT decoded in attribute position, unlike in text
`<p id="Ax">t</p>`,
// And the phantoms: id-shaped strings in positions that are not attributes.
`<p>id=phantom in prose</p><p id="x">t</p>`,
`<!-- <p id="phantom"> --><p id="x">t</p>`,
`<p title='id="phantom"' id="x">t</p>`,
`<p data-id="phantom" id="x">t</p>`, // `data-id` is not `id`
`<p id="a" id="b">t</p>`, // repeated name: the parser keeps the first
// Elements whose content is not markup, so a tag inside one is text. The plausible one
// is `<textarea>`: a page agent transcribing a filled-in form field emits exactly this.
`<textarea><p id="phantom"></textarea><p id="x">t</p>`,
`<script>var s='<p id="phantom">'</script><p id="x">t</p>`,
`<style>/* <p id="phantom"> */</style><p id="x">t</p>`,
`<title><p id="phantom"></title><p id="x">t</p>`,
`<template><p id="phantom"></template><p id="x">t</p>`,
`<xmp><p id="phantom"></xmp><p id="x">t</p>`,
`<iframe><p id="phantom"></iframe><p id="x">t</p>`,
`<noembed><p id="phantom"></noembed><p id="x">t</p>`,
`<noframes><p id="phantom"></noframes><p id="x">t</p>`,
`<select><b id="phantom"></select><p id="x">t</p>`,
`<textarea rows="2"><p id="phantom"></textarea><p id="x">t</p>`, // its OWN attributes are real
`<textarea id="real"><p id="phantom"></textarea><p id="x">t</p>`,
`<textarea><p id="phantom"></textarea foo="bar"><p id="x">t</p>`, // junk in the close tag
`<TEXTAREA><p id="phantom"></TEXTAREA><p id="x">t</p>`,
`<textarea><textarea><p id="phantom"></textarea><p id="x">t</p>`, // raw text does not nest
// A raw-text element with no close tag runs to the end of the page, so the ids after it
// are MISSED rather than invented β `sourceIds`' safe direction, asserted so a change
// that flipped it to the phantom direction would fail here.
`<textarea><p id="missed">`,
// `noscript` content IS parsed (scripting is disabled in these fragments), so it is not
// in RAW_CONTENT and its ids are real.
`<noscript><p id="real"></noscript><p id="x">t</p>`,
];
for (const shape of shapes) {
const parsed = new JSDOM(`<body>${shape}</body>`).window.document;
const fromParser = [...parsed.querySelectorAll("[id]")].map((el) => el.getAttribute("id")!).sort();
assert.deepEqual([...sourceIds(shape)].sort(), fromParser, `disagreed with the parser on: ${JSON.stringify(shape)}`);
}
});
test("the source reference scan agrees with the parser too", () => {
// The other half of what an unparseable page contributes. Same method as the id scan
// above, and it matters for the same reason: this is the only reading of that page's
// references there will ever be, so a miss leaves a frozen `for=`/`aria-*` unpinned and
// its owners renamed out from under it β the dangling reference this whole mechanism
// exists to avoid.
//
// Over-inclusiveness is the SAFE direction here, unlike ids (a reference that is not
// really there pins a first owner that did not need pinning: one colliding id left bare,
// no duplicate). The exact agreement is asserted anyway β a phantom reference is still a
// collision left half-fixed, and asserting equality is what makes a drift in either
// direction visible.
const shapes = [
`<a href="#t">a</a>`,
`<a href='#t'>a</a>`,
`<a href=#t>a</a>`,
`<a/href="#t">a</a>`,
`<label for="t">L</label>`,
`<label for=t>L</label>`,
`<p aria-describedby="t">x</p>`,
`<p/aria-labelledby="t">x</p>`,
`<p class="note"aria-details="t">x</p>`, // quote-adjacent
`<p class='note'aria-controls='t'>x</p>`,
`<p aria-labelledby="a b c">x</p>`, // space-separated token list
// And the non-references: an external URL's fragment, a bare `#`, prose, a reference
// inside another attribute's value or a comment, and a `data-` lookalike.
`<a href="http://e.com/#t">x</a>`,
`<a href="#">top</a>`,
`<p>see the headers for details</p>`,
`<p title="href=#phantom">x</p>`,
`<!-- <a href="#phantom">x</a> -->`,
`<p data-for="phantom">x</p>`,
// Character references, which the parser decodes. Reading these literally left a frozen
// `for=` unseen, so its owners were renamed and the reference named nothing β the exact
// defect the pin exists to prevent, and `sourceRefs`' unsafe direction.
`<label for="q1">L</label>`,
`<a href="#t">a</a>`,
`<p aria-labelledby="a b">x</p>`,
`<label for="a&b">L</label>`,
// And references inside elements whose content is not markup.
`<textarea><label for="phantom"></textarea>`,
`<script>var s='<label for="phantom">'</script>`,
`<template><a href="#phantom">x</a></template>`,
`<label for="a" for="b">L</label>`, // repeated name: first wins
];
const IDREF_ATTRS = ["for", "form", "list", "headers", "aria-labelledby", "aria-describedby", "aria-details", "aria-errormessage", "aria-controls", "aria-owns", "aria-flowto", "aria-activedescendant"];
for (const shape of shapes) {
const parsed = new JSDOM(`<body>${shape}</body>`).window.document;
const fromParser = new Set<string>();
for (const el of parsed.querySelectorAll("[href^='#']")) {
const target = el.getAttribute("href")!.slice(1);
if (target) fromParser.add(target);
}
for (const attr of IDREF_ATTRS) {
for (const el of parsed.querySelectorAll(`[${attr}]`)) {
for (const token of el.getAttribute(attr)!.split(/\s+/)) if (token) fromParser.add(token);
}
}
assert.deepEqual([...sourceRefs(shape)].sort(), [...fromParser].sort(), `disagreed with the parser on: ${JSON.stringify(shape)}`);
}
});
test("parsing that legitimately adds elements is not mistaken for a loss", () => {
// Two shapes where the parse produces MORE than the source wrote, both harmless:
//
// * A well-formed `<table><tr>` gains the `<tbody>` the source omitted. That tag
// is absent from the source counts, so it is never compared β but a check
// written as "the counts must match" would abandon the rewrite on most real
// tables, leaving exactly the collisions this function exists to fix.
// * The parser DUPLICATES a tag to repair misnesting: `<b>1<p>2</b>3</p>` becomes
// `<b>1</b><p><b>2</b>3</p>`, two `<b>` from one source `<b>`. An
// inequality check catches this one even though no content was lost, which is
// why the comparison is one-directional.
const page = `<table><tr><th id="c1">Year</th><td headers="c1">1994</td></tr></table><b>1<p>2</b>3</p>`;
const { body, anchors } = assembleBodyWithReport([frag(1, page), frag(2, page)]);
assert.deepEqual(anchors.skipped_pages, [], "a page that loses nothing was skipped");
assert.match(body, /id="p2-c1"/, "the rewrite was skipped for a page that loses nothing");
assert.match(body, /<tbody>/, "the fixture no longer exercises tbody insertion");
assert.match(body, /<b>1<\/b><p><b>2<\/b>3<\/p>/, "the fixture no longer exercises tag duplication");
});
test("no content is lost on a page the rewrite actually touches", () => {
// namespaceAnchors is not a validator. Whatever the agent emitted is what the rest
// of the pipeline reviews, so this must never drop text β the review loop and the
// lint gate are what judge the markup.
//
// Both pages carry `fn-1` so the rewrite runs; a single page would collide with
// nothing and be returned by the short-circuit, which would make this test assert
// the pass-through rather than the rewrite. The markup is deliberately sloppy
// (unclosed tags, misnesting) because that is what a page whose content ran off an
// edge tends to look like, and it is where a reserialization bug would bite.
const { body } = assembleBodyWithReport([
frag(1, `<p id="fn-1">Unclosed <div><span>nested one`),
frag(2, `<p id="fn-1">Second <b>page<i>text</b> tail</p>`),
]);
for (const word of ["Unclosed", "nested one", "Second", "page", "text", "tail"]) {
assert.match(body, new RegExp(word), `"${word}" was lost`);
}
assert.match(body, /id="p1-fn-1"/, "the rewrite did not run, so this asserts nothing");
assert.match(body, /id="p2-fn-1"/, "the rewrite did not run, so this asserts nothing");
});
// The two halves of the fix are independent and both load-bearing: assembly prevents
// the collision, and lint catches one the Copy Editor reintroduces when it rewrites
// the whole body. Asserting the gate directly, because the tag filter is what hid
// this in the first place.
test("axe reports a duplicate id, which the WCAG tag filter alone does not", async () => {
const colliding = `${footnotePage(1)}\n\n${footnotePage(1)}`;
const lint = await runAxe(wrapDocument(colliding));
if (!lint.violations) return; // no verdict: axe could not run here (see LintResult)
assert.equal(lint.ok, false, 'a document with two id="fn-1" passed the lint gate');
assert.ok(
lint.violations.some((v) => v.id.startsWith("duplicate-id")),
`expected a duplicate-id violation, got: ${lint.violations.map((v) => v.id).join(", ") || "none"}`,
);
});
// axe splits duplicate ids across three rules by what the element IS, and each skips
// the others' elements. Enabling `duplicate-id` alone covered only the least harmful
// third of the problem. Each case below was clean under some earlier version of this
// gate; they are separate tests so a regression names which third came back.
test("the duplicate-id backstop covers a static element", async () => {
const lint = await runAxe(wrapDocument(`<ul><li id="y">1</li><li id="y">2</li></ul>`));
if (!lint.violations) return; // no verdict: axe could not run here (see LintResult)
assert.deepEqual(lint.violations.map((v) => v.id), ["duplicate-id"]);
});
test("the duplicate-id backstop covers a focusable element", async () => {
// `duplicate-id` requires that NO element with the id is focusable, so two `<a id>`
// fall to `duplicate-id-active` β obsolete-tagged, therefore excluded by the WCAG tag
// filter and enabled by name.
const lint = await runAxe(wrapDocument(`<p><a id="x" href="/a">a</a><a id="x" href="/b">b</a></p>`));
if (!lint.violations) return; // no verdict: axe could not run here (see LintResult)
assert.deepEqual(lint.violations.map((v) => v.id), ["duplicate-id-active"]);
});
test("the duplicate-id backstop covers a referenced element, which axe reports as incomplete", async () => {
// The case with the clearest user harm, and the last one to be caught: an id that
// something REFERENCES belongs to neither obsolete rule (both require that the id is
// not an accessibility reference target) but to `duplicate-id-aria` β which is live
// WCAG 4.1.2 and in via the tag filter, yet `reviewOnFail`, so axe files it under
// `incomplete` and a gate reading only `violations` passes it. Two `<input id="q1">`
// under one `<label for="q1">` is exactly the shape assembly can produce, and exactly
// what makes a control announce the wrong name.
const lint = await runAxe(wrapDocument(`<form><label for="q1">A</label><input id="q1"><input id="q1"></form>`));
if (!lint.violations) return; // no verdict: axe could not run here (see LintResult)
assert.deepEqual(lint.violations.map((v) => v.id), ["duplicate-id-aria"]);
});
test("promoting duplicate-id-aria does not drag the rest of incomplete in with it", async () => {
// The promotion is scoped to one rule by name, because the rest of `incomplete` is
// genuinely can't-tell-without-rendering and promoting it would fail runs over
// nothing. `frame-title-unique` is the fixture: it is another `reviewOnFail` rule in
// the same tag set, and two same-titled iframes put it in `incomplete` β so widening
// the filter to all of `incomplete` shows up here as a failure. A document without an
// ambiguous id must stay clean.
const lint = await runAxe(wrapDocument(`<iframe title="Chart" src="a"></iframe><iframe title="Chart" src="b"></iframe>`));
if (!lint.violations) return; // no verdict: axe could not run here (see LintResult)
assert.equal(lint.violations.map((v) => v.id).join(", "), "", "an incomplete result other than duplicate-id-aria was promoted");
assert.equal(lint.ok, true);
});
test("the assembled document passes the gate that the colliding one fails", async () => {
// The same content, joined properly. Without this the test above would pass for a
// gate that fails everything, and the fix would be indistinguishable from a
// permanently red lint.
const lint = await runAxe(wrapDocument(assembleBody([frag(1, footnotePage(1)), frag(2, footnotePage(1))])));
if (!lint.violations) return; // no verdict: axe could not run here (see LintResult)
// Compared as a joined string, not with deepEqual: `runAxe` builds this array with
// the jsdom realm's `Array.prototype.map`, so it fails deepStrictEqual's prototype
// check against a literal `[]` even when both are empty.
assert.equal(lint.violations.map((v) => v.id).join(", "), "", "the namespaced document does not pass lint");
assert.equal(lint.ok, true);
});