playwright-genie 1.0.0 → 2.0.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/dist/index.js CHANGED
@@ -1,107 +1,23 @@
1
- // src/llm/client.js
2
- import "dotenv/config";
3
- import OpenAI from "openai";
4
- var DEBUG = process.env.LLM_LOCATOR_DEBUG === "true";
5
- var LLM_API_KEY = process.env.LLM_API_KEY || process.env.ROUTELLM_API_KEY || process.env.OPENAI_API_KEY || process.env.ANTHROPIC_API_KEY;
6
- var LLM_BASE_URL = process.env.LLM_BASE_URL || process.env.ROUTELLM_BASE_URL || "https://api.openai.com/v1";
7
- var LLM_MODEL = process.env.LLM_MODEL || process.env.ROUTELLM_MODEL || "gpt-4o-mini";
8
- if (!LLM_API_KEY) {
9
- throw new Error(
10
- "No LLM API key found. Set one of: LLM_API_KEY, ROUTELLM_API_KEY, OPENAI_API_KEY, or ANTHROPIC_API_KEY"
11
- );
12
- }
13
- var client = new OpenAI({
14
- baseURL: LLM_BASE_URL,
15
- apiKey: LLM_API_KEY
16
- });
17
- async function chatCompletion(messages, options = {}) {
18
- const {
19
- model = LLM_MODEL,
20
- temperature = 0,
21
- maxTokens = 1024
22
- } = options;
23
- if (DEBUG) {
24
- console.log(`[LLM Client] model=${model} base=${LLM_BASE_URL} msgs=${messages.length}`);
25
- }
26
- const completion = await client.chat.completions.create({
27
- model,
28
- temperature,
29
- max_tokens: maxTokens,
30
- messages
31
- });
32
- return completion.choices[0].message.content.trim();
33
- }
34
- function getConfig() {
35
- return {
36
- apiKey: LLM_API_KEY ? "***" + LLM_API_KEY.slice(-4) : null,
37
- baseUrl: LLM_BASE_URL,
38
- model: LLM_MODEL
39
- };
40
- }
41
-
42
- // src/llm/prompt.js
43
- var SYSTEM_PROMPT = `You are an expert at generating Playwright locators from page structure.
44
- You receive a (possibly trimmed) accessibility tree in YAML and special DOM elements.
45
-
46
- CRITICAL RULES:
47
- 1. ONLY use exact text/names that EXIST in the provided structure. NEVER invent or echo the user's query as locator text.
48
- 2. The user's query is a DESCRIPTION of the element, NOT the element's text. You must FIND the matching element in the tree.
49
- Example: Query "Admin Tab" \u2192 find the link/tab named "Admin" in the tree \u2192 getByRole('link', { name: /Admin/i })
50
- Example: Query "login button" \u2192 find button named "Login" in the tree \u2192 getByRole('button', { name: /Login/i })
51
- 3. YAML format: "- link \\"Sign in\\"" means a link element with accessible name "Sign in"
52
- "- textbox \\"Username\\"" means a textbox with accessible name "Username"
53
- 4. Prefer getByRole with name regex: { name: /text/i } for robustness.
54
- 5. Words like "tab", "button", "link", "field", "textbox", "input" in the query describe the ELEMENT TYPE, not the text content.
55
-
56
- ACTION-AWARE RULES:
57
- - If action is "fill", "type", "clear", "press": The target MUST be an editable element (textbox, searchbox, combobox, input). Use getByRole('textbox', ...) or getByPlaceholder or getByLabel. NEVER target a label or static text.
58
- - If action is "click", "dblclick", "tap", "focus", "hover": Target the interactive element (link, button, tab, menuitem, etc).
59
- - If action is "check", "uncheck": Target a checkbox or radio element.
60
- - If action is "select": Target a combobox or listbox element.
61
- - If action is "getText" or null: Target any matching element.
62
-
63
- PRIORITY: getByTestId > getByRole > getByLabel > getByPlaceholder > getByAltText > getByTitle > getByText > locator(CSS)
64
- For iframes: page.frameLocator('sel').getByRole(...)
65
- For positional: .first() / .nth(n) / .last()
66
-
67
- Return ONLY valid JSON.`;
68
- function buildMessages(payloadStr, query, action) {
69
- const actionHint = action ? `
70
- Action: "${action}" (the locator MUST target an element appropriate for this action)` : "";
71
- return [
72
- { role: "system", content: SYSTEM_PROMPT },
73
- {
74
- role: "user",
75
- content: `Page:
76
- ${payloadStr}
77
-
78
- Find element described as: "${query}"${actionHint}
79
-
80
- IMPORTANT: Match the query to an ACTUAL element in the tree above. Do NOT use the query text as locator text.
81
-
82
- JSON: { "strategy":"\u2026", "locatorString":"page.getBy\u2026()", "isInFrame":false, "frameSelector":null, "confidence":0.95, "reasoning":"\u2026" }`
83
- }
84
- ];
85
- }
86
- function buildBatchMessages(payloadStr, queries) {
87
- const queriesList = queries.map((q, i) => `${i + 1}. "${q}"`).join("\n");
88
- return [
89
- { role: "system", content: SYSTEM_PROMPT },
90
- {
91
- role: "user",
92
- content: `Page:
93
- ${payloadStr}
94
-
95
- Find locators for ALL (match each query to ACTUAL elements in the tree, do NOT echo query text):
96
- ${queriesList}
97
-
98
- Return JSON array:
99
- [{ "query":"\u2026", "strategy":"\u2026", "locatorString":"page.getBy\u2026()", "isInFrame":false, "frameSelector":null, "confidence":0.95, "reasoning":"\u2026" }]`
100
- }
101
- ];
102
- }
1
+ var __defProp = Object.defineProperty;
2
+ var __getOwnPropNames = Object.getOwnPropertyNames;
3
+ var __esm = (fn, res) => function __init() {
4
+ return fn && (res = (0, fn[__getOwnPropNames(fn)[0]])(fn = 0)), res;
5
+ };
6
+ var __export = (target, all) => {
7
+ for (var name in all)
8
+ __defProp(target, name, { get: all[name], enumerable: true });
9
+ };
103
10
 
104
11
  // src/llm/parser.js
12
+ var parser_exports = {};
13
+ __export(parser_exports, {
14
+ applyChain: () => applyChain,
15
+ createPlaywrightLocator: () => createPlaywrightLocator,
16
+ evalLocator: () => evalLocator,
17
+ parseJsonResponse: () => parseJsonResponse,
18
+ validateLocator: () => validateLocator,
19
+ validateWithFallback: () => validateWithFallback
20
+ });
105
21
  function parseJsonResponse(raw) {
106
22
  let text = raw.trim();
107
23
  if (text.startsWith("```")) {
@@ -121,17 +37,39 @@ function createPlaywrightLocator(page, locatorString, isInFrame, frameSelector)
121
37
  function parseInlineOptions(str) {
122
38
  if (!str) return {};
123
39
  const opts = {};
124
- const nameRegex = str.match(/name:\s*\/([^/]+)\/([gimsuy]*)/);
125
- const nameStr = str.match(/name:\s*'([^']+)'/);
40
+ let nameMatch = null;
41
+ const nameIdx = str.indexOf("name:");
42
+ if (nameIdx !== -1) {
43
+ const after = str.substring(nameIdx + 5).trim();
44
+ if (after.startsWith("/")) {
45
+ const lastSlash = after.lastIndexOf("/");
46
+ if (lastSlash > 0) {
47
+ const body = after.substring(1, lastSlash);
48
+ const flags = after.substring(lastSlash + 1).match(/^[gimsuy]*/)[0];
49
+ try {
50
+ opts.name = new RegExp(body, flags || void 0);
51
+ } catch {
52
+ opts.name = body;
53
+ }
54
+ }
55
+ } else if (after.startsWith("'")) {
56
+ const end = after.indexOf("'", 1);
57
+ if (end > 0) opts.name = after.substring(1, end);
58
+ }
59
+ }
126
60
  const exact = str.match(/exact:\s*(true|false)/);
127
- if (nameRegex) opts.name = new RegExp(nameRegex[1], nameRegex[2] || void 0);
128
- else if (nameStr) opts.name = nameStr[1];
129
61
  if (exact) opts.exact = exact[1] === "true";
130
62
  return opts;
131
63
  }
132
64
  function parseTextArg(quoted, regexBody, regexFlags) {
133
65
  if (quoted) return quoted;
134
- if (regexBody) return new RegExp(regexBody, regexFlags || void 0);
66
+ if (regexBody) {
67
+ try {
68
+ return new RegExp(regexBody, regexFlags || void 0);
69
+ } catch {
70
+ return regexBody;
71
+ }
72
+ }
135
73
  return null;
136
74
  }
137
75
  function parseFilterOptions(str) {
@@ -146,7 +84,7 @@ function parseFilterOptions(str) {
146
84
  function evalLocator(context, code) {
147
85
  const patterns = [
148
86
  {
149
- regex: /getByRole\('([^']+)'(?:,\s*({[^}]+}))?\)(.*)/,
87
+ regex: /getByRole\('([^']+)'(?:,\s*(\{.*\}))?\)(.*)/,
150
88
  handler: (m) => {
151
89
  const opts = parseInlineOptions(m[2]);
152
90
  return applyChain(context.getByRole(m[1], opts), m[3]);
@@ -196,9 +134,21 @@ function evalLocator(context, code) {
196
134
  return applyChain(context.getByTitle(t, opts), m[5]);
197
135
  }
198
136
  },
137
+ {
138
+ regex: /locator\('xpath=([^']+)'\)(.*)/,
139
+ handler: (m) => applyChain(context.locator(`xpath=${m[1]}`), m[2])
140
+ },
141
+ {
142
+ regex: /locator\("xpath=([^"]+)"\)(.*)/,
143
+ handler: (m) => applyChain(context.locator(`xpath=${m[1]}`), m[2])
144
+ },
199
145
  {
200
146
  regex: /locator\('([^']+)'\)(.*)/,
201
147
  handler: (m) => applyChain(context.locator(m[1]), m[2])
148
+ },
149
+ {
150
+ regex: /locator\("([^"]+)"\)(.*)/,
151
+ handler: (m) => applyChain(context.locator(m[1]), m[2])
202
152
  }
