Skip to content

Commit 00367b7

Browse files
authored
Merge pull request #8 from link-foundation/issue-7-895bb7f9c584
feat: add Playwright text selector support and use TIMING constants
2 parents 11fbf1f + 5048354 commit 00367b7

File tree

5 files changed

+132
-9
lines changed

5 files changed

+132
-9
lines changed

.changeset/bumpy-snakes-play.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
---
2+
'browser-commander': minor
3+
---
4+
5+
Add Playwright text selector support and use TIMING constants
6+
7+
- Add `isPlaywrightTextSelector()` and `parsePlaywrightTextSelector()` functions
8+
- Update `normalizeSelector()` to convert Playwright text selectors (`:has-text()`, `:text-is()`) to valid CSS selectors
9+
- Update `withTextSelectorSupport()` to handle both Puppeteer and Playwright text selectors
10+
- Add `NAVIGATION_TIMEOUT` constant and use it in navigation-manager

src/bindings.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -144,7 +144,8 @@ export function createBoundFunctions(options = {}) {
144144
const querySelectorAllBound = (opts) =>
145145
querySelectorAll({ ...opts, page, engine });
146146
const findByTextBound = (opts) => findByText({ ...opts, engine });
147-
const normalizeSelectorBound = (opts) => normalizeSelector({ ...opts, page });
147+
const normalizeSelectorBound = (opts) =>
148+
normalizeSelector({ ...opts, page, engine });
148149
const waitForSelectorBound = (opts) =>
149150
waitForSelector({ ...opts, page, engine });
150151

src/core/constants.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ export const TIMING = {
1818
DEFAULT_WAIT_AFTER_SCROLL: 1000, // Default wait after scrolling to element
1919
VISIBILITY_CHECK_TIMEOUT: 100, // Timeout for quick visibility checks
2020
DEFAULT_TIMEOUT: 5000, // Default timeout for most operations
21+
NAVIGATION_TIMEOUT: 30000, // Default timeout for navigation operations
2122
VERIFICATION_TIMEOUT: 3000, // Default timeout for action verification
2223
VERIFICATION_RETRY_INTERVAL: 100, // Interval between verification retries
2324
};

src/core/navigation-manager.js

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
*/
1010

1111
import { isNavigationError } from './navigation-safety.js';
12+
import { TIMING } from './constants.js';
1213

1314
/**
1415
* Create a NavigationManager instance for a page
@@ -230,7 +231,7 @@ export function createNavigationManager(options = {}) {
230231

231232
const networkIdle = await networkTracker.waitForNetworkIdle({
232233
timeout: remainingTimeout,
233-
// idleTime defaults to 30000ms from tracker config
234+
// idleTime defaults to TIMING.NAVIGATION_TIMEOUT from tracker config
234235
});
235236

236237
if (!networkIdle) {
@@ -347,7 +348,7 @@ export function createNavigationManager(options = {}) {
347348
* @returns {Promise<boolean>} - True if navigation completed
348349
*/
349350
async function waitForNavigation(opts = {}) {
350-
const { timeout = 30000 } = opts;
351+
const { timeout = TIMING.NAVIGATION_TIMEOUT } = opts;
351352

352353
if (!isNavigating) {
353354
return true; // Already ready

src/elements/selectors.js

Lines changed: 116 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -106,23 +106,121 @@ export async function findByText(options = {}) {
106106
}
107107

108108
/**
109-
* Normalize selector to handle Puppeteer text selectors
109+
* Check if a selector is a Playwright-specific text selector
110+
* @param {string} selector - The selector to check
111+
* @returns {boolean} - True if selector contains Playwright text pseudo-selectors
112+
*/
113+
function isPlaywrightTextSelector(selector) {
114+
if (typeof selector !== 'string') {
115+
return false;
116+
}
117+
return selector.includes(':has-text(') || selector.includes(':text-is(');
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 hasTextMatch = selector.match(/^(.+?):has-text\("(.+?)"\)$/);
128+
if (hasTextMatch) {
129+
return {
130+
baseSelector: hasTextMatch[1],
131+
text: hasTextMatch[2],
132+
exact: false,
133+
};
134+
}
135+
136+
const textIsMatch = selector.match(/^(.+?):text-is\("(.+?)"\)$/);
137+
if (textIsMatch) {
138+
return {
139+
baseSelector: textIsMatch[1],
140+
text: textIsMatch[2],
141+
exact: true,
142+
};
143+
}
144+
145+
return null;
146+
}
147+
148+
/**
149+
* Normalize selector to handle both Puppeteer and Playwright text selectors
150+
* Converts engine-specific text selectors to valid CSS selectors for browser context
151+
*
110152
* @param {Object} options - Configuration options
111153
* @param {Object} options.page - Browser page object
154+
* @param {string} options.engine - Engine type ('playwright' or 'puppeteer')
112155
* @param {string|Object} options.selector - CSS selector or text selector object
113-
* @returns {Promise<string|null>} - CSS selector or null if not found
156+
* @returns {Promise<string|null>} - Valid CSS selector or null if not found
114157
*/
115158
export async function normalizeSelector(options = {}) {
116-
const { page, selector } = options;
159+
const { page, engine, selector } = options;
117160

118161
if (!selector) {
119162
throw new Error('selector is required in options');
120163
}
121164

165+
// Handle Playwright text selectors (strings containing :has-text or :text-is)
166+
// These are valid for Playwright's locator API but NOT for document.querySelectorAll
167+
if (
168+
typeof selector === 'string' &&
169+
engine === 'playwright' &&
170+
isPlaywrightTextSelector(selector)
171+
) {
172+
const parsed = parsePlaywrightTextSelector(selector);
173+
if (!parsed) {
174+
// Could not parse, return as-is and hope for the best
175+
return selector;
176+
}
177+
178+
try {
179+
// Use page.evaluate to find matching element and generate a valid CSS selector
180+
const result = await page.evaluate(({ baseSelector, text, exact }) => {
181+
const elements = Array.from(document.querySelectorAll(baseSelector));
182+
const matchingElement = elements.find((el) => {
183+
const elementText = el.textContent.trim();
184+
return exact ? elementText === text : elementText.includes(text);
185+
});
186+
187+
if (!matchingElement) {
188+
return null;
189+
}
190+
191+
// Generate a unique selector using data-qa or nth-of-type
192+
const dataQa = matchingElement.getAttribute('data-qa');
193+
if (dataQa) {
194+
return `[data-qa="${dataQa}"]`;
195+
}
196+
197+
// Use nth-of-type as fallback
198+
const tagName = matchingElement.tagName.toLowerCase();
199+
const siblings = Array.from(
200+
matchingElement.parentElement.children
201+
).filter((el) => el.tagName.toLowerCase() === tagName);
202+
const index = siblings.indexOf(matchingElement);
203+
return `${tagName}:nth-of-type(${index + 1})`;
204+
}, parsed);
205+
206+
return result;
207+
} catch (error) {
208+
if (isNavigationError(error)) {
209+
console.log(
210+
'⚠️ Navigation detected during normalizeSelector (Playwright), returning null'
211+
);
212+
return null;
213+
}
214+
throw error;
215+
}
216+
}
217+
218+
// Plain string selector - return as-is
122219
if (typeof selector === 'string') {
123220
return selector;
124221
}
125222

223+
// Handle Puppeteer text selector objects
126224
if (selector._isPuppeteerTextSelector) {
127225
try {
128226
// Find element by text and generate a unique selector
@@ -161,7 +259,7 @@ export async function normalizeSelector(options = {}) {
161259
} catch (error) {
162260
if (isNavigationError(error)) {
163261
console.log(
164-
'⚠️ Navigation detected during normalizeSelector, returning null'
262+
'⚠️ Navigation detected during normalizeSelector (Puppeteer), returning null'
165263
);
166264
return null;
167265
}
@@ -183,13 +281,25 @@ export function withTextSelectorSupport(fn, engine, page) {
183281
return async (options = {}) => {
184282
let { selector } = options;
185283

186-
// Normalize Puppeteer text selectors
284+
// Normalize Puppeteer text selectors (object format)
187285
if (
188286
engine === 'puppeteer' &&
189287
typeof selector === 'object' &&
190288
selector._isPuppeteerTextSelector
191289
) {
192-
selector = await normalizeSelector({ page, selector });
290+
selector = await normalizeSelector({ page, engine, selector });
291+
if (!selector) {
292+
throw new Error('Element with specified text not found');
293+
}
294+
}
295+
296+
// Normalize Playwright text selectors (string format with :has-text or :text-is)
297+
if (
298+
engine === 'playwright' &&
299+
typeof selector === 'string' &&
300+
isPlaywrightTextSelector(selector)
301+
) {
302+
selector = await normalizeSelector({ page, engine, selector });
193303
if (!selector) {
194304
throw new Error('Element with specified text not found');
195305
}

0 commit comments

Comments
 (0)