Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -109,8 +109,19 @@ chrome.pid: number;

// The childProcess object for the launched Chrome
chrome.process: childProcess

// If chromeFlags contains --remote-debugging-pipe. Otherwise remoteDebuggingPipes is null.
chrome.remoteDebuggingPipes.incoming: ReadableStream
chrome.remoteDebuggingPipes.outgoing: WritableStream
```

When `--remote-debugging-pipe` is passed via `chromeFlags`, then `port` will be
unusable (0) by default. Instead, debugging messages are exchanged via
`remoteDebuggingPipes.incoming` and `remoteDebuggingPipes.outgoing`. The data
in these pipes are JSON values terminated by a NULL byte (`\x00`).
Data written to `remoteDebuggingPipes.outgoing` are sent to Chrome,
data read from `remoteDebuggingPipes.incoming` are received from Chrome.

### `ChromeLauncher.Launcher.defaultFlags()`

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.
Expand Down Expand Up @@ -176,6 +187,9 @@ ChromeLauncher.launch({
}).then(chrome => { ... });
```

To programatically load an extension at runtime, use `--remote-debugging-pipe`
as shown in [test/load-extension-test.ts](test/load-extension-test.ts).

### Continuous Integration

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.
Expand Down
50 changes: 45 additions & 5 deletions src/chrome-launcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,10 +44,15 @@ export interface Options {
envVars?: {[key: string]: string|undefined};
}

export interface RemoteDebuggingPipes {
incoming: NodeJS.ReadableStream, outgoing: NodeJS.WritableStream,
}

export interface LaunchedChrome {
pid: number;
port: number;
process: ChildProcess;
remoteDebuggingPipes: RemoteDebuggingPipes|null;
kill: () => void;
}

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

return {pid: instance.pid!, port: instance.port!, kill, process: instance.chromeProcess!};
return {
pid: instance.pid!,
port: instance.port!,
process: instance.chromeProcess!,
remoteDebuggingPipes: instance.remoteDebuggingPipes,
kill,
};
}

