@ai-ecoverse/slicc-shared-web 1.10.3 → 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 +5 -2
- package/harness/chrome.mjs +12 -7
- 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.
|
package/harness/chrome.mjs
CHANGED
|
@@ -224,6 +224,7 @@ export async function launch({
|
|
|
224
224
|
args = [],
|
|
225
225
|
timeout,
|
|
226
226
|
stallAfter,
|
|
227
|
+
profile = inherited.SLICC_PROFILE === '1',
|
|
227
228
|
} = {}) {
|
|
228
229
|
pruning ??= prune();
|
|
229
230
|
await pruning;
|
|
@@ -232,16 +233,16 @@ export async function launch({
|
|
|
232
233
|
const fromExtension = extensionSource(found);
|
|
233
234
|
const files = { ...server, source: (href) => server.source(href) ?? fromExtension(href) };
|
|
234
235
|
const shared = extensions.length > 0;
|
|
235
|
-
const
|
|
236
|
+
const userData = await mkdtemp(join(tmpdir(), PREFIX));
|
|
236
237
|
const loaded = [...found.values()].map((root) => root.slice(0, -1));
|
|
237
238
|
const dumps = await crashpad();
|
|
238
239
|
const { child, exited, stderr } = await start(
|
|
239
|
-
|
|
240
|
-
commandLine(
|
|
240
|
+
userData,
|
|
241
|
+
commandLine(userData, loaded, args),
|
|
241
242
|
chromium.executablePath(),
|
|
242
243
|
dumps.env
|
|
243
244
|
);
|
|
244
|
-
const untrack = track(child, [
|
|
245
|
+
const untrack = track(child, [userData, dumps.dir]);
|
|
245
246
|
const cdp = await connect(pipe(child.stdio[3], child.stdio[4]), {
|
|
246
247
|
exited,
|
|
247
248
|
tail: () => stderr.tail().map((line) => `stderr: ${line}`),
|
|
@@ -252,7 +253,11 @@ export async function launch({
|
|
|
252
253
|
cdp.on(({ method }) => {
|
|
253
254
|
if (method === CRASHED || method === 'Target.targetCrashed') crashes += 1;
|
|
254
255
|
});
|
|
255
|
-
const record = recorder(cdp, files, {
|
|
256
|
+
const record = recorder(cdp, files, {
|
|
257
|
+
coverage,
|
|
258
|
+
exits: await breakpoints(server, exits),
|
|
259
|
+
profiling: profile,
|
|
260
|
+
});
|
|
256
261
|
const remote = await cdn(cdp, intercept);
|
|
257
262
|
await cdp.send('Target.setDiscoverTargets', { discover: true });
|
|
258
263
|
await cdp.send('Target.setAutoAttach', {
|
|
@@ -296,7 +301,7 @@ export async function launch({
|
|
|
296
301
|
return {
|
|
297
302
|
url: server.url,
|
|
298
303
|
pid: child.pid,
|
|
299
|
-
profile,
|
|
304
|
+
profile: userData,
|
|
300
305
|
cdn: remote.state,
|
|
301
306
|
requests: server.requests,
|
|
302
307
|
overrides: server.overrides,
|
|
@@ -330,7 +335,7 @@ export async function launch({
|
|
|
330
335
|
},
|
|
331
336
|
async close() {
|
|
332
337
|
await stop(cdp, child);
|
|
333
|
-
await rm(
|
|
338
|
+
await rm(userData, { recursive: true, force: true });
|
|
334
339
|
untrack();
|
|
335
340
|
closed = true;
|
|
336
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",
|