@ai-ecoverse/slicc-shared-web 1.3.0 → 1.4.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
@@ -59,17 +59,20 @@ test('boots', async (t) => {
59
59
  | `coverage` | URL prefixes whose `.js`/`.mjs` scripts count for coverage. `node_modules/` never does. A `.map` next to a file is used | `['/']` |
60
60
  | `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 |
61
61
  | `intercept` | Remote URL prefixes answered from a disk cache in `node_modules/.cache/` | none |
62
+ | `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
+ | `args` | Extra Chromium flags, such as `--host-resolver-rules` or `--ignore-certificate-errors` | none |
62
64
  | `timeout` | How long `page.until` polls, in ms | `30000` |
63
65
 
64
- `chrome.page(t)` opens a tab and returns the page API: `goto(path)`, `reload()`, `evaluate(fn, ...args)`, `until(fn, ...args)`, `press(key, ...modifiers)`, `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), `errors`, `responses` and `dir` (the test's artifact directory). `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`.
66
+ `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. `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`.
65
67
 
66
68
  Why it works the way it does:
67
69
 
68
70
  - Playwright detaches from shared workers, which resumes them early, so their coverage started late about half the time. Raw CDP keeps every worker paused until the profiler runs.
69
- - `debugger;` pauses in `beforeunload` but not in `pagehide` or `pageswap`, so a `beforeunload` hook, installed after `Page.enable`, takes the coverage of a page before it navigates. The pause only answers CDP when the page itself starts the navigation, so `goto` and `reload` go through `location`, never `Page.navigate` or `Page.reload`.
71
+ - `debugger;` pauses in `beforeunload` but not in `pagehide` or `pageswap`, so a `beforeunload` hook, installed after `Page.enable`, takes the coverage of a page before it navigates. The pause only answers CDP when the page itself starts the navigation, so `goto` and `reload` go through `location`, never `Page.navigate` or `Page.reload`. Navigating away from `about:blank` skips the coverage dump so the first real load is not held in `beforeunload` for a useless snapshot.
72
+ - `goto` used to return as soon as the document swapped (`window.stale` cleared), which is before `readyState` is `complete` and before module scripts run. About 1 load in 20 then looked blank to the next assertion. It now waits for a complete, non-blank document.
70
73
  - `takePreciseCoverage` resets the counters, so every snapshot is a delta, and all of them are kept and summed.
71
74
  - monocart's `add()` from parallel test processes loses data, so each test writes raw JSON and the teardown merges it once.
72
75
  - Coverage of a script served from `chrome.overrides` would be mapped onto the file on disk, so a path overridden during a test is left out of that test's coverage.
73
76
  - `--disable-extensions --disable-component-extensions-with-background-pages` keeps a Hangouts service worker out of every run.
74
77
  - The attach event can arrive before `Target.createTarget` returns, so new tabs wait on a slot keyed by target id.
75
- - 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.
78
+ - 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.
package/harness/cdp.mjs CHANGED
@@ -7,8 +7,18 @@ export async function connect(url) {
7
7
  const pending = new Map();
8
8
  const listeners = new Set();
9
9
  let id = 0;
10
+ const drop = (sessionId, reason) => {
11
+ for (const [key, call] of pending) {
12
+ if (call.sessionId !== sessionId) continue;
13
+ pending.delete(key);
14
+ call.reject(new Error(`${call.method}: ${reason}`));
15
+ }
16
+ };
10
17
  socket.onmessage = ({ data }) => {
11
18
  const message = JSON.parse(data);
19
+ if (message.method === 'Target.detachedFromTarget')
20
+ drop(message.params.sessionId, 'target detached');
21
+ if (message.method === 'Inspector.targetCrashed') drop(message.sessionId, 'target crashed');
12
22
  const call = pending.get(message.id);
13
23
  pending.delete(message.id);
14
24
  if (!call) for (const listener of listeners) listener(message);
@@ -21,7 +31,7 @@ export async function connect(url) {
21
31
  return {
22
32
  send: (method, params = {}, sessionId) =>
23
33
  new Promise((resolve, reject) => {
24
- pending.set(++id, { method, resolve, reject });
34
+ pending.set(++id, { method, sessionId, resolve, reject });
25
35
  socket.send(JSON.stringify({ id, method, params, sessionId }));
26
36
  }),
27
37
  on: (listener) => {
@@ -5,6 +5,7 @@ import { basename, join } from 'node:path';
5
5
  import { chromium } from 'playwright-core';
6
6
  import { cdn } from './cdn.mjs';
7
7
  import { connect } from './cdp.mjs';
8
+ import { extensionSource, unpacked } from './extensions.mjs';
8
9
  import { page } from './page.mjs';
9
10
  import { breakpoints, recorder } from './recorder.mjs';
10
11
  import { serve } from './server.mjs';
@@ -26,8 +27,20 @@ export const flags = [
26
27
  '--window-size=1280,800',
27
28
  ];
28
29
 
29
- export async function start(profile, executable = chromium.executablePath()) {
30
- const args = [...flags, `--user-data-dir=${profile}`, 'about:blank'];
30
+ export function commandLine(profile, extensions = [], extra = []) {
31
+ const loaded = extensions.length > 0;
32
+ const base = loaded ? flags.filter((flag) => flag !== '--disable-extensions') : flags;
33
+ const load = loaded
34
+ ? [`--disable-extensions-except=${extensions}`, `--load-extension=${extensions}`]
35
+ : [];
36
+ return [...base, ...load, ...extra, `--user-data-dir=${profile}`, 'about:blank'];
37
+ }
38
+
39
+ export async function start(
40
+ profile,
41
+ args = commandLine(profile),
42
+ executable = chromium.executablePath()
43
+ ) {
31
44
  const child = spawn(executable, args, { stdio: ['ignore', 'ignore', 'pipe'] });
32
45
  let log = '';
33
46
  const url = await new Promise((resolve, reject) => {
@@ -59,13 +72,20 @@ export async function launch({
59
72
  coverage,
60
73
  exits = {},
61
74
  intercept = [],
75
+ extensions = [],
76
+ args = [],
62
77
  timeout,
63
78
  } = {}) {
64
79
  const server = await serve({ roots, aliases, isolated });
80
+ const found = await unpacked(extensions);
81
+ const fromExtension = extensionSource(found);
82
+ const files = { ...server, source: (href) => server.source(href) ?? fromExtension(href) };
83
+ const shared = extensions.length > 0;
65
84
  const profile = await mkdtemp(join(tmpdir(), 'slicc-harness-'));
66
- const { child, url } = await start(profile);
85
+ const loaded = [...found.values()].map((root) => root.slice(0, -1));
86
+ const { child, url } = await start(profile, commandLine(profile, loaded, args));
67
87
  const cdp = await connect(url);
68
- const record = recorder(cdp, server, { coverage, exits: await breakpoints(server, exits) });
88
+ const record = recorder(cdp, files, { coverage, exits: await breakpoints(server, exits) });
69
89
  const remote = await cdn(cdp, intercept);
70
90
  await cdp.send('Target.setAutoAttach', {
71
91
  autoAttach: true,
@@ -73,7 +93,18 @@ export async function launch({
73
93
  flatten: true,
74
94
  });
75
95
 
76
- async function finish(pages, browserContextId, dir) {
96
+ async function tabs() {
97
+ const { targetInfos } = await cdp.send('Target.getTargets');
98
+ return targetInfos.filter(({ type }) => type === 'page').map(({ targetId }) => targetId);
99
+ }
100
+
101
+ async function sweep(before) {
102
+ const added = (await tabs()).filter((targetId) => !before.has(targetId));
103
+ const closing = added.map((targetId) => cdp.send('Target.closeTarget', { targetId }));
104
+ await Promise.all(closing.map((sent) => sent.catch(ignore)));
105
+ }
106
+
107
+ async function finish(pages, browserContextId, dir, before) {
77
108
  const shots = pages.map((opened, i) =>
78
109
  opened.screenshot(new URL(`tab-${i + 1}.png`, dir)).catch(ignore)
79
110
  );
@@ -81,7 +112,10 @@ export async function launch({
81
112
  await record.end();
82
113
  for (const opened of pages) opened.dispose();
83
114
  remote.reset();
84
- await bounded(cdp.send('Target.disposeBrowserContext', { browserContextId }), 'dispose');
115
+ const closing = shared
116
+ ? sweep(before)
117
+ : cdp.send('Target.disposeBrowserContext', { browserContextId });
118
+ await bounded(closing, 'dispose');
85
119
  }
86
120
 
87
121
  return {
@@ -93,13 +127,14 @@ export async function launch({
93
127
  const suite = slug(basename(t.filePath ?? 'test', '.test.mjs'));
94
128
  const dir = new URL(`${suite}/${slug(t.name)}/`, artifacts);
95
129
  await mkdir(dir, { recursive: true });
96
- const { browserContextId } = await cdp.send('Target.createBrowserContext');
130
+ const { browserContextId } = shared ? {} : await cdp.send('Target.createBrowserContext');
131
+ const before = new Set(shared ? await tabs() : []);
97
132
  record.begin(browserContextId, dir, `${suite}-${slug(t.name)}`);
98
133
  server.requests.length = 0;
99
134
  server.overrides.clear();
100
135
  server.overridden.clear();
101
136
  const pages = [];
102
- t.after(() => finish(pages, browserContextId, dir));
137
+ t.after(() => finish(pages, browserContextId, dir, before));
103
138
  const tab = async () => {
104
139
  const opened = page(cdp, await record.open(browserContextId), server, timeout);
105
140
  pages.push(opened);
@@ -0,0 +1,28 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { readFile, realpath } from 'node:fs/promises';
3
+ import { join, normalize, sep } from 'node:path';
4
+
5
+ export function extensionId(input) {
6
+ const hex = createHash('sha256').update(input).digest('hex').slice(0, 32);
7
+ return [...hex].map((digit) => String.fromCharCode(97 + Number.parseInt(digit, 16))).join('');
8
+ }
9
+
10
+ export async function unpacked(dirs) {
11
+ const found = new Map();
12
+ for (const dir of dirs) {
13
+ const root = await realpath(dir);
14
+ const manifest = JSON.parse(await readFile(join(root, 'manifest.json'), 'utf8'));
15
+ const id = extensionId(manifest.key ? Buffer.from(manifest.key, 'base64') : root);
16
+ found.set(id, `${root}${sep}`);
17
+ }
18
+ return found;
19
+ }
20
+
21
+ export function extensionSource(found) {
22
+ return (href) => {
23
+ if (!href.startsWith('chrome-extension://')) return null;
24
+ const { host, pathname } = new URL(href);
25
+ const root = found.get(host);
26
+ return root ? join(root, normalize(decodeURIComponent(pathname))) : null;
27
+ };
28
+ }
package/harness/page.mjs CHANGED
@@ -7,21 +7,57 @@ const keys = new Map([
7
7
  ['Tab', { code: 'Tab', keyCode: 9, text: '\t' }],
8
8
  ['Backspace', { code: 'Backspace', keyCode: 8 }],
9
9
  ['Escape', { code: 'Escape', keyCode: 27 }],
10
+ ['Delete', { code: 'Delete', keyCode: 46 }],
11
+ ['Insert', { code: 'Insert', keyCode: 45 }],
12
+ ['Home', { code: 'Home', keyCode: 36 }],
13
+ ['End', { code: 'End', keyCode: 35 }],
14
+ ['PageUp', { code: 'PageUp', keyCode: 33 }],
15
+ ['PageDown', { code: 'PageDown', keyCode: 34 }],
10
16
  ['ArrowUp', { code: 'ArrowUp', keyCode: 38 }],
11
17
  ['ArrowDown', { code: 'ArrowDown', keyCode: 40 }],
12
18
  ['ArrowLeft', { code: 'ArrowLeft', keyCode: 37 }],
13
19
  ['ArrowRight', { code: 'ArrowRight', keyCode: 39 }],
14
- [' ', { code: 'Space', keyCode: 32, text: ' ' }],
15
20
  ]);
21
+ const punctuation = new Map([
22
+ [' ', ['Space', 32]],
23
+ [';', ['Semicolon', 186]],
24
+ ['=', ['Equal', 187]],
25
+ [',', ['Comma', 188]],
26
+ ['-', ['Minus', 189]],
27
+ ['.', ['Period', 190]],
28
+ ['/', ['Slash', 191]],
29
+ ['`', ['Backquote', 192]],
30
+ ['[', ['BracketLeft', 219]],
31
+ ['\\', ['Backslash', 220]],
32
+ [']', ['BracketRight', 221]],
33
+ ["'", ['Quote', 222]],
34
+ ]);
35
+ const shifted = new Map(
36
+ [...'~!@#$%^&*()_+{}|:"<>?'].map((char, i) => [char, "`1234567890-=[]\\;',./"[i]])
37
+ );
38
+
39
+ function named(key) {
40
+ const known = keys.get(key);
41
+ if (known) return known;
42
+ const fn = /^F(\d{1,2})$/.exec(key);
43
+ return { code: key, keyCode: fn ? 111 + Number(fn[1]) : 0 };
44
+ }
45
+
46
+ export function character(char) {
47
+ const base = shifted.get(char) ?? char.toLowerCase();
48
+ const upper = base.toUpperCase();
49
+ if (/^[a-z]$/.test(base))
50
+ return { code: `Key${upper}`, keyCode: upper.charCodeAt(0), text: char };
51
+ if (/^\d$/.test(base)) return { code: `Digit${base}`, keyCode: base.charCodeAt(0), text: char };
52
+ const [code, keyCode] = punctuation.get(base) ?? ['', 0];
53
+ return { code, keyCode, text: char };
54
+ }
16
55
 
17
56
  export function keystroke(key, modifiers) {
18
57
  const bits = modifiers.reduce((sum, name) => sum | masks[name], 0);
19
- const named = keys.get(key);
20
- const code = named?.code ?? `Key${key.toUpperCase()}`;
21
- const keyCode = named?.keyCode ?? key.toUpperCase().charCodeAt(0);
22
- const plain = named ? named.text : key;
23
- const text = bits & ~masks.shift ? undefined : plain;
24
- return { key, code, windowsVirtualKeyCode: keyCode, modifiers: bits, text };
58
+ const { code, keyCode, text } = [...key].length === 1 ? character(key) : named(key);
59
+ const typed = bits & ~masks.shift ? undefined : text;
60
+ return { key, code, windowsVirtualKeyCode: keyCode, modifiers: bits, text: typed };
25
61
  }
26
62
 
27
63
  export function settle(ms = 1000) {
@@ -37,10 +73,18 @@ export function mark() {
37
73
  }
38
74
 
39
75
  export function fresh() {
40
- return !window.stale;
76
+ return !window.stale && document.readyState === 'complete' && location.href !== 'about:blank';
41
77
  }
42
78
 
43
- export function page(cdp, sessionId, server, timeout = 30000) {
79
+ const specials = { Infinity, '-Infinity': -Infinity, NaN, '-0': -0 };
80
+
81
+ export function decoded({ value, unserializableValue }) {
82
+ if (unserializableValue === undefined) return value;
83
+ if (unserializableValue.endsWith('n')) return BigInt(unserializableValue.slice(0, -1));
84
+ return specials[unserializableValue];
85
+ }
86
+
87
+ export function page(cdp, { sessionId, targetId }, server, timeout = 30000) {
44
88
  const send = (method, params) => cdp.send(method, params, sessionId);
45
89
  const errors = [];
46
90
  const responses = [];
@@ -63,18 +107,24 @@ export function page(cdp, sessionId, server, timeout = 30000) {
63
107
  returnByValue: true,
64
108
  });
65
109
  if (exceptionDetails) throw new Error(exceptionDetails.exception?.description);
66
- return result.value;
110
+ return decoded(result);
67
111
  }
68
112
 
69
113
  async function until(fn, ...args) {
70
114
  const deadline = Date.now() + timeout;
71
115
  let last;
72
116
  while (Date.now() < deadline) {
73
- last = await evaluate(fn, ...args).catch((error) => error.message);
74
- if (last === true) return;
117
+ try {
118
+ last = await evaluate(fn, ...args);
119
+ if (last) return last;
120
+ } catch (error) {
121
+ last = error.message;
122
+ }
75
123
  await sleep(25);
76
124
  }
77
- throw new Error(`Timed out waiting for ${fn}\nlast result: ${JSON.stringify(last)}`);
125
+ throw new Error(
126
+ `Timed out waiting for a truthy result from ${fn}\nlast result: ${JSON.stringify(last)}`
127
+ );
78
128
  }
79
129
 
80
130
  async function press(key, ...modifiers) {
@@ -84,9 +134,16 @@ export function page(cdp, sessionId, server, timeout = 30000) {
84
134
  }
85
135
 
86
136
  async function navigate(expression) {
87
- await evaluate(mark);
88
- await send('Runtime.evaluate', { expression });
89
- await until(fresh);
137
+ for (let attempt = 0; attempt < 2; attempt++) {
138
+ await evaluate(mark);
139
+ await send('Runtime.evaluate', { expression });
140
+ try {
141
+ await until(fresh);
142
+ return;
143
+ } catch (error) {
144
+ if (attempt === 1) throw error;
145
+ }
146
+ }
90
147
  }
91
148
 
92
149
  return {
@@ -99,6 +156,7 @@ export function page(cdp, sessionId, server, timeout = 30000) {
99
156
  goto: (path) => navigate(`location.assign(${JSON.stringify(new URL(path, server.url).href)})`),
100
157
  reload: () => navigate('location.reload()'),
101
158
  insert: (text) => send('Input.insertText', { text }),
159
+ close: () => cdp.send('Target.closeTarget', { targetId }),
102
160
  enter: () => press('Enter'),
103
161
  async type(text) {
104
162
  for (const key of text) await press(key);
@@ -2,7 +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 { ignore, raw, samples, sleep } from './util.mjs';
5
+ import { bounded, ignore, raw, samples, sleep, TIMED_OUT } from './util.mjs';
6
6
 
7
7
  const profiled = new Set(['page', 'worker', 'service_worker', 'shared_worker']);
8
8
  const nested = { autoAttach: true, waitForDebuggerOnStart: true, flatten: true };
@@ -32,13 +32,10 @@ export async function breakpoints(server, exits) {
32
32
 
33
33
  export function covered(server, prefixes) {
34
34
  return ({ url }) => {
35
- if (!url.startsWith(server.url)) return false;
36
- const { pathname } = new URL(url);
37
- return (
38
- /\.m?js$/.test(pathname) &&
39
- !pathname.includes('/node_modules/') &&
40
- prefixes.some((prefix) => pathname.startsWith(prefix))
41
- );
35
+ if (!server.source(url)) return false;
36
+ const { pathname, protocol } = new URL(url);
37
+ const ours = protocol === 'chrome-extension:' || prefixes.some((p) => pathname.startsWith(p));
38
+ return ours && /\.m?js$/.test(pathname) && !pathname.includes('/node_modules/');
42
39
  };
43
40
  }
44
41
 
@@ -157,7 +154,12 @@ export function recorder(cdp, server, { coverage = ['/'], exits = new Map() } =
157
154
  async function paused(sessionId) {
158
155
  const target = sessions.get(sessionId);
159
156
  if (target?.type === 'worker') await dump(sessionId, target, false);
160
- else await checkpoint();
157
+ else {
158
+ const { result } = await cdp
159
+ .send('Runtime.evaluate', { expression: 'location.href', returnByValue: true }, sessionId)
160
+ .catch(() => ({ result: {} }));
161
+ if (result?.value && result.value !== 'about:blank') await checkpoint();
162
+ }
161
163
  await cdp.send('Debugger.resume', {}, sessionId).catch(ignore);
162
164
  }
163
165
 
@@ -180,9 +182,11 @@ export function recorder(cdp, server, { coverage = ['/'], exits = new Map() } =
180
182
  url: 'about:blank',
181
183
  browserContextId,
182
184
  });
183
- const sessionId = await tab(targetId).session;
185
+ const sessionId = await bounded(tab(targetId).session, 'attach');
184
186
  tabs.delete(targetId);
185
- return sessionId;
187
+ if (sessionId === TIMED_OUT) throw new Error(`Target.createTarget: ${targetId} never attached`);
188
+ if (run && !run.context) run.context = sessions.get(sessionId)?.browserContextId;
189
+ return { sessionId, targetId };
186
190
  }
187
191
 
188
192
  function begin(context, dir, name) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ai-ecoverse/slicc-shared-web",
3
- "version": "1.3.0",
3
+ "version": "1.4.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",