ff-automationv2 1.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.
Files changed (69) hide show
  1. package/ARCHITECTURE.md +112 -0
  2. package/FireFlink_Architecture.drawio +68 -0
  3. package/ONBOARDING.md +29 -0
  4. package/README.md +28 -0
  5. package/TECHNICAL_DEEP_DIVE.md +26 -0
  6. package/eslint.config.ts +29 -0
  7. package/package.json +51 -0
  8. package/src/ai/llmcalls/decodeApiKey.ts +14 -0
  9. package/src/ai/llmcalls/llmAction.ts +89 -0
  10. package/src/ai/llmcalls/parseLlmOputput.ts +69 -0
  11. package/src/ai/llmprompts/promptRegistry.ts +16 -0
  12. package/src/ai/llmprompts/systemPrompts/actionExtractorPrompt.ts +70 -0
  13. package/src/ai/llmprompts/systemPrompts/errorDescriptionPrompt.ts +23 -0
  14. package/src/ai/llmprompts/systemPrompts/fireflinkElementIndexExtactors.ts +198 -0
  15. package/src/ai/llmprompts/systemPrompts/userStoryToListPrompt.ts +24 -0
  16. package/src/ai/llmprompts/systemPrompts/visionPrompt.ts +28 -0
  17. package/src/ai/llmprompts/userPrompts/userPrompt.ts +41 -0
  18. package/src/automation/actions/executor.ts +75 -0
  19. package/src/automation/actions/interaction/click.ts +25 -0
  20. package/src/automation/actions/interaction/enterInput.ts +27 -0
  21. package/src/automation/actions/interface/interactionActionInterface.ts +27 -0
  22. package/src/automation/actions/interface/navigationActionInterface.ts +22 -0
  23. package/src/automation/actions/interface/waitActionInterface.ts +6 -0
  24. package/src/automation/actions/navigation/getTitle.ts +9 -0
  25. package/src/automation/actions/navigation/goBack.ts +9 -0
  26. package/src/automation/actions/navigation/navigate.ts +10 -0
  27. package/src/automation/actions/navigation/refresh.ts +9 -0
  28. package/src/automation/actions/wait/wait.ts +10 -0
  29. package/src/automation/browserSession/initiateBrowserSession.ts +81 -0
  30. package/src/core/constants/supportedActions.ts +8 -0
  31. package/src/core/interfaces/StableDomInterface.ts +6 -0
  32. package/src/core/interfaces/actionInterface.ts +13 -0
  33. package/src/core/interfaces/automationRunnerInterface.ts +3 -0
  34. package/src/core/interfaces/browserCapabilitiesInterface.ts +5 -0
  35. package/src/core/interfaces/browserConfigurationInterface.ts +3 -0
  36. package/src/core/interfaces/domAnalysisInterface.ts +34 -0
  37. package/src/core/interfaces/executionDetails.ts +29 -0
  38. package/src/core/interfaces/fireflinkScriptPayloadInterface.ts +39 -0
  39. package/src/core/interfaces/llmConfigurationInterface.ts +3 -0
  40. package/src/core/interfaces/llmResponseInterface.ts +38 -0
  41. package/src/core/interfaces/promptInterface.ts +21 -0
  42. package/src/core/interfaces/scriptGenrationDataInterface.ts +16 -0
  43. package/src/core/interfaces/toolsInterface.ts +5 -0
  44. package/src/core/main/actionHandlerFactory.ts +86 -0
  45. package/src/core/main/executionContext.ts +18 -0
  46. package/src/core/main/runAutomationScript.ts +177 -0
  47. package/src/core/main/stepProcessor.ts +28 -0
  48. package/src/core/types/llmResponseType.ts +11 -0
  49. package/src/core/types/promptMap.ts +7 -0
  50. package/src/core/types/promptType.ts +7 -0
  51. package/src/core/types/visionllmInputType.ts +4 -0
  52. package/src/domAnalysis/getRelaventElements.ts +24 -0
  53. package/src/domAnalysis/relativeElementsFromDom.ts +94 -0
  54. package/src/domAnalysis/searchBest.ts +159 -0
  55. package/src/domAnalysis/simplifyAndFlatten.ts +118 -0
  56. package/src/fireflinkData/fireflinkLocators/elementsFromHTML.ts +656 -0
  57. package/src/fireflinkData/fireflinkLocators/getListOfLocators.ts +31 -0
  58. package/src/fireflinkData/fireflinkLocators/typeList.ts +36 -0
  59. package/src/fireflinkData/fireflinkScript/scriptGenrationData.ts +30 -0
  60. package/src/index.ts +5 -0
  61. package/src/llmConfig/llmConfiguration.ts +26 -0
  62. package/src/service/fireflinkApi.service.ts +46 -0
  63. package/src/service/scriptRunner.service.ts +83 -0
  64. package/src/utils/DomExtraction/jsForAttributeInjection.ts +254 -0
  65. package/src/utils/javascript/jsFindElement.ts +161 -0
  66. package/src/utils/javascript/jsForShadowRoot.ts +216 -0
  67. package/src/utils/javascript/jsForToaster.ts +60 -0
  68. package/src/utils/logger/logData.ts +36 -0
  69. package/tsconfig.json +26 -0
