/** * 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', 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') { 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 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 < timeout) { for (const sel of rowSelectors) { const rows = document.querySelectorAll(sel); 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 || {}; // 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 { 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 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}`); }, // ── 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 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 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} */ 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, }; })();