@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 +4 -2
- package/harness/cdp.mjs +11 -1
- package/harness/chrome.mjs +43 -8
- package/harness/extensions.mjs +28 -0
- package/harness/page.mjs +45 -8
- package/harness/recorder.mjs +9 -10
- package/package.json +1 -1
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) => {
|
package/harness/chrome.mjs
CHANGED
|
@@ -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
|
|
30
|
-
const
|
|
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
|
|
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,
|
|
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
|
|
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
|
-
|
|
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
|
|
20
|
-
const
|
|
21
|
-
|
|
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);
|
package/harness/recorder.mjs
CHANGED
|
@@ -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 (!
|
|
36
|
-
const { pathname } = new URL(url);
|
|
37
|
-
|
|
38
|
-
|
|
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
|
-
|
|
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
|
+
"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",
|