browser-commander 0.27.0 → 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.
Files changed (75) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/README.md +4 -0
  3. package/eslint.config.js +11 -0
  4. package/package.json +5 -2
  5. package/src/browser/connector.js +117 -13
  6. package/src/browser/launch-options.js +2 -1
  7. package/src/browser/launch-restrictions.json +33 -2
  8. package/src/browser/launcher.js +26 -1
  9. package/src/browser/persistent-session.js +360 -0
  10. package/src/browser/profile-directory.js +20 -0
  11. package/src/browser/real-browser.js +7 -2
  12. package/src/browser/restrictions.js +13 -4
  13. package/src/browser/session-watchdog.js +71 -0
  14. package/src/browser/session-worker.js +70 -0
  15. package/src/browser/webdriver.js +4 -0
  16. package/src/capture/encoding.js +327 -0
  17. package/src/capture/errors.js +12 -0
  18. package/src/capture/files.js +9 -0
  19. package/src/capture/index.js +5 -0
  20. package/src/capture/recording.js +136 -0
  21. package/src/capture/screenshot.js +222 -0
  22. package/src/capture/video.js +186 -0
  23. package/src/capture/viewport.js +68 -0
  24. package/src/capture/worker.js +22 -0
  25. package/src/cli/args.js +66 -1
  26. package/src/cli/commands.js +113 -2
  27. package/src/cli/high-level-methods.js +40 -3
  28. package/src/cli/sessions.js +11 -3
  29. package/src/core/commander-features.js +105 -0
  30. package/src/core/constants.js +1 -0
  31. package/src/core/navigation-manager.js +4 -2
  32. package/src/core/navigation-readiness.js +30 -2
  33. package/src/core/navigation-safety.js +51 -0
  34. package/src/core/page-trigger-manager.js +115 -30
  35. package/src/core/readiness.js +10 -2
  36. package/src/elements/locators.js +26 -32
  37. package/src/elements/selectors.js +37 -81
  38. package/src/elements/visibility.js +32 -10
  39. package/src/factory.js +13 -12
  40. package/src/index.js +2 -0
  41. package/src/interactions/click.js +44 -0
  42. package/src/interactions/overlays.js +28 -0
  43. package/src/traces/bundle.js +14 -5
  44. package/src/traces/checkpoints.js +99 -0
  45. package/src/traces/links.js +117 -1
  46. package/src/traces/mutation-stream.js +1 -0
  47. package/src/traces/network.js +257 -0
  48. package/src/traces/page-capture.js +143 -52
  49. package/src/traces/reader.js +8 -10
  50. package/src/traces/recorder.js +221 -245
  51. package/src/traces/render.js +168 -0
  52. package/src/traces/rolling.js +180 -0
  53. package/src/traces/start-cleanup.js +15 -0
  54. package/src/traces/stop.js +159 -0
  55. package/src/traces/storage.js +43 -0
  56. package/src/utilities/subprocess.js +32 -0
  57. package/tests/e2e/cli.e2e.test.js +20 -1
  58. package/tests/e2e/click-readiness.e2e.test.js +1 -1
  59. package/tests/unit/browser/launch-building-blocks.test.js +5 -1
  60. package/tests/unit/browser/launcher.test.js +5 -1
  61. package/tests/unit/browser/persistent-session.test.js +59 -0
  62. package/tests/unit/browser/quiet-ui.test.js +16 -0
  63. package/tests/unit/browser/tab-selection.test.js +38 -0
  64. package/tests/unit/capture/capture.test.js +52 -0
  65. package/tests/unit/capture/cleanup.test.js +24 -0
  66. package/tests/unit/capture/viewport.test.js +168 -0
  67. package/tests/unit/cli/dispatcher.test.js +25 -0
  68. package/tests/unit/core/readiness.test.js +21 -0
  69. package/tests/unit/core/trigger-readiness.test.js +39 -0
  70. package/tests/unit/elements/visible-count.test.js +24 -0
  71. package/tests/unit/elements/wait-navigation.test.js +26 -0
  72. package/tests/unit/interactions/navigation-timeout.test.js +39 -0
  73. package/tests/unit/scripts/ci-timeout-budgets.test.js +1 -0
  74. package/tests/unit/traces/bundle.test.js +16 -0
  75. package/tests/unit/traces/network.test.js +97 -0
