• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
1'use strict';
2
3const assert = require('assert');
4const fixtures = require('../common/fixtures');
5const fs = require('fs');
6const fsPromises = fs.promises;
7const path = require('path');
8const events = require('events');
9const os = require('os');
10const { inspect } = require('util');
11const { Worker } = require('worker_threads');
12
13function getBrowserProperties() {
14  const { node: version } = process.versions; // e.g. 18.13.0, 20.0.0-nightly202302078e6e215481
15  const release = /^\d+\.\d+\.\d+$/.test(version);
16  const browser = {
17    browser_channel: release ? 'stable' : 'experimental',
18    browser_version: version,
19  };
20
21  return browser;
22}
23
24/**
25 * Return one of three expected values
26 * https://github.com/web-platform-tests/wpt/blob/1c6ff12/tools/wptrunner/wptrunner/tests/test_update.py#L953-L958
27 */
28function getOs() {
29  switch (os.type()) {
30    case 'Linux':
31      return 'linux';
32    case 'Darwin':
33      return 'mac';
34    case 'Windows_NT':
35      return 'win';
36    default:
37      throw new Error('Unsupported os.type()');
38  }
39}
40
41// https://github.com/web-platform-tests/wpt/blob/b24eedd/resources/testharness.js#L3705
42function sanitizeUnpairedSurrogates(str) {
43  return str.replace(
44    /([\ud800-\udbff]+)(?![\udc00-\udfff])|(^|[^\ud800-\udbff])([\udc00-\udfff]+)/g,
45    function(_, low, prefix, high) {
46      let output = prefix || '';  // Prefix may be undefined
47      const string = low || high;  // Only one of these alternates can match
48      for (let i = 0; i < string.length; i++) {
49        output += codeUnitStr(string[i]);
50      }
51      return output;
52    });
53}
54
55function codeUnitStr(char) {
56  return 'U+' + char.charCodeAt(0).toString(16);
57}
58
59class WPTReport {
60  constructor() {
61    this.results = [];
62    this.time_start = Date.now();
63  }
64
65  addResult(name, status) {
66    const result = {
67      test: name,
68      status,
69      subtests: [],
70      addSubtest(name, status, message) {
71        const subtest = {
72          status,
73          // https://github.com/web-platform-tests/wpt/blob/b24eedd/resources/testharness.js#L3722
74          name: sanitizeUnpairedSurrogates(name),
75        };
76        if (message) {
77          // https://github.com/web-platform-tests/wpt/blob/b24eedd/resources/testharness.js#L4506
78          subtest.message = sanitizeUnpairedSurrogates(message);
79        }
80        this.subtests.push(subtest);
81        return subtest;
82      },
83    };
84    this.results.push(result);
85    return result;
86  }
87
88  write() {
89    this.time_end = Date.now();
90    this.results = this.results.filter((result) => {
91      return result.status === 'SKIP' || result.subtests.length !== 0;
92    }).map((result) => {
93      const url = new URL(result.test, 'http://wpt');
94      url.pathname = url.pathname.replace(/\.js$/, '.html');
95      result.test = url.href.slice(url.origin.length);
96      return result;
97    });
98
99    if (fs.existsSync('out/wpt/wptreport.json')) {
100      const prev = JSON.parse(fs.readFileSync('out/wpt/wptreport.json'));
101      this.results = [...prev.results, ...this.results];
102      this.time_start = prev.time_start;
103      this.time_end = Math.max(this.time_end, prev.time_end);
104      this.run_info = prev.run_info;
105    } else {
106      /**
107       * Return required and some optional properties
108       * https://github.com/web-platform-tests/wpt.fyi/blob/60da175/api/README.md?plain=1#L331-L335
109       */
110      this.run_info = {
111        product: 'node.js',
112        ...getBrowserProperties(),
113        revision: process.env.WPT_REVISION || 'unknown',
114        os: getOs(),
115      };
116    }
117
118    fs.writeFileSync('out/wpt/wptreport.json', JSON.stringify(this));
119  }
120}
121
122// https://github.com/web-platform-tests/wpt/blob/HEAD/resources/testharness.js
123// TODO: get rid of this half-baked harness in favor of the one
124// pulled from WPT
125const harnessMock = {
126  test: (fn, desc) => {
127    try {
128      fn();
129    } catch (err) {
130      console.error(`In ${desc}:`);
131      throw err;
132    }
133  },
134  assert_equals: assert.strictEqual,
135  assert_true: (value, message) => assert.strictEqual(value, true, message),
136  assert_false: (value, message) => assert.strictEqual(value, false, message),
137  assert_throws: (code, func, desc) => {
138    assert.throws(func, function(err) {
139      return typeof err === 'object' &&
140             'name' in err &&
141             err.name.startsWith(code.name);
142    }, desc);
143  },
144  assert_array_equals: assert.deepStrictEqual,
145  assert_unreached(desc) {
146    assert.fail(`Reached unreachable code: ${desc}`);
147  },
148};
149
150class ResourceLoader {
151  constructor(path) {
152    this.path = path;
153  }
154
155  toRealFilePath(from, url) {
156    // We need to patch this to load the WebIDL parser
157    url = url.replace(
158      '/resources/WebIDLParser.js',
159      '/resources/webidl2/lib/webidl2.js',
160    );
161    const base = path.dirname(from);
162    return url.startsWith('/') ?
163      fixtures.path('wpt', url) :
164      fixtures.path('wpt', base, url);
165  }
166
167  /**
168   * Load a resource in test/fixtures/wpt specified with a URL
169   * @param {string} from the path of the file loading this resource,
170   *                      relative to the WPT folder.
171   * @param {string} url the url of the resource being loaded.
172   * @param {boolean} asFetch if true, return the resource in a
173   *                          pseudo-Response object.
174   */
175  read(from, url, asFetch = true) {
176    const file = this.toRealFilePath(from, url);
177    if (asFetch) {
178      return fsPromises.readFile(file)
179        .then((data) => {
180          return {
181            ok: true,
182            json() { return JSON.parse(data.toString()); },
183            text() { return data.toString(); },
184          };
185        });
186    }
187    return fs.readFileSync(file, 'utf8');
188  }
189}
190
191class StatusRule {
192  constructor(key, value, pattern) {
193    this.key = key;
194    this.requires = value.requires || [];
195    this.fail = value.fail;
196    this.skip = value.skip;
197    if (pattern) {
198      this.pattern = this.transformPattern(pattern);
199    }
200    // TODO(joyeecheung): implement this
201    this.scope = value.scope;
202    this.comment = value.comment;
203  }
204
205  /**
206   * Transform a filename pattern into a RegExp
207   * @param {string} pattern
208   * @returns {RegExp}
209   */
210  transformPattern(pattern) {
211    const result = path.normalize(pattern).replace(/[-/\\^$+?.()|[\]{}]/g, '\\$&');
212    return new RegExp(result.replace('*', '.*'));
213  }
214}
215
216class StatusRuleSet {
217  constructor() {
218    // We use two sets of rules to speed up matching
219    this.exactMatch = {};
220    this.patternMatch = [];
221  }
222
223  /**
224   * @param {object} rules
225   */
226  addRules(rules) {
227    for (const key of Object.keys(rules)) {
228      if (key.includes('*')) {
229        this.patternMatch.push(new StatusRule(key, rules[key], key));
230      } else {
231        const normalizedPath = path.normalize(key);
232        this.exactMatch[normalizedPath] = new StatusRule(key, rules[key]);
233      }
234    }
235  }
236
237  match(file) {
238    const result = [];
239    const exact = this.exactMatch[file];
240    if (exact) {
241      result.push(exact);
242    }
243    for (const item of this.patternMatch) {
244      if (item.pattern.test(file)) {
245        result.push(item);
246      }
247    }
248    return result;
249  }
250}
251
252// A specification of WPT test
253class WPTTestSpec {
254  /**
255   * @param {string} mod name of the WPT module, e.g.
256   *                     'html/webappapis/microtask-queuing'
257   * @param {string} filename path of the test, relative to mod, e.g.
258   *                          'test.any.js'
259   * @param {StatusRule[]} rules
260   */
261  constructor(mod, filename, rules) {
262    this.module = mod;
263    this.filename = filename;
264
265    this.requires = new Set();
266    this.failedTests = [];
267    this.flakyTests = [];
268    this.skipReasons = [];
269    for (const item of rules) {
270      if (item.requires.length) {
271        for (const req of item.requires) {
272          this.requires.add(req);
273        }
274      }
275      if (Array.isArray(item.fail?.expected)) {
276        this.failedTests.push(...item.fail.expected);
277      }
278      if (Array.isArray(item.fail?.flaky)) {
279        this.failedTests.push(...item.fail.flaky);
280        this.flakyTests.push(...item.fail.flaky);
281      }
282      if (item.skip) {
283        this.skipReasons.push(item.skip);
284      }
285    }
286
287    this.failedTests = [...new Set(this.failedTests)];
288    this.flakyTests = [...new Set(this.flakyTests)];
289    this.skipReasons = [...new Set(this.skipReasons)];
290  }
291
292  getRelativePath() {
293    return path.join(this.module, this.filename);
294  }
295
296  getAbsolutePath() {
297    return fixtures.path('wpt', this.getRelativePath());
298  }
299
300  getContent() {
301    return fs.readFileSync(this.getAbsolutePath(), 'utf8');
302  }
303}
304
305const kIntlRequirement = {
306  none: 0,
307  small: 1,
308  full: 2,
309  // TODO(joyeecheung): we may need to deal with --with-intl=system-icu
310};
311
312class IntlRequirement {
313  constructor() {
314    this.currentIntl = kIntlRequirement.none;
315    if (process.config.variables.v8_enable_i18n_support === 0) {
316      this.currentIntl = kIntlRequirement.none;
317      return;
318    }
319    // i18n enabled
320    if (process.config.variables.icu_small) {
321      this.currentIntl = kIntlRequirement.small;
322    } else {
323      this.currentIntl = kIntlRequirement.full;
324    }
325  }
326
327  /**
328   * @param {Set} requires
329   * @returns {string|false} The config that the build is lacking, or false
330   */
331  isLacking(requires) {
332    const current = this.currentIntl;
333    if (requires.has('full-icu') && current !== kIntlRequirement.full) {
334      return 'full-icu';
335    }
336    if (requires.has('small-icu') && current < kIntlRequirement.small) {
337      return 'small-icu';
338    }
339    return false;
340  }
341}
342
343const intlRequirements = new IntlRequirement();
344
345class StatusLoader {
346  /**
347   * @param {string} path relative path of the WPT subset
348   */
349  constructor(path) {
350    this.path = path;
351    this.loaded = false;
352    this.rules = new StatusRuleSet();
353    /** @type {WPTTestSpec[]} */
354    this.specs = [];
355  }
356
357  /**
358   * Grep for all .*.js file recursively in a directory.
359   * @param {string} dir
360   */
361  grep(dir) {
362    let result = [];
363    const list = fs.readdirSync(dir);
364    for (const file of list) {
365      const filepath = path.join(dir, file);
366      const stat = fs.statSync(filepath);
367      if (stat.isDirectory()) {
368        const list = this.grep(filepath);
369        result = result.concat(list);
370      } else {
371        if (!(/\.\w+\.js$/.test(filepath)) || filepath.endsWith('.helper.js')) {
372          continue;
373        }
374        result.push(filepath);
375      }
376    }
377    return result;
378  }
379
380  load() {
381    const dir = path.join(__dirname, '..', 'wpt');
382    const statusFile = path.join(dir, 'status', `${this.path}.json`);
383    const result = JSON.parse(fs.readFileSync(statusFile, 'utf8'));
384    this.rules.addRules(result);
385
386    const subDir = fixtures.path('wpt', this.path);
387    const list = this.grep(subDir);
388    for (const file of list) {
389      const relativePath = path.relative(subDir, file);
390      const match = this.rules.match(relativePath);
391      this.specs.push(new WPTTestSpec(this.path, relativePath, match));
392    }
393    this.loaded = true;
394  }
395}
396
397const kPass = 'pass';
398const kFail = 'fail';
399const kSkip = 'skip';
400const kTimeout = 'timeout';
401const kIncomplete = 'incomplete';
402const kUncaught = 'uncaught';
403const NODE_UNCAUGHT = 100;
404
405class WPTRunner {
406  constructor(path) {
407    this.path = path;
408    this.resource = new ResourceLoader(path);
409
410    this.flags = [];
411    this.globalThisInitScripts = [];
412    this.initScript = null;
413
414    this.status = new StatusLoader(path);
415    this.status.load();
416    this.specMap = new Map(
417      this.status.specs.map((item) => [item.filename, item]),
418    );
419
420    this.results = {};
421    this.inProgress = new Set();
422    this.workers = new Map();
423    this.unexpectedFailures = [];
424
425    this.scriptsModifier = null;
426
427    if (process.env.WPT_REPORT != null) {
428      this.report = new WPTReport();
429    }
430  }
431
432  /**
433   * Sets the Node.js flags passed to the worker.
434   * @param {Array<string>} flags
435   */
436  setFlags(flags) {
437    this.flags = flags;
438  }
439
440  /**
441   * Sets a script to be run in the worker before executing the tests.
442   * @param {string} script
443   */
444  setInitScript(script) {
445    this.initScript = script;
446  }
447
448  /**
449   * Set the scripts modifier for each script.
450   * @param {(meta: { code: string, filename: string }) => void} modifier
451   */
452  setScriptModifier(modifier) {
453    this.scriptsModifier = modifier;
454  }
455
456  fullInitScript(hasSubsetScript, locationSearchString) {
457    let { initScript } = this;
458    if (hasSubsetScript || locationSearchString) {
459      initScript = `${initScript}\n\n//===\nglobalThis.location ||= {};`;
460    }
461
462    if (locationSearchString) {
463      initScript = `${initScript}\n\n//===\nglobalThis.location.search = "${locationSearchString}";`;
464    }
465
466    if (this.globalThisInitScripts.length === null) {
467      return initScript;
468    }
469
470    const globalThisInitScript = this.globalThisInitScripts.join('\n\n//===\n');
471
472    if (initScript === null) {
473      return globalThisInitScript;
474    }
475
476    return `${globalThisInitScript}\n\n//===\n${initScript}`;
477  }
478
479  /**
480   * Pretend the runner is run in `name`'s environment (globalThis).
481   * @param {'Window'} name
482   * @see {@link https://github.com/nodejs/node/blob/24673ace8ae196bd1c6d4676507d6e8c94cf0b90/test/fixtures/wpt/resources/idlharness.js#L654-L671}
483   */
484  pretendGlobalThisAs(name) {
485    switch (name) {
486      case 'Window': {
487        this.globalThisInitScripts.push(
488          `global.Window = Object.getPrototypeOf(globalThis).constructor;
489          self.GLOBAL.isWorker = () => false;`);
490        this.loadLazyGlobals();
491        break;
492      }
493
494      // TODO(XadillaX): implement `ServiceWorkerGlobalScope`,
495      // `DedicateWorkerGlobalScope`, etc.
496      //
497      // e.g. `ServiceWorkerGlobalScope` should implement dummy
498      // `addEventListener` and so on.
499
500      default: throw new Error(`Invalid globalThis type ${name}.`);
501    }
502  }
503
504  loadLazyGlobals() {
505    const lazyProperties = [
506      'DOMException',
507      'Performance', 'PerformanceEntry', 'PerformanceMark', 'PerformanceMeasure',
508      'PerformanceObserver', 'PerformanceObserverEntryList', 'PerformanceResourceTiming',
509      'Blob', 'atob', 'btoa',
510      'MessageChannel', 'MessagePort', 'MessageEvent',
511      'EventTarget', 'Event',
512      'AbortController', 'AbortSignal',
513      'performance',
514      'TransformStream', 'TransformStreamDefaultController',
515      'WritableStream', 'WritableStreamDefaultController', 'WritableStreamDefaultWriter',
516      'ReadableStream', 'ReadableStreamDefaultReader',
517      'ReadableStreamBYOBReader', 'ReadableStreamBYOBRequest',
518      'ReadableByteStreamController', 'ReadableStreamDefaultController',
519      'ByteLengthQueuingStrategy', 'CountQueuingStrategy',
520      'TextEncoderStream', 'TextDecoderStream',
521      'CompressionStream', 'DecompressionStream',
522    ];
523    if (Boolean(process.versions.openssl) && !process.env.NODE_SKIP_CRYPTO) {
524      lazyProperties.push('crypto');
525    }
526    const script = lazyProperties.map((name) => `globalThis.${name};`).join('\n');
527    this.globalThisInitScripts.push(script);
528  }
529
530  // TODO(joyeecheung): work with the upstream to port more tests in .html
531  // to .js.
532  async runJsTests() {
533    let queue = [];
534
535    // If the tests are run as `node test/wpt/test-something.js subset.any.js`,
536    // only `subset.any.js` will be run by the runner.
537    if (process.argv[2]) {
538      const filename = process.argv[2];
539      if (!this.specMap.has(filename)) {
540        throw new Error(`${filename} not found!`);
541      }
542      queue.push(this.specMap.get(filename));
543    } else {
544      queue = this.buildQueue();
545    }
546
547    this.inProgress = new Set(queue.map((spec) => spec.filename));
548
549    for (const spec of queue) {
550      const testFileName = spec.filename;
551      const content = spec.getContent();
552      const meta = spec.meta = this.getMeta(content);
553
554      const absolutePath = spec.getAbsolutePath();
555      const relativePath = spec.getRelativePath();
556      const harnessPath = fixtures.path('wpt', 'resources', 'testharness.js');
557      const scriptsToRun = [];
558      let hasSubsetScript = false;
559
560      // Scripts specified with the `// META: script=` header
561      if (meta.script) {
562        for (const script of meta.script) {
563          if (script === '/common/subset-tests.js' || script === '/common/subset-tests-by-key.js') {
564            hasSubsetScript = true;
565          }
566          const obj = {
567            filename: this.resource.toRealFilePath(relativePath, script),
568            code: this.resource.read(relativePath, script, false),
569          };
570          this.scriptsModifier?.(obj);
571          scriptsToRun.push(obj);
572        }
573      }
574      // The actual test
575      const obj = {
576        code: content,
577        filename: absolutePath,
578      };
579      this.scriptsModifier?.(obj);
580      scriptsToRun.push(obj);
581
582      /**
583       * Example test with no META variant
584       * https://github.com/nodejs/node/blob/03854f6/test/fixtures/wpt/WebCryptoAPI/sign_verify/hmac.https.any.js#L1-L4
585       *
586       * Example test with multiple META variants
587       * https://github.com/nodejs/node/blob/03854f6/test/fixtures/wpt/WebCryptoAPI/generateKey/successes_RSASSA-PKCS1-v1_5.https.any.js#L1-L9
588       */
589      for (const variant of meta.variant || ['']) {
590        const workerPath = path.join(__dirname, 'wpt/worker.js');
591        const worker = new Worker(workerPath, {
592          execArgv: this.flags,
593          workerData: {
594            testRelativePath: relativePath,
595            wptRunner: __filename,
596            wptPath: this.path,
597            initScript: this.fullInitScript(hasSubsetScript, variant),
598            harness: {
599              code: fs.readFileSync(harnessPath, 'utf8'),
600              filename: harnessPath,
601            },
602            scriptsToRun,
603          },
604        });
605        this.workers.set(testFileName, worker);
606
607        let reportResult;
608        worker.on('message', (message) => {
609          switch (message.type) {
610            case 'result':
611              reportResult ||= this.report?.addResult(`/${relativePath}${variant}`, 'OK');
612              return this.resultCallback(testFileName, message.result, reportResult);
613            case 'completion':
614              return this.completionCallback(testFileName, message.status);
615            default:
616              throw new Error(`Unexpected message from worker: ${message.type}`);
617          }
618        });
619
620        worker.on('error', (err) => {
621          if (!this.inProgress.has(testFileName)) {
622            // The test is already finished. Ignore errors that occur after it.
623            // This can happen normally, for example in timers tests.
624            return;
625          }
626          this.fail(
627            testFileName,
628            {
629              status: NODE_UNCAUGHT,
630              name: 'evaluation in WPTRunner.runJsTests()',
631              message: err.message,
632              stack: inspect(err),
633            },
634            kUncaught,
635          );
636          this.inProgress.delete(testFileName);
637        });
638
639        await events.once(worker, 'exit').catch(() => {});
640      }
641    }
642
643    process.on('exit', () => {
644      for (const spec of this.inProgress) {
645        this.fail(spec, { name: 'Incomplete' }, kIncomplete);
646      }
647      inspect.defaultOptions.depth = Infinity;
648      // Sorts the rules to have consistent output
649      console.log(JSON.stringify(Object.keys(this.results).sort().reduce(
650        (obj, key) => {
651          obj[key] = this.results[key];
652          return obj;
653        },
654        {},
655      ), null, 2));
656
657      const failures = [];
658      let expectedFailures = 0;
659      let skipped = 0;
660      for (const [key, item] of Object.entries(this.results)) {
661        if (item.fail?.unexpected) {
662          failures.push(key);
663        }
664        if (item.fail?.expected) {
665          expectedFailures++;
666        }
667        if (item.skip) {
668          skipped++;
669        }
670      }
671
672      const unexpectedPasses = [];
673      for (const specMap of queue) {
674        const key = specMap.filename;
675
676        // File has no expected failures
677        if (!specMap.failedTests.length) {
678          continue;
679        }
680
681        // File was (maybe even conditionally) skipped
682        if (this.results[key]?.skip) {
683          continue;
684        }
685
686        // Full check: every expected to fail test is present
687        if (specMap.failedTests.some((expectedToFail) => {
688          if (specMap.flakyTests.includes(expectedToFail)) {
689            return false;
690          }
691          return this.results[key]?.fail?.expected?.includes(expectedToFail) !== true;
692        })) {
693          unexpectedPasses.push(key);
694          continue;
695        }
696      }
697
698      this.report?.write();
699
700      const ran = queue.length;
701      const total = ran + skipped;
702      const passed = ran - expectedFailures - failures.length;
703      console.log(`Ran ${ran}/${total} tests, ${skipped} skipped,`,
704                  `${passed} passed, ${expectedFailures} expected failures,`,
705                  `${failures.length} unexpected failures,`,
706                  `${unexpectedPasses.length} unexpected passes`);
707      if (failures.length > 0) {
708        const file = path.join('test', 'wpt', 'status', `${this.path}.json`);
709        throw new Error(
710          `Found ${failures.length} unexpected failures. ` +
711          `Consider updating ${file} for these files:\n${failures.join('\n')}`);
712      }
713      if (unexpectedPasses.length > 0) {
714        const file = path.join('test', 'wpt', 'status', `${this.path}.json`);
715        throw new Error(
716          `Found ${unexpectedPasses.length} unexpected passes. ` +
717          `Consider updating ${file} for these files:\n${unexpectedPasses.join('\n')}`);
718      }
719    });
720  }
721
722  getTestTitle(filename) {
723    const spec = this.specMap.get(filename);
724    return spec.meta?.title || filename.split('.')[0];
725  }
726
727  // Map WPT test status to strings
728  getTestStatus(status) {
729    switch (status) {
730      case 1:
731        return kFail;
732      case 2:
733        return kTimeout;
734      case 3:
735        return kIncomplete;
736      case NODE_UNCAUGHT:
737        return kUncaught;
738      default:
739        return kPass;
740    }
741  }
742
743  /**
744   * Report the status of each specific test case (there could be multiple
745   * in one test file).
746   * @param {string} filename
747   * @param {Test} test  The Test object returned by WPT harness
748   */
749  resultCallback(filename, test, reportResult) {
750    const status = this.getTestStatus(test.status);
751    const title = this.getTestTitle(filename);
752    if (/^Untitled( \d+)?$/.test(test.name)) {
753      test.name = `${title}${test.name.slice(8)}`;
754    }
755    console.log(`---- ${title} ----`);
756    if (status !== kPass) {
757      this.fail(filename, test, status, reportResult);
758    } else {
759      this.succeed(filename, test, status, reportResult);
760    }
761  }
762
763  /**
764   * Report the status of each WPT test (one per file)
765   * @param {string} filename
766   * @param {object} harnessStatus - The status object returned by WPT harness.
767   */
768  completionCallback(filename, harnessStatus) {
769    const status = this.getTestStatus(harnessStatus.status);
770
771    // Treat it like a test case failure
772    if (status === kTimeout) {
773      this.fail(filename, { name: 'WPT testharness timeout' }, kTimeout);
774    }
775    this.inProgress.delete(filename);
776    // Always force termination of the worker. Some tests allocate resources
777    // that would otherwise keep it alive.
778    this.workers.get(filename).terminate();
779  }
780
781  addTestResult(filename, item) {
782    let result = this.results[filename];
783    if (!result) {
784      result = this.results[filename] = {};
785    }
786    if (item.status === kSkip) {
787      // { filename: { skip: 'reason' } }
788      result[kSkip] = item.reason;
789    } else {
790      // { filename: { fail: { expected: [ ... ],
791      //                      unexpected: [ ... ] } }}
792      if (!result[item.status]) {
793        result[item.status] = {};
794      }
795      const key = item.expected ? 'expected' : 'unexpected';
796      if (!result[item.status][key]) {
797        result[item.status][key] = [];
798      }
799      const hasName = result[item.status][key].includes(item.name);
800      if (!hasName) {
801        result[item.status][key].push(item.name);
802      }
803    }
804  }
805
806  succeed(filename, test, status, reportResult) {
807    console.log(`[${status.toUpperCase()}] ${test.name}`);
808    reportResult?.addSubtest(test.name, 'PASS');
809  }
810
811  fail(filename, test, status, reportResult) {
812    const spec = this.specMap.get(filename);
813    const expected = spec.failedTests.includes(test.name);
814    if (expected) {
815      console.log(`[EXPECTED_FAILURE][${status.toUpperCase()}] ${test.name}`);
816      console.log(test.message || status);
817    } else {
818      console.log(`[UNEXPECTED_FAILURE][${status.toUpperCase()}] ${test.name}`);
819    }
820    if (status === kFail || status === kUncaught) {
821      console.log(test.message);
822      console.log(test.stack);
823    }
824    const command = `${process.execPath} ${process.execArgv}` +
825                    ` ${require.main.filename} ${filename}`;
826    console.log(`Command: ${command}\n`);
827
828    reportResult?.addSubtest(test.name, 'FAIL', test.message);
829
830    this.addTestResult(filename, {
831      name: test.name,
832      expected,
833      status: kFail,
834      reason: test.message || status,
835    });
836  }
837
838  skip(filename, reasons) {
839    const title = this.getTestTitle(filename);
840    console.log(`---- ${title} ----`);
841    const joinedReasons = reasons.join('; ');
842    console.log(`[SKIPPED] ${joinedReasons}`);
843    this.addTestResult(filename, {
844      status: kSkip,
845      reason: joinedReasons,
846    });
847  }
848
849  getMeta(code) {
850    const matches = code.match(/\/\/ META: .+/g);
851    if (!matches) {
852      return {};
853    }
854    const result = {};
855    for (const match of matches) {
856      const parts = match.match(/\/\/ META: ([^=]+?)=(.+)/);
857      const key = parts[1];
858      const value = parts[2];
859      if (key === 'script' || key === 'variant') {
860        if (result[key]) {
861          result[key].push(value);
862        } else {
863          result[key] = [value];
864        }
865      } else {
866        result[key] = value;
867      }
868    }
869    return result;
870  }
871
872  buildQueue() {
873    const queue = [];
874    for (const spec of this.specMap.values()) {
875      const filename = spec.filename;
876      if (spec.skipReasons.length > 0) {
877        this.skip(filename, spec.skipReasons);
878        continue;
879      }
880
881      const lackingIntl = intlRequirements.isLacking(spec.requires);
882      if (lackingIntl) {
883        this.skip(filename, [ `requires ${lackingIntl}` ]);
884        continue;
885      }
886
887      queue.push(spec);
888    }
889    return queue;
890  }
891}
892
893module.exports = {
894  harness: harnessMock,
895  ResourceLoader,
896  WPTRunner,
897};
898