@ai-ecoverse/slicc-shared-web 1.10.2 → 1.11.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 +7 -4
- package/harness/chrome.mjs +32 -13
- package/harness/global.mjs +3 -0
- package/harness/recorder.mjs +21 -13
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -31,7 +31,9 @@ The hooks expect two scripts in each repo: `test:unit`, which writes `coverage/u
|
|
|
31
31
|
|
|
32
32
|
## Integration-test harness
|
|
33
33
|
|
|
34
|
-
`harness/` drives playwright-core's Chromium over raw CDP on `--remote-debugging-pipe`, as its only client, from `node --test`. Every page, service worker, shared worker and dedicated worker, nested ones included, starts paused, gets coverage and the CPU profiler switched on in the same tick, and only then runs. Each test gets its own browser context and leaves `artifacts/<suite>/<test>/` behind:
|
|
34
|
+
`harness/` drives playwright-core's Chromium over raw CDP on `--remote-debugging-pipe`, as its only client, from `node --test`. Every page, service worker, shared worker and dedicated worker, nested ones included, starts paused, gets coverage (and the CPU profiler, when profiling is on) switched on in the same tick, and only then runs. Each test gets its own browser context and leaves `artifacts/<suite>/<test>/` behind: a screenshot per tab and a `console.log` of every target. Lines logged before the first test, such as an extension service worker's at startup, go into the first test's `console.log`. The global teardown merges the raw coverage into `coverage/` (console table, lcov, V8 HTML) and writes `artifacts/hotspots.md`.
|
|
35
|
+
|
|
36
|
+
CPU profiling is off by default. V8's sampler interrupts each page and worker thread every 100 µs with a signal, and the renderer SIGSEGVs in slicc-bios#114 all fault inside a signal handler walking the interrupted thread's frames, which points at it. Turn it on with `profile: true` or `SLICC_PROFILE=1` to get one CPU profile per target and snapshot in the test's artifact directory, and the 15 frames with the most self time in the repo's own scripts in `hotspots.md`. Without it, `hotspots.md` says that profiling is off.
|
|
35
37
|
|
|
36
38
|
`playwright-core` and `monocart-coverage-reports` are peer dependencies; pin both in the repo's `devDependencies`. A repo describes what to serve and launches the browser once per test file:
|
|
37
39
|
|
|
@@ -64,6 +66,7 @@ test('boots', async (t) => {
|
|
|
64
66
|
| `args` | Extra Chromium flags, such as `--host-resolver-rules` or `--ignore-certificate-errors` | none |
|
|
65
67
|
| `timeout` | How long `page.until` polls and `page.evaluate` waits, in ms | `30000` |
|
|
66
68
|
| `stallAfter` | How long one evaluation may run before the harness probes the test's targets, in ms | `30000` |
|
|
69
|
+
| `profile` | Record a CPU profile of every target, sampled every 100 µs | `true` if `SLICC_PROFILE=1`, else `false` |
|
|
67
70
|
|
|
68
71
|
`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
72
|
- when the browser dies, every pending and later call rejects with `<method>: browser exited with <signal>` (or `with code <n>`);
|
|
@@ -75,7 +78,7 @@ test('boots', async (t) => {
|
|
|
75
78
|
When one evaluation runs past `stallAfter`, the harness probes once, without interrupting the wait:
|
|
76
79
|
- 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;
|
|
77
80
|
- it saves `stall-<n>.png` (a screenshot of the page, if the renderer still answers) in the test's artifact directory;
|
|
78
|
-
- it saves a CPU profile sample of every page and worker in the test, including workers started by other workers, as `stall-<n>-<target>-<session>.cpuprofile`;
|
|
81
|
+
- when profiling is on, it saves a CPU profile sample of every page and worker in the test, including workers started by other workers, as `stall-<n>-<target>-<session>.cpuprofile`;
|
|
79
82
|
- it adds a line per target to `console.log`, saying whether that target is running or paused;
|
|
80
83
|
- on the first stall, it records a 10 s browser trace as `stall-1-trace.json` (open it in DevTools' Performance panel or Perfetto). The browser collects it with V8 CPU samples per thread, task, IPC, Blink and loading events, so it shows what a page's main thread is doing even when that thread no longer answers CDP. It logs each process's CPU time over the window, and for each renderer its main thread's task count, the call chain of its longest or still open task (function, file, line and URL of a request), and where its CPU samples fall. The targets are sampled after the trace starts, so their new profiles carry their call frames into it. A call that started before the trace shows only as a node id, since its frames were named before; a thread that is blocked keeps `Tracing.start` waiting, which the harness logs and traces through;
|
|
81
84
|
- it adds a line for every debugger pause still held in the browser, in any target, with its id, URL, start time, reason and top call frames.
|
|
@@ -99,8 +102,8 @@ Why it works the way it does:
|
|
|
99
102
|
## Cleanup
|
|
100
103
|
|
|
101
104
|
`close()` stops Chrome and removes its profile. It asks Chrome to close, waits up to 10 s for the exit, sends `SIGKILL`, waits up to 5 s more and then moves on with a warning, so a browser that ignores `Browser.close` or a missed exit never hangs teardown. 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 shuts itself down when the CDP pipe closes, but that can take seconds. Only the profile stays behind;
|
|
105
|
+
- on `exit` and on `SIGINT`, `SIGTERM` or `SIGHUP`, the harness kills every browser it started in that process, with all its helper processes (Chrome runs in its own process group), with `SIGKILL` and removes its profile and crash dump directory, then re-raises the signal so the process ends the way it would have;
|
|
106
|
+
- when the test process dies of `SIGKILL`, no handler runs. A watchdog, a `sh` in its own process group that reads a pipe from the test process, then gets end of file and kills Chrome's process group with `SIGKILL` at once. Chrome also shuts itself down when the CDP pipe closes, but that can take seconds. Only the profile stays behind;
|
|
104
107
|
- 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
108
|
|
|
106
109
|
## Crash dumps
|
package/harness/chrome.mjs
CHANGED
|
@@ -74,6 +74,7 @@ export async function start(
|
|
|
74
74
|
) {
|
|
75
75
|
const child = spawn(executable, args, {
|
|
76
76
|
env,
|
|
77
|
+
detached: true,
|
|
77
78
|
stdio: ['ignore', 'ignore', 'pipe', 'pipe', 'pipe'],
|
|
78
79
|
});
|
|
79
80
|
const stderr = lines(child.stderr);
|
|
@@ -111,7 +112,7 @@ export async function stop(cdp, child, waits = WAITS) {
|
|
|
111
112
|
await bounded(cdp.send('Browser.close').catch(ignore), 'Browser.close', waits.close);
|
|
112
113
|
cdp.close();
|
|
113
114
|
if (gone() || (await bounded(exited, 'exit', waits.exit)) !== TIMED_OUT || gone()) return;
|
|
114
|
-
|
|
115
|
+
kill(child);
|
|
115
116
|
if (gone() || (await bounded(exited, 'exit after SIGKILL', waits.kill)) !== TIMED_OUT) return;
|
|
116
117
|
if (!gone()) console.warn(`chrome: pid ${child.pid} still runs after SIGKILL, moving on`);
|
|
117
118
|
}
|
|
@@ -123,15 +124,27 @@ export const GRACE = 60 * 1000;
|
|
|
123
124
|
const signals = ['SIGINT', 'SIGTERM', 'SIGHUP'];
|
|
124
125
|
const running = new Set();
|
|
125
126
|
|
|
126
|
-
function kill(child) {
|
|
127
|
+
export function kill(child) {
|
|
128
|
+
if (child.pid > 0 && child.spawnargs?.length) {
|
|
129
|
+
try {
|
|
130
|
+
process.kill(-child.pid, 'SIGKILL');
|
|
131
|
+
} catch {}
|
|
132
|
+
}
|
|
127
133
|
if (child.exitCode === null && child.signalCode === null) child.kill('SIGKILL');
|
|
128
134
|
}
|
|
129
135
|
|
|
130
|
-
function
|
|
131
|
-
for (const {
|
|
132
|
-
for (const child of children) kill(child);
|
|
136
|
+
function remove(entries) {
|
|
137
|
+
for (const { paths } of entries) {
|
|
133
138
|
for (const path of paths) rmSync(path, { recursive: true, force: true });
|
|
134
139
|
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function reap() {
|
|
143
|
+
const entries = [...running];
|
|
144
|
+
for (const { children } of entries) children.forEach(kill);
|
|
145
|
+
remove(entries);
|
|
146
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 200);
|
|
147
|
+
remove(entries);
|
|
135
148
|
running.clear();
|
|
136
149
|
unhook();
|
|
137
150
|
}
|
|
@@ -152,7 +165,8 @@ function unhook() {
|
|
|
152
165
|
}
|
|
153
166
|
|
|
154
167
|
export function watchdog(pid) {
|
|
155
|
-
const dog = spawn('sh', ['-c', 'read -r _; kill -9 "$0" 2>/dev/null', String(pid)], {
|
|
168
|
+
const dog = spawn('sh', ['-c', 'read -r _; kill -9 -"$0" "$0" 2>/dev/null', String(pid)], {
|
|
169
|
+
detached: true,
|
|
156
170
|
stdio: ['pipe', 'ignore', 'ignore'],
|
|
157
171
|
});
|
|
158
172
|
dog.on('error', ignore);
|
|
@@ -210,6 +224,7 @@ export async function launch({
|
|
|
210
224
|
args = [],
|
|
211
225
|
timeout,
|
|
212
226
|
stallAfter,
|
|
227
|
+
profile = inherited.SLICC_PROFILE === '1',
|
|
213
228
|
} = {}) {
|
|
214
229
|
pruning ??= prune();
|
|
215
230
|
await pruning;
|
|
@@ -218,16 +233,16 @@ export async function launch({
|
|
|
218
233
|
const fromExtension = extensionSource(found);
|
|
219
234
|
const files = { ...server, source: (href) => server.source(href) ?? fromExtension(href) };
|
|
220
235
|
const shared = extensions.length > 0;
|
|
221
|
-
const
|
|
236
|
+
const userData = await mkdtemp(join(tmpdir(), PREFIX));
|
|
222
237
|
const loaded = [...found.values()].map((root) => root.slice(0, -1));
|
|
223
238
|
const dumps = await crashpad();
|
|
224
239
|
const { child, exited, stderr } = await start(
|
|
225
|
-
|
|
226
|
-
commandLine(
|
|
240
|
+
userData,
|
|
241
|
+
commandLine(userData, loaded, args),
|
|
227
242
|
chromium.executablePath(),
|
|
228
243
|
dumps.env
|
|
229
244
|
);
|
|
230
|
-
const untrack = track(child, [
|
|
245
|
+
const untrack = track(child, [userData, dumps.dir]);
|
|
231
246
|
const cdp = await connect(pipe(child.stdio[3], child.stdio[4]), {
|
|
232
247
|
exited,
|
|
233
248
|
tail: () => stderr.tail().map((line) => `stderr: ${line}`),
|
|
@@ -238,7 +253,11 @@ export async function launch({
|
|
|
238
253
|
cdp.on(({ method }) => {
|
|
239
254
|
if (method === CRASHED || method === 'Target.targetCrashed') crashes += 1;
|
|
240
255
|
});
|
|
241
|
-
const record = recorder(cdp, files, {
|
|
256
|
+
const record = recorder(cdp, files, {
|
|
257
|
+
coverage,
|
|
258
|
+
exits: await breakpoints(server, exits),
|
|
259
|
+
profiling: profile,
|
|
260
|
+
});
|
|
242
261
|
const remote = await cdn(cdp, intercept);
|
|
243
262
|
await cdp.send('Target.setDiscoverTargets', { discover: true });
|
|
244
263
|
await cdp.send('Target.setAutoAttach', {
|
|
@@ -282,7 +301,7 @@ export async function launch({
|
|
|
282
301
|
return {
|
|
283
302
|
url: server.url,
|
|
284
303
|
pid: child.pid,
|
|
285
|
-
profile,
|
|
304
|
+
profile: userData,
|
|
286
305
|
cdn: remote.state,
|
|
287
306
|
requests: server.requests,
|
|
288
307
|
overrides: server.overrides,
|
|
@@ -316,7 +335,7 @@ export async function launch({
|
|
|
316
335
|
},
|
|
317
336
|
async close() {
|
|
318
337
|
await stop(cdp, child);
|
|
319
|
-
await rm(
|
|
338
|
+
await rm(userData, { recursive: true, force: true });
|
|
320
339
|
untrack();
|
|
321
340
|
closed = true;
|
|
322
341
|
if (open === 0) await dumps.remove();
|
package/harness/global.mjs
CHANGED
|
@@ -41,6 +41,9 @@ export function table(runs) {
|
|
|
41
41
|
self.set(frame, (self.get(frame) ?? 0) + micros);
|
|
42
42
|
}
|
|
43
43
|
}
|
|
44
|
+
if (profiles === 0) {
|
|
45
|
+
return '### Hotspots: CPU profiling is off. Set SLICC_PROFILE=1 or pass `profile: true` to `launch()` to record profiles.\n';
|
|
46
|
+
}
|
|
44
47
|
const top = [...self].sort((a, b) => b[1] - a[1]).slice(0, 15);
|
|
45
48
|
const rows = top.map(([frame, micros]) => `| ${(micros / 1000).toFixed(2)} ms | \`${frame}\` |`);
|
|
46
49
|
return [
|
package/harness/recorder.mjs
CHANGED
|
@@ -8,6 +8,7 @@ import { bounded, ignore, raw, samples, sleep, TIMED_OUT } from './util.mjs';
|
|
|
8
8
|
|
|
9
9
|
const profiled = new Set(['page', 'worker', 'service_worker', 'shared_worker']);
|
|
10
10
|
const nested = { autoAttach: true, waitForDebuggerOnStart: true, flatten: true };
|
|
11
|
+
const sampling = [['Profiler.setSamplingInterval', { interval: 100 }], ['Profiler.start']];
|
|
11
12
|
const unload = "addEventListener('beforeunload', () => { debugger; })";
|
|
12
13
|
|
|
13
14
|
function pathOf(url) {
|
|
@@ -77,18 +78,28 @@ function frames(params) {
|
|
|
77
78
|
.join(' < ');
|
|
78
79
|
}
|
|
79
80
|
|
|
80
|
-
async function sample(cdp, pauses, [sessionId, target], dir, tag) {
|
|
81
|
+
async function sample(cdp, pauses, [sessionId, target], dir, tag, profiling) {
|
|
81
82
|
const send = (method) => cdp.send(method, {}, sessionId, 8000);
|
|
82
|
-
const stopped = await send('Profiler.stop').catch((error) => ({ error }));
|
|
83
|
-
send('Profiler.start').catch(ignore);
|
|
84
83
|
const pause = pauses.get(sessionId);
|
|
85
84
|
const state = pause ? `paused (${pause.reason})` : 'running';
|
|
85
|
+
if (!profiling) return `${label(target)}: ${state}`;
|
|
86
|
+
const stopped = await send('Profiler.stop').catch((error) => ({ error }));
|
|
87
|
+
send('Profiler.start').catch(ignore);
|
|
86
88
|
if (!stopped.profile) return `${label(target)}: ${state}, no profile (${stopped.error?.message})`;
|
|
87
89
|
const name = `${tag}-${label(target)}-${sessionId.slice(0, 6)}.cpuprofile`;
|
|
88
90
|
await writeFile(new URL(name, dir), JSON.stringify(stopped.profile)).catch(ignore);
|
|
89
91
|
return `${label(target)}: ${state}, profile ${name}`;
|
|
90
92
|
}
|
|
91
93
|
|
|
94
|
+
function collect(send, profiling) {
|
|
95
|
+
const stop = profiling ? send('Profiler.stop') : {};
|
|
96
|
+
const taken = Promise.all([send('Profiler.takePreciseCoverage'), stop]);
|
|
97
|
+
return Promise.race([taken, sleep(3000)]).then(
|
|
98
|
+
(answer) => answer ?? [{}, {}],
|
|
99
|
+
() => [{}, {}]
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
|
|
92
103
|
function held(pauses) {
|
|
93
104
|
return [...pauses.values()].map(
|
|
94
105
|
(pause) =>
|
|
@@ -120,7 +131,7 @@ function contextOf(sessions, parents, sessionId) {
|
|
|
120
131
|
return undefined;
|
|
121
132
|
}
|
|
122
133
|
|
|
123
|
-
export function recorder(cdp, server, { coverage = ['/'], exits = new Map() } = {}) {
|
|
134
|
+
export function recorder(cdp, server, { coverage = ['/'], exits = new Map(), profiling } = {}) {
|
|
124
135
|
const sessions = new Map();
|
|
125
136
|
const pauses = new Map();
|
|
126
137
|
const parents = new Map();
|
|
@@ -132,14 +143,11 @@ export function recorder(cdp, server, { coverage = ['/'], exits = new Map() } =
|
|
|
132
143
|
async function dump(sessionId, target, restart) {
|
|
133
144
|
const current = run;
|
|
134
145
|
const send = (method) => cdp.send(method, {}, sessionId);
|
|
135
|
-
const
|
|
136
|
-
const [{ result }, { profile }] = await Promise.race([taken, sleep(3000)]).then(
|
|
137
|
-
(answer) => answer ?? [{}, {}],
|
|
138
|
-
() => [{}, {}]
|
|
139
|
-
);
|
|
146
|
+
const [{ result }, { profile }] = await collect(send, profiling);
|
|
140
147
|
if (!(result && current)) return;
|
|
141
|
-
if (restart) send('Profiler.start').catch(ignore);
|
|
142
148
|
current.scripts.push(...result.filter(ours));
|
|
149
|
+
if (!profile) return;
|
|
150
|
+
if (restart) send('Profiler.start').catch(ignore);
|
|
143
151
|
current.profiles += 1;
|
|
144
152
|
selfTimes(profile, server, current.self);
|
|
145
153
|
const name = `${String(current.profiles).padStart(3, '0')}-${label(target)}.cpuprofile`;
|
|
@@ -168,11 +176,10 @@ export function recorder(cdp, server, { coverage = ['/'], exits = new Map() } =
|
|
|
168
176
|
list.push(
|
|
169
177
|
['Runtime.enable'],
|
|
170
178
|
['Profiler.enable'],
|
|
171
|
-
['Profiler.setSamplingInterval', { interval: 100 }],
|
|
172
179
|
['Profiler.startPreciseCoverage', { callCount: true, detailed: true }],
|
|
173
|
-
['Profiler.start'],
|
|
174
180
|
['Target.setAutoAttach', nested]
|
|
175
181
|
);
|
|
182
|
+
if (profiling) list.push(...sampling);
|
|
176
183
|
}
|
|
177
184
|
const lines = type === 'worker' ? exits.get(pathOf(url)) : undefined;
|
|
178
185
|
if (lines) {
|
|
@@ -230,7 +237,8 @@ export function recorder(cdp, server, { coverage = ['/'], exits = new Map() } =
|
|
|
230
237
|
const current = [...sessions].filter(
|
|
231
238
|
([sessionId]) => contextOf(sessions, parents, sessionId) === run?.context
|
|
232
239
|
);
|
|
233
|
-
const sampled = () =>
|
|
240
|
+
const sampled = () =>
|
|
241
|
+
Promise.all(current.map((entry) => sample(cdp, pauses, entry, dir, tag, profiling)));
|
|
234
242
|
const [lines, traced] = first ? await trace(cdp, dir, tag, sampled) : [await sampled(), []];
|
|
235
243
|
const all = [...pending, ...lines, ...traced, ...held(pauses)];
|
|
236
244
|
for (const line of all) run?.console.push(`${tag}: ${line}`);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ai-ecoverse/slicc-shared-web",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.11.0",
|
|
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",
|
|
@@ -52,7 +52,7 @@
|
|
|
52
52
|
"scripts": {
|
|
53
53
|
"lint": "bin/slicc-lint.sh && shellcheck bin/*.sh",
|
|
54
54
|
"pretest": "playwright-core install --no-shell chromium",
|
|
55
|
-
"test": "node --test --test-global-setup=harness/global.mjs test/integration/*.test.mjs && node test/integration/verify.mjs",
|
|
55
|
+
"test": "SLICC_PROFILE=1 node --test --test-global-setup=harness/global.mjs test/integration/*.test.mjs && node test/integration/verify.mjs",
|
|
56
56
|
"test:unit": "mcr -c test/unit/mcr.config.mjs node --test 'test/unit/**/*.test.mjs'",
|
|
57
57
|
"trust": "npx fledgling sync -y --skip-publish",
|
|
58
58
|
"trust:dry": "npx fledgling sync --dry-run -y --skip-publish",
|