/** Returns Chrome installation path that chrome-launcher will launch by default. */
Expand Down Expand Up @@ -121,6 +132,7 @@ class Launcher {
private prefs: Record<string, JSONLike>;
private requestedPort?: number;
private portStrictMode?: boolean;
private useRemoteDebuggingPipe: boolean;
private connectionPollInterval: number;
private maxConnectionRetries: number;
private fs: typeof fs;
Expand All @@ -131,6 +143,7 @@ class Launcher {
chromeProcess?: childProcess.ChildProcess;
userDataDir?: string;
port?: number;
remoteDebuggingPipes: RemoteDebuggingPipes|null = null;
pid?: number;

constructor(private opts: Options = {}, moduleOverrides: ModuleOverrides = {}) {
Expand Down Expand Up @@ -162,11 +175,18 @@ class Launcher {
this.useDefaultProfile = false;
this.userDataDir = this.opts.userDataDir;
}

// Using startsWith because it could also be --remote-debugging-pipe=cbor
this.useRemoteDebuggingPipe =
this.chromeFlags.some(f => f.startsWith('--remote-debugging-pipe'));
}

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

if (!this.ignoreDefaultFlags && getPlatform() === 'linux') {
flags.push('--disable-setuid-sandbox');
Expand Down Expand Up @@ -305,7 +325,12 @@ class Launcher {
// We do this here so that we can know the port before
// we pass it into chrome.
if (this.requestedPort === 0) {
this.port = await getRandomPort();
if (this.useRemoteDebuggingPipe) {
// When useRemoteDebuggingPipe is true, this.port defaults to 0.
this.port = 0;
} else {
this.port = await getRandomPort();
}
}

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

if (this.chromeProcess.pid) {
this.fs.writeFileSync(this.pidFile, this.chromeProcess.pid.toString());
}
if (this.useRemoteDebuggingPipe) {
this.remoteDebuggingPipes = {
incoming: this.chromeProcess.stdio[4] as NodeJS.ReadableStream,
outgoing: this.chromeProcess.stdio[3] as NodeJS.WritableStream,
};
}

log.verbose(
'ChromeLauncher',
Expand All @@ -330,7 +363,10 @@ class Launcher {
})();

const pid = await spawnPromise;
await this.waitUntilReady();
// When useRemoteDebuggingPipe is true, this.port defaults to 0.
if (this.port !== 0) {
await this.waitUntilReady();
}
return pid;
}

Expand All @@ -346,6 +382,10 @@ class Launcher {
// resolves if ready, rejects otherwise
private isDebuggerReady(): Promise<void> {
return new Promise((resolve, reject) => {
// Note: only meaningful when this.port is set.
// When useRemoteDebuggingPipe is true, this.port defaults to 0. In that
// case, we could consider ping-ponging over the pipe, but that may get
// in the way of the library user, so we do not.
const client = net.createConnection(this.port!, '127.0.0.1');
client.once('error', err => {
this.cleanup(client);
Expand Down
40 changes: 39 additions & 1 deletion test/chrome-launcher-test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ const fsMock = {
};

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

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

describe('remote-debugging-pipe flag', () => {
// These tests perform some basic tests on the expected effects of passing
// the --remote-debugging-pipe flag. There is a real end-to-end example in
// load-extension-test.ts.
it('remote-debugging-pipe flag adds pipes expected by Chrome', async () => {
const spawnStub = await launchChromeWithOpts({chromeFlags: ['--remote-debugging-pipe']});
const stdioAndPipes = spawnStub.getCall(0).args[2].stdio as string[];
assert.equal(stdioAndPipes.length, 5, 'Passed pipes to Chrome');
assert.equal(stdioAndPipes[3], 'pipe', 'Fourth pipe is pipe');
assert.equal(stdioAndPipes[4], 'pipe', 'Fifth pipe is pipe');
});

it('without remote-debugging-pipe flag, no pipes', async () => {
const spawnStub = await launchChromeWithOpts({});
const stdioAndPipes = spawnStub.getCall(0).args[2].stdio as string[];
assert.equal(stdioAndPipes.length, 3, 'Only standard stdio pipes');
});

it('remote-debugging-pipe flag without remote-debugging-port', async () => {
const spawnStub = await launchChromeWithOpts({chromeFlags: ['--remote-debugging-pipe']});
const chromeFlags = spawnStub.getCall(0).args[1] as string[];
assert.notEqual(chromeFlags.indexOf('--remote-debugging-pipe'), -1);
assert.ok(
!chromeFlags.find(f => f.startsWith('--remote-debugging-port')),
'--remote-debugging-port should be unset');
});

it('remote-debugging-pipe flag with value is also accepted', async () => {
// Chrome's source code indicates that it may support formats other than
// JSON, as an explicit value. Here is an example of what it could look
// like. Since chrome-launcher does not interpret the data in the pipes,
// we can support arbitrary types.
const spawnStub = await launchChromeWithOpts({chromeFlags: ['--remote-debugging-pipe=cbor']});
const stdioAndPipes = spawnStub.getCall(0).args[2].stdio as string[];
assert.equal(stdioAndPipes.length, 5);
});
});

describe('getChromePath', async () => {
it('returns the same path as a full Launcher launch', async () => {
const spawnStub = await launchChromeWithOpts();
Expand Down
18 changes: 18 additions & 0 deletions test/chrome_extension_fixture/background.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
"use strict";

chrome.runtime.onInstalled.addListener(() => {
injectContentScriptIfNeeded();
});

async function injectContentScriptIfNeeded() {
// If the web page loaded before the extension did, then we have to schedule
// execution from here.
const tabs = await chrome.tabs.query({ url: "*://*/start_extension_test" });
console.log(`BG: Found ${tabs.length} tabs with start_extension_test`);
for (const tab of tabs) {
chrome.scripting.executeScript({
target: { tabId: tab.id },
files: ["contentscript.js"],
});
}
}
15 changes: 15 additions & 0 deletions test/chrome_extension_fixture/contentscript.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
"use strict";

if (!window.firstRunOfExtension) {
window.firstRunOfExtension = true;
notifyTestServer();
}

async function notifyTestServer() {
try {
let res = await fetch(new URL('/hello_from_extension', location.origin));
console.log(`Notified server, status: ${res.status}`);
} catch (e) {
console.error(`Unexpected error: ${e}`);
}
}
17 changes: 17 additions & 0 deletions test/chrome_extension_fixture/manifest.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
{
"name": "Extension calls back to test server",
"version": "1",
"manifest_version": 3,
"background": {
"scripts": ["background.js"],
"service_worker": "background.js"
},
"content_scripts": [{
"js": ["contentscript.js"],
"matches": ["*://*/start_extension_test"]
}],
"permissions": ["tabs", "scripting"],
"host_permissions": ["*://*/*"],
"description": "Extension calls back to test server; key in manifest fixes extension ID to: gpehbnajinmdnomnoadgbmkjoohjfjco",
"key": "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA4lw1K+Rjtxei6dDA+DIBrGM4kDVR2TWISibAaF7LbDONI1SMS7NN/TkkKqIjVy6xHgBWnNvOahqkT4ltou4bkQpdLP102JKq/4b2FDX+u9pJozxCQqEY7eExvEIduFYcuswQ6QuFFJmiStyKLYqcZKdRuLD6yBNbG+p2mr11PrXzSBzu+9o98yysXNhHphogv02Kev7GTbme58FUHyb8fI8nPgi3KzzzzJAPgJkIpHRRBtkDSelQ0+9XjZPOxxPW0n4yHXDD/tIA0VpZJUGiDwRRZD9bzuxyMf/midlUA5jbGHdcdC5tgDH4PiBQp2AeowRXd1ww+kVwLvcaVhnqewIDAQAB"
}
Loading