browser-commander 0.27.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 +22 -0
- package/README.md +23 -9
- package/eslint.config.js +11 -0
- package/package.json +5 -2
- package/src/browser/browser-profile-files.js +5 -2
- package/src/browser/connector.js +215 -36
- package/src/browser/launch-options.js +2 -1
- package/src/browser/launch-restrictions.json +33 -2
- package/src/browser/launcher.js +26 -1
- package/src/browser/persistent-session.js +432 -0
- package/src/browser/profile-directory.js +20 -0
- package/src/browser/real-browser.js +7 -2
- package/src/browser/restrictions.js +13 -4
- package/src/browser/session-adoption.js +96 -0
- package/src/browser/session-watchdog.js +71 -0
- package/src/browser/session-worker.js +82 -0
- package/src/browser/webdriver.js +4 -0
- package/src/capture/encoding.js +327 -0
- package/src/capture/errors.js +12 -0
- package/src/capture/files.js +9 -0
- package/src/capture/index.js +5 -0
- package/src/capture/recording.js +136 -0
- package/src/capture/screenshot.js +222 -0
- package/src/capture/video.js +186 -0
- package/src/capture/viewport.js +68 -0
- package/src/capture/worker.js +22 -0
- package/src/cli/args.js +69 -1
- package/src/cli/commands.js +116 -2
- package/src/cli/high-level-methods.js +47 -3
- package/src/cli/sessions.js +14 -3
- package/src/core/commander-features.js +110 -0
- package/src/core/constants.js +1 -0
- package/src/core/navigation-manager.js +4 -2
- package/src/core/navigation-readiness.js +30 -2
- package/src/core/navigation-safety.js +52 -0
- package/src/core/page-trigger-manager.js +280 -156
- package/src/core/readiness.js +10 -2
- package/src/elements/locators.js +26 -32
- package/src/elements/selectors.js +37 -81
- package/src/elements/visibility.js +32 -10
- package/src/factory.js +13 -12
- package/src/index.js +4 -0
- package/src/interactions/click.js +44 -0
- package/src/interactions/overlays.js +73 -0
- package/src/traces/bundle.js +55 -8
- package/src/traces/checkpoints.js +103 -0
- package/src/traces/links.js +157 -2
- package/src/traces/mutation-stream.js +9 -2
- package/src/traces/network.js +309 -0
- package/src/traces/page-capture.js +203 -68
- package/src/traces/reader.js +8 -10
- package/src/traces/recorder.js +253 -247
- package/src/traces/redaction.js +89 -6
- package/src/traces/render.js +168 -0
- package/src/traces/rolling-bundle.js +240 -0
- package/src/traces/rolling.js +77 -0
- package/src/traces/start-cleanup.js +15 -0
- package/src/traces/stop.js +160 -0
- package/src/traces/storage.js +43 -0
- package/src/utilities/subprocess.js +32 -0
- package/tests/e2e/cli.e2e.test.js +20 -1
- package/tests/e2e/click-readiness.e2e.test.js +1 -1
- 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/browser/launch-building-blocks.test.js +5 -1
- package/tests/unit/browser/launcher.test.js +5 -1
- package/tests/unit/browser/persistent-session.test.js +59 -0
- package/tests/unit/browser/quiet-ui.test.js +16 -0
- package/tests/unit/browser/tab-selection.test.js +38 -0
- package/tests/unit/capture/capture.test.js +52 -0
- package/tests/unit/capture/cleanup.test.js +24 -0
- package/tests/unit/capture/viewport.test.js +168 -0
- package/tests/unit/cli/dispatcher.test.js +25 -0
- package/tests/unit/core/interrupted-navigation.test.js +32 -0
- package/tests/unit/core/readiness.test.js +21 -0
- package/tests/unit/core/trigger-readiness.test.js +39 -0
- package/tests/unit/core/trigger-stop-race.test.js +80 -0
- package/tests/unit/elements/visible-count.test.js +24 -0
- package/tests/unit/elements/wait-navigation.test.js +26 -0
- package/tests/unit/interactions/navigation-timeout.test.js +39 -0
- package/tests/unit/interactions/overlays.test.js +58 -0
- package/tests/unit/scripts/ci-timeout-budgets.test.js +1 -0
- package/tests/unit/traces/bundle.test.js +16 -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 +149 -0
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import { createManifest, TRACE_EVENT, TRACE_OUTCOME } from './schema.js';
|
|
2
|
+
import { readTrace } from './reader.js';
|
|
3
|
+
import { writeHar } from './network.js';
|
|
4
|
+
import { gzipTrace } from './storage.js';
|
|
5
|
+
import { abortTraceStart } from './start-cleanup.js';
|
|
6
|
+
export function createTraceStop(context) {
|
|
7
|
+
const {
|
|
8
|
+
getStopped,
|
|
9
|
+
setStopped,
|
|
10
|
+
options,
|
|
11
|
+
detachers,
|
|
12
|
+
navigationCapture,
|
|
13
|
+
video,
|
|
14
|
+
detachNetwork,
|
|
15
|
+
} = context;
|
|
16
|
+
const {
|
|
17
|
+
mutations,
|
|
18
|
+
captureState,
|
|
19
|
+
record,
|
|
20
|
+
note,
|
|
21
|
+
bundle,
|
|
22
|
+
checkpoints,
|
|
23
|
+
linksSink,
|
|
24
|
+
network,
|
|
25
|
+
} = context;
|
|
26
|
+
const {
|
|
27
|
+
recorderMode,
|
|
28
|
+
startedAt,
|
|
29
|
+
now,
|
|
30
|
+
commanderVersion,
|
|
31
|
+
engine,
|
|
32
|
+
domOptions,
|
|
33
|
+
eventSources,
|
|
34
|
+
privacyOptions,
|
|
35
|
+
limits,
|
|
36
|
+
} = context;
|
|
37
|
+
let stopping;
|
|
38
|
+
async function stop(stopOptions = {}) {
|
|
39
|
+
if (getStopped()) {
|
|
40
|
+
return getStopped();
|
|
41
|
+
}
|
|
42
|
+
const { discard = false, error = null } = stopOptions;
|
|
43
|
+
|
|
44
|
+
{
|
|
45
|
+
for (const detach of detachers.splice(0)) {
|
|
46
|
+
try {
|
|
47
|
+
await detach();
|
|
48
|
+
} catch (error) {
|
|
49
|
+
note(`could not detach a listener: ${error.message}`);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
await navigationCapture();
|
|
53
|
+
}
|
|
54
|
+
if (video) {
|
|
55
|
+
const recording = await video.stop();
|
|
56
|
+
if (recording.bytes) {
|
|
57
|
+
await bundle.writeMember(
|
|
58
|
+
`recording.${recording.format}`,
|
|
59
|
+
recording.bytes
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
await detachNetwork();
|
|
64
|
+
await mutations.drain(captureState.index);
|
|
65
|
+
if (error) {
|
|
66
|
+
await record(TRACE_EVENT.PAGE_ERROR, {
|
|
67
|
+
message: error.message ?? String(error),
|
|
68
|
+
stack: error.stack ?? null,
|
|
69
|
+
fatal: true,
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
await record(TRACE_EVENT.TRACE_STOP, { discarded: discard });
|
|
73
|
+
setStopped(true);
|
|
74
|
+
|
|
75
|
+
// The documents that exist stop observing here; the engine's init-script
|
|
76
|
+
// registration is removed with the detachers below.
|
|
77
|
+
await mutations.stop();
|
|
78
|
+
|
|
79
|
+
for (const detach of detachers.reverse()) {
|
|
80
|
+
try {
|
|
81
|
+
await detach();
|
|
82
|
+
} catch (detachError) {
|
|
83
|
+
note(`could not detach a listener: ${detachError.message}`);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const manifest = await bundle.close(
|
|
88
|
+
createManifest({
|
|
89
|
+
mode: recorderMode,
|
|
90
|
+
startedAt,
|
|
91
|
+
stoppedAt: new Date(now()).toISOString(),
|
|
92
|
+
outcome: TRACE_OUTCOME.COMPLETE,
|
|
93
|
+
commanderVersion,
|
|
94
|
+
engine,
|
|
95
|
+
dom: domOptions,
|
|
96
|
+
events: eventSources,
|
|
97
|
+
replay: {
|
|
98
|
+
checkpoints: true,
|
|
99
|
+
mutations: Boolean(domOptions.mutations),
|
|
100
|
+
childListPositions: Boolean(domOptions.mutations),
|
|
101
|
+
liveState:
|
|
102
|
+
Boolean(domOptions.mutations) && domOptions.liveState !== false,
|
|
103
|
+
identifiers: true,
|
|
104
|
+
},
|
|
105
|
+
privacy: {
|
|
106
|
+
redactSelectors: privacyOptions.redactSelectors,
|
|
107
|
+
redactAttributes: privacyOptions.redactAttributes,
|
|
108
|
+
redactQueryParams: privacyOptions.redactQueryParams,
|
|
109
|
+
hasCallback: Boolean(privacyOptions.redact),
|
|
110
|
+
},
|
|
111
|
+
limits,
|
|
112
|
+
})
|
|
113
|
+
);
|
|
114
|
+
|
|
115
|
+
// Closed after the manifest, because the closing link reports the outcome
|
|
116
|
+
// the manifest settled on, and the control diffs are read back out of the
|
|
117
|
+
// finished bundle rather than kept in memory for the length of a run.
|
|
118
|
+
if (linksSink) {
|
|
119
|
+
await linksSink.close({ manifest, bundlePath: bundle.root });
|
|
120
|
+
}
|
|
121
|
+
if (network?.har) {
|
|
122
|
+
await writeHar(
|
|
123
|
+
await readTrace(bundle.root),
|
|
124
|
+
typeof network.har === 'string' ? network.har : undefined
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
if (options.gzip || limits.gzip) {
|
|
128
|
+
await gzipTrace(bundle.root);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const result = {
|
|
132
|
+
path: bundle.root,
|
|
133
|
+
manifest,
|
|
134
|
+
checkpoints: [...checkpoints],
|
|
135
|
+
problems: [...bundle.problems, ...(linksSink?.problems ?? [])],
|
|
136
|
+
links: linksSink?.path ?? null,
|
|
137
|
+
...(bundle.segments?.length ? { segments: bundle.segments } : {}),
|
|
138
|
+
};
|
|
139
|
+
setStopped(result);
|
|
140
|
+
|
|
141
|
+
if (discard) {
|
|
142
|
+
const fs = await import('node:fs/promises');
|
|
143
|
+
await fs.rm(bundle.root, { recursive: true, force: true });
|
|
144
|
+
// An export of a bundle that no longer exists points at nothing.
|
|
145
|
+
await linksSink?.discard();
|
|
146
|
+
result.discarded = true;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
return result;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
return (options) => {
|
|
153
|
+
stopping ??= stop(options).catch(async (error) => {
|
|
154
|
+
setStopped(true);
|
|
155
|
+
await abortTraceStart({ ...context, video: null }).catch(() => {});
|
|
156
|
+
throw error;
|
|
157
|
+
});
|
|
158
|
+
return stopping;
|
|
159
|
+
};
|
|
160
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { gzip, gunzip } from 'node:zlib';
|
|
4
|
+
import { promisify } from 'node:util';
|
|
5
|
+
const compress = promisify(gzip),
|
|
6
|
+
decompress = promisify(gunzip);
|
|
7
|
+
|
|
8
|
+
export async function readTraceText(file) {
|
|
9
|
+
try {
|
|
10
|
+
return await fs.readFile(file, 'utf8');
|
|
11
|
+
} catch (error) {
|
|
12
|
+
if (error.code !== 'ENOENT') {
|
|
13
|
+
throw error;
|
|
14
|
+
}
|
|
15
|
+
try {
|
|
16
|
+
return (await decompress(await fs.readFile(`${file}.gz`))).toString(
|
|
17
|
+
'utf8'
|
|
18
|
+
);
|
|
19
|
+
} catch (error) {
|
|
20
|
+
if (error.code === 'ENOENT') {
|
|
21
|
+
return null;
|
|
22
|
+
}
|
|
23
|
+
throw error;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Compress closed NDJSON members only; active traces remain crash-readable. */
|
|
29
|
+
export async function gzipTrace(root) {
|
|
30
|
+
for (const item of await fs.readdir(root, { withFileTypes: true })) {
|
|
31
|
+
const file = path.join(root, item.name);
|
|
32
|
+
if (item.isDirectory()) {
|
|
33
|
+
await gzipTrace(file);
|
|
34
|
+
} else if (item.name.endsWith('.ndjson')) {
|
|
35
|
+
const temporary = `${file}.gz.tmp`;
|
|
36
|
+
await fs.writeFile(temporary, await compress(await fs.readFile(file)), {
|
|
37
|
+
mode: 0o600,
|
|
38
|
+
});
|
|
39
|
+
await fs.rename(temporary, `${file}.gz`);
|
|
40
|
+
await fs.rm(file);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
@@ -1,5 +1,37 @@
|
|
|
1
1
|
import { ProcessRunner } from 'command-stream/process-runner';
|
|
2
2
|
|
|
3
|
+
/** Start an explicitly persistent process, with no parent-owned pipes or signal forwarding. */
|
|
4
|
+
export async function startDetachedProcess(file, args = [], options = {}) {
|
|
5
|
+
const runner = runnerFor(file, args, { ...options, capture: false });
|
|
6
|
+
runner.start();
|
|
7
|
+
await new Promise((resolve, reject) => {
|
|
8
|
+
runner.child.once('spawn', resolve);
|
|
9
|
+
runner.child.once('error', reject);
|
|
10
|
+
});
|
|
11
|
+
const child = runner.child;
|
|
12
|
+
// command-stream 1.x starts noninteractive children in their own process
|
|
13
|
+
// group. Release its controller-owned cancellation registry while retaining
|
|
14
|
+
// the child's exit listeners. Keep this adapter isolated until it exposes a
|
|
15
|
+
// public detach operation; normal subprocesses retain managed cancellation.
|
|
16
|
+
runner._child = null;
|
|
17
|
+
runner._abortController = null;
|
|
18
|
+
runner.finished = true;
|
|
19
|
+
runner._cleanup();
|
|
20
|
+
Promise.resolve(runner).catch(() => {});
|
|
21
|
+
child.stderrTail = '';
|
|
22
|
+
child.stderr?.on('data', (chunk) => {
|
|
23
|
+
child.stderrTail = (child.stderrTail + String(chunk)).slice(-8192);
|
|
24
|
+
});
|
|
25
|
+
child.on('error', (error) => {
|
|
26
|
+
child.spawnError = error;
|
|
27
|
+
});
|
|
28
|
+
child.unref();
|
|
29
|
+
child.stdout?.unref?.();
|
|
30
|
+
child.stderr?.unref?.();
|
|
31
|
+
child.stdin?.unref?.();
|
|
32
|
+
return child;
|
|
33
|
+
}
|
|
34
|
+
|
|
3
35
|
/**
|
|
4
36
|
* Every subprocess Browser Commander starts - browsers, WebDriver servers, the
|
|
5
37
|
* engine bridge, credential tools (`security`, `secret-tool`, `kwallet-query`,
|
|
@@ -15,6 +15,8 @@
|
|
|
15
15
|
import { describe, it } from 'node:test';
|
|
16
16
|
import assert from 'node:assert';
|
|
17
17
|
import { readFileSync } from 'node:fs';
|
|
18
|
+
import { readFile, rm } from 'node:fs/promises';
|
|
19
|
+
import path from 'node:path';
|
|
18
20
|
import { fileURLToPath } from 'node:url';
|
|
19
21
|
|
|
20
22
|
import { ProcessRunner } from 'command-stream/process-runner';
|
|
@@ -23,6 +25,10 @@ import { normalizeRunOutput } from '../../../tests/cli-contract/normalize.mjs';
|
|
|
23
25
|
import { runCommand } from '../../src/utilities/subprocess.js';
|
|
24
26
|
import { SANDBOX_ARGS } from '../helpers/e2e-browser.js';
|
|
25
27
|
import { repoPath } from '../helpers/repo.js';
|
|
28
|
+
import {
|
|
29
|
+
closeRemoteBrowser,
|
|
30
|
+
SESSION_METADATA,
|
|
31
|
+
} from '../../src/browser/persistent-session.js';
|
|
26
32
|
|
|
27
33
|
const BIN = fileURLToPath(
|
|
28
34
|
new URL('../../bin/browser-commander.js', import.meta.url)
|
|
@@ -124,8 +130,9 @@ for (const engine of ENGINES) {
|
|
|
124
130
|
E2E,
|
|
125
131
|
async () => {
|
|
126
132
|
const { runner, printed } = startKeptOpenBrowser(engine);
|
|
133
|
+
let launched;
|
|
127
134
|
try {
|
|
128
|
-
|
|
135
|
+
launched = await printed;
|
|
129
136
|
assert.match(launched.cdpEndpoint, /^http:\/\/127\.0\.0\.1:\d+$/u);
|
|
130
137
|
const attach = [
|
|
131
138
|
'--cdp-endpoint',
|
|
@@ -153,6 +160,18 @@ for (const engine of ENGINES) {
|
|
|
153
160
|
} finally {
|
|
154
161
|
runner.kill('SIGTERM');
|
|
155
162
|
await Promise.resolve(runner).catch(() => {});
|
|
163
|
+
if (launched?.userDataDir) {
|
|
164
|
+
const file = path.join(launched.userDataDir, SESSION_METADATA);
|
|
165
|
+
const metadata = JSON.parse(await readFile(file, 'utf8'));
|
|
166
|
+
await closeRemoteBrowser(metadata.webSocketDebuggerUrl);
|
|
167
|
+
await assert.rejects(fetch(`${launched.cdpEndpoint}/json/version`));
|
|
168
|
+
// Chrome finishes flushing its profile after closing CDP.
|
|
169
|
+
await rm(launched.userDataDir, {
|
|
170
|
+
recursive: true,
|
|
171
|
+
force: true,
|
|
172
|
+
maxRetries: 5,
|
|
173
|
+
});
|
|
174
|
+
}
|
|
156
175
|
}
|
|
157
176
|
}
|
|
158
177
|
);
|
|
@@ -216,7 +216,7 @@ describe(
|
|
|
216
216
|
});
|
|
217
217
|
|
|
218
218
|
assert.strictEqual(result.ready, false);
|
|
219
|
-
assert.strictEqual(result.status, 'timed_out');
|
|
219
|
+
assert.strictEqual(result.status, 'timed_out', JSON.stringify(result));
|
|
220
220
|
assert.ok(
|
|
221
221
|
elapsed < 6000,
|
|
222
222
|
`the wait took ${elapsed}ms for a 1500ms budget`
|
|
@@ -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>
|
|
@@ -10,9 +10,9 @@
|
|
|
10
10
|
{"sequence":10,"at":"2026-01-01T00:00:00.000Z","monotonicMs":1235,"kind":"interaction","traceId":"trace-1","browserContextId":"context-1","pageId":"page-1","navigationId":"nav-2","actionId":"trace-1-action-1","action":"goto","target":"https://example.com/next?code=abc","durationMs":0,"ok":true}
|
|
11
11
|
{"sequence":11,"at":"2026-01-01T00:00:00.000Z","monotonicMs":1235,"kind":"interaction","traceId":"trace-1","browserContextId":"context-1","pageId":"page-1","navigationId":"nav-2","actionId":"trace-1-action-2","action":"clickButton","target":"#go","durationMs":0,"ok":false,"error":"button #go is not visible"}
|
|
12
12
|
{"sequence":12,"at":"2026-01-01T00:00:00.000Z","monotonicMs":1235,"kind":"dropped","reason":"size-limit","member":"mutations","detail":"2 records over the in-page queue limit"}
|
|
13
|
-
{"sequence":13,"at":"2026-01-01T00:00:00.000Z","monotonicMs":1235,"kind":"mutations","traceId":"trace-1","browserContextId":"context-1","pageId":"page-1","navigationId":"nav-2","member":"mutations/0001.ndjson","batches":2,"checkpoint":1,"frames":1}
|
|
13
|
+
{"sequence":13,"at":"2026-01-01T00:00:00.000Z","monotonicMs":1235,"kind":"mutations","traceId":"trace-1","browserContextId":"context-1","pageId":"page-1","navigationId":"nav-2","member":"mutations/0001.ndjson","batches":2,"checkpoint":1,"frames":1,"truncated":false}
|
|
14
14
|
{"sequence":14,"at":"2026-01-01T00:00:00.000Z","monotonicMs":1235,"kind":"checkpoint","traceId":"trace-1","browserContextId":"context-1","pageId":"page-1","navigationId":"nav-2","index":2,"name":"after click","actor":"automation","reason":"checkpoint","url":"https://example.com/next?token=[redacted]","truncated":true,"members":{"html":"checkpoints/0002.html","state":"checkpoints/0002.state.json","screenshot":"checkpoints/0002.png"}}
|
|
15
15
|
{"sequence":15,"at":"2026-01-01T00:00:00.000Z","monotonicMs":1235,"kind":"interaction","traceId":"trace-1","browserContextId":"context-1","pageId":"page-1","navigationId":"nav-2","action":"note","actor":"caller","authorization":"[redacted]","detail":"uses [redacted]","pageUrl":"https://example.com/?session=[redacted]","count":3,"ratio":0.25,"whole":2,"nested":{"list":[1,"two",null,true]}}
|
|
16
|
-
{"sequence":16,"at":"2026-01-01T00:00:00.000Z","monotonicMs":1235,"kind":"mutations","traceId":"trace-1","browserContextId":"context-1","pageId":"page-1","navigationId":"nav-2","member":"mutations/0002.ndjson","batches":1,"checkpoint":2,"frames":1}
|
|
16
|
+
{"sequence":16,"at":"2026-01-01T00:00:00.000Z","monotonicMs":1235,"kind":"mutations","traceId":"trace-1","browserContextId":"context-1","pageId":"page-1","navigationId":"nav-2","member":"mutations/0002.ndjson","batches":1,"checkpoint":2,"frames":1,"truncated":false}
|
|
17
17
|
{"sequence":17,"at":"2026-01-01T00:00:00.000Z","monotonicMs":1235,"kind":"checkpoint","traceId":"trace-1","browserContextId":"context-1","pageId":"page-1","navigationId":"nav-2","index":3,"name":"final","actor":"caller","reason":"failure","url":"https://example.com/next?token=[redacted]","truncated":true,"members":{"html":"checkpoints/0003.html","state":"checkpoints/0003.state.json","screenshot":"checkpoints/0003.png"}}
|
|
18
18
|
{"sequence":18,"at":"2026-01-01T00:00:00.000Z","monotonicMs":1235,"kind":"trace.stop","traceId":"trace-1","browserContextId":"context-1","pageId":"page-1","navigationId":"nav-2","discarded":false}
|
|
@@ -36,6 +36,7 @@
|
|
|
36
36
|
"privacy": {
|
|
37
37
|
"redactSelectors": [
|
|
38
38
|
"input[type=password]",
|
|
39
|
+
"input[type=hidden]",
|
|
39
40
|
"[data-private]",
|
|
40
41
|
"[data-bc-redact]"
|
|
41
42
|
],
|
|
@@ -61,6 +62,9 @@
|
|
|
61
62
|
"session",
|
|
62
63
|
"signature",
|
|
63
64
|
"token",
|
|
65
|
+
"csrf",
|
|
66
|
+
"xsrf",
|
|
67
|
+
"_xsrf",
|
|
64
68
|
"ticket"
|
|
65
69
|
],
|
|
66
70
|
"hasCallback": false
|