Skip to content

Commit 9817a05

Browse files
authored
feat: Support remote-debugging-pipe (#347)
And add loadExtension test/example
1 parent 324780d commit 9817a05

File tree

7 files changed

+286
-6
lines changed

7 files changed

+286
-6
lines changed

README.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,8 +109,19 @@ chrome.pid: number;
109109

110110
// The childProcess object for the launched Chrome
111111
chrome.process: childProcess
112+
113+
// If chromeFlags contains --remote-debugging-pipe. Otherwise remoteDebuggingPipes is null.
114+
chrome.remoteDebuggingPipes.incoming: ReadableStream
115+
chrome.remoteDebuggingPipes.outgoing: WritableStream
112116
```
113117

118+
When `--remote-debugging-pipe` is passed via `chromeFlags`, then `port` will be
119+
unusable (0) by default. Instead, debugging messages are exchanged via
120+
`remoteDebuggingPipes.incoming` and `remoteDebuggingPipes.outgoing`. The data
121+
in these pipes are JSON values terminated by a NULL byte (`\x00`).
122+
Data written to `remoteDebuggingPipes.outgoing` are sent to Chrome,
123+
data read from `remoteDebuggingPipes.incoming` are received from Chrome.
124+
114125
### `ChromeLauncher.Launcher.defaultFlags()`
115126

116127
Returns an `Array<string>` of the default [flags](docs/chrome-flags-for-tools.md) Chrome is launched with. Typically used along with the `ignoreDefaultFlags` and `chromeFlags` options.
@@ -176,6 +187,9 @@ ChromeLauncher.launch({
176187
}).then(chrome => { ... });
177188
```
178189

190+
To programatically load an extension at runtime, use `--remote-debugging-pipe`
191+
as shown in [test/load-extension-test.ts](test/load-extension-test.ts).
192+
179193
### Continuous Integration
180194

181195
In a CI environment like Travis, Chrome may not be installed. If you want to use `chrome-launcher`, Travis can [install Chrome at run time with an addon](https://docs.travis-ci.com/user/chrome). Alternatively, you can also install Chrome using the [`download-chrome.sh`](https://raw.githubusercontent.com/GoogleChrome/chrome-launcher/v0.8.0/scripts/download-chrome.sh) script.

src/chrome-launcher.ts

Lines changed: 45 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -44,10 +44,15 @@ export interface Options {
4444
envVars?: {[key: string]: string|undefined};
4545
}
4646

47+
export interface RemoteDebuggingPipes {
48+
incoming: NodeJS.ReadableStream, outgoing: NodeJS.WritableStream,
49+
}
50+
4751
export interface LaunchedChrome {
4852
pid: number;
4953
port: number;
5054
process: ChildProcess;
55+
remoteDebuggingPipes: RemoteDebuggingPipes|null;
5156
kill: () => void;
5257
}
5358

@@ -82,7 +87,13 @@ async function launch(opts: Options = {}): Promise<LaunchedChrome> {
8287
instance.kill();
8388
};
8489

85-
return {pid: instance.pid!, port: instance.port!, kill, process: instance.chromeProcess!};
90+
return {
91+
pid: instance.pid!,
92+
port: instance.port!,
93+
process: instance.chromeProcess!,
94+
remoteDebuggingPipes: instance.remoteDebuggingPipes,
95+
kill,
96+
};
8697
}
8798

8899
/** Returns Chrome installation path that chrome-launcher will launch by default. */
@@ -121,6 +132,7 @@ class Launcher {
121132
private prefs: Record<string, JSONLike>;
122133
private requestedPort?: number;
123134
private portStrictMode?: boolean;
135+
private useRemoteDebuggingPipe: boolean;
124136
private connectionPollInterval: number;
125137
private maxConnectionRetries: number;
126138
private fs: typeof fs;
@@ -131,6 +143,7 @@ class Launcher {
131143
chromeProcess?: childProcess.ChildProcess;
132144
userDataDir?: string;
133145
port?: number;
146+
remoteDebuggingPipes: RemoteDebuggingPipes|null = null;
134147
pid?: number;
135148

136149
constructor(private opts: Options = {}, moduleOverrides: ModuleOverrides = {}) {
@@ -162,11 +175,18 @@ class Launcher {
162175
this.useDefaultProfile = false;
163176
this.userDataDir = this.opts.userDataDir;
164177
}
178+
179+
// Using startsWith because it could also be --remote-debugging-pipe=cbor
180+
this.useRemoteDebuggingPipe =
181+
this.chromeFlags.some(f => f.startsWith('--remote-debugging-pipe'));
165182
}
166183

167184
private get flags() {
168185
const flags = this.ignoreDefaultFlags ? [] : DEFAULT_FLAGS.slice();
169-
flags.push(`--remote-debugging-port=${this.port}`);
186+
// When useRemoteDebuggingPipe is true, this.port defaults to 0.
187+
if (this.port) {
188+
flags.push(`--remote-debugging-port=${this.port}`);
189+
}
170190

171191
if (!this.ignoreDefaultFlags && getPlatform() === 'linux') {
172192
flags.push('--disable-setuid-sandbox');
@@ -305,7 +325,12 @@ class Launcher {
305325
// We do this here so that we can know the port before
306326
// we pass it into chrome.
307327
if (this.requestedPort === 0) {
308-
this.port = await getRandomPort();
328+
if (this.useRemoteDebuggingPipe) {
329+
// When useRemoteDebuggingPipe is true, this.port defaults to 0.
330+
this.port = 0;
331+
} else {
332+
this.port = await getRandomPort();
333+
}
309334
}
310335

311336
log.verbose(
@@ -315,13 +340,21 @@ class Launcher {
315340
// process group, making it possible to kill child process tree with `.kill(-pid)` command.
316341
// @see https://nodejs.org/api/child_process.html#child_process_options_detached
317342
detached: process.platform !== 'win32',
318-
stdio: ['ignore', this.outFile, this.errFile],
343+
stdio: this.useRemoteDebuggingPipe ?
344+
['ignore', this.outFile, this.errFile, 'pipe', 'pipe'] :
345+
['ignore', this.outFile, this.errFile],
319346
env: this.envVars
320347
});
321348

322349
if (this.chromeProcess.pid) {
323350
this.fs.writeFileSync(this.pidFile, this.chromeProcess.pid.toString());
324351
}
352+
if (this.useRemoteDebuggingPipe) {
353+
this.remoteDebuggingPipes = {
354+
incoming: this.chromeProcess.stdio[4] as NodeJS.ReadableStream,
355+
outgoing: this.chromeProcess.stdio[3] as NodeJS.WritableStream,
356+
};
357+
}
325358

326359
log.verbose(
327360
'ChromeLauncher',
@@ -330,7 +363,10 @@ class Launcher {
330363
})();
331364

332365
const pid = await spawnPromise;
333-
await this.waitUntilReady();
366+
// When useRemoteDebuggingPipe is true, this.port defaults to 0.
367+
if (this.port !== 0) {
368+
await this.waitUntilReady();
369+
}
334370
return pid;
335371
}
336372

@@ -346,6 +382,10 @@ class Launcher {
346382
// resolves if ready, rejects otherwise
347383
private isDebuggerReady(): Promise<void> {
348384
return new Promise((resolve, reject) => {
385+
// Note: only meaningful when this.port is set.
386+
// When useRemoteDebuggingPipe is true, this.port defaults to 0. In that
387+
// case, we could consider ping-ponging over the pipe, but that may get
388+
// in the way of the library user, so we do not.
349389
const client = net.createConnection(this.port!, '127.0.0.1');
350390
client.once('error', err => {
351391
this.cleanup(client);

test/chrome-launcher-test.ts

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ const fsMock = {
2525
};
2626

2727
const launchChromeWithOpts = async (opts: Options = {}) => {
28-
const spawnStub = stub().returns({pid: 'some_pid'});
28+
const spawnStub = stub().returns({pid: 'some_pid', stdio: []});
2929

3030
const chromeInstance =
3131
new Launcher(opts, {fs: fsMock as any, spawn: spawnStub as any});
@@ -244,6 +244,44 @@ describe('Launcher', () => {
244244
chromeInstance.launch().catch(() => done());
245245
});
246246

247+
describe('remote-debugging-pipe flag', () => {
248+
// These tests perform some basic tests on the expected effects of passing
249+
// the --remote-debugging-pipe flag. There is a real end-to-end example in
250+
// load-extension-test.ts.
251+
it('remote-debugging-pipe flag adds pipes expected by Chrome', async () => {
252+
const spawnStub = await launchChromeWithOpts({chromeFlags: ['--remote-debugging-pipe']});
253+
const stdioAndPipes = spawnStub.getCall(0).args[2].stdio as string[];
254+
assert.equal(stdioAndPipes.length, 5, 'Passed pipes to Chrome');
255+
assert.equal(stdioAndPipes[3], 'pipe', 'Fourth pipe is pipe');
256+
assert.equal(stdioAndPipes[4], 'pipe', 'Fifth pipe is pipe');
257+
});
258+
259+
it('without remote-debugging-pipe flag, no pipes', async () => {
260+
const spawnStub = await launchChromeWithOpts({});
261+
const stdioAndPipes = spawnStub.getCall(0).args[2].stdio as string[];
262+
assert.equal(stdioAndPipes.length, 3, 'Only standard stdio pipes');
263+
});
264+
265+
it('remote-debugging-pipe flag without remote-debugging-port', async () => {
266+
const spawnStub = await launchChromeWithOpts({chromeFlags: ['--remote-debugging-pipe']});
267+
const chromeFlags = spawnStub.getCall(0).args[1] as string[];
268+
assert.notEqual(chromeFlags.indexOf('--remote-debugging-pipe'), -1);
269+
assert.ok(
270+
!chromeFlags.find(f => f.startsWith('--remote-debugging-port')),
271+
'--remote-debugging-port should be unset');
272+
});
273+
274+
it('remote-debugging-pipe flag with value is also accepted', async () => {
275+
// Chrome's source code indicates that it may support formats other than
276+
// JSON, as an explicit value. Here is an example of what it could look
277+
// like. Since chrome-launcher does not interpret the data in the pipes,
278+
// we can support arbitrary types.
279+
const spawnStub = await launchChromeWithOpts({chromeFlags: ['--remote-debugging-pipe=cbor']});
280+
const stdioAndPipes = spawnStub.getCall(0).args[2].stdio as string[];
281+
assert.equal(stdioAndPipes.length, 5);
282+
});
283+
});
284+
247285
describe('getChromePath', async () => {
248286
it('returns the same path as a full Launcher launch', async () => {
249287
const spawnStub = await launchChromeWithOpts();
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
"use strict";
2+
3+
chrome.runtime.onInstalled.addListener(() => {
4+
injectContentScriptIfNeeded();
5+
});
6+
7+
async function injectContentScriptIfNeeded() {
8+
// If the web page loaded before the extension did, then we have to schedule
9+
// execution from here.
10+
const tabs = await chrome.tabs.query({ url: "*://*/start_extension_test" });
11+
console.log(`BG: Found ${tabs.length} tabs with start_extension_test`);
12+
for (const tab of tabs) {
13+
chrome.scripting.executeScript({
14+
target: { tabId: tab.id },
15+
files: ["contentscript.js"],
16+
});
17+
}
18+
}
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
"use strict";
2+
3+
if (!window.firstRunOfExtension) {
4+
window.firstRunOfExtension = true;
5+
notifyTestServer();
6+
}
7+
8+
async function notifyTestServer() {
9+
try {
10+
let res = await fetch(new URL('/hello_from_extension', location.origin));
11+
console.log(`Notified server, status: ${res.status}`);
12+
} catch (e) {
13+
console.error(`Unexpected error: ${e}`);
14+
}
15+
}
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
{
2+
"name": "Extension calls back to test server",
3+
"version": "1",
4+
"manifest_version": 3,
5+
"background": {
6+
"scripts": ["background.js"],
7+
"service_worker": "background.js"
8+
},
9+
"content_scripts": [{
10+
"js": ["contentscript.js"],
11+
"matches": ["*://*/start_extension_test"]
12+
}],
13+
"permissions": ["tabs", "scripting"],
14+
"host_permissions": ["*://*/*"],
15+
"description": "Extension calls back to test server; key in manifest fixes extension ID to: gpehbnajinmdnomnoadgbmkjoohjfjco",
16+
"key": "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA4lw1K+Rjtxei6dDA+DIBrGM4kDVR2TWISibAaF7LbDONI1SMS7NN/TkkKqIjVy6xHgBWnNvOahqkT4ltou4bkQpdLP102JKq/4b2FDX+u9pJozxCQqEY7eExvEIduFYcuswQ6QuFFJmiStyKLYqcZKdRuLD6yBNbG+p2mr11PrXzSBzu+9o98yysXNhHphogv02Kev7GTbme58FUHyb8fI8nPgi3KzzzzJAPgJkIpHRRBtkDSelQ0+9XjZPOxxPW0n4yHXDD/tIA0VpZJUGiDwRRZD9bzuxyMf/midlUA5jbGHdcdC5tgDH4PiBQp2AeowRXd1ww+kVwLvcaVhnqewIDAQAB"
17+
}

0 commit comments

Comments
 (0)