203
153
  ];
204
154
  for (const p of patterns) {
@@ -231,6 +181,243 @@ async function validateLocator(locator, timeoutMs = 3e3) {
231
181
  return false;
232
182
  }
233
183
  }
184
+ async function validateWithFallback(page, primary, fallbacks, isInFrame, frameSelector) {
185
+ const primaryLocator = createPlaywrightLocator(page, primary, isInFrame, frameSelector);
186
+ const valid = await validateLocator(primaryLocator);
187
+ if (valid) return { locator: primaryLocator, locatorString: primary };
188
+ if (fallbacks && fallbacks.length > 0) {
189
+ for (const fb of fallbacks) {
190
+ try {
191
+ const fbLocator = createPlaywrightLocator(page, fb, isInFrame, frameSelector);
192
+ const fbValid = await validateLocator(fbLocator, 2e3);
193
+ if (fbValid) return { locator: fbLocator, locatorString: fb };
194
+ } catch {
195
+ }
196
+ }
197
+ }
198
+ return null;
199
+ }
200
+ var init_parser = __esm({
201
+ "src/llm/parser.js"() {
202
+ }
203
+ });
204
+
205
+ // src/llm/client.js
206
+ import "dotenv/config";
207
+ import OpenAI from "openai";
208
+ var DEBUG = process.env.LLM_LOCATOR_DEBUG === "true";
209
+ var LLM_API_KEY = process.env.LLM_API_KEY || process.env.ROUTELLM_API_KEY || process.env.OPENAI_API_KEY || process.env.ANTHROPIC_API_KEY;
210
+ var LLM_BASE_URL = process.env.LLM_BASE_URL || process.env.ROUTELLM_BASE_URL || "https://api.openai.com/v1";
211
+ var LLM_MODEL = process.env.LLM_MODEL || process.env.ROUTELLM_MODEL || "gpt-4o-mini";
212
+ if (!LLM_API_KEY) {
213
+ throw new Error(
214
+ "No LLM API key found. Set one of: LLM_API_KEY, ROUTELLM_API_KEY, OPENAI_API_KEY, or ANTHROPIC_API_KEY"
215
+ );
216
+ }
217
+ var client = new OpenAI({
218
+ baseURL: LLM_BASE_URL,
219
+ apiKey: LLM_API_KEY
220
+ });
221
+ async function chatCompletion(messages, options = {}) {
222
+ const {
223
+ model = LLM_MODEL,
224
+ temperature = 0,
225
+ maxTokens = 256
226
+ } = options;
227
+ if (DEBUG) {
228
+ console.log(`[LLM Client] model=${model} base=${LLM_BASE_URL} msgs=${messages.length}`);
229
+ }
230
+ const params = {
231
+ model,
232
+ temperature,
233
+ max_tokens: maxTokens,
234
+ messages
235
+ };
236
+ if (model.includes("gpt-") || model.includes("o1") || model.includes("o3") || model.includes("o4")) {
237
+ params.response_format = { type: "json_object" };
238
+ }
239
+ const completion = await client.chat.completions.create(params);
240
+ return completion.choices[0].message.content.trim();
241
+ }
242
+ function getConfig() {
243
+ return {
244
+ apiKey: LLM_API_KEY ? "***" + LLM_API_KEY.slice(-4) : null,
245
+ baseUrl: LLM_BASE_URL,
246
+ model: LLM_MODEL
247
+ };
248
+ }
249
+
250
+ // src/llm/prompt.js
251
+ var SYSTEM_PROMPT = `You are an expert at generating Playwright locators from page structure.
252
+ You receive a (possibly trimmed) accessibility tree in YAML, special DOM elements, and interactive DOM elements.
253
+
254
+ CRITICAL RULES:
255
+ 1. ONLY use exact text/names that EXIST in the provided structure. NEVER invent or echo the user's query as locator text.
256
+ 2. The user's query is a DESCRIPTION of the element, NOT the element's text. You must FIND the matching element in the tree.
257
+ Example: Query "Admin Tab" \u2192 find the link/tab named "Admin" in the tree \u2192 getByRole('link', { name: /Admin/i })
258
+ Example: Query "login button" \u2192 find button named "Login" in the tree \u2192 getByRole('button', { name: /Login/i })
259
+ 3. YAML format: "- link \\"Sign in\\"" means a link element with accessible name "Sign in"
260
+ "- textbox \\"Username\\"" means a textbox with accessible name "Username"
261
+ 4. Prefer getByRole with name regex: { name: /text/i } for robustness.
262
+ 5. Words like "tab", "button", "link", "field", "textbox", "input" in the query describe the ELEMENT TYPE, not the text content.
263
+ 6. NEVER invent accessible names. If an element appears in the aria tree WITHOUT a quoted name
264
+ (e.g., "textbox", "button", "checkbox", "combobox", "link" with no "Name" after it),
265
+ it has NO accessible name. Do NOT guess a name from nearby text, labels, cells, or headings.
266
+ Instead, find that element in interactiveElements by matching tag/type/position, then use its
267
+ id, htmlName, testId, or selector to build a CSS locator:
268
+ - page.locator('#escapedId')
269
+ - page.locator('[name="htmlName"]')
270
+ - page.locator('[data-test="value"]')
271
+ - page.locator('select.className')
272
+ Only use getByRole('role', { name: /text/i }) when the aria tree EXPLICITLY shows a quoted name
273
+ like: button "Submit", link "Home", textbox "Email".
274
+
275
+ INTERACTIVE ELEMENTS:
276
+ - The interactiveElements array contains DOM metadata for ALL interactive elements including unnamed ones.
277
+ - Each entry has: tag, type, role, id, classes, name, testId, text, options (for selects), selector, parentContext.
278
+ - When an element has NO accessible name in the aria tree, use interactiveElements to find it by id, testId, classes, or parent context.
279
+ - For <select> elements: use the "selector" or "id" field to build page.locator('#id') or page.getByTestId('...').
280
+ - For unnamed comboboxes: look at interactiveElements for a select/combobox with matching options, then use its id/testId/classes.
281
+
282
+ ACTION-AWARE RULES:
283
+ - If action is "fill", "type", "clear", "press": The target MUST be an editable element (textbox, searchbox, combobox, input). Use getByRole('textbox', ...) or getByPlaceholder or getByLabel. NEVER target a label or static text.
284
+ - If action is "click", "dblclick", "tap", "focus", "hover": Target the interactive element (link, button, tab, menuitem, etc).
285
+ - If action is "check", "uncheck": Target a checkbox or radio element.
286
+ - If action is "select": Target a combobox, listbox, or <select> element. If the element is a native <select>, prefer page.locator('#id') or page.locator('select.className').
287
+ - If action is "getText" or null: Target any matching element.
288
+
289
+ LOCATOR PRIORITY: getByTestId > getByRole > getByLabel > getByPlaceholder > getByAltText > getByTitle > getByText > locator(CSS)
290
+ FALLBACK: When no accessible name exists, use locator('CSS selector') with id, class, or data attribute from interactiveElements.
291
+ - When using test IDs, use the EXACT attribute from the page data.
292
+ - If data shows [data-test="x"], use page.locator('[data-test="x"]') NOT getByTestId('x').
293
+ - Only use getByTestId() if the attribute is literally "data-testid".
294
+ For iframes: page.frameLocator('sel').getByRole(...)
295
+ For positional: .first() / .nth(n) / .last()
296
+
297
+ Return ONLY valid JSON.`;
298
+ var DOM_SYSTEM_PROMPT = `You are an expert at generating Playwright locators from raw DOM structure.
299
+ You receive a DOM tree with element metadata (tag, id, classes, xpath, css selectors, text, attributes).
300
+ There is NO accessibility tree available \u2014 you must build locators purely from DOM data.
301
+
302
+ CRITICAL RULES:
303
+ 1. The user's query is a DESCRIPTION of the element, NOT the element's text. Match the description to DOM nodes.
304
+ 2. ONLY use data that EXISTS in the provided DOM structure. NEVER invent IDs, classes, or text.
305
+ 3. Each DOM node may have: tag, id, classes, role, testId ({attr, value}), ariaLabel, title, name, placeholder, text, href, xpath, css, label, type, value, disabled, checked, depth, interactive, heading, landmark.
306
+
307
+ LOCATOR STRATEGY (in priority order):
308
+ 1. Test ID: If testId exists, use page.locator('[<attr>="<value>"]') with the EXACT attribute name.
309
+ Only use page.getByTestId('x') when attr is literally "data-testid".
310
+ 2. ID: page.locator('#<id>') \u2014 most stable, prefer when available.
311
+ 3. Role + accessible name: page.getByRole('<role>', { name: /<text>/i }) \u2014 when role and ariaLabel/label/text exist.
312
+ 4. Label: page.getByLabel(/<text>/i) \u2014 for form fields with associated labels.
313
+ 5. Placeholder: page.getByPlaceholder(/<text>/i) \u2014 for inputs with placeholder text.
314
+ 6. Name attribute: page.locator('[name="<name>"]') \u2014 for form elements.
315
+ 7. CSS selector: page.locator('<css>') \u2014 use the "css" field from DOM data when available.
316
+ 8. XPath: page.locator('xpath=<xpath>') \u2014 use as last resort, take from "xpath" field.
317
+ 9. Compound CSS: Build from tag + class + structural position when no unique identifier exists.
318
+ Example: page.locator('div.container button.submit')
319
+ Example: page.locator('form#login input[type="email"]')
320
+
321
+ COMBINING SELECTORS FOR UNIQUENESS:
322
+ - If a single attribute isn't unique, combine: page.locator('form#login input[name="email"]')
323
+ - Use parent context from "depth" and surrounding nodes to disambiguate.
324
+ - Use .first() / .nth(n) / .last() when multiple matches exist.
325
+ - Use .filter({ hasText: /text/i }) to narrow results.
326
+
327
+ ACTION-AWARE RULES:
328
+ - If action is "fill", "type", "clear", "press": Target MUST be input, textarea, select, or [contenteditable]. NEVER target labels, divs, or spans.
329
+ - If action is "click": Target the clickable element (button, a, [role="button"], etc).
330
+ - If action is "check", "uncheck": Target checkbox or radio input.
331
+ - If action is "select": Target the <select> element or [role="combobox"]/[role="listbox"].
332
+
333
+ FALLBACK LOCATORS:
334
+ - Always provide 1-2 fallback locators in case the primary doesn't match.
335
+ - Use different strategies for fallbacks (e.g., primary=CSS, fallback=XPath).
336
+
337
+ For iframes: page.frameLocator('sel').locator(...)
338
+ Return ONLY valid JSON.`;
339
+ function buildMessages(pageStructure, query, action) {
340
+ const actionNote = action ? `
341
+ ACTION: The user wants to "${action}" this element.` : "";
342
+ return [
343
+ { role: "system", content: SYSTEM_PROMPT },
344
+ {
345
+ role: "user",
346
+ content: `Page structure:
347
+ ${pageStructure}
348
+
349
+ Find element: "${query}"${actionNote}
350
+
351
+ IMPORTANT: Find the element that matches the description "${query}". Do NOT use "${query}" as the locator text - find the ACTUAL element name/text from the page structure above.
352
+
353
+ Return JSON: {"locatorString":"page.getBy...(...)", "confidence": 0.0-1.0, "explanation":"...", "fallbackLocators":["page.locator(...)"]}`
354
+ }
355
+ ];
356
+ }
357
+ function buildDomMessages(pageStructure, query, action) {
358
+ const actionNote = action ? `
359
+ ACTION: The user wants to "${action}" this element.` : "";
360
+ return [
361
+ { role: "system", content: DOM_SYSTEM_PROMPT },
362
+ {
363
+ role: "user",
364
+ content: `DOM structure:
365
+ ${pageStructure}
366
+
367
+ Find element: "${query}"${actionNote}
368
+
369
+ INSTRUCTIONS:
370
+ - Search the domTree and interactiveElements for the element matching "${query}".
371
+ - Use the element's id, testId, css, xpath, name, classes, role, text, label, or placeholder to build a Playwright locator.
372
+ - Prefer stable selectors (id, testId, name) over positional ones (xpath, nth).
373
+ - Provide fallback locators using alternative strategies.
374
+
375
+ Return JSON: {"locatorString":"page.locator(...)", "confidence": 0.0-1.0, "explanation":"...", "fallbackLocators":["page.locator('xpath=...')"]}`
376
+ }
377
+ ];
378
+ }
379
+ function buildBatchMessages(pageStructure, queries) {
380
+ const queryList = queries.map((q, i) => `${i + 1}. "${q.query || q}" (action: ${q.action || "none"})`).join("\n");
381
+ return [
382
+ { role: "system", content: SYSTEM_PROMPT },
383
+ {
384
+ role: "user",
385
+ content: `Page structure:
386
+ ${pageStructure}
387
+
388
+ Find ALL of these elements:
389
+ ${queryList}
390
+
391
+ IMPORTANT: For each query, find the ACTUAL element name/text from the page structure. Do NOT echo the query as locator text.
392
+
393
+ Return JSON array: [{"query":"...","locatorString":"page.getBy...(...)", "confidence": 0.0-1.0, "explanation":"...", "fallbackLocators":["..."]}]`
394
+ }
395
+ ];
396
+ }
397
+ function buildDomBatchMessages(pageStructure, queries) {
398
+ const queryList = queries.map((q, i) => `${i + 1}. "${q.query || q}" (action: ${q.action || "none"})`).join("\n");
399
+ return [
400
+ { role: "system", content: DOM_SYSTEM_PROMPT },
401
+ {
402
+ role: "user",
403
+ content: `DOM structure:
404
+ ${pageStructure}
405
+
406
+ Find ALL of these elements:
407
+ ${queryList}
408
+
409
+ INSTRUCTIONS:
410
+ - Search the domTree and interactiveElements for each element.
411
+ - Use id, testId, css, xpath, name, classes, role, text, label, or placeholder to build Playwright locators.
412
+ - Prefer stable selectors over positional ones.
413
+
414
+ Return JSON array: [{"query":"...","locatorString":"page.locator(...)", "confidence": 0.0-1.0, "explanation":"...", "fallbackLocators":["..."]}]`
415
+ }
416
+ ];
417
+ }
418
+
419
+ // src/resolver.js
420
+ init_parser();
234
421
 
