From 481643409eab07323109cb7851ccc5efd4298fc8 Mon Sep 17 00:00:00 2001 From: Dmitry Gozman Date: Tue, 19 May 2020 14:55:11 -0700 Subject: [PATCH] feat(debug): persist devtools preferences in Chromium (#2266) We store devtools-preferences.json file in the downloaded browser directory. --- src/chromium/crBrowser.ts | 10 +++- src/chromium/crDevTools.ts | 106 +++++++++++++++++++++++++++++++++ src/server/browserType.ts | 5 +- src/server/chromium.ts | 22 +++++-- test/chromium/launcher.spec.js | 20 +++---- 5 files changed, 146 insertions(+), 17 deletions(-) create mode 100644 src/chromium/crDevTools.ts diff --git a/src/chromium/crBrowser.ts b/src/chromium/crBrowser.ts index cdc90d0a53..9dc6c9a802 100644 --- a/src/chromium/crBrowser.ts +++ b/src/chromium/crBrowser.ts @@ -30,6 +30,7 @@ import { Events } from './events'; import { Protocol } from './protocol'; import { CRExecutionContext } from './crExecutionContext'; import { logError } from '../logger'; +import { CRDevTools } from './crDevTools'; export class CRBrowser extends BrowserBase { readonly _connection: CRConnection; @@ -40,14 +41,16 @@ export class CRBrowser extends BrowserBase { _crPages = new Map(); _backgroundPages = new Map(); _serviceWorkers = new Map(); + _devtools?: CRDevTools; private _tracingRecording = false; private _tracingPath: string | null = ''; private _tracingClient: CRSession | undefined; - static async connect(transport: ConnectionTransport, options: BrowserOptions): Promise { + static async connect(transport: ConnectionTransport, options: BrowserOptions, devtools?: CRDevTools): Promise { const connection = new CRConnection(SlowMoTransport.wrap(transport, options.slowMo), options.logger); const browser = new CRBrowser(connection, options); + browser._devtools = devtools; const session = connection.rootSession; if (!options.persistent) { await session.send('Target.setAutoAttach', { autoAttach: true, waitForDebuggerOnStart: true, flatten: true }); @@ -123,6 +126,11 @@ export class CRBrowser extends BrowserBase { context = this._defaultContext; } + if (targetInfo.type === 'other' && targetInfo.url.startsWith('devtools://devtools') && this._devtools) { + this._devtools.install(session); + return; + } + if (targetInfo.type === 'other' || !context) { if (waitingForDebugger) { // Ideally, detaching should resume any target, but there is a bug in the backend. diff --git a/src/chromium/crDevTools.ts b/src/chromium/crDevTools.ts new file mode 100644 index 0000000000..dd4e06e82f --- /dev/null +++ b/src/chromium/crDevTools.ts @@ -0,0 +1,106 @@ +/** + * Copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import * as fs from 'fs'; +import * as util from 'util'; +import { CRSession } from './crConnection'; + +const kBindingName = '__pw_devtools__'; + +// This method intercepts preferences-related DevTools embedder methods +// and stores preferences as a json file in the browser installation directory. +export class CRDevTools { + private _preferencesPath: string; + private _prefs: any; + private _savePromise: Promise; + __testHookOnBinding?: (parsed: any) => any; + + constructor(preferencesPath: string) { + this._preferencesPath = preferencesPath; + this._savePromise = Promise.resolve(); + } + + async install(session: CRSession) { + session.on('Runtime.bindingCalled', async event => { + if (event.name !== kBindingName) + return; + const parsed = JSON.parse(event.payload); + let result = undefined; + if (this.__testHookOnBinding) + this.__testHookOnBinding(parsed); + if (parsed.method === 'getPreferences') { + if (this._prefs === undefined) { + try { + const json = await util.promisify(fs.readFile)(this._preferencesPath, 'utf8'); + this._prefs = JSON.parse(json); + } catch (e) { + this._prefs = {}; + } + } + result = this._prefs; + } else if (parsed.method === 'setPreference') { + this._prefs[parsed.params[0]] = parsed.params[1]; + this._save(); + } else if (parsed.method === 'removePreference') { + delete this._prefs[parsed.params[0]]; + this._save(); + } else if (parsed.method === 'clearPreferences') { + this._prefs = {}; + this._save(); + } + session.send('Runtime.evaluate', { + expression: `window.DevToolsAPI.embedderMessageAck(${parsed.id}, ${JSON.stringify(result)})`, + contextId: event.executionContextId + }).catch(e => null); + }); + await Promise.all([ + session.send('Runtime.enable'), + session.send('Runtime.addBinding', { name: kBindingName }), + session.send('Page.enable'), + session.send('Page.addScriptToEvaluateOnNewDocument', { source: ` + (() => { + const init = () => { + // Lazy init happens when InspectorFrontendHost is initialized. + // At this point DevToolsHost is ready to be used. + const host = window.DevToolsHost; + const old = host.sendMessageToEmbedder.bind(host); + host.sendMessageToEmbedder = message => { + if (['getPreferences', 'setPreference', 'removePreference', 'clearPreferences'].includes(JSON.parse(message).method)) + window.${kBindingName}(message); + else + old(message); + }; + }; + let value; + Object.defineProperty(window, 'InspectorFrontendHost', { + configurable: true, + enumerable: true, + get() { return value; }, + set(v) { value = v; init(); }, + }); + })() + ` }), + session.send('Runtime.runIfWaitingForDebugger'), + ]).catch(e => null); + } + + _save() { + // Serialize saves to avoid corruption. + this._savePromise = this._savePromise.then(async () => { + await util.promisify(fs.writeFile)(this._preferencesPath, JSON.stringify(this._prefs)).catch(e => null); + }); + } +} diff --git a/src/server/browserType.ts b/src/server/browserType.ts index 07bb981041..56f8323956 100644 --- a/src/server/browserType.ts +++ b/src/server/browserType.ts @@ -60,12 +60,13 @@ export interface BrowserType { export abstract class AbstractBrowserType implements BrowserType { private _name: string; private _executablePath: string | undefined; + readonly _browserPath: string; constructor(packagePath: string, browser: browserPaths.BrowserDescriptor) { this._name = browser.name; const browsersPath = browserPaths.browsersPath(packagePath); - const browserPath = browserPaths.browserDirectory(browsersPath, browser); - this._executablePath = browserPaths.executablePath(browserPath, browser); + this._browserPath = browserPaths.browserDirectory(browsersPath, browser); + this._executablePath = browserPaths.executablePath(this._browserPath, browser); } executablePath(): string { diff --git a/src/server/chromium.ts b/src/server/chromium.ts index b0cbc9ef68..e7690a9713 100644 --- a/src/server/chromium.ts +++ b/src/server/chromium.ts @@ -19,7 +19,7 @@ import * as fs from 'fs'; import * as os from 'os'; import * as path from 'path'; import * as util from 'util'; -import { helper, assert } from '../helper'; +import { helper, assert, isDebugMode } from '../helper'; import { CRBrowser } from '../chromium/crBrowser'; import * as ws from 'ws'; import { launchProcess } from './processLauncher'; @@ -34,10 +34,19 @@ import { BrowserContext } from '../browserContext'; import { InnerLogger, logError, RootLogger } from '../logger'; import { BrowserDescriptor } from '../install/browserPaths'; import { TimeoutSettings } from '../timeoutSettings'; +import { CRDevTools } from '../chromium/crDevTools'; export class Chromium extends AbstractBrowserType { + private _devtools: CRDevTools | undefined; + constructor(packagePath: string, browser: BrowserDescriptor) { super(packagePath, browser); + if (isDebugMode()) + this._devtools = this._createDevTools(); + } + + private _createDevTools() { + return new CRDevTools(path.join(this._browserPath, 'devtools-preferences.json')); } async launch(options: LaunchOptions = {}): Promise { @@ -48,13 +57,18 @@ export class Chromium extends AbstractBrowserType { return await browserServer._initializeOrClose(deadline, async () => { if ((options as any).__testHookBeforeCreateBrowser) await (options as any).__testHookBeforeCreateBrowser(); + let devtools = this._devtools; + if ((options as any).__testHookForDevTools) { + devtools = this._createDevTools(); + await (options as any).__testHookForDevTools(devtools); + } return await CRBrowser.connect(transport!, { slowMo: options.slowMo, headful: !processBrowserArgOptions(options).headless, logger, downloadsPath, - ownedServer: browserServer - }); + ownedServer: browserServer, + }, devtools); }); } @@ -76,7 +90,7 @@ export class Chromium extends AbstractBrowserType { downloadsPath, headful: !processBrowserArgOptions(options).headless, ownedServer: browserServer - }); + }, this._devtools); const context = browser._defaultContext!; if (!options.ignoreDefaultArgs || Array.isArray(options.ignoreDefaultArgs)) await context._loadDefaultContext(); diff --git a/test/chromium/launcher.spec.js b/test/chromium/launcher.spec.js index 3c41f3df48..769e3aadd8 100644 --- a/test/chromium/launcher.spec.js +++ b/test/chromium/launcher.spec.js @@ -33,16 +33,16 @@ describe('launcher', function() { await browser.close(); }); it('should open devtools when "devtools: true" option is given', async({browserType, defaultBrowserOptions}) => { - const browser = await browserType.launch(Object.assign({devtools: true}, {...defaultBrowserOptions, headless: false})); + let devtoolsCallback; + const devtoolsPromise = new Promise(f => devtoolsCallback = f); + const __testHookForDevTools = devtools => devtools.__testHookOnBinding = parsed => { + if (parsed.method === 'getPreferences') + devtoolsCallback(); + }; + const browser = await browserType.launch({...defaultBrowserOptions, headless: false, devtools: true, __testHookForDevTools}); const context = await browser.newContext(); - const browserSession = await browser.newBrowserCDPSession(); - await browserSession.send('Target.setDiscoverTargets', { discover: true }); - const devtoolsPagePromise = new Promise(fulfill => browserSession.on('Target.targetCreated', async ({targetInfo}) => { - if (targetInfo.type === 'other' && targetInfo.url.includes('devtools://')) - fulfill(); - })); await Promise.all([ - devtoolsPagePromise, + devtoolsPromise, context.newPage() ]); await browser.close(); @@ -74,8 +74,8 @@ describe('extensions', () => { }); describe('BrowserContext', function() { - it('should not create pages automatically', async ({browserType}) => { - const browser = await browserType.launch(); + it('should not create pages automatically', async ({browserType, defaultBrowserOptions}) => { + const browser = await browserType.launch(defaultBrowserOptions); const browserSession = await browser.newBrowserCDPSession(); const targets = []; browserSession.on('Target.targetCreated', async ({targetInfo}) => {