browser-commander 0.26.3 → 0.28.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 +24 -23
- package/CHANGELOG.md +22 -0
- package/README.md +6 -0
- package/eslint.config.js +11 -0
- package/package.json +9 -4
- package/src/bindings.js +19 -0
- package/src/browser/attach/snapshot-launch.js +8 -6
- package/src/browser/attach/snapshot.js +39 -2
- package/src/browser/browser-cookie-database.js +71 -2
- package/src/browser/browser-cookie-session.js +183 -0
- package/src/browser/browser-cookies.js +29 -0
- package/src/browser/connector.js +117 -13
- package/src/browser/debugging-port.js +4 -2
- package/src/browser/default-browser.js +33 -9
- package/src/browser/launch-diagnostics.js +102 -0
- package/src/browser/launch-options.js +2 -1
- package/src/browser/launch-restrictions.json +33 -2
- package/src/browser/launcher.js +54 -9
- package/src/browser/migration/sqlite-snapshot.js +5 -27
- package/src/browser/navigation.js +173 -98
- package/src/browser/persistent-session.js +360 -0
- package/src/browser/profile-directory.js +20 -0
- package/src/browser/real-browser.js +142 -70
- package/src/browser/restrictions.js +13 -4
- package/src/browser/session-cookies.js +78 -0
- package/src/browser/session-persistence.js +72 -0
- package/src/browser/session-watchdog.js +71 -0
- package/src/browser/session-worker.js +70 -0
- package/src/browser/storage-state.js +32 -6
- 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 +66 -1
- package/src/cli/commands.js +113 -2
- package/src/cli/high-level-methods.js +40 -3
- package/src/cli/sessions.js +11 -3
- package/src/core/commander-features.js +105 -0
- package/src/core/constants.js +1 -0
- package/src/core/dialog-manager.js +7 -0
- package/src/core/managed-navigation.js +101 -0
- package/src/core/navigation-manager.js +66 -80
- package/src/core/navigation-operation.js +25 -0
- package/src/core/navigation-readiness.js +68 -16
- package/src/core/navigation-safety.js +51 -0
- package/src/core/network-tracker.js +39 -92
- package/src/core/page-session.js +3 -2
- package/src/core/page-trigger-manager.js +130 -42
- package/src/core/readiness.js +76 -14
- package/src/core/subscriptions.js +20 -0
- package/src/core/webdriver-page.js +38 -0
- package/src/elements/locators.js +61 -37
- package/src/elements/reusable.js +92 -0
- package/src/elements/selectors.js +37 -81
- package/src/elements/visibility.js +38 -21
- package/src/exports.js +14 -0
- package/src/factory.js +17 -16
- package/src/high-level/universal-logic.js +57 -21
- package/src/index.js +2 -0
- package/src/interactions/click.js +47 -0
- package/src/interactions/overlays.js +28 -0
- package/src/interactions/scroll.js +8 -2
- package/src/traces/bundle.js +14 -5
- package/src/traces/checkpoints.js +99 -0
- package/src/traces/links.js +117 -1
- package/src/traces/mutation-stream.js +1 -0
- package/src/traces/network.js +257 -0
- package/src/traces/page-capture.js +143 -52
- package/src/traces/reader.js +8 -10
- package/src/traces/recorder.js +221 -245
- package/src/traces/render.js +168 -0
- package/src/traces/rolling.js +180 -0
- package/src/traces/start-cleanup.js +15 -0
- package/src/traces/stop.js +159 -0
- package/src/traces/storage.js +43 -0
- package/src/utilities/subprocess.js +47 -4
- package/tests/e2e/cli.e2e.test.js +20 -1
- package/tests/e2e/click-readiness.e2e.test.js +1 -1
- package/tests/e2e/navigation-budget.e2e.test.js +132 -0
- package/tests/e2e/reusable-session.e2e.test.js +231 -0
- package/tests/unit/browser/attach/snapshot-launch.test.js +2 -2
- package/tests/unit/browser/attach/snapshot.test.js +33 -0
- package/tests/unit/browser/default-browser.test.js +18 -0
- package/tests/unit/browser/launch-building-blocks.test.js +5 -1
- package/tests/unit/browser/launch-diagnostics.test.js +52 -0
- 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/real-browser.test.js +1 -1
- package/tests/unit/browser/session-workflows.test.js +206 -0
- package/tests/unit/browser/storage-state.test.js +7 -2
- 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/navigation-budget.test.js +120 -0
- package/tests/unit/core/navigation-manager.test.js +23 -1
- package/tests/unit/core/readiness.test.js +21 -0
- package/tests/unit/core/trigger-readiness.test.js +39 -0
- package/tests/unit/core/webdriver-page.test.js +33 -0
- package/tests/unit/elements/reusable-helpers.test.js +83 -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/scripts/ci-timeout-budgets.test.js +1 -0
- package/tests/unit/traces/bundle.test.js +16 -0
- package/tests/unit/traces/links.test.js +2 -2
- package/tests/unit/traces/network.test.js +97 -0
|
@@ -1,3 +1,8 @@
|
|
|
1
|
+
import { launchFailure, redactLaunchEvidence } from './launch-diagnostics.js';
|
|
2
|
+
import {
|
|
3
|
+
installSessionPersistence,
|
|
4
|
+
sessionPersistencePath,
|
|
5
|
+
} from './session-persistence.js';
|
|
1
6
|
import { readFile } from 'node:fs/promises';
|
|
2
7
|
import path from 'node:path';
|
|
3
8
|
import { findBrowserSource } from './browser-sources.js';
|
|
@@ -98,6 +103,7 @@ export function buildRealBrowserArgs({
|
|
|
98
103
|
remoteDebuggingPort,
|
|
99
104
|
headless = false,
|
|
100
105
|
restrictions = [],
|
|
106
|
+
disableFeatures = [],
|
|
101
107
|
args = [],
|
|
102
108
|
extraArgs = [],
|
|
103
109
|
automationParity = true,
|
|
@@ -122,7 +128,7 @@ export function buildRealBrowserArgs({
|
|
|
122
128
|
`--user-data-dir=${userDataDir}`,
|
|
123
129
|
`--remote-debugging-port=${remoteDebuggingPort}`,
|
|
124
130
|
...(headless ? ['--headless=new'] : []),
|
|
125
|
-
...resolveRestrictions(restrictions).args,
|
|
131
|
+
...resolveRestrictions(restrictions, { disableFeatures }).args,
|
|
126
132
|
...customArgs,
|
|
127
133
|
]);
|
|
128
134
|
if (
|
|
@@ -187,56 +193,66 @@ export async function waitForCdpEndpoint({
|
|
|
187
193
|
timeout = 30_000,
|
|
188
194
|
fetchImplementation = globalThis.fetch,
|
|
189
195
|
}) {
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
if (browserProcess.exitCode !== null) {
|
|
202
|
-
throw new Error(
|
|
203
|
-
`Browser exited before its DevTools endpoint was ready (exit ${browserProcess.exitCode})`
|
|
204
|
-
);
|
|
205
|
-
}
|
|
206
|
-
if (ownership !== 'owned') {
|
|
207
|
-
const activePort = await readDevToolsActivePort(userDataDir);
|
|
208
|
-
if (activePort?.port === port) {
|
|
209
|
-
ownership = 'owned';
|
|
196
|
+
try {
|
|
197
|
+
const port = assertFixedDebuggingPort(remoteDebuggingPort);
|
|
198
|
+
const endpoint = `http://${LOOPBACK_HOST}:${port}`;
|
|
199
|
+
const deadline = Date.now() + timeout;
|
|
200
|
+
while (Date.now() < deadline) {
|
|
201
|
+
const output = devToolsOutput.state();
|
|
202
|
+
let ownership = devToolsOutput.available
|
|
203
|
+
? classifyDevToolsOwnership(output, port)
|
|
204
|
+
: 'unknown';
|
|
205
|
+
if (ownership === 'race') {
|
|
206
|
+
throw new PortRaceError(port, output.listening?.url ?? 'bind failed');
|
|
210
207
|
}
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
208
|
+
if (browserProcess.exitCode !== null) {
|
|
209
|
+
throw (
|
|
210
|
+
browserProcess.spawnError ??
|
|
211
|
+
Object.assign(
|
|
212
|
+
new Error(
|
|
213
|
+
`Browser exited before its DevTools endpoint was ready (exit ${browserProcess.exitCode})`
|
|
214
|
+
),
|
|
215
|
+
{ stderrTail: browserProcess.stderrTail }
|
|
216
|
+
)
|
|
220
217
|
);
|
|
221
|
-
} catch {
|
|
222
|
-
// The HTTP handler can lag the listening line by a moment.
|
|
223
218
|
}
|
|
224
|
-
if (
|
|
225
|
-
const
|
|
226
|
-
if (
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
219
|
+
if (ownership !== 'owned') {
|
|
220
|
+
const activePort = await readDevToolsActivePort(userDataDir);
|
|
221
|
+
if (activePort?.port === port) {
|
|
222
|
+
ownership = 'owned';
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
if (ownership === 'owned') {
|
|
227
|
+
let version = null;
|
|
228
|
+
try {
|
|
229
|
+
version = await fetchCdpVersion(
|
|
230
|
+
endpoint,
|
|
231
|
+
fetchImplementation,
|
|
232
|
+
Math.max(1, deadline - Date.now())
|
|
230
233
|
);
|
|
234
|
+
} catch {
|
|
235
|
+
// The HTTP handler can lag the listening line by a moment.
|
|
236
|
+
}
|
|
237
|
+
if (version) {
|
|
238
|
+
const expected = output.listening?.url;
|
|
239
|
+
if (expected && version.webSocketDebuggerUrl !== expected) {
|
|
240
|
+
throw new PortRaceError(
|
|
241
|
+
port,
|
|
242
|
+
`port serves ${version.webSocketDebuggerUrl}, browser announced ${expected}`
|
|
243
|
+
);
|
|
244
|
+
}
|
|
245
|
+
return endpoint;
|
|
231
246
|
}
|
|
232
|
-
return endpoint;
|
|
233
247
|
}
|
|
248
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
234
249
|
}
|
|
235
|
-
|
|
250
|
+
throw new Error(
|
|
251
|
+
`Timed out after ${timeout}ms waiting for the DevTools endpoint on port ${port}`
|
|
252
|
+
);
|
|
253
|
+
} finally {
|
|
254
|
+
devToolsOutput.dispose?.();
|
|
236
255
|
}
|
|
237
|
-
throw new Error(
|
|
238
|
-
`Timed out after ${timeout}ms waiting for the DevTools endpoint on port ${port}`
|
|
239
|
-
);
|
|
240
256
|
}
|
|
241
257
|
|
|
242
258
|
function spawnWithStartProcess(executablePath, args, { env, verbose }) {
|
|
@@ -251,13 +267,19 @@ async function waitForExit(browserProcess, timeout) {
|
|
|
251
267
|
return false;
|
|
252
268
|
}
|
|
253
269
|
let timer;
|
|
270
|
+
const onExit = () => resolveExit(true);
|
|
271
|
+
let resolveExit;
|
|
254
272
|
const exited = await Promise.race([
|
|
255
|
-
new Promise((resolve) =>
|
|
273
|
+
new Promise((resolve) => {
|
|
274
|
+
resolveExit = resolve;
|
|
275
|
+
browserProcess.once('exit', onExit);
|
|
276
|
+
}),
|
|
256
277
|
new Promise((resolve) => {
|
|
257
278
|
timer = setTimeout(() => resolve(false), timeout);
|
|
258
279
|
}),
|
|
259
280
|
]);
|
|
260
281
|
clearTimeout(timer);
|
|
282
|
+
browserProcess.removeListener?.('exit', onExit);
|
|
261
283
|
return exited;
|
|
262
284
|
}
|
|
263
285
|
|
|
@@ -369,6 +391,7 @@ async function spawnOnFreePort({
|
|
|
369
391
|
userDataDir,
|
|
370
392
|
startupTimeout,
|
|
371
393
|
dependencies,
|
|
394
|
+
diagnostics,
|
|
372
395
|
}) {
|
|
373
396
|
const reservePort = dependencies.reservePort ?? reserveLoopbackPort;
|
|
374
397
|
const spawnBrowser = dependencies.spawnBrowser ?? spawnWithStartProcess;
|
|
@@ -381,10 +404,20 @@ async function spawnOnFreePort({
|
|
|
381
404
|
userDataDir,
|
|
382
405
|
remoteDebuggingPort: port,
|
|
383
406
|
});
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
407
|
+
let browserProcess;
|
|
408
|
+
try {
|
|
409
|
+
browserProcess = await spawnBrowser(executablePath, browserArgs, {
|
|
410
|
+
env,
|
|
411
|
+
verbose: false,
|
|
412
|
+
});
|
|
413
|
+
} catch (error) {
|
|
414
|
+
throw launchFailure(error, {
|
|
415
|
+
...diagnostics,
|
|
416
|
+
phase: 'spawn',
|
|
417
|
+
executablePath,
|
|
418
|
+
userDataDir,
|
|
419
|
+
});
|
|
420
|
+
}
|
|
388
421
|
try {
|
|
389
422
|
const cdpEndpoint = await waitForEndpoint({
|
|
390
423
|
remoteDebuggingPort: port,
|
|
@@ -397,13 +430,24 @@ async function spawnOnFreePort({
|
|
|
397
430
|
if (browserProcess.exitCode === null) {
|
|
398
431
|
browserProcess.kill();
|
|
399
432
|
}
|
|
433
|
+
await waitForExit(browserProcess, 5_000);
|
|
400
434
|
if (!(error instanceof PortRaceError) || attempt >= attempts) {
|
|
401
|
-
throw error
|
|
435
|
+
throw launchFailure(error, {
|
|
436
|
+
...diagnostics,
|
|
437
|
+
phase: 'endpoint',
|
|
438
|
+
browserProcess,
|
|
439
|
+
executablePath,
|
|
440
|
+
userDataDir,
|
|
441
|
+
});
|
|
402
442
|
}
|
|
403
443
|
if (verbose) {
|
|
404
|
-
console.log(
|
|
444
|
+
console.log(
|
|
445
|
+
redactLaunchEvidence(
|
|
446
|
+
`${error.message}; retrying with a new port`,
|
|
447
|
+
diagnostics?.diagnosticRedactor
|
|
448
|
+
)
|
|
449
|
+
);
|
|
405
450
|
}
|
|
406
|
-
await waitForExit(browserProcess, 5_000);
|
|
407
451
|
}
|
|
408
452
|
}
|
|
409
453
|
}
|
|
@@ -565,6 +609,7 @@ export async function launchAndConnectRealBrowserWithDependencies(
|
|
|
565
609
|
options = {},
|
|
566
610
|
dependencies = {}
|
|
567
611
|
) {
|
|
612
|
+
sessionPersistencePath(options);
|
|
568
613
|
if (isSafariChannel(options.channel ?? options.browser)) {
|
|
569
614
|
return launchSafari(options, dependencies);
|
|
570
615
|
}
|
|
@@ -598,6 +643,7 @@ export async function launchAndConnectRealBrowserWithDependencies(
|
|
|
598
643
|
const argOptions = {
|
|
599
644
|
headless,
|
|
600
645
|
restrictions,
|
|
646
|
+
disableFeatures: options.disableFeatures,
|
|
601
647
|
args,
|
|
602
648
|
extraArgs,
|
|
603
649
|
automationParity,
|
|
@@ -614,10 +660,22 @@ export async function launchAndConnectRealBrowserWithDependencies(
|
|
|
614
660
|
|
|
615
661
|
const resolveExecutable =
|
|
616
662
|
dependencies.resolveExecutable ?? resolveSystemBrowserExecutable;
|
|
617
|
-
const
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
663
|
+
const launchChannel = attach?.browser ?? channel;
|
|
664
|
+
const executablePath = await Promise.resolve()
|
|
665
|
+
.then(() =>
|
|
666
|
+
resolveExecutable({
|
|
667
|
+
channel: launchChannel,
|
|
668
|
+
executablePath: requestedExecutablePath,
|
|
669
|
+
})
|
|
670
|
+
)
|
|
671
|
+
.catch((error) => {
|
|
672
|
+
throw launchFailure(error, {
|
|
673
|
+
...options,
|
|
674
|
+
phase: 'discovery',
|
|
675
|
+
engine,
|
|
676
|
+
executablePath: requestedExecutablePath,
|
|
677
|
+
});
|
|
678
|
+
});
|
|
621
679
|
|
|
622
680
|
const temporaryProfile = !requestedUserDataDir;
|
|
623
681
|
const { userDataDir, snapshotLaunch } = await prepareProfile({
|
|
@@ -644,7 +702,10 @@ export async function launchAndConnectRealBrowserWithDependencies(
|
|
|
644
702
|
await removingTemporaryProfileOnError(profile, () =>
|
|
645
703
|
configureUserDataDir(userDataDir, {
|
|
646
704
|
defaultBrowserCheck,
|
|
647
|
-
preferences
|
|
705
|
+
preferences: {
|
|
706
|
+
...resolveRestrictions(restrictions).preferences,
|
|
707
|
+
...preferences,
|
|
708
|
+
},
|
|
648
709
|
localState,
|
|
649
710
|
profileDirectory: attach?.profile ?? 'Default',
|
|
650
711
|
})
|
|
@@ -661,6 +722,7 @@ export async function launchAndConnectRealBrowserWithDependencies(
|
|
|
661
722
|
userDataDir,
|
|
662
723
|
startupTimeout,
|
|
663
724
|
dependencies,
|
|
725
|
+
diagnostics: { engine, diagnosticRedactor: options.diagnosticRedactor },
|
|
664
726
|
})
|
|
665
727
|
);
|
|
666
728
|
const { browserProcess, cdpEndpoint: resolvedCdpEndpoint } = launched;
|
|
@@ -694,19 +756,22 @@ export async function launchAndConnectRealBrowserWithDependencies(
|
|
|
694
756
|
closeTimeout,
|
|
695
757
|
closeConnection: engine === 'selenium' ? connection.close : undefined,
|
|
696
758
|
});
|
|
697
|
-
return
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
759
|
+
return await installSessionPersistence(
|
|
760
|
+
{
|
|
761
|
+
...connection,
|
|
762
|
+
close,
|
|
763
|
+
browserProcess,
|
|
764
|
+
cdpEndpoint: resolvedCdpEndpoint,
|
|
765
|
+
remoteDebuggingPort: launched.port,
|
|
766
|
+
executablePath,
|
|
767
|
+
userDataDir,
|
|
768
|
+
temporaryProfile,
|
|
769
|
+
args: launched.browserArgs,
|
|
770
|
+
...(migration ? { migration } : {}),
|
|
771
|
+
...(snapshotLaunch ? { attach: snapshotLaunch.attach } : {}),
|
|
772
|
+
},
|
|
773
|
+
options
|
|
774
|
+
);
|
|
710
775
|
} catch (error) {
|
|
711
776
|
if (browserProcess.exitCode === null) {
|
|
712
777
|
browserProcess.kill();
|
|
@@ -715,7 +780,14 @@ export async function launchAndConnectRealBrowserWithDependencies(
|
|
|
715
780
|
if (temporaryProfile) {
|
|
716
781
|
await removeUserDataDir(userDataDir);
|
|
717
782
|
}
|
|
718
|
-
throw error
|
|
783
|
+
throw launchFailure(error, {
|
|
784
|
+
...options,
|
|
785
|
+
phase: 'connect',
|
|
786
|
+
engine,
|
|
787
|
+
browserProcess,
|
|
788
|
+
executablePath,
|
|
789
|
+
userDataDir,
|
|
790
|
+
});
|
|
719
791
|
}
|
|
720
792
|
}
|
|
721
793
|
|
|
@@ -73,11 +73,14 @@ function expand(names) {
|
|
|
73
73
|
* @param {string[]} [names]
|
|
74
74
|
* @returns {{ids: string[], args: string[], env: Object<string,string>}}
|
|
75
75
|
*/
|
|
76
|
-
export function resolveRestrictions(names = []) {
|
|
76
|
+
export function resolveRestrictions(names = [], options = {}) {
|
|
77
77
|
const ids = expand(names);
|
|
78
78
|
const args = [];
|
|
79
|
-
const disableFeatures = [
|
|
79
|
+
const disableFeatures = [
|
|
80
|
+
...assertStringArray(options.disableFeatures ?? [], 'disableFeatures'),
|
|
81
|
+
];
|
|
80
82
|
const env = {};
|
|
83
|
+
const preferences = {};
|
|
81
84
|
for (const id of ids) {
|
|
82
85
|
const restriction = LAUNCH_RESTRICTIONS.find((entry) => entry.id === id);
|
|
83
86
|
if (!restriction) {
|
|
@@ -91,11 +94,17 @@ export function resolveRestrictions(names = []) {
|
|
|
91
94
|
args.push(...(restriction.args ?? []));
|
|
92
95
|
disableFeatures.push(...(restriction.disableFeatures ?? []));
|
|
93
96
|
Object.assign(env, restriction.env ?? {});
|
|
97
|
+
for (const [key, value] of Object.entries(restriction.preferences ?? {})) {
|
|
98
|
+
preferences[key] =
|
|
99
|
+
value && typeof value === 'object'
|
|
100
|
+
? { ...preferences[key], ...value }
|
|
101
|
+
: value;
|
|
102
|
+
}
|
|
94
103
|
}
|
|
95
104
|
if (disableFeatures.length > 0) {
|
|
96
|
-
args.push(`--disable-features=${disableFeatures.join(',')}`);
|
|
105
|
+
args.push(`--disable-features=${[...new Set(disableFeatures)].join(',')}`);
|
|
97
106
|
}
|
|
98
|
-
return { ids, args, env };
|
|
107
|
+
return { ids, args, env, preferences };
|
|
99
108
|
}
|
|
100
109
|
|
|
101
110
|
const LIST_SWITCHES = [
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { matchesDomains } from './migration/domains.js';
|
|
2
|
+
import { restoreWebDriverStorageState } from './storage-state.js';
|
|
3
|
+
|
|
4
|
+
/** Normalize cookie shape without turning session cookies into expired cookies. */
|
|
5
|
+
export function normalizeSessionCookies(cookies, engine) {
|
|
6
|
+
if (!Array.isArray(cookies)) {
|
|
7
|
+
throw new TypeError('cookies must be an array');
|
|
8
|
+
}
|
|
9
|
+
return cookies.map(({ expires, ...cookie }) => ({
|
|
10
|
+
...cookie,
|
|
11
|
+
...(cookie.sameSite
|
|
12
|
+
? {
|
|
13
|
+
sameSite:
|
|
14
|
+
cookie.sameSite[0].toUpperCase() +
|
|
15
|
+
cookie.sameSite.slice(1).toLowerCase(),
|
|
16
|
+
}
|
|
17
|
+
: {}),
|
|
18
|
+
...(Number.isFinite(expires) && expires > 0
|
|
19
|
+
? { expires }
|
|
20
|
+
: engine === 'playwright' && expires !== undefined
|
|
21
|
+
? { expires: -1 }
|
|
22
|
+
: {}),
|
|
23
|
+
}));
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Add cookies to the current context, including cookies for other origins. */
|
|
27
|
+
export async function setCookies({ page, engine, cookies } = {}) {
|
|
28
|
+
const normalized = normalizeSessionCookies(cookies, engine);
|
|
29
|
+
if (engine === 'playwright') {
|
|
30
|
+
return page.context().addCookies(normalized);
|
|
31
|
+
}
|
|
32
|
+
if (engine === 'selenium' && !(await page.bidi?.())) {
|
|
33
|
+
return restoreWebDriverStorageState({
|
|
34
|
+
page,
|
|
35
|
+
storageState: { cookies: normalized, origins: [] },
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
const context = page.browserContext?.();
|
|
39
|
+
if (typeof context?.setCookie === 'function') {
|
|
40
|
+
return context.setCookie(...normalized);
|
|
41
|
+
}
|
|
42
|
+
if (typeof page.browser?.().setCookie === 'function') {
|
|
43
|
+
return page.browser().setCookie(...normalized);
|
|
44
|
+
}
|
|
45
|
+
return page.setCookie(...normalized);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Clear the context or only cookies whose domain matches the requested site. */
|
|
49
|
+
export async function clearCookies({ page, engine, domain } = {}) {
|
|
50
|
+
if (engine === 'playwright' && !domain) {
|
|
51
|
+
return page.context().clearCookies();
|
|
52
|
+
}
|
|
53
|
+
const context =
|
|
54
|
+
engine === 'playwright' ? page.context() : page.browserContext?.();
|
|
55
|
+
const cookies =
|
|
56
|
+
engine === 'playwright'
|
|
57
|
+
? await context.cookies()
|
|
58
|
+
: typeof context?.cookies === 'function'
|
|
59
|
+
? await context.cookies()
|
|
60
|
+
: await page.cookies();
|
|
61
|
+
const selected = cookies.filter(
|
|
62
|
+
(cookie) => !domain || matchesDomains(cookie.domain, [domain])
|
|
63
|
+
);
|
|
64
|
+
if (engine === 'playwright') {
|
|
65
|
+
for (const cookie of selected) {
|
|
66
|
+
await context.clearCookies({
|
|
67
|
+
name: cookie.name,
|
|
68
|
+
domain: cookie.domain,
|
|
69
|
+
path: cookie.path,
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
} else if (typeof context?.deleteCookie === 'function') {
|
|
73
|
+
await context.deleteCookie(...selected);
|
|
74
|
+
} else {
|
|
75
|
+
await page.deleteCookie(...selected);
|
|
76
|
+
}
|
|
77
|
+
return selected.length;
|
|
78
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { access } from 'node:fs/promises';
|
|
3
|
+
import { assertDedicatedUserDataDir } from './system-browser.js';
|
|
4
|
+
import {
|
|
5
|
+
saveStorageState,
|
|
6
|
+
writeRestrictedState,
|
|
7
|
+
loadStorageState,
|
|
8
|
+
restorePlaywrightStorageState,
|
|
9
|
+
restorePuppeteerStorageState,
|
|
10
|
+
restoreWebDriverStorageState,
|
|
11
|
+
} from './storage-state.js';
|
|
12
|
+
|
|
13
|
+
const installed = Symbol('sessionPersistence');
|
|
14
|
+
export function sessionPersistencePath(options) {
|
|
15
|
+
if (!options.persistSessionCookies) {
|
|
16
|
+
return null;
|
|
17
|
+
}
|
|
18
|
+
if (!options.userDataDir || options.attach) {
|
|
19
|
+
throw new TypeError(
|
|
20
|
+
'persistSessionCookies requires an explicit dedicated userDataDir'
|
|
21
|
+
);
|
|
22
|
+
}
|
|
23
|
+
assertDedicatedUserDataDir(options.userDataDir);
|
|
24
|
+
return typeof options.persistSessionCookies === 'string'
|
|
25
|
+
? options.persistSessionCookies
|
|
26
|
+
: path.join(options.userDataDir, 'browser-commander-session.json');
|
|
27
|
+
}
|
|
28
|
+
export async function installSessionPersistence(result, options) {
|
|
29
|
+
const file = sessionPersistencePath(options);
|
|
30
|
+
if (!file || result[installed]) {
|
|
31
|
+
return result;
|
|
32
|
+
}
|
|
33
|
+
const engine = options.engine ?? 'playwright';
|
|
34
|
+
if (
|
|
35
|
+
await access(file).then(
|
|
36
|
+
() => true,
|
|
37
|
+
() => false
|
|
38
|
+
)
|
|
39
|
+
) {
|
|
40
|
+
const storageState = await loadStorageState(file);
|
|
41
|
+
if (engine === 'playwright') {
|
|
42
|
+
await restorePlaywrightStorageState({
|
|
43
|
+
context: result.page.context(),
|
|
44
|
+
storageState,
|
|
45
|
+
});
|
|
46
|
+
} else if (engine === 'puppeteer') {
|
|
47
|
+
await restorePuppeteerStorageState({ page: result.page, storageState });
|
|
48
|
+
} else {
|
|
49
|
+
await restoreWebDriverStorageState({ page: result.page, storageState });
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
const close = result.close;
|
|
53
|
+
let closing;
|
|
54
|
+
const persistAndClose = () =>
|
|
55
|
+
(closing ??= (async () => {
|
|
56
|
+
try {
|
|
57
|
+
const state = await saveStorageState(result.page);
|
|
58
|
+
await writeRestrictedState(file, {
|
|
59
|
+
cookies: state.cookies.filter((cookie) => !(cookie.expires > 0)),
|
|
60
|
+
origins: [],
|
|
61
|
+
});
|
|
62
|
+
} finally {
|
|
63
|
+
await close();
|
|
64
|
+
}
|
|
65
|
+
})());
|
|
66
|
+
result.close = persistAndClose;
|
|
67
|
+
if (typeof result.browser?.close === 'function') {
|
|
68
|
+
result.browser.close = persistAndClose;
|
|
69
|
+
}
|
|
70
|
+
result[installed] = true;
|
|
71
|
+
return result;
|
|
72
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
import {
|
|
3
|
+
probeSession,
|
|
4
|
+
closeRemoteBrowser,
|
|
5
|
+
acquireLease,
|
|
6
|
+
} from './persistent-session.js';
|
|
7
|
+
const [, , file, token] = process.argv;
|
|
8
|
+
const read = () =>
|
|
9
|
+
fs
|
|
10
|
+
.readFile(file, 'utf8')
|
|
11
|
+
.then(JSON.parse)
|
|
12
|
+
.catch(() => null);
|
|
13
|
+
while (true) {
|
|
14
|
+
const session = await read();
|
|
15
|
+
if (session?.token !== token) {
|
|
16
|
+
break;
|
|
17
|
+
}
|
|
18
|
+
await new Promise((resolve) =>
|
|
19
|
+
setTimeout(
|
|
20
|
+
resolve,
|
|
21
|
+
Math.min(30_000, Math.max(100, session.idleTimeoutMs / 10))
|
|
22
|
+
)
|
|
23
|
+
);
|
|
24
|
+
const current = await read();
|
|
25
|
+
if (current?.token !== token) {
|
|
26
|
+
break;
|
|
27
|
+
}
|
|
28
|
+
if (Date.now() - current.lastActive < current.idleTimeoutMs) {
|
|
29
|
+
continue;
|
|
30
|
+
}
|
|
31
|
+
let release;
|
|
32
|
+
try {
|
|
33
|
+
release = await acquireLease(`${file}.lock`);
|
|
34
|
+
} catch {
|
|
35
|
+
continue;
|
|
36
|
+
}
|
|
37
|
+
try {
|
|
38
|
+
const version = await probeSession(
|
|
39
|
+
`http://127.0.0.1:${current.remoteDebuggingPort}`
|
|
40
|
+
);
|
|
41
|
+
const latest = await read();
|
|
42
|
+
if (latest?.token !== token) {
|
|
43
|
+
break;
|
|
44
|
+
}
|
|
45
|
+
if (Date.now() - latest.lastActive < latest.idleTimeoutMs) {
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
if (!version) {
|
|
49
|
+
try {
|
|
50
|
+
process.kill(current.pid, 0);
|
|
51
|
+
continue;
|
|
52
|
+
} catch (error) {
|
|
53
|
+
if (error.code !== 'ESRCH') {
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
} else if (version.webSocketDebuggerUrl === current.webSocketDebuggerUrl) {
|
|
58
|
+
try {
|
|
59
|
+
await closeRemoteBrowser(version.webSocketDebuggerUrl);
|
|
60
|
+
} catch {
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
if ((await read())?.token === token) {
|
|
65
|
+
await fs.rm(file, { force: true });
|
|
66
|
+
}
|
|
67
|
+
break;
|
|
68
|
+
} finally {
|
|
69
|
+
await release();
|
|
70
|
+
}
|
|
71
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import {
|
|
4
|
+
connectOrLaunch,
|
|
5
|
+
SESSION_METADATA,
|
|
6
|
+
closeOwnedSession,
|
|
7
|
+
writeSessionMetadata,
|
|
8
|
+
acquireLease,
|
|
9
|
+
} from './persistent-session.js';
|
|
10
|
+
let input = '';
|
|
11
|
+
for await (const chunk of process.stdin) {
|
|
12
|
+
input += chunk;
|
|
13
|
+
if (input.length > 1024 * 1024) {
|
|
14
|
+
throw new RangeError('session options exceed 1 MiB');
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
const options = JSON.parse(input);
|
|
18
|
+
const file = path.join(path.resolve(options.userDataDir), SESSION_METADATA);
|
|
19
|
+
if (options.operation) {
|
|
20
|
+
const release = await acquireLease(`${file}.lock`);
|
|
21
|
+
try {
|
|
22
|
+
let metadata;
|
|
23
|
+
try {
|
|
24
|
+
metadata = JSON.parse(await fs.readFile(file, 'utf8'));
|
|
25
|
+
} catch (error) {
|
|
26
|
+
if (
|
|
27
|
+
error.code === 'ENOENT' &&
|
|
28
|
+
['close', 'touch'].includes(options.operation)
|
|
29
|
+
) {
|
|
30
|
+
process.stdout.write('{}');
|
|
31
|
+
process.exitCode = 0;
|
|
32
|
+
} else {
|
|
33
|
+
throw error;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
if (metadata) {
|
|
37
|
+
if (metadata.token !== options.token) {
|
|
38
|
+
throw new Error('Persistent session ownership changed');
|
|
39
|
+
}
|
|
40
|
+
if (options.operation === 'close') {
|
|
41
|
+
await closeOwnedSession(file, metadata);
|
|
42
|
+
} else if (options.operation === 'touch') {
|
|
43
|
+
metadata.lastActive = Date.now();
|
|
44
|
+
if (options.targetId) {
|
|
45
|
+
metadata.targetId = options.targetId;
|
|
46
|
+
}
|
|
47
|
+
await writeSessionMetadata(file, metadata);
|
|
48
|
+
} else {
|
|
49
|
+
throw new TypeError('Unknown session operation');
|
|
50
|
+
}
|
|
51
|
+
process.stdout.write('{}');
|
|
52
|
+
}
|
|
53
|
+
} finally {
|
|
54
|
+
await release();
|
|
55
|
+
}
|
|
56
|
+
} else {
|
|
57
|
+
const session = await connectOrLaunch(options);
|
|
58
|
+
await session.detach();
|
|
59
|
+
const metadata = JSON.parse(await fs.readFile(file, 'utf8'));
|
|
60
|
+
process.stdout.write(
|
|
61
|
+
JSON.stringify({
|
|
62
|
+
cdpEndpoint: session.cdpEndpoint,
|
|
63
|
+
userDataDir: session.userDataDir,
|
|
64
|
+
remoteDebuggingPort: session.remoteDebuggingPort,
|
|
65
|
+
reused: session.reused,
|
|
66
|
+
token: metadata.token,
|
|
67
|
+
targetId: metadata.targetId,
|
|
68
|
+
})
|
|
69
|
+
);
|
|
70
|
+
}
|