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

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,7 +65,7 @@ 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()`, `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:
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 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
69
  - when the browser dies, every pending and later call rejects with `<method>: browser exited with <signal>` (or `with code <n>`);
70
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
71
  - `until`, `within`, `goto` and `reload` stop retrying and rethrow it;
@@ -96,6 +96,13 @@ Why it works the way it does:
96
96
  - 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.
97
97
  - 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.
98
98
 
99
+ ## Cleanup
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;
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
+
99
106
  ## Crash dumps
100
107
 
101
108
  Chrome for Testing runs crashpad by default and ignores `--crash-dumps-dir` and `--enable-crash-reporter`: on Linux it writes every dump to `~/.config/google-chrome-for-testing/Crash Reports/pending/`. `launch` points it at a fresh directory per browser through `BREAKPAD_DUMP_LOCATION`, waits up to 10 s after a crash for crashpad to finish writing, copies the new dumps into the test's artifact directory and deletes the directory on `close()`. Nothing is uploaded.
@@ -1,8 +1,10 @@
1
- import { spawn } from 'node:child_process';
2
- import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
1
+ import { execFile, spawn } from 'node:child_process';
2
+ import { rmSync } from 'node:fs';
3
+ import { lstat, mkdir, mkdtemp, readdir, rm, writeFile } from 'node:fs/promises';
3
4
  import { tmpdir } from 'node:os';
4
5
  import { basename, join } from 'node:path';
5
6
  import { env as inherited } from 'node:process';
7
+ import { promisify } from 'node:util';
6
8
  import { chromium } from 'playwright-core';
7
9
  import { cdn } from './cdn.mjs';
8
10
  import { CRASHED, connect } from './cdp.mjs';