@@ -0,0 +1,36 @@
1
+ const elementType: { [key: string]: string } = {
2
+ 'Link': 'link',
3
+ 'Text Field': 'textfield',
4
+ 'Icon': 'icon',
5
+ 'Button': 'button',
6
+ 'Radio Button': 'radioButton',
7
+ 'Text': 'text',
8
+ 'Textarea': 'textarea',
9
+ 'Image': 'image',
10
+ 'Dropdown': 'dropdown',
11
+ 'Checkbox': 'checkbox',
12
+ 'Tab': 'tab',
13
+ 'Action Overflow button': 'action overflow button',
14
+ 'Hamburger menu': 'hamburger icon',
15
+ 'Toggle Button': 'toggle button',
16
+ 'Suggestion': 'suggestion',
17
+ 'Time Picker': 'time picker',
18
+ 'Date Picker': 'date picker',
19
+ 'Toaster Message': 'toaster message',
20
+ 'Card': 'card',
21
+ 'Tooltip': 'tooltip',
22
+ 'Option': 'option',
23
+ 'Calender': 'calender',
24
+ 'Sliders': 'sliders',
25
+ 'Visual Testing': 'visual testing'
26
+ } as const;
27
+
28
+ export const normalizedElementType: Record<string, string> =
29
+ Object.fromEntries(
30
+ Object.entries(elementType).map(([key, value]) => [
31
+ key.toLowerCase().replace(/\s+/g, ""),
32
+ value,
33
+ ])
34
+ );
35
+
36
+
@@ -0,0 +1,30 @@
1
+ import { IScriptGenerationData } from "../../core/interfaces/scriptGenrationDataInterface.js";
2
+ import { IPayload } from "../../core/interfaces/fireflinkScriptPayloadInterface.js";
3
+ import { FireFlinkApiService } from "../../service/fireflinkApi.service.js";
4
+ import { ScriptRunner } from "../../service/scriptRunner.service.js";
5
+
6
+ const apiService = new FireFlinkApiService();
7
+ const scriptRunner = new ScriptRunner(apiService);
8
+
9
+ export class ScriptDataAppender {
10
+ private data: IScriptGenerationData[] = [];
11
+ add(item: IScriptGenerationData): void {
12
+ this.data.push(item);
13
+ }
14
+ getData(): IScriptGenerationData[] {
15
+ return this.data;
16
+ }
17
+ produceScriptGenerationData(
18
+ result: any,
19
+ payload: IPayload,
20
+ token: string,
21
+ serverHost: string
22
+ ): Promise<any> {
23
+ return scriptRunner.runScriptFromPayload(
24
+ result,
25
+ payload,
26
+ token,
27
+ serverHost
28
+ );
29
+ }
30
+ }
package/src/index.ts ADDED
@@ -0,0 +1,5 @@
1
+
2
+ import { AutomationRunner } from "./core/main/runAutomationScript.js";
3
+
4
+ export { AutomationRunner };
5
+
@@ -0,0 +1,26 @@
1
+
2
+ import { IServiceProviderBaseUrlConfiguration } from "../core/interfaces/llmConfigurationInterface.js";
3
+ export class ServiceProviderBaseUrlProvider
4
+ implements IServiceProviderBaseUrlConfiguration {
5
+
6
+ private readonly baseUrls: Record<string, string> = {
7
+ Groq: "https://api.groq.com/openai/v1",
8
+ DefaultFireFlink: "https://api.groq.com/openai/v1",
9
+ OpenAi: "https://api.openai.com/v1",
10
+ Anthropic: "https://api.anthropic.com/v1/",
11
+ Gemini: "https://generativelanguage.googleapis.com/v1beta/openai/"
12
+ };
13
+
14
+ public getBaseUrl(provider: string): string {
15
+ const url = this.baseUrls[provider];
16
+
17
+ if (!url) {
18
+ throw new Error(`Unsupported service provider: ${provider}`);
19
+ }
20
+
21
+ return url;
22
+ }
23
+ }
24
+
25
+
26
+
@@ -0,0 +1,46 @@
1
+ import axios, { AxiosError } from "axios";
2
+ import { IFireFlinkApiService } from "../core/interfaces/fireflinkScriptPayloadInterface.js";
3
+
4
+ export class FireFlinkApiService implements IFireFlinkApiService {
5
+
6
+ async callFireFlinkApi(
7
+ headers: Record<string, string>,
8
+ body: any,
9
+ url: string
10
+ ): Promise<any> {
11
+
12
+ try {
13
+ const response = await axios.post(url, body, {
14
+ headers,
15
+ timeout: 30000,
16
+ });
17
+
18
+ return response.data;
19
+
20
+ } catch (error: any) {
21
+
22
+ const err = error as AxiosError;
23
+
24
+ if (err.code === "ECONNABORTED") {
25
+ throw new Error(
26
+ "Request to FireFlink API timed out",
27
+ { cause: error }
28
+ );
29
+ }
30
+
31
+ if (err.response) {
32
+ throw new Error(
33
+ `HTTP Error ${err.response.status}: ${JSON.stringify(
34
+ err.response.data
35
+ )}`,
36
+ { cause: error }
37
+ );
38
+ }
39
+
40
+ throw new Error(
41
+ "Unexpected error while calling FireFlink API",
42
+ { cause: error }
43
+ );
44
+ }
45
+ }
46
+ }
@@ -0,0 +1,83 @@
1
+ import {
2
+ IPayload,
3
+ IScriptRunner,
4
+ IStep,
5
+ IFireFlinkApiService,
6
+ } from "../core/interfaces/fireflinkScriptPayloadInterface";
7
+
8
+ import { logger } from "../utils/logger/logData.js"
9
+
10
+ export class ScriptRunner implements IScriptRunner {
11
+ constructor(private apiService: IFireFlinkApiService) { }
12
+
13
+ async runScriptFromPayload(
14
+ result: any[] | [],
15
+ payload: IPayload,
16
+ token: string,
17
+ serverHost: string
18
+ ): Promise<any> {
19
+ let stepData: IStep[];
20
+
21
+ try {
22
+ stepData =
23
+ typeof result === "string" ? JSON.parse(result) : result;
24
+
25
+ } catch (error) {
26
+ logger.error(`Failed to parse final llm data: ${error}`);
27
+ return;
28
+ }
29
+ logger.info("Sending script payload to FireFlink");
30
+
31
+ const body = {
32
+ stepData: stepData,
33
+ scriptName: payload.scriptName,
34
+ scriptType: payload.scriptType,
35
+ projectId: payload.projectId,
36
+ testCaseId: payload.testCaseId,
37
+ promptId: payload.promptId,
38
+ pageDetails: payload.pageDetails,
39
+ generatedBy: payload.generatedBy,
40
+ webSocketId: payload.webSocketId,
41
+ licenseType: payload.licenseType,
42
+ licenseId: payload.licenseId,
43
+ userId: payload.userId,
44
+ topic: payload.topic,
45
+ projectType: payload.projectType,
46
+ tokensConsumed: payload.tokensConsumed,
47
+ };
48
+ logger.info("PayLoad for Fireflink : ", body)
49
+ const headers = {
50
+ Accept: "*/*",
51
+ "Accept-Language": "en-US,en;q=0.9",
52
+ "Content-Type": "application/json",
53
+ projectId: payload.projectId || "",
54
+ Authorization: `Bearer ${token}`,
55
+ };
56
+
57
+ const url = `${serverHost}/project/optimize/v3/bulk/scripts/script-payload`;
58
+
59
+ try {
60
+ const finalResult = await this.apiService.callFireFlinkApi(
61
+ headers,
62
+ JSON.stringify(body),
63
+ url
64
+ );
65
+ if (finalResult) {
66
+ logger.error(
67
+ "Successfully submitted automation script to FireFlink API."
68
+ );
69
+ } else {
70
+ throw new Error(
71
+ "FireFlink API responded with an error: " + JSON.stringify(finalResult));
72
+ }
73
+ return finalResult;
74
+ } catch (error: any) {
75
+ logger.error(
76
+ `Failed to submit automation script, ${error}`
77
+ );
78
+ throw new Error(
79
+ "Failed to submit automation script", { cause: error }
80
+ );
81
+ }
82
+ }
83
+ }
@@ -0,0 +1,254 @@
1
+ import type { Browser } from "webdriverio";
2
+
3
+ export interface AnnotatedDOMResult {
4
+ dom: string;
5
+ selectors: Record<string, string>;
6
+ }
7
+
8
+ export async function getAnnotatedDOM(
9
+ browser: Browser
10
+ ): Promise<AnnotatedDOMResult> {
11
+
12
+ return await browser.execute<AnnotatedDOMResult, []>(() => {
13
+
14
+ let nodeIndex = 0;
15
+ const xpaths: Record<string, string> = {};
16
+
17
+ // ==========================================================
18
+ // COVERAGE CHECK
19
+ // ==========================================================
20
+ function getCoverageStatus(el: Element): string {
21
+
22
+ try {
23
+ if (!el || !el.getBoundingClientRect) return "any";
24
+
25
+ const rect = el.getBoundingClientRect();
26
+ if (rect.width === 0 || rect.height === 0) return "false";
27
+
28
+ const x = rect.left + rect.width / 2;
29
+ const y = rect.top + rect.height / 2;
30
+
31
+ const topEl = el.ownerDocument.elementFromPoint(x, y);
32
+ if (!topEl) return "false";
33
+
34
+ if (topEl === el || el.contains(topEl)) return "false";
35
+
36
+ const style = getComputedStyle(topEl);
37
+
38
+ if (
39
+ style.pointerEvents === "none" ||
40
+ style.opacity === "0" ||
41
+ style.visibility === "hidden"
42
+ ) return "false";
43
+
44
+ return "true";
45
+ } catch {
46
+ return "any";
47
+ }
48
+ }
49
+
50
+ // ==========================================================
51
+ // INTERACTIVITY METADATA
52
+ // ==========================================================
53
+ function annotateInteractivity(el: Element): void {
54
+
55
+ const s = getComputedStyle(el);
56
+
57
+ el.setAttribute("element-visibility", s.visibility || "");
58
+ el.setAttribute("element-pointer-events", s.pointerEvents || "");
59
+ el.setAttribute("element-zindex", s.zIndex || "");
60
+ el.setAttribute("element-covered", getCoverageStatus(el));
61
+ }
62
+
63
+ // ==========================================================
64
+ // GLOBAL XPATH (Shadow + Iframe Safe)
65
+ // ==========================================================
66
+ function getXPath(node: Element): string {
67
+
68
+ if (!node || node.nodeType !== 1) return "";
69
+
70
+ const segments: string[] = [];
71
+ let current: Node | null = node;
72
+
73
+ while (current && current.nodeType === 1) {
74
+
75
+ const el = current as Element;
76
+ const parent = el.parentNode;
77
+
78
+ let index = 1;
79
+
80
+ if (parent && (parent as Element).children) {
81
+ const siblings = Array.from(
82
+ (parent as Element).children
83
+ ).filter(n => n.tagName === el.tagName);
84
+
85
+ index = siblings.indexOf(el) + 1;
86
+ }
87
+
88
+ segments.unshift(
89
+ el.tagName.toLowerCase() + "[" + index + "]"
90
+ );
91
+
92
+ // ---------------------------
93
+ // Shadow DOM handling
94
+ // ---------------------------
95
+ if (parent instanceof ShadowRoot) {
96
+ segments.unshift("shadow");
97
+ current = parent.host;
98
+ continue;
99
+ }
100
+
101
+ // ---------------------------
102
+ // Iframe root handling
103
+ // ---------------------------
104
+ if (
105
+ el.ownerDocument !== document &&
106
+ el === el.ownerDocument.documentElement
107
+ ) {
108
+ const frameEl =
109
+ el.ownerDocument.defaultView?.frameElement as Element | null;
110
+
111
+ if (frameEl && frameEl.getAttribute("ff-xpath")) {
112
+ return (
113
+ frameEl.getAttribute("ff-xpath") +
114
+ "/" +
115
+ segments.join("/")
116
+ );
117
+ }
118
+ }
119
+
120
+ if (!parent || parent.nodeType !== 1) break;
121
+
122
+ current = parent;
123
+ }
124
+
125
+ return "/" + segments.join("/");
126
+ }
127
+
128
+ // ==========================================================
129
+ // PROCESS NODE
130
+ // ==========================================================
131
+ function processNode(node: Element): void {
132
+
133
+ if (!node || node.nodeType !== 1) return;
134
+
135
+ const fireId = `Fire-Flink-${nodeIndex++}`;
136
+ const xpath = getXPath(node);
137
+
138
+ node.setAttribute("ff-inspect", fireId);
139
+ node.setAttribute("ff-xpath", xpath);
140
+
141
+ xpaths[fireId] = xpath;
142
+
143
+ annotateInteractivity(node);
144
+
145
+ // ---------------------------
146
+ // Shadow DOM
147
+ // ---------------------------
148
+ const shadowRoot = (node as HTMLElement).shadowRoot;
149
+ if (shadowRoot) {
150
+ shadowRoot.querySelectorAll("*")
151
+ .forEach(el => processNode(el as Element));
152
+ }
153
+
154
+ // ---------------------------
155
+ // Same-origin iframe
156
+ // ---------------------------
157
+ if (node.tagName === "IFRAME") {
158
+ try {
159
+ const doc =
160
+ (node as HTMLIFrameElement).contentDocument;
161
+
162
+ if (doc) {
163
+ processNode(doc.documentElement);
164
+ doc.querySelectorAll("*")
165
+ .forEach(el => processNode(el as Element));
166
+ }
167
+ } catch {
168
+ node.setAttribute("ff-cross-origin", "true");
169
+ }
170
+ }
171
+
172
+ node.querySelectorAll(":scope > *")
173
+ .forEach(el => processNode(el as Element));
174
+ }
175
+
176
+ // ==========================================================
177
+ // INITIAL INJECTION
178
+ // ==========================================================
179
+ document.querySelectorAll("*")
180
+ .forEach(el => processNode(el as Element));
181
+
182
+ // ==========================================================
183
+ // SERIALIZER
184
+ // ==========================================================
185
+ function serializeNode(node: Node): string {
186
+
187
+ if (node.nodeType === Node.TEXT_NODE) {
188
+ return node.textContent || "";
189
+ }
190
+
191
+ if (node.nodeType !== Node.ELEMENT_NODE) return "";
192
+
193
+ const el = node as Element;
194
+ const tag = el.tagName.toLowerCase();
195
+ let html = `<${tag}`;
196
+
197
+ for (const attr of Array.from(el.attributes)) {
198
+
199
+ // Avoid dumping large srcdoc inline
200
+ if (attr.name === "srcdoc") continue;
201
+
202
+ html += ` ${attr.name}="${attr.value.replace(/"/g, "&quot;")}"`;
203
+ }
204
+
205
+ html += ">";
206
+
207
+ // Shadow DOM serialization
208
+ const shadowRoot = (el as HTMLElement).shadowRoot;
209
+ if (shadowRoot) {
210
+ html += "<!--#shadow-root-open-->";
211
+ shadowRoot.querySelectorAll("*")
212
+ .forEach(child => {
213
+ html += serializeNode(child);
214
+ });
215
+ html += "<!--#/shadow-root-->";
216
+ }
217
+
218
+ // Iframe serialization
219
+ if (
220
+ tag === "iframe" &&
221
+ el.getAttribute("ff-cross-origin") !== "true"
222
+ ) {
223
+ try {
224
+ const doc =
225
+ (el as HTMLIFrameElement).contentDocument;
226
+
227
+ if (doc) {
228
+ html += "<!--#iframe-content-->";
229
+ html += serializeNode(doc.documentElement);
230
+ html += "<!--#/iframe-content-->";
231
+ }
232
+ } catch {
233
+ // cross-origin iframe, cannot access content
234
+ }
235
+ }
236
+
237
+ for (const child of Array.from(el.childNodes)) {
238
+ html += serializeNode(child);
239
+ }
240
+
241
+ html += `</${tag}>`;
242
+
243
+ return html;
244
+ }
245
+
246
+ return {
247
+ dom:
248
+ "<!DOCTYPE html>\\n" +
249
+ serializeNode(document.documentElement),
250
+ selectors: xpaths
251
+ };
252
+
253
+ });
254
+ }
@@ -0,0 +1,161 @@
1
+ export const Find_element_by_ff_js_script = `
2
+ const target = arguments[0];
3
+ const callback = arguments[arguments.length - 1];
4
+
5
+ const MAX_WAIT = 2000;
6
+ const INTERVAL = 50;
7
+ const start = Date.now();
8
+
9
+ // ---------------- FINDER ----------------
10
+ function findByFfInspect(root) {
11
+ const visited = new Set();
12
+ const stack = [{ node: root, frames: [] }];
13
+
14
+ while (stack.length > 0) {
15
+ const { node, frames } = stack.pop();
16
+ if (!node) continue;
17
+
18
+ if (
19
+ node.nodeType === Node.DOCUMENT_NODE ||
20
+ node.nodeType === Node.DOCUMENT_FRAGMENT_NODE
21
+ ) {
22
+ if (visited.has(node)) continue;
23
+ visited.add(node);
24
+ }
25
+
26
+ if (node.nodeType === Node.ELEMENT_NODE) {
27
+
28
+ if (node.getAttribute && node.getAttribute("ff-inspect") === target) {
29
+ return { node, frames };
30
+ }
31
+
32
+ if (node.shadowRoot) {
33
+ stack.push({ node: node.shadowRoot, frames });
34
+ }
35
+
36
+ if (node.tagName === "IFRAME") {
37
+ try {
38
+ const doc = node.contentDocument || node.contentWindow?.document;
39
+ if (doc) {
40
+ const meta = {
41
+ id: node.id || null,
42
+ name: node.getAttribute("name") || null,
43
+ title: node.getAttribute("title") || null,
44
+ aria: node.getAttribute("aria-label") || null,
45
+ src: node.getAttribute("src") || null,
46
+ };
47
+
48
+ stack.push({
49
+ node: doc,
50
+ frames: [...frames, meta]
51
+ });
52
+ }
53
+ } catch (e) {}
54
+ }
55
+
56
+ const kids = node.children || node.childNodes;
57
+ for (let i = kids.length - 1; i >= 0; i--) {
58
+ stack.push({ node: kids[i], frames });
59
+ }
60
+ }
61
+
62
+ else if (
63
+ node.nodeType === Node.DOCUMENT_NODE ||
64
+ node.nodeType === Node.DOCUMENT_FRAGMENT_NODE
65
+ ) {
66
+ const kids = node.childNodes;
67
+ for (let i = kids.length - 1; i >= 0; i--) {
68
+ stack.push({ node: kids[i], frames });
69
+ }
70
+ }
71
+ }
72
+
73
+ return null;
74
+ }
75
+
76
+ // ---------------- CLICKABILITY ----------------
77
+ function isClickable(el) {
78
+ if (!el || el.nodeType !== Node.ELEMENT_NODE) return false;
79
+ const tag = el.tagName.toLowerCase();
80
+ if (tag === "button" || tag === "a") return true;
81
+ if (el.getAttribute("role") === "button") return true;
82
+ if (tag === "input") {
83
+ const t = (el.getAttribute("type") || "").toLowerCase();
84
+ return t === "button" || t === "submit";
85
+ }
86
+ return false;
87
+ }
88
+
89
+ function findClickableInside(root) {
90
+ const queue = [root];
91
+ while (queue.length) {
92
+ const node = queue.shift();
93
+ if (isClickable(node)) return node;
94
+ const kids = node.children || node.childNodes;
95
+ for (let k of kids) queue.push(k);
96
+ }
97
+ return null;
98
+ }
99
+
100
+ // ---------------- XPATH BUILDER ----------------
101
+ function buildXPath(el) {
102
+ if (!el) return null;
103
+
104
+ if (el.id) return \`//*[@id="\${el.id}"]\`;
105
+
106
+ const name = el.getAttribute("name");
107
+ if (name) return \`//*[@name="\${name}"]\`;
108
+
109
+ const text = el.textContent && el.textContent.trim();
110
+ if (text && text.length < 50) {
111
+ const escaped = text.replace(/"/g, '\\\\"');
112
+ const xp = \`//*[text()="\${escaped}"]\`;
113
+ try {
114
+ const n = document.evaluate(xp, document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null);
115
+ if (n.singleNodeValue === el) return xp;
116
+ } catch {}
117
+ }
118
+
119
+ const parts = [];
120
+ let node = el;
121
+
122
+ while (node && node.nodeType === Node.ELEMENT_NODE) {
123
+ let idx = 1;
124
+ let sib = node.previousSibling;
125
+ while (sib) {
126
+ if (sib.nodeType === Node.ELEMENT_NODE && sib.nodeName === node.nodeName) idx++;
127
+ sib = sib.previousSibling;
128
+ }
129
+ parts.unshift(node.nodeName.toLowerCase() + "[" + idx + "]");
130
+ node = node.parentNode instanceof ShadowRoot ? node.parentNode.host : node.parentNode;
131
+ }
132
+
133
+ return "/" + parts.join("/");
134
+ }
135
+
136
+ // ---------------- WAIT LOOP ----------------
137
+ (function attempt() {
138
+ const found = findByFfInspect(document);
139
+
140
+ if (!found) {
141
+ if (Date.now() - start > MAX_WAIT) {
142
+ return callback({ success: false, reason: "timeout" });
143
+ }
144
+ return setTimeout(attempt, INTERVAL);
145
+ }
146
+
147
+ let targetNode = found.node;
148
+ if (!isClickable(targetNode)) {
149
+ const inside = findClickableInside(targetNode);
150
+ if (inside) targetNode = inside;
151
+ }
152
+
153
+ const xpath = buildXPath(targetNode);
154
+
155
+ return callback({
156
+ success: true,
157
+ xpath,
158
+ frames: found.frames
159
+ });
160
+ })();
161
+ `;