• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
1 'use strict';
2 
3 const assert = require('assert');
4 const fixtures = require('../common/fixtures');
5 const fs = require('fs');
6 const fsPromises = fs.promises;
7 const path = require('path');
8 const { inspect } = require('util');
9 const { 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
14 const 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 
39 class 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 
80 class 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 
105 class 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
141 class 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 
184 const 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 
191 class 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 
222 const intlRequirements = new IntlRequirement();
223 
224 class 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 
276 const kPass = 'pass';
277 const kFail = 'fail';
278 const kSkip = 'skip';
279 const kTimeout = 'timeout';
280 const kIncomplete = 'incomplete';
281 const kUncaught = 'uncaught';
282 const NODE_UNCAUGHT = 100;
283 
284 class 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 
605 module.exports = {
606   harness: harnessMock,
607   ResourceLoader,
608   WPTRunner
609 };
610