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