@ai-ecoverse/slicc-shared-web 1.2.1 → 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
@@ -11,6 +11,7 @@ Configuration shared by SLICC's web repos ([slicc-bios](https://github.com/ai-ec
11
11
  | `lefthook.yml` | Pre-commit hooks: Biome, typecheck, no comments, no unit tests in git, AGENTS.md, diff coverage | `lefthook.yml`: `extends: [node_modules/@ai-ecoverse/slicc-shared-web/lefthook.yml]` |
12
12
  | `mcr.config.mjs` | Unit-test coverage for JS and TS sources in `src/` (TS types are stripped first), written to `coverage/unit/lcov.info` | `test:unit`: `mcr -c node_modules/@ai-ecoverse/slicc-shared-web/mcr.config.mjs node --test 'test/unit/**/*.test.mjs'` |
13
13
  | `bin/` | `slicc-lint` runs every check below plus Biome and, if the repo has one, `typecheck`. `slicc-lint-comments` runs the [slicc no-comment checker](https://github.com/ai-ecoverse/slicc/tree/main/packages/dev-tools/no-comment) at a pinned commit, allowing AGENTS.md. `slicc-agents-md`, `slicc-no-unit-tests` and `slicc-diff-cover` complete the set | `lint`: `slicc-lint`, and the hooks above |
14
+ | `harness/` | The CDP integration-test harness, described [below](#integration-test-harness) | `import { launch } from '@ai-ecoverse/slicc-shared-web/harness'`, and `--test-global-setup=node_modules/@ai-ecoverse/slicc-shared-web/harness/global.mjs` |
14
15
 
15
16
  Install it from npm, and pin the reusable workflow to the matching `vX.Y.Z` tag. Renovate keeps both current without the usual waiting period:
16
17
 
@@ -26,3 +27,51 @@ The hooks expect two scripts in each repo: `test:unit`, which writes `coverage/u
26
27
  - Agent guidance committed to a repo lives in AGENTS.md only, at most 1,000 characters per file. The repo has no CLAUDE.md, not even as a symlink, since Claude reads AGENTS.md. The check covers tracked and untracked files; gitignored local files are personal and stay out of it.
27
28
  - Unit tests stay out of git, in a gitignored `test/unit/`. The pre-commit hook requires them to execute every staged line in `src/`.
28
29
  - Integration tests are committed and run in CI.
30
+
31
+ ## Integration-test harness
32
+
33
+ `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
+
35
+ `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:
36
+
37
+ ```js
38
+ import { launch } from '@ai-ecoverse/slicc-shared-web/harness';
39
+
40
+ const chrome = await launch({
41
+ roots: [['/dist/', 'dist/'], ['/', 'test/integration/page/']],
42
+ isolated: true,
43
+ coverage: ['/dist/'],
44
+ });
45
+ after(() => chrome.close());
46
+
47
+ test('boots', async (t) => {
48
+ const page = await chrome.page(t);
49
+ await page.goto('/');
50
+ await page.until(() => document.title === 'ready');
51
+ });
52
+ ```
53
+
54
+ | `launch` option | Meaning | Default |
55
+ |---|---|---|
56
+ | `roots` | `[urlPrefix, directory]` pairs the local server serves, first match wins | `[['/', 'src/']]` |
57
+ | `aliases` | Extra `[urlPrefix, directory]` pairs used only to map coverage and profiles back to files, for scripts that something else serves, such as a service worker | none |
58
+ | `isolated` | Send COOP, COEP and CORP on every response, so pages are cross-origin isolated | `false` |
59
+ | `coverage` | URL prefixes whose `.js`/`.mjs` scripts count for coverage. `node_modules/` never does. A `.map` next to a file is used | `['/']` |
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
+ | `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 |
64
+ | `timeout` | How long `page.until` polls, in ms | `30000` |
65
+
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`.
67
+
68
+ Why it works the way it does:
69
+
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.
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`.
72
+ - `takePreciseCoverage` resets the counters, so every snapshot is a delta, and all of them are kept and summed.
73
+ - monocart's `add()` from parallel test processes loses data, so each test writes raw JSON and the teardown merges it once.
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.
75
+ - `--disable-extensions --disable-component-extensions-with-background-pages` keeps a Hangouts service worker out of every run.
76
+ - The attach event can arrive before `Target.createTarget` returns, so new tabs wait on a slot keyed by target id.
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.
@@ -0,0 +1,63 @@
1
+ import { mkdir, readFile, rename, writeFile } from 'node:fs/promises';
2
+ import { dirname } from 'node:path';
3
+ import { fileURLToPath } from 'node:url';
4
+ import { type } from './server.mjs';
5
+ import { cache, ignore } from './util.mjs';
6
+
7
+ const cors = [{ name: 'access-control-allow-origin', value: '*' }];
8
+ const store = new URL('cdn/', cache);
9
+
10
+ export async function download(url) {
11
+ const { host, pathname, search } = new URL(url);
12
+ const query = search ? encodeURIComponent(search) : '';
13
+ const file = new URL(`./${host}${pathname}${query}`, store);
14
+ const hit = await readFile(file).catch(ignore);
15
+ if (hit) return hit;
16
+ const response = await fetch(url);
17
+ if (!response.ok) throw new Error(`${response.status} ${url}`);
18
+ const body = Buffer.from(await response.arrayBuffer());
19
+ await mkdir(dirname(fileURLToPath(file)), { recursive: true });
20
+ const partial = new URL(`${file.href}.${process.pid}`);
21
+ await writeFile(partial, body);
22
+ await rename(partial, file);
23
+ return body;
24
+ }
25
+
26
+ export async function cdn(cdp, remotes) {
27
+ const state = { status: 0, corrupt: false, requests: [] };
28
+ const reset = () => {
29
+ state.status = 0;
30
+ state.corrupt = false;
31
+ state.requests.length = 0;
32
+ };
33
+
34
+ async function fulfil({ requestId, request }) {
35
+ state.requests.push(request.url);
36
+ if (state.status) {
37
+ const failure = { requestId, responseCode: state.status, responseHeaders: cors };
38
+ return cdp.send('Fetch.fulfillRequest', failure);
39
+ }
40
+ const body = await download(request.url).catch(ignore);
41
+ if (!body) return cdp.send('Fetch.failRequest', { requestId, errorReason: 'Failed' });
42
+ const sent = Buffer.from(body);
43
+ if (state.corrupt) sent[sent.length - 1] ^= 0xff;
44
+ return cdp.send('Fetch.fulfillRequest', {
45
+ requestId,
46
+ responseCode: 200,
47
+ responseHeaders: [
48
+ ...cors,
49
+ { name: 'content-type', value: type(new URL(request.url).pathname) },
50
+ ],
51
+ body: sent.toString('base64'),
52
+ });
53
+ }
54
+
55
+ if (remotes.length > 0) {
56
+ cdp.on(async ({ method, params }) => {
57
+ if (method === 'Fetch.requestPaused') await fulfil(params).catch(ignore);
58
+ });
59
+ const patterns = remotes.map((remote) => ({ urlPattern: `${remote}*` }));
60
+ await cdp.send('Fetch.enable', { patterns });
61
+ }
62
+ return { state, reset };
63
+ }
@@ -0,0 +1,43 @@
1
+ export async function connect(url) {
2
+ const socket = new WebSocket(url);
3
+ await new Promise((resolve, reject) => {
4
+ socket.onopen = resolve;
5
+ socket.onerror = reject;
6
+ });
7
+ const pending = new Map();
8
+ const listeners = new Set();
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
+ };
17
+ socket.onmessage = ({ data }) => {
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');
22
+ const call = pending.get(message.id);
23
+ pending.delete(message.id);
24
+ if (!call) for (const listener of listeners) listener(message);
25
+ else if (message.error) call.reject(new Error(`${call.method}: ${message.error.message}`));
26
+ else call.resolve(message.result);
27
+ };
28
+ socket.onclose = () => {
29
+ for (const call of pending.values()) call.reject(new Error(`${call.method}: socket closed`));
30
+ };
31
+ return {
32
+ send: (method, params = {}, sessionId) =>
33
+ new Promise((resolve, reject) => {
34
+ pending.set(++id, { method, sessionId, resolve, reject });
35
+ socket.send(JSON.stringify({ id, method, params, sessionId }));
36
+ }),
37
+ on: (listener) => {
38
+ listeners.add(listener);
39
+ return () => listeners.delete(listener);
40
+ },
41
+ close: () => socket.close(),
42
+ };
43
+ }
@@ -0,0 +1,151 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { mkdir, mkdtemp, rm } from 'node:fs/promises';
3
+ import { tmpdir } from 'node:os';
4
+ import { basename, join } from 'node:path';
5
+ import { chromium } from 'playwright-core';
6
+ import { cdn } from './cdn.mjs';
7
+ import { connect } from './cdp.mjs';
8
+ import { extensionSource, unpacked } from './extensions.mjs';
9
+ import { page } from './page.mjs';
10
+ import { breakpoints, recorder } from './recorder.mjs';
11
+ import { serve } from './server.mjs';
12
+ import { artifacts, bounded, ignore, slug, TIMED_OUT } from './util.mjs';
13
+
14
+ export const flags = [
15
+ '--headless',
16
+ '--remote-debugging-port=0',
17
+ '--no-first-run',
18
+ '--no-default-browser-check',
19
+ '--no-sandbox',
20
+ '--disable-extensions',
21
+ '--disable-component-extensions-with-background-pages',
22
+ '--disable-background-timer-throttling',
23
+ '--disable-backgrounding-occluded-windows',
24
+ '--disable-renderer-backgrounding',
25
+ '--hide-scrollbars',
26
+ '--mute-audio',
27
+ '--window-size=1280,800',
28
+ ];
29
+
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
+ ) {
44
+ const child = spawn(executable, args, { stdio: ['ignore', 'ignore', 'pipe'] });
45
+ let log = '';
46
+ 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+)/);
50
+ if (match) resolve(match[1]);
51
+ });
52
+ child.once('error', reject);
53
+ child.once('exit', (code) => reject(new Error(`Chromium exited with ${code}\n${log}`)));
54
+ });
55
+ return { child, url };
56
+ }
57
+
58
+ export async function stop(cdp, child) {
59
+ const exited = new Promise((resolve) => child.once('exit', resolve));
60
+ await bounded(cdp.send('Browser.close').catch(ignore), 'Browser.close');
61
+ cdp.close();
62
+ if (child.exitCode === null && (await bounded(exited, 'exit')) === TIMED_OUT) {
63
+ child.kill('SIGKILL');
64
+ await exited;
65
+ }
66
+ }
67
+
68
+ export async function launch({
69
+ roots,
70
+ aliases,
71
+ isolated,
72
+ coverage,
73
+ exits = {},
74
+ intercept = [],
75
+ extensions = [],
76
+ args = [],
77
+ timeout,
78
+ } = {}) {
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;
84
+ const profile = await mkdtemp(join(tmpdir(), 'slicc-harness-'));
85
+ const loaded = [...found.values()].map((root) => root.slice(0, -1));
86
+ const { child, url } = await start(profile, commandLine(profile, loaded, args));
87
+ const cdp = await connect(url);
88
+ const record = recorder(cdp, files, { coverage, exits: await breakpoints(server, exits) });
89
+ const remote = await cdn(cdp, intercept);
90
+ await cdp.send('Target.setAutoAttach', {
91
+ autoAttach: true,
92
+ waitForDebuggerOnStart: true,
93
+ flatten: true,
94
+ });
95
+
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) {
108
+ const shots = pages.map((opened, i) =>
109
+ opened.screenshot(new URL(`tab-${i + 1}.png`, dir)).catch(ignore)
110
+ );
111
+ await Promise.all(shots);
112
+ await record.end();
113
+ for (const opened of pages) opened.dispose();
114
+ remote.reset();
115
+ const closing = shared
116
+ ? sweep(before)
117
+ : cdp.send('Target.disposeBrowserContext', { browserContextId });
118
+ await bounded(closing, 'dispose');
119
+ }
120
+
121
+ return {
122
+ url: server.url,
123
+ cdn: remote.state,
124
+ requests: server.requests,
125
+ overrides: server.overrides,
126
+ async page(t) {
127
+ const suite = slug(basename(t.filePath ?? 'test', '.test.mjs'));
128
+ const dir = new URL(`${suite}/${slug(t.name)}/`, artifacts);
129
+ await mkdir(dir, { recursive: true });
130
+ const { browserContextId } = shared ? {} : await cdp.send('Target.createBrowserContext');
131
+ const before = new Set(shared ? await tabs() : []);
132
+ record.begin(browserContextId, dir, `${suite}-${slug(t.name)}`);
133
+ server.requests.length = 0;
134
+ server.overrides.clear();
135
+ server.overridden.clear();
136
+ const pages = [];
137
+ t.after(() => finish(pages, browserContextId, dir, before));
138
+ const tab = async () => {
139
+ const opened = page(cdp, await record.open(browserContextId), server, timeout);
140
+ pages.push(opened);
141
+ return Object.assign(opened, { tab, dir });
142
+ };
143
+ return tab();
144
+ },
145
+ async close() {
146
+ await stop(cdp, child);
147
+ await rm(profile, { recursive: true, force: true });
148
+ await server.close();
149
+ },
150
+ };
151
+ }
@@ -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
+ }
@@ -0,0 +1,72 @@
1
+ import { mkdir, readdir, readFile, rm, writeFile } from 'node:fs/promises';
2
+ import { basename } from 'node:path';
3
+ import { cwd } from 'node:process';
4
+ import { CoverageReport } from 'monocart-coverage-reports';
5
+ import { artifacts, raw, samples } from './util.mjs';
6
+
7
+ async function name() {
8
+ const manifest = await readFile('package.json', 'utf8').catch(() => '{}');
9
+ return JSON.parse(manifest).name ?? basename(cwd());
10
+ }
11
+
12
+ async function load(dir) {
13
+ const files = await readdir(dir).catch(() => []);
14
+ const parsed = files
15
+ .sort()
16
+ .map(async (file) => JSON.parse(await readFile(new URL(file, dir), 'utf8')));
17
+ return Promise.all(parsed);
18
+ }
19
+
20
+ export async function report() {
21
+ const merged = new CoverageReport({
22
+ name: await name(),
23
+ outputDir: 'coverage',
24
+ reports: ['console-details', 'lcovonly', 'v8'],
25
+ logging: 'error',
26
+ all: { dir: 'src', filter: '**/*.js' },
27
+ sourceFilter: (path) => path.startsWith('src/'),
28
+ });
29
+ for (const entries of await load(raw)) {
30
+ if (entries.length > 0) await merged.add(entries);
31
+ }
32
+ await merged.generate();
33
+ }
34
+
35
+ export function table(runs) {
36
+ const self = new Map();
37
+ let profiles = 0;
38
+ for (const run of runs) {
39
+ profiles += run.profiles;
40
+ for (const [frame, micros] of Object.entries(run.self)) {
41
+ self.set(frame, (self.get(frame) ?? 0) + micros);
42
+ }
43
+ }
44
+ const top = [...self].sort((a, b) => b[1] - a[1]).slice(0, 15);
45
+ const rows = top.map(([frame, micros]) => `| ${(micros / 1000).toFixed(2)} ms | \`${frame}\` |`);
46
+ return [
47
+ `### Hotspots: self time in our scripts across ${profiles} CPU profiles`,
48
+ '',
49
+ '| self | frame |',
50
+ '| ---: | :--- |',
51
+ ...rows,
52
+ '',
53
+ ].join('\n');
54
+ }
55
+
56
+ export async function hotspots() {
57
+ const markdown = table(await load(samples));
58
+ await mkdir(artifacts, { recursive: true });
59
+ await writeFile(new URL('hotspots.md', artifacts), markdown);
60
+ console.log(markdown);
61
+ }
62
+
63
+ export async function globalSetup() {
64
+ await rm(artifacts, { recursive: true, force: true });
65
+ await rm(raw, { recursive: true, force: true });
66
+ await rm(samples, { recursive: true, force: true });
67
+ }
68
+
69
+ export async function globalTeardown() {
70
+ await report();
71
+ await hotspots();
72
+ }
@@ -0,0 +1,4 @@
1
+ export { connect } from './cdp.mjs';
2
+ export { launch } from './chrome.mjs';
3
+ export { isolation, serve } from './server.mjs';
4
+ export { slug } from './util.mjs';
@@ -0,0 +1,154 @@
1
+ import { writeFile } from 'node:fs/promises';
2
+ import { sleep } from './util.mjs';
3
+
4
+ const masks = { alt: 1, ctrl: 2, meta: 4, shift: 8 };
5
+ const keys = new Map([
6
+ ['Enter', { code: 'Enter', keyCode: 13, text: '\r' }],
7
+ ['Tab', { code: 'Tab', keyCode: 9, text: '\t' }],
8
+ ['Backspace', { code: 'Backspace', keyCode: 8 }],
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 }],
16
+ ['ArrowUp', { code: 'ArrowUp', keyCode: 38 }],
17
+ ['ArrowDown', { code: 'ArrowDown', keyCode: 40 }],
18
+ ['ArrowLeft', { code: 'ArrowLeft', keyCode: 37 }],
19
+ ['ArrowRight', { code: 'ArrowRight', keyCode: 39 }],
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
+ }
55
+
56
+ export function keystroke(key, modifiers) {
57
+ const bits = modifiers.reduce((sum, name) => sum | masks[name], 0);
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 };
61
+ }
62
+
63
+ export function settle(ms = 1000) {
64
+ const finite = document
65
+ .getAnimations()
66
+ .filter((animation) => animation.effect?.getComputedTiming().endTime < Infinity);
67
+ const done = Promise.all(finite.map((animation) => animation.finished.catch(() => null)));
68
+ return Promise.race([done, new Promise((resolve) => setTimeout(resolve, ms))]);
69
+ }
70
+
71
+ export function mark() {
72
+ window.stale = true;
73
+ }
74
+
75
+ export function fresh() {
76
+ return !window.stale;
77
+ }
78
+
79
+ export function page(cdp, { sessionId, targetId }, server, timeout = 30000) {
80
+ const send = (method, params) => cdp.send(method, params, sessionId);
81
+ const errors = [];
82
+ const responses = [];
83
+ const bindings = new Map();
84
+ const dispose = cdp.on(({ method, params, sessionId: from }) => {
85
+ if (from !== sessionId) return;
86
+ if (method === 'Runtime.exceptionThrown') {
87
+ const { exception, text } = params.exceptionDetails;
88
+ errors.push(exception?.description ?? text);
89
+ }
90
+ if (method === 'Runtime.bindingCalled') bindings.get(params.name)?.(JSON.parse(params.payload));
91
+ if (method === 'Network.responseReceived') responses.push(params.response);
92
+ });
93
+
94
+ async function evaluate(fn, ...args) {
95
+ const expression = `(${fn})(...${JSON.stringify(args)})`;
96
+ const { result, exceptionDetails } = await send('Runtime.evaluate', {
97
+ expression,
98
+ awaitPromise: true,
99
+ returnByValue: true,
100
+ });
101
+ if (exceptionDetails) throw new Error(exceptionDetails.exception?.description);
102
+ return result.value;
103
+ }
104
+
105
+ async function until(fn, ...args) {
106
+ const deadline = Date.now() + timeout;
107
+ let last;
108
+ while (Date.now() < deadline) {
109
+ last = await evaluate(fn, ...args).catch((error) => error.message);
110
+ if (last === true) return;
111
+ await sleep(25);
112
+ }
113
+ throw new Error(`Timed out waiting for ${fn}\nlast result: ${JSON.stringify(last)}`);
114
+ }
115
+
116
+ async function press(key, ...modifiers) {
117
+ const event = keystroke(key, modifiers);
118
+ await send('Input.dispatchKeyEvent', { ...event, type: event.text ? 'keyDown' : 'rawKeyDown' });
119
+ await send('Input.dispatchKeyEvent', { ...event, type: 'keyUp', text: undefined });
120
+ }
121
+
122
+ async function navigate(expression) {
123
+ await evaluate(mark);
124
+ await send('Runtime.evaluate', { expression });
125
+ await until(fresh);
126
+ }
127
+
128
+ return {
129
+ errors,
130
+ responses,
131
+ dispose,
132
+ evaluate,
133
+ until,
134
+ press,
135
+ goto: (path) => navigate(`location.assign(${JSON.stringify(new URL(path, server.url).href)})`),
136
+ reload: () => navigate('location.reload()'),
137
+ insert: (text) => send('Input.insertText', { text }),
138
+ close: () => cdp.send('Target.closeTarget', { targetId }),
139
+ enter: () => press('Enter'),
140
+ async type(text) {
141
+ for (const key of text) await press(key);
142
+ },
143
+ init: (fn) => send('Page.addScriptToEvaluateOnNewDocument', { source: `(${fn})()` }),
144
+ async expose(name, handler) {
145
+ bindings.set(name, handler);
146
+ await send('Runtime.addBinding', { name });
147
+ },
148
+ async screenshot(file) {
149
+ await evaluate(settle);
150
+ const { data } = await send('Page.captureScreenshot', { format: 'png' });
151
+ await writeFile(file, Buffer.from(data, 'base64'));
152
+ },
153
+ };
154
+ }
@@ -0,0 +1,210 @@
1
+ import { mkdir, readFile, writeFile } from 'node:fs/promises';
2
+ import { basename, relative } from 'node:path';
3
+ import { cwd } from 'node:process';
4
+ import { pathToFileURL } from 'node:url';
5
+ import { bounded, ignore, raw, samples, sleep, TIMED_OUT } from './util.mjs';
6
+
7
+ const profiled = new Set(['page', 'worker', 'service_worker', 'shared_worker']);
8
+ const nested = { autoAttach: true, waitForDebuggerOnStart: true, flatten: true };
9
+ const unload = "addEventListener('beforeunload', () => { debugger; })";
10
+
11
+ function pathOf(url) {
12
+ return URL.canParse(url) ? new URL(url).pathname : '';
13
+ }
14
+
15
+ export function label(target) {
16
+ return target.type === 'page' ? 'page' : basename(pathOf(target.url), '.js') || target.type;
17
+ }
18
+
19
+ export async function breakpoints(server, exits) {
20
+ const found = new Map();
21
+ for (const [path, patterns] of Object.entries(exits)) {
22
+ const file = server.locate(path);
23
+ const text = file ? await readFile(file, 'utf8').catch(() => '') : '';
24
+ const lines = text.split('\n');
25
+ const numbers = patterns
26
+ .map((pattern) => lines.findIndex((line) => pattern.test(line)))
27
+ .filter((n) => n >= 0);
28
+ found.set(path, numbers);
29
+ }
30
+ return found;
31
+ }
32
+
33
+ export function covered(server, prefixes) {
34
+ return ({ url }) => {
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/');
39
+ };
40
+ }
41
+
42
+ export function selfTimes(profile, server, into) {
43
+ const frames = new Map(profile.nodes.map((node) => [node.id, node.callFrame]));
44
+ profile.samples.forEach((id, i) => {
45
+ const { url, functionName, lineNumber, columnNumber } = frames.get(id);
46
+ const file = url.includes('/node_modules/') ? null : server.source(url);
47
+ if (!file) return;
48
+ const top = lineNumber === 0 && columnNumber === 0 ? '(top level)' : '(anonymous)';
49
+ const key = `${relative(cwd(), file)}:${lineNumber + 1} ${functionName || top}`;
50
+ into[key] = (into[key] ?? 0) + (profile.timeDeltas[i + 1] ?? 0);
51
+ });
52
+ return into;
53
+ }
54
+
55
+ async function entry(server, script) {
56
+ const file = server.source(script.url);
57
+ const source = await readFile(file, 'utf8').catch(ignore);
58
+ if (source === null || server.overridden.has(new URL(script.url).pathname)) return null;
59
+ const map = await readFile(`${file}.map`, 'utf8').catch(ignore);
60
+ return {
61
+ ...script,
62
+ url: pathToFileURL(file).href,
63
+ source,
64
+ ...(map ? { sourceMap: JSON.parse(map) } : {}),
65
+ };
66
+ }
67
+
68
+ export function recorder(cdp, server, { coverage = ['/'], exits = new Map() } = {}) {
69
+ const sessions = new Map();
70
+ const tabs = new Map();
71
+ const ours = covered(server, coverage);
72
+ let run = null;
73
+
74
+ async function dump(sessionId, target, restart) {
75
+ const current = run;
76
+ const send = (method) => cdp.send(method, {}, sessionId);
77
+ const taken = Promise.all([send('Profiler.takePreciseCoverage'), send('Profiler.stop')]);
78
+ const [{ result }, { profile }] = await Promise.race([taken, sleep(3000)]).then(
79
+ (answer) => answer ?? [{}, {}],
80
+ () => [{}, {}]
81
+ );
82
+ if (!(result && current)) return;
83
+ if (restart) send('Profiler.start').catch(ignore);
84
+ current.scripts.push(...result.filter(ours));
85
+ current.profiles += 1;
86
+ selfTimes(profile, server, current.self);
87
+ const name = `${String(current.profiles).padStart(3, '0')}-${label(target)}.cpuprofile`;
88
+ await writeFile(new URL(name, current.dir), JSON.stringify(profile));
89
+ }
90
+
91
+ async function checkpoint() {
92
+ const current = [...sessions].filter(([, target]) => target.browserContextId === run?.context);
93
+ await Promise.all(current.map(([sessionId, target]) => dump(sessionId, target, true)));
94
+ }
95
+
96
+ function tab(targetId) {
97
+ if (!tabs.has(targetId)) {
98
+ const slot = {};
99
+ slot.session = new Promise((resolve) => {
100
+ slot.resolve = resolve;
101
+ });
102
+ tabs.set(targetId, slot);
103
+ }
104
+ return tabs.get(targetId);
105
+ }
106
+
107
+ function commands({ type, url }) {
108
+ const list = [];
109
+ if (profiled.has(type)) {
110
+ list.push(
111
+ ['Runtime.enable'],
112
+ ['Profiler.enable'],
113
+ ['Profiler.setSamplingInterval', { interval: 100 }],
114
+ ['Profiler.startPreciseCoverage', { callCount: true, detailed: true }],
115
+ ['Profiler.start'],
116
+ ['Target.setAutoAttach', nested]
117
+ );
118
+ }
119
+ const lines = type === 'worker' ? exits.get(pathOf(url)) : undefined;
120
+ if (lines) {
121
+ list.push(
122
+ ['Debugger.enable'],
123
+ ...lines.map((lineNumber) => ['Debugger.setBreakpointByUrl', { url, lineNumber }])
124
+ );
125
+ }
126
+ if (type === 'page') {
127
+ list.push(
128
+ ['Network.enable'],
129
+ ['Debugger.enable'],
130
+ ['Page.enable'],
131
+ ['Page.addScriptToEvaluateOnNewDocument', { source: unload }]
132
+ );
133
+ }
134
+ return list;
135
+ }
136
+
137
+ function attach({ sessionId, targetInfo, waitingForDebugger }) {
138
+ if (profiled.has(targetInfo.type)) sessions.set(sessionId, targetInfo);
139
+ const list = commands(targetInfo);
140
+ if (waitingForDebugger) list.push(['Runtime.runIfWaitingForDebugger']);
141
+ const ready = Promise.all(
142
+ list.map(([method, params]) => cdp.send(method, params, sessionId).catch(ignore))
143
+ );
144
+ if (targetInfo.type === 'page') tab(targetInfo.targetId).resolve(ready.then(() => sessionId));
145
+ return ready;
146
+ }
147
+
148
+ function log(sessionId, line) {
149
+ const target = sessions.get(sessionId);
150
+ if (!run || target?.browserContextId !== run.context) return;
151
+ run.console.push(`${label(target)}: ${line}`);
152
+ }
153
+
154
+ async function paused(sessionId) {
155
+ const target = sessions.get(sessionId);
156
+ if (target?.type === 'worker') await dump(sessionId, target, false);
157
+ else await checkpoint();
158
+ await cdp.send('Debugger.resume', {}, sessionId).catch(ignore);
159
+ }
160
+
161
+ cdp.on(async ({ method, params, sessionId }) => {
162
+ if (method === 'Target.attachedToTarget') await attach(params);
163
+ if (method === 'Target.detachedFromTarget') sessions.delete(params.sessionId);
164
+ if (method === 'Runtime.consoleAPICalled') {
165
+ const text = params.args.map((arg) => arg.value ?? arg.description).join(' ');
166
+ log(sessionId, `${params.type} ${text}`);
167
+ }
168
+ if (method === 'Runtime.exceptionThrown') {
169
+ const { exception, text } = params.exceptionDetails;
170
+ log(sessionId, `uncaught ${exception?.description ?? text}`);
171
+ }
172
+ if (method === 'Debugger.paused') await paused(sessionId);
173
+ });
174
+
175
+ async function open(browserContextId) {
176
+ const { targetId } = await cdp.send('Target.createTarget', {
177
+ url: 'about:blank',
178
+ browserContextId,
179
+ });
180
+ const sessionId = await bounded(tab(targetId).session, 'attach');
181
+ tabs.delete(targetId);
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 };
185
+ }
186
+
187
+ function begin(context, dir, name) {
188
+ run = { context, dir, name, scripts: [], console: [], profiles: 0, self: {} };
189
+ }
190
+
191
+ async function end() {
192
+ await checkpoint();
193
+ const current = run;
194
+ run = null;
195
+ const entries = await Promise.all(current.scripts.map((script) => entry(server, script)));
196
+ const found = { profiles: current.profiles, self: current.self };
197
+ await mkdir(raw, { recursive: true });
198
+ await mkdir(samples, { recursive: true });
199
+ await Promise.all([
200
+ writeFile(new URL(`${current.name}.json`, raw), JSON.stringify(entries.filter(Boolean))),
201
+ writeFile(new URL(`${current.name}.json`, samples), JSON.stringify(found)),
202
+ writeFile(
203
+ new URL('console.log', current.dir),
204
+ current.console.map((line) => `${line}\n`).join('')
205
+ ),
206
+ ]);
207
+ }
208
+
209
+ return { open, begin, end };
210
+ }
@@ -0,0 +1,83 @@
1
+ import { createReadStream } from 'node:fs';
2
+ import { stat } from 'node:fs/promises';
3
+ import { createServer } from 'node:http';
4
+ import { extname, join, normalize, resolve, sep } from 'node:path';
5
+
6
+ export const isolation = {
7
+ 'cross-origin-opener-policy': 'same-origin',
8
+ 'cross-origin-embedder-policy': 'require-corp',
9
+ 'cross-origin-resource-policy': 'same-origin',
10
+ };
11
+
12
+ const types = {
13
+ '.css': 'text/css; charset=utf-8',
14
+ '.html': 'text/html; charset=utf-8',
15
+ '.js': 'text/javascript; charset=utf-8',
16
+ '.json': 'application/json; charset=utf-8',
17
+ '.map': 'application/json; charset=utf-8',
18
+ '.mjs': 'text/javascript; charset=utf-8',
19
+ '.png': 'image/png',
20
+ '.svg': 'image/svg+xml',
21
+ '.wasm': 'application/wasm',
22
+ };
23
+
24
+ export function type(path) {
25
+ return types[extname(path)] ?? 'application/octet-stream';
26
+ }
27
+
28
+ export function locator(roots) {
29
+ const mounted = roots.map(([prefix, dir]) => [prefix, `${resolve(dir)}${sep}`]);
30
+ return (pathname) => {
31
+ const found = mounted.find(([prefix]) => pathname.startsWith(prefix));
32
+ if (!found) return null;
33
+ const [prefix, root] = found;
34
+ const rel = pathname.slice(prefix.length);
35
+ const file = join(root, normalize(rel === '' || rel.endsWith('/') ? `${rel}index.html` : rel));
36
+ return file.startsWith(root) ? file : null;
37
+ };
38
+ }
39
+
40
+ export async function serve({
41
+ roots = [['/', 'src/']],
42
+ aliases = [],
43
+ isolated = false,
44
+ port = 0,
45
+ } = {}) {
46
+ const headers = isolated ? isolation : {};
47
+ const locate = locator(roots);
48
+ const lookup = locator([...aliases, ...roots]);
49
+ const requests = [];
50
+ const overrides = new Map();
51
+ const overridden = new Set();
52
+ const server = createServer(async (request, response) => {
53
+ const pathname = decodeURIComponent(new URL(request.url, 'http://localhost').pathname);
54
+ requests.push(pathname);
55
+ if (overrides.has(pathname)) {
56
+ overridden.add(pathname);
57
+ return response
58
+ .writeHead(200, { ...headers, 'content-type': type(pathname), 'cache-control': 'no-cache' })
59
+ .end(overrides.get(pathname));
60
+ }
61
+ const file = locate(pathname);
62
+ const found = file && (await stat(file).catch(() => null))?.isFile();
63
+ if (!found) return response.writeHead(404, headers).end();
64
+ response.writeHead(200, {
65
+ ...headers,
66
+ 'content-type': type(file),
67
+ 'cache-control': 'no-cache',
68
+ });
69
+ createReadStream(file).pipe(response);
70
+ });
71
+ await new Promise((done) => server.listen(port, '127.0.0.1', done));
72
+ const url = `http://127.0.0.1:${server.address().port}/`;
73
+ return {
74
+ url,
75
+ requests,
76
+ overrides,
77
+ overridden,
78
+ locate,
79
+ source: (href) =>
80
+ href.startsWith(url) ? lookup(decodeURIComponent(new URL(href).pathname)) : null,
81
+ close: () => new Promise((done) => server.close(done)),
82
+ };
83
+ }
@@ -0,0 +1,32 @@
1
+ import { cwd } from 'node:process';
2
+ import { pathToFileURL } from 'node:url';
3
+
4
+ export const TIMED_OUT = Symbol('timed out');
5
+
6
+ const project = pathToFileURL(`${cwd()}/`);
7
+
8
+ export const artifacts = new URL('artifacts/', project);
9
+ export const cache = new URL('node_modules/.cache/slicc-harness/', project);
10
+ export const raw = new URL('coverage/', cache);
11
+ export const samples = new URL('hotspots/', cache);
12
+
13
+ export function ignore() {
14
+ return null;
15
+ }
16
+
17
+ export function sleep(ms) {
18
+ return new Promise((resolve) => setTimeout(resolve, ms));
19
+ }
20
+
21
+ export async function bounded(promise, label, ms = 10000) {
22
+ const result = await Promise.race([promise, sleep(ms).then(() => TIMED_OUT)]);
23
+ if (result === TIMED_OUT) console.warn(`chrome: ${label} took over ${ms} ms, moving on`);
24
+ return result;
25
+ }
26
+
27
+ export function slug(text) {
28
+ return text
29
+ .toLowerCase()
30
+ .replace(/[^a-z0-9]+/g, '-')
31
+ .replace(/^-|-$/g, '');
32
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@ai-ecoverse/slicc-shared-web",
3
- "version": "1.2.1",
4
- "description": "Shared Renovate, Biome, TypeScript, lefthook and GitHub Actions configuration for SLICC's web repos",
3
+ "version": "1.4.0",
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",
7
7
  "repository": {
@@ -26,7 +26,9 @@
26
26
  "./tsconfig.json": "./tsconfig.json",
27
27
  "./lefthook.yml": "./lefthook.yml",
28
28
  "./package.json": "./package.json",
29
- "./mcr.config.mjs": "./mcr.config.mjs"
29
+ "./mcr.config.mjs": "./mcr.config.mjs",
30
+ "./harness": "./harness/index.mjs",
31
+ "./harness/global": "./harness/global.mjs"
30
32
  },
31
33
  "bin": {
32
34
  "slicc-agents-md": "bin/slicc-agents-md.sh",
@@ -38,6 +40,7 @@
38
40
  "files": [
39
41
  "bin",
40
42
  "biome.json",
43
+ "harness",
41
44
  "default.json",
42
45
  "lefthook.yml",
43
46
  "mcr.config.mjs",
@@ -45,12 +48,21 @@
45
48
  ],
46
49
  "scripts": {
47
50
  "lint": "bin/slicc-lint.sh && shellcheck bin/*.sh",
51
+ "pretest": "playwright-core install --no-shell chromium",
52
+ "test": "node --test --test-global-setup=harness/global.mjs test/integration/*.test.mjs && node test/integration/verify.mjs",
53
+ "test:unit": "mcr -c test/unit/mcr.config.mjs node --test 'test/unit/**/*.test.mjs'",
48
54
  "trust": "npx fledgling sync -y --skip-publish",
49
55
  "trust:dry": "npx fledgling sync --dry-run -y --skip-publish",
50
56
  "release": "semantic-release"
51
57
  },
52
58
  "devDependencies": {
53
59
  "@biomejs/biome": "2.5.15",
60
+ "monocart-coverage-reports": "2.13.0",
61
+ "playwright-core": "1.63.0",
54
62
  "semantic-release": "25.0.9"
63
+ },
64
+ "peerDependencies": {
65
+ "monocart-coverage-reports": ">=2.13.0",
66
+ "playwright-core": ">=1.50.0"
55
67
  }
56
68
  }