testaro 78.4.0 → 78.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/workflows/publish.yml +5 -4
- package/CLAUDE.md +1 -1
- package/README.md +71 -1
- package/actSpecs-doc.md +1 -1
- package/actSpecs.js +2 -2
- package/dist/nameComputation.js +1 -0
- package/env.example +9 -4
- package/package.json +1 -1
- package/procs/config.d.ts +6 -0
- package/procs/config.js +36 -0
- package/procs/launch.js +6 -2
- package/tests/ibm.d.ts +5 -0
- package/tests/ibm.js +22 -13
- package/tests/ibm.ts +37 -1
- package/tests/qualWeb.d.ts +3 -3
- package/tests/qualWeb.js +190 -231
- package/tests/qualWeb.ts +203 -274
- package/validation/act/capture.js +485 -32
|
@@ -23,9 +23,12 @@
|
|
|
23
23
|
[--rules id,id] [--max N] [--out path.jsonl]
|
|
24
24
|
[--urls path.txt] [--recycle N]
|
|
25
25
|
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
26
|
+
First line is a header {header: true, capturedAt, mode, engines,
|
|
27
|
+
engineVersions, testaro, forkCommit, playwright}; scorers skip rows without
|
|
28
|
+
a testcaseId. Each further line: {testcaseId, ruleId, expected, engine,
|
|
29
|
+
prevented, instanceCount, outcomeTotals: {failed, cantTell},
|
|
30
|
+
asserted: {SC: count}, review: {SC: count}, ruleIDs: {engineRuleID: count},
|
|
31
|
+
ruleOutcomes: {engineRuleID: {failed, cantTell}}, ms}
|
|
29
32
|
`asserted` counts definite failures (standard-instance outcome `failed`);
|
|
30
33
|
`review` counts engine-flagged uncertainty (outcome `cantTell`). Criteria
|
|
31
34
|
are dotted WCAG SC numbers, extracted per engine from native results;
|
|
@@ -49,6 +52,50 @@ const CACHE_DIR = path.join(__dirname, 'cache');
|
|
|
49
52
|
const RESULTS_DIR = path.join(__dirname, 'results');
|
|
50
53
|
// Per-reporter time limit, matching the production limits in procs/doActs.js.
|
|
51
54
|
const REPORTER_TIMEOUT_MS = 45000;
|
|
55
|
+
/*
|
|
56
|
+
Failures that say nothing about the engine: the fixture host was
|
|
57
|
+
unreachable, or the browser wedged. Such a row is retried in place a few
|
|
58
|
+
times (fixtures are fetched live from w3.org, and a flaky uplink drops for
|
|
59
|
+
seconds at a time) and, if still failing, is recaptured by a later resumed
|
|
60
|
+
run instead of being treated as captured.
|
|
61
|
+
*/
|
|
62
|
+
const RETRYABLE_ERROR = /ERR_INTERNET_DISCONNECTED|ERR_NAME_NOT_RESOLVED|ERR_CONNECTION|ERR_NETWORK_CHANGED|ERR_TIMED_OUT|Timeout \d+ms exceeded|newPage deadline|reporter timeout|has been closed|Protocol error/;
|
|
63
|
+
const NETWORK_RETRIES = 3;
|
|
64
|
+
const NETWORK_RETRY_DELAY_MS = 15000;
|
|
65
|
+
// The testaro tool runs ~45 rules (screenshots, hover, motion) in its own
|
|
66
|
+
// browser: seconds on a fixture, minutes on a heavy real page.
|
|
67
|
+
const TESTARO_TIMEOUT_MS = 180000;
|
|
68
|
+
const TESTARO_URL_TIMEOUT_MS = 600000;
|
|
69
|
+
|
|
70
|
+
/*
|
|
71
|
+
The testaro tool is not a page-injected reporter: its rules launch and
|
|
72
|
+
navigate their own browser through procs/launch.js and validate the job
|
|
73
|
+
they are given. So it is run through doJob() with a minimal valid job, as
|
|
74
|
+
validation/validateTest.js does, and its test act is read back.
|
|
75
|
+
*/
|
|
76
|
+
let testaroJobCount = 0;
|
|
77
|
+
const testaroJob = (url, timeLimit = 120) => {
|
|
78
|
+
const stamp = new Date().toISOString().replace(/[-:]/g, '').replace(/^\d\d(\d{6})T(\d{4}).*$/, '$1T$2');
|
|
79
|
+
// The id names doJob's temporary directory, so it must be unique across
|
|
80
|
+
// fixtures and across concurrent capture processes.
|
|
81
|
+
testaroJobCount++;
|
|
82
|
+
return {
|
|
83
|
+
id: `${stamp}-act-${process.pid}-${testaroJobCount}`,
|
|
84
|
+
what: 'ACT fixture capture (testaro)',
|
|
85
|
+
strict: false,
|
|
86
|
+
standard: 'also',
|
|
87
|
+
observe: false,
|
|
88
|
+
device: {id: 'default', windowOptions: {reducedMotion: 'no-preference'}},
|
|
89
|
+
browserID: 'chromium',
|
|
90
|
+
timeLimit,
|
|
91
|
+
creationTimeStamp: stamp,
|
|
92
|
+
executionTimeStamp: stamp,
|
|
93
|
+
sendReportTo: '',
|
|
94
|
+
target: {what: 'ACT fixture', url},
|
|
95
|
+
sources: {script: 'act', batch: 'act', mergeID: 'act', requester: ''},
|
|
96
|
+
acts: [{type: 'test', which: 'testaro', withItems: true, stopOnFail: false}]
|
|
97
|
+
};
|
|
98
|
+
};
|
|
52
99
|
|
|
53
100
|
// Mirrors procs/doTestAct.js xPathNeeds for the engines this harness supports.
|
|
54
101
|
const XPATH_NEEDS = {
|
|
@@ -153,6 +200,216 @@ const ruleIDCounts = standardResult => {
|
|
|
153
200
|
return counts;
|
|
154
201
|
};
|
|
155
202
|
|
|
203
|
+
// Per-rule certainty bands: {ruleID: {failed, cantTell}} from instance outcomes.
|
|
204
|
+
// Lets a scorer join rules to ACT rule ids without a criterion extractor.
|
|
205
|
+
const ruleOutcomeCounts = standardResult => {
|
|
206
|
+
const bands = {};
|
|
207
|
+
((standardResult && standardResult.instances) || []).forEach(instance => {
|
|
208
|
+
const band = bands[instance.ruleID] ??= {failed: 0, cantTell: 0};
|
|
209
|
+
if (instance.outcome === 'failed' || instance.outcome === 'cantTell') {
|
|
210
|
+
band[instance.outcome] += instance.count || 1;
|
|
211
|
+
}
|
|
212
|
+
});
|
|
213
|
+
return bands;
|
|
214
|
+
};
|
|
215
|
+
|
|
216
|
+
/*
|
|
217
|
+
URL mode: one snapshot row per page, taken after every engine has run,
|
|
218
|
+
holding the DOM context of each element any engine flagged. Element
|
|
219
|
+
identity is the normalized XPath the engines already share, so one
|
|
220
|
+
snapshot serves every engine's instances (including the testaro tool,
|
|
221
|
+
whose own browser is gone by then). Bounded per page and per element.
|
|
222
|
+
*/
|
|
223
|
+
const SNAPSHOT_MAX_ELEMENTS = 400;
|
|
224
|
+
const SNAPSHOT_MAX_HTML = 4000;
|
|
225
|
+
// Elements per page for which the (slower) accessibility-tree snapshot is taken.
|
|
226
|
+
const SNAPSHOT_MAX_ARIA = 200;
|
|
227
|
+
/*
|
|
228
|
+
Snapshot profile version. v1: DOM context (outerHTML, two ancestor shells,
|
|
229
|
+
computed style subset, box, text) plus the accessibility invariant a
|
|
230
|
+
reducer must preserve, captured at source rather than reconstructed: the
|
|
231
|
+
element's own accessibility-tree snapshot (`aria`), the elements its IDREF
|
|
232
|
+
attributes point at (`refs`), and the structural context outside the
|
|
233
|
+
element itself (`context`: ancestor roles, table/list position).
|
|
234
|
+
*/
|
|
235
|
+
const SNAPSHOT_PROFILE_VERSION = 1;
|
|
236
|
+
const IDREF_ATTRIBUTES = [
|
|
237
|
+
'aria-labelledby', 'aria-describedby', 'aria-controls', 'aria-owns', 'aria-activedescendant',
|
|
238
|
+
'aria-flowto', 'aria-details', 'aria-errormessage', 'headers', 'for', 'list', 'form'
|
|
239
|
+
];
|
|
240
|
+
const snapshotScript = ([xPaths, maxHTML, idrefAttributes]) => {
|
|
241
|
+
const shell = element => {
|
|
242
|
+
const attrs = Array.from(element.attributes)
|
|
243
|
+
.map(attribute => `${attribute.name}="${attribute.value.slice(0, 120)}"`)
|
|
244
|
+
.join(' ');
|
|
245
|
+
return `<${element.tagName.toLowerCase()}${attrs ? ` ${attrs}` : ''}>`;
|
|
246
|
+
};
|
|
247
|
+
const styleKeys = [
|
|
248
|
+
'display', 'visibility', 'opacity', 'position', 'color', 'background-color',
|
|
249
|
+
'font-size', 'font-weight', 'line-height', 'letter-spacing', 'word-spacing', 'outline-style'
|
|
250
|
+
];
|
|
251
|
+
const snapshots = {};
|
|
252
|
+
for (const xPath of xPaths) {
|
|
253
|
+
try {
|
|
254
|
+
const element = document.evaluate(
|
|
255
|
+
xPath, document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null
|
|
256
|
+
).singleNodeValue;
|
|
257
|
+
if (! element || element.nodeType !== Node.ELEMENT_NODE) {
|
|
258
|
+
snapshots[xPath] = {missing: true};
|
|
259
|
+
continue;
|
|
260
|
+
}
|
|
261
|
+
const ancestors = [];
|
|
262
|
+
const ancestorRoles = [];
|
|
263
|
+
let parent = element.parentElement;
|
|
264
|
+
while (parent && parent !== document.documentElement) {
|
|
265
|
+
if (ancestors.length < 2) {
|
|
266
|
+
ancestors.unshift(shell(parent));
|
|
267
|
+
}
|
|
268
|
+
const role = parent.getAttribute('role');
|
|
269
|
+
const tag = parent.tagName.toLowerCase();
|
|
270
|
+
if (role || /^(main|nav|header|footer|aside|section|article|form|table|thead|tbody|tr|ul|ol|dl|fieldset|dialog|menu|figure|details)$/.test(tag)) {
|
|
271
|
+
ancestorRoles.unshift(role ? `${tag}[role=${role}]` : tag);
|
|
272
|
+
}
|
|
273
|
+
parent = parent.parentElement;
|
|
274
|
+
}
|
|
275
|
+
// Elements this element points at by IDREF: the part of the invariant that lives outside it.
|
|
276
|
+
const refs = {};
|
|
277
|
+
idrefAttributes.forEach(attribute => {
|
|
278
|
+
const value = element.getAttribute(attribute);
|
|
279
|
+
if (! value) {
|
|
280
|
+
return;
|
|
281
|
+
}
|
|
282
|
+
refs[attribute] = value.trim().split(/\s+/).slice(0, 8).map(id => {
|
|
283
|
+
const target = document.getElementById(id);
|
|
284
|
+
return target
|
|
285
|
+
? {id, html: target.outerHTML.slice(0, 400), text: (target.textContent || '').trim().slice(0, 120)}
|
|
286
|
+
: {id, missing: true};
|
|
287
|
+
});
|
|
288
|
+
});
|
|
289
|
+
// Structural context: table and list position, and whether the element labels something.
|
|
290
|
+
const context = {ancestorRoles};
|
|
291
|
+
const cell = element.closest('td, th');
|
|
292
|
+
if (cell) {
|
|
293
|
+
const row = cell.parentElement;
|
|
294
|
+
const table = cell.closest('table');
|
|
295
|
+
context.table = {
|
|
296
|
+
row: row ? Array.from(row.parentElement.children).indexOf(row) : -1,
|
|
297
|
+
col: Array.from(row ? row.children : []).indexOf(cell),
|
|
298
|
+
rows: table ? table.rows.length : 0,
|
|
299
|
+
scope: cell.getAttribute('scope') || '',
|
|
300
|
+
headers: cell.getAttribute('headers') || ''
|
|
301
|
+
};
|
|
302
|
+
}
|
|
303
|
+
const item = element.closest('li, option, [role=listitem], [role=option], [role=menuitem], [role=tab], [role=treeitem]');
|
|
304
|
+
if (item && item.parentElement) {
|
|
305
|
+
context.set = {
|
|
306
|
+
index: Array.from(item.parentElement.children).indexOf(item),
|
|
307
|
+
size: item.parentElement.children.length,
|
|
308
|
+
container: shell(item.parentElement)
|
|
309
|
+
};
|
|
310
|
+
}
|
|
311
|
+
if (element.id) {
|
|
312
|
+
const referrers = Array.from(document.querySelectorAll(
|
|
313
|
+
`[aria-labelledby~="${element.id}"], [aria-describedby~="${element.id}"], [headers~="${element.id}"], label[for="${element.id}"]`
|
|
314
|
+
)).slice(0, 8).map(shell);
|
|
315
|
+
if (referrers.length) {
|
|
316
|
+
context.referencedBy = referrers;
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
const computed = getComputedStyle(element);
|
|
320
|
+
const style = {};
|
|
321
|
+
styleKeys.forEach(key => {
|
|
322
|
+
style[key] = computed.getPropertyValue(key);
|
|
323
|
+
});
|
|
324
|
+
const rect = element.getBoundingClientRect();
|
|
325
|
+
const outerHTML = element.outerHTML;
|
|
326
|
+
snapshots[xPath] = {
|
|
327
|
+
outerHTML: outerHTML.length > maxHTML ? `${outerHTML.slice(0, maxHTML)} …` : outerHTML,
|
|
328
|
+
truncated: outerHTML.length > maxHTML,
|
|
329
|
+
ancestors,
|
|
330
|
+
style,
|
|
331
|
+
box: [rect.x, rect.y, rect.width, rect.height].map(Math.round),
|
|
332
|
+
text: (element.textContent || '').trim().slice(0, 200),
|
|
333
|
+
refs,
|
|
334
|
+
context
|
|
335
|
+
};
|
|
336
|
+
}
|
|
337
|
+
catch(error) {
|
|
338
|
+
snapshots[xPath] = {error: String(error && error.message || error).slice(0, 120)};
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
return snapshots;
|
|
342
|
+
};
|
|
343
|
+
// Adds each element's accessibility-tree snapshot (role, name, states, children) to its snapshot.
|
|
344
|
+
const addAriaSnapshots = async (page, snapshots, xPaths) => {
|
|
345
|
+
for (const xPath of xPaths.slice(0, SNAPSHOT_MAX_ARIA)) {
|
|
346
|
+
const snapshot = snapshots[xPath];
|
|
347
|
+
if (! snapshot || snapshot.missing || snapshot.error) {
|
|
348
|
+
continue;
|
|
349
|
+
}
|
|
350
|
+
try {
|
|
351
|
+
snapshot.aria = await page.locator(`xpath=${xPath}`).first().ariaSnapshot({timeout: 2000});
|
|
352
|
+
}
|
|
353
|
+
catch(error) {
|
|
354
|
+
snapshot.ariaError = String(error && error.message || error).split('\n')[0].slice(0, 120);
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
};
|
|
358
|
+
|
|
359
|
+
// Versions of the engines in play, for the capture header row.
|
|
360
|
+
const engineVersions = engines => {
|
|
361
|
+
const packages = {
|
|
362
|
+
alfa: '@siteimprove/alfa-rules',
|
|
363
|
+
aslint: 'aslint',
|
|
364
|
+
axe: 'axe-core',
|
|
365
|
+
ed11y: 'editoria11y',
|
|
366
|
+
htmlcs: 'html_codesniffer',
|
|
367
|
+
ibm: 'accessibility-checker',
|
|
368
|
+
qualWeb: '@qualweb/act-rules'
|
|
369
|
+
};
|
|
370
|
+
const versions = {};
|
|
371
|
+
const root = path.join(__dirname, '..', '..');
|
|
372
|
+
engines.forEach(engine => {
|
|
373
|
+
try {
|
|
374
|
+
if (packages[engine]) {
|
|
375
|
+
// Read from disk: some packages' `exports` maps block require() of package.json.
|
|
376
|
+
const packageJSON = path.join(root, 'node_modules', packages[engine], 'package.json');
|
|
377
|
+
versions[engine] = JSON.parse(fs.readFileSync(packageJSON, 'utf8')).version;
|
|
378
|
+
}
|
|
379
|
+
else if (['pour', 'surea11y'].includes(engine)) {
|
|
380
|
+
const readme = fs.readFileSync(path.join(root, engine, 'README.md'), 'utf8');
|
|
381
|
+
const match = readme.match(/\b(\d+\.\d+\.\d+)\b/);
|
|
382
|
+
versions[engine] = match ? match[1] : 'unknown';
|
|
383
|
+
}
|
|
384
|
+
else if (engine === 'testaro') {
|
|
385
|
+
versions[engine] = require('../../package.json').version;
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
catch(error) {
|
|
389
|
+
versions[engine] = 'unknown';
|
|
390
|
+
}
|
|
391
|
+
});
|
|
392
|
+
return versions;
|
|
393
|
+
};
|
|
394
|
+
|
|
395
|
+
/*
|
|
396
|
+
The ibm engine version is the one that actually runs: by default IBM's deployed rule archive
|
|
397
|
+
("latest"), whose engine can be older than the package's own (4.0.27 under accessibility-checker
|
|
398
|
+
4.0.33 on 2026-09-23), or with IBM_ENGINE=package the engine released with the checker
|
|
399
|
+
(tests/ibm.ts, ibmEngine).
|
|
400
|
+
*/
|
|
401
|
+
const ibmRuleArchive = async () => {
|
|
402
|
+
try {
|
|
403
|
+
// Applies IBM_ENGINE first, so the header records the engine that will run.
|
|
404
|
+
const {mode, version} = await require('../../tests/ibm').ibmEngine();
|
|
405
|
+
const {ruleArchive} = await require('accessibility-checker').getConfig();
|
|
406
|
+
return {id: mode === 'package' ? 'package' : ruleArchive, version, mode};
|
|
407
|
+
}
|
|
408
|
+
catch(error) {
|
|
409
|
+
return null;
|
|
410
|
+
}
|
|
411
|
+
};
|
|
412
|
+
|
|
156
413
|
// FUNCTIONS
|
|
157
414
|
|
|
158
415
|
// Parses CLI arguments of the form --name value.
|
|
@@ -207,6 +464,55 @@ const getXPathScript = () => {
|
|
|
207
464
|
|
|
208
465
|
// OPERATION
|
|
209
466
|
|
|
467
|
+
/*
|
|
468
|
+
Races a promise against a deadline and clears the timer once either settles.
|
|
469
|
+
An uncleared timer keeps the event loop alive: each Testaro job and engine
|
|
470
|
+
reporter used to leave one behind, so a finished capture lingered until the
|
|
471
|
+
last fired (up to 3 minutes for fixtures, 10 in URL mode). `message` keeps
|
|
472
|
+
the error text the resume logic matches (RETRYABLE_ERROR).
|
|
473
|
+
*/
|
|
474
|
+
const withDeadline = (promise, ms, label, message = `${label} deadline ${ms}ms`) => {
|
|
475
|
+
let timer;
|
|
476
|
+
const deadline = new Promise((resolve, reject) => {
|
|
477
|
+
timer = setTimeout(() => reject(new Error(message)), ms);
|
|
478
|
+
});
|
|
479
|
+
return Promise.race([promise, deadline]).finally(() => clearTimeout(timer));
|
|
480
|
+
};
|
|
481
|
+
|
|
482
|
+
/*
|
|
483
|
+
Stop on SIGTERM or SIGINT. Playwright's browser launcher registers listeners
|
|
484
|
+
for both that close its browsers but don't end the process, so a plain kill
|
|
485
|
+
used to be ignored. Now the capture stops once the (testcase, engine) run in
|
|
486
|
+
progress has been recorded (as a retryable prevention, if its browser was
|
|
487
|
+
just closed under it), writes out, and exits; rerunning the same command
|
|
488
|
+
resumes. A second signal exits at once.
|
|
489
|
+
*/
|
|
490
|
+
let stopSignal = null;
|
|
491
|
+
for (const signal of ['SIGTERM', 'SIGINT']) {
|
|
492
|
+
process.on(signal, () => {
|
|
493
|
+
if (stopSignal) {
|
|
494
|
+
console.log(`${signal} again: exiting now`);
|
|
495
|
+
process.exit(signal === 'SIGINT' ? 130 : 143);
|
|
496
|
+
}
|
|
497
|
+
stopSignal = signal;
|
|
498
|
+
console.log(`${signal}: stopping after the current engine run`);
|
|
499
|
+
});
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
// A tool's stray promise (e.g. a playwright-extra CDP session settling after
|
|
503
|
+
// the page is closed) must not abort a multi-hour capture; the row's own
|
|
504
|
+
// try/catch and the browser-replacement heuristics handle the page.
|
|
505
|
+
process.on('unhandledRejection', reason => {
|
|
506
|
+
const message = reason && reason.message ? reason.message : String(reason);
|
|
507
|
+
if (/cdpSession|Target page, context or browser has been closed/.test(message)) {
|
|
508
|
+
console.log(`WARNING: unhandled rejection (${message})`);
|
|
509
|
+
return;
|
|
510
|
+
}
|
|
511
|
+
// Anything else is a bug in the harness: fail loudly rather than hang.
|
|
512
|
+
console.error(`ERROR: unhandled rejection (${message})`);
|
|
513
|
+
process.exit(1);
|
|
514
|
+
});
|
|
515
|
+
|
|
210
516
|
(async () => {
|
|
211
517
|
const args = parseArgs(process.argv);
|
|
212
518
|
const engines = (args.engines || 'pour').split(',');
|
|
@@ -236,13 +542,38 @@ const getXPathScript = () => {
|
|
|
236
542
|
if (args.max) {
|
|
237
543
|
testcases = testcases.slice(0, Number(args.max));
|
|
238
544
|
}
|
|
239
|
-
// Resume support: skip (testcase, engine) pairs already in the out file
|
|
545
|
+
// Resume support: skip (testcase, engine) pairs already in the out file,
|
|
546
|
+
// except rows prevented for a retryable reason — those are recaptured, and
|
|
547
|
+
// a scorer keeps the last row per pair.
|
|
240
548
|
const alreadyCaptured = new Set();
|
|
549
|
+
// URL mode: flagged XPaths from rows captured by earlier runs, so a resumed
|
|
550
|
+
// page still gets its snapshot row (its engine rows are skipped).
|
|
551
|
+
const priorFlagged = new Map();
|
|
241
552
|
if (fs.existsSync(outPath)) {
|
|
242
553
|
fs.readFileSync(outPath, 'utf8').split('\n').filter(Boolean).forEach(line => {
|
|
243
554
|
try {
|
|
244
555
|
const row = JSON.parse(line);
|
|
245
|
-
|
|
556
|
+
if (! row.testcaseId) {
|
|
557
|
+
return;
|
|
558
|
+
}
|
|
559
|
+
// Fixture ids are content hashes shared across rules (e.g. the AA and
|
|
560
|
+
// AAA contrast rules), so the rule is part of the identity.
|
|
561
|
+
const key = `${row.ruleId || ''}/${row.testcaseId}:${row.engine}`;
|
|
562
|
+
if (row.prevented && RETRYABLE_ERROR.test(row.error || '')) {
|
|
563
|
+
alreadyCaptured.delete(key);
|
|
564
|
+
}
|
|
565
|
+
else {
|
|
566
|
+
alreadyCaptured.add(key);
|
|
567
|
+
}
|
|
568
|
+
if (Array.isArray(row.instances)) {
|
|
569
|
+
const flagged = priorFlagged.get(row.testcaseId) || new Set();
|
|
570
|
+
row.instances.forEach(instance => {
|
|
571
|
+
if (instance.xPath && instance.outcome === 'failed') {
|
|
572
|
+
flagged.add(instance.xPath);
|
|
573
|
+
}
|
|
574
|
+
});
|
|
575
|
+
priorFlagged.set(row.testcaseId, flagged);
|
|
576
|
+
}
|
|
246
577
|
}
|
|
247
578
|
catch(error) {}
|
|
248
579
|
});
|
|
@@ -252,6 +583,34 @@ const getXPathScript = () => {
|
|
|
252
583
|
+ (alreadyCaptured.size ? ` (resuming; ${alreadyCaptured.size} rows already captured)` : '')
|
|
253
584
|
);
|
|
254
585
|
const out = fs.createWriteStream(outPath, {flags: 'a'});
|
|
586
|
+
// Header row (no testcaseId) pinning what produced a fresh capture; scorers
|
|
587
|
+
// skip it, and a resumed capture keeps the original header.
|
|
588
|
+
if (! alreadyCaptured.size) {
|
|
589
|
+
let forkCommit = 'unknown';
|
|
590
|
+
try {
|
|
591
|
+
forkCommit = require('child_process')
|
|
592
|
+
.execFileSync('git', ['rev-parse', 'HEAD'], {cwd: path.join(__dirname, '..', '..'), encoding: 'utf8'})
|
|
593
|
+
.trim();
|
|
594
|
+
}
|
|
595
|
+
catch(error) {}
|
|
596
|
+
const versions = engineVersions(engines);
|
|
597
|
+
const ibmArchive = engines.includes('ibm') ? await ibmRuleArchive() : null;
|
|
598
|
+
if (ibmArchive) {
|
|
599
|
+
ibmArchive.checker = versions.ibm;
|
|
600
|
+
versions.ibm = ibmArchive.version;
|
|
601
|
+
}
|
|
602
|
+
out.write(`${JSON.stringify({
|
|
603
|
+
header: true,
|
|
604
|
+
capturedAt: new Date().toISOString(),
|
|
605
|
+
mode: args.urls ? 'urls' : 'act-fixtures',
|
|
606
|
+
engines,
|
|
607
|
+
engineVersions: versions,
|
|
608
|
+
...(ibmArchive ? {ibmRuleArchive: ibmArchive} : {}),
|
|
609
|
+
testaro: require('../../package.json').version,
|
|
610
|
+
forkCommit,
|
|
611
|
+
playwright: require('playwright/package.json').version
|
|
612
|
+
})}\n`);
|
|
613
|
+
}
|
|
255
614
|
/*
|
|
256
615
|
The browser is recycled every RECYCLE_EVERY testcases: a 2026-08-22 full
|
|
257
616
|
run showed script-tag injection silently failing (tool global never
|
|
@@ -272,12 +631,6 @@ const getXPathScript = () => {
|
|
|
272
631
|
replaced (the old one is closed with its own deadline and abandoned if
|
|
273
632
|
that hangs too).
|
|
274
633
|
*/
|
|
275
|
-
const withDeadline = (promise, ms, label) => Promise.race([
|
|
276
|
-
promise,
|
|
277
|
-
new Promise((resolve, reject) => setTimeout(
|
|
278
|
-
() => reject(new Error(`${label} deadline ${ms}ms`)), ms
|
|
279
|
-
))
|
|
280
|
-
]);
|
|
281
634
|
const replaceBrowser = async () => {
|
|
282
635
|
const oldBrowser = browser;
|
|
283
636
|
withDeadline(oldBrowser.close(), 10000, 'browser.close').catch(() => {});
|
|
@@ -285,11 +638,19 @@ const getXPathScript = () => {
|
|
|
285
638
|
context = await browser.newContext();
|
|
286
639
|
};
|
|
287
640
|
for (const testcase of testcases) {
|
|
641
|
+
if (stopSignal) {
|
|
642
|
+
break;
|
|
643
|
+
}
|
|
288
644
|
if (done > 0 && RECYCLE_EVERY && done % RECYCLE_EVERY === 0) {
|
|
289
645
|
await replaceBrowser();
|
|
290
646
|
}
|
|
647
|
+
// XPaths flagged on this page by any engine, for the URL-mode snapshot row.
|
|
648
|
+
const flaggedXPaths = new Set(priorFlagged.get(testcase.testcaseId) || []);
|
|
291
649
|
for (const engine of engines) {
|
|
292
|
-
if (
|
|
650
|
+
if (stopSignal) {
|
|
651
|
+
break;
|
|
652
|
+
}
|
|
653
|
+
if (alreadyCaptured.has(`${testcase.ruleId || ''}/${testcase.testcaseId}:${engine}`)) {
|
|
293
654
|
continue;
|
|
294
655
|
}
|
|
295
656
|
const row = {
|
|
@@ -308,7 +669,32 @@ const getXPathScript = () => {
|
|
|
308
669
|
if (xPathNeed === 'script' || xPathNeed === 'attribute') {
|
|
309
670
|
await page.addInitScript(getXPathScript);
|
|
310
671
|
}
|
|
311
|
-
|
|
672
|
+
// Navigate, waiting out a dropped uplink rather than recording it as a result.
|
|
673
|
+
for (let attempt = 0; ; attempt++) {
|
|
674
|
+
try {
|
|
675
|
+
await page.goto(testcase.url, {waitUntil: 'load', timeout: args.urls ? 60000 : 20000});
|
|
676
|
+
break;
|
|
677
|
+
}
|
|
678
|
+
catch(error) {
|
|
679
|
+
if (attempt < NETWORK_RETRIES && /ERR_INTERNET_DISCONNECTED|ERR_NAME_NOT_RESOLVED|ERR_CONNECTION|ERR_NETWORK_CHANGED/.test(error.message)) {
|
|
680
|
+
console.log(`WARNING: network error on ${testcase.testcaseId}; retrying in ${NETWORK_RETRY_DELAY_MS / 1000}s`);
|
|
681
|
+
await new Promise(resolve => setTimeout(resolve, NETWORK_RETRY_DELAY_MS));
|
|
682
|
+
continue;
|
|
683
|
+
}
|
|
684
|
+
throw error;
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
/*
|
|
688
|
+
Chromium shows an XML file it cannot render as a document (e.g. a <math> root with no
|
|
689
|
+
namespace) in its XML tree viewer, an HTML page of its own; the engines would test that
|
|
690
|
+
page instead of the fixture.
|
|
691
|
+
*/
|
|
692
|
+
const inXMLViewer = await page.evaluate(
|
|
693
|
+
() => !! document.getElementById('webkit-xml-viewer-source-xml')
|
|
694
|
+
).catch(() => false);
|
|
695
|
+
if (inXMLViewer) {
|
|
696
|
+
throw new Error('fixture not renderable as a document in Chromium (XML viewer)');
|
|
697
|
+
}
|
|
312
698
|
if (xPathNeed === 'attribute') {
|
|
313
699
|
// Stamp data-xpath attributes, as procs/launch.js does.
|
|
314
700
|
await withDeadline(page.evaluate(() => {
|
|
@@ -326,19 +712,36 @@ const getXPathScript = () => {
|
|
|
326
712
|
const nonced = document.querySelector('script[nonce]');
|
|
327
713
|
return (nonced && nonced.nonce) || '';
|
|
328
714
|
}).catch(() => '');
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
715
|
+
let report;
|
|
716
|
+
let actReport;
|
|
717
|
+
if (engine === 'testaro') {
|
|
718
|
+
const {doJob} = require('../../run');
|
|
719
|
+
report = await withDeadline(
|
|
720
|
+
doJob(testaroJob(testcase.url, args.urls ? 540 : 120)),
|
|
721
|
+
args.urls ? TESTARO_URL_TIMEOUT_MS : TESTARO_TIMEOUT_MS,
|
|
722
|
+
'testaro',
|
|
723
|
+
'reporter timeout'
|
|
724
|
+
);
|
|
725
|
+
actReport = report.jobData && report.jobData.aborted
|
|
726
|
+
? {data: {prevented: true, error: report.jobData.abortMessage || 'job aborted'}}
|
|
727
|
+
: report.acts.find(jobAct => jobAct.type === 'test')
|
|
728
|
+
|| {data: {prevented: true, error: 'no test act in job report'}};
|
|
729
|
+
}
|
|
730
|
+
else {
|
|
731
|
+
report = {
|
|
732
|
+
standard: 'also',
|
|
733
|
+
jobData: scriptNonce ? {lastScriptNonce: scriptNonce} : {},
|
|
734
|
+
catalog: {},
|
|
735
|
+
target: {url: testcase.url},
|
|
736
|
+
acts: [{type: 'test', which: engine, withItems: true}]
|
|
737
|
+
};
|
|
738
|
+
actReport = await withDeadline(
|
|
739
|
+
require(`../../tests/${engine}`).reporter(page, report, 0, 40),
|
|
740
|
+
REPORTER_TIMEOUT_MS,
|
|
741
|
+
engine,
|
|
742
|
+
'reporter timeout'
|
|
743
|
+
);
|
|
744
|
+
}
|
|
342
745
|
const {data, result} = actReport;
|
|
343
746
|
row.prevented = !! (data && data.prevented);
|
|
344
747
|
if (row.prevented) {
|
|
@@ -348,6 +751,7 @@ const getXPathScript = () => {
|
|
|
348
751
|
row.instanceCount = result.standardResult.instances.length;
|
|
349
752
|
row.outcomeTotals = result.standardResult.outcomeTotals;
|
|
350
753
|
row.ruleIDs = ruleIDCounts(result.standardResult);
|
|
754
|
+
row.ruleOutcomes = ruleOutcomeCounts(result.standardResult);
|
|
351
755
|
const unoutcomed = result.standardResult.instances
|
|
352
756
|
.filter(instance => ! OUTCOMES.includes(instance.outcome)).length;
|
|
353
757
|
if (unoutcomed) {
|
|
@@ -368,8 +772,13 @@ const getXPathScript = () => {
|
|
|
368
772
|
severity: instance.ordinalSeverity,
|
|
369
773
|
outcome: instance.outcome,
|
|
370
774
|
uncertainty: instance.uncertainty,
|
|
371
|
-
xPath: (report.catalog[instance.catalogIndex] || {}).pathID || ''
|
|
775
|
+
xPath: ((report.catalog || {})[instance.catalogIndex] || {}).pathID || ''
|
|
372
776
|
}));
|
|
777
|
+
row.instances.forEach(instance => {
|
|
778
|
+
if (instance.xPath && instance.outcome === 'failed') {
|
|
779
|
+
flaggedXPaths.add(instance.xPath);
|
|
780
|
+
}
|
|
781
|
+
});
|
|
373
782
|
}
|
|
374
783
|
}
|
|
375
784
|
}
|
|
@@ -395,7 +804,7 @@ const getXPathScript = () => {
|
|
|
395
804
|
rows fail before the next scheduled recycle).
|
|
396
805
|
*/
|
|
397
806
|
if (
|
|
398
|
-
closeHung
|
|
807
|
+
! stopSignal && (closeHung
|
|
399
808
|
|| (row.error && /deadline|has been closed|Protocol error/.test(row.error))
|
|
400
809
|
/*
|
|
401
810
|
Canary: a script-tag-injecting tool reporting its global undefined
|
|
@@ -405,7 +814,7 @@ const getXPathScript = () => {
|
|
|
405
814
|
consecutive rows in a browser only ~36 pages old). XML pages are
|
|
406
815
|
excluded: script elements legitimately never execute there.
|
|
407
816
|
*/
|
|
408
|
-
|| (row.error && /global not defined \(contentType text\/html/.test(row.error))
|
|
817
|
+
|| (row.error && /global not defined \(contentType text\/html/.test(row.error)))
|
|
409
818
|
) {
|
|
410
819
|
row.browserReplaced = true;
|
|
411
820
|
await replaceBrowser();
|
|
@@ -413,12 +822,56 @@ const getXPathScript = () => {
|
|
|
413
822
|
row.ms = Date.now() - started;
|
|
414
823
|
out.write(`${JSON.stringify(row)}\n`);
|
|
415
824
|
}
|
|
825
|
+
// A stop mid-testcase leaves its remaining engines (and snapshot) to a resumed run.
|
|
826
|
+
if (stopSignal) {
|
|
827
|
+
break;
|
|
828
|
+
}
|
|
829
|
+
// URL mode: snapshot the flagged elements once per page.
|
|
830
|
+
if (args.urls && flaggedXPaths.size && ! alreadyCaptured.has(`${testcase.ruleId || ''}/${testcase.testcaseId}:_snapshot`)) {
|
|
831
|
+
const snapshotRow = {testcaseId: testcase.testcaseId, engine: '_snapshot', prevented: false};
|
|
832
|
+
const started = Date.now();
|
|
833
|
+
let page;
|
|
834
|
+
try {
|
|
835
|
+
page = await withDeadline(context.newPage(), 20000, 'newPage');
|
|
836
|
+
await page.goto(testcase.url, {waitUntil: 'load', timeout: 30000});
|
|
837
|
+
const xPaths = [...flaggedXPaths].slice(0, SNAPSHOT_MAX_ELEMENTS);
|
|
838
|
+
snapshotRow.snapshots = await withDeadline(
|
|
839
|
+
page.evaluate(snapshotScript, [xPaths, SNAPSHOT_MAX_HTML, IDREF_ATTRIBUTES]), 30000, 'snapshot'
|
|
840
|
+
);
|
|
841
|
+
await withDeadline(addAriaSnapshots(page, snapshotRow.snapshots, xPaths), 120000, 'ariaSnapshot');
|
|
842
|
+
snapshotRow.profileVersion = SNAPSHOT_PROFILE_VERSION;
|
|
843
|
+
snapshotRow.flaggedCount = flaggedXPaths.size;
|
|
844
|
+
snapshotRow.capped = flaggedXPaths.size > SNAPSHOT_MAX_ELEMENTS;
|
|
845
|
+
}
|
|
846
|
+
catch(error) {
|
|
847
|
+
snapshotRow.prevented = true;
|
|
848
|
+
snapshotRow.error = error.message.slice(0, 200);
|
|
849
|
+
}
|
|
850
|
+
finally {
|
|
851
|
+
if (page) {
|
|
852
|
+
await withDeadline(page.close(), 5000, 'page.close').catch(() => {});
|
|
853
|
+
}
|
|
854
|
+
}
|
|
855
|
+
snapshotRow.ms = Date.now() - started;
|
|
856
|
+
out.write(`${JSON.stringify(snapshotRow)}\n`);
|
|
857
|
+
}
|
|
416
858
|
done++;
|
|
417
859
|
if (done % 25 === 0) {
|
|
418
860
|
console.log(`${done}/${testcases.length}`);
|
|
419
861
|
}
|
|
420
862
|
}
|
|
421
|
-
await browser.close();
|
|
422
|
-
out.end();
|
|
423
|
-
|
|
863
|
+
await withDeadline(browser.close(), 10000, 'browser.close').catch(() => {});
|
|
864
|
+
await new Promise(resolve => out.end(resolve));
|
|
865
|
+
if (stopSignal) {
|
|
866
|
+
console.log(`Stopped (${stopSignal}) after ${done} testcases → ${outPath}; rerun the same command to resume`);
|
|
867
|
+
}
|
|
868
|
+
else {
|
|
869
|
+
console.log(`Done: ${done} testcases → ${outPath}`);
|
|
870
|
+
}
|
|
871
|
+
/*
|
|
872
|
+
Exit explicitly: code under test (Testaro's own jobs, engine adapters) can
|
|
873
|
+
leave handles that would otherwise hold the process open after the output
|
|
874
|
+
is complete.
|
|
875
|
+
*/
|
|
876
|
+
process.exit(stopSignal ? (stopSignal === 'SIGINT' ? 130 : 143) : 0);
|
|
424
877
|
})();
|