1'use strict'; 2const common = require('../common'); 3const assert = require('assert'); 4const fs = require('fs'); 5const http = require('http'); 6const fixtures = require('../common/fixtures'); 7const { spawn } = require('child_process'); 8const { URL, pathToFileURL } = require('url'); 9const { EventEmitter } = require('events'); 10 11const _MAINSCRIPT = fixtures.path('loop.js'); 12const DEBUG = false; 13const TIMEOUT = common.platformTimeout(15 * 1000); 14 15function spawnChildProcess(inspectorFlags, scriptContents, scriptFile) { 16 const args = [].concat(inspectorFlags); 17 if (scriptContents) { 18 args.push('-e', scriptContents); 19 } else { 20 args.push(scriptFile); 21 } 22 const child = spawn(process.execPath, args); 23 24 const handler = tearDown.bind(null, child); 25 process.on('exit', handler); 26 process.on('uncaughtException', handler); 27 process.on('unhandledRejection', handler); 28 process.on('SIGINT', handler); 29 30 return child; 31} 32 33function makeBufferingDataCallback(dataCallback) { 34 let buffer = Buffer.alloc(0); 35 return (data) => { 36 const newData = Buffer.concat([buffer, data]); 37 const str = newData.toString('utf8'); 38 const lines = str.replace(/\r/g, '').split('\n'); 39 if (str.endsWith('\n')) 40 buffer = Buffer.alloc(0); 41 else 42 buffer = Buffer.from(lines.pop(), 'utf8'); 43 for (const line of lines) 44 dataCallback(line); 45 }; 46} 47 48function tearDown(child, err) { 49 child.kill(); 50 if (err) { 51 console.error(err); 52 process.exit(1); 53 } 54} 55 56function parseWSFrame(buffer) { 57 // Protocol described in https://tools.ietf.org/html/rfc6455#section-5 58 let message = null; 59 if (buffer.length < 2) 60 return { length: 0, message }; 61 if (buffer[0] === 0x88 && buffer[1] === 0x00) { 62 return { length: 2, message, closed: true }; 63 } 64 assert.strictEqual(buffer[0], 0x81); 65 let dataLen = 0x7F & buffer[1]; 66 let bodyOffset = 2; 67 if (buffer.length < bodyOffset + dataLen) 68 return 0; 69 if (dataLen === 126) { 70 dataLen = buffer.readUInt16BE(2); 71 bodyOffset = 4; 72 } else if (dataLen === 127) { 73 assert(buffer[2] === 0 && buffer[3] === 0, 'Inspector message too big'); 74 dataLen = buffer.readUIntBE(4, 6); 75 bodyOffset = 10; 76 } 77 if (buffer.length < bodyOffset + dataLen) 78 return { length: 0, message }; 79 const jsonPayload = 80 buffer.slice(bodyOffset, bodyOffset + dataLen).toString('utf8'); 81 try { 82 message = JSON.parse(jsonPayload); 83 } catch (e) { 84 console.error(`JSON.parse() failed for: ${jsonPayload}`); 85 throw e; 86 } 87 if (DEBUG) 88 console.log('[received]', JSON.stringify(message)); 89 return { length: bodyOffset + dataLen, message }; 90} 91 92function formatWSFrame(message) { 93 const messageBuf = Buffer.from(JSON.stringify(message)); 94 95 const wsHeaderBuf = Buffer.allocUnsafe(16); 96 wsHeaderBuf.writeUInt8(0x81, 0); 97 let byte2 = 0x80; 98 const bodyLen = messageBuf.length; 99 100 let maskOffset = 2; 101 if (bodyLen < 126) { 102 byte2 = 0x80 + bodyLen; 103 } else if (bodyLen < 65536) { 104 byte2 = 0xFE; 105 wsHeaderBuf.writeUInt16BE(bodyLen, 2); 106 maskOffset = 4; 107 } else { 108 byte2 = 0xFF; 109 wsHeaderBuf.writeUInt32BE(bodyLen, 2); 110 wsHeaderBuf.writeUInt32BE(0, 6); 111 maskOffset = 10; 112 } 113 wsHeaderBuf.writeUInt8(byte2, 1); 114 wsHeaderBuf.writeUInt32BE(0x01020408, maskOffset); 115 116 for (let i = 0; i < messageBuf.length; i++) 117 messageBuf[i] = messageBuf[i] ^ (1 << (i % 4)); 118 119 return Buffer.concat([wsHeaderBuf.slice(0, maskOffset + 4), messageBuf]); 120} 121 122class InspectorSession { 123 constructor(socket, instance) { 124 this._instance = instance; 125 this._socket = socket; 126 this._nextId = 1; 127 this._commandResponsePromises = new Map(); 128 this._unprocessedNotifications = []; 129 this._notificationCallback = null; 130 this._scriptsIdsByUrl = new Map(); 131 this._pausedDetails = null; 132 133 let buffer = Buffer.alloc(0); 134 socket.on('data', (data) => { 135 buffer = Buffer.concat([buffer, data]); 136 do { 137 const { length, message, closed } = parseWSFrame(buffer); 138 if (!length) 139 break; 140 141 if (closed) { 142 socket.write(Buffer.from([0x88, 0x00])); // WS close frame 143 } 144 buffer = buffer.slice(length); 145 if (message) 146 this._onMessage(message); 147 } while (true); 148 }); 149 this._terminationPromise = new Promise((resolve) => { 150 socket.once('close', resolve); 151 }); 152 } 153 154 155 waitForServerDisconnect() { 156 return this._terminationPromise; 157 } 158 159 async disconnect() { 160 this._socket.destroy(); 161 return this.waitForServerDisconnect(); 162 } 163 164 _onMessage(message) { 165 if (message.id) { 166 const { resolve, reject } = this._commandResponsePromises.get(message.id); 167 this._commandResponsePromises.delete(message.id); 168 if (message.result) 169 resolve(message.result); 170 else 171 reject(message.error); 172 } else { 173 if (message.method === 'Debugger.scriptParsed') { 174 const { scriptId, url } = message.params; 175 this._scriptsIdsByUrl.set(scriptId, url); 176 const fileUrl = url.startsWith('file:') ? 177 url : pathToFileURL(url).toString(); 178 if (fileUrl === this.scriptURL().toString()) { 179 this.mainScriptId = scriptId; 180 } 181 } 182 if (message.method === 'Debugger.paused') 183 this._pausedDetails = message.params; 184 if (message.method === 'Debugger.resumed') 185 this._pausedDetails = null; 186 187 if (this._notificationCallback) { 188 // In case callback needs to install another 189 const callback = this._notificationCallback; 190 this._notificationCallback = null; 191 callback(message); 192 } else { 193 this._unprocessedNotifications.push(message); 194 } 195 } 196 } 197 198 unprocessedNotifications() { 199 return this._unprocessedNotifications; 200 } 201 202 _sendMessage(message) { 203 const msg = JSON.parse(JSON.stringify(message)); // Clone! 204 msg.id = this._nextId++; 205 if (DEBUG) 206 console.log('[sent]', JSON.stringify(msg)); 207 208 const responsePromise = new Promise((resolve, reject) => { 209 this._commandResponsePromises.set(msg.id, { resolve, reject }); 210 }); 211 212 return new Promise( 213 (resolve) => this._socket.write(formatWSFrame(msg), resolve)) 214 .then(() => responsePromise); 215 } 216 217 send(commands) { 218 if (Array.isArray(commands)) { 219 // Multiple commands means the response does not matter. There might even 220 // never be a response. 221 return Promise 222 .all(commands.map((command) => this._sendMessage(command))) 223 .then(() => {}); 224 } 225 return this._sendMessage(commands); 226 } 227 228 waitForNotification(methodOrPredicate, description) { 229 const desc = description || methodOrPredicate; 230 const message = `Timed out waiting for matching notification (${desc})`; 231 return fires( 232 this._asyncWaitForNotification(methodOrPredicate), message, TIMEOUT); 233 } 234 235 async _asyncWaitForNotification(methodOrPredicate) { 236 function matchMethod(notification) { 237 return notification.method === methodOrPredicate; 238 } 239 const predicate = 240 typeof methodOrPredicate === 'string' ? matchMethod : methodOrPredicate; 241 let notification = null; 242 do { 243 if (this._unprocessedNotifications.length) { 244 notification = this._unprocessedNotifications.shift(); 245 } else { 246 notification = await new Promise( 247 (resolve) => this._notificationCallback = resolve); 248 } 249 } while (!predicate(notification)); 250 return notification; 251 } 252 253 _isBreakOnLineNotification(message, line, expectedScriptPath) { 254 if (message.method === 'Debugger.paused') { 255 const callFrame = message.params.callFrames[0]; 256 const location = callFrame.location; 257 const scriptPath = this._scriptsIdsByUrl.get(location.scriptId); 258 assert.strictEqual(scriptPath.toString(), 259 expectedScriptPath.toString(), 260 `${scriptPath} !== ${expectedScriptPath}`); 261 assert.strictEqual(location.lineNumber, line); 262 return true; 263 } 264 } 265 266 waitForBreakOnLine(line, url) { 267 return this 268 .waitForNotification( 269 (notification) => 270 this._isBreakOnLineNotification(notification, line, url), 271 `break on ${url}:${line}`); 272 } 273 274 pausedDetails() { 275 return this._pausedDetails; 276 } 277 278 _matchesConsoleOutputNotification(notification, type, values) { 279 if (!Array.isArray(values)) 280 values = [ values ]; 281 if (notification.method === 'Runtime.consoleAPICalled') { 282 const params = notification.params; 283 if (params.type === type) { 284 let i = 0; 285 for (const value of params.args) { 286 if (value.value !== values[i++]) 287 return false; 288 } 289 return i === values.length; 290 } 291 } 292 } 293 294 waitForConsoleOutput(type, values) { 295 const desc = `Console output matching ${JSON.stringify(values)}`; 296 return this.waitForNotification( 297 (notification) => this._matchesConsoleOutputNotification(notification, 298 type, values), 299 desc); 300 } 301 302 async runToCompletion() { 303 console.log('[test]', 'Verify node waits for the frontend to disconnect'); 304 await this.send({ 'method': 'Debugger.resume' }); 305 await this.waitForNotification((notification) => { 306 return notification.method === 'Runtime.executionContextDestroyed' && 307 notification.params.executionContextId === 1; 308 }); 309 while ((await this._instance.nextStderrString()) !== 310 'Waiting for the debugger to disconnect...'); 311 await this.disconnect(); 312 } 313 314 scriptPath() { 315 return this._instance.scriptPath(); 316 } 317 318 script() { 319 return this._instance.script(); 320 } 321 322 scriptURL() { 323 return pathToFileURL(this.scriptPath()); 324 } 325} 326 327class NodeInstance extends EventEmitter { 328 constructor(inspectorFlags = ['--inspect-brk=0', '--expose-internals'], 329 scriptContents = '', 330 scriptFile = _MAINSCRIPT, 331 logger = console) { 332 super(); 333 334 this._logger = logger; 335 this._scriptPath = scriptFile; 336 this._script = scriptFile ? null : scriptContents; 337 this._portCallback = null; 338 this.resetPort(); 339 this._process = spawnChildProcess(inspectorFlags, scriptContents, 340 scriptFile); 341 this._running = true; 342 this._stderrLineCallback = null; 343 this._unprocessedStderrLines = []; 344 345 this._process.stdout.on('data', makeBufferingDataCallback( 346 (line) => { 347 this.emit('stdout', line); 348 this._logger.log('[out]', line); 349 })); 350 351 this._process.stderr.on('data', makeBufferingDataCallback( 352 (message) => this.onStderrLine(message))); 353 354 this._shutdownPromise = new Promise((resolve) => { 355 this._process.once('exit', (exitCode, signal) => { 356 if (signal) { 357 this._logger.error(`[err] child process crashed, signal ${signal}`); 358 } 359 resolve({ exitCode, signal }); 360 this._running = false; 361 }); 362 }); 363 } 364 365 get pid() { 366 return this._process.pid; 367 } 368 369 resetPort() { 370 this.portPromise = new Promise((resolve) => this._portCallback = resolve); 371 } 372 373 static async startViaSignal(scriptContents) { 374 const instance = new NodeInstance( 375 ['--expose-internals', '--inspect-port=0'], 376 `${scriptContents}\nprocess._rawDebug('started');`, undefined); 377 const msg = 'Timed out waiting for process to start'; 378 while (await fires(instance.nextStderrString(), msg, TIMEOUT) !== 'started'); 379 process._debugProcess(instance._process.pid); 380 return instance; 381 } 382 383 onStderrLine(line) { 384 this.emit('stderr', line); 385 this._logger.log('[err]', line); 386 if (this._portCallback) { 387 const matches = line.match(/Debugger listening on ws:\/\/.+:(\d+)\/.+/); 388 if (matches) { 389 this._portCallback(matches[1]); 390 this._portCallback = null; 391 } 392 } 393 if (this._stderrLineCallback) { 394 this._stderrLineCallback(line); 395 this._stderrLineCallback = null; 396 } else { 397 this._unprocessedStderrLines.push(line); 398 } 399 } 400 401 httpGet(host, path, hostHeaderValue) { 402 this._logger.log('[test]', `Testing ${path}`); 403 const headers = hostHeaderValue ? { 'Host': hostHeaderValue } : null; 404 return this.portPromise.then((port) => new Promise((resolve, reject) => { 405 const req = http.get({ host, port, family: 4, path, headers }, (res) => { 406 let response = ''; 407 res.setEncoding('utf8'); 408 res 409 .on('data', (data) => response += data.toString()) 410 .on('end', () => { 411 resolve(response); 412 }); 413 }); 414 req.on('error', reject); 415 })).then((response) => { 416 try { 417 return JSON.parse(response); 418 } catch (e) { 419 e.body = response; 420 throw e; 421 } 422 }); 423 } 424 425 async sendUpgradeRequest() { 426 const response = await this.httpGet(null, '/json/list'); 427 const devtoolsUrl = response[0].webSocketDebuggerUrl; 428 const port = await this.portPromise; 429 return http.get({ 430 port, 431 family: 4, 432 path: new URL(devtoolsUrl).pathname, 433 headers: { 434 'Connection': 'Upgrade', 435 'Upgrade': 'websocket', 436 'Sec-WebSocket-Version': 13, 437 'Sec-WebSocket-Key': 'key==', 438 }, 439 }); 440 } 441 442 async connectInspectorSession() { 443 this._logger.log('[test]', 'Connecting to a child Node process'); 444 const upgradeRequest = await this.sendUpgradeRequest(); 445 return new Promise((resolve) => { 446 upgradeRequest 447 .on('upgrade', 448 (message, socket) => resolve(new InspectorSession(socket, this))) 449 .on('response', common.mustNotCall('Upgrade was not received')); 450 }); 451 } 452 453 async expectConnectionDeclined() { 454 this._logger.log('[test]', 'Checking upgrade is not possible'); 455 const upgradeRequest = await this.sendUpgradeRequest(); 456 return new Promise((resolve) => { 457 upgradeRequest 458 .on('upgrade', common.mustNotCall('Upgrade was received')) 459 .on('response', (response) => 460 response.on('data', () => {}) 461 .on('end', () => resolve(response.statusCode))); 462 }); 463 } 464 465 expectShutdown() { 466 return this._shutdownPromise; 467 } 468 469 nextStderrString() { 470 if (this._unprocessedStderrLines.length) 471 return Promise.resolve(this._unprocessedStderrLines.shift()); 472 return new Promise((resolve) => this._stderrLineCallback = resolve); 473 } 474 475 write(message) { 476 this._process.stdin.write(message); 477 } 478 479 kill() { 480 this._process.kill(); 481 return this.expectShutdown(); 482 } 483 484 scriptPath() { 485 return this._scriptPath; 486 } 487 488 script() { 489 if (this._script === null) 490 this._script = fs.readFileSync(this.scriptPath(), 'utf8'); 491 return this._script; 492 } 493} 494 495function onResolvedOrRejected(promise, callback) { 496 return promise.then((result) => { 497 callback(); 498 return result; 499 }, (error) => { 500 callback(); 501 throw error; 502 }); 503} 504 505function timeoutPromise(error, timeoutMs) { 506 let clearCallback = null; 507 let done = false; 508 const promise = onResolvedOrRejected(new Promise((resolve, reject) => { 509 const timeout = setTimeout(() => reject(error), timeoutMs); 510 clearCallback = () => { 511 if (done) 512 return; 513 clearTimeout(timeout); 514 resolve(); 515 }; 516 }), () => done = true); 517 promise.clear = clearCallback; 518 return promise; 519} 520 521// Returns a new promise that will propagate `promise` resolution or rejection 522// if that happens within the `timeoutMs` timespan, or rejects with `error` as 523// a reason otherwise. 524function fires(promise, error, timeoutMs) { 525 const timeout = timeoutPromise(error, timeoutMs); 526 return Promise.race([ 527 onResolvedOrRejected(promise, () => timeout.clear()), 528 timeout, 529 ]); 530} 531 532module.exports = { 533 NodeInstance, 534}; 535