@@ -22,6 +22,110 @@ import { createDispatcher } from './dispatcher.js';
22
22
  import { DEFAULT_DEPENDENCIES } from './modules.js';
23
23
  import { runScript } from './script.js';
24
24
  import { serveStdio } from './serve.js';
25
+ import { encodeAnimation } from '../capture/index.js';
26
+ import { renderTrace, summarizeTrace } from '../traces/render.js';
27
+
28
+ function mediaOptions(options) {
29
+ const result = { ...options };
30
+ for (const key of [
31
+ 'quality',
32
+ 'fps',
33
+ 'loop',
34
+ 'palette',
35
+ 'maxFrames',
36
+ 'maxBytes',
37
+ 'maxDurationMs',
38
+ ]) {
39
+ if (options[key] !== undefined) {
40
+ result[key] = Number(options[key]);
41
+ }
42
+ }
43
+ if (
44
+ options.scale !== undefined &&
45
+ !['css', 'device'].includes(options.scale)
46
+ ) {
47
+ result.scale = Number(options.scale);
48
+ }
49
+ for (const key of ['clip', 'size']) {
50
+ if (options[key]) {
51
+ try {
52
+ result[key] = JSON.parse(options[key]);
53
+ } catch {
54
+ throw new UsageError(`${key} must be a JSON object`);
55
+ }
56
+ }
57
+ }
58
+ return result;
59
+ }
60
+
61
+ async function gif(parsed) {
62
+ const target = path.resolve(requireOption(parsed.options, 'out', 'gif'));
63
+ const bytes = await encodeAnimation(parsed.options.frame ?? [], {
64
+ ...mediaOptions(parsed.options),
65
+ path: target,
66
+ });
67
+ return done({ path: target, bytes: bytes.length });
68
+ }
69
+ async function traceSummarize(parsed) {
70
+ return done(await summarizeTrace(parsed.args.dir, parsed.options));
71
+ }
72
+ async function traceRender(parsed) {
73
+ const target = path.resolve(
74
+ requireOption(parsed.options, 'out', 'trace render')
75
+ );
76
+ const options = { ...mediaOptions(parsed.options), path: target };
77
+ let browser;
78
+ try {
79
+ if (
80
+ ['webm', 'mp4', 'mov'].includes(
81
+ options.format ?? path.extname(target).slice(1)
82
+ ) &&
83
+ !options.ffmpeg
84
+ ) {
85
+ const { chromium } = await import('playwright');
86
+ browser = await chromium.launch({ headless: true });
87
+ options.page = await browser.newPage();
88
+ }
89
+ const bytes = await renderTrace(parsed.args.dir, options);
90
+ return done({ path: target, bytes: bytes.length });
91
+ } finally {
92
+ await browser?.close();
93
+ }
94
+ }
95
+ function recordStart(parsed, io) {
96
+ const target = path.resolve(
97
+ requireOption(parsed.options, 'out', 'record start')
98
+ );
99
+ const marker = `${target}.stop`;
100
+ return withPageSession(parsed, io, async (session, dispatcher) => {
101
+ await rm(marker, { force: true });
102
+ await dispatcher.dispatch('record.start', {
103
+ session,
104
+ ...mediaOptions(parsed.options),
105
+ format: parsed.options.format ?? path.extname(target).slice(1),
106
+ path: target,
107
+ });
108
+ let stopped = false;
109
+ try {
110
+ io.print({ recording: target });
111
+ await waitForStop({
112
+ signals: io.signals,
113
+ extra: [markerAppears(marker, () => stopped)],
114
+ });
115
+ return done(await dispatcher.dispatch('record.stop', { session }));
116
+ } finally {
117
+ stopped = true;
118
+ await rm(marker, { force: true });
119
+ }
120
+ });
121
+ }
122
+ async function recordStop(parsed) {
123
+ const target = path.resolve(
124
+ requireOption(parsed.options, 'out', 'record stop')
125
+ );
126
+ await writeFile(`${target}.stop`, '', { mode: 0o600 });
127
+ return done({ recording: target, stopRequested: true });
128
+ }
25
129
 
