@ai-ecoverse/slicc-shared-web 1.6.0 → 1.7.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -1
- package/harness/cdn.mjs +4 -2
- package/harness/chrome.mjs +1 -1
- package/harness/recorder.mjs +78 -4
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -67,8 +67,9 @@ test('boots', async (t) => {
|
|
|
67
67
|
|
|
68
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
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`;
|
|
70
|
+
- it saves a CPU profile sample of every page and worker in the test, including workers started by other workers, as `stall-<n>-<target>-<session>.cpuprofile`;
|
|
71
71
|
- it adds a line per target to `console.log`, saying whether that target is running or paused;
|
|
72
|
+
- on the first stall, it records a 10 s browser trace as `stall-1-trace.json` (open it in DevTools' Performance panel or Perfetto). The browser collects it with V8 CPU samples per thread, so it shows what a page's main thread is doing even when that thread no longer answers CDP. It also logs each process's CPU time over the window;
|
|
72
73
|
- 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
|
|
|
74
75
|
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`.
|
package/harness/cdn.mjs
CHANGED
|
@@ -24,7 +24,7 @@ export async function fetched(url, { attempts = 3, ms = DOWNLOAD_TIMEOUT, fetche
|
|
|
24
24
|
export async function download(url) {
|
|
25
25
|
const { host, pathname, search } = new URL(url);
|
|
26
26
|
const query = search ? encodeURIComponent(search) : '';
|
|
27
|
-
const file = new URL(`./${host}${pathname}${query}
|
|
27
|
+
const file = new URL(`./${host}${pathname}${query}`.replaceAll('%', '%25'), store);
|
|
28
28
|
const hit = await readFile(file).catch(ignore);
|
|
29
29
|
if (hit) return hit;
|
|
30
30
|
const body = await fetched(url);
|
|
@@ -49,7 +49,9 @@ export async function cdn(cdp, remotes) {
|
|
|
49
49
|
const failure = { requestId, responseCode: state.status, responseHeaders: cors };
|
|
50
50
|
return cdp.send('Fetch.fulfillRequest', failure);
|
|
51
51
|
}
|
|
52
|
-
const body = await download(request.url).catch(
|
|
52
|
+
const body = await download(request.url).catch((error) => {
|
|
53
|
+
console.warn(`harness: could not serve ${request.url}: ${error.message}`);
|
|
54
|
+
});
|
|
53
55
|
if (!body) return cdp.send('Fetch.failRequest', { requestId, errorReason: 'Failed' });
|
|
54
56
|
const sent = Buffer.from(body);
|
|
55
57
|
if (state.corrupt) sent[sent.length - 1] ^= 0xff;
|
package/harness/chrome.mjs
CHANGED
|
@@ -142,7 +142,7 @@ export async function launch({
|
|
|
142
142
|
stallAfter,
|
|
143
143
|
stall: async (tag, png) => {
|
|
144
144
|
if (png) await writeFile(new URL(`${tag}.png`, dir), png).catch(ignore);
|
|
145
|
-
await bounded(record.stall(dir, tag), tag,
|
|
145
|
+
await bounded(record.stall(dir, tag), tag, 45000);
|
|
146
146
|
},
|
|
147
147
|
});
|
|
148
148
|
pages.push(opened);
|
package/harness/recorder.mjs
CHANGED
|
@@ -104,9 +104,74 @@ function pauseOf(target = {}, params) {
|
|
|
104
104
|
};
|
|
105
105
|
}
|
|
106
106
|
|
|
107
|
+
const TRACED = [
|
|
108
|
+
'toplevel',
|
|
109
|
+
'devtools.timeline',
|
|
110
|
+
'disabled-by-default-devtools.timeline',
|
|
111
|
+
'v8.execute',
|
|
112
|
+
'disabled-by-default-v8.cpu_profiler',
|
|
113
|
+
];
|
|
114
|
+
|
|
115
|
+
async function cpuTimes(cdp) {
|
|
116
|
+
const { processInfo = [] } = await cdp
|
|
117
|
+
.send('SystemInfo.getProcessInfo', {}, undefined, 5000)
|
|
118
|
+
.catch(() => ({}));
|
|
119
|
+
return new Map(processInfo.map((info) => [`${info.type} ${info.id}`, info.cpuTime]));
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export async function trace(cdp, dir, tag, ms = 10000) {
|
|
123
|
+
const events = [];
|
|
124
|
+
const done = Promise.withResolvers();
|
|
125
|
+
const off = cdp.on(({ method, params }) => {
|
|
126
|
+
if (method === 'Tracing.dataCollected') events.push(...params.value);
|
|
127
|
+
if (method === 'Tracing.tracingComplete') done.resolve();
|
|
128
|
+
});
|
|
129
|
+
try {
|
|
130
|
+
const before = await cpuTimes(cdp);
|
|
131
|
+
await cdp.send(
|
|
132
|
+
'Tracing.start',
|
|
133
|
+
{ traceConfig: { includedCategories: TRACED }, transferMode: 'ReportEvents' },
|
|
134
|
+
undefined,
|
|
135
|
+
10000
|
|
136
|
+
);
|
|
137
|
+
await sleep(ms);
|
|
138
|
+
await cdp.send('Tracing.end', {}, undefined, 10000);
|
|
139
|
+
await bounded(done.promise, 'trace', 20000);
|
|
140
|
+
const after = await cpuTimes(cdp);
|
|
141
|
+
await writeFile(new URL(`${tag}-trace.json`, dir), JSON.stringify({ traceEvents: events }));
|
|
142
|
+
const busy = [...after]
|
|
143
|
+
.map(([name, time]) => [name, time - (before.get(name) ?? 0)])
|
|
144
|
+
.filter(([, used]) => used > 0.05)
|
|
145
|
+
.map(([name, used]) => `${name} ${used.toFixed(1)}s`);
|
|
146
|
+
return [
|
|
147
|
+
`trace ${tag}-trace.json (${events.length} events)`,
|
|
148
|
+
`cpu over ${ms / 1000}s: ${busy.join(', ') || 'none'}`,
|
|
149
|
+
];
|
|
150
|
+
} catch (error) {
|
|
151
|
+
return [`trace failed: ${error.message}`];
|
|
152
|
+
} finally {
|
|
153
|
+
off();
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function claim(run) {
|
|
158
|
+
if (!run || run.traced) return false;
|
|
159
|
+
run.traced = true;
|
|
160
|
+
return true;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function contextOf(sessions, parents, sessionId) {
|
|
164
|
+
for (let id = sessionId; id; id = parents.get(id)) {
|
|
165
|
+
const context = sessions.get(id)?.browserContextId;
|
|
166
|
+
if (context) return context;
|
|
167
|
+
}
|
|
168
|
+
return undefined;
|
|
169
|
+
}
|
|
170
|
+
|
|
107
171
|
export function recorder(cdp, server, { coverage = ['/'], exits = new Map() } = {}) {
|
|
108
172
|
const sessions = new Map();
|
|
109
173
|
const pauses = new Map();
|
|
174
|
+
const parents = new Map();
|
|
110
175
|
const tabs = new Map();
|
|
111
176
|
const ours = covered(server, coverage);
|
|
112
177
|
let run = null;
|
|
@@ -208,15 +273,24 @@ export function recorder(cdp, server, { coverage = ['/'], exits = new Map() } =
|
|
|
208
273
|
}
|
|
209
274
|
|
|
210
275
|
async function stall(dir, tag) {
|
|
211
|
-
const
|
|
212
|
-
const
|
|
213
|
-
|
|
276
|
+
const first = claim(run);
|
|
277
|
+
const current = [...sessions].filter(
|
|
278
|
+
([sessionId]) => contextOf(sessions, parents, sessionId) === run?.context
|
|
279
|
+
);
|
|
280
|
+
const [lines, traced] = await Promise.all([
|
|
281
|
+
Promise.all(current.map((entry) => sample(cdp, pauses, entry, dir, tag))),
|
|
282
|
+
first ? trace(cdp, dir, tag) : [],
|
|
283
|
+
]);
|
|
284
|
+
const all = [...lines, ...traced, ...held(pauses)];
|
|
214
285
|
for (const line of all) run?.console.push(`${tag}: ${line}`);
|
|
215
286
|
return all;
|
|
216
287
|
}
|
|
217
288
|
|
|
218
289
|
cdp.on(async ({ method, params, sessionId }) => {
|
|
219
|
-
if (method === 'Target.attachedToTarget')
|
|
290
|
+
if (method === 'Target.attachedToTarget') {
|
|
291
|
+
if (sessionId) parents.set(params.sessionId, sessionId);
|
|
292
|
+
await attach(params);
|
|
293
|
+
}
|
|
220
294
|
if (method === 'Target.detachedFromTarget') {
|
|
221
295
|
sessions.delete(params.sessionId);
|
|
222
296
|
pauses.delete(params.sessionId);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ai-ecoverse/slicc-shared-web",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.7.1",
|
|
4
4
|
"description": "Shared Renovate, Biome, TypeScript, lefthook and GitHub Actions configuration, plus the CDP integration-test harness, for SLICC's web repos",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "Apache-2.0",
|