mocha-runner.ts (9095B)
1 #! /usr/bin/env -S node 2 3 /** 4 * @license 5 * Copyright 2022 Google Inc. 6 * SPDX-License-Identifier: Apache-2.0 7 */ 8 9 import {spawn} from 'node:child_process'; 10 import {randomUUID} from 'node:crypto'; 11 import fs from 'node:fs'; 12 import os from 'node:os'; 13 import path from 'node:path'; 14 15 import {globSync} from 'glob'; 16 import yargs from 'yargs'; 17 import {hideBin} from 'yargs/helpers'; 18 19 import { 20 zPlatform, 21 zTestSuiteFile, 22 type MochaResults, 23 type TestExpectation, 24 type TestSuite, 25 type TestSuiteFile, 26 } from './types.js'; 27 import { 28 extendProcessEnv, 29 filterByParameters, 30 filterByPlatform, 31 getExpectationUpdates, 32 getSuggestionsForAction, 33 printSuggestions, 34 readJSON, 35 writeJSON, 36 type RecommendedExpectation, 37 } from './utils.js'; 38 39 const { 40 _: mochaArgs, 41 testSuite: testSuiteId, 42 saveStatsTo, 43 cdpTests: includeCdpTests, 44 suggestions: provideSuggestions, 45 coverage: useCoverage, 46 minTests, 47 shard, 48 reporter, 49 printMemory, 50 ignoreUnexpectedlyPassing, 51 } = yargs(hideBin(process.argv)) 52 .parserConfiguration({'unknown-options-as-args': true}) 53 .scriptName('@puppeteer/mocha-runner') 54 .option('coverage', { 55 boolean: true, 56 default: false, 57 }) 58 .option('suggestions', { 59 boolean: true, 60 default: true, 61 }) 62 .option('cdp-tests', { 63 boolean: true, 64 default: true, 65 }) 66 .option('save-stats-to', { 67 string: true, 68 requiresArg: true, 69 }) 70 .option('min-tests', { 71 number: true, 72 default: 0, 73 requiresArg: true, 74 }) 75 .option('test-suite', { 76 string: true, 77 requiresArg: true, 78 }) 79 .option('shard', { 80 string: true, 81 requiresArg: true, 82 }) 83 .option('reporter', { 84 string: true, 85 requiresArg: true, 86 }) 87 .option('print-memory', { 88 boolean: true, 89 default: false, 90 }) 91 .option('ignore-unexpectedly-passing', { 92 boolean: true, 93 default: false, 94 }) 95 .parseSync(); 96 97 function getApplicableTestSuites(parsedSuitesFile: TestSuiteFile): TestSuite[] { 98 let applicableSuites: TestSuite[] = []; 99 100 if (!testSuiteId) { 101 applicableSuites = parsedSuitesFile.testSuites; 102 } else { 103 const testSuite = parsedSuitesFile.testSuites.find(suite => { 104 return suite.id === testSuiteId; 105 }); 106 107 if (!testSuite) { 108 console.error(`Test suite ${testSuiteId} is not defined`); 109 process.exit(1); 110 } 111 112 applicableSuites = [testSuite]; 113 } 114 115 return applicableSuites; 116 } 117 118 async function main() { 119 let statsPath = saveStatsTo; 120 if (statsPath && statsPath.includes('INSERTID')) { 121 statsPath = statsPath.replace(/INSERTID/gi, randomUUID()); 122 } 123 124 const platform = zPlatform.parse(os.platform()); 125 126 const expectations = readJSON( 127 path.join(process.cwd(), 'test', 'TestExpectations.json'), 128 ) as TestExpectation[]; 129 130 const parsedSuitesFile = zTestSuiteFile.parse( 131 readJSON(path.join(process.cwd(), 'test', 'TestSuites.json')), 132 ); 133 134 const applicableSuites = getApplicableTestSuites(parsedSuitesFile); 135 136 console.log('Planning to run the following test suites', applicableSuites); 137 if (statsPath) { 138 console.log('Test stats will be saved to', statsPath); 139 } 140 141 let fail = false; 142 const recommendations: RecommendedExpectation[] = []; 143 try { 144 for (const suite of applicableSuites) { 145 const parameters = suite.parameters; 146 147 const applicableExpectations = filterByParameters( 148 filterByPlatform(expectations, platform), 149 parameters, 150 ).reverse(); 151 152 // Add more logging when the GitHub Action Debugging option is set 153 // https://docs.github.com/en/actions/learn-github-actions/variables#default-environment-variables 154 const githubActionDebugging = process.env['RUNNER_DEBUG'] 155 ? { 156 DEBUG: 'puppeteer:*', 157 EXTRA_LAUNCH_OPTIONS: JSON.stringify({ 158 dumpio: true, 159 extraPrefsFirefox: { 160 'remote.log.level': 'Trace', 161 }, 162 }), 163 } 164 : {}; 165 166 const env = extendProcessEnv([ 167 ...parameters.map(param => { 168 return parsedSuitesFile.parameterDefinitions[param]; 169 }), 170 { 171 PUPPETEER_SKIPPED_TEST_CONFIG: JSON.stringify( 172 applicableExpectations.map(ex => { 173 return { 174 testIdPattern: ex.testIdPattern, 175 skip: ex.expectations.includes('SKIP'), 176 }; 177 }), 178 ), 179 }, 180 githubActionDebugging, 181 ]); 182 183 const tmpDir = fs.mkdtempSync( 184 path.join(os.tmpdir(), 'puppeteer-test-runner-'), 185 ); 186 const tmpFilename = statsPath 187 ? statsPath 188 : path.join(tmpDir, 'output.json'); 189 console.log('Running', JSON.stringify(parameters), tmpFilename); 190 const args = [ 191 '-u', 192 path.join(__dirname, 'interface.js'), 193 '-R', 194 !reporter ? path.join(__dirname, 'reporter.js') : reporter, 195 '-O', 196 `output=${tmpFilename}`, 197 '-n', 198 'trace-warnings', 199 ]; 200 201 if (printMemory) { 202 args.push('-n', 'expose-gc'); 203 } 204 205 const specPattern = 'test/build/**/*.spec.js'; 206 const specs = globSync(specPattern, { 207 ignore: !includeCdpTests ? 'test/build/cdp/**/*.spec.js' : undefined, 208 }).sort((a, b) => { 209 return a.localeCompare(b); 210 }); 211 if (shard) { 212 // Shard ID is 1-based. 213 const [shardId, shards] = shard.split('-').map(s => { 214 return Number(s); 215 }) as [number, number]; 216 const argsLength = args.length; 217 for (let i = 0; i < specs.length; i++) { 218 if (i % shards === shardId - 1) { 219 args.push(specs[i]!); 220 } 221 } 222 if (argsLength === args.length) { 223 throw new Error('Shard did not result in any test files'); 224 } 225 console.log( 226 `Running shard ${shardId}-${shards}. Picked ${ 227 args.length - argsLength 228 } files out of ${specs.length}.`, 229 ); 230 } else { 231 args.push(...specs); 232 } 233 const handle = spawn( 234 'npx', 235 [ 236 ...(useCoverage 237 ? ['c8', '--check-coverage', '--lines', '90', 'npx'] 238 : []), 239 'mocha', 240 ...mochaArgs.map(String), 241 ...args, 242 ], 243 { 244 shell: true, 245 cwd: process.cwd(), 246 stdio: 'inherit', 247 env, 248 }, 249 ); 250 await new Promise<void>((resolve, reject) => { 251 handle.on('error', err => { 252 reject(err); 253 }); 254 handle.on('close', () => { 255 resolve(); 256 }); 257 }); 258 try { 259 const results = (() => { 260 try { 261 return readJSON(tmpFilename) as MochaResults; 262 } catch (cause) { 263 throw new Error('Test results are not found', { 264 cause, 265 }); 266 } 267 })(); 268 console.log('Finished', JSON.stringify(parameters)); 269 const updates = getExpectationUpdates( 270 results, 271 applicableExpectations, 272 { 273 platforms: [os.platform()], 274 parameters, 275 }, 276 ignoreUnexpectedlyPassing, 277 ); 278 const totalTests = results.stats.tests; 279 results.parameters = parameters; 280 results.platform = platform; 281 results.date = new Date().toISOString(); 282 if (updates.length > 0) { 283 fail = true; 284 recommendations.push(...updates); 285 results.updates = updates; 286 writeJSON(tmpFilename, results); 287 } else { 288 if (!shard && totalTests < minTests) { 289 fail = true; 290 console.log( 291 `Test run matches expectations but the number of discovered tests is too low (expected: ${minTests}, actual: ${totalTests}).`, 292 ); 293 writeJSON(tmpFilename, results); 294 continue; 295 } 296 console.log('Test run matches expectations'); 297 writeJSON(tmpFilename, results); 298 continue; 299 } 300 } catch (err) { 301 fail = true; 302 console.error(err); 303 } 304 } 305 } catch (err) { 306 fail = true; 307 console.error(err); 308 } finally { 309 const added = getSuggestionsForAction(recommendations, 'add'); 310 const removed = getSuggestionsForAction(recommendations, 'remove'); 311 const updated = getSuggestionsForAction(recommendations, 'update'); 312 if (!!provideSuggestions) { 313 printSuggestions( 314 added, 315 'Add the following to TestExpectations.json to ignore the error:', 316 true, 317 ); 318 printSuggestions( 319 removed, 320 'Remove the following from the TestExpectations.json to ignore the error:', 321 ); 322 printSuggestions( 323 updated, 324 'Update the following expectations in the TestExpectations.json to ignore the error:', 325 true, 326 ); 327 } 328 const unexpected = added.length + removed.length + updated.length; 329 console.log( 330 fail && Boolean(unexpected) 331 ? `Run failed: ${unexpected} unexpected result(s).` 332 : `Run succeeded.`, 333 ); 334 process.exit(fail ? 1 : 0); 335 } 336 } 337 338 main().catch(error => { 339 console.error(error); 340 process.exit(1); 341 });