• 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.dummyGlobalThisScript = null;
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 (initScript === null && this.dummyGlobalThisScript === null) {
467      return null;
468    }
469
470    if (initScript === null) {
471      return this.dummyGlobalThisScript;
472    } else if (this.dummyGlobalThisScript === null) {
473      return initScript;
474    }
475
476    return `${this.dummyGlobalThisScript}\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.dummyGlobalThisScript =
488          'global.Window = Object.getPrototypeOf(globalThis).constructor;';
489        break;
490      }
491
492      // TODO(XadillaX): implement `ServiceWorkerGlobalScope`,
493      // `DedicateWorkerGlobalScope`, etc.
494      //
495      // e.g. `ServiceWorkerGlobalScope` should implement dummy
496      // `addEventListener` and so on.
497
498      default: throw new Error(`Invalid globalThis type ${name}.`);
499    }
500  }
501
502  // TODO(joyeecheung): work with the upstream to port more tests in .html
503  // to .js.
504  async runJsTests() {
505    let queue = [];
506
507    // If the tests are run as `node test/wpt/test-something.js subset.any.js`,
508    // only `subset.any.js` will be run by the runner.
509    if (process.argv[2]) {
510      const filename = process.argv[2];
511      if (!this.specMap.has(filename)) {
512        throw new Error(`${filename} not found!`);
513      }
514      queue.push(this.specMap.get(filename));
515    } else {
516      queue = this.buildQueue();
517    }
518
519    this.inProgress = new Set(queue.map((spec) => spec.filename));
520
521    for (const spec of queue) {
522      const testFileName = spec.filename;
523      const content = spec.getContent();
524      const meta = spec.meta = this.getMeta(content);
525
526      const absolutePath = spec.getAbsolutePath();
527      const relativePath = spec.getRelativePath();
528      const harnessPath = fixtures.path('wpt', 'resources', 'testharness.js');
529      const scriptsToRun = [];
530      let hasSubsetScript = false;
531
532      // Scripts specified with the `// META: script=` header
533      if (meta.script) {
534        for (const script of meta.script) {
535          if (script === '/common/subset-tests.js' || script === '/common/subset-tests-by-key.js') {
536            hasSubsetScript = true;
537          }
538          const obj = {
539            filename: this.resource.toRealFilePath(relativePath, script),
540            code: this.resource.read(relativePath, script, false),
541          };
542          this.scriptsModifier?.(obj);
543          scriptsToRun.push(obj);
544        }
545      }
546      // The actual test
547      const obj = {
548        code: content,
549        filename: absolutePath,
550      };
551      this.scriptsModifier?.(obj);
552      scriptsToRun.push(obj);
553
554      /**
555       * Example test with no META variant
556       * https://github.com/nodejs/node/blob/03854f6/test/fixtures/wpt/WebCryptoAPI/sign_verify/hmac.https.any.js#L1-L4
557       *
558       * Example test with multiple META variants
559       * https://github.com/nodejs/node/blob/03854f6/test/fixtures/wpt/WebCryptoAPI/generateKey/successes_RSASSA-PKCS1-v1_5.https.any.js#L1-L9
560       */
561      for (const variant of meta.variant || ['']) {
562        const workerPath = path.join(__dirname, 'wpt/worker.js');
563        const worker = new Worker(workerPath, {
564          execArgv: this.flags,
565          workerData: {
566            testRelativePath: relativePath,
567            wptRunner: __filename,
568            wptPath: this.path,
569            initScript: this.fullInitScript(hasSubsetScript, variant),
570            harness: {
571              code: fs.readFileSync(harnessPath, 'utf8'),
572              filename: harnessPath,
573            },
574            scriptsToRun,
575          },
576        });
577        this.workers.set(testFileName, worker);
578
579        let reportResult;
580        worker.on('message', (message) => {
581          switch (message.type) {
582            case 'result':
583              reportResult ||= this.report?.addResult(`/${relativePath}${variant}`, 'OK');
584              return this.resultCallback(testFileName, message.result, reportResult);
585            case 'completion':
586              return this.completionCallback(testFileName, message.status);
587            default:
588              throw new Error(`Unexpected message from worker: ${message.type}`);
589          }
590        });
591
592        worker.on('error', (err) => {
593          if (!this.inProgress.has(testFileName)) {
594            // The test is already finished. Ignore errors that occur after it.
595            // This can happen normally, for example in timers tests.
596            return;
597          }
598          this.fail(
599            testFileName,
600            {
601              status: NODE_UNCAUGHT,
602              name: 'evaluation in WPTRunner.runJsTests()',
603              message: err.message,
604              stack: inspect(err),
605            },
606            kUncaught,
607          );
608          this.inProgress.delete(testFileName);
609        });
610
611        await events.once(worker, 'exit').catch(() => {});
612      }
613    }
614
615    process.on('exit', () => {
616      for (const spec of this.inProgress) {
617        this.fail(spec, { name: 'Incomplete' }, kIncomplete);
618      }
619      inspect.defaultOptions.depth = Infinity;
620      // Sorts the rules to have consistent output
621      console.log(JSON.stringify(Object.keys(this.results).sort().reduce(
622        (obj, key) => {
623          obj[key] = this.results[key];
624          return obj;
625        },
626        {},
627      ), null, 2));
628
629      const failures = [];
630      let expectedFailures = 0;
631      let skipped = 0;
632      for (const [key, item] of Object.entries(this.results)) {
633        if (item.fail?.unexpected) {
634          failures.push(key);
635        }
636        if (item.fail?.expected) {
637          expectedFailures++;
638        }
639        if (item.skip) {
640          skipped++;
641        }
642      }
643
644      const unexpectedPasses = [];
645      for (const specMap of queue) {
646        const key = specMap.filename;
647
648        // File has no expected failures
649        if (!specMap.failedTests.length) {
650          continue;
651        }
652
653        // File was (maybe even conditionally) skipped
654        if (this.results[key]?.skip) {
655          continue;
656        }
657
658        // Full check: every expected to fail test is present
659        if (specMap.failedTests.some((expectedToFail) => {
660          if (specMap.flakyTests.includes(expectedToFail)) {
661            return false;
662          }
663          return this.results[key]?.fail?.expected?.includes(expectedToFail) !== true;
664        })) {
665          unexpectedPasses.push(key);
666          continue;
667        }
668      }
669
670      this.report?.write();
671
672      const ran = queue.length;
673      const total = ran + skipped;
674      const passed = ran - expectedFailures - failures.length;
675      console.log(`Ran ${ran}/${total} tests, ${skipped} skipped,`,
676                  `${passed} passed, ${expectedFailures} expected failures,`,
677                  `${failures.length} unexpected failures,`,
678                  `${unexpectedPasses.length} unexpected passes`);
679      if (failures.length > 0) {
680        const file = path.join('test', 'wpt', 'status', `${this.path}.json`);
681        throw new Error(
682          `Found ${failures.length} unexpected failures. ` +
683          `Consider updating ${file} for these files:\n${failures.join('\n')}`);
684      }
685      if (unexpectedPasses.length > 0) {
686        const file = path.join('test', 'wpt', 'status', `${this.path}.json`);
687        throw new Error(
688          `Found ${unexpectedPasses.length} unexpected passes. ` +
689          `Consider updating ${file} for these files:\n${unexpectedPasses.join('\n')}`);
690      }
691    });
692  }
693
694  getTestTitle(filename) {
695    const spec = this.specMap.get(filename);
696    return spec.meta?.title || filename.split('.')[0];
697  }
698
699  // Map WPT test status to strings
700  getTestStatus(status) {
701    switch (status) {
702      case 1:
703        return kFail;
704      case 2:
705        return kTimeout;
706      case 3:
707        return kIncomplete;
708      case NODE_UNCAUGHT:
709        return kUncaught;
710      default:
711        return kPass;
712    }
713  }
714
715  /**
716   * Report the status of each specific test case (there could be multiple
717   * in one test file).
718   * @param {string} filename
719   * @param {Test} test  The Test object returned by WPT harness
720   */
721  resultCallback(filename, test, reportResult) {
722    const status = this.getTestStatus(test.status);
723    const title = this.getTestTitle(filename);
724    if (/^Untitled( \d+)?$/.test(test.name)) {
725      test.name = `${title}${test.name.slice(8)}`;
726    }
727    console.log(`---- ${title} ----`);
728    if (status !== kPass) {
729      this.fail(filename, test, status, reportResult);
730    } else {
731      this.succeed(filename, test, status, reportResult);
732    }
733  }
734
735  /**
736   * Report the status of each WPT test (one per file)
737   * @param {string} filename
738   * @param {object} harnessStatus - The status object returned by WPT harness.
739   */
740  completionCallback(filename, harnessStatus) {
741    const status = this.getTestStatus(harnessStatus.status);
742
743    // Treat it like a test case failure
744    if (status === kTimeout) {
745      this.fail(filename, { name: 'WPT testharness timeout' }, kTimeout);
746    }
747    this.inProgress.delete(filename);
748    // Always force termination of the worker. Some tests allocate resources
749    // that would otherwise keep it alive.
750    this.workers.get(filename).terminate();
751  }
752
753  addTestResult(filename, item) {
754    let result = this.results[filename];
755    if (!result) {
756      result = this.results[filename] = {};
757    }
758    if (item.status === kSkip) {
759      // { filename: { skip: 'reason' } }
760      result[kSkip] = item.reason;
761    } else {
762      // { filename: { fail: { expected: [ ... ],
763      //                      unexpected: [ ... ] } }}
764      if (!result[item.status]) {
765        result[item.status] = {};
766      }
767      const key = item.expected ? 'expected' : 'unexpected';
768      if (!result[item.status][key]) {
769        result[item.status][key] = [];
770      }
771      const hasName = result[item.status][key].includes(item.name);
772      if (!hasName) {
773        result[item.status][key].push(item.name);
774      }
775    }
776  }
777
778  succeed(filename, test, status, reportResult) {
779    console.log(`[${status.toUpperCase()}] ${test.name}`);
780    reportResult?.addSubtest(test.name, 'PASS');
781  }
782
783  fail(filename, test, status, reportResult) {
784    const spec = this.specMap.get(filename);
785    const expected = spec.failedTests.includes(test.name);
786    if (expected) {
787      console.log(`[EXPECTED_FAILURE][${status.toUpperCase()}] ${test.name}`);
788      console.log(test.message || status);
789    } else {
790      console.log(`[UNEXPECTED_FAILURE][${status.toUpperCase()}] ${test.name}`);
791    }
792    if (status === kFail || status === kUncaught) {
793      console.log(test.message);
794      console.log(test.stack);
795    }
796    const command = `${process.execPath} ${process.execArgv}` +
797                    ` ${require.main.filename} ${filename}`;
798    console.log(`Command: ${command}\n`);
799
800    reportResult?.addSubtest(test.name, 'FAIL', test.message);
801
802    this.addTestResult(filename, {
803      name: test.name,
804      expected,
805      status: kFail,
806      reason: test.message || status,
807    });
808  }
809
810  skip(filename, reasons) {
811    const title = this.getTestTitle(filename);
812    console.log(`---- ${title} ----`);
813    const joinedReasons = reasons.join('; ');
814    console.log(`[SKIPPED] ${joinedReasons}`);
815    this.addTestResult(filename, {
816      status: kSkip,
817      reason: joinedReasons,
818    });
819  }
820
821  getMeta(code) {
822    const matches = code.match(/\/\/ META: .+/g);
823    if (!matches) {
824      return {};
825    }
826    const result = {};
827    for (const match of matches) {
828      const parts = match.match(/\/\/ META: ([^=]+?)=(.+)/);
829      const key = parts[1];
830      const value = parts[2];
831      if (key === 'script' || key === 'variant') {
832        if (result[key]) {
833          result[key].push(value);
834        } else {
835          result[key] = [value];
836        }
837      } else {
838        result[key] = value;
839      }
840    }
841    return result;
842  }
843
844  buildQueue() {
845    const queue = [];
846    for (const spec of this.specMap.values()) {
847      const filename = spec.filename;
848      if (spec.skipReasons.length > 0) {
849        this.skip(filename, spec.skipReasons);
850        continue;
851      }
852
853      const lackingIntl = intlRequirements.isLacking(spec.requires);
854      if (lackingIntl) {
855        this.skip(filename, [ `requires ${lackingIntl}` ]);
856        continue;
857      }
858
859      queue.push(spec);
860    }
861    return queue;
862  }
863}
864
865module.exports = {
866  harness: harnessMock,
867  ResourceLoader,
868  WPTRunner,
869};
870