ff-automationv2 2.1.2 → 2.1.3-beta.1

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ff-automationv2",
3
- "version": "2.1.2",
3
+ "version": "2.1.3-beta.1",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "This lib is used to automate the manual testcase",
@@ -1,4 +1,5 @@
1
1
  import { FireFlinkLLMResponse } from "../../core/types/llmResponseType.js";
2
+ import { logger } from "../../utils/logger/logData.js";
2
3
 
3
4
 
4
5
  export class LLMResultParser {
@@ -47,6 +48,8 @@ export class LLMResultParser {
47
48
  this.outputTokens += outputTokens;
48
49
  this.totalTokens += totalTokens;
49
50
 
51
+ logger.info(`Total tokens for this call is : ${usage.total_tokens}`);
52
+
50
53
  return { response: parsedContent };
51
54
 
52
55
  } catch (error) {
@@ -15,7 +15,7 @@ You are an expert in Web application testing.
15
15
  Rules:
16
16
  - Only give response for the current step.
17
17
  - understand the step and context from the ${JSON.stringify(priorAndNextSteps)}.and keywords should be from step. it should not be related to other steps.
18
- - 3 to 5 keywords maximum.
18
+ - 3 to 5 keywords maximum. dont give more than 5 keywords. and no need for casesensitive.
19
19
  - If the step is about entering text or Uploading file, Should NOT include input value from the step into keywords.
20
20
  - If the step has words like tag name audio, video, image,svg, checkbox etc, include them in the keywords.
21
21
  - If icon is metioned in step than 'svg' should add in keywords and for Upload action first keyword should be 'file'.
@@ -23,7 +23,7 @@ You are an expert in Web application testing.
23
23
  - If a keyword contains exactly two words, you MUST always include both the spaced and concatenated (no-space) versions; omission of either is invalid.
24
24
  - Do NOT split single-word keywords and do NOT include relation terms (above, below, next to, etc.) in keywords.
25
25
  - Treat each keyword independently—never merge different keywords or combine them with relation words.
26
- - Example: Click on Sign In above Create Account button → ["Sign In", "SignIn", "Create Account", "CreateAccount"]
26
+ - Example: Click on Sign In above Create Account button → ["Sign In", "SignIn", "Create Account", "CreateAccount"] never break kewword like ["create", "account"]
27
27
  - Keywords can be string or number if the step contains a number ,add it also as keyword.
28
28
  - Do Not include any other unrelated keywords for step.
29
29
  - Do NOT include generic UI words (button, field, etc) and action words (tap, click, press, etc).
@@ -40,7 +40,7 @@ You are an expert in Web application testing.
40
40
  Respond only with JSON using this format:
41
41
  {
42
42
  "keywords": [key1,key2,key3,key4,key5],
43
- "element_name": "x",
43
+ "elementName": "x",
44
44
  "action": "x"
45
45
  }
46
46
 
@@ -137,7 +137,7 @@ Return **only valid JSON** in the following format:
137
137
  "keyword": "x",
138
138
  "num_of_scrolls": "0",
139
139
  "direction": "down",
140
- "element_type": "x"
140
+ "elementType": "x"
141
141
  }
142
142
 
143
143
  Rules:
@@ -165,7 +165,7 @@ example: step: "Enter sss.@gmail.com in email field" and step :
165
165
  - For Action "upload" extract file path from step.
166
166
  - if u cant find any input_text or any other dont give null just return them "" empty.
167
167
  - Based on step give most relevant type of element. use this list to choose element_type: ${elementType} and Never change syntax of element_type, follow the syntax of element_type in list.if element_type is not there in list return 'link'.
168
- Simplified JSON: ${extractedDomJson}
168
+ Simplified JSON: ${extractedDomJson} if this simplified json is empty return Fire-Flink-0.
169
169
 
170
170
  `;
171
171
  }
@@ -1,12 +1,20 @@
1
1
  import { EnterInputInterface } from "../interface/interactionActionInterface.js";
2
+ import { enterActionHelper } from "../../../utils/helpers/enterActionHelper.js";
2
3
  export async function enterInput(args: EnterInputInterface): Promise<void> {
4
+
3
5
  try {
4
- const element = args.driver.$(args.selector);
6
+
7
+ const { ffNumber, xpathPath } = enterActionHelper(
8
+ args.pageDOM,
9
+ args.fireflinkIndex
10
+ );
11
+
12
+ const element = await args.driver.$(xpathPath);
5
13
  await element.scrollIntoView({ block: 'center', inline: 'center' });
6
14
  await element.setValue(args.value);
7
15
  const ffElement: any = await args.elementGetter.getFireFlinkElement(
8
16
  args.pageDOM,
9
- `[ff-inspect="${args.fireflinkIndex}"]`
17
+ `[ff-inspect="Fire-Flink-${ffNumber}"]`
10
18
  );
11
19
  args.scriptDataAppender.add({
12
20
  nlpName: 'SendKeys',
@@ -21,6 +29,7 @@ export async function enterInput(args: EnterInputInterface): Promise<void> {
21
29
 
22
30
  }
23
31
  catch (error: any) {
32
+ console.log(error);
24
33
  throw new Error("enter action failed", { cause: error });
25
34
  }
26
35
 
@@ -14,12 +14,64 @@ import { DomProcessingEngine } from "../../domAnalysis/getRelaventElements.js"
14
14
  import { getAnnotatedDOM } from "../../utils/DomExtraction/jsForAttributeInjection.js"
15
15
  import { logger } from "../../utils/logger/logData.js"
16
16
  export class AutomationRunner implements IAutomationRunner {
17
+ static sessionTerminationDetails: Record<string, boolean> = {};
17
18
  constructor(
18
19
  private readonly request: AutomationRequest,
19
20
  private pageLoad: number = 20000,
20
21
  private implicit: number = 15000,
21
22
  private tokensConsumed: number = 0
22
- ) { }
23
+
24
+ ) { AutomationRunner.sessionTerminationDetails[request.testCaseId] = false }
25
+
26
+ static getSessionTerminationInfo(testCaseId: string): boolean {
27
+ return AutomationRunner.sessionTerminationDetails[testCaseId]
28
+ }
29
+ static updateSessionTerminationInfo(testCaseId: string): void {
30
+ AutomationRunner.sessionTerminationDetails[testCaseId] = true
31
+ }
32
+
33
+ private async cleanup(
34
+ context: ExecutionContext,
35
+ domInfo: any,
36
+ extractedRelevantDom: any,
37
+ stepProcessor: StepProcessor,
38
+ scriptRunner: ScriptRunner
39
+ ) {
40
+ try {
41
+ logger.info("Starting cleanup process...");
42
+ if (context?.session) {
43
+ try {
44
+ const browser = await context.session.getCurrentBrowser();
45
+ // if (browser) {
46
+ // await browser.deleteSession?.();
47
+ // }
48
+ } catch (e) {
49
+ logger.error("Browser cleanup failed:", e);
50
+ }
51
+ }
52
+
53
+ domInfo = null;
54
+ extractedRelevantDom = null;
55
+
56
+ if (stepProcessor) {
57
+ (stepProcessor as any).llm = null;
58
+ }
59
+
60
+ scriptRunner = null as any;
61
+
62
+ delete AutomationRunner.sessionTerminationDetails[this.request.testCaseId];
63
+
64
+ if (global.gc) {
65
+ global.gc();
66
+ }
67
+
68
+ logger.info("Cleanup completed successfully.");
69
+ }
70
+ catch (error) {
71
+ logger.error("Cleanup error:", error);
72
+ }
73
+ }
74
+
23
75
 
24
76
  async run(): Promise<void> {
25
77
  const apiService = new FireFlinkApiService();
@@ -42,11 +94,16 @@ export class AutomationRunner implements IAutomationRunner {
42
94
  const domProcessor = new DomProcessingEngine();
43
95
  let stepCount = 1;
44
96
  const listOfSteps = stepResult.response.manualSteps
45
- if (listOfSteps.length === 0) {
97
+ if (listOfSteps.length == 0) {
46
98
  throw new Error("No executable manual steps were returned by the LLM.");
47
99
  }
100
+
101
+ logger.info(listOfSteps)
102
+
48
103
  for (const step of listOfSteps) {
49
104
  try {
105
+ if (AutomationRunner.getSessionTerminationInfo(this.request.testCaseId)) { break; }
106
+
50
107
  const start = Math.max(0, stepCount - 3);
51
108
  const end = Math.min(listOfSteps.length, stepCount + 3);
52
109
  const priorAndNextSteps = listOfSteps.slice(start, end);
@@ -128,7 +185,7 @@ export class AutomationRunner implements IAutomationRunner {
128
185
  if (!xpath) {
129
186
  throw new Error(`Unable to resolve xpath for ${fireflinkIndex}`);
130
187
  }
131
- else if (fireflinkIndex == 0) {
188
+ else if (fireflinkIndex == "Fire-Flink-0") {
132
189
  throw new Error(`Unable to find element for ${step}`);
133
190
  }
134
191
 
@@ -184,5 +241,14 @@ export class AutomationRunner implements IAutomationRunner {
184
241
  "Failed to send payload to FireFlink API:", { cause: error }
185
242
  );
186
243
  }
244
+ finally {
245
+ await this.cleanup(
246
+ context,
247
+ domInfo,
248
+ extractedRelevantDom,
249
+ stepProcessor,
250
+ scriptRunner
251
+ );
252
+ }
187
253
  }
188
254
  }
@@ -88,7 +88,7 @@ export class DomSearcher {
88
88
 
89
89
  let matched = false;
90
90
 
91
- if (searchText === kw) {
91
+ if (searchText == kw) {
92
92
  exact.push(i);
93
93
  matched = true;
94
94
  }
@@ -105,7 +105,7 @@ export class DomSearcher {
105
105
  }
106
106
  }
107
107
 
108
- if (kw === tagName || tagName.includes(kw)) {
108
+ if (kw == tagName || tagName.includes(kw)) {
109
109
  tagMatches.push(i);
110
110
  }
111
111
  }
@@ -19,19 +19,25 @@ export async function getAnnotatedDOM(
19
19
  // ==========================================================
20
20
  function getCoverageStatus(el: Element): string {
21
21
 
22
- try {
23
- if (!el || !el.getBoundingClientRect) return "any";
22
+ if (!el || !el.getBoundingClientRect) return "unknown";
23
+
24
+ const rect = el.getBoundingClientRect();
25
+ if (rect.width === 0 || rect.height === 0) return "false";
24
26
 
25
- const rect = el.getBoundingClientRect();
26
- if (rect.width === 0 || rect.height === 0) return "false";
27
+ const testPoints = [
28
+ [rect.left + 5, rect.top + 5],
29
+ [rect.right - 5, rect.top + 5],
30
+ [rect.left + 5, rect.bottom - 5],
31
+ [rect.right - 5, rect.bottom - 5],
32
+ [rect.left + rect.width / 2, rect.top + rect.height / 2]
33
+ ];
27
34
 
28
- const x = rect.left + rect.width / 2;
29
- const y = rect.top + rect.height / 2;
35
+ for (let [x, y] of testPoints) {
30
36
 
31
- const topEl = el.ownerDocument.elementFromPoint(x, y);
32
- if (!topEl) return "false";
37
+ let topEl = document.elementFromPoint(x, y);
38
+ if (!topEl) continue;
33
39
 
34
- if (topEl === el || el.contains(topEl)) return "false";
40
+ if (topEl === el || el.contains(topEl)) continue;
35
41
 
36
42
  const style = getComputedStyle(topEl);
37
43
 
@@ -39,213 +45,275 @@ export async function getAnnotatedDOM(
39
45
  style.pointerEvents === "none" ||
40
46
  style.opacity === "0" ||
41
47
  style.visibility === "hidden"
42
- ) return "false";
48
+ ) continue;
43
49
 
44
- return "true";
45
- } catch {
46
- return "any";
50
+ if (el.closest("label")?.contains(topEl)) continue;
51
+ if (topEl.closest("label")?.contains(el)) continue;
52
+ if (el.parentElement?.contains(topEl)) continue;
53
+
54
+ const isBlocking =
55
+ ["BUTTON", "A"].includes(topEl.tagName) ||
56
+ (topEl as HTMLElement).onclick ||
57
+ topEl.getAttribute("role") === "button";
58
+
59
+ if (isBlocking) return "true";
47
60
  }
61
+
62
+ return "false";
48
63
  }
49
64
 
50
65
  // ==========================================================
51
- // INTERACTIVITY METADATA
66
+ // VISIBILITY CHECK
52
67
  // ==========================================================
53
- function annotateInteractivity(el: Element): void {
68
+ function isUsable(node: Element): boolean {
69
+
70
+ const s = getComputedStyle(node);
54
71
 
55
- const s = getComputedStyle(el);
72
+ if (
73
+ s.display === "none" ||
74
+ s.visibility === "hidden" ||
75
+ s.opacity === "0" ||
76
+ s.pointerEvents === "none"
77
+ ) return false;
56
78
 
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));
79
+ const rect = node.getBoundingClientRect();
80
+ if (rect.width === 0 || rect.height === 0) return false;
81
+
82
+ return true;
61
83
  }
62
84
 
63
85
  // ==========================================================
64
- // GLOBAL XPATH (Shadow + Iframe Safe)
86
+ // ANNOTATION
87
+ // ==========================================================
88
+ function annotate(node: Element): void {
89
+
90
+ const s = getComputedStyle(node);
91
+
92
+ node.setAttribute("element-visibility", s.visibility || "");
93
+ node.setAttribute("element-pointer-events", s.pointerEvents || "");
94
+ node.setAttribute("element-zindex", s.zIndex || "");
95
+
96
+ if (!isUsable(node)) {
97
+ node.setAttribute("element-covered", "true");
98
+ return;
99
+ }
100
+
101
+ try {
102
+ node.setAttribute(
103
+ "element-covered",
104
+ getCoverageStatus(node)
105
+ );
106
+ } catch {
107
+ node.setAttribute("element-covered", "unknown");
108
+ }
109
+ }
110
+
111
+ // ==========================================================
112
+ // XPATH (Shadow + iframe safe)
65
113
  // ==========================================================
66
114
  function getXPath(node: Element): string {
67
115
 
68
116
  if (!node || node.nodeType !== 1) return "";
69
117
 
70
- const segments: string[] = [];
71
- let current: Node | null = node;
118
+ let segments: string[] = [];
119
+ let current: Element | null = node;
72
120
 
73
121
  while (current && current.nodeType === 1) {
74
122
 
75
- const el = current as Element;
76
- const parent = el.parentNode;
123
+ const parent = current.parentNode;
124
+
125
+ if (parent instanceof ShadowRoot) {
126
+
127
+ const host = parent.host;
128
+
129
+ const siblings = Array.from(parent.children)
130
+ .filter(n => n.tagName === current!.tagName);
131
+
132
+ const index = siblings.indexOf(current) + 1;
133
+
134
+ segments.unshift(
135
+ "shadow/" +
136
+ current.tagName.toLowerCase() +
137
+ "[" + index + "]"
138
+ );
139
+
140
+ current = host as Element;
141
+ continue;
142
+ }
77
143
 
78
144
  let index = 1;
79
145
 
80
146
  if (parent && (parent as Element).children) {
147
+
81
148
  const siblings = Array.from(
82
149
  (parent as Element).children
83
- ).filter(n => n.tagName === el.tagName);
150
+ ).filter(n => n.tagName === current!.tagName);
84
151
 
85
- index = siblings.indexOf(el) + 1;
152
+ index = siblings.indexOf(current) + 1;
86
153
  }
87
154
 
88
155
  segments.unshift(
89
- el.tagName.toLowerCase() + "[" + index + "]"
156
+ current.tagName.toLowerCase() +
157
+ "[" + index + "]"
90
158
  );
91
159
 
92
- // ---------------------------
93
- // Shadow DOM handling
94
- // ---------------------------
95
- if (parent instanceof ShadowRoot) {
96
- segments.unshift("shadow");
97
- current = parent.host;
98
- continue;
99
- }
160
+ if (!parent || parent.nodeType !== 1) break;
100
161
 
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
- }
162
+ current = parent as Element;
163
+ }
119
164
 
120
- if (!parent || parent.nodeType !== 1) break;
165
+ let path = "/" + segments.join("/");
166
+
167
+ if (node.ownerDocument !== document) {
168
+
169
+ const frameEl =
170
+ node.ownerDocument.defaultView?.frameElement;
171
+
172
+ if (frameEl) {
121
173
 
122
- current = parent;
174
+ let framePath =
175
+ frameEl.getAttribute("ff-xpath");
176
+
177
+ if (!framePath) {
178
+ framePath = getXPath(frameEl);
179
+ }
180
+
181
+ path = framePath + path;
182
+ }
123
183
  }
124
184
 
125
- return "/" + segments.join("/");
185
+ return path;
126
186
  }
127
187
 
128
188
  // ==========================================================
129
- // PROCESS NODE
189
+ // TRUE NODE WALK (TreeWalker)
130
190
  // ==========================================================
131
- function processNode(node: Element): void {
191
+ function walk(root: Node): void {
132
192
 
133
- if (!node || node.nodeType !== 1) return;
193
+ const walker = document.createTreeWalker(
194
+ root,
195
+ NodeFilter.SHOW_ELEMENT,
196
+ null
197
+ );
134
198
 
135
- const fireId = `Fire-Flink-${nodeIndex++}`;
136
- const xpath = getXPath(node);
199
+ let node = walker.currentNode as Element | null;
137
200
 
138
- node.setAttribute("ff-inspect", fireId);
139
- node.setAttribute("ff-xpath", xpath);
201
+ while (node) {
140
202
 
141
- xpaths[fireId] = xpath;
203
+ if (
204
+ node.nodeType === 1 &&
205
+ !["META", "STYLE", "SCRIPT"]
206
+ .includes(node.tagName)
207
+ ) {
142
208
 
143
- annotateInteractivity(node);
209
+ const fireId =
210
+ "Fire-Flink-" + nodeIndex++;
144
211
 
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
- }
212
+ const xpath = getXPath(node);
153
213
 
154
- // ---------------------------
155
- // Same-origin iframe
156
- // ---------------------------
157
- if (node.tagName === "IFRAME") {
158
- try {
159
- const doc =
160
- (node as HTMLIFrameElement).contentDocument;
214
+ node.setAttribute("ff-inspect", fireId);
215
+ node.setAttribute("ff-xpath", xpath);
161
216
 
162
- if (doc) {
163
- processNode(doc.documentElement);
164
- doc.querySelectorAll("*")
165
- .forEach(el => processNode(el as Element));
217
+ xpaths[fireId] = xpath;
218
+
219
+ annotate(node);
220
+ }
221
+
222
+ if ((node as HTMLElement).shadowRoot) {
223
+ walk((node as HTMLElement).shadowRoot!);
224
+ }
225
+
226
+ if (node.tagName === "IFRAME") {
227
+ try {
228
+ const doc =
229
+ (node as HTMLIFrameElement)
230
+ .contentDocument;
231
+
232
+ if (doc?.documentElement) {
233
+ walk(doc.documentElement);
234
+ }
235
+ } catch {
236
+ node.setAttribute(
237
+ "ff-cross-origin",
238
+ "true"
239
+ );
166
240
  }
167
- } catch {
168
- node.setAttribute("ff-cross-origin", "true");
169
241
  }
170
- }
171
242
 
172
- node.querySelectorAll(":scope > *")
173
- .forEach(el => processNode(el as Element));
243
+ node = walker.nextNode() as Element | null;
244
+ }
174
245
  }
175
246
 
176
- // ==========================================================
177
- // INITIAL INJECTION
178
- // ==========================================================
179
- document.querySelectorAll("*")
180
- .forEach(el => processNode(el as Element));
247
+ // Start traversal
248
+ walk(document.documentElement);
181
249
 
182
250
  // ==========================================================
183
251
  // SERIALIZER
184
252
  // ==========================================================
185
253
  function serializeNode(node: Node): string {
186
254
 
187
- if (node.nodeType === Node.TEXT_NODE) {
255
+ if (node.nodeType === Node.TEXT_NODE)
188
256
  return node.textContent || "";
189
- }
190
257
 
191
- if (node.nodeType !== Node.ELEMENT_NODE) return "";
258
+ if (node.nodeType !== Node.ELEMENT_NODE)
259
+ return "";
192
260
 
193
261
  const el = node as Element;
194
262
  const tag = el.tagName.toLowerCase();
195
- let html = `<${tag}`;
196
263
 
197
- for (const attr of Array.from(el.attributes)) {
264
+ let html = "<" + tag;
198
265
 
199
- // Avoid dumping large srcdoc inline
266
+ for (const attr of Array.from(el.attributes)) {
200
267
  if (attr.name === "srcdoc") continue;
201
268
 
202
- html += ` ${attr.name}="${attr.value.replace(/"/g, "&quot;")}"`;
269
+ html +=
270
+ " " + attr.name +
271
+ "=\"" +
272
+ attr.value.replace(/"/g, "&quot;") +
273
+ "\"";
203
274
  }
