• 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 { 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