@@ -16,6 +18,7 @@ import { artifacts, bounded, ignore, sleep, slug, TIMED_OUT } from './util.mjs';
16
18
  export const flags = [
17
19
  '--headless',
18
20
  '--remote-debugging-port=0',
21
+ '--remote-debugging-pipe',
19
22
  '--no-first-run',
20
23
  '--no-default-browser-check',
21
24
  '--no-sandbox',
@@ -71,7 +74,10 @@ export async function start(
71
74
  executable = chromium.executablePath(),
72
75
  env = inherited
73
76
  ) {
74
- const child = spawn(executable, args, { env, stdio: ['ignore', 'ignore', 'pipe'] });
77
+ const child = spawn(executable, args, {
78
+ env,
79
+ stdio: ['ignore', 'ignore', 'pipe', 'pipe', 'pipe'],
80
+ });
75
81
  const stderr = lines(child.stderr);
76
82
  const drained = new Promise((resolve) => child.stderr.once('close', resolve));
77
83
  const exited = new Promise((resolve) => {
@@ -110,6 +116,89 @@ export async function stop(cdp, child) {
110
116
  }
111
117
  }
112
118
 
119
+ export const PREFIX = 'slicc-harness-';
120
+ export const DAY = 24 * 60 * 60 * 1000;
121
+ export const GRACE = 60 * 1000;
122
+
123
+ const signals = ['SIGINT', 'SIGTERM', 'SIGHUP'];
124
+ const running = new Set();
125
+
126
+ function kill(child) {
127
+ if (child.exitCode === null && child.signalCode === null) child.kill('SIGKILL');
128
+ }
129
+
130
+ function reap() {
131
+ for (const { children, paths } of running) {
132
+ for (const child of children) kill(child);
133
+ for (const path of paths) rmSync(path, { recursive: true, force: true });
134
+ }
135
+ running.clear();
136
+ unhook();
137
+ }
138
+
139
+ function reraise(signal) {
140
+ reap();
141
+ process.kill(process.pid, signal);
142
+ }
143
+
144
+ function hook() {
145
+ process.on('exit', reap);
146
+ for (const signal of signals) process.on(signal, reraise);
147
+ }
148
+
149
+ function unhook() {
150
+ process.off('exit', reap);
151
+ for (const signal of signals) process.off(signal, reraise);
152
+ }
153
+
154
+ export function watchdog(pid) {
155
+ const dog = spawn('sh', ['-c', 'read -r _; kill -9 "$0" 2>/dev/null', String(pid)], {
156
+ stdio: ['pipe', 'ignore', 'ignore'],
157
+ });
158
+ dog.on('error', ignore);
159
+ dog.stdin.on('error', ignore);
160
+ return dog;
161
+ }
162
+
163
+ export function track(child, paths) {
164
+ const dog = watchdog(child.pid);
165
+ const entry = { children: [child, dog], paths };
166
+ if (running.size === 0) hook();
167
+ running.add(entry);
168
+ return () => {
169
+ running.delete(entry);
170
+ if (running.size === 0) unhook();
171
+ kill(dog);
172
+ };
173
+ }
174
+
175
+ export async function commandLines() {
176
+ const { stdout } = await promisify(execFile)('ps', ['axww', '-o', 'args='], {
177
+ maxBuffer: 64 * 1024 * 1024,
178
+ });
179
+ return stdout.split('\n').map((line) => `${line} `);
180
+ }
181
+
182
+ export async function prune(dir = tmpdir(), grace = GRACE) {
183
+ const lines = await commandLines().catch(ignore);
184
+ const used = (path) => lines.some((line) => line.includes(`--user-data-dir=${path} `));
185
+ const names = await readdir(dir).catch(() => []);
186
+ const now = Date.now();
187
+ const removing = names
188
+ .filter((name) => name.startsWith(PREFIX))
189
+ .map(async (name) => {
190
+ const path = join(dir, name);
191
+ const info = await lstat(path).catch(ignore);
192
+ if (!info?.isDirectory()) return;
193
+ const age = now - info.mtimeMs;
194
+ const orphaned = lines !== null && age > grace && !used(path);
195
+ if (age > DAY || orphaned) await rm(path, { recursive: true, force: true }).catch(ignore);
196
+ });
197
+ await Promise.all(removing);
198
+ }
199
+
200
+ let pruning = null;
201
+
113
202
  export async function launch({
114
203
  roots,
115
204
  aliases,
@@ -122,12 +211,14 @@ export async function launch({
122
211
  timeout,
123
212
  stallAfter,
124
213
  } = {}) {
214
+ pruning ??= prune();
215
+ await pruning;
125
216
  const server = await serve({ roots, aliases, isolated });
126
217
  const found = await unpacked(extensions);
127
218
  const fromExtension = extensionSource(found);
128
219
  const files = { ...server, source: (href) => server.source(href) ?? fromExtension(href) };
129
220
  const shared = extensions.length > 0;
130
- const profile = await mkdtemp(join(tmpdir(), 'slicc-harness-'));
221
+ const profile = await mkdtemp(join(tmpdir(), PREFIX));
131
222
  const loaded = [...found.values()].map((root) => root.slice(0, -1));
132
223
  const dumps = await crashpad();
133
224
  const { child, url, exited, stderr } = await start(
@@ -136,6 +227,7 @@ export async function launch({
136
227
  chromium.executablePath(),
137
228
  dumps.env
138
229
  );
230
+ const untrack = track(child, [profile, dumps.dir]);
139
231
  const cdp = await connect(url, {
140
232
  exited,
141
233
  tail: () => stderr.tail().map((line) => `stderr: ${line}`),
@@ -189,6 +281,7 @@ export async function launch({
189
281
  return {
190
282
  url: server.url,
191
283
  pid: child.pid,
284
+ profile,
192
285
  cdn: remote.state,
193
286
  requests: server.requests,
194
287
  overrides: server.overrides,
@@ -223,6 +316,7 @@ export async function launch({
223
316
  async close() {
224
317
  await stop(cdp, child);
225
318
  await rm(profile, { recursive: true, force: true });
319
+ untrack();
226
320
  closed = true;
227
321
  if (open === 0) await dumps.remove();
228
322
  await server.close();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ai-ecoverse/slicc-shared-web",
3
- "version": "1.10.0",
3
+ "version": "1.10.1",
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",