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