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