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