browser-commander 0.21.0 → 0.21.1
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.json +2 -1
- package/.prettierignore +1 -0
- package/CHANGELOG.md +14 -0
- package/package.json +4 -2
- package/src/browser/attach/snapshot.js +4 -4
- package/src/browser/browser-cookie-cache.js +14 -2
- package/src/browser/profile-directory.js +109 -6
- package/src/browser/real-browser.js +38 -6
- package/src/cli/args.js +47 -0
- package/src/cli/sessions.js +4 -0
- package/src/traces/bundle.js +3 -2
- package/src/traces/observers.js +14 -5
- package/src/traces/recorder.js +3 -0
- package/src/traces/viewer.js +16 -8
- package/tests/e2e/playwright.e2e.test.js +1 -0
- package/tests/e2e/puppeteer.e2e.test.js +1 -0
- package/tests/fixtures/traces/conformance/expected/bundle/checkpoints/0001.html +2 -0
- package/tests/fixtures/traces/conformance/expected/bundle/checkpoints/0001.png +1 -0
- package/tests/fixtures/traces/conformance/expected/bundle/checkpoints/0001.state.json +54 -0
- package/tests/fixtures/traces/conformance/expected/bundle/checkpoints/0002.html +2 -0
- package/tests/fixtures/traces/conformance/expected/bundle/checkpoints/0002.png +1 -0
- package/tests/fixtures/traces/conformance/expected/bundle/checkpoints/0002.state.json +60 -0
- package/tests/fixtures/traces/conformance/expected/bundle/checkpoints/0003.html +2 -0
- package/tests/fixtures/traces/conformance/expected/bundle/checkpoints/0003.png +1 -0
- package/tests/fixtures/traces/conformance/expected/bundle/checkpoints/0003.state.json +60 -0
- package/tests/fixtures/traces/conformance/expected/bundle/events.ndjson +18 -0
- package/tests/fixtures/traces/conformance/expected/bundle/manifest.json +77 -0
- package/tests/fixtures/traces/conformance/expected/bundle/mutations/0001.ndjson +2 -0
- package/tests/fixtures/traces/conformance/expected/bundle/mutations/0002.ndjson +1 -0
- package/tests/fixtures/traces/conformance/expected/bundle/viewer.html +309 -0
- package/tests/fixtures/traces/conformance/expected/redaction.json +86 -0
- package/tests/fixtures/traces/conformance/expected/trace.lino +27 -0
- package/tests/fixtures/traces/conformance/scenario.json +249 -0
- package/tests/unit/browser/attach/extension-relay.test.js +1 -1
- package/tests/unit/browser/attach/snapshot-launch.test.js +10 -1
- package/tests/unit/browser/attach/snapshot.test.js +23 -4
- package/tests/unit/browser/browser-cookie-lock.test.js +102 -0
- package/tests/unit/browser/launch-building-blocks.test.js +127 -4
- package/tests/unit/browser/migration/index.test.js +1 -1
- package/tests/unit/browser/open-in-user-browser.test.js +1 -1
- package/tests/unit/browser/parity.test.js +1 -1
- package/tests/unit/browser/real-browser.test.js +1 -1
- package/tests/unit/browser/storage-state.test.js +2 -0
- package/tests/unit/cli/main.test.js +30 -2
- package/tests/unit/core/navigation-manager.test.js +11 -1
- package/tests/unit/playwright-protocol-coverage.test.js +231 -0
- package/tests/unit/puppeteer-bindings-coverage.test.js +214 -0
- package/tests/unit/scripts/ci-timeout-budgets.test.js +23 -0
- package/tests/unit/scripts/feature-parity.test.js +111 -5
- package/tests/unit/traces/conformance.test.js +92 -0
- package/tests/unit/traces/reader.test.js +1 -1
- package/tests/unit/traces/recorder.test.js +1 -1
- package/tests/unit/traces/viewer.test.js +78 -0
package/.jscpd.json
CHANGED
package/.prettierignore
CHANGED
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,19 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.21.1
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- 4caa385: Suppress Chromium's default-browser prompt through profile settings on fresh and snapshot launches, and expose configurable Preferences and Local State values.
|
|
8
|
+
|
|
9
|
+
Update the locked brace-expansion dependency to 5.0.12 to resolve its CPU and stack exhaustion advisories.
|
|
10
|
+
|
|
11
|
+
Retry transient Windows credential-lock EPERM within the existing bounded deadline, preserving the underlying error when contention persists.
|
|
12
|
+
|
|
13
|
+
Accept a `monotonic` clock in `startTrace` and use the injected `now` for interaction durations, so a trace recorded with a fixed clock is byte-for-byte reproducible; the Python and Rust recorders are checked against such a recording.
|
|
14
|
+
|
|
15
|
+
Escape recorded text in the offline trace viewer. A recorded page's URL, title, form control values and event fields were inserted into the viewer as HTML, so a page could run script in the viewer of whoever opened its trace.
|
|
16
|
+
|
|
3
17
|
## 0.21.0
|
|
4
18
|
|
|
5
19
|
### Minor Changes
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "browser-commander",
|
|
3
|
-
"version": "0.21.
|
|
3
|
+
"version": "0.21.1",
|
|
4
4
|
"description": "Universal browser automation library that supports both Playwright and Puppeteer with a unified API",
|
|
5
5
|
"main": "src/index.js",
|
|
6
6
|
"bin": {
|
|
@@ -97,7 +97,9 @@
|
|
|
97
97
|
"playwright": "^1.62.1",
|
|
98
98
|
"prettier": "^3.9.6",
|
|
99
99
|
"puppeteer": "^25.10.0",
|
|
100
|
-
"selenium-webdriver": "^4.49.0"
|
|
100
|
+
"selenium-webdriver": "^4.49.0",
|
|
101
|
+
"typescript": "^5.9.3",
|
|
102
|
+
"yaml": "^2.9.1"
|
|
101
103
|
},
|
|
102
104
|
"allowScripts": {
|
|
103
105
|
"better-sqlite3@13.0.3": true,
|
|
@@ -155,9 +155,9 @@ function isInside(child, parent) {
|
|
|
155
155
|
);
|
|
156
156
|
}
|
|
157
157
|
|
|
158
|
-
async function prepareTarget(to, sourceRoot) {
|
|
158
|
+
async function prepareTarget(to, sourceRoot, profile) {
|
|
159
159
|
if (!to) {
|
|
160
|
-
return await createTemporaryUserDataDir();
|
|
160
|
+
return await createTemporaryUserDataDir({ profileDirectory: profile });
|
|
161
161
|
}
|
|
162
162
|
if (isInside(to, sourceRoot)) {
|
|
163
163
|
throw new Error(
|
|
@@ -328,7 +328,7 @@ export async function snapshotUserDataDir({
|
|
|
328
328
|
);
|
|
329
329
|
}
|
|
330
330
|
|
|
331
|
-
const target = await prepareTarget(to, sourceRoot);
|
|
331
|
+
const target = await prepareTarget(to, sourceRoot, profile);
|
|
332
332
|
try {
|
|
333
333
|
return await copyProfile({
|
|
334
334
|
sourceRoot,
|
|
@@ -361,7 +361,7 @@ async function copyProfile({ sourceRoot, profile, target, source }) {
|
|
|
361
361
|
});
|
|
362
362
|
}
|
|
363
363
|
await copier.copyDirectory(profile);
|
|
364
|
-
await prepareUserDataDir(target);
|
|
364
|
+
await prepareUserDataDir(target, { profileDirectory: profile });
|
|
365
365
|
await markExitedCleanly(path.join(target, profile));
|
|
366
366
|
|
|
367
367
|
return {
|
|
@@ -162,13 +162,21 @@ async function wait(milliseconds) {
|
|
|
162
162
|
|
|
163
163
|
async function acquireCredentialLock(lockPath, cachedPath, options) {
|
|
164
164
|
const startedAt = options.now();
|
|
165
|
+
let contention;
|
|
165
166
|
while (options.now() - startedAt <= LOCK_WAIT_MILLISECONDS) {
|
|
166
167
|
try {
|
|
167
168
|
return await open(lockPath, 'wx', 0o600);
|
|
168
169
|
} catch (error) {
|
|
169
|
-
|
|
170
|
+
// Windows can report EPERM while another process's unlinked lock is
|
|
171
|
+
// still pending deletion. Retry within the same bounded lock deadline.
|
|
172
|
+
// Preserve the original error if it proves to be permanent.
|
|
173
|
+
if (
|
|
174
|
+
error.code !== 'EEXIST' &&
|
|
175
|
+
!(options.platform === 'win32' && error.code === 'EPERM')
|
|
176
|
+
) {
|
|
170
177
|
throw error;
|
|
171
178
|
}
|
|
179
|
+
contention = error;
|
|
172
180
|
const cached = await readFreshJson(
|
|
173
181
|
cachedPath,
|
|
174
182
|
options.ttlSeconds,
|
|
@@ -184,7 +192,9 @@ async function acquireCredentialLock(lockPath, cachedPath, options) {
|
|
|
184
192
|
await wait(50);
|
|
185
193
|
}
|
|
186
194
|
}
|
|
187
|
-
throw new Error('timed out waiting for another cookie credential reader'
|
|
195
|
+
throw new Error('timed out waiting for another cookie credential reader', {
|
|
196
|
+
cause: contention,
|
|
197
|
+
});
|
|
188
198
|
}
|
|
189
199
|
|
|
190
200
|
async function loadOrCreateCredential({
|
|
@@ -194,6 +204,7 @@ async function loadOrCreateCredential({
|
|
|
194
204
|
create,
|
|
195
205
|
now,
|
|
196
206
|
metadata,
|
|
207
|
+
platform = process.platform,
|
|
197
208
|
}) {
|
|
198
209
|
if (!cache.enabled) {
|
|
199
210
|
return { key: Buffer.from(await create()), savedAt: now() / 1000 };
|
|
@@ -214,6 +225,7 @@ async function loadOrCreateCredential({
|
|
|
214
225
|
now,
|
|
215
226
|
refresh,
|
|
216
227
|
ttlSeconds: cache.ttlSeconds,
|
|
228
|
+
platform,
|
|
217
229
|
});
|
|
218
230
|
if (acquired.cached) {
|
|
219
231
|
return {
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
|
|
1
|
+
import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
|
|
2
2
|
import os from 'node:os';
|
|
3
3
|
import path from 'node:path';
|
|
4
4
|
|
|
@@ -14,6 +14,7 @@ export const FIRST_RUN_SENTINEL = 'First Run';
|
|
|
14
14
|
|
|
15
15
|
/** Chrome's profile-wide settings file, next to the profile directories. */
|
|
16
16
|
export const LOCAL_STATE_FILE = 'Local State';
|
|
17
|
+
export const PREFERENCES_FILE = path.join('Default', 'Preferences');
|
|
17
18
|
|
|
18
19
|
/**
|
|
19
20
|
* Local State written into a brand-new user data directory.
|
|
@@ -51,11 +52,111 @@ export const TEMPORARY_PROFILE_PREFIX = 'browser-commander-profile-';
|
|
|
51
52
|
* @param {string} userDataDir
|
|
52
53
|
* @returns {Promise<string>} The same directory
|
|
53
54
|
*/
|
|
54
|
-
|
|
55
|
+
function assertObject(value, name) {
|
|
56
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
|
57
|
+
throw new TypeError(`${name} must be a JSON object`);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function deepMerge(target, source) {
|
|
62
|
+
for (const [key, value] of Object.entries(source)) {
|
|
63
|
+
if (['__proto__', 'prototype', 'constructor'].includes(key)) {
|
|
64
|
+
throw new TypeError(`profile setting key ${key} is not allowed`);
|
|
65
|
+
}
|
|
66
|
+
if (value && typeof value === 'object' && !Array.isArray(value)) {
|
|
67
|
+
target[key] = deepMerge(
|
|
68
|
+
target[key] &&
|
|
69
|
+
typeof target[key] === 'object' &&
|
|
70
|
+
!Array.isArray(target[key])
|
|
71
|
+
? target[key]
|
|
72
|
+
: {},
|
|
73
|
+
value
|
|
74
|
+
);
|
|
75
|
+
} else {
|
|
76
|
+
target[key] = value;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return target;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
async function mergeJsonFile(file, defaults, overrides) {
|
|
83
|
+
let current = {};
|
|
84
|
+
try {
|
|
85
|
+
current = JSON.parse(await readFile(file, 'utf8'));
|
|
86
|
+
assertObject(current, file);
|
|
87
|
+
} catch (error) {
|
|
88
|
+
if (error.code !== 'ENOENT') {
|
|
89
|
+
throw error;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
const merged = deepMerge(deepMerge(current, defaults), overrides);
|
|
93
|
+
await mkdir(path.dirname(file), { recursive: true });
|
|
94
|
+
await writeFile(file, JSON.stringify(merged));
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Merge launch settings into a fresh, migrated, or snapshotted profile. */
|
|
98
|
+
export async function configureUserDataDir(
|
|
99
|
+
userDataDir,
|
|
100
|
+
{
|
|
101
|
+
defaultBrowserCheck,
|
|
102
|
+
preferences = {},
|
|
103
|
+
localState = {},
|
|
104
|
+
profileDirectory = 'Default',
|
|
105
|
+
} = {}
|
|
106
|
+
) {
|
|
107
|
+
assertObject(preferences, 'preferences');
|
|
108
|
+
assertObject(localState, 'localState');
|
|
109
|
+
if (
|
|
110
|
+
defaultBrowserCheck !== undefined &&
|
|
111
|
+
typeof defaultBrowserCheck !== 'boolean'
|
|
112
|
+
) {
|
|
113
|
+
throw new TypeError('defaultBrowserCheck must be a boolean');
|
|
114
|
+
}
|
|
115
|
+
if (
|
|
116
|
+
typeof profileDirectory !== 'string' ||
|
|
117
|
+
!profileDirectory ||
|
|
118
|
+
path.basename(profileDirectory) !== profileDirectory ||
|
|
119
|
+
profileDirectory === '.' ||
|
|
120
|
+
profileDirectory === '..'
|
|
121
|
+
) {
|
|
122
|
+
throw new TypeError('profileDirectory must be one profile directory name');
|
|
123
|
+
}
|
|
124
|
+
const profilePreferences = deepMerge(
|
|
125
|
+
{ browser: { check_default_browser: false } },
|
|
126
|
+
preferences
|
|
127
|
+
);
|
|
128
|
+
assertObject(profilePreferences.browser, 'preferences.browser');
|
|
129
|
+
if (defaultBrowserCheck !== undefined) {
|
|
130
|
+
profilePreferences.browser.check_default_browser = defaultBrowserCheck;
|
|
131
|
+
}
|
|
132
|
+
await mergeJsonFile(
|
|
133
|
+
path.join(userDataDir, profileDirectory, 'Preferences'),
|
|
134
|
+
{},
|
|
135
|
+
profilePreferences
|
|
136
|
+
);
|
|
137
|
+
const check = profilePreferences.browser?.check_default_browser === true;
|
|
138
|
+
await mergeJsonFile(
|
|
139
|
+
path.join(userDataDir, LOCAL_STATE_FILE),
|
|
140
|
+
{
|
|
141
|
+
browser: {
|
|
142
|
+
default_browser_infobar_declined_count: check ? 0 : 5,
|
|
143
|
+
default_browser_declined_count: check ? 0 : 5,
|
|
144
|
+
},
|
|
145
|
+
},
|
|
146
|
+
localState
|
|
147
|
+
);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export async function prepareUserDataDir(userDataDir, options = {}) {
|
|
151
|
+
if (options.firstRun !== undefined && typeof options.firstRun !== 'boolean') {
|
|
152
|
+
throw new TypeError('firstRun must be a boolean');
|
|
153
|
+
}
|
|
55
154
|
await mkdir(userDataDir, { recursive: true });
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
155
|
+
if (options.firstRun !== true) {
|
|
156
|
+
await writeFile(path.join(userDataDir, FIRST_RUN_SENTINEL), '', {
|
|
157
|
+
flag: 'a',
|
|
158
|
+
});
|
|
159
|
+
}
|
|
59
160
|
try {
|
|
60
161
|
await writeFile(
|
|
61
162
|
path.join(userDataDir, LOCAL_STATE_FILE),
|
|
@@ -67,6 +168,7 @@ export async function prepareUserDataDir(userDataDir) {
|
|
|
67
168
|
throw error;
|
|
68
169
|
}
|
|
69
170
|
}
|
|
171
|
+
await configureUserDataDir(userDataDir, options);
|
|
70
172
|
return userDataDir;
|
|
71
173
|
}
|
|
72
174
|
|
|
@@ -79,11 +181,12 @@ export async function prepareUserDataDir(userDataDir) {
|
|
|
79
181
|
*/
|
|
80
182
|
export async function createTemporaryUserDataDir({
|
|
81
183
|
parent = os.tmpdir(),
|
|
184
|
+
...options
|
|
82
185
|
} = {}) {
|
|
83
186
|
const userDataDir = await mkdtemp(
|
|
84
187
|
path.join(parent, TEMPORARY_PROFILE_PREFIX)
|
|
85
188
|
);
|
|
86
|
-
return await prepareUserDataDir(userDataDir);
|
|
189
|
+
return await prepareUserDataDir(userDataDir, options);
|
|
87
190
|
}
|
|
88
191
|
|
|
89
192
|
/**
|
|
@@ -21,6 +21,7 @@ import {
|
|
|
21
21
|
watchDevToolsOutput,
|
|
22
22
|
} from './debugging-port.js';
|
|
23
23
|
import {
|
|
24
|
+
configureUserDataDir,
|
|
24
25
|
createTemporaryUserDataDir,
|
|
25
26
|
prepareUserDataDir,
|
|
26
27
|
removeUserDataDir,
|
|
@@ -324,6 +325,9 @@ function createCloser({
|
|
|
324
325
|
* @param {string[]} [options.restrictions] - Opt-in restrictions from launch-restrictions.json, such as 'no-extensions'
|
|
325
326
|
* @param {string[]} [options.args] - Additional browser arguments
|
|
326
327
|
* @param {string[]} [options.extraArgs] - Additional browser arguments appended after args
|
|
328
|
+
* @param {boolean} [options.defaultBrowserCheck=false] - Allow the disposable browser to ask to become the default
|
|
329
|
+
* @param {Object} [options.preferences] - Deep-merged into Default/Preferences before launch
|
|
330
|
+
* @param {Object} [options.localState] - Deep-merged into Local State before launch
|
|
327
331
|
* @param {Object<string,string>} [options.env] - Extra environment for the browser process only
|
|
328
332
|
* @param {boolean} [options.automationParity=true] - Add the AutomationControlled off switch when a custom argument turns the feature on
|
|
329
333
|
* @param {number} [options.startupTimeout=30000] - CDP readiness timeout in milliseconds
|
|
@@ -455,6 +459,7 @@ async function prepareProfile({
|
|
|
455
459
|
requestedUserDataDir,
|
|
456
460
|
argOptions,
|
|
457
461
|
snapshot,
|
|
462
|
+
firstRun,
|
|
458
463
|
}) {
|
|
459
464
|
if (attach) {
|
|
460
465
|
const snapshotLaunch = await prepareSnapshotLaunch({
|
|
@@ -469,8 +474,8 @@ async function prepareProfile({
|
|
|
469
474
|
}
|
|
470
475
|
return {
|
|
471
476
|
userDataDir: requestedUserDataDir
|
|
472
|
-
? await prepareUserDataDir(requestedUserDataDir)
|
|
473
|
-
: await createTemporaryUserDataDir(),
|
|
477
|
+
? await prepareUserDataDir(requestedUserDataDir, { firstRun })
|
|
478
|
+
: await createTemporaryUserDataDir({ firstRun }),
|
|
474
479
|
};
|
|
475
480
|
}
|
|
476
481
|
|
|
@@ -516,6 +521,12 @@ async function runPreLaunchMigration({
|
|
|
516
521
|
}
|
|
517
522
|
}
|
|
518
523
|
|
|
524
|
+
function mergeSeedCookies(seedCookies, migratedCookies) {
|
|
525
|
+
return migratedCookies.length > 0
|
|
526
|
+
? [...(seedCookies ?? []), ...migratedCookies]
|
|
527
|
+
: seedCookies;
|
|
528
|
+
}
|
|
529
|
+
|
|
519
530
|
export async function launchAndConnectRealBrowserWithDependencies(
|
|
520
531
|
options = {},
|
|
521
532
|
dependencies = {}
|
|
@@ -541,6 +552,10 @@ export async function launchAndConnectRealBrowserWithDependencies(
|
|
|
541
552
|
wsEndpoint,
|
|
542
553
|
migrateFrom,
|
|
543
554
|
attach,
|
|
555
|
+
defaultBrowserCheck,
|
|
556
|
+
preferences,
|
|
557
|
+
localState,
|
|
558
|
+
firstRun,
|
|
544
559
|
...connectionOptions
|
|
545
560
|
} = options;
|
|
546
561
|
const argOptions = {
|
|
@@ -573,6 +588,7 @@ export async function launchAndConnectRealBrowserWithDependencies(
|
|
|
573
588
|
requestedUserDataDir,
|
|
574
589
|
argOptions,
|
|
575
590
|
snapshot: dependencies.snapshotUserDataDir,
|
|
591
|
+
firstRun,
|
|
576
592
|
});
|
|
577
593
|
const childEnv = browserEnvironment(restrictions, env);
|
|
578
594
|
|
|
@@ -584,6 +600,22 @@ export async function launchAndConnectRealBrowserWithDependencies(
|
|
|
584
600
|
migrate: dependencies.migrateProfile ?? migrateProfile,
|
|
585
601
|
});
|
|
586
602
|
|
|
603
|
+
// Migration and snapshot attach can copy a preference that enables the
|
|
604
|
+
// default-browser infobar, so apply launch settings after either copy.
|
|
605
|
+
try {
|
|
606
|
+
await configureUserDataDir(userDataDir, {
|
|
607
|
+
defaultBrowserCheck,
|
|
608
|
+
preferences,
|
|
609
|
+
localState,
|
|
610
|
+
profileDirectory: attach?.profile ?? 'Default',
|
|
611
|
+
});
|
|
612
|
+
} catch (error) {
|
|
613
|
+
if (temporaryProfile) {
|
|
614
|
+
await removeUserDataDir(userDataDir);
|
|
615
|
+
}
|
|
616
|
+
throw error;
|
|
617
|
+
}
|
|
618
|
+
|
|
587
619
|
let launched;
|
|
588
620
|
try {
|
|
589
621
|
launched = await spawnOnFreePort({
|
|
@@ -614,10 +646,10 @@ export async function launchAndConnectRealBrowserWithDependencies(
|
|
|
614
646
|
|
|
615
647
|
try {
|
|
616
648
|
const connect = dependencies.connect ?? connectBrowser;
|
|
617
|
-
const seedCookies =
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
649
|
+
const seedCookies = mergeSeedCookies(
|
|
650
|
+
connectionOptions.seedCookies,
|
|
651
|
+
migratedCookies
|
|
652
|
+
);
|
|
621
653
|
const connection = await connect({
|
|
622
654
|
engine,
|
|
623
655
|
cdpEndpoint: resolvedCdpEndpoint,
|
package/src/cli/args.js
CHANGED
|
@@ -27,6 +27,10 @@ const BROWSER_OPTIONS = Object.freeze({
|
|
|
27
27
|
launch: STRING,
|
|
28
28
|
restriction: STRINGS,
|
|
29
29
|
arg: STRINGS,
|
|
30
|
+
pref: STRINGS,
|
|
31
|
+
'local-state': STRINGS,
|
|
32
|
+
'default-browser-check': FLAG,
|
|
33
|
+
'first-run': FLAG,
|
|
30
34
|
});
|
|
31
35
|
|
|
32
36
|
/** Options accepted by every page command. */
|
|
@@ -182,6 +186,45 @@ export function requireOption(options, name, command) {
|
|
|
182
186
|
* `--restriction` → `restrictions`), with unset options left out.
|
|
183
187
|
*/
|
|
184
188
|
export function launchParams(options) {
|
|
189
|
+
const settings = (entries, flag) => {
|
|
190
|
+
if (entries === undefined) {
|
|
191
|
+
return undefined;
|
|
192
|
+
}
|
|
193
|
+
const result = {};
|
|
194
|
+
for (const entry of entries) {
|
|
195
|
+
const separator = entry.indexOf('=');
|
|
196
|
+
const path = entry.slice(0, separator).split('.');
|
|
197
|
+
if (
|
|
198
|
+
separator < 1 ||
|
|
199
|
+
path.some(
|
|
200
|
+
(part) =>
|
|
201
|
+
!part || ['__proto__', 'prototype', 'constructor'].includes(part)
|
|
202
|
+
)
|
|
203
|
+
) {
|
|
204
|
+
throw new UsageError(`${flag} requires a dotted key=value`);
|
|
205
|
+
}
|
|
206
|
+
let value = entry.slice(separator + 1);
|
|
207
|
+
try {
|
|
208
|
+
value = JSON.parse(value);
|
|
209
|
+
} catch {
|
|
210
|
+
// Unquoted CLI text is a string.
|
|
211
|
+
}
|
|
212
|
+
let parent = result;
|
|
213
|
+
for (const part of path.slice(0, -1)) {
|
|
214
|
+
parent[part] ??= {};
|
|
215
|
+
if (
|
|
216
|
+
!parent[part] ||
|
|
217
|
+
typeof parent[part] !== 'object' ||
|
|
218
|
+
Array.isArray(parent[part])
|
|
219
|
+
) {
|
|
220
|
+
throw new UsageError(`${flag} has a conflicting key: ${entry}`);
|
|
221
|
+
}
|
|
222
|
+
parent = parent[part];
|
|
223
|
+
}
|
|
224
|
+
parent[path.at(-1)] = value;
|
|
225
|
+
}
|
|
226
|
+
return result;
|
|
227
|
+
};
|
|
185
228
|
const params = {
|
|
186
229
|
engine: options.engine,
|
|
187
230
|
browser: options.browser,
|
|
@@ -191,6 +234,10 @@ export function launchParams(options) {
|
|
|
191
234
|
launch: options.launch,
|
|
192
235
|
restrictions: options.restriction,
|
|
193
236
|
args: options.arg,
|
|
237
|
+
preferences: settings(options.pref, '--pref'),
|
|
238
|
+
localState: settings(options.localState, '--local-state'),
|
|
239
|
+
defaultBrowserCheck: options.defaultBrowserCheck,
|
|
240
|
+
firstRun: options.firstRun,
|
|
194
241
|
};
|
|
195
242
|
return Object.fromEntries(
|
|
196
243
|
Object.entries(params).filter(([, value]) => value !== undefined)
|
package/src/cli/sessions.js
CHANGED
package/src/traces/bundle.js
CHANGED
|
@@ -32,7 +32,7 @@ export const TRACE_DIRECTORY_MODE = 0o700;
|
|
|
32
32
|
/**
|
|
33
33
|
* Open a bundle for writing.
|
|
34
34
|
*
|
|
35
|
-
* @param {Object} options - `{output, limits, strict, now, onEvent}`
|
|
35
|
+
* @param {Object} options - `{output, limits, strict, now, monotonic, onEvent}`
|
|
36
36
|
* @returns {Promise<Object>} The bundle writer
|
|
37
37
|
*/
|
|
38
38
|
export async function openTraceBundle(options = {}) {
|
|
@@ -41,6 +41,7 @@ export async function openTraceBundle(options = {}) {
|
|
|
41
41
|
strict = false,
|
|
42
42
|
limits = {},
|
|
43
43
|
now = () => Date.now(),
|
|
44
|
+
monotonic = () => performance.now(),
|
|
44
45
|
onEvent = null,
|
|
45
46
|
} = options;
|
|
46
47
|
|
|
@@ -119,7 +120,7 @@ export async function openTraceBundle(options = {}) {
|
|
|
119
120
|
const record = {
|
|
120
121
|
sequence: ++sequence,
|
|
121
122
|
at: new Date(now()).toISOString(),
|
|
122
|
-
monotonicMs: Math.round(
|
|
123
|
+
monotonicMs: Math.round(monotonic()),
|
|
123
124
|
...event,
|
|
124
125
|
};
|
|
125
126
|
const line = `${JSON.stringify(record)}\n`;
|
package/src/traces/observers.js
CHANGED
|
@@ -51,7 +51,7 @@ export function interactionTarget(argument) {
|
|
|
51
51
|
/**
|
|
52
52
|
* Subscribe to everything a running trace listens to.
|
|
53
53
|
*
|
|
54
|
-
* @param {Object} options - `{commander, page, eventSources, record, note, identity}`
|
|
54
|
+
* @param {Object} options - `{commander, page, eventSources, record, note, identity, now}`
|
|
55
55
|
* @param {Object} [options.commander] - The commander being traced, when there
|
|
56
56
|
* is one; interactions and managed downloads are reached through it
|
|
57
57
|
* @param {Object} options.page - The page being traced
|
|
@@ -61,10 +61,19 @@ export function interactionTarget(argument) {
|
|
|
61
61
|
* @param {Object} [options.identity] - Trace identity, see `identity.js`; the
|
|
62
62
|
* navigation observer is what moves it on to the next navigation, and the
|
|
63
63
|
* interaction observer is what names an action (issue #93)
|
|
64
|
+
* @param {Function} [options.now] - Wall clock in milliseconds, for durations
|
|
64
65
|
* @returns {Function[]} One detach function per attached observer
|
|
65
66
|
*/
|
|
66
67
|
export function attachTimelineObservers(options) {
|
|
67
|
-
const {
|
|
68
|
+
const {
|
|
69
|
+
commander,
|
|
70
|
+
page,
|
|
71
|
+
eventSources,
|
|
72
|
+
record,
|
|
73
|
+
note,
|
|
74
|
+
identity,
|
|
75
|
+
now = () => Date.now(),
|
|
76
|
+
} = options;
|
|
68
77
|
const detachers = [];
|
|
69
78
|
|
|
70
79
|
function subscribe(source, attach) {
|
|
@@ -234,7 +243,7 @@ export function attachTimelineObservers(options) {
|
|
|
234
243
|
}
|
|
235
244
|
originals.set(name, original);
|
|
236
245
|
commander[name] = async (...args) => {
|
|
237
|
-
const startedMs =
|
|
246
|
+
const startedMs = now();
|
|
238
247
|
const target = interactionTarget(args[0]);
|
|
239
248
|
// Named before the call, so everything the action causes can be traced
|
|
240
249
|
// back to it even when the action itself ends in an exception.
|
|
@@ -245,7 +254,7 @@ export function attachTimelineObservers(options) {
|
|
|
245
254
|
actionId,
|
|
246
255
|
action: name,
|
|
247
256
|
target,
|
|
248
|
-
durationMs:
|
|
257
|
+
durationMs: now() - startedMs,
|
|
249
258
|
ok: true,
|
|
250
259
|
});
|
|
251
260
|
return result;
|
|
@@ -254,7 +263,7 @@ export function attachTimelineObservers(options) {
|
|
|
254
263
|
actionId,
|
|
255
264
|
action: name,
|
|
256
265
|
target,
|
|
257
|
-
durationMs:
|
|
266
|
+
durationMs: now() - startedMs,
|
|
258
267
|
ok: false,
|
|
259
268
|
error: error.message,
|
|
260
269
|
});
|
package/src/traces/recorder.js
CHANGED
|
@@ -110,6 +110,7 @@ export async function startTrace(options = {}) {
|
|
|
110
110
|
commanderVersion = null,
|
|
111
111
|
log = commander?.log,
|
|
112
112
|
now = () => Date.now(),
|
|
113
|
+
monotonic,
|
|
113
114
|
} = options;
|
|
114
115
|
|
|
115
116
|
if (!page) {
|
|
@@ -150,6 +151,7 @@ export async function startTrace(options = {}) {
|
|
|
150
151
|
limits,
|
|
151
152
|
strict,
|
|
152
153
|
now,
|
|
154
|
+
monotonic,
|
|
153
155
|
onEvent: (event) => linksSink?.event(event),
|
|
154
156
|
});
|
|
155
157
|
const startedAt = new Date(now()).toISOString();
|
|
@@ -323,6 +325,7 @@ export async function startTrace(options = {}) {
|
|
|
323
325
|
record,
|
|
324
326
|
note,
|
|
325
327
|
identity,
|
|
328
|
+
now,
|
|
326
329
|
});
|
|
327
330
|
|
|
328
331
|
// Registered before the first record, so a navigation that starts in the same
|
package/src/traces/viewer.js
CHANGED
|
@@ -36,7 +36,7 @@ function embed(data) {
|
|
|
36
36
|
);
|
|
37
37
|
}
|
|
38
38
|
|
|
39
|
-
const VIEWER_STYLE = `
|
|
39
|
+
export const VIEWER_STYLE = `
|
|
40
40
|
:root { color-scheme: light dark; font-family: system-ui, sans-serif; }
|
|
41
41
|
body { margin: 0; display: grid; grid-template-columns: 22rem 1fr;
|
|
42
42
|
grid-template-rows: auto 1fr; height: 100vh; }
|
|
@@ -62,7 +62,7 @@ const VIEWER_STYLE = `
|
|
|
62
62
|
button { font: inherit; }
|
|
63
63
|
`;
|
|
64
64
|
|
|
65
|
-
const VIEWER_SCRIPT = String.raw`
|
|
65
|
+
export const VIEWER_SCRIPT = String.raw`
|
|
66
66
|
const trace = JSON.parse(document.getElementById('trace-data').textContent);
|
|
67
67
|
const timeline = document.getElementById('timeline');
|
|
68
68
|
const frame = document.getElementById('stage');
|
|
@@ -72,6 +72,13 @@ const stepLabel = document.getElementById('step');
|
|
|
72
72
|
let selectedCheckpoint = null;
|
|
73
73
|
let mutationStep = 0;
|
|
74
74
|
|
|
75
|
+
// Everything in the trace is what the recorded page made it, and the policy
|
|
76
|
+
// allows inline handlers for this script, so recorded text is escaped before
|
|
77
|
+
// it is put into the viewer's markup.
|
|
78
|
+
function escapeHtml(value) {
|
|
79
|
+
return String(value).replace(/[&<>"']/g, (c) => '&#' + c.charCodeAt(0) + ';');
|
|
80
|
+
}
|
|
81
|
+
|
|
75
82
|
function checkpointFor(event) {
|
|
76
83
|
if (event.kind === 'checkpoint') return event.index;
|
|
77
84
|
let latest = null;
|
|
@@ -241,15 +248,16 @@ function showCheckpoint(index) {
|
|
|
241
248
|
const state = trace.state[index];
|
|
242
249
|
const previous = trace.state[index - 1];
|
|
243
250
|
details.innerHTML = state
|
|
244
|
-
? '<table><tr><th>url</th><td>' + state.url + '</td></tr>' +
|
|
245
|
-
'<tr><th>title</th><td>' + (state.title || '') + '</td></tr>' +
|
|
251
|
+
? '<table><tr><th>url</th><td>' + escapeHtml(state.url) + '</td></tr>' +
|
|
252
|
+
'<tr><th>title</th><td>' + escapeHtml(state.title || '') + '</td></tr>' +
|
|
246
253
|
'<tr><th>controls</th><td>' + (state.controls || []).length + '</td></tr></table>'
|
|
247
254
|
: '<p>no state captured</p>';
|
|
248
255
|
const changes = trace.diffs[index] || [];
|
|
249
256
|
diffPanel.innerHTML = previous && changes.length
|
|
250
257
|
? '<table><tr><th>control</th><th>before</th><th>after</th></tr>' +
|
|
251
|
-
changes.map((c) => '<tr><td>' + c.path + '</td><td>' +
|
|
252
|
-
|
|
258
|
+
changes.map((c) => '<tr><td>' + escapeHtml(c.path) + '</td><td>' +
|
|
259
|
+
escapeHtml(c.before ?? '') + '</td><td>' + escapeHtml(c.after ?? '') +
|
|
260
|
+
'</td></tr>').join('') + '</table>'
|
|
253
261
|
: '<p>no control changes against the previous checkpoint</p>';
|
|
254
262
|
stepLabel.textContent = (trace.mutations[index] || []).length + ' mutation batches';
|
|
255
263
|
}
|
|
@@ -261,8 +269,8 @@ function select(event, element) {
|
|
|
261
269
|
if (index != null && index !== selectedCheckpoint) showCheckpoint(index);
|
|
262
270
|
if (event.kind !== 'checkpoint') {
|
|
263
271
|
details.innerHTML = '<table>' + Object.entries(event)
|
|
264
|
-
.map(([k, v]) => '<tr><th>' + k + '</th><td>' + String(
|
|
265
|
-
typeof v === 'object' ? JSON.stringify(v) : v).slice(0, 400) + '</td></tr>')
|
|
272
|
+
.map(([k, v]) => '<tr><th>' + escapeHtml(k) + '</th><td>' + escapeHtml(String(
|
|
273
|
+
typeof v === 'object' ? JSON.stringify(v) : v).slice(0, 400)) + '</td></tr>')
|
|
266
274
|
.join('') + '</table>';
|
|
267
275
|
}
|
|
268
276
|
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
fake-png
|