@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 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: one CPU profile per target and snapshot, 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`, the 15 frames with the most self time in the repo's own scripts.
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.
@@ -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 profile = await mkdtemp(join(tmpdir(), PREFIX));
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
- profile,
240
- commandLine(profile, loaded, args),
240
+ userData,
241
+ commandLine(userData, loaded, args),
241
242
  chromium.executablePath(),
242
243
  dumps.env
243
244
  );
244
- const untrack = track(child, [profile, dumps.dir]);
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, { coverage, exits: await breakpoints(server, exits) });
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(profile, { recursive: true, force: true });
338
+ await rm(userData, { recursive: true, force: true });
334
339
  untrack();
335
340
  closed = true;
336
341
  if (open === 0) await dumps.remove();
@@ -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 [
@@ -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 taken = Promise.all([send('Profiler.takePreciseCoverage'), send('Profiler.stop')]);
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 = () => Promise.all(current.map((entry) => sample(cdp, pauses, entry, dir, tag)));
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.10.3",
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",