From 06aee790b8349a6ca747f59b64116d1efb845cef Mon Sep 17 00:00:00 2001 From: Denis Sikuler Date: Mon, 16 Mar 2020 19:22:16 +0300 Subject: [PATCH] feat: add waitForInspectableTarget option (fix #145) --- README.md | 5 ++ package.json | 2 + src/chrome-launcher.ts | 42 +++++++++++- test/chrome-launcher-test.ts | 122 +++++++++++++++++++++++++++++++++++ yarn.lock | 17 +++++ 5 files changed, 186 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index f45e0e285..c9f465a86 100644 --- a/README.md +++ b/README.md @@ -77,6 +77,11 @@ npm install chrome-launcher // Default: 50 maxConnectionRetries: number; + // (optional) Interval in ms, which defines whether and how long an inspectable target should be awaited. + // `0` means that list of inspectable targets will not be requested and awaited. + // Default: 0 + waitForInspectableTarget: number; + // (optional) A dict of environmental key value pairs to pass to the spawned chrome process. envVars: {[key: string]: string}; }; diff --git a/package.json b/package.json index f0465a214..7b2d620de 100644 --- a/package.json +++ b/package.json @@ -25,6 +25,8 @@ "is-wsl": "^2.1.0", "lighthouse-logger": "^1.0.0", "mkdirp": "0.5.1", + "phin": "^3.4.1", + "povtor": "^1.1.0", "rimraf": "^2.6.1" }, "version": "0.13.0", diff --git a/src/chrome-launcher.ts b/src/chrome-launcher.ts index 66ede149a..e6db523e7 100644 --- a/src/chrome-launcher.ts +++ b/src/chrome-launcher.ts @@ -8,6 +8,8 @@ import * as childProcess from 'child_process'; import * as fs from 'fs'; import * as net from 'net'; +import * as phin from 'phin'; +import {retry} from 'povtor'; import * as rimraf from 'rimraf'; import * as chromeFinder from './chrome-finder'; import {getRandomPort} from './random-port'; @@ -40,6 +42,7 @@ export interface Options { ignoreDefaultFlags?: boolean; connectionPollInterval?: number; maxConnectionRetries?: number; + waitForInspectableTarget?: number; envVars?: {[key: string]: string|undefined}; } @@ -112,6 +115,7 @@ class Launcher { private requestedPort?: number; private connectionPollInterval: number; private maxConnectionRetries: number; + private waitForInspectableTarget: number; private fs: typeof fs; private rimraf: RimrafModule; private spawn: typeof childProcess.spawn; @@ -122,6 +126,7 @@ class Launcher { userDataDir?: string; port?: number; pid?: number; + getTargetRetryTimeout: number = 500; constructor(private opts: Options = {}, moduleOverrides: ModuleOverrides = {}) { this.fs = moduleOverrides.fs || fs; @@ -138,6 +143,7 @@ class Launcher { this.ignoreDefaultFlags = defaults(this.opts.ignoreDefaultFlags, false); this.connectionPollInterval = defaults(this.opts.connectionPollInterval, 500); this.maxConnectionRetries = defaults(this.opts.maxConnectionRetries, 50); + this.waitForInspectableTarget = defaults(this.opts.waitForInspectableTarget, 0); this.envVars = defaults(opts.envVars, Object.assign({}, process.env)); if (typeof this.opts.userDataDir === 'boolean') { @@ -278,8 +284,26 @@ class Launcher { } } + getTargetList() { + return phin({ + url: `http://127.0.0.1:${this.port}/json/list`, + parse: 'json' + }); + } + + waitForTarget() { + return retry({ + action: this.getTargetList, + actionContext: this, + retryOnError: true, + retryTest: (response: phin.IResponse) => !response || !Array.isArray(response.body) || !response.body.length, + retryTimeout: this.getTargetRetryTimeout, + timeLimit: this.waitForInspectableTarget + }).promise; + } + // resolves if ready, rejects otherwise - private isDebuggerReady(): Promise<{}> { + isDebuggerReady(): Promise<{}> { return new Promise((resolve, reject) => { const client = net.createConnection(this.port!); client.once('error', err => { @@ -312,7 +336,21 @@ class Launcher { launcher.isDebuggerReady() .then(() => { log.log('ChromeLauncher', waitStatus + `${log.greenify(log.tick)}`); - resolve(); + if (launcher.waitForInspectableTarget > 0) { + log.log('ChromeLauncher', 'Waiting for an inspectable target...'); + launcher.waitForTarget() + .then((response: phin.IResponse) => { + log.log('ChromeLauncher', 'Received target list: %O', response.body); + resolve(response.body); + }) + .catch((reason: unknown) => { + log.error('ChromeLauncher', `Cannot get target list. Reason: ${reason}`); + reject(reason); + }); + } + else { + resolve(); + } }) .catch(err => { if (retries > launcher.maxConnectionRetries) { diff --git a/test/chrome-launcher-test.ts b/test/chrome-launcher-test.ts index b2672b670..4e2388239 100644 --- a/test/chrome-launcher-test.ts +++ b/test/chrome-launcher-test.ts @@ -183,4 +183,126 @@ describe('Launcher', () => { const chromeInstance = new Launcher({chromePath: ''}); chromeInstance.launch().catch(() => done()); }); + + describe('waitForTarget method', () => { + function getChromeInstance(targetList: unknown, waitForInspectableTarget?: number) { + const chromeInstance = new Launcher({waitForInspectableTarget: typeof waitForInspectableTarget === 'number' ? waitForInspectableTarget : 100}); + const getTargetListStub = stub(chromeInstance, 'getTargetList').returns(Promise.resolve(targetList)); + + return { + chromeInstance, + getTargetListStub + }; + } + + it('returns promise that is resolved with the same value as promise returned by getTargetList method', () => { + const response = { + body: ['test', 'list'] + }; + const {chromeInstance, getTargetListStub} = getChromeInstance(response); + + return chromeInstance.waitForTarget().then((result) => { + assert.ok(getTargetListStub.calledOnce); + assert.strictEqual(result, response); + }); + }); + + it('returns promise that is resolved with the same value as promise returned by getTargetList method after interval specified in waitForInspectableTarget option', () => { + const response = { + body: [] + }; + const waitTime = 90; + const {chromeInstance, getTargetListStub} = getChromeInstance(response, waitTime); + chromeInstance.getTargetRetryTimeout = 50; + const startTime = new Date().getTime(); + + return chromeInstance.waitForTarget().then((result) => { + assert.ok(getTargetListStub.callCount === 3); + assert.ok(new Date().getTime() - startTime > waitTime); + assert.strictEqual(result, response); + }); + }); + + it('returns promise that is rejected with the same value as promise returned by getTargetList method after interval specified in waitForInspectableTarget option', () => { + const reason = 'No target'; + const waitTime = 100; + const {chromeInstance, getTargetListStub} = getChromeInstance(Promise.reject(reason), waitTime); + chromeInstance.getTargetRetryTimeout = 40; + const startTime = new Date().getTime(); + + return chromeInstance.waitForTarget().catch((result) => { + assert.ok(getTargetListStub.callCount === 4); + assert.ok(new Date().getTime() - startTime > waitTime); + assert.strictEqual(result, reason); + }); + }); + }); + + describe('waitForInspectableTarget option', () => { + function getChromeInstance(options?: Options) { + const chromeInstance = new Launcher(options); + stub(chromeInstance, 'isDebuggerReady').returns(Promise.resolve()); + + return chromeInstance; + } + + it('waitUntilReady does not call waitForTarget method when the option is not set', () => { + const chromeInstance = getChromeInstance(); + const waitForTargetSpy = spy(chromeInstance, 'waitForTarget'); + + return chromeInstance.waitUntilReady().then(() => { + assert.ok(waitForTargetSpy.notCalled); + }); + }); + + it('waitUntilReady does not call waitForTarget method when 0 is set for the option', () => { + const chromeInstance = getChromeInstance({waitForInspectableTarget: 0}); + const waitForTargetSpy = spy(chromeInstance, 'waitForTarget'); + + return chromeInstance.waitUntilReady().then(() => { + assert.ok(waitForTargetSpy.notCalled); + }); + }); + + it('waitUntilReady does not call waitForTarget method when negative value is set for the option', () => { + const chromeInstance = getChromeInstance({waitForInspectableTarget: -1}); + const waitForTargetSpy = spy(chromeInstance, 'waitForTarget'); + + return chromeInstance.waitUntilReady().then(() => { + assert.ok(waitForTargetSpy.notCalled); + }); + }); + + it('waitUntilReady calls waitForTarget method when the option is set', () => { + const chromeInstance = getChromeInstance({waitForInspectableTarget: 1000}); + const response = { + body: [{ + description: '', + devtoolsFrontendUrl: '/devtools/inspector.html?ws=127.0.0.1:54321/devtools/page/1C2C62A45591F2DECB9CC50E7C3B1FA5', + id: '1C2C62A45591F2DECB9CC50E7C3B1FA5', + title: '', + type: 'page', + url: 'about:blank', + webSocketDebuggerUrl: 'ws://127.0.0.1:54321/devtools/page/1C2C62A45591F2DECB9CC50E7C3B1FA5' + }] + }; + const waitForTargetStub = stub(chromeInstance, 'waitForTarget').returns(Promise.resolve(response)); + + return chromeInstance.waitUntilReady().then((result) => { + assert.ok(waitForTargetStub.calledOnce); + assert.deepEqual(result, response.body); + }); + }); + + it('waitUntilReady rejects when waitForTarget method returns rejected promise', () => { + const chromeInstance = getChromeInstance({waitForInspectableTarget: 1}); + const reason = 'No targets'; + const waitForTargetStub = stub(chromeInstance, 'waitForTarget').returns(Promise.reject(reason)); + + return chromeInstance.waitUntilReady().catch((result) => { + assert.ok(waitForTargetStub.calledOnce); + assert.strictEqual(result, reason); + }); + }); + }); }); diff --git a/yarn.lock b/yarn.lock index 5759ffcaf..bb77b33b9 100644 --- a/yarn.lock +++ b/yarn.lock @@ -87,6 +87,11 @@ camelcase@^5.0.0: resolved "https://registry.yarnpkg.com/camelcase/-/camelcase-5.3.1.tgz#e3c9b31569e106811df242f715725a1f4c494320" integrity sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg== +centra@^2.2.1: + version "2.4.0" + resolved "https://registry.yarnpkg.com/centra/-/centra-2.4.0.tgz#53846f97db27705e9f90c46e0f824f6eb697e2d1" + integrity sha512-AWmF3EHNe/noJHviynZOrdnUuQzT5AMgl9nJPXGvnzGXrI2ZvNDrEcdqskc4EtQwt2Q1IggXb0OXy7zZ1Xvvew== + chalk@^2.0.1: version "2.4.2" resolved "https://registry.yarnpkg.com/chalk/-/chalk-2.4.2.tgz#cd42541677a54333cf541a49108c1432b44c9424" @@ -518,6 +523,18 @@ path-to-regexp@^1.7.0: dependencies: isarray "0.0.1" +phin@^3.4.1: + version "3.4.1" + resolved "https://registry.yarnpkg.com/phin/-/phin-3.4.1.tgz#b023d14fa86fc6e4b40b6d0dfd5fe478c9c1bbb8" + integrity sha512-NkBCNRPxeyrgaPlWx4DHTAdca3s2LkvIBiiG6RoSbykcOtW/pA/7rUP/67FPIinvbo7h+HENST/vJ17LdRNUdw== + dependencies: + centra "^2.2.1" + +povtor@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/povtor/-/povtor-1.1.0.tgz#bebe6618c0bcd0df55bd9f6dd2bebebb4d15c5a5" + integrity sha512-gUhd8L9iC4rSipLzx3mCInjusheig56wDrQLiwi5DH5FuumXJE0fEtvZNuheDqjXgMxARLoCz2erqOaa6Trgiw== + require-directory@^2.1.1: version "2.1.1" resolved "https://registry.yarnpkg.com/require-directory/-/require-directory-2.1.1.tgz#8c64ad5fd30dab1c976e2344ffe7f792a6a6df42"