browser-commander 0.28.0 → 0.30.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 +7 -20
- package/CHANGELOG.md +12 -0
- package/README.md +27 -10
- package/package.json +1 -1
- package/scripts/run-tests.mjs +3 -1
- package/src/browser/browser-profile-files.js +5 -2
- package/src/browser/connector.js +140 -65
- package/src/browser/launcher.js +19 -8
- package/src/browser/persistent-session.js +110 -38
- package/src/browser/session-adoption.js +96 -0
- package/src/browser/session-worker.js +12 -0
- package/src/cli/args.js +3 -0
- package/src/cli/commands.js +3 -0
- package/src/cli/high-level-methods.js +7 -0
- package/src/cli/sessions.js +3 -0
- package/src/core/commander-features.js +33 -25
- package/src/core/navigation-safety.js +1 -0
- package/src/core/page-trigger-manager.js +173 -134
- package/src/index.js +2 -0
- package/src/interactions/overlays.js +59 -14
- package/src/traces/bundle.js +41 -3
- package/src/traces/checkpoints.js +7 -3
- package/src/traces/commander-actions.js +146 -0
- package/src/traces/debug-session.js +119 -0
- package/src/traces/drain-timer.js +25 -0
- package/src/traces/links.js +44 -5
- package/src/traces/mutation-stream.js +12 -5
- package/src/traces/network.js +65 -13
- package/src/traces/observers.js +50 -46
- package/src/traces/page-capture.js +65 -21
- package/src/traces/recorder.js +79 -28
- package/src/traces/redaction.js +89 -6
- package/src/traces/rolling-bundle.js +240 -0
- package/src/traces/rolling.js +10 -113
- package/src/traces/stop.js +1 -0
- package/src/traces/user-actions.js +405 -0
- package/tests/e2e/issue-160.e2e.test.js +291 -0
- package/tests/e2e/issue-162-recording.e2e.test.js +175 -0
- package/tests/e2e/traces.e2e.test.js +4 -1
- package/tests/fixtures/traces/conformance/expected/bundle/checkpoints/0002.html +1 -1
- package/tests/fixtures/traces/conformance/expected/bundle/checkpoints/0003.html +1 -1
- package/tests/fixtures/traces/conformance/expected/bundle/events.ndjson +4 -4
- package/tests/fixtures/traces/conformance/expected/bundle/manifest.json +4 -0
- package/tests/fixtures/traces/conformance/expected/bundle/viewer.html +1 -1
- package/tests/fixtures/traces/conformance/expected/trace.lino +4 -4
- package/tests/fixtures/traces/conformance/scenario.json +1 -0
- package/tests/helpers/e2e-browser.js +2 -1
- package/tests/unit/browser/issue-160-connector.test.js +157 -0
- package/tests/unit/core/interrupted-navigation.test.js +32 -0
- package/tests/unit/core/trigger-stop-race.test.js +80 -0
- package/tests/unit/interactions/overlays.test.js +58 -0
- package/tests/unit/traces/issue-160-privacy.test.js +110 -0
- package/tests/unit/traces/issue-160-rotation.test.js +140 -0
- package/tests/unit/traces/issue-162-defaults.test.js +223 -0
- package/tests/unit/traces/issue-162-user-actions.test.js +181 -0
- package/tests/unit/traces/network.test.js +55 -3
- package/tests/unit/traces/recorder.test.js +4 -1
|
@@ -0,0 +1,405 @@
|
|
|
1
|
+
import { withDeadline } from './deadline.js';
|
|
2
|
+
import { framesOf, installInitScript } from './init-script.js';
|
|
3
|
+
import { TRACE_EVENT, TRACE_DROP_REASON } from './schema.js';
|
|
4
|
+
|
|
5
|
+
const bindings = new WeakMap();
|
|
6
|
+
const USER_ACTION_BINDING = '__browserCommanderUserActionsEmit__';
|
|
7
|
+
|
|
8
|
+
async function attachBinding(page, route, emit) {
|
|
9
|
+
if (!page.exposeFunction) {
|
|
10
|
+
return null;
|
|
11
|
+
}
|
|
12
|
+
let binding = bindings.get(page);
|
|
13
|
+
if (!binding) {
|
|
14
|
+
const callbacks = new Map();
|
|
15
|
+
binding = {
|
|
16
|
+
callbacks,
|
|
17
|
+
ready: page.exposeFunction(USER_ACTION_BINDING, (id, payload) =>
|
|
18
|
+
callbacks.get(id)?.(payload)
|
|
19
|
+
),
|
|
20
|
+
};
|
|
21
|
+
bindings.set(page, binding);
|
|
22
|
+
}
|
|
23
|
+
await binding.ready;
|
|
24
|
+
binding.callbacks.set(route, emit);
|
|
25
|
+
return () => binding.callbacks.delete(route);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Runs in each document. Event descriptions never read control values. */
|
|
29
|
+
export function installUserActionRecorderInPage(options) {
|
|
30
|
+
const {
|
|
31
|
+
globalName,
|
|
32
|
+
binding,
|
|
33
|
+
maxQueued,
|
|
34
|
+
redactSelectors,
|
|
35
|
+
ignoreSelectors,
|
|
36
|
+
redacted,
|
|
37
|
+
} = options;
|
|
38
|
+
if (window[globalName]?.observing) {
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
const state = {
|
|
42
|
+
observing: true,
|
|
43
|
+
queue: [],
|
|
44
|
+
dropped: 0,
|
|
45
|
+
frameId: `frame-${Math.random().toString(36).slice(2, 10)}`,
|
|
46
|
+
};
|
|
47
|
+
const matches = (node, selectors) => {
|
|
48
|
+
for (let at = node; at; at = at.getRootNode?.()?.host) {
|
|
49
|
+
if (
|
|
50
|
+
selectors.some((selector) => {
|
|
51
|
+
try {
|
|
52
|
+
return Boolean(at.closest?.(selector));
|
|
53
|
+
} catch {
|
|
54
|
+
return false;
|
|
55
|
+
}
|
|
56
|
+
})
|
|
57
|
+
) {
|
|
58
|
+
return true;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
return false;
|
|
62
|
+
};
|
|
63
|
+
const privateNode = (node) => matches(node, redactSelectors);
|
|
64
|
+
const pathOf = (node) => {
|
|
65
|
+
const steps = [];
|
|
66
|
+
let separator = '';
|
|
67
|
+
for (let at = node; at?.nodeType === 1 && steps.length < 20;) {
|
|
68
|
+
const siblings = [...(at.parentElement?.children ?? [])].filter(
|
|
69
|
+
(child) => child.localName === at.localName
|
|
70
|
+
);
|
|
71
|
+
const step = at.id
|
|
72
|
+
? `#${window.CSS.escape(at.id)}`
|
|
73
|
+
: `${at.localName}${siblings.length > 1 ? `:nth-of-type(${siblings.indexOf(at) + 1})` : ''}`;
|
|
74
|
+
steps.unshift(`${step}${separator}`);
|
|
75
|
+
if (at.id) {
|
|
76
|
+
break;
|
|
77
|
+
}
|
|
78
|
+
separator = at.parentElement ? ' > ' : ' >>> ';
|
|
79
|
+
at = at.parentElement ?? at.getRootNode()?.host;
|
|
80
|
+
}
|
|
81
|
+
return steps.join('');
|
|
82
|
+
};
|
|
83
|
+
const visibleText = (node, depth = 0, budget = { remaining: 500 }) => {
|
|
84
|
+
if (
|
|
85
|
+
!node ||
|
|
86
|
+
depth >= 20 ||
|
|
87
|
+
--budget.remaining < 0 ||
|
|
88
|
+
matches(node.nodeType === 1 ? node : node.parentElement, [
|
|
89
|
+
'input',
|
|
90
|
+
'textarea',
|
|
91
|
+
'select',
|
|
92
|
+
'[contenteditable]',
|
|
93
|
+
...ignoreSelectors,
|
|
94
|
+
])
|
|
95
|
+
) {
|
|
96
|
+
return '';
|
|
97
|
+
}
|
|
98
|
+
if (privateNode(node.nodeType === 1 ? node : node.parentElement)) {
|
|
99
|
+
return redacted;
|
|
100
|
+
}
|
|
101
|
+
if (node.nodeType === 3) {
|
|
102
|
+
return node.nodeValue ?? '';
|
|
103
|
+
}
|
|
104
|
+
if (
|
|
105
|
+
node.nodeType === 1 &&
|
|
106
|
+
(!node.getClientRects().length ||
|
|
107
|
+
window.getComputedStyle(node).visibility === 'hidden')
|
|
108
|
+
) {
|
|
109
|
+
return '';
|
|
110
|
+
}
|
|
111
|
+
let text = '';
|
|
112
|
+
for (const child of node.childNodes ?? []) {
|
|
113
|
+
text += visibleText(child, depth + 1, budget);
|
|
114
|
+
if (text.length >= 500) {
|
|
115
|
+
break;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
return text.slice(0, 500);
|
|
119
|
+
};
|
|
120
|
+
const describe = (target) => {
|
|
121
|
+
const secret = privateNode(target);
|
|
122
|
+
const text = secret ? redacted : visibleText(target).trim();
|
|
123
|
+
const labelled = (target.getAttribute('aria-labelledby') ?? '')
|
|
124
|
+
.split(/\s+/)
|
|
125
|
+
.filter(Boolean)
|
|
126
|
+
.map((id) => visibleText(target.getRootNode().getElementById?.(id)))
|
|
127
|
+
.join(' ')
|
|
128
|
+
.trim();
|
|
129
|
+
const label = [...(target.labels ?? [])]
|
|
130
|
+
.slice(0, 20)
|
|
131
|
+
.map((node) => visibleText(node))
|
|
132
|
+
.join(' ')
|
|
133
|
+
.trim();
|
|
134
|
+
const roles = {
|
|
135
|
+
button: 'button',
|
|
136
|
+
a: 'link',
|
|
137
|
+
textarea: 'textbox',
|
|
138
|
+
select: 'combobox',
|
|
139
|
+
form: 'form',
|
|
140
|
+
};
|
|
141
|
+
const inputRole =
|
|
142
|
+
target.type === 'checkbox'
|
|
143
|
+
? 'checkbox'
|
|
144
|
+
: target.type === 'radio'
|
|
145
|
+
? 'radio'
|
|
146
|
+
: ['submit', 'button', 'reset'].includes(target.type)
|
|
147
|
+
? 'button'
|
|
148
|
+
: 'textbox';
|
|
149
|
+
return {
|
|
150
|
+
path: pathOf(target),
|
|
151
|
+
role:
|
|
152
|
+
target.getAttribute('role') ??
|
|
153
|
+
(target.localName === 'input'
|
|
154
|
+
? inputRole
|
|
155
|
+
: (roles[target.localName] ?? null)),
|
|
156
|
+
name: secret
|
|
157
|
+
? redacted
|
|
158
|
+
: (
|
|
159
|
+
labelled ||
|
|
160
|
+
target.getAttribute('aria-label') ||
|
|
161
|
+
label ||
|
|
162
|
+
target.getAttribute('alt') ||
|
|
163
|
+
target.getAttribute('title') ||
|
|
164
|
+
text
|
|
165
|
+
).slice(0, 500),
|
|
166
|
+
text,
|
|
167
|
+
};
|
|
168
|
+
};
|
|
169
|
+
const nonTextKeys =
|
|
170
|
+
/^(?:Enter|Escape|Tab|Backspace|Delete|Insert|Home|End|PageUp|PageDown|Arrow(?:Up|Down|Left|Right)|F(?:[1-9]|1\d|2[0-4]))$/;
|
|
171
|
+
const listener = (event) => {
|
|
172
|
+
if (
|
|
173
|
+
!event.isTrusted ||
|
|
174
|
+
(event.type === 'keydown' &&
|
|
175
|
+
(!nonTextKeys.test(event.key) || event.isComposing))
|
|
176
|
+
) {
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
const nodes = event.composedPath?.() ?? [event.target];
|
|
180
|
+
let target = nodes.find((node) => node?.nodeType === 1);
|
|
181
|
+
if (!target || matches(target, ignoreSelectors)) {
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
// Only suppress engine input aimed at the commander's current target.
|
|
185
|
+
// A person using another control while an automation call waits is still
|
|
186
|
+
// recorded. Trusted input outside commander methods is always user input.
|
|
187
|
+
const commanderInput = (window.__browserCommanderActions__ ?? []).some(
|
|
188
|
+
(action) => {
|
|
189
|
+
if (!action.types.includes(event.type)) {
|
|
190
|
+
return false;
|
|
191
|
+
}
|
|
192
|
+
if (action.element) {
|
|
193
|
+
return (
|
|
194
|
+
action.element === target ||
|
|
195
|
+
action.element.contains(target) ||
|
|
196
|
+
(event.type === 'click' &&
|
|
197
|
+
event.detail === 0 &&
|
|
198
|
+
action.types.includes('keydown') &&
|
|
199
|
+
action.element.form &&
|
|
200
|
+
action.element.form === target.form) ||
|
|
201
|
+
(event.type === 'submit' && target.contains(action.element))
|
|
202
|
+
);
|
|
203
|
+
}
|
|
204
|
+
if (!action.selector) {
|
|
205
|
+
return event.type === 'keydown';
|
|
206
|
+
}
|
|
207
|
+
try {
|
|
208
|
+
return (
|
|
209
|
+
Boolean(target.closest(action.selector)) ||
|
|
210
|
+
(event.type === 'submit' &&
|
|
211
|
+
Boolean(target.querySelector(action.selector)))
|
|
212
|
+
);
|
|
213
|
+
} catch {
|
|
214
|
+
return false;
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
);
|
|
218
|
+
if (commanderInput) {
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
if (event.type !== 'keydown') {
|
|
222
|
+
target =
|
|
223
|
+
target.closest(
|
|
224
|
+
'button, a, input, select, textarea, form, [role], [contenteditable]'
|
|
225
|
+
) ?? target;
|
|
226
|
+
}
|
|
227
|
+
const payload = {
|
|
228
|
+
source: 'user',
|
|
229
|
+
actor: 'user',
|
|
230
|
+
action: event.type,
|
|
231
|
+
target: describe(target),
|
|
232
|
+
at: new Date().toISOString(),
|
|
233
|
+
url: location.href,
|
|
234
|
+
frameId: state.frameId,
|
|
235
|
+
mainFrame: window.top === window,
|
|
236
|
+
...(event.type === 'keydown'
|
|
237
|
+
? {
|
|
238
|
+
key: event.key,
|
|
239
|
+
altKey: event.altKey,
|
|
240
|
+
ctrlKey: event.ctrlKey,
|
|
241
|
+
metaKey: event.metaKey,
|
|
242
|
+
shiftKey: event.shiftKey,
|
|
243
|
+
}
|
|
244
|
+
: {}),
|
|
245
|
+
};
|
|
246
|
+
const enqueue = () => {
|
|
247
|
+
if (!state.observing) {
|
|
248
|
+
return;
|
|
249
|
+
}
|
|
250
|
+
if (state.queue.length < maxQueued) {
|
|
251
|
+
state.queue.push(payload);
|
|
252
|
+
} else {
|
|
253
|
+
state.dropped += 1;
|
|
254
|
+
}
|
|
255
|
+
};
|
|
256
|
+
if (binding && typeof window[binding] === 'function') {
|
|
257
|
+
// Bindings deliver the action before the app's request/response callbacks;
|
|
258
|
+
// engines without bindings use the bounded polling queue instead.
|
|
259
|
+
Promise.resolve(window[binding](globalName, payload)).catch(enqueue);
|
|
260
|
+
} else {
|
|
261
|
+
enqueue();
|
|
262
|
+
}
|
|
263
|
+
};
|
|
264
|
+
const types = ['click', 'pointerdown', 'keydown', 'submit', 'contextmenu'];
|
|
265
|
+
for (const type of types) {
|
|
266
|
+
document.addEventListener(type, listener, true);
|
|
267
|
+
}
|
|
268
|
+
state.stop = () => {
|
|
269
|
+
state.observing = false;
|
|
270
|
+
for (const type of types) {
|
|
271
|
+
document.removeEventListener(type, listener, true);
|
|
272
|
+
}
|
|
273
|
+
};
|
|
274
|
+
window[globalName] = state;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function drainUserActionsInPage(globalName) {
|
|
278
|
+
const state = window[globalName];
|
|
279
|
+
if (!state) {
|
|
280
|
+
return { actions: [], dropped: 0 };
|
|
281
|
+
}
|
|
282
|
+
const result = { actions: state.queue.splice(0), dropped: state.dropped };
|
|
283
|
+
state.dropped = 0;
|
|
284
|
+
return result;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
function stopUserActionsInPage(globalName) {
|
|
288
|
+
window[globalName]?.stop();
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/** Host half, shared by raw launched pages and commander traces. */
|
|
292
|
+
export function createUserActionStream(options) {
|
|
293
|
+
const {
|
|
294
|
+
page,
|
|
295
|
+
enabled,
|
|
296
|
+
record,
|
|
297
|
+
identity,
|
|
298
|
+
note,
|
|
299
|
+
privacyOptions,
|
|
300
|
+
limits,
|
|
301
|
+
evaluateInPage,
|
|
302
|
+
captureTimeoutMs,
|
|
303
|
+
} = options;
|
|
304
|
+
const globalName = `__browserCommanderUserActions_${identity.traceId}`;
|
|
305
|
+
let active = true,
|
|
306
|
+
detachInit,
|
|
307
|
+
detachBinding;
|
|
308
|
+
const pending = new Set();
|
|
309
|
+
const emit = (payload) => {
|
|
310
|
+
if (!active) {
|
|
311
|
+
return Promise.resolve();
|
|
312
|
+
}
|
|
313
|
+
if (pending.size >= (limits.maxQueuedUserActions ?? 5000)) {
|
|
314
|
+
return Promise.reject(new Error('user action writer busy'));
|
|
315
|
+
}
|
|
316
|
+
const promise = record(TRACE_EVENT.INTERACTION, {
|
|
317
|
+
...payload,
|
|
318
|
+
actionId: identity.nextActionId(),
|
|
319
|
+
});
|
|
320
|
+
pending.add(promise);
|
|
321
|
+
promise.finally(() => pending.delete(promise)).catch(() => {});
|
|
322
|
+
return promise;
|
|
323
|
+
};
|
|
324
|
+
const evaluate = async (fn, arg) => {
|
|
325
|
+
const frames = framesOf(page);
|
|
326
|
+
return await withDeadline(
|
|
327
|
+
frames.length
|
|
328
|
+
? Promise.all(
|
|
329
|
+
frames.map((frame) => frame.evaluate(fn, arg).catch(() => null))
|
|
330
|
+
)
|
|
331
|
+
: Promise.resolve(evaluateInPage(fn, arg)).then((result) => [result]),
|
|
332
|
+
captureTimeoutMs,
|
|
333
|
+
'trace user actions'
|
|
334
|
+
);
|
|
335
|
+
};
|
|
336
|
+
async function install() {
|
|
337
|
+
if (!enabled) {
|
|
338
|
+
return;
|
|
339
|
+
}
|
|
340
|
+
try {
|
|
341
|
+
detachBinding = await attachBinding(page, globalName, emit).catch(
|
|
342
|
+
(error) => {
|
|
343
|
+
note(`using queued user actions: ${error.message}`);
|
|
344
|
+
return null;
|
|
345
|
+
}
|
|
346
|
+
);
|
|
347
|
+
const config = {
|
|
348
|
+
globalName,
|
|
349
|
+
binding: detachBinding ? USER_ACTION_BINDING : null,
|
|
350
|
+
maxQueued: limits.maxQueuedUserActions ?? 5000,
|
|
351
|
+
redactSelectors: privacyOptions.redactSelectors,
|
|
352
|
+
ignoreSelectors: options.ignoreSelectors ?? [],
|
|
353
|
+
redacted: '[redacted]',
|
|
354
|
+
};
|
|
355
|
+
detachInit = await installInitScript({
|
|
356
|
+
page,
|
|
357
|
+
fn: installUserActionRecorderInPage,
|
|
358
|
+
arg: config,
|
|
359
|
+
note,
|
|
360
|
+
});
|
|
361
|
+
await evaluate(installUserActionRecorderInPage, config);
|
|
362
|
+
} catch (error) {
|
|
363
|
+
note(`could not install user action recorder: ${error.message}`);
|
|
364
|
+
await record(TRACE_EVENT.DROPPED, {
|
|
365
|
+
reason: TRACE_DROP_REASON.CAPTURE_FAILED,
|
|
366
|
+
member: 'user-actions',
|
|
367
|
+
detail: error.message,
|
|
368
|
+
});
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
async function drain() {
|
|
372
|
+
if (!enabled) {
|
|
373
|
+
return;
|
|
374
|
+
}
|
|
375
|
+
try {
|
|
376
|
+
for (const result of await evaluate(drainUserActionsInPage, globalName)) {
|
|
377
|
+
for (const action of result?.actions ?? []) {
|
|
378
|
+
await emit(action);
|
|
379
|
+
}
|
|
380
|
+
if (result?.dropped) {
|
|
381
|
+
await record(TRACE_EVENT.DROPPED, {
|
|
382
|
+
reason: TRACE_DROP_REASON.SIZE_LIMIT,
|
|
383
|
+
member: 'user-actions',
|
|
384
|
+
detail: `${result.dropped} user actions over the queue limit`,
|
|
385
|
+
});
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
await Promise.all([...pending]);
|
|
389
|
+
} catch (error) {
|
|
390
|
+
note(`could not drain user actions: ${error.message}`);
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
async function stop() {
|
|
394
|
+
active = false;
|
|
395
|
+
await evaluate(stopUserActionsInPage, globalName).catch((error) =>
|
|
396
|
+
note(error.message)
|
|
397
|
+
);
|
|
398
|
+
try {
|
|
399
|
+
await detachInit?.();
|
|
400
|
+
} finally {
|
|
401
|
+
detachBinding?.();
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
return { install, drain, stop };
|
|
405
|
+
}
|
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
import { it } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import fs from 'node:fs/promises';
|
|
4
|
+
import os from 'node:os';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import net from 'node:net';
|
|
7
|
+
import {
|
|
8
|
+
startTrace,
|
|
9
|
+
readTrace,
|
|
10
|
+
connectOrLaunch,
|
|
11
|
+
connectBrowser,
|
|
12
|
+
} from '../../src/index.js';
|
|
13
|
+
import { isNavigationError } from '../../src/core/navigation-safety.js';
|
|
14
|
+
import { startProcess } from '../../src/utilities/subprocess.js';
|
|
15
|
+
import {
|
|
16
|
+
closeOwnedSession,
|
|
17
|
+
closeRemoteBrowser,
|
|
18
|
+
SESSION_METADATA,
|
|
19
|
+
probeSession,
|
|
20
|
+
} from '../../src/browser/persistent-session.js';
|
|
21
|
+
import {
|
|
22
|
+
launchE2EBrowser,
|
|
23
|
+
CHROME_LAUNCH_OPTIONS,
|
|
24
|
+
PARITY_CHROME,
|
|
25
|
+
} from '../helpers/e2e-browser.js';
|
|
26
|
+
import { sendHtml, startFixtureHost } from '../helpers/fixture-server.js';
|
|
27
|
+
|
|
28
|
+
const options = { skip: !process.env.RUN_E2E, timeout: 60000 };
|
|
29
|
+
async function removeProfile(userDataDir) {
|
|
30
|
+
await fs.rm(userDataDir, {
|
|
31
|
+
recursive: true,
|
|
32
|
+
force: true,
|
|
33
|
+
maxRetries: 10,
|
|
34
|
+
retryDelay: 100,
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
async function port() {
|
|
38
|
+
const server = net.createServer();
|
|
39
|
+
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
|
|
40
|
+
const number = server.address().port;
|
|
41
|
+
await new Promise((resolve) => server.close(resolve));
|
|
42
|
+
return number;
|
|
43
|
+
}
|
|
44
|
+
async function until(predicate) {
|
|
45
|
+
for (let attempt = 0; attempt < 100; attempt++) {
|
|
46
|
+
if (await predicate()) {
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
50
|
+
}
|
|
51
|
+
throw new Error('browser condition did not settle');
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
for (const engine of ['playwright', 'puppeteer']) {
|
|
55
|
+
it(
|
|
56
|
+
`records bounded, private mutations and interrupted goto: ${engine}`,
|
|
57
|
+
options,
|
|
58
|
+
async () => {
|
|
59
|
+
const output = await fs.mkdtemp(path.join(os.tmpdir(), 'bc-160-e2e-'));
|
|
60
|
+
const launched = await launchE2EBrowser({ engine });
|
|
61
|
+
let trace;
|
|
62
|
+
let slowStarted;
|
|
63
|
+
const started = new Promise((resolve) => {
|
|
64
|
+
slowStarted = resolve;
|
|
65
|
+
});
|
|
66
|
+
const server = await startFixtureHost((route, req, res) => {
|
|
67
|
+
if (route === '/private') {
|
|
68
|
+
res.setHeader('content-type', 'application/json');
|
|
69
|
+
res.setHeader('X-Xsrftoken', 'PRIVATE_HEADER');
|
|
70
|
+
res.end(
|
|
71
|
+
JSON.stringify({ token: 'PRIVATE_RESPONSE', public: 'visible' })
|
|
72
|
+
);
|
|
73
|
+
} else if (route === '/slow') {
|
|
74
|
+
slowStarted();
|
|
75
|
+
req.once('close', () => res.destroy());
|
|
76
|
+
} else {
|
|
77
|
+
sendHtml(
|
|
78
|
+
res,
|
|
79
|
+
'<div id="app">public</div><input type="hidden" value="PRIVATE_HIDDEN"><meta name="csrf-token" content="PRIVATE_META"><script>window.xsrfToken="PRIVATE_SCRIPT";</script>'
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
});
|
|
83
|
+
try {
|
|
84
|
+
await launched.page.goto(server.baseUrl);
|
|
85
|
+
trace = await startTrace({
|
|
86
|
+
page: launched.page,
|
|
87
|
+
engine,
|
|
88
|
+
output,
|
|
89
|
+
mode: 'continuous',
|
|
90
|
+
screenshots: false,
|
|
91
|
+
network: { bodies: true, har: true },
|
|
92
|
+
gzip: engine === 'puppeteer',
|
|
93
|
+
privacy: { redactPatterns: [/PRIVATE_DYNAMIC/g] },
|
|
94
|
+
links: { dom: 'full', output: path.join(output, 'trace.lino') },
|
|
95
|
+
});
|
|
96
|
+
await launched.page.evaluate(async () => {
|
|
97
|
+
await fetch('/private?xsrf=PRIVATE_QUERY', {
|
|
98
|
+
method: 'POST',
|
|
99
|
+
headers: {
|
|
100
|
+
'content-type': 'application/json',
|
|
101
|
+
'X-Xsrftoken': 'PRIVATE_HEADER',
|
|
102
|
+
},
|
|
103
|
+
body: JSON.stringify({
|
|
104
|
+
password: 'PRIVATE_PASSWORD',
|
|
105
|
+
_xsrf: 'PRIVATE_BODY',
|
|
106
|
+
public: 'visible',
|
|
107
|
+
}),
|
|
108
|
+
}).then((response) => response.text());
|
|
109
|
+
});
|
|
110
|
+
await launched.page.evaluate(() => {
|
|
111
|
+
const app = document.querySelector('#app');
|
|
112
|
+
app.innerHTML = `<section>${'public'.repeat(1000)}</section>`;
|
|
113
|
+
for (let i = 0; i < 50; i++) {
|
|
114
|
+
const node = document.createElement('span');
|
|
115
|
+
node.textContent = `public-${i} PRIVATE_DYNAMIC`;
|
|
116
|
+
app.appendChild(node);
|
|
117
|
+
}
|
|
118
|
+
document.querySelector('meta').content = 'PRIVATE_CHANGED';
|
|
119
|
+
});
|
|
120
|
+
await trace.checkpoint('after');
|
|
121
|
+
await trace.stop();
|
|
122
|
+
const opened = await readTrace(output);
|
|
123
|
+
const batches = await opened.mutations(opened.checkpoints[0].index);
|
|
124
|
+
const data = JSON.stringify(batches);
|
|
125
|
+
assert.ok(data.length < 60000, `mutation bytes: ${data.length}`);
|
|
126
|
+
for (const batch of batches) {
|
|
127
|
+
for (const record of batch.records ?? []) {
|
|
128
|
+
assert.equal(record.target?.html, undefined);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
const contents = [
|
|
132
|
+
data,
|
|
133
|
+
JSON.stringify(opened.events),
|
|
134
|
+
await fs.readFile(path.join(output, 'trace.lino'), 'utf8'),
|
|
135
|
+
await fs.readFile(path.join(output, 'network.har'), 'utf8'),
|
|
136
|
+
...(await Promise.all(
|
|
137
|
+
opened.checkpoints.map((cp) => opened.html(cp.index))
|
|
138
|
+
)),
|
|
139
|
+
];
|
|
140
|
+
for (const text of contents) {
|
|
141
|
+
assert.doesNotMatch(text, /PRIVATE_/);
|
|
142
|
+
assert.match(text, /public|visible/);
|
|
143
|
+
}
|
|
144
|
+
const interrupted = launched.page
|
|
145
|
+
.goto(`${server.baseUrl}/slow`)
|
|
146
|
+
.catch((error) => error);
|
|
147
|
+
await started;
|
|
148
|
+
await launched.page.goto(server.baseUrl);
|
|
149
|
+
assert.equal(isNavigationError(await interrupted), true);
|
|
150
|
+
} finally {
|
|
151
|
+
await trace?.stop();
|
|
152
|
+
await launched.cleanup();
|
|
153
|
+
await server.close();
|
|
154
|
+
await fs.rm(output, { recursive: true, force: true });
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
it(
|
|
161
|
+
'adopts a verified profile, heartbeats an idle controller and closes later tabs',
|
|
162
|
+
options,
|
|
163
|
+
async () => {
|
|
164
|
+
const userDataDir = await fs.mkdtemp(
|
|
165
|
+
path.join(os.tmpdir(), 'bc-160-session-')
|
|
166
|
+
);
|
|
167
|
+
const remoteDebuggingPort = await port();
|
|
168
|
+
const settings = {
|
|
169
|
+
engine: 'playwright',
|
|
170
|
+
userDataDir,
|
|
171
|
+
remoteDebuggingPort,
|
|
172
|
+
idleTimeoutMs: 2500,
|
|
173
|
+
headless: true,
|
|
174
|
+
...CHROME_LAUNCH_OPTIONS,
|
|
175
|
+
};
|
|
176
|
+
let session, originalOwner;
|
|
177
|
+
try {
|
|
178
|
+
session = await connectOrLaunch(settings);
|
|
179
|
+
originalOwner = JSON.parse(
|
|
180
|
+
await fs.readFile(path.join(userDataDir, SESSION_METADATA), 'utf8')
|
|
181
|
+
);
|
|
182
|
+
const originalUrl = session.page.url();
|
|
183
|
+
const preferred = await session.browser.contexts()[0].newPage();
|
|
184
|
+
await preferred.goto('data:text/html,<p>preferred tab</p>');
|
|
185
|
+
const preferredUrl = preferred.url();
|
|
186
|
+
assert.ok(
|
|
187
|
+
(await session.reusePage({
|
|
188
|
+
urlMatchers: [preferredUrl, originalUrl],
|
|
189
|
+
})) === preferred,
|
|
190
|
+
'ranked URL matchers must override the remembered target'
|
|
191
|
+
);
|
|
192
|
+
await session.detach();
|
|
193
|
+
session = await connectOrLaunch({
|
|
194
|
+
...settings,
|
|
195
|
+
urlMatchers: [originalUrl, preferredUrl],
|
|
196
|
+
});
|
|
197
|
+
assert.equal(session.page.url(), originalUrl);
|
|
198
|
+
await session.detach();
|
|
199
|
+
await fs.rm(path.join(userDataDir, SESSION_METADATA));
|
|
200
|
+
session = await connectOrLaunch({
|
|
201
|
+
...settings,
|
|
202
|
+
adoptExisting: true,
|
|
203
|
+
closeNewTabs: true,
|
|
204
|
+
singleTab: true,
|
|
205
|
+
});
|
|
206
|
+
assert.equal(session.adopted, true);
|
|
207
|
+
const selected = session.page;
|
|
208
|
+
await session.browser
|
|
209
|
+
.contexts()[0]
|
|
210
|
+
.newPage()
|
|
211
|
+
.catch(() => {});
|
|
212
|
+
await until(() => session.browser.contexts()[0].pages().length === 1);
|
|
213
|
+
assert.equal(session.page, selected);
|
|
214
|
+
await selected.close();
|
|
215
|
+
const replacement = await session.reusePage();
|
|
216
|
+
assert.equal(replacement.isClosed(), false);
|
|
217
|
+
await new Promise((resolve) => setTimeout(resolve, 4000));
|
|
218
|
+
assert.ok(await probeSession(`http://127.0.0.1:${remoteDebuggingPort}`));
|
|
219
|
+
await session.detach();
|
|
220
|
+
await closeOwnedSession(
|
|
221
|
+
path.join(userDataDir, SESSION_METADATA),
|
|
222
|
+
JSON.parse(
|
|
223
|
+
await fs.readFile(path.join(userDataDir, SESSION_METADATA), 'utf8')
|
|
224
|
+
)
|
|
225
|
+
);
|
|
226
|
+
} finally {
|
|
227
|
+
await session?.detach();
|
|
228
|
+
const metadataFile = path.join(userDataDir, SESSION_METADATA);
|
|
229
|
+
const metadata = await fs
|
|
230
|
+
.readFile(metadataFile, 'utf8')
|
|
231
|
+
.then(JSON.parse, () => null);
|
|
232
|
+
if (metadata) {
|
|
233
|
+
await closeOwnedSession(metadataFile, metadata);
|
|
234
|
+
} else if (originalOwner) {
|
|
235
|
+
const version = await probeSession(
|
|
236
|
+
`http://127.0.0.1:${remoteDebuggingPort}`
|
|
237
|
+
);
|
|
238
|
+
if (
|
|
239
|
+
version?.webSocketDebuggerUrl === originalOwner.webSocketDebuggerUrl
|
|
240
|
+
) {
|
|
241
|
+
await closeRemoteBrowser(version.webSocketDebuggerUrl);
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
await removeProfile(userDataDir);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
);
|
|
248
|
+
|
|
249
|
+
it(
|
|
250
|
+
'reconnects to headed Chrome with no page targets in its default context',
|
|
251
|
+
options,
|
|
252
|
+
async () => {
|
|
253
|
+
const userDataDir = await fs.mkdtemp(
|
|
254
|
+
path.join(os.tmpdir(), 'bc-160-zero-pages-')
|
|
255
|
+
);
|
|
256
|
+
const remoteDebuggingPort = await port();
|
|
257
|
+
const endpoint = `http://127.0.0.1:${remoteDebuggingPort}`;
|
|
258
|
+
const process = startProcess(PARITY_CHROME, [
|
|
259
|
+
...(CHROME_LAUNCH_OPTIONS.args ?? []),
|
|
260
|
+
`--user-data-dir=${userDataDir}`,
|
|
261
|
+
`--remote-debugging-port=${remoteDebuggingPort}`,
|
|
262
|
+
'--no-startup-window',
|
|
263
|
+
'--keep-alive-for-test',
|
|
264
|
+
'--no-first-run',
|
|
265
|
+
]);
|
|
266
|
+
let connection;
|
|
267
|
+
try {
|
|
268
|
+
await until(() => probeSession(endpoint));
|
|
269
|
+
const targets = await fetch(`${endpoint}/json/list`).then((response) =>
|
|
270
|
+
response.json()
|
|
271
|
+
);
|
|
272
|
+
assert.equal(
|
|
273
|
+
targets.filter((target) => target.type === 'page').length,
|
|
274
|
+
0
|
|
275
|
+
);
|
|
276
|
+
connection = await connectBrowser({
|
|
277
|
+
engine: 'playwright',
|
|
278
|
+
cdpEndpoint: endpoint,
|
|
279
|
+
});
|
|
280
|
+
assert.ok(connection.page);
|
|
281
|
+
await connection.page.goto('data:text/html,<p>public profile page</p>');
|
|
282
|
+
await connection.detach();
|
|
283
|
+
assert.ok(await probeSession(endpoint));
|
|
284
|
+
} finally {
|
|
285
|
+
await connection?.detach();
|
|
286
|
+
process.kill();
|
|
287
|
+
await process.exited;
|
|
288
|
+
await removeProfile(userDataDir);
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
);
|