204
275
 
205
276
  html += ">";
206
277
 
207
- // Shadow DOM serialization
208
- const shadowRoot = (el as HTMLElement).shadowRoot;
209
- if (shadowRoot) {
210
- html += "<!--#shadow-root-open-->";
211
- shadowRoot.querySelectorAll("*")
278
+ if ((el as HTMLElement).shadowRoot) {
279
+ html += "<!--#shadow-root-->";
280
+ (el as HTMLElement)
281
+ .shadowRoot!
282
+ .childNodes
212
283
  .forEach(child => {
213
284
  html += serializeNode(child);
214
285
  });
215
286
  html += "<!--#/shadow-root-->";
216
287
  }
217
288
 
218
- // Iframe serialization
219
289
  if (
220
290
  tag === "iframe" &&
221
291
  el.getAttribute("ff-cross-origin") !== "true"
222
292
  ) {
223
293
  try {
224
294
  const doc =
225
- (el as HTMLIFrameElement).contentDocument;
295
+ (el as HTMLIFrameElement)
296
+ .contentDocument;
226
297
 
227
298
  if (doc) {
228
- html += "<!--#iframe-content-->";
229
- html += serializeNode(doc.documentElement);
230
- html += "<!--#/iframe-content-->";
299
+ html += serializeNode(
300
+ doc.documentElement
301
+ );
231
302
  }
232
- } catch {
233
- // cross-origin iframe, cannot access content
234
- }
303
+ } catch { }
235
304
  }
