@ai-ecoverse/slicc-shared-web 1.10.1 → 1.10.3

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,7 @@ 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, 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. 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 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.
35
35
 
36
36
  `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
37
 
@@ -60,7 +60,7 @@ test('boots', async (t) => {
60
60
  | `coverage` | URL prefixes whose `.js`/`.mjs` scripts count for coverage. `node_modules/` never does. A `.map` next to a file is used | `['/']` |
61
61
  | `exits` | `{ urlPath: [regex, ...] }`: a breakpoint on the first line matching each pattern in that worker script, where the harness takes the worker's coverage and profile before it closes itself | none |
62
62
  | `intercept` | Remote URL prefixes answered from a disk cache in `node_modules/.cache/`. Registry documents (anything on a `registry.*` host but a `/-/….tgz` tarball: packuments, abbreviated or full, and dist-tags) are fetched fresh once per test process and only served from the disk cache when the registry can't be reached (a `4xx` is passed on) | none |
63
- | `extensions` | Unpacked extension directories to load. Their scripts are covered and profiled like the repo's own, and tests then run in the default browser context, closing their tabs when they end, because Chrome does not run extensions in the per-test contexts | none |
63
+ | `extensions` | Unpacked extension directories to load. They are loaded with `Extensions.loadUnpacked` (behind `--enable-unsafe-extension-debugging`) once the harness is attached, so their service workers start paused like every other target. Their scripts are covered and profiled like the repo's own, and tests then run in the default browser context, closing their tabs when they end, because Chrome does not run extensions in the per-test contexts | none |
64
64
  | `args` | Extra Chromium flags, such as `--host-resolver-rules` or `--ignore-certificate-errors` | none |
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` |
@@ -98,9 +98,9 @@ Why it works the way it does:
98
98
 
99
99
  ## Cleanup
100
100
 
101
- `close()` stops Chrome and removes its profile. A test process that ends without `close()` still leaves nothing running:
102
- - on `exit` and on `SIGINT`, `SIGTERM` or `SIGHUP`, the harness kills every browser it started in that process with `SIGKILL` and removes its profile and crash dump directory, then re-raises the signal so the process ends the way it would have;
103
- - when the test process dies of `SIGKILL`, no handler runs. A watchdog, a `sh` that reads a pipe from the test process, then gets end of file and kills Chrome with `SIGKILL` at once. Chrome also runs with `--remote-debugging-pipe` next to the port, so it shuts itself down when that pipe closes, but its shutdown can take seconds. Only the profile stays behind;
101
+ `close()` stops Chrome and removes its profile. It asks Chrome to close, waits up to 10 s for the exit, sends `SIGKILL`, waits up to 5 s more and then moves on with a warning, so a browser that ignores `Browser.close` or a missed exit never hangs teardown. A test process that ends without `close()` still leaves nothing running:
102
+ - on `exit` and on `SIGINT`, `SIGTERM` or `SIGHUP`, the harness kills every browser it started in that process, with all its helper processes (Chrome runs in its own process group), with `SIGKILL` and removes its profile and crash dump directory, then re-raises the signal so the process ends the way it would have;
103
+ - when the test process dies of `SIGKILL`, no handler runs. A watchdog, a `sh` in its own process group that reads a pipe from the test process, then gets end of file and kills Chrome's process group with `SIGKILL` at once. Chrome also shuts itself down when the CDP pipe closes, but that can take seconds. Only the profile stays behind;
104
104
  - the first `launch()` in a process removes every `slicc-harness-*` directory in `$TMPDIR` that is older than a day, or older than a minute and no running process uses as its `--user-data-dir`. It never kills a process.
105
105
 
106
106
  ## Crash dumps
package/harness/cdp.mjs CHANGED
@@ -17,12 +17,42 @@ function grace(ms) {
17
17
  return new Promise((resolve) => setTimeout(resolve, ms, 'socket closed').unref());
18
18
  }
19
19
 
