| 'use strict'; |
| const common = require('../common'); |
| const assert = require('assert'); |
| const fs = require('fs'); |
| const http = require('http'); |
| const fixtures = require('../common/fixtures'); |
| const { spawn } = require('child_process'); |
| const { parse: parseURL } = require('url'); |
| const { pathToFileURL } = require('url'); |
| const { EventEmitter } = require('events'); |
| |
| const _MAINSCRIPT = fixtures.path('loop.js'); |
| const DEBUG = false; |
| const TIMEOUT = common.platformTimeout(15 * 1000); |
| |
| function spawnChildProcess(inspectorFlags, scriptContents, scriptFile) { |
| const args = [].concat(inspectorFlags); |
| if (scriptContents) { |
| args.push('-e', scriptContents); |
| } else { |
| args.push(scriptFile); |
| } |
| const child = spawn(process.execPath, args); |
| |
| const handler = tearDown.bind(null, child); |
| process.on('exit', handler); |
| process.on('uncaughtException', handler); |
| common.disableCrashOnUnhandledRejection(); |
| process.on('unhandledRejection', handler); |
| process.on('SIGINT', handler); |
| |
| return child; |
| } |
| |
| function makeBufferingDataCallback(dataCallback) { |
| let buffer = Buffer.alloc(0); |
| return (data) => { |
| const newData = Buffer.concat([buffer, data]); |
| const str = newData.toString('utf8'); |
| const lines = str.replace(/\r/g, '').split('\n'); |
| if (str.endsWith('\n')) |
| buffer = Buffer.alloc(0); |
| else |
| buffer = Buffer.from(lines.pop(), 'utf8'); |
| for (const line of lines) |
| dataCallback(line); |
| }; |
| } |
| |
| function tearDown(child, err) { |
| child.kill(); |
| if (err) { |
| console.error(err); |
| process.exit(1); |
| } |
| } |
| |
| function parseWSFrame(buffer) { |
| // Protocol described in https://tools.ietf.org/html/rfc6455#section-5 |
| let message = null; |
| if (buffer.length < 2) |
| return { length: 0, message }; |
| if (buffer[0] === 0x88 && buffer[1] === 0x00) { |
| return { length: 2, message, closed: true }; |
| } |
| assert.strictEqual(buffer[0], 0x81); |
| let dataLen = 0x7F & buffer[1]; |
| let bodyOffset = 2; |
| if (buffer.length < bodyOffset + dataLen) |
| return 0; |
| if (dataLen === 126) { |
| dataLen = buffer.readUInt16BE(2); |
| bodyOffset = 4; |
| } else if (dataLen === 127) { |
| assert(buffer[2] === 0 && buffer[3] === 0, 'Inspector message too big'); |
| dataLen = buffer.readUIntBE(4, 6); |
| bodyOffset = 10; |
| } |
| if (buffer.length < bodyOffset + dataLen) |
| return { length: 0, message }; |
| const jsonPayload = |
| buffer.slice(bodyOffset, bodyOffset + dataLen).toString('utf8'); |
| try { |
| message = JSON.parse(jsonPayload); |
| } catch (e) { |
| console.error(`JSON.parse() failed for: ${jsonPayload}`); |
| throw e; |
| } |
| if (DEBUG) |
| console.log('[received]', JSON.stringify(message)); |
| return { length: bodyOffset + dataLen, message }; |
| } |
| |
| function formatWSFrame(message) { |
| const messageBuf = Buffer.from(JSON.stringify(message)); |
| |
| const wsHeaderBuf = Buffer.allocUnsafe(16); |
| wsHeaderBuf.writeUInt8(0x81, 0); |
| let byte2 = 0x80; |
| const bodyLen = messageBuf.length; |
| |
| let maskOffset = 2; |
| if (bodyLen < 126) { |
| byte2 = 0x80 + bodyLen; |
| } else if (bodyLen < 65536) { |
| byte2 = 0xFE; |
| wsHeaderBuf.writeUInt16BE(bodyLen, 2); |
| maskOffset = 4; |
| } else { |
| byte2 = 0xFF; |
| wsHeaderBuf.writeUInt32BE(bodyLen, 2); |
| wsHeaderBuf.writeUInt32BE(0, 6); |
| maskOffset = 10; |
| } |
| wsHeaderBuf.writeUInt8(byte2, 1); |
| wsHeaderBuf.writeUInt32BE(0x01020408, maskOffset); |
| |
| for (let i = 0; i < messageBuf.length; i++) |
| messageBuf[i] = messageBuf[i] ^ (1 << (i % 4)); |
| |
| return Buffer.concat([wsHeaderBuf.slice(0, maskOffset + 4), messageBuf]); |
| } |
| |
| class InspectorSession { |
| constructor(socket, instance) { |
| this._instance = instance; |
| this._socket = socket; |
| this._nextId = 1; |
| this._commandResponsePromises = new Map(); |
| this._unprocessedNotifications = []; |
| this._notificationCallback = null; |
| this._scriptsIdsByUrl = new Map(); |
| |
| let buffer = Buffer.alloc(0); |
| socket.on('data', (data) => { |
| buffer = Buffer.concat([buffer, data]); |
| do { |
| const { length, message, closed } = parseWSFrame(buffer); |
| if (!length) |
| break; |
| |
| if (closed) { |
| socket.write(Buffer.from([0x88, 0x00])); // WS close frame |
| } |
| buffer = buffer.slice(length); |
| if (message) |
| this._onMessage(message); |
| } while (true); |
| }); |
| this._terminationPromise = new Promise((resolve) => { |
| socket.once('close', resolve); |
| }); |
| } |
| |
| waitForServerDisconnect() { |
| return this._terminationPromise; |
| } |
| |
| async disconnect() { |
| this._socket.destroy(); |
| return this.waitForServerDisconnect(); |
| } |
| |
| _onMessage(message) { |
| if (message.id) { |
| const { resolve, reject } = this._commandResponsePromises.get(message.id); |
| this._commandResponsePromises.delete(message.id); |
| if (message.result) |
| resolve(message.result); |
| else |
| reject(message.error); |
| } else { |
| if (message.method === 'Debugger.scriptParsed') { |
| const { scriptId, url } = message.params; |
| this._scriptsIdsByUrl.set(scriptId, url); |
| const fileUrl = url.startsWith('file:') ? |
| url : pathToFileURL(url).toString(); |
| if (fileUrl === this.scriptURL().toString()) { |
| this.mainScriptId = scriptId; |
| } |
| } |
| |
| if (this._notificationCallback) { |
| // In case callback needs to install another |
| const callback = this._notificationCallback; |
| this._notificationCallback = null; |
| callback(message); |
| } else { |
| this._unprocessedNotifications.push(message); |
| } |
| } |
| } |
| |
| unprocessedNotifications() { |
| return this._unprocessedNotifications; |
| } |
| |
| _sendMessage(message) { |
| const msg = JSON.parse(JSON.stringify(message)); // Clone! |
| msg.id = this._nextId++; |
| if (DEBUG) |
| console.log('[sent]', JSON.stringify(msg)); |
| |
| const responsePromise = new Promise((resolve, reject) => { |
| this._commandResponsePromises.set(msg.id, { resolve, reject }); |
| }); |
| |
| return new Promise( |
| (resolve) => this._socket.write(formatWSFrame(msg), resolve)) |
| .then(() => responsePromise); |
| } |
| |
| send(commands) { |
| if (Array.isArray(commands)) { |
| // Multiple commands means the response does not matter. There might even |
| // never be a response. |
| return Promise |
| .all(commands.map((command) => this._sendMessage(command))) |
| .then(() => {}); |
| } else { |
| return this._sendMessage(commands); |
| } |
| } |
| |
| waitForNotification(methodOrPredicate, description) { |
| const desc = description || methodOrPredicate; |
| const message = `Timed out waiting for matching notification (${desc}))`; |
| return fires( |
| this._asyncWaitForNotification(methodOrPredicate), message, TIMEOUT); |
| } |
| |
| async _asyncWaitForNotification(methodOrPredicate) { |
| function matchMethod(notification) { |
| return notification.method === methodOrPredicate; |
| } |
| const predicate = |
| typeof methodOrPredicate === 'string' ? matchMethod : methodOrPredicate; |
| let notification = null; |
| do { |
| if (this._unprocessedNotifications.length) { |
| notification = this._unprocessedNotifications.shift(); |
| } else { |
| notification = await new Promise( |
| (resolve) => this._notificationCallback = resolve); |
| } |
| } while (!predicate(notification)); |
| return notification; |
| } |
| |
| _isBreakOnLineNotification(message, line, expectedScriptPath) { |
| if (message.method === 'Debugger.paused') { |
| const callFrame = message.params.callFrames[0]; |
| const location = callFrame.location; |
| const scriptPath = this._scriptsIdsByUrl.get(location.scriptId); |
| assert.strictEqual(scriptPath.toString(), |
| expectedScriptPath.toString(), |
| `${scriptPath} !== ${expectedScriptPath}`); |
| assert.strictEqual(location.lineNumber, line); |
| return true; |
| } |
| } |
| |
| waitForBreakOnLine(line, url) { |
| return this |
| .waitForNotification( |
| (notification) => |
| this._isBreakOnLineNotification(notification, line, url), |
| `break on ${url}:${line}`); |
| } |
| |
| _matchesConsoleOutputNotification(notification, type, values) { |
| if (!Array.isArray(values)) |
| values = [ values ]; |
| if (notification.method === 'Runtime.consoleAPICalled') { |
| const params = notification.params; |
| if (params.type === type) { |
| let i = 0; |
| for (const value of params.args) { |
| if (value.value !== values[i++]) |
| return false; |
| } |
| return i === values.length; |
| } |
| } |
| } |
| |
| waitForConsoleOutput(type, values) { |
| const desc = `Console output matching ${JSON.stringify(values)}`; |
| return this.waitForNotification( |
| (notification) => this._matchesConsoleOutputNotification(notification, |
| type, values), |
| desc); |
| } |
| |
| async runToCompletion() { |
| console.log('[test]', 'Verify node waits for the frontend to disconnect'); |
| await this.send({ 'method': 'Debugger.resume' }); |
| await this.waitForNotification((notification) => { |
| return notification.method === 'Runtime.executionContextDestroyed' && |
| notification.params.executionContextId === 1; |
| }); |
| while ((await this._instance.nextStderrString()) !== |
| 'Waiting for the debugger to disconnect...'); |
| await this.disconnect(); |
| } |
| |
| scriptPath() { |
| return this._instance.scriptPath(); |
| } |
| |
| script() { |
| return this._instance.script(); |
| } |
| |
| scriptURL() { |
| return pathToFileURL(this.scriptPath()); |
| } |
| } |
| |
| class NodeInstance extends EventEmitter { |
| constructor(inspectorFlags = ['--inspect-brk=0', '--expose-internals'], |
| scriptContents = '', |
| scriptFile = _MAINSCRIPT) { |
| super(); |
| |
| this._scriptPath = scriptFile; |
| this._script = scriptFile ? null : scriptContents; |
| this._portCallback = null; |
| this.portPromise = new Promise((resolve) => this._portCallback = resolve); |
| this._process = spawnChildProcess(inspectorFlags, scriptContents, |
| scriptFile); |
| this._running = true; |
| this._stderrLineCallback = null; |
| this._unprocessedStderrLines = []; |
| |
| this._process.stdout.on('data', makeBufferingDataCallback( |
| (line) => { |
| this.emit('stdout', line); |
| console.log('[out]', line); |
| })); |
| |
| this._process.stderr.on('data', makeBufferingDataCallback( |
| (message) => this.onStderrLine(message))); |
| |
| this._shutdownPromise = new Promise((resolve) => { |
| this._process.once('exit', (exitCode, signal) => { |
| resolve({ exitCode, signal }); |
| this._running = false; |
| }); |
| }); |
| } |
| |
| static async startViaSignal(scriptContents) { |
| const instance = new NodeInstance( |
| ['--expose-internals'], |
| `${scriptContents}\nprocess._rawDebug('started');`, undefined); |
| const msg = 'Timed out waiting for process to start'; |
| while (await fires(instance.nextStderrString(), msg, TIMEOUT) !== |
| 'started') {} |
| process._debugProcess(instance._process.pid); |
| return instance; |
| } |
| |
| onStderrLine(line) { |
| console.log('[err]', line); |
| if (this._portCallback) { |
| const matches = line.match(/Debugger listening on ws:\/\/.+:(\d+)\/.+/); |
| if (matches) { |
| this._portCallback(matches[1]); |
| this._portCallback = null; |
| } |
| } |
| if (this._stderrLineCallback) { |
| this._stderrLineCallback(line); |
| this._stderrLineCallback = null; |
| } else { |
| this._unprocessedStderrLines.push(line); |
| } |
| } |
| |
| httpGet(host, path, hostHeaderValue) { |
| console.log('[test]', `Testing ${path}`); |
| const headers = hostHeaderValue ? { 'Host': hostHeaderValue } : null; |
| return this.portPromise.then((port) => new Promise((resolve, reject) => { |
| const req = http.get({ host, port, path, headers }, (res) => { |
| let response = ''; |
| res.setEncoding('utf8'); |
| res |
| .on('data', (data) => response += data.toString()) |
| .on('end', () => { |
| resolve(response); |
| }); |
| }); |
| req.on('error', reject); |
| })).then((response) => { |
| try { |
| return JSON.parse(response); |
| } catch (e) { |
| e.body = response; |
| throw e; |
| } |
| }); |
| } |
| |
| async sendUpgradeRequest() { |
| const response = await this.httpGet(null, '/json/list'); |
| const devtoolsUrl = response[0].webSocketDebuggerUrl; |
| const port = await this.portPromise; |
| return http.get({ |
| port, |
| path: parseURL(devtoolsUrl).path, |
| headers: { |
| 'Connection': 'Upgrade', |
| 'Upgrade': 'websocket', |
| 'Sec-WebSocket-Version': 13, |
| 'Sec-WebSocket-Key': 'key==' |
| } |
| }); |
| } |
| |
| async connectInspectorSession() { |
| console.log('[test]', 'Connecting to a child Node process'); |
| const upgradeRequest = await this.sendUpgradeRequest(); |
| return new Promise((resolve) => { |
| upgradeRequest |
| .on('upgrade', |
| (message, socket) => resolve(new InspectorSession(socket, this))) |
| .on('response', common.mustNotCall('Upgrade was not received')); |
| }); |
| } |
| |
| async expectConnectionDeclined() { |
| console.log('[test]', 'Checking upgrade is not possible'); |
| const upgradeRequest = await this.sendUpgradeRequest(); |
| return new Promise((resolve) => { |
| upgradeRequest |
| .on('upgrade', common.mustNotCall('Upgrade was received')) |
| .on('response', (response) => |
| response.on('data', () => {}) |
| .on('end', () => resolve(response.statusCode))); |
| }); |
| } |
| |
| expectShutdown() { |
| return this._shutdownPromise; |
| } |
| |
| nextStderrString() { |
| if (this._unprocessedStderrLines.length) |
| return Promise.resolve(this._unprocessedStderrLines.shift()); |
| return new Promise((resolve) => this._stderrLineCallback = resolve); |
| } |
| |
| write(message) { |
| this._process.stdin.write(message); |
| } |
| |
| kill() { |
| this._process.kill(); |
| return this.expectShutdown(); |
| } |
| |
| scriptPath() { |
| return this._scriptPath; |
| } |
| |
| script() { |
| if (this._script === null) |
| this._script = fs.readFileSync(this.scriptPath(), 'utf8'); |
| return this._script; |
| } |
| } |
| |
| function onResolvedOrRejected(promise, callback) { |
| return promise.then((result) => { |
| callback(); |
| return result; |
| }, (error) => { |
| callback(); |
| throw error; |
| }); |
| } |
| |
| function timeoutPromise(error, timeoutMs) { |
| let clearCallback = null; |
| let done = false; |
| const promise = onResolvedOrRejected(new Promise((resolve, reject) => { |
| const timeout = setTimeout(() => reject(error), timeoutMs); |
| clearCallback = () => { |
| if (done) |
| return; |
| clearTimeout(timeout); |
| resolve(); |
| }; |
| }), () => done = true); |
| promise.clear = clearCallback; |
| return promise; |
| } |
| |
| // Returns a new promise that will propagate `promise` resolution or rejection |
| // if that happens within the `timeoutMs` timespan, or rejects with `error` as |
| // a reason otherwise. |
| function fires(promise, error, timeoutMs) { |
| const timeout = timeoutPromise(error, timeoutMs); |
| return Promise.race([ |
| onResolvedOrRejected(promise, () => timeout.clear()), |
| timeout |
| ]); |
| } |
| |
| module.exports = { |
| NodeInstance |
| }; |