@ai-ecoverse/slicc-shared-web 1.10.3 → 1.12.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 -3
- package/harness/chrome.mjs +26 -9
- package/harness/global.mjs +3 -0
- package/harness/recorder.mjs +21 -13
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -20,7 +20,7 @@ Install it from npm, and pin the reusable workflow to the matching `vX.Y.Z` tag.
|
|
|
20
20
|
npm install --save-dev --save-exact @ai-ecoverse/slicc-shared-web
|
|
21
21
|
```
|
|
22
22
|
|
|
23
|
-
The hooks expect two scripts in each repo: `test:unit`, which writes `coverage/unit/lcov.info` (unit tests stay out of git), and, in TypeScript repos, `typecheck`. The reusable workflow expects `lint`, plus `test` for the integration tests, with a `pretest` that installs Chromium (`playwright-core install --no-shell chromium`). The diff-coverage hook runs `diff-cover` through `uvx`, so install [uv](https://docs.astral.sh/uv/) once.
|
|
23
|
+
The hooks expect two scripts in each repo: `test:unit`, which writes `coverage/unit/lcov.info` (unit tests stay out of git), and, in TypeScript repos, `typecheck`. The reusable workflow expects `lint`, plus `test` for the integration tests, with a `pretest` that installs Chromium (`playwright-core install --no-shell chromium`). Its inputs `lint`, `test`, `chromium` and `node-version` override those, and `timeout-minutes` (20 by default) gives a long job, such as a background-tab measurement, more time. The diff-coverage hook runs `diff-cover` through `uvx`, so install [uv](https://docs.astral.sh/uv/) once.
|
|
24
24
|
|
|
25
25
|
## Rules every repo follows
|
|
26
26
|
|
|
@@ -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,8 @@ 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` |
|
|
70
|
+
| `realistic` | Run Chrome as a user would: headed, and without `--disable-background-timer-throttling`, `--disable-backgrounding-occluded-windows` and `--disable-renderer-backgrounding`, so a tab behind another one is hidden and throttled. For measuring background tabs. It needs a display: on CI, run the tests under `xvfb-run -a` | `false` |
|
|
67
71
|
|
|
68
72
|
`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
73
|
- when the browser dies, every pending and later call rejects with `<method>: browser exited with <signal>` (or `with code <n>`);
|
|
@@ -75,7 +79,7 @@ test('boots', async (t) => {
|
|
|
75
79
|
When one evaluation runs past `stallAfter`, the harness probes once, without interrupting the wait:
|
|
76
80
|
- 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
81
|
- 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`;
|
|
82
|
+
- 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
83
|
- it adds a line per target to `console.log`, saying whether that target is running or paused;
|
|
80
84
|
- 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
85
|
- 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
|
@@ -32,9 +32,20 @@ export const flags = [
|
|
|
32
32
|
'--window-size=1280,800',
|
|
33
33
|
];
|
|
34
34
|
|
|
35
|
-
export
|
|
35
|
+
export const unrealistic = [
|
|
36
|
+
'--headless',
|
|
37
|
+
'--disable-background-timer-throttling',
|
|
38
|
+
'--disable-backgrounding-occluded-windows',
|
|
39
|
+
'--disable-renderer-backgrounding',
|
|
40
|
+
];
|
|
41
|
+
|
|
42
|
+
export function commandLine(profile, extensions = [], extra = [], realistic = false) {
|
|
36
43
|
const loaded = extensions.length > 0;
|
|
37
|
-
const
|
|
44
|
+
const dropped = new Set([
|
|
45
|
+
...(loaded ? ['--disable-extensions'] : []),
|
|
46
|
+
...(realistic ? unrealistic : []),
|
|
47
|
+
]);
|
|
48
|
+
const base = flags.filter((flag) => !dropped.has(flag));
|
|
38
49
|
const load = loaded ? ['--enable-unsafe-extension-debugging'] : [];
|
|
39
50
|
return [...base, ...load, ...extra, `--user-data-dir=${profile}`, 'about:blank'];
|
|
40
51
|
}
|
|
@@ -224,6 +235,8 @@ export async function launch({
|
|
|
224
235
|
args = [],
|
|
225
236
|
timeout,
|
|
226
237
|
stallAfter,
|
|
238
|
+
profile = inherited.SLICC_PROFILE === '1',
|
|
239
|
+
realistic = false,
|
|
227
240
|
} = {}) {
|
|
228
241
|
pruning ??= prune();
|
|
229
242
|
await pruning;
|
|
@@ -232,16 +245,16 @@ export async function launch({
|
|
|
232
245
|
const fromExtension = extensionSource(found);
|
|
233
246
|
const files = { ...server, source: (href) => server.source(href) ?? fromExtension(href) };
|
|
234
247
|
const shared = extensions.length > 0;
|
|
235
|
-
const
|
|
248
|
+
const userData = await mkdtemp(join(tmpdir(), PREFIX));
|
|
236
249
|
const loaded = [...found.values()].map((root) => root.slice(0, -1));
|
|
237
250
|
const dumps = await crashpad();
|
|
238
251
|
const { child, exited, stderr } = await start(
|
|
239
|
-
|
|
240
|
-
commandLine(
|
|
252
|
+
userData,
|
|
253
|
+
commandLine(userData, loaded, args, realistic),
|
|
241
254
|
chromium.executablePath(),
|
|
242
255
|
dumps.env
|
|
243
256
|
);
|
|
244
|
-
const untrack = track(child, [
|
|
257
|
+
const untrack = track(child, [userData, dumps.dir]);
|
|
245
258
|
const cdp = await connect(pipe(child.stdio[3], child.stdio[4]), {
|
|
246
259
|
exited,
|
|
247
260
|
tail: () => stderr.tail().map((line) => `stderr: ${line}`),
|
|
@@ -252,7 +265,11 @@ export async function launch({
|
|
|
252
265
|
cdp.on(({ method }) => {
|
|
253
266
|
if (method === CRASHED || method === 'Target.targetCrashed') crashes += 1;
|
|
254
267
|
});
|
|
255
|
-
const record = recorder(cdp, files, {
|
|
268
|
+
const record = recorder(cdp, files, {
|
|
269
|
+
coverage,
|
|
270
|
+
exits: await breakpoints(server, exits),
|
|
271
|
+
profiling: profile,
|
|
272
|
+
});
|
|
256
273
|
const remote = await cdn(cdp, intercept);
|
|
257
274
|
await cdp.send('Target.setDiscoverTargets', { discover: true });
|
|
258
275
|
await cdp.send('Target.setAutoAttach', {
|
|
@@ -296,7 +313,7 @@ export async function launch({
|
|
|
296
313
|
return {
|
|
297
314
|
url: server.url,
|
|
298
315
|
pid: child.pid,
|
|
299
|
-
profile,
|
|
316
|
+
profile: userData,
|
|
300
317
|
cdn: remote.state,
|
|
301
318
|
requests: server.requests,
|
|
302
319
|
overrides: server.overrides,
|
|
@@ -330,7 +347,7 @@ export async function launch({
|
|
|
330
347
|
},
|
|
331
348
|
async close() {
|
|
332
349
|
await stop(cdp, child);
|
|
333
|
-
await rm(
|
|
350
|
+
await rm(userData, { recursive: true, force: true });
|
|
334
351
|
untrack();
|
|
335
352
|
closed = true;
|
|
336
353
|
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.12.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",
|