20
- export async function connect(url, { exited, tail = () => [] } = {}) {
20
+ async function websocket(url) {
21
21
  const socket = new WebSocket(url);
22
22
  await new Promise((resolve, reject) => {
23
23
  socket.onopen = resolve;
24
24
  socket.onerror = reject;
25
25
  });
26
+ return {
27
+ send: (text) => socket.send(text),
28
+ close: () => socket.close(),
29
+ listen(message, closed) {
30
+ socket.onmessage = ({ data }) => message(data);
31
+ socket.onclose = closed;
32
+ },
33
+ };
34
+ }
35
+
36
+ export function pipe(input, output) {
37
+ input.on('error', () => null);
38
+ return {
39
+ send: (text) => input.write(`${text}\0`),
40
+ close: () => input.end(),
41
+ listen(message, closed) {
42
+ let partial = '';
43
+ output.setEncoding('utf8');
44
+ output.on('data', (chunk) => {
45
+ const parts = `${partial}${chunk}`.split('\0');
46
+ partial = parts.pop();
47
+ for (const part of parts) message(part);
48
+ });
49
+ output.once('close', closed);
50
+ },
51
+ };
52
+ }
53
+
54
+ export async function connect(url, { exited, tail = () => [] } = {}) {
55
+ const socket = typeof url?.listen === 'function' ? url : await websocket(url);
26
56
  const pending = new Map();
27
57
  const listeners = new Set();
28
58
  const targets = new Map();
@@ -79,16 +109,18 @@ export async function connect(url, { exited, tail = () => [] } = {}) {
79
109
  gone = reason;
80
110
  drop(() => true, told(reason), fatal);
81
111
  };
82
- socket.onmessage = ({ data }) => {
83
- const message = JSON.parse(data);
84
- track(message);
85
- const call = pending.get(message.id);
86
- pending.delete(message.id);
87
- if (!call) notify(message);
88
- else if (message.error) call.reject(new Error(`${call.method}: ${message.error.message}`));
89
- else call.resolve(message.result);
90
- };
91
- socket.onclose = async () => die(await Promise.race([exited ?? 'socket closed', grace(2000)]));
112
+ socket.listen(
113
+ (data) => {
114
+ const message = JSON.parse(data);
115
+ track(message);
116
+ const call = pending.get(message.id);
117
+ pending.delete(message.id);
118
+ if (!call) notify(message);
119
+ else if (message.error) call.reject(new Error(`${call.method}: ${message.error.message}`));
120
+ else call.resolve(message.result);
121
+ },
122
+ async () => die(await Promise.race([exited ?? 'socket closed', grace(2000)]))
123
+ );
92
124
  void exited?.then(die);
93
125
  return {
94
126
  send: (method, params = {}, sessionId, ms = SEND_TIMEOUT) =>
@@ -7,9 +7,9 @@ import { env as inherited } from 'node:process';
7
7
  import { promisify } from 'node:util';
8
8
  import { chromium } from 'playwright-core';
9
9
  import { cdn } from './cdn.mjs';
10
- import { CRASHED, connect } from './cdp.mjs';
10
+ import { CRASHED, connect, pipe } from './cdp.mjs';
11
11
  import { crashpad } from './dumps.mjs';
12
- import { extensionSource, unpacked } from './extensions.mjs';
12
+ import { extensionSource, install, unpacked } from './extensions.mjs';
13
13
  import { page } from './page.mjs';
14
14
  import { breakpoints, recorder } from './recorder.mjs';
15
15
  import { serve } from './server.mjs';
@@ -35,9 +35,7 @@ export const flags = [
35
35
  export function commandLine(profile, extensions = [], extra = []) {
36
36
  const loaded = extensions.length > 0;
37
37
  const base = loaded ? flags.filter((flag) => flag !== '--disable-extensions') : flags;
38
- const load = loaded
39
- ? [`--disable-extensions-except=${extensions}`, `--load-extension=${extensions}`]
40
- : [];
38
+ const load = loaded ? ['--enable-unsafe-extension-debugging'] : [];
41
39
  return [...base, ...load, ...extra, `--user-data-dir=${profile}`, 'about:blank'];
42
40
  }
43
41
 
@@ -76,6 +74,7 @@ export async function start(
76
74
  ) {
77
75
  const child = spawn(executable, args, {
78
76
  env,
77
+ detached: true,
79
78
  stdio: ['ignore', 'ignore', 'pipe', 'pipe', 'pipe'],
80
79
  });
81
80
  const stderr = lines(child.stderr);
@@ -105,15 +104,17 @@ export function died(child) {
105
104
  return (child.exitCode ?? 0) !== 0;
106
105
  }
107
106
 
108
- export async function stop(cdp, child) {
107
+ export const WAITS = { close: 10000, exit: 10000, kill: 5000 };
108
+
109
+ export async function stop(cdp, child, waits = WAITS) {
110
+ const gone = () => child.exitCode !== null || child.signalCode !== null;
109
111
  const exited = new Promise((resolve) => child.once('exit', resolve));
110
- await bounded(cdp.send('Browser.close').catch(ignore), 'Browser.close');
112
+ await bounded(cdp.send('Browser.close').catch(ignore), 'Browser.close', waits.close);
111
113
  cdp.close();
112
- const running = child.exitCode === null && child.signalCode === null;
113
- if (running && (await bounded(exited, 'exit')) === TIMED_OUT) {
114
- child.kill('SIGKILL');
115
- await exited;
116
- }
114
+ if (gone() || (await bounded(exited, 'exit', waits.exit)) !== TIMED_OUT || gone()) return;
115
+ kill(child);
116
+ if (gone() || (await bounded(exited, 'exit after SIGKILL', waits.kill)) !== TIMED_OUT) return;
117
+ if (!gone()) console.warn(`chrome: pid ${child.pid} still runs after SIGKILL, moving on`);
117
118
  }
118
119
 
119
120
  export const PREFIX = 'slicc-harness-';
@@ -123,15 +124,27 @@ export const GRACE = 60 * 1000;
123
124
  const signals = ['SIGINT', 'SIGTERM', 'SIGHUP'];
124
125
  const running = new Set();
125
126
 
126
- function kill(child) {
127
+ export function kill(child) {
128
+ if (child.pid > 0 && child.spawnargs?.length) {
129
+ try {
130
+ process.kill(-child.pid, 'SIGKILL');
131
+ } catch {}
132
+ }
127
133
  if (child.exitCode === null && child.signalCode === null) child.kill('SIGKILL');
128
134
  }
129
135
 
130
- function reap() {
131
- for (const { children, paths } of running) {
132
- for (const child of children) kill(child);
136
+ function remove(entries) {
137
+ for (const { paths } of entries) {
133
138
  for (const path of paths) rmSync(path, { recursive: true, force: true });
134
139
  }
140
+ }
141
+
142
+ function reap() {
143
+ const entries = [...running];
144
+ for (const { children } of entries) children.forEach(kill);
145
+ remove(entries);
146
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 200);
147
+ remove(entries);
135
148
  running.clear();
136
149
  unhook();
137
150
  }
@@ -152,7 +165,8 @@ function unhook() {
152
165
  }
153
166
 
154
167
  export function watchdog(pid) {
155
- const dog = spawn('sh', ['-c', 'read -r _; kill -9 "$0" 2>/dev/null', String(pid)], {
168
+ const dog = spawn('sh', ['-c', 'read -r _; kill -9 -"$0" "$0" 2>/dev/null', String(pid)], {
169
+ detached: true,
156
170
  stdio: ['pipe', 'ignore', 'ignore'],
157
171
  });
158
172
  dog.on('error', ignore);
@@ -221,14 +235,14 @@ export async function launch({
221
235
  const profile = await mkdtemp(join(tmpdir(), PREFIX));
222
236
  const loaded = [...found.values()].map((root) => root.slice(0, -1));
223
237
  const dumps = await crashpad();
224
- const { child, url, exited, stderr } = await start(
238
+ const { child, exited, stderr } = await start(
225
239
  profile,
226
240
  commandLine(profile, loaded, args),
227
241
  chromium.executablePath(),
228
242
  dumps.env
229
243
  );
230
244
  const untrack = track(child, [profile, dumps.dir]);
231
- const cdp = await connect(url, {
245
+ const cdp = await connect(pipe(child.stdio[3], child.stdio[4]), {
232
246
  exited,
233
247
  tail: () => stderr.tail().map((line) => `stderr: ${line}`),
234
248
  });
@@ -246,6 +260,7 @@ export async function launch({
246
260
  waitForDebuggerOnStart: true,
247
261
  flatten: true,
248
262
  });
263
+ await install(cdp, found);
249
264
 
250
265
  async function tabs() {
251
266
  const { targetInfos } = await cdp.send('Target.getTargets');
@@ -26,3 +26,10 @@ export function extensionSource(found) {
26
26
  return root ? join(root, normalize(decodeURIComponent(pathname))) : null;
27
27
  };
28
28
  }
29
+
30
+ export async function install(cdp, found) {
31
+ for (const [expected, root] of [...found]) {
32
+ const { id } = await cdp.send('Extensions.loadUnpacked', { path: root.slice(0, -1) });
33
+ if (id !== expected) found.set(id, root);
34
+ }
35
+ }
@@ -125,6 +125,7 @@ export function recorder(cdp, server, { coverage = ['/'], exits = new Map() } =
125
125
  const pauses = new Map();
126
126
  const parents = new Map();
127
127
  const tabs = new Map();
128
+ const early = [];
128
129
  const ours = covered(server, coverage);
129
130
  let run = null;
130
131
 
@@ -204,8 +205,8 @@ export function recorder(cdp, server, { coverage = ['/'], exits = new Map() } =
204
205
 
205
206
  function log(sessionId, line) {
206
207
  const target = sessions.get(sessionId);
207
- if (!run || target?.browserContextId !== run.context) return;
208
- run.console.push(`${label(target)}: ${line}`);
208
+ const into = run ? target?.browserContextId === run.context && run.console : target && early;
209
+ if (into) into.push(`${label(target)}: ${line}`);
209
210
  }
210
211
 
211
212
  async function capture(sessionId, target) {
@@ -274,7 +275,7 @@ export function recorder(cdp, server, { coverage = ['/'], exits = new Map() } =
274
275
  }
275
276
 
276
277
  function begin(context, dir, name) {
277
- run = { context, dir, name, scripts: [], console: [], profiles: 0, self: {} };
278
+ run = { context, dir, name, scripts: [], console: early.splice(0), profiles: 0, self: {} };
278
279
  }
279
280
 
280
281
  async function end() {
@@ -78,6 +78,10 @@ export async function serve({
78
78
  locate,
79
79
  source: (href) =>
80
80
  href.startsWith(url) ? lookup(decodeURIComponent(new URL(href).pathname)) : null,
81
- close: () => new Promise((done) => server.close(done)),
81
+ close: () =>
82
+ new Promise((done) => {
83
+ server.close(done);
84
+ server.closeAllConnections();
85
+ }),
82
86
  };
83
87
  }
package/harness/util.mjs CHANGED
@@ -19,7 +19,11 @@ export function sleep(ms) {
19
19
  }
20
20
 
21
21
  export async function bounded(promise, label, ms = 10000) {
22
- const result = await Promise.race([promise, sleep(ms).then(() => TIMED_OUT)]);
22
+ let timer;
23
+ const expired = new Promise((resolve) => {
24
+ timer = setTimeout(resolve, ms, TIMED_OUT);
25
+ });
26
+ const result = await Promise.race([promise, expired]).finally(() => clearTimeout(timer));
23
27
  if (result === TIMED_OUT) console.warn(`chrome: ${label} took over ${ms} ms, moving on`);
24
28
  return result;
25
29
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ai-ecoverse/slicc-shared-web",
3
- "version": "1.10.1",
3
+ "version": "1.10.3",
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",