browser-commander 0.31.0 → 0.31.2

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/CHANGELOG.md CHANGED
@@ -1,5 +1,19 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.31.2
4
+
5
+ ### Patch Changes
6
+
7
+ - 9f486af: Preserve native Playwright text selectors and CSS sibling positions. Resolve nonnative text matches to unique ancestor paths so interactions keep their intended target across Playwright, Puppeteer, and Selenium.
8
+
9
+ Retain sanitized engine failures alongside Chrome stderr in launch error messages. Identify attachment stages and optionally log their timing with verbose mode; wait for actual child exit before profile cleanup, and verify readiness, cleanup ownership, and fresh-profile startup on macOS in CI.
10
+
11
+ ## 0.31.1
12
+
13
+ ### Patch Changes
14
+
15
+ - c8f52e3: Upgrade Links Notation to ^0.25.2 so consumers using 0.25.2 can share one copy without an override. Preserve trace text containing lone UTF-16 surrogates, verify export/import and streaming compatibility, and document the new empty/control-value literals for consumers upgrading older parsers.
16
+
3
17
  ## 0.31.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.31.0",
3
+ "version": "0.31.2",
4
4
  "description": "Universal browser automation library that supports Playwright, Puppeteer, and Selenium with a unified API",
5
5
  "main": "src/index.js",