235
422
  // src/cache/disk-cache.js
236
423
  import fs from "fs";
@@ -356,6 +543,8 @@ function clearMemoryCache() {
356
543
 
357
544
  // src/page-structure.js
358
545
  var DEBUG3 = process.env.LLM_LOCATOR_DEBUG === "true";
546
+ var MAX_DOM_NODES = 300;
547
+ var MAX_INTERACTIVE = 100;
359
548
  async function getPageStructure(page, forceRefresh = false) {
360
549
  const url = page.url();
361
550
  if (!forceRefresh && isStructureCacheValid(url)) {
@@ -382,7 +571,9 @@ async function getPageStructure(page, forceRefresh = false) {
382
571
  async function getFrameStructure(pageOrFrame) {
383
572
  let ariaTree = null;
384
573
  let specialElements = [];
385
- const [ariaResult, domResult] = await Promise.allSettled([
574
+ let interactiveElements = [];
575
+ let domTree = [];
576
+ const [ariaResult, domResult, interactiveResult, domTreeResult] = await Promise.allSettled([
386
577
  pageOrFrame.locator("body").ariaSnapshot({ timeout: 5e3 }),
387
578
  pageOrFrame.evaluate(() => {
388
579
  const els = [];
@@ -399,7 +590,220 @@ async function getFrameStructure(pageOrFrame) {
399
590
  if (e.testId || e.placeholder || e.alt || e.ariaLabel) els.push(e);
400
591
  }
401
592
  return els;
402
- })
593
+ }),
594
+ pageOrFrame.evaluate((maxInteractive) => {
595
+ const els = [];
596
+ const selectors = 'select, input, textarea, button, a, [role="combobox"], [role="listbox"], [role="menu"], [role="tab"], [role="checkbox"], [role="radio"], [role="slider"], [role="switch"], [contenteditable="true"]';
597
+ for (const el of document.querySelectorAll(selectors)) {
598
+ if (els.length >= maxInteractive) break;
599
+ const ariaLabel = el.getAttribute("aria-label") || el.getAttribute("placeholder") || null;
600
+ const htmlName = el.getAttribute("name") || null;
601
+ const id = el.id || null;
602
+ const classes = el.className ? el.className.toString().split(/\s+/).filter((c) => c && c.length < 40).slice(0, 3).join(" ") : null;
603
+ const tag = el.tagName.toLowerCase();
604
+ const type = el.getAttribute("type") || null;
605
+ const role = el.getAttribute("role") || null;
606
+ const testIdAttr = el.getAttribute("data-testid") ? "data-testid" : el.getAttribute("data-test") ? "data-test" : el.getAttribute("data-test-id") ? "data-test-id" : el.getAttribute("data-qa") ? "data-qa" : el.getAttribute("data-cy") ? "data-cy" : null;
607
+ const testId = testIdAttr ? el.getAttribute(testIdAttr) : null;
608
+ const text = el.textContent?.trim().substring(0, 50) || null;
609
+ const labelEl = id ? document.querySelector(`label[for="${id}"]`) : null;
610
+ const labelText = labelEl ? labelEl.textContent.trim().substring(0, 50) : null;
611
+ let options = null;
612
+ if (tag === "select") {
613
+ options = [...el.options].map((o) => ({ value: o.value, text: o.text, selected: o.selected })).slice(0, 10);
614
+ }
615
+ const parent = el.closest("[class], [id], [data-testid], [data-test], [data-cy], [data-qa]");
616
+ let parentContext = null;
617
+ if (parent && parent !== el) {
618
+ const parentTestAttr = parent.getAttribute("data-testid") ? "data-testid" : parent.getAttribute("data-test") ? "data-test" : parent.getAttribute("data-qa") ? "data-qa" : parent.getAttribute("data-cy") ? "data-cy" : null;
619
+ parentContext = parent.id ? `#${parent.id}` : parentTestAttr ? `[${parentTestAttr}="${parent.getAttribute(parentTestAttr)}"]` : parent.className ? `.${parent.className.toString().split(/\s+/)[0]}` : null;
620
+ }
621
+ const selector = testId ? `[${testIdAttr}="${testId}"]` : id ? `[id="${id}"]` : htmlName ? `[name="${htmlName}"]` : classes ? `.${classes.split(" ")[0]}` : null;
622
+ els.push({
623
+ tag,
624
+ type,
625
+ role,
626
+ id,
627
+ classes,
628
+ ariaLabel,
629
+ htmlName,
630
+ testId,
631
+ labelText,
632
+ text: tag === "select" ? null : text,
633
+ options,
634
+ selector,
635
+ parentContext
636
+ });
637
+ }
638
+ return els;
639
+ }, MAX_INTERACTIVE),
640
+ pageOrFrame.evaluate((maxNodes) => {
641
+ function getXPath(el) {
642
+ if (el.id) return `//*[@id="${el.id}"]`;
643
+ const parts = [];
644
+ let current = el;
645
+ while (current && current.nodeType === 1) {
646
+ let idx = 0;
647
+ let sibling = current.previousSibling;
648
+ while (sibling) {
649
+ if (sibling.nodeType === 1 && sibling.tagName === current.tagName) idx++;
650
+ sibling = sibling.previousSibling;
651
+ }
652
+ const tag = current.tagName.toLowerCase();
653
+ parts.unshift(idx > 0 || current.nextElementSibling?.tagName === current.tagName ? `${tag}[${idx + 1}]` : tag);
654
+ current = current.parentElement;
655
+ }
656
+ return "/" + parts.join("/");
657
+ }
658
+ function getUniqueSelector(el) {
659
+ if (el.id) return `#${CSS.escape(el.id)}`;
660
+ const testAttrs = ["data-testid", "data-test-id", "data-test", "data-qa", "data-cy"];
661
+ for (const attr of testAttrs) {
662
+ const val = el.getAttribute(attr);
663
+ if (val) return `[${attr}="${CSS.escape(val)}"]`;
664
+ }
665
+ const tag = el.tagName.toLowerCase();
666
+ const name = el.getAttribute("name");
667
+ if (name) return `${tag}[name="${CSS.escape(name)}"]`;
668
+ const cls = el.className?.toString().trim();
669
+ if (cls) {
670
+ const sel = tag + "." + cls.split(/\s+/).map((c) => CSS.escape(c)).join(".");
671
+ if (document.querySelectorAll(sel).length === 1) return sel;
672
+ }
673
+ return null;
674
+ }
675
+ function isVisible(el) {
676
+ if (!el.offsetParent && el.tagName !== "BODY" && el.tagName !== "HTML" && getComputedStyle(el).position !== "fixed" && getComputedStyle(el).position !== "sticky") return false;
677
+ const style = getComputedStyle(el);
678
+ return style.display !== "none" && style.visibility !== "hidden" && parseFloat(style.opacity) > 0;
679
+ }
680
+ const interactiveTags = /* @__PURE__ */ new Set([
681
+ "a",
682
+ "button",
683
+ "input",
684
+ "textarea",
685
+ "select",
686
+ "details",
687
+ "summary",
688
+ "label"
689
+ ]);
690
+ const interactiveRoles = /* @__PURE__ */ new Set([
691
+ "button",
692
+ "link",
693
+ "tab",
694
+ "menuitem",
695
+ "menuitemcheckbox",
696
+ "menuitemradio",
697
+ "option",
698
+ "checkbox",
699
+ "radio",
700
+ "switch",
701
+ "slider",
702
+ "spinbutton",
703
+ "combobox",
704
+ "listbox",
705
+ "searchbox",
706
+ "textbox",
707
+ "treeitem"
708
+ ]);
709
+ const landmarkRoles = /* @__PURE__ */ new Set([
710
+ "banner",
711
+ "navigation",
712
+ "main",
713
+ "complementary",
714
+ "contentinfo",
715
+ "form",
716
+ "region",
717
+ "search",
718
+ "dialog",
719
+ "alertdialog",
720
+ "tablist",
721
+ "toolbar",
722
+ "menu",
723
+ "menubar",
724
+ "tree",
725
+ "grid",
726
+ "table",
727
+ "list"
728
+ ]);
729
+ const structuralSel = "h1, h2, h3, h4, h5, h6, nav, main, header, footer, section, form, table, thead, tbody, tr, th, td, ul, ol, li, dl, dt, dd, fieldset, legend, [role]";
730
+ const nodes = [];
731
+ const allEls = document.querySelectorAll("body *");
732
+ for (const el of allEls) {
733
+ if (nodes.length >= maxNodes) break;
734
+ const tag = el.tagName.toLowerCase();
735
+ if (["script", "style", "noscript", "br", "hr", "wbr", "meta", "link"].includes(tag)) continue;
736
+ if (!isVisible(el)) continue;
737
+ const role = el.getAttribute("role") || null;
738
+ const isInteractive = interactiveTags.has(tag) || role && interactiveRoles.has(role) || el.getAttribute("contenteditable") === "true" || el.getAttribute("tabindex") !== null || el.getAttribute("onclick") !== null;
739
+ const isLandmark = landmarkRoles.has(role) || ["nav", "main", "header", "footer", "section", "form", "table"].includes(tag);
740
+ const isHeading = /^h[1-6]$/.test(tag);
741
+ const isStructural = el.matches(structuralSel);
742
+ if (!isInteractive && !isLandmark && !isHeading && !isStructural) continue;
743
+ const id = el.id || null;
744
+ const classes = el.className ? el.className.toString().split(/\s+/).filter((c) => c && c.length < 50).slice(0, 5).join(" ") : null;
745
+ const text = (el.textContent?.trim() || "").substring(0, 80) || null;
746
+ const directText = (() => {
747
+ let t = "";
748
+ for (const child of el.childNodes) {
749
+ if (child.nodeType === 3) t += child.textContent;
750
+ }
751
+ return t.trim().substring(0, 80) || null;
752
+ })();
753
+ const ariaLabel = el.getAttribute("aria-label") || null;
754
+ const title = el.getAttribute("title") || null;
755
+ const type = el.getAttribute("type") || null;
756
+ const href = tag === "a" ? (el.getAttribute("href") || "").substring(0, 100) : null;
757
+ const placeholder = el.getAttribute("placeholder") || null;
758
+ const htmlName = el.getAttribute("name") || null;
759
+ const value = tag === "input" || tag === "textarea" ? (el.value || "").substring(0, 50) || null : null;
760
+ const disabled = el.disabled || el.getAttribute("aria-disabled") === "true" || null;
761
+ const checked = el.checked !== void 0 ? el.checked : null;
762
+ const testIdAttr = el.getAttribute("data-testid") ? "data-testid" : el.getAttribute("data-test") ? "data-test" : el.getAttribute("data-test-id") ? "data-test-id" : el.getAttribute("data-qa") ? "data-qa" : el.getAttribute("data-cy") ? "data-cy" : null;
763
+ const testId = testIdAttr ? { attr: testIdAttr, value: el.getAttribute(testIdAttr) } : null;
764
+ const xpath = getXPath(el);
765
+ const cssSelector = getUniqueSelector(el);
766
+ const depth = (() => {
767
+ let d = 0;
768
+ let p = el.parentElement;
769
+ while (p && p !== document.body) {
770
+ d++;
771
+ p = p.parentElement;
772
+ }
773
+ return d;
774
+ })();
775
+ const node = { tag, depth };
776
+ if (role) node.role = role;
777
+ if (id) node.id = id;
778
+ if (classes) node.classes = classes;
779
+ if (testId) node.testId = testId;
780
+ if (ariaLabel) node.ariaLabel = ariaLabel;
781
+ if (title) node.title = title;
782
+ if (type) node.type = type;
783
+ if (htmlName) node.name = htmlName;
784
+ if (placeholder) node.placeholder = placeholder;
785
+ if (href) node.href = href;
786
+ if (directText) node.text = directText;
787
+ else if (text && text.length <= 80) node.text = text;
788
+ if (value) node.value = value;
789
+ if (disabled) node.disabled = true;
790
+ if (checked !== null && checked !== void 0) node.checked = checked;
791
+ if (isInteractive) node.interactive = true;
792
+ if (isHeading) node.heading = true;
793
+ if (isLandmark) node.landmark = true;
794
+ node.xpath = xpath;
795
+ if (cssSelector) node.css = cssSelector;
796
+ const labelEl = id ? document.querySelector(`label[for="${id}"]`) : null;
797
+ if (labelEl) node.label = labelEl.textContent.trim().substring(0, 50);
798
+ let selectOpts = null;
799
+ if (tag === "select") {
800
+ selectOpts = [...el.options].map((o) => ({ value: o.value, text: o.text, selected: o.selected })).slice(0, 15);
801
+ if (selectOpts.length) node.options = selectOpts;
802
+ }
803
+ nodes.push(node);
804
+ }
805
+ return nodes;
806
+ }, MAX_DOM_NODES)
403
807
  ]);
404
808
  if (ariaResult.status === "fulfilled") ariaTree = ariaResult.value;
405
809
  else {
@@ -412,7 +816,19 @@ async function getFrameStructure(pageOrFrame) {
412
816
  }
413
817
  }
414
818
  if (domResult.status === "fulfilled") specialElements = domResult.value;
415
- return { ariaTree, specialElements };
819
+ if (interactiveResult.status === "fulfilled") interactiveElements = interactiveResult.value;
820
+ if (domTreeResult.status === "fulfilled") domTree = domTreeResult.value;
821
+ const ariaQuality = evaluateAriaQuality(ariaTree, interactiveElements.length);
822
+ return { ariaTree, specialElements, interactiveElements, domTree, ariaQuality };
823
+ }
824
+ function evaluateAriaQuality(ariaTree, interactiveCount) {
825
+ if (!ariaTree || typeof ariaTree !== "string") return "none";
826
+ const lines = ariaTree.split("\n").filter((l) => l.trim());
827
+ const namedInteractive = (ariaTree.match(/(button|link|textbox|combobox|checkbox|tab|radio|switch)\s+"[^"]+"/g) || []).length;
828
+ if (lines.length < 5) return "none";
829
+ if (namedInteractive < 3 && interactiveCount > 5) return "sparse";
830
+ if (lines.length < 20 && interactiveCount > 10) return "sparse";
831
+ return "good";
416
832
  }
417
833
  function simplifyAriaTree(node, depth = 0) {
418
834
  if (!node || depth > 8) return null;
@@ -466,30 +882,123 @@ function trimSnapshotForQuery(ariaYaml, query, maxLines = 150) {
466
882
  }
467
883
  return result.join("\n");
468
884
  }
885
+ function filterDomTreeForQuery(domTree, query, maxNodes = 80) {
886
+ if (!domTree || !domTree.length) return domTree;
887
+ if (domTree.length <= maxNodes) return domTree;
888
+ const queryTokens = query.toLowerCase().replace(/[^a-z0-9\s]/g, "").split(/\s+/).filter(Boolean);
889
+ const scored = domTree.map((node, idx) => {
890
+ let score = 0;
891
+ if (node.interactive) score += 5;
892
+ if (node.heading) score += 3;
893
+ if (node.landmark) score += 2;
894
+ const searchable = [node.text, node.ariaLabel, node.label, node.placeholder, node.title, node.id, node.name, node.testId?.value].filter(Boolean).join(" ").toLowerCase();
895
+ for (const token of queryTokens) {
896
+ if (searchable.includes(token)) score += 15;
897
+ }
898
+ return { node, idx, score };
899
+ });
900
+ scored.sort((a, b) => b.score - a.score);
901
+ return scored.slice(0, maxNodes).sort((a, b) => a.idx - b.idx).map((s) => s.node);
902
+ }
469
903
 
470
904
  // src/resolver.js
471
905
  var DEBUG4 = process.env.LLM_LOCATOR_DEBUG === "true";
906
+ var PAYLOAD_LIMITS = {
907
+ good: 15e3,
908
+ sparse: 25e3,
909
+ none: 3e4
910
+ };
472
911
  function buildPayload(pageStructure, query) {
912
+ const quality = pageStructure.mainFrame.ariaQuality || "good";
913
+ const maxPayload = PAYLOAD_LIMITS[quality] || PAYLOAD_LIMITS.good;
914
+ if (quality === "none") {
915
+ return buildDomOnlyPayload(pageStructure, query, maxPayload);
916
+ }
917
+ if (quality === "sparse") {
918
+ return buildHybridPayload(pageStructure, query, maxPayload);
919
+ }
920
+ return buildAriaPayload(pageStructure, query, maxPayload);
921
+ }
922
+ function buildAriaPayload(pageStructure, query, maxPayload) {
473
923
  const trimmedAria = trimSnapshotForQuery(pageStructure.mainFrame.ariaTree, query);
924
+ const specialElements = pageStructure.mainFrame.specialElements;
925
+ const interactiveElements = pageStructure.mainFrame.interactiveElements || [];
474
926
  const payload = {
927
+ mode: "aria",
475
928
  ariaTree: trimmedAria,
476
- specialElements: pageStructure.mainFrame.specialElements,
477
- frames: pageStructure.frames.length > 0 ? pageStructure.frames.map((f) => ({
478
- selector: f.selector,
479
- ariaTree: trimSnapshotForQuery(f.content?.ariaTree, query, 50),
480
- specialElements: f.content?.specialElements || []
481
- })) : void 0
929
+ specialElements: specialElements.length > 30 ? specialElements.slice(0, 30) : specialElements,
930
+ interactiveElements: interactiveElements.length > 50 ? interactiveElements.slice(0, 50) : interactiveElements,
931
+ frames: buildFramesPayload(pageStructure, query)
482
932
  };
483
- let payloadStr = JSON.stringify(payload, null, 2);
484
- if (payloadStr.length > 15e3) payloadStr = payloadStr.substring(0, 15e3) + "\n...(truncated)";
485
- return payloadStr;
933
+ let payloadStr = JSON.stringify(payload);
934
+ if (payloadStr.length > maxPayload) payloadStr = payloadStr.substring(0, maxPayload) + "...(truncated)";
935
+ return { payloadStr, mode: "aria" };
936
+ }
937
+ function buildHybridPayload(pageStructure, query, maxPayload) {
938
+ const trimmedAria = trimSnapshotForQuery(pageStructure.mainFrame.ariaTree, query);
939
+ const interactiveElements = pageStructure.mainFrame.interactiveElements || [];
940
+ const domTree = filterDomTreeForQuery(pageStructure.mainFrame.domTree || [], query, 120);
941
+ const payload = {
942
+ mode: "hybrid",
943
+ ariaTree: trimmedAria,
944
+ interactiveElements: interactiveElements.slice(0, 60),
945
+ domTree,
946
+ frames: buildFramesPayload(pageStructure, query)
947
+ };
948
+ let payloadStr = JSON.stringify(payload);
949
+ if (payloadStr.length > maxPayload) {
950
+ payload.domTree = filterDomTreeForQuery(pageStructure.mainFrame.domTree || [], query, 60);
951
+ payloadStr = JSON.stringify(payload);
952
+ }
953
+ if (payloadStr.length > maxPayload) payloadStr = payloadStr.substring(0, maxPayload) + "...(truncated)";
954
+ return { payloadStr, mode: "hybrid" };
955
+ }
956
+ function buildDomOnlyPayload(pageStructure, query, maxPayload) {
957
+ const interactiveElements = pageStructure.mainFrame.interactiveElements || [];
958
+ const domTree = filterDomTreeForQuery(pageStructure.mainFrame.domTree || [], query, 150);
959
+ const specialElements = pageStructure.mainFrame.specialElements || [];
960
+ const payload = {
961
+ mode: "dom",
962
+ domTree,
963
+ specialElements: specialElements.slice(0, 30),
964
+ interactiveElements: interactiveElements.slice(0, 80),
965
+ frames: buildFramesPayload(pageStructure, query)
966
+ };
967
+ let payloadStr = JSON.stringify(payload);
968
+ if (payloadStr.length > maxPayload) {
969
+ payload.domTree = filterDomTreeForQuery(pageStructure.mainFrame.domTree || [], query, 80);
970
+ payload.interactiveElements = interactiveElements.slice(0, 50);
971
+ payloadStr = JSON.stringify(payload);
972
+ }
973
+ if (payloadStr.length > maxPayload) payloadStr = payloadStr.substring(0, maxPayload) + "...(truncated)";
974
+ return { payloadStr, mode: "dom" };
975
+ }
976
+ function buildFramesPayload(pageStructure, query) {
977
+ if (!pageStructure.frames || pageStructure.frames.length === 0) return void 0;
978
+ return pageStructure.frames.map((f) => {
979
+ const frame = { selector: f.selector };
980
+ if (f.content?.ariaTree) {
981
+ frame.ariaTree = trimSnapshotForQuery(f.content.ariaTree, query, 50);
982
+ }
983
+ if (f.content?.specialElements) {
984
+ frame.specialElements = (f.content.specialElements || []).slice(0, 10);
985
+ }
986
+ if (f.content?.interactiveElements) {
987
+ frame.interactiveElements = (f.content.interactiveElements || []).slice(0, 20);
988
+ }
989
+ if (f.content?.domTree) {
990
+ frame.domTree = filterDomTreeForQuery(f.content.domTree || [], query, 30);
991
+ }
992
+ return frame;
993
+ });
486
994
  }
487
995
  async function queryLLM(pageStructure, query, options = {}) {
488
996
  const { action = null } = options;
489
- const payloadStr = buildPayload(pageStructure, query);
490
- if (DEBUG4) console.log("[Resolver] Payload size:", payloadStr.length, "chars");
491
- const messages = buildMessages(payloadStr, query, action);
492
- const raw = await chatCompletion(messages, options);
997
+ const { payloadStr, mode } = buildPayload(pageStructure, query);
998
+ if (DEBUG4) console.log(`[Resolver] mode=${mode} payload=${payloadStr.length} chars`);
999
+ const messages = mode === "dom" ? buildDomMessages(payloadStr, query, action) : mode === "hybrid" ? buildDomMessages(payloadStr, query, action) : buildMessages(payloadStr, query, action);
1000
+ const maxTokens = mode === "aria" ? 256 : 512;
1001
+ const raw = await chatCompletion(messages, { ...options, maxTokens });
493
1002
  return parseJsonResponse(raw);
494
1003
  }
495
1004
  async function resolveLocator(page, query, options = {}) {
@@ -499,7 +1008,7 @@ async function resolveLocator(page, query, options = {}) {
499
1008
  const memHit = getMemEntry(url, query, action);
500
1009
  if (memHit) {
501
1010
  if (shouldDebug) console.log(`[Memory Cache Hit] "${query}" \u2192 ${memHit.locatorString}`);
502
- return memHit;
1011
+ return { ...memHit, source: "memory" };
503
1012
  }
504
1013
  const diskHit = getDiskEntry(url, query, action);
505
1014
  if (diskHit) {
@@ -557,6 +1066,17 @@ async function getLocator(page, query, options = {}) {
557
1066
  bumpHitCount(url, query, action);
558
1067
  return { locator, result };
559
1068
  }
1069
+ if (result.fallbackLocators && result.fallbackLocators.length > 0) {
1070
+ if (shouldDebug) console.log(`[Fallback] Primary "${result.locatorString}" not found, trying ${result.fallbackLocators.length} fallbacks...`);
1071
+ const fbResult = await validateWithFallback(page, result.locatorString, result.fallbackLocators, result.isInFrame, result.frameSelector);
1072
+ if (fbResult && fbResult.locatorString !== result.locatorString) {
1073
+ if (shouldDebug) console.log(`[Fallback] Using fallback: ${fbResult.locatorString}`);
1074
+ const updatedResult = { ...result, locatorString: fbResult.locatorString, strategy: "fallback" };
1075
+ setMemEntry(url, query, action, updatedResult);
1076
+ setDiskEntry(url, query, action, updatedResult);
1077
+ return { locator: fbResult.locator, result: updatedResult };
1078
+ }
1079
+ }
560
1080
  if (shouldDebug) console.log(`[Stale Cache] "${query}" \u2192 ${result.locatorString} no longer valid, re-querying LLM...`);
561
1081
  invalidateDiskEntry(url, query, action);
562
1082
  deleteMemEntry(url, query, action);
@@ -566,6 +1086,20 @@ async function getLocator(page, query, options = {}) {
566
1086
  const freshLocator = createPlaywrightLocator(page, freshResult.locatorString, freshResult.isInFrame, freshResult.frameSelector);
567
1087
  return { locator: freshLocator, result: freshResult };
568
1088
  }
1089
+ if (result.source === "llm" && result.fallbackLocators && result.fallbackLocators.length > 0) {
1090
+ const valid = await validateLocator(locator, 2e3);
1091
+ if (!valid) {
1092
+ if (shouldDebug) console.log(`[Fallback] Primary "${result.locatorString}" not found, trying ${result.fallbackLocators.length} fallbacks...`);
1093
+ const fbResult = await validateWithFallback(page, result.locatorString, result.fallbackLocators, result.isInFrame, result.frameSelector);
1094
+ if (fbResult && fbResult.locatorString !== result.locatorString) {
1095
+ if (shouldDebug) console.log(`[Fallback] Using fallback: ${fbResult.locatorString}`);
1096
+ const updatedResult = { ...result, locatorString: fbResult.locatorString, strategy: "fallback" };
1097
+ setMemEntry(url, query, action, updatedResult);
1098
+ setDiskEntry(url, query, action, updatedResult);
1099
+ return { locator: fbResult.locator, result: updatedResult };
1100
+ }
1101
+ }
1102
+ }
569
1103
  return { locator, result };
570
1104
  }
571
1105
  async function resolveLocatorsBatch(page, queries, options = {}) {
@@ -601,11 +1135,11 @@ async function resolveLocatorsBatch(page, queries, options = {}) {
601
1135
  if (shouldDebug) console.log(`[Batch] Resolving ${uncached.length} queries in 1 call`);
602
1136
  const t0 = Date.now();
603
1137
  const pageStructure = await getPageStructure(page);
604
- let payloadStr = buildPayload(pageStructure, uncached.join(" "));
605
- if (payloadStr.length > 2e4) payloadStr = payloadStr.substring(0, 2e4) + "\n...(truncated)";
1138
+ const { payloadStr, mode } = buildPayload(pageStructure, uncached.join(" "));
606
1139
  try {
607
- const messages = buildBatchMessages(payloadStr, uncached);
608
- const raw = await chatCompletion(messages, options);
1140
+ const messages = mode === "dom" || mode === "hybrid" ? buildDomBatchMessages(payloadStr, uncached) : buildBatchMessages(payloadStr, uncached);
1141
+ const maxTokens = mode === "aria" ? 256 : 512;
1142
+ const raw = await chatCompletion(messages, { ...options, maxTokens });
609
1143
  const parsed = parseJsonResponse(raw);
610
1144
  if (shouldDebug) console.log(`[Batch] ${parsed.length} results in ${Date.now() - t0}ms`);
611
1145
  for (let i = 0; i < uncached.length && i < parsed.length; i++) {
@@ -655,66 +1189,84 @@ var SmartAction = class _SmartAction {
655
1189
  get query() {
656
1190
  return this.#query;
657
1191
  }
1192
+ async #safeAction(action, ...args) {
1193
+ try {
1194
+ return await this.#locator[action](...args);
1195
+ } catch (err) {
1196
+ if (err.message && err.message.includes("strict mode violation")) {
1197
+ if (DEBUG5) console.log(`[SmartAction] Strict mode on "${this.#query}", retrying with .first()`);
1198
+ return await this.#locator.first()[action](...args);
1199
+ }
1200
+ throw err;
1201
+ }
1202
+ }
658
1203
  async click(options) {
659
- return this.#locator.click(options);
1204
+ return this.#safeAction("click", options);
660
1205
  }
661
1206
  async dblclick(options) {
662
- return this.#locator.dblclick(options);
1207
+ return this.#safeAction("dblclick", options);
663
1208
  }
664
1209
  async tap(options) {
665
- return this.#locator.tap(options);
1210
+ return this.#safeAction("tap", options);
666
1211
  }
667
1212
  async hover(options) {
668
- return this.#locator.hover(options);
1213
+ return this.#safeAction("hover", options);
669
1214
  }
670
1215
  async focus() {
671
- return this.#locator.focus();
1216
+ return this.#safeAction("focus");
672
1217
  }
673
1218
  async blur() {
674
- return this.#locator.blur();
1219
+ return this.#safeAction("blur");
675
1220
  }
676
1221
  async fill(value, options) {
677
- return this.#locator.fill(value, options);
1222
+ return this.#safeAction("fill", value, options);
678
1223
  }
679
1224
  async type(text, options) {
680
- return this.#locator.type(text, options);
1225
+ return this.#safeAction("type", text, options);
681
1226
  }
682
1227
  async press(key, options) {
683
- return this.#locator.press(key, options);
1228
+ return this.#safeAction("press", key, options);
684
1229
  }
685
1230
  async pressSequentially(text, options) {
686
- return this.#locator.pressSequentially(text, options);
1231
+ return this.#safeAction("pressSequentially", text, options);
687
1232
  }
688
1233
  async clear(options) {
689
- return this.#locator.clear(options);
1234
+ return this.#safeAction("clear", options);
690
1235
  }
691
1236
  async setInputFiles(files, options) {
692
- return this.#locator.setInputFiles(files, options);
1237
+ return this.#safeAction("setInputFiles", files, options);
693
1238
  }
694
1239
  async selectOption(values, options) {
695
- return this.#locator.selectOption(values, options);
1240
+ return this.#safeAction("selectOption", values, options);
696
1241
  }
697
1242
  async selectText(options) {
698
- return this.#locator.selectText(options);
1243
+ return this.#safeAction("selectText", options);
699
1244
  }
700
1245
  async check(options) {
701
- return this.#locator.check(options);
1246
+ return this.#safeAction("check", options);
702
1247
  }
703
1248
  async uncheck(options) {
704
- return this.#locator.uncheck(options);
1249
+ return this.#safeAction("uncheck", options);
705
1250
  }
706
1251
  async setChecked(checked, options) {
707
- return this.#locator.setChecked(checked, options);
1252
+ return this.#safeAction("setChecked", checked, options);
708
1253
  }
709
1254
  async scrollIntoViewIfNeeded(options) {
710
- return this.#locator.scrollIntoViewIfNeeded(options);
1255
+ return this.#safeAction("scrollIntoViewIfNeeded", options);
711
1256
  }
712
1257
  async screenshot(options) {
713
- return this.#locator.screenshot(options);
1258
+ return this.#safeAction("screenshot", options);
714
1259
  }
715
1260
  async dragTo(target, options) {
716
1261
  const dest = target instanceof _SmartAction ? target.rawLocator : target;
717
- return this.#locator.dragTo(dest, options);
1262
+ try {
1263
+ return await this.#locator.dragTo(dest, options);
1264
+ } catch (err) {
1265
+ if (err.message && err.message.includes("strict mode violation")) {
1266
+ return await this.#locator.first().dragTo(dest, options);
1267
+ }
1268
+ throw err;
1269
+ }
718
1270
  }
719
1271
  async waitFor(options) {
720
1272
  return this.#locator.waitFor(options);
@@ -750,22 +1302,22 @@ var SmartAction = class _SmartAction {
750
1302
  return this.#locator.isEditable();
751
1303
  }
752
1304
  async textContent() {
753
- return this.#locator.textContent();
1305
+ return this.#safeAction("textContent");
754
1306
  }
755
1307
  async innerText() {
756
- return this.#locator.innerText();
1308
+ return this.#safeAction("innerText");
757
1309
  }
758
1310
  async innerHTML() {
759
- return this.#locator.innerHTML();
1311
+ return this.#safeAction("innerHTML");
760
1312
  }
761
1313
  async inputValue(options) {
762
- return this.#locator.inputValue(options);
1314
+ return this.#safeAction("inputValue", options);
763
1315
  }
764
1316
  async getAttribute(name) {
765
- return this.#locator.getAttribute(name);
1317
+ return this.#safeAction("getAttribute", name);
766
1318
  }
767
1319
  async boundingBox() {
768
- return this.#locator.boundingBox();
1320
+ return this.#safeAction("boundingBox");
769
1321
  }
770
1322
  async count() {
771
1323
  return this.#locator.count();
@@ -780,13 +1332,13 @@ var SmartAction = class _SmartAction {
780
1332
  return this.#locator.all();
781
1333
  }
782
1334
  async evaluate(pageFunction, arg) {
783
- return this.#locator.evaluate(pageFunction, arg);
1335
+ return this.#safeAction("evaluate", pageFunction, arg);
784
1336
  }
785
1337
  async evaluateAll(pageFunction, arg) {
786
1338
  return this.#locator.evaluateAll(pageFunction, arg);
787
1339
  }
788
1340
  async evaluateHandle(pageFunction, arg) {
789
- return this.#locator.evaluateHandle(pageFunction, arg);
1341
+ return this.#safeAction("evaluateHandle", pageFunction, arg);
790
1342
  }
791
1343
  first() {
792
1344
  return this.#locator.first();
@@ -832,14 +1384,15 @@ var SmartAction = class _SmartAction {
832
1384
  }
833
1385
  }
834
1386
  async getClasses() {
835
- const cls = await this.#locator.getAttribute("class");
1387
+ const cls = await this.getAttribute("class");
836
1388
  return cls ? cls.split(/\s+/).filter(Boolean) : [];
837
1389
  }
838
1390
  async hasClass(className) {
839
1391
  return (await this.getClasses()).includes(className);
840
1392
  }
841
1393
  async getCssProperty(property) {
842
- return this.#locator.evaluate(
1394
+ return this.#safeAction(
1395
+ "evaluate",
843
1396
  (el, prop) => window.getComputedStyle(el).getPropertyValue(prop),
844
1397
  property
845
1398
  );
@@ -858,12 +1411,21 @@ function clearAllCaches() {
858
1411
  clearDiskCache();
859
1412
  }
860
1413
  function createSmartLocator(page, options = {}) {
861
- const { verbose = false } = options;
1414
+ const { verbose = false, actionTimeout = 1e4 } = options;
862
1415
  async function resolve(query, action) {
863
1416
  const { locator, result } = await getLocator(page, query, { ...options, action });
864
1417
  if (verbose) console.log(`[Smart Locator] ${action || "locate"}("${query}") \u2192 ${result.locatorString} (${result.source})`);
865
1418
  return new SmartAction(locator, result, query);
866
1419
  }
1420
+ function withTimeout(promise, ms, label) {
1421
+ if (!ms) return promise;
1422
+ return Promise.race([
1423
+ promise,
1424
+ new Promise(
1425
+ (_, reject) => setTimeout(() => reject(new Error(`Timeout: "${label}" exceeded ${ms}ms`)), ms)
1426
+ )
1427
+ ]);
1428
+ }
867
1429
  return {
868
1430
  async prefetch(...queries) {
869
1431
  const t0 = Date.now();
@@ -876,93 +1438,93 @@ function createSmartLocator(page, options = {}) {
876
1438
  },
877
1439
  async click(query, clickOptions) {
878
1440
  const el = await resolve(query, "click");
879
- await el.click(clickOptions);
1441
+ await withTimeout(el.click(clickOptions), actionTimeout, `click("${query}")`);
880
1442
  return el;
881
1443
  },
882
1444
  async dblclick(query, clickOptions) {
883
1445
  const el = await resolve(query, "click");
884
- await el.dblclick(clickOptions);
1446
+ await withTimeout(el.dblclick(clickOptions), actionTimeout, `dblclick("${query}")`);
885
1447
  return el;
886
1448
  },
887
1449
  async fill(query, value, fillOptions) {
888
1450
  const el = await resolve(query, "fill");
889
- await el.fill(value, fillOptions);
1451
+ await withTimeout(el.fill(value, fillOptions), actionTimeout, `fill("${query}")`);
890
1452
  return el;
891
1453
  },
892
1454
  async type(query, text, typeOptions) {
893
1455
  const el = await resolve(query, "fill");
894
- await el.type(text, typeOptions);
1456
+ await withTimeout(el.type(text, typeOptions), actionTimeout, `type("${query}")`);
895
1457
  return el;
896
1458
  },
897
1459
  async pressSequentially(query, text, pressOptions) {
898
1460
  const el = await resolve(query, "fill");
899
- await el.pressSequentially(text, pressOptions);
1461
+ await withTimeout(el.pressSequentially(text, pressOptions), actionTimeout, `pressSequentially("${query}")`);
900
1462
  return el;
901
1463
  },
902
1464
  async clear(query) {
903
1465
  const el = await resolve(query, "fill");
904
- await el.clear();
1466
+ await withTimeout(el.clear(), actionTimeout, `clear("${query}")`);
905
1467
  return el;
906
1468
  },
907
1469
  async press(query, key, pressOptions) {
908
1470
  const el = await resolve(query, "fill");
909
- await el.press(key, pressOptions);
1471
+ await withTimeout(el.press(key, pressOptions), actionTimeout, `press("${query}")`);
910
1472
  return el;
911
1473
  },
912
1474
  async check(query, checkOptions) {
913
1475
  const el = await resolve(query, "check");
914
- await el.check(checkOptions);
1476
+ await withTimeout(el.check(checkOptions), actionTimeout, `check("${query}")`);
915
1477
  return el;
916
1478
  },
917
1479
  async uncheck(query, uncheckOptions) {
918
1480
  const el = await resolve(query, "uncheck");
919
- await el.uncheck(uncheckOptions);
1481
+ await withTimeout(el.uncheck(uncheckOptions), actionTimeout, `uncheck("${query}")`);
920
1482
  return el;
921
1483
  },
922
1484
  async setChecked(query, checked, checkOptions) {
923
1485
  const el = await resolve(query, checked ? "check" : "uncheck");
924
- await el.setChecked(checked, checkOptions);
1486
+ await withTimeout(el.setChecked(checked, checkOptions), actionTimeout, `setChecked("${query}")`);
925
1487
  return el;
926
1488
  },
927
1489
  async select(query, values, selectOptions) {
928
1490
  const el = await resolve(query, "select");
929
- await el.selectOption(values, selectOptions);
1491
+ await withTimeout(el.selectOption(values, selectOptions), actionTimeout, `select("${query}")`);
930
1492
  return el;
931
1493
  },
932
1494
  async hover(query, hoverOptions) {
933
1495
  const el = await resolve(query, "hover");
934
- await el.hover(hoverOptions);
1496
+ await withTimeout(el.hover(hoverOptions), actionTimeout, `hover("${query}")`);
935
1497
  return el;
936
1498
  },
937
1499
  async focus(query) {
938
1500
  const el = await resolve(query, "click");
939
- await el.focus();
1501
+ await withTimeout(el.focus(), actionTimeout, `focus("${query}")`);
940
1502
  return el;
941
1503
  },
942
1504
  async tap(query, tapOptions) {
943
1505
  const el = await resolve(query, "click");
944
- await el.tap(tapOptions);
1506
+ await withTimeout(el.tap(tapOptions), actionTimeout, `tap("${query}")`);
945
1507
  return el;
946
1508
  },
947
1509
  async setInputFiles(query, files, fileOptions) {
948
1510
  const el = await resolve(query, "fill");
949
- await el.setInputFiles(files, fileOptions);
1511
+ await withTimeout(el.setInputFiles(files, fileOptions), actionTimeout, `setInputFiles("${query}")`);
950
1512
  return el;
951
1513
  },
952
1514
  async dragTo(srcQuery, destQuery, dragOptions) {
953
1515
  const src = await resolve(srcQuery, "click");
954
1516
  const dest = await resolve(destQuery, "click");
955
- await src.dragTo(dest, dragOptions);
1517
+ await withTimeout(src.dragTo(dest, dragOptions), actionTimeout, `dragTo("${srcQuery}")`);
956
1518
  return { source: src, target: dest };
957
1519
  },
958
1520
  async selectText(query) {
959
1521
  const el = await resolve(query, "click");
960
- await el.selectText();
1522
+ await withTimeout(el.selectText(), actionTimeout, `selectText("${query}")`);
961
1523
  return el;
962
1524
  },
963
1525
  async scrollIntoView(query) {
964
1526
  const el = await resolve(query, null);
965
- await el.scrollIntoViewIfNeeded();
1527
+ await withTimeout(el.scrollIntoViewIfNeeded(), actionTimeout, `scrollIntoView("${query}")`);
966
1528
  return el;
967
1529
  },
968
1530
  async screenshot(query, screenshotOptions) {
@@ -1049,8 +1611,11 @@ function createSmartLocator(page, options = {}) {
1049
1611
  },
1050
1612
  async exists(query) {
1051
1613
  try {
1052
- const el = await resolve(query, null);
1053
- return await el.exists();
1614
+ const result = await resolveLocator(page, query, { ...options, action: null });
1615
+ if (!result.found || result.confidence < 0.7) return false;
1616
+ const { createPlaywrightLocator: createPlaywrightLocator2 } = await Promise.resolve().then(() => (init_parser(), parser_exports));
1617
+ const locator = createPlaywrightLocator2(page, result.locatorString, result.isInFrame, result.frameSelector);
1618
+ return await locator.count() > 0;
1054
1619
  } catch {
1055
1620
  return false;
1056
1621
  }