- 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>
403 lines
19 KiB
JavaScript
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();
|