@testnexus/locatai 1.7.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.mjs ADDED
@@ -0,0 +1,1145 @@
1
+ // src/locatai.ts
2
+ import path2 from "path";
3
+
4
+ // src/providers/openai.ts
5
+ import OpenAI from "openai";
6
+
7
+ // src/providers/types.ts
8
+ function cleanJson(raw) {
9
+ let s = raw.replace(/^```(?:json)?\s*/gm, "").replace(/```\s*$/gm, "").trim();
10
+ s = s.replace(/^(\s*)\/\/.*$/gm, "$1");
11
+ s = s.replace(/\/\*[\s\S]*?\*\//g, "");
12
+ s = s.replace(/,\s*([}\]])/g, "$1");
13
+ try {
14
+ JSON.parse(s);
15
+ return s;
16
+ } catch {
17
+ }
18
+ const jsonStart = s.search(/[{\[]/);
19
+ if (jsonStart >= 0) {
20
+ s = s.slice(jsonStart);
21
+ const openChar = s[0];
22
+ const closeChar = openChar === "{" ? "}" : "]";
23
+ let depth = 0;
24
+ let inString = false;
25
+ let escaped = false;
26
+ for (let i = 0; i < s.length; i++) {
27
+ const ch = s[i];
28
+ if (escaped) {
29
+ escaped = false;
30
+ continue;
31
+ }
32
+ if (ch === "\\") {
33
+ escaped = true;
34
+ continue;
35
+ }
36
+ if (ch === '"') {
37
+ inString = !inString;
38
+ continue;
39
+ }
40
+ if (inString) continue;
41
+ if (ch === openChar) depth++;
42
+ else if (ch === closeChar) {
43
+ depth--;
44
+ if (depth === 0) {
45
+ s = s.slice(0, i + 1);
46
+ break;
47
+ }
48
+ }
49
+ }
50
+ }
51
+ s = s.replace(/(?<="[^"]*?)\n(?=[^"]*?")/g, "\\n");
52
+ s = s.replace(/(?<="[^"]*?)\t(?=[^"]*?")/g, "\\t");
53
+ s = s.replace(/,\s*([}\]])/g, "$1");
54
+ return s.trim();
55
+ }
56
+ var DEFAULT_MODELS = {
57
+ openai: "gpt-5-nano",
58
+ gpt: "gpt-5-nano",
59
+ anthropic: "claude-sonnet-4-20250514",
60
+ claude: "claude-sonnet-4-20250514",
61
+ google: "gemini-2.5-flash",
62
+ gemini: "gemini-2.5-flash",
63
+ local: "gemma3:4b",
64
+ ollama: "gemma3:4b"
65
+ };
66
+
67
+ // src/types.ts
68
+ import { z } from "zod";
69
+ var Strategy = z.object({
70
+ type: z.enum(["testid", "role", "label", "placeholder", "text", "altText", "title", "css"]),
71
+ value: z.string().nullable().optional(),
72
+ selector: z.string().nullable().optional(),
73
+ role: z.string().nullable().optional(),
74
+ name: z.string().nullable().optional(),
75
+ text: z.string().nullable().optional(),
76
+ exact: z.boolean().nullable().optional()
77
+ });
78
+ var LocatAIPlan = z.object({
79
+ candidates: z.array(z.object({
80
+ strategy: Strategy,
81
+ confidence: z.number(),
82
+ why: z.string()
83
+ }))
84
+ });
85
+ var LocatAIError = class _LocatAIError extends Error {
86
+ constructor(message, context) {
87
+ const detailedMessage = _LocatAIError.formatMessage(message, context);
88
+ super(detailedMessage);
89
+ this.name = "LocatAIError";
90
+ this.context = context;
91
+ if (Error.captureStackTrace) {
92
+ Error.captureStackTrace(this, _LocatAIError);
93
+ }
94
+ }
95
+ static formatMessage(message, ctx) {
96
+ const lines = [
97
+ `
98
+ \u256D\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u256E`,
99
+ `\u2502 \u{1F50D} LOCATAI: Element Not Found \u2502`,
100
+ `\u2570\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u256F`,
101
+ ``,
102
+ ` \u274C ${message}`,
103
+ ``,
104
+ ` \u{1F4CB} Context:`,
105
+ ` \u2022 Action: ${ctx.action.toUpperCase()}`,
106
+ ` \u2022 Looking for: "${ctx.contextName}"`,
107
+ ` \u2022 Page URL: ${ctx.url}`,
108
+ ` \u2022 Candidates analyzed: ${ctx.candidatesAnalyzed}`
109
+ ];
110
+ if (ctx.originalError) {
111
+ lines.push(` \u2022 Original error: ${ctx.originalError}`);
112
+ }
113
+ if (ctx.strategiesTried.length > 0) {
114
+ lines.push(``, ` \u{1F52C} Strategies tried:`);
115
+ ctx.strategiesTried.forEach((s, i) => {
116
+ lines.push(` ${i + 1}. [${s.type}] \u2192 ${s.reason}`);
117
+ });
118
+ } else {
119
+ lines.push(``, ` \u26A0\uFE0F No strategies were returned by AI`);
120
+ }
121
+ lines.push(
122
+ ``,
123
+ ` \u{1F4A1} Tips:`,
124
+ ` \u2022 Make sure the element exists on the page`,
125
+ ` \u2022 Try a more specific description`,
126
+ ` \u2022 Check if the element is visible and not hidden`,
127
+ ``
128
+ );
129
+ return lines.join("\n");
130
+ }
131
+ };
132
+ var locatAIPlanJsonSchema = {
133
+ type: "object",
134
+ properties: {
135
+ candidates: {
136
+ type: "array",
137
+ items: {
138
+ type: "object",
139
+ properties: {
140
+ strategy: {
141
+ type: "object",
142
+ properties: {
143
+ type: { type: "string", enum: ["testid", "role", "label", "placeholder", "text", "altText", "title", "css"] },
144
+ value: { type: ["string", "null"] },
145
+ selector: { type: ["string", "null"] },
146
+ role: { type: ["string", "null"] },
147
+ name: { type: ["string", "null"] },
148
+ text: { type: ["string", "null"] },
149
+ exact: { type: ["boolean", "null"] }
150
+ },
151
+ required: ["type", "value", "selector", "role", "name", "text", "exact"],
152
+ additionalProperties: false
153
+ },
154
+ confidence: { type: "number" },
155
+ why: { type: "string" }
156
+ },
157
+ required: ["strategy", "confidence", "why"],
158
+ additionalProperties: false
159
+ },
160
+ maxItems: 3
161
+ }
162
+ },
163
+ required: ["candidates"],
164
+ additionalProperties: false
165
+ };
166
+
167
+ // src/logger.ts
168
+ var c = {
169
+ reset: "\x1B[0m",
170
+ bold: "\x1B[1m",
171
+ dim: "\x1B[2m",
172
+ italic: "\x1B[3m",
173
+ gray: "\x1B[90m",
174
+ red: "\x1B[91m",
175
+ green: "\x1B[92m",
176
+ yellow: "\x1B[93m",
177
+ blue: "\x1B[94m",
178
+ purple: "\x1B[95m",
179
+ cyan: "\x1B[96m",
180
+ white: "\x1B[97m",
181
+ bgPurple: "\x1B[48;5;99m",
182
+ bgGreen: "\x1B[48;5;28m",
183
+ bgRed: "\x1B[48;5;124m",
184
+ bgBlue: "\x1B[48;5;24m"
185
+ };
186
+ function formatStrategy(s) {
187
+ switch (s.type) {
188
+ case "testid":
189
+ return `getByTestId("${s.value}")`;
190
+ case "role":
191
+ return `getByRole("${s.role}"${s.name ? `, { name: "${s.name}" }` : ""})`;
192
+ case "label":
193
+ return `getByLabel("${s.text}")`;
194
+ case "placeholder":
195
+ return `getByPlaceholder("${s.text}")`;
196
+ case "text":
197
+ return `getByText("${s.text}")`;
198
+ case "css":
199
+ return `locator("${s.selector}")`;
200
+ default:
201
+ return JSON.stringify(s);
202
+ }
203
+ }
204
+ var locataiLog = {
205
+ banner: () => {
206
+ console.log();
207
+ console.log(`${c.bgPurple}${c.bold}${c.white} \u2726 LocatAI ${c.reset}`);
208
+ },
209
+ actionFailed: (action, contextName) => {
210
+ console.log(`${c.gray} \u2502${c.reset}`);
211
+ console.log(`${c.gray} \u251C\u2500${c.reset} ${c.yellow}\u26A1${c.reset} ${c.dim}${action.toUpperCase()}${c.reset} ${c.white}${contextName}${c.reset}`);
212
+ },
213
+ askingAI: (_contextName, candidateCount, totalCollected) => {
214
+ const filtered = totalCollected && totalCollected > candidateCount ? ` ${c.dim}(filtered from ${totalCollected})${c.reset}` : "";
215
+ console.log(`${c.gray} \u2502 ${c.purple}\u2B21${c.reset} ${c.dim}analyzing ${candidateCount} elements...${c.reset}${filtered}`);
216
+ },
217
+ aiResponse: (length) => {
218
+ console.log(`${c.gray} \u2502 ${c.dim}\u21B3 received ${length} chars${c.reset}`);
219
+ },
220
+ candidateRejected: (type, reason) => {
221
+ console.log(`${c.gray} \u2502 ${c.dim}\u21B3 [${type}] skipped: ${reason}${c.reset}`);
222
+ },
223
+ candidateError: (type, error) => {
224
+ console.log(`${c.gray} \u2502 ${c.red}\u21B3 [${type}] error: ${error}${c.reset}`);
225
+ },
226
+ healed: (contextName, strategy) => {
227
+ const strategyStr = formatStrategy(strategy);
228
+ console.log(`${c.gray} \u2502${c.reset}`);
229
+ console.log(`${c.gray} \u251C\u2500${c.reset} ${c.green}\u2713${c.reset} ${c.bold}${c.white}${contextName}${c.reset}`);
230
+ console.log(`${c.gray} \u2502 ${c.dim}\u2192 ${strategyStr}${c.reset}`);
231
+ },
232
+ tokenUsage: (input, output, total) => {
233
+ console.log(`${c.gray} \u2502 ${c.dim}\u2191 ${c.cyan}${input}${c.dim} input \xB7 ${c.cyan}${output}${c.dim} output \xB7 ${c.bold}${c.cyan}${total}${c.dim} total tokens${c.reset}`);
234
+ console.log(`${c.gray} \u2514${"\u2500".repeat(49)}${c.reset}`);
235
+ console.log();
236
+ },
237
+ usedCache: (contextName) => {
238
+ console.log(`${c.gray} \u2502 ${c.cyan}\u25C6${c.reset} ${c.dim}cached: ${contextName}${c.reset}`);
239
+ console.log(`${c.gray} \u2514${"\u2500".repeat(49)}${c.reset}`);
240
+ console.log();
241
+ },
242
+ cacheMiss: (contextName) => {
243
+ console.log(`${c.gray} \u2502 ${c.yellow}\u25CB${c.reset} ${c.dim}cache stale for "${contextName}", re-healing...${c.reset}`);
244
+ },
245
+ healFailed: (contextName, error) => {
246
+ console.log(`${c.gray} \u2502${c.reset}`);
247
+ console.log(`${c.gray} \u251C\u2500${c.reset} ${c.red}\u2715${c.reset} ${c.red}${contextName}${c.reset}`);
248
+ console.log(`${c.gray} \u2502 ${c.dim}${error}${c.reset}`);
249
+ console.log(`${c.gray} \u2514${"\u2500".repeat(49)}${c.reset}`);
250
+ console.log();
251
+ },
252
+ aiDetectMode: (_action, contextName) => {
253
+ console.log(`${c.gray} \u2502${c.reset}`);
254
+ console.log(`${c.gray} \u251C\u2500${c.reset} ${c.purple}\u25C8${c.reset} ${c.dim}AI DETECT${c.reset} ${c.white}${contextName}${c.reset}`);
255
+ },
256
+ noValidCandidate: (contextName) => {
257
+ console.log(`${c.gray} \u2502 ${c.red}\u21B3 no valid candidate found for "${contextName}"${c.reset}`);
258
+ },
259
+ warn: (message) => {
260
+ console.log(`${c.gray} \u2502${c.reset}`);
261
+ console.log(`${c.gray} \u251C\u2500${c.reset} ${c.yellow}\u26A0${c.reset} ${c.yellow}${message}${c.reset}`);
262
+ console.log(`${c.gray} \u2514${"\u2500".repeat(49)}${c.reset}`);
263
+ },
264
+ aiDisabled: () => {
265
+ console.log(`${c.yellow}\u26A0 Set SELF_HEAL=1 or AI_SELF_HEAL=true with AI_API_KEY to enable AI detection${c.reset}`);
266
+ },
267
+ nativeFallback: (action, contextName) => {
268
+ console.log(`${c.dim} \u21B3 AI healing disabled \u2014 running native Playwright ${action} for "${contextName}"${c.reset}`);
269
+ }
270
+ };
271
+
272
+ // src/providers/openai.ts
273
+ var OpenAIProvider = class {
274
+ constructor(config) {
275
+ this.name = "openai";
276
+ this.client = new OpenAI({ apiKey: config.apiKey });
277
+ this.model = config.model ?? DEFAULT_MODELS.openai;
278
+ }
279
+ async generateLocatAIPlan(input) {
280
+ try {
281
+ const resp = await this.client.responses.create({
282
+ model: this.model,
283
+ input: [
284
+ { role: "system", content: input.systemPrompt },
285
+ { role: "user", content: input.userContent }
286
+ ],
287
+ text: {
288
+ format: {
289
+ type: "json_schema",
290
+ name: "LocatAIPlan",
291
+ strict: true,
292
+ schema: input.jsonSchema
293
+ }
294
+ },
295
+ store: false,
296
+ reasoning: { "effort": "low" }
297
+ });
298
+ const content = resp.output_text;
299
+ locataiLog.aiResponse(content?.length ?? 0);
300
+ const usage = resp.usage;
301
+ const tokenUsage = usage ? {
302
+ inputTokens: usage.input_tokens ?? usage.prompt_tokens ?? 0,
303
+ outputTokens: usage.output_tokens ?? usage.completion_tokens ?? 0,
304
+ totalTokens: (usage.input_tokens ?? usage.prompt_tokens ?? 0) + (usage.output_tokens ?? usage.completion_tokens ?? 0)
305
+ } : null;
306
+ if (!content) return { plan: null, tokenUsage };
307
+ try {
308
+ return { plan: LocatAIPlan.parse(JSON.parse(cleanJson(content))), tokenUsage };
309
+ } catch (parseErr) {
310
+ locataiLog.candidateError("parse", `Failed to parse AI response: ${parseErr?.message ?? ""}`);
311
+ return { plan: null, tokenUsage };
312
+ }
313
+ } catch (aiErr) {
314
+ locataiLog.candidateError("api", aiErr?.message ?? String(aiErr));
315
+ throw aiErr;
316
+ }
317
+ }
318
+ };
319
+
320
+ // src/providers/anthropic.ts
321
+ import Anthropic from "@anthropic-ai/sdk";
322
+ var AnthropicProvider = class {
323
+ constructor(config) {
324
+ this.name = "anthropic";
325
+ this.client = new Anthropic({ apiKey: config.apiKey });
326
+ this.model = config.model ?? DEFAULT_MODELS.anthropic;
327
+ }
328
+ async generateLocatAIPlan(input) {
329
+ try {
330
+ const resp = await this.client.messages.create({
331
+ model: this.model,
332
+ max_tokens: 4096,
333
+ system: input.systemPrompt,
334
+ messages: [
335
+ { role: "user", content: input.userContent }
336
+ ]
337
+ }, {
338
+ headers: {
339
+ "anthropic-beta": "structured-outputs-2025-11-13"
340
+ }
341
+ });
342
+ const content = resp.structured_output ?? resp.content?.[0]?.text;
343
+ locataiLog.aiResponse(typeof content === "string" ? content.length : JSON.stringify(content).length);
344
+ const usage = resp.usage;
345
+ const tokenUsage = usage ? {
346
+ inputTokens: usage.input_tokens ?? 0,
347
+ outputTokens: usage.output_tokens ?? 0,
348
+ totalTokens: (usage.input_tokens ?? 0) + (usage.output_tokens ?? 0)
349
+ } : null;
350
+ if (!content) return { plan: null, tokenUsage };
351
+ try {
352
+ const raw = typeof content === "string" ? content : JSON.stringify(content);
353
+ return { plan: LocatAIPlan.parse(JSON.parse(cleanJson(raw))), tokenUsage };
354
+ } catch (parseErr) {
355
+ locataiLog.candidateError("parse", `Failed to parse AI response: ${parseErr?.message ?? ""}`);
356
+ return { plan: null, tokenUsage };
357
+ }
358
+ } catch (aiErr) {
359
+ locataiLog.candidateError("api", aiErr?.message ?? String(aiErr));
360
+ throw aiErr;
361
+ }
362
+ }
363
+ };
364
+
365
+ // src/providers/google.ts
366
+ import { GoogleGenAI } from "@google/genai";
367
+ var GoogleProvider = class {
368
+ constructor(config) {
369
+ this.name = "google";
370
+ this.ai = new GoogleGenAI({ apiKey: config.apiKey });
371
+ this.model = config.model ?? DEFAULT_MODELS.google;
372
+ }
373
+ async generateLocatAIPlan(input) {
374
+ try {
375
+ const combinedPrompt = `${input.systemPrompt}
376
+
377
+ ---
378
+
379
+ ${input.userContent}
380
+
381
+ Respond with valid JSON matching this schema:
382
+ ${JSON.stringify(input.jsonSchema, null, 2)}`;
383
+ const response = await this.ai.models.generateContent({
384
+ model: this.model,
385
+ contents: combinedPrompt,
386
+ config: {
387
+ responseMimeType: "application/json"
388
+ }
389
+ });
390
+ const content = response.text;
391
+ locataiLog.aiResponse(content?.length ?? 0);
392
+ const usage = response.usageMetadata;
393
+ const tokenUsage = usage ? {
394
+ inputTokens: usage.promptTokenCount ?? 0,
395
+ outputTokens: usage.candidatesTokenCount ?? 0,
396
+ totalTokens: (usage.promptTokenCount ?? 0) + (usage.candidatesTokenCount ?? 0)
397
+ } : null;
398
+ if (!content) return { plan: null, tokenUsage };
399
+ try {
400
+ return { plan: LocatAIPlan.parse(JSON.parse(cleanJson(content))), tokenUsage };
401
+ } catch (parseErr) {
402
+ locataiLog.candidateError("parse", `Failed to parse AI response: ${parseErr?.message ?? ""}`);
403
+ return { plan: null, tokenUsage };
404
+ }
405
+ } catch (aiErr) {
406
+ locataiLog.candidateError("api", aiErr?.message ?? String(aiErr));
407
+ throw aiErr;
408
+ }
409
+ }
410
+ };
411
+
412
+ // src/providers/local.ts
413
+ import { Ollama } from "ollama";
414
+ function normalizeResponse(raw) {
415
+ if (!raw || typeof raw !== "object") return raw;
416
+ if (Array.isArray(raw.candidates) && raw.candidates.length > 0 && raw.candidates[0].strategy) {
417
+ return raw;
418
+ }
419
+ if (Array.isArray(raw.candidates)) {
420
+ raw.candidates = raw.candidates.map((c2) => {
421
+ if (c2.strategy) return c2;
422
+ const strategyFields = ["type", "value", "selector", "role", "name", "text", "exact"];
423
+ const strategy = {};
424
+ const rest = {};
425
+ for (const [k, v] of Object.entries(c2)) {
426
+ if (strategyFields.includes(k)) strategy[k] = v;
427
+ else rest[k] = v;
428
+ }
429
+ if (strategy.type) {
430
+ return { strategy, ...rest };
431
+ }
432
+ return c2;
433
+ });
434
+ return raw;
435
+ }
436
+ if (raw.type && (raw.confidence !== void 0 || raw.why)) {
437
+ const strategyFields = ["type", "value", "selector", "role", "name", "text", "exact"];
438
+ const strategy = {};
439
+ const rest = {};
440
+ for (const [k, v] of Object.entries(raw)) {
441
+ if (strategyFields.includes(k)) strategy[k] = v;
442
+ else rest[k] = v;
443
+ }
444
+ return { candidates: [{ strategy, ...rest }] };
445
+ }
446
+ for (const wrapper of ["result", "response", "data", "output"]) {
447
+ if (raw[wrapper]) return normalizeResponse(raw[wrapper]);
448
+ }
449
+ return raw;
450
+ }
451
+ var LocalProvider = class {
452
+ constructor(config) {
453
+ this.name = "local";
454
+ const host = config.apiKey || process.env.OLLAMA_HOST || "http://127.0.0.1:11434";
455
+ this.client = new Ollama({ host });
456
+ this.model = config.model ?? DEFAULT_MODELS.local;
457
+ }
458
+ async generateLocatAIPlan(input) {
459
+ try {
460
+ const formatSchema = input.jsonSchema ?? "json";
461
+ const resp = await this.client.chat({
462
+ model: this.model,
463
+ messages: [
464
+ { role: "system", content: input.systemPrompt },
465
+ { role: "user", content: input.userContent }
466
+ ],
467
+ format: formatSchema,
468
+ stream: false,
469
+ think: false,
470
+ options: { temperature: 0 }
471
+ });
472
+ const content = resp.message?.content;
473
+ locataiLog.aiResponse(content?.length ?? 0);
474
+ const tokenUsage = resp.prompt_eval_count != null ? {
475
+ inputTokens: resp.prompt_eval_count ?? 0,
476
+ outputTokens: resp.eval_count ?? 0,
477
+ totalTokens: (resp.prompt_eval_count ?? 0) + (resp.eval_count ?? 0)
478
+ } : null;
479
+ if (!content) return { plan: null, tokenUsage };
480
+ try {
481
+ const parsed = JSON.parse(cleanJson(content));
482
+ const normalized = normalizeResponse(parsed);
483
+ return { plan: LocatAIPlan.parse(normalized), tokenUsage };
484
+ } catch (parseErr) {
485
+ locataiLog.candidateError("parse", `Failed to parse AI response: ${parseErr?.message ?? ""}`);
486
+ return { plan: null, tokenUsage };
487
+ }
488
+ } catch (aiErr) {
489
+ locataiLog.candidateError("api", aiErr?.message ?? String(aiErr));
490
+ throw aiErr;
491
+ }
492
+ }
493
+ };
494
+
495
+ // src/providers/index.ts
496
+ function createAIProvider(providerName, config) {
497
+ switch (providerName) {
498
+ case "openai":
499
+ case "gpt":
500
+ return new OpenAIProvider(config);
501
+ case "anthropic":
502
+ case "claude":
503
+ return new AnthropicProvider(config);
504
+ case "google":
505
+ case "gemini":
506
+ return new GoogleProvider(config);
507
+ case "local":
508
+ case "ollama":
509
+ return new LocalProvider(config);
510
+ default:
511
+ throw new Error(`Unknown AI provider: ${providerName}. Supported: openai/gpt, anthropic/claude, google/gemini, local/ollama`);
512
+ }
513
+ }
514
+
515
+ // src/utils.ts
516
+ import fs from "fs/promises";
517
+ import path from "path";
518
+ function isValidLocator(target) {
519
+ return typeof target !== "string" || target !== "";
520
+ }
521
+ function buildLocator(page, s) {
522
+ switch (s.type) {
523
+ case "testid": {
524
+ if (!s.value) throw new Error("testid strategy requires 'value'");
525
+ const v = s.value.replace(/"/g, '\\"');
526
+ return page.locator(
527
+ `[data-testid="${v}"],[data-test="${v}"],[data-test-id="${v}"],[data-qa="${v}"],[data-cy="${v}"]`
528
+ );
529
+ }
530
+ case "role":
531
+ if (!s.role) throw new Error("role strategy requires 'role'");
532
+ return page.getByRole(s.role, { name: s.name ?? void 0, exact: s.exact ?? true });
533
+ case "label":
534
+ if (!s.text) throw new Error("label strategy requires 'text'");
535
+ return page.getByLabel(s.text, { exact: s.exact ?? void 0 });
536
+ case "placeholder":
537
+ if (!s.text) throw new Error("placeholder strategy requires 'text'");
538
+ return page.getByPlaceholder(s.text, { exact: s.exact ?? void 0 });
539
+ case "text":
540
+ if (!s.text) throw new Error("text strategy requires 'text'");
541
+ return page.getByText(s.text, { exact: s.exact ?? void 0 });
542
+ case "altText":
543
+ if (!s.text) throw new Error("altText strategy requires 'text'");
544
+ return page.getByAltText(s.text, { exact: s.exact ?? void 0 });
545
+ case "title":
546
+ if (!s.text) throw new Error("title strategy requires 'text'");
547
+ return page.getByTitle(s.text, { exact: s.exact ?? void 0 });
548
+ case "css":
549
+ if (!s.selector) throw new Error("css strategy requires 'selector'");
550
+ return page.locator(s.selector);
551
+ }
552
+ throw new Error(`Unknown strategy type: ${s.type}`);
553
+ }
554
+ function validateStrategy(s) {
555
+ switch (s.type) {
556
+ case "testid":
557
+ if (!s.value) return "missing 'value'";
558
+ break;
559
+ case "role":
560
+ if (!s.role) return "missing 'role'";
561
+ break;
562
+ case "label":
563
+ case "placeholder":
564
+ case "text":
565
+ case "altText":
566
+ case "title":
567
+ if (!s.text) return "missing 'text'";
568
+ break;
569
+ case "css":
570
+ if (!s.selector) return "missing 'selector'";
571
+ break;
572
+ }
573
+ return null;
574
+ }
575
+ async function waitForReady(loc, _action, timeout) {
576
+ await loc.waitFor({ state: "visible", timeout });
577
+ }
578
+ async function waitForStable(page) {
579
+ await page.waitForLoadState("domcontentloaded");
580
+ }
581
+ async function collectCandidates(page, action, limit = 80) {
582
+ const selector = action === "click" || action === "dblclick" || action === "hover" ? [
583
+ // Native interactive elements
584
+ "button",
585
+ "a",
586
+ "label",
587
+ "summary",
588
+ "img",
589
+ // Input types that are clickable
590
+ "input[type='button']",
591
+ "input[type='submit']",
592
+ "input[type='reset']",
593
+ "input[type='checkbox']",
594
+ "input[type='radio']",
595
+ "input[type='image']",
596
+ "input[type='file']",
597
+ // Dropdowns and list items
598
+ "select",
599
+ "option",
600
+ "li",
601
+ // ARIA widget roles
602
+ "[role='button']",
603
+ "[role='link']",
604
+ "[role='menuitem']",
605
+ "[role='menuitemcheckbox']",
606
+ "[role='menuitemradio']",
607
+ "[role='tab']",
608
+ "[role='switch']",
609
+ "[role='option']",
610
+ "[role='checkbox']",
611
+ "[role='radio']",
612
+ "[role='combobox']",
613
+ "[role='slider']",
614
+ "[role='spinbutton']",
615
+ // ARIA structural roles that are often clickable
616
+ "[role='treeitem']",
617
+ "[role='gridcell']",
618
+ "[role='row']",
619
+ // Event-based and focusable
620
+ "[onclick]",
621
+ "[ondblclick]",
622
+ "[onmouseenter]",
623
+ "[onmouseover]",
624
+ "[tabindex]:not([tabindex='-1'])",
625
+ // Test-targeted elements
626
+ "[data-testid]",
627
+ "[data-test]",
628
+ "[data-test-id]",
629
+ "[data-qa]",
630
+ "[data-cy]"
631
+ ].join(",") : action === "selectOption" ? "select,[role='combobox'],[role='listbox'],option,[role='option'],[data-testid]" : "input,textarea,[contenteditable='true'],[role='textbox'],select,[role='combobox'],[data-testid]";
632
+ return page.evaluate(({ selector: selector2, limit: limit2 }) => {
633
+ const els = Array.from(document.querySelectorAll(selector2)).slice(0, limit2);
634
+ const pickTest = (el) => {
635
+ for (const a of ["data-testid", "data-test", "data-test-id", "data-qa", "data-cy"]) {
636
+ const v = el.getAttribute(a);
637
+ if (v) return v;
638
+ }
639
+ return null;
640
+ };
641
+ const isVisible = (el) => {
642
+ const e = el;
643
+ const s = window.getComputedStyle(e);
644
+ if (s.display === "none" || s.visibility === "hidden") return false;
645
+ const r = e.getBoundingClientRect();
646
+ return r.width > 0 && r.height > 0;
647
+ };
648
+ const norm = (t) => t.replace(/\s+/g, " ").trim().slice(0, 60);
649
+ const result = [];
650
+ for (const el of els) {
651
+ const vis = isVisible(el);
652
+ const o = { tag: el.tagName.toLowerCase() };
653
+ if (!vis) o.hid = true;
654
+ const role = el.getAttribute("role");
655
+ if (role) o.role = role;
656
+ const aria = el.getAttribute("aria-label");
657
+ if (aria) o.aria = aria;
658
+ const name = el.getAttribute("name");
659
+ if (name) o.name = name;
660
+ const ph = el.getAttribute("placeholder");
661
+ if (ph) o.ph = ph;
662
+ const type = el.getAttribute("type");
663
+ if (type) o.type = type;
664
+ const href = el.getAttribute("href");
665
+ if (href) o.href = href;
666
+ const alt = el.getAttribute("alt");
667
+ if (alt) o.alt = alt;
668
+ const title = el.getAttribute("title");
669
+ if (title) o.title = title;
670
+ const forAttr = el.getAttribute("for");
671
+ if (forAttr) o.for = forAttr;
672
+ const id = el.id;
673
+ if (id) o.id = id;
674
+ const cls = el.className;
675
+ if (cls && typeof cls === "string") o.cls = cls.slice(0, 60);
676
+ const tid = pickTest(el);
677
+ if (tid) o.tid = tid;
678
+ const text = norm(el.innerText || el.textContent || "");
679
+ if (text) o.txt = text;
680
+ result.push(o);
681
+ }
682
+ return result;
683
+ }, { selector, limit });
684
+ }
685
+ function rankCandidates(candidates, contextName, limit) {
686
+ if (candidates.length <= limit) return candidates;
687
+ const ctx = contextName.toLowerCase();
688
+ const stopWords = /* @__PURE__ */ new Set(["the", "for", "and", "with", "that", "this", "from", "into", "field", "element"]);
689
+ const ctxWords = ctx.split(/\W+/).filter((w) => w.length >= 3 && !stopWords.has(w));
690
+ const inferTag = (c2) => {
691
+ if (/\b(button|btn|submit|reset|click)\b/i.test(c2)) return ["button", "input"];
692
+ if (/\b(link|anchor|nav)\b/i.test(c2)) return ["a"];
693
+ if (/\b(input|text|email|password|search|phone|tel|url|number|name)\b/i.test(c2)) return ["input", "textarea"];
694
+ if (/\b(checkbox|check|agree|subscribe|toggle)\b/i.test(c2)) return ["input", "label"];
695
+ if (/\b(radio)\b/i.test(c2)) return ["input"];
696
+ if (/\b(select|dropdown|combo)\b/i.test(c2)) return ["select"];
697
+ if (/\b(textarea|comment)\b/i.test(c2)) return ["textarea"];
698
+ if (/\b(image|img|icon|logo)\b/i.test(c2)) return ["img"];
699
+ if (/\b(label)\b/i.test(c2)) return ["label"];
700
+ if (/\b(list item|li)\b/i.test(c2)) return ["li"];
701
+ return [];
702
+ };
703
+ const expectedTags = inferTag(contextName);
704
+ const textFields = (cand) => {
705
+ const fields = [];
706
+ for (const key of ["aria", "txt", "ph", "name", "tid", "alt", "title", "id", "for", "role"]) {
707
+ const v = cand[key];
708
+ if (typeof v === "string" && v) fields.push(v.toLowerCase());
709
+ }
710
+ return fields;
711
+ };
712
+ const scored = candidates.map((cand, idx) => {
713
+ let score = 0;
714
+ const texts = textFields(cand);
715
+ const allText = texts.join(" ");
716
+ if (texts.some((t) => t.includes(ctx))) score += 15;
717
+ for (const t of texts) {
718
+ if (ctx.includes(t) && t.length >= 3) {
719
+ score += 8;
720
+ break;
721
+ }
722
+ }
723
+ for (const word of ctxWords) {
724
+ if (allText.includes(word)) score += 3;
725
+ }
726
+ const tag = String(cand.tag ?? "").toLowerCase();
727
+ if (expectedTags.length > 0 && expectedTags.includes(tag)) score += 5;
728
+ const role = String(cand.role ?? "").toLowerCase();
729
+ if (role && ctx.includes(role)) score += 5;
730
+ if (cand.tid) score += 2;
731
+ if (!cand.hid) score += 1;
732
+ return { cand, score, idx };
733
+ });
734
+ scored.sort((a, b) => b.score - a.score || a.idx - b.idx);
735
+ return scored.slice(0, limit).map((s) => s.cand);
736
+ }
737
+ async function readJson(file, fallback) {
738
+ try {
739
+ return JSON.parse(await fs.readFile(file, "utf8"));
740
+ } catch {
741
+ return fallback;
742
+ }
743
+ }
744
+ async function writeAtomic(file, data) {
745
+ const dir = path.dirname(file);
746
+ try {
747
+ await fs.mkdir(dir, { recursive: true });
748
+ } catch {
749
+ }
750
+ const tmp = file + ".tmp";
751
+ await fs.writeFile(tmp, JSON.stringify(data, null, 2), "utf8");
752
+ await fs.rename(tmp, file);
753
+ }
754
+ async function appendLog(reportFile, entry) {
755
+ const dir = path.dirname(reportFile);
756
+ try {
757
+ await fs.mkdir(dir, { recursive: true });
758
+ } catch {
759
+ }
760
+ await fs.appendFile(reportFile, JSON.stringify(entry) + "\n", "utf8").catch(() => {
761
+ });
762
+ }
763
+ function cacheKey(page, action, contextName) {
764
+ const u = new URL(page.url());
765
+ return `${action}::${u.origin}${u.pathname}::${contextName}`;
766
+ }
767
+ function isRetryableError(err) {
768
+ const error = err;
769
+ const status = error?.status ?? error?.statusCode ?? error?.response?.status;
770
+ if (status === 429 || status !== void 0 && status >= 500) return true;
771
+ const code = error?.code;
772
+ if (code === "ECONNRESET" || code === "ETIMEDOUT" || code === "ENOTFOUND") return true;
773
+ const msg = String(error?.message ?? "").toLowerCase();
774
+ return msg.includes("rate limit") || msg.includes("timeout") || msg.includes("overloaded");
775
+ }
776
+ async function withRetry(fn, maxRetries = 1, baseDelayMs = 1e3) {
777
+ let lastError;
778
+ for (let attempt = 0; attempt <= maxRetries; attempt++) {
779
+ try {
780
+ return await fn();
781
+ } catch (err) {
782
+ lastError = err;
783
+ if (attempt < maxRetries && isRetryableError(err)) {
784
+ const delay = baseDelayMs * Math.pow(2, attempt);
785
+ await new Promise((resolve) => setTimeout(resolve, delay));
786
+ } else {
787
+ throw err;
788
+ }
789
+ }
790
+ }
791
+ throw lastError;
792
+ }
793
+
794
+ // src/locatai.ts
795
+ var LOCATAI_SYMBOL = /* @__PURE__ */ Symbol.for("locatai");
796
+ function withLocatAI(page, opts) {
797
+ if (page[LOCATAI_SYMBOL]) {
798
+ return page;
799
+ }
800
+ const enabled = opts?.enabled ?? (process.env.SELF_HEAL === "1" || process.env.AI_SELF_HEAL === "true");
801
+ const apiKey = opts?.apiKey ?? process.env.AI_API_KEY;
802
+ const providerName = opts?.provider ?? process.env.AI_PROVIDER?.toLowerCase() ?? "openai";
803
+ const model = opts?.model ?? process.env.AI_MODEL;
804
+ const isLocalProvider = providerName === "local" || providerName === "ollama";
805
+ let aiProvider = null;
806
+ if (enabled && (apiKey || isLocalProvider)) {
807
+ aiProvider = createAIProvider(providerName, {
808
+ apiKey: isLocalProvider ? process.env.OLLAMA_HOST ?? "" : apiKey ?? "",
809
+ model
810
+ });
811
+ }
812
+ const cacheFile = opts?.cacheFile ?? path2.join(process.cwd(), ".self-heal", "healed_locators.json");
813
+ const reportFile = opts?.reportFile ?? path2.join(process.cwd(), ".self-heal", "heal_events.jsonl");
814
+ const maxAiTries = opts?.maxAiTries ?? 4;
815
+ const maxCandidates = opts?.maxCandidates ?? 80;
816
+ const timeout = opts?.timeout ?? 5e3;
817
+ const quickTimeout = enabled ? 1e3 : timeout;
818
+ let currentTestName = opts?.testName;
819
+ let bannerShownForTest;
820
+ const mem = /* @__PURE__ */ new Map();
821
+ let diskCacheLoaded = false;
822
+ let diskCache = {};
823
+ async function ensureDiskCacheLoaded() {
824
+ if (!diskCacheLoaded) {
825
+ diskCache = await readJson(cacheFile, {});
826
+ diskCacheLoaded = true;
827
+ }
828
+ return diskCache;
829
+ }
830
+ function showBannerOnce() {
831
+ if (bannerShownForTest !== currentTestName) {
832
+ locataiLog.banner();
833
+ bannerShownForTest = currentTestName;
834
+ }
835
+ }
836
+ async function log(entry) {
837
+ await appendLog(reportFile, entry);
838
+ }
839
+ async function askAI(action, contextName) {
840
+ if (!aiProvider) throw new Error("Healing disabled or API key not set");
841
+ const allCandidates = await collectCandidates(page, action, maxCandidates);
842
+ const candidates = rankCandidates(allCandidates, contextName, Math.min(maxCandidates, 40));
843
+ locataiLog.askingAI(contextName, candidates.length, allCandidates.length);
844
+ const systemPrompt = [
845
+ "You are a Playwright locator expert. Given a list of candidate elements from the page, identify the one matching contextName.",
846
+ "Return up to 3 strategy alternatives. Prefer: testid > role+name > label > placeholder > text > altText > title > css.",
847
+ "Candidate keys: tag=tagName, tid=data-testid, aria=aria-label, ph=placeholder, txt=innerText, alt=alt, title=title, for=htmlFor, cls=className, hid=hidden. id/name/role/type/href as named.",
848
+ "Strategy types: testid(value=testid), role(role+name, exact optional), label(text), placeholder(text), text(text, exact optional), altText(text), title(text), css(selector). No XPath.",
849
+ "IMPORTANT: For label/placeholder/text/altText/title strategies, the 'text' field is REQUIRED and must be the actual text/label/placeholder value.",
850
+ "For testid strategy, the 'value' field is REQUIRED. For css, the 'selector' field is REQUIRED. For role, the 'role' field is REQUIRED.",
851
+ "Elements with hid:true may be CSS-hidden inputs (opacity:0) or offscreen \u2014 they are still valid targets if they match contextName."
852
+ ].join("\n");
853
+ const userContent = JSON.stringify({ url: page.url(), action, contextName, candidates });
854
+ const result = await withRetry(
855
+ () => aiProvider.generateLocatAIPlan({
856
+ systemPrompt,
857
+ userContent,
858
+ jsonSchema: locatAIPlanJsonSchema
859
+ }),
860
+ 2,
861
+ // 2 retries (3 total attempts)
862
+ 1e3
863
+ );
864
+ return { plan: result.plan, candidatesAnalyzed: candidates.length, tokenUsage: result.tokenUsage };
865
+ }
866
+ async function pickValid(plan, options) {
867
+ const strategiesTried = [];
868
+ const skipVisibilityCheck = options?.skipVisibilityCheck ?? false;
869
+ for (const c2 of plan.candidates.slice(0, maxAiTries)) {
870
+ const validationError = validateStrategy(c2.strategy);
871
+ if (validationError) {
872
+ locataiLog.candidateRejected(c2.strategy.type, validationError);
873
+ strategiesTried.push({ type: c2.strategy.type, reason: validationError });
874
+ continue;
875
+ }
876
+ try {
877
+ const loc = buildLocator(page, c2.strategy);
878
+ const count = await loc.count();
879
+ if (count !== 1) {
880
+ const reason = count === 0 ? "element not found" : `matched ${count} elements (must be unique)`;
881
+ locataiLog.candidateRejected(c2.strategy.type, `count=${count}`);
882
+ strategiesTried.push({ type: c2.strategy.type, reason });
883
+ continue;
884
+ }
885
+ if (!skipVisibilityCheck) {
886
+ const visible = await loc.first().isVisible();
887
+ if (!visible) {
888
+ locataiLog.candidateRejected(c2.strategy.type, "not visible");
889
+ strategiesTried.push({ type: c2.strategy.type, reason: "element exists but not visible" });
890
+ continue;
891
+ }
892
+ }
893
+ return { choice: c2, strategiesTried };
894
+ } catch (err) {
895
+ const reason = err?.message ?? "unknown error";
896
+ locataiLog.candidateError(c2.strategy.type, reason);
897
+ strategiesTried.push({ type: c2.strategy.type, reason: `error: ${reason}` });
898
+ continue;
899
+ }
900
+ }
901
+ return { choice: null, strategiesTried };
902
+ }
903
+ async function saveToCache(key, strategy, contextName) {
904
+ mem.set(key, strategy);
905
+ const cache = await ensureDiskCacheLoaded();
906
+ cache[key] = { ...strategy, context: contextName, testName: currentTestName };
907
+ await writeAtomic(cacheFile, cache);
908
+ }
909
+ async function locataiAction(action, target, contextName, performAction, options) {
910
+ const ts = (/* @__PURE__ */ new Date()).toISOString();
911
+ const key = cacheKey(page, action, contextName);
912
+ const aiOnlyMode = !isValidLocator(target);
913
+ let originalError = null;
914
+ if (!aiOnlyMode) {
915
+ try {
916
+ await waitForReady(target, action, quickTimeout);
917
+ await performAction(target);
918
+ return;
919
+ } catch (err) {
920
+ if (!enabled) throw err;
921
+ originalError = err;
922
+ }
923
+ }
924
+ if (!enabled && aiOnlyMode) {
925
+ locataiLog.nativeFallback(action, contextName);
926
+ const selector = typeof target === "string" ? target : "";
927
+ const nativeLoc = page.locator(selector);
928
+ await performAction(nativeLoc);
929
+ return;
930
+ }
931
+ showBannerOnce();
932
+ if (aiOnlyMode) {
933
+ locataiLog.aiDetectMode(action, contextName);
934
+ } else {
935
+ locataiLog.actionFailed(action, contextName);
936
+ }
937
+ const cache = await ensureDiskCacheLoaded();
938
+ const cached = mem.get(key) ?? cache[key];
939
+ if (cached) {
940
+ try {
941
+ const cachedLoc = buildLocator(page, cached);
942
+ await waitForReady(cachedLoc, action, timeout);
943
+ await performAction(cachedLoc);
944
+ if (action === "click" || action === "dblclick" || action === "selectOption") await waitForStable(page);
945
+ await log({ ts, url: page.url(), key, action, contextName, used: "cache", success: true, strategy: cached });
946
+ locataiLog.usedCache(contextName);
947
+ return;
948
+ } catch {
949
+ locataiLog.cacheMiss(contextName);
950
+ }
951
+ }
952
+ let candidatesAnalyzed = 0;
953
+ let strategiesTried = [];
954
+ let tokenUsage = null;
955
+ try {
956
+ const aiResult = await askAI(options?.aiAction ?? action, contextName);
957
+ candidatesAnalyzed = aiResult.candidatesAnalyzed;
958
+ tokenUsage = aiResult.tokenUsage;
959
+ if (!aiResult.plan) {
960
+ await log({ ts, url: page.url(), key, action, contextName, used: "healed", success: false, error: "AI returned no plan", tokenUsage });
961
+ locataiLog.noValidCandidate(contextName);
962
+ if (tokenUsage) locataiLog.tokenUsage(tokenUsage.inputTokens, tokenUsage.outputTokens, tokenUsage.totalTokens);
963
+ throw new LocatAIError("AI returned no suggestions", {
964
+ action,
965
+ contextName,
966
+ url: page.url(),
967
+ candidatesAnalyzed,
968
+ strategiesTried: [],
969
+ originalError: originalError?.message
970
+ });
971
+ }
972
+ const pickResult = await pickValid(aiResult.plan, { skipVisibilityCheck: options?.forceClick });
973
+ strategiesTried = pickResult.strategiesTried;
974
+ const choice = pickResult.choice;
975
+ if (!choice) {
976
+ await log({ ts, url: page.url(), key, action, contextName, used: "healed", success: false, error: "No valid candidate", tokenUsage });
977
+ locataiLog.noValidCandidate(contextName);
978
+ if (tokenUsage) locataiLog.tokenUsage(tokenUsage.inputTokens, tokenUsage.outputTokens, tokenUsage.totalTokens);
979
+ throw new LocatAIError("Could not find a matching element", {
980
+ action,
981
+ contextName,
982
+ url: page.url(),
983
+ candidatesAnalyzed,
984
+ strategiesTried,
985
+ originalError: originalError?.message
986
+ });
987
+ }
988
+ await saveToCache(key, choice.strategy, contextName);
989
+ const healedLoc = buildLocator(page, choice.strategy);
990
+ if (!options?.forceClick) {
991
+ await waitForReady(healedLoc, action, timeout);
992
+ }
993
+ await performAction(healedLoc);
994
+ if (action === "click" || action === "dblclick" || action === "selectOption") await waitForStable(page);
995
+ await log({
996
+ ts,
997
+ url: page.url(),
998
+ key,
999
+ action,
1000
+ contextName,
1001
+ used: "healed",
1002
+ success: true,
1003
+ confidence: choice.confidence,
1004
+ why: choice.why,
1005
+ strategy: choice.strategy,
1006
+ tokenUsage
1007
+ });
1008
+ locataiLog.healed(contextName, choice.strategy);
1009
+ if (tokenUsage) locataiLog.tokenUsage(tokenUsage.inputTokens, tokenUsage.outputTokens, tokenUsage.totalTokens);
1010
+ } catch (healErr) {
1011
+ if (healErr instanceof LocatAIError) {
1012
+ await log({
1013
+ ts,
1014
+ url: page.url(),
1015
+ key,
1016
+ action,
1017
+ contextName,
1018
+ used: "healed",
1019
+ success: false,
1020
+ error: healErr.message
1021
+ });
1022
+ locataiLog.healFailed(contextName, "No matching element found");
1023
+ throw healErr;
1024
+ }
1025
+ await log({
1026
+ ts,
1027
+ url: page.url(),
1028
+ key,
1029
+ action,
1030
+ contextName,
1031
+ used: "healed",
1032
+ success: false,
1033
+ error: `Heal failed: ${String(healErr?.message ?? healErr)}`
1034
+ });
1035
+ locataiLog.healFailed(contextName, String(healErr?.message ?? healErr));
1036
+ throw new LocatAIError(String(healErr?.message ?? healErr), {
1037
+ action,
1038
+ contextName,
1039
+ url: page.url(),
1040
+ candidatesAnalyzed,
1041
+ strategiesTried,
1042
+ originalError: originalError?.message
1043
+ });
1044
+ }
1045
+ }
1046
+ const locataiMethods = {
1047
+ click: (target, contextName, clickOptions) => locataiAction(
1048
+ "click",
1049
+ target,
1050
+ contextName,
1051
+ // For display:none elements (force mode), use dispatchEvent which bypasses
1052
+ // the need for element dimensions. Regular click() fails on display:none.
1053
+ clickOptions?.force ? (loc) => loc.dispatchEvent("click") : (loc) => loc.click({ timeout }),
1054
+ { forceClick: clickOptions?.force }
1055
+ ),
1056
+ fill: (target, contextName, value) => locataiAction(
1057
+ "fill",
1058
+ target,
1059
+ contextName,
1060
+ (loc) => loc.fill(value, { timeout })
1061
+ ),
1062
+ selectOption: (target, contextName, value) => locataiAction(
1063
+ "selectOption",
1064
+ target,
1065
+ contextName,
1066
+ async (loc) => {
1067
+ await loc.selectOption(value, { timeout });
1068
+ }
1069
+ ),
1070
+ dblclick: (target, contextName) => locataiAction(
1071
+ "dblclick",
1072
+ target,
1073
+ contextName,
1074
+ (loc) => loc.dblclick({ timeout }),
1075
+ { aiAction: "click" }
1076
+ // Use click candidates for dblclick
1077
+ ),
1078
+ check: (target, contextName) => locataiAction(
1079
+ "check",
1080
+ target,
1081
+ contextName,
1082
+ (loc) => loc.check({ timeout }),
1083
+ { aiAction: "fill" }
1084
+ // Use fill candidates for check (inputs)
1085
+ ),
1086
+ uncheck: (target, contextName) => locataiAction(
1087
+ "uncheck",
1088
+ target,
1089
+ contextName,
1090
+ (loc) => loc.uncheck({ timeout }),
1091
+ { aiAction: "fill" }
1092
+ // Use fill candidates for uncheck (inputs)
1093
+ ),
1094
+ hover: (target, contextName) => locataiAction(
1095
+ "hover",
1096
+ target,
1097
+ contextName,
1098
+ (loc) => loc.hover({ timeout }),
1099
+ { aiAction: "click" }
1100
+ // Use click candidates for hover
1101
+ ),
1102
+ focus: (target, contextName) => locataiAction(
1103
+ "focus",
1104
+ target,
1105
+ contextName,
1106
+ (loc) => loc.focus({ timeout }),
1107
+ { aiAction: "click" }
1108
+ // Use click candidates — focusable elements include buttons, divs, etc.
1109
+ ),
1110
+ setTestName: (name) => {
1111
+ currentTestName = name;
1112
+ },
1113
+ // Create a self-healing locator with semantic description fallback
1114
+ locator: (selector, contextName) => {
1115
+ const baseLoc = page.locator(selector);
1116
+ return {
1117
+ click: (options) => locataiMethods.click(baseLoc, contextName, options),
1118
+ fill: (value) => locataiMethods.fill(baseLoc, contextName, value),
1119
+ dblclick: () => locataiMethods.dblclick(baseLoc, contextName),
1120
+ check: () => locataiMethods.check(baseLoc, contextName),
1121
+ uncheck: () => locataiMethods.uncheck(baseLoc, contextName),
1122
+ hover: () => locataiMethods.hover(baseLoc, contextName),
1123
+ focus: () => locataiMethods.focus(baseLoc, contextName),
1124
+ selectOption: (value) => locataiMethods.selectOption(baseLoc, contextName, value)
1125
+ };
1126
+ }
1127
+ };
1128
+ page[LOCATAI_SYMBOL] = true;
1129
+ page.locatai = locataiMethods;
1130
+ return page;
1131
+ }
1132
+ function createLocatAIFixture(opts) {
1133
+ return {
1134
+ page: async ({ page }, use) => {
1135
+ const locataiPage = withLocatAI(page, opts);
1136
+ await use(locataiPage);
1137
+ }
1138
+ };
1139
+ }
1140
+ export {
1141
+ DEFAULT_MODELS,
1142
+ LocatAIError,
1143
+ createLocatAIFixture,
1144
+ withLocatAI
1145
+ };