Files
sam-hotfix/e2e/runner/gen-input-fields.js
김보곤 67d0a4c2fd feat: E2E 시나리오 생성기 및 감사 스크립트 17종 추가
- gen-*.js: 시나리오 자동 생성기 12종 (CRUD, edge, a11y, perf 등)
- search-*.js: 검색/버튼 감사 수집기 3종
- revert-hard-actions.js: 하드 액션 복원 유틸
- _gen_writer.py: 생성기 보조 스크립트

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 16:59:15 +09:00

403 lines
19 KiB
JavaScript

#!/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();