@ai-ecoverse/slicc-cdp 1.25.1 → 1.26.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +20 -2
- package/bin/curlwright.wasm +0 -0
- package/bin/playwright-cli.wasm +0 -0
- package/package.json +14 -8
- package/src/browser.js +456 -0
- package/src/cli.js +91 -0
- package/src/index.d.ts +64 -0
- package/src/index.js +8 -0
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# slicc-cdp
|
|
2
2
|
|
|
3
|
-
`@ai-ecoverse/slicc-cdp` publishes `playwright-cli` and `curlwright`, Rust clients for an already-running Chrome. They speak Chrome DevTools Protocol JSON. The package root exports `CDPClient` and `connect`, one browser DevTools socket. Host `connect()` does not dial port 9222 unless `options.url` or `SLICC_CDP_URL` names it.
|
|
3
|
+
`@ai-ecoverse/slicc-cdp` publishes `playwright-cli` and `curlwright`, Rust clients for an already-running Chrome. They speak Chrome DevTools Protocol JSON. The package root exports `CDPClient` and `connect`, one browser DevTools socket, and `findBrowser`, `installBrowser` and `launchBrowser` (see [Browser](#browser)). Host `connect()` does not dial port 9222 unless `options.url` or `SLICC_CDP_URL` names it.
|
|
4
4
|
|
|
5
5
|
`playwright-cli` is a playwright-cli-compatible command for SLICC and not affiliated with Microsoft Playwright.
|
|
6
6
|
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
|
|
9
9
|
The binary is `crates/playwright-cli`. Host `open` launches a browser into `<config>/slicc/seven-profiles/<id>` when no debugger is already connected, and inside slicc it only opens a tab. It does not invent a debugger path.
|
|
10
10
|
|
|
11
|
-
Connect, in order: `--cdp`, else `SLICC_CDP_URL`, else a live `DevToolsActivePort` under `<config>/slicc/seven-profiles`, else a loopback endpoint this tool saved at `<config>/slicc/playwright-cli/sessions/<hash>.json`. A `.playwright-cli/session.json` in the working directory is ignored. A child of that directory is a profile only when its name is a browser id: it starts with a letter or digit, then letters, digits, `.`, `_`, or `-`, at most 64 characters, and it does not contain `..`. Dotfiles, including `.migrated-from-profiles`, are not profiles. `playwright-cli` does not migrate `slicc/profiles`. Host `list`, and `open` when that port is absent or unreachable, GET `/json/version` and `/json/list` on `127.0.0.1:9222-9229`. An unreachable seven-profiles port falls through to that probe, so `open` may attach to a personal browser that already has a seven tab. A successful command writes the loopback endpoint it used, its provenance, and the ids of tabs it created. A saved endpoint is reused only when it is loopback. A probe-derived session is reused only while that browser still has a seven tab, and later commands show only tabs this tool created. An explicit `--cdp` or `SLICC_CDP_URL` may name any host, and that URL is not saved unless it is loopback. Host `open` and `tab-new` treat an unreachable saved endpoint as absent, then probe and may launch. Other commands report that connection error. Launch never binds those ports. The wasm build still uses port 9222. A `ws://` URL is dialed as given. An `http://` URL is discovery. On the native build a `wss://` URL is dialed as given. The WASI build has no TLS, so `wss://` and `https://` fail there. `--runtime <name>` is appended to the query of the URL that is opened and is not interpreted.
|
|
11
|
+
Connect, in order: `--cdp`, else `SLICC_CDP_URL`, else a live `DevToolsActivePort` under `<config>/slicc/seven-profiles`, else a loopback endpoint this tool saved at `<config>/slicc/playwright-cli/sessions/<hash>.json`. A `.playwright-cli/session.json` in the working directory is ignored. In the kernel, the wasm builds keep it at `<state>/slicc/playwright-cli/sessions/<hash>.json`, where `<state>` is `$XDG_STATE_HOME`, else `$XDG_CONFIG_HOME`, else `$HOME/.config`, and the hash is of `$PWD`. When that is not writable, `playwright-cli` prints `playwright-cli: running without saved state (<reason>)` on stderr and the command still succeeds, except `route` and `unroute`, which fail because they only save state. A child of that directory is a profile only when its name is a browser id: it starts with a letter or digit, then letters, digits, `.`, `_`, or `-`, at most 64 characters, and it does not contain `..`. Dotfiles, including `.migrated-from-profiles`, are not profiles. `playwright-cli` does not migrate `slicc/profiles`. Host `list`, and `open` when that port is absent or unreachable, GET `/json/version` and `/json/list` on `127.0.0.1:9222-9229`. An unreachable seven-profiles port falls through to that probe, so `open` may attach to a personal browser that already has a seven tab. A successful command writes the loopback endpoint it used, its provenance, and the ids of tabs it created. A saved endpoint is reused only when it is loopback. A probe-derived session is reused only while that browser still has a seven tab, and later commands show only tabs this tool created. An explicit `--cdp` or `SLICC_CDP_URL` may name any host, and that URL is not saved unless it is loopback. Host `open` and `tab-new` treat an unreachable saved endpoint as absent, then probe and may launch. Other commands report that connection error. Launch never binds those ports. The wasm build still uses port 9222. A `ws://` URL is dialed as given. An `http://` URL is discovery. On the native build a `wss://` URL is dialed as given. The WASI build has no TLS, so `wss://` and `https://` fail there. `--runtime <name>` is appended to the query of the URL that is opened and is not interpreted.
|
|
12
12
|
|
|
13
13
|
One browser WebSocket. `Target.attachToTarget` uses `{targetId, flatten: true}`. Later page commands send that `sessionId` at the top level. There is no `/devtools/page/<id>` socket.
|
|
14
14
|
|
|
@@ -58,6 +58,24 @@ A non-2xx discovery response prints the status and the body and exits non-zero.
|
|
|
58
58
|
|
|
59
59
|
`curlwright` is the Rust binary in `crates/curlwright`. It runs curl-style requests as a page-context `fetch()` in an already-open tab, so that tab's cookies, origin, and service worker apply. It uses `--cdp`, else `SLICC_CDP_URL`, else a live `seven-profiles` DevToolsActivePort. On the host, if none of those exist, it exits without dialing port 9222. Ports 9222-9229 inside those files are not dialed. The wasm build still uses port 9222. The shared CDP client is `crates/cdp-client`. Without `--tab`, it uses the `current` target this tool saved for the working directory under the user config dir when that tab is still open, otherwise the single open tab on the request URL's origin. It does not read a `.playwright-cli/session.json` in the working directory and it does not read the saved endpoint. Any other case exits 2 and lists `--tab` choices. A tab on a different origin is not used unless `--tab` or that session names it. A request URL on a protected origin (any `sliccy.ai` host, the vault, or `//` to one) is refused before any tab is chosen, with `curlwright: slicc: refused: target is on seven's origin`, and so is `--tab` on a protected tab.
|
|
60
60
|
|
|
61
|
+
## Browser
|
|
62
|
+
|
|
63
|
+
On the host, the package can find, install and launch a Chrome for a test suite or a script. This is Node only, not in the wasm commands.
|
|
64
|
+
|
|
65
|
+
- **`findBrowser({ version })`:** `$CHROME_PATH`, else the newest Chrome for Testing that `install-browser` put under the install root, else a system Chrome or Chromium (`/Applications`, `/usr/bin`, `/snap/bin`, Program Files). With `version` (a full version or a milestone) only that installed Chrome for Testing counts, and `$CHROME_PATH` is not used. Returns `{ executable, source, version? }` or `null`.
|
|
66
|
+
- **`installBrowser({ version, channel, withDeps })`:** downloads Chrome for Testing for this platform, unzips it, checks that `--version` prints the expected version (on Windows, which prints nothing, that the build's `<version>.manifest` is there), and marks it with `.slicc-cdp-browser.json`. An exact version that is already installed is used without the network. The install root is `$SLICC_BROWSERS_PATH`, else `~/Library/Caches/slicc-cdp/browsers`, `$XDG_CACHE_HOME/slicc-cdp/browsers` (default `~/.cache`), or `%LOCALAPPDATA%\slicc-cdp\browsers`. `withDeps` is opt-in. On Linux it runs `apt-get satisfy` on the build's `deb.deps`, as root or with `sudo -n`, and otherwise fails saying so.
|
|
67
|
+
- **`launchBrowser({ executable, userDataDir, port, headless, sandbox, args })`:** starts the browser with `--remote-debugging-port` (0 by default) on `127.0.0.1`. Without `userDataDir` the profile is a fresh `slicc-cdp-profile-*` directory in the temp dir, never the user's, and `close()` kills the browser and removes the profile. Ports 9222-9229 are refused, and so are `args` that set the debugging port, address, pipe or profile. A fixed `port` that already answers is refused. Returns `{ child, executable, userDataDir, port, wsUrl, close }`. With port 0 the `wsUrl` comes from a fresh `DevToolsActivePort`, which is removed before launch. Otherwise it comes from `/json/version` on that port. The sandbox stays on unless `sandbox: false`. Linux without unprivileged user namespaces (Ubuntu 23.10 and later, GitHub's `ubuntu-latest` included) needs that, or Chrome aborts with "No usable sandbox".
|
|
68
|
+
|
|
69
|
+
The `slicc-cdp` bin does the same from a shell:
|
|
70
|
+
|
|
71
|
+
```
|
|
72
|
+
npx slicc-cdp install-browser --version 153.0.8010.12 # prints the executable
|
|
73
|
+
npx slicc-cdp install-browser --channel stable --with-deps
|
|
74
|
+
npx slicc-cdp find-browser
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
Trust: Chrome for Testing publishes no checksums or signatures for its zips. `installBrowser` downloads only over HTTPS, only from `googlechromelabs.github.io` (the version index) and `storage.googleapis.com` (the zips). Redirects are refused. The only checks are the byte count and the version, so you are trusting those Google hosts and TLS. Pin an exact version (`--version x.y.z.w`) in CI, so a run gets the build you tested.
|
|
78
|
+
|
|
61
79
|
## Kernel commands
|
|
62
80
|
|
|
63
81
|
`slicc.abi` is `wasi`. The commands are `playwright-cli` (`bin/playwright-cli.wasm`) and `curlwright` (`bin/curlwright.wasm`). `npm run build:wasm` builds both for `wasm32-wasip1` and copies them there. The release workflow runs that before publish. Do not commit the wasm. On that target, TCP comes from the `wasix-net` 0.1 crate on crates.io.
|
package/bin/curlwright.wasm
CHANGED
|
Binary file
|
package/bin/playwright-cli.wasm
CHANGED
|
Binary file
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ai-ecoverse/slicc-cdp",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.26.0",
|
|
4
4
|
"description": "SLICC's CDP layer: client, browser API, transports, HAR, and ARIA snapshot",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "Apache-2.0",
|
|
@@ -30,6 +30,9 @@
|
|
|
30
30
|
"default": "./src/index.js"
|
|
31
31
|
}
|
|
32
32
|
},
|
|
33
|
+
"bin": {
|
|
34
|
+
"slicc-cdp": "src/cli.js"
|
|
35
|
+
},
|
|
33
36
|
"files": [
|
|
34
37
|
"src",
|
|
35
38
|
"bin/playwright-cli.wasm",
|
|
@@ -51,21 +54,24 @@
|
|
|
51
54
|
"build:wasm": "cargo build --release --target wasm32-wasip1 -p slicc-cdp -p curlwright && mkdir -p bin && cp target/wasm32-wasip1/release/playwright-cli.wasm bin/playwright-cli.wasm && cp target/wasm32-wasip1/release/curlwright.wasm bin/curlwright.wasm",
|
|
52
55
|
"prepublishOnly": "npm run build:wasm",
|
|
53
56
|
"lint": "slicc-lint",
|
|
54
|
-
"typecheck": "node --check src/index.js && node --check src/cdp-client.js && node --check src/connect.js && node --check src/page.js && node --check
|
|
57
|
+
"typecheck": "node --check src/index.js && node --check src/cdp-client.js && node --check src/connect.js && node --check src/page.js && node --check src/browser.js && node --check src/cli.js && node --check packages/sliccy-browser/src/browser.js && node --check packages/sliccy-browser/src/fetch-script.js",
|
|
55
58
|
"format": "biome check --write .",
|
|
56
59
|
"test": "true",
|
|
57
|
-
"test:integration": "PATH=\"$HOME/.cargo/bin:$PATH\" cargo test --workspace && PATH=\"$HOME/.cargo/bin:$PATH\" cargo build -p slicc-cdp -p curlwright && PATH=\"$HOME/.cargo/bin:$PATH\" cargo build --target wasm32-wasip1 -p slicc-cdp -p curlwright &&
|
|
60
|
+
"test:integration": "PATH=\"$HOME/.cargo/bin:$PATH\" cargo test --workspace && PATH=\"$HOME/.cargo/bin:$PATH\" cargo build -p slicc-cdp -p curlwright && PATH=\"$HOME/.cargo/bin:$PATH\" cargo build --target wasm32-wasip1 -p slicc-cdp -p curlwright && npm run -s browser >/dev/null && node --test --test-timeout=600000 test/integration/*.test.mjs",
|
|
58
61
|
"test:unit": "mcr -c node_modules/@ai-ecoverse/slicc-shared-web/mcr.config.mjs node --test 'test/unit/**/*.test.mjs'",
|
|
59
62
|
"trust": "npx fledgling sync -y --skip-publish",
|
|
60
63
|
"trust:dry": "npx fledgling sync --dry-run -y --skip-publish",
|
|
61
|
-
"release": "semantic-release"
|
|
64
|
+
"release": "semantic-release",
|
|
65
|
+
"browser": "node src/cli.js install-browser --version \"$npm_package_config_chrome\""
|
|
66
|
+
},
|
|
67
|
+
"config": {
|
|
68
|
+
"chrome": "156.0.8078.4"
|
|
62
69
|
},
|
|
63
70
|
"devDependencies": {
|
|
64
|
-
"@ai-ecoverse/slicc-kernel": "1.
|
|
65
|
-
"@ai-ecoverse/slicc-shared-web": "1.
|
|
71
|
+
"@ai-ecoverse/slicc-kernel": "1.92.0",
|
|
72
|
+
"@ai-ecoverse/slicc-shared-web": "1.15.1",
|
|
66
73
|
"@biomejs/biome": "2.5.15",
|
|
67
|
-
"lefthook": "2.
|
|
68
|
-
"playwright-core": "1.63.0",
|
|
74
|
+
"lefthook": "2.2.0",
|
|
69
75
|
"semantic-release": "25.0.9"
|
|
70
76
|
}
|
|
71
77
|
}
|
package/src/browser.js
ADDED
|
@@ -0,0 +1,456 @@
|
|
|
1
|
+
import { execFile as execFileCallback, spawn } from 'node:child_process';
|
|
2
|
+
import { createWriteStream, existsSync, statSync } from 'node:fs';
|
|
3
|
+
import { mkdir, mkdtemp, readdir, readFile, rename, rm, writeFile } from 'node:fs/promises';
|
|
4
|
+
import { homedir, tmpdir } from 'node:os';
|
|
5
|
+
import { join } from 'node:path';
|
|
6
|
+
import { Readable } from 'node:stream';
|
|
7
|
+
import { pipeline } from 'node:stream/promises';
|
|
8
|
+
import { promisify } from 'node:util';
|
|
9
|
+
|
|
10
|
+
const execFile = promisify(execFileCallback);
|
|
11
|
+
|
|
12
|
+
export const CFT = 'https://googlechromelabs.github.io/chrome-for-testing';
|
|
13
|
+
export const TRUSTED_HOSTS = ['googlechromelabs.github.io', 'storage.googleapis.com'];
|
|
14
|
+
export const CHANNELS = ['Stable', 'Beta', 'Dev', 'Canary'];
|
|
15
|
+
export const MARKER = '.slicc-cdp-browser.json';
|
|
16
|
+
export const PROFILE_PREFIX = 'slicc-cdp-profile-';
|
|
17
|
+
export const RESERVED_PORTS = [9222, 9229];
|
|
18
|
+
const EXACT = /^\d+\.\d+\.\d+\.\d+$/u;
|
|
19
|
+
const MILESTONE = /^\d+$/u;
|
|
20
|
+
|
|
21
|
+
export function cftPlatform(platform = process.platform, arch = process.arch) {
|
|
22
|
+
if (platform === 'darwin') return arch === 'arm64' ? 'mac-arm64' : 'mac-x64';
|
|
23
|
+
if (platform === 'linux') {
|
|
24
|
+
if (arch === 'x64') return 'linux64';
|
|
25
|
+
if (arch === 'arm64') return 'linux-arm64';
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
if (platform === 'win32') return arch === 'ia32' ? 'win32' : 'win64';
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function executableIn(dir, platform) {
|
|
33
|
+
const root = join(dir, `chrome-${platform}`);
|
|
34
|
+
if (platform.startsWith('mac-')) {
|
|
35
|
+
return join(
|
|
36
|
+
root,
|
|
37
|
+
'Google Chrome for Testing.app',
|
|
38
|
+
'Contents',
|
|
39
|
+
'MacOS',
|
|
40
|
+
'Google Chrome for Testing'
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
if (platform.startsWith('win')) return join(root, 'chrome.exe');
|
|
44
|
+
return join(root, 'chrome');
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function browsersRoot(env = process.env, platform = process.platform, home = homedir()) {
|
|
48
|
+
if (env.SLICC_BROWSERS_PATH) return env.SLICC_BROWSERS_PATH;
|
|
49
|
+
if (platform === 'darwin') return join(home, 'Library', 'Caches', 'slicc-cdp', 'browsers');
|
|
50
|
+
if (platform === 'win32') {
|
|
51
|
+
return join(env.LOCALAPPDATA || join(home, 'AppData', 'Local'), 'slicc-cdp', 'browsers');
|
|
52
|
+
}
|
|
53
|
+
return join(env.XDG_CACHE_HOME || join(home, '.cache'), 'slicc-cdp', 'browsers');
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function systemCandidates(env = process.env, platform = process.platform) {
|
|
57
|
+
if (platform === 'darwin') {
|
|
58
|
+
return [
|
|
59
|
+
'Google Chrome',
|
|
60
|
+
'Google Chrome for Testing',
|
|
61
|
+
'Chromium',
|
|
62
|
+
'Google Chrome Canary',
|
|
63
|
+
'Google Chrome Beta',
|
|
64
|
+
'Google Chrome Dev',
|
|
65
|
+
].map((app) => `/Applications/${app}.app/Contents/MacOS/${app}`);
|
|
66
|
+
}
|
|
67
|
+
if (platform === 'win32') {
|
|
68
|
+
const roots = [env.PROGRAMFILES, env['PROGRAMFILES(X86)'], env.LOCALAPPDATA].filter(Boolean);
|
|
69
|
+
return roots.flatMap((root) => [
|
|
70
|
+
join(root, 'Google', 'Chrome', 'Application', 'chrome.exe'),
|
|
71
|
+
join(root, 'Chromium', 'Application', 'chrome.exe'),
|
|
72
|
+
]);
|
|
73
|
+
}
|
|
74
|
+
return [
|
|
75
|
+
'/usr/bin/google-chrome',
|
|
76
|
+
'/usr/bin/google-chrome-stable',
|
|
77
|
+
'/usr/bin/chromium',
|
|
78
|
+
'/usr/bin/chromium-browser',
|
|
79
|
+
'/snap/bin/chromium',
|
|
80
|
+
];
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function isFile(path) {
|
|
84
|
+
try {
|
|
85
|
+
return statSync(path).isFile();
|
|
86
|
+
} catch {
|
|
87
|
+
return false;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function compareVersions(a, b) {
|
|
92
|
+
const left = a.split('.').map(Number);
|
|
93
|
+
const right = b.split('.').map(Number);
|
|
94
|
+
for (let i = 0; i < Math.max(left.length, right.length); i += 1) {
|
|
95
|
+
const diff = (left[i] ?? 0) - (right[i] ?? 0);
|
|
96
|
+
if (diff !== 0) return diff;
|
|
97
|
+
}
|
|
98
|
+
return 0;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export async function installedBrowsers({ root = browsersRoot(), platform = cftPlatform() } = {}) {
|
|
102
|
+
const names = await readdir(root).catch(() => []);
|
|
103
|
+
const found = [];
|
|
104
|
+
for (const name of names) {
|
|
105
|
+
const marker = await readFile(join(root, name, MARKER), 'utf8').catch(() => null);
|
|
106
|
+
if (marker === null) continue;
|
|
107
|
+
let info;
|
|
108
|
+
try {
|
|
109
|
+
info = JSON.parse(marker);
|
|
110
|
+
} catch {
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
if (info.platform !== platform || typeof info.version !== 'string') continue;
|
|
114
|
+
const executable = executableIn(join(root, name), platform);
|
|
115
|
+
if (!isFile(executable)) continue;
|
|
116
|
+
found.push({ version: info.version, platform, dir: join(root, name), executable });
|
|
117
|
+
}
|
|
118
|
+
return found.sort((a, b) => compareVersions(b.version, a.version));
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export async function findBrowser({
|
|
122
|
+
env = process.env,
|
|
123
|
+
platform = process.platform,
|
|
124
|
+
arch = process.arch,
|
|
125
|
+
root = browsersRoot(env, platform),
|
|
126
|
+
candidates = systemCandidates(env, platform),
|
|
127
|
+
version,
|
|
128
|
+
} = {}) {
|
|
129
|
+
if (env.CHROME_PATH && !version) {
|
|
130
|
+
if (isFile(env.CHROME_PATH)) return { executable: env.CHROME_PATH, source: 'env' };
|
|
131
|
+
throw new Error(`CHROME_PATH is ${env.CHROME_PATH}, which is not a file`);
|
|
132
|
+
}
|
|
133
|
+
const target = cftPlatform(platform, arch);
|
|
134
|
+
if (target) {
|
|
135
|
+
const installs = await installedBrowsers({ root, platform: target });
|
|
136
|
+
const match = version
|
|
137
|
+
? installs.find((item) => item.version === version || item.version.startsWith(`${version}.`))
|
|
138
|
+
: installs[0];
|
|
139
|
+
if (match) return { executable: match.executable, source: 'installed', version: match.version };
|
|
140
|
+
if (version) return null;
|
|
141
|
+
}
|
|
142
|
+
const system = candidates.find(isFile);
|
|
143
|
+
if (system) return { executable: system, source: 'system' };
|
|
144
|
+
return null;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export function trusted(url) {
|
|
148
|
+
let parsed;
|
|
149
|
+
try {
|
|
150
|
+
parsed = new URL(url);
|
|
151
|
+
} catch {
|
|
152
|
+
return false;
|
|
153
|
+
}
|
|
154
|
+
return parsed.protocol === 'https:' && TRUSTED_HOSTS.includes(parsed.hostname);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function channelName(channel) {
|
|
158
|
+
const match = CHANNELS.find((name) => name.toLowerCase() === String(channel).toLowerCase());
|
|
159
|
+
if (!match) throw new Error(`unknown channel "${channel}" (expected ${CHANNELS.join(', ')})`);
|
|
160
|
+
return match;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
async function json(fetchImpl, url) {
|
|
164
|
+
const response = await fetchImpl(url);
|
|
165
|
+
if (!response.ok) throw new Error(`GET ${url}: ${response.status} ${response.statusText}`);
|
|
166
|
+
return response.json();
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export async function resolveRelease({
|
|
170
|
+
channel = 'Stable',
|
|
171
|
+
version,
|
|
172
|
+
fetch: fetchImpl = fetch,
|
|
173
|
+
} = {}) {
|
|
174
|
+
let entry;
|
|
175
|
+
if (version && EXACT.test(version)) {
|
|
176
|
+
const data = await json(fetchImpl, `${CFT}/known-good-versions-with-downloads.json`);
|
|
177
|
+
entry = data.versions?.find((item) => item.version === version);
|
|
178
|
+
} else if (version && MILESTONE.test(version)) {
|
|
179
|
+
const data = await json(fetchImpl, `${CFT}/latest-versions-per-milestone-with-downloads.json`);
|
|
180
|
+
entry = data.milestones?.[version];
|
|
181
|
+
} else if (version) {
|
|
182
|
+
throw new Error(
|
|
183
|
+
`unsupported version "${version}" (expected a full version such as 148.0.7778.97, or a milestone)`
|
|
184
|
+
);
|
|
185
|
+
} else {
|
|
186
|
+
const data = await json(fetchImpl, `${CFT}/last-known-good-versions-with-downloads.json`);
|
|
187
|
+
entry = data.channels?.[channelName(channel)];
|
|
188
|
+
}
|
|
189
|
+
if (!entry) throw new Error(`Chrome for Testing has no release for ${version ?? channel}`);
|
|
190
|
+
return { version: entry.version, downloads: entry.downloads?.chrome ?? [] };
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
async function download(fetchImpl, url, file) {
|
|
194
|
+
if (!trusted(url)) {
|
|
195
|
+
throw new Error(`refusing to download ${url}: only https from ${TRUSTED_HOSTS.join(', ')}`);
|
|
196
|
+
}
|
|
197
|
+
const response = await fetchImpl(url, { redirect: 'error' });
|
|
198
|
+
if (!response.ok || !response.body) {
|
|
199
|
+
throw new Error(`GET ${url}: ${response.status} ${response.statusText}`);
|
|
200
|
+
}
|
|
201
|
+
await pipeline(Readable.fromWeb(response.body), createWriteStream(file));
|
|
202
|
+
const expected = Number(response.headers.get('content-length'));
|
|
203
|
+
const actual = statSync(file).size;
|
|
204
|
+
if (expected > 0 && actual !== expected) {
|
|
205
|
+
throw new Error(`GET ${url}: got ${actual} bytes, expected ${expected}`);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
export async function extract(zip, dir, platform, run = execFile) {
|
|
210
|
+
if (platform.startsWith('mac')) return run('ditto', ['-x', '-k', zip, dir]);
|
|
211
|
+
if (platform.startsWith('win')) return run('tar', ['-xf', zip, '-C', dir]);
|
|
212
|
+
return run('unzip', ['-q', '-o', zip, '-d', dir]);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
export async function verify(executable, version, platform, run = execFile) {
|
|
216
|
+
if (!isFile(executable)) throw new Error(`${executable} is missing after extraction`);
|
|
217
|
+
if (platform.startsWith('win')) {
|
|
218
|
+
const manifest = join(executable, '..', `${version}.manifest`);
|
|
219
|
+
if (!isFile(manifest)) throw new Error(`${manifest} is missing, so this is not ${version}`);
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
222
|
+
const { stdout } = await run(executable, ['--version']);
|
|
223
|
+
if (!stdout.includes(version)) {
|
|
224
|
+
throw new Error(`${executable} --version printed "${stdout.trim()}", expected ${version}`);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
export async function privilege({ run = execFile, uid = process.getuid?.() } = {}) {
|
|
229
|
+
if (uid === 0) return [];
|
|
230
|
+
const sudo = await run('sudo', ['-n', 'true']).then(
|
|
231
|
+
() => true,
|
|
232
|
+
() => false
|
|
233
|
+
);
|
|
234
|
+
if (sudo) return ['sudo', '-n'];
|
|
235
|
+
throw new Error(
|
|
236
|
+
'--with-deps installs system packages with apt-get and needs root or passwordless sudo'
|
|
237
|
+
);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
export async function installDeps(
|
|
241
|
+
dir,
|
|
242
|
+
platform,
|
|
243
|
+
{ run = execFile, uid = process.getuid?.() } = {}
|
|
244
|
+
) {
|
|
245
|
+
if (!platform.startsWith('linux')) return;
|
|
246
|
+
const deps = await readFile(join(dir, `chrome-${platform}`, 'deb.deps'), 'utf8');
|
|
247
|
+
const list = deps
|
|
248
|
+
.split('\n')
|
|
249
|
+
.map((line) => line.trim())
|
|
250
|
+
.filter(Boolean);
|
|
251
|
+
if (list.length === 0) return;
|
|
252
|
+
const [file, ...prefix] = [...(await privilege({ run, uid })), 'apt-get'];
|
|
253
|
+
const env = { ...process.env, DEBIAN_FRONTEND: 'noninteractive' };
|
|
254
|
+
await run(file, [...prefix, 'update'], { env });
|
|
255
|
+
await run(file, [...prefix, 'satisfy', '-y', '--no-install-recommends', list.join(', ')], {
|
|
256
|
+
env,
|
|
257
|
+
});
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
export async function installBrowser({
|
|
261
|
+
channel = 'Stable',
|
|
262
|
+
version,
|
|
263
|
+
root = browsersRoot(),
|
|
264
|
+
platform = cftPlatform(),
|
|
265
|
+
withDeps = false,
|
|
266
|
+
force = false,
|
|
267
|
+
fetch: fetchImpl = fetch,
|
|
268
|
+
run = execFile,
|
|
269
|
+
uid = process.getuid?.(),
|
|
270
|
+
log = () => {},
|
|
271
|
+
} = {}) {
|
|
272
|
+
if (!platform) {
|
|
273
|
+
throw new Error(`Chrome for Testing has no build for ${process.platform} ${process.arch}`);
|
|
274
|
+
}
|
|
275
|
+
const deps = (dir) => (withDeps ? installDeps(dir, platform, { run, uid }) : undefined);
|
|
276
|
+
if (version && EXACT.test(version) && !force) {
|
|
277
|
+
const installs = await installedBrowsers({ root, platform });
|
|
278
|
+
const ready = installs.find((item) => item.version === version);
|
|
279
|
+
if (ready) {
|
|
280
|
+
await deps(ready.dir);
|
|
281
|
+
return { ...ready, installed: false };
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
const release = await resolveRelease({ channel, version, fetch: fetchImpl });
|
|
285
|
+
const dir = join(root, `chrome-${release.version}-${platform}`);
|
|
286
|
+
const executable = executableIn(dir, platform);
|
|
287
|
+
if (!force && existsSync(join(dir, MARKER)) && isFile(executable)) {
|
|
288
|
+
await deps(dir);
|
|
289
|
+
return { version: release.version, platform, dir, executable, installed: false };
|
|
290
|
+
}
|
|
291
|
+
const url = release.downloads.find((item) => item.platform === platform)?.url;
|
|
292
|
+
if (!url) throw new Error(`Chrome for Testing ${release.version} has no ${platform} build`);
|
|
293
|
+
await mkdir(root, { recursive: true });
|
|
294
|
+
const staging = await mkdtemp(join(root, '.staging-'));
|
|
295
|
+
try {
|
|
296
|
+
const zip = join(staging, 'chrome.zip');
|
|
297
|
+
log(`downloading Chrome for Testing ${release.version} (${platform}) from ${url}`);
|
|
298
|
+
await download(fetchImpl, url, zip);
|
|
299
|
+
const unpacked = join(staging, 'unpacked');
|
|
300
|
+
await mkdir(unpacked);
|
|
301
|
+
await extract(zip, unpacked, platform, run);
|
|
302
|
+
await deps(unpacked);
|
|
303
|
+
await verify(executableIn(unpacked, platform), release.version, platform, run);
|
|
304
|
+
await writeFile(
|
|
305
|
+
join(unpacked, MARKER),
|
|
306
|
+
`${JSON.stringify({ version: release.version, platform, url }, null, 2)}\n`
|
|
307
|
+
);
|
|
308
|
+
await rm(dir, { recursive: true, force: true });
|
|
309
|
+
await rename(unpacked, dir);
|
|
310
|
+
} finally {
|
|
311
|
+
await rm(staging, { recursive: true, force: true });
|
|
312
|
+
}
|
|
313
|
+
log(`installed ${executable}`);
|
|
314
|
+
return { version: release.version, platform, dir, executable, installed: true };
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
export const LAUNCH_FLAGS = [
|
|
318
|
+
'--no-first-run',
|
|
319
|
+
'--no-default-browser-check',
|
|
320
|
+
'--disable-background-networking',
|
|
321
|
+
'--disable-component-update',
|
|
322
|
+
'--disable-sync',
|
|
323
|
+
'--password-store=basic',
|
|
324
|
+
'--use-mock-keychain',
|
|
325
|
+
];
|
|
326
|
+
|
|
327
|
+
export function launchArgs({ userDataDir, port = 0, headless = true, sandbox = true, args = [] }) {
|
|
328
|
+
return [
|
|
329
|
+
`--remote-debugging-port=${port}`,
|
|
330
|
+
'--remote-debugging-address=127.0.0.1',
|
|
331
|
+
`--user-data-dir=${userDataDir}`,
|
|
332
|
+
...(headless ? ['--headless'] : []),
|
|
333
|
+
...(sandbox ? [] : ['--no-sandbox']),
|
|
334
|
+
...LAUNCH_FLAGS,
|
|
335
|
+
...args,
|
|
336
|
+
'about:blank',
|
|
337
|
+
];
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
export const OWNED_SWITCHES = [
|
|
341
|
+
'--remote-debugging-port',
|
|
342
|
+
'--remote-debugging-address',
|
|
343
|
+
'--remote-debugging-pipe',
|
|
344
|
+
'--user-data-dir',
|
|
345
|
+
];
|
|
346
|
+
|
|
347
|
+
function owned(arg) {
|
|
348
|
+
return OWNED_SWITCHES.some((name) => arg === name || arg.startsWith(`${name}=`));
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
const pause = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
352
|
+
const reserved = (port) => port >= RESERVED_PORTS[0] && port <= RESERVED_PORTS[1];
|
|
353
|
+
const gone = (child) => child.exitCode !== null || child.signalCode !== null;
|
|
354
|
+
|
|
355
|
+
async function versionAt(port, fetchImpl) {
|
|
356
|
+
try {
|
|
357
|
+
const response = await fetchImpl(`http://127.0.0.1:${port}/json/version`);
|
|
358
|
+
if (!response.ok) return null;
|
|
359
|
+
const { webSocketDebuggerUrl } = await response.json();
|
|
360
|
+
if (typeof webSocketDebuggerUrl !== 'string') return null;
|
|
361
|
+
return { port, path: new URL(webSocketDebuggerUrl).pathname };
|
|
362
|
+
} catch {
|
|
363
|
+
return null;
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
async function ready(userDataDir, port, child, deadline, fetchImpl) {
|
|
368
|
+
const file = join(userDataDir, 'DevToolsActivePort');
|
|
369
|
+
while (Date.now() < deadline) {
|
|
370
|
+
if (port === 0) {
|
|
371
|
+
const text = await readFile(file, 'utf8').catch(() => '');
|
|
372
|
+
const [found, path] = text.split('\n');
|
|
373
|
+
if (found && path) return { port: Number(found), path: path.trim() };
|
|
374
|
+
} else {
|
|
375
|
+
const found = await versionAt(port, fetchImpl);
|
|
376
|
+
if (found) return found;
|
|
377
|
+
}
|
|
378
|
+
if (gone(child)) return null;
|
|
379
|
+
await pause(50);
|
|
380
|
+
}
|
|
381
|
+
return null;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
export async function launchBrowser({
|
|
385
|
+
executable,
|
|
386
|
+
userDataDir,
|
|
387
|
+
port = 0,
|
|
388
|
+
headless = true,
|
|
389
|
+
sandbox = true,
|
|
390
|
+
args = [],
|
|
391
|
+
env = process.env,
|
|
392
|
+
timeout = 30000,
|
|
393
|
+
fetch: fetchImpl = fetch,
|
|
394
|
+
} = {}) {
|
|
395
|
+
const path = executable ?? (await findBrowser({ env }))?.executable;
|
|
396
|
+
if (!path) {
|
|
397
|
+
throw new Error(
|
|
398
|
+
'no Chrome or Chromium found: set CHROME_PATH or run `slicc-cdp install-browser`'
|
|
399
|
+
);
|
|
400
|
+
}
|
|
401
|
+
if (reserved(port)) {
|
|
402
|
+
throw new Error(`refusing to bind port ${port} (9222-9229 belong to the kernel and the probe)`);
|
|
403
|
+
}
|
|
404
|
+
const taken = args.find(owned);
|
|
405
|
+
if (taken) {
|
|
406
|
+
throw new Error(`args may not set ${taken}: use the port and userDataDir options`);
|
|
407
|
+
}
|
|
408
|
+
if (port !== 0 && (await versionAt(port, fetchImpl))) {
|
|
409
|
+
throw new Error(`a browser already listens on 127.0.0.1:${port}`);
|
|
410
|
+
}
|
|
411
|
+
const temporary = userDataDir === undefined;
|
|
412
|
+
const profile = userDataDir ?? (await mkdtemp(join(tmpdir(), PROFILE_PREFIX)));
|
|
413
|
+
await rm(join(profile, 'DevToolsActivePort'), { force: true });
|
|
414
|
+
let log = '';
|
|
415
|
+
const child = spawn(path, launchArgs({ userDataDir: profile, port, headless, sandbox, args }), {
|
|
416
|
+
env,
|
|
417
|
+
stdio: ['ignore', 'ignore', 'pipe'],
|
|
418
|
+
});
|
|
419
|
+
child.stderr.setEncoding('utf8');
|
|
420
|
+
child.stderr.on('data', (chunk) => {
|
|
421
|
+
log = (log + chunk).slice(-4000);
|
|
422
|
+
});
|
|
423
|
+
const spawned = new Promise((resolve) => child.once('error', resolve));
|
|
424
|
+
const exited = new Promise((resolve) => child.once('close', resolve));
|
|
425
|
+
const close = async () => {
|
|
426
|
+
if (child.pid !== undefined && !gone(child)) {
|
|
427
|
+
child.kill('SIGKILL');
|
|
428
|
+
await exited;
|
|
429
|
+
}
|
|
430
|
+
if (temporary) await rm(profile, { recursive: true, force: true });
|
|
431
|
+
};
|
|
432
|
+
const found = await Promise.race([
|
|
433
|
+
ready(profile, port, child, Date.now() + timeout, fetchImpl),
|
|
434
|
+
spawned.then((error) => ({ error })),
|
|
435
|
+
]);
|
|
436
|
+
if (!found || found.error) {
|
|
437
|
+
const status = gone(child)
|
|
438
|
+
? `exited with ${child.signalCode ?? `code ${child.exitCode}`}`
|
|
439
|
+
: `no DevTools endpoint after ${timeout} ms`;
|
|
440
|
+
await close();
|
|
441
|
+
const reason = found?.error?.message ?? (log.trim() || status);
|
|
442
|
+
throw new Error(`${path} did not start: ${reason}`);
|
|
443
|
+
}
|
|
444
|
+
if (reserved(found.port)) {
|
|
445
|
+
await close();
|
|
446
|
+
throw new Error(`browser bound reserved port ${found.port}`);
|
|
447
|
+
}
|
|
448
|
+
return {
|
|
449
|
+
child,
|
|
450
|
+
executable: path,
|
|
451
|
+
userDataDir: profile,
|
|
452
|
+
port: found.port,
|
|
453
|
+
wsUrl: `ws://127.0.0.1:${found.port}${found.path}`,
|
|
454
|
+
close,
|
|
455
|
+
};
|
|
456
|
+
}
|
package/src/cli.js
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { readFileSync, realpathSync } from 'node:fs';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
import { parseArgs } from 'node:util';
|
|
5
|
+
import { browsersRoot, findBrowser, installBrowser } from './browser.js';
|
|
6
|
+
|
|
7
|
+
export const USAGE = `usage: slicc-cdp <command> [options]
|
|
8
|
+
|
|
9
|
+
commands:
|
|
10
|
+
install-browser download Chrome for Testing, verify it, print its path
|
|
11
|
+
--version <x.y.z.w|milestone> pin a full version (recommended) or a milestone
|
|
12
|
+
--channel <stable|beta|dev|canary> without --version (default stable)
|
|
13
|
+
--path <dir> install root (default $SLICC_BROWSERS_PATH or the user cache)
|
|
14
|
+
--with-deps on Linux, apt-get Chrome's packages (needs root or sudo -n)
|
|
15
|
+
--force download again even when installed
|
|
16
|
+
find-browser print the browser launchBrowser() uses: $CHROME_PATH, else the
|
|
17
|
+
newest installed Chrome for Testing, else a system Chrome
|
|
18
|
+
--version <x.y.z.w|milestone> only that installed Chrome for Testing ($CHROME_PATH is not used)
|
|
19
|
+
--version, -V print the slicc-cdp version
|
|
20
|
+
`;
|
|
21
|
+
|
|
22
|
+
export const VERSION = JSON.parse(
|
|
23
|
+
readFileSync(new URL('../package.json', import.meta.url), 'utf8')
|
|
24
|
+
).version;
|
|
25
|
+
|
|
26
|
+
export async function main(
|
|
27
|
+
argv,
|
|
28
|
+
io = { out: process.stdout, err: process.stderr, env: process.env }
|
|
29
|
+
) {
|
|
30
|
+
const [command, ...rest] = argv;
|
|
31
|
+
if (!command || command === '--help' || command === '-h' || command === 'help') {
|
|
32
|
+
io.out.write(USAGE);
|
|
33
|
+
return command ? 0 : 2;
|
|
34
|
+
}
|
|
35
|
+
if (command === '--version' || command === '-V') {
|
|
36
|
+
io.out.write(`slicc-cdp ${VERSION}\n`);
|
|
37
|
+
return 0;
|
|
38
|
+
}
|
|
39
|
+
if (command === 'install-browser') {
|
|
40
|
+
const { values } = parseArgs({
|
|
41
|
+
args: rest,
|
|
42
|
+
options: {
|
|
43
|
+
channel: { type: 'string', default: 'stable' },
|
|
44
|
+
version: { type: 'string' },
|
|
45
|
+
path: { type: 'string' },
|
|
46
|
+
'with-deps': { type: 'boolean', default: false },
|
|
47
|
+
force: { type: 'boolean', default: false },
|
|
48
|
+
},
|
|
49
|
+
});
|
|
50
|
+
const result = await installBrowser({
|
|
51
|
+
channel: values.channel,
|
|
52
|
+
version: values.version,
|
|
53
|
+
root: values.path ?? browsersRoot(io.env),
|
|
54
|
+
withDeps: values['with-deps'],
|
|
55
|
+
force: values.force,
|
|
56
|
+
log: (line) => io.err.write(`${line}\n`),
|
|
57
|
+
});
|
|
58
|
+
if (!result.installed) {
|
|
59
|
+
io.err.write(`Chrome for Testing ${result.version} is already installed\n`);
|
|
60
|
+
}
|
|
61
|
+
io.out.write(`${result.executable}\n`);
|
|
62
|
+
return 0;
|
|
63
|
+
}
|
|
64
|
+
if (command === 'find-browser') {
|
|
65
|
+
const { values } = parseArgs({ args: rest, options: { version: { type: 'string' } } });
|
|
66
|
+
const found = await findBrowser({ env: io.env, version: values.version });
|
|
67
|
+
if (!found) {
|
|
68
|
+
io.err.write(
|
|
69
|
+
'no Chrome or Chromium found: set CHROME_PATH or run `slicc-cdp install-browser`\n'
|
|
70
|
+
);
|
|
71
|
+
return 1;
|
|
72
|
+
}
|
|
73
|
+
io.out.write(`${found.executable}\n`);
|
|
74
|
+
return 0;
|
|
75
|
+
}
|
|
76
|
+
io.err.write(`slicc-cdp: unknown command "${command}"\n${USAGE}`);
|
|
77
|
+
return 2;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const invoked = process.argv[1] && realpathSync(process.argv[1]) === fileURLToPath(import.meta.url);
|
|
81
|
+
if (invoked) {
|
|
82
|
+
main(process.argv.slice(2)).then(
|
|
83
|
+
(code) => {
|
|
84
|
+
process.exitCode = code;
|
|
85
|
+
},
|
|
86
|
+
(error) => {
|
|
87
|
+
process.stderr.write(`slicc-cdp: ${error.message}\n`);
|
|
88
|
+
process.exitCode = 1;
|
|
89
|
+
}
|
|
90
|
+
);
|
|
91
|
+
}
|
package/src/index.d.ts
CHANGED
|
@@ -31,3 +31,67 @@ export declare class CDPClient {
|
|
|
31
31
|
attach(targetId: string): Promise<string>;
|
|
32
32
|
close(): void;
|
|
33
33
|
}
|
|
34
|
+
|
|
35
|
+
export type CftPlatform = 'linux64' | 'linux-arm64' | 'mac-arm64' | 'mac-x64' | 'win32' | 'win64';
|
|
36
|
+
|
|
37
|
+
export interface FoundBrowser {
|
|
38
|
+
executable: string;
|
|
39
|
+
source: 'env' | 'installed' | 'system';
|
|
40
|
+
version?: string;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface InstalledBrowser {
|
|
44
|
+
version: string;
|
|
45
|
+
platform: CftPlatform;
|
|
46
|
+
dir: string;
|
|
47
|
+
executable: string;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export declare function cftPlatform(platform?: string, arch?: string): CftPlatform | null;
|
|
51
|
+
export declare function browsersRoot(
|
|
52
|
+
env?: Record<string, string | undefined>,
|
|
53
|
+
platform?: string,
|
|
54
|
+
home?: string
|
|
55
|
+
): string;
|
|
56
|
+
export declare function installedBrowsers(options?: {
|
|
57
|
+
root?: string;
|
|
58
|
+
platform?: CftPlatform;
|
|
59
|
+
}): Promise<InstalledBrowser[]>;
|
|
60
|
+
export declare function findBrowser(options?: {
|
|
61
|
+
env?: Record<string, string | undefined>;
|
|
62
|
+
platform?: string;
|
|
63
|
+
arch?: string;
|
|
64
|
+
root?: string;
|
|
65
|
+
candidates?: string[];
|
|
66
|
+
version?: string;
|
|
67
|
+
}): Promise<FoundBrowser | null>;
|
|
68
|
+
export declare function installBrowser(options?: {
|
|
69
|
+
channel?: string;
|
|
70
|
+
version?: string;
|
|
71
|
+
root?: string;
|
|
72
|
+
platform?: CftPlatform | null;
|
|
73
|
+
withDeps?: boolean;
|
|
74
|
+
force?: boolean;
|
|
75
|
+
fetch?: typeof fetch;
|
|
76
|
+
log?: (line: string) => void;
|
|
77
|
+
}): Promise<InstalledBrowser & { installed: boolean }>;
|
|
78
|
+
|
|
79
|
+
export interface LaunchedBrowser {
|
|
80
|
+
child: import('node:child_process').ChildProcess;
|
|
81
|
+
executable: string;
|
|
82
|
+
userDataDir: string;
|
|
83
|
+
port: number;
|
|
84
|
+
wsUrl: string;
|
|
85
|
+
close(): Promise<void>;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export declare function launchBrowser(options?: {
|
|
89
|
+
executable?: string;
|
|
90
|
+
userDataDir?: string;
|
|
91
|
+
port?: number;
|
|
92
|
+
headless?: boolean;
|
|
93
|
+
sandbox?: boolean;
|
|
94
|
+
args?: string[];
|
|
95
|
+
env?: Record<string, string | undefined>;
|
|
96
|
+
timeout?: number;
|
|
97
|
+
}): Promise<LaunchedBrowser>;
|