Skip to content

Commit 4816434

Browse files
authored
feat(debug): persist devtools preferences in Chromium (#2266)
We store devtools-preferences.json file in the downloaded browser directory.
1 parent fbccd32 commit 4816434

File tree

5 files changed

+146
-17
lines changed

5 files changed

+146
-17
lines changed

src/chromium/crBrowser.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ import { Events } from './events';
3030
import { Protocol } from './protocol';
3131
import { CRExecutionContext } from './crExecutionContext';
3232
import { logError } from '../logger';
33+
import { CRDevTools } from './crDevTools';
3334

3435
export class CRBrowser extends BrowserBase {
3536
readonly _connection: CRConnection;
@@ -40,14 +41,16 @@ export class CRBrowser extends BrowserBase {
4041
_crPages = new Map<string, CRPage>();
4142
_backgroundPages = new Map<string, CRPage>();
4243
_serviceWorkers = new Map<string, CRServiceWorker>();
44+
_devtools?: CRDevTools;
4345

4446
private _tracingRecording = false;
4547
private _tracingPath: string | null = '';
4648
private _tracingClient: CRSession | undefined;
4749

48-
static async connect(transport: ConnectionTransport, options: BrowserOptions): Promise<CRBrowser> {
50+
static async connect(transport: ConnectionTransport, options: BrowserOptions, devtools?: CRDevTools): Promise<CRBrowser> {
4951
const connection = new CRConnection(SlowMoTransport.wrap(transport, options.slowMo), options.logger);
5052
const browser = new CRBrowser(connection, options);
53+
browser._devtools = devtools;
5154
const session = connection.rootSession;
5255
if (!options.persistent) {
5356
await session.send('Target.setAutoAttach', { autoAttach: true, waitForDebuggerOnStart: true, flatten: true });
@@ -123,6 +126,11 @@ export class CRBrowser extends BrowserBase {
123126
context = this._defaultContext;
124127
}
125128

129+
if (targetInfo.type === 'other' && targetInfo.url.startsWith('devtools://devtools') && this._devtools) {
130+
this._devtools.install(session);
131+
return;
132+
}
133+
126134
if (targetInfo.type === 'other' || !context) {
127135
if (waitingForDebugger) {
128136
// Ideally, detaching should resume any target, but there is a bug in the backend.

src/chromium/crDevTools.ts

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
/**
2+
* Copyright (c) Microsoft Corporation.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
import * as fs from 'fs';
18+
import * as util from 'util';
19+
import { CRSession } from './crConnection';
20+
21+
const kBindingName = '__pw_devtools__';
22+
23+
// This method intercepts preferences-related DevTools embedder methods
24+
// and stores preferences as a json file in the browser installation directory.
25+
export class CRDevTools {
26+
private _preferencesPath: string;
27+
private _prefs: any;
28+
private _savePromise: Promise<any>;
29+
__testHookOnBinding?: (parsed: any) => any;
30+
31+
constructor(preferencesPath: string) {
32+
this._preferencesPath = preferencesPath;
33+
this._savePromise = Promise.resolve();
34+
}
35+
36+
async install(session: CRSession) {
37+
session.on('Runtime.bindingCalled', async event => {
38+
if (event.name !== kBindingName)
39+
return;
40+
const parsed = JSON.parse(event.payload);
41+
let result = undefined;
42+
if (this.__testHookOnBinding)
43+
this.__testHookOnBinding(parsed);
44+
if (parsed.method === 'getPreferences') {
45+
if (this._prefs === undefined) {
46+
try {
47+
const json = await util.promisify(fs.readFile)(this._preferencesPath, 'utf8');
48+
this._prefs = JSON.parse(json);
49+
} catch (e) {
50+
this._prefs = {};
51+
}
52+
}
53+
result = this._prefs;
54+
} else if (parsed.method === 'setPreference') {
55+
this._prefs[parsed.params[0]] = parsed.params[1];
56+
this._save();
57+
} else if (parsed.method === 'removePreference') {
58+
delete this._prefs[parsed.params[0]];
59+
this._save();
60+
} else if (parsed.method === 'clearPreferences') {
61+
this._prefs = {};
62+
this._save();
63+
}
64+
session.send('Runtime.evaluate', {
65+
expression: `window.DevToolsAPI.embedderMessageAck(${parsed.id}, ${JSON.stringify(result)})`,
66+
contextId: event.executionContextId
67+
}).catch(e => null);
68+
});
69+
await Promise.all([
70+
session.send('Runtime.enable'),
71+
session.send('Runtime.addBinding', { name: kBindingName }),
72+
session.send('Page.enable'),
73+
session.send('Page.addScriptToEvaluateOnNewDocument', { source: `
74+
(() => {
75+
const init = () => {
76+
// Lazy init happens when InspectorFrontendHost is initialized.
77+
// At this point DevToolsHost is ready to be used.
78+
const host = window.DevToolsHost;
79+
const old = host.sendMessageToEmbedder.bind(host);
80+
host.sendMessageToEmbedder = message => {
81+
if (['getPreferences', 'setPreference', 'removePreference', 'clearPreferences'].includes(JSON.parse(message).method))
82+
window.${kBindingName}(message);
83+
else
84+
old(message);
85+
};
86+
};
87+
let value;
88+
Object.defineProperty(window, 'InspectorFrontendHost', {
89+
configurable: true,
90+
enumerable: true,
91+
get() { return value; },
92+
set(v) { value = v; init(); },
93+
});
94+
})()
95+
` }),
96+
session.send('Runtime.runIfWaitingForDebugger'),
97+
]).catch(e => null);
98+
}
99+
100+
_save() {
101+
// Serialize saves to avoid corruption.
102+
this._savePromise = this._savePromise.then(async () => {
103+
await util.promisify(fs.writeFile)(this._preferencesPath, JSON.stringify(this._prefs)).catch(e => null);
104+
});
105+
}
106+
}

src/server/browserType.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -60,12 +60,13 @@ export interface BrowserType<Browser> {
6060
export abstract class AbstractBrowserType<Browser> implements BrowserType<Browser> {
6161
private _name: string;
6262
private _executablePath: string | undefined;
63+
readonly _browserPath: string;
6364

6465
constructor(packagePath: string, browser: browserPaths.BrowserDescriptor) {
6566
this._name = browser.name;
6667
const browsersPath = browserPaths.browsersPath(packagePath);
67-
const browserPath = browserPaths.browserDirectory(browsersPath, browser);
68-
this._executablePath = browserPaths.executablePath(browserPath, browser);
68+
this._browserPath = browserPaths.browserDirectory(browsersPath, browser);
69+
this._executablePath = browserPaths.executablePath(this._browserPath, browser);
6970
}
7071

7172
executablePath(): string {

src/server/chromium.ts

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ import * as fs from 'fs';
1919
import * as os from 'os';
2020
import * as path from 'path';
2121
import * as util from 'util';
22-
import { helper, assert } from '../helper';
22+
import { helper, assert, isDebugMode } from '../helper';
2323
import { CRBrowser } from '../chromium/crBrowser';
2424
import * as ws from 'ws';
2525
import { launchProcess } from './processLauncher';
@@ -34,10 +34,19 @@ import { BrowserContext } from '../browserContext';
3434
import { InnerLogger, logError, RootLogger } from '../logger';
3535
import { BrowserDescriptor } from '../install/browserPaths';
3636
import { TimeoutSettings } from '../timeoutSettings';
37+
import { CRDevTools } from '../chromium/crDevTools';
3738

3839
export class Chromium extends AbstractBrowserType<CRBrowser> {
40+
private _devtools: CRDevTools | undefined;
41+
3942
constructor(packagePath: string, browser: BrowserDescriptor) {
4043
super(packagePath, browser);
44+
if (isDebugMode())
45+
this._devtools = this._createDevTools();
46+
}
47+
48+
private _createDevTools() {
49+
return new CRDevTools(path.join(this._browserPath, 'devtools-preferences.json'));
4150
}
4251

4352
async launch(options: LaunchOptions = {}): Promise<CRBrowser> {
@@ -48,13 +57,18 @@ export class Chromium extends AbstractBrowserType<CRBrowser> {
4857
return await browserServer._initializeOrClose(deadline, async () => {
4958
if ((options as any).__testHookBeforeCreateBrowser)
5059
await (options as any).__testHookBeforeCreateBrowser();
60+
let devtools = this._devtools;
61+
if ((options as any).__testHookForDevTools) {
62+
devtools = this._createDevTools();
63+
await (options as any).__testHookForDevTools(devtools);
64+
}
5165
return await CRBrowser.connect(transport!, {
5266
slowMo: options.slowMo,
5367
headful: !processBrowserArgOptions(options).headless,
5468
logger,
5569
downloadsPath,
56-
ownedServer: browserServer
57-
});
70+
ownedServer: browserServer,
71+
}, devtools);
5872
});
5973
}
6074

@@ -76,7 +90,7 @@ export class Chromium extends AbstractBrowserType<CRBrowser> {
7690
downloadsPath,
7791
headful: !processBrowserArgOptions(options).headless,
7892
ownedServer: browserServer
79-
});
93+
}, this._devtools);
8094
const context = browser._defaultContext!;
8195
if (!options.ignoreDefaultArgs || Array.isArray(options.ignoreDefaultArgs))
8296
await context._loadDefaultContext();

test/chromium/launcher.spec.js

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -33,16 +33,16 @@ describe('launcher', function() {
3333
await browser.close();
3434
});
3535
it('should open devtools when "devtools: true" option is given', async({browserType, defaultBrowserOptions}) => {
36-
const browser = await browserType.launch(Object.assign({devtools: true}, {...defaultBrowserOptions, headless: false}));
36+
let devtoolsCallback;
37+
const devtoolsPromise = new Promise(f => devtoolsCallback = f);
38+
const __testHookForDevTools = devtools => devtools.__testHookOnBinding = parsed => {
39+
if (parsed.method === 'getPreferences')
40+
devtoolsCallback();
41+
};
42+
const browser = await browserType.launch({...defaultBrowserOptions, headless: false, devtools: true, __testHookForDevTools});
3743
const context = await browser.newContext();
38-
const browserSession = await browser.newBrowserCDPSession();
39-
await browserSession.send('Target.setDiscoverTargets', { discover: true });
40-
const devtoolsPagePromise = new Promise(fulfill => browserSession.on('Target.targetCreated', async ({targetInfo}) => {
41-
if (targetInfo.type === 'other' && targetInfo.url.includes('devtools://'))
42-
fulfill();
43-
}));
4444
await Promise.all([
45-
devtoolsPagePromise,
45+
devtoolsPromise,
4646
context.newPage()
4747
]);
4848
await browser.close();
@@ -74,8 +74,8 @@ describe('extensions', () => {
7474
});
7575

7676
describe('BrowserContext', function() {
77-
it('should not create pages automatically', async ({browserType}) => {
78-
const browser = await browserType.launch();
77+
it('should not create pages automatically', async ({browserType, defaultBrowserOptions}) => {
78+
const browser = await browserType.launch(defaultBrowserOptions);
7979
const browserSession = await browser.newBrowserCDPSession();
8080
const targets = [];
8181
browserSession.on('Target.targetCreated', async ({targetInfo}) => {

0 commit comments

Comments
 (0)