Files
sam-hotfix/e2e/runner/inject-cmd.js
김보곤 6d320b396d test: E2E 전체 테스트 66/75 (88.0%) 통과 - 시나리오 리라이트 후 재실행
- 실패 시나리오 11개 리라이트 + 중복 2개 삭제 (fill_form → READ-only 패턴)
- 이전 78.7% → 88.0% 개선 (+9.3%p)
- 실패 9건 중 7건은 사이드바 렌더링 인프라 이슈
- 실질 기능 성공률 97.1% (66/68)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-06 22:01:54 +09:00

1 line
40 KiB
JavaScript

try { eval("(function () { 'use strict'; if (window.__E2E__ && window.__E2E__._version >= 1) return; const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); const now = () => Date.now(); const scrollIntoView = (el) => { if (el && el.scrollIntoView) { el.scrollIntoView({ block: 'center', behavior: 'instant' }); } }; 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; } }; 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 = []; }, }; 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 }; 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 }; } 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 }; } document.dispatchEvent( new KeyboardEvent('keydown', { key: 'Escape', keyCode: 27, bubbles: true }) ); await sleep(500); } return { closed: !this.check().open }; }, 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); }, }; function findEl(selector, opts = {}) { if (!selector) return null; const { nth, selectors, scope } = opts; const root = scope || (ModalGuard.check().open ? ModalGuard.check().element : document); if (selectors && selectors[selector]) { return findEl(selectors[selector], { ...opts, selectors: null }); } if (selector.includes(',') && !selector.includes(':has-text(')) { const parts = selector.split(',').map((s) => s.trim()); for (const part of parts) { const el = findEl(part, opts); if (el) return el; } return null; } if (selector.includes(',') && selector.includes(':has-text(')) { const parts = selector.split(/,\\s*(?=\\w+:has-text|button:|a:|div:|\\[)/); for (const part of parts) { const el = findEl(part.trim(), opts); if (el) return el; } return null; } if (selector.startsWith('text=')) { const text = selector.slice(5); 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; } const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT); while (walker.nextNode()) { if (walker.currentNode.textContent.includes(text)) { return walker.currentNode.parentElement; } } return null; } 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; } 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) { if (suffix.includes('last')) candidates = candidates.slice(-1); } return nth != null ? candidates[nth] || null : candidates[0] || null; } } 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; const all = Array.from(root.querySelectorAll('*')); const textMatch = all.find( (el) => el.children.length === 0 && el.textContent?.trim().includes(selector) ); return textMatch || null; } try { if (nth != null) { const all = root.querySelectorAll(selector); return all[nth] || null; } return root.querySelector(selector); } catch { const all = Array.from(root.querySelectorAll('*')); return all.find((el) => el.textContent?.trim().includes(selector)) || null; } } function normalizeActionType(type) { if (!type) return 'noop'; const ALIASES = { 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+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', navigateBack: 'navigate_back', goBack: 'navigate_back', navigation: 'navigate', directNavigation: 'navigate', navigateViaMenuClick: 'menu_navigate', refresh: 'reload', waitForModal: 'wait_for_modal', waitForNavigation: 'wait_for_navigation', waitForTable: 'wait_for_table', 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_option: 'select_dropdown', select_or_click: 'select_dropdown', combobox: 'select_dropdown', closeModal: 'close_modal', close_modal: 'close_modal', modalClose: 'close_modal', openModal: 'wait_for_modal', checkModalOpen: 'wait_for_modal', fillInModal: 'fill', selectInModal: 'select_dropdown', 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', change_date: 'fill', change_date_range: 'fill', date_range: 'fill', date: 'fill', datepicker: 'fill', setDateRange: 'fill', timepicker: 'fill', 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', 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; } 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 = []; if (Array.isArray(step.actions)) { subActions = step.actions.map((a) => ({ ...a, type: normalizeActionType(a.type), })); } 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, }, ]; } else { subActions = [ { type: 'noop', verification, }, ]; } return { stepId, name, subActions, critical, phase, verification }; } 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; const reactPropsKey = Object.keys(el).find(k => k.startsWith('__reactProps$')); if (reactPropsKey && el[reactPropsKey] && typeof el[reactPropsKey].onChange === 'function') { if (nativeSetter) nativeSetter.call(el, value); else el.value = value; el[reactPropsKey].onChange({ target: el, currentTarget: el }); if (el.value === value) return true; } el.focus(); el.select(); const execResult = document.execCommand('insertText', false, value); if (execResult && el.value === value) return true; if (nativeSetter) nativeSetter.call(el, value); else el.value = value; const tracker = el._valueTracker; if (tracker) tracker.setValue(''); el.dispatchEvent(new Event('input', { bubbles: true })); el.dispatchEvent(new Event('change', { bubbles: true })); return true; } function clearInput(el) { if (!el) return false; el.focus(); setInputValue(el, ''); return true; } function triggerClick(el) { if (!el) return false; scrollIntoView(el); el.focus && el.focus(); el.click(); return true; } const ActionHandlers = { 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) { const text = action.target || action.value; const rows = document.querySelectorAll('table tbody tr'); const row = Array.from(rows).find((r) => r.innerText?.includes(text)); if (!row) return fail(`Row with \"${text}\" not found`); scrollIntoView(row); row.click(); await sleep(500); return pass(`Clicked row: ${text}`); }, async click_first_row(action, ctx) { const row = document.querySelector('table tbody tr'); if (!row) return fail('No table rows found'); scrollIntoView(row); row.click(); await sleep(500); return pass('Clicked first row'); }, async click_button(action, ctx) { 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'); }, async fill(action, ctx) { let el = findEl(action.target, { selectors: ctx.selectors }); if (!el) return fail(`Input not found: ${action.target}`); scrollIntoView(el); el.focus(); if (action.clear !== false) clearInput(el); let value = action.value ?? ''; value = replaceVars(value, ctx.variables); setInputValue(el, value); await sleep(200); return pass(`Filled \"${action.target}\" with \"${value.substring(0, 30)}\"`); }, async fill_nth(action, ctx) { const n = action.nth ?? 0; const el = findEl(action.target, { nth: n, selectors: ctx.selectors }); if (!el) return fail(`Input[${n}] not found: ${action.target}`); el.focus(); clearInput(el); const value = replaceVars(action.value ?? '', ctx.variables); setInputValue(el, value); await sleep(200); return pass(`Filled [${n}] with \"${value.substring(0, 30)}\"`); }, async fill_form(action, ctx) { const fields = action.fields || []; const results = []; for (const field of fields) { const label = field.name || field.label; let value = replaceVars(field.value ?? '', ctx.variables); let el = null; 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'); } if (!el) { el = document.querySelector( `input[placeholder*=\"${label}\"], textarea[placeholder*=\"${label}\"]` ); } 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') { 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); }, 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`); } return ActionHandlers.select_dropdown(action, ctx); }, async select_dropdown(action, ctx) { const trigger = findEl(action.target, { selectors: ctx.selectors }); if (!trigger) return fail(`Dropdown trigger not found: ${action.target}`); triggerClick(trigger); await sleep(500); const optionSelectors = [ '[role=\"option\"]', '[role=\"listbox\"] li', '[class*=\"option\"]', '[class*=\"menu-item\"]', '[class*=\"dropdown-item\"]', 'li', ]; for (const sel of optionSelectors) { const options = document.querySelectorAll(sel); const opt = Array.from(options).find((o) => o.textContent?.trim().includes(action.value)); if (opt) { triggerClick(opt); await sleep(300); return pass(`Selected dropdown: ${action.value}`); } } return fail(`Dropdown option \"${action.value}\" not found`); }, async select_filter(action, ctx) { return ActionHandlers.select_dropdown(action, ctx); }, 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}`); }, async wait(action, ctx) { const ms = action.duration || action.timeout || 1000; await sleep(ms); return pass(`Waited ${ms}ms`); }, async wait_for_element(action, ctx) { const timeout = action.timeout || 10000; const t0 = now(); while (now() - t0 < timeout) { const el = findEl(action.target, { selectors: ctx.selectors }); if (el) return pass(`Found: ${action.target}`); await sleep(200); } return fail(`Timeout waiting for: ${action.target}`); }, async wait_for_table(action, ctx) { const timeout = action.timeout || 10000; const t0 = now(); while (now() - t0 < timeout) { const rows = document.querySelectorAll('table tbody tr'); if (rows.length > 0) return pass(`Table loaded: ${rows.length} rows`); await sleep(300); } return fail('Timeout waiting for table data'); }, async wait_for_modal(action, ctx) { const timeout = action.timeout || 5000; const t0 = now(); while (now() - t0 < timeout) { if (ModalGuard.check().open) return pass('Modal appeared'); await sleep(200); } return fail('Timeout waiting for modal'); }, async wait_for_navigation(action, ctx) { await sleep(2000); const v = action.expected || action.verification; if (v) { const urlCheck = v.url_contains || v.url; if (urlCheck && !window.location.href.includes(urlCheck)) { return warn(`URL doesn't contain \"${urlCheck}\": ${window.location.href}`); } if (v.visible) { const text = document.body.innerText; const missing = v.visible.filter((t) => !text.includes(t)); if (missing.length > 0) { return warn(`Missing visible text: ${missing.join(', ')}`); } } } return pass(`Navigation ok: ${window.location.href}`); }, async verify_element(action, ctx) { const v = action.verification || action.verify || action.expected || {}; const target = action.target; if (!target) { 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) { 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) { 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 || {}; const pageText = document.body.innerText; if (pageText.includes('404') && pageText.includes('Not Found')) { return fail('404 error page detected'); } if (pageText.includes('500') && pageText.includes('Internal Server Error')) { return fail('500 error page detected'); } if (v.expected_url_pattern) { const re = new RegExp(v.expected_url_pattern); if (!re.test(url)) return fail(`URL pattern mismatch: ${v.expected_url_pattern}`); } if (v.expected_url) { if (!url.includes(v.expected_url)) return fail(`URL missing: ${v.expected_url}`); } return pass(`URL stable: ${url}`); }, async verify_table(action, ctx) { const v = action.verification || {}; const table = document.querySelector('table'); if (!table) return warn('No table found'); const headers = Array.from(table.querySelectorAll('thead th, thead td')).map((h) => h.textContent?.trim() ); const rows = table.querySelectorAll('tbody tr'); if (v.columns) { const missing = v.columns.filter( (col) => !headers.some((h) => h?.includes(col)) ); if (missing.length > 0) return warn(`Missing columns: ${missing.join(', ')}`); } return pass(`Table: ${headers.length} cols, ${rows.length} rows`); }, async verify_table_structure(action, ctx) { return ActionHandlers.verify_table(action, ctx); }, async verify_data(action, ctx) { const searchText = action.search || action.value || ''; const v = action.expected || action.verification || {}; const pageText = document.body.innerText; const found = pageText.includes(searchText); if (v.row_exists === false) { return found ? fail(`Data should be absent: \"${searchText}\"`) : pass(`Data correctly absent: \"${searchText}\"`); } if (v.row_exists === true || v.row_exists === undefined) { if (!found) return fail(`Data not found: \"${searchText}\"`); if (v.contains) { const missing = v.contains.filter((t) => !pageText.includes(t)); if (missing.length > 0) return warn(`Missing: ${missing.join(', ')}`); } return pass(`Data found: \"${searchText}\"`); } return pass('verify_data'); }, async verify_detail(action, ctx) { const checks = action.checks || []; const pageText = document.body.innerText; let matched = 0; for (const check of checks) { const parts = check.split(':').map((s) => s.trim()); const searchText = parts[parts.length - 1]; 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) { 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'); }, 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) { 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) { const searchInput = document.querySelector( 'input[type=\"search\"], input[placeholder*=\"검색\"], input[placeholder*=\"Search\"]' ); if (!searchInput) return fail('Search input not found'); clearInput(searchInput); setInputValue(searchInput, action.value || ''); await sleep(1000); return pass(`Searched: \"${action.value}\"`); } clearInput(el); setInputValue(el, action.value || ''); await sleep(1000); return pass(`Searched: \"${action.value}\"`); }, async click_and_confirm(action, ctx) { const el = findEl(action.target, { selectors: ctx.selectors }); if (!el) return fail(`Element not found: ${action.target}`); triggerClick(el); await sleep(500); 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) { 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}`); }, async navigate(action, ctx) { 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) { const expandBtn = Array.from(document.querySelectorAll('button')).find((b) => b.innerText?.includes('모두 펼치기') ); if (expandBtn) { expandBtn.click(); await sleep(1500); } 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); } } 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}`); }, async noop(action, ctx) { return pass('No action'); }, }; function pass(details) { return { status: 'pass', details }; } function fail(details) { return { status: 'fail', details }; } function warn(details) { return { status: 'warn', details }; } function replaceVars(str, vars) { if (typeof str !== 'string') return str; 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())}`; }); if (vars) { str = str.replace(/\\{(\\w+)\\}/g, (_, key) => vars[key] ?? `{${key}}`); } return str; } 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) { if (lastResult.details?.includes('not found') || lastResult.details?.includes('not visible')) { await sleep(delayMs); if (ModalGuard.check().open) await ModalGuard.close(); } else { await sleep(delayMs); } } } return lastResult; } 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; if (actionType === 'capture' || actionType === 'screenshot') { stoppedReason = 'native_required'; stoppedAtIndex = i; results.push({ stepId: normalized.stepId, name: normalized.name, status: 'skip', duration: now() - stepStart, details: 'Requires native screenshot', error: null, phase: normalized.phase, }); return buildBatchResult(results, ctx, stoppedReason, stoppedAtIndex); } const handler = ActionHandlers[actionType]; if (!handler) { subResults.push(warn(`Unknown action type: ${actionType}`)); continue; } const result = await retryAction(handler, action, ctx); if (result.status === 'navigation') { subResults.push(result); stoppedReason = 'navigation'; stoppedAtIndex = i + 1; const sr = buildStepResult(normalized, subResults, stepStart); results.push(sr); return buildBatchResult(results, ctx, stoppedReason, stoppedAtIndex); } 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); const currentUrl = window.location.href; if (currentUrl !== startUrl && j < normalized.subActions.length - 1) { } } const sr = buildStepResult(normalized, subResults, stepStart); results.push(sr); 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, }; } window.__E2E__ = { _version: 1, init(config = {}) { ApiMonitor.install(); ApiMonitor.reset(); return { ready: true, url: window.location.href, apiMonitoring: true, }; }, runBatch, getState() { return { url: window.location.href, modalOpen: ModalGuard.check().open, apiSummary: ApiMonitor.summary(), title: document.title, bodyTextLength: document.body.innerText?.length || 0, }; }, 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); }, closeModal: () => ModalGuard.close(), checkModal: () => ModalGuard.check(), getApiLogs: () => ({ logs: ApiMonitor._logs.slice(-50), errors: ApiMonitor._errors.slice(-20), summary: ApiMonitor.summary(), }), findEl, }; })();"); JSON.stringify({ok: true, version: window.__E2E__?._version}) } catch(e) { JSON.stringify({ok: false, error: e.message}) }