@auto-wiz/dom 1.1.0 → 1.1.2

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/runner.d.ts CHANGED
@@ -1,6 +1,5 @@
1
1
  import { type FlowRunner, type RunResult, type ExecutionResult, type RunnerOptions, type Flow, type Step } from "@auto-wiz/core";
2
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>;
3
+ run(flow: Flow, _context?: any, options?: RunnerOptions): Promise<RunResult>;
4
+ runStep(step: Step, _context?: void, options?: RunnerOptions): Promise<ExecutionResult>;
6
5
  }
package/dist/runner.js CHANGED
@@ -1,14 +1,12 @@
1
1
  import { executeStep } from "./steps/stepExecution";
2
2
  export class DomFlowRunner {
3
- async run(flow, _context = {}, // unused
4
- _options = {} // unused
5
- ) {
3
+ async run(flow, _context = {}, options = {}) {
6
4
  const extractedData = {};
7
5
  const steps = flow.steps;
8
6
  for (let i = 0; i < steps.length; i++) {
9
7
  const step = steps[i];
10
8
  try {
11
- const result = await this.runStep(step);
9
+ const result = await this.runStep(step, undefined, options);
12
10
  if (!result.success) {
13
11
  return {
14
12
  success: false,
@@ -32,10 +30,10 @@ export class DomFlowRunner {
32
30
  }
33
31
  return { success: true, extractedData };
34
32
  }
35
- async runStep(step) {
33
+ async runStep(step, _context, options = {}) {
36
34
  try {
37
35
  // Direct DOM execution using existing logic
38
- const result = await executeStep(step);
36
+ const result = await executeStep(step, options);
39
37
  return result;
40
38
  }
41
39
  catch (error) {
@@ -45,20 +45,21 @@ export declare function findByLabelText(text: string, options?: {
45
45
  */
46
46
  export declare function findByTestId(testId: string): HTMLElement | null;
47
47
  /**
48
- * ElementLocator로 요소 찾기 (fallback 지원)
48
+ * ElementLocator로 요소 찾기 (fallback 지원 + 다중 매칭 검증)
49
49
  *
50
50
  * Primary selector부터 시도하고, 실패하면 fallback들을 순차적으로 시도
51
+ * 여러 요소가 매칭되면 metadata로 정확한 요소 선별
51
52
  */
52
- export declare function findByLocator(locator: ElementLocator): HTMLElement | null;
53
+ export declare function findByLocator(locator: ElementLocator): HTMLElement | SVGElement | null;
53
54
  /**
54
- * 요소가 상호작용 가능한지 확인 (enabled + visible)
55
+ * 요소가 상호작용 가능한지 확인 (enabled + visible) - HTMLElement와 SVGElement 모두 지원
55
56
  */
56
- export declare function isInteractable(element: HTMLElement): boolean;
57
+ export declare function isInteractable(element: HTMLElement | SVGElement): boolean;
57
58
  /**
58
- * Smart waiting: 요소가 나타날 때까지 대기
59
+ * Smart waiting: 요소가 나타날 때까지 대기 - HTMLElement와 SVGElement 모두 지원
59
60
  */
60
61
  export declare function waitForLocator(locator: ElementLocator, options?: {
61
62
  timeout?: number;
62
63
  visible?: boolean;
63
64
  interactable?: boolean;
64
- }): Promise<HTMLElement>;
65
+ }): Promise<HTMLElement | SVGElement>;
@@ -258,27 +258,207 @@ export function findByTestId(testId) {
258
258
  return null;
259
259
  }
260
260
  /**
261
- * ElementLocator로 요소 찾기 (fallback 지원)
261
+ * Input 요소와 연결된 label 텍스트 가져오기 (재생 시 검증용)
262
+ */
263
+ function getAssociatedLabelTextForElement(element) {
264
+ // 1. label[for="id"] 방식
265
+ if (element.id) {
266
+ const label = document.querySelector(`label[for="${CSS.escape(element.id)}"]`);
267
+ if (label)
268
+ return label.textContent?.trim() || null;
269
+ }
270
+ // 2. 부모 label 방식
271
+ const parentLabel = element.closest("label");
272
+ if (parentLabel) {
273
+ const clone = parentLabel.cloneNode(true);
274
+ const input = clone.querySelector("input, textarea, select");
275
+ if (input)
276
+ input.remove();
277
+ const text = clone.textContent?.trim();
278
+ if (text)
279
+ return text;
280
+ }
281
+ // 3. aria-labelledby 방식
282
+ const labelledby = element.getAttribute("aria-labelledby");
283
+ if (labelledby) {
284
+ const labelEl = document.getElementById(labelledby);
285
+ if (labelEl)
286
+ return labelEl.textContent?.trim() || null;
287
+ }
288
+ // 4. 이전 형제 label 탐지
289
+ const prevSibling = element.previousElementSibling;
290
+ if (prevSibling && prevSibling.tagName.toLowerCase() === "label") {
291
+ return prevSibling.textContent?.trim() || null;
292
+ }
293
+ // 5. 같은 컨테이너 내 첫 번째 label
294
+ const parent = element.parentElement;
295
+ if (parent) {
296
+ const labelInParent = parent.querySelector("label");
297
+ if (labelInParent) {
298
+ const forAttr = labelInParent.getAttribute("for");
299
+ if (!forAttr || forAttr === element.id) {
300
+ return labelInParent.textContent?.trim() || null;
301
+ }
302
+ }
303
+ }
304
+ return null;
305
+ }
306
+ /**
307
+ * Form 내에서의 위치 정보 가져오기 (재생 시 검증용)
308
+ */
309
+ function getFormContextForElement(element) {
310
+ const form = element.closest("form");
311
+ if (!form)
312
+ return null;
313
+ let formSelector = "";
314
+ if (form.id && !form.id.match(/[0-9a-f]{8,}/i)) {
315
+ formSelector = `#${CSS.escape(form.id)}`;
316
+ }
317
+ else if (form.getAttribute("name")) {
318
+ formSelector = `form[name="${form.getAttribute("name")}"]`;
319
+ }
320
+ else {
321
+ // 클래스 기반
322
+ const classes = Array.from(form.classList).filter((c) => !c.match(/[0-9a-f]{8,}/i) && !c.startsWith("_"));
323
+ if (classes.length > 0) {
324
+ formSelector = "form." + classes.slice(0, 2).map((c) => CSS.escape(c)).join(".");
325
+ }
326
+ else {
327
+ const forms = document.querySelectorAll("form");
328
+ const index = Array.from(forms).indexOf(form);
329
+ formSelector = `form:nth-of-type(${index + 1})`;
330
+ }
331
+ }
332
+ const allFormFields = form.querySelectorAll("input, textarea, select");
333
+ const fieldIndex = Array.from(allFormFields).indexOf(element) + 1;
334
+ return { formSelector, fieldIndex };
335
+ }
336
+ /**
337
+ * Metadata로 후보 요소들 중 정확한 요소 찾기 (다중 매칭 시 검증)
338
+ */
339
+ function verifyWithMetadata(candidates, metadata) {
340
+ if (!metadata)
341
+ return candidates[0]; // metadata 없으면 첫 번째 반환
342
+ let bestMatch = null;
343
+ let bestScore = -1;
344
+ for (const el of candidates) {
345
+ if (!isVisible(el))
346
+ continue;
347
+ let score = 0;
348
+ // labelText 매칭 (가장 높은 우선순위)
349
+ if (metadata.labelText &&
350
+ (el instanceof HTMLInputElement ||
351
+ el instanceof HTMLTextAreaElement ||
352
+ el instanceof HTMLSelectElement)) {
353
+ const labelText = getAssociatedLabelTextForElement(el);
354
+ if (labelText === metadata.labelText) {
355
+ score += 100;
356
+ }
357
+ else if (labelText && labelText.includes(metadata.labelText)) {
358
+ score += 50;
359
+ }
360
+ }
361
+ // formContext 매칭
362
+ if (metadata.formContext && el instanceof HTMLElement) {
363
+ const context = getFormContextForElement(el);
364
+ if (context) {
365
+ // formSelector가 일치하고 fieldIndex도 일치하면 높은 점수
366
+ if (context.fieldIndex === metadata.formContext.fieldIndex) {
367
+ score += 80;
368
+ }
369
+ }
370
+ }
371
+ // placeholder 매칭
372
+ if (metadata.placeholder) {
373
+ if (el.getAttribute("placeholder") === metadata.placeholder) {
374
+ score += 60;
375
+ }
376
+ }
377
+ // tagName 매칭
378
+ if (metadata.tagName) {
379
+ if (el.tagName.toLowerCase() === metadata.tagName) {
380
+ score += 10;
381
+ }
382
+ }
383
+ // role 매칭
384
+ if (metadata.role) {
385
+ const elementRole = el.getAttribute("role") ||
386
+ (el instanceof HTMLElement ? getImplicitRoleForElement(el) : null);
387
+ if (elementRole === metadata.role) {
388
+ score += 10;
389
+ }
390
+ }
391
+ if (score > bestScore) {
392
+ bestScore = score;
393
+ bestMatch = el;
394
+ }
395
+ }
396
+ // 최소 점수 기준 (labelText나 formContext가 매칭되어야 신뢰)
397
+ if (bestScore >= 50) {
398
+ return bestMatch;
399
+ }
400
+ // 점수가 낮으면 첫 번째 visible 요소 반환 (기존 동작 유지)
401
+ return candidates.find((el) => isVisible(el)) || null;
402
+ }
403
+ /**
404
+ * 암시적 ARIA role 추론 (verifyWithMetadata용)
405
+ */
406
+ function getImplicitRoleForElement(element) {
407
+ const tagName = element.tagName.toLowerCase();
408
+ const type = element.getAttribute("type");
409
+ const roleMap = {
410
+ button: "button",
411
+ input: type === "text" || !type
412
+ ? "textbox"
413
+ : type === "checkbox"
414
+ ? "checkbox"
415
+ : type === "radio"
416
+ ? "radio"
417
+ : type === "button" || type === "submit"
418
+ ? "button"
419
+ : "",
420
+ textarea: "textbox",
421
+ select: "combobox",
422
+ };
423
+ return roleMap[tagName] || null;
424
+ }
425
+ /**
426
+ * ElementLocator로 요소 찾기 (fallback 지원 + 다중 매칭 검증)
262
427
  *
263
428
  * Primary selector부터 시도하고, 실패하면 fallback들을 순차적으로 시도
429
+ * 여러 요소가 매칭되면 metadata로 정확한 요소 선별
264
430
  */
265
431
  export function findByLocator(locator) {
266
- // 1. Primary selector 시도
432
+ // 1. Primary selector 모든 요소 찾기
267
433
  try {
268
- const el = document.querySelector(locator.primary);
269
- if (el instanceof HTMLElement && isVisible(el)) {
270
- return el;
434
+ const elements = document.querySelectorAll(locator.primary);
435
+ const candidates = Array.from(elements).filter((el) => (el instanceof HTMLElement || el instanceof SVGElement) && isVisible(el));
436
+ if (candidates.length === 1) {
437
+ // 하나만 매칭되면 그것 사용
438
+ return candidates[0];
439
+ }
440
+ else if (candidates.length > 1) {
441
+ // 여러 개 매칭되면 metadata로 검증
442
+ const verified = verifyWithMetadata(candidates, locator.metadata);
443
+ if (verified)
444
+ return verified;
271
445
  }
272
446
  }
273
447
  catch (error) {
274
448
  console.warn(`Primary selector failed: ${locator.primary}`, error);
275
449
  }
276
- // 2. Fallback selectors 순차 시도
450
+ // 2. Fallback selectors 순차 시도 (동일 로직)
277
451
  for (const selector of locator.fallbacks) {
278
452
  try {
279
- const el = document.querySelector(selector);
280
- if (el instanceof HTMLElement && isVisible(el)) {
281
- return el;
453
+ const elements = document.querySelectorAll(selector);
454
+ const candidates = Array.from(elements).filter((el) => (el instanceof HTMLElement || el instanceof SVGElement) && isVisible(el));
455
+ if (candidates.length === 1) {
456
+ return candidates[0];
457
+ }
458
+ else if (candidates.length > 1) {
459
+ const verified = verifyWithMetadata(candidates, locator.metadata);
460
+ if (verified)
461
+ return verified;
282
462
  }
283
463
  }
284
464
  catch (error) {
@@ -288,6 +468,22 @@ export function findByLocator(locator) {
288
468
  // 3. Metadata 기반 fuzzy matching
289
469
  if (!locator.metadata)
290
470
  return null;
471
+ // labelText로 시도 (form input 특화)
472
+ if (locator.metadata.labelText) {
473
+ const elements = findByLabelTextExtended(locator.metadata.labelText, {
474
+ exact: true,
475
+ });
476
+ if (elements.length > 0) {
477
+ // tagName도 일치하는 것 우선
478
+ if (locator.metadata.tagName) {
479
+ const matchingTag = elements.find((el) => el.tagName.toLowerCase() === locator.metadata.tagName);
480
+ if (matchingTag && isVisible(matchingTag))
481
+ return matchingTag;
482
+ }
483
+ if (isVisible(elements[0]))
484
+ return elements[0];
485
+ }
486
+ }
291
487
  // TestID로 시도
292
488
  if (locator.metadata.testId) {
293
489
  const el = findByTestId(locator.metadata.testId);
@@ -331,7 +527,65 @@ export function findByLocator(locator) {
331
527
  return null;
332
528
  }
333
529
  /**
334
- * 요소가 화면에 보이는지 확인
530
+ * Label로 요소 찾기 (확장 버전 - 이전 형제 label도 지원)
531
+ */
532
+ function findByLabelTextExtended(text, options) {
533
+ const results = [];
534
+ // 1. 기존 label[for] 및 부모 label 방식
535
+ const labels = Array.from(document.querySelectorAll("label"));
536
+ for (const label of labels) {
537
+ const labelText = label.textContent?.trim() || "";
538
+ const matches = options?.exact ? labelText === text : labelText.includes(text);
539
+ if (!matches)
540
+ continue;
541
+ // label[for] 참조
542
+ const forAttr = label.getAttribute("for");
543
+ if (forAttr) {
544
+ const input = document.getElementById(forAttr);
545
+ if (input instanceof HTMLElement) {
546
+ results.push(input);
547
+ }
548
+ }
549
+ else {
550
+ // label 내부의 input
551
+ const input = label.querySelector("input, textarea, select");
552
+ if (input instanceof HTMLElement) {
553
+ results.push(input);
554
+ }
555
+ }
556
+ }
557
+ // 2. 이전 형제 label 방식 (div > label + input 구조)
558
+ const allInputs = document.querySelectorAll("input, textarea, select");
559
+ for (const input of allInputs) {
560
+ if (!(input instanceof HTMLElement))
561
+ continue;
562
+ if (results.includes(input))
563
+ continue; // 이미 찾은 요소는 제외
564
+ const prevSibling = input.previousElementSibling;
565
+ if (prevSibling && prevSibling.tagName.toLowerCase() === "label") {
566
+ const labelText = prevSibling.textContent?.trim() || "";
567
+ const matches = options?.exact ? labelText === text : labelText.includes(text);
568
+ if (matches) {
569
+ results.push(input);
570
+ }
571
+ }
572
+ // 같은 컨테이너 내 label
573
+ const parent = input.parentElement;
574
+ if (parent && !results.includes(input)) {
575
+ const labelInParent = parent.querySelector("label");
576
+ if (labelInParent && labelInParent !== prevSibling) {
577
+ const labelText = labelInParent.textContent?.trim() || "";
578
+ const matches = options?.exact ? labelText === text : labelText.includes(text);
579
+ if (matches) {
580
+ results.push(input);
581
+ }
582
+ }
583
+ }
584
+ }
585
+ return results;
586
+ }
587
+ /**
588
+ * 요소가 화면에 보이는지 확인 - HTMLElement와 SVGElement 모두 지원
335
589
  */
336
590
  function isVisible(element) {
337
591
  // BODY와 HTML은 항상 visible로 간주
@@ -349,11 +603,12 @@ function isVisible(element) {
349
603
  return true;
350
604
  }
351
605
  /**
352
- * 요소가 상호작용 가능한지 확인 (enabled + visible)
606
+ * 요소가 상호작용 가능한지 확인 (enabled + visible) - HTMLElement와 SVGElement 모두 지원
353
607
  */
354
608
  export function isInteractable(element) {
355
609
  if (!isVisible(element))
356
610
  return false;
611
+ // HTMLElement의 disabled 체크
357
612
  if (element instanceof HTMLInputElement ||
358
613
  element instanceof HTMLTextAreaElement ||
359
614
  element instanceof HTMLSelectElement ||
@@ -367,7 +622,7 @@ export function isInteractable(element) {
367
622
  return true;
368
623
  }
369
624
  /**
370
- * Smart waiting: 요소가 나타날 때까지 대기
625
+ * Smart waiting: 요소가 나타날 때까지 대기 - HTMLElement와 SVGElement 모두 지원
371
626
  */
372
627
  export async function waitForLocator(locator, options) {
373
628
  const timeout = options?.timeout || 5000;
@@ -36,13 +36,13 @@ export declare function makeSelector(el: HTMLElement): string;
36
36
  */
37
37
  export declare function isValidSelector(selector: string): boolean;
38
38
  /**
39
- * Selector로 단일 요소 찾기 (안전)
39
+ * Selector로 단일 요소 찾기 (안전) - HTMLElement와 SVGElement 모두 지원
40
40
  */
41
- export declare function querySelector(selector: string): HTMLElement | null;
41
+ export declare function querySelector(selector: string): HTMLElement | SVGElement | null;
42
42
  /**
43
- * Selector로 여러 요소 찾기 (안전)
43
+ * Selector로 여러 요소 찾기 (안전) - HTMLElement와 SVGElement 모두 지원
44
44
  */
45
- export declare function querySelectorAll(selector: string): HTMLElement[];
45
+ export declare function querySelectorAll(selector: string): (HTMLElement | SVGElement)[];
46
46
  /**
47
47
  * Robust한 다중 selector 생성 (Playwright/Maestro 스타일)
48
48
  */
@@ -20,6 +20,23 @@
20
20
  * Tier 4 (텍스트 기반):
21
21
  * - 텍스트 내용 검색
22
22
  */
23
+ /**
24
+ * CSS-in-JS 클래스 간단 체크 (getSimpleSelector용)
25
+ */
26
+ function isUnstableClass(className) {
27
+ // CSS-in-JS 패턴들
28
+ if (/^css-[a-z0-9]+$/i.test(className))
29
+ return true;
30
+ if (/^sc-[a-zA-Z0-9]+$/.test(className))
31
+ return true;
32
+ if (/^_[a-zA-Z0-9_]+$/.test(className))
33
+ return true;
34
+ if (/[0-9a-f]{8,}/i.test(className))
35
+ return true;
36
+ if (/^chakra-/.test(className))
37
+ return true;
38
+ return false;
39
+ }
23
40
  /**
24
41
  * 단순 selector 생성 (빠른 선택용)
25
42
  * ID가 있으면 ID만 사용, 없으면 전체 경로 생성
@@ -27,18 +44,21 @@
27
44
  export function getSimpleSelector(el) {
28
45
  if (!(el instanceof Element))
29
46
  return "";
30
- if (el.id)
47
+ if (el.id && !el.id.match(/[0-9a-f]{8,}/i))
31
48
  return `#${CSS.escape(el.id)}`;
32
49
  const parts = [];
33
50
  let node = el;
34
51
  while (node && node.nodeType === 1 && parts.length < 5) {
35
52
  let part = node.tagName.toLowerCase();
36
53
  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;
54
+ // CSS-in-JS 클래스 제외
55
+ const stableClasses = Array.from(node.classList)
56
+ .filter((c) => !isUnstableClass(c))
57
+ .slice(0, 2);
58
+ if (stableClasses.length > 0) {
59
+ const cls = stableClasses.map((c) => `.${CSS.escape(c)}`).join("");
60
+ part += cls;
61
+ }
42
62
  }
43
63
  // nth-child
44
64
  let idx = 1;
@@ -103,12 +123,15 @@ export function isValidSelector(selector) {
103
123
  }
104
124
  }
105
125
  /**
106
- * Selector로 단일 요소 찾기 (안전)
126
+ * Selector로 단일 요소 찾기 (안전) - HTMLElement와 SVGElement 모두 지원
107
127
  */
108
128
  export function querySelector(selector) {
109
129
  try {
110
130
  const el = document.querySelector(selector);
111
- return el instanceof HTMLElement ? el : null;
131
+ if (el instanceof HTMLElement || el instanceof SVGElement) {
132
+ return el;
133
+ }
134
+ return null;
112
135
  }
113
136
  catch (error) {
114
137
  console.error(`Invalid selector: ${selector}`, error);
@@ -116,12 +139,12 @@ export function querySelector(selector) {
116
139
  }
117
140
  }
118
141
  /**
119
- * Selector로 여러 요소 찾기 (안전)
142
+ * Selector로 여러 요소 찾기 (안전) - HTMLElement와 SVGElement 모두 지원
120
143
  */
121
144
  export function querySelectorAll(selector) {
122
145
  try {
123
146
  const elements = document.querySelectorAll(selector);
124
- return Array.from(elements).filter((el) => el instanceof HTMLElement);
147
+ return Array.from(elements).filter((el) => el instanceof HTMLElement || el instanceof SVGElement);
125
148
  }
126
149
  catch (error) {
127
150
  console.error(`Invalid selector: ${selector}`, error);
@@ -191,6 +214,29 @@ function getTestId(element) {
191
214
  element.getAttribute("data-test-id") ||
192
215
  null);
193
216
  }
217
+ /**
218
+ * CSS-in-JS 라이브러리가 생성한 클래스인지 확인
219
+ */
220
+ function isCssInJsClass(className) {
221
+ // Emotion: css-xxxx, css-1abc2de
222
+ if (/^css-[a-z0-9]+$/i.test(className))
223
+ return true;
224
+ // Styled-components: sc-xxx, StyledXxx
225
+ if (/^sc-[a-zA-Z0-9]+$/.test(className))
226
+ return true;
227
+ // CSS Modules: _xxx_hash, Component_class__hash
228
+ if (/^_[a-zA-Z0-9_]+$/.test(className))
229
+ return true;
230
+ if (/^[A-Z][a-zA-Z]+_[a-zA-Z]+__[a-zA-Z0-9]+$/.test(className))
231
+ return true;
232
+ // Random hashes (8+ hex chars)
233
+ if (/[0-9a-f]{8,}/i.test(className))
234
+ return true;
235
+ // Chakra UI: chakra-xxx
236
+ if (/^chakra-/.test(className))
237
+ return true;
238
+ return false;
239
+ }
194
240
  /**
195
241
  * CSS 클래스 기반 selector 생성 (고유한 클래스 우선)
196
242
  */
@@ -198,8 +244,8 @@ function generateClassSelector(element) {
198
244
  const classes = Array.from(element.classList);
199
245
  if (classes.length === 0)
200
246
  return null;
201
- // 고유해 보이는 클래스 우선 (숫자나 해시가 없는 것)
202
- const stableClasses = classes.filter((c) => !c.match(/[0-9a-f]{8,}/) && !c.startsWith("_"));
247
+ // CSS-in-JS 클래스 제외, 안정적인 클래스만 사용
248
+ const stableClasses = classes.filter((c) => !isCssInJsClass(c));
203
249
  if (stableClasses.length > 0) {
204
250
  // 최대 2개 클래스 사용
205
251
  const selectedClasses = stableClasses.slice(0, 2);
@@ -207,6 +253,93 @@ function generateClassSelector(element) {
207
253
  }
208
254
  return null;
209
255
  }
256
+ /**
257
+ * Input 요소와 연결된 label 텍스트 가져오기
258
+ * 다양한 label 연결 방식 지원:
259
+ * 1. label[for="id"] - 명시적 연결
260
+ * 2. 부모 label - 암시적 연결
261
+ * 3. aria-labelledby - ARIA 연결
262
+ * 4. 이전 형제 label - div > label + input 구조
263
+ * 5. 같은 컨테이너 내 label - div 내 첫 번째 label
264
+ */
265
+ function getAssociatedLabelText(element) {
266
+ // 1. label[for="id"] 방식 (명시적 연결)
267
+ if (element.id) {
268
+ const label = document.querySelector(`label[for="${CSS.escape(element.id)}"]`);
269
+ if (label)
270
+ return label.textContent?.trim() || null;
271
+ }
272
+ // 2. 부모 label 방식 (암시적 연결)
273
+ const parentLabel = element.closest("label");
274
+ if (parentLabel) {
275
+ // input 자체의 텍스트는 제외하고 label 텍스트만 추출
276
+ const clone = parentLabel.cloneNode(true);
277
+ const input = clone.querySelector("input, textarea, select");
278
+ if (input)
279
+ input.remove();
280
+ const text = clone.textContent?.trim();
281
+ if (text)
282
+ return text;
283
+ }
284
+ // 3. aria-labelledby 방식
285
+ const labelledby = element.getAttribute("aria-labelledby");
286
+ if (labelledby) {
287
+ const labelEl = document.getElementById(labelledby);
288
+ if (labelEl)
289
+ return labelEl.textContent?.trim() || null;
290
+ }
291
+ // 4. 이전 형제 label 탐지 (div > label + input 구조)
292
+ const prevSibling = element.previousElementSibling;
293
+ if (prevSibling && prevSibling.tagName.toLowerCase() === "label") {
294
+ return prevSibling.textContent?.trim() || null;
295
+ }
296
+ // 5. 같은 컨테이너(div) 내 첫 번째 label
297
+ const parent = element.parentElement;
298
+ if (parent) {
299
+ const labelInParent = parent.querySelector("label");
300
+ if (labelInParent) {
301
+ // 해당 label이 다른 input과 연결되어 있지 않은지 확인
302
+ const forAttr = labelInParent.getAttribute("for");
303
+ if (!forAttr || forAttr === element.id) {
304
+ return labelInParent.textContent?.trim() || null;
305
+ }
306
+ }
307
+ }
308
+ return null;
309
+ }
310
+ /**
311
+ * Form 내에서의 위치 정보 가져오기
312
+ */
313
+ function getFormContext(element) {
314
+ const form = element.closest("form");
315
+ if (!form)
316
+ return null;
317
+ // Form의 selector 생성 (id, name, 또는 구조적)
318
+ let formSelector = "";
319
+ if (form.id && !form.id.match(/[0-9a-f]{8,}/i)) {
320
+ formSelector = `#${CSS.escape(form.id)}`;
321
+ }
322
+ else if (form.getAttribute("name")) {
323
+ formSelector = `form[name="${form.getAttribute("name")}"]`;
324
+ }
325
+ else {
326
+ // form의 클래스 기반 selector 시도
327
+ const formClassSelector = generateClassSelector(form);
328
+ if (formClassSelector) {
329
+ formSelector = formClassSelector;
330
+ }
331
+ else {
332
+ // 여러 form이 있을 경우 구조적 selector
333
+ const forms = document.querySelectorAll("form");
334
+ const index = Array.from(forms).indexOf(form);
335
+ formSelector = `form:nth-of-type(${index + 1})`;
336
+ }
337
+ }
338
+ // Form 내에서 모든 input/textarea/select 요소 중 순서 계산
339
+ const allFormFields = form.querySelectorAll("input, textarea, select");
340
+ const fieldIndex = Array.from(allFormFields).indexOf(element) + 1;
341
+ return { formSelector, fieldIndex };
342
+ }
210
343
  /**
211
344
  * Robust한 다중 selector 생성 (Playwright/Maestro 스타일)
212
345
  */
@@ -265,6 +398,25 @@ export function generateRobustLocator(element) {
265
398
  if (element instanceof HTMLImageElement && element.alt) {
266
399
  selectors.push(`img[alt="${element.alt}"]`);
267
400
  }
401
+ // === Form Input 특화: Label 텍스트 및 Form Context ===
402
+ if (element instanceof HTMLInputElement ||
403
+ element instanceof HTMLTextAreaElement ||
404
+ element instanceof HTMLSelectElement) {
405
+ // Label 텍스트 추출
406
+ const labelText = getAssociatedLabelText(element);
407
+ if (labelText) {
408
+ metadata.labelText = labelText;
409
+ }
410
+ // Form 내 위치 정보
411
+ const formContext = getFormContext(element);
412
+ if (formContext) {
413
+ metadata.formContext = formContext;
414
+ // Form context 기반 selector 추가 (fallback용)
415
+ selectors.push(`${formContext.formSelector} :nth-child(${formContext.fieldIndex}) input, ` +
416
+ `${formContext.formSelector} :nth-child(${formContext.fieldIndex}) textarea, ` +
417
+ `${formContext.formSelector} :nth-child(${formContext.fieldIndex}) select`);
418
+ }
419
+ }
268
420
  // === Tier 3: 구조적 selector ===
269
421
  // 클래스 기반
270
422
  const classSelector = generateClassSelector(element);
@@ -1,4 +1,4 @@
1
- import type { Step } from "@auto-wiz/core";
1
+ import type { Step, RunnerOptions } from "@auto-wiz/core";
2
2
  /**
3
3
  * Step execution 유틸리티
4
4
  * 각 Step 타입별 실행 로직
@@ -19,7 +19,7 @@ export declare function executeClickStep(step: Step): Promise<ExecutionResult>;
19
19
  /**
20
20
  * Type step 실행
21
21
  */
22
- export declare function executeTypeStep(step: Step): Promise<ExecutionResult>;
22
+ export declare function executeTypeStep(step: Step, options?: RunnerOptions): Promise<ExecutionResult>;
23
23
  /**
24
24
  * Select step 실행
25
25
  */
@@ -32,7 +32,11 @@ export declare function executeExtractStep(step: Step): Promise<ExecutionResult>
32
32
  * WaitFor step 실행
33
33
  */
34
34
  export declare function executeWaitForStep(step: Step): Promise<ExecutionResult>;
35
+ /**
36
+ * Keyboard step 실행
37
+ */
38
+ export declare function executeKeyboardStep(step: Step): Promise<ExecutionResult>;
35
39
  /**
36
40
  * Step 실행 (타입에 따라 자동 분기)
37
41
  */
38
- export declare function executeStep(step: Step): Promise<ExecutionResult>;
42
+ export declare function executeStep(step: Step, options?: RunnerOptions): Promise<ExecutionResult>;
@@ -1,6 +1,6 @@
1
1
  import { waitForLocator, isInteractable } from "../selectors/locatorUtils";
2
2
  /**
3
- * Step에서 요소 찾기 (locator 시스템 사용)
3
+ * Step에서 요소 찾기 (locator 시스템 사용) - HTMLElement와 SVGElement 모두 지원
4
4
  */
5
5
  async function findElement(step) {
6
6
  if (!("locator" in step) || !step.locator) {
@@ -41,7 +41,19 @@ export async function executeClickStep(step) {
41
41
  };
42
42
  }
43
43
  try {
44
- element.click();
44
+ // HTMLElement는 click() 메서드 사용, SVGElement는 dispatchEvent 사용
45
+ if (element instanceof HTMLElement) {
46
+ element.click();
47
+ }
48
+ else {
49
+ // SVGElement doesn't have click(), use dispatchEvent
50
+ const clickEvent = new MouseEvent("click", {
51
+ bubbles: true,
52
+ cancelable: true,
53
+ view: window,
54
+ });
55
+ element.dispatchEvent(clickEvent);
56
+ }
45
57
  return { success: true, usedSelector };
46
58
  }
47
59
  catch (error) {
@@ -52,10 +64,18 @@ export async function executeClickStep(step) {
52
64
  };
53
65
  }
54
66
  }
67
+ /**
68
+ * Resolve placeholders in text (e.g., {{username}} → variables.username)
69
+ */
70
+ function resolveText(text, variables) {
71
+ if (!variables || !text)
72
+ return text;
73
+ return text.replace(/\{\{(\w+)\}\}/g, (_, key) => variables[key] ?? "");
74
+ }
55
75
  /**
56
76
  * Type step 실행
57
77
  */
58
- export async function executeTypeStep(step) {
78
+ export async function executeTypeStep(step, options = {}) {
59
79
  if (step.type !== "type") {
60
80
  return { success: false, error: "Invalid type step" };
61
81
  }
@@ -81,7 +101,8 @@ export async function executeTypeStep(step) {
81
101
  };
82
102
  }
83
103
  try {
84
- const text = step.originalText || step.text || "";
104
+ const rawText = step.originalText || step.text || "";
105
+ const text = resolveText(rawText, options.variables);
85
106
  element.value = text;
86
107
  element.dispatchEvent(new Event("input", { bubbles: true }));
87
108
  element.dispatchEvent(new Event("change", { bubbles: true }));
@@ -250,16 +271,106 @@ export async function executeWaitForStep(step) {
250
271
  };
251
272
  }
252
273
  }
274
+ /**
275
+ * Keyboard step 실행
276
+ */
277
+ export async function executeKeyboardStep(step) {
278
+ if (step.type !== "keyboard") {
279
+ return { success: false, error: "Invalid keyboard step" };
280
+ }
281
+ const key = step.key;
282
+ if (!key) {
283
+ return { success: false, error: "Keyboard step requires key" };
284
+ }
285
+ try {
286
+ // locator가 있으면 해당 요소에 포커스 후 키 이벤트 발생
287
+ let targetElement = document.activeElement;
288
+ let usedSelector = "activeElement";
289
+ if ("locator" in step && step.locator) {
290
+ const { element, usedSelector: selector } = await findElement(step);
291
+ if (element) {
292
+ targetElement = element;
293
+ usedSelector = selector;
294
+ // HTMLElement만 focus() 메서드 지원
295
+ if (element instanceof HTMLElement) {
296
+ element.focus();
297
+ }
298
+ }
299
+ }
300
+ if (!targetElement) {
301
+ targetElement = document.body;
302
+ usedSelector = "body";
303
+ }
304
+ // 키보드 이벤트 발생
305
+ const keyCode = getKeyCode(key);
306
+ targetElement.dispatchEvent(new KeyboardEvent("keydown", {
307
+ key,
308
+ code: key === "Enter" ? "Enter" : key,
309
+ keyCode,
310
+ which: keyCode,
311
+ bubbles: true,
312
+ cancelable: true,
313
+ }));
314
+ // Enter 키인 경우 폼 제출 시도
315
+ if (key === "Enter") {
316
+ const activeEl = document.activeElement;
317
+ if (activeEl instanceof HTMLInputElement || activeEl instanceof HTMLTextAreaElement) {
318
+ const form = activeEl.form;
319
+ if (form) {
320
+ if (typeof form.requestSubmit === "function") {
321
+ form.requestSubmit();
322
+ }
323
+ else {
324
+ form.submit();
325
+ }
326
+ }
327
+ }
328
+ }
329
+ targetElement.dispatchEvent(new KeyboardEvent("keyup", {
330
+ key,
331
+ code: key === "Enter" ? "Enter" : key,
332
+ keyCode,
333
+ which: keyCode,
334
+ bubbles: true,
335
+ cancelable: true,
336
+ }));
337
+ return { success: true, usedSelector };
338
+ }
339
+ catch (error) {
340
+ return {
341
+ success: false,
342
+ error: `Failed to execute keyboard step: ${error.message}`,
343
+ };
344
+ }
345
+ }
346
+ /**
347
+ * 키 이름을 keyCode로 변환
348
+ */
349
+ function getKeyCode(key) {
350
+ const keyCodes = {
351
+ Enter: 13,
352
+ Tab: 9,
353
+ Escape: 27,
354
+ Backspace: 8,
355
+ Delete: 46,
356
+ ArrowUp: 38,
357
+ ArrowDown: 40,
358
+ ArrowLeft: 37,
359
+ ArrowRight: 39,
360
+ Space: 32,
361
+ };
362
+ return keyCodes[key] || key.charCodeAt(0);
363
+ }
253
364
  /**
254
365
  * Step 실행 (타입에 따라 자동 분기)
255
366
  */
256
- export async function executeStep(step) {
367
+ export async function executeStep(step, options = {}) {
257
368
  try {
258
369
  switch (step.type) {
259
370
  case "click":
260
371
  return await executeClickStep(step);
261
372
  case "type":
262
- return await executeTypeStep(step);
373
+ return await executeTypeStep(step, options);
263
374
  case "select":
264
375
  return await executeSelectStep(step);
265
376
  case "extract":
@@ -269,6 +380,11 @@ export async function executeStep(step) {
269
380
  case "navigate":
270
381
  // navigate는 background에서 처리
271
382
  return { success: true };
383
+ case "keyboard":
384
+ return await executeKeyboardStep(step);
385
+ case "waitForNavigation":
386
+ // waitForNavigation은 background에서 처리
387
+ return { success: true };
272
388
  default:
273
389
  return { success: false, error: `Unknown step type: ${step.type}` };
274
390
  }
@@ -295,7 +411,7 @@ function formatXml(xml) {
295
411
  // <[^>]+> : 태그
296
412
  // [^<]+ : 텍스트
297
413
  const tags = xml.match(/<[^>]+>|[^<]+/g) || [];
298
- tags.forEach(tag => {
414
+ tags.forEach((tag) => {
299
415
  // 닫는 태그 </... >
300
416
  if (tag.match(/^<\//)) {
301
417
  indent = Math.max(0, indent - 1);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@auto-wiz/dom",
3
- "version": "1.1.0",
3
+ "version": "1.1.2",
4
4
  "license": "MIT",
5
5
  "author": "JaeSang",
6
6
  "repository": {
@@ -23,7 +23,7 @@
23
23
  }
24
24
  },
25
25
  "dependencies": {
26
- "@auto-wiz/core": "1.2.0"
26
+ "@auto-wiz/core": "1.2.3"
27
27
  },
28
28
  "devDependencies": {
29
29
  "typescript": "^5.0.0"