Files
sam-hotfix/e2e/runner/step-executor.js
김보곤 95b7c4afe3 test: E2E 6·7차 테스트 결과 및 개발팀 수정 요청서 (176 PASS / 8 FAIL)
- 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>
2026-02-26 16:59:21 +09:00

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,
};
})();