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 +1 -1
- package/src/ai/llmcalls/parseLlmOputput.ts +3 -0
- package/src/ai/llmprompts/systemPrompts/actionExtractorPrompt.ts +3 -3
- package/src/ai/llmprompts/systemPrompts/fireflinkElementIndexExtactors.ts +2 -2
- package/src/automation/actions/interaction/enterInput.ts +11 -2
- package/src/core/main/runAutomationScript.ts +69 -3
- package/src/domAnalysis/searchBest.ts +2 -2
- package/src/utils/DomExtraction/jsForAttributeInjection.ts +188 -120
- package/src/utils/helpers/enterActionHelper.ts +94 -0
package/package.json
CHANGED
|
@@ -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
|
-
"
|
|
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
|
-
"
|
|
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
|
-
|
|
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="
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
23
|
-
|
|
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
|
-
|
|
26
|
-
|
|
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
|
-
|
|
29
|
-
const y = rect.top + rect.height / 2;
|
|
35
|
+
for (let [x, y] of testPoints) {
|
|
30
36
|
|
|
31
|
-
|
|
32
|
-
if (!topEl)
|
|
37
|
+
let topEl = document.elementFromPoint(x, y);
|
|
38
|
+
if (!topEl) continue;
|
|
33
39
|
|
|
34
|
-
if (topEl === el || el.contains(topEl))
|
|
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
|
-
)
|
|
48
|
+
) continue;
|
|
43
49
|
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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
|
-
//
|
|
66
|
+
// VISIBILITY CHECK
|
|
52
67
|
// ==========================================================
|
|
53
|
-
function
|
|
68
|
+
function isUsable(node: Element): boolean {
|
|
69
|
+
|
|
70
|
+
const s = getComputedStyle(node);
|
|
54
71
|
|
|
55
|
-
|
|
72
|
+
if (
|
|
73
|
+
s.display === "none" ||
|
|
74
|
+
s.visibility === "hidden" ||
|
|
75
|
+
s.opacity === "0" ||
|
|
76
|
+
s.pointerEvents === "none"
|
|
77
|
+
) return false;
|
|
56
78
|
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
71
|
-
let current:
|
|
118
|
+
let segments: string[] = [];
|
|
119
|
+
let current: Element | null = node;
|
|
72
120
|
|
|
73
121
|
while (current && current.nodeType === 1) {
|
|
74
122
|
|
|
75
|
-
const
|
|
76
|
-
|
|
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 ===
|
|
150
|
+
).filter(n => n.tagName === current!.tagName);
|
|
84
151
|
|
|
85
|
-
index = siblings.indexOf(
|
|
152
|
+
index = siblings.indexOf(current) + 1;
|
|
86
153
|
}
|
|
87
154
|
|
|
88
155
|
segments.unshift(
|
|
89
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
185
|
+
return path;
|
|
126
186
|
}
|
|
127
187
|
|
|
128
188
|
// ==========================================================
|
|
129
|
-
//
|
|
189
|
+
// TRUE NODE WALK (TreeWalker)
|
|
130
190
|
// ==========================================================
|
|
131
|
-
function
|
|
191
|
+
function walk(root: Node): void {
|
|
132
192
|
|
|
133
|
-
|
|
193
|
+
const walker = document.createTreeWalker(
|
|
194
|
+
root,
|
|
195
|
+
NodeFilter.SHOW_ELEMENT,
|
|
196
|
+
null
|
|
197
|
+
);
|
|
134
198
|
|
|
135
|
-
|
|
136
|
-
const xpath = getXPath(node);
|
|
199
|
+
let node = walker.currentNode as Element | null;
|
|
137
200
|
|
|
138
|
-
node
|
|
139
|
-
node.setAttribute("ff-xpath", xpath);
|
|
201
|
+
while (node) {
|
|
140
202
|
|
|
141
|
-
|
|
203
|
+
if (
|
|
204
|
+
node.nodeType === 1 &&
|
|
205
|
+
!["META", "STYLE", "SCRIPT"]
|
|
206
|
+
.includes(node.tagName)
|
|
207
|
+
) {
|
|
142
208
|
|
|
143
|
-
|
|
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
|
-
|
|
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
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
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
|
-
|
|
173
|
-
|
|
243
|
+
node = walker.nextNode() as Element | null;
|
|
244
|
+
}
|
|
174
245
|
}
|
|
175
246
|
|
|
176
|
-
//
|
|
177
|
-
|
|
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)
|
|
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
|
-
|
|
264
|
+
let html = "<" + tag;
|
|
198
265
|
|
|
199
|
-
|
|
266
|
+
for (const attr of Array.from(el.attributes)) {
|
|
200
267
|
if (attr.name === "srcdoc") continue;
|
|
201
268
|
|
|
202
|
-
html +=
|
|
269
|
+
html +=
|
|
270
|
+
" " + attr.name +
|
|
271
|
+
"=\"" +
|
|
272
|
+
attr.value.replace(/"/g, """) +
|
|
273
|
+
"\"";
|
|
203
274
|
}
|
|
204
275
|
|
|
205
276
|
html += ">";
|
|
206
277
|
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
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)
|
|
295
|
+
(el as HTMLIFrameElement)
|
|
296
|
+
.contentDocument;
|
|
226
297
|
|
|
227
298
|
if (doc) {
|
|
228
|
-
html +=
|
|
229
|
-
|
|
230
|
-
|
|
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
|
-
|
|
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
|
|
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
|
+
};
|