'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 };