Files
sam-hotfix/e2e/runner/step-executor.js
김보곤 2e16da9549 fix: step-executor.js Shadcn UI 호환 셀렉터 확장 (7개 핸들러)
- search: 11개 폴백 셀렉터 (Shadcn Input 지원)
- click_first_row: 5초 폴링 + role/class 폴백
- verify_table: role="table"/role="grid" 지원
- wait_for_table: 다중 row 셀렉터 폴링
- click_row: role="row", class*="list-item" 폴백
- fill: SELECT/combobox/date 스마트 위임
- select_dropdown: data-value, cmdk-item 등 Shadcn 옵션 지원

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-07 13:46:52 +09:00

1755 lines
59 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',
drag_start: 'noop',
drag_over: 'noop',
drag_end: 'noop',
};
return ALIASES[type] || type;
}
/**
* Normalize a step into unified format:
* Returns { stepId, name, subActions: [{type, target, value, ...}], critical, phase, verification }
*/
function normalizeStep(step) {
const stepId = step.id || step.step || 0;
const name = step.name || step.description || `Step ${stepId}`;
const critical = step.critical || false;
const phase = step.phase || null;
const verification = step.verification || step.verify || step.expected || null;
let subActions = [];
// Format B: actions array
if (Array.isArray(step.actions)) {
subActions = step.actions.map((a) => ({
...a,
type: normalizeActionType(a.type),
}));
}
// Format A: single action
else if (step.action) {
subActions = [
{
type: normalizeActionType(step.action),
target: step.target,
value: step.value,
variable: step.variable,
pattern: step.pattern,
nth: step.nth,
clear: step.clear,
fields: step.fields,
checks: step.checks,
search: step.search,
level1: step.level1,
level2: step.level2,
script: step.script,
duration: step.duration,
verification: step.verification,
verify: step.verify,
expected: step.expected,
critical: step.critical,
},
];
}
// No action defined - might be just verification
else {
subActions = [
{
type: 'noop',
verification,
},
];
}
return { stepId, name, subActions, critical, phase, verification };
}
// ─── Input Helpers ──────────────────────────────────────
/** Set value on an input/textarea using React-compatible events */
function setInputValue(el, value) {
if (!el) return false;
const isTextarea = el instanceof HTMLTextAreaElement;
const proto = isTextarea ? HTMLTextAreaElement.prototype : HTMLInputElement.prototype;
const nativeSetter = Object.getOwnPropertyDescriptor(proto, 'value')?.set;
// Method 1: React internal __reactProps$ onChange (most reliable for React controlled components)
const reactPropsKey = Object.keys(el).find(k => k.startsWith('__reactProps$'));
if (reactPropsKey && el[reactPropsKey] && typeof el[reactPropsKey].onChange === 'function') {
try { if (nativeSetter) nativeSetter.call(el, value); else el.value = value; }
catch (_) { el.value = value; }
el[reactPropsKey].onChange({ target: el, currentTarget: el });
if (el.value === value) return true;
}
// Method 2: execCommand('insertText') - goes through browser native input pipeline
el.focus();
if (typeof el.select === 'function') el.select();
const execResult = document.execCommand('insertText', false, value);
if (execResult && el.value === value) return true;
// Method 3: native setter + _valueTracker reset + events (fallback)
try { if (nativeSetter) nativeSetter.call(el, value); else el.value = value; }
catch (_) { el.value = value; }
const tracker = el._valueTracker;
if (tracker) tracker.setValue('');
el.dispatchEvent(new Event('input', { bubbles: true }));
el.dispatchEvent(new Event('change', { bubbles: true }));
return true;
}
/** Clear an input */
function clearInput(el) {
if (!el) return false;
el.focus();
setInputValue(el, '');
return true;
}
/** Trigger a click reliably */
function triggerClick(el) {
if (!el) return false;
scrollIntoView(el);
el.focus && el.focus();
el.click();
return true;
}
// ─── Action Handlers ────────────────────────────────────
const ActionHandlers = {
// ── Click group ──
async click(action, ctx) {
const el = findEl(action.target, { selectors: ctx.selectors });
if (!el) return fail(`Element not found: ${action.target}`);
scrollIntoView(el);
await sleep(100);
triggerClick(el);
await sleep(300);
return pass(`Clicked: ${action.target}`);
},
async click_nth(action, ctx) {
const n = action.nth ?? 0;
const el = findEl(action.target, { nth: n, selectors: ctx.selectors });
if (!el) return fail(`Element[${n}] not found: ${action.target}`);
scrollIntoView(el);
triggerClick(el);
await sleep(300);
return pass(`Clicked [${n}]: ${action.target}`);
},
async click_row(action, ctx) {
// Click a table row containing text with fallback selectors
const text = action.target || action.value;
const rowSelectors = [
'table tbody tr',
'[role="row"]',
'tr',
'[class*="list-item"]',
'[class*="row"]',
];
for (const sel of rowSelectors) {
const rows = document.querySelectorAll(sel);
const row = Array.from(rows).find((r) => r.innerText?.includes(text));
if (row) {
scrollIntoView(row);
row.click();
await sleep(500);
return pass(`Clicked row: ${text}`);
}
}
return fail(`Row with "${text}" not found`);
},
async click_first_row(action, ctx) {
// Poll for up to 5 seconds with fallback selectors
const rowSelectors = [
'table tbody tr',
'[role="row"]:not([role="row"]:first-child)',
'table tr:nth-child(2)',
'[class*="table"] [class*="row"]',
'[class*="list"] [class*="item"]',
];
const timeout = 5000;
const t0 = now();
while (now() - t0 < timeout) {
for (const sel of rowSelectors) {
const row = document.querySelector(sel);
if (row && row.offsetParent !== null) {
scrollIntoView(row);
row.click();
await sleep(500);
return pass('Clicked first row');
}
}
await sleep(300);
}
return fail('No table rows found');
},
async click_button(action, ctx) {
// Find button by text
const text = action.value || action.target;
const btns = Array.from(document.querySelectorAll('button, [role="button"]'));
const btn = btns.find((b) => b.innerText?.trim().includes(text));
if (!btn) return fail(`Button "${text}" not found`);
scrollIntoView(btn);
triggerClick(btn);
await sleep(300);
return pass(`Clicked button: ${text}`);
},
async click_dialog_confirm(action, ctx) {
await sleep(300);
const dialog =
document.querySelector('[role="alertdialog"]') ||
document.querySelector('[role="dialog"]');
if (!dialog) return fail('No dialog found');
const confirmTexts = ['확인', '예', '삭제', 'OK', 'Yes', 'Confirm'];
const btns = Array.from(dialog.querySelectorAll('button'));
const btn = btns.find((b) =>
confirmTexts.some((t) => b.innerText?.trim().includes(t))
);
if (!btn) return fail('Confirm button not found in dialog');
triggerClick(btn);
await sleep(500);
return pass('Confirmed dialog');
},
// ── Fill group ──
async fill(action, ctx) {
let el = findEl(action.target, { selectors: ctx.selectors });
if (!el) return fail(`Input not found: ${action.target}`);
scrollIntoView(el);
let value = action.value ?? '';
value = replaceVars(value, ctx.variables);
// Smart type detection: delegate to appropriate handler
if (el.tagName === 'SELECT') {
// Delegate to select handler
return ActionHandlers.select({ ...action, value }, ctx);
}
if (el.getAttribute('role') === 'combobox') {
// Delegate to select_dropdown handler
return ActionHandlers.select_dropdown({ ...action, value }, ctx);
}
el.focus();
if (action.clear !== false) clearInput(el);
// Date input: use nativeInputValueSetter + change event
if (el.type === 'date' || el.type === 'datetime-local') {
const proto = HTMLInputElement.prototype;
const nativeSetter = Object.getOwnPropertyDescriptor(proto, 'value')?.set;
if (nativeSetter) nativeSetter.call(el, value);
else el.value = value;
el.dispatchEvent(new Event('input', { bubbles: true }));
el.dispatchEvent(new Event('change', { bubbles: true }));
await sleep(200);
return pass(`Filled date "${action.target}" with "${value}"`);
}
setInputValue(el, value);
await sleep(200);
return pass(`Filled "${action.target}" with "${value.substring(0, 30)}"`);
},
async fill_nth(action, ctx) {
const n = action.nth ?? 0;
const el = findEl(action.target, { nth: n, selectors: ctx.selectors });
if (!el) return fail(`Input[${n}] not found: ${action.target}`);
el.focus();
clearInput(el);
const value = replaceVars(action.value ?? '', ctx.variables);
setInputValue(el, value);
await sleep(200);
return pass(`Filled [${n}] with "${value.substring(0, 30)}"`);
},
async fill_form(action, ctx) {
const fields = action.fields || [];
const results = [];
for (const field of fields) {
const label = field.name || field.label;
let value = replaceVars(field.value ?? '', ctx.variables);
// Try to find by label text → associated input
let el = null;
// Try label[for] → input
const labels = Array.from(document.querySelectorAll('label'));
const matchLabel = labels.find((l) => l.textContent?.includes(label));
if (matchLabel) {
const forId = matchLabel.getAttribute('for');
if (forId) el = document.getElementById(forId);
if (!el) el = matchLabel.querySelector('input, textarea, select');
if (!el) el = matchLabel.parentElement?.querySelector('input, textarea, select');
}
// Fallback: placeholder search
if (!el) {
el = document.querySelector(
`input[placeholder*="${label}"], textarea[placeholder*="${label}"]`
);
}
// Fallback: name search
if (!el) {
el = document.querySelector(`input[name*="${label}"], textarea[name*="${label}"]`);
}
if (!el) {
results.push({ field: label, status: 'skip', detail: 'not found' });
continue;
}
if (field.type === 'select' || el.tagName === 'SELECT') {
// Select handling
const options = Array.from(el.querySelectorAll('option'));
const opt = options.find((o) => o.textContent?.includes(value));
if (opt) {
el.value = opt.value;
el.dispatchEvent(new Event('change', { bubbles: true }));
}
} else if (field.type === 'date') {
setInputValue(el, value);
} else {
clearInput(el);
setInputValue(el, value);
}
results.push({ field: label, status: 'ok' });
await sleep(150);
}
const filled = results.filter((r) => r.status === 'ok').length;
const skipped = results.filter((r) => r.status === 'skip').length;
if (filled === 0) return fail(`fill_form: no fields filled (${skipped} not found)`);
return pass(`fill_form: ${filled}/${fields.length} filled`);
},
async fill_and_wait(action, ctx) {
const result = await ActionHandlers.fill(action, ctx);
await sleep(1000);
return result;
},
async clear(action, ctx) {
const el = findEl(action.target, { selectors: ctx.selectors });
if (!el) return fail(`Input not found: ${action.target}`);
clearInput(el);
await sleep(200);
return pass(`Cleared: ${action.target}`);
},
async edit_field(action, ctx) {
return ActionHandlers.fill(action, ctx);
},
// ── Select group ──
async select(action, ctx) {
const el = findEl(action.target, { selectors: ctx.selectors });
if (!el) return fail(`Select not found: ${action.target}`);
if (el.tagName === 'SELECT') {
const options = Array.from(el.querySelectorAll('option'));
const opt = options.find((o) => o.textContent?.includes(action.value));
if (opt) {
el.value = opt.value;
el.dispatchEvent(new Event('change', { bubbles: true }));
return pass(`Selected: ${action.value}`);
}
return fail(`Option "${action.value}" not found`);
}
// Custom dropdown
return ActionHandlers.select_dropdown(action, ctx);
},
async select_dropdown(action, ctx) {
// Click trigger to open dropdown
const trigger = findEl(action.target, { selectors: ctx.selectors });
if (!trigger) return fail(`Dropdown trigger not found: ${action.target}`);
triggerClick(trigger);
await sleep(500);
// Find option in dropdown list with expanded selectors for Shadcn UI
const optionSelectors = [
'[role="option"]',
'[role="listbox"] li',
'[data-value]',
'[class*="option"]',
'[class*="Option"]',
'[class*="select-option"]',
'[class*="menu-item"]',
'[class*="MenuItem"]',
'[class*="dropdown-item"]',
'[class*="DropdownItem"]',
'[cmdk-item]',
'li',
];
for (const sel of optionSelectors) {
const options = document.querySelectorAll(sel);
const opt = Array.from(options).find((o) => o.textContent?.trim().includes(action.value));
if (opt) {
triggerClick(opt);
await sleep(300);
return pass(`Selected dropdown: ${action.value}`);
}
}
return fail(`Dropdown option "${action.value}" not found`);
},
async select_filter(action, ctx) {
return ActionHandlers.select_dropdown(action, ctx);
},
// ── Check group ──
async check(action, ctx) {
const el = findEl(action.target, { selectors: ctx.selectors });
if (!el) return fail(`Checkbox not found: ${action.target}`);
if (!el.checked) {
triggerClick(el);
await sleep(200);
}
return pass(`Checked: ${action.target}`);
},
async uncheck(action, ctx) {
const el = findEl(action.target, { selectors: ctx.selectors });
if (!el) return fail(`Checkbox not found: ${action.target}`);
if (el.checked) {
triggerClick(el);
await sleep(200);
}
return pass(`Unchecked: ${action.target}`);
},
async check_nth(action, ctx) {
const n = action.nth ?? 0;
const el = findEl(action.target, { nth: n, selectors: ctx.selectors });
if (!el) return fail(`Checkbox[${n}] not found: ${action.target}`);
if (!el.checked) {
triggerClick(el);
await sleep(200);
}
return pass(`Checked [${n}]: ${action.target}`);
},
async uncheck_nth(action, ctx) {
const n = action.nth ?? 0;
const el = findEl(action.target, { nth: n, selectors: ctx.selectors });
if (!el) return fail(`Checkbox[${n}] not found: ${action.target}`);
if (el.checked) {
triggerClick(el);
await sleep(200);
}
return pass(`Unchecked [${n}]: ${action.target}`);
},
// ── Wait group ──
async wait(action, ctx) {
const ms = action.duration || action.timeout || 1000;
await sleep(ms);
return pass(`Waited ${ms}ms`);
},
async wait_for_element(action, ctx) {
const timeout = action.timeout || 10000;
const t0 = now();
while (now() - t0 < timeout) {
const el = findEl(action.target, { selectors: ctx.selectors });
if (el) return pass(`Found: ${action.target}`);
await sleep(200);
}
return fail(`Timeout waiting for: ${action.target}`);
},
async wait_for_table(action, ctx) {
const timeout = action.timeout || 10000;
const rowSelectors = [
'table tbody tr',
'[role="table"] [role="row"]',
'[role="grid"] [role="row"]',
'[class*="table"]:not([class*="tooltip"]) tbody tr',
];
const t0 = now();
while (now() - t0 < timeout) {
for (const sel of rowSelectors) {
const rows = document.querySelectorAll(sel);
if (rows.length > 0) return pass(`Table loaded: ${rows.length} rows`);
}
await sleep(300);
}
return fail('Timeout waiting for table data');
},
async wait_for_modal(action, ctx) {
const timeout = action.timeout || 5000;
const t0 = now();
while (now() - t0 < timeout) {
if (ModalGuard.check().open) return pass('Modal appeared');
await sleep(200);
}
return fail('Timeout waiting for modal');
},
async wait_for_navigation(action, ctx) {
await sleep(2000);
const v = action.expected || action.verification;
if (v) {
const urlCheck = v.url_contains || v.url;
if (urlCheck && !window.location.href.includes(urlCheck)) {
return warn(`URL doesn't contain "${urlCheck}": ${window.location.href}`);
}
if (v.visible) {
const text = document.body.innerText;
const missing = v.visible.filter((t) => !text.includes(t));
if (missing.length > 0) {
return warn(`Missing visible text: ${missing.join(', ')}`);
}
}
}
return pass(`Navigation ok: ${window.location.href}`);
},
// ── Verify group ──
async verify_element(action, ctx) {
const v = action.verification || action.verify || action.expected || {};
const target = action.target;
if (!target) {
// checks-based verification
return ActionHandlers.verify_checks(action, ctx);
}
const el = findEl(target, { selectors: ctx.selectors });
if (v.exists === false) {
return el ? fail(`Element should NOT exist: ${target}`) : pass(`Confirmed absent: ${target}`);
}
if (v.count != null) {
const all = document.querySelectorAll(target);
return all.length >= v.count
? pass(`Count ${all.length} >= ${v.count}: ${target}`)
: warn(`Count ${all.length} < ${v.count}: ${target}`);
}
if (el) return pass(`Element exists: ${target}`);
return warn(`Element not found: ${target}`);
},
async verify_checks(action, ctx) {
// Verify general checks array (text-based)
const checks = action.checks || [];
if (checks.length === 0) return pass('No checks defined');
const text = document.body.innerText;
let passed = 0;
for (const check of checks) {
// Extract key terms from check text
const terms = check.match(/['']([^'']+)['']|[가-힣\w]+/g) || [];
if (terms.some((t) => text.includes(t.replace(/['']/g, '')))) passed++;
}
return passed > 0
? pass(`Checks: ${passed}/${checks.length} verified`)
: warn(`Checks: 0/${checks.length} verified`);
},
async verify_text(action, ctx) {
const v = action.verification || {};
const text = v.text || v.text_pattern;
if (!text) return pass('No text to verify');
const pageText = document.body.innerText;
if (v.text_pattern) {
const re = new RegExp(v.text_pattern);
return re.test(pageText)
? pass(`Text pattern found: ${v.text_pattern}`)
: fail(`Text pattern NOT found: ${v.text_pattern}`);
}
const exists = v.exists !== false;
const found = pageText.includes(text);
if (exists && found) return pass(`Text found: "${text.substring(0, 40)}"`);
if (exists && !found) return fail(`Text NOT found: "${text.substring(0, 40)}"`);
if (!exists && !found) return pass(`Text correctly absent: "${text.substring(0, 40)}"`);
if (!exists && found) return fail(`Text should be absent: "${text.substring(0, 40)}"`);
return pass('verify_text');
},
async verify_url(action, ctx) {
const v = action.verification || action.expected || {};
const url = window.location.href;
if (v.url_contains) {
if (!url.includes(v.url_contains)) return fail(`URL missing: ${v.url_contains}`);
}
if (v.url) {
if (!url.includes(v.url)) return fail(`URL missing: ${v.url}`);
}
if (v.url_pattern) {
const re = new RegExp(v.url_pattern);
if (!re.test(url)) return fail(`URL pattern mismatch: ${v.url_pattern}`);
}
if (v.visible) {
const text = document.body.innerText;
const missing = v.visible.filter((t) => !text.includes(t));
if (missing.length > 0) return warn(`Missing text: ${missing.join(', ')}`);
}
return pass(`URL verified: ${url}`);
},
async verify_url_stability(action, ctx) {
await sleep(2000);
const url = window.location.href;
const v = action.verification || {};
// Check 404 / error page
const pageText = document.body.innerText;
if (pageText.includes('404') && pageText.includes('Not Found')) {
return fail('404 error page detected');
}
if (pageText.includes('500') && pageText.includes('Internal Server Error')) {
return fail('500 error page detected');
}
if (v.expected_url_pattern) {
const re = new RegExp(v.expected_url_pattern);
if (!re.test(url)) return fail(`URL pattern mismatch: ${v.expected_url_pattern}`);
}
if (v.expected_url) {
if (!url.includes(v.expected_url)) return fail(`URL missing: ${v.expected_url}`);
}
return pass(`URL stable: ${url}`);
},
async verify_table(action, ctx) {
const v = action.verification || {};
// Expanded table detection for Shadcn UI
const tableSelectors = [
'table',
'[role="table"]',
'[role="grid"]',
'[class*="table"]:not([class*="tooltip"]):not([class*="Table"][class*="Cell"])',
];
let table = null;
for (const sel of tableSelectors) {
table = document.querySelector(sel);
if (table) break;
}
if (!table) return warn('No table found');
const headers = Array.from(table.querySelectorAll('thead th, thead td, [role="columnheader"]')).map((h) =>
h.textContent?.trim()
);
const rows = table.querySelectorAll('tbody tr, [role="row"]');
if (v.columns) {
const missing = v.columns.filter(
(col) => !headers.some((h) => h?.includes(col))
);
if (missing.length > 0) return warn(`Missing columns: ${missing.join(', ')}`);
}
return pass(`Table: ${headers.length} cols, ${rows.length} rows`);
},
async verify_table_structure(action, ctx) {
return ActionHandlers.verify_table(action, ctx);
},
async verify_data(action, ctx) {
const searchText = action.search || action.value || '';
const v = action.expected || action.verification || {};
const pageText = document.body.innerText;
const found = pageText.includes(searchText);
if (v.row_exists === false) {
return found
? fail(`Data should be absent: "${searchText}"`)
: pass(`Data correctly absent: "${searchText}"`);
}
if (v.row_exists === true || v.row_exists === undefined) {
if (!found) return fail(`Data not found: "${searchText}"`);
if (v.contains) {
const missing = v.contains.filter((t) => !pageText.includes(t));
if (missing.length > 0) return warn(`Missing: ${missing.join(', ')}`);
}
return pass(`Data found: "${searchText}"`);
}
return pass('verify_data');
},
async verify_detail(action, ctx) {
const checks = action.checks || [];
const pageText = document.body.innerText;
let matched = 0;
for (const check of checks) {
// Parse "label: value" format
const parts = check.split(':').map((s) => s.trim());
const searchText = parts[parts.length - 1]; // just check value part
if (pageText.includes(searchText)) matched++;
}
return matched > 0
? pass(`Detail checks: ${matched}/${checks.length}`)
: warn(`Detail checks: 0/${checks.length} matched`);
},
async verify_not_mockup(action, ctx) {
const inputs = document.querySelectorAll(
'input:not([type="hidden"]), textarea, select'
);
const buttons = document.querySelectorAll('button, [role="button"]');
const tables = document.querySelectorAll('table');
let mockupScore = 0;
if (inputs.length === 0) mockupScore++;
if (buttons.length <= 1) mockupScore++;
if (tables.length === 0 && inputs.length === 0) mockupScore++;
return mockupScore >= 2
? warn(`Possible mockup page (score: ${mockupScore})`)
: pass(`Real page: ${inputs.length} inputs, ${buttons.length} buttons`);
},
async verify_dialog(action, ctx) {
const v = action.verification || {};
const dialog =
document.querySelector('[role="alertdialog"]') ||
document.querySelector('[role="dialog"]');
if (!dialog) return warn('No dialog found');
const text = dialog.innerText || '';
if (v.content_contains && !text.includes(v.content_contains)) {
return warn(`Dialog missing text: ${v.content_contains}`);
}
return pass('Dialog verified');
},
async verify_input_value(action, ctx) {
const el = findEl(action.target, { selectors: ctx.selectors });
if (!el) return fail(`Input not found: ${action.target}`);
const v = action.verification || {};
if (v.value && el.value !== v.value) {
return fail(`Value mismatch: expected "${v.value}", got "${el.value}"`);
}
return pass(`Input value: "${el.value?.substring(0, 30)}"`);
},
async verify_page(action, ctx) {
const v = action.verification || {};
const pageText = document.body.innerText;
if (v.title && !pageText.includes(v.title)) {
return fail(`Page title missing: ${v.title}`);
}
if (v.content_contains && !pageText.includes(v.content_contains)) {
return fail(`Page content missing: ${v.content_contains}`);
}
return pass('Page verified');
},
async verify_console(action, ctx) {
// Can't access real console from page context; return pass
return pass('Console check (monitored externally)');
},
async verify_data_change(action, ctx) {
await sleep(1000);
return pass('Data change check');
},
async verify_edit_mode(action, ctx) {
const url = window.location.href;
const hasEdit = url.includes('mode=edit') || url.includes('edit');
const inputs = document.querySelectorAll('input:not([type="hidden"]):not([disabled])');
return hasEdit || inputs.length > 0
? pass('Edit mode active')
: warn('Edit mode not detected');
},
// ── State/Other group ──
async save_url(action, ctx) {
const varName = action.variable || 'saved_url';
ctx.variables[varName] = window.location.href;
return pass(`Saved URL → ${varName}`);
},
async extract_from_url(action, ctx) {
const pattern = action.pattern;
const varName = action.variable || 'extracted';
const m = window.location.href.match(new RegExp(pattern));
if (m && m[1]) {
ctx.variables[varName] = m[1];
return pass(`Extracted "${m[1]}" → ${varName}`);
}
return warn(`Pattern not matched: ${pattern}`);
},
async capture(action, ctx) {
// If selector + extract are specified, count/extract data (no screenshot needed)
if (action.selector) {
const els = document.querySelectorAll(action.selector);
const count = els.length;
if (action.variable && ctx.variables) {
ctx.variables[action.variable] = count;
}
return pass(`Captured ${action.extract || 'count'}: ${count}`);
}
// Fallback: requires native MCP call - signal to orchestrator
return { status: 'native_required', type: 'screenshot', details: action.name || 'capture' };
},
async close_modal(action, ctx) {
const result = await ModalGuard.close();
return result.closed ? pass('Modal closed') : warn('Modal close failed');
},
async close_modal_if_open(action, ctx) {
if (!ModalGuard.check().open) return pass('No modal open');
const result = await ModalGuard.close();
return result.closed ? pass('Modal closed') : warn('Modal close failed');
},
async scrollAndFind(action, ctx) {
const text = action.target;
const containerSel = action.container || '.sidebar-scroll, [data-sidebar="content"], nav';
const maxAttempts = action.maxAttempts || 10;
const scrollStep = action.scrollStep || 200;
const container =
document.querySelector(containerSel.split(',')[0].trim()) ||
document.querySelector(containerSel.split(',')[1]?.trim()) ||
document.querySelector('nav');
if (container) container.scrollTo({ top: 0, behavior: 'instant' });
await sleep(200);
for (let i = 0; i < maxAttempts; i++) {
const el = findEl(text);
if (el) {
scrollIntoView(el);
return pass(`Found: ${text}`);
}
if (container) container.scrollBy({ top: scrollStep, behavior: 'instant' });
await sleep(150);
}
return warn(`scrollAndFind: "${text}" not found after ${maxAttempts} scrolls`);
},
async evaluate(action, ctx) {
try {
const result = eval(action.script);
if (result instanceof Promise) await result;
return pass('evaluate ok');
} catch (err) {
return warn(`evaluate error: ${err.message}`);
}
},
async search(action, ctx) {
const el = findEl(action.target, { selectors: ctx.selectors });
if (!el) {
// Fallback: find any search input with expanded selectors for Shadcn UI
const searchSelectors = [
'input[type="search"]',
'input[placeholder*="검색"]',
'input[placeholder*="Search"]',
'input[placeholder*="search"]',
'input[placeholder*="조회"]',
'input[role="searchbox"]',
'input[aria-label*="검색"]',
'[class*="search"] input',
'[class*="Search"] input',
'div.relative > input[type="text"]',
'header input[type="text"]',
'[class*="toolbar"] input',
];
let searchInput = null;
for (const sel of searchSelectors) {
searchInput = document.querySelector(sel);
if (searchInput) break;
}
if (!searchInput) return fail('Search input not found');
clearInput(searchInput);
setInputValue(searchInput, action.value || '');
await sleep(1000);
return pass(`Searched: "${action.value}"`);
}
clearInput(el);
setInputValue(el, action.value || '');
await sleep(1000);
return pass(`Searched: "${action.value}"`);
},
async click_and_confirm(action, ctx) {
const el = findEl(action.target, { selectors: ctx.selectors });
if (!el) return fail(`Element not found: ${action.target}`);
triggerClick(el);
await sleep(500);
// Now look for confirm dialog
return ActionHandlers.click_dialog_confirm(action, ctx);
},
async click_if_exists(action, ctx) {
const el = findEl(action.target, { selectors: ctx.selectors });
if (!el) return pass(`Element not present (ok): ${action.target}`);
triggerClick(el);
await sleep(300);
return pass(`Clicked (existed): ${action.target}`);
},
async press_key(action, ctx) {
const key = action.value || action.key || 'Escape';
const target = action.target ? findEl(action.target, { selectors: ctx.selectors }) : document.activeElement;
if (target) {
target.dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true }));
target.dispatchEvent(new KeyboardEvent('keyup', { key, bubbles: true }));
}
await sleep(200);
return pass(`Pressed key: ${key}`);
},
async blur(action, ctx) {
const el = action.target
? findEl(action.target, { selectors: ctx.selectors })
: document.activeElement;
if (el) el.blur();
await sleep(200);
return pass('Blurred');
},
async generate_timestamp(action, ctx) {
const n = new Date();
const pad = (v) => v.toString().padStart(2, '0');
const ts = `${n.getFullYear()}${pad(n.getMonth() + 1)}${pad(n.getDate())}_${pad(n.getHours())}${pad(n.getMinutes())}${pad(n.getSeconds())}`;
const varName = action.variable || 'timestamp';
ctx.variables[varName] = ts;
return pass(`Generated timestamp: ${ts}`);
},
async verify_toast(action, ctx) {
// Look for toast/notification elements
const toastSels = [
'[class*="toast"]',
'[class*="Toast"]',
'[class*="notification"]',
'[class*="Notification"]',
'[class*="snackbar"]',
'[class*="Snackbar"]',
'[role="alert"]',
'[class*="alert"]:not([class*="dialog"])',
];
await sleep(500);
for (const sel of toastSels) {
const el = document.querySelector(sel);
if (el && el.offsetParent !== null) {
const text = el.innerText || '';
const v = action.verification || action.verify || {};
if (v.contains) {
const patterns = v.contains.split('|');
if (patterns.some((p) => text.includes(p))) {
return pass(`Toast found: "${text.substring(0, 50)}"`);
}
}
return pass(`Toast visible: "${text.substring(0, 50)}"`);
}
}
return warn('No toast/notification found');
},
async hover(action, ctx) {
const el = findEl(action.target, { selectors: ctx.selectors });
if (!el) return warn(`Hover target not found: ${action.target}`);
el.dispatchEvent(new MouseEvent('mouseover', { bubbles: true }));
el.dispatchEvent(new MouseEvent('mouseenter', { bubbles: true }));
await sleep(300);
return pass(`Hovered: ${action.target}`);
},
// ── Navigation group (handled by orchestrator mostly) ──
async navigate(action, ctx) {
// Signal navigation needed
const url = action.target;
if (url && url.startsWith('/')) {
window.location.href = window.location.origin + url;
return { status: 'navigation', details: `Navigate to ${url}` };
}
return { status: 'navigation', details: `Navigate: ${url}` };
},
async reload(action, ctx) {
window.location.reload();
return { status: 'navigation', details: 'Page reload' };
},
async navigate_back(action, ctx) {
window.history.back();
return { status: 'navigation', details: 'Navigate back' };
},
async menu_navigate(action, ctx) {
// Complex menu navigation - handled by scrollAndFind + click pattern
// Expand all menus first
const expandBtn = Array.from(document.querySelectorAll('button')).find((b) =>
b.innerText?.includes('모두 펼치기')
);
if (expandBtn) {
expandBtn.click();
await sleep(1500);
}
// Find and click level1
const l1 = action.level1;
const l2 = action.level2;
if (l1) {
const l1El = findEl(l1);
if (l1El) {
triggerClick(l1El);
await sleep(500);
}
}
if (l2) {
const l2El = findEl(l2);
if (l2El) {
triggerClick(l2El);
await sleep(2000);
}
}
// Check expected URL
const v = action.expected || {};
if (v.url_contains && !window.location.href.includes(v.url_contains)) {
return warn(`Menu nav: URL missing "${v.url_contains}"`);
}
return pass(`Menu navigation: ${l1} > ${l2}`);
},
// ── Noop ──
async noop(action, ctx) {
return pass('No action');
},
};
// ─── Result Constructors ────────────────────────────────
function pass(details) {
return { status: 'pass', details };
}
function fail(details) {
return { status: 'fail', details };
}
function warn(details) {
return { status: 'warn', details };
}
// ─── Variable Replacement ───────────────────────────────
function replaceVars(str, vars) {
if (typeof str !== 'string') return str;
// Replace {timestamp}
str = str.replace(/\{timestamp\}/g, () => {
const n = new Date();
const pad = (v) => v.toString().padStart(2, '0');
return `${n.getFullYear()}${pad(n.getMonth() + 1)}${pad(n.getDate())}_${pad(n.getHours())}${pad(n.getMinutes())}${pad(n.getSeconds())}`;
});
// Replace {variableName}
if (vars) {
str = str.replace(/\{(\w+)\}/g, (_, key) => vars[key] ?? `{${key}}`);
}
return str;
}
// ─── Retry Engine ───────────────────────────────────────
async function retryAction(handler, action, ctx, maxRetries = 2, delayMs = 500) {
let lastResult;
for (let attempt = 0; attempt <= maxRetries; attempt++) {
lastResult = await handler(action, ctx);
if (lastResult.status === 'pass' || lastResult.status === 'navigation' || lastResult.status === 'native_required') {
return lastResult;
}
if (attempt < maxRetries) {
// Pre-retry actions
if (lastResult.details?.includes('not found') || lastResult.details?.includes('not visible')) {
await sleep(delayMs);
// Try closing overlays
if (ModalGuard.check().open) await ModalGuard.close();
} else {
await sleep(delayMs);
}
}
}
return lastResult;
}
// ─── Batch Runner ───────────────────────────────────────
/**
* Run a batch of steps
* @param {Array} steps - array of step objects from scenario JSON
* @param {Object} vars - variables carried between batches
* @param {Object} config - { selectors }
* @returns {BatchResult}
*/
async function runBatch(steps, vars = {}, config = {}) {
const results = [];
const ctx = {
variables: { ...vars },
selectors: config.selectors || {},
};
const startUrl = window.location.href;
let stoppedReason = 'complete';
let stoppedAtIndex = steps.length;
for (let i = 0; i < steps.length; i++) {
const step = steps[i];
const normalized = normalizeStep(step);
const stepStart = now();
let stepStatus = 'pass';
let stepDetails = '';
let stepError = null;
let subResults = [];
for (let j = 0; j < normalized.subActions.length; j++) {
const action = normalized.subActions[j];
const actionType = action.type;
// Check if action requires native (screenshot without selector)
if ((actionType === 'capture' && !action.selector) || actionType === 'screenshot') {
stoppedReason = 'native_required';
stoppedAtIndex = i;
results.push({
stepId: normalized.stepId,
name: normalized.name,
status: 'skip',
duration: now() - stepStart,
details: 'Requires native screenshot',
error: null,
phase: normalized.phase,
});
// Return with position info
return buildBatchResult(results, ctx, stoppedReason, stoppedAtIndex);
}
// Get handler
const handler = ActionHandlers[actionType];
if (!handler) {
subResults.push(warn(`Unknown action type: ${actionType}`));
continue;
}
// Execute with retry
const result = await retryAction(handler, action, ctx);
// Handle navigation signal
if (result.status === 'navigation') {
subResults.push(result);
stoppedReason = 'navigation';
stoppedAtIndex = i + 1; // continue from next step
const sr = buildStepResult(normalized, subResults, stepStart);
results.push(sr);
return buildBatchResult(results, ctx, stoppedReason, stoppedAtIndex);
}
// Handle native_required signal
if (result.status === 'native_required') {
stoppedReason = 'native_required';
stoppedAtIndex = i;
const sr = buildStepResult(normalized, subResults, stepStart);
results.push(sr);
return buildBatchResult(results, ctx, stoppedReason, stoppedAtIndex);
}
subResults.push(result);
// Check URL change (indicates page navigation)
const currentUrl = window.location.href;
if (currentUrl !== startUrl && j < normalized.subActions.length - 1) {
// URL changed mid-step, might need re-injection
// Continue for now, check at step boundary
}
}
// Build step result
const sr = buildStepResult(normalized, subResults, stepStart);
results.push(sr);
// Check critical failure
if (sr.status === 'fail' && normalized.critical) {
stoppedReason = 'critical_failure';
stoppedAtIndex = i + 1;
return buildBatchResult(results, ctx, stoppedReason, stoppedAtIndex);
}
}
return buildBatchResult(results, ctx, stoppedReason, stoppedAtIndex);
}
function buildStepResult(normalized, subResults, stepStart) {
const failed = subResults.filter((r) => r.status === 'fail');
const warns = subResults.filter((r) => r.status === 'warn');
let status = 'pass';
if (failed.length > 0) status = 'fail';
else if (warns.length > 0) status = 'warn';
const details = subResults.map((r) => r.details).join(' | ');
const error = failed.length > 0 ? failed.map((f) => f.details).join('; ') : null;
return {
stepId: normalized.stepId,
name: normalized.name,
status,
duration: now() - stepStart,
details: details.substring(0, 200),
error,
phase: normalized.phase,
};
}
function buildBatchResult(results, ctx, stoppedReason, stoppedAtIndex) {
return {
totalSteps: results.length,
completedSteps: results.filter((r) => r.status !== 'skip').length,
passed: results.filter((r) => r.status === 'pass').length,
failed: results.filter((r) => r.status === 'fail').length,
warned: results.filter((r) => r.status === 'warn').length,
results,
variables: ctx.variables,
apiSummary: ApiMonitor.summary(),
currentUrl: window.location.href,
stoppedReason,
stoppedAtIndex,
};
}
// ─── Public API ─────────────────────────────────────────
window.__E2E__ = {
_version: 1,
/**
* Initialize the executor
* @param {Object} config - { selectors, variables }
*/
init(config = {}) {
ApiMonitor.install();
ApiMonitor.reset();
return {
ready: true,
url: window.location.href,
apiMonitoring: true,
};
},
/**
* Run a batch of steps
* @param {Array} steps - step objects from scenario JSON
* @param {Object} vars - variables from previous batches
* @param {Object} config - { selectors }
* @returns {Promise<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,
};
})();