browser-commander 0.31.1 → 0.32.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +14 -0
- package/README.md +26 -0
- package/package.json +1 -1
- package/src/browser/browser-handoff.js +304 -0
- package/src/browser/cdp-browser-endpoint.js +55 -0
- package/src/browser/connection-stage.js +23 -0
- package/src/browser/connector.js +29 -15
- package/src/browser/launch-diagnostics.js +4 -1
- package/src/browser/launcher.js +8 -0
- package/src/browser/persistent-session.js +5 -56
- package/src/browser/real-browser.js +56 -5
- package/src/browser/session-persistence.js +9 -5
- package/src/browser/webdriver.js +8 -1
- package/src/core/engine-adapter.js +0 -7
- package/src/elements/locators.js +1 -10
- package/src/elements/selectors.js +10 -155
- package/src/elements/text-selector.js +23 -0
- package/src/exports.js +6 -0
- package/src/utilities/subprocess.js +227 -38
- package/tests/e2e/browser-handoff.e2e.test.js +94 -0
- package/tests/e2e/selector-identity.e2e.test.js +104 -0
- package/tests/e2e/startup-readiness.e2e.test.js +95 -0
- package/tests/helpers/fake-cdp-endpoint.js +64 -0
- package/tests/helpers/temp-directory.js +25 -0
- package/tests/unit/browser/browser-handoff.test.js +406 -0
- package/tests/unit/browser/connection-stage.test.js +74 -0
- package/tests/unit/browser/launch-diagnostics.test.js +23 -1
- package/tests/unit/browser/launcher.test.js +23 -0
- package/tests/unit/browser/real-browser.test.js +1 -1
- package/tests/unit/browser/startup-readiness.test.js +129 -0
- package/tests/unit/elements/selector-identity.test.js +50 -0
- package/tests/unit/elements/selectors.test.js +12 -26
- package/tests/unit/scripts/ci-timeout-budgets.test.js +1 -0
- package/tests/unit/utilities/interruption.test.js +182 -0
- package/tests/unit/utilities/subprocess.test.js +39 -13
|
@@ -9,7 +9,9 @@ import { findBrowserSource } from './browser-sources.js';
|
|
|
9
9
|
import { launchSafari } from './safari-launch.js';
|
|
10
10
|
import { isSafariChannel } from './safari-support.js';
|
|
11
11
|
|
|
12
|
-
import { startProcess } from '../utilities/subprocess.js';
|
|
12
|
+
import { interruptionMode, startProcess } from '../utilities/subprocess.js';
|
|
13
|
+
import { installHandoff } from './browser-handoff.js';
|
|
14
|
+
import { closeRemoteBrowser } from './cdp-browser-endpoint.js';
|
|
13
15
|
import { withDeadline } from '../traces/deadline.js';
|
|
14
16
|
import {
|
|
15
17
|
applyAutomationParityArgs,
|
|
@@ -256,8 +258,16 @@ export async function waitForCdpEndpoint({
|
|
|
256
258
|
}
|
|
257
259
|
}
|
|
258
260
|
|
|
259
|
-
function spawnWithStartProcess(
|
|
260
|
-
|
|
261
|
+
function spawnWithStartProcess(
|
|
262
|
+
executablePath,
|
|
263
|
+
args,
|
|
264
|
+
{ env, verbose, interruption }
|
|
265
|
+
) {
|
|
266
|
+
return startProcess(executablePath, args, {
|
|
267
|
+
env,
|
|
268
|
+
forwardOutput: verbose,
|
|
269
|
+
interruption,
|
|
270
|
+
});
|
|
261
271
|
}
|
|
262
272
|
|
|
263
273
|
async function waitForExit(browserProcess, timeout) {
|
|
@@ -310,12 +320,20 @@ function createCloser({
|
|
|
310
320
|
closeTimeout,
|
|
311
321
|
closeConnection,
|
|
312
322
|
verbose,
|
|
323
|
+
handoffState = {},
|
|
313
324
|
}) {
|
|
314
325
|
const originalClose = closeConnection ?? browser?.close;
|
|
315
326
|
let closing;
|
|
316
327
|
const close = () => {
|
|
317
328
|
closing ??= (async () => {
|
|
318
|
-
if (browserProcess.exitCode === null &&
|
|
329
|
+
if (browserProcess.exitCode === null && handoffState.detached) {
|
|
330
|
+
// The engine connection was handed off; ask the browser directly.
|
|
331
|
+
await withDeadline(
|
|
332
|
+
closeRemoteBrowser(handoffState.webSocketDebuggerUrl),
|
|
333
|
+
closeTimeout,
|
|
334
|
+
'browser close'
|
|
335
|
+
).catch(() => {});
|
|
336
|
+
} else if (browserProcess.exitCode === null && originalClose) {
|
|
319
337
|
// A signal can sever the engine transport while its close RPC is pending.
|
|
320
338
|
// Keep the existing close budget active until owned-process cleanup runs.
|
|
321
339
|
await withDeadline(
|
|
@@ -348,6 +366,14 @@ function createCloser({
|
|
|
348
366
|
return close;
|
|
349
367
|
}
|
|
350
368
|
|
|
369
|
+
/**
|
|
370
|
+
* Selenium attaches through a chromedriver the launch starts; the driver
|
|
371
|
+
* follows the browser's interruption ownership (issue #174).
|
|
372
|
+
*/
|
|
373
|
+
function driverInterruption(engine, interruption) {
|
|
374
|
+
return engine === 'selenium' ? { interruption } : {};
|
|
375
|
+
}
|
|
376
|
+
|
|
351
377
|
/**
|
|
352
378
|
* Spawn a genuine installed Chrome-family browser with a dedicated profile,
|
|
353
379
|
* wait for its loopback CDP endpoint, and attach through {@link connectBrowser}.
|
|
@@ -400,6 +426,7 @@ async function spawnOnFreePort({
|
|
|
400
426
|
argOptions,
|
|
401
427
|
env,
|
|
402
428
|
verbose,
|
|
429
|
+
interruption,
|
|
403
430
|
userDataDir,
|
|
404
431
|
startupTimeout,
|
|
405
432
|
dependencies,
|
|
@@ -421,6 +448,7 @@ async function spawnOnFreePort({
|
|
|
421
448
|
browserProcess = await spawnBrowser(executablePath, browserArgs, {
|
|
422
449
|
env,
|
|
423
450
|
verbose: false,
|
|
451
|
+
interruption,
|
|
424
452
|
});
|
|
425
453
|
} catch (error) {
|
|
426
454
|
throw launchFailure(error, {
|
|
@@ -650,8 +678,10 @@ export async function launchAndConnectRealBrowserWithDependencies(
|
|
|
650
678
|
preferences,
|
|
651
679
|
localState,
|
|
652
680
|
firstRun,
|
|
681
|
+
interruption: requestedInterruption,
|
|
653
682
|
...connectionOptions
|
|
654
683
|
} = options;
|
|
684
|
+
const interruption = interruptionMode(requestedInterruption);
|
|
655
685
|
const argOptions = {
|
|
656
686
|
headless,
|
|
657
687
|
restrictions,
|
|
@@ -731,6 +761,7 @@ export async function launchAndConnectRealBrowserWithDependencies(
|
|
|
731
761
|
argOptions,
|
|
732
762
|
env: childEnv,
|
|
733
763
|
verbose,
|
|
764
|
+
interruption,
|
|
734
765
|
userDataDir,
|
|
735
766
|
startupTimeout,
|
|
736
767
|
dependencies,
|
|
@@ -756,9 +787,11 @@ export async function launchAndConnectRealBrowserWithDependencies(
|
|
|
756
787
|
engine,
|
|
757
788
|
cdpEndpoint: resolvedCdpEndpoint,
|
|
758
789
|
...connectionOptions,
|
|
790
|
+
...driverInterruption(engine, interruption),
|
|
759
791
|
seedCookies,
|
|
760
792
|
verbose,
|
|
761
793
|
});
|
|
794
|
+
const handoffState = { detached: false };
|
|
762
795
|
const close = createCloser({
|
|
763
796
|
engine,
|
|
764
797
|
browser: connection.browser,
|
|
@@ -768,8 +801,9 @@ export async function launchAndConnectRealBrowserWithDependencies(
|
|
|
768
801
|
closeTimeout,
|
|
769
802
|
closeConnection: engine === 'selenium' ? connection.close : undefined,
|
|
770
803
|
verbose,
|
|
804
|
+
handoffState,
|
|
771
805
|
});
|
|
772
|
-
|
|
806
|
+
const session = await installSessionPersistence(
|
|
773
807
|
{
|
|
774
808
|
...connection,
|
|
775
809
|
close,
|
|
@@ -785,6 +819,23 @@ export async function launchAndConnectRealBrowserWithDependencies(
|
|
|
785
819
|
},
|
|
786
820
|
options
|
|
787
821
|
);
|
|
822
|
+
return installHandoff(
|
|
823
|
+
session,
|
|
824
|
+
{
|
|
825
|
+
engine,
|
|
826
|
+
cdpEndpoint: resolvedCdpEndpoint,
|
|
827
|
+
userDataDir,
|
|
828
|
+
temporaryProfile,
|
|
829
|
+
remoteDebuggingPort: launched.port,
|
|
830
|
+
executablePath,
|
|
831
|
+
pid: () => browserProcess.pid,
|
|
832
|
+
connectedBrowser: () => connection.browser,
|
|
833
|
+
// The browser outlives this controller: no SIGINT forwarding, and
|
|
834
|
+
// its handle no longer keeps this program running.
|
|
835
|
+
release: () => browserProcess.detach?.(),
|
|
836
|
+
},
|
|
837
|
+
handoffState
|
|
838
|
+
);
|
|
788
839
|
} catch (error) {
|
|
789
840
|
if (browserProcess.exitCode === null) {
|
|
790
841
|
browserProcess.kill();
|
|
@@ -54,11 +54,15 @@ export async function installSessionPersistence(result, options) {
|
|
|
54
54
|
const persistAndClose = () =>
|
|
55
55
|
(closing ??= (async () => {
|
|
56
56
|
try {
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
57
|
+
// A handed-off session no longer has an engine connection to save
|
|
58
|
+
// from; the controller that reconnects owns persistence.
|
|
59
|
+
if (!result.detached) {
|
|
60
|
+
const state = await saveStorageState(result.page);
|
|
61
|
+
await writeRestrictedState(file, {
|
|
62
|
+
cookies: state.cookies.filter((cookie) => !(cookie.expires > 0)),
|
|
63
|
+
origins: [],
|
|
64
|
+
});
|
|
65
|
+
}
|
|
62
66
|
} finally {
|
|
63
67
|
await close();
|
|
64
68
|
}
|
package/src/browser/webdriver.js
CHANGED
|
@@ -30,6 +30,7 @@ import path from 'node:path';
|
|
|
30
30
|
import { createWebDriverPage } from '../core/webdriver-page.js';
|
|
31
31
|
import { isVerboseEnabled } from '../core/logger.js';
|
|
32
32
|
import {
|
|
33
|
+
interruptionMode,
|
|
33
34
|
runCommand as defaultRunCommand,
|
|
34
35
|
startProcess as defaultStartProcess,
|
|
35
36
|
} from '../utilities/subprocess.js';
|
|
@@ -483,6 +484,7 @@ async function teardown({
|
|
|
483
484
|
* @param {Object<string,string>} [options.env] - Extra environment for the driver and browser
|
|
484
485
|
* @param {number} [options.startupTimeout=30000]
|
|
485
486
|
* @param {boolean} [options.verbose=false] - Mirror the driver server's output
|
|
487
|
+
* @param {'managed'|'caller'} [options.interruption='managed'] - Who handles Ctrl+C for the driver server and the browser under it; see `startProcess`
|
|
486
488
|
* @param {Object} [dependencies] - Injected selenium modules, runCommand, startProcess, fetch, reservePort, environment, platform (tests)
|
|
487
489
|
* @returns {Promise<{driver: Object, page: Object, close: function(): Promise<void>, serverUrl: string, driverProcess: Object, driverPath: string, driverSource: string, browser: string, userDataDir: string, temporaryProfile: boolean, args: Array<string>, bidi: boolean}>}
|
|
488
490
|
*/
|
|
@@ -564,6 +566,7 @@ async function launchWebDriverOnce(options, dependencies) {
|
|
|
564
566
|
startupTimeout = 30_000,
|
|
565
567
|
verbose = false,
|
|
566
568
|
debuggerAddress,
|
|
569
|
+
interruption,
|
|
567
570
|
} = options;
|
|
568
571
|
assertBrowser(browser);
|
|
569
572
|
const safari = isSafariChannel(browser);
|
|
@@ -607,7 +610,11 @@ async function launchWebDriverOnce(options, dependencies) {
|
|
|
607
610
|
driverProcess = startProcess(
|
|
608
611
|
resolved.driverPath,
|
|
609
612
|
buildDriverServerArgs(browser, port),
|
|
610
|
-
{
|
|
613
|
+
{
|
|
614
|
+
env: childEnv,
|
|
615
|
+
forwardOutput: verbose,
|
|
616
|
+
interruption: interruptionMode(interruption),
|
|
617
|
+
}
|
|
611
618
|
);
|
|
612
619
|
for (const stream of [driverProcess.stdout, driverProcess.stderr]) {
|
|
613
620
|
stream?.on('data', (chunk) => {
|
|
@@ -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
|
|
package/src/elements/locators.js
CHANGED
|
@@ -6,7 +6,7 @@ import {
|
|
|
6
6
|
|
|
7
7
|
/**
|
|
8
8
|
* Helper to create Playwright locator from selector string
|
|
9
|
-
*
|
|
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
|
-
*
|
|
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>} -
|
|
92
|
+
* @returns {Promise<string|null>} - Native selector, unique CSS path, or null
|
|
149
93
|
*/
|
|
150
94
|
export async function normalizeSelector(options = {}) {
|
|
151
|
-
const { page,
|
|
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
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
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
|
+
}
|
package/src/exports.js
CHANGED
|
@@ -70,6 +70,12 @@ export {
|
|
|
70
70
|
launchAndConnectRealBrowser,
|
|
71
71
|
launchRealBrowser,
|
|
72
72
|
} from './browser/real-browser.js';
|
|
73
|
+
export {
|
|
74
|
+
BROWSER_HANDOFF_VERSION,
|
|
75
|
+
BrowserHandoffError,
|
|
76
|
+
reconnectBrowser,
|
|
77
|
+
} from './browser/browser-handoff.js';
|
|
78
|
+
export { INTERRUPTION_MODES } from './utilities/subprocess.js';
|
|
73
79
|
export {
|
|
74
80
|
classifyDifferences,
|
|
75
81
|
compareCommandLines,
|