@ai-ecoverse/slicc-shared-web 1.7.1 → 1.8.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
@@ -66,10 +66,11 @@ test('boots', async (t) => {
66
66
  | `stallAfter` | How long one evaluation may run before the harness probes the test's targets, in ms | `30000` |
67
67
 
68
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()`, `errors`, `responses` and `dir` (the test's artifact directory). `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. When one evaluation runs past `stallAfter`, the harness probes once, without interrupting the wait:
69
+ - it adds a `pending` line to `console.log` for every navigation and request of the page that has no answer yet, with its age. 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;
69
70
  - it saves `stall-<n>.png` (a screenshot of the page, if the renderer still answers) in the test's artifact directory;
70
71
  - 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`;
71
72
  - it adds a line per target to `console.log`, saying whether that target is running or paused;
72
- - 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, so it shows what a page's main thread is doing even when that thread no longer answers CDP. It also logs each process's CPU time over the window;
73
+ - 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;
73
74
  - 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.
74
75
 
75
76
  A pause in one target blocks every page in the same renderer process, so those pause lines name the culprit even when it isn't the page under test. The harness resumes its own coverage pauses (`beforeunload`, worker exits) within 10 s, even when collecting coverage fails. Downloads of intercepted URLs time out after 60 s and are tried up to three times; a `4xx` is not retried. `chrome.requests` lists the paths the server saw in this test, `chrome.overrides` is a `Map` of paths to bodies served instead of files, and `chrome.cdn` takes `status` (answer every intercepted request with it), `corrupt` (flip the last byte) and lists the intercepted `requests`. All of them reset with each test. `serve(options)` starts the same server on its own, for an `npm start`.
@@ -140,9 +140,9 @@ export async function launch({
140
140
  const tab = async () => {
141
141
  const opened = page(cdp, await record.open(browserContextId), server, timeout, {
142
142
  stallAfter,
143
- stall: async (tag, png) => {
143
+ stall: async (tag, png, pending) => {
144
144
  if (png) await writeFile(new URL(`${tag}.png`, dir), png).catch(ignore);
145
- await bounded(record.stall(dir, tag), tag, 45000);
145
+ await bounded(record.stall(dir, tag, pending), tag, 45000);
146
146
  },
147
147
  });
148
148
  pages.push(opened);
@@ -0,0 +1,34 @@
1
+ const done = new Set(['Network.loadingFinished', 'Network.loadingFailed']);
2
+ const landed = new Set([
3
+ 'Page.frameNavigated',
4
+ 'Page.frameStoppedLoading',
5
+ 'Page.navigatedWithinDocument',
6
+ ]);
7
+
8
+ export function flights(now = Date.now) {
9
+ const requests = new Map();
10
+ const navigations = new Map();
11
+
12
+ function watch({ method, params }) {
13
+ if (method === 'Network.requestWillBeSent') {
14
+ const { request, type = 'Other', requestId } = params;
15
+ requests.set(requestId, { what: `${request.method} ${request.url} (${type})`, since: now() });
16
+ }
17
+ if (done.has(method)) requests.delete(params.requestId);
18
+ if (method === 'Page.frameStartedNavigating') {
19
+ const what = `navigation to ${params.url} (${params.navigationType})`;
20
+ navigations.set(params.frameId, { what, since: now() });
21
+ }
22
+ if (landed.has(method)) navigations.delete(params.frame?.id ?? params.frameId);
23
+ }
24
+
25
+ function pending() {
26
+ const at = now();
27
+ return [...navigations.values(), ...requests.values()]
28
+ .sort((a, b) => a.since - b.since)
29
+ .slice(0, 20)
30
+ .map(({ what, since }) => `pending ${what} for ${((at - since) / 1000).toFixed(1)}s`);
31
+ }
32
+
33
+ return { watch, pending };
34
+ }
package/harness/page.mjs CHANGED
@@ -1,4 +1,5 @@
1
1
  import { writeFile } from 'node:fs/promises';
2
+ import { flights } from './flight.mjs';
2
3
  import { sleep } from './util.mjs';
3
4
 
4
5
  const masks = { alt: 1, ctrl: 2, meta: 4, shift: 8 };
@@ -99,8 +100,11 @@ export function page(
99
100
  const errors = [];
100
101
  const responses = [];
101
102
  const bindings = new Map();
102
- const dispose = cdp.on(({ method, params, sessionId: from }) => {
103
+ const flying = flights();
104
+ const dispose = cdp.on((message) => {
105
+ const { method, params, sessionId: from } = message;
103
106
  if (from !== sessionId) return;
107
+ flying.watch(message);
104
108
  if (method === 'Runtime.exceptionThrown') {
105
109
  const { exception, text } = params.exceptionDetails;
106
110
  errors.push(exception?.description ?? text);
@@ -112,8 +116,9 @@ export function page(
112
116
  async function capture() {
113
117
  stalls += 1;
114
118
  const tag = `stall-${stalls}`;
119
+ const pending = flying.pending();
115
120
  const shot = await send('Page.captureScreenshot', { format: 'png' }, 10000).catch(() => null);
116
- await stall(tag, shot?.data ? Buffer.from(shot.data, 'base64') : null);
121
+ await stall(tag, shot?.data ? Buffer.from(shot.data, 'base64') : null, pending);
117
122
  }
118
123
 
119
124
  function probe() {
@@ -2,6 +2,7 @@ import { mkdir, readFile, writeFile } from 'node:fs/promises';
2
2
  import { basename, relative } from 'node:path';
3
3
  import { cwd } from 'node:process';
4
4
  import { pathToFileURL } from 'node:url';
5
+ import { trace } from './trace.mjs';
5
6
  import { bounded, ignore, raw, samples, sleep, TIMED_OUT } from './util.mjs';
6
7
 
7
8
  const profiled = new Set(['page', 'worker', 'service_worker', 'shared_worker']);
@@ -104,56 +105,6 @@ function pauseOf(target = {}, params) {
104
105
  };
105
106
  }
106
107
 
107
- const TRACED = [
108
- 'toplevel',
109
- 'devtools.timeline',
110
- 'disabled-by-default-devtools.timeline',
111
- 'v8.execute',
112
- 'disabled-by-default-v8.cpu_profiler',
113
- ];
114
-
115
- async function cpuTimes(cdp) {
116
- const { processInfo = [] } = await cdp
117
- .send('SystemInfo.getProcessInfo', {}, undefined, 5000)
118
- .catch(() => ({}));
119
- return new Map(processInfo.map((info) => [`${info.type} ${info.id}`, info.cpuTime]));
120
- }
121
-
122
- export async function trace(cdp, dir, tag, ms = 10000) {
123
- const events = [];
124
- const done = Promise.withResolvers();
125
- const off = cdp.on(({ method, params }) => {
126
- if (method === 'Tracing.dataCollected') events.push(...params.value);
127
- if (method === 'Tracing.tracingComplete') done.resolve();
128
- });
129
- try {
130
- const before = await cpuTimes(cdp);
131
- await cdp.send(
132
- 'Tracing.start',
133
- { traceConfig: { includedCategories: TRACED }, transferMode: 'ReportEvents' },
134
- undefined,
135
- 10000
136
- );
137
- await sleep(ms);
138
- await cdp.send('Tracing.end', {}, undefined, 10000);
139
- await bounded(done.promise, 'trace', 20000);
140
- const after = await cpuTimes(cdp);
141
- await writeFile(new URL(`${tag}-trace.json`, dir), JSON.stringify({ traceEvents: events }));
142
- const busy = [...after]
143
- .map(([name, time]) => [name, time - (before.get(name) ?? 0)])
144
- .filter(([, used]) => used > 0.05)
145
- .map(([name, used]) => `${name} ${used.toFixed(1)}s`);
146
- return [
147
- `trace ${tag}-trace.json (${events.length} events)`,
148
- `cpu over ${ms / 1000}s: ${busy.join(', ') || 'none'}`,
149
- ];
150
- } catch (error) {
151
- return [`trace failed: ${error.message}`];
152
- } finally {
153
- off();
154
- }
155
- }
156
-
157
108
  function claim(run) {
158
109
  if (!run || run.traced) return false;
159
110
  run.traced = true;
@@ -272,16 +223,14 @@ export function recorder(cdp, server, { coverage = ['/'], exits = new Map() } =
272
223
  }
273
224
  }
274
225
 
275
- async function stall(dir, tag) {
226
+ async function stall(dir, tag, pending = []) {
276
227
  const first = claim(run);
277
228
  const current = [...sessions].filter(
278
229
  ([sessionId]) => contextOf(sessions, parents, sessionId) === run?.context
279
230
  );
280
- const [lines, traced] = await Promise.all([
281
- Promise.all(current.map((entry) => sample(cdp, pauses, entry, dir, tag))),
282
- first ? trace(cdp, dir, tag) : [],
283
- ]);
284
- const all = [...lines, ...traced, ...held(pauses)];
231
+ const sampled = () => Promise.all(current.map((entry) => sample(cdp, pauses, entry, dir, tag)));
232
+ const [lines, traced] = first ? await trace(cdp, dir, tag, sampled) : [await sampled(), []];
233
+ const all = [...pending, ...lines, ...traced, ...held(pauses)];
285
234
  for (const line of all) run?.console.push(`${tag}: ${line}`);
286
235
  return all;
287
236
  }
@@ -0,0 +1,192 @@
1
+ import { writeFile } from 'node:fs/promises';
2
+ import { bounded, sleep } from './util.mjs';
3
+
4
+ export const TRACED = [
5
+ 'toplevel',
6
+ 'ipc',
7
+ 'disabled-by-default-ipc.flow',
8
+ 'mojom',
9
+ 'blink',
10
+ 'loading',
11
+ 'navigation',
12
+ 'devtools.timeline',
13
+ 'disabled-by-default-devtools.timeline',
14
+ 'v8.execute',
15
+ 'disabled-by-default-v8.cpu_profiler',
16
+ ];
17
+
18
+ function where({ functionName, url, lineNumber }) {
19
+ const path = URL.canParse(url) ? new URL(url).pathname : url;
20
+ const name = functionName || '(anonymous)';
21
+ return path ? `${name} ${path}:${lineNumber + 1}` : name;
22
+ }
23
+
24
+ function threads(events) {
25
+ const names = new Map();
26
+ const processes = new Map();
27
+ for (const { name, pid, tid, args } of events) {
28
+ if (name === 'thread_name') names.set(`${pid}:${tid}`, args.name);
29
+ if (name === 'process_name') processes.set(pid, args.name);
30
+ }
31
+ return { names, processes };
32
+ }
33
+
34
+ function detail(event) {
35
+ const data = event.args?.data ?? event.args?.beginData ?? {};
36
+ const tag = event.args?.chrome_mojo_event_info?.mojo_interface_tag;
37
+ if (typeof data.functionName === 'string') {
38
+ return where({ ...data, lineNumber: (data.lineNumber ?? 1) - 1 });
39
+ }
40
+ return data.url ?? event.args?.url ?? tag ?? '';
41
+ }
42
+
43
+ function labels(slices) {
44
+ const named = slices.map((event) => `${event.name} ${detail(event)}`.trim());
45
+ return named
46
+ .filter((name, i) => name !== named[i - 1])
47
+ .slice(0, 10)
48
+ .join(' > ');
49
+ }
50
+
51
+ function inside(slices, task) {
52
+ const end = task.ts + task.dur;
53
+ return slices
54
+ .filter(
55
+ ({ ph, ts, dur }) => ph === 'X' && ts >= task.ts && ts + dur <= end && dur >= task.dur / 2
56
+ )
57
+ .sort((a, b) => a.ts - b.ts || b.dur - a.dur);
58
+ }
59
+
60
+ function unmatched(slices) {
61
+ const stacks = new Map();
62
+ for (const event of [...slices].sort((a, b) => a.ts - b.ts)) {
63
+ const stack = stacks.get(event.tid) ?? [];
64
+ if (event.ph === 'B') stack.push(event);
65
+ if (event.ph === 'E') stack.pop();
66
+ stacks.set(event.tid, stack);
67
+ }
68
+ return [...stacks.values()].flat();
69
+ }
70
+
71
+ function main(pid, slices, last) {
72
+ const tasks = slices.filter(({ name, ph }) => name === 'RunTask' && ph === 'X');
73
+ const longest = tasks.reduce((most, task) => (task.dur > (most?.dur ?? 0) ? task : most), null);
74
+ const open = unmatched(slices).filter(({ ts }) => ts < last - 50000);
75
+ const head = `renderer ${pid} main thread: ${tasks.length} tasks`;
76
+ if (open.length) return `${head}, still in ${labels(open)}`;
77
+ if (!longest) return head;
78
+ const ms = (longest.dur / 1000).toFixed(1);
79
+ return longest.dur < 50000
80
+ ? `${head}, longest ${ms} ms`
81
+ : `${head}, longest ${ms} ms in ${labels(inside(slices, longest))}`;
82
+ }
83
+
84
+ function mains(events, { names, processes }) {
85
+ const found = new Map();
86
+ const last = events.reduce((most, { ts = 0 }) => Math.max(most, ts), 0);
87
+ for (const event of events) {
88
+ const ours = names.get(`${event.pid}:${event.tid}`) === 'CrRendererMain';
89
+ if (!ours || processes.get(event.pid) !== 'Renderer' || event.ph === 'M') continue;
90
+ found.set(event.pid, [...(found.get(event.pid) ?? []), event]);
91
+ }
92
+ return [...found].map(([pid, slices]) => main(pid, slices, last));
93
+ }
94
+
95
+ function stack(nodes, id) {
96
+ const frames = [];
97
+ for (let node = nodes.get(id); node && frames.length < 3; node = nodes.get(node.parent)) {
98
+ frames.push(where(node.callFrame));
99
+ }
100
+ return frames.join(' < ');
101
+ }
102
+
103
+ function profiles(events) {
104
+ const streams = new Map();
105
+ for (const event of events) {
106
+ if (event.name !== 'ProfileChunk') continue;
107
+ const key = `${event.pid} ${event.id}`;
108
+ const stream = streams.get(key) ?? { pid: event.pid, nodes: new Map(), counts: new Map() };
109
+ const { nodes = [], samples = [] } = event.args.data.cpuProfile ?? {};
110
+ for (const node of nodes) stream.nodes.set(node.id, node);
111
+ for (const id of samples) stream.counts.set(id, (stream.counts.get(id) ?? 0) + 1);
112
+ streams.set(key, stream);
113
+ }
114
+ return [...streams.values()].filter(({ counts }) => counts.size > 0).map(busiest);
115
+ }
116
+
117
+ function busiest({ pid, nodes, counts }) {
118
+ const total = [...counts.values()].reduce((sum, n) => sum + n, 0);
119
+ const ranked = [...counts].sort((a, b) => b[1] - a[1]);
120
+ const share = (n) => `${Math.round((100 * n) / total)}%`;
121
+ const [id, most] = ranked[0];
122
+ const named = nodes.has(id) ? stack(nodes, id) : `node ${id}, named before the trace began`;
123
+ const line = `samples in ${pid}: ${share(most)} of ${total} samples in ${named}`;
124
+ const script = ranked.find(
125
+ ([n]) => nodes.has(n) && !nodes.get(n).callFrame.functionName.startsWith('(')
126
+ );
127
+ if (!script || script[0] === id || script[1] * 100 < total) return line;
128
+ return `${line}, ${share(script[1])} in ${stack(nodes, script[0])}`;
129
+ }
130
+
131
+ export function summary(events) {
132
+ const known = threads(events);
133
+ return [...mains(events, known), ...profiles(events)];
134
+ }
135
+
136
+ async function cpuTimes(cdp) {
137
+ const { processInfo = [] } = await cdp
138
+ .send('SystemInfo.getProcessInfo', {}, undefined, 5000)
139
+ .catch(() => ({}));
140
+ return new Map(processInfo.map((info) => [`${info.type} ${info.id}`, info.cpuTime]));
141
+ }
142
+
143
+ async function begin(cdp) {
144
+ const traceConfig = { includedCategories: TRACED };
145
+ try {
146
+ await cdp.send(
147
+ 'Tracing.start',
148
+ { traceConfig, transferMode: 'ReportEvents' },
149
+ undefined,
150
+ 10000
151
+ );
152
+ return [];
153
+ } catch (error) {
154
+ if (!/no answer/.test(error.message)) throw error;
155
+ return [`${error.message}, a renderer may be blocked; tracing anyway`];
156
+ }
157
+ }
158
+
159
+ export async function trace(cdp, dir, tag, during = () => [], ms = 10000) {
160
+ const events = [];
161
+ let ran = null;
162
+ const done = Promise.withResolvers();
163
+ const off = cdp.on(({ method, params }) => {
164
+ if (method === 'Tracing.dataCollected') events.push(...params.value);
165
+ if (method === 'Tracing.tracingComplete') done.resolve();
166
+ });
167
+ try {
168
+ const before = await cpuTimes(cdp);
169
+ const late = await begin(cdp);
170
+ ran = during();
171
+ await sleep(ms);
172
+ await cdp.send('Tracing.end', {}, undefined, 10000);
173
+ await bounded(done.promise, 'trace', 20000);
174
+ const after = await cpuTimes(cdp);
175
+ await writeFile(new URL(`${tag}-trace.json`, dir), JSON.stringify({ traceEvents: events }));
176
+ const busy = [...after]
177
+ .map(([name, time]) => [name, time - (before.get(name) ?? 0)])
178
+ .filter(([, used]) => used > 0.05)
179
+ .map(([name, used]) => `${name} ${used.toFixed(1)}s`);
180
+ const lines = [
181
+ ...late,
182
+ `trace ${tag}-trace.json (${events.length} events)`,
183
+ `cpu over ${ms / 1000}s: ${busy.join(', ') || 'none'}`,
184
+ ...summary(events),
185
+ ];
186
+ return [await ran, lines];
187
+ } catch (error) {
188
+ return [await (ran ?? during()), [`trace failed: ${error.message}`]];
189
+ } finally {
190
+ off();
191
+ }
192
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ai-ecoverse/slicc-shared-web",
3
- "version": "1.7.1",
3
+ "version": "1.8.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",