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.
@@ -23,9 +23,12 @@
23
23
  [--rules id,id] [--max N] [--out path.jsonl]
24
24
  [--urls path.txt] [--recycle N]
25
25
 
26
- Each output line: {testcaseId, ruleId, expected, engine, prevented,
27
- instanceCount, outcomeTotals: {failed, cantTell}, asserted: {SC: count},
28
- review: {SC: count}, ruleIDs: {engineRuleID: count}, ms}
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
- alreadyCaptured.add(`${row.testcaseId}:${row.engine}`);
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 (alreadyCaptured.has(`${testcase.testcaseId}:${engine}`)) {
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
- await page.goto(testcase.url, {waitUntil: 'load', timeout: args.urls ? 30000 : 20000});
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
- const report = {
330
- standard: 'also',
331
- jobData: scriptNonce ? {lastScriptNonce: scriptNonce} : {},
332
- catalog: {},
333
- target: {url: testcase.url},
334
- acts: [{type: 'test', which: engine, withItems: true}]
335
- };
336
- const actReport = await Promise.race([
337
- require(`../../tests/${engine}`).reporter(page, report, 0, 40),
338
- new Promise((resolve, reject) => setTimeout(
339
- () => reject(new Error('reporter timeout')), REPORTER_TIMEOUT_MS
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
- console.log(`Done: ${done} testcases → ${outPath}`);
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
  })();