browser-commander 0.17.2 → 0.19.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/.jscpd-baseline.json +12 -17
- package/CHANGELOG.md +68 -0
- package/README.md +132 -0
- package/eslint.config.js +5 -0
- package/package.json +5 -1
- package/src/bindings.js +13 -1
- package/src/browser/connector.js +15 -3
- package/src/browser/launcher.js +14 -2
- package/src/browser/navigation.js +63 -0
- package/src/core/dialog-manager.js +48 -0
- package/src/core/navigation-manager.js +83 -87
- package/src/core/navigation-readiness.js +133 -0
- package/src/core/readiness.js +478 -0
- package/src/downloads/attach.js +58 -0
- package/src/downloads/destination.js +125 -0
- package/src/downloads/manager.js +476 -0
- package/src/downloads/naming.js +246 -0
- package/src/downloads/sources.js +238 -0
- package/src/downloads/staging.js +146 -0
- package/src/downloads/store.js +256 -0
- package/src/exports.js +86 -0
- package/src/factory.js +55 -0
- package/src/interactions/click-activation.js +317 -0
- package/src/interactions/click-result.js +108 -0
- package/src/interactions/click.js +766 -352
- package/src/tests/index.js +75 -11
- package/src/tests/tracing.js +236 -0
- package/src/traces/bundle.js +338 -0
- package/src/traces/deadline.js +33 -0
- package/src/traces/identity.js +127 -0
- package/src/traces/init-script.js +95 -0
- package/src/traces/links.js +623 -0
- package/src/traces/mutation-stream.js +232 -0
- package/src/traces/observers.js +272 -0
- package/src/traces/page-capture.js +601 -0
- package/src/traces/reader.js +226 -0
- package/src/traces/recorder.js +454 -0
- package/src/traces/redaction.js +231 -0
- package/src/traces/schema.js +229 -0
- package/src/traces/viewer.js +448 -0
- package/tests/e2e/click-readiness.e2e.test.js +240 -0
- package/tests/e2e/downloads.e2e.test.js +235 -0
- package/tests/e2e/traces.e2e.test.js +742 -0
- package/tests/helpers/click-fixtures.js +62 -0
- package/tests/helpers/download-fixtures.js +125 -0
- package/tests/helpers/download-server.js +129 -0
- package/tests/helpers/e2e-browser.js +67 -0
- package/tests/helpers/file-modes.js +31 -0
- package/tests/helpers/fixture-server.js +56 -0
- package/tests/helpers/mocks.js +63 -66
- package/tests/helpers/readiness-server.js +96 -0
- package/tests/helpers/temp-directory.js +44 -0
- package/tests/helpers/trace-fixtures.js +274 -0
- package/tests/helpers/trace-server.js +173 -0
- package/tests/unit/core/dialog-manager.test.js +118 -10
- package/tests/unit/core/navigation-manager.test.js +162 -0
- package/tests/unit/core/readiness.test.js +517 -0
- package/tests/unit/downloads/attach.test.js +220 -0
- package/tests/unit/downloads/destination.test.js +114 -0
- package/tests/unit/downloads/manager.test.js +485 -0
- package/tests/unit/downloads/naming.test.js +174 -0
- package/tests/unit/downloads/staging.test.js +117 -0
- package/tests/unit/downloads/store.test.js +215 -0
- package/tests/unit/interactions/click-activation.test.js +283 -0
- package/tests/unit/interactions/click-result.test.js +90 -0
- package/tests/unit/interactions/click.test.js +409 -200
- package/tests/unit/tests/tracing.test.js +299 -0
- package/tests/unit/traces/bundle.test.js +260 -0
- package/tests/unit/traces/links.test.js +557 -0
- package/tests/unit/traces/reader.test.js +208 -0
- package/tests/unit/traces/recorder.test.js +730 -0
- package/tests/unit/traces/redaction.test.js +170 -0
- package/tests/unit/traces/schema.test.js +92 -0
- package/tests/unit/traces/viewer.test.js +294 -0
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The continuous half of a trace (issue #93).
|
|
3
|
+
*
|
|
4
|
+
* A checkpoint is a photograph; this is the film between two of them. Keeping
|
|
5
|
+
* it here rather than inside the recorder is what lets it answer the three
|
|
6
|
+
* questions issue #93 said it could not: what happened while a new document was
|
|
7
|
+
* loading, what happened inside an iframe, and what happened to state the DOM
|
|
8
|
+
* never mentions.
|
|
9
|
+
*
|
|
10
|
+
* The in-page half lives in `page-capture.js` and is installed twice over: once
|
|
11
|
+
* into every document that already exists, and once as an init script so every
|
|
12
|
+
* document created from now on is observed before its own code runs.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { withDeadline } from './deadline.js';
|
|
16
|
+
import { framesOf, installInitScript } from './init-script.js';
|
|
17
|
+
import {
|
|
18
|
+
drainMutationsInPage,
|
|
19
|
+
installMutationRecorderInPage,
|
|
20
|
+
RECORDER_GLOBAL,
|
|
21
|
+
stopMutationRecorderInPage,
|
|
22
|
+
} from './page-capture.js';
|
|
23
|
+
import { REDACTED } from './redaction.js';
|
|
24
|
+
import { TRACE_DROP_REASON, TRACE_EVENT } from './schema.js';
|
|
25
|
+
|
|
26
|
+
/** In-page records kept before the recorder starts counting drops instead. */
|
|
27
|
+
export const DEFAULT_MAX_QUEUED_MUTATIONS = 5000;
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Build the recorder's continuous stream.
|
|
31
|
+
*
|
|
32
|
+
* @param {Object} options - Collaborators the stream needs
|
|
33
|
+
* @param {Object} options.page - The page being traced
|
|
34
|
+
* @param {Object} options.bundle - Open trace bundle
|
|
35
|
+
* @param {Function} options.record - Writes one timeline record
|
|
36
|
+
* @param {Object} options.identity - Trace identity, see `identity.js`
|
|
37
|
+
* @param {Function} options.note - Reports something that could not be done
|
|
38
|
+
* @param {Object} options.domOptions - Resolved `dom` options
|
|
39
|
+
* @param {Object} options.privacyOptions - Resolved privacy options
|
|
40
|
+
* @param {Object} options.limits - Resolved limits
|
|
41
|
+
* @param {number} options.captureTimeoutMs - Budget for one drain
|
|
42
|
+
* @param {Function} options.evaluateInPage - Fallback for a page without frames
|
|
43
|
+
* @returns {Object} `{install, installPersistent, drain, stop}`
|
|
44
|
+
*/
|
|
45
|
+
export function createMutationStream(options) {
|
|
46
|
+
const {
|
|
47
|
+
page,
|
|
48
|
+
bundle,
|
|
49
|
+
record,
|
|
50
|
+
identity,
|
|
51
|
+
note,
|
|
52
|
+
domOptions,
|
|
53
|
+
privacyOptions,
|
|
54
|
+
limits,
|
|
55
|
+
captureTimeoutMs,
|
|
56
|
+
evaluateInPage,
|
|
57
|
+
} = options;
|
|
58
|
+
|
|
59
|
+
const enabled = Boolean(domOptions.mutations);
|
|
60
|
+
|
|
61
|
+
/** What the in-page recorder is built with, wherever it is installed. */
|
|
62
|
+
const recorderOptions = {
|
|
63
|
+
globalName: RECORDER_GLOBAL,
|
|
64
|
+
redactSelectors: privacyOptions.redactSelectors,
|
|
65
|
+
redacted: REDACTED,
|
|
66
|
+
maxQueued: limits.maxQueuedMutations ?? DEFAULT_MAX_QUEUED_MUTATIONS,
|
|
67
|
+
liveState: domOptions.liveState !== false,
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Run something in every frame of the page.
|
|
72
|
+
*
|
|
73
|
+
* An iframe is a separate document with a separate observer, so a trace that
|
|
74
|
+
* only ever spoke to the main frame recorded nothing that happened inside
|
|
75
|
+
* one. A frame that goes away mid-call is expected rather than a failure: a
|
|
76
|
+
* frame that has already gone has nothing left to report.
|
|
77
|
+
*
|
|
78
|
+
* @param {Function} fn - The function to evaluate
|
|
79
|
+
* @param {*} argument - Its argument
|
|
80
|
+
* @returns {Promise<Array>} One result per frame that answered
|
|
81
|
+
*/
|
|
82
|
+
async function evaluateInFrames(fn, argument) {
|
|
83
|
+
const frames = framesOf(page);
|
|
84
|
+
if (frames.length === 0) {
|
|
85
|
+
return [await evaluateInPage(fn, argument)];
|
|
86
|
+
}
|
|
87
|
+
const results = await Promise.all(
|
|
88
|
+
frames.map(async (frame) => {
|
|
89
|
+
try {
|
|
90
|
+
return await frame.evaluate(fn, argument);
|
|
91
|
+
} catch {
|
|
92
|
+
return null;
|
|
93
|
+
}
|
|
94
|
+
})
|
|
95
|
+
);
|
|
96
|
+
return results.filter((result) => result !== null);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
async function dropped(
|
|
100
|
+
member,
|
|
101
|
+
error,
|
|
102
|
+
reason = TRACE_DROP_REASON.CAPTURE_FAILED
|
|
103
|
+
) {
|
|
104
|
+
await bundle.drop({
|
|
105
|
+
reason,
|
|
106
|
+
member,
|
|
107
|
+
detail: error.message ?? String(error),
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Install the recorder into the documents that already exist.
|
|
113
|
+
*
|
|
114
|
+
* New documents are covered by the init script; this covers the one the trace
|
|
115
|
+
* started in, and any frame that was already open when it did.
|
|
116
|
+
*
|
|
117
|
+
* @returns {Promise<void>} Resolves once every frame has been asked
|
|
118
|
+
*/
|
|
119
|
+
async function install() {
|
|
120
|
+
if (!enabled) {
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
try {
|
|
124
|
+
await evaluateInFrames(installMutationRecorderInPage, recorderOptions);
|
|
125
|
+
} catch (error) {
|
|
126
|
+
await dropped('mutation-recorder', error);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Register the recorder to run before the code of every future document.
|
|
132
|
+
*
|
|
133
|
+
* This is what closes the gap between a navigation and the next checkpoint:
|
|
134
|
+
* without it a new document's own scripts run unobserved, and everything they
|
|
135
|
+
* build looks, on replay, like it had been there all along.
|
|
136
|
+
*
|
|
137
|
+
* @returns {Promise<Function|null>} Detach, when the engine offers one
|
|
138
|
+
*/
|
|
139
|
+
async function installPersistent() {
|
|
140
|
+
if (!enabled) {
|
|
141
|
+
return null;
|
|
142
|
+
}
|
|
143
|
+
try {
|
|
144
|
+
return await installInitScript({
|
|
145
|
+
page,
|
|
146
|
+
fn: installMutationRecorderInPage,
|
|
147
|
+
arg: recorderOptions,
|
|
148
|
+
note,
|
|
149
|
+
});
|
|
150
|
+
} catch (error) {
|
|
151
|
+
await dropped('mutation-recorder-init', error);
|
|
152
|
+
return null;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Take everything every frame has queued and write it as one interval.
|
|
158
|
+
*
|
|
159
|
+
* @param {number} index - Checkpoint the interval ends at
|
|
160
|
+
* @returns {Promise<string|null>} The member written, when there was anything
|
|
161
|
+
*/
|
|
162
|
+
async function drain(index) {
|
|
163
|
+
if (!enabled) {
|
|
164
|
+
return null;
|
|
165
|
+
}
|
|
166
|
+
try {
|
|
167
|
+
const drained = await withDeadline(
|
|
168
|
+
evaluateInFrames(drainMutationsInPage, RECORDER_GLOBAL),
|
|
169
|
+
captureTimeoutMs,
|
|
170
|
+
'trace mutation drain'
|
|
171
|
+
);
|
|
172
|
+
|
|
173
|
+
const batches = [];
|
|
174
|
+
let over = 0;
|
|
175
|
+
for (const frame of drained) {
|
|
176
|
+
over += frame?.dropped ?? 0;
|
|
177
|
+
for (const batch of frame?.batches ?? []) {
|
|
178
|
+
// The identity of the trace comes from here and the identity of the
|
|
179
|
+
// document comes from inside it: a frame handle is not something an
|
|
180
|
+
// NDJSON record can hold, so each document names itself.
|
|
181
|
+
batches.push({ ...identity.owner(), ...batch });
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
if (over) {
|
|
186
|
+
await dropped(
|
|
187
|
+
'mutations',
|
|
188
|
+
new Error(`${over} records over the in-page queue limit`),
|
|
189
|
+
TRACE_DROP_REASON.SIZE_LIMIT
|
|
190
|
+
);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// Frames are drained in parallel, so the batches come back grouped by
|
|
194
|
+
// frame; one ordered file is what a replay reads.
|
|
195
|
+
batches.sort((left, right) => (left.at ?? 0) - (right.at ?? 0));
|
|
196
|
+
|
|
197
|
+
const member = await bundle.writeMutations(index, batches);
|
|
198
|
+
if (member) {
|
|
199
|
+
await record(TRACE_EVENT.MUTATIONS, {
|
|
200
|
+
member,
|
|
201
|
+
batches: batches.length,
|
|
202
|
+
checkpoint: index,
|
|
203
|
+
frames: drained.length,
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
return member;
|
|
207
|
+
} catch (error) {
|
|
208
|
+
await dropped('mutations', error);
|
|
209
|
+
return null;
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Switch the recorder off in every document that has one.
|
|
215
|
+
*
|
|
216
|
+
* A recorder left observing would keep queueing into a queue nobody drains.
|
|
217
|
+
*
|
|
218
|
+
* @returns {Promise<void>} Resolves once every frame has been asked
|
|
219
|
+
*/
|
|
220
|
+
async function stop() {
|
|
221
|
+
if (!enabled) {
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
224
|
+
try {
|
|
225
|
+
await evaluateInFrames(stopMutationRecorderInPage, RECORDER_GLOBAL);
|
|
226
|
+
} catch (error) {
|
|
227
|
+
note?.(`could not stop the in-page recorder: ${error.message}`);
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
return { install, installPersistent, drain, stop };
|
|
232
|
+
}
|
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The observers behind a trace's shared timeline (issue #87).
|
|
3
|
+
*
|
|
4
|
+
* Navigation, Browser Commander interactions, console output, page errors,
|
|
5
|
+
* dialogs, failed requests and downloads are reported by different objects in
|
|
6
|
+
* different engines. They all end up as records on one ordered timeline, and
|
|
7
|
+
* this module is the only place that knows how each of them is reached.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { TRACE_EVENT } from './schema.js';
|
|
11
|
+
|
|
12
|
+
/** Commander methods recorded as interactions while a trace is running. */
|
|
13
|
+
export const TRACED_INTERACTIONS = Object.freeze([
|
|
14
|
+
'click',
|
|
15
|
+
'clickButton',
|
|
16
|
+
'fill',
|
|
17
|
+
'fillTextArea',
|
|
18
|
+
'goto',
|
|
19
|
+
'pressKey',
|
|
20
|
+
'typeText',
|
|
21
|
+
'setContent',
|
|
22
|
+
'scrollIntoView',
|
|
23
|
+
]);
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* What an interaction was aimed at.
|
|
27
|
+
*
|
|
28
|
+
* A selector and a URL are the useful part of an interaction; typed text is
|
|
29
|
+
* never taken, because that is where passwords are. Most commander methods
|
|
30
|
+
* take an options object, and a few legacy ones take the selector directly,
|
|
31
|
+
* so both shapes are read here.
|
|
32
|
+
*
|
|
33
|
+
* @param {*} argument - The interaction's first argument
|
|
34
|
+
* @returns {string|null} The selector or URL it acted on
|
|
35
|
+
*/
|
|
36
|
+
export function interactionTarget(argument) {
|
|
37
|
+
if (typeof argument === 'string') {
|
|
38
|
+
return argument;
|
|
39
|
+
}
|
|
40
|
+
if (!argument || typeof argument !== 'object') {
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
for (const key of ['selector', 'url', 'locatorOrElement']) {
|
|
44
|
+
if (typeof argument[key] === 'string') {
|
|
45
|
+
return argument[key];
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Subscribe to everything a running trace listens to.
|
|
53
|
+
*
|
|
54
|
+
* @param {Object} options - `{commander, page, eventSources, record, note, identity}`
|
|
55
|
+
* @param {Object} [options.commander] - The commander being traced, when there
|
|
56
|
+
* is one; interactions and managed downloads are reached through it
|
|
57
|
+
* @param {Object} options.page - The page being traced
|
|
58
|
+
* @param {string[]} options.eventSources - Sources the caller asked for
|
|
59
|
+
* @param {Function} options.record - Writes one timeline record
|
|
60
|
+
* @param {Function} options.note - Reports an observer that could not attach
|
|
61
|
+
* @param {Object} [options.identity] - Trace identity, see `identity.js`; the
|
|
62
|
+
* navigation observer is what moves it on to the next navigation, and the
|
|
63
|
+
* interaction observer is what names an action (issue #93)
|
|
64
|
+
* @returns {Function[]} One detach function per attached observer
|
|
65
|
+
*/
|
|
66
|
+
export function attachTimelineObservers(options) {
|
|
67
|
+
const { commander, page, eventSources, record, note, identity } = options;
|
|
68
|
+
const detachers = [];
|
|
69
|
+
|
|
70
|
+
function subscribe(source, attach) {
|
|
71
|
+
if (!eventSources.includes(source)) {
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
try {
|
|
75
|
+
const detach = attach();
|
|
76
|
+
if (typeof detach === 'function') {
|
|
77
|
+
detachers.push(detach);
|
|
78
|
+
}
|
|
79
|
+
} catch (error) {
|
|
80
|
+
note(`could not observe ${source}: ${error.message}`);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function onPage(eventName, listener) {
|
|
85
|
+
page.on?.(eventName, listener);
|
|
86
|
+
return () =>
|
|
87
|
+
page.off?.(eventName, listener) ??
|
|
88
|
+
page.removeListener?.(eventName, listener);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
subscribe('navigation', () => {
|
|
92
|
+
const manager = commander?.navigationManager;
|
|
93
|
+
if (!manager) {
|
|
94
|
+
// Without the manager the engine's own event still gives ordering.
|
|
95
|
+
return onPage('framenavigated', (frame) => {
|
|
96
|
+
const isMain =
|
|
97
|
+
typeof frame?.parentFrame === 'function'
|
|
98
|
+
? frame.parentFrame() === null
|
|
99
|
+
: true;
|
|
100
|
+
// A record's navigation is the one it happened during, so the counter
|
|
101
|
+
// moves on before the record that reports the move is written.
|
|
102
|
+
const navigationId = isMain
|
|
103
|
+
? identity?.navigated()
|
|
104
|
+
: identity?.navigationId;
|
|
105
|
+
void record(TRACE_EVENT.NAVIGATION, {
|
|
106
|
+
phase: 'framenavigated',
|
|
107
|
+
navigationId,
|
|
108
|
+
mainFrame: isMain,
|
|
109
|
+
url: typeof frame?.url === 'function' ? frame.url() : String(frame),
|
|
110
|
+
});
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
const started = (info) =>
|
|
114
|
+
void record(TRACE_EVENT.NAVIGATION, { phase: 'start', ...info });
|
|
115
|
+
const completed = (info) =>
|
|
116
|
+
void record(TRACE_EVENT.NAVIGATION, {
|
|
117
|
+
phase: 'complete',
|
|
118
|
+
// Everything recorded after this belongs to the document that just
|
|
119
|
+
// loaded, which is the distinction a replay needs to stop mixing two
|
|
120
|
+
// pages' changes together (issue #93).
|
|
121
|
+
navigationId: identity?.navigated(),
|
|
122
|
+
...info,
|
|
123
|
+
});
|
|
124
|
+
const changed = (info) =>
|
|
125
|
+
void record(TRACE_EVENT.NAVIGATION, { phase: 'urlchange', ...info });
|
|
126
|
+
manager.on('onNavigationStart', started);
|
|
127
|
+
manager.on('onNavigationComplete', completed);
|
|
128
|
+
manager.on('onUrlChange', changed);
|
|
129
|
+
return () => {
|
|
130
|
+
manager.off?.('onNavigationStart', started);
|
|
131
|
+
manager.off?.('onNavigationComplete', completed);
|
|
132
|
+
manager.off?.('onUrlChange', changed);
|
|
133
|
+
};
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
subscribe('console', () =>
|
|
137
|
+
onPage('console', (message) => {
|
|
138
|
+
void record(TRACE_EVENT.CONSOLE, {
|
|
139
|
+
level: typeof message?.type === 'function' ? message.type() : null,
|
|
140
|
+
text:
|
|
141
|
+
typeof message?.text === 'function'
|
|
142
|
+
? message.text()
|
|
143
|
+
: String(message),
|
|
144
|
+
});
|
|
145
|
+
})
|
|
146
|
+
);
|
|
147
|
+
|
|
148
|
+
subscribe('pageerror', () =>
|
|
149
|
+
onPage('pageerror', (error) => {
|
|
150
|
+
void record(TRACE_EVENT.PAGE_ERROR, {
|
|
151
|
+
message: error?.message ?? String(error),
|
|
152
|
+
stack: error?.stack ?? null,
|
|
153
|
+
});
|
|
154
|
+
})
|
|
155
|
+
);
|
|
156
|
+
|
|
157
|
+
subscribe('requestfailed', () =>
|
|
158
|
+
onPage('requestfailed', (request) => {
|
|
159
|
+
void record(TRACE_EVENT.REQUEST_FAILED, {
|
|
160
|
+
url: typeof request?.url === 'function' ? request.url() : null,
|
|
161
|
+
method: typeof request?.method === 'function' ? request.method() : null,
|
|
162
|
+
failure:
|
|
163
|
+
typeof request?.failure === 'function'
|
|
164
|
+
? (request.failure()?.errorText ?? null)
|
|
165
|
+
: null,
|
|
166
|
+
});
|
|
167
|
+
})
|
|
168
|
+
);
|
|
169
|
+
|
|
170
|
+
subscribe('dialog', () => {
|
|
171
|
+
const manager = commander?.dialogManager;
|
|
172
|
+
if (!manager?.observeDialogs && !manager?.onDialog) {
|
|
173
|
+
return null;
|
|
174
|
+
}
|
|
175
|
+
const listener = (dialog) => {
|
|
176
|
+
void record(TRACE_EVENT.DIALOG, {
|
|
177
|
+
type: typeof dialog?.type === 'function' ? dialog.type() : null,
|
|
178
|
+
message:
|
|
179
|
+
typeof dialog?.message === 'function' ? dialog.message() : null,
|
|
180
|
+
});
|
|
181
|
+
};
|
|
182
|
+
// Recording a dialog must not decide what happens to it: a passive
|
|
183
|
+
// observer leaves the manager's auto-dismissal in place, so a traced run
|
|
184
|
+
// answers dialogs exactly the way the same untraced run would.
|
|
185
|
+
if (manager.observeDialogs) {
|
|
186
|
+
manager.observeDialogs(listener);
|
|
187
|
+
return () => manager.unobserveDialogs?.(listener);
|
|
188
|
+
}
|
|
189
|
+
manager.onDialog(listener);
|
|
190
|
+
return () => manager.offDialog?.(listener);
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
subscribe('download', () => {
|
|
194
|
+
const downloads = commander?.downloads;
|
|
195
|
+
if (!downloads?.on) {
|
|
196
|
+
return null;
|
|
197
|
+
}
|
|
198
|
+
const listeners = ['started', 'completed', 'failed', 'cancelled'].map(
|
|
199
|
+
(phase) => {
|
|
200
|
+
const listener = (artifact) => {
|
|
201
|
+
void record(TRACE_EVENT.DOWNLOAD, {
|
|
202
|
+
phase,
|
|
203
|
+
id: artifact?.id ?? null,
|
|
204
|
+
// The path and checksum are the reference; the bytes stay where
|
|
205
|
+
// the download manager put them.
|
|
206
|
+
suggestedFilename: artifact?.suggestedFilename ?? null,
|
|
207
|
+
path: artifact?.path ?? null,
|
|
208
|
+
checksum: artifact?.checksum ?? null,
|
|
209
|
+
bytes: artifact?.bytes ?? null,
|
|
210
|
+
url: artifact?.url ?? null,
|
|
211
|
+
failure: artifact?.failure ?? null,
|
|
212
|
+
});
|
|
213
|
+
};
|
|
214
|
+
downloads.on(phase, listener);
|
|
215
|
+
return [phase, listener];
|
|
216
|
+
}
|
|
217
|
+
);
|
|
218
|
+
return () => {
|
|
219
|
+
for (const [phase, listener] of listeners) {
|
|
220
|
+
downloads.off?.(phase, listener);
|
|
221
|
+
}
|
|
222
|
+
};
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
subscribe('interaction', () => {
|
|
226
|
+
if (!commander) {
|
|
227
|
+
return null;
|
|
228
|
+
}
|
|
229
|
+
const originals = new Map();
|
|
230
|
+
for (const name of TRACED_INTERACTIONS) {
|
|
231
|
+
const original = commander[name];
|
|
232
|
+
if (typeof original !== 'function') {
|
|
233
|
+
continue;
|
|
234
|
+
}
|
|
235
|
+
originals.set(name, original);
|
|
236
|
+
commander[name] = async (...args) => {
|
|
237
|
+
const startedMs = Date.now();
|
|
238
|
+
const target = interactionTarget(args[0]);
|
|
239
|
+
// Named before the call, so everything the action causes can be traced
|
|
240
|
+
// back to it even when the action itself ends in an exception.
|
|
241
|
+
const actionId = identity?.nextActionId() ?? null;
|
|
242
|
+
try {
|
|
243
|
+
const result = await original(...args);
|
|
244
|
+
await record(TRACE_EVENT.INTERACTION, {
|
|
245
|
+
actionId,
|
|
246
|
+
action: name,
|
|
247
|
+
target,
|
|
248
|
+
durationMs: Date.now() - startedMs,
|
|
249
|
+
ok: true,
|
|
250
|
+
});
|
|
251
|
+
return result;
|
|
252
|
+
} catch (error) {
|
|
253
|
+
await record(TRACE_EVENT.INTERACTION, {
|
|
254
|
+
actionId,
|
|
255
|
+
action: name,
|
|
256
|
+
target,
|
|
257
|
+
durationMs: Date.now() - startedMs,
|
|
258
|
+
ok: false,
|
|
259
|
+
error: error.message,
|
|
260
|
+
});
|
|
261
|
+
throw error;
|
|
262
|
+
}
|
|
263
|
+
};
|
|
264
|
+
}
|
|
265
|
+
return () => {
|
|
266
|
+
for (const [name, original] of originals) {
|
|
267
|
+
commander[name] = original;
|
|
268
|
+
}
|
|
269
|
+
};
|
|
270
|
+
});
|
|
271
|
+
return detachers;
|
|
272
|
+
}
|