@ai-ecoverse/slicc-shared-web 1.9.1 → 1.10.1
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 +26 -3
- package/bin/slicc-minidump.mjs +125 -0
- package/harness/chrome.mjs +130 -9
- package/harness/dumps.mjs +46 -0
- package/package.json +2 -1
package/README.md
CHANGED
|
@@ -5,12 +5,12 @@ Configuration shared by SLICC's web repos ([slicc-bios](https://github.com/ai-ec
|
|
|
5
5
|
| File | What it is | How a repo uses it |
|
|
6
6
|
|---|---|---|
|
|
7
7
|
| `default.json` | Root Renovate preset: automerge on, one dependency per PR, exact pins, 7-day release age (14 for majors, 0 for vulnerability fixes and for our own `@ai-ecoverse` packages, which also jump the PR queue), and a manager for jsDelivr pins inside `src/` | `renovate.json`: `{ "extends": ["github>ai-ecoverse/slicc-shared-web"] }` |
|
|
8
|
-
| `.github/workflows/node-ci.yml` | Reusable CI: `npm ci`, lint, Chromium's system libraries, integration tests, hotspots in the job summary, coverage/profiles/screenshots as artifacts. The browser itself comes from each repo's `pretest` | `jobs: { ci: { uses: ai-ecoverse/slicc-shared-web/.github/workflows/node-ci.yml@vX.Y.Z } }` |
|
|
8
|
+
| `.github/workflows/node-ci.yml` | Reusable CI: `npm ci`, lint, Chromium's system libraries, integration tests, hotspots and the stack of every crash dump in the job summary, coverage/profiles/screenshots/crash dumps as artifacts. The browser itself comes from each repo's `pretest` | `jobs: { ci: { uses: ai-ecoverse/slicc-shared-web/.github/workflows/node-ci.yml@vX.Y.Z } }` |
|
|
9
9
|
| `biome.json` | Formatter settings and slicc's strict linter: cognitive complexity ≤ 25, functions ≤ 150 lines, no unused variables or imports, no floating or misused promises, naming conventions, `import type`/`export type`. Test files under `test/` get slicc's test exemptions | `biome.json`: `{ "extends": ["@ai-ecoverse/slicc-shared-web/biome"] }` |
|
|
10
10
|
| `tsconfig.json` | TypeScript base for Node's type stripping | `tsconfig.json`: `{ "extends": "@ai-ecoverse/slicc-shared-web/tsconfig.json", "include": ["src/**/*.ts"] }` |
|
|
11
11
|
| `lefthook.yml` | Pre-commit hooks: Biome, typecheck, no comments, no unit tests in git, AGENTS.md, diff coverage | `lefthook.yml`: `extends: [node_modules/@ai-ecoverse/slicc-shared-web/lefthook.yml]` |
|
|
12
12
|
| `mcr.config.mjs` | Unit-test coverage for JS and TS sources in `src/` (TS types are stripped first), written to `coverage/unit/lcov.info` | `test:unit`: `mcr -c node_modules/@ai-ecoverse/slicc-shared-web/mcr.config.mjs node --test 'test/unit/**/*.test.mjs'` |
|
|
13
|
-
| `bin/` | `slicc-lint` runs every check below plus Biome and, if the repo has one, `typecheck`. `slicc-lint-comments` runs the [slicc no-comment checker](https://github.com/ai-ecoverse/slicc/tree/main/packages/dev-tools/no-comment) at a pinned commit, allowing AGENTS.md. `slicc-agents-md`, `slicc-no-unit-tests` and `slicc-diff-cover` complete the set | `lint`: `slicc-lint`, and the hooks above |
|
|
13
|
+
| `bin/` | `slicc-lint` runs every check below plus Biome and, if the repo has one, `typecheck`. `slicc-lint-comments` runs the [slicc no-comment checker](https://github.com/ai-ecoverse/slicc/tree/main/packages/dev-tools/no-comment) at a pinned commit, allowing AGENTS.md. `slicc-agents-md`, `slicc-no-unit-tests` and `slicc-diff-cover` complete the set. `slicc-minidump <file.dmp>...` prints a crash dump's stack, described [below](#crash-dumps) | `lint`: `slicc-lint`, and the hooks above |
|
|
14
14
|
| `pnpmfile.mjs` | pnpm's counterpart to the Renovate preset: an `updateConfig` hook that adds `@ai-ecoverse/*` to `minimumReleaseAgeExclude`, so pnpm 12 doesn't hold back our own fresh releases (`ERR_PNPM_MINIMUM_RELEASE_AGE_VIOLATION`) while third-party packages keep its age check | in a repo with a pnpm lockfile, as a [config dependency](https://pnpm.io/config-dependencies) in `pnpm-workspace.yaml`: `configDependencies: { '@ai-ecoverse/slicc-shared-web': X.Y.Z }` plus `pnpmfile: node_modules/.pnpm-config/@ai-ecoverse/slicc-shared-web/pnpmfile.mjs` |
|
|
15
15
|
| `harness/` | The CDP integration-test harness, described [below](#integration-test-harness) | `import { launch } from '@ai-ecoverse/slicc-shared-web/harness'`, and `--test-global-setup=node_modules/@ai-ecoverse/slicc-shared-web/harness/global.mjs` |
|
|
16
16
|
|
|
@@ -65,11 +65,12 @@ test('boots', async (t) => {
|
|
|
65
65
|
| `timeout` | How long `page.until` polls and `page.evaluate` waits, in ms | `30000` |
|
|
66
66
|
| `stallAfter` | How long one evaluation may run before the harness probes the test's targets, in ms | `30000` |
|
|
67
67
|
|
|
68
|
-
`chrome.page(t)` opens a tab and returns the page API: `goto(path)`, `reload()`, `evaluate(fn, ...args)`, `until(fn, ...args)`, `press(key, ...modifiers)` (a key name like `Enter` or `F5`, or a single character typed with its US-layout key code), `type(text)`, `insert(text)`, `enter()`, `init(fn)` (runs in every new document), `expose(name, handler)` (a binding the page calls with a JSON string), `screenshot(file)`, `tab()` (another tab in the same context), `close()`, `send(method, params, ms)` (a raw CDP call in the tab's session), `errors`, `responses` and `dir` (the test's artifact directory). `chrome.pid` is the browser's process id. `goto` and `reload` wait until the new document is complete and no longer `about:blank`, and retry the navigation once if that never happens. `until` polls until the function returns a truthy value (not only strict `true`) and resolves with that value; the timeout error names the last result. `within(ms, fn, ...args)` is `until` with its own deadline, for a stage that should take a known time. Both stop at the deadline even if one evaluation never answers. Every CDP call has a deadline: `connect(url).send(method, params, sessionId, ms)` rejects with `<method>: no answer in <ms> ms` (60 s unless given), so a stuck page fails its test instead of hanging the run. A crash fails it at once instead:
|
|
68
|
+
`chrome.page(t)` opens a tab and returns the page API: `goto(path)`, `reload()`, `evaluate(fn, ...args)`, `until(fn, ...args)`, `press(key, ...modifiers)` (a key name like `Enter` or `F5`, or a single character typed with its US-layout key code), `type(text)`, `insert(text)`, `enter()`, `init(fn)` (runs in every new document), `expose(name, handler)` (a binding the page calls with a JSON string), `screenshot(file)`, `tab()` (another tab in the same context), `close()`, `send(method, params, ms)` (a raw CDP call in the tab's session), `errors`, `responses` and `dir` (the test's artifact directory). `chrome.pid` is the browser's process id and `chrome.profile` its `slicc-harness-*` profile directory in `$TMPDIR`. `goto` and `reload` wait until the new document is complete and no longer `about:blank`, and retry the navigation once if that never happens. `until` polls until the function returns a truthy value (not only strict `true`) and resolves with that value; the timeout error names the last result. `within(ms, fn, ...args)` is `until` with its own deadline, for a stage that should take a known time. Both stop at the deadline even if one evaluation never answers. Every CDP call has a deadline: `connect(url).send(method, params, sessionId, ms)` rejects with `<method>: no answer in <ms> ms` (60 s unless given), so a stuck page fails its test instead of hanging the run. A crash fails it at once instead:
|
|
69
69
|
- when the browser dies, every pending and later call rejects with `<method>: browser exited with <signal>` (or `with code <n>`);
|
|
70
70
|
- when a renderer crashes, every pending and later call to that target, and to the dedicated workers it started, rejects with `<method>: page renderer crashed (<status>, code <n>) at <url>` (or `worker`, `shared worker`, `service worker`), and `console.log` gets the same line;
|
|
71
71
|
- `until`, `within`, `goto` and `reload` stop retrying and rethrow it;
|
|
72
72
|
- both errors end with the last five lines of the browser's stderr, and each test keeps up to 200 lines it wrote during the test in `chrome-stderr.log`.
|
|
73
|
+
- a test that saw a renderer crash, or a browser that died of anything but `SIGKILL`, keeps Chrome's minidump of it as `<id>.dmp` in its artifact directory (see [Crash dumps](#crash-dumps)).
|
|
73
74
|
|
|
74
75
|
When one evaluation runs past `stallAfter`, the harness probes once, without interrupting the wait:
|
|
75
76
|
- it adds a `pending` line to `console.log` for every navigation and request of the page that has no answer yet, with its age. A worker's script loads in the worker's own session, so its request is left out. Chrome holds CDP messages to a page while a navigation is waiting for its response, so an evaluation that hangs while the page is idle usually means one of these;
|
|
@@ -94,3 +95,25 @@ Why it works the way it does:
|
|
|
94
95
|
- The attach event can arrive before `Target.createTarget` returns, so new tabs wait on a slot keyed by target id.
|
|
95
96
|
- A worker blocked in `Atomics.wait`, or one that is already gone, may never answer; snapshots give up after 3 s, and closing a context or the browser after 10 s. A call to a target that detaches or crashes fails instead of waiting forever, and a new tab that never attaches fails after 10 s.
|
|
96
97
|
- A WebSocket that has closed takes new messages without an error, and a crashed target never answers, so after a crash every call used to wait out its own deadline: a browser that died 4 s in showed up as `no answer` 15 minutes later. The harness now remembers the crash or the exit and fails each call with it.
|
|
98
|
+
|
|
99
|
+
## Cleanup
|
|
100
|
+
|
|
101
|
+
`close()` stops Chrome and removes its profile. A test process that ends without `close()` still leaves nothing running:
|
|
102
|
+
- on `exit` and on `SIGINT`, `SIGTERM` or `SIGHUP`, the harness kills every browser it started in that process with `SIGKILL` and removes its profile and crash dump directory, then re-raises the signal so the process ends the way it would have;
|
|
103
|
+
- when the test process dies of `SIGKILL`, no handler runs. A watchdog, a `sh` that reads a pipe from the test process, then gets end of file and kills Chrome with `SIGKILL` at once. Chrome also runs with `--remote-debugging-pipe` next to the port, so it shuts itself down when that pipe closes, but its shutdown can take seconds. Only the profile stays behind;
|
|
104
|
+
- the first `launch()` in a process removes every `slicc-harness-*` directory in `$TMPDIR` that is older than a day, or older than a minute and no running process uses as its `--user-data-dir`. It never kills a process.
|
|
105
|
+
|
|
106
|
+
## Crash dumps
|
|
107
|
+
|
|
108
|
+
Chrome for Testing runs crashpad by default and ignores `--crash-dumps-dir` and `--enable-crash-reporter`: on Linux it writes every dump to `~/.config/google-chrome-for-testing/Crash Reports/pending/`. `launch` points it at a fresh directory per browser through `BREAKPAD_DUMP_LOCATION`, waits up to 10 s after a crash for crashpad to finish writing, copies the new dumps into the test's artifact directory and deletes the directory on `close()`. Nothing is uploaded.
|
|
109
|
+
|
|
110
|
+
Google publishes no symbols for Chrome for Testing: no Breakpad files next to the `chrome-linux64.zip` downloads, and the binary is stripped (only `.eh_frame` and a `.gnu_debuglink` to a file that isn't public). Chrome keeps frame pointers, though, so the stack walks cleanly as `module + offset`, and the dump carries crashpad's annotations: `ver`, `ptype` (`renderer`, `gpu-process`, `browser` and so on), the GPU (`gpu-gl-renderer` shows SwiftShader in CI), the page's origin, and V8 crash keys when V8 sets them.
|
|
111
|
+
|
|
112
|
+
`slicc-minidump <file.dmp>...` downloads a pinned, checksummed [`minidump-stackwalk`](https://github.com/rust-minidump/rust-minidump) 0.27.0 into `~/.cache/slicc-minidump/` (Linux x64 and macOS) and prints, per dump, the signal and address, the faulting module and offset, the annotations without the command-line switches, the crashing thread's first 40 frames and the module list with build ids. The reusable workflow runs it on every `artifacts/**/*.dmp`, writes the full report next to the dump as `<id>.txt` and puts everything above the module list into the job summary. To read a dump from a CI run locally:
|
|
113
|
+
|
|
114
|
+
```sh
|
|
115
|
+
gh run download <run-id> -n coverage-profiles-screenshots -D ci
|
|
116
|
+
npx slicc-minidump ci/artifacts/<suite>/<test>/*.dmp
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
A Linux dump reads fine on a Mac. Offsets are only comparable across dumps from the same `ver`. A frame in `chrome` with the `ud2` instruction and `SIGTRAP` is a deliberate `CHECK` or `IMMEDIATE_CRASH`; `SIGSEGV` with a wild address points at memory corruption, and a top frame in `libvk_swiftshader.so`, `libGLESv2.so` or `libEGL.so` at the software GPU.
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { execFile } from 'node:child_process';
|
|
3
|
+
import { createHash } from 'node:crypto';
|
|
4
|
+
import { access, mkdir, writeFile } from 'node:fs/promises';
|
|
5
|
+
import { homedir } from 'node:os';
|
|
6
|
+
import { join } from 'node:path';
|
|
7
|
+
import process, { arch, argv, env, platform, stderr, stdout } from 'node:process';
|
|
8
|
+
import { promisify } from 'node:util';
|
|
9
|
+
|
|
10
|
+
const run = promisify(execFile);
|
|
11
|
+
const VERSION = '0.27.0';
|
|
12
|
+
const FRAMES = 40;
|
|
13
|
+
const builds = {
|
|
14
|
+
'linux-x64': [
|
|
15
|
+
'x86_64-unknown-linux-gnu',
|
|
16
|
+
'0020324c54cc359596e927ee907204f4c8d4da6718765536edcabaa1622122ad',
|
|
17
|
+
],
|
|
18
|
+
'linux-x64-musl': [
|
|
19
|
+
'x86_64-unknown-linux-musl',
|
|
20
|
+
'bfaf97804965c87155b91765f4042440a38bb002f20a62fc4da9b6b6bcb20faf',
|
|
21
|
+
],
|
|
22
|
+
'darwin-arm64': [
|
|
23
|
+
'aarch64-apple-darwin',
|
|
24
|
+
'5fcd4d25a5b0bbdc8faac6f4f81629bc67483c3eb458b86a8304733a7c6a2ca9',
|
|
25
|
+
],
|
|
26
|
+
'darwin-x64': [
|
|
27
|
+
'x86_64-apple-darwin',
|
|
28
|
+
'801fa500d159fe474260ef1aad80706bed822434aced7fdb21c1b2c7a06afe6f',
|
|
29
|
+
],
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
function host() {
|
|
33
|
+
const musl = platform === 'linux' && !process.report.getReport().header.glibcVersionRuntime;
|
|
34
|
+
return `${platform}-${arch}${musl ? '-musl' : ''}`;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async function stackwalk() {
|
|
38
|
+
const build = builds[host()];
|
|
39
|
+
if (!build) throw new Error(`no minidump-stackwalk ${VERSION} build for ${host()}`);
|
|
40
|
+
const [target, sha256] = build;
|
|
41
|
+
const dir = join(env.XDG_CACHE_HOME ?? join(homedir(), '.cache'), 'slicc-minidump', VERSION);
|
|
42
|
+
const binary = join(dir, `minidump-stackwalk-${target}`, 'minidump-stackwalk');
|
|
43
|
+
const cached = await access(binary).then(
|
|
44
|
+
() => true,
|
|
45
|
+
() => false
|
|
46
|
+
);
|
|
47
|
+
if (cached) return binary;
|
|
48
|
+
const name = `minidump-stackwalk-${target}.tar.xz`;
|
|
49
|
+
const url = `https://github.com/rust-minidump/rust-minidump/releases/download/v${VERSION}/${name}`;
|
|
50
|
+
const response = await fetch(url);
|
|
51
|
+
if (!response.ok) throw new Error(`${url}: HTTP ${response.status}`);
|
|
52
|
+
const archive = Buffer.from(await response.arrayBuffer());
|
|
53
|
+
const digest = createHash('sha256').update(archive).digest('hex');
|
|
54
|
+
if (digest !== sha256) throw new Error(`${url}: sha256 ${digest}, expected ${sha256}`);
|
|
55
|
+
await mkdir(dir, { recursive: true });
|
|
56
|
+
await writeFile(join(dir, name), archive);
|
|
57
|
+
await run('tar', ['-xJf', join(dir, name), '-C', dir]);
|
|
58
|
+
return binary;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function annotations(dump) {
|
|
62
|
+
const pattern =
|
|
63
|
+
/^\s*(?:simple_annotations|module_list\[\d+\]\.annotation_objects)\["([^"]+)"\] = (.*)$/;
|
|
64
|
+
return dump
|
|
65
|
+
.split('\n')
|
|
66
|
+
.map((line) => line.match(pattern))
|
|
67
|
+
.filter((match) => match && !/^(switch-|variations$)/.test(match[1]))
|
|
68
|
+
.map(([, key, value]) => ` ${key} = ${value}`);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function frame({ frame: n, module, module_offset, offset, function: fn, trust }) {
|
|
72
|
+
const where = module ? `${module} + ${module_offset}` : offset;
|
|
73
|
+
const named = fn ? ` ${fn}` : '';
|
|
74
|
+
return `${String(n).padStart(4)} ${where}${named} (${trust})`;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function report(walked, dump) {
|
|
78
|
+
const { crash_info: crash, crashing_thread: thread, modules, system_info: system } = walked;
|
|
79
|
+
const top = thread?.frames?.[0];
|
|
80
|
+
return [
|
|
81
|
+
`crash: ${crash?.type ?? 'none'} at ${crash?.address ?? '?'}`,
|
|
82
|
+
`faulting: ${top ? frame(top).trim() : 'no crashing thread'}`,
|
|
83
|
+
`instruction: ${crash?.instruction ?? '?'}`,
|
|
84
|
+
`system: ${system?.os} ${system?.os_ver} ${system?.cpu_arch}, ${walked.process_uptime ?? '?'} s up`,
|
|
85
|
+
'annotations:',
|
|
86
|
+
...annotations(dump),
|
|
87
|
+
`thread ${crash?.crashing_thread} ${thread?.thread_name ?? ''} (${thread?.frame_count ?? 0} frames):`,
|
|
88
|
+
...(thread?.frames ?? []).slice(0, FRAMES).map(frame),
|
|
89
|
+
'modules:',
|
|
90
|
+
...(modules ?? []).map(
|
|
91
|
+
({ base_addr, end_addr, filename, code_id }) =>
|
|
92
|
+
` ${base_addr}-${end_addr} ${filename} ${code_id ?? ''}`
|
|
93
|
+
),
|
|
94
|
+
'',
|
|
95
|
+
].join('\n');
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
async function describe(binary, file) {
|
|
99
|
+
const options = { maxBuffer: 256 * 1024 * 1024 };
|
|
100
|
+
const [json, dump] = await Promise.all([
|
|
101
|
+
run(binary, ['--json', file], options),
|
|
102
|
+
run(binary, ['--dump', file], options),
|
|
103
|
+
]);
|
|
104
|
+
return report(JSON.parse(json.stdout), dump.stdout);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
async function main(files) {
|
|
108
|
+
if (files.length === 0) {
|
|
109
|
+
stderr.write('usage: slicc-minidump <file.dmp>...\n');
|
|
110
|
+
return 2;
|
|
111
|
+
}
|
|
112
|
+
const binary = await stackwalk();
|
|
113
|
+
for (const file of files) stdout.write(`== ${file}\n${await describe(binary, file)}\n`);
|
|
114
|
+
return 0;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
main(argv.slice(2)).then(
|
|
118
|
+
(code) => {
|
|
119
|
+
process.exitCode = code;
|
|
120
|
+
},
|
|
121
|
+
(error) => {
|
|
122
|
+
stderr.write(`slicc-minidump: ${error.message}\n`);
|
|
123
|
+
process.exitCode = 1;
|
|
124
|
+
}
|
|
125
|
+
);
|
package/harness/chrome.mjs
CHANGED
|
@@ -1,10 +1,14 @@
|
|
|
1
|
-
import { spawn } from 'node:child_process';
|
|
2
|
-
import {
|
|
1
|
+
import { execFile, spawn } from 'node:child_process';
|
|
2
|
+
import { rmSync } from 'node:fs';
|
|
3
|
+
import { lstat, mkdir, mkdtemp, readdir, rm, writeFile } from 'node:fs/promises';
|
|
3
4
|
import { tmpdir } from 'node:os';
|
|
4
5
|
import { basename, join } from 'node:path';
|
|
6
|
+
import { env as inherited } from 'node:process';
|
|
7
|
+
import { promisify } from 'node:util';
|
|
5
8
|
import { chromium } from 'playwright-core';
|
|
6
9
|
import { cdn } from './cdn.mjs';
|
|
7
|
-
import { connect } from './cdp.mjs';
|
|
10
|
+
import { CRASHED, connect } from './cdp.mjs';
|
|
11
|
+
import { crashpad } from './dumps.mjs';
|
|
8
12
|
import { extensionSource, unpacked } from './extensions.mjs';
|
|
9
13
|
import { page } from './page.mjs';
|
|
10
14
|
import { breakpoints, recorder } from './recorder.mjs';
|
|
@@ -14,6 +18,7 @@ import { artifacts, bounded, ignore, sleep, slug, TIMED_OUT } from './util.mjs';
|
|
|
14
18
|
export const flags = [
|
|
15
19
|
'--headless',
|
|
16
20
|
'--remote-debugging-port=0',
|
|
21
|
+
'--remote-debugging-pipe',
|
|
17
22
|
'--no-first-run',
|
|
18
23
|
'--no-default-browser-check',
|
|
19
24
|
'--no-sandbox',
|
|
@@ -66,9 +71,13 @@ export function exitOf(code, signal) {
|
|
|
66
71
|
export async function start(
|
|
67
72
|
profile,
|
|
68
73
|
args = commandLine(profile),
|
|
69
|
-
executable = chromium.executablePath()
|
|
74
|
+
executable = chromium.executablePath(),
|
|
75
|
+
env = inherited
|
|
70
76
|
) {
|
|
71
|
-
const child = spawn(executable, args, {
|
|
77
|
+
const child = spawn(executable, args, {
|
|
78
|
+
env,
|
|
79
|
+
stdio: ['ignore', 'ignore', 'pipe', 'pipe', 'pipe'],
|
|
80
|
+
});
|
|
72
81
|
const stderr = lines(child.stderr);
|
|
73
82
|
const drained = new Promise((resolve) => child.stderr.once('close', resolve));
|
|
74
83
|
const exited = new Promise((resolve) => {
|
|
@@ -91,6 +100,11 @@ export async function start(
|
|
|
91
100
|
return { child, url, exited, stderr };
|
|
92
101
|
}
|
|
93
102
|
|
|
103
|
+
export function died(child) {
|
|
104
|
+
if (child.signalCode) return child.signalCode !== 'SIGKILL';
|
|
105
|
+
return (child.exitCode ?? 0) !== 0;
|
|
106
|
+
}
|
|
107
|
+
|
|
94
108
|
export async function stop(cdp, child) {
|
|
95
109
|
const exited = new Promise((resolve) => child.once('exit', resolve));
|
|
96
110
|
await bounded(cdp.send('Browser.close').catch(ignore), 'Browser.close');
|
|
@@ -102,6 +116,89 @@ export async function stop(cdp, child) {
|
|
|
102
116
|
}
|
|
103
117
|
}
|
|
104
118
|
|
|
119
|
+
export const PREFIX = 'slicc-harness-';
|
|
120
|
+
export const DAY = 24 * 60 * 60 * 1000;
|
|
121
|
+
export const GRACE = 60 * 1000;
|
|
122
|
+
|
|
123
|
+
const signals = ['SIGINT', 'SIGTERM', 'SIGHUP'];
|
|
124
|
+
const running = new Set();
|
|
125
|
+
|
|
126
|
+
function kill(child) {
|
|
127
|
+
if (child.exitCode === null && child.signalCode === null) child.kill('SIGKILL');
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function reap() {
|
|
131
|
+
for (const { children, paths } of running) {
|
|
132
|
+
for (const child of children) kill(child);
|
|
133
|
+
for (const path of paths) rmSync(path, { recursive: true, force: true });
|
|
134
|
+
}
|
|
135
|
+
running.clear();
|
|
136
|
+
unhook();
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function reraise(signal) {
|
|
140
|
+
reap();
|
|
141
|
+
process.kill(process.pid, signal);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function hook() {
|
|
145
|
+
process.on('exit', reap);
|
|
146
|
+
for (const signal of signals) process.on(signal, reraise);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function unhook() {
|
|
150
|
+
process.off('exit', reap);
|
|
151
|
+
for (const signal of signals) process.off(signal, reraise);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export function watchdog(pid) {
|
|
155
|
+
const dog = spawn('sh', ['-c', 'read -r _; kill -9 "$0" 2>/dev/null', String(pid)], {
|
|
156
|
+
stdio: ['pipe', 'ignore', 'ignore'],
|
|
157
|
+
});
|
|
158
|
+
dog.on('error', ignore);
|
|
159
|
+
dog.stdin.on('error', ignore);
|
|
160
|
+
return dog;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export function track(child, paths) {
|
|
164
|
+
const dog = watchdog(child.pid);
|
|
165
|
+
const entry = { children: [child, dog], paths };
|
|
166
|
+
if (running.size === 0) hook();
|
|
167
|
+
running.add(entry);
|
|
168
|
+
return () => {
|
|
169
|
+
running.delete(entry);
|
|
170
|
+
if (running.size === 0) unhook();
|
|
171
|
+
kill(dog);
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export async function commandLines() {
|
|
176
|
+
const { stdout } = await promisify(execFile)('ps', ['axww', '-o', 'args='], {
|
|
177
|
+
maxBuffer: 64 * 1024 * 1024,
|
|
178
|
+
});
|
|
179
|
+
return stdout.split('\n').map((line) => `${line} `);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
export async function prune(dir = tmpdir(), grace = GRACE) {
|
|
183
|
+
const lines = await commandLines().catch(ignore);
|
|
184
|
+
const used = (path) => lines.some((line) => line.includes(`--user-data-dir=${path} `));
|
|
185
|
+
const names = await readdir(dir).catch(() => []);
|
|
186
|
+
const now = Date.now();
|
|
187
|
+
const removing = names
|
|
188
|
+
.filter((name) => name.startsWith(PREFIX))
|
|
189
|
+
.map(async (name) => {
|
|
190
|
+
const path = join(dir, name);
|
|
191
|
+
const info = await lstat(path).catch(ignore);
|
|
192
|
+
if (!info?.isDirectory()) return;
|
|
193
|
+
const age = now - info.mtimeMs;
|
|
194
|
+
const orphaned = lines !== null && age > grace && !used(path);
|
|
195
|
+
if (age > DAY || orphaned) await rm(path, { recursive: true, force: true }).catch(ignore);
|
|
196
|
+
});
|
|
197
|
+
await Promise.all(removing);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
let pruning = null;
|
|
201
|
+
|
|
105
202
|
export async function launch({
|
|
106
203
|
roots,
|
|
107
204
|
aliases,
|
|
@@ -114,18 +211,33 @@ export async function launch({
|
|
|
114
211
|
timeout,
|
|
115
212
|
stallAfter,
|
|
116
213
|
} = {}) {
|
|
214
|
+
pruning ??= prune();
|
|
215
|
+
await pruning;
|
|
117
216
|
const server = await serve({ roots, aliases, isolated });
|
|
118
217
|
const found = await unpacked(extensions);
|
|
119
218
|
const fromExtension = extensionSource(found);
|
|
120
219
|
const files = { ...server, source: (href) => server.source(href) ?? fromExtension(href) };
|
|
121
220
|
const shared = extensions.length > 0;
|
|
122
|
-
const profile = await mkdtemp(join(tmpdir(),
|
|
221
|
+
const profile = await mkdtemp(join(tmpdir(), PREFIX));
|
|
123
222
|
const loaded = [...found.values()].map((root) => root.slice(0, -1));
|
|
124
|
-
const
|
|
223
|
+
const dumps = await crashpad();
|
|
224
|
+
const { child, url, exited, stderr } = await start(
|
|
225
|
+
profile,
|
|
226
|
+
commandLine(profile, loaded, args),
|
|
227
|
+
chromium.executablePath(),
|
|
228
|
+
dumps.env
|
|
229
|
+
);
|
|
230
|
+
const untrack = track(child, [profile, dumps.dir]);
|
|
125
231
|
const cdp = await connect(url, {
|
|
126
232
|
exited,
|
|
127
233
|
tail: () => stderr.tail().map((line) => `stderr: ${line}`),
|
|
128
234
|
});
|
|
235
|
+
let crashes = 0;
|
|
236
|
+
let open = 0;
|
|
237
|
+
let closed = false;
|
|
238
|
+
cdp.on(({ method }) => {
|
|
239
|
+
if (method === CRASHED || method === 'Target.targetCrashed') crashes += 1;
|
|
240
|
+
});
|
|
129
241
|
const record = recorder(cdp, files, { coverage, exits: await breakpoints(server, exits) });
|
|
130
242
|
const remote = await cdn(cdp, intercept);
|
|
131
243
|
await cdp.send('Target.setDiscoverTargets', { discover: true });
|
|
@@ -146,9 +258,12 @@ export async function launch({
|
|
|
146
258
|
await Promise.all(closing.map((sent) => sent.catch(ignore)));
|
|
147
259
|
}
|
|
148
260
|
|
|
149
|
-
async function finish(pages, browserContextId, dir, before, mark) {
|
|
261
|
+
async function finish(pages, browserContextId, dir, before, mark, seen) {
|
|
150
262
|
const log = stderr.since(mark).map((line) => `${line}\n`);
|
|
151
263
|
await writeFile(new URL('chrome-stderr.log', dir), log.join(''));
|
|
264
|
+
if (crashes > seen || died(child)) await dumps.keep(dir).catch(ignore);
|
|
265
|
+
open -= 1;
|
|
266
|
+
if (closed && open === 0) await dumps.remove();
|
|
152
267
|
await bounded(Promise.all(pages.map((opened) => opened.probed())), 'probes', 30000);
|
|
153
268
|
const shots = pages.map((opened, i) =>
|
|
154
269
|
opened.screenshot(new URL(`tab-${i + 1}.png`, dir)).catch(ignore)
|
|
@@ -166,6 +281,7 @@ export async function launch({
|
|
|
166
281
|
return {
|
|
167
282
|
url: server.url,
|
|
168
283
|
pid: child.pid,
|
|
284
|
+
profile,
|
|
169
285
|
cdn: remote.state,
|
|
170
286
|
requests: server.requests,
|
|
171
287
|
overrides: server.overrides,
|
|
@@ -181,7 +297,9 @@ export async function launch({
|
|
|
181
297
|
server.overridden.clear();
|
|
182
298
|
const pages = [];
|
|
183
299
|
const mark = stderr.total;
|
|
184
|
-
|
|
300
|
+
const seen = crashes;
|
|
301
|
+
open += 1;
|
|
302
|
+
t.after(() => finish(pages, browserContextId, dir, before, mark, seen));
|
|
185
303
|
const tab = async () => {
|
|
186
304
|
const opened = page(cdp, await record.open(browserContextId), server, timeout, {
|
|
187
305
|
stallAfter,
|
|
@@ -198,6 +316,9 @@ export async function launch({
|
|
|
198
316
|
async close() {
|
|
199
317
|
await stop(cdp, child);
|
|
200
318
|
await rm(profile, { recursive: true, force: true });
|
|
319
|
+
untrack();
|
|
320
|
+
closed = true;
|
|
321
|
+
if (open === 0) await dumps.remove();
|
|
201
322
|
await server.close();
|
|
202
323
|
},
|
|
203
324
|
};
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { copyFile, mkdtemp, readdir, rm } from 'node:fs/promises';
|
|
2
|
+
import { tmpdir } from 'node:os';
|
|
3
|
+
import { basename, join } from 'node:path';
|
|
4
|
+
import { env } from 'node:process';
|
|
5
|
+
import { sleep } from './util.mjs';
|
|
6
|
+
|
|
7
|
+
export const DUMP_WAIT = 10000;
|
|
8
|
+
|
|
9
|
+
async function list(dir, sub) {
|
|
10
|
+
return (await readdir(join(dir, sub)).catch(() => [])).map((file) => join(sub, file));
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export async function crashpad() {
|
|
14
|
+
const dir = await mkdtemp(join(tmpdir(), 'slicc-crashes-'));
|
|
15
|
+
const kept = new Set();
|
|
16
|
+
const scan = async () => {
|
|
17
|
+
const [pending, completed, writing] = await Promise.all(
|
|
18
|
+
['pending', 'completed', 'new'].map((sub) => list(dir, sub))
|
|
19
|
+
);
|
|
20
|
+
const fresh = [...pending, ...completed].filter(
|
|
21
|
+
(file) => file.endsWith('.dmp') && !kept.has(file)
|
|
22
|
+
);
|
|
23
|
+
return { fresh, writing: writing.some((file) => file.endsWith('.dmp')) };
|
|
24
|
+
};
|
|
25
|
+
return {
|
|
26
|
+
dir,
|
|
27
|
+
env: { ...env, BREAKPAD_DUMP_LOCATION: dir },
|
|
28
|
+
async keep(target, ms = DUMP_WAIT) {
|
|
29
|
+
const deadline = Date.now() + ms;
|
|
30
|
+
let found = await scan();
|
|
31
|
+
while ((found.fresh.length === 0 || found.writing) && Date.now() < deadline) {
|
|
32
|
+
await sleep(250);
|
|
33
|
+
found = await scan();
|
|
34
|
+
}
|
|
35
|
+
const names = [];
|
|
36
|
+
for (const file of found.fresh) {
|
|
37
|
+
kept.add(file);
|
|
38
|
+
const name = basename(file);
|
|
39
|
+
await copyFile(join(dir, file), new URL(name, target));
|
|
40
|
+
names.push(name);
|
|
41
|
+
}
|
|
42
|
+
return names;
|
|
43
|
+
},
|
|
44
|
+
remove: () => rm(dir, { recursive: true, force: true }),
|
|
45
|
+
};
|
|
46
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ai-ecoverse/slicc-shared-web",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.10.1",
|
|
4
4
|
"description": "Shared Renovate, Biome, TypeScript, lefthook and GitHub Actions configuration, plus the CDP integration-test harness, for SLICC's web repos",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "Apache-2.0",
|
|
@@ -36,6 +36,7 @@
|
|
|
36
36
|
"slicc-diff-cover": "bin/slicc-diff-cover.sh",
|
|
37
37
|
"slicc-lint": "bin/slicc-lint.sh",
|
|
38
38
|
"slicc-lint-comments": "bin/slicc-lint-comments.sh",
|
|
39
|
+
"slicc-minidump": "bin/slicc-minidump.mjs",
|
|
39
40
|
"slicc-no-unit-tests": "bin/slicc-no-unit-tests.sh"
|
|
40
41
|
},
|
|
41
42
|
"files": [
|