browser-commander 0.26.2 → 0.27.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 (58) hide show
  1. package/.jscpd-baseline.json +24 -23
  2. package/CHANGELOG.md +12 -0
  3. package/README.md +2 -0
  4. package/package.json +5 -3
  5. package/src/bindings.js +19 -0
  6. package/src/browser/attach/snapshot-launch.js +8 -6
  7. package/src/browser/attach/snapshot.js +39 -2
  8. package/src/browser/browser-cookie-database.js +71 -2
  9. package/src/browser/browser-cookie-session.js +183 -0
  10. package/src/browser/browser-cookies.js +29 -0
  11. package/src/browser/browser-sources.js +13 -0
  12. package/src/browser/browser-sources.json +13 -0
  13. package/src/browser/debugging-port.js +4 -2
  14. package/src/browser/default-browser.js +33 -9
  15. package/src/browser/launch-diagnostics.js +102 -0
  16. package/src/browser/launcher.js +28 -8
  17. package/src/browser/migration/sqlite-snapshot.js +5 -27
  18. package/src/browser/navigation.js +173 -98
  19. package/src/browser/real-browser.js +135 -68
  20. package/src/browser/session-cookies.js +78 -0
  21. package/src/browser/session-persistence.js +72 -0
  22. package/src/browser/storage-state.js +32 -6
  23. package/src/browser/system-browser.js +2 -2
  24. package/src/core/dialog-manager.js +7 -0
  25. package/src/core/managed-navigation.js +101 -0
  26. package/src/core/navigation-manager.js +62 -78
  27. package/src/core/navigation-operation.js +25 -0
  28. package/src/core/navigation-readiness.js +40 -16
  29. package/src/core/network-tracker.js +39 -92
  30. package/src/core/page-session.js +3 -2
  31. package/src/core/page-trigger-manager.js +15 -12
  32. package/src/core/readiness.js +67 -13
  33. package/src/core/subscriptions.js +20 -0
  34. package/src/core/webdriver-page.js +38 -0
  35. package/src/elements/locators.js +35 -5
  36. package/src/elements/reusable.js +92 -0
  37. package/src/elements/visibility.js +6 -11
  38. package/src/exports.js +14 -0
  39. package/src/factory.js +4 -4
  40. package/src/high-level/universal-logic.js +57 -21
  41. package/src/interactions/click.js +3 -0
  42. package/src/interactions/scroll.js +8 -2
  43. package/src/utilities/subprocess.js +15 -4
  44. package/tests/e2e/navigation-budget.e2e.test.js +132 -0
  45. package/tests/e2e/reusable-session.e2e.test.js +231 -0
  46. package/tests/unit/browser/attach/snapshot-launch.test.js +2 -2
  47. package/tests/unit/browser/attach/snapshot.test.js +33 -0
  48. package/tests/unit/browser/catalogue-launch.test.js +50 -2
  49. package/tests/unit/browser/default-browser.test.js +18 -0
  50. package/tests/unit/browser/launch-diagnostics.test.js +52 -0
  51. package/tests/unit/browser/real-browser.test.js +1 -1
  52. package/tests/unit/browser/session-workflows.test.js +206 -0
  53. package/tests/unit/browser/storage-state.test.js +7 -2
  54. package/tests/unit/core/navigation-budget.test.js +120 -0
  55. package/tests/unit/core/navigation-manager.test.js +23 -1
  56. package/tests/unit/core/webdriver-page.test.js +33 -0
  57. package/tests/unit/elements/reusable-helpers.test.js +83 -0
  58. package/tests/unit/traces/links.test.js +2 -2