26
130
  /** Where `trace stop` asks a running `trace start` to finish. */
27
131
  export const TRACE_STOP_MARKER = '.stop';
@@ -80,6 +184,8 @@ async function withPageSession(parsed, io, work, { skipUrl = false } = {}) {
80
184
  ? await dispatcher.dispatch('session.connect', {
81
185
  cdpEndpoint: options.cdpEndpoint,
82
186
  engine: options.engine,
187
+ targetId: options.targetId,
188
+ singleTab: options.singleTab,
83
189
  ...(options.serverUrl ? { serverUrl: options.serverUrl } : {}),
84
190
  ...(options.driverPath ? { driverPath: options.driverPath } : {}),
85
191
  ...(options.bidi !== undefined ? { bidi: options.bidi } : {}),
@@ -145,7 +251,7 @@ async function launch(parsed, io) {
145
251
  args: session.args,
146
252
  ...(opened.attach ? { attach: opened.attach } : {}),
147
253
  };
148
- if (!parsed.options.keepOpen) {
254
+ if (!parsed.options.keepOpen || session.persistent) {
149
255
  return done(document);
150
256
  }
151
257
  io.print(document);
@@ -342,13 +448,18 @@ export const COMMAND_HANDLERS = Object.freeze({
342
448
  expression: args.expression,
343
449
  })),
344
450
  screenshot: pageCommand('page.screenshot', ({ args, options }) => ({
451
+ ...mediaOptions(options),
345
452
  path: args.path,
346
- fullPage: options.fullPage === true,
347
453
  })),
348
454
  pdf: pageCommand('page.pdf', ({ args }) => ({ path: args.path })),
349
455
  'trace start': traceStart,
350
456
  'trace stop': traceStop,
351
457
  'trace view': traceView,
458
+ 'trace summarize': traceSummarize,
459
+ 'trace render': traceRender,
460
+ 'record start': recordStart,
461
+ 'record stop': recordStop,
462
+ gif,
352
463
  'cookies import': pageCommand('cookies.import', ({ options }) => ({
353
464
  from: requireOption(options, 'from', 'cookies import'),
354
465
  profile: options.profile,
@@ -7,6 +7,10 @@
7
7
  * dispatcher state built by `createDispatcher()`.
8
8
  */
9
9
  import path from 'node:path';
10
+ import {
11
+ screenshot as captureScreenshot,
12
+ startRecording,
13
+ } from '../capture/index.js';
10
14
 
11
15
  import { invalidParams, requireString } from './rpc-error.js';
12
16
  import {
@@ -24,7 +28,7 @@ async function launchSession(state, params) {
24
28
  const options = buildLaunchOptions(params);
25
29
  const launched = await state.dependencies.launchBrowser(options);
26
30
  const session = state.sessions.add(
27
- sessionFromLaunch(options.engine, launched)
31
+ sessionFromLaunch(options.engine, launched, options)
28
32
  );
29
33
  return {
30
34
  session,
@@ -48,6 +52,9 @@ async function connectSession(state, params) {
48
52
  const connected = await state.dependencies.connectBrowser({
49
53
  engine,
50
54
  ...endpoint,
55
+ ...(params.targetId ? { targetId: params.targetId } : {}),
56
+ ...(params.urlMatcher ? { url: params.urlMatcher } : {}),
57
+ ...(params.singleTab !== undefined ? { singleTab: params.singleTab } : {}),
51
58
  ...(params.driverPath ? { driverPath: params.driverPath } : {}),
52
59
  ...(params.bidi !== undefined ? { bidi: params.bidi } : {}),
53
60
  });
@@ -111,12 +118,40 @@ async function capture(params, write) {
111
118
  }
112
119
 
113
120
  function screenshot(state, params) {
114
- const { page } = sessionOf(state, params);
121
+ const { page, engine } = sessionOf(state, params);
115
122
  return capture(params, (options) =>
116
- page.screenshot({ ...options, fullPage: params.fullPage === true })
123
+ captureScreenshot({ ...params, ...options, page, engine })
117
124
  );
118
125
  }
119
126
 
127
+ async function recordStart(state, params) {
128
+ const session = sessionOf(state, params);
129
+ if (session.recording) {
130
+ throw invalidParams('A recording is already active');
131
+ }
132
+ session.recording = await startRecording({
133
+ ...params,
134
+ page: session.page,
135
+ engine: session.engine,
136
+ });
137
+ return { recording: true };
138
+ }
139
+ async function recordStop(state, params) {
140
+ const session = sessionOf(state, params);
141
+ if (!session.recording) {
142
+ throw invalidParams('No recording is active');
143
+ }
144
+ const recording = session.recording;
145
+ session.recording = null;
146
+ const result = await recording.stop(params);
147
+ return {
148
+ path: result.path,
149
+ bytes: result.bytes?.length ?? 0,
150
+ frames: result.frames.length,
151
+ truncated: result.truncated,
152
+ };
153
+ }
154
+
120
155
  function pdf(state, params) {
121
156
  const { page } = sessionOf(state, params);
122
157
  return capture(params, (options) => page.pdf(options));
@@ -340,6 +375,8 @@ export const HIGH_LEVEL_METHODS = Object.freeze({
340
375
  'page.pdf': pdf,
341
376
  'trace.start': traceStart,
342
377
  'trace.stop': traceStop,
378
+ 'record.start': recordStart,
379
+ 'record.stop': recordStop,
343
380
  'cookies.import': importCookies,
344
381
  'cookies.sources': cookieSources,
345
382
  'profile.migrate': migrateProfile,
@@ -44,6 +44,9 @@ const LAUNCH_PASSTHROUGH = Object.freeze([
44
44
  'driverPath',
45
45
  'bidi',
46
46
  'seedCookies',
47
+ 'disableFeatures',
48
+ 'keepOpen',
49
+ 'idleTimeoutMs',
47
50
  ]);
48
51
 
49
52
  /** Validate an engine name, defaulting to Playwright. */
@@ -121,20 +124,24 @@ export function buildLaunchOptions(params = {}) {
121
124
  * CDP-connected `Browser` as `connectedBrowser`; the engine launch returns a
122
125
  * persistent context whose `browser()` is null. Puppeteer returns a Browser.
123
126
  */
124
- export function sessionFromLaunch(engine, launched) {
127
+ export function sessionFromLaunch(engine, launched, { keepOpen = false } = {}) {
125
128
  engine = launched.engine ?? engine;
126
129
  const playwright = engine === 'playwright';
127
130
  const handle = launched.browser;
131
+ const persistent = keepOpen && Boolean(launched.detach);
128
132
  return {
129
133
  engine,
130
134
  browser: playwright
131
135
  ? (launched.connectedBrowser ?? handle?.browser?.() ?? null)
132
136
  : handle,
133
- context: playwright ? handle : (handle?.defaultBrowserContext?.() ?? null),
137
+ context: playwright
138
+ ? (launched.page?.context?.() ?? handle)
139
+ : (handle?.defaultBrowserContext?.() ?? null),
134
140
  page: launched.page,
135
141
  driver: launched.driver ?? (engine === 'selenium' ? handle : null),
136
142
  args: launched.args ?? [],
137
- close: () => launched.close(),
143
+ close: () => (persistent ? launched.detach() : launched.close()),
144
+ persistent,
138
145
  connected: false,
139
146
  };
140
147
  }
@@ -205,6 +212,7 @@ export class SessionTable {
205
212
  this.latest = [...this.sessions.keys()].at(-1) ?? null;
206
213
  }
207
214
  await session.trace?.stop?.().catch(() => {});
215
+ await session.recording?.stop?.().catch(() => {});
208
216
  await session.close();
209
217
  }
210
218
 
@@ -0,0 +1,105 @@
1
+ import { startTrace } from '../traces/recorder.js';
2
+ import { touchSessionPage } from '../browser/persistent-session.js';
3
+ import { dismissOverlays } from '../interactions/overlays.js';
4
+ import {
5
+ screenshot,
6
+ startRecording,
7
+ encodeAnimation,
8
+ } from '../capture/index.js';
9
+
10
+ const INTERACTIONS = new Set([
11
+ 'clickButton',
12
+ 'clickElement',
13
+ 'fillTextArea',
14
+ 'check',
15
+ 'pressKey',
16
+ ]);
17
+
18
+ /** Bind capture, persistence and opt-in debugging to the commander lifecycle. */
19
+ export function attachCommanderFeatures(commander, options, recreate) {
20
+ const { page, engine } = commander;
21
+ const managed = new Set();
22
+ function track(item) {
23
+ const stop = item.stop.bind(item);
24
+ let stopping;
25
+ item.stop = (...args) => {
26
+ stopping ??= Promise.resolve()
27
+ .then(() => stop(...args))
28
+ .finally(() => managed.delete(item));
29
+ return stopping;
30
+ };
31
+ managed.add(item);
32
+ return item;
33
+ }
34
+ commander.screenshot = (captureOptions = {}) =>
35
+ screenshot({ ...options.capture, ...captureOptions, page, engine });
36
+ commander.startRecording = async (recordingOptions = {}) =>
37
+ track(await startRecording({ ...recordingOptions, page, engine }));
38
+ commander.gif = (frames, animationOptions = {}) =>
39
+ encodeAnimation(frames, { ...animationOptions, format: 'gif' });
40
+ commander.dismissOverlays = (overlays = options.overlays) =>
41
+ dismissOverlays({ page, engine, overlays });
42
+ commander.startTrace = async (traceOptions = {}) =>
43
+ track(await startTrace({ commander, page, ...traceOptions }));
44
+ const output =
45
+ options.debug?.output ??
46
+ options.output ??
47
+ process.env.BROWSER_COMMANDER_TRACE;
48
+ let debug;
49
+ const ensureDebug = async () => {
50
+ if (!output || (!options.debug && !process.env.BROWSER_COMMANDER_TRACE)) {
51
+ return;
52
+ }
53
+ debug ??= commander.startTrace({
54
+ output,
55
+ mode: 'continuous',
56
+ network: { har: true },
57
+ links: { output: `${output}.links`, dom: 'text' },
58
+ checkpointOnNavigation: true,
59
+ });
60
+ await debug;
61
+ };
62
+ for (const name of [...INTERACTIONS, 'goto', 'evaluate', 'count']) {
63
+ const method = commander[name];
64
+ if (!method) {
65
+ continue;
66
+ }
67
+ commander[name] = async (...args) => {
68
+ touchSessionPage(page);
69
+ await ensureDebug();
70
+ if (INTERACTIONS.has(name)) {
71
+ await commander.dismissOverlays();
72
+ }
73
+ return method(...args);
74
+ };
75
+ }
76
+ const destroy = commander.destroy;
77
+ commander.destroy = async () => {
78
+ try {
79
+ if (debug) {
80
+ await debug;
81
+ }
82
+ await Promise.all([...managed].map((item) => item.stop()));
83
+ } finally {
84
+ await destroy();
85
+ }
86
+ };
87
+ commander.reusePage = async (selection = {}) => {
88
+ if (!options.session?.reusePage) {
89
+ throw new Error('reusePage requires a persistent session');
90
+ }
91
+ const selected = await options.session.reusePage(selection);
92
+ if (selected !== page) {
93
+ await commander.destroy();
94
+ Object.assign(
95
+ commander,
96
+ recreate({ ...options, page: selected, downloads: null })
97
+ );
98
+ }
99
+ touchSessionPage(selected);
100
+ return selected;
101
+ };
102
+ if (options.debug || process.env.BROWSER_COMMANDER_TRACE) {
103
+ commander.ready = ensureDebug();
104
+ }
105
+ }
@@ -15,6 +15,7 @@ export const CHROME_ARGS = [
15
15
  '--no-first-run',
16
16
  '--no-default-browser-check',
17
17
  '--disable-crash-restore',
18
+ '--disable-features=SessionRestoreInfobar',
18
19
  ];
19
20
 
20
21
  /**
@@ -257,9 +257,11 @@ export function createNavigationManager(options = {}) {
257
257
  log.debug(() => `🔄 Redirect detected: ${url}`);
258
258
  }
259
259
  },
260
- onReady: () => {
260
+ onReady: (readyOn) => {
261
261
  finishNavigationTracking({ ready: true });
262
- emitPageReady();
262
+ if (readyOn === 'networkidle') {
263
+ emitPageReady();
264
+ }
263
265
  },
264
266
  onNotReady: () => finishNavigationTracking({ ready: false }),
265
267
  });
@@ -12,6 +12,7 @@ import {
12
12
  networkIdleFor,
13
13
  runReadinessChecks,
14
14
  urlStableFor,
15
+ stableCheck,
15
16
  } from './readiness.js';
16
17
 
17
18
  /**
@@ -113,7 +114,7 @@ export function createReadinessWaiter(options = {}) {
113
114
  let result;
114
115
  try {
115
116
  result = await runReadinessChecks({
116
- checks: checks ?? defaultReadinessChecks(config),
117
+ checks: checks ?? checksForReadiness(opts.readyOn, config),
117
118
  deadline,
118
119
  context: {
119
120
  page,
@@ -135,7 +136,7 @@ export function createReadinessWaiter(options = {}) {
135
136
 
136
137
  if (outcome.ready) {
137
138
  if (notify()) {
138
- onReady();
139
+ onReady(opts.readyOn ?? 'networkidle');
139
140
  }
140
141
  log.debug(() => `✅ Page ready after ${outcome.elapsedMs}ms (${reason})`);
141
142
  } else {
@@ -155,3 +156,30 @@ export function createReadinessWaiter(options = {}) {
155
156
 
156
157
  return { waitForReady, getAdapter };
157
158
  }
159
+
160
+ export function checksForReadiness(readyOn = 'networkidle', config) {
161
+ if (readyOn === 'networkidle') {
162
+ return defaultReadinessChecks(config);
163
+ }
164
+ if (readyOn === 'urlchange') {
165
+ return [];
166
+ }
167
+ if (!['domcontentloaded', 'load'].includes(readyOn)) {
168
+ throw new RangeError(`Unknown readyOn: ${readyOn}`);
169
+ }
170
+ return [
171
+ stableCheck({
172
+ name: readyOn,
173
+ stableForMs: 0,
174
+ intervalMs: 50,
175
+ sample: async ({ page }) => {
176
+ const state = await page.evaluate(() => document.readyState);
177
+ return {
178
+ stable:
179
+ readyOn === 'load' ? state === 'complete' : state !== 'loading',
180
+ detail: { state },
181
+ };
182
+ },
183
+ }),
184
+ ];
185
+ }
@@ -8,7 +8,58 @@
8
8
  * @param {Error} error - The error to check
9
9
  * @returns {boolean} - True if this is a navigation error
10
10
  */
11
+ export class NavigationInterruptedError extends Error {
12
+ constructor(cause) {
13
+ super('Navigation interrupted the element wait', { cause });
14
+ this.name = 'NavigationInterruptedError';
15
+ }
16
+ }
17
+
18
+ export function waitNavigationState(page, manager) {
19
+ return { url: page.url?.(), session: manager?.getSessionId?.() };
20
+ }
21
+
22
+ export function classifyWaitError(error, page, manager, initial) {
23
+ if (!isTimeoutError(error)) {
24
+ return error;
25
+ }
26
+ try {
27
+ const current = waitNavigationState(page, manager);
28
+ if (current.url !== initial.url || current.session !== initial.session) {
29
+ return new NavigationInterruptedError(error);
30
+ }
31
+ } catch {
32
+ // Keep the original timeout when the page identity is unavailable.
33
+ }
34
+ return error;
35
+ }
36
+
37
+ export function recoverWaitError(
38
+ caught,
39
+ options,
40
+ initial,
41
+ operation,
42
+ fallback
43
+ ) {
44
+ const error = classifyWaitError(
45
+ caught,
46
+ options.page,
47
+ options.navigationManager,
48
+ initial
49
+ );
50
+ if (!isNavigationError(error) || options.throwOnNavigation !== false) {
51
+ throw error;
52
+ }
53
+ console.log(
54
+ `⚠️ Navigation detected during ${operation}, recovering gracefully`
55
+ );
56
+ return fallback;
57
+ }
58
+
11
59
  export function isNavigationError(error) {
60
+ if (error instanceof NavigationInterruptedError) {
61
+ return true;
62
+ }
12
63
  if (!error || !error.message) {
13
64
  return false;
14
65
  }