@auto-wiz/dom 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.
- package/dist/index.d.ts +4 -0
- package/dist/index.js +5 -0
- package/dist/runner.d.ts +6 -0
- package/dist/runner.js +45 -0
- package/dist/selectors/locatorUtils.d.ts +64 -0
- package/dist/selectors/locatorUtils.js +394 -0
- package/dist/selectors/selectorGenerator.d.ts +54 -0
- package/dist/selectors/selectorGenerator.js +299 -0
- package/dist/steps/stepExecution.d.ts +39 -0
- package/dist/steps/stepExecution.js +285 -0
- package/package.json +34 -0
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
package/dist/runner.d.ts
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import { type FlowRunner, type RunResult, type ExecutionResult, type RunnerOptions, type Flow, type Step } from "@auto-wiz/core";
|
|
2
|
+
export declare class DomFlowRunner implements FlowRunner<void> {
|
|
3
|
+
run(flow: Flow, _context?: any, // unused
|
|
4
|
+
_options?: RunnerOptions): Promise<RunResult>;
|
|
5
|
+
runStep(step: Step): Promise<ExecutionResult>;
|
|
6
|
+
}
|
package/dist/runner.js
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { executeStep } from "./steps/stepExecution";
|
|
2
|
+
export class DomFlowRunner {
|
|
3
|
+
async run(flow, _context = {}, // unused
|
|
4
|
+
_options = {} // unused
|
|
5
|
+
) {
|
|
6
|
+
const extractedData = {};
|
|
7
|
+
const steps = flow.steps;
|
|
8
|
+
for (let i = 0; i < steps.length; i++) {
|
|
9
|
+
const step = steps[i];
|
|
10
|
+
try {
|
|
11
|
+
const result = await this.runStep(step);
|
|
12
|
+
if (!result.success) {
|
|
13
|
+
return {
|
|
14
|
+
success: false,
|
|
15
|
+
error: result.error,
|
|
16
|
+
failedStepIndex: i,
|
|
17
|
+
extractedData,
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
if (result.extractedData) {
|
|
21
|
+
extractedData[`step_${i}`] = result.extractedData;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
catch (e) {
|
|
25
|
+
return {
|
|
26
|
+
success: false,
|
|
27
|
+
error: e.message,
|
|
28
|
+
failedStepIndex: i,
|
|
29
|
+
extractedData,
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
return { success: true, extractedData };
|
|
34
|
+
}
|
|
35
|
+
async runStep(step) {
|
|
36
|
+
try {
|
|
37
|
+
// Direct DOM execution using existing logic
|
|
38
|
+
const result = await executeStep(step);
|
|
39
|
+
return result;
|
|
40
|
+
}
|
|
41
|
+
catch (error) {
|
|
42
|
+
return { success: false, error: error.message };
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Locator 유틸리티 - 고급 요소 검색 기능
|
|
3
|
+
* Playwright/Maestro 스타일의 텍스트 기반, role 기반 매칭
|
|
4
|
+
*/
|
|
5
|
+
import type { ElementLocator } from "@auto-wiz/core";
|
|
6
|
+
/**
|
|
7
|
+
* Role로 요소 찾기 (ARIA role)
|
|
8
|
+
*/
|
|
9
|
+
export declare function findByRole(role: string, options?: {
|
|
10
|
+
name?: string;
|
|
11
|
+
exact?: boolean;
|
|
12
|
+
level?: number;
|
|
13
|
+
}): HTMLElement[];
|
|
14
|
+
/**
|
|
15
|
+
* 텍스트로 찾기 (공백/대소문자 무시)
|
|
16
|
+
*/
|
|
17
|
+
export declare function findByCleanText(text: string): HTMLElement[];
|
|
18
|
+
/**
|
|
19
|
+
* 텍스트로 찾기 (부분 일치)
|
|
20
|
+
*/
|
|
21
|
+
export declare function findByFuzzyText(text: string): HTMLElement[];
|
|
22
|
+
/**
|
|
23
|
+
* 텍스트로 요소 찾기 (Playwright 스타일)
|
|
24
|
+
*/
|
|
25
|
+
export declare function findByText(text: string, options?: {
|
|
26
|
+
exact?: boolean;
|
|
27
|
+
normalize?: boolean;
|
|
28
|
+
selector?: string;
|
|
29
|
+
role?: string;
|
|
30
|
+
}): HTMLElement[];
|
|
31
|
+
/**
|
|
32
|
+
* Placeholder로 요소 찾기
|
|
33
|
+
*/
|
|
34
|
+
export declare function findByPlaceholder(text: string, options?: {
|
|
35
|
+
exact?: boolean;
|
|
36
|
+
}): HTMLElement[];
|
|
37
|
+
/**
|
|
38
|
+
* Label로 요소 찾기 (form inputs)
|
|
39
|
+
*/
|
|
40
|
+
export declare function findByLabelText(text: string, options?: {
|
|
41
|
+
exact?: boolean;
|
|
42
|
+
}): HTMLElement[];
|
|
43
|
+
/**
|
|
44
|
+
* TestID로 요소 찾기
|
|
45
|
+
*/
|
|
46
|
+
export declare function findByTestId(testId: string): HTMLElement | null;
|
|
47
|
+
/**
|
|
48
|
+
* ElementLocator로 요소 찾기 (fallback 지원)
|
|
49
|
+
*
|
|
50
|
+
* Primary selector부터 시도하고, 실패하면 fallback들을 순차적으로 시도
|
|
51
|
+
*/
|
|
52
|
+
export declare function findByLocator(locator: ElementLocator): HTMLElement | null;
|
|
53
|
+
/**
|
|
54
|
+
* 요소가 상호작용 가능한지 확인 (enabled + visible)
|
|
55
|
+
*/
|
|
56
|
+
export declare function isInteractable(element: HTMLElement): boolean;
|
|
57
|
+
/**
|
|
58
|
+
* Smart waiting: 요소가 나타날 때까지 대기
|
|
59
|
+
*/
|
|
60
|
+
export declare function waitForLocator(locator: ElementLocator, options?: {
|
|
61
|
+
timeout?: number;
|
|
62
|
+
visible?: boolean;
|
|
63
|
+
interactable?: boolean;
|
|
64
|
+
}): Promise<HTMLElement>;
|
|
@@ -0,0 +1,394 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Locator 유틸리티 - 고급 요소 검색 기능
|
|
3
|
+
* Playwright/Maestro 스타일의 텍스트 기반, role 기반 매칭
|
|
4
|
+
*/
|
|
5
|
+
/**
|
|
6
|
+
* 텍스트 정규화 (공백, 대소문자 무시)
|
|
7
|
+
*/
|
|
8
|
+
function normalizeText(text) {
|
|
9
|
+
return text
|
|
10
|
+
.toLowerCase()
|
|
11
|
+
.replace(/\s+/g, " ")
|
|
12
|
+
.trim();
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* 요소의 접근 가능한 이름(accessible name) 계산
|
|
16
|
+
* ARIA 명세에 따른 우선순위
|
|
17
|
+
*/
|
|
18
|
+
function getAccessibleName(element) {
|
|
19
|
+
// 1. aria-label
|
|
20
|
+
const ariaLabel = element.getAttribute("aria-label");
|
|
21
|
+
if (ariaLabel)
|
|
22
|
+
return ariaLabel;
|
|
23
|
+
// 2. aria-labelledby
|
|
24
|
+
const labelledby = element.getAttribute("aria-labelledby");
|
|
25
|
+
if (labelledby) {
|
|
26
|
+
const labelEl = document.getElementById(labelledby);
|
|
27
|
+
if (labelEl)
|
|
28
|
+
return labelEl.textContent?.trim() || "";
|
|
29
|
+
}
|
|
30
|
+
// 3. label 요소 (form controls)
|
|
31
|
+
if (element instanceof HTMLInputElement ||
|
|
32
|
+
element instanceof HTMLTextAreaElement ||
|
|
33
|
+
element instanceof HTMLSelectElement) {
|
|
34
|
+
const id = element.id;
|
|
35
|
+
if (id) {
|
|
36
|
+
const label = document.querySelector(`label[for="${id}"]`);
|
|
37
|
+
if (label)
|
|
38
|
+
return label.textContent?.trim() || "";
|
|
39
|
+
}
|
|
40
|
+
// 부모 label
|
|
41
|
+
const parentLabel = element.closest("label");
|
|
42
|
+
if (parentLabel)
|
|
43
|
+
return parentLabel.textContent?.trim() || "";
|
|
44
|
+
}
|
|
45
|
+
// 4. alt (이미지)
|
|
46
|
+
if (element instanceof HTMLImageElement) {
|
|
47
|
+
return element.alt;
|
|
48
|
+
}
|
|
49
|
+
// 5. placeholder
|
|
50
|
+
const placeholder = element.getAttribute("placeholder");
|
|
51
|
+
if (placeholder)
|
|
52
|
+
return placeholder;
|
|
53
|
+
// 6. title
|
|
54
|
+
const title = element.getAttribute("title");
|
|
55
|
+
if (title)
|
|
56
|
+
return title;
|
|
57
|
+
// 7. 텍스트 내용
|
|
58
|
+
return element.textContent?.trim() || "";
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Role로 요소 찾기 (ARIA role)
|
|
62
|
+
*/
|
|
63
|
+
export function findByRole(role, options) {
|
|
64
|
+
const allElements = Array.from(document.querySelectorAll("*"));
|
|
65
|
+
const results = [];
|
|
66
|
+
for (const el of allElements) {
|
|
67
|
+
if (!(el instanceof HTMLElement))
|
|
68
|
+
continue;
|
|
69
|
+
// Role 확인
|
|
70
|
+
const explicitRole = el.getAttribute("role");
|
|
71
|
+
const implicitRole = getImplicitRole(el);
|
|
72
|
+
const elementRole = explicitRole || implicitRole;
|
|
73
|
+
if (elementRole !== role)
|
|
74
|
+
continue;
|
|
75
|
+
// Level 확인 (heading용)
|
|
76
|
+
if (options?.level !== undefined) {
|
|
77
|
+
const tagName = el.tagName.toLowerCase();
|
|
78
|
+
const headingLevel = parseInt(tagName.charAt(1), 10);
|
|
79
|
+
if (headingLevel !== options.level)
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
// Name 확인
|
|
83
|
+
if (options?.name !== undefined) {
|
|
84
|
+
const accessibleName = getAccessibleName(el);
|
|
85
|
+
if (options.exact) {
|
|
86
|
+
if (accessibleName !== options.name)
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
89
|
+
else {
|
|
90
|
+
if (!accessibleName.includes(options.name))
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
results.push(el);
|
|
95
|
+
}
|
|
96
|
+
return results;
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* 텍스트로 찾기 (공백/대소문자 무시)
|
|
100
|
+
*/
|
|
101
|
+
export function findByCleanText(text) {
|
|
102
|
+
return findByText(text, { normalize: true });
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* 텍스트로 찾기 (부분 일치)
|
|
106
|
+
*/
|
|
107
|
+
export function findByFuzzyText(text) {
|
|
108
|
+
return findByText(text, { exact: false });
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* 암시적 ARIA role 추론
|
|
112
|
+
*/
|
|
113
|
+
function getImplicitRole(element) {
|
|
114
|
+
const tagName = element.tagName.toLowerCase();
|
|
115
|
+
const type = element.getAttribute("type");
|
|
116
|
+
const roleMap = {
|
|
117
|
+
a: element.hasAttribute("href") ? "link" : "",
|
|
118
|
+
button: "button",
|
|
119
|
+
input: type === "text" || !type ? "textbox" :
|
|
120
|
+
type === "checkbox" ? "checkbox" :
|
|
121
|
+
type === "radio" ? "radio" :
|
|
122
|
+
type === "button" || type === "submit" ? "button" : "",
|
|
123
|
+
textarea: "textbox",
|
|
124
|
+
select: "combobox",
|
|
125
|
+
img: "img",
|
|
126
|
+
h1: "heading",
|
|
127
|
+
h2: "heading",
|
|
128
|
+
h3: "heading",
|
|
129
|
+
h4: "heading",
|
|
130
|
+
h5: "heading",
|
|
131
|
+
h6: "heading",
|
|
132
|
+
nav: "navigation",
|
|
133
|
+
main: "main",
|
|
134
|
+
aside: "complementary",
|
|
135
|
+
header: "banner",
|
|
136
|
+
footer: "contentinfo",
|
|
137
|
+
section: "region",
|
|
138
|
+
article: "article",
|
|
139
|
+
form: "form",
|
|
140
|
+
table: "table",
|
|
141
|
+
ul: "list",
|
|
142
|
+
ol: "list",
|
|
143
|
+
li: "listitem",
|
|
144
|
+
};
|
|
145
|
+
return roleMap[tagName] || null;
|
|
146
|
+
}
|
|
147
|
+
/**
|
|
148
|
+
* 텍스트로 요소 찾기 (Playwright 스타일)
|
|
149
|
+
*/
|
|
150
|
+
export function findByText(text, options) {
|
|
151
|
+
const container = options?.selector
|
|
152
|
+
? document.querySelector(options.selector)
|
|
153
|
+
: document.body;
|
|
154
|
+
if (!container)
|
|
155
|
+
return [];
|
|
156
|
+
const allElements = Array.from(container.querySelectorAll("*"));
|
|
157
|
+
const results = [];
|
|
158
|
+
for (const el of allElements) {
|
|
159
|
+
if (!(el instanceof HTMLElement))
|
|
160
|
+
continue;
|
|
161
|
+
// Role 필터
|
|
162
|
+
if (options?.role) {
|
|
163
|
+
const elementRole = el.getAttribute("role") || getImplicitRole(el);
|
|
164
|
+
if (elementRole !== options.role)
|
|
165
|
+
continue;
|
|
166
|
+
}
|
|
167
|
+
// 텍스트 내용 가져오기
|
|
168
|
+
let elementText = getAccessibleName(el);
|
|
169
|
+
if (!elementText) {
|
|
170
|
+
elementText = el.textContent || "";
|
|
171
|
+
}
|
|
172
|
+
// 자식 요소의 텍스트는 제외 (직접 텍스트만)
|
|
173
|
+
if (el.children.length > 0) {
|
|
174
|
+
let directText = "";
|
|
175
|
+
for (const node of el.childNodes) {
|
|
176
|
+
if (node.nodeType === Node.TEXT_NODE) {
|
|
177
|
+
directText += node.textContent || "";
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
if (directText.trim()) {
|
|
181
|
+
elementText = directText;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
// 매칭
|
|
185
|
+
if (options?.normalize) {
|
|
186
|
+
if (normalizeText(elementText) === normalizeText(text)) {
|
|
187
|
+
results.push(el);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
else if (options?.exact) {
|
|
191
|
+
if (elementText.trim() === text) {
|
|
192
|
+
results.push(el);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
else {
|
|
196
|
+
if (elementText.includes(text)) {
|
|
197
|
+
results.push(el);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
return results;
|
|
202
|
+
}
|
|
203
|
+
/**
|
|
204
|
+
* Placeholder로 요소 찾기
|
|
205
|
+
*/
|
|
206
|
+
export function findByPlaceholder(text, options) {
|
|
207
|
+
const selector = options?.exact
|
|
208
|
+
? `[placeholder="${text}"]`
|
|
209
|
+
: `[placeholder*="${text}"]`;
|
|
210
|
+
return Array.from(document.querySelectorAll(selector)).filter((el) => el instanceof HTMLElement);
|
|
211
|
+
}
|
|
212
|
+
/**
|
|
213
|
+
* Label로 요소 찾기 (form inputs)
|
|
214
|
+
*/
|
|
215
|
+
export function findByLabelText(text, options) {
|
|
216
|
+
const labels = Array.from(document.querySelectorAll("label"));
|
|
217
|
+
const results = [];
|
|
218
|
+
for (const label of labels) {
|
|
219
|
+
const labelText = label.textContent?.trim() || "";
|
|
220
|
+
const matches = options?.exact
|
|
221
|
+
? labelText === text
|
|
222
|
+
: labelText.includes(text);
|
|
223
|
+
if (!matches)
|
|
224
|
+
continue;
|
|
225
|
+
// label[for] 참조
|
|
226
|
+
const forAttr = label.getAttribute("for");
|
|
227
|
+
if (forAttr) {
|
|
228
|
+
const input = document.getElementById(forAttr);
|
|
229
|
+
if (input instanceof HTMLElement) {
|
|
230
|
+
results.push(input);
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
else {
|
|
234
|
+
// label 내부의 input
|
|
235
|
+
const input = label.querySelector("input, textarea, select");
|
|
236
|
+
if (input instanceof HTMLElement) {
|
|
237
|
+
results.push(input);
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
return results;
|
|
242
|
+
}
|
|
243
|
+
/**
|
|
244
|
+
* TestID로 요소 찾기
|
|
245
|
+
*/
|
|
246
|
+
export function findByTestId(testId) {
|
|
247
|
+
const selectors = [
|
|
248
|
+
`[data-testid="${testId}"]`,
|
|
249
|
+
`[data-test="${testId}"]`,
|
|
250
|
+
`[data-cy="${testId}"]`,
|
|
251
|
+
`[data-test-id="${testId}"]`,
|
|
252
|
+
];
|
|
253
|
+
for (const selector of selectors) {
|
|
254
|
+
const el = document.querySelector(selector);
|
|
255
|
+
if (el instanceof HTMLElement)
|
|
256
|
+
return el;
|
|
257
|
+
}
|
|
258
|
+
return null;
|
|
259
|
+
}
|
|
260
|
+
/**
|
|
261
|
+
* ElementLocator로 요소 찾기 (fallback 지원)
|
|
262
|
+
*
|
|
263
|
+
* Primary selector부터 시도하고, 실패하면 fallback들을 순차적으로 시도
|
|
264
|
+
*/
|
|
265
|
+
export function findByLocator(locator) {
|
|
266
|
+
// 1. Primary selector 시도
|
|
267
|
+
try {
|
|
268
|
+
const el = document.querySelector(locator.primary);
|
|
269
|
+
if (el instanceof HTMLElement && isVisible(el)) {
|
|
270
|
+
return el;
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
catch (error) {
|
|
274
|
+
console.warn(`Primary selector failed: ${locator.primary}`, error);
|
|
275
|
+
}
|
|
276
|
+
// 2. Fallback selectors 순차 시도
|
|
277
|
+
for (const selector of locator.fallbacks) {
|
|
278
|
+
try {
|
|
279
|
+
const el = document.querySelector(selector);
|
|
280
|
+
if (el instanceof HTMLElement && isVisible(el)) {
|
|
281
|
+
return el;
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
catch (error) {
|
|
285
|
+
console.warn(`Fallback selector failed: ${selector}`, error);
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
// 3. Metadata 기반 fuzzy matching
|
|
289
|
+
if (!locator.metadata)
|
|
290
|
+
return null;
|
|
291
|
+
// TestID로 시도
|
|
292
|
+
if (locator.metadata.testId) {
|
|
293
|
+
const el = findByTestId(locator.metadata.testId);
|
|
294
|
+
if (el && isVisible(el))
|
|
295
|
+
return el;
|
|
296
|
+
}
|
|
297
|
+
// 텍스트로 시도 (role 필터링)
|
|
298
|
+
if (locator.metadata.text) {
|
|
299
|
+
const elements = findByText(locator.metadata.text, {
|
|
300
|
+
normalize: true,
|
|
301
|
+
role: locator.metadata.role,
|
|
302
|
+
});
|
|
303
|
+
// tagName도 일치하는 것 우선
|
|
304
|
+
if (locator.metadata.tagName) {
|
|
305
|
+
const matchingTag = elements.find((el) => el.tagName.toLowerCase() === locator.metadata.tagName);
|
|
306
|
+
if (matchingTag)
|
|
307
|
+
return matchingTag;
|
|
308
|
+
}
|
|
309
|
+
if (elements.length > 0 && isVisible(elements[0])) {
|
|
310
|
+
return elements[0];
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
// Placeholder로 시도
|
|
314
|
+
if (locator.metadata.placeholder) {
|
|
315
|
+
const elements = findByPlaceholder(locator.metadata.placeholder, {
|
|
316
|
+
exact: true,
|
|
317
|
+
});
|
|
318
|
+
if (elements.length > 0 && isVisible(elements[0])) {
|
|
319
|
+
return elements[0];
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
// Label로 시도
|
|
323
|
+
if (locator.metadata.ariaLabel) {
|
|
324
|
+
const elements = findByLabelText(locator.metadata.ariaLabel, {
|
|
325
|
+
exact: true,
|
|
326
|
+
});
|
|
327
|
+
if (elements.length > 0 && isVisible(elements[0])) {
|
|
328
|
+
return elements[0];
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
return null;
|
|
332
|
+
}
|
|
333
|
+
/**
|
|
334
|
+
* 요소가 화면에 보이는지 확인
|
|
335
|
+
*/
|
|
336
|
+
function isVisible(element) {
|
|
337
|
+
// BODY와 HTML은 항상 visible로 간주
|
|
338
|
+
if (element.tagName === "BODY" || element.tagName === "HTML") {
|
|
339
|
+
return true;
|
|
340
|
+
}
|
|
341
|
+
const style = window.getComputedStyle(element);
|
|
342
|
+
if (style.display === "none" ||
|
|
343
|
+
style.visibility === "hidden" ||
|
|
344
|
+
style.opacity === "0") {
|
|
345
|
+
return false;
|
|
346
|
+
}
|
|
347
|
+
// offsetParent는 happy-dom에서 제대로 동작하지 않을 수 있음
|
|
348
|
+
// 위의 스타일 체크만으로 충분
|
|
349
|
+
return true;
|
|
350
|
+
}
|
|
351
|
+
/**
|
|
352
|
+
* 요소가 상호작용 가능한지 확인 (enabled + visible)
|
|
353
|
+
*/
|
|
354
|
+
export function isInteractable(element) {
|
|
355
|
+
if (!isVisible(element))
|
|
356
|
+
return false;
|
|
357
|
+
if (element instanceof HTMLInputElement ||
|
|
358
|
+
element instanceof HTMLTextAreaElement ||
|
|
359
|
+
element instanceof HTMLSelectElement ||
|
|
360
|
+
element instanceof HTMLButtonElement) {
|
|
361
|
+
if (element.disabled)
|
|
362
|
+
return false;
|
|
363
|
+
}
|
|
364
|
+
const style = window.getComputedStyle(element);
|
|
365
|
+
if (style.pointerEvents === "none")
|
|
366
|
+
return false;
|
|
367
|
+
return true;
|
|
368
|
+
}
|
|
369
|
+
/**
|
|
370
|
+
* Smart waiting: 요소가 나타날 때까지 대기
|
|
371
|
+
*/
|
|
372
|
+
export async function waitForLocator(locator, options) {
|
|
373
|
+
const timeout = options?.timeout || 5000;
|
|
374
|
+
const startTime = Date.now();
|
|
375
|
+
const pollInterval = 100;
|
|
376
|
+
while (Date.now() - startTime < timeout) {
|
|
377
|
+
const element = findByLocator(locator);
|
|
378
|
+
if (element) {
|
|
379
|
+
// visible 체크
|
|
380
|
+
if (options?.visible && !isVisible(element)) {
|
|
381
|
+
await new Promise((resolve) => setTimeout(resolve, pollInterval));
|
|
382
|
+
continue;
|
|
383
|
+
}
|
|
384
|
+
// interactable 체크
|
|
385
|
+
if (options?.interactable && !isInteractable(element)) {
|
|
386
|
+
await new Promise((resolve) => setTimeout(resolve, pollInterval));
|
|
387
|
+
continue;
|
|
388
|
+
}
|
|
389
|
+
return element;
|
|
390
|
+
}
|
|
391
|
+
await new Promise((resolve) => setTimeout(resolve, pollInterval));
|
|
392
|
+
}
|
|
393
|
+
throw new Error(`Timeout waiting for element. Primary selector: ${locator.primary}`);
|
|
394
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CSS Selector 생성 유틸리티
|
|
3
|
+
*
|
|
4
|
+
* Playwright/Maestro 스타일의 다중 selector 전략:
|
|
5
|
+
*
|
|
6
|
+
* Tier 1 (가장 안정적):
|
|
7
|
+
* - data-testid, data-test, data-cy
|
|
8
|
+
* - id 속성
|
|
9
|
+
* - name 속성
|
|
10
|
+
* - ARIA labels
|
|
11
|
+
*
|
|
12
|
+
* Tier 2 (의미론적):
|
|
13
|
+
* - role + 텍스트
|
|
14
|
+
* - placeholder, title, alt
|
|
15
|
+
*
|
|
16
|
+
* Tier 3 (구조적):
|
|
17
|
+
* - CSS selector (class + structure)
|
|
18
|
+
* - nth-of-type
|
|
19
|
+
*
|
|
20
|
+
* Tier 4 (텍스트 기반):
|
|
21
|
+
* - 텍스트 내용 검색
|
|
22
|
+
*/
|
|
23
|
+
import type { ElementLocator } from "@auto-wiz/core";
|
|
24
|
+
/**
|
|
25
|
+
* 단순 selector 생성 (빠른 선택용)
|
|
26
|
+
* ID가 있으면 ID만 사용, 없으면 전체 경로 생성
|
|
27
|
+
*/
|
|
28
|
+
export declare function getSimpleSelector(el: Element): string;
|
|
29
|
+
/**
|
|
30
|
+
* 상세한 selector 생성 (안정성 우선)
|
|
31
|
+
* data-testid, aria-label 등 안정적인 속성 우선 사용
|
|
32
|
+
*/
|
|
33
|
+
export declare function makeSelector(el: HTMLElement): string;
|
|
34
|
+
/**
|
|
35
|
+
* Selector가 유효한지 검증
|
|
36
|
+
*/
|
|
37
|
+
export declare function isValidSelector(selector: string): boolean;
|
|
38
|
+
/**
|
|
39
|
+
* Selector로 단일 요소 찾기 (안전)
|
|
40
|
+
*/
|
|
41
|
+
export declare function querySelector(selector: string): HTMLElement | null;
|
|
42
|
+
/**
|
|
43
|
+
* Selector로 여러 요소 찾기 (안전)
|
|
44
|
+
*/
|
|
45
|
+
export declare function querySelectorAll(selector: string): HTMLElement[];
|
|
46
|
+
/**
|
|
47
|
+
* Robust한 다중 selector 생성 (Playwright/Maestro 스타일)
|
|
48
|
+
*/
|
|
49
|
+
export declare function generateRobustLocator(element: HTMLElement): ElementLocator;
|
|
50
|
+
/**
|
|
51
|
+
* 단순 selector 생성 래퍼 (하위 호환성)
|
|
52
|
+
* @deprecated generateRobustLocator 사용 권장
|
|
53
|
+
*/
|
|
54
|
+
export declare function generateSelector(element: HTMLElement): string;
|
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CSS Selector 생성 유틸리티
|
|
3
|
+
*
|
|
4
|
+
* Playwright/Maestro 스타일의 다중 selector 전략:
|
|
5
|
+
*
|
|
6
|
+
* Tier 1 (가장 안정적):
|
|
7
|
+
* - data-testid, data-test, data-cy
|
|
8
|
+
* - id 속성
|
|
9
|
+
* - name 속성
|
|
10
|
+
* - ARIA labels
|
|
11
|
+
*
|
|
12
|
+
* Tier 2 (의미론적):
|
|
13
|
+
* - role + 텍스트
|
|
14
|
+
* - placeholder, title, alt
|
|
15
|
+
*
|
|
16
|
+
* Tier 3 (구조적):
|
|
17
|
+
* - CSS selector (class + structure)
|
|
18
|
+
* - nth-of-type
|
|
19
|
+
*
|
|
20
|
+
* Tier 4 (텍스트 기반):
|
|
21
|
+
* - 텍스트 내용 검색
|
|
22
|
+
*/
|
|
23
|
+
/**
|
|
24
|
+
* 단순 selector 생성 (빠른 선택용)
|
|
25
|
+
* ID가 있으면 ID만 사용, 없으면 전체 경로 생성
|
|
26
|
+
*/
|
|
27
|
+
export function getSimpleSelector(el) {
|
|
28
|
+
if (!(el instanceof Element))
|
|
29
|
+
return "";
|
|
30
|
+
if (el.id)
|
|
31
|
+
return `#${CSS.escape(el.id)}`;
|
|
32
|
+
const parts = [];
|
|
33
|
+
let node = el;
|
|
34
|
+
while (node && node.nodeType === 1 && parts.length < 5) {
|
|
35
|
+
let part = node.tagName.toLowerCase();
|
|
36
|
+
if (node.classList.length > 0) {
|
|
37
|
+
const cls = Array.from(node.classList)
|
|
38
|
+
.slice(0, 2)
|
|
39
|
+
.map((c) => `.${CSS.escape(c)}`)
|
|
40
|
+
.join("");
|
|
41
|
+
part += cls;
|
|
42
|
+
}
|
|
43
|
+
// nth-child
|
|
44
|
+
let idx = 1;
|
|
45
|
+
let sib = node;
|
|
46
|
+
while ((sib = sib.previousElementSibling)) {
|
|
47
|
+
if (sib && sib.tagName === node.tagName)
|
|
48
|
+
idx++;
|
|
49
|
+
}
|
|
50
|
+
part += `:nth-of-type(${idx})`;
|
|
51
|
+
parts.unshift(part);
|
|
52
|
+
node = node.parentElement;
|
|
53
|
+
}
|
|
54
|
+
return parts.join(" > ");
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* 상세한 selector 생성 (안정성 우선)
|
|
58
|
+
* data-testid, aria-label 등 안정적인 속성 우선 사용
|
|
59
|
+
*/
|
|
60
|
+
export function makeSelector(el) {
|
|
61
|
+
const segs = [];
|
|
62
|
+
let cur = el;
|
|
63
|
+
for (let depth = 0; cur && depth < 5; depth++) {
|
|
64
|
+
let s = cur.nodeName.toLowerCase();
|
|
65
|
+
const id = cur.id;
|
|
66
|
+
if (id) {
|
|
67
|
+
segs.unshift(`${s}#${CSS.escape(id)}`);
|
|
68
|
+
break;
|
|
69
|
+
}
|
|
70
|
+
const testid = cur.getAttribute("data-testid");
|
|
71
|
+
const aria = cur.getAttribute("aria-label");
|
|
72
|
+
if (testid) {
|
|
73
|
+
s += `[data-testid="${testid}"]`;
|
|
74
|
+
}
|
|
75
|
+
else if (aria) {
|
|
76
|
+
s += `[aria-label="${aria}"]`;
|
|
77
|
+
}
|
|
78
|
+
else {
|
|
79
|
+
const parent = cur.parentElement;
|
|
80
|
+
if (parent && cur) {
|
|
81
|
+
const currentNode = cur;
|
|
82
|
+
const same = Array.from(parent.children).filter((c) => c.nodeName === currentNode.nodeName);
|
|
83
|
+
if (same.length > 1) {
|
|
84
|
+
s += `:nth-of-type(${same.indexOf(currentNode) + 1})`;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
segs.unshift(s);
|
|
89
|
+
cur = cur.parentElement;
|
|
90
|
+
}
|
|
91
|
+
return segs.join(">");
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* Selector가 유효한지 검증
|
|
95
|
+
*/
|
|
96
|
+
export function isValidSelector(selector) {
|
|
97
|
+
try {
|
|
98
|
+
document.querySelector(selector);
|
|
99
|
+
return true;
|
|
100
|
+
}
|
|
101
|
+
catch {
|
|
102
|
+
return false;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* Selector로 단일 요소 찾기 (안전)
|
|
107
|
+
*/
|
|
108
|
+
export function querySelector(selector) {
|
|
109
|
+
try {
|
|
110
|
+
const el = document.querySelector(selector);
|
|
111
|
+
return el instanceof HTMLElement ? el : null;
|
|
112
|
+
}
|
|
113
|
+
catch (error) {
|
|
114
|
+
console.error(`Invalid selector: ${selector}`, error);
|
|
115
|
+
return null;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* Selector로 여러 요소 찾기 (안전)
|
|
120
|
+
*/
|
|
121
|
+
export function querySelectorAll(selector) {
|
|
122
|
+
try {
|
|
123
|
+
const elements = document.querySelectorAll(selector);
|
|
124
|
+
return Array.from(elements).filter((el) => el instanceof HTMLElement);
|
|
125
|
+
}
|
|
126
|
+
catch (error) {
|
|
127
|
+
console.error(`Invalid selector: ${selector}`, error);
|
|
128
|
+
return [];
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
/**
|
|
132
|
+
* ARIA role을 추론
|
|
133
|
+
*/
|
|
134
|
+
function inferRole(element) {
|
|
135
|
+
const tagName = element.tagName.toLowerCase();
|
|
136
|
+
const type = element.getAttribute("type");
|
|
137
|
+
// 명시적 role이 있으면 사용
|
|
138
|
+
const explicitRole = element.getAttribute("role");
|
|
139
|
+
if (explicitRole)
|
|
140
|
+
return explicitRole;
|
|
141
|
+
// 암시적 role 추론
|
|
142
|
+
const roleMap = {
|
|
143
|
+
button: "button",
|
|
144
|
+
a: "link",
|
|
145
|
+
input: type === "text" ? "textbox" : type || "textbox",
|
|
146
|
+
textarea: "textbox",
|
|
147
|
+
select: "combobox",
|
|
148
|
+
img: "img",
|
|
149
|
+
h1: "heading",
|
|
150
|
+
h2: "heading",
|
|
151
|
+
h3: "heading",
|
|
152
|
+
h4: "heading",
|
|
153
|
+
h5: "heading",
|
|
154
|
+
h6: "heading",
|
|
155
|
+
};
|
|
156
|
+
return roleMap[tagName] || null;
|
|
157
|
+
}
|
|
158
|
+
/**
|
|
159
|
+
* 요소의 가시 텍스트 추출 (trimmed)
|
|
160
|
+
*/
|
|
161
|
+
function getVisibleText(element) {
|
|
162
|
+
// input/textarea의 경우 value 또는 placeholder
|
|
163
|
+
if (element instanceof HTMLInputElement || element instanceof HTMLTextAreaElement) {
|
|
164
|
+
return element.value || element.placeholder || "";
|
|
165
|
+
}
|
|
166
|
+
// 이미지의 경우 alt
|
|
167
|
+
if (element instanceof HTMLImageElement) {
|
|
168
|
+
return element.alt || "";
|
|
169
|
+
}
|
|
170
|
+
// 일반 텍스트 내용 (자식 요소는 제외하고 직접 텍스트만)
|
|
171
|
+
let text = "";
|
|
172
|
+
for (const node of element.childNodes) {
|
|
173
|
+
if (node.nodeType === Node.TEXT_NODE) {
|
|
174
|
+
text += node.textContent || "";
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
// 텍스트가 너무 길면 앞부분만 (50자)
|
|
178
|
+
text = text.trim();
|
|
179
|
+
if (text.length > 50) {
|
|
180
|
+
text = text.substring(0, 50);
|
|
181
|
+
}
|
|
182
|
+
return text;
|
|
183
|
+
}
|
|
184
|
+
/**
|
|
185
|
+
* Test ID 속성 찾기 (다양한 형태 지원)
|
|
186
|
+
*/
|
|
187
|
+
function getTestId(element) {
|
|
188
|
+
return (element.getAttribute("data-testid") ||
|
|
189
|
+
element.getAttribute("data-test") ||
|
|
190
|
+
element.getAttribute("data-cy") ||
|
|
191
|
+
element.getAttribute("data-test-id") ||
|
|
192
|
+
null);
|
|
193
|
+
}
|
|
194
|
+
/**
|
|
195
|
+
* CSS 클래스 기반 selector 생성 (고유한 클래스 우선)
|
|
196
|
+
*/
|
|
197
|
+
function generateClassSelector(element) {
|
|
198
|
+
const classes = Array.from(element.classList);
|
|
199
|
+
if (classes.length === 0)
|
|
200
|
+
return null;
|
|
201
|
+
// 고유해 보이는 클래스 우선 (숫자나 해시가 없는 것)
|
|
202
|
+
const stableClasses = classes.filter((c) => !c.match(/[0-9a-f]{8,}/) && !c.startsWith("_"));
|
|
203
|
+
if (stableClasses.length > 0) {
|
|
204
|
+
// 최대 2개 클래스 사용
|
|
205
|
+
const selectedClasses = stableClasses.slice(0, 2);
|
|
206
|
+
return element.tagName.toLowerCase() + selectedClasses.map((c) => `.${CSS.escape(c)}`).join("");
|
|
207
|
+
}
|
|
208
|
+
return null;
|
|
209
|
+
}
|
|
210
|
+
/**
|
|
211
|
+
* Robust한 다중 selector 생성 (Playwright/Maestro 스타일)
|
|
212
|
+
*/
|
|
213
|
+
export function generateRobustLocator(element) {
|
|
214
|
+
const selectors = [];
|
|
215
|
+
const metadata = {
|
|
216
|
+
tagName: element.tagName.toLowerCase(),
|
|
217
|
+
};
|
|
218
|
+
// === Tier 1: 가장 안정적인 속성들 ===
|
|
219
|
+
// Test ID (최우선)
|
|
220
|
+
const testId = getTestId(element);
|
|
221
|
+
if (testId) {
|
|
222
|
+
selectors.push(`[data-testid="${testId}"]`);
|
|
223
|
+
metadata.testId = testId;
|
|
224
|
+
}
|
|
225
|
+
// ID 속성
|
|
226
|
+
if (element.id && !element.id.match(/[0-9a-f]{8,}/)) {
|
|
227
|
+
// 랜덤 해시가 아닌 의미있는 ID만
|
|
228
|
+
selectors.push(`#${CSS.escape(element.id)}`);
|
|
229
|
+
}
|
|
230
|
+
// Name 속성 (forms)
|
|
231
|
+
const name = element.getAttribute("name");
|
|
232
|
+
if (name) {
|
|
233
|
+
selectors.push(`${element.tagName.toLowerCase()}[name="${name}"]`);
|
|
234
|
+
}
|
|
235
|
+
// ARIA label
|
|
236
|
+
const ariaLabel = element.getAttribute("aria-label");
|
|
237
|
+
if (ariaLabel) {
|
|
238
|
+
selectors.push(`[aria-label="${ariaLabel}"]`);
|
|
239
|
+
metadata.ariaLabel = ariaLabel;
|
|
240
|
+
}
|
|
241
|
+
// === Tier 2: 의미론적 속성들 ===
|
|
242
|
+
const role = inferRole(element);
|
|
243
|
+
const text = getVisibleText(element);
|
|
244
|
+
if (role) {
|
|
245
|
+
metadata.role = role;
|
|
246
|
+
}
|
|
247
|
+
if (text) {
|
|
248
|
+
metadata.text = text;
|
|
249
|
+
}
|
|
250
|
+
// Placeholder (input/textarea)
|
|
251
|
+
const placeholder = element.getAttribute("placeholder");
|
|
252
|
+
if (placeholder) {
|
|
253
|
+
const tagName = element.tagName.toLowerCase();
|
|
254
|
+
selectors.push(`${tagName}[placeholder="${placeholder}"]`);
|
|
255
|
+
metadata.placeholder = placeholder;
|
|
256
|
+
}
|
|
257
|
+
// Title
|
|
258
|
+
const title = element.getAttribute("title");
|
|
259
|
+
if (title) {
|
|
260
|
+
const tagName = element.tagName.toLowerCase();
|
|
261
|
+
selectors.push(`${tagName}[title="${title}"]`);
|
|
262
|
+
metadata.title = title;
|
|
263
|
+
}
|
|
264
|
+
// Alt (이미지)
|
|
265
|
+
if (element instanceof HTMLImageElement && element.alt) {
|
|
266
|
+
selectors.push(`img[alt="${element.alt}"]`);
|
|
267
|
+
}
|
|
268
|
+
// === Tier 3: 구조적 selector ===
|
|
269
|
+
// 클래스 기반
|
|
270
|
+
const classSelector = generateClassSelector(element);
|
|
271
|
+
if (classSelector) {
|
|
272
|
+
selectors.push(classSelector);
|
|
273
|
+
}
|
|
274
|
+
// 기존 makeSelector 함수 사용 (구조 기반)
|
|
275
|
+
const structuralSelector = makeSelector(element);
|
|
276
|
+
if (structuralSelector) {
|
|
277
|
+
selectors.push(structuralSelector);
|
|
278
|
+
}
|
|
279
|
+
// === Tier 4: 폴백 - XPath (가장 정확하지만 취약) ===
|
|
280
|
+
// XPath는 마지막 수단으로만 사용
|
|
281
|
+
// (기존 XPath 생성 로직이 필요하다면 여기 추가)
|
|
282
|
+
// Primary는 첫 번째, 나머지는 fallback
|
|
283
|
+
const [primary, ...fallbacks] = selectors;
|
|
284
|
+
// 중복 제거
|
|
285
|
+
const uniqueFallbacks = Array.from(new Set(fallbacks));
|
|
286
|
+
return {
|
|
287
|
+
primary: primary || structuralSelector, // 최소한 구조 기반이라도
|
|
288
|
+
fallbacks: uniqueFallbacks,
|
|
289
|
+
metadata,
|
|
290
|
+
};
|
|
291
|
+
}
|
|
292
|
+
/**
|
|
293
|
+
* 단순 selector 생성 래퍼 (하위 호환성)
|
|
294
|
+
* @deprecated generateRobustLocator 사용 권장
|
|
295
|
+
*/
|
|
296
|
+
export function generateSelector(element) {
|
|
297
|
+
const locator = generateRobustLocator(element);
|
|
298
|
+
return locator.primary;
|
|
299
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import type { Step } from "@auto-wiz/core";
|
|
2
|
+
/**
|
|
3
|
+
* Step execution 유틸리티
|
|
4
|
+
* 각 Step 타입별 실행 로직
|
|
5
|
+
*
|
|
6
|
+
* 새로운 locator 시스템 지원:
|
|
7
|
+
* - step.locator가 있으면 다중 selector fallback 사용
|
|
8
|
+
* - 없으면 기존 step.selector 사용 (하위 호환성)
|
|
9
|
+
*/
|
|
10
|
+
export interface ExecutionResult {
|
|
11
|
+
success: boolean;
|
|
12
|
+
error?: string;
|
|
13
|
+
extractedData?: any;
|
|
14
|
+
usedSelector?: string;
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Click step 실행
|
|
18
|
+
*/
|
|
19
|
+
export declare function executeClickStep(step: Step): Promise<ExecutionResult>;
|
|
20
|
+
/**
|
|
21
|
+
* Type step 실행
|
|
22
|
+
*/
|
|
23
|
+
export declare function executeTypeStep(step: Step): Promise<ExecutionResult>;
|
|
24
|
+
/**
|
|
25
|
+
* Select step 실행
|
|
26
|
+
*/
|
|
27
|
+
export declare function executeSelectStep(step: Step): Promise<ExecutionResult>;
|
|
28
|
+
/**
|
|
29
|
+
* Extract step 실행
|
|
30
|
+
*/
|
|
31
|
+
export declare function executeExtractStep(step: Step): Promise<ExecutionResult>;
|
|
32
|
+
/**
|
|
33
|
+
* WaitFor step 실행
|
|
34
|
+
*/
|
|
35
|
+
export declare function executeWaitForStep(step: Step): Promise<ExecutionResult>;
|
|
36
|
+
/**
|
|
37
|
+
* Step 실행 (타입에 따라 자동 분기)
|
|
38
|
+
*/
|
|
39
|
+
export declare function executeStep(step: Step): Promise<ExecutionResult>;
|
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
import { querySelector } from "../selectors/selectorGenerator";
|
|
2
|
+
import { waitForLocator, isInteractable } from "../selectors/locatorUtils";
|
|
3
|
+
/**
|
|
4
|
+
* Step에서 요소 찾기 (locator 우선, fallback to selector)
|
|
5
|
+
*/
|
|
6
|
+
async function findElement(step) {
|
|
7
|
+
// 1. 새로운 locator 시스템 시도
|
|
8
|
+
if ("locator" in step && step.locator) {
|
|
9
|
+
try {
|
|
10
|
+
const element = await waitForLocator(step.locator, {
|
|
11
|
+
timeout: step.timeoutMs || 5000,
|
|
12
|
+
visible: true,
|
|
13
|
+
interactable: true,
|
|
14
|
+
});
|
|
15
|
+
return { element, usedSelector: step.locator.primary };
|
|
16
|
+
}
|
|
17
|
+
catch (error) {
|
|
18
|
+
// Locator로 찾지 못하면 selector로 폴백
|
|
19
|
+
console.warn("Locator failed, falling back to selector", error);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
// 2. 기존 selector 사용 (하위 호환성)
|
|
23
|
+
if ("selector" in step && step.selector) {
|
|
24
|
+
const element = querySelector(step.selector);
|
|
25
|
+
return { element, usedSelector: step.selector };
|
|
26
|
+
}
|
|
27
|
+
return { element: null, usedSelector: "none" };
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Click step 실행
|
|
31
|
+
*/
|
|
32
|
+
export async function executeClickStep(step) {
|
|
33
|
+
if (step.type !== "click") {
|
|
34
|
+
return { success: false, error: "Invalid click step" };
|
|
35
|
+
}
|
|
36
|
+
const { element, usedSelector } = await findElement(step);
|
|
37
|
+
if (!element) {
|
|
38
|
+
return {
|
|
39
|
+
success: false,
|
|
40
|
+
error: `Element not found with selector: ${usedSelector}`,
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
// 상호작용 가능 여부 확인
|
|
44
|
+
if (!isInteractable(element)) {
|
|
45
|
+
return {
|
|
46
|
+
success: false,
|
|
47
|
+
error: `Element is not interactable: ${usedSelector}`,
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
try {
|
|
51
|
+
element.click();
|
|
52
|
+
return { success: true, usedSelector };
|
|
53
|
+
}
|
|
54
|
+
catch (error) {
|
|
55
|
+
return {
|
|
56
|
+
success: false,
|
|
57
|
+
error: `Failed to click element: ${error.message}`,
|
|
58
|
+
usedSelector,
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Type step 실행
|
|
64
|
+
*/
|
|
65
|
+
export async function executeTypeStep(step) {
|
|
66
|
+
if (step.type !== "type") {
|
|
67
|
+
return { success: false, error: "Invalid type step" };
|
|
68
|
+
}
|
|
69
|
+
const { element, usedSelector } = await findElement(step);
|
|
70
|
+
if (!element) {
|
|
71
|
+
return {
|
|
72
|
+
success: false,
|
|
73
|
+
error: `Element not found with selector: ${usedSelector}`,
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
if (!(element instanceof HTMLInputElement) &&
|
|
77
|
+
!(element instanceof HTMLTextAreaElement)) {
|
|
78
|
+
return {
|
|
79
|
+
success: false,
|
|
80
|
+
error: "Element is not a text input",
|
|
81
|
+
usedSelector,
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
if (!isInteractable(element)) {
|
|
85
|
+
return {
|
|
86
|
+
success: false,
|
|
87
|
+
error: `Element is not interactable: ${usedSelector}`,
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
try {
|
|
91
|
+
const text = step.originalText || step.text || "";
|
|
92
|
+
element.value = text;
|
|
93
|
+
element.dispatchEvent(new Event("input", { bubbles: true }));
|
|
94
|
+
element.dispatchEvent(new Event("change", { bubbles: true }));
|
|
95
|
+
// Submit 플래그가 있으면 Enter 키 입력
|
|
96
|
+
if (step.submit) {
|
|
97
|
+
const form = element.form;
|
|
98
|
+
if (form) {
|
|
99
|
+
// 폼이 있으면 submit
|
|
100
|
+
if (typeof form.requestSubmit === "function") {
|
|
101
|
+
form.requestSubmit();
|
|
102
|
+
}
|
|
103
|
+
else {
|
|
104
|
+
form.submit();
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
else {
|
|
108
|
+
// 폼이 없으면 Enter 키 이벤트 발생
|
|
109
|
+
element.dispatchEvent(new KeyboardEvent("keydown", {
|
|
110
|
+
key: "Enter",
|
|
111
|
+
code: "Enter",
|
|
112
|
+
keyCode: 13,
|
|
113
|
+
which: 13,
|
|
114
|
+
bubbles: true,
|
|
115
|
+
cancelable: true,
|
|
116
|
+
}));
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
return { success: true, usedSelector };
|
|
120
|
+
}
|
|
121
|
+
catch (error) {
|
|
122
|
+
return {
|
|
123
|
+
success: false,
|
|
124
|
+
error: `Failed to type into element: ${error.message}`,
|
|
125
|
+
usedSelector,
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* Select step 실행
|
|
131
|
+
*/
|
|
132
|
+
export async function executeSelectStep(step) {
|
|
133
|
+
if (step.type !== "select" || step.value === undefined) {
|
|
134
|
+
return { success: false, error: "Invalid select step" };
|
|
135
|
+
}
|
|
136
|
+
const { element, usedSelector } = await findElement(step);
|
|
137
|
+
if (!element) {
|
|
138
|
+
return {
|
|
139
|
+
success: false,
|
|
140
|
+
error: `Element not found with selector: ${usedSelector}`,
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
if (!(element instanceof HTMLSelectElement)) {
|
|
144
|
+
return {
|
|
145
|
+
success: false,
|
|
146
|
+
error: "Element is not a select element",
|
|
147
|
+
usedSelector,
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
if (!isInteractable(element)) {
|
|
151
|
+
return {
|
|
152
|
+
success: false,
|
|
153
|
+
error: `Element is not interactable: ${usedSelector}`,
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
try {
|
|
157
|
+
element.value = step.value;
|
|
158
|
+
element.dispatchEvent(new Event("change", { bubbles: true }));
|
|
159
|
+
return { success: true, usedSelector };
|
|
160
|
+
}
|
|
161
|
+
catch (error) {
|
|
162
|
+
return {
|
|
163
|
+
success: false,
|
|
164
|
+
error: `Failed to select option: ${error.message}`,
|
|
165
|
+
usedSelector,
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
/**
|
|
170
|
+
* Extract step 실행
|
|
171
|
+
*/
|
|
172
|
+
export async function executeExtractStep(step) {
|
|
173
|
+
if (step.type !== "extract") {
|
|
174
|
+
return { success: false, error: "Invalid extract step" };
|
|
175
|
+
}
|
|
176
|
+
const { element, usedSelector } = await findElement(step);
|
|
177
|
+
if (!element) {
|
|
178
|
+
return {
|
|
179
|
+
success: false,
|
|
180
|
+
error: `Element not found with selector: ${usedSelector}`,
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
try {
|
|
184
|
+
let extractedData;
|
|
185
|
+
// prop에 따라 다른 데이터 추출 (기본값: innerText)
|
|
186
|
+
const prop = step.prop || "innerText";
|
|
187
|
+
if (prop === "value" && "value" in element) {
|
|
188
|
+
extractedData = element.value;
|
|
189
|
+
}
|
|
190
|
+
else if (prop === "innerText") {
|
|
191
|
+
extractedData = element.textContent?.trim() || "";
|
|
192
|
+
}
|
|
193
|
+
else {
|
|
194
|
+
extractedData = element.textContent?.trim() || "";
|
|
195
|
+
}
|
|
196
|
+
return { success: true, extractedData, usedSelector };
|
|
197
|
+
}
|
|
198
|
+
catch (error) {
|
|
199
|
+
return {
|
|
200
|
+
success: false,
|
|
201
|
+
error: `Failed to extract data: ${error.message}`,
|
|
202
|
+
usedSelector,
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
/**
|
|
207
|
+
* WaitFor step 실행
|
|
208
|
+
*/
|
|
209
|
+
export async function executeWaitForStep(step) {
|
|
210
|
+
if (step.type !== "waitFor") {
|
|
211
|
+
return { success: false, error: "Invalid waitFor step" };
|
|
212
|
+
}
|
|
213
|
+
// 단순 timeout인 경우
|
|
214
|
+
if (!("selector" in step) && !("locator" in step) && step.timeoutMs) {
|
|
215
|
+
await new Promise((resolve) => setTimeout(resolve, step.timeoutMs));
|
|
216
|
+
return { success: true };
|
|
217
|
+
}
|
|
218
|
+
const timeout = step.timeoutMs || 5000; // 기본 5초
|
|
219
|
+
try {
|
|
220
|
+
// locator가 있으면 waitForLocator 사용 (자동 대기 기능)
|
|
221
|
+
if ("locator" in step && step.locator) {
|
|
222
|
+
await waitForLocator(step.locator, {
|
|
223
|
+
timeout,
|
|
224
|
+
visible: true,
|
|
225
|
+
});
|
|
226
|
+
return { success: true, usedSelector: step.locator.primary };
|
|
227
|
+
}
|
|
228
|
+
// selector가 있으면 기존 방식 (하위 호환성)
|
|
229
|
+
if ("selector" in step && step.selector) {
|
|
230
|
+
const startTime = Date.now();
|
|
231
|
+
const pollInterval = 100;
|
|
232
|
+
while (Date.now() - startTime < timeout) {
|
|
233
|
+
const element = querySelector(step.selector);
|
|
234
|
+
if (element) {
|
|
235
|
+
return { success: true, usedSelector: step.selector };
|
|
236
|
+
}
|
|
237
|
+
await new Promise((resolve) => setTimeout(resolve, pollInterval));
|
|
238
|
+
}
|
|
239
|
+
return {
|
|
240
|
+
success: false,
|
|
241
|
+
error: `Timeout waiting for element: ${step.selector}`,
|
|
242
|
+
};
|
|
243
|
+
}
|
|
244
|
+
return {
|
|
245
|
+
success: false,
|
|
246
|
+
error: "WaitFor step requires selector, locator, or timeoutMs",
|
|
247
|
+
};
|
|
248
|
+
}
|
|
249
|
+
catch (error) {
|
|
250
|
+
return {
|
|
251
|
+
success: false,
|
|
252
|
+
error: `WaitFor failed: ${error.message}`,
|
|
253
|
+
};
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
/**
|
|
257
|
+
* Step 실행 (타입에 따라 자동 분기)
|
|
258
|
+
*/
|
|
259
|
+
export async function executeStep(step) {
|
|
260
|
+
try {
|
|
261
|
+
switch (step.type) {
|
|
262
|
+
case "click":
|
|
263
|
+
return await executeClickStep(step);
|
|
264
|
+
case "type":
|
|
265
|
+
return await executeTypeStep(step);
|
|
266
|
+
case "select":
|
|
267
|
+
return await executeSelectStep(step);
|
|
268
|
+
case "extract":
|
|
269
|
+
return await executeExtractStep(step);
|
|
270
|
+
case "waitFor":
|
|
271
|
+
return await executeWaitForStep(step);
|
|
272
|
+
case "navigate":
|
|
273
|
+
// navigate는 background에서 처리
|
|
274
|
+
return { success: true };
|
|
275
|
+
default:
|
|
276
|
+
return { success: false, error: `Unknown step type: ${step.type}` };
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
catch (error) {
|
|
280
|
+
return {
|
|
281
|
+
success: false,
|
|
282
|
+
error: `Step execution failed: ${error.message}`,
|
|
283
|
+
};
|
|
284
|
+
}
|
|
285
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@auto-wiz/dom",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"license": "MIT",
|
|
5
|
+
"author": "JaeSang",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "https://github.com/JaeSang1998/automation-wizard.git"
|
|
9
|
+
},
|
|
10
|
+
"publishConfig": {
|
|
11
|
+
"access": "public"
|
|
12
|
+
},
|
|
13
|
+
"files": [
|
|
14
|
+
"dist"
|
|
15
|
+
],
|
|
16
|
+
"main": "./dist/index.js",
|
|
17
|
+
"types": "./dist/index.d.ts",
|
|
18
|
+
"exports": {
|
|
19
|
+
".": {
|
|
20
|
+
"types": "./dist/index.d.ts",
|
|
21
|
+
"import": "./dist/index.js",
|
|
22
|
+
"require": "./dist/index.js"
|
|
23
|
+
}
|
|
24
|
+
},
|
|
25
|
+
"dependencies": {
|
|
26
|
+
"@auto-wiz/core": "1.0.0"
|
|
27
|
+
},
|
|
28
|
+
"devDependencies": {
|
|
29
|
+
"typescript": "^5.0.0"
|
|
30
|
+
},
|
|
31
|
+
"scripts": {
|
|
32
|
+
"build": "tsc"
|
|
33
|
+
}
|
|
34
|
+
}
|