@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 +2 -3
- package/dist/runner.js +4 -6
- package/dist/selectors/locatorUtils.d.ts +7 -6
- package/dist/selectors/locatorUtils.js +267 -12
- package/dist/selectors/selectorGenerator.d.ts +4 -4
- package/dist/selectors/selectorGenerator.js +164 -12
- package/dist/steps/stepExecution.d.ts +7 -3
- package/dist/steps/stepExecution.js +123 -7
- package/package.json +2 -2
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,
|
|
4
|
-
|
|
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 = {},
|
|
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
|
-
*
|
|
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
|
|
269
|
-
|
|
270
|
-
|
|
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
|
|
280
|
-
|
|
281
|
-
|
|
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
|
-
|
|
38
|
-
|
|
39
|
-
.
|
|
40
|
-
.
|
|
41
|
-
|
|
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
|
-
|
|
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) => !
|
|
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
|
-
|
|
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
|
|
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.
|
|
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.
|
|
26
|
+
"@auto-wiz/core": "1.2.3"
|
|
27
27
|
},
|
|
28
28
|
"devDependencies": {
|
|
29
29
|
"typescript": "^5.0.0"
|