@ai-ecoverse/slicc-shared-web 1.8.0 → 1.9.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
@@ -65,8 +65,14 @@ test('boots', async (t) => {
65
65
  | `timeout` | How long `page.until` polls and `page.evaluate` waits, in ms | `30000` |
66
66
  | `stallAfter` | How long one evaluation may run before the harness probes the test's targets, in ms | `30000` |
67
67
 
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;
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()`, `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. `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
+ - when the browser dies, every pending and later call rejects with `<method>: browser exited with <signal>` (or `with code <n>`);
70
+ - when a renderer crashes, every pending and later call to that target, and to the dedicated workers it started, rejects with `<method>: page renderer crashed (<status>, code <n>) at <url>` (or `worker`, `shared worker`, `service worker`), and `console.log` gets the same line;
71
+ - `until`, `within`, `goto` and `reload` stop retrying and rethrow it;
72
+ - both errors end with the last five lines of the browser's stderr, and each test keeps up to 200 lines it wrote during the test in `chrome-stderr.log`.
73
+
74
+ When one evaluation runs past `stallAfter`, the harness probes once, without interrupting the wait:
75
+ - 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;
70
76
  - it saves `stall-<n>.png` (a screenshot of the page, if the renderer still answers) in the test's artifact directory;
71
77
  - 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`;
72
78
  - it adds a line per target to `console.log`, saying whether that target is running or paused;
@@ -86,3 +92,4 @@ Why it works the way it does:
86
92
  - `--disable-extensions --disable-component-extensions-with-background-pages` keeps a Hangouts service worker out of every run.
87
93
  - The attach event can arrive before `Target.createTarget` returns, so new tabs wait on a slot keyed by target id.
88
94
  - A worker blocked in `Atomics.wait`, or one that is already gone, may never answer; snapshots give up after 3 s, and closing a context or the browser after 10 s. A call to a target that detaches or crashes fails instead of waiting forever, and a new tab that never attaches fails after 10 s.
95
+ - A WebSocket that has closed takes new messages without an error, and a crashed target never answers, so after a crash every call used to wait out its own deadline: a browser that died 4 s in showed up as `no answer` 15 minutes later. The harness now remembers the crash or the exit and fails each call with it.
package/harness/cdp.mjs CHANGED
@@ -1,6 +1,23 @@
1
1
  export const SEND_TIMEOUT = 60000;
2
+ export const CRASHED = 'Harness.targetCrashed';
2
3
 
3
- export async function connect(url) {
4
+ const kinds = {
5
+ page: 'page',
6
+ iframe: 'iframe',
7
+ worker: 'worker',
8
+ shared_worker: 'shared worker',
9
+ service_worker: 'service worker',
10
+ };
11
+
12
+ export function fatal(message) {
13
+ return Object.assign(new Error(message), { fatal: true });
14
+ }
15
+
16
+ function grace(ms) {
17
+ return new Promise((resolve) => setTimeout(resolve, ms, 'socket closed').unref());
18
+ }
19
+
20
+ export async function connect(url, { exited, tail = () => [] } = {}) {
4
21
  const socket = new WebSocket(url);
5
22
  await new Promise((resolve, reject) => {
6
23
  socket.onopen = resolve;
@@ -8,31 +25,79 @@ export async function connect(url) {
8
25
  });
9
26
  const pending = new Map();
10
27
  const listeners = new Set();
28
+ const targets = new Map();
29
+ const crashed = new Map();
30
+ let gone = null;
11
31
  let id = 0;
12
- const drop = (sessionId, reason) => {
32
+ const told = (reason) => [reason, ...tail()].join('\n');
33
+ const drop = (matches, reason, error = Error) => {
13
34
  for (const [key, call] of pending) {
14
- if (call.sessionId !== sessionId) continue;
35
+ if (!matches(call)) continue;
15
36
  pending.delete(key);
16
- call.reject(new Error(`${call.method}: ${reason}`));
37
+ call.reject(error(`${call.method}: ${reason}`));
38
+ }
39
+ };
40
+ const notify = (message) => {
41
+ for (const listener of listeners) listener(message);
42
+ };
43
+ const crash = (sessionId, status) => {
44
+ const target = targets.get(sessionId);
45
+ if (!target || crashed.has(sessionId)) return;
46
+ const how = status ? ` (${status})` : '';
47
+ const reason = `${kinds[target.type] ?? target.type} renderer crashed${how} at ${target.url}`;
48
+ crashed.set(sessionId, reason);
49
+ drop((call) => call.sessionId === sessionId, told(reason), fatal);
50
+ notify({ method: CRASHED, sessionId, params: { reason } });
51
+ for (const [child, { parent, type }] of targets) {
52
+ if (parent === sessionId && type === 'worker') crash(child, status);
17
53
  }
18
54
  };
55
+ const sessionsOf = (targetId) =>
56
+ [...targets]
57
+ .filter(([, target]) => target.targetId === targetId)
58
+ .map(([sessionId]) => sessionId);
59
+ const track = ({ method, params, sessionId }) => {
60
+ if (method === 'Target.attachedToTarget')
61
+ targets.set(params.sessionId, { ...params.targetInfo, parent: sessionId });
62
+ if (method === 'Target.targetInfoChanged') {
63
+ for (const session of sessionsOf(params.targetInfo.targetId)) {
64
+ targets.set(session, { ...targets.get(session), url: params.targetInfo.url });
65
+ }
66
+ }
67
+ if (method === 'Target.detachedFromTarget') {
68
+ targets.delete(params.sessionId);
69
+ drop((call) => call.sessionId === params.sessionId, 'target detached');
70
+ }
71
+ if (method === 'Inspector.targetCrashed') setTimeout(crash, 200, sessionId);
72
+ if (method === 'Target.targetCrashed') {
73
+ const status = `${params.status}, code ${params.errorCode}`;
74
+ for (const session of sessionsOf(params.targetId)) crash(session, status);
75
+ }
76
+ };
77
+ const die = (reason) => {
78
+ if (gone) return;
79
+ gone = reason;
80
+ drop(() => true, told(reason), fatal);
81
+ };
19
82
  socket.onmessage = ({ data }) => {
20
83
  const message = JSON.parse(data);
21
- if (message.method === 'Target.detachedFromTarget')
22
- drop(message.params.sessionId, 'target detached');
23
- if (message.method === 'Inspector.targetCrashed') drop(message.sessionId, 'target crashed');
84
+ track(message);
24
85
  const call = pending.get(message.id);
25
86
  pending.delete(message.id);
26
- if (!call) for (const listener of listeners) listener(message);
87
+ if (!call) notify(message);
27
88
  else if (message.error) call.reject(new Error(`${call.method}: ${message.error.message}`));
28
89
  else call.resolve(message.result);
29
90
  };
30
- socket.onclose = () => {
31
- for (const call of pending.values()) call.reject(new Error(`${call.method}: socket closed`));
32
- };
91
+ socket.onclose = async () => die(await Promise.race([exited ?? 'socket closed', grace(2000)]));
92
+ void exited?.then(die);
33
93
  return {
34
94
  send: (method, params = {}, sessionId, ms = SEND_TIMEOUT) =>
35
95
  new Promise((resolve, reject) => {
96
+ const stopped = gone ?? crashed.get(sessionId);
97
+ if (stopped) {
98
+ reject(fatal(`${method}: ${told(stopped)}`));
99
+ return;
100
+ }
36
101
  const key = ++id;
37
102
  const timer = setTimeout(() => {
38
103
  pending.delete(key);
@@ -49,6 +114,7 @@ export async function connect(url) {
49
114
  listeners.add(listener);
50
115
  return () => listeners.delete(listener);
51
116
  },
117
+ target: (sessionId) => targets.get(sessionId),
52
118
  close: () => socket.close(),
53
119
  };
54
120
  }
@@ -9,7 +9,7 @@ import { extensionSource, unpacked } from './extensions.mjs';
9
9
  import { page } from './page.mjs';
10
10
  import { breakpoints, recorder } from './recorder.mjs';
11
11
  import { serve } from './server.mjs';
12
- import { artifacts, bounded, ignore, slug, TIMED_OUT } from './util.mjs';
12
+ import { artifacts, bounded, ignore, sleep, slug, TIMED_OUT } from './util.mjs';
13
13
 
14
14
  export const flags = [
15
15
  '--headless',
@@ -36,30 +36,67 @@ export function commandLine(profile, extensions = [], extra = []) {
36
36
  return [...base, ...load, ...extra, `--user-data-dir=${profile}`, 'about:blank'];
37
37
  }
38
38
 
39
+ export const KEPT = 200;
40
+
41
+ export function lines(stream, kept = KEPT) {
42
+ const last = [];
43
+ let partial = '';
44
+ let total = 0;
45
+ stream.setEncoding('utf8');
46
+ stream.on('data', (chunk) => {
47
+ const split = `${partial}${chunk}`.split('\n');
48
+ partial = split.pop();
49
+ total += split.length;
50
+ last.push(...split);
51
+ last.splice(0, last.length - kept);
52
+ });
53
+ return {
54
+ get total() {
55
+ return total;
56
+ },
57
+ since: (mark) => last.slice(Math.max(0, last.length - (total - mark))),
58
+ tail: (n = 5) => last.slice(-n),
59
+ };
60
+ }
61
+
62
+ export function exitOf(code, signal) {
63
+ return signal ? `browser exited with ${signal}` : `browser exited with code ${code}`;
64
+ }
65
+
39
66
  export async function start(
40
67
  profile,
41
68
  args = commandLine(profile),
42
69
  executable = chromium.executablePath()
43
70
  ) {
44
71
  const child = spawn(executable, args, { stdio: ['ignore', 'ignore', 'pipe'] });
45
- let log = '';
72
+ const stderr = lines(child.stderr);
73
+ const drained = new Promise((resolve) => child.stderr.once('close', resolve));
74
+ const exited = new Promise((resolve) => {
75
+ child.once('exit', (code, signal) => {
76
+ const reason = exitOf(code, signal);
77
+ void Promise.race([drained, sleep(1000)]).then(() => resolve(reason));
78
+ });
79
+ });
46
80
  const url = await new Promise((resolve, reject) => {
47
- child.stderr.on('data', (chunk) => {
48
- log += chunk;
49
- const match = log.match(/DevTools listening on (ws:\/\/\S+)/);
81
+ child.stderr.on('data', () => {
82
+ const match = stderr
83
+ .tail(KEPT)
84
+ .join('\n')
85
+ .match(/DevTools listening on (ws:\/\/\S+)/);
50
86
  if (match) resolve(match[1]);
51
87
  });
52
88
  child.once('error', reject);
53
- child.once('exit', (code) => reject(new Error(`Chromium exited with ${code}\n${log}`)));
89
+ void exited.then((reason) => reject(new Error(`${reason}\n${stderr.tail(KEPT).join('\n')}`)));
54
90
  });
55
- return { child, url };
91
+ return { child, url, exited, stderr };
56
92
  }
57
93
 
58
94
  export async function stop(cdp, child) {
59
95
  const exited = new Promise((resolve) => child.once('exit', resolve));
60
96
  await bounded(cdp.send('Browser.close').catch(ignore), 'Browser.close');
61
97
  cdp.close();
62
- if (child.exitCode === null && (await bounded(exited, 'exit')) === TIMED_OUT) {
98
+ const running = child.exitCode === null && child.signalCode === null;
99
+ if (running && (await bounded(exited, 'exit')) === TIMED_OUT) {
63
100
  child.kill('SIGKILL');
64
101
  await exited;
65
102
  }
@@ -84,10 +121,14 @@ export async function launch({
84
121
  const shared = extensions.length > 0;
85
122
  const profile = await mkdtemp(join(tmpdir(), 'slicc-harness-'));
86
123
  const loaded = [...found.values()].map((root) => root.slice(0, -1));
87
- const { child, url } = await start(profile, commandLine(profile, loaded, args));
88
- const cdp = await connect(url);
124
+ const { child, url, exited, stderr } = await start(profile, commandLine(profile, loaded, args));
125
+ const cdp = await connect(url, {
126
+ exited,
127
+ tail: () => stderr.tail().map((line) => `stderr: ${line}`),
128
+ });
89
129
  const record = recorder(cdp, files, { coverage, exits: await breakpoints(server, exits) });
90
130
  const remote = await cdn(cdp, intercept);
131
+ await cdp.send('Target.setDiscoverTargets', { discover: true });
91
132
  await cdp.send('Target.setAutoAttach', {
92
133
  autoAttach: true,
93
134
  waitForDebuggerOnStart: true,
@@ -105,7 +146,9 @@ export async function launch({
105
146
  await Promise.all(closing.map((sent) => sent.catch(ignore)));
106
147
  }
107
148
 
108
- async function finish(pages, browserContextId, dir, before) {
149
+ async function finish(pages, browserContextId, dir, before, mark) {
150
+ const log = stderr.since(mark).map((line) => `${line}\n`);
151
+ await writeFile(new URL('chrome-stderr.log', dir), log.join(''));
109
152
  await bounded(Promise.all(pages.map((opened) => opened.probed())), 'probes', 30000);
110
153
  const shots = pages.map((opened, i) =>
111
154
  opened.screenshot(new URL(`tab-${i + 1}.png`, dir)).catch(ignore)
@@ -117,11 +160,12 @@ export async function launch({
117
160
  const closing = shared
118
161
  ? sweep(before)
119
162
  : cdp.send('Target.disposeBrowserContext', { browserContextId });
120
- await bounded(closing, 'dispose');
163
+ await bounded(closing.catch(ignore), 'dispose');
121
164
  }
122
165
 
123
166
  return {
124
167
  url: server.url,
168
+ pid: child.pid,
125
169
  cdn: remote.state,
126
170
  requests: server.requests,
127
171
  overrides: server.overrides,
@@ -136,7 +180,8 @@ export async function launch({
136
180
  server.overrides.clear();
137
181
  server.overridden.clear();
138
182
  const pages = [];
139
- t.after(() => finish(pages, browserContextId, dir, before));
183
+ const mark = stderr.total;
184
+ t.after(() => finish(pages, browserContextId, dir, before, mark));
140
185
  const tab = async () => {
141
186
  const opened = page(cdp, await record.open(browserContextId), server, timeout, {
142
187
  stallAfter,
@@ -30,5 +30,9 @@ export function flights(now = Date.now) {
30
30
  .map(({ what, since }) => `pending ${what} for ${((at - since) / 1000).toFixed(1)}s`);
31
31
  }
32
32
 
33
- return { watch, pending };
33
+ function loaded(targetId) {
34
+ requests.delete(targetId);
35
+ }
36
+
37
+ return { watch, loaded, pending };
34
38
  }
package/harness/page.mjs CHANGED
@@ -103,6 +103,7 @@ export function page(
103
103
  const flying = flights();
104
104
  const dispose = cdp.on((message) => {
105
105
  const { method, params, sessionId: from } = message;
106
+ if (method === 'Inspector.workerScriptLoaded') flying.loaded(cdp.target(from)?.targetId);
106
107
  if (from !== sessionId) return;
107
108
  flying.watch(message);
108
109
  if (method === 'Runtime.exceptionThrown') {
@@ -156,6 +157,7 @@ export function page(
156
157
  last = await run(fn, args, left);
157
158
  if (last) return last;
158
159
  } catch (error) {
160
+ if (error.fatal) throw error;
159
161
  last = error.message;
160
162
  }
161
163
  await sleep(25);
@@ -183,7 +185,7 @@ export function page(
183
185
  await until(fresh);
184
186
  return;
185
187
  } catch (error) {
186
- if (attempt === 1) throw error;
188
+ if (error.fatal || attempt === 1) throw error;
187
189
  }
188
190
  }
189
191
  }
@@ -192,6 +194,7 @@ export function page(
192
194
  errors,
193
195
  responses,
194
196
  dispose,
197
+ send,
195
198
  evaluate,
196
199
  until,
197
200
  within,
@@ -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 { CRASHED } from './cdp.mjs';
5
6
  import { trace } from './trace.mjs';
6
7
  import { bounded, ignore, raw, samples, sleep, TIMED_OUT } from './util.mjs';
7
8
 
@@ -257,6 +258,7 @@ export function recorder(cdp, server, { coverage = ['/'], exits = new Map() } =
257
258
  await paused(sessionId);
258
259
  }
259
260
  if (method === 'Debugger.resumed') pauses.delete(sessionId);
261
+ if (method === CRASHED) log(sessionId, params.reason);
260
262
  });
261
263
 
262
264
  async function open(browserContextId) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ai-ecoverse/slicc-shared-web",
3
- "version": "1.8.0",
3
+ "version": "1.9.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",