feat(runner): Create new @wxt-dev/runner package (#1566)
📼 VHS / Create VHS (push) Waiting to run
📼 VHS / Create VHS (push) Waiting to run
This commit is contained in:
@@ -14,6 +14,7 @@ on:
|
||||
- module-solid
|
||||
- module-svelte
|
||||
- module-vue
|
||||
- runner
|
||||
- storage
|
||||
- unocss
|
||||
- webextension-polyfill
|
||||
|
||||
@@ -14,6 +14,7 @@ on:
|
||||
- module-solid
|
||||
- module-svelte
|
||||
- module-vue
|
||||
- runner
|
||||
- storage
|
||||
- webextension-polyfill
|
||||
- wxt
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
.output
|
||||
.webextrc
|
||||
.wxt
|
||||
.wxt-runner
|
||||
*.log
|
||||
/docs/.vitepress/cache
|
||||
docs/.vitepress/.temp
|
||||
|
||||
@@ -15,6 +15,7 @@ import { version as autoIconsVersion } from '../../packages/auto-icons/package.j
|
||||
import { version as unocssVersion } from '../../packages/unocss/package.json';
|
||||
import { version as storageVersion } from '../../packages/storage/package.json';
|
||||
import { version as analyticsVersion } from '../../packages/analytics/package.json';
|
||||
import { version as runnerVersion } from '../../packages/runner/package.json';
|
||||
import addKnowledge from 'vitepress-knowledge';
|
||||
import {
|
||||
groupIconMdPlugin,
|
||||
@@ -41,6 +42,7 @@ const otherPackages = {
|
||||
i18n: i18nVersion,
|
||||
storage: storageVersion,
|
||||
unocss: unocssVersion,
|
||||
runner: runnerVersion,
|
||||
};
|
||||
|
||||
const knowledge = addKnowledge<DefaultTheme.Config>({
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
<!--@include: ../packages/runner/README.md-->
|
||||
@@ -0,0 +1,225 @@
|
||||
# `@wxt-dev/runner`
|
||||
|
||||
Programmatically open a browser and install a web extension from a local directory.
|
||||
|
||||
###### With WXT
|
||||
|
||||
> [!WARNING]
|
||||
> This package is intended to replace [`web-ext`](https://github.com/mozilla/web-ext) in the future, but it is not ready at the moment. Once it's ready for testing in WXT, more details will be added here.
|
||||
|
||||
```ts
|
||||
// ~/wxt.runner.config.ts OR <project>/wxt.runner.config.ts
|
||||
import { defineRunnerConfig } from 'wxt';
|
||||
|
||||
export default defineRunnerConfig({
|
||||
// Options go here
|
||||
});
|
||||
```
|
||||
|
||||
###### JS API
|
||||
|
||||
```ts
|
||||
import { run } from '@wxt-dev/runner';
|
||||
|
||||
await run({
|
||||
extensionDir: '/path/to/extension',
|
||||
// Other options...
|
||||
});
|
||||
```
|
||||
|
||||
## Features
|
||||
|
||||
- Supports all Chromium and Firefox based browsers
|
||||
- Zero dependencies
|
||||
- One-line config for persisting data between launches
|
||||
|
||||
## Requirements
|
||||
|
||||
`@wxt-dev/runner` requires a JS runtime that implements the `WebSocket` standard:
|
||||
|
||||
| JS Runtime | Version |
|
||||
| ---------- | ----------- |
|
||||
| NodeJS | ≥ 22.4.0 |
|
||||
| Bun | ≥ 1.2.0 |
|
||||
|
||||
You also need to have a specific version of the browser installed that supports the latest features so extensions can be loaded:
|
||||
|
||||
| Browser | Version |
|
||||
| -------- | -------- |
|
||||
| Chromium | Unknown |
|
||||
| Firefox | ≥ 139 |
|
||||
|
||||
## TODO
|
||||
|
||||
- [x] Provide install functions to allow hooking into already running instances of Chrome/Firefox
|
||||
- [ ] Try to setup E2E tests on Firefox with Puppeteer using this approach
|
||||
- [ ] Try to setup E2E tests on Chrome with Puppeteer using this approach
|
||||
|
||||
## Options
|
||||
|
||||
### Target
|
||||
|
||||
To open a specific browser, use the `target` option:
|
||||
|
||||
```ts
|
||||
import { run } from '@wxt-dev/runner';
|
||||
|
||||
await run({
|
||||
extensionDir: 'path/to/extension',
|
||||
target: 'firefox',
|
||||
});
|
||||
```
|
||||
|
||||
Defaults to opening `chrome`. You may see type-hints for a list of popular browsers, but you can enter any string you want here.
|
||||
|
||||
### Data Persistence
|
||||
|
||||
Browsers block you from using your normal browser profiles when using the [BiDi and CDP protocols](#implementation-details) for security reasons.
|
||||
|
||||
To change how the new profile's data is saved between sessions, use the `dataPersistence` option:
|
||||
|
||||
```ts
|
||||
import { run } from '@wxt-dev/runner';
|
||||
|
||||
await run({
|
||||
dataPersistence: 'user',
|
||||
});
|
||||
```
|
||||
|
||||
- `"none"` (default): Use a brand new browser profile every time the browser is opened (stored in the system's tmp directory)
|
||||
- `"project"`: Create a new profile that is re-used for your current directory (by default stored in `.wxt-runner` or `.wxt/runner` for WXT projects)
|
||||
- `"user"`: Create a new profile that is re-used for all projects using `@wxt-dev/runner` (by default stored in `$HOME/.wxt-runner`)
|
||||
|
||||
These presets configure different flags for different operating systems when spawning the browser process.
|
||||
|
||||
If you want to customize your data persistence beyond what these presets define, [you can override the browser flags yourself](#Arguments) to configure persistence.
|
||||
|
||||
### Browser Binaries
|
||||
|
||||
`@wxt-dev/runner` will look for browser binaries/executables in [a hard-coded list of paths](https://github.com/wxt-dev/wxt/blob/main/packages/runner/src/browser-paths.ts). It does not and will not explore your filesystem/`$PATH` to find where the browser is installed. That means there are times you will need to specify the path to a browser's binary on your system:
|
||||
|
||||
- Your browser's path is non-standard or missing from the hard-coded list.
|
||||
- You want to use a specific version/release of the browser.
|
||||
- You're using a less popular browser and `@wxt-dev/runner` doesn't have hard-coded paths for it.
|
||||
|
||||
To do this, use the `browserBinaries` option and set the path to the browser's binary:
|
||||
|
||||
```ts
|
||||
import { run } from '@wxt-dev/runner';
|
||||
|
||||
await run({
|
||||
extensionDir: 'path/to/extension',
|
||||
browserBinaries: {
|
||||
chrome: '/path/to/chrome',
|
||||
firefox: '/path/to/firefox',
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
### Arguments
|
||||
|
||||
To pass custom arguments to the browser on startup, use the `chromiumArgs` or `firefoxArgs` options:
|
||||
|
||||
```ts
|
||||
import { run } from '@wxt-dev/runner';
|
||||
|
||||
await run({
|
||||
extensionDir: 'path/to/extension',
|
||||
chromiumArgs: ['--window-size=1920,1080'],
|
||||
firefoxArgs: ['--window-size', '1920,1080'],
|
||||
});
|
||||
```
|
||||
|
||||
### Start URLs
|
||||
|
||||
To open specific URLs in tabs by default, you also use the `chromiumArgs` or `firefoxArgs` options.
|
||||
|
||||
Any URLs passed as a CLI argument will be opened in the browser when it starts.
|
||||
|
||||
```ts
|
||||
import { run } from '@wxt-dev/runner';
|
||||
|
||||
await run({
|
||||
extensionDir: 'path/to/extension',
|
||||
chromiumArgs: ['https://example.com'],
|
||||
firefoxArgs: ['https://example.com'],
|
||||
});
|
||||
```
|
||||
|
||||
### Debugging
|
||||
|
||||
To see debug logs, set the `DEBUG` env var to `"@wxt-dev/runner"`. This will print the resolved config, commands used to spawn the browser, any messages sent on the browser's communication protocol, and more for you to debug.
|
||||
|
||||
<details>
|
||||
<summary>Example debug output</summary>
|
||||
|
||||
```
|
||||
@wxt-dev/runner:options User options: { extensionDir: 'demo-extension', target: undefined }
|
||||
@wxt-dev/runner:options Resolved options: {
|
||||
browserBinary: '/usr/bin/chromium',
|
||||
chromiumArgs: [
|
||||
'--disable-features=Translate,OptimizationHints,MediaRouter,DialMediaRouteProvider,CalculateNativeWinOcclusion,InterestFeedContentSuggestions,CertificateTransparencyComponentUpdater,AutofillServerCommunication,PrivacySandboxSettings4',
|
||||
'--disable-component-extensions-with-background-pages',
|
||||
'--disable-background-networking',
|
||||
'--disable-component-update',
|
||||
'--disable-client-side-phishing-detection',
|
||||
'--disable-sync',
|
||||
'--metrics-recording-only',
|
||||
'--disable-default-apps',
|
||||
'--no-default-browser-check',
|
||||
'--no-first-run',
|
||||
'--disable-background-timer-throttling',
|
||||
'--disable-ipc-flooding-protection',
|
||||
'--password-store=basic',
|
||||
'--use-mock-keychain',
|
||||
'--force-fieldtrials=*BackgroundTracing/default/',
|
||||
'--disable-hang-monitor',
|
||||
'--disable-prompt-on-repost',
|
||||
'--disable-domain-reliability',
|
||||
'--propagate-iph-for-testing',
|
||||
'--remote-debugging-port=0',
|
||||
'--remote-debugging-pipe',
|
||||
'--user-data-dir=/tmp/wxt-runner-pWXLO1',
|
||||
'--enable-unsafe-extension-debugging'
|
||||
],
|
||||
dataDir: '/tmp/wxt-runner-pWXLO1',
|
||||
dataPersistence: 'none',
|
||||
chromiumRemoteDebuggingPort: 0,
|
||||
extensionDir: '/home/aklinker1/Development/github.com/wxt-dev/wxt/packages/runner/demo-extension',
|
||||
firefoxArgs: [
|
||||
'--new-instance',
|
||||
'--no-remote',
|
||||
'--profile',
|
||||
'/tmp/wxt-runner-pWXLO1',
|
||||
'--remote-debugging-port=0',
|
||||
'about:debugging#/runtime/this-firefox'
|
||||
],
|
||||
firefoxRemoteDebuggingPort: 0,
|
||||
target: 'chrome'
|
||||
}
|
||||
@wxt-dev/runner:chrome:stderr DevTools listening on ws://127.0.0.1:38397/devtools/browser/93dc4de5-64cb-4e0b-a9d3-7549527015f0
|
||||
@wxt-dev/runner:cdp Sending command: {
|
||||
id: 1,
|
||||
method: 'Extensions.loadUnpacked',
|
||||
params: {
|
||||
path: '/home/aklinker1/Development/github.com/wxt-dev/wxt/packages/runner/demo-extension'
|
||||
}
|
||||
}
|
||||
@wxt-dev/runner:cdp Received response: { id: 1, result: { id: 'hckhakegfgenefhikdcfkaaonnclljmf' } }
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
## Implementation Details
|
||||
|
||||
All this package does is spawn a child process to open the browser with some default flags before using remote protocols to install the extension.
|
||||
|
||||
### Firefox
|
||||
|
||||
We use the new [WebDriver BiDi protocol](https://www.w3.org/TR/webdriver-bidi) to install the extension. This just involves connecting to a web socket and sending a few messages.
|
||||
|
||||
### Chrome
|
||||
|
||||
We use the [CDP](https://chromedevtools.github.io/devtools-protocol/) with `--remote-debugging-pipe` and `--enable-unsafe-extension-debugging` to install the extension by sending a message via IO pipes 3 and 4.
|
||||
|
||||
We don't use Webdriver Bidi because it's not built into Chrome yet. It requires us instantiating a separate child process for `chromedriver`. This is slower and more difficult than just using the CDP built into Chrome.
|
||||
@@ -0,0 +1 @@
|
||||
console.log('Hello background!');
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"name": "Test",
|
||||
"version": "1.0.0",
|
||||
"manifest_version": 3,
|
||||
"background": {
|
||||
"service_worker": "background.js",
|
||||
"scripts": ["background.js"]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
//
|
||||
// USAGE:
|
||||
// pnpm dev
|
||||
// pnpm dev firefox-nightly
|
||||
// pnpm dev <target>
|
||||
//
|
||||
|
||||
import { run } from './src';
|
||||
|
||||
// Uncomment to enable debug logs
|
||||
process.env.DEBUG = '@wxt-dev/runner';
|
||||
|
||||
await run({
|
||||
extensionDir: 'demo-extension',
|
||||
target: process.argv[2],
|
||||
});
|
||||
@@ -0,0 +1,51 @@
|
||||
{
|
||||
"name": "@wxt-dev/runner",
|
||||
"description": "Launch Chrome and Firefox with a web extension installed",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/wxt-dev/wxt.git",
|
||||
"directory": "packages/runner"
|
||||
},
|
||||
"homepage": "https://github.com/wxt-dev/wxt/tree/main/packages/runner#readme",
|
||||
"keywords": [
|
||||
"web-extension",
|
||||
"chrome-extension",
|
||||
"wxt"
|
||||
],
|
||||
"author": {
|
||||
"name": "Aaron Klinker",
|
||||
"email": "aaronklinker1+wxt@gmail.com"
|
||||
},
|
||||
"license": "MIT",
|
||||
"funding": "https://github.com/sponsors/wxt-dev",
|
||||
"scripts": {
|
||||
"check": "pnpm build && check",
|
||||
"test": "buildc --deps-only -- vitest",
|
||||
"dev": "tsx --trace-warnings dev.ts",
|
||||
"build": "buildc -- unbuild",
|
||||
"prepublishOnly": "pnpm build"
|
||||
},
|
||||
"dependencies": {},
|
||||
"devDependencies": {
|
||||
"@aklinker1/check": "2.0.0",
|
||||
"oxlint": "^0.16.8",
|
||||
"publint": "^0.3.12",
|
||||
"tsx": "4.19.4",
|
||||
"typescript": "^5.8.3",
|
||||
"unbuild": "^3.5.0",
|
||||
"vitest": "^3.1.2"
|
||||
},
|
||||
"main": "dist/index.mjs",
|
||||
"types": "dist/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.mts",
|
||||
"default": "./dist/index.mjs"
|
||||
}
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { mock } from 'vitest-mock-extended';
|
||||
import { createCdpConnection, type CDPConnection } from '../cdp';
|
||||
import { createBidiConnection, type BidiConnection } from '../bidi';
|
||||
import type { ChildProcess } from 'node:child_process';
|
||||
import { installChromium, installFirefox } from '../install';
|
||||
|
||||
vi.mock('../cdp');
|
||||
const createCdpConnectionMock = vi.mocked(createCdpConnection);
|
||||
|
||||
vi.mock('../bidi');
|
||||
const createBidiConnectionMock = vi.mocked(createBidiConnection);
|
||||
|
||||
describe('Install', () => {
|
||||
describe('Chromium', () => {
|
||||
it('Should send the install command to the process', async () => {
|
||||
const browserProcess = mock<ChildProcess>();
|
||||
const connection = mock<CDPConnection>({
|
||||
[Symbol.dispose]: vi.fn(),
|
||||
});
|
||||
const extensionDir = '/path/to/extension';
|
||||
const expectedExtensionId = 'chromium-extension-id';
|
||||
|
||||
createCdpConnectionMock.mockReturnValue(connection);
|
||||
connection.send.mockImplementation(async (method) => {
|
||||
if (method === 'Extensions.loadUnpacked')
|
||||
return { id: expectedExtensionId };
|
||||
throw Error('Unknown method');
|
||||
});
|
||||
|
||||
const res = await installChromium(browserProcess, extensionDir);
|
||||
|
||||
expect(createCdpConnectionMock).toBeCalledTimes(1);
|
||||
expect(createCdpConnectionMock).toBeCalledWith(browserProcess);
|
||||
|
||||
expect(connection.send).toBeCalledTimes(1);
|
||||
expect(connection.send).toBeCalledWith('Extensions.loadUnpacked', {
|
||||
path: extensionDir,
|
||||
});
|
||||
|
||||
expect(res).toEqual({ id: expectedExtensionId });
|
||||
});
|
||||
});
|
||||
|
||||
describe('Firefox', () => {
|
||||
it('Should connect to the server, start a session, then install the extension', async () => {
|
||||
const debuggerUrl = 'http://127.0.0.1:9222';
|
||||
const extensionDir = '/path/to/extension';
|
||||
const expectedExtensionId = 'firefox-extension-id';
|
||||
const connection = mock<BidiConnection>({
|
||||
[Symbol.dispose]: vi.fn(),
|
||||
});
|
||||
|
||||
createBidiConnectionMock.mockResolvedValue(connection);
|
||||
connection.send.mockImplementation(async (method) => {
|
||||
if (method === 'session.new') return { sessionId: 'session-id' };
|
||||
if (method === 'webExtension.install')
|
||||
return { extension: expectedExtensionId };
|
||||
});
|
||||
|
||||
const res = await installFirefox(debuggerUrl, extensionDir);
|
||||
|
||||
expect(createBidiConnectionMock).toBeCalledTimes(1);
|
||||
expect(createBidiConnectionMock).toBeCalledWith(debuggerUrl);
|
||||
|
||||
expect(connection.send).toBeCalledTimes(2);
|
||||
expect(connection.send).toBeCalledWith('session.new', {
|
||||
capabilities: {},
|
||||
});
|
||||
expect(connection.send).toBeCalledWith('webExtension.install', {
|
||||
extensionData: {
|
||||
type: 'path',
|
||||
path: extensionDir,
|
||||
},
|
||||
});
|
||||
|
||||
expect(res).toEqual({ extension: expectedExtensionId });
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,306 @@
|
||||
import { describe, it, expect, vi, beforeAll } from 'vitest';
|
||||
import { ResolvedRunOptions, resolveRunOptions } from '../options';
|
||||
import { resolve, join } from 'node:path';
|
||||
import { tmpdir, homedir } from 'node:os';
|
||||
import { mkdir } from 'node:fs/promises';
|
||||
|
||||
vi.mock('node:os', async () => {
|
||||
const { vi } = await import('vitest');
|
||||
const os: any = await vi.importActual('node:os');
|
||||
const { join } = await import('node:path');
|
||||
return {
|
||||
...os,
|
||||
tmpdir: () => join(os.tmpdir(), 'tmpdir-mock'),
|
||||
homedir: () => join(os.tmpdir(), 'homedir-mock'),
|
||||
};
|
||||
});
|
||||
|
||||
describe('Options', () => {
|
||||
beforeAll(async () => {
|
||||
// Make sure mock test directories exist
|
||||
await mkdir(tmpdir(), { recursive: true });
|
||||
await mkdir(homedir(), { recursive: true });
|
||||
});
|
||||
|
||||
describe('extensionDir', () => {
|
||||
it('should default to the current working directory', async () => {
|
||||
const actual = await resolveRunOptions({});
|
||||
expect(actual).toMatchObject<Partial<ResolvedRunOptions>>({
|
||||
extensionDir: process.cwd(),
|
||||
});
|
||||
});
|
||||
|
||||
it('should resolve relative to the current working directory', async () => {
|
||||
const actual = await resolveRunOptions({
|
||||
extensionDir: './path/to/extension',
|
||||
});
|
||||
expect(actual).toMatchObject<Partial<ResolvedRunOptions>>({
|
||||
extensionDir: resolve(process.cwd(), './path/to/extension'),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('target', () => {
|
||||
it('should be "chrome" by default', async () => {
|
||||
const actual = await resolveRunOptions({
|
||||
extensionDir: 'path/to/extension',
|
||||
});
|
||||
expect(actual).toMatchObject<Partial<ResolvedRunOptions>>({
|
||||
target: 'chrome',
|
||||
});
|
||||
});
|
||||
|
||||
it('should be what is passed in', async () => {
|
||||
const actual = await resolveRunOptions({
|
||||
extensionDir: 'path/to/extension',
|
||||
target: 'custom',
|
||||
browserBinaries: {
|
||||
custom: '/path/to/custom/browser',
|
||||
},
|
||||
});
|
||||
expect(actual).toMatchObject<Partial<ResolvedRunOptions>>({
|
||||
target: 'custom',
|
||||
});
|
||||
});
|
||||
|
||||
it('should throw an error if the target binary could not be found', async () => {
|
||||
const actual = resolveRunOptions({
|
||||
extensionDir: 'path/to/extension',
|
||||
target: 'custom',
|
||||
});
|
||||
await expect(actual).rejects.toThrow('Could not find "custom" binary.');
|
||||
});
|
||||
});
|
||||
|
||||
describe('browserBinary', () => {
|
||||
it('should denormalize the browserBinary', async () => {
|
||||
const path =
|
||||
process.platform === 'win32'
|
||||
? 'C:/path/to/custom/browser.exe'
|
||||
: '/path/to/custom/browser';
|
||||
const expectedPath =
|
||||
process.platform === 'win32'
|
||||
? 'C:\\path\\to\\custom\\browser.exe'
|
||||
: path;
|
||||
const actual = await resolveRunOptions({
|
||||
extensionDir: 'path/to/extension',
|
||||
target: 'custom',
|
||||
browserBinaries: {
|
||||
custom: path,
|
||||
},
|
||||
});
|
||||
expect(actual).toMatchObject<Partial<ResolvedRunOptions>>({
|
||||
browserBinary: expectedPath,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('chromiumArgs', () => {
|
||||
it('should log a warning when --user-data-dir is passed in', async () => {
|
||||
const warnSpy = vi.spyOn(console, 'warn');
|
||||
await resolveRunOptions({
|
||||
chromiumArgs: ['--user-data-dir=some/custom/path'],
|
||||
});
|
||||
expect(warnSpy).toBeCalledTimes(1);
|
||||
expect(warnSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining(
|
||||
'Custom Chromium --user-data-dir argument ignored',
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it('should log a warning when --remote-debugging-port is passed in', async () => {
|
||||
const warnSpy = vi.spyOn(console, 'warn');
|
||||
await resolveRunOptions({
|
||||
chromiumArgs: ['--remote-debugging-port=9222'],
|
||||
});
|
||||
expect(warnSpy).toBeCalledTimes(1);
|
||||
expect(warnSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining(
|
||||
'Custom Chromium --remote-debugging-port argument ignored',
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it('should combine default args with user provided ones', async () => {
|
||||
const actual = await resolveRunOptions({
|
||||
chromiumArgs: ['--window-size=1920,1080'],
|
||||
});
|
||||
expect(actual.chromiumArgs).toEqual([
|
||||
// Defaults
|
||||
'--disable-features=Translate,OptimizationHints,MediaRouter,DialMediaRouteProvider,CalculateNativeWinOcclusion,InterestFeedContentSuggestions,CertificateTransparencyComponentUpdater,AutofillServerCommunication,PrivacySandboxSettings4',
|
||||
'--disable-component-extensions-with-background-pages',
|
||||
'--disable-background-networking',
|
||||
'--disable-component-update',
|
||||
'--disable-client-side-phishing-detection',
|
||||
'--disable-sync',
|
||||
'--metrics-recording-only',
|
||||
'--disable-default-apps',
|
||||
'--no-default-browser-check',
|
||||
'--no-first-run',
|
||||
'--disable-background-timer-throttling',
|
||||
'--disable-ipc-flooding-protection',
|
||||
'--password-store=basic',
|
||||
'--use-mock-keychain',
|
||||
'--force-fieldtrials=*BackgroundTracing/default/',
|
||||
'--disable-hang-monitor',
|
||||
'--disable-prompt-on-repost',
|
||||
'--disable-domain-reliability',
|
||||
'--propagate-iph-for-testing',
|
||||
// Debugging
|
||||
expect.stringContaining('--remote-debugging-port='),
|
||||
'--remote-debugging-pipe',
|
||||
expect.stringContaining('--user-data-dir='), // See dataPersistence tests
|
||||
'--enable-unsafe-extension-debugging',
|
||||
// User provided
|
||||
'--window-size=1920,1080',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('firefoxArgs', () => {
|
||||
it('should log a warning when --profile is passed in', async () => {
|
||||
const warnSpy = vi.spyOn(console, 'warn');
|
||||
await resolveRunOptions({
|
||||
firefoxArgs: ['--profile=some/custom/path'],
|
||||
});
|
||||
expect(warnSpy).toBeCalledTimes(1);
|
||||
expect(warnSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Custom Firefox --profile argument ignored'),
|
||||
);
|
||||
});
|
||||
|
||||
it('should log a warning when --remote-debugging-port is passed in', async () => {
|
||||
const warnSpy = vi.spyOn(console, 'warn');
|
||||
await resolveRunOptions({
|
||||
firefoxArgs: ['--remote-debugging-port=9222'],
|
||||
});
|
||||
expect(warnSpy).toBeCalledTimes(1);
|
||||
expect(warnSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining(
|
||||
'Custom Firefox --remote-debugging-port argument ignored',
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it('should combine default args with user provided ones', async () => {
|
||||
const actual = await resolveRunOptions({
|
||||
firefoxArgs: ['--window-size=1920,1080'],
|
||||
});
|
||||
expect(actual.firefoxArgs).toEqual([
|
||||
// Defaults
|
||||
'--new-instance',
|
||||
'--no-remote',
|
||||
'--profile',
|
||||
expect.any(String), // See dataPersistence tests
|
||||
expect.stringContaining('--remote-debugging-port='),
|
||||
'about:debugging#/runtime/this-firefox',
|
||||
// User provided
|
||||
'--window-size=1920,1080',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('chromiumRemoteDebuggingPort', () => {
|
||||
it('should default to 0', async () => {
|
||||
const actual = await resolveRunOptions({});
|
||||
expect(actual).toMatchObject<Partial<ResolvedRunOptions>>({
|
||||
chromiumRemoteDebuggingPort: 0,
|
||||
chromiumArgs: expect.arrayContaining([`--remote-debugging-port=0`]),
|
||||
});
|
||||
});
|
||||
|
||||
it('should respect user provided port', async () => {
|
||||
const actual = await resolveRunOptions({
|
||||
chromiumRemoteDebuggingPort: 9222,
|
||||
});
|
||||
expect(actual).toMatchObject<Partial<ResolvedRunOptions>>({
|
||||
chromiumRemoteDebuggingPort: 9222,
|
||||
chromiumArgs: expect.arrayContaining([`--remote-debugging-port=9222`]),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('firefoxRemoteDebuggingPort', () => {
|
||||
it('should default to 0', async () => {
|
||||
const actual = await resolveRunOptions({});
|
||||
expect(actual).toMatchObject<Partial<ResolvedRunOptions>>({
|
||||
firefoxRemoteDebuggingPort: 0,
|
||||
firefoxArgs: expect.arrayContaining([`--remote-debugging-port=0`]),
|
||||
});
|
||||
});
|
||||
|
||||
it('should respect user provided port', async () => {
|
||||
const actual = await resolveRunOptions({
|
||||
firefoxRemoteDebuggingPort: 9222,
|
||||
});
|
||||
expect(actual).toMatchObject<Partial<ResolvedRunOptions>>({
|
||||
firefoxRemoteDebuggingPort: 9222,
|
||||
firefoxArgs: expect.arrayContaining([`--remote-debugging-port=9222`]),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('dataPersistence', () => {
|
||||
it('should default to "none"', async () => {
|
||||
const actual = await resolveRunOptions({});
|
||||
expect(actual).toMatchObject<Partial<ResolvedRunOptions>>({
|
||||
dataPersistence: 'none',
|
||||
});
|
||||
});
|
||||
|
||||
it('should use a temporary directory when set to "none"', async () => {
|
||||
const actual = await resolveRunOptions({
|
||||
dataPersistence: 'none',
|
||||
});
|
||||
expect(actual).toMatchObject<Partial<ResolvedRunOptions>>({
|
||||
dataPersistence: 'none',
|
||||
dataDir: expect.stringContaining(join(tmpdir(), 'wxt-runner-')),
|
||||
chromiumArgs: expect.arrayContaining([
|
||||
expect.stringContaining(
|
||||
`--user-data-dir=${join(tmpdir(), 'wxt-runner-')}`,
|
||||
),
|
||||
]),
|
||||
firefoxArgs: expect.arrayContaining([
|
||||
expect.stringContaining(join(tmpdir(), 'wxt-runner-')),
|
||||
]),
|
||||
});
|
||||
});
|
||||
|
||||
it('should use a directory in the current working directory when set to "project"', async () => {
|
||||
const actual = await resolveRunOptions({
|
||||
dataPersistence: 'project',
|
||||
});
|
||||
expect(actual).toMatchObject<Partial<ResolvedRunOptions>>({
|
||||
dataPersistence: 'project',
|
||||
dataDir: expect.stringContaining(join(process.cwd(), '.wxt-runner')),
|
||||
chromiumArgs: expect.arrayContaining([
|
||||
expect.stringContaining(
|
||||
`--user-data-dir=${join(process.cwd(), '.wxt-runner')}`,
|
||||
),
|
||||
]),
|
||||
firefoxArgs: expect.arrayContaining([
|
||||
expect.stringContaining(join(process.cwd(), '.wxt-runner')),
|
||||
]),
|
||||
});
|
||||
});
|
||||
|
||||
it('should use a directory in the user\'s home directory when set to "user"', async () => {
|
||||
const actual = await resolveRunOptions({
|
||||
dataPersistence: 'user',
|
||||
});
|
||||
expect(actual).toMatchObject<Partial<ResolvedRunOptions>>({
|
||||
dataPersistence: 'user',
|
||||
dataDir: expect.stringContaining(join(homedir(), '.wxt-runner')),
|
||||
chromiumArgs: expect.arrayContaining([
|
||||
expect.stringContaining(
|
||||
`--user-data-dir=${join(homedir(), '.wxt-runner')}`,
|
||||
),
|
||||
]),
|
||||
firefoxArgs: expect.arrayContaining([
|
||||
expect.stringContaining(join(homedir(), '.wxt-runner')),
|
||||
]),
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,75 @@
|
||||
import { openWebSocket } from './web-socket';
|
||||
import { debug } from './debug';
|
||||
|
||||
const debugBidi = debug.scoped('bidi');
|
||||
|
||||
export interface BidiConnection extends Disposable {
|
||||
send<T>(method: string, params: any, timeout?: number): Promise<T>;
|
||||
close(): void;
|
||||
}
|
||||
|
||||
export async function createBidiConnection(
|
||||
baseUrl: string,
|
||||
): Promise<BidiConnection> {
|
||||
const url = new URL('/session', baseUrl);
|
||||
debugBidi('Connecting to BiDi server @', url.href);
|
||||
|
||||
const webSocket = await openWebSocket(url.href);
|
||||
debugBidi('Connected');
|
||||
|
||||
let requestId = 0;
|
||||
|
||||
return {
|
||||
send(method, params, timeout = 10e3) {
|
||||
const id = ++requestId;
|
||||
const command = { id, method, params };
|
||||
debugBidi('Sending command:', command);
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const cleanup = () => {
|
||||
webSocket.removeEventListener('message', onMessage);
|
||||
webSocket.removeEventListener('error', onError);
|
||||
};
|
||||
|
||||
setTimeout(() => {
|
||||
cleanup();
|
||||
reject(
|
||||
new Error(
|
||||
`Timed out after ${timeout}ms waiting for ${method} response`,
|
||||
),
|
||||
);
|
||||
}, timeout);
|
||||
|
||||
const onMessage = (event: MessageEvent) => {
|
||||
const data = JSON.parse(event.data);
|
||||
if (data.id === id) {
|
||||
debugBidi('Received response:', data);
|
||||
cleanup();
|
||||
if (data.type === 'success') resolve(data.result);
|
||||
else reject(Error(data.message, { cause: data }));
|
||||
}
|
||||
};
|
||||
const onError = (error: any) => {
|
||||
cleanup();
|
||||
reject(new Error('Error sending request', { cause: error }));
|
||||
};
|
||||
|
||||
webSocket.addEventListener('message', onMessage);
|
||||
webSocket.addEventListener('error', onError);
|
||||
|
||||
webSocket.send(JSON.stringify(command));
|
||||
});
|
||||
},
|
||||
|
||||
close() {
|
||||
debugBidi('Closing connection...');
|
||||
webSocket.close();
|
||||
debugBidi('Closed connection');
|
||||
},
|
||||
[Symbol.dispose]() {
|
||||
debugBidi('Disposing connection...');
|
||||
webSocket.close();
|
||||
debugBidi('Disposed connection');
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
export type BrowserPlatform = 'windows' | 'mac' | 'linux';
|
||||
|
||||
export type KnownTarget =
|
||||
| 'chromium'
|
||||
| 'chrome'
|
||||
| 'chrome-beta'
|
||||
| 'chrome-dev'
|
||||
| 'chrome-canary'
|
||||
| 'edge'
|
||||
| 'edge-beta'
|
||||
| 'edge-dev'
|
||||
| 'edge-canary'
|
||||
| 'firefox'
|
||||
| 'firefox-nightly'
|
||||
| 'firefox-developer-edition'
|
||||
| 'zen';
|
||||
|
||||
export const KNOWN_BROWSER_PATHS: Record<
|
||||
KnownTarget,
|
||||
Record<BrowserPlatform, string[]>
|
||||
> = {
|
||||
// Chromium based targets
|
||||
|
||||
chromium: {
|
||||
mac: [],
|
||||
linux: [
|
||||
// Arch
|
||||
'/usr/bin/chromium',
|
||||
],
|
||||
windows: [],
|
||||
},
|
||||
chrome: {
|
||||
mac: ['/Applications/Chrome.app/Contents/MacOS/Google Chrome'],
|
||||
linux: [],
|
||||
windows: ['C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe'],
|
||||
},
|
||||
'chrome-beta': {
|
||||
mac: [],
|
||||
linux: [],
|
||||
windows: [],
|
||||
},
|
||||
'chrome-canary': {
|
||||
mac: [
|
||||
'/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary',
|
||||
],
|
||||
linux: [],
|
||||
windows: [],
|
||||
},
|
||||
'chrome-dev': {
|
||||
mac: [],
|
||||
linux: [],
|
||||
windows: [],
|
||||
},
|
||||
edge: {
|
||||
mac: [],
|
||||
linux: [],
|
||||
windows: [],
|
||||
},
|
||||
'edge-beta': {
|
||||
mac: [],
|
||||
linux: [],
|
||||
windows: [],
|
||||
},
|
||||
'edge-canary': {
|
||||
mac: [],
|
||||
linux: [],
|
||||
windows: [],
|
||||
},
|
||||
'edge-dev': {
|
||||
mac: [],
|
||||
linux: [],
|
||||
windows: [],
|
||||
},
|
||||
|
||||
// Firefox based targets
|
||||
|
||||
firefox: {
|
||||
mac: ['/Applications/Firefox.app/Contents/MacOS/firefox'],
|
||||
linux: [
|
||||
// Arch
|
||||
'/usr/bin/firefox',
|
||||
],
|
||||
windows: [],
|
||||
},
|
||||
'firefox-nightly': {
|
||||
mac: ['/Applications/Firefox Nightly.app/Contents/MacOS/firefox'],
|
||||
linux: [],
|
||||
windows: ['C:\\Program Files\\Firefox Nightly\\firefox.exe'],
|
||||
},
|
||||
'firefox-developer-edition': {
|
||||
mac: ['/Applications/Firefox Developer Edition.app/Contents/MacOS/firefox'],
|
||||
linux: [
|
||||
// Arch
|
||||
'/usr/bin/firefox-developer-edition',
|
||||
],
|
||||
windows: [],
|
||||
},
|
||||
zen: {
|
||||
mac: ['/Applications/Zen Browser.app/Contents/MacOS/zen'],
|
||||
linux: [],
|
||||
windows: [],
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* When targeting a browser, this map contains the other targets to fall back on when a binary could not be found for the primary target.
|
||||
*/
|
||||
export const FALLBACK_TARGETS: Partial<Record<KnownTarget, KnownTarget[]>> = {
|
||||
chrome: [
|
||||
'chromium',
|
||||
'chrome-canary',
|
||||
'chrome-beta',
|
||||
'chrome-dev',
|
||||
'edge',
|
||||
'edge-canary',
|
||||
'edge-beta',
|
||||
'edge-dev',
|
||||
],
|
||||
firefox: ['firefox-developer-edition', 'firefox-nightly', 'zen'],
|
||||
};
|
||||
@@ -0,0 +1,57 @@
|
||||
import type { ChildProcess } from 'node:child_process';
|
||||
import type { Readable, Writable } from 'node:stream';
|
||||
import { debug } from './debug';
|
||||
|
||||
const debugCdp = debug.scoped('cdp');
|
||||
|
||||
export interface CDPConnection extends Disposable {
|
||||
send<T>(method: string, params: any, timeout?: number): Promise<T>;
|
||||
close(): void;
|
||||
}
|
||||
|
||||
export function createCdpConnection(
|
||||
browserProcess: ChildProcess,
|
||||
): CDPConnection {
|
||||
const inputStream = browserProcess.stdio[3] as Writable;
|
||||
const outputStream = browserProcess.stdio[4] as Readable;
|
||||
|
||||
let requestId = 0;
|
||||
|
||||
return {
|
||||
send(method, params, timeout = 10e3) {
|
||||
const id = ++requestId;
|
||||
const command = { id, method, params };
|
||||
debugCdp('Sending command:', command);
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
reject(new Error(`CDP command timed out: ${method}`));
|
||||
}, timeout);
|
||||
|
||||
const onData = (data: Buffer) => {
|
||||
// Trim the trailing null character
|
||||
const text = data.toString().slice(0, -1);
|
||||
const res = JSON.parse(text);
|
||||
|
||||
if (res.id !== id) return;
|
||||
|
||||
debugCdp('Received response:', res);
|
||||
clearTimeout(timer);
|
||||
outputStream.removeListener('data', onData);
|
||||
|
||||
if ('error' in res) {
|
||||
reject(new Error(res.error.message, { cause: res.error }));
|
||||
} else {
|
||||
resolve(res.result);
|
||||
}
|
||||
};
|
||||
|
||||
outputStream.addListener('data', onData);
|
||||
|
||||
inputStream.write(JSON.stringify(command) + '\0');
|
||||
});
|
||||
},
|
||||
close() {},
|
||||
[Symbol.dispose]() {},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
export interface Debug {
|
||||
(...args: any[]): void;
|
||||
scoped: (scope: string) => Debug;
|
||||
}
|
||||
|
||||
function createDebug(scopes: string[]): Debug {
|
||||
const debug = (...args: any[]) => {
|
||||
const scope = scopes.join(':');
|
||||
if (
|
||||
process.env.DEBUG === '1' ||
|
||||
process.env.DEBUG === 'true' ||
|
||||
scope.startsWith(process.env.DEBUG ?? '@NOT')
|
||||
) {
|
||||
const params = scope ? [`\x1b[31m${scope}\x1b[0m`, ...args] : args;
|
||||
console.log(...params);
|
||||
}
|
||||
};
|
||||
|
||||
debug.scoped = (scope: string) => createDebug([...scopes, scope]);
|
||||
|
||||
return debug;
|
||||
}
|
||||
|
||||
export const debug = createDebug(['@wxt-dev/runner']);
|
||||
@@ -0,0 +1,3 @@
|
||||
export * from './run';
|
||||
export * from './options';
|
||||
export * from './install';
|
||||
@@ -0,0 +1,59 @@
|
||||
import type { ChildProcess } from 'node:child_process';
|
||||
import { createBidiConnection } from './bidi';
|
||||
import { createCdpConnection } from './cdp';
|
||||
|
||||
/**
|
||||
* Install an extension to an already running instance of Firefox.
|
||||
* @param debuggerUrl The URL of the Firefox BiDi server (ex: `ws://127.0.0.1:45912`).
|
||||
* @param extensionDir Absolute path to the directory containing the extension to be installed.
|
||||
*/
|
||||
export async function installFirefox(
|
||||
debuggerUrl: string,
|
||||
extensionDir: string,
|
||||
): Promise<BidiWebExtensionInstallResponse> {
|
||||
using bidi = await createBidiConnection(debuggerUrl);
|
||||
|
||||
// Start a session
|
||||
await bidi.send<unknown>('session.new', { capabilities: {} });
|
||||
|
||||
// Install the extension
|
||||
return await bidi.send<BidiWebExtensionInstallResponse>(
|
||||
'webExtension.install',
|
||||
{
|
||||
extensionData: {
|
||||
type: 'path',
|
||||
path: extensionDir,
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export type BidiWebExtensionInstallResponse = {
|
||||
extension: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Given a child process of Chrome, install an extension. The process must be started with the following flags:
|
||||
*
|
||||
* - `--remote-debugging-pipe`
|
||||
* - `--user-data-dir=...`
|
||||
* - `--enable-unsafe-extension-debugging`
|
||||
*
|
||||
* Otherwise it the CDP doesn't have permission to install extensions.
|
||||
*/
|
||||
export async function installChromium(
|
||||
browserProcess: ChildProcess,
|
||||
extensionDir: string,
|
||||
): Promise<CdpExtensionsLoadUnpackedResponse> {
|
||||
using cdp = createCdpConnection(browserProcess);
|
||||
return await cdp.send<CdpExtensionsLoadUnpackedResponse>(
|
||||
'Extensions.loadUnpacked',
|
||||
{
|
||||
path: extensionDir,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export type CdpExtensionsLoadUnpackedResponse = {
|
||||
id: string;
|
||||
};
|
||||
@@ -0,0 +1,286 @@
|
||||
import {
|
||||
FALLBACK_TARGETS,
|
||||
KNOWN_BROWSER_PATHS,
|
||||
KnownTarget,
|
||||
type BrowserPlatform,
|
||||
} from './browser-paths';
|
||||
import { resolve, join } from 'node:path';
|
||||
import { homedir, tmpdir } from 'node:os';
|
||||
import { debug } from './debug';
|
||||
import { mkdtemp, open } from 'node:fs/promises';
|
||||
|
||||
const debugOptions = debug.scoped('options');
|
||||
|
||||
export type UnknownTarget = string & {};
|
||||
export type Target = KnownTarget | UnknownTarget;
|
||||
|
||||
export type RunOptions = {
|
||||
/** Paths to binaries to use for each target. */
|
||||
browserBinaries?: Record<string, string>;
|
||||
/** Customize the arguments passed to the chromium binary. Conflicting arguments with required ones to install extensions are ignored. */
|
||||
chromiumArgs?: string[];
|
||||
/** Control how data is persisted between launches. Either save data at a user level, project level, or don't persist data at all. Defaults to `project`. */
|
||||
dataPersistence?: 'user' | 'project' | 'none';
|
||||
/** Customize where your profile's data is stored when using `dataPersistence: 'project'`. Can be absolute or relative to the current working directory. */
|
||||
projectDataDir?: string;
|
||||
/** Customize the port Chrome's debugger is listening on. Defaults to a random open port. */
|
||||
chromiumRemoteDebuggingPort?: number;
|
||||
/** Directory where the extension will be installed from. Should contain a `manifest.json` file. Can be relative to the current working directory. Defaults to the current working directory. */
|
||||
extensionDir?: string;
|
||||
/** Customize the arguments passed to the firefox binary. Conflicting arguments with required ones to install extensions are ignored. */
|
||||
firefoxArgs?: string[];
|
||||
/** Customize the port Firefox's debugger is listening on. Defaults to a random open port. */
|
||||
firefoxRemoteDebuggingPort?: number;
|
||||
/** Specify the browser to open. Defaults to `"chrome"`, but you can pass any string. */
|
||||
target?: Target;
|
||||
};
|
||||
|
||||
export type ResolvedRunOptions = {
|
||||
/** Absolute path to the browser binary. */
|
||||
browserBinary: string;
|
||||
chromiumArgs: string[];
|
||||
chromiumRemoteDebuggingPort: number;
|
||||
/** Absolute path to the directory where browser data will be stored. */
|
||||
dataDir: string;
|
||||
dataPersistence: 'user' | 'project' | 'none';
|
||||
/** Absolute path to the extension directory. */
|
||||
extensionDir: string;
|
||||
firefoxArgs: string[];
|
||||
firefoxRemoteDebuggingPort: number;
|
||||
target: string;
|
||||
};
|
||||
|
||||
export async function resolveRunOptions(
|
||||
options: RunOptions | undefined,
|
||||
): Promise<ResolvedRunOptions> {
|
||||
debugOptions('User options:', options);
|
||||
|
||||
const target = options?.target || 'chrome';
|
||||
|
||||
const _browserBinary =
|
||||
options?.browserBinaries?.[target] ?? (await findBrowserBinary(target));
|
||||
if (!_browserBinary)
|
||||
throw Error(
|
||||
`Could not find "${target}" binary.\n\nIf it is installed in a custom location, you can specify the path with the browserPaths option.`,
|
||||
);
|
||||
|
||||
// Denormalize the path so it uses the correct path separator for the OS
|
||||
const browserBinary = resolve(_browserBinary);
|
||||
|
||||
const chromiumRemoteDebuggingPort = options?.chromiumRemoteDebuggingPort ?? 0;
|
||||
const firefoxRemoteDebuggingPort = options?.firefoxRemoteDebuggingPort ?? 0;
|
||||
const dataPersistence = options?.dataPersistence ?? 'none';
|
||||
const dataDir =
|
||||
dataPersistence === 'user'
|
||||
? join(homedir(), '.wxt-runner', target)
|
||||
: dataPersistence === 'project'
|
||||
? options?.projectDataDir
|
||||
? resolve(options.projectDataDir)
|
||||
: resolve('.wxt-runner', target)
|
||||
: dataPersistence === 'none'
|
||||
? await mkdtemp(join(tmpdir(), 'wxt-runner-'))
|
||||
: resolve(dataPersistence);
|
||||
|
||||
const resolved: ResolvedRunOptions = {
|
||||
browserBinary,
|
||||
chromiumArgs: resolveChromiumArgs(
|
||||
options?.chromiumArgs,
|
||||
chromiumRemoteDebuggingPort,
|
||||
dataDir,
|
||||
),
|
||||
dataDir,
|
||||
dataPersistence,
|
||||
chromiumRemoteDebuggingPort,
|
||||
extensionDir: resolve(options?.extensionDir ?? '.'),
|
||||
firefoxArgs: resolveFirefoxArgs(
|
||||
options?.firefoxArgs,
|
||||
firefoxRemoteDebuggingPort,
|
||||
dataDir,
|
||||
),
|
||||
firefoxRemoteDebuggingPort,
|
||||
target,
|
||||
};
|
||||
debugOptions('Resolved options:', resolved);
|
||||
return resolved;
|
||||
}
|
||||
|
||||
async function findBrowserBinary(target: string): Promise<string | undefined> {
|
||||
const targets = new Set<KnownTarget>([target as KnownTarget]);
|
||||
FALLBACK_TARGETS[target as KnownTarget]?.forEach((fallback) =>
|
||||
targets.add(fallback),
|
||||
);
|
||||
const platform = getPlatform();
|
||||
|
||||
for (const target of targets) {
|
||||
const potentialPaths = KNOWN_BROWSER_PATHS[target]?.[platform] ?? [];
|
||||
for (const path of potentialPaths) {
|
||||
if (await exists(path)) return path;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function getPlatform(): BrowserPlatform {
|
||||
switch (process.platform) {
|
||||
case 'win32':
|
||||
return 'windows';
|
||||
case 'darwin':
|
||||
return 'mac';
|
||||
default:
|
||||
return 'linux';
|
||||
}
|
||||
}
|
||||
|
||||
function resolveChromiumArgs(
|
||||
userArgs: string[] | undefined,
|
||||
chromiumRemoteDebuggingPort: ResolvedRunOptions['chromiumRemoteDebuggingPort'],
|
||||
dataDir: string,
|
||||
): string[] {
|
||||
return deduplicateArgs(
|
||||
[
|
||||
// Limit features to improve performance
|
||||
...CHROME_LAUNCHER_DEFAULT_FLAGS,
|
||||
// Enable debugging
|
||||
`--remote-debugging-port=${chromiumRemoteDebuggingPort}`,
|
||||
// Required for installing extensions
|
||||
`--remote-debugging-pipe`,
|
||||
`--user-data-dir=${dataDir}`,
|
||||
`--enable-unsafe-extension-debugging`,
|
||||
],
|
||||
userArgs,
|
||||
{
|
||||
'--remote-debugging-port':
|
||||
'\x1b[1m\x1b[33mCustom Chromium --remote-debugging-port argument ignored.\x1b[0m Use \x1b[36mchromiumRemoteDebuggingPort\x1b[0m option instead.',
|
||||
'--user-data-dir':
|
||||
'\x1b[1m\x1b[33mCustom Chromium --user-data-dir argument ignored.\x1b[0m Use \x1b[36mdataPersistence\x1b[0m option instead.',
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function resolveFirefoxArgs(
|
||||
userArgs: string[] | undefined,
|
||||
firefoxRemoteDebuggingPort: ResolvedRunOptions['firefoxRemoteDebuggingPort'],
|
||||
dataDir: string,
|
||||
): string[] {
|
||||
return deduplicateArgs(
|
||||
[
|
||||
// Allows opening multiple instances of Firefox at the same time
|
||||
`--new-instance`,
|
||||
`--no-remote`,
|
||||
`--profile`,
|
||||
dataDir,
|
||||
// Required for installing extensions
|
||||
`--remote-debugging-port=${firefoxRemoteDebuggingPort}`,
|
||||
// Default URL to start with
|
||||
`about:debugging#/runtime/this-firefox`,
|
||||
],
|
||||
userArgs,
|
||||
{
|
||||
'--remote-debugging-port':
|
||||
'\x1b[1m\x1b[33mCustom Firefox --remote-debugging-port argument ignored.\x1b[0m Use \x1b[36mfirefoxDebuggerPort\x1b[0m option instead.',
|
||||
'--profile':
|
||||
'\x1b[1m\x1b[33mCustom Firefox --profile argument ignored.\x1b[0m Use \x1b[36mdataPersistence\x1b[0m option instead.',
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function deduplicateArgs(
|
||||
requiredArgs: string[],
|
||||
userArgs: string[] | undefined,
|
||||
warnings: Record<string, string>,
|
||||
): string[] {
|
||||
const getKey = (arg: string) => {
|
||||
return arg.startsWith('--') ? arg.split('=')[0] : arg;
|
||||
};
|
||||
const alreadyAdded = new Set<string>(requiredArgs.map(getKey));
|
||||
|
||||
const args = [...requiredArgs];
|
||||
userArgs?.forEach((arg) => {
|
||||
const key = getKey(arg);
|
||||
if (alreadyAdded.has(key)) {
|
||||
if (warnings[key]) console.warn(`[@wxt-dev/runner] ${warnings[key]}`);
|
||||
} else {
|
||||
alreadyAdded.add(key);
|
||||
args.push(arg);
|
||||
}
|
||||
});
|
||||
|
||||
return args;
|
||||
}
|
||||
|
||||
async function exists(path: string): Promise<boolean> {
|
||||
try {
|
||||
await open(path, 'r');
|
||||
return true;
|
||||
} catch (err) {
|
||||
// @ts-expect-error: Unknown error type
|
||||
if (err?.code === 'ENOENT') return false;
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Copied from https://github.com/GoogleChrome/chrome-launcher/blob/main/src/flags.ts
|
||||
* with some flags commented out. Run tests after updating to compare.
|
||||
*/
|
||||
const CHROME_LAUNCHER_DEFAULT_FLAGS = [
|
||||
'--disable-features=' +
|
||||
[
|
||||
// Disable built-in Google Translate service
|
||||
'Translate',
|
||||
// Disable the Chrome Optimization Guide background networking
|
||||
'OptimizationHints',
|
||||
// Disable the Chrome Media Router (cast target discovery) background networking
|
||||
'MediaRouter',
|
||||
/// Avoid the startup dialog for _Do you want the application “Chromium.app” to accept incoming network connections?_. This is a sub-component of the MediaRouter.
|
||||
'DialMediaRouteProvider',
|
||||
// Disable the feature of: Calculate window occlusion on Windows will be used in the future to throttle and potentially unload foreground tabs in occluded windows.
|
||||
'CalculateNativeWinOcclusion',
|
||||
// Disables the Discover feed on NTP
|
||||
'InterestFeedContentSuggestions',
|
||||
// Don't update the CT lists
|
||||
'CertificateTransparencyComponentUpdater',
|
||||
// Disables autofill server communication. This feature isn't disabled via other 'parent' flags.
|
||||
'AutofillServerCommunication',
|
||||
// Disables "Enhanced ad privacy in Chrome" dialog (though as of 2024-03-20 it shouldn't show up if the profile has no stored country).
|
||||
'PrivacySandboxSettings4',
|
||||
].join(','),
|
||||
|
||||
// Disable some extensions that aren't affected by --disable-extensions
|
||||
'--disable-component-extensions-with-background-pages',
|
||||
// Disable various background network services, including extension updating,
|
||||
// safe browsing service, upgrade detector, translate, UMA
|
||||
'--disable-background-networking',
|
||||
// Don't update the browser 'components' listed at chrome://components/
|
||||
'--disable-component-update',
|
||||
// Disables client-side phishing detection.
|
||||
'--disable-client-side-phishing-detection',
|
||||
// Disable syncing to a Google account
|
||||
'--disable-sync',
|
||||
// Disable reporting to UMA, but allows for collection
|
||||
'--metrics-recording-only',
|
||||
// Disable installation of default apps on first run
|
||||
'--disable-default-apps',
|
||||
// Disable the default browser check, do not prompt to set it as such
|
||||
'--no-default-browser-check',
|
||||
// Skip first run wizards
|
||||
'--no-first-run',
|
||||
// Disable task throttling of timer tasks from background pages.
|
||||
'--disable-background-timer-throttling',
|
||||
// Disable the default throttling of IPC between renderer & browser processes.
|
||||
'--disable-ipc-flooding-protection',
|
||||
// Avoid potential instability of using Gnome Keyring or KDE wallet. crbug.com/571003 crbug.com/991424
|
||||
'--password-store=basic',
|
||||
// Use mock keychain on Mac to prevent blocking permissions dialogs
|
||||
'--use-mock-keychain',
|
||||
// Disable background tracing (aka slow reports & deep reports) to avoid 'Tracing already started'
|
||||
'--force-fieldtrials=*BackgroundTracing/default/',
|
||||
|
||||
// Suppresses hang monitor dialogs in renderer processes. This flag may allow slow unload handlers on a page to prevent the tab from closing.
|
||||
'--disable-hang-monitor',
|
||||
// Reloading a page that came from a POST normally prompts the user.
|
||||
'--disable-prompt-on-repost',
|
||||
// Disables Domain Reliability Monitoring, which tracks whether the browser has difficulty contacting Google-owned sites and uploads reports to Google.
|
||||
'--disable-domain-reliability',
|
||||
// Disable the in-product Help (IPH) system.
|
||||
'--propagate-iph-for-testing',
|
||||
];
|
||||
@@ -0,0 +1,19 @@
|
||||
export function promiseWithResolvers<T>(): {
|
||||
promise: Promise<T>;
|
||||
resolve: (value: T) => void;
|
||||
reject: (error: unknown) => void;
|
||||
} {
|
||||
let resolve: (value: T) => void;
|
||||
let reject: (error: unknown) => void;
|
||||
|
||||
const promise = new Promise<T>((res, rej) => {
|
||||
resolve = res;
|
||||
reject = rej;
|
||||
});
|
||||
|
||||
return {
|
||||
promise,
|
||||
resolve: resolve!,
|
||||
reject: reject!,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
import { debug } from './debug';
|
||||
import {
|
||||
resolveRunOptions,
|
||||
type ResolvedRunOptions,
|
||||
type RunOptions,
|
||||
} from './options';
|
||||
import { spawn } from 'node:child_process';
|
||||
import { installChromium, installFirefox } from './install';
|
||||
import { promiseWithResolvers } from './promises';
|
||||
|
||||
const debugFirefox = debug.scoped('firefox');
|
||||
const debugChrome = debug.scoped('chrome');
|
||||
|
||||
export interface Runner {
|
||||
stop(): void;
|
||||
}
|
||||
|
||||
export async function run(options: RunOptions): Promise<Runner> {
|
||||
const resolvedOptions = await resolveRunOptions(options);
|
||||
|
||||
if (
|
||||
resolvedOptions.target.includes('firefox') ||
|
||||
resolvedOptions.target.includes('zen')
|
||||
) {
|
||||
return runFirefox(resolvedOptions);
|
||||
} else {
|
||||
return runChromium(resolvedOptions);
|
||||
}
|
||||
}
|
||||
|
||||
async function runFirefox(options: ResolvedRunOptions): Promise<Runner> {
|
||||
const urlRes = promiseWithResolvers<string>();
|
||||
const urlTimeout = setTimeout(() => {
|
||||
urlRes.reject(Error('Timed out after 10s waiting for the browser to open'));
|
||||
}, 10e3);
|
||||
|
||||
// Firefox notifies the user if an instance is already running, so we don't add any logs for it.
|
||||
|
||||
const browserProcess = spawn(
|
||||
`"${options.browserBinary}"`,
|
||||
options.firefoxArgs,
|
||||
{
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
shell: true,
|
||||
},
|
||||
);
|
||||
const debugFirefoxStderr = debugFirefox.scoped('stderr');
|
||||
browserProcess.stderr.on('data', (data: string) => {
|
||||
const message = data.toString().trim();
|
||||
debugFirefoxStderr(message);
|
||||
|
||||
if (message.startsWith('WebDriver BiDi listening on ws://')) {
|
||||
clearTimeout(urlTimeout);
|
||||
urlRes.resolve(message.slice(28));
|
||||
}
|
||||
});
|
||||
const debugFirefoxStdout = debugFirefox.scoped('stdout');
|
||||
browserProcess.stdout.on('data', (data: string) => {
|
||||
const message = data.toString().trim();
|
||||
debugFirefoxStdout(message);
|
||||
});
|
||||
|
||||
const baseUrl = await urlRes.promise;
|
||||
await installFirefox(baseUrl, options.extensionDir);
|
||||
|
||||
return {
|
||||
stop() {
|
||||
browserProcess.kill('SIGINT');
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function runChromium(options: ResolvedRunOptions): Promise<Runner> {
|
||||
const browserProcess = spawn(
|
||||
`"${options.browserBinary}"`,
|
||||
options.chromiumArgs,
|
||||
{
|
||||
stdio: ['ignore', 'pipe', 'pipe', 'pipe', 'pipe'],
|
||||
shell: true,
|
||||
},
|
||||
);
|
||||
|
||||
const opened = promiseWithResolvers<void>();
|
||||
const openedTimeout = setTimeout(() => {
|
||||
opened.reject(Error('Timed out after 10s waiting for browser to open.'));
|
||||
}, 10e3);
|
||||
|
||||
const debugChromeStderr = debugChrome.scoped('stderr');
|
||||
browserProcess.stderr!.on('data', (data: string) => {
|
||||
const message = data.toString().trim();
|
||||
debugChromeStderr(message);
|
||||
|
||||
// This message signifies Chrome started up correctly.
|
||||
if (message.startsWith('DevTools listening on')) {
|
||||
clearTimeout(openedTimeout);
|
||||
opened.resolve();
|
||||
}
|
||||
});
|
||||
const debugChromeStdout = debugChrome.scoped('stdout');
|
||||
browserProcess.stdout!.on('data', (data: string) => {
|
||||
const message = data.toString().trim();
|
||||
debugChromeStdout(message);
|
||||
|
||||
// This message signifies Chrome was already open, and thus we couldn't open the required new instance.
|
||||
if (message === 'Opening in existing browser session.') {
|
||||
clearTimeout(openedTimeout);
|
||||
opened.reject(
|
||||
Error(
|
||||
'An instance of the browser is already running. Close it and try again.',
|
||||
),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
// Wait for the browser to open before proceeding.
|
||||
await opened.promise;
|
||||
|
||||
await installChromium(browserProcess, options.extensionDir);
|
||||
|
||||
return {
|
||||
stop() {
|
||||
browserProcess.kill('SIGINT');
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
export function openWebSocket(url: string): Promise<WebSocket> {
|
||||
if (typeof WebSocket === 'undefined') {
|
||||
throw new Error(
|
||||
'To open Firefox, your JS runtime must support the standard WebSocket API (NodeJS >=22.4.0, Bun, etc).',
|
||||
);
|
||||
}
|
||||
|
||||
return new Promise<WebSocket>((resolve, reject) => {
|
||||
const webSocket = new WebSocket(url);
|
||||
|
||||
const cleanup = () => {
|
||||
webSocket.removeEventListener('open', onOpen);
|
||||
webSocket.removeEventListener('error', onError);
|
||||
webSocket.removeEventListener('close', onClose);
|
||||
};
|
||||
const onOpen = async () => {
|
||||
cleanup();
|
||||
resolve(webSocket);
|
||||
};
|
||||
const onClose = (event: CloseEvent) => {
|
||||
cleanup();
|
||||
reject(
|
||||
new Error(
|
||||
`Connection closed: code=${event.code}, reason=${event.reason}`,
|
||||
),
|
||||
);
|
||||
};
|
||||
const onError = (error: any) => {
|
||||
cleanup();
|
||||
reject(new Error('Error connecting to WebSocket', { cause: error }));
|
||||
};
|
||||
|
||||
webSocket.addEventListener('open', onOpen);
|
||||
webSocket.addEventListener('error', onError);
|
||||
webSocket.addEventListener('close', onClose);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { defineProject } from 'vitest/config';
|
||||
|
||||
export default defineProject({
|
||||
test: {
|
||||
mockReset: true,
|
||||
restoreMocks: true,
|
||||
},
|
||||
});
|
||||
Generated
+489
-366
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user