browser-commander 0.28.0 → 0.29.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 +5 -19
- package/CHANGELOG.md +6 -0
- package/README.md +20 -10
- package/package.json +1 -1
- package/src/browser/browser-profile-files.js +5 -2
- package/src/browser/connector.js +140 -65
- 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 +6 -1
- 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/links.js +44 -5
- package/src/traces/mutation-stream.js +8 -2
- package/src/traces/network.js +65 -13
- package/src/traces/page-capture.js +65 -21
- package/src/traces/recorder.js +33 -3
- 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/tests/e2e/issue-160.e2e.test.js +291 -0
- 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 +2 -2
- 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 +2 -2
- 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/network.test.js +55 -3
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { openTraceBundle } from './bundle.js';
|
|
4
|
+
import { createManifest } from './schema.js';
|
|
5
|
+
|
|
6
|
+
/** Rotate complete bundles at the producer's write boundary, including background events. */
|
|
7
|
+
export async function openRollingBundle(options, config, traceOptions) {
|
|
8
|
+
const root = path.resolve(options.output);
|
|
9
|
+
const maxBytes =
|
|
10
|
+
config.maxBytes ?? options.limits.maxBundleBytes ?? 32 * 1024 * 1024;
|
|
11
|
+
const maxSegments = config.maxSegments ?? Infinity;
|
|
12
|
+
if (
|
|
13
|
+
!Number.isSafeInteger(maxBytes) ||
|
|
14
|
+
maxBytes < 1024 ||
|
|
15
|
+
(maxSegments !== Infinity &&
|
|
16
|
+
(!Number.isSafeInteger(maxSegments) ||
|
|
17
|
+
maxSegments < 1 ||
|
|
18
|
+
maxSegments > 100))
|
|
19
|
+
) {
|
|
20
|
+
throw new RangeError('invalid trace rotation bounds');
|
|
21
|
+
}
|
|
22
|
+
let current,
|
|
23
|
+
sequence = 0,
|
|
24
|
+
pinned = false,
|
|
25
|
+
base = null,
|
|
26
|
+
baseEvent = null;
|
|
27
|
+
let eventSequence = 0;
|
|
28
|
+
const segments = [],
|
|
29
|
+
writers = [];
|
|
30
|
+
let queue = Promise.resolve();
|
|
31
|
+
const run = (fn) => {
|
|
32
|
+
const result = queue.then(fn);
|
|
33
|
+
queue = result.catch(() => {});
|
|
34
|
+
return result;
|
|
35
|
+
};
|
|
36
|
+
const template = () =>
|
|
37
|
+
createManifest({
|
|
38
|
+
mode: traceOptions.mode ?? 'checkpoints',
|
|
39
|
+
engine: traceOptions.commander?.engine,
|
|
40
|
+
startedAt: new Date().toISOString(),
|
|
41
|
+
stoppedAt: new Date().toISOString(),
|
|
42
|
+
dom: traceOptions.dom,
|
|
43
|
+
limits: traceOptions.limits,
|
|
44
|
+
});
|
|
45
|
+
async function open() {
|
|
46
|
+
const name = `segment-${String(++sequence).padStart(6, '0')}`;
|
|
47
|
+
current = await openTraceBundle({
|
|
48
|
+
...options,
|
|
49
|
+
output: path.join(root, name),
|
|
50
|
+
nextSequence: () => ++eventSequence,
|
|
51
|
+
limits: { ...options.limits, maxBundleBytes: Infinity },
|
|
52
|
+
onEvent: (event) => options.onEvent?.(event, { root: current.root }),
|
|
53
|
+
});
|
|
54
|
+
writers.push(current);
|
|
55
|
+
segments.push(name);
|
|
56
|
+
if (segments.length > maxSegments) {
|
|
57
|
+
await fs.rm(path.join(root, segments.shift()), {
|
|
58
|
+
recursive: true,
|
|
59
|
+
force: true,
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
await fs.writeFile(
|
|
63
|
+
path.join(root, 'segments.json.tmp'),
|
|
64
|
+
JSON.stringify({ segments }),
|
|
65
|
+
{ mode: 0o600 }
|
|
66
|
+
);
|
|
67
|
+
await fs.rename(
|
|
68
|
+
path.join(root, 'segments.json.tmp'),
|
|
69
|
+
path.join(root, 'segments.json')
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
async function prepare(bytes, carry = false) {
|
|
73
|
+
if (
|
|
74
|
+
!pinned &&
|
|
75
|
+
current.bytesWritten > 0 &&
|
|
76
|
+
current.bytesWritten + bytes > maxBytes
|
|
77
|
+
) {
|
|
78
|
+
await current.close(template());
|
|
79
|
+
if (sequence === 0) {
|
|
80
|
+
const name = 'segment-000001';
|
|
81
|
+
const destination = path.join(root, name);
|
|
82
|
+
await fs.mkdir(destination, { mode: 0o700 });
|
|
83
|
+
for (const member of [
|
|
84
|
+
'events.ndjson',
|
|
85
|
+
'manifest.json',
|
|
86
|
+
'checkpoints',
|
|
87
|
+
'mutations',
|
|
88
|
+
'artifacts',
|
|
89
|
+
'viewer.html',
|
|
90
|
+
]) {
|
|
91
|
+
await fs
|
|
92
|
+
.rename(path.join(root, member), path.join(destination, member))
|
|
93
|
+
.catch((error) => {
|
|
94
|
+
if (error.code !== 'ENOENT') {
|
|
95
|
+
throw error;
|
|
96
|
+
}
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
sequence = 1;
|
|
100
|
+
segments.push(name);
|
|
101
|
+
}
|
|
102
|
+
await open();
|
|
103
|
+
if (carry && base && baseEvent) {
|
|
104
|
+
const members = await current.writeCheckpoint(base);
|
|
105
|
+
await current.appendEvent({
|
|
106
|
+
...baseEvent,
|
|
107
|
+
members,
|
|
108
|
+
reason: 'rotation-base',
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
await fs.mkdir(root, { recursive: true, mode: 0o700 });
|
|
114
|
+
if (config.lazy) {
|
|
115
|
+
current = await openTraceBundle({
|
|
116
|
+
...options,
|
|
117
|
+
nextSequence: () => ++eventSequence,
|
|
118
|
+
limits: { ...options.limits, maxBundleBytes: Infinity },
|
|
119
|
+
});
|
|
120
|
+
writers.push(current);
|
|
121
|
+
} else {
|
|
122
|
+
await open();
|
|
123
|
+
}
|
|
124
|
+
const counts = () =>
|
|
125
|
+
writers.reduce(
|
|
126
|
+
(sum, b) => {
|
|
127
|
+
for (const k of Object.keys(sum)) {
|
|
128
|
+
sum[k] += b.counts[k];
|
|
129
|
+
}
|
|
130
|
+
return sum;
|
|
131
|
+
},
|
|
132
|
+
{ checkpoints: 0, events: 0, mutationBatches: 0 }
|
|
133
|
+
);
|
|
134
|
+
return {
|
|
135
|
+
root,
|
|
136
|
+
get currentPath() {
|
|
137
|
+
return current.root;
|
|
138
|
+
},
|
|
139
|
+
get segments() {
|
|
140
|
+
return [...segments];
|
|
141
|
+
},
|
|
142
|
+
get counts() {
|
|
143
|
+
return counts();
|
|
144
|
+
},
|
|
145
|
+
get problems() {
|
|
146
|
+
return writers.flatMap((b) => b.problems);
|
|
147
|
+
},
|
|
148
|
+
get dropped() {
|
|
149
|
+
return writers.reduce((sum, b) => sum + b.dropped, 0);
|
|
150
|
+
},
|
|
151
|
+
get bytesWritten() {
|
|
152
|
+
return current.bytesWritten;
|
|
153
|
+
},
|
|
154
|
+
get mutationTruncated() {
|
|
155
|
+
return current.mutationTruncated;
|
|
156
|
+
},
|
|
157
|
+
appendEvent: (event) =>
|
|
158
|
+
run(async () => {
|
|
159
|
+
await prepare(Buffer.byteLength(JSON.stringify(event)) + 256, true);
|
|
160
|
+
const result = await current.appendEvent(event);
|
|
161
|
+
if (event.kind === 'checkpoint') {
|
|
162
|
+
baseEvent = result;
|
|
163
|
+
}
|
|
164
|
+
if (
|
|
165
|
+
event.kind === 'checkpoint' ||
|
|
166
|
+
(event.kind === 'mutations' && pinned === 'mutations')
|
|
167
|
+
) {
|
|
168
|
+
pinned = false;
|
|
169
|
+
}
|
|
170
|
+
return result;
|
|
171
|
+
}),
|
|
172
|
+
writeCheckpoint: (checkpoint) =>
|
|
173
|
+
run(async () => {
|
|
174
|
+
const bytes =
|
|
175
|
+
Buffer.byteLength(checkpoint.html ?? '') +
|
|
176
|
+
Buffer.byteLength(JSON.stringify(checkpoint.state ?? {})) +
|
|
177
|
+
(checkpoint.screenshot?.length ?? 0) +
|
|
178
|
+
1024;
|
|
179
|
+
await prepare(bytes);
|
|
180
|
+
pinned = 'checkpoint';
|
|
181
|
+
base = checkpoint;
|
|
182
|
+
return current.writeCheckpoint(checkpoint);
|
|
183
|
+
}),
|
|
184
|
+
writeMutations: (index, batches) =>
|
|
185
|
+
run(async () => {
|
|
186
|
+
await prepare(Buffer.byteLength(JSON.stringify(batches)) + 256, true);
|
|
187
|
+
const result = await current.writeMutations(index, batches);
|
|
188
|
+
if (pinned !== 'checkpoint') {
|
|
189
|
+
pinned = result ? 'mutations' : false;
|
|
190
|
+
}
|
|
191
|
+
return result;
|
|
192
|
+
}),
|
|
193
|
+
writeMember: (member, contents, writeOptions) =>
|
|
194
|
+
run(async () => {
|
|
195
|
+
await prepare(Buffer.byteLength(contents));
|
|
196
|
+
return current.writeMember(member, contents, writeOptions);
|
|
197
|
+
}),
|
|
198
|
+
writeArtifact: (contents, extension) =>
|
|
199
|
+
run(async () => {
|
|
200
|
+
await prepare(Buffer.byteLength(contents));
|
|
201
|
+
const result = await current.writeArtifact(contents, extension);
|
|
202
|
+
return result
|
|
203
|
+
? {
|
|
204
|
+
...result,
|
|
205
|
+
member: segments.length
|
|
206
|
+
? `${segments.at(-1)}/${result.member}`
|
|
207
|
+
: result.member,
|
|
208
|
+
}
|
|
209
|
+
: null;
|
|
210
|
+
}),
|
|
211
|
+
drop: (record) => run(() => current.drop(record)),
|
|
212
|
+
abort: () => run(() => current.abort()),
|
|
213
|
+
close: (manifest) =>
|
|
214
|
+
run(async () => {
|
|
215
|
+
manifest = await current.close(manifest);
|
|
216
|
+
if (writers.some((writer) => writer.dropped)) {
|
|
217
|
+
manifest.outcome = 'partial';
|
|
218
|
+
}
|
|
219
|
+
// Finished segments get the final capabilities and outcome as well.
|
|
220
|
+
for (const name of segments) {
|
|
221
|
+
const file = path.join(root, name, 'manifest.json');
|
|
222
|
+
const previous = JSON.parse(await fs.readFile(file, 'utf8'));
|
|
223
|
+
await fs.writeFile(
|
|
224
|
+
file,
|
|
225
|
+
JSON.stringify({
|
|
226
|
+
...manifest,
|
|
227
|
+
counts: previous.counts,
|
|
228
|
+
dropped: previous.dropped,
|
|
229
|
+
}),
|
|
230
|
+
{ mode: 0o600 }
|
|
231
|
+
);
|
|
232
|
+
}
|
|
233
|
+
return {
|
|
234
|
+
...manifest,
|
|
235
|
+
counts: counts(),
|
|
236
|
+
dropped: writers.reduce((sum, b) => sum + b.dropped, 0),
|
|
237
|
+
};
|
|
238
|
+
}),
|
|
239
|
+
};
|
|
240
|
+
}
|
package/src/traces/rolling.js
CHANGED
|
@@ -1,120 +1,15 @@
|
|
|
1
|
-
import fs from 'node:fs/promises';
|
|
2
1
|
import path from 'node:path';
|
|
3
2
|
|
|
4
|
-
/**
|
|
3
|
+
/** Keep the ordinary trace handle while routing every write through rotation. */
|
|
5
4
|
export async function startRollingTrace(options, start) {
|
|
5
|
+
const { openRollingBundle } = await import('./rolling-bundle.js');
|
|
6
6
|
const config = options.limits.rotate === true ? {} : options.limits.rotate;
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
!Number.isSafeInteger(maxSegments) ||
|
|
14
|
-
maxSegments < 1 ||
|
|
15
|
-
maxSegments > 100
|
|
16
|
-
) {
|
|
17
|
-
throw new RangeError('invalid trace rotation bounds');
|
|
18
|
-
}
|
|
19
|
-
const root = path.resolve(options.output);
|
|
20
|
-
await fs.mkdir(root, { recursive: true, mode: 0o700 });
|
|
21
|
-
let current,
|
|
22
|
-
sequence = 0,
|
|
23
|
-
stopping,
|
|
24
|
-
failure,
|
|
25
|
-
ticking = false;
|
|
26
|
-
const segments = [];
|
|
27
|
-
let queue = Promise.resolve();
|
|
28
|
-
const enqueue = (action) => {
|
|
29
|
-
const task = queue.then(action);
|
|
30
|
-
queue = task.catch((error) => {
|
|
31
|
-
failure ??= error;
|
|
32
|
-
});
|
|
33
|
-
return task;
|
|
34
|
-
};
|
|
35
|
-
async function open() {
|
|
36
|
-
const name = `segment-${String(++sequence).padStart(6, '0')}`;
|
|
37
|
-
const output = path.join(root, name);
|
|
38
|
-
current = await start({
|
|
39
|
-
...options,
|
|
40
|
-
output,
|
|
41
|
-
limits: { ...options.limits, rotate: false, maxBundleBytes: maxBytes },
|
|
42
|
-
links: options.links
|
|
43
|
-
? { ...options.links, output: path.join(output, 'trace.lino') }
|
|
44
|
-
: null,
|
|
45
|
-
});
|
|
46
|
-
segments.push(name);
|
|
47
|
-
while (segments.length > maxSegments) {
|
|
48
|
-
await fs.rm(path.join(root, segments.shift()), {
|
|
49
|
-
recursive: true,
|
|
50
|
-
force: true,
|
|
51
|
-
});
|
|
52
|
-
}
|
|
53
|
-
const temporary = path.join(root, 'segments.json.tmp');
|
|
54
|
-
await fs.writeFile(temporary, JSON.stringify({ segments }), {
|
|
55
|
-
mode: 0o600,
|
|
56
|
-
});
|
|
57
|
-
await fs.rename(temporary, path.join(root, 'segments.json'));
|
|
58
|
-
}
|
|
59
|
-
await open();
|
|
60
|
-
const timer = setInterval(() => {
|
|
61
|
-
if (!stopping && !ticking) {
|
|
62
|
-
ticking = true;
|
|
63
|
-
enqueue(async () => {
|
|
64
|
-
if (current.bytesWritten >= maxBytes * 0.8) {
|
|
65
|
-
await current.stop();
|
|
66
|
-
await open();
|
|
67
|
-
}
|
|
68
|
-
})
|
|
69
|
-
.catch(() => clearInterval(timer))
|
|
70
|
-
.finally(() => {
|
|
71
|
-
ticking = false;
|
|
72
|
-
});
|
|
73
|
-
}
|
|
74
|
-
}, 100);
|
|
75
|
-
timer.unref?.();
|
|
76
|
-
return {
|
|
77
|
-
path: root,
|
|
78
|
-
mode: options.mode,
|
|
79
|
-
checkpoint: (...args) =>
|
|
80
|
-
enqueue(() => {
|
|
81
|
-
if (stopping) {
|
|
82
|
-
throw new Error('trace has stopped');
|
|
83
|
-
}
|
|
84
|
-
return current.checkpoint(...args);
|
|
85
|
-
}),
|
|
86
|
-
event: (...args) =>
|
|
87
|
-
enqueue(async () => {
|
|
88
|
-
if (stopping) {
|
|
89
|
-
throw new Error('trace has stopped');
|
|
90
|
-
}
|
|
91
|
-
if (
|
|
92
|
-
current.bytesWritten +
|
|
93
|
-
Buffer.byteLength(JSON.stringify(args)) +
|
|
94
|
-
256 >=
|
|
95
|
-
maxBytes * 0.8
|
|
96
|
-
) {
|
|
97
|
-
await current.stop();
|
|
98
|
-
await open();
|
|
99
|
-
}
|
|
100
|
-
return current.event(...args);
|
|
101
|
-
}),
|
|
102
|
-
stop: (...args) => {
|
|
103
|
-
stopping ??= (async () => {
|
|
104
|
-
clearInterval(timer);
|
|
105
|
-
await queue;
|
|
106
|
-
const result = await current.stop(...args);
|
|
107
|
-
if (args[0]?.discard) {
|
|
108
|
-
await fs.rm(root, { recursive: true, force: true });
|
|
109
|
-
}
|
|
110
|
-
if (failure) {
|
|
111
|
-
throw failure;
|
|
112
|
-
}
|
|
113
|
-
return { ...result, path: root, segments: [...segments] };
|
|
114
|
-
})();
|
|
115
|
-
return stopping;
|
|
116
|
-
},
|
|
117
|
-
};
|
|
7
|
+
return start({
|
|
8
|
+
...options,
|
|
9
|
+
limits: { ...options.limits, rotate: false },
|
|
10
|
+
openBundle: (bundleOptions) =>
|
|
11
|
+
openRollingBundle(bundleOptions, config, options),
|
|
12
|
+
});
|
|
118
13
|
}
|
|
119
14
|
|
|
120
15
|
/** Combine segment timelines while keeping checkpoint member paths distinct. */
|
|
@@ -174,6 +69,8 @@ export async function readRollingTrace(root, index, read) {
|
|
|
174
69
|
locations.get(index)?.opened.html(locations.get(index).index),
|
|
175
70
|
state: (index) =>
|
|
176
71
|
locations.get(index)?.opened.state(locations.get(index).index),
|
|
72
|
+
screenshot: (index) =>
|
|
73
|
+
locations.get(index)?.opened.screenshot(locations.get(index).index),
|
|
177
74
|
mutations: (index) =>
|
|
178
75
|
locations.get(index)?.opened.mutations(locations.get(index).index) ?? [],
|
|
179
76
|
};
|
package/src/traces/stop.js
CHANGED
|
@@ -134,6 +134,7 @@ export function createTraceStop(context) {
|
|
|
134
134
|
checkpoints: [...checkpoints],
|
|
135
135
|
problems: [...bundle.problems, ...(linksSink?.problems ?? [])],
|
|
136
136
|
links: linksSink?.path ?? null,
|
|
137
|
+
...(bundle.segments?.length ? { segments: bundle.segments } : {}),
|
|
137
138
|
};
|
|
138
139
|
setStopped(result);
|
|
139
140
|
|
|
@@ -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
|
+
);
|
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
<!DOCTYPE html>
|
|
2
|
-
<html><body><form><input name="user" value="bob"><textarea>
|
|
2
|
+
<html><body><form><input name="user" value="bob"><textarea>[redacted] note</textarea></form></body></html>
|
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
<!DOCTYPE html>
|
|
2
|
-
<html><body><form><input name="user" value="bob"><textarea>
|
|
2
|
+
<html><body><form><input name="user" value="bob"><textarea>[redacted] note</textarea></form></body></html>
|