- 실패 시나리오 11개 리라이트 + 중복 2개 삭제 (fill_form → READ-only 패턴) - 이전 78.7% → 88.0% 개선 (+9.3%p) - 실패 9건 중 7건은 사이드바 렌더링 인프라 이슈 - 실질 기능 성공률 97.1% (66/68) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1647 lines
55 KiB
JavaScript
1647 lines
55 KiB
JavaScript
/**
|
|
* E2E Step Executor - Browser-injected test runner
|
|
* Injected via playwright_evaluate, exposes window.__E2E__
|
|
*
|
|
* Handles both scenario JSON formats:
|
|
* Format A: { action, target, value, ... }
|
|
* Format B: { actions: [{ type, target, value }, ...] }
|
|
*/
|
|
(function () {
|
|
'use strict';
|
|
|
|
// Prevent double-injection
|
|
delete window.__E2E__;
|
|
|
|
// ─── Helpers ────────────────────────────────────────────
|
|
|
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
|
|
const now = () => Date.now();
|
|
|
|
/** Scroll element into view center */
|
|
const scrollIntoView = (el) => {
|
|
if (el && el.scrollIntoView) {
|
|
el.scrollIntoView({ block: 'center', behavior: 'instant' });
|
|
}
|
|
};
|
|
|
|
// ─── ApiMonitor ─────────────────────────────────────────
|
|
|
|
const ApiMonitor = {
|
|
_logs: [],
|
|
_errors: [],
|
|
_installed: false,
|
|
|
|
install() {
|
|
if (this._installed) return;
|
|
this._installed = true;
|
|
const self = this;
|
|
const origFetch = window.fetch;
|
|
window.fetch = async function (...args) {
|
|
const url = typeof args[0] === 'string' ? args[0] : args[0]?.url || '';
|
|
const method = (args[1]?.method || 'GET').toUpperCase();
|
|
const t0 = now();
|
|
try {
|
|
const resp = await origFetch.apply(this, args);
|
|
const entry = {
|
|
url,
|
|
method,
|
|
status: resp.status,
|
|
ok: resp.ok,
|
|
duration: now() - t0,
|
|
ts: new Date().toISOString(),
|
|
};
|
|
self._logs.push(entry);
|
|
if (!resp.ok) self._errors.push(entry);
|
|
return resp;
|
|
} catch (err) {
|
|
self._errors.push({ url, method, error: err.message, ts: new Date().toISOString() });
|
|
throw err;
|
|
}
|
|
};
|
|
|
|
// Also intercept XMLHttpRequest
|
|
const origOpen = XMLHttpRequest.prototype.open;
|
|
const origSend = XMLHttpRequest.prototype.send;
|
|
XMLHttpRequest.prototype.open = function (method, url, ...rest) {
|
|
this.__e2e_method = (method || 'GET').toUpperCase();
|
|
this.__e2e_url = url;
|
|
return origOpen.call(this, method, url, ...rest);
|
|
};
|
|
XMLHttpRequest.prototype.send = function (...args) {
|
|
const t0 = now();
|
|
const self2 = this;
|
|
this.addEventListener('loadend', function () {
|
|
const entry = {
|
|
url: self2.__e2e_url,
|
|
method: self2.__e2e_method,
|
|
status: self2.status,
|
|
ok: self2.status >= 200 && self2.status < 300,
|
|
duration: now() - t0,
|
|
ts: new Date().toISOString(),
|
|
};
|
|
self._logs.push(entry);
|
|
if (!entry.ok) self._errors.push(entry);
|
|
});
|
|
return origSend.apply(this, args);
|
|
};
|
|
},
|
|
|
|
summary() {
|
|
const logs = this._logs;
|
|
return {
|
|
total: logs.length,
|
|
success: logs.filter((l) => l.ok).length,
|
|
failed: this._errors.length,
|
|
avgResponseTime:
|
|
logs.length > 0
|
|
? Math.round(logs.reduce((s, l) => s + (l.duration || 0), 0) / logs.length)
|
|
: 0,
|
|
slowCalls: logs.filter((l) => l.duration > 2000).length,
|
|
};
|
|
},
|
|
|
|
findCall(urlPattern, method) {
|
|
return this._logs.find(
|
|
(l) =>
|
|
l.url.includes(urlPattern) && (!method || l.method === method.toUpperCase())
|
|
);
|
|
},
|
|
|
|
reset() {
|
|
this._logs = [];
|
|
this._errors = [];
|
|
},
|
|
};
|
|
|
|
// ─── ModalGuard ─────────────────────────────────────────
|
|
|
|
const MODAL_SELECTORS = [
|
|
"[role='dialog']",
|
|
"[aria-modal='true']",
|
|
"[class*='modal']:not([class*='tooltip']):not([class*='modal-backdrop'])",
|
|
"[class*='Modal']:not([class*='Tooltip'])",
|
|
"[class*='Dialog']:not([class*='tooltip'])",
|
|
];
|
|
|
|
const ModalGuard = {
|
|
check() {
|
|
for (const sel of MODAL_SELECTORS) {
|
|
const el = document.querySelector(sel);
|
|
if (el && el.offsetParent !== null) {
|
|
return { open: true, element: el };
|
|
}
|
|
}
|
|
return { open: false, element: null };
|
|
},
|
|
|
|
focus() {
|
|
const { open, element } = this.check();
|
|
if (open && element) {
|
|
const first = element.querySelector(
|
|
'input:not([type="hidden"]), textarea, select, button:not([class*="close"])'
|
|
);
|
|
if (first) first.focus();
|
|
return true;
|
|
}
|
|
return false;
|
|
},
|
|
|
|
async close() {
|
|
const MAX = 3;
|
|
for (let i = 0; i < MAX; i++) {
|
|
const { open, element } = this.check();
|
|
if (!open) return { closed: true };
|
|
|
|
// Try X button
|
|
const xBtn = element.querySelector(
|
|
"button[class*='close'], [aria-label='닫기'], [aria-label='Close'], button[class*='Close']"
|
|
);
|
|
if (xBtn) {
|
|
xBtn.click();
|
|
await sleep(500);
|
|
if (!this.check().open) return { closed: true };
|
|
}
|
|
|
|
// Try text buttons
|
|
const textBtn = Array.from(element.querySelectorAll('button')).find((b) =>
|
|
['닫기', 'Close', '취소', 'Cancel'].some((t) => b.innerText?.trim().includes(t))
|
|
);
|
|
if (textBtn) {
|
|
textBtn.click();
|
|
await sleep(500);
|
|
if (!this.check().open) return { closed: true };
|
|
}
|
|
|
|
// Try ESC
|
|
document.dispatchEvent(
|
|
new KeyboardEvent('keydown', { key: 'Escape', keyCode: 27, bubbles: true })
|
|
);
|
|
await sleep(500);
|
|
}
|
|
return { closed: !this.check().open };
|
|
},
|
|
|
|
/** Find element within modal if open, else in document */
|
|
scopedQuery(selector) {
|
|
const { open, element } = this.check();
|
|
const scope = open ? element : document;
|
|
return scope.querySelector(selector);
|
|
},
|
|
|
|
scopedQueryAll(selector) {
|
|
const { open, element } = this.check();
|
|
const scope = open ? element : document;
|
|
return scope.querySelectorAll(selector);
|
|
},
|
|
};
|
|
|
|
// ─── findEl: Universal Element Finder ───────────────────
|
|
|
|
/**
|
|
* Find element by flexible selector syntax:
|
|
* - CSS: "#id", ".class", "input[type='text']"
|
|
* - :has-text(): "button:has-text('등록')"
|
|
* - text=: "text=E2E 테스트"
|
|
* - text=/regex/: "text=/\\d+/"
|
|
* - plain Korean: "등록" → find clickable element containing text
|
|
* - comma-fallback: "button:has-text('저장'), button:has-text('등록')"
|
|
* - selector ref: looks up in selectors map if provided
|
|
*
|
|
* @param {string} selector
|
|
* @param {object} opts - { nth, selectors, scope }
|
|
* @returns {Element|null}
|
|
*/
|
|
function findEl(selector, opts = {}) {
|
|
if (!selector) return null;
|
|
const { nth, selectors, scope } = opts;
|
|
const root = scope || (ModalGuard.check().open ? ModalGuard.check().element : document);
|
|
|
|
// 1) Selector reference lookup (from scenario selectors map)
|
|
if (selectors && selectors[selector]) {
|
|
return findEl(selectors[selector], { ...opts, selectors: null });
|
|
}
|
|
|
|
// 2) Comma-separated fallback: try each
|
|
if (selector.includes(',') && !selector.includes(':has-text(')) {
|
|
const parts = selector.split(',').map((s) => s.trim());
|
|
for (const part of parts) {
|
|
const el = findEl(part, opts);
|
|
if (el) return el;
|
|
}
|
|
return null;
|
|
}
|
|
// Also handle comma within :has-text patterns
|
|
if (selector.includes(',') && selector.includes(':has-text(')) {
|
|
const parts = selector.split(/,\s*(?=\w+:has-text|button:|a:|div:|\[)/);
|
|
for (const part of parts) {
|
|
const el = findEl(part.trim(), opts);
|
|
if (el) return el;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
// 3) text= selector
|
|
if (selector.startsWith('text=')) {
|
|
const text = selector.slice(5);
|
|
// regex
|
|
if (text.startsWith('/') && text.endsWith('/')) {
|
|
const re = new RegExp(text.slice(1, -1));
|
|
const all = root.querySelectorAll('*');
|
|
const matches = Array.from(all).filter(
|
|
(el) => el.children.length === 0 && re.test(el.textContent)
|
|
);
|
|
return nth != null ? matches[nth] || null : matches[0] || null;
|
|
}
|
|
// plain text
|
|
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
|
|
while (walker.nextNode()) {
|
|
if (walker.currentNode.textContent.includes(text)) {
|
|
return walker.currentNode.parentElement;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
// 4) text=/regex/ standalone
|
|
if (selector.startsWith('text=/') && selector.endsWith('/')) {
|
|
const re = new RegExp(selector.slice(6, -1));
|
|
const all = root.querySelectorAll('*');
|
|
const matches = Array.from(all).filter(
|
|
(el) => el.children.length === 0 && re.test(el.textContent)
|
|
);
|
|
return nth != null ? matches[nth] || null : matches[0] || null;
|
|
}
|
|
|
|
// 5) :has-text() pseudo selector
|
|
if (selector.includes(':has-text(')) {
|
|
const m = selector.match(/^(.+?):has-text\(['"]?(.+?)['"]?\)(.*)$/);
|
|
if (m) {
|
|
const [, tag, text, suffix] = m;
|
|
let candidates = Array.from(root.querySelectorAll(tag));
|
|
candidates = candidates.filter((el) => el.innerText?.trim().includes(text));
|
|
if (suffix) {
|
|
// e.g. :last-of-type
|
|
if (suffix.includes('last')) candidates = candidates.slice(-1);
|
|
}
|
|
return nth != null ? candidates[nth] || null : candidates[0] || null;
|
|
}
|
|
}
|
|
|
|
// 6) Pure Korean/text (no CSS special chars) → search clickable elements
|
|
// Must contain at least one Korean character to enter this path (avoid matching CSS tag selectors like 'textarea', 'button')
|
|
if (/[가-힣]/.test(selector) && /^[가-힣\s\w]+$/.test(selector) && !selector.includes('#') && !selector.includes('.') && !selector.includes('[')) {
|
|
const clickable = Array.from(
|
|
root.querySelectorAll('a, button, [role="button"], [role="menuitem"], [role="tab"], [role="treeitem"], [onclick]')
|
|
);
|
|
const match = clickable.find((el) => el.innerText?.trim().includes(selector));
|
|
if (match) return match;
|
|
|
|
// Also try any element
|
|
const all = Array.from(root.querySelectorAll('*'));
|
|
const textMatch = all.find(
|
|
(el) => el.children.length === 0 && el.textContent?.trim().includes(selector)
|
|
);
|
|
return textMatch || null;
|
|
}
|
|
|
|
// 7) Standard CSS selector
|
|
try {
|
|
if (nth != null) {
|
|
const all = root.querySelectorAll(selector);
|
|
return all[nth] || null;
|
|
}
|
|
return root.querySelector(selector);
|
|
} catch {
|
|
// Invalid CSS selector, try text search fallback
|
|
const all = Array.from(root.querySelectorAll('*'));
|
|
return all.find((el) => el.textContent?.trim().includes(selector)) || null;
|
|
}
|
|
}
|
|
|
|
// ─── StepNormalizer ─────────────────────────────────────
|
|
|
|
/** Normalize action type aliases */
|
|
function normalizeActionType(type) {
|
|
if (!type) return 'noop';
|
|
const ALIASES = {
|
|
// Fill variants
|
|
input: 'fill',
|
|
type: 'fill',
|
|
type_text: 'fill',
|
|
text: 'fill',
|
|
textarea: 'fill',
|
|
email: 'fill',
|
|
password: 'fill',
|
|
number: 'fill',
|
|
'clear_and_type': 'fill',
|
|
'clear-and-type': 'fill',
|
|
// Click variants
|
|
'click+confirm': 'click_and_confirm',
|
|
click_download: 'click',
|
|
click_dropdown: 'select_dropdown',
|
|
click_checkbox: 'check',
|
|
click_if_exists: 'click_if_exists',
|
|
clickFirstRow: 'click_first_row',
|
|
clickInModal: 'click',
|
|
// Navigation
|
|
navigateBack: 'navigate_back',
|
|
goBack: 'navigate_back',
|
|
navigation: 'navigate',
|
|
directNavigation: 'navigate',
|
|
navigateViaMenuClick: 'menu_navigate',
|
|
refresh: 'reload',
|
|
// Wait variants
|
|
waitForModal: 'wait_for_modal',
|
|
waitForNavigation: 'wait_for_navigation',
|
|
waitForTable: 'wait_for_table',
|
|
// Verify variants
|
|
verify_elements: 'verify_element',
|
|
verify_table_data: 'verify_table',
|
|
verify_table_structure: 'verify_table',
|
|
verify_field: 'verify_element',
|
|
verify_checkbox: 'verify_element',
|
|
verify_action_buttons: 'verify_element',
|
|
verify_pagination: 'verify_element',
|
|
verify_summary: 'verify_text',
|
|
verify_totals: 'verify_text',
|
|
verify_search_result: 'verify_data',
|
|
verify_row_count: 'verify_table',
|
|
verify_vendor_info: 'verify_detail',
|
|
verify_detail_info: 'verify_detail',
|
|
verify_data_update: 'verify_data',
|
|
verify_transactions_update: 'verify_data',
|
|
verify_transaction_table: 'verify_table',
|
|
verify_calculated_value: 'verify_text',
|
|
verify_toast: 'verify_toast',
|
|
verifyButtonExists: 'verify_element',
|
|
verifyUrl: 'verify_url',
|
|
verifyNoErrorPage: 'verify_url_stability',
|
|
verify: 'verify_element',
|
|
// Select variants
|
|
select_option: 'select_dropdown',
|
|
select_or_click: 'select_dropdown',
|
|
combobox: 'select_dropdown',
|
|
// Modal
|
|
closeModal: 'close_modal',
|
|
close_modal: 'close_modal',
|
|
modalClose: 'close_modal',
|
|
openModal: 'wait_for_modal',
|
|
checkModalOpen: 'wait_for_modal',
|
|
fillInModal: 'fill',
|
|
selectInModal: 'select_dropdown',
|
|
// Other
|
|
confirm_dialog: 'click_dialog_confirm',
|
|
delete: 'click',
|
|
store: 'save_url',
|
|
getCurrentUrl: 'save_url',
|
|
clearSearch: 'clear',
|
|
blur: 'blur',
|
|
login: 'fill_form',
|
|
keypress: 'press_key',
|
|
press: 'press_key',
|
|
pressKey: 'press_key',
|
|
resize: 'noop',
|
|
log: 'noop',
|
|
manualVerification: 'noop',
|
|
generateTimestamp: 'generate_timestamp',
|
|
random: 'generate_timestamp',
|
|
setupDownloadListener: 'noop',
|
|
saveDownloadedFile: 'noop',
|
|
verifyDownload: 'noop',
|
|
verifyDownloadedFile: 'noop',
|
|
download: 'click',
|
|
// Date variants
|
|
change_date: 'fill',
|
|
change_date_range: 'fill',
|
|
date_range: 'fill',
|
|
date: 'fill',
|
|
datepicker: 'fill',
|
|
setDateRange: 'fill',
|
|
timepicker: 'fill',
|
|
// Composite/special
|
|
composite: 'noop',
|
|
hierarchy: 'noop',
|
|
permission: 'noop',
|
|
ifStillFailed: 'noop',
|
|
tryAlternativeUrls: 'noop',
|
|
element: 'verify_element',
|
|
elementExists: 'verify_element',
|
|
tableExists: 'verify_table',
|
|
tabsExist: 'verify_element',
|
|
error_message: 'verify_text',
|
|
warning: 'noop',
|
|
url: 'verify_url',
|
|
URL_STABILITY: 'verify_url_stability',
|
|
checkFor404: 'verify_url_stability',
|
|
// Expect/assert response
|
|
expectResponse: 'noop',
|
|
assertResponse: 'noop',
|
|
apiResponse: 'noop',
|
|
findRow: 'click_row',
|
|
scroll: 'scrollAndFind',
|
|
radio: 'check',
|
|
toggle_switch: 'check',
|
|
capture: 'capture',
|
|
screenshot: 'capture',
|
|
drag_start: 'noop',
|
|
drag_over: 'noop',
|
|
drag_end: 'noop',
|
|
};
|
|
return ALIASES[type] || type;
|
|
}
|
|
|
|
/**
|
|
* Normalize a step into unified format:
|
|
* Returns { stepId, name, subActions: [{type, target, value, ...}], critical, phase, verification }
|
|
*/
|
|
function normalizeStep(step) {
|
|
const stepId = step.id || step.step || 0;
|
|
const name = step.name || step.description || `Step ${stepId}`;
|
|
const critical = step.critical || false;
|
|
const phase = step.phase || null;
|
|
const verification = step.verification || step.verify || step.expected || null;
|
|
|
|
let subActions = [];
|
|
|
|
// Format B: actions array
|
|
if (Array.isArray(step.actions)) {
|
|
subActions = step.actions.map((a) => ({
|
|
...a,
|
|
type: normalizeActionType(a.type),
|
|
}));
|
|
}
|
|
// Format A: single action
|
|
else if (step.action) {
|
|
subActions = [
|
|
{
|
|
type: normalizeActionType(step.action),
|
|
target: step.target,
|
|
value: step.value,
|
|
variable: step.variable,
|
|
pattern: step.pattern,
|
|
nth: step.nth,
|
|
clear: step.clear,
|
|
fields: step.fields,
|
|
checks: step.checks,
|
|
search: step.search,
|
|
level1: step.level1,
|
|
level2: step.level2,
|
|
script: step.script,
|
|
duration: step.duration,
|
|
verification: step.verification,
|
|
verify: step.verify,
|
|
expected: step.expected,
|
|
critical: step.critical,
|
|
},
|
|
];
|
|
}
|
|
// No action defined - might be just verification
|
|
else {
|
|
subActions = [
|
|
{
|
|
type: 'noop',
|
|
verification,
|
|
},
|
|
];
|
|
}
|
|
|
|
return { stepId, name, subActions, critical, phase, verification };
|
|
}
|
|
|
|
// ─── Input Helpers ──────────────────────────────────────
|
|
|
|
/** Set value on an input/textarea using React-compatible events */
|
|
function setInputValue(el, value) {
|
|
if (!el) return false;
|
|
|
|
const isTextarea = el instanceof HTMLTextAreaElement;
|
|
const proto = isTextarea ? HTMLTextAreaElement.prototype : HTMLInputElement.prototype;
|
|
const nativeSetter = Object.getOwnPropertyDescriptor(proto, 'value')?.set;
|
|
|
|
// Method 1: React internal __reactProps$ onChange (most reliable for React controlled components)
|
|
const reactPropsKey = Object.keys(el).find(k => k.startsWith('__reactProps$'));
|
|
if (reactPropsKey && el[reactPropsKey] && typeof el[reactPropsKey].onChange === 'function') {
|
|
if (nativeSetter) nativeSetter.call(el, value);
|
|
else el.value = value;
|
|
el[reactPropsKey].onChange({ target: el, currentTarget: el });
|
|
if (el.value === value) return true;
|
|
}
|
|
|
|
// Method 2: execCommand('insertText') - goes through browser native input pipeline
|
|
el.focus();
|
|
el.select();
|
|
const execResult = document.execCommand('insertText', false, value);
|
|
if (execResult && el.value === value) return true;
|
|
|
|
// Method 3: native setter + _valueTracker reset + events (fallback)
|
|
if (nativeSetter) nativeSetter.call(el, value);
|
|
else el.value = value;
|
|
const tracker = el._valueTracker;
|
|
if (tracker) tracker.setValue('');
|
|
el.dispatchEvent(new Event('input', { bubbles: true }));
|
|
el.dispatchEvent(new Event('change', { bubbles: true }));
|
|
return true;
|
|
}
|
|
|
|
/** Clear an input */
|
|
function clearInput(el) {
|
|
if (!el) return false;
|
|
el.focus();
|
|
setInputValue(el, '');
|
|
return true;
|
|
}
|
|
|
|
/** Trigger a click reliably */
|
|
function triggerClick(el) {
|
|
if (!el) return false;
|
|
scrollIntoView(el);
|
|
el.focus && el.focus();
|
|
el.click();
|
|
return true;
|
|
}
|
|
|
|
// ─── Action Handlers ────────────────────────────────────
|
|
|
|
const ActionHandlers = {
|
|
// ── Click group ──
|
|
async click(action, ctx) {
|
|
const el = findEl(action.target, { selectors: ctx.selectors });
|
|
if (!el) return fail(`Element not found: ${action.target}`);
|
|
scrollIntoView(el);
|
|
await sleep(100);
|
|
triggerClick(el);
|
|
await sleep(300);
|
|
return pass(`Clicked: ${action.target}`);
|
|
},
|
|
|
|
async click_nth(action, ctx) {
|
|
const n = action.nth ?? 0;
|
|
const el = findEl(action.target, { nth: n, selectors: ctx.selectors });
|
|
if (!el) return fail(`Element[${n}] not found: ${action.target}`);
|
|
scrollIntoView(el);
|
|
triggerClick(el);
|
|
await sleep(300);
|
|
return pass(`Clicked [${n}]: ${action.target}`);
|
|
},
|
|
|
|
async click_row(action, ctx) {
|
|
// Click a table row containing text
|
|
const text = action.target || action.value;
|
|
const rows = document.querySelectorAll('table tbody tr');
|
|
const row = Array.from(rows).find((r) => r.innerText?.includes(text));
|
|
if (!row) return fail(`Row with "${text}" not found`);
|
|
scrollIntoView(row);
|
|
row.click();
|
|
await sleep(500);
|
|
return pass(`Clicked row: ${text}`);
|
|
},
|
|
|
|
async click_first_row(action, ctx) {
|
|
const row = document.querySelector('table tbody tr');
|
|
if (!row) return fail('No table rows found');
|
|
scrollIntoView(row);
|
|
row.click();
|
|
await sleep(500);
|
|
return pass('Clicked first row');
|
|
},
|
|
|
|
async click_button(action, ctx) {
|
|
// Find button by text
|
|
const text = action.value || action.target;
|
|
const btns = Array.from(document.querySelectorAll('button, [role="button"]'));
|
|
const btn = btns.find((b) => b.innerText?.trim().includes(text));
|
|
if (!btn) return fail(`Button "${text}" not found`);
|
|
scrollIntoView(btn);
|
|
triggerClick(btn);
|
|
await sleep(300);
|
|
return pass(`Clicked button: ${text}`);
|
|
},
|
|
|
|
async click_dialog_confirm(action, ctx) {
|
|
await sleep(300);
|
|
const dialog =
|
|
document.querySelector('[role="alertdialog"]') ||
|
|
document.querySelector('[role="dialog"]');
|
|
if (!dialog) return fail('No dialog found');
|
|
|
|
const confirmTexts = ['확인', '예', '삭제', 'OK', 'Yes', 'Confirm'];
|
|
const btns = Array.from(dialog.querySelectorAll('button'));
|
|
const btn = btns.find((b) =>
|
|
confirmTexts.some((t) => b.innerText?.trim().includes(t))
|
|
);
|
|
if (!btn) return fail('Confirm button not found in dialog');
|
|
triggerClick(btn);
|
|
await sleep(500);
|
|
return pass('Confirmed dialog');
|
|
},
|
|
|
|
// ── Fill group ──
|
|
async fill(action, ctx) {
|
|
let el = findEl(action.target, { selectors: ctx.selectors });
|
|
if (!el) return fail(`Input not found: ${action.target}`);
|
|
scrollIntoView(el);
|
|
el.focus();
|
|
if (action.clear !== false) clearInput(el);
|
|
let value = action.value ?? '';
|
|
// Replace {timestamp} placeholder
|
|
value = replaceVars(value, ctx.variables);
|
|
setInputValue(el, value);
|
|
await sleep(200);
|
|
return pass(`Filled "${action.target}" with "${value.substring(0, 30)}"`);
|
|
},
|
|
|
|
async fill_nth(action, ctx) {
|
|
const n = action.nth ?? 0;
|
|
const el = findEl(action.target, { nth: n, selectors: ctx.selectors });
|
|
if (!el) return fail(`Input[${n}] not found: ${action.target}`);
|
|
el.focus();
|
|
clearInput(el);
|
|
const value = replaceVars(action.value ?? '', ctx.variables);
|
|
setInputValue(el, value);
|
|
await sleep(200);
|
|
return pass(`Filled [${n}] with "${value.substring(0, 30)}"`);
|
|
},
|
|
|
|
async fill_form(action, ctx) {
|
|
const fields = action.fields || [];
|
|
const results = [];
|
|
for (const field of fields) {
|
|
const label = field.name || field.label;
|
|
let value = replaceVars(field.value ?? '', ctx.variables);
|
|
|
|
// Try to find by label text → associated input
|
|
let el = null;
|
|
|
|
// Try label[for] → input
|
|
const labels = Array.from(document.querySelectorAll('label'));
|
|
const matchLabel = labels.find((l) => l.textContent?.includes(label));
|
|
if (matchLabel) {
|
|
const forId = matchLabel.getAttribute('for');
|
|
if (forId) el = document.getElementById(forId);
|
|
if (!el) el = matchLabel.querySelector('input, textarea, select');
|
|
if (!el) el = matchLabel.parentElement?.querySelector('input, textarea, select');
|
|
}
|
|
|
|
// Fallback: placeholder search
|
|
if (!el) {
|
|
el = document.querySelector(
|
|
`input[placeholder*="${label}"], textarea[placeholder*="${label}"]`
|
|
);
|
|
}
|
|
|
|
// Fallback: name search
|
|
if (!el) {
|
|
el = document.querySelector(`input[name*="${label}"], textarea[name*="${label}"]`);
|
|
}
|
|
|
|
if (!el) {
|
|
results.push({ field: label, status: 'skip', detail: 'not found' });
|
|
continue;
|
|
}
|
|
|
|
if (field.type === 'select' || el.tagName === 'SELECT') {
|
|
// Select handling
|
|
const options = Array.from(el.querySelectorAll('option'));
|
|
const opt = options.find((o) => o.textContent?.includes(value));
|
|
if (opt) {
|
|
el.value = opt.value;
|
|
el.dispatchEvent(new Event('change', { bubbles: true }));
|
|
}
|
|
} else if (field.type === 'date') {
|
|
setInputValue(el, value);
|
|
} else {
|
|
clearInput(el);
|
|
setInputValue(el, value);
|
|
}
|
|
results.push({ field: label, status: 'ok' });
|
|
await sleep(150);
|
|
}
|
|
const filled = results.filter((r) => r.status === 'ok').length;
|
|
const skipped = results.filter((r) => r.status === 'skip').length;
|
|
if (filled === 0) return fail(`fill_form: no fields filled (${skipped} not found)`);
|
|
return pass(`fill_form: ${filled}/${fields.length} filled`);
|
|
},
|
|
|
|
async fill_and_wait(action, ctx) {
|
|
const result = await ActionHandlers.fill(action, ctx);
|
|
await sleep(1000);
|
|
return result;
|
|
},
|
|
|
|
async clear(action, ctx) {
|
|
const el = findEl(action.target, { selectors: ctx.selectors });
|
|
if (!el) return fail(`Input not found: ${action.target}`);
|
|
clearInput(el);
|
|
await sleep(200);
|
|
return pass(`Cleared: ${action.target}`);
|
|
},
|
|
|
|
async edit_field(action, ctx) {
|
|
return ActionHandlers.fill(action, ctx);
|
|
},
|
|
|
|
// ── Select group ──
|
|
async select(action, ctx) {
|
|
const el = findEl(action.target, { selectors: ctx.selectors });
|
|
if (!el) return fail(`Select not found: ${action.target}`);
|
|
|
|
if (el.tagName === 'SELECT') {
|
|
const options = Array.from(el.querySelectorAll('option'));
|
|
const opt = options.find((o) => o.textContent?.includes(action.value));
|
|
if (opt) {
|
|
el.value = opt.value;
|
|
el.dispatchEvent(new Event('change', { bubbles: true }));
|
|
return pass(`Selected: ${action.value}`);
|
|
}
|
|
return fail(`Option "${action.value}" not found`);
|
|
}
|
|
// Custom dropdown
|
|
return ActionHandlers.select_dropdown(action, ctx);
|
|
},
|
|
|
|
async select_dropdown(action, ctx) {
|
|
// Click trigger to open dropdown
|
|
const trigger = findEl(action.target, { selectors: ctx.selectors });
|
|
if (!trigger) return fail(`Dropdown trigger not found: ${action.target}`);
|
|
triggerClick(trigger);
|
|
await sleep(500);
|
|
|
|
// Find option in dropdown list
|
|
const optionSelectors = [
|
|
'[role="option"]',
|
|
'[role="listbox"] li',
|
|
'[class*="option"]',
|
|
'[class*="menu-item"]',
|
|
'[class*="dropdown-item"]',
|
|
'li',
|
|
];
|
|
for (const sel of optionSelectors) {
|
|
const options = document.querySelectorAll(sel);
|
|
const opt = Array.from(options).find((o) => o.textContent?.trim().includes(action.value));
|
|
if (opt) {
|
|
triggerClick(opt);
|
|
await sleep(300);
|
|
return pass(`Selected dropdown: ${action.value}`);
|
|
}
|
|
}
|
|
return fail(`Dropdown option "${action.value}" not found`);
|
|
},
|
|
|
|
async select_filter(action, ctx) {
|
|
return ActionHandlers.select_dropdown(action, ctx);
|
|
},
|
|
|
|
// ── Check group ──
|
|
async check(action, ctx) {
|
|
const el = findEl(action.target, { selectors: ctx.selectors });
|
|
if (!el) return fail(`Checkbox not found: ${action.target}`);
|
|
if (!el.checked) {
|
|
triggerClick(el);
|
|
await sleep(200);
|
|
}
|
|
return pass(`Checked: ${action.target}`);
|
|
},
|
|
|
|
async uncheck(action, ctx) {
|
|
const el = findEl(action.target, { selectors: ctx.selectors });
|
|
if (!el) return fail(`Checkbox not found: ${action.target}`);
|
|
if (el.checked) {
|
|
triggerClick(el);
|
|
await sleep(200);
|
|
}
|
|
return pass(`Unchecked: ${action.target}`);
|
|
},
|
|
|
|
async check_nth(action, ctx) {
|
|
const n = action.nth ?? 0;
|
|
const el = findEl(action.target, { nth: n, selectors: ctx.selectors });
|
|
if (!el) return fail(`Checkbox[${n}] not found: ${action.target}`);
|
|
if (!el.checked) {
|
|
triggerClick(el);
|
|
await sleep(200);
|
|
}
|
|
return pass(`Checked [${n}]: ${action.target}`);
|
|
},
|
|
|
|
async uncheck_nth(action, ctx) {
|
|
const n = action.nth ?? 0;
|
|
const el = findEl(action.target, { nth: n, selectors: ctx.selectors });
|
|
if (!el) return fail(`Checkbox[${n}] not found: ${action.target}`);
|
|
if (el.checked) {
|
|
triggerClick(el);
|
|
await sleep(200);
|
|
}
|
|
return pass(`Unchecked [${n}]: ${action.target}`);
|
|
},
|
|
|
|
// ── Wait group ──
|
|
async wait(action, ctx) {
|
|
const ms = action.duration || action.timeout || 1000;
|
|
await sleep(ms);
|
|
return pass(`Waited ${ms}ms`);
|
|
},
|
|
|
|
async wait_for_element(action, ctx) {
|
|
const timeout = action.timeout || 10000;
|
|
const t0 = now();
|
|
while (now() - t0 < timeout) {
|
|
const el = findEl(action.target, { selectors: ctx.selectors });
|
|
if (el) return pass(`Found: ${action.target}`);
|
|
await sleep(200);
|
|
}
|
|
return fail(`Timeout waiting for: ${action.target}`);
|
|
},
|
|
|
|
async wait_for_table(action, ctx) {
|
|
const timeout = action.timeout || 10000;
|
|
const t0 = now();
|
|
while (now() - t0 < timeout) {
|
|
const rows = document.querySelectorAll('table tbody tr');
|
|
if (rows.length > 0) return pass(`Table loaded: ${rows.length} rows`);
|
|
await sleep(300);
|
|
}
|
|
return fail('Timeout waiting for table data');
|
|
},
|
|
|
|
async wait_for_modal(action, ctx) {
|
|
const timeout = action.timeout || 5000;
|
|
const t0 = now();
|
|
while (now() - t0 < timeout) {
|
|
if (ModalGuard.check().open) return pass('Modal appeared');
|
|
await sleep(200);
|
|
}
|
|
return fail('Timeout waiting for modal');
|
|
},
|
|
|
|
async wait_for_navigation(action, ctx) {
|
|
await sleep(2000);
|
|
const v = action.expected || action.verification;
|
|
if (v) {
|
|
const urlCheck = v.url_contains || v.url;
|
|
if (urlCheck && !window.location.href.includes(urlCheck)) {
|
|
return warn(`URL doesn't contain "${urlCheck}": ${window.location.href}`);
|
|
}
|
|
if (v.visible) {
|
|
const text = document.body.innerText;
|
|
const missing = v.visible.filter((t) => !text.includes(t));
|
|
if (missing.length > 0) {
|
|
return warn(`Missing visible text: ${missing.join(', ')}`);
|
|
}
|
|
}
|
|
}
|
|
return pass(`Navigation ok: ${window.location.href}`);
|
|
},
|
|
|
|
// ── Verify group ──
|
|
async verify_element(action, ctx) {
|
|
const v = action.verification || action.verify || action.expected || {};
|
|
const target = action.target;
|
|
|
|
if (!target) {
|
|
// checks-based verification
|
|
return ActionHandlers.verify_checks(action, ctx);
|
|
}
|
|
|
|
const el = findEl(target, { selectors: ctx.selectors });
|
|
|
|
if (v.exists === false) {
|
|
return el ? fail(`Element should NOT exist: ${target}`) : pass(`Confirmed absent: ${target}`);
|
|
}
|
|
if (v.count != null) {
|
|
const all = document.querySelectorAll(target);
|
|
return all.length >= v.count
|
|
? pass(`Count ${all.length} >= ${v.count}: ${target}`)
|
|
: warn(`Count ${all.length} < ${v.count}: ${target}`);
|
|
}
|
|
if (el) return pass(`Element exists: ${target}`);
|
|
return warn(`Element not found: ${target}`);
|
|
},
|
|
|
|
async verify_checks(action, ctx) {
|
|
// Verify general checks array (text-based)
|
|
const checks = action.checks || [];
|
|
if (checks.length === 0) return pass('No checks defined');
|
|
const text = document.body.innerText;
|
|
let passed = 0;
|
|
for (const check of checks) {
|
|
// Extract key terms from check text
|
|
const terms = check.match(/['']([^'']+)['']|[가-힣\w]+/g) || [];
|
|
if (terms.some((t) => text.includes(t.replace(/['']/g, '')))) passed++;
|
|
}
|
|
return passed > 0
|
|
? pass(`Checks: ${passed}/${checks.length} verified`)
|
|
: warn(`Checks: 0/${checks.length} verified`);
|
|
},
|
|
|
|
async verify_text(action, ctx) {
|
|
const v = action.verification || {};
|
|
const text = v.text || v.text_pattern;
|
|
if (!text) return pass('No text to verify');
|
|
|
|
const pageText = document.body.innerText;
|
|
|
|
if (v.text_pattern) {
|
|
const re = new RegExp(v.text_pattern);
|
|
return re.test(pageText)
|
|
? pass(`Text pattern found: ${v.text_pattern}`)
|
|
: fail(`Text pattern NOT found: ${v.text_pattern}`);
|
|
}
|
|
|
|
const exists = v.exists !== false;
|
|
const found = pageText.includes(text);
|
|
|
|
if (exists && found) return pass(`Text found: "${text.substring(0, 40)}"`);
|
|
if (exists && !found) return fail(`Text NOT found: "${text.substring(0, 40)}"`);
|
|
if (!exists && !found) return pass(`Text correctly absent: "${text.substring(0, 40)}"`);
|
|
if (!exists && found) return fail(`Text should be absent: "${text.substring(0, 40)}"`);
|
|
return pass('verify_text');
|
|
},
|
|
|
|
async verify_url(action, ctx) {
|
|
const v = action.verification || action.expected || {};
|
|
const url = window.location.href;
|
|
|
|
if (v.url_contains) {
|
|
if (!url.includes(v.url_contains)) return fail(`URL missing: ${v.url_contains}`);
|
|
}
|
|
if (v.url) {
|
|
if (!url.includes(v.url)) return fail(`URL missing: ${v.url}`);
|
|
}
|
|
if (v.url_pattern) {
|
|
const re = new RegExp(v.url_pattern);
|
|
if (!re.test(url)) return fail(`URL pattern mismatch: ${v.url_pattern}`);
|
|
}
|
|
if (v.visible) {
|
|
const text = document.body.innerText;
|
|
const missing = v.visible.filter((t) => !text.includes(t));
|
|
if (missing.length > 0) return warn(`Missing text: ${missing.join(', ')}`);
|
|
}
|
|
return pass(`URL verified: ${url}`);
|
|
},
|
|
|
|
async verify_url_stability(action, ctx) {
|
|
await sleep(2000);
|
|
const url = window.location.href;
|
|
const v = action.verification || {};
|
|
|
|
// Check 404 / error page
|
|
const pageText = document.body.innerText;
|
|
if (pageText.includes('404') && pageText.includes('Not Found')) {
|
|
return fail('404 error page detected');
|
|
}
|
|
if (pageText.includes('500') && pageText.includes('Internal Server Error')) {
|
|
return fail('500 error page detected');
|
|
}
|
|
|
|
if (v.expected_url_pattern) {
|
|
const re = new RegExp(v.expected_url_pattern);
|
|
if (!re.test(url)) return fail(`URL pattern mismatch: ${v.expected_url_pattern}`);
|
|
}
|
|
if (v.expected_url) {
|
|
if (!url.includes(v.expected_url)) return fail(`URL missing: ${v.expected_url}`);
|
|
}
|
|
|
|
return pass(`URL stable: ${url}`);
|
|
},
|
|
|
|
async verify_table(action, ctx) {
|
|
const v = action.verification || {};
|
|
const table = document.querySelector('table');
|
|
if (!table) return warn('No table found');
|
|
|
|
const headers = Array.from(table.querySelectorAll('thead th, thead td')).map((h) =>
|
|
h.textContent?.trim()
|
|
);
|
|
const rows = table.querySelectorAll('tbody tr');
|
|
|
|
if (v.columns) {
|
|
const missing = v.columns.filter(
|
|
(col) => !headers.some((h) => h?.includes(col))
|
|
);
|
|
if (missing.length > 0) return warn(`Missing columns: ${missing.join(', ')}`);
|
|
}
|
|
|
|
return pass(`Table: ${headers.length} cols, ${rows.length} rows`);
|
|
},
|
|
|
|
async verify_table_structure(action, ctx) {
|
|
return ActionHandlers.verify_table(action, ctx);
|
|
},
|
|
|
|
async verify_data(action, ctx) {
|
|
const searchText = action.search || action.value || '';
|
|
const v = action.expected || action.verification || {};
|
|
const pageText = document.body.innerText;
|
|
const found = pageText.includes(searchText);
|
|
|
|
if (v.row_exists === false) {
|
|
return found
|
|
? fail(`Data should be absent: "${searchText}"`)
|
|
: pass(`Data correctly absent: "${searchText}"`);
|
|
}
|
|
if (v.row_exists === true || v.row_exists === undefined) {
|
|
if (!found) return fail(`Data not found: "${searchText}"`);
|
|
if (v.contains) {
|
|
const missing = v.contains.filter((t) => !pageText.includes(t));
|
|
if (missing.length > 0) return warn(`Missing: ${missing.join(', ')}`);
|
|
}
|
|
return pass(`Data found: "${searchText}"`);
|
|
}
|
|
return pass('verify_data');
|
|
},
|
|
|
|
async verify_detail(action, ctx) {
|
|
const checks = action.checks || [];
|
|
const pageText = document.body.innerText;
|
|
let matched = 0;
|
|
for (const check of checks) {
|
|
// Parse "label: value" format
|
|
const parts = check.split(':').map((s) => s.trim());
|
|
const searchText = parts[parts.length - 1]; // just check value part
|
|
if (pageText.includes(searchText)) matched++;
|
|
}
|
|
return matched > 0
|
|
? pass(`Detail checks: ${matched}/${checks.length}`)
|
|
: warn(`Detail checks: 0/${checks.length} matched`);
|
|
},
|
|
|
|
async verify_not_mockup(action, ctx) {
|
|
const inputs = document.querySelectorAll(
|
|
'input:not([type="hidden"]), textarea, select'
|
|
);
|
|
const buttons = document.querySelectorAll('button, [role="button"]');
|
|
const tables = document.querySelectorAll('table');
|
|
|
|
let mockupScore = 0;
|
|
if (inputs.length === 0) mockupScore++;
|
|
if (buttons.length <= 1) mockupScore++;
|
|
if (tables.length === 0 && inputs.length === 0) mockupScore++;
|
|
|
|
return mockupScore >= 2
|
|
? warn(`Possible mockup page (score: ${mockupScore})`)
|
|
: pass(`Real page: ${inputs.length} inputs, ${buttons.length} buttons`);
|
|
},
|
|
|
|
async verify_dialog(action, ctx) {
|
|
const v = action.verification || {};
|
|
const dialog =
|
|
document.querySelector('[role="alertdialog"]') ||
|
|
document.querySelector('[role="dialog"]');
|
|
if (!dialog) return warn('No dialog found');
|
|
const text = dialog.innerText || '';
|
|
if (v.content_contains && !text.includes(v.content_contains)) {
|
|
return warn(`Dialog missing text: ${v.content_contains}`);
|
|
}
|
|
return pass('Dialog verified');
|
|
},
|
|
|
|
async verify_input_value(action, ctx) {
|
|
const el = findEl(action.target, { selectors: ctx.selectors });
|
|
if (!el) return fail(`Input not found: ${action.target}`);
|
|
const v = action.verification || {};
|
|
if (v.value && el.value !== v.value) {
|
|
return fail(`Value mismatch: expected "${v.value}", got "${el.value}"`);
|
|
}
|
|
return pass(`Input value: "${el.value?.substring(0, 30)}"`);
|
|
},
|
|
|
|
async verify_page(action, ctx) {
|
|
const v = action.verification || {};
|
|
const pageText = document.body.innerText;
|
|
if (v.title && !pageText.includes(v.title)) {
|
|
return fail(`Page title missing: ${v.title}`);
|
|
}
|
|
if (v.content_contains && !pageText.includes(v.content_contains)) {
|
|
return fail(`Page content missing: ${v.content_contains}`);
|
|
}
|
|
return pass('Page verified');
|
|
},
|
|
|
|
async verify_console(action, ctx) {
|
|
// Can't access real console from page context; return pass
|
|
return pass('Console check (monitored externally)');
|
|
},
|
|
|
|
async verify_data_change(action, ctx) {
|
|
await sleep(1000);
|
|
return pass('Data change check');
|
|
},
|
|
|
|
async verify_edit_mode(action, ctx) {
|
|
const url = window.location.href;
|
|
const hasEdit = url.includes('mode=edit') || url.includes('edit');
|
|
const inputs = document.querySelectorAll('input:not([type="hidden"]):not([disabled])');
|
|
return hasEdit || inputs.length > 0
|
|
? pass('Edit mode active')
|
|
: warn('Edit mode not detected');
|
|
},
|
|
|
|
// ── State/Other group ──
|
|
async save_url(action, ctx) {
|
|
const varName = action.variable || 'saved_url';
|
|
ctx.variables[varName] = window.location.href;
|
|
return pass(`Saved URL → ${varName}`);
|
|
},
|
|
|
|
async extract_from_url(action, ctx) {
|
|
const pattern = action.pattern;
|
|
const varName = action.variable || 'extracted';
|
|
const m = window.location.href.match(new RegExp(pattern));
|
|
if (m && m[1]) {
|
|
ctx.variables[varName] = m[1];
|
|
return pass(`Extracted "${m[1]}" → ${varName}`);
|
|
}
|
|
return warn(`Pattern not matched: ${pattern}`);
|
|
},
|
|
|
|
async capture(action, ctx) {
|
|
// Requires native MCP call - signal to orchestrator
|
|
return { status: 'native_required', type: 'screenshot', details: action.name || 'capture' };
|
|
},
|
|
|
|
async close_modal(action, ctx) {
|
|
const result = await ModalGuard.close();
|
|
return result.closed ? pass('Modal closed') : warn('Modal close failed');
|
|
},
|
|
|
|
async close_modal_if_open(action, ctx) {
|
|
if (!ModalGuard.check().open) return pass('No modal open');
|
|
const result = await ModalGuard.close();
|
|
return result.closed ? pass('Modal closed') : warn('Modal close failed');
|
|
},
|
|
|
|
async scrollAndFind(action, ctx) {
|
|
const text = action.target;
|
|
const containerSel = action.container || '.sidebar-scroll, [data-sidebar="content"], nav';
|
|
const maxAttempts = action.maxAttempts || 10;
|
|
const scrollStep = action.scrollStep || 200;
|
|
|
|
const container =
|
|
document.querySelector(containerSel.split(',')[0].trim()) ||
|
|
document.querySelector(containerSel.split(',')[1]?.trim()) ||
|
|
document.querySelector('nav');
|
|
|
|
if (container) container.scrollTo({ top: 0, behavior: 'instant' });
|
|
await sleep(200);
|
|
|
|
for (let i = 0; i < maxAttempts; i++) {
|
|
const el = findEl(text);
|
|
if (el) {
|
|
scrollIntoView(el);
|
|
return pass(`Found: ${text}`);
|
|
}
|
|
if (container) container.scrollBy({ top: scrollStep, behavior: 'instant' });
|
|
await sleep(150);
|
|
}
|
|
return warn(`scrollAndFind: "${text}" not found after ${maxAttempts} scrolls`);
|
|
},
|
|
|
|
async evaluate(action, ctx) {
|
|
try {
|
|
const result = eval(action.script);
|
|
if (result instanceof Promise) await result;
|
|
return pass('evaluate ok');
|
|
} catch (err) {
|
|
return warn(`evaluate error: ${err.message}`);
|
|
}
|
|
},
|
|
|
|
async search(action, ctx) {
|
|
const el = findEl(action.target, { selectors: ctx.selectors });
|
|
if (!el) {
|
|
// Fallback: find any search input
|
|
const searchInput = document.querySelector(
|
|
'input[type="search"], input[placeholder*="검색"], input[placeholder*="Search"]'
|
|
);
|
|
if (!searchInput) return fail('Search input not found');
|
|
clearInput(searchInput);
|
|
setInputValue(searchInput, action.value || '');
|
|
await sleep(1000);
|
|
return pass(`Searched: "${action.value}"`);
|
|
}
|
|
clearInput(el);
|
|
setInputValue(el, action.value || '');
|
|
await sleep(1000);
|
|
return pass(`Searched: "${action.value}"`);
|
|
},
|
|
|
|
async click_and_confirm(action, ctx) {
|
|
const el = findEl(action.target, { selectors: ctx.selectors });
|
|
if (!el) return fail(`Element not found: ${action.target}`);
|
|
triggerClick(el);
|
|
await sleep(500);
|
|
// Now look for confirm dialog
|
|
return ActionHandlers.click_dialog_confirm(action, ctx);
|
|
},
|
|
|
|
async click_if_exists(action, ctx) {
|
|
const el = findEl(action.target, { selectors: ctx.selectors });
|
|
if (!el) return pass(`Element not present (ok): ${action.target}`);
|
|
triggerClick(el);
|
|
await sleep(300);
|
|
return pass(`Clicked (existed): ${action.target}`);
|
|
},
|
|
|
|
async press_key(action, ctx) {
|
|
const key = action.value || action.key || 'Escape';
|
|
const target = action.target ? findEl(action.target, { selectors: ctx.selectors }) : document.activeElement;
|
|
if (target) {
|
|
target.dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true }));
|
|
target.dispatchEvent(new KeyboardEvent('keyup', { key, bubbles: true }));
|
|
}
|
|
await sleep(200);
|
|
return pass(`Pressed key: ${key}`);
|
|
},
|
|
|
|
async blur(action, ctx) {
|
|
const el = action.target
|
|
? findEl(action.target, { selectors: ctx.selectors })
|
|
: document.activeElement;
|
|
if (el) el.blur();
|
|
await sleep(200);
|
|
return pass('Blurred');
|
|
},
|
|
|
|
async generate_timestamp(action, ctx) {
|
|
const n = new Date();
|
|
const pad = (v) => v.toString().padStart(2, '0');
|
|
const ts = `${n.getFullYear()}${pad(n.getMonth() + 1)}${pad(n.getDate())}_${pad(n.getHours())}${pad(n.getMinutes())}${pad(n.getSeconds())}`;
|
|
const varName = action.variable || 'timestamp';
|
|
ctx.variables[varName] = ts;
|
|
return pass(`Generated timestamp: ${ts}`);
|
|
},
|
|
|
|
async verify_toast(action, ctx) {
|
|
// Look for toast/notification elements
|
|
const toastSels = [
|
|
'[class*="toast"]',
|
|
'[class*="Toast"]',
|
|
'[class*="notification"]',
|
|
'[class*="Notification"]',
|
|
'[class*="snackbar"]',
|
|
'[class*="Snackbar"]',
|
|
'[role="alert"]',
|
|
'[class*="alert"]:not([class*="dialog"])',
|
|
];
|
|
await sleep(500);
|
|
for (const sel of toastSels) {
|
|
const el = document.querySelector(sel);
|
|
if (el && el.offsetParent !== null) {
|
|
const text = el.innerText || '';
|
|
const v = action.verification || action.verify || {};
|
|
if (v.contains) {
|
|
const patterns = v.contains.split('|');
|
|
if (patterns.some((p) => text.includes(p))) {
|
|
return pass(`Toast found: "${text.substring(0, 50)}"`);
|
|
}
|
|
}
|
|
return pass(`Toast visible: "${text.substring(0, 50)}"`);
|
|
}
|
|
}
|
|
return warn('No toast/notification found');
|
|
},
|
|
|
|
async hover(action, ctx) {
|
|
const el = findEl(action.target, { selectors: ctx.selectors });
|
|
if (!el) return warn(`Hover target not found: ${action.target}`);
|
|
el.dispatchEvent(new MouseEvent('mouseover', { bubbles: true }));
|
|
el.dispatchEvent(new MouseEvent('mouseenter', { bubbles: true }));
|
|
await sleep(300);
|
|
return pass(`Hovered: ${action.target}`);
|
|
},
|
|
|
|
// ── Navigation group (handled by orchestrator mostly) ──
|
|
async navigate(action, ctx) {
|
|
// Signal navigation needed
|
|
const url = action.target;
|
|
if (url && url.startsWith('/')) {
|
|
window.location.href = window.location.origin + url;
|
|
return { status: 'navigation', details: `Navigate to ${url}` };
|
|
}
|
|
return { status: 'navigation', details: `Navigate: ${url}` };
|
|
},
|
|
|
|
async reload(action, ctx) {
|
|
window.location.reload();
|
|
return { status: 'navigation', details: 'Page reload' };
|
|
},
|
|
|
|
async navigate_back(action, ctx) {
|
|
window.history.back();
|
|
return { status: 'navigation', details: 'Navigate back' };
|
|
},
|
|
|
|
async menu_navigate(action, ctx) {
|
|
// Complex menu navigation - handled by scrollAndFind + click pattern
|
|
// Expand all menus first
|
|
const expandBtn = Array.from(document.querySelectorAll('button')).find((b) =>
|
|
b.innerText?.includes('모두 펼치기')
|
|
);
|
|
if (expandBtn) {
|
|
expandBtn.click();
|
|
await sleep(1500);
|
|
}
|
|
|
|
// Find and click level1
|
|
const l1 = action.level1;
|
|
const l2 = action.level2;
|
|
|
|
if (l1) {
|
|
const l1El = findEl(l1);
|
|
if (l1El) {
|
|
triggerClick(l1El);
|
|
await sleep(500);
|
|
}
|
|
}
|
|
if (l2) {
|
|
const l2El = findEl(l2);
|
|
if (l2El) {
|
|
triggerClick(l2El);
|
|
await sleep(2000);
|
|
}
|
|
}
|
|
|
|
// Check expected URL
|
|
const v = action.expected || {};
|
|
if (v.url_contains && !window.location.href.includes(v.url_contains)) {
|
|
return warn(`Menu nav: URL missing "${v.url_contains}"`);
|
|
}
|
|
return pass(`Menu navigation: ${l1} > ${l2}`);
|
|
},
|
|
|
|
// ── Noop ──
|
|
async noop(action, ctx) {
|
|
return pass('No action');
|
|
},
|
|
};
|
|
|
|
// ─── Result Constructors ────────────────────────────────
|
|
|
|
function pass(details) {
|
|
return { status: 'pass', details };
|
|
}
|
|
function fail(details) {
|
|
return { status: 'fail', details };
|
|
}
|
|
function warn(details) {
|
|
return { status: 'warn', details };
|
|
}
|
|
|
|
// ─── Variable Replacement ───────────────────────────────
|
|
|
|
function replaceVars(str, vars) {
|
|
if (typeof str !== 'string') return str;
|
|
// Replace {timestamp}
|
|
str = str.replace(/\{timestamp\}/g, () => {
|
|
const n = new Date();
|
|
const pad = (v) => v.toString().padStart(2, '0');
|
|
return `${n.getFullYear()}${pad(n.getMonth() + 1)}${pad(n.getDate())}_${pad(n.getHours())}${pad(n.getMinutes())}${pad(n.getSeconds())}`;
|
|
});
|
|
// Replace {variableName}
|
|
if (vars) {
|
|
str = str.replace(/\{(\w+)\}/g, (_, key) => vars[key] ?? `{${key}}`);
|
|
}
|
|
return str;
|
|
}
|
|
|
|
// ─── Retry Engine ───────────────────────────────────────
|
|
|
|
async function retryAction(handler, action, ctx, maxRetries = 2, delayMs = 500) {
|
|
let lastResult;
|
|
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
|
lastResult = await handler(action, ctx);
|
|
if (lastResult.status === 'pass' || lastResult.status === 'navigation' || lastResult.status === 'native_required') {
|
|
return lastResult;
|
|
}
|
|
if (attempt < maxRetries) {
|
|
// Pre-retry actions
|
|
if (lastResult.details?.includes('not found') || lastResult.details?.includes('not visible')) {
|
|
await sleep(delayMs);
|
|
// Try closing overlays
|
|
if (ModalGuard.check().open) await ModalGuard.close();
|
|
} else {
|
|
await sleep(delayMs);
|
|
}
|
|
}
|
|
}
|
|
return lastResult;
|
|
}
|
|
|
|
// ─── Batch Runner ───────────────────────────────────────
|
|
|
|
/**
|
|
* Run a batch of steps
|
|
* @param {Array} steps - array of step objects from scenario JSON
|
|
* @param {Object} vars - variables carried between batches
|
|
* @param {Object} config - { selectors }
|
|
* @returns {BatchResult}
|
|
*/
|
|
async function runBatch(steps, vars = {}, config = {}) {
|
|
const results = [];
|
|
const ctx = {
|
|
variables: { ...vars },
|
|
selectors: config.selectors || {},
|
|
};
|
|
const startUrl = window.location.href;
|
|
let stoppedReason = 'complete';
|
|
let stoppedAtIndex = steps.length;
|
|
|
|
for (let i = 0; i < steps.length; i++) {
|
|
const step = steps[i];
|
|
const normalized = normalizeStep(step);
|
|
const stepStart = now();
|
|
let stepStatus = 'pass';
|
|
let stepDetails = '';
|
|
let stepError = null;
|
|
let subResults = [];
|
|
|
|
for (let j = 0; j < normalized.subActions.length; j++) {
|
|
const action = normalized.subActions[j];
|
|
const actionType = action.type;
|
|
|
|
// Check if action requires native (screenshot, etc.)
|
|
if (actionType === 'capture' || actionType === 'screenshot') {
|
|
stoppedReason = 'native_required';
|
|
stoppedAtIndex = i;
|
|
results.push({
|
|
stepId: normalized.stepId,
|
|
name: normalized.name,
|
|
status: 'skip',
|
|
duration: now() - stepStart,
|
|
details: 'Requires native screenshot',
|
|
error: null,
|
|
phase: normalized.phase,
|
|
});
|
|
// Return with position info
|
|
return buildBatchResult(results, ctx, stoppedReason, stoppedAtIndex);
|
|
}
|
|
|
|
// Get handler
|
|
const handler = ActionHandlers[actionType];
|
|
if (!handler) {
|
|
subResults.push(warn(`Unknown action type: ${actionType}`));
|
|
continue;
|
|
}
|
|
|
|
// Execute with retry
|
|
const result = await retryAction(handler, action, ctx);
|
|
|
|
// Handle navigation signal
|
|
if (result.status === 'navigation') {
|
|
subResults.push(result);
|
|
stoppedReason = 'navigation';
|
|
stoppedAtIndex = i + 1; // continue from next step
|
|
const sr = buildStepResult(normalized, subResults, stepStart);
|
|
results.push(sr);
|
|
return buildBatchResult(results, ctx, stoppedReason, stoppedAtIndex);
|
|
}
|
|
|
|
// Handle native_required signal
|
|
if (result.status === 'native_required') {
|
|
stoppedReason = 'native_required';
|
|
stoppedAtIndex = i;
|
|
const sr = buildStepResult(normalized, subResults, stepStart);
|
|
results.push(sr);
|
|
return buildBatchResult(results, ctx, stoppedReason, stoppedAtIndex);
|
|
}
|
|
|
|
subResults.push(result);
|
|
|
|
// Check URL change (indicates page navigation)
|
|
const currentUrl = window.location.href;
|
|
if (currentUrl !== startUrl && j < normalized.subActions.length - 1) {
|
|
// URL changed mid-step, might need re-injection
|
|
// Continue for now, check at step boundary
|
|
}
|
|
}
|
|
|
|
// Build step result
|
|
const sr = buildStepResult(normalized, subResults, stepStart);
|
|
results.push(sr);
|
|
|
|
// Check critical failure
|
|
if (sr.status === 'fail' && normalized.critical) {
|
|
stoppedReason = 'critical_failure';
|
|
stoppedAtIndex = i + 1;
|
|
return buildBatchResult(results, ctx, stoppedReason, stoppedAtIndex);
|
|
}
|
|
}
|
|
|
|
return buildBatchResult(results, ctx, stoppedReason, stoppedAtIndex);
|
|
}
|
|
|
|
function buildStepResult(normalized, subResults, stepStart) {
|
|
const failed = subResults.filter((r) => r.status === 'fail');
|
|
const warns = subResults.filter((r) => r.status === 'warn');
|
|
let status = 'pass';
|
|
if (failed.length > 0) status = 'fail';
|
|
else if (warns.length > 0) status = 'warn';
|
|
|
|
const details = subResults.map((r) => r.details).join(' | ');
|
|
const error = failed.length > 0 ? failed.map((f) => f.details).join('; ') : null;
|
|
|
|
return {
|
|
stepId: normalized.stepId,
|
|
name: normalized.name,
|
|
status,
|
|
duration: now() - stepStart,
|
|
details: details.substring(0, 200),
|
|
error,
|
|
phase: normalized.phase,
|
|
};
|
|
}
|
|
|
|
function buildBatchResult(results, ctx, stoppedReason, stoppedAtIndex) {
|
|
return {
|
|
totalSteps: results.length,
|
|
completedSteps: results.filter((r) => r.status !== 'skip').length,
|
|
passed: results.filter((r) => r.status === 'pass').length,
|
|
failed: results.filter((r) => r.status === 'fail').length,
|
|
warned: results.filter((r) => r.status === 'warn').length,
|
|
results,
|
|
variables: ctx.variables,
|
|
apiSummary: ApiMonitor.summary(),
|
|
currentUrl: window.location.href,
|
|
stoppedReason,
|
|
stoppedAtIndex,
|
|
};
|
|
}
|
|
|
|
// ─── Public API ─────────────────────────────────────────
|
|
|
|
window.__E2E__ = {
|
|
_version: 1,
|
|
|
|
/**
|
|
* Initialize the executor
|
|
* @param {Object} config - { selectors, variables }
|
|
*/
|
|
init(config = {}) {
|
|
ApiMonitor.install();
|
|
ApiMonitor.reset();
|
|
return {
|
|
ready: true,
|
|
url: window.location.href,
|
|
apiMonitoring: true,
|
|
};
|
|
},
|
|
|
|
/**
|
|
* Run a batch of steps
|
|
* @param {Array} steps - step objects from scenario JSON
|
|
* @param {Object} vars - variables from previous batches
|
|
* @param {Object} config - { selectors }
|
|
* @returns {Promise<BatchResult>}
|
|
*/
|
|
runBatch,
|
|
|
|
/**
|
|
* Get current state
|
|
*/
|
|
getState() {
|
|
return {
|
|
url: window.location.href,
|
|
modalOpen: ModalGuard.check().open,
|
|
apiSummary: ApiMonitor.summary(),
|
|
title: document.title,
|
|
bodyTextLength: document.body.innerText?.length || 0,
|
|
};
|
|
},
|
|
|
|
/**
|
|
* Run a single action (for orchestrator ad-hoc use)
|
|
*/
|
|
async runAction(actionType, params = {}, selectors = {}) {
|
|
const handler = ActionHandlers[normalizeActionType(actionType)];
|
|
if (!handler) return fail(`Unknown action: ${actionType}`);
|
|
const ctx = { variables: {}, selectors };
|
|
return handler(params, ctx);
|
|
},
|
|
|
|
/**
|
|
* Close any open modal
|
|
*/
|
|
closeModal: () => ModalGuard.close(),
|
|
|
|
/**
|
|
* Check modal state
|
|
*/
|
|
checkModal: () => ModalGuard.check(),
|
|
|
|
/**
|
|
* Get API logs
|
|
*/
|
|
getApiLogs: () => ({
|
|
logs: ApiMonitor._logs.slice(-50),
|
|
errors: ApiMonitor._errors.slice(-20),
|
|
summary: ApiMonitor.summary(),
|
|
}),
|
|
|
|
/**
|
|
* Find element (for debugging)
|
|
*/
|
|
findEl,
|
|
};
|
|
})();
|