@ai-ecoverse/slicc-shared-web 1.3.0 → 1.4.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
@@ -59,9 +59,11 @@ 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). `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
 
@@ -72,4 +74,4 @@ Why it works the way it does:
72
74
  - 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
75
  - `--disable-extensions --disable-component-extensions-with-background-pages` keeps a Hangouts service worker out of every run.
74
76
  - 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.
77
+ - 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) {
@@ -40,7 +76,7 @@ export function fresh() {
40
76
  return !window.stale;
41
77
  }
42
78
 
43
- export function page(cdp, sessionId, server, timeout = 30000) {
79
+ export function page(cdp, { sessionId, targetId }, server, timeout = 30000) {
44
80
  const send = (method, params) => cdp.send(method, params, sessionId);
45
81
  const errors = [];
46
82
  const responses = [];
@@ -99,6 +135,7 @@ export function page(cdp, sessionId, server, timeout = 30000) {
99
135
  goto: (path) => navigate(`location.assign(${JSON.stringify(new URL(path, server.url).href)})`),
100
136
  reload: () => navigate('location.reload()'),
101
137
  insert: (text) => send('Input.insertText', { text }),
138
+ close: () => cdp.send('Target.closeTarget', { targetId }),
102
139
  enter: () => press('Enter'),
103
140
  async type(text) {
104
141
  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
 
@@ -180,9 +177,11 @@ export function recorder(cdp, server, { coverage = ['/'], exits = new Map() } =
180
177
  url: 'about:blank',
181
178
  browserContextId,
182
179
  });
183
- const sessionId = await tab(targetId).session;
180
+ const sessionId = await bounded(tab(targetId).session, 'attach');
184
181
  tabs.delete(targetId);
185
- return sessionId;
182
+ if (sessionId === TIMED_OUT) throw new Error(`Target.createTarget: ${targetId} never attached`);
183
+ if (run && !run.context) run.context = sessions.get(sessionId)?.browserContextId;
184
+ return { sessionId, targetId };
186
185
  }
187
186
 
188
187
  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.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",