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