236
305
 
237
- for (const child of Array.from(el.childNodes)) {
306
+ el.childNodes.forEach(child => {
238
307
  html += serializeNode(child);
239
- }
240
-
241
- html += `</${tag}>`;
308
+ });
242
309
 
310
+ html += "</" + tag + ">";
243
311
  return html;
244
312
  }
245
313
 
246
314
  return {
247
315
  dom:
248
- "<!DOCTYPE html>\\n" +
316
+ "<!DOCTYPE html>\n" +
249
317
  serializeNode(document.documentElement),
250
318
  selectors: xpaths
251
319
  };
@@ -0,0 +1,94 @@
1
+ import { JSDOM } from "jsdom";
2
+
3
+ export interface EnterActionResult {
4
+ ffNumber: number;
5
+ xpathPath: string;
6
+ }
7
+
8
+ export const enterActionHelper = (
9
+ pageDOM: string,
10
+ ffNumber: string
11
+ ): EnterActionResult => {
12
+
13
+ // Extract numeric part
14
+ const match = ffNumber.match(/\d+$/);
15
+ let numericFF = match ? parseInt(match[0], 10) : 0;
16
+
17
+ const parser = new JSDOM(pageDOM);
18
+ const doc = parser.window.document;
19
+
20
+ const isEditable = (el: Element | null): boolean => {
21
+ if (!el) return false;
22
+
23
+ const tag = el.tagName?.toLowerCase();
24
+
25
+ return (
26
+ ["input", "textarea", "select"].includes(tag) ||
27
+ el.getAttribute("contenteditable") === "true"
28
+ );
29
+ };
30
+
31
+ const getXPath = (el: Element | null): string => {
32
+ return el?.getAttribute("ff-xpath") || "";
33
+ };
34
+
35
+ let element = doc.querySelector(
36
+ `[ff-inspect="Fire-Flink-${numericFF}"]`
37
+ );
38
+
39
+ if (!element) {
40
+ return { ffNumber: numericFF, xpathPath: "" };
41
+ }
42
+
43
+ let xpathPath = "";
44
+
45
+ // Current element editable
46
+ if (isEditable(element)) {
47
+ xpathPath = getXPath(element);
48
+
49
+ } else {
50
+
51
+ // Search editable child
52
+ let editableChild: Element | null = null;
53
+
54
+ for (const child of Array.from(element.querySelectorAll("*"))) {
55
+ if (isEditable(child)) {
56
+ editableChild = child;
57
+ break;
58
+ }
59
+ }
60
+
61
+ if (editableChild) {
62
+ xpathPath = getXPath(editableChild);
63
+
64
+ } else {
65
+
66
+ // Try next 10 FF elements
67
+ for (let offset = 1; offset <= 10; offset++) {
68
+ const nextNum = numericFF + offset;
69
+
70
+ const nextTarget = doc.querySelector(
71
+ `[ff-inspect="Fire-Flink-${nextNum}"]`
72
+ );
73
+
74
+ if (isEditable(nextTarget)) {
75
+ numericFF = nextNum;
76
+ xpathPath = getXPath(nextTarget);
77
+ break;
78
+ }
79
+ }
80
+
81
+ // Final fallback
82
+ if (!xpathPath) {
83
+ xpathPath = getXPath(element);
84
+ }
85
+ }
86
+ }
87
+
88
+ // console.log("xpathPath", xpathPath);
89
+
90
+ return {
91
+ ffNumber: numericFF,
92
+ xpathPath
93
+ };
94
+ };