@ai-ecoverse/slicc-shared-web 1.5.1 → 1.6.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 +8 -1
- package/harness/chrome.mjs +10 -2
- package/harness/page.mjs +38 -8
- package/harness/recorder.mjs +70 -11
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -63,8 +63,15 @@ test('boots', async (t) => {
|
|
|
63
63
|
| `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 |
|
|
64
64
|
| `args` | Extra Chromium flags, such as `--host-resolver-rules` or `--ignore-certificate-errors` | none |
|
|
65
65
|
| `timeout` | How long `page.until` polls and `page.evaluate` waits, in ms | `30000` |
|
|
66
|
+
| `stallAfter` | How long one evaluation may run before the harness probes the test's targets, in ms | `30000` |
|
|
66
67
|
|
|
67
|
-
`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. `within(ms, fn, ...args)` is `until` with its own deadline, for a stage that should take a known time. Both stop at the deadline even if one evaluation never answers. Every CDP call has a deadline: `connect(url).send(method, params, sessionId, ms)` rejects with `<method>: no answer in <ms> ms` (60 s unless given), so a stuck page fails its test instead of hanging the run.
|
|
68
|
+
`chrome.page(t)` opens a tab and returns the page API: `goto(path)`, `reload()`, `evaluate(fn, ...args)`, `until(fn, ...args)`, `press(key, ...modifiers)` (a key name like `Enter` or `F5`, or a single character typed with its US-layout key code), `type(text)`, `insert(text)`, `enter()`, `init(fn)` (runs in every new document), `expose(name, handler)` (a binding the page calls with a JSON string), `screenshot(file)`, `tab()` (another tab in the same context), `close()`, `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. `within(ms, fn, ...args)` is `until` with its own deadline, for a stage that should take a known time. Both stop at the deadline even if one evaluation never answers. Every CDP call has a deadline: `connect(url).send(method, params, sessionId, ms)` rejects with `<method>: no answer in <ms> ms` (60 s unless given), so a stuck page fails its test instead of hanging the run. When one evaluation runs past `stallAfter`, the harness probes once, without interrupting the wait:
|
|
69
|
+
- it saves `stall-<n>.png` (a screenshot of the page, if the renderer still answers) in the test's artifact directory;
|
|
70
|
+
- it saves a CPU profile sample of every page and worker in the test as `stall-<n>-<target>-<session>.cpuprofile`;
|
|
71
|
+
- it adds a line per target to `console.log`, saying whether that target is running or paused;
|
|
72
|
+
- it adds a line for every debugger pause still held in the browser, in any target, with its id, URL, start time, reason and top call frames.
|
|
73
|
+
|
|
74
|
+
A pause in one target blocks every page in the same renderer process, so those pause lines name the culprit even when it isn't the page under test. The harness resumes its own coverage pauses (`beforeunload`, worker exits) within 10 s, even when collecting coverage fails. Downloads of intercepted URLs time out after 60 s and are tried up to three times; a `4xx` is not retried. `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`.
|
|
68
75
|
|
|
69
76
|
Why it works the way it does:
|
|
70
77
|
|
package/harness/chrome.mjs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { spawn } from 'node:child_process';
|
|
2
|
-
import { mkdir, mkdtemp, rm } from 'node:fs/promises';
|
|
2
|
+
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
|
|
3
3
|
import { tmpdir } from 'node:os';
|
|
4
4
|
import { basename, join } from 'node:path';
|
|
5
5
|
import { chromium } from 'playwright-core';
|
|
@@ -75,6 +75,7 @@ export async function launch({
|
|
|
75
75
|
extensions = [],
|
|
76
76
|
args = [],
|
|
77
77
|
timeout,
|
|
78
|
+
stallAfter,
|
|
78
79
|
} = {}) {
|
|
79
80
|
const server = await serve({ roots, aliases, isolated });
|
|
80
81
|
const found = await unpacked(extensions);
|
|
@@ -105,6 +106,7 @@ export async function launch({
|
|
|
105
106
|
}
|
|
106
107
|
|
|
107
108
|
async function finish(pages, browserContextId, dir, before) {
|
|
109
|
+
await bounded(Promise.all(pages.map((opened) => opened.probed())), 'probes', 30000);
|
|
108
110
|
const shots = pages.map((opened, i) =>
|
|
109
111
|
opened.screenshot(new URL(`tab-${i + 1}.png`, dir)).catch(ignore)
|
|
110
112
|
);
|
|
@@ -136,7 +138,13 @@ export async function launch({
|
|
|
136
138
|
const pages = [];
|
|
137
139
|
t.after(() => finish(pages, browserContextId, dir, before));
|
|
138
140
|
const tab = async () => {
|
|
139
|
-
const opened = page(cdp, await record.open(browserContextId), server, timeout
|
|
141
|
+
const opened = page(cdp, await record.open(browserContextId), server, timeout, {
|
|
142
|
+
stallAfter,
|
|
143
|
+
stall: async (tag, png) => {
|
|
144
|
+
if (png) await writeFile(new URL(`${tag}.png`, dir), png).catch(ignore);
|
|
145
|
+
await bounded(record.stall(dir, tag), tag, 20000);
|
|
146
|
+
},
|
|
147
|
+
});
|
|
140
148
|
pages.push(opened);
|
|
141
149
|
return Object.assign(opened, { tab, dir });
|
|
142
150
|
};
|
package/harness/page.mjs
CHANGED
|
@@ -84,7 +84,17 @@ export function decoded({ value, unserializableValue }) {
|
|
|
84
84
|
return specials[unserializableValue];
|
|
85
85
|
}
|
|
86
86
|
|
|
87
|
-
export
|
|
87
|
+
export const STALL_AFTER = 30000;
|
|
88
|
+
|
|
89
|
+
export function page(
|
|
90
|
+
cdp,
|
|
91
|
+
{ sessionId, targetId },
|
|
92
|
+
server,
|
|
93
|
+
timeout = 30000,
|
|
94
|
+
{ stall, stallAfter = STALL_AFTER } = {}
|
|
95
|
+
) {
|
|
96
|
+
let stalls = 0;
|
|
97
|
+
const probes = new Set();
|
|
88
98
|
const send = (method, params, ms) => cdp.send(method, params, sessionId, ms);
|
|
89
99
|
const errors = [];
|
|
90
100
|
const responses = [];
|
|
@@ -99,15 +109,34 @@ export function page(cdp, { sessionId, targetId }, server, timeout = 30000) {
|
|
|
99
109
|
if (method === 'Network.responseReceived') responses.push(params.response);
|
|
100
110
|
});
|
|
101
111
|
|
|
112
|
+
async function capture() {
|
|
113
|
+
stalls += 1;
|
|
114
|
+
const tag = `stall-${stalls}`;
|
|
115
|
+
const shot = await send('Page.captureScreenshot', { format: 'png' }, 10000).catch(() => null);
|
|
116
|
+
await stall(tag, shot?.data ? Buffer.from(shot.data, 'base64') : null);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function probe() {
|
|
120
|
+
const running = capture().catch(() => undefined);
|
|
121
|
+
probes.add(running);
|
|
122
|
+
void running.then(() => probes.delete(running));
|
|
123
|
+
}
|
|
124
|
+
|
|
102
125
|
async function run(fn, args, ms) {
|
|
103
126
|
const expression = `(${fn})(...${JSON.stringify(args)})`;
|
|
104
|
-
const
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
127
|
+
const at = Math.min(stallAfter, Math.max(ms - 1000, ms / 2));
|
|
128
|
+
const timer = stall ? setTimeout(probe, at) : null;
|
|
129
|
+
try {
|
|
130
|
+
const { result, exceptionDetails } = await send(
|
|
131
|
+
'Runtime.evaluate',
|
|
132
|
+
{ expression, awaitPromise: true, returnByValue: true },
|
|
133
|
+
ms
|
|
134
|
+
);
|
|
135
|
+
if (exceptionDetails) throw new Error(exceptionDetails.exception?.description);
|
|
136
|
+
return decoded(result);
|
|
137
|
+
} finally {
|
|
138
|
+
clearTimeout(timer);
|
|
139
|
+
}
|
|
111
140
|
}
|
|
112
141
|
|
|
113
142
|
function evaluate(fn, ...args) {
|
|
@@ -161,6 +190,7 @@ export function page(cdp, { sessionId, targetId }, server, timeout = 30000) {
|
|
|
161
190
|
evaluate,
|
|
162
191
|
until,
|
|
163
192
|
within,
|
|
193
|
+
probed: () => Promise.all(probes),
|
|
164
194
|
press,
|
|
165
195
|
goto: (path) => navigate(`location.assign(${JSON.stringify(new URL(path, server.url).href)})`),
|
|
166
196
|
reload: () => navigate('location.reload()'),
|
package/harness/recorder.mjs
CHANGED
|
@@ -65,8 +65,48 @@ async function entry(server, script) {
|
|
|
65
65
|
};
|
|
66
66
|
}
|
|
67
67
|
|
|
68
|
+
function frames(params) {
|
|
69
|
+
return (params.callFrames ?? [])
|
|
70
|
+
.slice(0, 3)
|
|
71
|
+
.map(
|
|
72
|
+
(frame) =>
|
|
73
|
+
`${frame.functionName || '(anonymous)'} ${pathOf(frame.url) || frame.url}:${frame.location.lineNumber + 1}`
|
|
74
|
+
)
|
|
75
|
+
.join(' < ');
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async function sample(cdp, pauses, [sessionId, target], dir, tag) {
|
|
79
|
+
const send = (method) => cdp.send(method, {}, sessionId, 8000);
|
|
80
|
+
const stopped = await send('Profiler.stop').catch((error) => ({ error }));
|
|
81
|
+
send('Profiler.start').catch(ignore);
|
|
82
|
+
const pause = pauses.get(sessionId);
|
|
83
|
+
const state = pause ? `paused (${pause.reason})` : 'running';
|
|
84
|
+
if (!stopped.profile) return `${label(target)}: ${state}, no profile (${stopped.error?.message})`;
|
|
85
|
+
const name = `${tag}-${label(target)}-${sessionId.slice(0, 6)}.cpuprofile`;
|
|
86
|
+
await writeFile(new URL(name, dir), JSON.stringify(stopped.profile)).catch(ignore);
|
|
87
|
+
return `${label(target)}: ${state}, profile ${name}`;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function held(pauses) {
|
|
91
|
+
return [...pauses.values()].map(
|
|
92
|
+
(pause) =>
|
|
93
|
+
`paused ${pause.type} ${pause.targetId} ${pause.url} since ${pause.since}: ${pause.reason}`
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function pauseOf(target = {}, params) {
|
|
98
|
+
return {
|
|
99
|
+
type: target.type ?? 'unknown',
|
|
100
|
+
targetId: target.targetId ?? '?',
|
|
101
|
+
url: target.url ?? '?',
|
|
102
|
+
since: new Date().toISOString(),
|
|
103
|
+
reason: `${params.reason}: ${frames(params)}`,
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
|
|
68
107
|
export function recorder(cdp, server, { coverage = ['/'], exits = new Map() } = {}) {
|
|
69
108
|
const sessions = new Map();
|
|
109
|
+
const pauses = new Map();
|
|
70
110
|
const tabs = new Map();
|
|
71
111
|
const ours = covered(server, coverage);
|
|
72
112
|
let run = null;
|
|
@@ -151,21 +191,36 @@ export function recorder(cdp, server, { coverage = ['/'], exits = new Map() } =
|
|
|
151
191
|
run.console.push(`${label(target)}: ${line}`);
|
|
152
192
|
}
|
|
153
193
|
|
|
194
|
+
async function capture(sessionId, target) {
|
|
195
|
+
if (target?.type === 'worker') return dump(sessionId, target, false);
|
|
196
|
+
const { result } = await cdp
|
|
197
|
+
.send('Runtime.evaluate', { expression: 'location.href', returnByValue: true }, sessionId)
|
|
198
|
+
.catch(() => ({ result: {} }));
|
|
199
|
+
if (result?.value && result.value !== 'about:blank') await checkpoint();
|
|
200
|
+
}
|
|
201
|
+
|
|
154
202
|
async function paused(sessionId) {
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
.send('Runtime.evaluate', { expression: 'location.href', returnByValue: true }, sessionId)
|
|
160
|
-
.catch(() => ({ result: {} }));
|
|
161
|
-
if (result?.value && result.value !== 'about:blank') await checkpoint();
|
|
203
|
+
try {
|
|
204
|
+
await bounded(capture(sessionId, sessions.get(sessionId)).catch(ignore), 'pause', 10000);
|
|
205
|
+
} finally {
|
|
206
|
+
await cdp.send('Debugger.resume', {}, sessionId, 10000).catch(ignore);
|
|
162
207
|
}
|
|
163
|
-
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
async function stall(dir, tag) {
|
|
211
|
+
const current = [...sessions].filter(([, target]) => target.browserContextId === run?.context);
|
|
212
|
+
const lines = await Promise.all(current.map((entry) => sample(cdp, pauses, entry, dir, tag)));
|
|
213
|
+
const all = [...lines, ...held(pauses)];
|
|
214
|
+
for (const line of all) run?.console.push(`${tag}: ${line}`);
|
|
215
|
+
return all;
|
|
164
216
|
}
|
|
165
217
|
|
|
166
218
|
cdp.on(async ({ method, params, sessionId }) => {
|
|
167
219
|
if (method === 'Target.attachedToTarget') await attach(params);
|
|
168
|
-
if (method === 'Target.detachedFromTarget')
|
|
220
|
+
if (method === 'Target.detachedFromTarget') {
|
|
221
|
+
sessions.delete(params.sessionId);
|
|
222
|
+
pauses.delete(params.sessionId);
|
|
223
|
+
}
|
|
169
224
|
if (method === 'Runtime.consoleAPICalled') {
|
|
170
225
|
const text = params.args.map((arg) => arg.value ?? arg.description).join(' ');
|
|
171
226
|
log(sessionId, `${params.type} ${text}`);
|
|
@@ -174,7 +229,11 @@ export function recorder(cdp, server, { coverage = ['/'], exits = new Map() } =
|
|
|
174
229
|
const { exception, text } = params.exceptionDetails;
|
|
175
230
|
log(sessionId, `uncaught ${exception?.description ?? text}`);
|
|
176
231
|
}
|
|
177
|
-
if (method === 'Debugger.paused')
|
|
232
|
+
if (method === 'Debugger.paused') {
|
|
233
|
+
pauses.set(sessionId, pauseOf(sessions.get(sessionId), params));
|
|
234
|
+
await paused(sessionId);
|
|
235
|
+
}
|
|
236
|
+
if (method === 'Debugger.resumed') pauses.delete(sessionId);
|
|
178
237
|
});
|
|
179
238
|
|
|
180
239
|
async function open(browserContextId) {
|
|
@@ -211,5 +270,5 @@ export function recorder(cdp, server, { coverage = ['/'], exits = new Map() } =
|
|
|
211
270
|
]);
|
|
212
271
|
}
|
|
213
272
|
|
|
214
|
-
return { open, begin, end };
|
|
273
|
+
return { open, begin, end, stall };
|
|
215
274
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ai-ecoverse/slicc-shared-web",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.6.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",
|