@@ -0,0 +1,92 @@
1
+ import { count, isVisible } from './visibility.js';
2
+ import { getLocatorOrElement } from './locators.js';
3
+ import { clickButton } from '../interactions/click.js';
4
+ import { evaluate } from '../utilities/wait.js';
5
+
6
+ /** Return the first selector in caller order with a match (optionally visible). */
7
+ export async function findFirst({
8
+ selectors,
9
+ visible = false,
10
+ ...options
11
+ } = {}) {
12
+ if (!Array.isArray(selectors)) {
13
+ throw new TypeError('selectors must be an array');
14
+ }
15
+ for (const selector of selectors) {
16
+ if (
17
+ (await count({ ...options, selector })) > 0 &&
18
+ (!visible || (await isVisible({ ...options, selector })))
19
+ ) {
20
+ return selector;
21
+ }
22
+ }
23
+ return null;
24
+ }
25
+
26
+ /** Match any phrase in body text; Unicode whitespace includes nonbreaking spaces. */
27
+ export function hasText({
28
+ texts,
29
+ normalizeWhitespace = false,
30
+ ...options
31
+ } = {}) {
32
+ if (!Array.isArray(texts) || texts.some((text) => typeof text !== 'string')) {
33
+ throw new TypeError('texts must be strings');
34
+ }
35
+ return evaluate({
36
+ ...options,
37
+ fn: (phrases, normalize) => {
38
+ const clean = (text) =>
39
+ normalize ? text.replace(/\s+/gu, ' ').trim() : text;
40
+ const body = clean(document.body?.textContent ?? '');
41
+ return phrases.some((text) => body.includes(clean(text)));
42
+ },
43
+ args: [texts, normalizeWhitespace],
44
+ });
45
+ }
46
+
47
+ /** Observe checked state of the selected checkbox or radio. */
48
+ export async function isChecked(options = {}) {
49
+ const element = await getLocatorOrElement(options);
50
+ if (!element) {
51
+ return false;
52
+ }
53
+ const probe = (el) => Boolean(el.checked);
54
+ return options.engine === 'playwright'
55
+ ? element.evaluate(probe)
56
+ : options.page.evaluate(probe, element);
57
+ }
58
+
59
+ /** Idempotently set a checkbox or radio and verify the resulting state. */
60
+ export async function check({ checked = true, ...options } = {}) {
61
+ const element = await getLocatorOrElement(options);
62
+ if (!element) {
63
+ return { checked: false, changed: false, verified: false };
64
+ }
65
+ const type =
66
+ options.engine === 'playwright'
67
+ ? await element.evaluate((el) => el.type)
68
+ : await options.page.evaluate((el) => el.type, element);
69
+ if (!['checkbox', 'radio'].includes(type)) {
70
+ throw new TypeError('check requires a checkbox or radio');
71
+ }
72
+ if (type === 'radio' && !checked) {
73
+ throw new TypeError('A radio cannot be unchecked by clicking');
74
+ }
75
+ const before = await isChecked({ ...options, selector: element });
76
+ if (before === checked) {
77
+ return { checked: before, changed: false, verified: true };
78
+ }
79
+ await clickButton({
80
+ ...options,
81
+ selector: element,
82
+ verify: false,
83
+ waitAfterClick: 0,
84
+ waitForNavigation: false,
85
+ });
86
+ const after = await isChecked({ ...options, selector: element });
87
+ return {
88
+ checked: after,
89
+ changed: before !== after,
90
+ verified: after === checked,
91
+ };
92
+ }
@@ -19,7 +19,7 @@ export async function isVisible(options = {}) {
19
19
 
20
20
  try {
21
21
  if (engine === 'playwright') {
22
- const locator = await getLocatorOrElement({ page, engine, selector });
22
+ const locator = await getLocatorOrElement(options);
23
23
  try {
24
24
  await locator.waitFor({
25
25
  state: 'visible',
@@ -30,7 +30,7 @@ export async function isVisible(options = {}) {
30
30
  return false;
31
31
  }
32
32
  } else {
33
- const element = await getLocatorOrElement({ page, engine, selector });
33
+ const element = await getLocatorOrElement(options);
34
34
  if (!element) {
35
35
  return false;
36
36
  }
@@ -56,16 +56,11 @@ export async function isVisible(options = {}) {
56
56
  * @param {Object} options.page - Browser page object
57
57
  * @param {string} options.engine - Engine type ('playwright' or 'puppeteer')
58
58
  * @param {string|Object} options.selector - CSS selector or locator
59
- * @param {Array<string>} options.disabledClasses - Additional CSS classes that indicate disabled state (default: ['magritte-button_loading'])
59
+ * @param {Array<string>} options.disabledClasses - Additional CSS classes that indicate disabled state (default: ['disabled'])
60
60
  * @returns {Promise<boolean>} - True if enabled
61
61
  */
62
62
  export async function isEnabled(options = {}) {
63
- const {
64
- page,
65
- engine,
66
- selector,
67
- disabledClasses = ['magritte-button_loading'],
68
- } = options;
63
+ const { page, engine, selector, disabledClasses = ['disabled'] } = options;
69
64
 
70
65
  if (!selector) {
71
66
  throw new Error('selector is required in options');
@@ -76,7 +71,7 @@ export async function isEnabled(options = {}) {
76
71
  // For Playwright, use locator API
77
72
  const locator =
78
73
  typeof selector === 'string'
79
- ? page.locator(selector).first()
74
+ ? (await getLocatorOrElement(options)).first()
80
75
  : selector;
81
76
  return await locator.evaluate((el, classes) => {
82
77
  const isDisabled =
@@ -87,7 +82,7 @@ export async function isEnabled(options = {}) {
87
82
  }, disabledClasses);
88
83
  } else {
89
84
  // For Puppeteer (selector should already be normalized by withTextSelectorSupport wrapper)
90
- const element = await getLocatorOrElement({ page, engine, selector });
85
+ const element = await getLocatorOrElement(options);
91
86
  if (!element) {
92
87
  return false;
93
88
  }
package/src/exports.js CHANGED
@@ -328,3 +328,17 @@ export {
328
328
  SafariUnsupportedError,
329
329
  openSafariSettings,
330
330
  } from './browser/safari-support.js';
331
+
332
+ export { BrowserLaunchError } from './browser/launch-diagnostics.js';
333
+
334
+ export { findFirst, hasText, isChecked, check } from './elements/reusable.js';
335
+ export { setCookies, clearCookies } from './browser/session-cookies.js';
336
+ export {
337
+ readFlag,
338
+ uninstallClickListener,
339
+ } from './high-level/universal-logic.js';
340
+
341
+ export {
342
+ findSiteSessions,
343
+ readBrowserCookieSession,
344
+ } from './browser/browser-cookie-session.js';
package/src/factory.js CHANGED
@@ -215,16 +215,16 @@ export function makeBrowserCommander(options = {}) {
215
215
  // Subscribe to navigation events (legacy API)
216
216
  onNavigationStart: navigationManager
217
217
  ? (fn) => navigationManager.on('onNavigationStart', fn)
218
- : () => {},
218
+ : () => () => {},
219
219
  onNavigationComplete: navigationManager
220
220
  ? (fn) => navigationManager.on('onNavigationComplete', fn)
221
- : () => {},
221
+ : () => () => {},
222
222
  onUrlChange: navigationManager
223
223
  ? (fn) => navigationManager.on('onUrlChange', fn)
224
- : () => {},
224
+ : () => () => {},
225
225
  onPageReady: navigationManager
226
226
  ? (fn) => navigationManager.on('onPageReady', fn)
227
- : () => {},
227
+ : () => () => {},
228
228
 
229
229
  // Abort handling - check these to stop operations when navigation occurs
230
230
  shouldAbort: navigationManager
@@ -101,26 +101,29 @@ export async function installClickListener(options = {}) {
101
101
 
102
102
  const result = await safeEvaluate({
103
103
  fn: (text, key) => {
104
- document.addEventListener(
105
- 'click',
106
- (event) => {
107
- let element = event.target;
108
- while (element && element !== document.body) {
109
- const elementText = element.textContent?.trim() || '';
110
- if (
111
- elementText === text ||
112
- ((element.tagName === 'A' || element.tagName === 'BUTTON') &&
113
- elementText.includes(text))
114
- ) {
115
- console.log(`[Click Listener] Detected click on ${text} button!`);
116
- window.sessionStorage.setItem(key, 'true');
117
- break;
118
- }
119
- element = element.parentElement;
104
+ window.__browserCommanderClickListeners ??= new Map();
105
+ const previous = window.__browserCommanderClickListeners.get(key);
106
+ if (previous) {
107
+ document.removeEventListener('click', previous, true);
108
+ }
109
+ const listener = (event) => {
110
+ let element = event.target;
111
+ while (element && element !== document.body) {
112
+ const elementText = element.textContent?.trim() || '';
113
+ if (
114
+ elementText === text ||
115
+ ((element.tagName === 'A' || element.tagName === 'BUTTON') &&
116
+ elementText.includes(text))
117
+ ) {
118
+ console.log(`[Click Listener] Detected click on ${text} button!`);
119
+ window.sessionStorage.setItem(key, 'true');
120
+ break;
120
121
  }
121
- },
122
- true
123
- );
122
+ element = element.parentElement;
123
+ }
124
+ };
125
+ window.__browserCommanderClickListeners.set(key, listener);
126
+ document.addEventListener('click', listener, true);
124
127
  },
125
128
  args: [buttonText, storageKey],
126
129
  });
@@ -176,6 +179,7 @@ export async function findToggleButton(options = {}) {
176
179
  findByText,
177
180
  dataQaSelectors = [],
178
181
  textToFind,
182
+ texts = textToFind ? [textToFind] : [],
179
183
  elementTypes = ['button', 'a', 'span'],
180
184
  } = options;
181
185
 
@@ -188,10 +192,10 @@ export async function findToggleButton(options = {}) {
188
192
  }
189
193
 
190
194
  // Fallback to text search
191
- if (textToFind) {
195
+ for (const text of texts) {
192
196
  for (const elementType of elementTypes) {
193
197
  const selector = await findByText({
194
- text: textToFind,
198
+ text,
195
199
  selector: elementType,
196
200
  });
197
201
  const elemCount = await count({ selector });
@@ -203,3 +207,35 @@ export async function findToggleButton(options = {}) {
203
207
 
204
208
  return null;
205
209
  }
210
+
211
+ /** Read without clearing; interruption is distinct from an unset flag. */
212
+ export async function readFlag({ evaluate, storageKey } = {}) {
213
+ try {
214
+ const set = await evaluate({
215
+ fn: (key) => window.sessionStorage.getItem(key) === 'true',
216
+ args: [storageKey],
217
+ });
218
+ return { status: 'observed', set };
219
+ } catch (error) {
220
+ if (isNavigationError(error)) {
221
+ return { status: 'interrupted', set: null };
222
+ }
223
+ throw error;
224
+ }
225
+ }
226
+
227
+ /** Uninstall only the click listener installed for this storage key. */
228
+ export function uninstallClickListener({ evaluate, storageKey } = {}) {
229
+ return evaluate({
230
+ fn: (key) => {
231
+ const listener = window.__browserCommanderClickListeners?.get(key);
232
+ if (!listener) {
233
+ return false;
234
+ }
235
+ document.removeEventListener('click', listener, true);
236
+ window.__browserCommanderClickListeners.delete(key);
237
+ return true;
238
+ },
239
+ args: [storageKey],
240
+ });
241
+ }
@@ -709,6 +709,7 @@ async function prepareElement(options = {}) {
709
709
  log,
710
710
  verbose = false,
711
711
  selector,
712
+ index,
712
713
  scrollIntoView: shouldScroll = true,
713
714
  waitAfterScroll,
714
715
  smoothScroll = true,
@@ -719,6 +720,7 @@ async function prepareElement(options = {}) {
719
720
  page,
720
721
  engine,
721
722
  selector,
723
+ index,
722
724
  timeout,
723
725
  });
724
726
 
@@ -982,6 +984,7 @@ export async function clickButton(options = {}) {
982
984
  log,
983
985
  verbose,
984
986
  selector,
987
+ index: options.index,
985
988
  scrollIntoView: activationOptions.scroll === CLICK_SCROLL.AUTO,
986
989
  waitAfterScroll,
987
990
  smoothScroll,
@@ -1,3 +1,4 @@
1
+ import { getLocatorOrElement } from '../elements/locators.js';
1
2
  import { TIMING } from '../core/constants.js';
2
3
  import { isNavigationError } from '../core/navigation-safety.js';
3
4
  import { isActionStoppedError } from '../core/page-trigger-manager.js';
@@ -146,7 +147,7 @@ export async function scrollIntoView(options = {}) {
146
147
  const {
147
148
  page,
148
149
  engine,
149
- locatorOrElement,
150
+ locatorOrElement: suppliedElement,
150
151
  behavior = 'smooth',
151
152
  verify = true,
152
153
  verifyFn,
@@ -154,8 +155,13 @@ export async function scrollIntoView(options = {}) {
154
155
  log = { debug: () => {} },
155
156
  } = options;
156
157
 
158
+ const locatorOrElement =
159
+ suppliedElement ??
160
+ (options.selector ? await getLocatorOrElement(options) : null);
157
161
  if (!locatorOrElement) {
158
- throw new Error('locatorOrElement is required in options');
162
+ throw new Error(
163
+ 'locatorOrElement is required (or provide selector) in options'
164
+ );
159
165
  }
160
166
 
161
167
  try {
@@ -14,12 +14,16 @@ import { ProcessRunner } from 'command-stream/process-runner';
14
14
  * optional PTY and terminal-rendering modules are never loaded.
15
15
  */
16
16
 
17
- function runnerFor(file, args, { env, cwd, stdin = 'ignore', killGrace }) {
17
+ function runnerFor(
18
+ file,
19
+ args,
20
+ { env, cwd, stdin = 'ignore', killGrace, capture = true }
21
+ ) {
18
22
  return new ProcessRunner(
19
23
  { mode: 'exec', file, args },
20
24
  {
21
25
  mirror: false,
22
- capture: true,
26
+ capture,
23
27
  stdin,
24
28
  ...(env ? { env } : {}),
25
29
  ...(cwd ? { cwd } : {}),
@@ -102,7 +106,7 @@ export async function runCommand(file, args = [], options = {}) {
102
106
  */
103
107
  export function startProcess(file, args = [], options = {}) {
104
108
  const { env, cwd, forwardOutput = false, killGrace = 2000 } = options;
105
- const runner = runnerFor(file, args, { env, cwd, killGrace });
109
+ const runner = runnerFor(file, args, { env, cwd, killGrace, capture: false });
106
110
  return new ManagedProcess(runner, { forwardOutput });
107
111
  }
108
112
 
@@ -136,6 +140,8 @@ export class ManagedProcess {
136
140
  this.runner = runner;
137
141
  this.exitCode = null;
138
142
  this.signalCode = null;
143
+ this.stderrTail = '';
144
+ this.spawnError = null;
139
145
  this.stdout = new OutputChannel();
140
146
  this.stderr = new OutputChannel();
141
147
  this.exitListeners = new Set();
@@ -146,6 +152,7 @@ export class ManagedProcess {
146
152
  this.stdout.emit(chunk);
147
153
  });
148
154
  runner.on('stderr', (chunk) => {
155
+ this.stderrTail = (this.stderrTail + String(chunk)).slice(-8192);
149
156
  if (forwardOutput) {
150
157
  process.stderr.write(chunk);
151
158
  }
@@ -153,9 +160,13 @@ export class ManagedProcess {
153
160
  });
154
161
  runner.start();
155
162
  this.exited = Promise.resolve(runner)
156
- .catch((error) => ({ code: error?.code ?? 1 }))
163
+ .catch((error) => {
164
+ this.spawnError = error;
165
+ return { code: error?.code ?? 1 };
166
+ })
157
167
  .then((result) => {
158
168
  this.exitCode = typeof result.code === 'number' ? result.code : 1;
169
+ this.signalCode = result.signal ?? null;
159
170
  for (const listener of this.exitListeners) {
160
171
  listener(this.exitCode);
161
172
  }
@@ -0,0 +1,132 @@
1
+ import { describe, it, before, after } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { CHROME_LAUNCH_OPTIONS } from '../helpers/e2e-browser.js';
4
+ import { createCommander, predicate } from '../../src/index.js';
5
+ import { sendHtml, startFixtureHost } from '../helpers/fixture-server.js';
6
+
7
+ // Authored local fixtures only. Each deliberately stalled connection is closed
8
+ // by server teardown; browser actions and suites have finite budgets.
9
+ for (const engine of ['playwright', 'puppeteer']) {
10
+ describe(
11
+ `navigation budgets: ${engine}`,
12
+ { skip: !process.env.RUN_E2E, timeout: 60000 },
13
+ () => {
14
+ let browser;
15
+ let page;
16
+ let commander;
17
+ let server;
18
+ before(async () => {
19
+ server = await startFixtureHost((route, _req, res) => {
20
+ if (route === '/stall' || route === '/pending') {
21
+ return;
22
+ }
23
+ if (route === '/redirect') {
24
+ res.writeHead(302, { location: '/ready' });
25
+ res.end();
26
+ return;
27
+ }
28
+ if (route === '/continuous') {
29
+ sendHtml(
30
+ res,
31
+ '<h1>Continuous requests</h1><script>setInterval(() => fetch("/pending"), 60);</script>'
32
+ );
33
+ return;
34
+ }
35
+ sendHtml(res, '<h1 id="ready">Ready</h1>');
36
+ });
37
+ const { chromium } = await import('playwright');
38
+ const launchOptions = { headless: true, ...CHROME_LAUNCH_OPTIONS };
39
+ if (engine === 'playwright') {
40
+ browser = await chromium.launch(launchOptions);
41
+ page = await browser.newPage();
42
+ } else {
43
+ const { default: puppeteer } = await import('puppeteer');
44
+ browser = await puppeteer.launch({
45
+ ...launchOptions,
46
+ executablePath:
47
+ CHROME_LAUNCH_OPTIONS.executablePath ?? chromium.executablePath(),
48
+ });
49
+ page = await browser.newPage();
50
+ }
51
+ commander = createCommander({ page, engine });
52
+ });
53
+ after(async () => {
54
+ await commander?.destroy();
55
+ await browser?.close();
56
+ await server?.close();
57
+ });
58
+ const navigate = async (url, options = {}) => {
59
+ const start = performance.now();
60
+ const result = await commander.goto({
61
+ url,
62
+ timeout: 800,
63
+ verify: false,
64
+ waitForStableUrlBefore: false,
65
+ waitForStableUrlAfter: false,
66
+ waitForNetworkIdle: false,
67
+ ...options,
68
+ });
69
+ assert.ok(
70
+ performance.now() - start < 1600,
71
+ 'end-to-end budget must bound elapsed time'
72
+ );
73
+ return result;
74
+ };
75
+ it('loads a data page quickly with tracking enabled', async () => {
76
+ assert.equal(
77
+ (await navigate('data:text/html,<h1>Authored fixture</h1>')).status,
78
+ 'ready'
79
+ );
80
+ assert.ok(commander.networkTracker);
81
+ assert.ok(page.listenerCount?.('request') > 0);
82
+ });
83
+ it('follows redirects under the same deadline', async () => {
84
+ const result = await navigate(`${server.baseUrl}/redirect`);
85
+ assert.equal(result.status, 'ready');
86
+ assert.equal(page.url(), `${server.baseUrl}/ready`);
87
+ });
88
+ it('times out continuous network activity and cleans navigation state', async () => {
89
+ const result = await navigate(`${server.baseUrl}/continuous`, {
90
+ waitForNetworkIdle: true,
91
+ });
92
+ assert.equal(result.status, 'timed_out');
93
+ assert.equal(commander.navigationManager.isNavigating(), false);
94
+ });
95
+ it('bounds a stalled engine navigation', async () => {
96
+ assert.equal(
97
+ (await navigate(`${server.baseUrl}/stall`)).status,
98
+ 'timed_out'
99
+ );
100
+ });
101
+ it('observes a positive selector check', async () => {
102
+ const ready = predicate({
103
+ name: 'selector_ready',
104
+ fn: async () =>
105
+ page.evaluate(() => Boolean(document.querySelector('#ready'))),
106
+ });
107
+ assert.equal(
108
+ (await navigate(`${server.baseUrl}/ready`, { checks: [ready] }))
109
+ .status,
110
+ 'ready'
111
+ );
112
+ });
113
+ it('cancels a stalled call and preserves only the session listeners', async () => {
114
+ const abort = new AbortController();
115
+ const event =
116
+ engine === 'playwright' ? 'framenavigated' : 'framenavigated';
117
+ const count = page.listenerCount?.(event);
118
+ const timer = setTimeout(() => abort.abort(), 80);
119
+ try {
120
+ const result = await navigate(`${server.baseUrl}/stall`, {
121
+ signal: abort.signal,
122
+ });
123
+ assert.equal(result.status, 'interrupted');
124
+ assert.equal(commander.navigationManager.isNavigating(), false);
125
+ assert.equal(page.listenerCount?.(event), count);
126
+ } finally {
127
+ clearTimeout(timer);
128
+ }
129
+ });
130
+ }
131
+ );
132
+ }