6
6
  "bin": {
@@ -84,7 +84,7 @@
84
84
  "command-stream": "^2.0.2",
85
85
  "csv-parse": "^7.0.3",
86
86
  "gifenc": "^1.0.3",
87
- "links-notation": "^0.23.0",
87
+ "links-notation": "^0.25.2",
88
88
  "log-lazy": "^1.0.4",
89
89
  "plist": "^5.0.0",
90
90
  "test-anywhere": "^0.9.1",
@@ -0,0 +1,23 @@
1
+ /** Identify attachment failures; optional logs contain stage names and timings. */
2
+ export async function connectionStage(options, stage, work) {
3
+ const start = performance.now();
4
+ const report = (state) => {
5
+ if (options.verbose) {
6
+ console.log(
7
+ `Browser attachment ${stage} ${state} after ${Math.round(performance.now() - start)}ms`
8
+ );
9
+ }
10
+ };
11
+ report('started');
12
+ try {
13
+ const result = await work();
14
+ report('completed');
15
+ return result;
16
+ } catch (error) {
17
+ if (error && typeof error === 'object' && Object.isExtensible(error)) {
18
+ error.attachmentStage ??= stage;
19
+ }
20
+ report('failed');
21
+ throw error;
22
+ }
23
+ }
@@ -1,4 +1,5 @@
1
1
  import { attachDownloads } from '../downloads/attach.js';
2
+ import { connectionStage } from './connection-stage.js';
2
3
  import { connectWebDriver, launchWebDriver } from './webdriver.js';
3
4
  import {
4
5
  loadStorageState,
@@ -282,7 +283,9 @@ async function connectPlaywright({ options, loadPlaywright, storageState }) {
282
283
  const settings = buildPlaywrightConnectOptions(options);
283
284
  let browser;
284
285
  try {
285
- browser = await chromium.connectOverCDP(endpoint, settings);
286
+ browser = await connectionStage(options, 'engine-connect', () =>
287
+ chromium.connectOverCDP(endpoint, settings)
288
+ );
286
289
  } catch (error) {
287
290
  if (
288
291
  settings.noDefaults !== undefined ||
@@ -291,21 +294,26 @@ async function connectPlaywright({ options, loadPlaywright, storageState }) {
291
294
  ) {
292
295
  throw error;
293
296
  }
294
- browser = await chromium.connectOverCDP(endpoint, {
295
- ...settings,
296
- noDefaults: true,
297
- });
297
+ browser = await connectionStage(options, 'engine-connect', () =>
298
+ chromium.connectOverCDP(endpoint, { ...settings, noDefaults: true })
299
+ );
298
300
  }
299
301
  try {
300
302
  const context = browser.contexts()[0];
301
303
  if (!context) {
302
304
  throw new Error('Connected Playwright browser has no default context');
303
305
  }
304
- const page =
305
- (await pickForegroundPage(context.pages(), options)) ??
306
- (await context.newPage());
306
+ const page = await connectionStage(
307
+ options,
308
+ 'page-selection',
309
+ async () =>
310
+ (await pickForegroundPage(context.pages(), options)) ??
311
+ (await context.newPage())
312
+ );
307
313
 
308
- await restorePlaywrightStorageState({ context, storageState });
314
+ await connectionStage(options, 'storage-restore', () =>
315
+ restorePlaywrightStorageState({ context, storageState })
316
+ );
309
317
  return { browser, page, detach: browser.close?.bind(browser) };
310
318
  } catch (error) {
311
319
  await ignoreCleanup(() => browser.close?.());
@@ -316,15 +324,21 @@ async function connectPlaywright({ options, loadPlaywright, storageState }) {
316
324
  async function connectPuppeteer({ options, loadPuppeteer, storageState }) {
317
325
  const puppeteerModule = await loadPuppeteer();
318
326
  const puppeteer = puppeteerModule.default ?? puppeteerModule;
319
- const browser = await puppeteer.connect(
320
- buildPuppeteerConnectOptions(options)
327
+ const browser = await connectionStage(options, 'engine-connect', () =>
328
+ puppeteer.connect(buildPuppeteerConnectOptions(options))
321
329
  );
322
330
  try {
323
- const page =
324
- (await pickForegroundPage(await browser.pages(), options)) ??
325
- (await browser.newPage());
331
+ const page = await connectionStage(
332
+ options,
333
+ 'page-selection',
334
+ async () =>
335
+ (await pickForegroundPage(await browser.pages(), options)) ??
336
+ (await browser.newPage())
337
+ );
326
338
 
327
- await restorePuppeteerStorageState({ page, storageState });
339
+ await connectionStage(options, 'storage-restore', () =>
340
+ restorePuppeteerStorageState({ page, storageState })
341
+ );
328
342
  return { browser, page, detach: browser.disconnect?.bind(browser) };
329
343
  } catch (error) {
330
344
  await ignoreCleanup(() => browser.disconnect?.());
@@ -7,10 +7,11 @@ export class BrowserLaunchError extends Error {
7
7
  exitCode = null,
8
8
  signal = null,
9
9
  stderrTail = '',
10
+ attachmentStage,
10
11
  cause,
11
12
  }) {
12
13
  super(
13
- `Browser launch failed (${phase}: ${category}): ${stderrTail || cause?.message || ''}`,
14
+ `Browser launch failed (${phase}: ${category}): ${cause?.message || ''}${stderrTail ? `\nBrowser stderr:\n${stderrTail}` : ''}`,
14
15
  { cause }
15
16
  );
16
17
  this.name = 'BrowserLaunchError';
@@ -21,6 +22,7 @@ export class BrowserLaunchError extends Error {
21
22
  exitCode,
22
23
  signal,
23
24
  stderrTail,
25
+ ...(attachmentStage ? { attachmentStage } : {}),
24
26
  });
25
27
  }
26
28
  }
@@ -98,5 +100,6 @@ export function launchFailure(
98
100
  signal: browserProcess?.signalCode ?? null,
99
101
  stderrTail: scrub(browserProcess?.stderrTail ?? cause?.stderrTail ?? ''),
100
102
  cause: safeCause,
103
+ attachmentStage: cause?.attachmentStage,
101
104
  });
102
105
  }
@@ -284,13 +284,6 @@ export class PlaywrightAdapter extends EngineAdapter {
284
284
  // ============================================================================
285
285
 
286
286
  createLocator(selector) {
287
- // Handle :nth-of-type() pseudo-selectors which don't work in Playwright locators
288
- const nthOfTypeMatch = selector.match(/^(.+):nth-of-type\((\d+)\)$/);
289
- if (nthOfTypeMatch) {
290
- const baseSelector = nthOfTypeMatch[1];
291
- const index = parseInt(nthOfTypeMatch[2], 10) - 1; // Convert to 0-based
292
- return this.page.locator(baseSelector).nth(index);
293
- }
294
287
  return this.page.locator(selector);
295
288
  }
296
289
 
@@ -6,7 +6,7 @@ import {
6
6
 
7
7
  /**
8
8
  * Helper to create Playwright locator from selector string
9
- * Handles :nth-of-type() pseudo-selectors which don't work in Playwright locators
9
+ * Preserves native CSS and text selector semantics.
10
10
  * @param {Object} options - Configuration options
11
11
  * @param {Object} options.page - Browser page object
12
12
  * @param {string} options.selector - CSS selector
@@ -22,15 +22,6 @@ export function createPlaywrightLocator(options = {}) {
22
22
  const { page, selector } = options;
23
23
 
24
24
  requireSelector(selector);
25
- // Check if selector has :nth-of-type(n) pattern
26
- const nthOfTypeMatch = selector.match(/^(.+):nth-of-type\((\d+)\)$/);
27
-
28
- if (nthOfTypeMatch) {
29
- const baseSelector = nthOfTypeMatch[1];
30
- const index = parseInt(nthOfTypeMatch[2], 10) - 1; // Convert to 0-based index
31
- return page.locator(baseSelector).nth(index);
32
- }
33
-
34
25
  return page.locator(selector);
35
26
  }
36
27
 
@@ -4,6 +4,7 @@ import {
4
4
  recoverWaitError,
5
5
  } from '../core/navigation-safety.js';
6
6
  import { createPlaywrightLocator, requireSelector } from './locators.js';
7
+ import { textSelectorInPage } from './text-selector.js';
7
8
 
8
9
  /**
9
10
  * Query single element
@@ -80,75 +81,18 @@ export async function findByText(options = {}) {
80
81
  }
81
82
  }
82
83
 
83
- /**
84
- * Check if a selector is a Playwright-specific text selector
85
- * @param {string} selector - The selector to check
86
- * @returns {boolean} - True if selector contains Playwright text pseudo-selectors
87
- */
88
- function isPlaywrightTextSelector(selector) {
89
- if (typeof selector !== 'string') {
90
- return false;
91
- }
92
- return selector.includes(':has-text(') || selector.includes(':text-is(');
93
- }
94
-
95
- /**
96
- * Split `base:<pseudo>("text")` into its base selector and text.
97
- *
98
- * String operations rather than a regular expression: the former
99
- * /^(.+?):has-text\("(.+?)"\)$/ backtracked quadratically on long selectors
100
- * that do not match (CodeQL js/polynomial-redos). The base is everything before
101
- * the first `:<pseudo>("`, as the lazy `(.+?)` made it; base and text must be
102
- * non-empty.
103
- * @param {string} selector - Selector to split
104
- * @param {string} pseudo - Pseudo-class name, e.g. 'has-text'
105
- * @returns {{baseSelector: string, text: string}|null} - Parts, or null
106
- */
107
- function splitTextPseudo(selector, pseudo) {
108
- const open = `:${pseudo}("`;
109
- const start = selector.indexOf(open, 1);
110
- if (start === -1 || !selector.endsWith('")')) {
111
- return null;
112
- }
113
- const text = selector.slice(start + open.length, -2);
114
- if (!text) {
115
- return null;
116
- }
117
- return { baseSelector: selector.slice(0, start), text };
118
- }
119
-
120
- /**
121
- * Parse a Playwright text selector to extract base selector and text
122
- * @param {string} selector - Playwright text selector like 'a:has-text("text")'
123
- * @returns {Object|null} - { baseSelector, text, exact } or null if not parseable
124
- */
125
- function parsePlaywrightTextSelector(selector) {
126
- // Match patterns like 'a:has-text("text")' or 'button:text-is("exact text")'
127
- const hasText = splitTextPseudo(selector, 'has-text');
128
- if (hasText) {
129
- return { ...hasText, exact: false };
130
- }
131
-
132
- const textIs = splitTextPseudo(selector, 'text-is');
133
- if (textIs) {
134
- return { ...textIs, exact: true };
135
- }
136
-
137
- return null;
138
- }
139
-
140
84
  /**
141
85
  * Normalize selector to handle both Puppeteer and Playwright text selectors
142
- * Converts engine-specific text selectors to valid CSS selectors for browser context
86
+ * Preserves native Playwright strings; resolves text objects to unique CSS paths.
143
87
  *
144
88
  * @param {Object} options - Configuration options
145
89
  * @param {Object} options.page - Browser page object
146
90
  * @param {string} options.engine - Engine type ('playwright' or 'puppeteer')
147
91
  * @param {string|Object} options.selector - CSS selector or text selector object
148
- * @returns {Promise<string|null>} - Valid CSS selector or null if not found
92
+ * @returns {Promise<string|null>} - Native selector, unique CSS path, or null
149
93
  */
150
94
  export async function normalizeSelector(options = {}) {
151
- const { page, engine, selector } = options;
95
+ const { page, selector } = options;
152
96
 
153
97
  requireSelector(selector);
154
98
 
@@ -171,59 +115,6 @@ export async function normalizeSelector(options = {}) {
171
115
  return null;
172
116
  }
173
117
 
174
- // Handle Playwright text selectors (strings containing :has-text or :text-is)
175
- // These are valid for Playwright's locator API but NOT for document.querySelectorAll
176
- if (
177
- typeof selector === 'string' &&
178
- engine === 'playwright' &&
179
- isPlaywrightTextSelector(selector)
180
- ) {
181
- const parsed = parsePlaywrightTextSelector(selector);
182
- if (!parsed) {
183
- // Could not parse, return as-is and hope for the best
184
- return selector;
185
- }
186
-
187
- try {
188
- // Use page.evaluate to find matching element and generate a valid CSS selector
189
- const result = await page.evaluate(({ baseSelector, text, exact }) => {
190
- const elements = Array.from(document.querySelectorAll(baseSelector));
191
- const matchingElement = elements.find((el) => {
192
- const elementText = el.textContent.trim();
193
- return exact ? elementText === text : elementText.includes(text);
194
- });
195
-
196
- if (!matchingElement) {
197
- return null;
198
- }
199
-
200
- // Generate a unique selector using data-qa or nth-of-type
201
- const dataQa = matchingElement.getAttribute('data-qa');
202
- if (dataQa) {
203
- return `[data-qa="${dataQa}"]`;
204
- }
205
-
206
- // Use nth-of-type as fallback
207
- const tagName = matchingElement.tagName.toLowerCase();
208
- const siblings = Array.from(
209
- matchingElement.parentElement.children
210
- ).filter((el) => el.tagName.toLowerCase() === tagName);
211
- const index = siblings.indexOf(matchingElement);
212
- return `${tagName}:nth-of-type(${index + 1})`;
213
- }, parsed);
214
-
215
- return result;
216
- } catch (error) {
217
- if (isNavigationError(error)) {
218
- console.log(
219
- '⚠️ Navigation detected during normalizeSelector (Playwright), returning null'
220
- );
221
- return null;
222
- }
223
- throw error;
224
- }
225
- }
226
-
227
118
  // Plain string selector - return as-is
228
119
  if (typeof selector === 'string') {
229
120
  return selector;
@@ -233,36 +124,11 @@ export async function normalizeSelector(options = {}) {
233
124
  if (selector._isPuppeteerTextSelector) {
234
125
  try {
235
126
  // Find element by text and generate a unique selector
236
- const result = await page.evaluate(
237
- (baseSelector, text, exact) => {
238
- const elements = Array.from(document.querySelectorAll(baseSelector));
239
- const matchingElement = elements.find((el) => {
240
- const elementText = el.textContent.trim();
241
- return exact ? elementText === text : elementText.includes(text);
242
- });
243
-
244
- if (!matchingElement) {
245
- return null;
246
- }
247
-
248
- // Generate a unique selector using data-qa or nth-of-type
249
- const dataQa = matchingElement.getAttribute('data-qa');
250
- if (dataQa) {
251
- return `[data-qa="${dataQa}"]`;
252
- }
253
-
254
- // Use nth-of-type as fallback
255
- const tagName = matchingElement.tagName.toLowerCase();
256
- const siblings = Array.from(
257
- matchingElement.parentElement.children
258
- ).filter((el) => el.tagName.toLowerCase() === tagName);
259
- const index = siblings.indexOf(matchingElement);
260
- return `${tagName}:nth-of-type(${index + 1})`;
261
- },
262
- selector.baseSelector,
263
- selector.text,
264
- selector.exact
265
- );
127
+ const result = await page.evaluate(textSelectorInPage, {
128
+ baseSelector: selector.baseSelector,
129
+ text: selector.text,
130
+ exact: selector.exact,
131
+ });
266
132
 
267
133
  return result;
268
134
  } catch (error) {
@@ -296,6 +162,7 @@ export function withTextSelectorSupport(fn, engine, page) {
296
162
  // selenium)
297
163
  if (
298
164
  engine !== 'playwright' &&
165
+ selector &&
299
166
  typeof selector === 'object' &&
300
167
  selector._isPuppeteerTextSelector
301
168
  ) {
@@ -305,18 +172,6 @@ export function withTextSelectorSupport(fn, engine, page) {
305
172
  }
306
173
  }
307
174
 
308
- // Normalize Playwright text selectors (string format with :has-text or :text-is)
309
- if (
310
- engine === 'playwright' &&
311
- typeof selector === 'string' &&
312
- isPlaywrightTextSelector(selector)
313
- ) {
314
- selector = await normalizeSelector({ page, engine, selector });
315
- if (!selector) {
316
- throw new Error('Element with specified text not found');
317
- }
318
- }
319
-
320
175
  return fn({ ...options, selector });
321
176
  };
322
177
  }
@@ -0,0 +1,23 @@
1
+ /** Resolve a text match to an unambiguous CSS path without changing the DOM. */
2
+ export function textSelectorInPage({ baseSelector, text, exact }) {
3
+ const matching = Array.from(document.querySelectorAll(baseSelector)).find(
4
+ (element) => {
5
+ const content = element.textContent.trim();
6
+ return exact ? content === text : content.includes(text);
7
+ }
8
+ );
9
+ if (!matching) {
10
+ return null;
11
+ }
12
+ const steps = [];
13
+ for (let node = matching; node; node = node.parentElement) {
14
+ const siblings = Array.from(node.parentElement?.children ?? []).filter(
15
+ (sibling) => sibling.localName === node.localName
16
+ );
17
+ const index = siblings.indexOf(node) + 1;
18
+ steps.unshift(
19
+ `${window.CSS.escape(node.localName)}:nth-of-type(${Math.max(1, index)})`
20
+ );
21
+ }
22
+ return steps.join(' > ');
23
+ }
@@ -107,11 +107,11 @@ const HEAD_FIELDS = new Set([
107
107
  /**
108
108
  * Make one value safe to write as a link.
109
109
  *
110
- * Links Notation 0.20 quotes a value with whichever quote character the value
111
- * does not contain, and its parser does not read escapes, so a value holding
112
- * both quote characters cannot be read back. Newlines are worse: they would
113
- * split one record across lines. Both are encoded here, reversibly, rather
114
- * than dropped, so the export loses nothing the bundle holds.
110
+ * Keep the representation's legacy escapes even though current Links Notation
111
+ * can quote both quote characters. Newlines must not split a record across
112
+ * lines. Lone UTF-16 surrogates are escaped too: the notation requires valid
113
+ * Unicode, but JavaScript trace strings can hold individual surrogate units.
114
+ * All these encodings are reversible, so the export loses no scalar text.
115
115
  *
116
116
  * @param {*} value - Any scalar from a trace record
117
117
  * @returns {string} Text that survives a format and parse round trip
@@ -122,9 +122,13 @@ export function encodeLinkText(value) {
122
122
  .replaceAll('\\', '\\\\')
123
123
  .replaceAll('\n', '\\n')
124
124
  .replaceAll('\r', '\\r')
125
- .replaceAll('\t', '\\t');
126
- // Only a value that holds both quote characters is unquotable, so only that
127
- // value pays for the ugliness of an escaped quote.
125
+ .replaceAll('\t', '\\t')
126
+ // Unicode mode matches lone surrogates without splitting valid pairs.
127
+ .replace(
128
+ /[\ud800-\udfff]/gu,
129
+ (unit) => `\\u${unit.charCodeAt(0).toString(16)}`
130
+ );
131
+ // Preserve the existing encoding for values holding both quote characters.
128
132
  return escaped.includes("'") && escaped.includes('"')
129
133
  ? escaped.replaceAll('"', '\\u0022')
130
134
  : escaped;
@@ -139,8 +143,9 @@ export function encodeLinkText(value) {
139
143
  export function decodeLinkText(text) {
140
144
  const decoded = { '\\': '\\', n: '\n', r: '\r', t: '\t', u0022: '"' };
141
145
  return String(text).replace(
142
- /\\(\\|n|r|t|u0022)/g,
143
- (match, code) => decoded[code]
146
+ /\\(\\|n|r|t|u0022|ud[89a-f][0-9a-f]{2})/g,
147
+ (match, code) =>
148
+ decoded[code] ?? String.fromCharCode(Number.parseInt(code.slice(1), 16))
144
149
  );
145
150
  }
146
151
 
@@ -177,6 +177,7 @@ export class ManagedProcess {
177
177
  this.stdout = new OutputChannel();
178
178
  this.stderr = new OutputChannel();
179
179
  this.exitListeners = new Set();
180
+ this.termination = null;
180
181
  runner.on('stdout', (chunk) => {
181
182
  if (forwardOutput) {
182
183
  process.stdout.write(chunk);
@@ -196,7 +197,15 @@ export class ManagedProcess {
196
197
  this.spawnError = error;
197
198
  return { code: error?.code ?? 1 };
198
199
  })
199
- .then((result) => {
200
+ .then(async (result) => {
201
+ const actualExit = await this.termination;
202
+ if (actualExit) {
203
+ result = {
204
+ ...result,
205
+ code: actualExit.code ?? result.code,
206
+ signal: actualExit.signal,
207
+ };
208
+ }
200
209
  this.exitCode = typeof result.code === 'number' ? result.code : 1;
201
210
  this.signalCode = result.signal ?? null;
202
211
  for (const listener of this.exitListeners) {
@@ -233,10 +242,36 @@ export class ManagedProcess {
233
242
  * @returns {boolean} Whether a running process was signalled
234
243
  */
235
244
  kill(signal) {
236
- if (this.exitCode !== null) {
245
+ if (this.exitCode !== null || this.termination) {
237
246
  return false;
238
247
  }
248
+ // command-stream resolves cancellation immediately and removes the child's
249
+ // listeners. Retain its handle and observe the real exit after signalling,
250
+ // so launchers cannot delete profiles while Chrome still writes to them.
251
+ const child = this.runner.child;
252
+ let resolveExit;
253
+ if (child?.pid) {
254
+ this.termination = new Promise((resolve) => {
255
+ resolveExit = resolve;
256
+ });
257
+ }
239
258
  this.runner.kill(signal);
259
+ if (resolveExit) {
260
+ if (child.exited) {
261
+ child.exited.then((code) =>
262
+ resolveExit({ code, signal: child.signalCode ?? null })
263
+ );
264
+ } else if (
265
+ Number.isInteger(child.exitCode) ||
266
+ typeof child.signalCode === 'string'
267
+ ) {
268
+ resolveExit({ code: child.exitCode, signal: child.signalCode });
269
+ } else {
270
+ child.once('exit', (code, exitSignal) =>
271
+ resolveExit({ code, signal: exitSignal })
272
+ );
273
+ }
274
+ }
240
275
  return true;
241
276
  }
242
277
  }
@@ -0,0 +1,104 @@
1
+ import assert from 'node:assert/strict';
2
+ import { it } from 'node:test';
3
+ import { makeBrowserCommander } from '../../src/factory.js';
4
+ import { createEngineAdapter } from '../../src/core/engine-adapter.js';
5
+ import { launchE2EBrowser } from '../helpers/e2e-browser.js';
6
+
7
+ for (const engine of ['playwright', 'puppeteer', 'selenium']) {
8
+ it(
9
+ `${engine} keeps scoped text selectors on the intended element`,
10
+ { skip: process.env.RUN_E2E !== 'true', timeout: 60000 },
11
+ async () => {
12
+ const session = await launchE2EBrowser({ engine });
13
+ const commander = makeBrowserCommander({
14
+ page: session.page,
15
+ verbose: false,
16
+ debug: false,
17
+ enableNetworkTracking: false,
18
+ enableNavigationManager: false,
19
+ });
20
+ try {
21
+ await session.page.setContent(`
22
+ <nav><button>A</button><button data-qa='duplicate'
23
+ onclick="window.clicked='nav-2'">B</button></nav>
24
+ <div class="parent"><section><button>A</button>
25
+ <button data-qa='duplicate' data-target='B'
26
+ onclick="window.clicked='B'; this.setAttribute('aria-pressed','true')">B</button>
27
+ <textarea>Edit</textarea>
28
+ </section></div>`);
29
+ for (const exact of [false, true]) {
30
+ const selector = await commander.findByText({
31
+ selector: '.parent button',
32
+ text: 'B',
33
+ exact,
34
+ });
35
+ assert.equal(
36
+ await commander.getAttribute({
37
+ selector,
38
+ attribute: 'data-target',
39
+ }),
40
+ 'B'
41
+ );
42
+ assert.equal(await commander.textContent({ selector }), 'B');
43
+ assert.equal(await commander.isVisible({ selector }), true);
44
+ assert.equal(await commander.isEnabled({ selector }), true);
45
+ await commander.clickButton({ selector, verify: false });
46
+ assert.equal(await session.page.evaluate(() => window.clicked), 'B');
47
+ }
48
+ const editable = await commander.findByText({
49
+ selector: '.parent textarea',
50
+ text: 'Edit',
51
+ exact: true,
52
+ });
53
+ await commander.fillTextArea({
54
+ selector: editable,
55
+ text: 'Changed',
56
+ checkEmpty: false,
57
+ simulateTyping: false,
58
+ });
59
+ assert.equal(
60
+ await session.page.evaluate(
61
+ () => document.querySelector('.parent textarea').value
62
+ ),
63
+ 'Changed'
64
+ );
65
+ } finally {
66
+ await commander.destroy();
67
+ await session.cleanup();
68
+ }
69
+ }
70
+ );
71
+ }
72
+
73
+ it(
74
+ 'Playwright preserves CSS sibling position across multiple parents',
75
+ { skip: process.env.RUN_E2E !== 'true', timeout: 60000 },
76
+ async () => {
77
+ const session = await launchE2EBrowser({ engine: 'playwright' });
78
+ const commander = makeBrowserCommander({
79
+ page: session.page,
80
+ debug: false,
81
+ enableNetworkTracking: false,
82
+ enableNavigationManager: false,
83
+ });
84
+ try {
85
+ await session.page.setContent(
86
+ '<section><span></span><button>A</button></section><section><button>B</button><button>C</button></section>'
87
+ );
88
+ const matches = await commander.querySelectorAll({
89
+ selector: 'section button:nth-of-type(2)',
90
+ });
91
+ assert.equal(matches.length, 1);
92
+ assert.equal(await matches[0].textContent(), 'C');
93
+ const adapter = createEngineAdapter(session.page, 'playwright');
94
+ const adapterMatches = await adapter.querySelectorAll(
95
+ 'section button:nth-of-type(2)'
96
+ );
97
+ assert.equal(adapterMatches.length, 1);
98
+ assert.equal(await adapterMatches[0].textContent(), 'C');
99
+ } finally {
100
+ await commander.destroy();
101
+ await session.cleanup();
102
+ }
103
+ }
104
+ );
@@ -0,0 +1,95 @@
1
+ import assert from 'node:assert/strict';
2
+ import { it } from 'node:test';
3
+ import { access, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
4
+ import os from 'node:os';
5
+ import path from 'node:path';
6
+ import { launchBrowser } from '../../src/browser/launcher.js';
7
+ import { launchAndConnectRealBrowserWithDependencies } from '../../src/browser/real-browser.js';
8
+ import { startProcess } from '../../src/utilities/subprocess.js';
9
+ import { CHROME_LAUNCH_OPTIONS } from '../helpers/e2e-browser.js';
10
+
11
+ it(
12
+ 'fresh-profile Chrome starts repeatedly with session persistence',
13
+ { skip: process.env.RUN_E2E !== 'true', timeout: 90000 },
14
+ async () => {
15
+ const root = await mkdtemp(path.join(os.tmpdir(), 'bc-startup-smoke-'));
16
+ try {
17
+ for (let attempt = 0; attempt < 3; attempt++) {
18
+ const userDataDir = path.join(root, `profile-${attempt}`);
19
+ const session = await launchBrowser({
20
+ debug: false,
21
+ channel: 'chrome',
22
+ headless: true,
23
+ userDataDir,
24
+ persistSessionCookies: true,
25
+ ...CHROME_LAUNCH_OPTIONS,
26
+ });
27
+ try {
28
+ assert.equal(await session.page.evaluate(() => 2 + 2), 4);
29
+ } finally {
30
+ await session.close();
31
+ }
32
+ assert.notEqual(session.browserProcess.exitCode, null);
33
+ await access(userDataDir);
34
+ }
35
+ } finally {
36
+ await rm(root, { recursive: true, force: true });
37
+ }
38
+ }
39
+ );
40
+
41
+ it(
42
+ 'failure after real DevTools readiness leaves another Chrome and caller profile intact',
43
+ { skip: process.env.RUN_E2E !== 'true', timeout: 90000 },
44
+ async () => {
45
+ const sibling = await launchBrowser({
46
+ debug: false,
47
+ headless: true,
48
+ ...CHROME_LAUNCH_OPTIONS,
49
+ });
50
+ const root = await mkdtemp(path.join(os.tmpdir(), 'bc-owned-startup-'));
51
+ const marker = path.join(root, 'keep.txt');
52
+ await writeFile(marker, 'caller profile');
53
+ try {
54
+ for (const suppliedProfile of [false, true]) {
55
+ let child, profile;
56
+ await assert.rejects(
57
+ () =>
58
+ launchAndConnectRealBrowserWithDependencies(
59
+ {
60
+ debug: false,
61
+ headless: true,
62
+ persistSessionCookies: suppliedProfile,
63
+ ...(suppliedProfile ? { userDataDir: root } : {}),
64
+ ...CHROME_LAUNCH_OPTIONS,
65
+ },
66
+ {
67
+ spawnBrowser: (file, args, options) => {
68
+ profile = args
69
+ .find((arg) => arg.startsWith('--user-data-dir='))
70
+ .slice('--user-data-dir='.length);
71
+ child = startProcess(file, args, { env: options.env });
72
+ return child;
73
+ },
74
+ connect: async () => {
75
+ throw new Error(
76
+ 'Injected attachment timeout after endpoint readiness'
77
+ );
78
+ },
79
+ }
80
+ ),
81
+ /Injected attachment timeout/
82
+ );
83
+ assert.notEqual(child.exitCode, null);
84
+ assert.equal(await sibling.page.evaluate(() => 2 + 2), 4);
85
+ assert.equal(await readFile(marker, 'utf8'), 'caller profile');
86
+ if (!suppliedProfile) {
87
+ await assert.rejects(() => access(profile), /ENOENT/);
88
+ }
89
+ }
90
+ } finally {
91
+ await sibling.close();
92
+ await rm(root, { recursive: true, force: true });
93
+ }
94
+ }
95
+ );
@@ -0,0 +1,74 @@
1
+ import assert from 'node:assert/strict';
2
+ import { it } from 'node:test';
3
+ import { connectBrowserWithDependencies } from '../../../src/browser/connector.js';
4
+
5
+ it('identifies an engine attachment failure and logs stage timing only when verbose', async (t) => {
6
+ const logs = [];
7
+ t.mock.method(console, 'log', (message) => logs.push(message));
8
+ for (const verbose of [false, true]) {
9
+ logs.length = 0;
10
+ await assert.rejects(
11
+ () =>
12
+ connectBrowserWithDependencies(
13
+ { cdpEndpoint: 'http://127.0.0.1:9222', verbose, timeout: 123 },
14
+ {
15
+ loadPlaywright: async () => ({
16
+ chromium: {
17
+ connectOverCDP: async (_endpoint, options) => {
18
+ assert.equal(options.timeout, 123);
19
+ throw new Error('connectOverCDP: Timeout 123ms exceeded');
20
+ },
21
+ },
22
+ }),
23
+ }
24
+ ),
25
+ (error) => {
26
+ assert.equal(error.attachmentStage, 'engine-connect');
27
+ return true;
28
+ }
29
+ );
30
+ if (verbose) {
31
+ assert.ok(logs.some((message) => /engine-connect started/.test(message)));
32
+ assert.ok(
33
+ logs.some((message) =>
34
+ /engine-connect failed after \d+ms/.test(message)
35
+ )
36
+ );
37
+ } else {
38
+ assert.deepEqual(logs, []);
39
+ }
40
+ }
41
+ });
42
+
43
+ it('distinguishes page selection from engine connection and detaches on failure', async () => {
44
+ let detached = 0;
45
+ const browser = {
46
+ contexts: () => [
47
+ {
48
+ pages: () => [],
49
+ newPage: async () => {
50
+ throw new Error('newPage timeout');
51
+ },
52
+ },
53
+ ],
54
+ close: async () => {
55
+ detached++;
56
+ },
57
+ };
58
+ await assert.rejects(
59
+ () =>
60
+ connectBrowserWithDependencies(
61
+ { cdpEndpoint: 'http://127.0.0.1:9222' },
62
+ {
63
+ loadPlaywright: async () => ({
64
+ chromium: { connectOverCDP: async () => browser },
65
+ }),
66
+ }
67
+ ),
68
+ (error) => {
69
+ assert.equal(error.attachmentStage, 'page-selection');
70
+ return true;
71
+ }
72
+ );
73
+ assert.equal(detached, 1);
74
+ });
@@ -1,6 +1,28 @@
1
1
  // feature-parity: launch.diagnostics@native-typed
2
2
  import { it } from 'node:test';
3
- import { redactLaunchEvidence } from '../../../src/browser/launch-diagnostics.js';
3
+ import {
4
+ launchFailure,
5
+ redactLaunchEvidence,
6
+ } from '../../../src/browser/launch-diagnostics.js';
7
+
8
+ it('retains the attachment timeout alongside incidental Chrome stderr', () => {
9
+ const error = launchFailure(
10
+ new Error(
11
+ 'connectOverCDP: Timeout 30000ms exceeded; <ws connected> ws://127.0.0.1:9222/devtools/browser/private'
12
+ ),
13
+ {
14
+ phase: 'connect',
15
+ browserProcess: {
16
+ exitCode: null,
17
+ stderrTail:
18
+ 'DevTools listening on ws://127.0.0.1:9222/devtools/browser/private\nCVDisplayLinkCreateWithCGDisplay failed',
19
+ },
20
+ }
21
+ );
22
+ assert.match(error.message, /connectOverCDP: Timeout 30000ms exceeded/);
23
+ assert.match(error.message, /CVDisplayLinkCreateWithCGDisplay failed/);
24
+ assert.doesNotMatch(error.message, /devtools|private|127\.0\.0\.1/);
25
+ });
4
26
 
5
27
  it('diagnostics scrub URL credentials and queries and bound multibyte evidence', () => {
6
28
  const redacted = redactLaunchEvidence(
@@ -0,0 +1,129 @@
1
+ import assert from 'node:assert/strict';
2
+ import { PassThrough } from 'node:stream';
3
+ import * as fs from 'node:fs/promises';
4
+ import os from 'node:os';
5
+ import path from 'node:path';
6
+ import { it } from 'node:test';
7
+ import {
8
+ launchAndConnectRealBrowserWithDependencies,
9
+ waitForCdpEndpoint,
10
+ } from '../../../src/browser/real-browser.js';
11
+
12
+ it(
13
+ 'waits for HTTP readiness after DevTools is announced, despite incidental warnings',
14
+ { timeout: 5000 },
15
+ async () => {
16
+ const stderr = new PassThrough();
17
+ stderr.write(
18
+ 'DevTools listening on ws://127.0.0.1:9777/devtools/browser/ours\nCVDisplayLinkCreateWithCGDisplay failed\n'
19
+ );
20
+ let probes = 0;
21
+ const endpoint = await waitForCdpEndpoint({
22
+ remoteDebuggingPort: 9777,
23
+ userDataDir: '/nonexistent',
24
+ browserProcess: { exitCode: null, stderr },
25
+ timeout: 2000,
26
+ fetchImplementation: async () => {
27
+ probes++;
28
+ if (probes === 1) {
29
+ throw new Error('ECONNREFUSED');
30
+ }
31
+ if (probes === 2) {
32
+ return { ok: false };
33
+ }
34
+ return {
35
+ ok: true,
36
+ json: async () => ({
37
+ webSocketDebuggerUrl: 'ws://127.0.0.1:9777/devtools/browser/ours',
38
+ }),
39
+ };
40
+ },
41
+ });
42
+ assert.equal(endpoint, 'http://127.0.0.1:9777');
43
+ assert.equal(probes, 3);
44
+ }
45
+ );
46
+
47
+ for (const suppliedProfile of [false, true]) {
48
+ it(
49
+ `attachment timeout cleans only owned state (supplied profile: ${suppliedProfile})`,
50
+ { timeout: 5000 },
51
+ async () => {
52
+ const root = await fs.mkdtemp(path.join(os.tmpdir(), 'bc-startup-test-'));
53
+ const marker = path.join(root, 'keep.txt');
54
+ await fs.writeFile(marker, 'caller owned');
55
+ let profile;
56
+ let kills = 0;
57
+ let launches = 0;
58
+ const child = {
59
+ exitCode: null,
60
+ stderrTail:
61
+ 'DevTools listening on ws://127.0.0.1:9777/devtools/browser/ours\nCVDisplayLinkCreateWithCGDisplay failed',
62
+ kill: () => {
63
+ kills++;
64
+ child.exitCode = 143;
65
+ },
66
+ };
67
+ try {
68
+ await assert.rejects(
69
+ () =>
70
+ launchAndConnectRealBrowserWithDependencies(
71
+ {
72
+ ...(suppliedProfile ? { userDataDir: root } : {}),
73
+ debug: false,
74
+ headless: true,
75
+ persistSessionCookies: suppliedProfile,
76
+ timeout: 123,
77
+ closeTimeout: 50,
78
+ },
79
+ {
80
+ resolveExecutable: async () => '/opt/google/chrome',
81
+ reservePort: async () => 9777,
82
+ spawnBrowser: (_file, args) => {
83
+ launches++;
84
+ profile = args
85
+ .find((arg) => arg.startsWith('--user-data-dir='))
86
+ .slice('--user-data-dir='.length);
87
+ return child;
88
+ },
89
+ waitForEndpoint: async () => 'http://127.0.0.1:9777',
90
+ connect: async (options) => {
91
+ assert.equal(options.timeout, 123);
92
+ const error = new Error(
93
+ 'connectOverCDP: Timeout 123ms exceeded'
94
+ );
95
+ error.attachmentStage = 'engine-connect';
96
+ throw error;
97
+ },
98
+ }
99
+ ),
100
+ (error) => {
101
+ assert.equal(error.phase, 'connect');
102
+ assert.equal(error.category, 'startup_timeout');
103
+ assert.equal(error.attachmentStage, 'engine-connect');
104
+ assert.match(
105
+ error.message,
106
+ /connectOverCDP: Timeout 123ms exceeded/
107
+ );
108
+ assert.match(error.message, /CVDisplayLink/);
109
+ return true;
110
+ }
111
+ );
112
+ assert.equal(kills, 1);
113
+ assert.equal(launches, 1);
114
+ assert.equal(await fs.readFile(marker, 'utf8'), 'caller owned');
115
+ if (!suppliedProfile) {
116
+ await assert.rejects(
117
+ () => fs.readFile(path.join(profile, 'First Run')),
118
+ /ENOENT/
119
+ );
120
+ }
121
+ } finally {
122
+ await fs.rm(root, { recursive: true, force: true });
123
+ if (!suppliedProfile && profile) {
124
+ await fs.rm(profile, { recursive: true, force: true });
125
+ }
126
+ }
127
+ }
128
+ );
129
+ }
@@ -0,0 +1,50 @@
1
+ import assert from 'node:assert/strict';
2
+ import { it } from 'node:test';
3
+ import {
4
+ normalizeSelector,
5
+ withTextSelectorSupport,
6
+ } from '../../../src/elements/selectors.js';
7
+ import { createPlaywrightLocator } from '../../../src/elements/locators.js';
8
+ import { PlaywrightAdapter } from '../../../src/core/engine-adapter.js';
9
+
10
+ it('keeps native Playwright text selectors without an eager DOM lookup', async () => {
11
+ const selector = '.parent button:has-text("B")';
12
+ const page = {
13
+ evaluate: () => {
14
+ throw new Error('unexpected DOM lookup');
15
+ },
16
+ };
17
+ assert.equal(
18
+ await normalizeSelector({ page, engine: 'playwright', selector }),
19
+ selector
20
+ );
21
+ const wrapped = withTextSelectorSupport(
22
+ async ({ selector }) => selector,
23
+ 'playwright',
24
+ page
25
+ );
26
+ assert.equal(await wrapped({ selector }), selector);
27
+ });
28
+
29
+ for (const [name, create] of [
30
+ [
31
+ 'locator helper',
32
+ (page, selector) => createPlaywrightLocator({ page, selector }),
33
+ ],
34
+ [
35
+ 'shared adapter',
36
+ (page, selector) => new PlaywrightAdapter(page).createLocator(selector),
37
+ ],
38
+ ]) {
39
+ it(`preserves CSS sibling semantics in the Playwright ${name}`, () => {
40
+ const selector = 'section button:nth-of-type(2)';
41
+ const expected = {};
42
+ const page = {
43
+ locator: (received) => {
44
+ assert.equal(received, selector);
45
+ return expected;
46
+ },
47
+ };
48
+ assert.equal(create(page, selector), expected);
49
+ });
50
+ }
@@ -205,32 +205,18 @@ describe('selectors', () => {
205
205
  return page;
206
206
  }
207
207
 
208
- it('splits :has-text into base selector and text', async () => {
209
- const page = capturingPage();
210
- await normalizeSelector({
211
- page,
212
- engine: 'playwright',
213
- selector: 'nav a:has-text("Sign in")',
214
- });
215
- assert.deepStrictEqual(page.parsed, {
216
- baseSelector: 'nav a',
217
- text: 'Sign in',
218
- exact: false,
219
- });
220
- });
221
-
222
- it('splits :text-is into base selector and exact text', async () => {
223
- const page = capturingPage();
224
- await normalizeSelector({
225
- page,
226
- engine: 'playwright',
227
- selector: 'button:text-is("Submit")',
228
- });
229
- assert.deepStrictEqual(page.parsed, {
230
- baseSelector: 'button',
231
- text: 'Submit',
232
- exact: true,
233
- });
208
+ it('preserves native text selectors without an eager lookup', async () => {
209
+ for (const selector of [
210
+ 'nav a:has-text("Sign in")',
211
+ 'button:text-is("Submit")',
212
+ ]) {
213
+ const page = capturingPage();
214
+ assert.equal(
215
+ await normalizeSelector({ page, engine: 'playwright', selector }),
216
+ selector
217
+ );
218
+ assert.equal(page.parsed, null);
219
+ }
234
220
  });
235
221
 
236
222
  it('returns an unparseable selector unchanged', async () => {
@@ -196,6 +196,7 @@ describe('CI execution budgets', () => {
196
196
  assert.deepEqual(budgeted, [
197
197
  'docs.yml build-docs Rust API docs',
198
198
  'js.yml test Node.js test suite',
199
+ 'js.yml test macOS Chrome startup regression',
199
200
  'parity.yml cli CLI and API coverage suites',
200
201
  'parity.yml cli Cross-language CLI contract',
201
202
  'parity.yml cli Rust CLI build',
@@ -0,0 +1,56 @@
1
+ import { describe, it } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { Parser, StreamParser } from 'links-notation';
4
+ import {
5
+ decodeLinkText,
6
+ formatTraceLinks,
7
+ timelineLink,
8
+ } from '../../../src/traces/links.js';
9
+ import { readRepoText } from '../../helpers/repo.js';
10
+
11
+ describe(
12
+ 'trace compatibility with Links Notation (issue #168)',
13
+ { timeout: 10000 },
14
+ () => {
15
+ it('preserves scalar text through the formatter, parser and trace decoder', () => {
16
+ for (const text of [
17
+ '',
18
+ '#comment',
19
+ 'issue#168',
20
+ '~1{61}',
21
+ 'both\'and" with `backticks`',
22
+ 'line\r\nbreak\ttab\\literal\\u0022\\ud800\\udfff',
23
+ '\u0000\u0001\u007f',
24
+ 'a\u00a0b\u2028c\u2029d\ufeff',
25
+ 'Tiếng Việt 日本語 🧭 e\u0301',
26
+ '\ud800',
27
+ '\udfff',
28
+ '🧭\ud800🧭\udfff',
29
+ ]) {
30
+ const line = formatTraceLinks([
31
+ timelineLink({ kind: 'console', text }),
32
+ ]);
33
+ assert.equal(line.split('\n').length, 2, 'one record per line');
34
+ const [parsed] = new Parser().parse(line);
35
+ const field = parsed.values.find((value) => value.id === 'text');
36
+ assert.equal(decodeLinkText(field.values[0].id), text);
37
+ }
38
+ });
39
+
40
+ it('imports the existing cross-language golden export in arbitrary chunks', () => {
41
+ const text = readRepoText(
42
+ 'js/tests/fixtures/traces/conformance/expected/trace.lino'
43
+ );
44
+ const expected = new Parser().parse(text);
45
+ assert.equal(expected.length, 27);
46
+ for (const chunkSize of [1, 17, text.length]) {
47
+ const stream = new StreamParser();
48
+ for (let offset = 0; offset < text.length; offset += chunkSize) {
49
+ stream.write(text.slice(offset, offset + chunkSize));
50
+ }
51
+ stream.end();
52
+ assert.deepEqual(stream.drain(), expected);
53
+ }
54
+ });
55
+ }
56
+ );
@@ -544,8 +544,8 @@ describe('trace links export (issue #94)', () => {
544
544
  // The notation's formatter and parser decide whether an export can be read
545
545
  // back, so the range this package depends on and the version the tests
546
546
  // proved are the same range.
547
- assert.strictEqual(manifest.dependencies['links-notation'], '^0.23.0');
548
- assert.match(installed.version, /^0\.23\./);
547
+ assert.strictEqual(manifest.dependencies['links-notation'], '^0.25.2');
548
+ assert.match(installed.version, /^0\.25\./);
549
549
  });
550
550
 
551
551
  it('should export a bundle named by path as readily as an open one', async () => {
@@ -97,6 +97,39 @@ describe('startProcess', { timeout: 10000 }, () => {
97
97
  const exitCode = await child.exited;
98
98
  assert.notEqual(exitCode, 0);
99
99
  });
100
+
101
+ it(
102
+ 'reports exit only after a signalled child finishes its profile writes',
103
+ { skip: process.platform === 'win32' && 'Windows termination is forceful' },
104
+ async (t) => {
105
+ const directory = await fs.mkdtemp(
106
+ path.join(tmpdir(), 'bc-shutdown-172-')
107
+ );
108
+ const marker = path.join(directory, 'flushed');
109
+ const child = startProcess(
110
+ node,
111
+ [repoPath('experiments', 'issue-172', 'delayed-shutdown.mjs'), marker],
112
+ { killGrace: 1000 }
113
+ );
114
+ const processHandle = child.runner.child;
115
+ t.after(async () => {
116
+ if (
117
+ processHandle.exitCode === null &&
118
+ processHandle.signalCode === null
119
+ ) {
120
+ processHandle.kill('SIGKILL');
121
+ await new Promise((resolve) => processHandle.once('exit', resolve));
122
+ }
123
+ await fs.rm(directory, { recursive: true, force: true });
124
+ });
125
+ await new Promise((resolve) => child.stdout.on('data', resolve));
126
+ child.kill();
127
+ await child.exited;
128
+ assert.equal(await fs.readFile(marker, 'utf8'), 'flushed');
129
+ assert.equal(processHandle.exitCode, 0);
130
+ assert.equal(child.exitCode, 0);
131
+ }
132
+ );
100
133
  });
101
134
 
102
135
  describe(