@ai-ecoverse/slicc-shared-web 1.7.1 → 1.9.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 +10 -2
- package/harness/cdp.mjs +77 -11
- package/harness/chrome.mjs +60 -15
- package/harness/flight.mjs +38 -0
- package/harness/page.mjs +11 -3
- package/harness/recorder.mjs +7 -56
- package/harness/trace.mjs +192 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -65,11 +65,18 @@ test('boots', async (t) => {
|
|
|
65
65
|
| `timeout` | How long `page.until` polls and `page.evaluate` waits, in ms | `30000` |
|
|
66
66
|
| `stallAfter` | How long one evaluation may run before the harness probes the test's targets, in ms | `30000` |
|
|
67
67
|
|
|
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.
|
|
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()`, `send(method, params, ms)` (a raw CDP call in the tab's session), `errors`, `responses` and `dir` (the test's artifact directory). `chrome.pid` is the browser's process id. `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. A crash fails it at once instead:
|
|
69
|
+
- when the browser dies, every pending and later call rejects with `<method>: browser exited with <signal>` (or `with code <n>`);
|
|
70
|
+
- when a renderer crashes, every pending and later call to that target, and to the dedicated workers it started, rejects with `<method>: page renderer crashed (<status>, code <n>) at <url>` (or `worker`, `shared worker`, `service worker`), and `console.log` gets the same line;
|
|
71
|
+
- `until`, `within`, `goto` and `reload` stop retrying and rethrow it;
|
|
72
|
+
- both errors end with the last five lines of the browser's stderr, and each test keeps up to 200 lines it wrote during the test in `chrome-stderr.log`.
|
|
73
|
+
|
|
74
|
+
When one evaluation runs past `stallAfter`, the harness probes once, without interrupting the wait:
|
|
75
|
+
- it adds a `pending` line to `console.log` for every navigation and request of the page that has no answer yet, with its age. A worker's script loads in the worker's own session, so its request is left out. Chrome holds CDP messages to a page while a navigation is waiting for its response, so an evaluation that hangs while the page is idle usually means one of these;
|
|
69
76
|
- it saves `stall-<n>.png` (a screenshot of the page, if the renderer still answers) in the test's artifact directory;
|
|
70
77
|
- 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
78
|
- 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
|
|
79
|
+
- 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, task, IPC, Blink and loading events, so it shows what a page's main thread is doing even when that thread no longer answers CDP. It logs each process's CPU time over the window, and for each renderer its main thread's task count, the call chain of its longest or still open task (function, file, line and URL of a request), and where its CPU samples fall. The targets are sampled after the trace starts, so their new profiles carry their call frames into it. A call that started before the trace shows only as a node id, since its frames were named before; a thread that is blocked keeps `Tracing.start` waiting, which the harness logs and traces through;
|
|
73
80
|
- 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.
|
|
74
81
|
|
|
75
82
|
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`.
|
|
@@ -85,3 +92,4 @@ Why it works the way it does:
|
|
|
85
92
|
- `--disable-extensions --disable-component-extensions-with-background-pages` keeps a Hangouts service worker out of every run.
|
|
86
93
|
- The attach event can arrive before `Target.createTarget` returns, so new tabs wait on a slot keyed by target id.
|
|
87
94
|
- 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.
|
|
95
|
+
- A WebSocket that has closed takes new messages without an error, and a crashed target never answers, so after a crash every call used to wait out its own deadline: a browser that died 4 s in showed up as `no answer` 15 minutes later. The harness now remembers the crash or the exit and fails each call with it.
|
package/harness/cdp.mjs
CHANGED
|
@@ -1,6 +1,23 @@
|
|
|
1
1
|
export const SEND_TIMEOUT = 60000;
|
|
2
|
+
export const CRASHED = 'Harness.targetCrashed';
|
|
2
3
|
|
|
3
|
-
|
|
4
|
+
const kinds = {
|
|
5
|
+
page: 'page',
|
|
6
|
+
iframe: 'iframe',
|
|
7
|
+
worker: 'worker',
|
|
8
|
+
shared_worker: 'shared worker',
|
|
9
|
+
service_worker: 'service worker',
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
export function fatal(message) {
|
|
13
|
+
return Object.assign(new Error(message), { fatal: true });
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function grace(ms) {
|
|
17
|
+
return new Promise((resolve) => setTimeout(resolve, ms, 'socket closed').unref());
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export async function connect(url, { exited, tail = () => [] } = {}) {
|
|
4
21
|
const socket = new WebSocket(url);
|
|
5
22
|
await new Promise((resolve, reject) => {
|
|
6
23
|
socket.onopen = resolve;
|
|
@@ -8,31 +25,79 @@ export async function connect(url) {
|
|
|
8
25
|
});
|
|
9
26
|
const pending = new Map();
|
|
10
27
|
const listeners = new Set();
|
|
28
|
+
const targets = new Map();
|
|
29
|
+
const crashed = new Map();
|
|
30
|
+
let gone = null;
|
|
11
31
|
let id = 0;
|
|
12
|
-
const
|
|
32
|
+
const told = (reason) => [reason, ...tail()].join('\n');
|
|
33
|
+
const drop = (matches, reason, error = Error) => {
|
|
13
34
|
for (const [key, call] of pending) {
|
|
14
|
-
if (call
|
|
35
|
+
if (!matches(call)) continue;
|
|
15
36
|
pending.delete(key);
|
|
16
|
-
call.reject(
|
|
37
|
+
call.reject(error(`${call.method}: ${reason}`));
|
|
38
|
+
}
|
|
39
|
+
};
|
|
40
|
+
const notify = (message) => {
|
|
41
|
+
for (const listener of listeners) listener(message);
|
|
42
|
+
};
|
|
43
|
+
const crash = (sessionId, status) => {
|
|
44
|
+
const target = targets.get(sessionId);
|
|
45
|
+
if (!target || crashed.has(sessionId)) return;
|
|
46
|
+
const how = status ? ` (${status})` : '';
|
|
47
|
+
const reason = `${kinds[target.type] ?? target.type} renderer crashed${how} at ${target.url}`;
|
|
48
|
+
crashed.set(sessionId, reason);
|
|
49
|
+
drop((call) => call.sessionId === sessionId, told(reason), fatal);
|
|
50
|
+
notify({ method: CRASHED, sessionId, params: { reason } });
|
|
51
|
+
for (const [child, { parent, type }] of targets) {
|
|
52
|
+
if (parent === sessionId && type === 'worker') crash(child, status);
|
|
17
53
|
}
|
|
18
54
|
};
|
|
55
|
+
const sessionsOf = (targetId) =>
|
|
56
|
+
[...targets]
|
|
57
|
+
.filter(([, target]) => target.targetId === targetId)
|
|
58
|
+
.map(([sessionId]) => sessionId);
|
|
59
|
+
const track = ({ method, params, sessionId }) => {
|
|
60
|
+
if (method === 'Target.attachedToTarget')
|
|
61
|
+
targets.set(params.sessionId, { ...params.targetInfo, parent: sessionId });
|
|
62
|
+
if (method === 'Target.targetInfoChanged') {
|
|
63
|
+
for (const session of sessionsOf(params.targetInfo.targetId)) {
|
|
64
|
+
targets.set(session, { ...targets.get(session), url: params.targetInfo.url });
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
if (method === 'Target.detachedFromTarget') {
|
|
68
|
+
targets.delete(params.sessionId);
|
|
69
|
+
drop((call) => call.sessionId === params.sessionId, 'target detached');
|
|
70
|
+
}
|
|
71
|
+
if (method === 'Inspector.targetCrashed') setTimeout(crash, 200, sessionId);
|
|
72
|
+
if (method === 'Target.targetCrashed') {
|
|
73
|
+
const status = `${params.status}, code ${params.errorCode}`;
|
|
74
|
+
for (const session of sessionsOf(params.targetId)) crash(session, status);
|
|
75
|
+
}
|
|
76
|
+
};
|
|
77
|
+
const die = (reason) => {
|
|
78
|
+
if (gone) return;
|
|
79
|
+
gone = reason;
|
|
80
|
+
drop(() => true, told(reason), fatal);
|
|
81
|
+
};
|
|
19
82
|
socket.onmessage = ({ data }) => {
|
|
20
83
|
const message = JSON.parse(data);
|
|
21
|
-
|
|
22
|
-
drop(message.params.sessionId, 'target detached');
|
|
23
|
-
if (message.method === 'Inspector.targetCrashed') drop(message.sessionId, 'target crashed');
|
|
84
|
+
track(message);
|
|
24
85
|
const call = pending.get(message.id);
|
|
25
86
|
pending.delete(message.id);
|
|
26
|
-
if (!call)
|
|
87
|
+
if (!call) notify(message);
|
|
27
88
|
else if (message.error) call.reject(new Error(`${call.method}: ${message.error.message}`));
|
|
28
89
|
else call.resolve(message.result);
|
|
29
90
|
};
|
|
30
|
-
socket.onclose = () =>
|
|
31
|
-
|
|
32
|
-
};
|
|
91
|
+
socket.onclose = async () => die(await Promise.race([exited ?? 'socket closed', grace(2000)]));
|
|
92
|
+
void exited?.then(die);
|
|
33
93
|
return {
|
|
34
94
|
send: (method, params = {}, sessionId, ms = SEND_TIMEOUT) =>
|
|
35
95
|
new Promise((resolve, reject) => {
|
|
96
|
+
const stopped = gone ?? crashed.get(sessionId);
|
|
97
|
+
if (stopped) {
|
|
98
|
+
reject(fatal(`${method}: ${told(stopped)}`));
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
36
101
|
const key = ++id;
|
|
37
102
|
const timer = setTimeout(() => {
|
|
38
103
|
pending.delete(key);
|
|
@@ -49,6 +114,7 @@ export async function connect(url) {
|
|
|
49
114
|
listeners.add(listener);
|
|
50
115
|
return () => listeners.delete(listener);
|
|
51
116
|
},
|
|
117
|
+
target: (sessionId) => targets.get(sessionId),
|
|
52
118
|
close: () => socket.close(),
|
|
53
119
|
};
|
|
54
120
|
}
|
package/harness/chrome.mjs
CHANGED
|
@@ -9,7 +9,7 @@ import { extensionSource, unpacked } from './extensions.mjs';
|
|
|
9
9
|
import { page } from './page.mjs';
|
|
10
10
|
import { breakpoints, recorder } from './recorder.mjs';
|
|
11
11
|
import { serve } from './server.mjs';
|
|
12
|
-
import { artifacts, bounded, ignore, slug, TIMED_OUT } from './util.mjs';
|
|
12
|
+
import { artifacts, bounded, ignore, sleep, slug, TIMED_OUT } from './util.mjs';
|
|
13
13
|
|
|
14
14
|
export const flags = [
|
|
15
15
|
'--headless',
|
|
@@ -36,30 +36,67 @@ export function commandLine(profile, extensions = [], extra = []) {
|
|
|
36
36
|
return [...base, ...load, ...extra, `--user-data-dir=${profile}`, 'about:blank'];
|
|
37
37
|
}
|
|
38
38
|
|
|
39
|
+
export const KEPT = 200;
|
|
40
|
+
|
|
41
|
+
export function lines(stream, kept = KEPT) {
|
|
42
|
+
const last = [];
|
|
43
|
+
let partial = '';
|
|
44
|
+
let total = 0;
|
|
45
|
+
stream.setEncoding('utf8');
|
|
46
|
+
stream.on('data', (chunk) => {
|
|
47
|
+
const split = `${partial}${chunk}`.split('\n');
|
|
48
|
+
partial = split.pop();
|
|
49
|
+
total += split.length;
|
|
50
|
+
last.push(...split);
|
|
51
|
+
last.splice(0, last.length - kept);
|
|
52
|
+
});
|
|
53
|
+
return {
|
|
54
|
+
get total() {
|
|
55
|
+
return total;
|
|
56
|
+
},
|
|
57
|
+
since: (mark) => last.slice(Math.max(0, last.length - (total - mark))),
|
|
58
|
+
tail: (n = 5) => last.slice(-n),
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function exitOf(code, signal) {
|
|
63
|
+
return signal ? `browser exited with ${signal}` : `browser exited with code ${code}`;
|
|
64
|
+
}
|
|
65
|
+
|
|
39
66
|
export async function start(
|
|
40
67
|
profile,
|
|
41
68
|
args = commandLine(profile),
|
|
42
69
|
executable = chromium.executablePath()
|
|
43
70
|
) {
|
|
44
71
|
const child = spawn(executable, args, { stdio: ['ignore', 'ignore', 'pipe'] });
|
|
45
|
-
|
|
72
|
+
const stderr = lines(child.stderr);
|
|
73
|
+
const drained = new Promise((resolve) => child.stderr.once('close', resolve));
|
|
74
|
+
const exited = new Promise((resolve) => {
|
|
75
|
+
child.once('exit', (code, signal) => {
|
|
76
|
+
const reason = exitOf(code, signal);
|
|
77
|
+
void Promise.race([drained, sleep(1000)]).then(() => resolve(reason));
|
|
78
|
+
});
|
|
79
|
+
});
|
|
46
80
|
const url = await new Promise((resolve, reject) => {
|
|
47
|
-
child.stderr.on('data', (
|
|
48
|
-
|
|
49
|
-
|
|
81
|
+
child.stderr.on('data', () => {
|
|
82
|
+
const match = stderr
|
|
83
|
+
.tail(KEPT)
|
|
84
|
+
.join('\n')
|
|
85
|
+
.match(/DevTools listening on (ws:\/\/\S+)/);
|
|
50
86
|
if (match) resolve(match[1]);
|
|
51
87
|
});
|
|
52
88
|
child.once('error', reject);
|
|
53
|
-
|
|
89
|
+
void exited.then((reason) => reject(new Error(`${reason}\n${stderr.tail(KEPT).join('\n')}`)));
|
|
54
90
|
});
|
|
55
|
-
return { child, url };
|
|
91
|
+
return { child, url, exited, stderr };
|
|
56
92
|
}
|
|
57
93
|
|
|
58
94
|
export async function stop(cdp, child) {
|
|
59
95
|
const exited = new Promise((resolve) => child.once('exit', resolve));
|
|
60
96
|
await bounded(cdp.send('Browser.close').catch(ignore), 'Browser.close');
|
|
61
97
|
cdp.close();
|
|
62
|
-
|
|
98
|
+
const running = child.exitCode === null && child.signalCode === null;
|
|
99
|
+
if (running && (await bounded(exited, 'exit')) === TIMED_OUT) {
|
|
63
100
|
child.kill('SIGKILL');
|
|
64
101
|
await exited;
|
|
65
102
|
}
|
|
@@ -84,10 +121,14 @@ export async function launch({
|
|
|
84
121
|
const shared = extensions.length > 0;
|
|
85
122
|
const profile = await mkdtemp(join(tmpdir(), 'slicc-harness-'));
|
|
86
123
|
const loaded = [...found.values()].map((root) => root.slice(0, -1));
|
|
87
|
-
const { child, url } = await start(profile, commandLine(profile, loaded, args));
|
|
88
|
-
const cdp = await connect(url
|
|
124
|
+
const { child, url, exited, stderr } = await start(profile, commandLine(profile, loaded, args));
|
|
125
|
+
const cdp = await connect(url, {
|
|
126
|
+
exited,
|
|
127
|
+
tail: () => stderr.tail().map((line) => `stderr: ${line}`),
|
|
128
|
+
});
|
|
89
129
|
const record = recorder(cdp, files, { coverage, exits: await breakpoints(server, exits) });
|
|
90
130
|
const remote = await cdn(cdp, intercept);
|
|
131
|
+
await cdp.send('Target.setDiscoverTargets', { discover: true });
|
|
91
132
|
await cdp.send('Target.setAutoAttach', {
|
|
92
133
|
autoAttach: true,
|
|
93
134
|
waitForDebuggerOnStart: true,
|
|
@@ -105,7 +146,9 @@ export async function launch({
|
|
|
105
146
|
await Promise.all(closing.map((sent) => sent.catch(ignore)));
|
|
106
147
|
}
|
|
107
148
|
|
|
108
|
-
async function finish(pages, browserContextId, dir, before) {
|
|
149
|
+
async function finish(pages, browserContextId, dir, before, mark) {
|
|
150
|
+
const log = stderr.since(mark).map((line) => `${line}\n`);
|
|
151
|
+
await writeFile(new URL('chrome-stderr.log', dir), log.join(''));
|
|
109
152
|
await bounded(Promise.all(pages.map((opened) => opened.probed())), 'probes', 30000);
|
|
110
153
|
const shots = pages.map((opened, i) =>
|
|
111
154
|
opened.screenshot(new URL(`tab-${i + 1}.png`, dir)).catch(ignore)
|
|
@@ -117,11 +160,12 @@ export async function launch({
|
|
|
117
160
|
const closing = shared
|
|
118
161
|
? sweep(before)
|
|
119
162
|
: cdp.send('Target.disposeBrowserContext', { browserContextId });
|
|
120
|
-
await bounded(closing, 'dispose');
|
|
163
|
+
await bounded(closing.catch(ignore), 'dispose');
|
|
121
164
|
}
|
|
122
165
|
|
|
123
166
|
return {
|
|
124
167
|
url: server.url,
|
|
168
|
+
pid: child.pid,
|
|
125
169
|
cdn: remote.state,
|
|
126
170
|
requests: server.requests,
|
|
127
171
|
overrides: server.overrides,
|
|
@@ -136,13 +180,14 @@ export async function launch({
|
|
|
136
180
|
server.overrides.clear();
|
|
137
181
|
server.overridden.clear();
|
|
138
182
|
const pages = [];
|
|
139
|
-
|
|
183
|
+
const mark = stderr.total;
|
|
184
|
+
t.after(() => finish(pages, browserContextId, dir, before, mark));
|
|
140
185
|
const tab = async () => {
|
|
141
186
|
const opened = page(cdp, await record.open(browserContextId), server, timeout, {
|
|
142
187
|
stallAfter,
|
|
143
|
-
stall: async (tag, png) => {
|
|
188
|
+
stall: async (tag, png, pending) => {
|
|
144
189
|
if (png) await writeFile(new URL(`${tag}.png`, dir), png).catch(ignore);
|
|
145
|
-
await bounded(record.stall(dir, tag), tag, 45000);
|
|
190
|
+
await bounded(record.stall(dir, tag, pending), tag, 45000);
|
|
146
191
|
},
|
|
147
192
|
});
|
|
148
193
|
pages.push(opened);
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
const done = new Set(['Network.loadingFinished', 'Network.loadingFailed']);
|
|
2
|
+
const landed = new Set([
|
|
3
|
+
'Page.frameNavigated',
|
|
4
|
+
'Page.frameStoppedLoading',
|
|
5
|
+
'Page.navigatedWithinDocument',
|
|
6
|
+
]);
|
|
7
|
+
|
|
8
|
+
export function flights(now = Date.now) {
|
|
9
|
+
const requests = new Map();
|
|
10
|
+
const navigations = new Map();
|
|
11
|
+
|
|
12
|
+
function watch({ method, params }) {
|
|
13
|
+
if (method === 'Network.requestWillBeSent') {
|
|
14
|
+
const { request, type = 'Other', requestId } = params;
|
|
15
|
+
requests.set(requestId, { what: `${request.method} ${request.url} (${type})`, since: now() });
|
|
16
|
+
}
|
|
17
|
+
if (done.has(method)) requests.delete(params.requestId);
|
|
18
|
+
if (method === 'Page.frameStartedNavigating') {
|
|
19
|
+
const what = `navigation to ${params.url} (${params.navigationType})`;
|
|
20
|
+
navigations.set(params.frameId, { what, since: now() });
|
|
21
|
+
}
|
|
22
|
+
if (landed.has(method)) navigations.delete(params.frame?.id ?? params.frameId);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function pending() {
|
|
26
|
+
const at = now();
|
|
27
|
+
return [...navigations.values(), ...requests.values()]
|
|
28
|
+
.sort((a, b) => a.since - b.since)
|
|
29
|
+
.slice(0, 20)
|
|
30
|
+
.map(({ what, since }) => `pending ${what} for ${((at - since) / 1000).toFixed(1)}s`);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function loaded(targetId) {
|
|
34
|
+
requests.delete(targetId);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
return { watch, loaded, pending };
|
|
38
|
+
}
|
package/harness/page.mjs
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { writeFile } from 'node:fs/promises';
|
|
2
|
+
import { flights } from './flight.mjs';
|
|
2
3
|
import { sleep } from './util.mjs';
|
|
3
4
|
|
|
4
5
|
const masks = { alt: 1, ctrl: 2, meta: 4, shift: 8 };
|
|
@@ -99,8 +100,12 @@ export function page(
|
|
|
99
100
|
const errors = [];
|
|
100
101
|
const responses = [];
|
|
101
102
|
const bindings = new Map();
|
|
102
|
-
const
|
|
103
|
+
const flying = flights();
|
|
104
|
+
const dispose = cdp.on((message) => {
|
|
105
|
+
const { method, params, sessionId: from } = message;
|
|
106
|
+
if (method === 'Inspector.workerScriptLoaded') flying.loaded(cdp.target(from)?.targetId);
|
|
103
107
|
if (from !== sessionId) return;
|
|
108
|
+
flying.watch(message);
|
|
104
109
|
if (method === 'Runtime.exceptionThrown') {
|
|
105
110
|
const { exception, text } = params.exceptionDetails;
|
|
106
111
|
errors.push(exception?.description ?? text);
|
|
@@ -112,8 +117,9 @@ export function page(
|
|
|
112
117
|
async function capture() {
|
|
113
118
|
stalls += 1;
|
|
114
119
|
const tag = `stall-${stalls}`;
|
|
120
|
+
const pending = flying.pending();
|
|
115
121
|
const shot = await send('Page.captureScreenshot', { format: 'png' }, 10000).catch(() => null);
|
|
116
|
-
await stall(tag, shot?.data ? Buffer.from(shot.data, 'base64') : null);
|
|
122
|
+
await stall(tag, shot?.data ? Buffer.from(shot.data, 'base64') : null, pending);
|
|
117
123
|
}
|
|
118
124
|
|
|
119
125
|
function probe() {
|
|
@@ -151,6 +157,7 @@ export function page(
|
|
|
151
157
|
last = await run(fn, args, left);
|
|
152
158
|
if (last) return last;
|
|
153
159
|
} catch (error) {
|
|
160
|
+
if (error.fatal) throw error;
|
|
154
161
|
last = error.message;
|
|
155
162
|
}
|
|
156
163
|
await sleep(25);
|
|
@@ -178,7 +185,7 @@ export function page(
|
|
|
178
185
|
await until(fresh);
|
|
179
186
|
return;
|
|
180
187
|
} catch (error) {
|
|
181
|
-
if (attempt === 1) throw error;
|
|
188
|
+
if (error.fatal || attempt === 1) throw error;
|
|
182
189
|
}
|
|
183
190
|
}
|
|
184
191
|
}
|
|
@@ -187,6 +194,7 @@ export function page(
|
|
|
187
194
|
errors,
|
|
188
195
|
responses,
|
|
189
196
|
dispose,
|
|
197
|
+
send,
|
|
190
198
|
evaluate,
|
|
191
199
|
until,
|
|
192
200
|
within,
|
package/harness/recorder.mjs
CHANGED
|
@@ -2,6 +2,8 @@ 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 { CRASHED } from './cdp.mjs';
|
|
6
|
+
import { trace } from './trace.mjs';
|
|
5
7
|
import { bounded, ignore, raw, samples, sleep, TIMED_OUT } from './util.mjs';
|
|
6
8
|
|
|
7
9
|
const profiled = new Set(['page', 'worker', 'service_worker', 'shared_worker']);
|
|
@@ -104,56 +106,6 @@ function pauseOf(target = {}, params) {
|
|
|
104
106
|
};
|
|
105
107
|
}
|
|
106
108
|
|
|
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
109
|
function claim(run) {
|
|
158
110
|
if (!run || run.traced) return false;
|
|
159
111
|
run.traced = true;
|
|
@@ -272,16 +224,14 @@ export function recorder(cdp, server, { coverage = ['/'], exits = new Map() } =
|
|
|
272
224
|
}
|
|
273
225
|
}
|
|
274
226
|
|
|
275
|
-
async function stall(dir, tag) {
|
|
227
|
+
async function stall(dir, tag, pending = []) {
|
|
276
228
|
const first = claim(run);
|
|
277
229
|
const current = [...sessions].filter(
|
|
278
230
|
([sessionId]) => contextOf(sessions, parents, sessionId) === run?.context
|
|
279
231
|
);
|
|
280
|
-
const
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
]);
|
|
284
|
-
const all = [...lines, ...traced, ...held(pauses)];
|
|
232
|
+
const sampled = () => Promise.all(current.map((entry) => sample(cdp, pauses, entry, dir, tag)));
|
|
233
|
+
const [lines, traced] = first ? await trace(cdp, dir, tag, sampled) : [await sampled(), []];
|
|
234
|
+
const all = [...pending, ...lines, ...traced, ...held(pauses)];
|
|
285
235
|
for (const line of all) run?.console.push(`${tag}: ${line}`);
|
|
286
236
|
return all;
|
|
287
237
|
}
|
|
@@ -308,6 +258,7 @@ export function recorder(cdp, server, { coverage = ['/'], exits = new Map() } =
|
|
|
308
258
|
await paused(sessionId);
|
|
309
259
|
}
|
|
310
260
|
if (method === 'Debugger.resumed') pauses.delete(sessionId);
|
|
261
|
+
if (method === CRASHED) log(sessionId, params.reason);
|
|
311
262
|
});
|
|
312
263
|
|
|
313
264
|
async function open(browserContextId) {
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
import { writeFile } from 'node:fs/promises';
|
|
2
|
+
import { bounded, sleep } from './util.mjs';
|
|
3
|
+
|
|
4
|
+
export const TRACED = [
|
|
5
|
+
'toplevel',
|
|
6
|
+
'ipc',
|
|
7
|
+
'disabled-by-default-ipc.flow',
|
|
8
|
+
'mojom',
|
|
9
|
+
'blink',
|
|
10
|
+
'loading',
|
|
11
|
+
'navigation',
|
|
12
|
+
'devtools.timeline',
|
|
13
|
+
'disabled-by-default-devtools.timeline',
|
|
14
|
+
'v8.execute',
|
|
15
|
+
'disabled-by-default-v8.cpu_profiler',
|
|
16
|
+
];
|
|
17
|
+
|
|
18
|
+
function where({ functionName, url, lineNumber }) {
|
|
19
|
+
const path = URL.canParse(url) ? new URL(url).pathname : url;
|
|
20
|
+
const name = functionName || '(anonymous)';
|
|
21
|
+
return path ? `${name} ${path}:${lineNumber + 1}` : name;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function threads(events) {
|
|
25
|
+
const names = new Map();
|
|
26
|
+
const processes = new Map();
|
|
27
|
+
for (const { name, pid, tid, args } of events) {
|
|
28
|
+
if (name === 'thread_name') names.set(`${pid}:${tid}`, args.name);
|
|
29
|
+
if (name === 'process_name') processes.set(pid, args.name);
|
|
30
|
+
}
|
|
31
|
+
return { names, processes };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function detail(event) {
|
|
35
|
+
const data = event.args?.data ?? event.args?.beginData ?? {};
|
|
36
|
+
const tag = event.args?.chrome_mojo_event_info?.mojo_interface_tag;
|
|
37
|
+
if (typeof data.functionName === 'string') {
|
|
38
|
+
return where({ ...data, lineNumber: (data.lineNumber ?? 1) - 1 });
|
|
39
|
+
}
|
|
40
|
+
return data.url ?? event.args?.url ?? tag ?? '';
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function labels(slices) {
|
|
44
|
+
const named = slices.map((event) => `${event.name} ${detail(event)}`.trim());
|
|
45
|
+
return named
|
|
46
|
+
.filter((name, i) => name !== named[i - 1])
|
|
47
|
+
.slice(0, 10)
|
|
48
|
+
.join(' > ');
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function inside(slices, task) {
|
|
52
|
+
const end = task.ts + task.dur;
|
|
53
|
+
return slices
|
|
54
|
+
.filter(
|
|
55
|
+
({ ph, ts, dur }) => ph === 'X' && ts >= task.ts && ts + dur <= end && dur >= task.dur / 2
|
|
56
|
+
)
|
|
57
|
+
.sort((a, b) => a.ts - b.ts || b.dur - a.dur);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function unmatched(slices) {
|
|
61
|
+
const stacks = new Map();
|
|
62
|
+
for (const event of [...slices].sort((a, b) => a.ts - b.ts)) {
|
|
63
|
+
const stack = stacks.get(event.tid) ?? [];
|
|
64
|
+
if (event.ph === 'B') stack.push(event);
|
|
65
|
+
if (event.ph === 'E') stack.pop();
|
|
66
|
+
stacks.set(event.tid, stack);
|
|
67
|
+
}
|
|
68
|
+
return [...stacks.values()].flat();
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function main(pid, slices, last) {
|
|
72
|
+
const tasks = slices.filter(({ name, ph }) => name === 'RunTask' && ph === 'X');
|
|
73
|
+
const longest = tasks.reduce((most, task) => (task.dur > (most?.dur ?? 0) ? task : most), null);
|
|
74
|
+
const open = unmatched(slices).filter(({ ts }) => ts < last - 50000);
|
|
75
|
+
const head = `renderer ${pid} main thread: ${tasks.length} tasks`;
|
|
76
|
+
if (open.length) return `${head}, still in ${labels(open)}`;
|
|
77
|
+
if (!longest) return head;
|
|
78
|
+
const ms = (longest.dur / 1000).toFixed(1);
|
|
79
|
+
return longest.dur < 50000
|
|
80
|
+
? `${head}, longest ${ms} ms`
|
|
81
|
+
: `${head}, longest ${ms} ms in ${labels(inside(slices, longest))}`;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function mains(events, { names, processes }) {
|
|
85
|
+
const found = new Map();
|
|
86
|
+
const last = events.reduce((most, { ts = 0 }) => Math.max(most, ts), 0);
|
|
87
|
+
for (const event of events) {
|
|
88
|
+
const ours = names.get(`${event.pid}:${event.tid}`) === 'CrRendererMain';
|
|
89
|
+
if (!ours || processes.get(event.pid) !== 'Renderer' || event.ph === 'M') continue;
|
|
90
|
+
found.set(event.pid, [...(found.get(event.pid) ?? []), event]);
|
|
91
|
+
}
|
|
92
|
+
return [...found].map(([pid, slices]) => main(pid, slices, last));
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function stack(nodes, id) {
|
|
96
|
+
const frames = [];
|
|
97
|
+
for (let node = nodes.get(id); node && frames.length < 3; node = nodes.get(node.parent)) {
|
|
98
|
+
frames.push(where(node.callFrame));
|
|
99
|
+
}
|
|
100
|
+
return frames.join(' < ');
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function profiles(events) {
|
|
104
|
+
const streams = new Map();
|
|
105
|
+
for (const event of events) {
|
|
106
|
+
if (event.name !== 'ProfileChunk') continue;
|
|
107
|
+
const key = `${event.pid} ${event.id}`;
|
|
108
|
+
const stream = streams.get(key) ?? { pid: event.pid, nodes: new Map(), counts: new Map() };
|
|
109
|
+
const { nodes = [], samples = [] } = event.args.data.cpuProfile ?? {};
|
|
110
|
+
for (const node of nodes) stream.nodes.set(node.id, node);
|
|
111
|
+
for (const id of samples) stream.counts.set(id, (stream.counts.get(id) ?? 0) + 1);
|
|
112
|
+
streams.set(key, stream);
|
|
113
|
+
}
|
|
114
|
+
return [...streams.values()].filter(({ counts }) => counts.size > 0).map(busiest);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function busiest({ pid, nodes, counts }) {
|
|
118
|
+
const total = [...counts.values()].reduce((sum, n) => sum + n, 0);
|
|
119
|
+
const ranked = [...counts].sort((a, b) => b[1] - a[1]);
|
|
120
|
+
const share = (n) => `${Math.round((100 * n) / total)}%`;
|
|
121
|
+
const [id, most] = ranked[0];
|
|
122
|
+
const named = nodes.has(id) ? stack(nodes, id) : `node ${id}, named before the trace began`;
|
|
123
|
+
const line = `samples in ${pid}: ${share(most)} of ${total} samples in ${named}`;
|
|
124
|
+
const script = ranked.find(
|
|
125
|
+
([n]) => nodes.has(n) && !nodes.get(n).callFrame.functionName.startsWith('(')
|
|
126
|
+
);
|
|
127
|
+
if (!script || script[0] === id || script[1] * 100 < total) return line;
|
|
128
|
+
return `${line}, ${share(script[1])} in ${stack(nodes, script[0])}`;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export function summary(events) {
|
|
132
|
+
const known = threads(events);
|
|
133
|
+
return [...mains(events, known), ...profiles(events)];
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
async function cpuTimes(cdp) {
|
|
137
|
+
const { processInfo = [] } = await cdp
|
|
138
|
+
.send('SystemInfo.getProcessInfo', {}, undefined, 5000)
|
|
139
|
+
.catch(() => ({}));
|
|
140
|
+
return new Map(processInfo.map((info) => [`${info.type} ${info.id}`, info.cpuTime]));
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
async function begin(cdp) {
|
|
144
|
+
const traceConfig = { includedCategories: TRACED };
|
|
145
|
+
try {
|
|
146
|
+
await cdp.send(
|
|
147
|
+
'Tracing.start',
|
|
148
|
+
{ traceConfig, transferMode: 'ReportEvents' },
|
|
149
|
+
undefined,
|
|
150
|
+
10000
|
|
151
|
+
);
|
|
152
|
+
return [];
|
|
153
|
+
} catch (error) {
|
|
154
|
+
if (!/no answer/.test(error.message)) throw error;
|
|
155
|
+
return [`${error.message}, a renderer may be blocked; tracing anyway`];
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export async function trace(cdp, dir, tag, during = () => [], ms = 10000) {
|
|
160
|
+
const events = [];
|
|
161
|
+
let ran = null;
|
|
162
|
+
const done = Promise.withResolvers();
|
|
163
|
+
const off = cdp.on(({ method, params }) => {
|
|
164
|
+
if (method === 'Tracing.dataCollected') events.push(...params.value);
|
|
165
|
+
if (method === 'Tracing.tracingComplete') done.resolve();
|
|
166
|
+
});
|
|
167
|
+
try {
|
|
168
|
+
const before = await cpuTimes(cdp);
|
|
169
|
+
const late = await begin(cdp);
|
|
170
|
+
ran = during();
|
|
171
|
+
await sleep(ms);
|
|
172
|
+
await cdp.send('Tracing.end', {}, undefined, 10000);
|
|
173
|
+
await bounded(done.promise, 'trace', 20000);
|
|
174
|
+
const after = await cpuTimes(cdp);
|
|
175
|
+
await writeFile(new URL(`${tag}-trace.json`, dir), JSON.stringify({ traceEvents: events }));
|
|
176
|
+
const busy = [...after]
|
|
177
|
+
.map(([name, time]) => [name, time - (before.get(name) ?? 0)])
|
|
178
|
+
.filter(([, used]) => used > 0.05)
|
|
179
|
+
.map(([name, used]) => `${name} ${used.toFixed(1)}s`);
|
|
180
|
+
const lines = [
|
|
181
|
+
...late,
|
|
182
|
+
`trace ${tag}-trace.json (${events.length} events)`,
|
|
183
|
+
`cpu over ${ms / 1000}s: ${busy.join(', ') || 'none'}`,
|
|
184
|
+
...summary(events),
|
|
185
|
+
];
|
|
186
|
+
return [await ran, lines];
|
|
187
|
+
} catch (error) {
|
|
188
|
+
return [await (ran ?? during()), [`trace failed: ${error.message}`]];
|
|
189
|
+
} finally {
|
|
190
|
+
off();
|
|
191
|
+
}
|
|
192
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ai-ecoverse/slicc-shared-web",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.9.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",
|