- 6차 결과: 180/184 PASS (97.8%) - 시나리오 내성 강화 효과 - 7차 결과: 176/184 PASS (95.7%) - Board 삭제 리그레션 발생 - step-executor.js: wait_for_table allowEmpty 옵션 추가 - run-all.js: --iterate, --stage 모드 추가 - 개발팀 수정 요청서: BUG-BOARD-DELETE-001(신규), BUG-DEPOSIT-001, BUG-SALES-CALC-001 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2104 lines
76 KiB
JavaScript
2104 lines
76 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
|
|
if (window.__E2E__ && window.__E2E__._version >= 1) return;
|
|
|
|
// ─── 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 part
|
|
if (selector.includes(',')) {
|
|
let parts;
|
|
if (!selector.includes(':has-text(')) {
|
|
// Simple case: no :has-text, just split on commas
|
|
parts = selector.split(',').map((s) => s.trim());
|
|
} else {
|
|
// Complex case: has :has-text(), use smart regex split first
|
|
parts = selector.split(/,\s*(?=\w+:has-text|button:|a:|div:|\[)/);
|
|
// If regex didn't split, try simple comma split as fallback
|
|
if (parts.length <= 1) {
|
|
parts = selector.split(',').map((s) => s.trim());
|
|
}
|
|
}
|
|
// Only recurse if we actually split into multiple parts (prevents infinite recursion)
|
|
if (parts.length > 1) {
|
|
for (const part of parts) {
|
|
const el = findEl(part.trim(), opts);
|
|
if (el) return el;
|
|
}
|
|
return null;
|
|
}
|
|
// Single part after split = fall through to other strategies
|
|
}
|
|
|
|
// 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',
|
|
// Workflow (Wave 1)
|
|
save_context: 'workflow_context_save',
|
|
load_context: 'workflow_context_load',
|
|
context_save: 'workflow_context_save',
|
|
context_load: 'workflow_context_load',
|
|
verify_cross_module: 'cross_module_verify',
|
|
// Performance (Wave 2)
|
|
perf_measure: 'measure_performance',
|
|
perf_api: 'measure_api_performance',
|
|
perf_assert: 'assert_performance',
|
|
performance: 'measure_performance',
|
|
// Edge cases (Wave 3)
|
|
boundary_fill: 'fill_boundary',
|
|
rapid: 'rapid_click',
|
|
a11y_audit: 'accessibility_audit',
|
|
accessibility: 'accessibility_audit',
|
|
keyboard_nav: 'keyboard_navigate',
|
|
tab_navigate: 'keyboard_navigate',
|
|
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,
|
|
timeout: step.timeout,
|
|
allowEmpty: step.allowEmpty,
|
|
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') {
|
|
try { if (nativeSetter) nativeSetter.call(el, value); else el.value = value; }
|
|
catch (_) { 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();
|
|
if (typeof el.select === 'function') el.select();
|
|
const execResult = document.execCommand('insertText', false, value);
|
|
if (execResult && el.value === value) return true;
|
|
|
|
// Method 3: native setter + _valueTracker reset + events (fallback)
|
|
try { if (nativeSetter) nativeSetter.call(el, value); else el.value = value; }
|
|
catch (_) { 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 with fallback selectors
|
|
const text = action.target || action.value;
|
|
const rowSelectors = [
|
|
'table tbody tr',
|
|
'[role="row"]',
|
|
'tr',
|
|
'[class*="list-item"]',
|
|
'[class*="row"]',
|
|
];
|
|
for (const sel of rowSelectors) {
|
|
const rows = document.querySelectorAll(sel);
|
|
const row = Array.from(rows).find((r) => r.innerText?.includes(text));
|
|
if (row) {
|
|
scrollIntoView(row);
|
|
row.click();
|
|
await sleep(500);
|
|
return pass(`Clicked row: ${text}`);
|
|
}
|
|
}
|
|
return fail(`Row with "${text}" not found`);
|
|
},
|
|
|
|
async click_first_row(action, ctx) {
|
|
// Poll for up to 5 seconds with fallback selectors
|
|
const rowSelectors = [
|
|
'table tbody tr',
|
|
'[role="row"]:not([role="row"]:first-child)',
|
|
'table tr:nth-child(2)',
|
|
'[class*="table"] [class*="row"]',
|
|
'[class*="list"] [class*="item"]',
|
|
];
|
|
const timeout = 5000;
|
|
const t0 = now();
|
|
while (now() - t0 < timeout) {
|
|
for (const sel of rowSelectors) {
|
|
const row = document.querySelector(sel);
|
|
if (row && row.offsetParent !== null) {
|
|
scrollIntoView(row);
|
|
row.click();
|
|
await sleep(500);
|
|
return pass('Clicked first row');
|
|
}
|
|
}
|
|
await sleep(300);
|
|
}
|
|
return fail('No table rows found');
|
|
},
|
|
|
|
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);
|
|
|
|
let value = action.value ?? '';
|
|
value = replaceVars(value, ctx.variables);
|
|
|
|
// Smart type detection: delegate to appropriate handler
|
|
if (el.tagName === 'SELECT') {
|
|
// Delegate to select handler
|
|
return ActionHandlers.select({ ...action, value }, ctx);
|
|
}
|
|
if (el.getAttribute('role') === 'combobox') {
|
|
// Delegate to select_dropdown handler
|
|
return ActionHandlers.select_dropdown({ ...action, value }, ctx);
|
|
}
|
|
|
|
el.focus();
|
|
if (action.clear !== false) clearInput(el);
|
|
|
|
// Date input: use nativeInputValueSetter + change event
|
|
if (el.type === 'date' || el.type === 'datetime-local') {
|
|
const proto = HTMLInputElement.prototype;
|
|
const nativeSetter = Object.getOwnPropertyDescriptor(proto, 'value')?.set;
|
|
if (nativeSetter) nativeSetter.call(el, value);
|
|
else el.value = value;
|
|
el.dispatchEvent(new Event('input', { bubbles: true }));
|
|
el.dispatchEvent(new Event('change', { bubbles: true }));
|
|
await sleep(200);
|
|
return pass(`Filled date "${action.target}" with "${value}"`);
|
|
}
|
|
|
|
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 with expanded selectors for Shadcn UI
|
|
const optionSelectors = [
|
|
'[role="option"]',
|
|
'[role="listbox"] li',
|
|
'[data-value]',
|
|
'[class*="option"]',
|
|
'[class*="Option"]',
|
|
'[class*="select-option"]',
|
|
'[class*="menu-item"]',
|
|
'[class*="MenuItem"]',
|
|
'[class*="dropdown-item"]',
|
|
'[class*="DropdownItem"]',
|
|
'[cmdk-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 loopTimeout = action.allowEmpty ? Math.max(timeout - 2000, 3000) : timeout;
|
|
const rowSelectors = [
|
|
'table tbody tr',
|
|
'[role="table"] [role="row"]',
|
|
'[role="grid"] [role="row"]',
|
|
'[class*="table"]:not([class*="tooltip"]) tbody tr',
|
|
];
|
|
const t0 = now();
|
|
while (now() - t0 < loopTimeout) {
|
|
for (const sel of rowSelectors) {
|
|
const rows = document.querySelectorAll(sel);
|
|
if (rows.length > 0) return pass(`Table loaded: ${rows.length} rows`);
|
|
}
|
|
await sleep(300);
|
|
}
|
|
if (action.allowEmpty) {
|
|
return pass('Table loaded: 0 rows (allowEmpty)');
|
|
}
|
|
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 || {};
|
|
// Expanded table detection for Shadcn UI
|
|
const tableSelectors = [
|
|
'table',
|
|
'[role="table"]',
|
|
'[role="grid"]',
|
|
'[class*="table"]:not([class*="tooltip"]):not([class*="Table"][class*="Cell"])',
|
|
];
|
|
let table = null;
|
|
for (const sel of tableSelectors) {
|
|
table = document.querySelector(sel);
|
|
if (table) break;
|
|
}
|
|
if (!table) return warn('No table found');
|
|
|
|
const headers = Array.from(table.querySelectorAll('thead th, thead td, [role="columnheader"]')).map((h) =>
|
|
h.textContent?.trim()
|
|
);
|
|
const rows = table.querySelectorAll('tbody tr, [role="row"]');
|
|
|
|
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) {
|
|
// If selector + extract are specified, count/extract data (no screenshot needed)
|
|
if (action.selector) {
|
|
const els = document.querySelectorAll(action.selector);
|
|
const count = els.length;
|
|
if (action.variable && ctx.variables) {
|
|
ctx.variables[action.variable] = count;
|
|
}
|
|
return pass(`Captured ${action.extract || 'count'}: ${count}`);
|
|
}
|
|
// Fallback: 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 {
|
|
let result = eval(action.script);
|
|
const isPromise = result instanceof Promise;
|
|
if (isPromise) result = await result;
|
|
const resultType = typeof result;
|
|
// Debug: report what we got from eval
|
|
if (resultType !== 'string') {
|
|
return pass('eval_type:' + resultType + '|isPromise:' + isPromise + '|val:' + String(result).substring(0, 80));
|
|
}
|
|
// It's a string - try JSON parse
|
|
let parsed;
|
|
try {
|
|
parsed = JSON.parse(result);
|
|
} catch (jsonErr) {
|
|
return pass('json_fail:' + jsonErr.message + '|raw:' + result.substring(0, 80));
|
|
}
|
|
// Store in ctx.variables
|
|
if (parsed && typeof parsed === 'object') {
|
|
const key = action.phase || 'evaluate';
|
|
ctx.variables['__eval_' + key] = result;
|
|
}
|
|
// Check ok field
|
|
if (parsed.ok === false) {
|
|
return fail(parsed.error || parsed.warn || 'evaluate returned ok:false');
|
|
}
|
|
if (parsed.error) {
|
|
return fail(parsed.error);
|
|
}
|
|
// Build detail string
|
|
const details = [];
|
|
if (parsed.phase) details.push(parsed.phase);
|
|
if (parsed.warn) details.push('W:' + parsed.warn);
|
|
if (parsed.info) details.push(parsed.info);
|
|
if (parsed.grade) details.push('grade:' + parsed.grade);
|
|
if (parsed.vendorFound !== undefined) details.push('vendor:' + parsed.vendorFound);
|
|
if (parsed.itemFound !== undefined) details.push('item:' + parsed.itemFound);
|
|
if (parsed.hasIdInUrl !== undefined) details.push('idInUrl:' + parsed.hasIdInUrl);
|
|
if (parsed.dataMatch !== undefined) details.push('match:' + parsed.dataMatch);
|
|
if (parsed.intact !== undefined) details.push('intact:' + parsed.intact);
|
|
if (parsed.urlChanged !== undefined) details.push('urlChg:' + parsed.urlChanged);
|
|
if (parsed.rowCount !== undefined) details.push('rows:' + parsed.rowCount);
|
|
if (parsed.totalCalls !== undefined) details.push('api:' + parsed.totalCalls);
|
|
if (parsed.summary) details.push(parsed.summary);
|
|
if (parsed.keyword) details.push('kw:' + parsed.keyword);
|
|
if (parsed.filterWorked !== undefined) details.push('filter:' + parsed.filterWorked);
|
|
if (parsed.grade === 'FAIL') {
|
|
return fail('API FAIL: ' + (parsed.summary || JSON.stringify(parsed.failedUrls || [])));
|
|
}
|
|
return pass(details.length > 0 ? details.join(' | ') : '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 with expanded selectors for Shadcn UI
|
|
const searchSelectors = [
|
|
'input[type="search"]',
|
|
'input[placeholder*="검색"]',
|
|
'input[placeholder*="Search"]',
|
|
'input[placeholder*="search"]',
|
|
'input[placeholder*="조회"]',
|
|
'input[role="searchbox"]',
|
|
'input[aria-label*="검색"]',
|
|
'[class*="search"] input',
|
|
'[class*="Search"] input',
|
|
'div.relative > input[type="text"]',
|
|
'header input[type="text"]',
|
|
'[class*="toolbar"] input',
|
|
];
|
|
let searchInput = null;
|
|
for (const sel of searchSelectors) {
|
|
searchInput = document.querySelector(sel);
|
|
if (searchInput) break;
|
|
}
|
|
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}`);
|
|
},
|
|
|
|
// ── Workflow group (Wave 1) ──
|
|
async workflow_context_save(action, ctx) {
|
|
const varName = action.variable || action.key;
|
|
if (!varName) return fail('workflow_context_save: variable/key required');
|
|
let value;
|
|
if (action.selector) {
|
|
const el = findEl(action.selector, { selectors: ctx.selectors });
|
|
value = el ? (el.value || el.innerText?.trim() || '') : null;
|
|
if (!value) return warn(`workflow_context_save: element empty or not found: ${action.selector}`);
|
|
} else if (action.value) {
|
|
value = replaceVars(action.value, ctx.variables);
|
|
} else if (action.extract === 'url_id') {
|
|
const m = window.location.href.match(/\/(\d+)(?:\?|$)/);
|
|
value = m ? m[1] : null;
|
|
if (!value) return warn('workflow_context_save: no ID found in URL');
|
|
} else if (action.extract === 'first_row_cell') {
|
|
const rows = document.querySelectorAll('table tbody tr');
|
|
if (rows.length > 0) {
|
|
const cellIdx = action.cellIndex ?? 1;
|
|
const cell = rows[0].querySelectorAll('td')[cellIdx];
|
|
value = cell?.innerText?.trim() || null;
|
|
}
|
|
if (!value) return warn('workflow_context_save: no table data');
|
|
} else {
|
|
return fail('workflow_context_save: need selector, value, or extract');
|
|
}
|
|
if (!window.__WORKFLOW_CTX__) window.__WORKFLOW_CTX__ = {};
|
|
window.__WORKFLOW_CTX__[varName] = value;
|
|
ctx.variables[varName] = value;
|
|
return pass(`Saved workflow ctx: ${varName}=${String(value).substring(0, 40)}`);
|
|
},
|
|
|
|
async workflow_context_load(action, ctx) {
|
|
const varName = action.variable || action.key;
|
|
if (!varName) return fail('workflow_context_load: variable/key required');
|
|
const value = (window.__WORKFLOW_CTX__ && window.__WORKFLOW_CTX__[varName]) || ctx.variables[varName];
|
|
if (value === undefined || value === null) return warn(`workflow_context_load: "${varName}" not found in context`);
|
|
ctx.variables[varName] = value;
|
|
return pass(`Loaded workflow ctx: ${varName}=${String(value).substring(0, 40)}`);
|
|
},
|
|
|
|
async cross_module_verify(action, ctx) {
|
|
const searchText = action.search || action.value || ctx.variables[action.variable];
|
|
if (!searchText) return warn('cross_module_verify: no search text');
|
|
await sleep(1500);
|
|
const pageText = document.body.innerText;
|
|
const found = pageText.includes(searchText);
|
|
if (action.expect === false) {
|
|
return found
|
|
? fail(`cross_module_verify: "${searchText}" should NOT exist`)
|
|
: pass(`cross_module_verify: correctly absent "${searchText}"`);
|
|
}
|
|
return found
|
|
? pass(`cross_module_verify: "${searchText}" found in target module`)
|
|
: warn(`cross_module_verify: "${searchText}" NOT found - possible data inconsistency`);
|
|
},
|
|
|
|
// ── Performance group (Wave 2) ──
|
|
async measure_performance(action, ctx) {
|
|
const metrics = {};
|
|
try {
|
|
const nav = performance.getEntriesByType('navigation')[0];
|
|
if (nav) {
|
|
metrics.domContentLoaded = Math.round(nav.domContentLoadedEventEnd - nav.startTime);
|
|
metrics.load = Math.round(nav.loadEventEnd - nav.startTime);
|
|
metrics.ttfb = Math.round(nav.responseStart - nav.requestStart);
|
|
metrics.domInteractive = Math.round(nav.domInteractive - nav.startTime);
|
|
}
|
|
const resources = performance.getEntriesByType('resource');
|
|
metrics.resourceCount = resources.length;
|
|
metrics.totalTransferKB = Math.round(resources.reduce((s, r) => s + (r.transferSize || 0), 0) / 1024);
|
|
metrics.domNodes = document.getElementsByTagName('*').length;
|
|
if (performance.memory) {
|
|
metrics.jsHeapMB = Math.round(performance.memory.usedJSHeapSize / 1024 / 1024);
|
|
metrics.heapPct = Math.round((performance.memory.usedJSHeapSize / performance.memory.jsHeapSizeLimit) * 100);
|
|
}
|
|
} catch (e) { metrics.error = e.message; }
|
|
const varName = action.variable || 'perf_metrics';
|
|
ctx.variables[varName] = JSON.stringify(metrics);
|
|
if (!window.__PERF_DATA__) window.__PERF_DATA__ = {};
|
|
window.__PERF_DATA__[window.location.pathname] = metrics;
|
|
const loadTime = metrics.load || metrics.domContentLoaded || 0;
|
|
const grade = loadTime < 1000 ? 'A' : loadTime < 2000 ? 'B' : loadTime < 3000 ? 'C' : 'F';
|
|
return pass(`Perf: load=${loadTime}ms grade=${grade} dom=${metrics.domNodes} res=${metrics.resourceCount}`);
|
|
},
|
|
|
|
async measure_api_performance(action, ctx) {
|
|
const logs = ApiMonitor._logs.slice();
|
|
const apiMetrics = {
|
|
totalCalls: logs.length,
|
|
avgResponseTime: logs.length > 0 ? Math.round(logs.reduce((s, l) => s + (l.duration || 0), 0) / logs.length) : 0,
|
|
maxResponseTime: logs.length > 0 ? Math.max(...logs.map(l => l.duration || 0)) : 0,
|
|
slowCalls: logs.filter(l => l.duration > 2000).length,
|
|
failedCalls: logs.filter(l => !l.ok).length,
|
|
calls: logs.slice(-10).map(l => ({ url: l.url.split('?')[0].split('/').slice(-2).join('/'), ms: l.duration, status: l.status })),
|
|
};
|
|
const varName = action.variable || 'api_perf';
|
|
ctx.variables[varName] = JSON.stringify(apiMetrics);
|
|
return pass(`API: ${apiMetrics.totalCalls} calls, avg=${apiMetrics.avgResponseTime}ms, slow=${apiMetrics.slowCalls}`);
|
|
},
|
|
|
|
async assert_performance(action, ctx) {
|
|
const thresholds = action.thresholds || {};
|
|
const maxPageLoad = thresholds.pageLoad || 3000;
|
|
const maxApiAvg = thresholds.apiAvg || 2000;
|
|
const maxDomNodes = thresholds.domNodes || 5000;
|
|
const issues = [];
|
|
const perfStr = ctx.variables['perf_metrics'];
|
|
if (perfStr) {
|
|
try {
|
|
const m = JSON.parse(perfStr);
|
|
const loadTime = m.load || m.domContentLoaded || 0;
|
|
if (loadTime > maxPageLoad) issues.push(`page load ${loadTime}ms > ${maxPageLoad}ms`);
|
|
if (m.domNodes > maxDomNodes) issues.push(`DOM nodes ${m.domNodes} > ${maxDomNodes}`);
|
|
} catch (e) {}
|
|
}
|
|
const apiStr = ctx.variables['api_perf'];
|
|
if (apiStr) {
|
|
try {
|
|
const a = JSON.parse(apiStr);
|
|
if (a.avgResponseTime > maxApiAvg) issues.push(`API avg ${a.avgResponseTime}ms > ${maxApiAvg}ms`);
|
|
} catch (e) {}
|
|
}
|
|
if (issues.length > 0) return warn(`Performance: ${issues.join('; ')}`);
|
|
return pass('Performance within thresholds');
|
|
},
|
|
|
|
// ── Edge case group (Wave 3) ──
|
|
async fill_boundary(action, ctx) {
|
|
const el = findEl(action.target, { selectors: ctx.selectors });
|
|
if (!el) return fail(`Input not found: ${action.target}`);
|
|
scrollIntoView(el);
|
|
el.focus();
|
|
const boundaryType = action.boundaryType || action.boundary || 'empty';
|
|
let value = '';
|
|
switch (boundaryType) {
|
|
case 'empty': value = ''; break;
|
|
case 'whitespace': value = ' '; break;
|
|
case 'max_length': value = 'A'.repeat(action.maxLength || 255); break;
|
|
case 'overflow': value = 'X'.repeat((action.maxLength || 255) + 50); break;
|
|
case 'special_chars': value = "<script>alert('xss')</script>"; break;
|
|
case 'sql_injection': value = "'; DROP TABLE users; --"; break;
|
|
case 'unicode': value = '한글テスト中文🎉'; break;
|
|
case 'numeric_min': value = String(action.min ?? -999999); break;
|
|
case 'numeric_max': value = String(action.max ?? 999999999); break;
|
|
case 'negative': value = '-1'; break;
|
|
case 'zero': value = '0'; break;
|
|
case 'decimal': value = '0.123456789'; break;
|
|
default: value = action.value || '';
|
|
}
|
|
clearInput(el);
|
|
setInputValue(el, value);
|
|
await sleep(300);
|
|
return pass(`Boundary fill [${boundaryType}]: "${value.substring(0, 30)}${value.length > 30 ? '...' : ''}"`);
|
|
},
|
|
|
|
async rapid_click(action, ctx) {
|
|
const el = findEl(action.target, { selectors: ctx.selectors });
|
|
if (!el) return fail(`Element not found: ${action.target}`);
|
|
scrollIntoView(el);
|
|
const clicks = action.count || 5;
|
|
const delay = action.delay || 50;
|
|
for (let i = 0; i < clicks; i++) {
|
|
el.click();
|
|
if (delay > 0) await sleep(delay);
|
|
}
|
|
await sleep(500);
|
|
return pass(`Rapid clicked ${clicks}x: ${action.target}`);
|
|
},
|
|
|
|
async accessibility_audit(action, ctx) {
|
|
const issues = { critical: [], serious: [], moderate: [], minor: [] };
|
|
// Image alt text
|
|
document.querySelectorAll('img').forEach(img => {
|
|
if (!img.alt && !img.getAttribute('aria-label') && !img.getAttribute('aria-hidden') && img.offsetParent !== null) {
|
|
issues.critical.push({ rule: 'image-alt', wcag: '1.1.1', el: img.src?.split('/').pop()?.substring(0, 30) || 'img' });
|
|
}
|
|
});
|
|
// Form labels
|
|
document.querySelectorAll('input:not([type="hidden"]), select, textarea').forEach(el => {
|
|
if (el.offsetParent === null) return;
|
|
const hasLabel = el.id && document.querySelector(`label[for="${el.id}"]`);
|
|
const hasAria = el.getAttribute('aria-label') || el.getAttribute('aria-labelledby');
|
|
const hasPlaceholder = el.placeholder;
|
|
const hasTitle = el.title;
|
|
if (!hasLabel && !hasAria && !hasPlaceholder && !hasTitle) {
|
|
issues.critical.push({ rule: 'form-label', wcag: '1.3.1', el: `${el.tagName}[${el.type || 'text'}]` });
|
|
}
|
|
});
|
|
// Button names
|
|
document.querySelectorAll('button, [role="button"]').forEach(btn => {
|
|
if (btn.offsetParent === null) return;
|
|
if (!btn.innerText?.trim() && !btn.getAttribute('aria-label') && !btn.title) {
|
|
const hasSvg = btn.querySelector('svg');
|
|
if (!hasSvg || !btn.getAttribute('aria-label')) {
|
|
issues.serious.push({ rule: 'button-name', wcag: '4.1.2' });
|
|
}
|
|
}
|
|
});
|
|
// Heading order
|
|
const headings = Array.from(document.querySelectorAll('h1,h2,h3,h4,h5,h6')).filter(h => h.offsetParent !== null);
|
|
let prevLevel = 0;
|
|
headings.forEach(h => {
|
|
const level = parseInt(h.tagName[1]);
|
|
if (prevLevel > 0 && level > prevLevel + 1) {
|
|
issues.moderate.push({ rule: 'heading-order', wcag: '1.3.1', detail: `h${prevLevel}→h${level}` });
|
|
}
|
|
prevLevel = level;
|
|
});
|
|
// Page language
|
|
if (!document.documentElement.lang) {
|
|
issues.moderate.push({ rule: 'html-lang', wcag: '3.1.1' });
|
|
}
|
|
// Score
|
|
const score = Math.max(0, 100 - (issues.critical.length * 10) - (issues.serious.length * 5) - (issues.moderate.length * 2) - (issues.minor.length * 1));
|
|
const grade = score >= 70 ? 'PASS' : 'FAIL';
|
|
const result = { score, grade, issues, url: window.location.href };
|
|
ctx.variables['a11y_result'] = JSON.stringify(result);
|
|
if (!window.__A11Y_DATA__) window.__A11Y_DATA__ = {};
|
|
window.__A11Y_DATA__[window.location.pathname] = result;
|
|
const summary = `A11y: score=${score} ${grade} (C:${issues.critical.length} S:${issues.serious.length} M:${issues.moderate.length})`;
|
|
return score >= 70 ? pass(summary) : warn(summary);
|
|
},
|
|
|
|
async keyboard_navigate(action, ctx) {
|
|
const maxTabs = action.count || 20;
|
|
const focusable = [];
|
|
let prevActive = document.activeElement;
|
|
document.body.focus();
|
|
await sleep(100);
|
|
for (let i = 0; i < maxTabs; i++) {
|
|
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Tab', keyCode: 9, bubbles: true }));
|
|
document.activeElement?.dispatchEvent?.(new KeyboardEvent('keydown', { key: 'Tab', keyCode: 9, bubbles: true }));
|
|
await sleep(100);
|
|
const current = document.activeElement;
|
|
if (current && current !== document.body && current !== prevActive) {
|
|
const hasOutline = window.getComputedStyle(current).outlineStyle !== 'none' || window.getComputedStyle(current).boxShadow !== 'none';
|
|
focusable.push({
|
|
tag: current.tagName,
|
|
text: (current.innerText || current.value || '').substring(0, 20),
|
|
visible: current.offsetParent !== null,
|
|
focusIndicator: hasOutline,
|
|
});
|
|
prevActive = current;
|
|
}
|
|
}
|
|
const withIndicator = focusable.filter(f => f.focusIndicator).length;
|
|
const allVisible = focusable.every(f => f.visible);
|
|
ctx.variables['keyboard_nav'] = JSON.stringify({ elements: focusable.length, withIndicator, allVisible });
|
|
return pass(`Keyboard: ${focusable.length} focusable, ${withIndicator} with indicator, allVisible=${allVisible}`);
|
|
},
|
|
|
|
// ── 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;
|
|
}
|
|
|
|
// ─── Step Timeout ───────────────────────────────────────
|
|
|
|
const STEP_TIMEOUT_MS = 60000; // 60초: 개별 스텝 최대 대기 시간
|
|
|
|
/** Wrap a promise with a timeout - 1분 초과 시 즉시 오류 처리 */
|
|
function withStepTimeout(promise, timeoutMs, stepName) {
|
|
return Promise.race([
|
|
promise,
|
|
new Promise((_, reject) =>
|
|
setTimeout(() => reject(new Error(`Step timeout (>${timeoutMs / 1000}s): ${stepName}`)), timeoutMs)
|
|
),
|
|
]);
|
|
}
|
|
|
|
// ─── 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 = [];
|
|
|
|
// Per-step timeout: use step-specific timeout or global 60s
|
|
const stepTimeoutMs = step.timeout || STEP_TIMEOUT_MS;
|
|
|
|
try {
|
|
await withStepTimeout((async () => {
|
|
for (let j = 0; j < normalized.subActions.length; j++) {
|
|
const action = normalized.subActions[j];
|
|
const actionType = action.type;
|
|
|
|
// Check if action requires native (screenshot without selector)
|
|
if ((actionType === 'capture' && !action.selector) || 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 '__EARLY_RETURN__';
|
|
}
|
|
|
|
// 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 '__EARLY_RETURN__';
|
|
}
|
|
|
|
// Handle native_required signal
|
|
if (result.status === 'native_required') {
|
|
stoppedReason = 'native_required';
|
|
stoppedAtIndex = i;
|
|
const sr = buildStepResult(normalized, subResults, stepStart);
|
|
results.push(sr);
|
|
return '__EARLY_RETURN__';
|
|
}
|
|
|
|
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
|
|
}
|
|
}
|
|
})(), stepTimeoutMs, normalized.name);
|
|
|
|
// Check if early return was signaled
|
|
if (stoppedReason !== 'complete') {
|
|
return buildBatchResult(results, ctx, stoppedReason, stoppedAtIndex);
|
|
}
|
|
} catch (timeoutErr) {
|
|
// Step exceeded timeout - record as fail and continue to next step
|
|
subResults.push(fail(timeoutErr.message));
|
|
}
|
|
|
|
// 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,
|
|
};
|
|
})();
|