• 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 { inspect } = require('util');
9const { Worker } = require('worker_threads');
10
11// https://github.com/web-platform-tests/wpt/blob/HEAD/resources/testharness.js
12// TODO: get rid of this half-baked harness in favor of the one
13// pulled from WPT
14const harnessMock = {
15  test: (fn, desc) => {
16    try {
17      fn();
18    } catch (err) {
19      console.error(`In ${desc}:`);
20      throw err;
21    }
22  },
23  assert_equals: assert.strictEqual,
24  assert_true: (value, message) => assert.strictEqual(value, true, message),
25  assert_false: (value, message) => assert.strictEqual(value, false, message),
26  assert_throws: (code, func, desc) => {
27    assert.throws(func, function(err) {
28      return typeof err === 'object' &&
29             'name' in err &&
30             err.name.startsWith(code.name);
31    }, desc);
32  },
33  assert_array_equals: assert.deepStrictEqual,
34  assert_unreached(desc) {
35    assert.fail(`Reached unreachable code: ${desc}`);
36  }
37};
38
39class ResourceLoader {
40  constructor(path) {
41    this.path = path;
42  }
43
44  toRealFilePath(from, url) {
45    // We need to patch this to load the WebIDL parser
46    url = url.replace(
47      '/resources/WebIDLParser.js',
48      '/resources/webidl2/lib/webidl2.js'
49    );
50    const base = path.dirname(from);
51    return url.startsWith('/') ?
52      fixtures.path('wpt', url) :
53      fixtures.path('wpt', base, url);
54  }
55
56  /**
57   * Load a resource in test/fixtures/wpt specified with a URL
58   * @param {string} from the path of the file loading this resource,
59   *                      relative to thw WPT folder.
60   * @param {string} url the url of the resource being loaded.
61   * @param {boolean} asPromise if true, return the resource in a
62   *                            pseudo-Response object.
63   */
64  read(from, url, asFetch = true) {
65    const file = this.toRealFilePath(from, url);
66    if (asFetch) {
67      return fsPromises.readFile(file)
68        .then((data) => {
69          return {
70            ok: true,
71            json() { return JSON.parse(data.toString()); },
72            text() { return data.toString(); }
73          };
74        });
75    }
76    return fs.readFileSync(file, 'utf8');
77  }
78}
79
80class StatusRule {
81  constructor(key, value, pattern = undefined) {
82    this.key = key;
83    this.requires = value.requires || [];
84    this.fail = value.fail;
85    this.skip = value.skip;
86    if (pattern) {
87      this.pattern = this.transformPattern(pattern);
88    }
89    // TODO(joyeecheung): implement this
90    this.scope = value.scope;
91    this.comment = value.comment;
92  }
93
94  /**
95   * Transform a filename pattern into a RegExp
96   * @param {string} pattern
97   * @returns {RegExp}
98   */
99  transformPattern(pattern) {
100    const result = path.normalize(pattern).replace(/[-/\\^$+?.()|[\]{}]/g, '\\$&');
101    return new RegExp(result.replace('*', '.*'));
102  }
103}
104
105class StatusRuleSet {
106  constructor() {
107    // We use two sets of rules to speed up matching
108    this.exactMatch = {};
109    this.patternMatch = [];
110  }
111
112  /**
113   * @param {object} rules
114   */
115  addRules(rules) {
116    for (const key of Object.keys(rules)) {
117      if (key.includes('*')) {
118        this.patternMatch.push(new StatusRule(key, rules[key], key));
119      } else {
120        this.exactMatch[key] = new StatusRule(key, rules[key]);
121      }
122    }
123  }
124
125  match(file) {
126    const result = [];
127    const exact = this.exactMatch[file];
128    if (exact) {
129      result.push(exact);
130    }
131    for (const item of this.patternMatch) {
132      if (item.pattern.test(file)) {
133        result.push(item);
134      }
135    }
136    return result;
137  }
138}
139
140// A specification of WPT test
141class WPTTestSpec {
142  /**
143   * @param {string} mod name of the WPT module, e.g.
144   *                     'html/webappapis/microtask-queuing'
145   * @param {string} filename path of the test, relative to mod, e.g.
146   *                          'test.any.js'
147   * @param {StatusRule[]} rules
148   */
149  constructor(mod, filename, rules) {
150    this.module = mod;
151    this.filename = filename;
152
153    this.requires = new Set();
154    this.failReasons = [];
155    this.skipReasons = [];
156    for (const item of rules) {
157      if (item.requires.length) {
158        for (const req of item.requires) {
159          this.requires.add(req);
160        }
161      }
162      if (item.fail) {
163        this.failReasons.push(item.fail);
164      }
165      if (item.skip) {
166        this.skipReasons.push(item.skip);
167      }
168    }
169  }
170
171  getRelativePath() {
172    return path.join(this.module, this.filename);
173  }
174
175  getAbsolutePath() {
176    return fixtures.path('wpt', this.getRelativePath());
177  }
178
179  getContent() {
180    return fs.readFileSync(this.getAbsolutePath(), 'utf8');
181  }
182}
183
184const kIntlRequirement = {
185  none: 0,
186  small: 1,
187  full: 2,
188  // TODO(joyeecheung): we may need to deal with --with-intl=system-icu
189};
190
191class IntlRequirement {
192  constructor() {
193    this.currentIntl = kIntlRequirement.none;
194    if (process.config.variables.v8_enable_i18n_support === 0) {
195      this.currentIntl = kIntlRequirement.none;
196      return;
197    }
198    // i18n enabled
199    if (process.config.variables.icu_small) {
200      this.currentIntl = kIntlRequirement.small;
201    } else {
202      this.currentIntl = kIntlRequirement.full;
203    }
204  }
205
206  /**
207   * @param {Set} requires
208   * @returns {string|false} The config that the build is lacking, or false
209   */
210  isLacking(requires) {
211    const current = this.currentIntl;
212    if (requires.has('full-icu') && current !== kIntlRequirement.full) {
213      return 'full-icu';
214    }
215    if (requires.has('small-icu') && current < kIntlRequirement.small) {
216      return 'small-icu';
217    }
218    return false;
219  }
220}
221
222const intlRequirements = new IntlRequirement();
223
224class StatusLoader {
225  /**
226   * @param {string} path relative path of the WPT subset
227   */
228  constructor(path) {
229    this.path = path;
230    this.loaded = false;
231    this.rules = new StatusRuleSet();
232    /** @type {WPTTestSpec[]} */
233    this.specs = [];
234  }
235
236  /**
237   * Grep for all .*.js file recursively in a directory.
238   * @param {string} dir
239   */
240  grep(dir) {
241    let result = [];
242    const list = fs.readdirSync(dir);
243    for (const file of list) {
244      const filepath = path.join(dir, file);
245      const stat = fs.statSync(filepath);
246      if (stat.isDirectory()) {
247        const list = this.grep(filepath);
248        result = result.concat(list);
249      } else {
250        if (!(/\.\w+\.js$/.test(filepath))) {
251          continue;
252        }
253        result.push(filepath);
254      }
255    }
256    return result;
257  }
258
259  load() {
260    const dir = path.join(__dirname, '..', 'wpt');
261    const statusFile = path.join(dir, 'status', `${this.path}.json`);
262    const result = JSON.parse(fs.readFileSync(statusFile, 'utf8'));
263    this.rules.addRules(result);
264
265    const subDir = fixtures.path('wpt', this.path);
266    const list = this.grep(subDir);
267    for (const file of list) {
268      const relativePath = path.relative(subDir, file);
269      const match = this.rules.match(relativePath);
270      this.specs.push(new WPTTestSpec(this.path, relativePath, match));
271    }
272    this.loaded = true;
273  }
274}
275
276const kPass = 'pass';
277const kFail = 'fail';
278const kSkip = 'skip';
279const kTimeout = 'timeout';
280const kIncomplete = 'incomplete';
281const kUncaught = 'uncaught';
282const NODE_UNCAUGHT = 100;
283
284class WPTRunner {
285  constructor(path) {
286    this.path = path;
287    this.resource = new ResourceLoader(path);
288
289    this.flags = [];
290    this.initScript = null;
291
292    this.status = new StatusLoader(path);
293    this.status.load();
294    this.specMap = new Map(
295      this.status.specs.map((item) => [item.filename, item])
296    );
297
298    this.results = {};
299    this.inProgress = new Set();
300    this.unexpectedFailures = [];
301  }
302
303  /**
304   * Sets the Node.js flags passed to the worker.
305   * @param {Array<string>} flags
306   */
307  setFlags(flags) {
308    this.flags = flags;
309  }
310
311  /**
312   * Sets a script to be run in the worker before executing the tests.
313   * @param {string} script
314   */
315  setInitScript(script) {
316    this.initScript = script;
317  }
318
319  // TODO(joyeecheung): work with the upstream to port more tests in .html
320  // to .js.
321  runJsTests() {
322    let queue = [];
323
324    // If the tests are run as `node test/wpt/test-something.js subset.any.js`,
325    // only `subset.any.js` will be run by the runner.
326    if (process.argv[2]) {
327      const filename = process.argv[2];
328      if (!this.specMap.has(filename)) {
329        throw new Error(`${filename} not found!`);
330      }
331      queue.push(this.specMap.get(filename));
332    } else {
333      queue = this.buildQueue();
334    }
335
336    this.inProgress = new Set(queue.map((spec) => spec.filename));
337
338    for (const spec of queue) {
339      const testFileName = spec.filename;
340      const content = spec.getContent();
341      const meta = spec.title = this.getMeta(content);
342
343      const absolutePath = spec.getAbsolutePath();
344      const relativePath = spec.getRelativePath();
345      const harnessPath = fixtures.path('wpt', 'resources', 'testharness.js');
346      const scriptsToRun = [];
347      // Scripts specified with the `// META: script=` header
348      if (meta.script) {
349        for (const script of meta.script) {
350          scriptsToRun.push({
351            filename: this.resource.toRealFilePath(relativePath, script),
352            code: this.resource.read(relativePath, script, false)
353          });
354        }
355      }
356      // The actual test
357      scriptsToRun.push({
358        code: content,
359        filename: absolutePath
360      });
361
362      const workerPath = path.join(__dirname, 'wpt/worker.js');
363      const worker = new Worker(workerPath, {
364        execArgv: this.flags,
365        workerData: {
366          filename: testFileName,
367          wptRunner: __filename,
368          wptPath: this.path,
369          initScript: this.initScript,
370          harness: {
371            code: fs.readFileSync(harnessPath, 'utf8'),
372            filename: harnessPath,
373          },
374          scriptsToRun,
375        },
376      });
377
378      worker.on('message', (message) => {
379        switch (message.type) {
380          case 'result':
381            return this.resultCallback(testFileName, message.result);
382          case 'completion':
383            return this.completionCallback(testFileName, message.status);
384          default:
385            throw new Error(`Unexpected message from worker: ${message.type}`);
386        }
387      });
388
389      worker.on('error', (err) => {
390        this.fail(
391          testFileName,
392          {
393            status: NODE_UNCAUGHT,
394            name: 'evaluation in WPTRunner.runJsTests()',
395            message: err.message,
396            stack: inspect(err)
397          },
398          kUncaught
399        );
400        this.inProgress.delete(testFileName);
401      });
402    }
403
404    process.on('exit', () => {
405      const total = this.specMap.size;
406      if (this.inProgress.size > 0) {
407        for (const filename of this.inProgress) {
408          this.fail(filename, { name: 'Unknown' }, kIncomplete);
409        }
410      }
411      inspect.defaultOptions.depth = Infinity;
412      console.log(this.results);
413
414      const failures = [];
415      let expectedFailures = 0;
416      let skipped = 0;
417      for (const key of Object.keys(this.results)) {
418        const item = this.results[key];
419        if (item.fail && item.fail.unexpected) {
420          failures.push(key);
421        }
422        if (item.fail && item.fail.expected) {
423          expectedFailures++;
424        }
425        if (item.skip) {
426          skipped++;
427        }
428      }
429      const ran = total - skipped;
430      const passed = ran - expectedFailures - failures.length;
431      console.log(`Ran ${ran}/${total} tests, ${skipped} skipped,`,
432                  `${passed} passed, ${expectedFailures} expected failures,`,
433                  `${failures.length} unexpected failures`);
434      if (failures.length > 0) {
435        const file = path.join('test', 'wpt', 'status', `${this.path}.json`);
436        throw new Error(
437          `Found ${failures.length} unexpected failures. ` +
438          `Consider updating ${file} for these files:\n${failures.join('\n')}`);
439      }
440    });
441  }
442
443  getTestTitle(filename) {
444    const spec = this.specMap.get(filename);
445    const title = spec.meta && spec.meta.title;
446    return title ? `${filename} : ${title}` : filename;
447  }
448
449  // Map WPT test status to strings
450  getTestStatus(status) {
451    switch (status) {
452      case 1:
453        return kFail;
454      case 2:
455        return kTimeout;
456      case 3:
457        return kIncomplete;
458      case NODE_UNCAUGHT:
459        return kUncaught;
460      default:
461        return kPass;
462    }
463  }
464
465  /**
466   * Report the status of each specific test case (there could be multiple
467   * in one test file).
468   *
469   * @param {string} filename
470   * @param {Test} test  The Test object returned by WPT harness
471   */
472  resultCallback(filename, test) {
473    const status = this.getTestStatus(test.status);
474    const title = this.getTestTitle(filename);
475    console.log(`---- ${title} ----`);
476    if (status !== kPass) {
477      this.fail(filename, test, status);
478    } else {
479      this.succeed(filename, test, status);
480    }
481  }
482
483  /**
484   * Report the status of each WPT test (one per file)
485   *
486   * @param {string} filename
487   * @param {object} harnessStatus - The status object returned by WPT harness.
488   */
489  completionCallback(filename, harnessStatus) {
490    // Treat it like a test case failure
491    if (harnessStatus.status === 2) {
492      const title = this.getTestTitle(filename);
493      console.log(`---- ${title} ----`);
494      this.resultCallback(filename, { status: 2, name: 'Unknown' });
495    }
496    this.inProgress.delete(filename);
497  }
498
499  addTestResult(filename, item) {
500    let result = this.results[filename];
501    if (!result) {
502      result = this.results[filename] = {};
503    }
504    if (item.status === kSkip) {
505      // { filename: { skip: 'reason' } }
506      result[kSkip] = item.reason;
507    } else {
508      // { filename: { fail: { expected: [ ... ],
509      //                      unexpected: [ ... ] } }}
510      if (!result[item.status]) {
511        result[item.status] = {};
512      }
513      const key = item.expected ? 'expected' : 'unexpected';
514      if (!result[item.status][key]) {
515        result[item.status][key] = [];
516      }
517      if (result[item.status][key].indexOf(item.reason) === -1) {
518        result[item.status][key].push(item.reason);
519      }
520    }
521  }
522
523  succeed(filename, test, status) {
524    console.log(`[${status.toUpperCase()}] ${test.name}`);
525  }
526
527  fail(filename, test, status) {
528    const spec = this.specMap.get(filename);
529    const expected = !!(spec.failReasons.length);
530    if (expected) {
531      console.log(`[EXPECTED_FAILURE][${status.toUpperCase()}] ${test.name}`);
532      console.log(spec.failReasons.join('; '));
533    } else {
534      console.log(`[UNEXPECTED_FAILURE][${status.toUpperCase()}] ${test.name}`);
535    }
536    if (status === kFail || status === kUncaught) {
537      console.log(test.message);
538      console.log(test.stack);
539    }
540    const command = `${process.execPath} ${process.execArgv}` +
541                    ` ${require.main.filename} ${filename}`;
542    console.log(`Command: ${command}\n`);
543    this.addTestResult(filename, {
544      expected,
545      status: kFail,
546      reason: test.message || status
547    });
548  }
549
550  skip(filename, reasons) {
551    const title = this.getTestTitle(filename);
552    console.log(`---- ${title} ----`);
553    const joinedReasons = reasons.join('; ');
554    console.log(`[SKIPPED] ${joinedReasons}`);
555    this.addTestResult(filename, {
556      status: kSkip,
557      reason: joinedReasons
558    });
559  }
560
561  getMeta(code) {
562    const matches = code.match(/\/\/ META: .+/g);
563    if (!matches) {
564      return {};
565    }
566    const result = {};
567    for (const match of matches) {
568      const parts = match.match(/\/\/ META: ([^=]+?)=(.+)/);
569      const key = parts[1];
570      const value = parts[2];
571      if (key === 'script') {
572        if (result[key]) {
573          result[key].push(value);
574        } else {
575          result[key] = [value];
576        }
577      } else {
578        result[key] = value;
579      }
580    }
581    return result;
582  }
583
584  buildQueue() {
585    const queue = [];
586    for (const spec of this.specMap.values()) {
587      const filename = spec.filename;
588      if (spec.skipReasons.length > 0) {
589        this.skip(filename, spec.skipReasons);
590        continue;
591      }
592
593      const lackingIntl = intlRequirements.isLacking(spec.requires);
594      if (lackingIntl) {
595        this.skip(filename, [ `requires ${lackingIntl}` ]);
596        continue;
597      }
598
599      queue.push(spec);
600    }
601    return queue;
602  }
603}
604
605module.exports = {
606  harness: harnessMock,
607  ResourceLoader,
608  WPTRunner
609};
610