@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.
@@ -0,0 +1,4 @@
1
+ export * from "./selectors/selectorGenerator";
2
+ export * from "./selectors/locatorUtils";
3
+ export * from "./steps/stepExecution";
4
+ export * from "./runner";
package/dist/index.js ADDED
@@ -0,0 +1,5 @@
1
+ export * from "./selectors/selectorGenerator";
2
+ export * from "./selectors/locatorUtils";
3
+ export * from "./steps/stepExecution";
4
+ export * from "./runner";
5
+ // stepValidation might still belong in core if it's pure logic, but checking dependencies...
@@ -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
+ }