browser-commander 0.22.0 → 0.24.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.
@@ -302,11 +302,20 @@ export function buildBrowserOptions({
302
302
  args,
303
303
  automationParity,
304
304
  bidi,
305
+ debuggerAddress,
305
306
  }) {
306
307
  let options;
307
308
  let browserArgs;
308
309
  if (browser === 'chrome') {
309
310
  options = new selenium.chrome.Options();
311
+ if (debuggerAddress) {
312
+ options.debuggerAddress(debuggerAddress);
313
+ if (bidi) {
314
+ options.set('webSocketUrl', true);
315
+ options.set('unhandledPromptBehavior', 'ignore');
316
+ }
317
+ return { options, args: [] };
318
+ }
310
319
  browserArgs = buildRealBrowserArgs({
311
320
  userDataDir,
312
321
  remoteDebuggingPort,
@@ -450,8 +459,12 @@ export async function launchWebDriver(options = {}, dependencies = {}) {
450
459
  env,
451
460
  startupTimeout = 30_000,
452
461
  verbose = false,
462
+ debuggerAddress,
453
463
  } = options;
454
464
  assertBrowser(browser);
465
+ if (debuggerAddress && browser !== 'chrome') {
466
+ throw new Error('debuggerAddress is only supported by Chrome WebDriver');
467
+ }
455
468
  if (browser === 'firefox' && restrictions.length > 0) {
456
469
  throw new Error(
457
470
  'Launch restrictions are Chrome switches; not available for firefox'
@@ -476,8 +489,10 @@ export async function launchWebDriver(options = {}, dependencies = {}) {
476
489
  ...(dependencies.platform ? { platform: dependencies.platform } : {}),
477
490
  });
478
491
 
479
- const temporaryProfile = !requestedUserDataDir;
480
- const userDataDir = await resolveProfile(browser, requestedUserDataDir);
492
+ const temporaryProfile = !debuggerAddress && !requestedUserDataDir;
493
+ const userDataDir = debuggerAddress
494
+ ? undefined
495
+ : await resolveProfile(browser, requestedUserDataDir);
481
496
  const childEnv = driverEnvironment(restrictions, env);
482
497
 
483
498
  let driverProcess;
@@ -513,6 +528,7 @@ export async function launchWebDriver(options = {}, dependencies = {}) {
513
528
  args,
514
529
  automationParity,
515
530
  bidi,
531
+ debuggerAddress,
516
532
  });
517
533
  driver = await buildSession({
518
534
  selenium,
package/src/cli/args.js CHANGED
@@ -31,12 +31,15 @@ const BROWSER_OPTIONS = Object.freeze({
31
31
  'local-state': STRINGS,
32
32
  'default-browser-check': FLAG,
33
33
  'first-run': FLAG,
34
+ 'driver-path': STRING,
35
+ bidi: FLAG,
34
36
  });
35
37
 
36
38
  /** Options accepted by every page command. */
37
39
  const PAGE_OPTIONS = Object.freeze({
38
40
  ...BROWSER_OPTIONS,
39
41
  'cdp-endpoint': STRING,
42
+ 'server-url': STRING,
40
43
  url: STRING,
41
44
  });
42
45
 
@@ -248,6 +251,8 @@ export function launchParams(options) {
248
251
  localState: settings(options.localState, '--local-state'),
249
252
  defaultBrowserCheck: options.defaultBrowserCheck,
250
253
  firstRun: options.firstRun,
254
+ driverPath: options.driverPath,
255
+ bidi: options.bidi,
251
256
  };
252
257
  return Object.fromEntries(
253
258
  Object.entries(params).filter(([, value]) => value !== undefined)
@@ -75,12 +75,16 @@ async function withPageSession(parsed, io, work, { skipUrl = false } = {}) {
75
75
  const { options } = parsed;
76
76
  const dispatcher = createDispatcher({ dependencies: io.dependencies });
77
77
  try {
78
- const opened = options.cdpEndpoint
79
- ? await dispatcher.dispatch('session.connect', {
80
- cdpEndpoint: options.cdpEndpoint,
81
- engine: options.engine,
82
- })
83
- : await dispatcher.dispatch('session.launch', launchParams(options));
78
+ const opened =
79
+ options.cdpEndpoint || options.serverUrl
80
+ ? await dispatcher.dispatch('session.connect', {
81
+ cdpEndpoint: options.cdpEndpoint,
82
+ engine: options.engine,
83
+ ...(options.serverUrl ? { serverUrl: options.serverUrl } : {}),
84
+ ...(options.driverPath ? { driverPath: options.driverPath } : {}),
85
+ ...(options.bidi !== undefined ? { bidi: options.bidi } : {}),
86
+ })
87
+ : await dispatcher.dispatch('session.launch', launchParams(options));
84
88
  if (options.url && !skipUrl) {
85
89
  await dispatcher.dispatch('page.goto', {
86
90
  session: opened.session,
@@ -9,7 +9,7 @@
9
9
  import { decodeValue, describeObject, encodeValue } from './handles.js';
10
10
  import { invalidParams, requireString } from './rpc-error.js';
11
11
 
12
- const ENGINE_ROOTS = Object.freeze(['playwright', 'puppeteer']);
12
+ const ENGINE_ROOTS = Object.freeze(['playwright', 'puppeteer', 'selenium']);
13
13
 
14
14
  function targetOf(state, params) {
15
15
  const handle = params.handle;
@@ -36,11 +36,14 @@ async function root(state, params) {
36
36
  browser: handleOrNull(state, session.browser),
37
37
  context: handleOrNull(state, session.context),
38
38
  page: handleOrNull(state, session.page),
39
+ ...(session.engine === 'selenium'
40
+ ? { driver: handleOrNull(state, session.driver) }
41
+ : {}),
39
42
  };
40
43
  }
41
44
  if (!ENGINE_ROOTS.includes(name)) {
42
45
  throw invalidParams(
43
- `Unknown root "${name}"; expected playwright, puppeteer or session:<id>`
46
+ `Unknown root "${name}"; expected playwright, puppeteer, selenium or session:<id>`
44
47
  );
45
48
  }
46
49
  const module = await state.dependencies.loadEngine(name);
@@ -71,6 +74,19 @@ async function get(state, params) {
71
74
  );
72
75
  }
73
76
 
77
+ /** Instantiate an engine constructor obtained through `handle.get`. */
78
+ function construct(state, params) {
79
+ const target = targetOf(state, params);
80
+ const args = params.args ?? [];
81
+ if (typeof target !== 'function' || !Array.isArray(args)) {
82
+ throw invalidParams(
83
+ 'handle.construct requires a constructor handle and an args array'
84
+ );
85
+ }
86
+ const result = Reflect.construct(target, decodeValue(args, state.handles));
87
+ return encodeValue(result, state.handles);
88
+ }
89
+
74
90
  function dispose(state, params) {
75
91
  const id =
76
92
  typeof params.handle === 'object' && params.handle
@@ -129,6 +145,7 @@ export const HANDLE_METHODS = Object.freeze({
129
145
  'handle.root': root,
130
146
  'handle.call': call,
131
147
  'handle.get': get,
148
+ 'handle.construct': construct,
132
149
  'handle.dispose': dispose,
133
150
  'handle.describe': describe,
134
151
  'events.subscribe': subscribe,
@@ -37,14 +37,27 @@ async function launchSession(state, params) {
37
37
  }
38
38
 
39
39
  async function connectSession(state, params) {
40
- const cdpEndpoint = requireString(params, 'cdpEndpoint');
41
40
  const engine = resolveEngine(params.engine);
41
+ const endpoint =
42
+ engine === 'selenium' && params.serverUrl !== undefined
43
+ ? {
44
+ serverUrl: requireString(params, 'serverUrl'),
45
+ capabilities: params.capabilities,
46
+ }
47
+ : { cdpEndpoint: requireString(params, 'cdpEndpoint') };
42
48
  const connected = await state.dependencies.connectBrowser({
43
49
  engine,
44
- cdpEndpoint,
50
+ ...endpoint,
51
+ ...(params.driverPath ? { driverPath: params.driverPath } : {}),
52
+ ...(params.bidi !== undefined ? { bidi: params.bidi } : {}),
45
53
  });
46
54
  const session = state.sessions.add(sessionFromConnect(engine, connected));
47
- return { session, cdpEndpoint };
55
+ return {
56
+ session,
57
+ ...(endpoint.serverUrl
58
+ ? { serverUrl: endpoint.serverUrl }
59
+ : { cdpEndpoint: endpoint.cdpEndpoint }),
60
+ };
48
61
  }
49
62
 
50
63
  async function closeSession(state, params) {
@@ -70,6 +83,10 @@ async function fill(state, params) {
70
83
  const { engine, page } = sessionOf(state, params);
71
84
  if (engine === 'playwright') {
72
85
  await page.fill(selector, value);
86
+ } else if (engine === 'selenium') {
87
+ const element = await page.waitForSelector(selector, { visible: true });
88
+ await element.clear();
89
+ await element.sendKeys(value);
73
90
  } else {
74
91
  await page.locator(selector).fill(value);
75
92
  }
@@ -70,7 +70,11 @@ export const DEFAULT_DEPENDENCIES = Object.freeze({
70
70
  (await import('../browser/browser-profiles.js')).listBrowserProfiles(
71
71
  options
72
72
  ),
73
- loadEngine: (name) => importOptional(name, `The ${name} engine`),
73
+ loadEngine: (name) =>
74
+ importOptional(
75
+ name === 'selenium' ? 'selenium-webdriver' : name,
76
+ `The ${name} engine`
77
+ ),
74
78
  openInUserBrowser: async (url) =>
75
79
  (
76
80
  await importOptional(
@@ -14,7 +14,7 @@ export const BROWSER_CHANNELS = Object.freeze({
14
14
  chromium: 'chromium',
15
15
  });
16
16
 
17
- const ENGINES = Object.freeze(['playwright', 'puppeteer']);
17
+ const ENGINES = Object.freeze(['playwright', 'puppeteer', 'selenium']);
18
18
 
19
19
  /**
20
20
  * Launch options that are forwarded to `launchBrowser()` as they are, so the
@@ -38,6 +38,8 @@ const LAUNCH_PASSTHROUGH = Object.freeze([
38
38
  'localState',
39
39
  'defaultBrowserCheck',
40
40
  'firstRun',
41
+ 'driverPath',
42
+ 'bidi',
41
43
  ]);
42
44
 
43
45
  /** Validate an engine name, defaulting to Playwright. */
@@ -85,13 +87,17 @@ export function buildLaunchOptions(params = {}) {
85
87
  engine: resolveEngine(params.engine),
86
88
  headless: params.headless === true,
87
89
  };
88
- const channel = resolveChannel(params.browser);
90
+ const channel =
91
+ options.engine === 'selenium' ? undefined : resolveChannel(params.browser);
89
92
  const defined = {
90
93
  launch: params.launch,
91
94
  executablePath: params.executablePath,
92
95
  userDataDir: params.userDataDir,
93
96
  restrictions: params.restrictions,
94
97
  channel,
98
+ ...(options.engine === 'selenium' && params.browser !== undefined
99
+ ? { browser: params.browser }
100
+ : {}),
95
101
  };
96
102
  for (const name of LAUNCH_PASSTHROUGH) {
97
103
  defined[name] ??= params[name];
@@ -121,6 +127,7 @@ export function sessionFromLaunch(engine, launched) {
121
127
  : handle,
122
128
  context: playwright ? handle : (handle?.defaultBrowserContext?.() ?? null),
123
129
  page: launched.page,
130
+ driver: launched.driver ?? (engine === 'selenium' ? handle : null),
124
131
  args: launched.args ?? [],
125
132
  close: () => launched.close(),
126
133
  connected: false,
@@ -141,10 +148,15 @@ export function sessionFromConnect(engine, connected) {
141
148
  ? (page?.context?.() ?? null)
142
149
  : (browser?.defaultBrowserContext?.() ?? null),
143
150
  page,
151
+ driver: connected.driver ?? null,
144
152
  // Playwright's close() on a connectOverCDP browser disconnects;
145
153
  // Puppeteer's close() would quit the browser, disconnect() does not.
146
154
  close: async () => {
147
155
  await connected.downloads?.dispose?.();
156
+ if (engine === 'selenium') {
157
+ await connected.close();
158
+ return;
159
+ }
148
160
  await (playwright ? browser.close() : browser.disconnect());
149
161
  },
150
162
  connected: true,
@@ -57,6 +57,7 @@ export {
57
57
  export const SUPPORTED_BROWSER_TEST_ENGINES = Object.freeze([
58
58
  'playwright',
59
59
  'puppeteer',
60
+ 'selenium',
60
61
  ]);
61
62
 
62
63
  export const DEFAULT_TIMINGS_FILENAME = '.browser-commander-test-timings.json';
@@ -0,0 +1,107 @@
1
+ /** Native common launch and commander contracts for every JS engine (#124). */
2
+ import assert from 'node:assert/strict';
3
+ import { existsSync } from 'node:fs';
4
+ import { createServer } from 'node:http';
5
+ import { describe, it } from 'node:test';
6
+ import { launchBrowser } from '../../src/browser/launcher.js';
7
+ import { makeBrowserCommander } from '../../src/factory.js';
8
+ import {
9
+ CHROME_LAUNCH_OPTIONS,
10
+ PARITY_CHROME,
11
+ } from '../helpers/e2e-browser.js';
12
+
13
+ // feature-parity: engines.webdriver@native-typed engines.cli-matrix@native-typed
14
+ describe('Common native engine API', { skip: !process.env.RUN_E2E }, () => {
15
+ it(
16
+ 'selenium restores portable cookies and BiDi origin state',
17
+ { timeout: 60000 },
18
+ async () => {
19
+ const server = createServer((_request, response) => {
20
+ response.end('<title>Portable state</title>');
21
+ });
22
+ await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
23
+ const origin = `http://127.0.0.1:${server.address().port}`;
24
+ let session;
25
+ try {
26
+ session = await launchBrowser({
27
+ engine: 'selenium',
28
+ launch: 'engine',
29
+ bidi: true,
30
+ headless: true,
31
+ executablePath: PARITY_CHROME,
32
+ ...CHROME_LAUNCH_OPTIONS,
33
+ storageState: {
34
+ cookies: [{ name: 'session', value: 'restored', url: origin }],
35
+ origins: [
36
+ { origin, localStorage: [{ name: 'theme', value: 'dark' }] },
37
+ ],
38
+ },
39
+ });
40
+ await session.page.goto(origin);
41
+ assert.equal(
42
+ await session.page.evaluate(() =>
43
+ globalThis.localStorage.getItem('theme')
44
+ ),
45
+ 'dark'
46
+ );
47
+ assert.match(
48
+ await session.page.evaluate(() => document.cookie),
49
+ /session=restored/u
50
+ );
51
+ } finally {
52
+ await session?.close();
53
+ await new Promise((resolve) => server.close(resolve));
54
+ }
55
+ }
56
+ );
57
+ for (const engine of ['playwright', 'puppeteer', 'selenium']) {
58
+ for (const launch of ['real', 'engine']) {
59
+ it(
60
+ `${engine}/${launch}: input, click, native handles, PDF, cleanup`,
61
+ { timeout: 120000 },
62
+ async () => {
63
+ const session = await launchBrowser({
64
+ engine,
65
+ launch,
66
+ headless: true,
67
+ executablePath: PARITY_CHROME,
68
+ ...CHROME_LAUNCH_OPTIONS,
69
+ });
70
+ const commander = makeBrowserCommander({
71
+ page: session.page,
72
+ enableNetworkTracking: false,
73
+ enableNavigationManager: false,
74
+ });
75
+ const profile = session.userDataDir;
76
+ try {
77
+ assert.equal(commander.engine, engine);
78
+ await commander.goto({
79
+ url: `data:text/html,${encodeURIComponent('<input id="name"><button id="go" onclick="document.title=document.querySelector(\'#name\').value">Go</button>')}`,
80
+ });
81
+ await commander.fill({ selector: '#name', text: 'Ada' });
82
+ assert.equal(
83
+ await commander.inputValue({ selector: '#name' }),
84
+ 'Ada'
85
+ );
86
+ await commander.click({ selector: '#go' });
87
+ assert.equal(await session.page.title(), 'Ada');
88
+ assert.ok(
89
+ Buffer.from(await session.page.pdf({ format: 'A4' }))
90
+ .subarray(0, 5)
91
+ .equals(Buffer.from('%PDF-'))
92
+ );
93
+ if (engine === 'selenium') {
94
+ assert.equal(await session.driver.getTitle(), 'Ada');
95
+ }
96
+ } finally {
97
+ await commander.destroy();
98
+ await session.close();
99
+ }
100
+ if (session.temporaryProfile) {
101
+ assert.equal(existsSync(profile), false);
102
+ }
103
+ }
104
+ );
105
+ }
106
+ }
107
+ });
@@ -9,6 +9,7 @@
9
9
 
10
10
  import { describe, it, before, after } from 'node:test';
11
11
  import assert from 'node:assert';
12
+ import { CHROME_LAUNCH_OPTIONS } from '../helpers/e2e-browser.js';
12
13
 
13
14
  // Dynamic import for playwright since it may not be installed
14
15
  let playwright;
@@ -25,23 +26,16 @@ describe(
25
26
  const BASE_URL = process.env.TEST_URL || 'http://localhost:3000';
26
27
 
27
28
  before(async () => {
28
- try {
29
- playwright = await import('playwright');
30
- const module = await import('../../src/index.js');
31
- createCommander = module.createCommander;
32
-
33
- browser = await playwright.chromium.launch({
34
- headless: process.env.HEADLESS !== 'false',
35
- });
36
- const context = await browser.newContext();
37
- page = await context.newPage();
38
- commander = createCommander({ page, verbose: true });
39
- } catch (error) {
40
- console.log(
41
- 'Skipping E2E tests - playwright not available or test app not running'
42
- );
43
- console.log('Error:', error.message);
44
- }
29
+ playwright = await import('playwright');
30
+ const module = await import('../../src/index.js');
31
+ createCommander = module.createCommander;
32
+ browser = await playwright.chromium.launch({
33
+ headless: process.env.HEADLESS !== 'false',
34
+ ...CHROME_LAUNCH_OPTIONS,
35
+ });
36
+ const context = await browser.newContext();
37
+ page = await context.newPage();
38
+ commander = createCommander({ page, verbose: true });
45
39
  });
46
40
 
47
41
  after(async () => {
@@ -9,6 +9,7 @@
9
9
 
10
10
  import { describe, it, before, after } from 'node:test';
11
11
  import assert from 'node:assert';
12
+ import { CHROME_LAUNCH_OPTIONS } from '../helpers/e2e-browser.js';
12
13
 
13
14
  // Dynamic import for puppeteer since it may not be installed
14
15
  let puppeteer;
@@ -22,22 +23,15 @@ describe('E2E Tests - Puppeteer Engine', { skip: !process.env.RUN_E2E }, () => {
22
23
  const BASE_URL = process.env.TEST_URL || 'http://localhost:3000';
23
24
 
24
25
  before(async () => {
25
- try {
26
- puppeteer = await import('puppeteer');
27
- const module = await import('../../src/index.js');
28
- createCommander = module.createCommander;
29
-
30
- browser = await puppeteer.default.launch({
31
- headless: process.env.HEADLESS !== 'false' ? 'new' : false,
32
- });
33
- page = await browser.newPage();
34
- commander = createCommander({ page, verbose: true });
35
- } catch (error) {
36
- console.log(
37
- 'Skipping E2E tests - puppeteer not available or test app not running'
38
- );
39
- console.log('Error:', error.message);
40
- }
26
+ puppeteer = await import('puppeteer');
27
+ const module = await import('../../src/index.js');
28
+ createCommander = module.createCommander;
29
+ browser = await puppeteer.default.launch({
30
+ headless: process.env.HEADLESS !== 'false',
31
+ ...CHROME_LAUNCH_OPTIONS,
32
+ });
33
+ page = await browser.newPage();
34
+ commander = createCommander({ page, verbose: true });
41
35
  });
42
36
 
43
37
  after(async () => {
@@ -352,7 +346,9 @@ describe('E2E Tests - Puppeteer Engine', { skip: !process.env.RUN_E2E }, () => {
352
346
  }
353
347
 
354
348
  // Scroll to specific item
355
- await commander.scroll({ selector: '[data-testid="scroll-item-15"]' });
349
+ await commander.scrollIntoView({
350
+ locatorOrElement: await page.$('[data-testid="scroll-item-15"]'),
351
+ });
356
352
  await commander.wait({ ms: 500 });
357
353
 
358
354
  // Target should be visible