#!/usr/bin/env node /** * 입력 필드 전수 테스트 시나리오 생성기 * * 12개 페이지에서 모든 필드 유형(text, number, textarea, combobox, * datepicker, radio, toggle, checkbox)을 동적으로 발견하고 테스트하는 * 시나리오 JSON을 생성한다. * * Usage: * node e2e/runner/gen-input-fields.js * * Output: * e2e/scenarios/input-fields-acc-1.json * e2e/scenarios/input-fields-acc-2.json * e2e/scenarios/input-fields-sales.json * e2e/scenarios/input-fields-production.json * e2e/scenarios/input-fields-material-quality.json */ const fs = require('fs'); const path = require('path'); const SCENARIOS_DIR = path.resolve(__dirname, '..', 'scenarios'); // ──────────────────────────────────────────────────────────────── // 핵심 evaluate 스크립트: 등록 버튼 클릭 + 폼 열기 // ──────────────────────────────────────────────────────────────── // 전략: // 1) "등록" 포함 버튼 우선 (가장 구체적) // 2) "추가" 포함 버튼 // 3) "신규" 포함 버튼 (단, "신규업체" 같은 필터 버튼 제외) // 4) "작성" 포함 버튼 // 5) 테이블 첫 행 클릭 (상세보기 → 수정모드) // 6) 이미 인라인 폼이 있으면 그대로 사용 // 클릭 후 모달 또는 URL 변경(?mode=new 등) 감지 const OPEN_FORM_SCRIPT = [ '(async()=>{', 'const w=ms=>new Promise(r=>setTimeout(r,ms));', 'const urlBefore=location.href;', 'const btns=Array.from(document.querySelectorAll("button")).filter(b=>b.offsetParent!==null&&!b.disabled);', // Priority matching: 등록 > 추가 > 작성 > 신규 (with + prefix bonus) 'const priorities=[', ' b=>/등록/.test(b.innerText?.trim()),', ' b=>/추가/.test(b.innerText?.trim()),', ' b=>/작성/.test(b.innerText?.trim()),', ' b=>/^\\+/.test(b.innerText?.trim()),', ' b=>/신규/.test(b.innerText?.trim())&&!/신규업체|신규거래/.test(b.innerText?.trim()),', '];', 'let regBtn=null;', 'for(const pred of priorities){regBtn=btns.find(pred);if(regBtn)break;}', // Fallback: 신규 containing buttons 'if(!regBtn)regBtn=btns.find(b=>/신규/.test(b.innerText?.trim()));', 'if(regBtn){', ' regBtn.scrollIntoView({block:"center"});await w(300);', ' regBtn.click();await w(2500);', ' const modal=document.querySelector("[role=\'dialog\'],[aria-modal=\'true\']");', ' const hasModal=modal&&modal.offsetParent!==null;', ' const urlChanged=location.href!==urlBefore;', ' const inputs=document.querySelectorAll("input,textarea,select,button[role=\'combobox\']");', ' const vis=Array.from(inputs).filter(el=>el.offsetParent!==null&&!el.disabled).length;', ' return JSON.stringify({opened:true,hasModal,urlChanged,url:location.pathname+location.search,btnText:regBtn.innerText?.trim().substring(0,30),visibleInputs:vis});', '}', // Fallback: click first table row for detail view 'const row=document.querySelector("table tbody tr");', 'if(row){', ' row.click();await w(2500);', ' const modal=document.querySelector("[role=\'dialog\'],[aria-modal=\'true\']");', ' const hasModal=modal&&modal.offsetParent!==null;', ' const urlChanged=location.href!==urlBefore;', ' const inputs=document.querySelectorAll("input,textarea,select,button[role=\'combobox\']");', ' const vis=Array.from(inputs).filter(el=>el.offsetParent!==null&&!el.disabled).length;', ' return JSON.stringify({opened:true,hasModal,urlChanged,usedRowClick:true,visibleInputs:vis});', '}', // Already inline form 'const form=document.querySelector("form");', 'if(form){return JSON.stringify({opened:true,isInlineForm:true});}', 'return JSON.stringify({opened:false,noBtn:true,available:btns.slice(0,8).map(b=>b.innerText?.trim().substring(0,20)).filter(Boolean)});', '})()', ].join(''); // ──────────────────────────────────────────────────────────────── // 핵심 evaluate 스크립트: 필드 전수 테스트 // ──────────────────────────────────────────────────────────────── // 모달 > main content > document.body 순으로 scope 결정 // 모든 필드 유형을 동적 발견하여 값 설정/선택/토글 테스트 const FIELD_TEST_SCRIPT = [ '(async()=>{', 'const w=ms=>new Promise(r=>setTimeout(r,ms));', 'const R={url:location.pathname+location.search,fields:[],summary:{}};', // Determine scope: modal > main content area > body 'const modal=document.querySelector("[role=\'dialog\'],[aria-modal=\'true\']");', 'const hasModal=modal&&modal.offsetParent!==null;', 'const mainContent=document.querySelector("main,[class*=\'content\']:not(nav):not(aside),[class*=\'Content\']:not(nav)");', 'const scope=hasModal?modal:(mainContent||document.body);', 'R.scope=hasModal?"modal":mainContent?"main":"body";', // React-compatible value setter 'const sv=(el,v)=>{', ' const proto=el.tagName==="TEXTAREA"?HTMLTextAreaElement.prototype:HTMLInputElement.prototype;', ' const ns=Object.getOwnPropertyDescriptor(proto,"value")?.set;', ' if(ns)ns.call(el,v);else el.value=v;', ' el.dispatchEvent(new Event("input",{bubbles:true}));', ' el.dispatchEvent(new Event("change",{bubbles:true}));', '};', 'let tested=0,errors=0,skipped=0;', // ─── TEXT / NUMBER / TEL / EMAIL inputs ─── 'const textSel="input[type=text],input[type=number],input[type=email],input[type=tel],input[type=url],input:not([type]):not([role])";', 'const textInputs=scope.querySelectorAll(textSel);', 'for(const el of textInputs){', ' if(el.offsetParent===null||el.readOnly||el.disabled)continue;', // skip search bars ' if(el.placeholder?.includes("검색")||el.type==="search")continue;', ' const label=(el.closest("[class*=field],[class*=Field],[class*=form-item],[class*=FormItem]")?.querySelector("label")?.innerText||el.getAttribute("aria-label")||el.getAttribute("placeholder")||el.name||"").substring(0,40);', ' const orig=el.value;', ' try{sv(el,"E2E_TEST");await w(80);const ok=el.value==="E2E_TEST";sv(el,orig);await w(50);', ' R.fields.push({type:el.type||"text",label,ok});tested++;', ' }catch(e){R.fields.push({type:el.type||"text",label,err:e.message?.substring(0,60)});errors++;}', '}', // ─── TEXTAREA ─── 'const textareas=scope.querySelectorAll("textarea");', 'for(const el of textareas){', ' if(el.offsetParent===null||el.readOnly||el.disabled)continue;', ' const label=(el.closest("[class*=field],[class*=Field],[class*=form-item]")?.querySelector("label")?.innerText||el.getAttribute("placeholder")||"").substring(0,40);', ' const orig=el.value;', ' try{sv(el,"E2E_TEXTAREA");await w(80);const ok=el.value==="E2E_TEXTAREA";sv(el,orig);await w(50);', ' R.fields.push({type:"textarea",label,ok});tested++;', ' }catch(e){R.fields.push({type:"textarea",label,err:e.message?.substring(0,60)});errors++;}', '}', // ─── COMBOBOX (Shadcn Select) ─── 'const combos=scope.querySelectorAll("button[role=combobox]");', 'for(const cb of combos){', ' if(cb.offsetParent===null||cb.disabled)continue;', ' const label=(cb.closest("[class*=field],[class*=Field],[class*=form-item]")?.querySelector("label")?.innerText||cb.getAttribute("aria-label")||cb.innerText?.trim()||"").substring(0,40);', ' const origText=cb.innerText?.trim();', ' try{', ' cb.scrollIntoView({block:"center"});await w(200);cb.click();await w(600);', ' const lb=document.querySelector("[role=listbox]");', ' if(!lb){document.dispatchEvent(new KeyboardEvent("keydown",{key:"Escape",bubbles:true}));await w(200);', ' R.fields.push({type:"combobox",label,opts:0,err:"no listbox"});skipped++;continue;}', ' const opts=Array.from(lb.querySelectorAll("[role=option]"));', ' R.fields.push({type:"combobox",label,opts:opts.length,samples:opts.slice(0,5).map(o=>o.innerText?.trim())});', ' if(opts.length>0){', ' const pick=opts.find(o=>o.innerText?.trim()!==origText)||opts[0];', ' pick.click();await w(400);', ' const changed=cb.innerText?.trim()!==origText;', ' R.fields[R.fields.length-1].selected=pick.innerText?.trim().substring(0,30);', ' R.fields[R.fields.length-1].changed=changed;', // restore original ' cb.click();await w(400);', ' const lb2=document.querySelector("[role=listbox]");', ' if(lb2){const r2=Array.from(lb2.querySelectorAll("[role=option]")).find(o=>o.innerText?.trim()===origText)||lb2.querySelector("[role=option]");if(r2)r2.click();await w(300);}', ' else{document.dispatchEvent(new KeyboardEvent("keydown",{key:"Escape",bubbles:true}));await w(200);}', ' }else{document.dispatchEvent(new KeyboardEvent("keydown",{key:"Escape",bubbles:true}));await w(200);}', ' tested++;', ' }catch(e){document.dispatchEvent(new KeyboardEvent("keydown",{key:"Escape",bubbles:true}));', ' R.fields.push({type:"combobox",label,err:e.message?.substring(0,60)});errors++;}', '}', // ─── DATEPICKER ─── 'const dateSel="input[type=date],input[placeholder*=날짜],input[placeholder*=일자],input[placeholder*=YYYY],input[placeholder*=yyyy]";', 'const dateInputs=scope.querySelectorAll(dateSel);', 'for(const el of dateInputs){', ' if(el.offsetParent===null||el.readOnly||el.disabled)continue;', ' const label=(el.closest("[class*=field],[class*=Field]")?.querySelector("label")?.innerText||el.getAttribute("placeholder")||"").substring(0,40);', ' const orig=el.value;', ' try{sv(el,"2026-01-15");await w(150);const ok=el.value.includes("2026");sv(el,orig);await w(80);', ' R.fields.push({type:"datepicker",label,ok});tested++;', ' }catch(e){R.fields.push({type:"datepicker",label,err:e.message?.substring(0,60)});errors++;}', '}', // ─── RADIO ─── 'const radios=scope.querySelectorAll("input[type=radio]");', 'const radioGroups=new Map();', 'for(const r of radios){if(r.offsetParent===null||r.disabled)continue;const n=r.name||r.id||"unnamed";if(!radioGroups.has(n))radioGroups.set(n,[]);radioGroups.get(n).push(r);}', 'for(const[name,group]of radioGroups){', ' const lbl=group[0].closest("[class*=field],[class*=Field],[class*=form-item]");', ' const label=(lbl?.querySelector("label")?.innerText||group[0].closest("label")?.innerText||name).substring(0,40);', ' try{const origIdx=group.findIndex(r=>r.checked);const target=group.find(r=>!r.checked)||group[0];', ' target.click();await w(200);const ok=target.checked;', ' if(origIdx>=0&&group[origIdx]){group[origIdx].click();await w(150);}', ' R.fields.push({type:"radio",label,count:group.length,options:group.map(r=>r.closest("label")?.innerText?.trim()||r.value).slice(0,5),ok});tested++;', ' }catch(e){R.fields.push({type:"radio",label,err:e.message?.substring(0,60)});errors++;}', '}', // ─── TOGGLE / SWITCH ─── 'const switches=scope.querySelectorAll("[role=switch]");', 'for(const sw of switches){', ' if(sw.offsetParent===null||sw.disabled)continue;', ' const label=(sw.closest("[class*=field],[class*=Field]")?.querySelector("label")?.innerText||sw.getAttribute("aria-label")||"").substring(0,40);', ' const origState=sw.getAttribute("aria-checked")||sw.getAttribute("data-state");', ' try{sw.click();await w(250);', ' const newState=sw.getAttribute("aria-checked")||sw.getAttribute("data-state");', ' const changed=newState!==origState;', ' sw.click();await w(250);', ' R.fields.push({type:"toggle",label,origState,changed});tested++;', ' }catch(e){R.fields.push({type:"toggle",label,err:e.message?.substring(0,60)});errors++;}', '}', // ─── CHECKBOX ─── 'const checkboxes=scope.querySelectorAll("input[type=checkbox]");', 'for(const cb of checkboxes){', ' if(cb.offsetParent===null||cb.disabled)continue;', ' const label=(cb.closest("label")?.innerText||cb.getAttribute("aria-label")||cb.name||"").substring(0,40);', ' if(/전체|all|select.all/i.test(label))continue;', ' const orig=cb.checked;', ' try{cb.click();await w(150);const toggled=cb.checked!==orig;cb.click();await w(150);', ' R.fields.push({type:"checkbox",label,toggled});tested++;', ' }catch(e){R.fields.push({type:"checkbox",label,err:e.message?.substring(0,60)});errors++;}', '}', // ─── Summary ─── 'R.summary={totalFields:R.fields.length,totalTested:tested,totalErrors:errors,totalSkipped:skipped,byType:{}};', 'for(const f of R.fields){R.summary.byType[f.type]=(R.summary.byType[f.type]||0)+1;}', 'R.summary.coverage=R.fields.length>0?Math.round(tested/R.fields.length*100):0;', 'return JSON.stringify(R);', '})()', ].join(''); // ──────────────────────────────────────────────────────────────── // 모달 닫기 + 인라인 폼에서 목록으로 복귀 // ──────────────────────────────────────────────────────────────── const CLOSE_FORM_SCRIPT = [ '(async()=>{', 'const w=ms=>new Promise(r=>setTimeout(r,ms));', // Try closing modal first 'for(let i=0;i<3;i++){', ' const modal=document.querySelector("[role=\'dialog\'],[aria-modal=\'true\']");', ' if(!modal||modal.offsetParent===null)break;', ' const closeBtn=modal.querySelector("button[class*=close],[aria-label=닫기],[aria-label=Close]")', ' ||Array.from(modal.querySelectorAll("button")).find(b=>/닫기|Close|취소|Cancel/.test(b.innerText?.trim()));', ' if(closeBtn){closeBtn.click();await w(500);}', ' else{document.dispatchEvent(new KeyboardEvent("keydown",{key:"Escape",keyCode:27,bubbles:true}));await w(500);}', '}', // If URL has ?mode=new or ?mode=edit, click cancel/back to return to list 'if(/mode=(new|edit)/.test(location.search)){', ' const cancelBtn=Array.from(document.querySelectorAll("button")).find(b=>/취소|Cancel|목록/.test(b.innerText?.trim()));', ' if(cancelBtn){cancelBtn.click();await w(1500);}', ' else{history.back();await w(1500);}', '}', 'return JSON.stringify({url:location.pathname+location.search});', '})()', ].join(''); // ── 시나리오 그룹 정의 ────────────────────────────────────────── const GROUPS = [ { id: 'input-fields-acc-1', name: '입력 필드 전수 테스트: 어음/입금/출금 (1/5)', menuNavigation: { level1: '회계관리', level2: '어음관리' }, pages: [ { level1: '회계관리', level2: '어음관리' }, { level1: '회계관리', level2: '입금관리' }, { level1: '회계관리', level2: '출금관리' }, ], }, { id: 'input-fields-acc-2', name: '입력 필드 전수 테스트: 거래처(회계)/악성채권 (2/5)', menuNavigation: { level1: '회계관리', level2: '거래처관리' }, pages: [ { level1: '회계관리', level2: '거래처관리' }, { level1: '회계관리', level2: '악성채권추심관리' }, ], }, { id: 'input-fields-sales', name: '입력 필드 전수 테스트: 거래처(판매)/수주/견적 (3/5)', menuNavigation: { level1: '판매관리', level2: '거래처관리' }, pages: [ { level1: '판매관리', level2: '거래처관리' }, { level1: '판매관리', level2: '수주관리' }, { level1: '판매관리', level2: '견적관리' }, ], }, { id: 'input-fields-production', name: '입력 필드 전수 테스트: 작업지시/작업실적 (4/5)', menuNavigation: { level1: '생산관리', level2: '작업지시 관리' }, pages: [ { level1: '생산관리', level2: '작업지시 관리' }, { level1: '생산관리', level2: '작업실적' }, ], }, { id: 'input-fields-material-quality', name: '입력 필드 전수 테스트: 입고/제품검사 (5/5)', menuNavigation: { level1: '자재관리', level2: '입고관리' }, pages: [ { level1: '자재관리', level2: '입고관리' }, { level1: '품질관리', level2: '제품검사관리' }, ], }, ]; // ── 시나리오 생성 ───────────────────────────────────────────── function generateScenario(group) { const steps = []; let stepId = 1; group.pages.forEach((page, pageIdx) => { const prefix = `[${page.level1} > ${page.level2}]`; // 첫 페이지는 menuNavigation으로 자동 이동, 이후 페이지는 menu_navigate if (pageIdx > 0) { steps.push({ id: stepId++, name: `${prefix} 메뉴 이동`, action: 'menu_navigate', level1: page.level1, level2: page.level2, }); } // 페이지 로드 대기 steps.push({ id: stepId++, name: `${prefix} 페이지 로드 대기`, action: 'wait', timeout: 3000, }); // 테이블 로드 대기 (목록 페이지) steps.push({ id: stepId++, name: `${prefix} 테이블 로드 대기`, action: 'wait_for_table', timeout: 5000, }); // 등록 버튼 클릭하여 모달/폼 열기 steps.push({ id: stepId++, name: `${prefix} 등록 폼 열기`, action: 'evaluate', script: OPEN_FORM_SCRIPT, timeout: 15000, }); // 폼 렌더링 대기 steps.push({ id: stepId++, name: `${prefix} 폼 렌더링 대기`, action: 'wait', timeout: 1500, }); // 필드 전수 테스트 (핵심) steps.push({ id: stepId++, name: `${prefix} 입력 필드 전수 테스트`, action: 'evaluate', script: FIELD_TEST_SCRIPT, timeout: 60000, }); // 모달/폼 닫기 + 목록 복귀 steps.push({ id: stepId++, name: `${prefix} 모달/폼 닫기`, action: 'evaluate', script: CLOSE_FORM_SCRIPT, timeout: 10000, }); }); return { id: group.id, name: group.name, version: '1.1.0', auth: { role: 'admin' }, menuNavigation: group.menuNavigation, screenshotPolicy: { captureOnFail: true, captureOnPass: false, }, steps, }; } // ── 메인 실행 ───────────────────────────────────────────────── function main() { if (!fs.existsSync(SCENARIOS_DIR)) { fs.mkdirSync(SCENARIOS_DIR, { recursive: true }); } const generated = []; for (const group of GROUPS) { const scenario = generateScenario(group); const filePath = path.join(SCENARIOS_DIR, `${group.id}.json`); fs.writeFileSync(filePath, JSON.stringify(scenario, null, 2), 'utf-8'); generated.push({ id: group.id, steps: scenario.steps.length, pages: group.pages.length }); console.log(` ${group.id}.json (${scenario.steps.length} steps, ${group.pages.length} pages)`); } console.log(`\n Generated ${generated.length} scenarios in ${SCENARIOS_DIR}`); console.log(`\n Run: node e2e/runner/run-all.js --filter input-fields`); } main();