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>
This commit is contained in:
김보곤
2026-02-19 16:59:15 +09:00
parent 4ca5c40a35
commit 67d0a4c2fd
17 changed files with 5489 additions and 0 deletions

View File

@@ -0,0 +1,2 @@
# Auto-generated writer script
MARKER = True

View File

@@ -0,0 +1,143 @@
#!/usr/bin/env node
/**
* 접근성 감사 시나리오 생성기
*
* 주요 18개 페이지에 대해 WCAG 2.1 AA 접근성 감사 시나리오를 자동 생성한다.
* 각 시나리오는:
* 1. 페이지 로드 대기
* 2. 테이블/콘텐츠 로드 대기
* 3. accessibility_audit - 이미지 alt, 폼 레이블, 버튼 이름, 제목 순서, 페이지 언어 검사
* 4. keyboard_navigate - 탭 키 네비게이션, 포커스 표시 검사
*
* Usage: node e2e/runner/gen-accessibility-audit.js
*
* Output:
* e2e/scenarios/a11y-{page-id}.json (18개 파일)
*
* Run generated scenarios:
* node e2e/runner/run-all.js --filter a11y
*/
const fs = require('fs');
const path = require('path');
const SCENARIOS_DIR = path.resolve(__dirname, '..', 'scenarios');
// ════════════════════════════════════════════════════════════════
// 페이지 매니페스트 (18개)
// ════════════════════════════════════════════════════════════════
const PAGES = [
// 판매관리
{ id: 'sales-client', name: '판매관리 > 거래처관리', level1: '판매관리', level2: '거래처관리' },
{ id: 'sales-estimate', name: '판매관리 > 견적관리', level1: '판매관리', level2: '견적관리' },
{ id: 'sales-order', name: '판매관리 > 수주관리', level1: '판매관리', level2: '수주관리' },
// 회계관리
{ id: 'acc-client', name: '회계관리 > 거래처관리', level1: '회계관리', level2: '거래처관리' },
{ id: 'acc-sales', name: '회계관리 > 매출관리', level1: '회계관리', level2: '매출관리', tableTimeout: 20000 },
{ id: 'acc-purchase', name: '회계관리 > 매입관리', level1: '회계관리', level2: '매입관리' },
{ id: 'acc-deposit', name: '회계관리 > 입금관리', level1: '회계관리', level2: '입금관리' },
// 인사관리
{ id: 'hr-employee', name: '인사관리 > 사원관리', level1: '인사관리', level2: '사원관리' },
{ id: 'hr-attendance', name: '인사관리 > 근태관리', level1: '인사관리', level2: '근태관리' },
{ id: 'hr-salary', name: '인사관리 > 급여관리', level1: '인사관리', level2: '급여관리' },
{ id: 'hr-department', name: '인사관리 > 부서관리', level1: '인사관리', level2: '부서관리', tableTimeout: 20000 },
// 생산관리
{ id: 'prod-work-order', name: '생산관리 > 작업지시', level1: '생산관리', level2: '작업지시' },
{ id: 'prod-item', name: '생산관리 > 품목관리', level1: '생산관리', level2: '품목관리', tableTimeout: 20000 },
// 자재관리
{ id: 'material-stock', name: '자재관리 > 재고현황', level1: '자재관리', level2: '재고현황' },
{ id: 'material-receiving', name: '자재관리 > 입고관리', level1: '자재관리', level2: '입고관리' },
// 게시판
{ id: 'board-free', name: '게시판 > 자유게시판', level1: '게시판', level2: '자유게시판' },
// 결재관리
{ id: 'approval-draft', name: '결재관리 > 기안함', level1: '결재관리', level2: '기안함' },
{ id: 'approval-box', name: '결재관리 > 결재함', level1: '결재관리', level2: '결재함' },
];
// ════════════════════════════════════════════════════════════════
// 시나리오 생성 함수
// ════════════════════════════════════════════════════════════════
function generateScenario(page) {
const steps = [];
let stepId = 1;
// Step 1: 페이지 로드 대기
steps.push({
id: stepId++,
name: '페이지 로드 대기',
action: 'wait',
timeout: 3000,
});
// Step 2: 테이블/콘텐츠 로드 대기
steps.push({
id: stepId++,
name: '테이블/콘텐츠 로드 대기',
action: 'wait_for_table',
timeout: page.tableTimeout || 5000,
});
// Step 3: 접근성 감사 (WCAG 2.1 AA)
steps.push({
id: stepId++,
name: '접근성 감사',
action: 'accessibility_audit',
timeout: 15000,
phase: 'A11Y_AUDIT',
});
// Step 4: 키보드 네비게이션 검사
steps.push({
id: stepId++,
name: '키보드 네비게이션 검사',
action: 'keyboard_navigate',
timeout: 15000,
phase: 'KBD_NAV',
});
return {
id: `a11y-${page.id}`,
name: `접근성 검사: ${page.name}`,
version: '1.0.0',
auth: { role: 'admin' },
menuNavigation: {
level1: page.level1,
level2: page.level2,
},
screenshotPolicy: {
captureOnFail: true,
captureOnPass: false,
},
steps,
};
}
// ════════════════════════════════════════════════════════════════
// 메인 실행
// ════════════════════════════════════════════════════════════════
function main() {
if (!fs.existsSync(SCENARIOS_DIR)) {
fs.mkdirSync(SCENARIOS_DIR, { recursive: true });
}
console.log('\n === Accessibility Audit Scenario Generator ===\n');
let count = 0;
for (const page of PAGES) {
const scenario = generateScenario(page);
const fp = path.join(SCENARIOS_DIR, `${scenario.id}.json`);
fs.writeFileSync(fp, JSON.stringify(scenario, null, 2), 'utf-8');
console.log(` ${scenario.id}.json (${scenario.steps.length} steps)`);
count++;
}
console.log(`\n Generated ${count} accessibility scenarios`);
console.log(` Run: node e2e/runner/run-all.js --filter a11y\n`);
}
main();

View File

@@ -0,0 +1,442 @@
#!/usr/bin/env node
/**
* 연속 등록(Batch Create) 테스트 시나리오 생성기
*
* 실제 고객 사용 시나리오: 여러 건을 연속으로 등록.
* 버그 발견 포인트: 폼 초기화 실패, 이전 데이터 잔존, 중복 등록, 연속 등록 후 목록 미반영.
*
* 흐름: Create #1 → Create #2 → Create #3 → Verify 3건 → Delete 3건 → Verify 0건
*
* Usage: node e2e/runner/gen-batch-create.js
*
* Output:
* e2e/scenarios/batch-create-board.json
* e2e/scenarios/batch-create-acc-bills.json
* e2e/scenarios/batch-create-acc-deposit.json
*/
const fs = require('fs');
const path = require('path');
const SCENARIOS_DIR = path.resolve(__dirname, '..', 'scenarios');
// ════════════════════════════════════════════════════════════════
// COMMON HELPERS
// ════════════════════════════════════════════════════════════════
const H = `const w=ms=>new Promise(r=>setTimeout(r,ms));const sv=(el,v)=>{const p=el.tagName==='TEXTAREA'?HTMLTextAreaElement.prototype:HTMLInputElement.prototype;const ns=Object.getOwnPropertyDescriptor(p,'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}));};const ts=window.__E2E_TS__||sessionStorage.getItem('__E2E_TS__')||(()=>{const n=new Date();const p=v=>v.toString().padStart(2,'0');return n.getFullYear()+p(n.getMonth()+1)+p(n.getDate())+'_'+p(n.getHours())+p(n.getMinutes())+p(n.getSeconds());})();window.__E2E_TS__=ts;try{sessionStorage.setItem('__E2E_TS__',ts);}catch(e){}`;
// ─── 목록 복귀 공통 스크립트 ─────────────────────────────────
const BACK_TO_LIST = `(async()=>{const w=ms=>new Promise(r=>setTimeout(r,ms));const onForm=location.search.includes('mode=new')||location.search.includes('mode=edit')||location.search.includes('mode=view')||new RegExp('/(new|[0-9]+|[0-9a-f]{8,})$').test(location.pathname);if(onForm){const btn=Array.from(document.querySelectorAll('button,a')).find(b=>/목록|취소|뒤로/.test(b.innerText?.trim()));if(btn){btn.click();await w(2000);}else{history.back();await w(2000);}}return JSON.stringify({url:location.pathname+location.search});})()`;
// ════════════════════════════════════════════════════════════════
// PAGE CONFIGURATIONS
// ════════════════════════════════════════════════════════════════
const PAGES = {
// ─── 자유게시판 ─────────────────────────────────────────────
'board': {
id: 'batch-create-board',
name: '연속 등록 테스트: 자유게시판',
menuNavigation: { level1: '게시판', level2: '자유게시판' },
batchCount: 3,
// CREATE: 글쓰기 → 제목+내용 → 등록
makeCreateScript(n) {
return [
`(async()=>{`, H,
`const R={phase:'CREATE_${n}',ts,n:${n}};`,
`const testTitle='E2E_BATCH_${n}_'+ts;`,
`R.testTitle=testTitle;`,
// Click 글쓰기
`const btn=Array.from(document.querySelectorAll('button')).find(b=>b.innerText?.trim()==='글쓰기'||/등록|작성/.test(b.innerText?.trim()));`,
`if(!btn){R.error='글쓰기 버튼 없음';return JSON.stringify(R);}`,
`btn.click();await w(2500);`,
`R.url=location.pathname+location.search;`,
// Fill title
`const titleInput=document.querySelector('input[placeholder*="제목"]')||document.querySelector('input[type="text"]');`,
`if(!titleInput){R.error='제목 입력란 없음';return JSON.stringify(R);}`,
`sv(titleInput,testTitle);await w(200);`,
// Fill content
`const contentArea=document.querySelector('textarea[placeholder*="내용"]')||document.querySelector('textarea');`,
`if(contentArea){sv(contentArea,'E2E 연속 등록 테스트 #${n}. 자동 삭제 예정.');await w(200);}`,
// Click 등록
`const submitBtn=Array.from(document.querySelectorAll('button')).find(b=>b.innerText?.trim()==='등록');`,
`if(!submitBtn){R.error='등록 버튼 없음';return JSON.stringify(R);}`,
`submitBtn.click();await w(5000);`,
`R.urlAfter=location.pathname+location.search;`,
`const valErrs=Array.from(document.querySelectorAll('[class*="error"],[class*="invalid"],.text-red-500,.text-destructive')).filter(e=>e.offsetParent!==null&&e.innerText?.trim()).map(e=>e.innerText.trim()).slice(0,5);`,
`if(valErrs.length)R.validationErrors=valErrs;`,
`R.ok=!R.urlAfter.includes('mode=new')&&!location.pathname.endsWith('/new');if(!R.ok)R.error='등록 실패 (url='+R.urlAfter+') errors='+JSON.stringify(valErrs);`,
`return JSON.stringify(R);`,
`})()`,
].join('');
},
// VERIFY: E2E_BATCH_ 데이터 개수 확인
makeVerifyScript(expectedCount) {
const useFallback = expectedCount > 0;
return [
`(async()=>{`, H,
`const R={phase:'VERIFY_BATCH',expected:${expectedCount}};`,
`await w(1000);`,
`R.url=location.pathname;`,
`const rows=Array.from(document.querySelectorAll('table tbody tr'));`,
`R.rowCount=rows.length;`,
`let batchRows=rows.filter(r=>r.innerText?.includes('E2E_BATCH_')&&r.innerText?.includes(ts));`,
...(useFallback ? [`if(!batchRows.length)batchRows=rows.filter(r=>r.innerText?.includes('E2E_BATCH_'));`] : []),
`R.batchCount=batchRows.length;`,
`R.batchTexts=batchRows.map(r=>r.innerText?.substring(0,60));`,
`R.countMatch=${expectedCount}===0?R.batchCount===0:R.batchCount>=${expectedCount};`,
`if(!R.countMatch){R.row0=rows[0]?.innerText?.substring(0,80);R.bodyHas=document.body.innerText.includes('E2E_BATCH_');R.warn='기대 ${expectedCount}건, 실제 '+R.batchCount+'건 rows='+R.rowCount+' body='+R.bodyHas+' row0=['+R.row0+']';}`,
`R.ok=R.countMatch;`,
`return JSON.stringify(R);`,
`})()`,
].join('');
},
// DELETE: E2E_BATCH_ 데이터 1건 삭제 (첫 번째 발견된 것)
makeDeleteScript(n) {
return [
`(async()=>{`, H,
`const R={phase:'DELETE_${n}'};`,
`const rows=Array.from(document.querySelectorAll('table tbody tr'));`,
`const targetRow=rows.find(r=>r.innerText?.includes('E2E_BATCH_')&&r.innerText?.includes(ts))||rows.find(r=>r.innerText?.includes('E2E_BATCH_'));`,
`if(!targetRow){R.error='E2E_BATCH_+ts 데이터 없음 (ts='+ts+')';R.ok=false;return JSON.stringify(R);}`,
`R.targetText=targetRow.innerText?.substring(0,60);`,
`targetRow.click();await w(2500);`,
`R.detailUrl=location.pathname+location.search;`,
`const delBtn=Array.from(document.querySelectorAll('button')).find(b=>b.innerText?.trim()==='삭제');`,
`if(!delBtn){R.error='삭제 버튼 없음';R.ok=false;return JSON.stringify(R);}`,
`delBtn.click();await w(1000);`,
`const confirmBtn=Array.from(document.querySelectorAll('[role="alertdialog"] button,[role="dialog"] button,button')).find(b=>/확인|삭제|예|Yes/.test(b.innerText?.trim())&&b!==delBtn&&b.offsetParent!==null);`,
`if(confirmBtn){confirmBtn.click();await w(3000);}`,
`R.urlAfter=location.pathname+location.search;`,
`R.ok=true;`,
`return JSON.stringify(R);`,
`})()`,
].join('');
},
},
// ─── 어음관리 ───────────────────────────────────────────────
'bills': {
id: 'batch-create-acc-bills',
name: '연속 등록 테스트: 어음관리',
menuNavigation: { level1: '회계관리', level2: '어음관리' },
batchCount: 3,
makeCreateScript(n) {
return [
`(async()=>{`, H,
`const R={phase:'CREATE_${n}',ts,n:${n}};`,
`const testId='EB${n}'+ts.replace(/_/g,'').substring(4,10);`,
// Click 등록
`const btn=Array.from(document.querySelectorAll('button')).find(b=>/어음.*등록|등록/.test(b.innerText?.trim()));`,
`if(!btn){R.error='등록 버튼 없음';return JSON.stringify(R);}`,
`btn.click();await w(2500);`,
`R.url=location.pathname+location.search;`,
`const formArea=document.querySelector('main')||document.querySelector('[class*="content"]')||document.body;`,
// ── PHASE 1: Comboboxes FIRST (triggers React re-renders) ──
`const combos=Array.from(formArea.querySelectorAll('button[role="combobox"]')).filter(b=>b.offsetParent!==null&&!b.closest('nav,[class*=sidebar],[class*=Sidebar]'));`,
`R.comboCount=combos.length;`,
`for(let i=0;i<combos.length;i++){`,
` document.body.click();await w(200);`,
` combos[i].scrollIntoView({block:'center'});`,
` combos[i].click();await w(700);`,
` const lb=document.querySelector('[role="listbox"]');`,
` if(lb){const opt=lb.querySelector('[role="option"]');if(opt){opt.click();await w(500);}}`,
` else{document.dispatchEvent(new KeyboardEvent('keydown',{key:'Escape',bubbles:true}));await w(200);}`,
`}`,
`await w(300);`,
// ── PHASE 2: Date pickers SECOND (also triggers re-renders) ──
`const dateButtons=Array.from(formArea.querySelectorAll('button')).filter(b=>b.innerText?.trim()==='날짜 선택'&&b.offsetParent!==null);`,
`for(const db of dateButtons){`,
` db.scrollIntoView({block:'center'});await w(200);`,
` db.click();await w(600);`,
` if(!document.querySelector('table[class*="rdp"],.rdp-month,[role="grid"]')){db.click();await w(800);}`,
` const today=document.querySelector('[aria-selected="true"]')||document.querySelector('button[name="day"].bg-primary')||document.querySelector('.rdp-day_today button')||Array.from(document.querySelectorAll('button[name="day"],td[role="gridcell"] button,.rdp-day button')).find(b=>b.getAttribute('aria-selected')==='true'||b.classList.contains('bg-primary')||b.tabIndex===0)||document.querySelector('button[name="day"]')||document.querySelector('td[role="gridcell"] button');`,
` if(today){today.click();await w(400);}`,
` else{document.dispatchEvent(new KeyboardEvent('keydown',{key:'Escape',bubbles:true}));await w(300);}`,
`}`,
`await w(300);`,
// ── PHASE 3: Text inputs LAST (after React re-renders settle) ──
`const numInput=document.querySelector('input[placeholder*="어음번호"]')||Array.from(formArea.querySelectorAll('input[type="text"]')).find(i=>i.offsetParent!==null&&!i.readOnly&&!i.disabled);`,
`if(numInput){sv(numInput,'E2E_TEST_'+testId);await w(200);}`,
`R.numFound=!!numInput;`,
`const amtInput=document.querySelector('input[placeholder*="금액"]');`,
`if(amtInput){sv(amtInput,'${(n + 1) * 10000}');await w(200);}`,
`const noteInput=document.querySelector('input[placeholder*="비고"]')||document.querySelector('textarea[placeholder*="비고"]');`,
`if(noteInput){sv(noteInput,'E2E_TEST_어음_'+ts);await w(200);}`,
`R.noteFound=!!noteInput;`,
// ── PHASE 4: Re-verify key field before submit ──
`if(noteInput&&!noteInput.value?.includes('E2E_TEST_')){sv(noteInput,'E2E_TEST_어음_'+ts);await w(200);R.refilled=true;}`,
// ── PHASE 5: Submit ──
`const submitBtn=Array.from(document.querySelectorAll('button')).find(b=>/^등록$|^저장$/.test(b.innerText?.trim())&&b.offsetParent!==null);`,
`if(!submitBtn){R.error='등록 버튼 없음';return JSON.stringify(R);}`,
`submitBtn.click();await w(3000);`,
`R.urlAfter=location.pathname+location.search;`,
`const valErrs=Array.from((formArea||document.body).querySelectorAll('[class*="error"],[class*="invalid"],.text-red-500,.text-destructive')).filter(e=>e.offsetParent!==null&&e.innerText?.trim()&&!e.closest('nav,[class*=sidebar],[class*=Sidebar]')).map(e=>e.innerText.trim()).slice(0,3);`,
`if(valErrs.length)R.valErrs=valErrs;`,
`R.ok=!R.urlAfter.includes('mode=new')&&!location.pathname.endsWith('/new');`,
`if(!R.ok)R.error='등록실패 url='+R.urlAfter+' errs='+JSON.stringify(valErrs);`,
`return JSON.stringify(R);`,
`})()`,
].join('');
},
makeVerifyScript(expectedCount) {
return [
`(async()=>{`, H,
`const R={phase:'VERIFY_BATCH',expected:${expectedCount},ts};`,
`await w(1000);`,
`R.url=location.pathname;`,
`const rows=Array.from(document.querySelectorAll('table tbody tr'));`,
`R.rowCount=rows.length;`,
// Search with ts first, fallback to any E2E_TEST_어음_ if expectedCount > 0
`let batchRows=rows.filter(r=>r.innerText?.includes('E2E_TEST_어음_')&&r.innerText?.includes(ts));`,
`if(!batchRows.length&&${expectedCount}>0){batchRows=rows.filter(r=>r.innerText?.includes('E2E_TEST_어음_'));R.usedFallback=true;}`,
`R.batchCount=batchRows.length;`,
`R.countMatch=${expectedCount}===0?R.batchCount===0:R.batchCount>=${expectedCount};`,
`if(!R.countMatch){R.row0=rows[0]?.innerText?.substring(0,80);R.bodyHas=document.body.innerText.includes('E2E_TEST_어음_');R.warn='기대 ${expectedCount}건, 실제 '+R.batchCount+'건 rows='+R.rowCount+' body='+R.bodyHas+' row0=['+R.row0+']';}`,
`R.ok=R.countMatch;`,
`return JSON.stringify(R);`,
`})()`,
].join('');
},
makeDeleteScript(n) {
return [
`(async()=>{`, H,
`const R={phase:'DELETE_${n}'};`,
`const rows=Array.from(document.querySelectorAll('table tbody tr'));`,
// Search with ts, fallback to any E2E_TEST_어음_
`const targetRow=rows.find(r=>r.innerText?.includes('E2E_TEST_어음_')&&r.innerText?.includes(ts))||rows.find(r=>r.innerText?.includes('E2E_TEST_어음_'));`,
`if(!targetRow){R.error='E2E_TEST_어음_ 데이터 없음 (ts='+ts+')';R.ok=false;return JSON.stringify(R);}`,
`R.targetText=targetRow.innerText?.substring(0,60);`,
`targetRow.click();await w(2500);`,
`const delBtn=Array.from(document.querySelectorAll('button')).find(b=>b.innerText?.trim()==='삭제');`,
`if(!delBtn){R.error='삭제 버튼 없음';R.ok=false;return JSON.stringify(R);}`,
`delBtn.click();await w(1000);`,
`const confirmBtn=Array.from(document.querySelectorAll('[role="alertdialog"] button,[role="dialog"] button,button')).find(b=>/확인|삭제|예|Yes/.test(b.innerText?.trim())&&b!==delBtn&&b.offsetParent!==null);`,
`if(confirmBtn){confirmBtn.click();await w(3000);}`,
`R.urlAfter=location.pathname+location.search;`,
`R.ok=true;`,
`return JSON.stringify(R);`,
`})()`,
].join('');
},
},
// ─── 입금관리 ───────────────────────────────────────────────
'deposit': {
id: 'batch-create-acc-deposit',
name: '연속 등록 테스트: 입금관리',
menuNavigation: { level1: '회계관리', level2: '입금관리' },
batchCount: 3,
makeCreateScript(n) {
return [
`(async()=>{`, H,
`const R={phase:'CREATE_${n}',ts,n:${n}};`,
// Click 등록
`const btn=Array.from(document.querySelectorAll('button')).find(b=>/입금.*등록|입금등록|등록/.test(b.innerText?.trim()));`,
`if(!btn){R.error='등록 버튼 없음';return JSON.stringify(R);}`,
`btn.click();await w(2500);`,
`R.url=location.pathname+location.search;`,
`const formArea=document.querySelector('main')||document.querySelector('[class*="content"]')||document.body;`,
// ── PHASE 1: Comboboxes FIRST (triggers React re-renders) ──
`const combos=Array.from(formArea.querySelectorAll('button[role="combobox"]')).filter(b=>b.offsetParent!==null&&!b.closest('nav,[class*=sidebar],[class*=Sidebar]'));`,
`R.comboCount=combos.length;`,
`for(let i=0;i<combos.length;i++){`,
` document.body.click();await w(200);`,
` combos[i].scrollIntoView({block:'center'});`,
` combos[i].click();await w(700);`,
` const lb=document.querySelector('[role="listbox"]');`,
` if(lb){const opt=lb.querySelector('[role="option"]');if(opt){opt.click();await w(500);}}`,
` else{document.dispatchEvent(new KeyboardEvent('keydown',{key:'Escape',bubbles:true}));await w(200);}`,
`}`,
`await w(500);`,
// ── PHASE 2: Date pickers SECOND (also triggers re-renders) ──
`const dateButtons=Array.from(formArea.querySelectorAll('button')).filter(b=>b.innerText?.trim()==='날짜 선택'&&b.offsetParent!==null);`,
`R.dateButtonCount=dateButtons.length;`,
`for(const db of dateButtons){`,
` db.scrollIntoView({block:'center'});await w(200);`,
` db.click();await w(600);`,
` if(!document.querySelector('table[class*="rdp"],.rdp-month,[role="grid"]')){db.click();await w(800);}`,
` const today=document.querySelector('[aria-selected="true"]')||document.querySelector('button[name="day"].bg-primary')||document.querySelector('.rdp-day_today button')||Array.from(document.querySelectorAll('button[name="day"],td[role="gridcell"] button,.rdp-day button')).find(b=>b.getAttribute('aria-selected')==='true'||b.classList.contains('bg-primary')||b.tabIndex===0)||document.querySelector('button[name="day"]')||document.querySelector('td[role="gridcell"] button');`,
` if(today){today.click();await w(400);}`,
` else{document.dispatchEvent(new KeyboardEvent('keydown',{key:'Escape',bubbles:true}));await w(300);}`,
`}`,
`await w(300);`,
// ── PHASE 3: Text inputs LAST (after React re-renders settle) ──
`const nameInput=document.querySelector('input[placeholder*="입금자명"]')||document.querySelector('input[placeholder*="입금자"]');`,
`if(nameInput){sv(nameInput,'E2E_TEST_입금자_'+ts);await w(200);}`,
`R.nameFound=!!nameInput;`,
`const amtInput=document.querySelector('input[placeholder*="입금금액"]')||document.querySelector('input[type="number"]');`,
`if(amtInput){sv(amtInput,'${(n + 1) * 50000}');await w(200);}`,
`const noteInput=document.querySelector('input[placeholder*="적요"]')||document.querySelector('textarea[placeholder*="적요"]');`,
`if(noteInput){sv(noteInput,'E2E_TEST_입금_'+ts);await w(200);}`,
`R.noteFound=!!noteInput;`,
// ── PHASE 4: Re-verify key fields before submit ──
`if(nameInput&&!nameInput.value?.includes('E2E_TEST_')){sv(nameInput,'E2E_TEST_입금자_'+ts);await w(200);R.refilledName=true;}`,
`if(noteInput&&!noteInput.value?.includes('E2E_TEST_')){sv(noteInput,'E2E_TEST_입금_'+ts);await w(200);R.refilledNote=true;}`,
// ── PHASE 5: Submit ──
`const submitBtn=Array.from(document.querySelectorAll('button')).find(b=>/^등록$|^저장$/.test(b.innerText?.trim())&&b.offsetParent!==null);`,
`if(!submitBtn){R.error='등록 버튼 없음';return JSON.stringify(R);}`,
`submitBtn.click();await w(3000);`,
`R.urlAfter=location.pathname+location.search;`,
`const valErrs=Array.from((formArea||document.body).querySelectorAll('[class*="error"],[class*="invalid"],.text-red-500,.text-destructive')).filter(e=>e.offsetParent!==null&&e.innerText?.trim()&&!e.closest('nav,[class*=sidebar],[class*=Sidebar]')).map(e=>e.innerText.trim()).slice(0,3);`,
`if(valErrs.length)R.valErrs=valErrs;`,
`R.ok=!R.urlAfter.includes('mode=new')&&!location.pathname.endsWith('/new');`,
`if(!R.ok)R.error='등록실패 url='+R.urlAfter+' errs='+JSON.stringify(valErrs);`,
`return JSON.stringify(R);`,
`})()`,
].join('');
},
makeVerifyScript(expectedCount) {
return [
`(async()=>{`, H,
`const R={phase:'VERIFY_BATCH',expected:${expectedCount},ts};`,
`await w(1000);`,
`R.url=location.pathname;`,
`const rows=Array.from(document.querySelectorAll('table tbody tr'));`,
`R.rowCount=rows.length;`,
// Search with ts first, fallback to any E2E_TEST_ if expectedCount > 0
`let batchRows=rows.filter(r=>r.innerText?.includes('E2E_TEST_')&&r.innerText?.includes(ts));`,
`if(!batchRows.length&&${expectedCount}>0){batchRows=rows.filter(r=>r.innerText?.includes('E2E_TEST_입금'));R.usedFallback=true;}`,
`R.batchCount=batchRows.length;`,
`R.countMatch=${expectedCount}===0?R.batchCount===0:R.batchCount>=${expectedCount};`,
`if(!R.countMatch){R.row0=rows[0]?.innerText?.substring(0,80);R.bodyHas=document.body.innerText.includes('E2E_TEST_');R.warn='기대 ${expectedCount}건, 실제 '+R.batchCount+'건 rows='+R.rowCount+' body='+R.bodyHas+' row0=['+R.row0+']';}`,
`R.ok=R.countMatch;`,
`return JSON.stringify(R);`,
`})()`,
].join('');
},
makeDeleteScript(n) {
return [
`(async()=>{`, H,
`const R={phase:'DELETE_${n}'};`,
`const rows=Array.from(document.querySelectorAll('table tbody tr'));`,
// Search with ts, fallback to any E2E_TEST_
`const targetRow=rows.find(r=>r.innerText?.includes('E2E_TEST_')&&r.innerText?.includes(ts))||rows.find(r=>r.innerText?.includes('E2E_TEST_입금'));`,
`if(!targetRow){R.error='E2E_TEST_ 데이터 없음 (ts='+ts+')';R.ok=false;return JSON.stringify(R);}`,
`R.targetText=targetRow.innerText?.substring(0,60);`,
`targetRow.click();await w(2500);`,
`const delBtn=Array.from(document.querySelectorAll('button')).find(b=>b.innerText?.trim()==='삭제');`,
`if(!delBtn){R.error='삭제 버튼 없음';R.ok=false;return JSON.stringify(R);}`,
`delBtn.click();await w(1000);`,
`const confirmBtn=Array.from(document.querySelectorAll('[role="alertdialog"] button,[role="dialog"] button,button')).find(b=>/확인|삭제|예|Yes/.test(b.innerText?.trim())&&b!==delBtn&&b.offsetParent!==null);`,
`if(confirmBtn){confirmBtn.click();await w(3000);}`,
`R.urlAfter=location.pathname+location.search;`,
`R.ok=true;`,
`return JSON.stringify(R);`,
`})()`,
].join('');
},
},
};
// ════════════════════════════════════════════════════════════════
// SCENARIO GENERATOR
// ════════════════════════════════════════════════════════════════
function generateScenario(pageKey) {
const page = PAGES[pageKey];
const steps = [];
let id = 1;
const p = page.menuNavigation;
const pfx = `[${p.level1} > ${p.level2}]`;
const N = page.batchCount;
// ─── SETUP ───
steps.push({ id: id++, name: `${pfx} 페이지 로드 대기`, action: 'wait', timeout: 3000 });
// Clear stale ts from previous scenario (prevents cross-scenario data contamination)
steps.push({
id: id++, name: `${pfx} ts 초기화`,
action: 'evaluate',
script: `(()=>{try{sessionStorage.removeItem('__E2E_TS__');}catch(e){}delete window.__E2E_TS__;return JSON.stringify({ok:true,cleared:true});})()`,
timeout: 3000,
});
steps.push({ id: id++, name: `${pfx} 테이블 로드 대기`, action: 'wait_for_table', timeout: 5000 });
// ─── CREATE × N ───
for (let n = 1; n <= N; n++) {
steps.push({
id: id++, name: `${pfx} [CREATE #${n}] 데이터 생성`,
action: 'evaluate', script: page.makeCreateScript(n), timeout: 30000, phase: 'CREATE',
});
steps.push({ id: id++, name: `${pfx} [CREATE #${n}] 생성 후 대기`, action: 'wait', timeout: 2000 });
// 목록 복귀
steps.push({
id: id++, name: `${pfx} [CREATE #${n}] 목록 복귀`,
action: 'evaluate', script: BACK_TO_LIST, timeout: 10000, phase: 'CREATE',
});
steps.push({ id: id++, name: `${pfx} [CREATE #${n}] 목록 안정화`, action: 'wait', timeout: 1500 });
}
// ─── VERIFY ALL N EXIST ───
steps.push({ id: id++, name: `${pfx} [VERIFY] 목록 새로고침`, action: 'reload' });
steps.push({ id: id++, name: `${pfx} [VERIFY] 테이블 로드 대기`, action: 'wait_for_table', timeout: 10000 });
steps.push({
id: id++, name: `${pfx} [VERIFY] ${N}건 생성 확인`,
action: 'evaluate', script: page.makeVerifyScript(N), timeout: 15000, phase: 'VERIFY',
});
// ─── DELETE × N ───
for (let n = 1; n <= N; n++) {
steps.push({
id: id++, name: `${pfx} [DELETE #${n}] 데이터 삭제`,
action: 'evaluate', script: page.makeDeleteScript(n), timeout: 30000, phase: 'DELETE', critical: true,
});
steps.push({ id: id++, name: `${pfx} [DELETE #${n}] 삭제 후 대기`, action: 'wait', timeout: 2000 });
// 목록 복귀
steps.push({
id: id++, name: `${pfx} [DELETE #${n}] 목록 복귀`,
action: 'evaluate', script: BACK_TO_LIST, timeout: 10000, phase: 'DELETE',
});
steps.push({ id: id++, name: `${pfx} [DELETE #${n}] 목록 안정화`, action: 'wait', timeout: 1500 });
}
// ─── VERIFY ALL DELETED ───
steps.push({ id: id++, name: `${pfx} [VERIFY] 목록 새로고침`, action: 'reload' });
steps.push({ id: id++, name: `${pfx} [VERIFY] 테이블 로드 대기`, action: 'wait_for_table', timeout: 10000 });
steps.push({
id: id++, name: `${pfx} [VERIFY] 전체 삭제 확인`,
action: 'evaluate', script: page.makeVerifyScript(0), timeout: 15000, phase: 'VERIFY',
});
return {
id: page.id,
name: page.name,
version: '1.0.0',
auth: { role: 'admin' },
menuNavigation: page.menuNavigation,
screenshotPolicy: { captureOnFail: true, captureOnPass: false },
steps,
};
}
// ════════════════════════════════════════════════════════════════
// MAIN
// ════════════════════════════════════════════════════════════════
function main() {
if (!fs.existsSync(SCENARIOS_DIR)) {
fs.mkdirSync(SCENARIOS_DIR, { recursive: true });
}
const generated = [];
for (const key of Object.keys(PAGES)) {
const scenario = generateScenario(key);
const filePath = path.join(SCENARIOS_DIR, `${scenario.id}.json`);
fs.writeFileSync(filePath, JSON.stringify(scenario, null, 2), 'utf-8');
generated.push({ id: scenario.id, steps: scenario.steps.length });
console.log(` ${scenario.id}.json (${scenario.steps.length} steps)`);
}
console.log(`\n Generated ${generated.length} scenarios`);
console.log(`\n Run: node e2e/runner/run-all.js --filter batch-create`);
}
main();

View File

@@ -0,0 +1,367 @@
#!/usr/bin/env node
/**
* Business Workflow E2E Scenario Generator (Phase 3 Wave 1)
*
* Generates multi-module workflow scenarios that test real business journeys
* spanning multiple modules. Each workflow chains operations across modules
* using window.__WORKFLOW_CTX__ for data passing.
*
* Usage: node e2e/runner/gen-business-workflow.js
* Output: e2e/scenarios/workflow-*.json (5 scenarios)
*/
const fs = require('fs');
const path = require('path');
const SCENARIOS_DIR = path.resolve(__dirname, '..', 'scenarios');
const H = `const w=ms=>new Promise(r=>setTimeout(r,ms));`;
// ════════════════════════════════════════════════════════════════
// Shared script builders
// ════════════════════════════════════════════════════════════════
function captureFirstRowCell(phase, varName, cellIndex = 1, fallbackCells = [2, 3]) {
return [
`(async()=>{`, H,
`const R={phase:'${phase}'};`,
`await w(1500);`,
`const rows=Array.from(document.querySelectorAll('table tbody tr')).filter(r=>r.offsetParent!==null);`,
`R.rowCount=rows.length;`,
`if(rows.length===0){R.warn='테이블에 데이터 없음';R.ok=true;return JSON.stringify(R);}`,
`const cells=rows[0].querySelectorAll('td');`,
`let val='';`,
`const indices=[${cellIndex},${fallbackCells.join(',')}];`,
`for(const i of indices){`,
` const t=cells[i]?.innerText?.trim();`,
` if(t&&t.length>=2&&t.length<=40&&!/^[\\d,.]+$/.test(t)&&!/^\\d{4}[-/]/.test(t)){val=t;break;}`,
`}`,
`R.${varName}=val;`,
`if(!val){R.warn='${varName} 추출 실패';R.ok=true;return JSON.stringify(R);}`,
`if(!window.__WORKFLOW_CTX__)window.__WORKFLOW_CTX__={};`,
`window.__WORKFLOW_CTX__.${varName}=val;`,
`R.ok=true;R.info='캐처: '+val;`,
`return JSON.stringify(R);`,
`})()`,
].join('');
}
function verifyInModule(phase, varName, moduleName) {
return [
`(async()=>{`, H,
`const R={phase:'${phase}'};`,
`await w(2000);`,
`const val=window.__WORKFLOW_CTX__?.${varName};`,
`if(!val){R.warn='컨텍스트에 ${varName} 없음';R.ok=true;return JSON.stringify(R);}`,
`R.searchTarget=val;`,
`const si=document.querySelector('input[placeholder*="검색"]')||document.querySelector('input[type="search"]');`,
`if(si){`,
` si.focus();await w(200);`,
` const ns=Object.getOwnPropertyDescriptor(HTMLInputElement.prototype,'value')?.set;`,
` if(ns)ns.call(si,val);else si.value=val;`,
` si.dispatchEvent(new Event('input',{bubbles:true}));`,
` si.dispatchEvent(new Event('change',{bubbles:true}));`,
` await w(2500);`,
`}`,
`const found=document.body.innerText.includes(val);`,
`R.found=found;`,
`if(found){R.info='✅ ${moduleName}에서 ['+val+'] 확인';R.ok=true;}`,
`else{R.warn='⚠️ ${moduleName}에서 ['+val+'] 미발견';R.ok=true;}`,
// Clear search
`if(si){`,
` const ns2=Object.getOwnPropertyDescriptor(HTMLInputElement.prototype,'value')?.set;`,
` if(ns2)ns2.call(si,'');else si.value='';`,
` si.dispatchEvent(new Event('input',{bubbles:true}));await w(1000);`,
`}`,
`return JSON.stringify(R);`,
`})()`,
].join('');
}
function countTableRows(phase) {
return [
`(async()=>{`, H,
`const R={phase:'${phase}'};`,
`await w(1500);`,
`const rows=Array.from(document.querySelectorAll('table tbody tr')).filter(r=>r.offsetParent!==null);`,
`R.rowCount=rows.length;`,
`R.ok=true;`,
`R.info='테이블 행: '+rows.length;`,
`return JSON.stringify(R);`,
`})()`,
].join('');
}
// ════════════════════════════════════════════════════════════════
// Workflow Definitions
// ════════════════════════════════════════════════════════════════
const WORKFLOWS = [
// ── 1. Purchase-to-Payment ──
{
id: 'workflow-purchase-to-payment',
name: '비즈니스 워크플로우: 구매→매입 흐름',
startMenu: { level1: '판매관리', level2: '거래처관리' },
modules: [
{
label: '판매 > 거래처관리',
menu: null, // start page
steps: [
{ action: 'wait', timeout: 3000 },
{ action: 'wait_for_table', timeout: 5000 },
{ action: 'evaluate', script: captureFirstRowCell('CAPTURE_VENDOR', 'vendorName'), phase: 'CAPTURE_VENDOR' },
],
},
{
label: '회계 > 거래처관리',
menu: { level1: '회계관리', level2: '거래처관리' },
steps: [
{ action: 'wait', timeout: 3000 },
{ action: 'wait_for_table', timeout: 5000 },
{ action: 'evaluate', script: verifyInModule('VERIFY_VENDOR_ACC', 'vendorName', '회계>거래처관리'), phase: 'VERIFY_VENDOR_ACC' },
],
},
],
},
// ── 2. Employee Onboarding ──
{
id: 'workflow-employee-onboarding',
name: '비즈니스 워크플로우: 사원등록→부서→근태→급여 흐름',
startMenu: { level1: '인사관리', level2: '사원관리' },
modules: [
{
label: '인사 > 사원관리',
menu: null, // start page
steps: [
{ action: 'wait', timeout: 3000 },
{ action: 'wait_for_table', timeout: 5000 },
{ action: 'evaluate', script: captureFirstRowCell('CAPTURE_EMPLOYEE', 'employeeName'), phase: 'CAPTURE_EMPLOYEE' },
],
},
{
label: '인사 > 부서관리',
menu: { level1: '인사관리', level2: '부서관리' },
steps: [
{ action: 'wait', timeout: 3000 },
{ action: 'wait_for_table', timeout: 5000 },
{ action: 'evaluate', script: countTableRows('CHECK_DEPARTMENTS'), phase: 'CHECK_DEPARTMENTS' },
],
},
{
label: '인사 > 근태관리',
menu: { level1: '인사관리', level2: '근태관리' },
steps: [
{ action: 'wait', timeout: 3000 },
{ action: 'evaluate', script: verifyInModule('VERIFY_EMPLOYEE_ATTEND', 'employeeName', '근태관리'), phase: 'VERIFY_EMPLOYEE_ATTEND' },
],
},
{
label: '인사 > 급여관리',
menu: { level1: '인사관리', level2: '급여관리' },
steps: [
{ action: 'wait', timeout: 3000 },
{ action: 'wait_for_table', timeout: 5000 },
{ action: 'evaluate', script: verifyInModule('VERIFY_EMPLOYEE_SALARY', 'employeeName', '급여관리'), phase: 'VERIFY_EMPLOYEE_SALARY' },
],
},
],
},
// ── 3. Sales Order Lifecycle ──
{
id: 'workflow-sales-lifecycle',
name: '비즈니스 워크플로우: 거래처→단가→수주→매출 흐름',
startMenu: { level1: '판매관리', level2: '거래처관리' },
modules: [
{
label: '판매 > 거래처관리',
menu: null, // start page
steps: [
{ action: 'wait', timeout: 3000 },
{ action: 'wait_for_table', timeout: 5000 },
{ action: 'evaluate', script: captureFirstRowCell('CAPTURE_CLIENT', 'clientName'), phase: 'CAPTURE_CLIENT' },
],
},
{
label: '판매 > 단가관리',
menu: { level1: '판매관리', level2: '단가관리' },
steps: [
{ action: 'wait', timeout: 3000 },
{ action: 'wait_for_table', timeout: 5000 },
{ action: 'evaluate', script: captureFirstRowCell('CAPTURE_PRICE_ITEM', 'itemName', 1, [2, 3]), phase: 'CAPTURE_PRICE_ITEM' },
],
},
{
label: '판매 > 수주관리',
menu: { level1: '판매관리', level2: '수주관리' },
steps: [
{ action: 'wait', timeout: 3000 },
{ action: 'wait_for_table', timeout: 5000 },
{ action: 'evaluate', script: countTableRows('CHECK_ORDERS'), phase: 'CHECK_ORDERS' },
],
},
{
label: '회계 > 매출관리',
menu: { level1: '회계관리', level2: '매출관리' },
steps: [
{ action: 'wait', timeout: 3000 },
{ action: 'wait_for_table', timeout: 5000 },
{ action: 'evaluate', script: countTableRows('CHECK_SALES'), phase: 'CHECK_SALES' },
],
},
],
},
// ── 4. Board Approval Flow ──
{
id: 'workflow-board-approval',
name: '비즈니스 워크플로우: 게시판→결재기안→결재함 흐름',
startMenu: { level1: '게시판', level2: '자유게시판' },
modules: [
{
label: '게시판 > 자유게시판',
menu: null, // start page
steps: [
{ action: 'wait', timeout: 3000 },
{ action: 'wait_for_table', timeout: 5000 },
{ action: 'evaluate', script: captureFirstRowCell('CAPTURE_POST', 'postTitle'), phase: 'CAPTURE_POST' },
],
},
{
label: '결재관리 > 기안함',
menu: { level1: '결재관리', level2: '기안함' },
steps: [
{ action: 'wait', timeout: 3000 },
{ action: 'wait_for_table', timeout: 5000 },
{ action: 'evaluate', script: countTableRows('CHECK_DRAFTS'), phase: 'CHECK_DRAFTS' },
],
},
{
label: '결재관리 > 결재함',
menu: { level1: '결재관리', level2: '결재함' },
steps: [
{ action: 'wait', timeout: 3000 },
{ action: 'wait_for_table', timeout: 5000 },
{ action: 'evaluate', script: countTableRows('CHECK_APPROVALS'), phase: 'CHECK_APPROVALS' },
],
},
{
label: '결재관리 > 참조함',
menu: { level1: '결재관리', level2: '참조함' },
steps: [
{ action: 'wait', timeout: 3000 },
{ action: 'wait_for_table', timeout: 5000 },
{ action: 'evaluate', script: countTableRows('CHECK_REFERENCES'), phase: 'CHECK_REFERENCES' },
],
},
],
},
// ── 5. Inventory Cycle ──
{
id: 'workflow-inventory-cycle',
name: '비즈니스 워크플로우: 품목→입고→재고→출고 흐름',
startMenu: { level1: '생산관리', level2: '품목관리' },
modules: [
{
label: '생산 > 품목관리',
menu: null, // start page
steps: [
{ action: 'wait', timeout: 3000 },
{ action: 'wait_for_table', timeout: 10000 },
{ action: 'evaluate', script: captureFirstRowCell('CAPTURE_ITEM', 'itemName'), phase: 'CAPTURE_ITEM' },
],
},
{
label: '자재관리 > 입고관리',
menu: { level1: '자재관리', level2: '입고관리' },
steps: [
{ action: 'wait', timeout: 3000 },
{ action: 'wait_for_table', timeout: 5000 },
{ action: 'evaluate', script: verifyInModule('VERIFY_ITEM_RECEIVING', 'itemName', '입고관리'), phase: 'VERIFY_ITEM_RECEIVING' },
],
},
{
label: '자재관리 > 재고현황',
menu: { level1: '자재관리', level2: '재고현황' },
steps: [
{ action: 'wait', timeout: 3000 },
{ action: 'wait_for_table', timeout: 5000 },
{ action: 'evaluate', script: verifyInModule('VERIFY_ITEM_STOCK', 'itemName', '재고현황'), phase: 'VERIFY_ITEM_STOCK' },
],
},
{
label: '회계 > 출금관리',
menu: { level1: '회계관리', level2: '출금관리' },
steps: [
{ action: 'wait', timeout: 3000 },
{ action: 'wait_for_table', timeout: 5000 },
{ action: 'evaluate', script: countTableRows('CHECK_WITHDRAWAL'), phase: 'CHECK_WITHDRAWAL' },
],
},
],
},
];
// ════════════════════════════════════════════════════════════════
// Generate scenario JSON from workflow definition
// ════════════════════════════════════════════════════════════════
function generateScenario(workflow) {
const steps = [];
let id = 1;
for (const mod of workflow.modules) {
// Menu navigation if needed
if (mod.menu) {
steps.push({
id: id++,
name: `[${mod.label}] 메뉴 이동`,
action: 'menu_navigate',
level1: mod.menu.level1,
level2: mod.menu.level2,
timeout: 10000,
});
}
// Module steps
for (const step of mod.steps) {
steps.push({
id: id++,
name: `[${mod.label}] ${step.phase || step.action}`,
...step,
});
}
}
return {
id: workflow.id,
name: workflow.name,
version: '1.0.0',
category: 'workflow',
auth: { role: 'admin' },
menuNavigation: workflow.startMenu,
screenshotPolicy: { captureOnFail: true, captureOnPass: false },
steps,
};
}
function main() {
if (!fs.existsSync(SCENARIOS_DIR)) fs.mkdirSync(SCENARIOS_DIR, { recursive: true });
console.log('Generating Business Workflow scenarios..\n');
let total = 0;
for (const workflow of WORKFLOWS) {
const scenario = generateScenario(workflow);
const fp = path.join(SCENARIOS_DIR, `${scenario.id}.json`);
fs.writeFileSync(fp, JSON.stringify(scenario, null, 2), 'utf-8');
console.log(` ${scenario.id}.json (${scenario.steps.length} steps)`);
total++;
}
console.log(`\n Generated ${total} workflow scenarios`);
console.log(` Run: node e2e/runner/run-all.js --filter workflow`);
}
main();

View File

@@ -0,0 +1,455 @@
#!/usr/bin/env node
/**
* Create + Delete CRUD 테스트 시나리오 생성기
*
* 각 페이지에서 테스트 데이터를 생성(CREATE)하고 삭제(DELETE)하는 전체 흐름 테스트.
* E2E_TEST_ 접두사 데이터만 사용, 테스트 종료 시 반드시 삭제.
*
* Usage:
* node e2e/runner/gen-create-delete.js
*
* Output:
* e2e/scenarios/create-delete-board.json
* e2e/scenarios/create-delete-acc-bills.json
* e2e/scenarios/create-delete-acc-deposit.json
*/
const fs = require('fs');
const path = require('path');
const SCENARIOS_DIR = path.resolve(__dirname, '..', 'scenarios');
// ════════════════════════════════════════════════════════════════
// COMMON HELPERS (shared across all evaluate scripts)
// ════════════════════════════════════════════════════════════════
const HELPERS = `const w=ms=>new Promise(r=>setTimeout(r,ms));const sv=(el,v)=>{const p=el.tagName==='TEXTAREA'?HTMLTextAreaElement.prototype:HTMLInputElement.prototype;const ns=Object.getOwnPropertyDescriptor(p,'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}));};const ts=window.__E2E_TS__||(()=>{const n=new Date();const p=v=>v.toString().padStart(2,'0');return n.getFullYear()+p(n.getMonth()+1)+p(n.getDate())+'_'+p(n.getHours())+p(n.getMinutes())+p(n.getSeconds());})();window.__E2E_TS__=ts;`;
// ════════════════════════════════════════════════════════════════
// PAGE CONFIGURATIONS
// ════════════════════════════════════════════════════════════════
const PAGES = {
// ─── 자유게시판 ─────────────────────────────────────────────
'board': {
id: 'create-delete-board',
name: 'Create+Delete 테스트: 자유게시판',
menuNavigation: { level1: '게시판', level2: '자유게시판' },
expectedUrl: '/boards/free',
// CREATE: click 글쓰기 → fill title+content → click 등록
createScript: [
`(async()=>{`, HELPERS,
`const R={phase:'CREATE',ts};`,
`const testTitle='E2E_TEST_게시글_'+ts;`,
`R.testTitle=testTitle;`,
// Click 글쓰기 button
`const btn=Array.from(document.querySelectorAll('button')).find(b=>b.innerText?.trim()==='글쓰기'||/등록|작성/.test(b.innerText?.trim()));`,
`if(!btn){R.error='글쓰기 버튼 없음';return JSON.stringify(R);}`,
`btn.click();await w(2500);`,
`R.url=location.pathname+location.search;`,
// Fill title
`const titleInput=document.querySelector('input[placeholder*="제목"]')||document.querySelector('input[type="text"]');`,
`if(!titleInput){R.error='제목 입력란 없음';return JSON.stringify(R);}`,
`sv(titleInput,testTitle);await w(200);`,
// Fill content (textarea)
`const contentArea=document.querySelector('textarea[placeholder*="내용"]')||document.querySelector('textarea');`,
`if(contentArea){sv(contentArea,'E2E 자동 테스트 게시글입니다. 자동 삭제 예정.');await w(200);}`,
// Click 등록 submit button
`const submitBtn=Array.from(document.querySelectorAll('button')).find(b=>b.innerText?.trim()==='등록');`,
`if(!submitBtn){R.error='등록 버튼 없음';return JSON.stringify(R);}`,
`submitBtn.click();await w(3000);`,
// Check result
`R.urlAfter=location.pathname+location.search;`,
`R.navigatedBack=!location.search.includes('mode=new');`,
`R.ok=true;`,
`return JSON.stringify(R);`,
`})()`,
].join(''),
// VERIFY: search for the created post in the list
verifyScript: [
`(async()=>{`, HELPERS,
`const R={phase:'VERIFY_CREATE'};`,
`await w(1000);`,
// Check if we're on list page
`R.url=location.pathname;`,
`const rows=document.querySelectorAll('table tbody tr,[class*="list"] [class*="item"]');`,
`R.rowCount=rows.length;`,
// Search for E2E_TEST_ data
`const found=Array.from(rows).find(r=>r.innerText?.includes('E2E_TEST_'));`,
`R.found=!!found;`,
`if(found){R.foundText=found.innerText?.substring(0,80);}`,
`R.ok=R.found;`,
`return JSON.stringify(R);`,
`})()`,
].join(''),
// DELETE: find the E2E_TEST_ post → open → delete → confirm
deleteScript: [
`(async()=>{`, HELPERS,
`const R={phase:'DELETE'};`,
// Find and click the E2E_TEST_ row using specific ts
`const rows=Array.from(document.querySelectorAll('table tbody tr'));`,
`const targetRow=rows.find(r=>r.innerText?.includes(ts))||rows.find(r=>r.innerText?.includes('E2E_TEST_'));`,
`if(!targetRow){R.error='E2E_TEST_ 데이터 없음';R.ok=false;return JSON.stringify(R);}`,
`R.targetText=targetRow.innerText?.substring(0,60);R.ts=ts;`,
`targetRow.click();await w(2500);`,
`R.detailUrl=location.pathname+location.search;`,
// Find and click 삭제 button
`const delBtn=Array.from(document.querySelectorAll('button')).find(b=>b.innerText?.trim()==='삭제');`,
`if(!delBtn){R.error='삭제 버튼 없음';R.ok=false;return JSON.stringify(R);}`,
`delBtn.click();await w(1000);`,
// Confirm deletion dialog
`const confirmBtn=Array.from(document.querySelectorAll('[role="alertdialog"] button,[role="dialog"] button,button')).find(b=>/확인|삭제|예|Yes/.test(b.innerText?.trim())&&b!==delBtn&&b.offsetParent!==null);`,
`if(confirmBtn){confirmBtn.click();await w(3000);}`,
`R.urlAfter=location.pathname+location.search;`,
`R.ok=true;`,
`return JSON.stringify(R);`,
`})()`,
].join(''),
// VERIFY DELETE: check that E2E_TEST_ data is gone from list
verifyDeleteScript: [
`(async()=>{`, HELPERS,
`const R={phase:'VERIFY_DELETE'};`,
`await w(1000);`,
`R.url=location.pathname;R.ts=ts;`,
`const rows=document.querySelectorAll('table tbody tr');`,
`R.rowCount=rows.length;`,
`const found=Array.from(rows).find(r=>r.innerText?.includes(ts));`,
`R.stillExists=!!found;`,
`R.ok=!found;`,
`if(found)R.warn='E2E_TEST_ 데이터가 여전히 존재 - 수동 삭제 필요';`,
`return JSON.stringify(R);`,
`})()`,
].join(''),
},
// ─── 어음관리 ───────────────────────────────────────────────
'bills': {
id: 'create-delete-acc-bills',
name: 'Create+Delete 테스트: 어음관리',
menuNavigation: { level1: '회계관리', level2: '어음관리' },
expectedUrl: '/accounting/bills',
createScript: [
`(async()=>{`, HELPERS,
`const R={phase:'CREATE',ts};`,
`const testId='E2E'+ts.replace(/_/g,'').substring(4,10);`,
`R.testId=testId;`,
// Click 어음 등록
`const btn=Array.from(document.querySelectorAll('button')).find(b=>/어음.*등록|등록/.test(b.innerText?.trim()));`,
`if(!btn){R.error='등록 버튼 없음';return JSON.stringify(R);}`,
`btn.click();await w(2500);`,
`R.url=location.pathname+location.search;`,
// Fill 어음번호 (required)
`const numInput=document.querySelector('input[placeholder*="어음번호"]')||Array.from(document.querySelectorAll('input[type="text"]')).find(i=>i.offsetParent!==null&&!i.readOnly&&!i.disabled);`,
`if(numInput){sv(numInput,'E2E_TEST_'+testId);await w(200);}`,
// Select 구분 combobox (first one - default is 수취, leave as-is)
// Select 거래처 combobox (required)
`const combos=Array.from(document.querySelectorAll('button[role="combobox"]')).filter(b=>b.offsetParent!==null);`,
`R.comboCount=combos.length;`,
// 거래처 combobox - typically the second one
`for(let i=0;i<combos.length;i++){`,
` const cb=combos[i];`,
` const label=cb.closest('[class*=field],[class*=Field],[class*=form-item]')?.querySelector('label')?.innerText||'';`,
` if(label.includes('거래처')||i===1){`,
` cb.click();await w(600);`,
` const lb=document.querySelector('[role="listbox"]');`,
` if(lb){const opt=lb.querySelector('[role="option"]');if(opt){opt.click();await w(400);R.selectedClient=opt.innerText?.trim().substring(0,30);}}`,
` else{document.dispatchEvent(new KeyboardEvent('keydown',{key:'Escape',bubbles:true}));await w(200);}`,
` break;`,
` }`,
`}`,
// Fill 금액
`const amtInput=document.querySelector('input[placeholder*="금액"]');`,
`if(amtInput){sv(amtInput,'10000');await w(200);}`,
// 발행일/만기일 datepicker - click the date buttons and select today
`const dateButtons=Array.from(document.querySelectorAll('button')).filter(b=>b.innerText?.trim()==='날짜 선택'&&b.offsetParent!==null);`,
`R.dateCount=dateButtons.length;`,
`for(const db of dateButtons){`,
` db.click();await w(500);`,
` const today=document.querySelector('[aria-selected="true"]')||document.querySelector('button[name="day"].bg-primary')||Array.from(document.querySelectorAll('button[name="day"],td button')).find(b=>b.getAttribute('aria-selected')==='true'||b.classList.contains('bg-primary'));`,
` if(today){today.click();await w(300);}`,
` else{document.dispatchEvent(new KeyboardEvent('keydown',{key:'Escape',bubbles:true}));await w(300);}`,
`}`,
// Fill 비고
`const noteInput=document.querySelector('input[placeholder*="비고"]');`,
`if(noteInput){sv(noteInput,'E2E_TEST_어음_'+ts);await w(200);}`,
// Click 등록 submit
`const submitBtn=Array.from(document.querySelectorAll('button')).find(b=>b.innerText?.trim()==='등록'&&b.offsetParent!==null);`,
`if(!submitBtn){R.error='등록 버튼 없음';return JSON.stringify(R);}`,
`submitBtn.click();await w(3000);`,
`R.urlAfter=location.pathname+location.search;`,
`R.navigatedBack=!location.search.includes('mode=new');`,
`R.ok=true;`,
`return JSON.stringify(R);`,
`})()`,
].join(''),
verifyScript: [
`(async()=>{`, HELPERS,
`const R={phase:'VERIFY_CREATE'};`,
`await w(1000);`,
`R.url=location.pathname;`,
`const rows=document.querySelectorAll('table tbody tr');`,
`R.rowCount=rows.length;`,
`const found=Array.from(rows).find(r=>r.innerText?.includes('E2E_TEST_')||r.innerText?.includes('E2E'));`,
`R.found=!!found;`,
`if(found)R.foundText=found.innerText?.substring(0,80);`,
`R.ok=R.found;`,
`return JSON.stringify(R);`,
`})()`,
].join(''),
deleteScript: [
`(async()=>{`, HELPERS,
`const R={phase:'DELETE'};`,
`const rows=Array.from(document.querySelectorAll('table tbody tr'));`,
`const targetRow=rows.find(r=>r.innerText?.includes(ts))||rows.find(r=>r.innerText?.includes('E2E_TEST_')||r.innerText?.includes('E2E'));`,
`if(!targetRow){R.error='E2E_TEST_ 데이터 없음';R.ok=false;return JSON.stringify(R);}`,
`R.targetText=targetRow.innerText?.substring(0,60);`,
`targetRow.click();await w(2500);`,
`R.detailUrl=location.pathname+location.search;`,
`const delBtn=Array.from(document.querySelectorAll('button')).find(b=>b.innerText?.trim()==='삭제');`,
`if(!delBtn){R.error='삭제 버튼 없음';R.ok=false;return JSON.stringify(R);}`,
`delBtn.click();await w(1000);`,
`const confirmBtn=Array.from(document.querySelectorAll('[role="alertdialog"] button,[role="dialog"] button,button')).find(b=>/확인|삭제|예|Yes/.test(b.innerText?.trim())&&b!==delBtn&&b.offsetParent!==null);`,
`if(confirmBtn){confirmBtn.click();await w(3000);}`,
`R.urlAfter=location.pathname+location.search;`,
`R.ok=true;`,
`return JSON.stringify(R);`,
`})()`,
].join(''),
verifyDeleteScript: [
`(async()=>{`, HELPERS,
`const R={phase:'VERIFY_DELETE'};`,
`await w(1000);`,
`R.url=location.pathname;`,
// Navigate back to list if still on detail
`if(location.search.includes('mode=view')){`,
` const backBtn=Array.from(document.querySelectorAll('button,a')).find(b=>/목록/.test(b.innerText?.trim()));`,
` if(backBtn){backBtn.click();await w(2000);}`,
` else{history.back();await w(2000);}`,
`}`,
`const rows=document.querySelectorAll('table tbody tr');`,
`R.rowCount=rows.length;`,
`const found=Array.from(rows).find(r=>r.innerText?.includes(ts));`,
`R.stillExists=!!found;`,
`R.ok=!found;R.ts=ts;`,
`if(found)R.warn='E2E_TEST_ 데이터가 여전히 존재 - 수동 삭제 필요';`,
`return JSON.stringify(R);`,
`})()`,
].join(''),
},
// ─── 입금관리 ───────────────────────────────────────────────
'deposit': {
id: 'create-delete-acc-deposit',
name: 'Create+Delete 테스트: 입금관리',
menuNavigation: { level1: '회계관리', level2: '입금관리' },
expectedUrl: '/accounting/deposits',
createScript: [
`(async()=>{`, HELPERS,
`const R={phase:'CREATE',ts};`,
// Click 입금등록
`const btn=Array.from(document.querySelectorAll('button')).find(b=>/입금.*등록|입금등록|등록/.test(b.innerText?.trim()));`,
`if(!btn){R.error='등록 버튼 없음';return JSON.stringify(R);}`,
`btn.click();await w(2500);`,
`R.url=location.pathname+location.search;`,
// Fill 입금자명
`const nameInput=document.querySelector('input[placeholder*="입금자명"]')||document.querySelector('input[placeholder*="입금자"]');`,
`if(nameInput){sv(nameInput,'E2E_TEST_입금자_'+ts);await w(200);}`,
// Fill 입금금액
`const amtInput=document.querySelector('input[placeholder*="입금금액"]')||document.querySelector('input[type="number"]');`,
`if(amtInput){sv(amtInput,'50000');await w(200);}`,
// Fill 적요
`const noteInput=document.querySelector('input[placeholder*="적요"]');`,
`if(noteInput){sv(noteInput,'E2E_TEST_입금_'+ts);await w(200);}`,
// Select 거래처 combobox
`const combos=Array.from(document.querySelectorAll('button[role="combobox"]')).filter(b=>b.offsetParent!==null);`,
`R.comboCount=combos.length;`,
`for(const cb of combos){`,
` const label=cb.closest('[class*=field],[class*=Field],[class*=form-item]')?.querySelector('label')?.innerText||'';`,
` if(label.includes('거래처')){`,
` cb.click();await w(600);`,
` const lb=document.querySelector('[role="listbox"]');`,
` if(lb){const opt=lb.querySelector('[role="option"]');if(opt){opt.click();await w(400);}}`,
` else{document.dispatchEvent(new KeyboardEvent('keydown',{key:'Escape',bubbles:true}));await w(200);}`,
` break;`,
` }`,
`}`,
// Select 입금 유형 combobox
`for(const cb of combos){`,
` const label=cb.closest('[class*=field],[class*=Field],[class*=form-item]')?.querySelector('label')?.innerText||'';`,
` if(label.includes('입금 유형')||label.includes('유형')){`,
` cb.click();await w(600);`,
` const lb=document.querySelector('[role="listbox"]');`,
` if(lb){const opt=lb.querySelector('[role="option"]');if(opt){opt.click();await w(400);}}`,
` else{document.dispatchEvent(new KeyboardEvent('keydown',{key:'Escape',bubbles:true}));await w(200);}`,
` break;`,
` }`,
`}`,
// Click 등록 submit
`const submitBtn=Array.from(document.querySelectorAll('button')).find(b=>b.innerText?.trim()==='등록'&&b.offsetParent!==null);`,
`if(!submitBtn){R.error='등록 버튼 없음';return JSON.stringify(R);}`,
`submitBtn.click();await w(3000);`,
`R.urlAfter=location.pathname+location.search;`,
`R.navigatedBack=!location.search.includes('mode=new');`,
`R.ok=true;`,
`return JSON.stringify(R);`,
`})()`,
].join(''),
verifyScript: [
`(async()=>{`, HELPERS,
`const R={phase:'VERIFY_CREATE'};`,
`await w(1000);`,
`R.url=location.pathname;`,
`const rows=document.querySelectorAll('table tbody tr');`,
`R.rowCount=rows.length;`,
`const found=Array.from(rows).find(r=>r.innerText?.includes('E2E_TEST_'));`,
`R.found=!!found;`,
`if(found)R.foundText=found.innerText?.substring(0,80);`,
`R.ok=R.found;`,
`return JSON.stringify(R);`,
`})()`,
].join(''),
deleteScript: [
`(async()=>{`, HELPERS,
`const R={phase:'DELETE'};`,
`const rows=Array.from(document.querySelectorAll('table tbody tr'));`,
`const targetRow=rows.find(r=>r.innerText?.includes(ts))||rows.find(r=>r.innerText?.includes('E2E_TEST_'));`,
`if(!targetRow){R.error='E2E_TEST_ 데이터 없음';R.ok=false;return JSON.stringify(R);}`,
`R.targetText=targetRow.innerText?.substring(0,60);R.ts=ts;`,
`targetRow.click();await w(2500);`,
`R.detailUrl=location.pathname+location.search;`,
`const delBtn=Array.from(document.querySelectorAll('button')).find(b=>b.innerText?.trim()==='삭제');`,
`if(!delBtn){R.error='삭제 버튼 없음';R.ok=false;return JSON.stringify(R);}`,
`delBtn.click();await w(1000);`,
`const confirmBtn=Array.from(document.querySelectorAll('[role="alertdialog"] button,[role="dialog"] button,button')).find(b=>/확인|삭제|예|Yes/.test(b.innerText?.trim())&&b!==delBtn&&b.offsetParent!==null);`,
`if(confirmBtn){confirmBtn.click();await w(3000);}`,
`R.urlAfter=location.pathname+location.search;`,
`R.ok=true;`,
`return JSON.stringify(R);`,
`})()`,
].join(''),
verifyDeleteScript: [
`(async()=>{`, HELPERS,
`const R={phase:'VERIFY_DELETE'};`,
`await w(1000);`,
`R.url=location.pathname;`,
`if(location.search.includes('mode=view')){`,
` const backBtn=Array.from(document.querySelectorAll('button,a')).find(b=>/목록/.test(b.innerText?.trim()));`,
` if(backBtn){backBtn.click();await w(2000);}`,
` else{history.back();await w(2000);}`,
`}`,
`const rows=document.querySelectorAll('table tbody tr');`,
`R.rowCount=rows.length;`,
`const found=Array.from(rows).find(r=>r.innerText?.includes(ts));`,
`R.stillExists=!!found;`,
`R.ok=!found;R.ts=ts;`,
`if(found)R.warn='E2E_TEST_ 데이터가 여전히 존재 - 수동 삭제 필요';`,
`return JSON.stringify(R);`,
`})()`,
].join(''),
},
};
// ════════════════════════════════════════════════════════════════
// SCENARIO GENERATOR
// ════════════════════════════════════════════════════════════════
function generateScenario(pageKey) {
const page = PAGES[pageKey];
const steps = [];
let id = 1;
const p = page.menuNavigation;
const prefix = `[${p.level1} > ${p.level2}]`;
// ─── SETUP ───
steps.push({ id: id++, name: `${prefix} 페이지 로드 대기`, action: 'wait', timeout: 3000 });
steps.push({ id: id++, name: `${prefix} 테이블 로드 대기`, action: 'wait_for_table', timeout: 5000 });
// ─── CREATE PHASE ───
steps.push({
id: id++, name: `${prefix} [CREATE] 데이터 생성`,
action: 'evaluate', script: page.createScript, timeout: 30000, phase: 'CREATE',
});
// Wait for navigation back to list after form submission
steps.push({ id: id++, name: `${prefix} [CREATE] 생성 후 대기`, action: 'wait', timeout: 3000 });
// If still on form page, go back to list
steps.push({
id: id++, name: `${prefix} [CREATE] 목록 복귀`,
action: 'evaluate', timeout: 10000, phase: 'CREATE',
script: `(async()=>{const w=ms=>new Promise(r=>setTimeout(r,ms));const onForm=location.search.includes('mode=new')||location.search.includes('mode=edit')||new RegExp('/(new|[0-9]+|[0-9a-f]{8,})$').test(location.pathname);if(onForm){const btn=Array.from(document.querySelectorAll('button,a')).find(b=>/목록|취소|뒤로/.test(b.innerText?.trim()));if(btn){btn.click();await w(2000);}else{history.back();await w(2000);}}return JSON.stringify({url:location.pathname+location.search});})()`,
});
steps.push({ id: id++, name: `${prefix} [CREATE] 목록 안정화 대기`, action: 'wait', timeout: 2000 });
// ─── VERIFY CREATE PHASE ───
steps.push({
id: id++, name: `${prefix} [VERIFY] 생성 데이터 확인`,
action: 'evaluate', script: page.verifyScript, timeout: 15000, phase: 'VERIFY',
});
// ─── DELETE PHASE ───
steps.push({
id: id++, name: `${prefix} [DELETE] 데이터 삭제`,
action: 'evaluate', script: page.deleteScript, timeout: 30000, phase: 'DELETE', critical: true,
});
steps.push({ id: id++, name: `${prefix} [DELETE] 삭제 후 대기`, action: 'wait', timeout: 3000 });
// Navigate back to list if stuck on detail page
steps.push({
id: id++, name: `${prefix} [DELETE] 목록 복귀`,
action: 'evaluate', timeout: 10000, phase: 'DELETE',
script: `(async()=>{const w=ms=>new Promise(r=>setTimeout(r,ms));const onDetail=location.search.includes('mode=view')||location.search.includes('mode=edit')||new RegExp('/[0-9]+$|/[0-9a-f]{8,}$').test(location.pathname);if(onDetail){const btn=Array.from(document.querySelectorAll('button,a')).find(b=>/목록|뒤로/.test(b.innerText?.trim()));if(btn){btn.click();await w(2000);}else{history.back();await w(2000);}}return JSON.stringify({url:location.pathname+location.search});})()`,
});
steps.push({ id: id++, name: `${prefix} [DELETE] 목록 안정화 대기`, action: 'wait', timeout: 2000 });
// ─── VERIFY DELETE PHASE ───
steps.push({
id: id++, name: `${prefix} [VERIFY] 삭제 확인`,
action: 'evaluate', script: page.verifyDeleteScript, timeout: 15000, phase: 'VERIFY',
});
return {
id: page.id,
name: page.name,
version: '1.0.0',
auth: { role: 'admin' },
menuNavigation: page.menuNavigation,
screenshotPolicy: { captureOnFail: true, captureOnPass: false },
steps,
};
}
// ════════════════════════════════════════════════════════════════
// MAIN
// ════════════════════════════════════════════════════════════════
function main() {
if (!fs.existsSync(SCENARIOS_DIR)) {
fs.mkdirSync(SCENARIOS_DIR, { recursive: true });
}
const generated = [];
for (const key of Object.keys(PAGES)) {
const scenario = generateScenario(key);
const filePath = path.join(SCENARIOS_DIR, `${scenario.id}.json`);
fs.writeFileSync(filePath, JSON.stringify(scenario, null, 2), 'utf-8');
generated.push({ id: scenario.id, steps: scenario.steps.length });
console.log(` ${scenario.id}.json (${scenario.steps.length} steps)`);
}
console.log(`\n Generated ${generated.length} scenarios`);
console.log(`\n Run: node e2e/runner/run-all.js --filter create-delete`);
}
main();

View File

@@ -0,0 +1,889 @@
#!/usr/bin/env node
/**
* 엣지 케이스 테스트 시나리오 생성기
*
* 폼 제출 빈 값 검증, 경계값 입력, 특수문자 검색, 연타 클릭(디바운스) 등
* 15개 시나리오를 자동 생성한다.
*
* Usage: node e2e/runner/gen-edge-cases.js
*
* Output:
* e2e/scenarios/edge-*.json (15 files)
*
* Run:
* node e2e/runner/run-all.js --filter edge
*/
const fs = require('fs');
const path = require('path');
const SCENARIOS_DIR = path.resolve(__dirname, '..', 'scenarios');
// ════════════════════════════════════════════════════════════════
// Reusable evaluate script builders
// ════════════════════════════════════════════════════════════════
const H = `const w=ms=>new Promise(r=>setTimeout(r,ms));`;
// Open registration form (find and click 등록/추가/작성 button)
const OPEN_FORM_SCRIPT = [
`(async()=>{`, H,
`const R={phase:'OPEN_FORM'};`,
`const priorities=['등록','추가','작성','글쓰기','신규'];`,
`const exclude=['신규업체','신규거래'];`,
`let btn=null;`,
`for(const kw of priorities){`,
` btn=Array.from(document.querySelectorAll('button')).find(b=>{`,
` const t=b.innerText?.trim()||'';`,
` if(exclude.some(e=>t.includes(e)))return false;`,
` return t.includes(kw)&&b.offsetParent!==null&&!b.disabled;`,
` });if(btn)break;`,
`}`,
`if(!btn){btn=Array.from(document.querySelectorAll('button')).find(b=>b.innerText?.trim()?.startsWith('+')&&b.offsetParent!==null);}`,
`if(!btn){R.err='등록 버튼 없음';R.ok=true;return JSON.stringify(R);}`,
`R.btnText=btn.innerText?.trim();btn.click();await w(2500);`,
`R.url=location.pathname+location.search;R.ok=true;return JSON.stringify(R);`,
`})()`,
].join('');
// Try empty submit and check validation
const EMPTY_SUBMIT_SCRIPT = [
`(async()=>{`, H,
`const R={phase:'EMPTY_SUBMIT_CHECK'};`,
`await w(500);`,
`const beforeToasts=document.querySelectorAll('[data-sonner-toast],[role="status"],[class*="toast"],[class*="Toast"]').length;`,
`const beforeErrors=document.querySelectorAll('[class*="error"],[class*="Error"],[class*="destructive"],[role="alert"]').length;`,
`const submitBtn=Array.from(document.querySelectorAll('button')).find(b=>{`,
` const t=b.innerText?.trim()||'';`,
` return(/등록|저장|확인|제출/.test(t))&&b.offsetParent!==null&&!b.disabled;`,
`});`,
`if(!submitBtn){R.err='저장/등록 버튼 없음';R.ok=true;return JSON.stringify(R);}`,
`R.submitBtnText=submitBtn.innerText?.trim();`,
`submitBtn.click();await w(2000);`,
`const toasts=document.querySelectorAll('[data-sonner-toast],[role="status"],[class*="toast"],[class*="Toast"],[class*="Toaster"] [data-content]');`,
`R.toastCount=toasts.length;R.newToasts=toasts.length-beforeToasts;`,
`if(toasts.length>0){R.toastTexts=Array.from(toasts).map(t=>t.innerText?.trim().substring(0,80)).filter(Boolean);}`,
`const errors=document.querySelectorAll('[class*="error"],[class*="Error"],[class*="destructive"],[role="alert"],[class*="invalid"]');`,
`R.errorCount=errors.length;R.newErrors=errors.length-beforeErrors;`,
`if(errors.length>0){R.errorTexts=Array.from(errors).slice(0,5).map(e=>e.innerText?.trim().substring(0,60)).filter(Boolean);}`,
`const invalidFields=document.querySelectorAll('[aria-invalid="true"]');`,
`R.ariaInvalidCount=invalidFields.length;`,
`const redBorders=Array.from(document.querySelectorAll('input,textarea,select,[role="combobox"]')).filter(el=>{`,
` const cs=getComputedStyle(el);`,
` return cs.borderColor?.includes('rgb(239')||cs.borderColor?.includes('rgb(220')||cs.borderColor?.includes('rgb(248')||cs.outlineColor?.includes('rgb(239');`,
`});`,
`R.redBorderCount=redBorders.length;`,
`const dialogs=document.querySelectorAll('[role="alertdialog"],[role="dialog"]');`,
`const validationDialog=Array.from(dialogs).find(d=>d.offsetParent!==null);`,
`R.hasValidationDialog=!!validationDialog;`,
`if(validationDialog){R.dialogText=validationDialog.innerText?.trim().substring(0,100);}`,
`R.totalValidationSignals=R.newToasts+R.newErrors+R.ariaInvalidCount+R.redBorderCount+(R.hasValidationDialog?1:0);`,
`R.validationTriggered=R.totalValidationSignals>0;`,
`R.ok=true;`,
`R.info=R.validationTriggered?'✅ 유효성 검사 정상 동작':'⚠️ 유효성 검사 미감지 - 빈 폼 제출 시 에러 메시지 없음';`,
`return JSON.stringify(R);`,
`})()`,
].join('');
// Close form / modal
const CLOSE_FORM_SCRIPT = [
`(async()=>{`, H,
`const R={phase:'CLOSE_FORM'};`,
// Close alert dialog first
`const dlg=document.querySelector('[role="alertdialog"],[role="dialog"]');`,
`if(dlg&&dlg.offsetParent!==null){`,
` const closeBtn=dlg.querySelector('button[class*="close"]')||Array.from(dlg.querySelectorAll('button')).find(b=>/닫기|확인|취소|Close/.test(b.innerText?.trim()));`,
` if(closeBtn){closeBtn.click();await w(500);}`,
` else{document.dispatchEvent(new KeyboardEvent('keydown',{key:'Escape',bubbles:true}));await w(500);}`,
`}`,
// If in new/edit mode, go back
`if(location.search.includes('mode=new')||location.search.includes('mode=edit')){`,
` const backBtn=Array.from(document.querySelectorAll('button,a')).find(b=>/목록|취소|뒤로/.test(b.innerText?.trim()));`,
` if(backBtn){backBtn.click();await w(2000);}`,
` else{history.back();await w(2000);}`,
`}`,
// Close any remaining modal
`const modal=document.querySelector('[role="dialog"],[aria-modal="true"],[class*="modal"]:not([class*="tooltip"])');`,
`if(modal&&modal.offsetParent!==null){`,
` const xBtn=modal.querySelector('button[class*="close"],[aria-label="닫기"],[aria-label="Close"]')||Array.from(modal.querySelectorAll('button')).find(b=>/닫기|취소|Close/.test(b.innerText?.trim()));`,
` if(xBtn){xBtn.click();await w(500);}`,
` else{document.dispatchEvent(new KeyboardEvent('keydown',{key:'Escape',bubbles:true}));await w(500);}`,
`}`,
`R.url=location.pathname+location.search;R.ok=true;return JSON.stringify(R);`,
`})()`,
].join('');
// Check boundary fill results (after fill_boundary steps)
function boundaryCheckScript(phase) {
return [
`(async()=>{`, H,
`const R={phase:'${phase}'};`,
`await w(1000);`,
`const hasError=document.querySelector('[class*="error"],[class*="Error"],[role="alert"],.text-red-500,.text-destructive');`,
`const errorTexts=Array.from(document.querySelectorAll('[class*="error"],[class*="Error"],[role="alert"],.text-red-500,.text-destructive')).map(e=>e.innerText?.trim()).filter(Boolean);`,
`R.errorDetected=!!hasError||errorTexts.length>0;`,
`R.errorMessages=errorTexts.slice(0,5);`,
`const inputs=Array.from(document.querySelectorAll('input:not([type="hidden"]),textarea'));`,
`R.inputCount=inputs.length;`,
`const overflowInputs=inputs.filter(el=>{`,
` const max=el.maxLength;`,
` return max>0&&el.value.length>max;`,
`});`,
`R.overflowCount=overflowInputs.length;`,
`const truncatedInputs=inputs.filter(el=>{`,
` const max=el.maxLength;`,
` return max>0&&el.value.length===max;`,
`});`,
`R.truncatedCount=truncatedInputs.length;`,
`R.ok=true;`,
`R.info=R.errorDetected?'✅ 경계값 입력 시 에러/경고 감지':'⚠️ 경계값 입력 시 에러 미감지 (정상 가능)';`,
`return JSON.stringify(R);`,
`})()`,
].join('');
}
// Check search result after special char search
function searchResultCheckScript(phase) {
return [
`(async()=>{`, H,
`const R={phase:'${phase}'};`,
`await w(2000);`,
`const rows=Array.from(document.querySelectorAll('table tbody tr')).filter(r=>r.offsetParent!==null);`,
`R.rowCount=rows.length;`,
`const hasError=document.querySelector('[class*="error"],[class*="Error"],[role="alert"]');`,
`R.hasError=!!hasError;`,
`if(hasError){R.errorText=hasError.innerText?.trim().substring(0,80);}`,
`const noDataMsg=document.body.innerText.includes('데이터가 없습니다')||document.body.innerText.includes('검색 결과가 없습니다')||document.body.innerText.includes('No data');`,
`R.noDataMessage=noDataMsg;`,
`R.pageStable=!document.querySelector('.loading,.spinner,[class*="skeleton"]');`,
`R.ok=true;`,
`R.info=R.hasError?'⚠️ 특수문자 검색 시 에러 발생':'✅ 특수문자 검색 시 에러 없음 (정상)';`,
`return JSON.stringify(R);`,
`})()`,
].join('');
}
// Clear search input
const CLEAR_SEARCH_SCRIPT = [
`(async()=>{`, H,
`const R={phase:'CLEAR_SEARCH'};`,
`const searchInput=document.querySelector('input[placeholder*="검색"]')||document.querySelector('input[type="search"]')||document.querySelector('input[role="searchbox"]');`,
`if(searchInput){`,
` const ns=Object.getOwnPropertyDescriptor(HTMLInputElement.prototype,'value')?.set;`,
` if(ns)ns.call(searchInput,'');else searchInput.value='';`,
` searchInput.dispatchEvent(new Event('input',{bubbles:true}));`,
` searchInput.dispatchEvent(new Event('change',{bubbles:true}));`,
` searchInput.dispatchEvent(new KeyboardEvent('keydown',{key:'Enter',code:'Enter',keyCode:13,bubbles:true}));`,
` await w(1500);`,
` R.cleared=true;`,
`}else{R.cleared=false;R.warn='검색 입력란 없음';}`,
`R.ok=true;return JSON.stringify(R);`,
`})()`,
].join('');
// Rapid click result check
function rapidClickCheckScript(phase) {
return [
`(async()=>{`, H,
`const R={phase:'${phase}'};`,
`await w(2000);`,
`const dialogs=document.querySelectorAll('[role="dialog"],[role="alertdialog"],[aria-modal="true"]');`,
`const visibleDialogs=Array.from(dialogs).filter(d=>d.offsetParent!==null);`,
`R.dialogCount=visibleDialogs.length;`,
`const toasts=document.querySelectorAll('[data-sonner-toast],[role="status"],[class*="toast"],[class*="Toast"]');`,
`R.toastCount=toasts.length;`,
`if(toasts.length>0){R.toastTexts=Array.from(toasts).map(t=>t.innerText?.trim().substring(0,80)).filter(Boolean);}`,
`const hasError=document.querySelector('[class*="error"],[class*="Error"],[role="alert"]');`,
`R.hasError=!!hasError;`,
`R.url=location.pathname+location.search;`,
`R.pageStable=!document.querySelector('.loading,.spinner,[class*="skeleton"]');`,
`R.ok=true;`,
`R.info=R.dialogCount<=1&&!R.hasError?'✅ 연타 클릭 후 정상 상태':'⚠️ 연타 클릭 후 비정상 상태 (다중 모달/에러)';`,
`return JSON.stringify(R);`,
`})()`,
].join('');
}
// Fill search input with value
function fillSearchScript(value) {
return [
`(async()=>{`, H,
`const R={phase:'FILL_SEARCH'};`,
`const searchInput=document.querySelector('input[placeholder*="검색"]')||document.querySelector('input[type="search"]')||document.querySelector('input[role="searchbox"]');`,
`if(!searchInput){R.err='검색 입력란 없음';R.ok=true;return JSON.stringify(R);}`,
`searchInput.focus();await w(200);`,
`const ns=Object.getOwnPropertyDescriptor(HTMLInputElement.prototype,'value')?.set;`,
`if(ns)ns.call(searchInput,${JSON.stringify(value)});else searchInput.value=${JSON.stringify(value)};`,
`searchInput.dispatchEvent(new Event('input',{bubbles:true}));`,
`searchInput.dispatchEvent(new Event('change',{bubbles:true}));`,
`searchInput.dispatchEvent(new KeyboardEvent('keydown',{key:'Enter',code:'Enter',keyCode:13,bubbles:true}));`,
`await w(2000);`,
`R.searchValue=${JSON.stringify(value)};R.ok=true;return JSON.stringify(R);`,
`})()`,
].join('');
}
// Find first visible input target selector
const FIND_INPUT_SCRIPT = [
`(async()=>{`, H,
`const R={phase:'FIND_INPUTS'};`,
`await w(1000);`,
`const inputs=Array.from(document.querySelectorAll('input:not([type="hidden"]):not([type="checkbox"]):not([type="radio"]),textarea')).filter(el=>el.offsetParent!==null&&!el.disabled&&!el.readOnly);`,
`R.inputCount=inputs.length;`,
`R.inputSelectors=inputs.slice(0,5).map((el,i)=>{`,
` const tag=el.tagName.toLowerCase();`,
` const name=el.name||'';const placeholder=el.placeholder||'';const type=el.type||'text';`,
` let sel='';`,
` if(el.id)sel='#'+el.id;`,
` else if(name)sel=tag+'[name="'+name+'"]';`,
` else if(placeholder)sel=tag+'[placeholder*="'+placeholder.substring(0,15)+'"]';`,
` else sel=tag+':nth-of-type('+(i+1)+')';`,
` return{index:i,selector:sel,name,placeholder:placeholder.substring(0,20),type};`,
`});`,
`R.ok=true;return JSON.stringify(R);`,
`})()`,
].join('');
// ════════════════════════════════════════════════════════════════
// Scenario Builders
// ════════════════════════════════════════════════════════════════
function emptySubmitScenario(id, name, level1, level2) {
const steps = [];
let sid = 1;
steps.push({ id: sid++, name: '페이지 로드 대기', action: 'wait', timeout: 3000 });
steps.push({ id: sid++, name: '테이블 로드 대기', action: 'wait_for_table', timeout: 5000 });
steps.push({ id: sid++, name: '등록 폼 열기', action: 'evaluate', script: OPEN_FORM_SCRIPT, timeout: 15000, phase: 'OPEN_FORM' });
steps.push({ id: sid++, name: '폼 렌더링 대기', action: 'wait', timeout: 2000 });
steps.push({ id: sid++, name: '빈 상태로 저장 클릭', action: 'evaluate', script: EMPTY_SUBMIT_SCRIPT, timeout: 15000, phase: 'EMPTY_SUBMIT_CHECK' });
steps.push({ id: sid++, name: '결과 확인 대기', action: 'wait', timeout: 1000 });
steps.push({ id: sid++, name: '폼/모달 닫기', action: 'evaluate', script: CLOSE_FORM_SCRIPT, timeout: 10000, phase: 'CLOSE_FORM' });
return {
id,
name,
version: '1.0.0',
auth: { role: 'admin' },
menuNavigation: { level1, level2 },
screenshotPolicy: { captureOnFail: true, captureOnPass: false },
steps,
};
}
function boundaryInputScenario(id, name, level1, level2) {
const steps = [];
let sid = 1;
steps.push({ id: sid++, name: '페이지 로드 대기', action: 'wait', timeout: 3000 });
steps.push({ id: sid++, name: '테이블 로드 대기', action: 'wait_for_table', timeout: 5000 });
steps.push({ id: sid++, name: '등록 폼 열기', action: 'evaluate', script: OPEN_FORM_SCRIPT, timeout: 15000, phase: 'OPEN_FORM' });
steps.push({ id: sid++, name: '폼 렌더링 대기', action: 'wait', timeout: 2000 });
steps.push({ id: sid++, name: '입력 필드 탐색', action: 'evaluate', script: FIND_INPUT_SCRIPT, timeout: 10000, phase: 'FIND_INPUTS' });
// Boundary test: max_length
steps.push({ id: sid++, name: '경계값: 최대 길이 입력', action: 'fill_boundary', target: 'input:not([type="hidden"]):not([type="checkbox"]):not([type="radio"]):not([disabled]):not([readonly])', boundaryType: 'max_length', maxLength: 255, timeout: 5000 });
steps.push({ id: sid++, name: '최대 길이 결과 확인', action: 'evaluate', script: boundaryCheckScript('MAX_LENGTH_CHECK'), timeout: 5000, phase: 'MAX_LENGTH_CHECK' });
// Boundary test: overflow
steps.push({ id: sid++, name: '경계값: 오버플로우 입력', action: 'fill_boundary', target: 'input:not([type="hidden"]):not([type="checkbox"]):not([type="radio"]):not([disabled]):not([readonly])', boundaryType: 'overflow', maxLength: 255, timeout: 5000 });
steps.push({ id: sid++, name: '오버플로우 결과 확인', action: 'evaluate', script: boundaryCheckScript('OVERFLOW_CHECK'), timeout: 5000, phase: 'OVERFLOW_CHECK' });
// Boundary test: special_chars
steps.push({ id: sid++, name: '경계값: 특수문자(XSS) 입력', action: 'fill_boundary', target: 'input:not([type="hidden"]):not([type="checkbox"]):not([type="radio"]):not([disabled]):not([readonly])', boundaryType: 'special_chars', timeout: 5000 });
steps.push({ id: sid++, name: '특수문자 결과 확인', action: 'evaluate', script: boundaryCheckScript('SPECIAL_CHARS_CHECK'), timeout: 5000, phase: 'SPECIAL_CHARS_CHECK' });
// Boundary test: whitespace
steps.push({ id: sid++, name: '경계값: 공백만 입력', action: 'fill_boundary', target: 'input:not([type="hidden"]):not([type="checkbox"]):not([type="radio"]):not([disabled]):not([readonly])', boundaryType: 'whitespace', timeout: 5000 });
// Try submitting with boundary values
steps.push({ id: sid++, name: '경계값 상태로 저장 시도', action: 'evaluate', script: EMPTY_SUBMIT_SCRIPT, timeout: 15000, phase: 'BOUNDARY_SUBMIT_CHECK' });
steps.push({ id: sid++, name: '폼/모달 닫기', action: 'evaluate', script: CLOSE_FORM_SCRIPT, timeout: 10000, phase: 'CLOSE_FORM' });
return {
id,
name,
version: '1.0.0',
auth: { role: 'admin' },
menuNavigation: { level1, level2 },
screenshotPolicy: { captureOnFail: true, captureOnPass: false },
steps,
};
}
function specialCharsSearchScenario(id, name, level1, level2) {
const steps = [];
let sid = 1;
steps.push({ id: sid++, name: '페이지 로드 대기', action: 'wait', timeout: 3000 });
steps.push({ id: sid++, name: '테이블 로드 대기', action: 'wait_for_table', timeout: 5000 });
// Test 1: special_chars
steps.push({ id: sid++, name: '특수문자 검색: <script>', action: 'evaluate', script: fillSearchScript("<script>alert('xss')</script>"), timeout: 10000, phase: 'SEARCH_XSS' });
steps.push({ id: sid++, name: '특수문자 검색 결과 확인', action: 'evaluate', script: searchResultCheckScript('SEARCH_XSS_CHECK'), timeout: 10000, phase: 'SEARCH_XSS_CHECK' });
steps.push({ id: sid++, name: '검색 초기화', action: 'evaluate', script: CLEAR_SEARCH_SCRIPT, timeout: 5000 });
// Test 2: SQL injection
steps.push({ id: sid++, name: 'SQL 인젝션 검색', action: 'evaluate', script: fillSearchScript("'; DROP TABLE users; --"), timeout: 10000, phase: 'SEARCH_SQL' });
steps.push({ id: sid++, name: 'SQL 인젝션 검색 결과 확인', action: 'evaluate', script: searchResultCheckScript('SEARCH_SQL_CHECK'), timeout: 10000, phase: 'SEARCH_SQL_CHECK' });
steps.push({ id: sid++, name: '검색 초기화', action: 'evaluate', script: CLEAR_SEARCH_SCRIPT, timeout: 5000 });
// Test 3: Unicode / emoji
steps.push({ id: sid++, name: '유니코드/이모지 검색', action: 'evaluate', script: fillSearchScript('한글テスト🎉中文العربية'), timeout: 10000, phase: 'SEARCH_UNICODE' });
steps.push({ id: sid++, name: '유니코드 검색 결과 확인', action: 'evaluate', script: searchResultCheckScript('SEARCH_UNICODE_CHECK'), timeout: 10000, phase: 'SEARCH_UNICODE_CHECK' });
steps.push({ id: sid++, name: '검색 초기화', action: 'evaluate', script: CLEAR_SEARCH_SCRIPT, timeout: 5000 });
// Test 4: Very long string
steps.push({ id: sid++, name: '초장문 검색', action: 'evaluate', script: fillSearchScript('A'.repeat(500)), timeout: 10000, phase: 'SEARCH_LONG' });
steps.push({ id: sid++, name: '초장문 검색 결과 확인', action: 'evaluate', script: searchResultCheckScript('SEARCH_LONG_CHECK'), timeout: 10000, phase: 'SEARCH_LONG_CHECK' });
steps.push({ id: sid++, name: '검색 초기화', action: 'evaluate', script: CLEAR_SEARCH_SCRIPT, timeout: 5000 });
return {
id,
name,
version: '1.0.0',
auth: { role: 'admin' },
menuNavigation: { level1, level2 },
screenshotPolicy: { captureOnFail: true, captureOnPass: false },
steps,
};
}
function rapidClickSaveScenario(id, name, level1, level2) {
const steps = [];
let sid = 1;
steps.push({ id: sid++, name: '페이지 로드 대기', action: 'wait', timeout: 3000 });
steps.push({ id: sid++, name: '테이블 로드 대기', action: 'wait_for_table', timeout: 5000 });
steps.push({ id: sid++, name: '등록 폼 열기', action: 'evaluate', script: OPEN_FORM_SCRIPT, timeout: 15000, phase: 'OPEN_FORM' });
steps.push({ id: sid++, name: '폼 렌더링 대기', action: 'wait', timeout: 2000 });
// Rapid click the save/submit button
steps.push({
id: sid++,
name: '저장 버튼 연타 (5회, 50ms 간격)',
action: 'rapid_click',
target: 'button',
// rapid_click will find the first visible button; we use evaluate to find save button first
timeout: 10000,
});
// Actually, we need to target the save button specifically. Let's use evaluate to find it and rapid-click.
// Override the previous step with evaluate-based rapid click.
steps.pop();
steps.push({
id: sid++,
name: '저장 버튼 연타 (5회, 50ms 간격)',
action: 'evaluate',
script: [
`(async()=>{`, H,
`const R={phase:'RAPID_CLICK_SAVE'};`,
`const btn=Array.from(document.querySelectorAll('button')).find(b=>{`,
` const t=b.innerText?.trim()||'';`,
` return(/저장|등록|확인|제출/.test(t))&&b.offsetParent!==null&&!b.disabled;`,
`});`,
`if(!btn){R.err='저장/등록 버튼 없음';R.ok=true;return JSON.stringify(R);}`,
`R.btnText=btn.innerText?.trim();`,
`let clickCount=0;`,
`for(let i=0;i<5;i++){btn.click();clickCount++;await w(50);}`,
`R.clickCount=clickCount;`,
`await w(2000);`,
`R.ok=true;return JSON.stringify(R);`,
`})()`,
].join(''),
timeout: 15000,
phase: 'RAPID_CLICK_SAVE',
});
steps.push({ id: sid++, name: '연타 후 상태 확인', action: 'evaluate', script: rapidClickCheckScript('RAPID_CLICK_RESULT'), timeout: 10000, phase: 'RAPID_CLICK_RESULT' });
steps.push({ id: sid++, name: '폼/모달 닫기', action: 'evaluate', script: CLOSE_FORM_SCRIPT, timeout: 10000, phase: 'CLOSE_FORM' });
return {
id,
name,
version: '1.0.0',
auth: { role: 'admin' },
menuNavigation: { level1, level2 },
screenshotPolicy: { captureOnFail: true, captureOnPass: false },
steps,
};
}
function rapidClickDeleteScenario(id, name, level1, level2) {
const steps = [];
let sid = 1;
steps.push({ id: sid++, name: '페이지 로드 대기', action: 'wait', timeout: 3000 });
steps.push({ id: sid++, name: '테이블 로드 대기', action: 'wait_for_table', timeout: 5000 });
steps.push({ id: sid++, name: '첫 번째 행 클릭', action: 'click_first_row', timeout: 5000 });
steps.push({ id: sid++, name: '상세 페이지 대기', action: 'wait', timeout: 2000 });
// Find and rapid-click delete button
steps.push({
id: sid++,
name: '삭제 버튼 연타 (5회, 50ms 간격)',
action: 'evaluate',
script: [
`(async()=>{`, H,
`const R={phase:'RAPID_CLICK_DELETE'};`,
`const btn=Array.from(document.querySelectorAll('button')).find(b=>{`,
` const t=b.innerText?.trim()||'';`,
` return(/삭제|제거|Delete/.test(t))&&b.offsetParent!==null&&!b.disabled;`,
`});`,
`if(!btn){R.err='삭제 버튼 없음';R.ok=true;return JSON.stringify(R);}`,
`R.btnText=btn.innerText?.trim();`,
`let clickCount=0;`,
`for(let i=0;i<5;i++){btn.click();clickCount++;await w(50);}`,
`R.clickCount=clickCount;`,
`await w(2000);`,
// Check if confirm dialog appeared
`const dialogs=document.querySelectorAll('[role="alertdialog"],[role="dialog"]');`,
`const visDialog=Array.from(dialogs).find(d=>d.offsetParent!==null);`,
`R.confirmDialogShown=!!visDialog;`,
`if(visDialog){R.dialogText=visDialog.innerText?.trim().substring(0,100);}`,
// Cancel the deletion (do NOT actually delete)
`if(visDialog){`,
` const cancelBtn=Array.from(visDialog.querySelectorAll('button')).find(b=>/취소|Cancel|아니오|닫기/.test(b.innerText?.trim()));`,
` if(cancelBtn){cancelBtn.click();await w(500);R.cancelled=true;}`,
` else{document.dispatchEvent(new KeyboardEvent('keydown',{key:'Escape',bubbles:true}));await w(500);R.cancelled=true;}`,
`}`,
`R.ok=true;return JSON.stringify(R);`,
`})()`,
].join(''),
timeout: 15000,
phase: 'RAPID_CLICK_DELETE',
});
steps.push({ id: sid++, name: '연타 후 상태 확인', action: 'evaluate', script: rapidClickCheckScript('RAPID_DELETE_RESULT'), timeout: 10000, phase: 'RAPID_DELETE_RESULT' });
return {
id,
name,
version: '1.0.0',
auth: { role: 'admin' },
menuNavigation: { level1, level2 },
screenshotPolicy: { captureOnFail: true, captureOnPass: false },
steps,
};
}
function numericBoundaryScenario(id, name, level1, level2) {
const steps = [];
let sid = 1;
steps.push({ id: sid++, name: '페이지 로드 대기', action: 'wait', timeout: 3000 });
steps.push({ id: sid++, name: '테이블 로드 대기', action: 'wait_for_table', timeout: 5000 });
steps.push({ id: sid++, name: '등록 폼 열기', action: 'evaluate', script: OPEN_FORM_SCRIPT, timeout: 15000, phase: 'OPEN_FORM' });
steps.push({ id: sid++, name: '폼 렌더링 대기', action: 'wait', timeout: 2000 });
steps.push({ id: sid++, name: '입력 필드 탐색', action: 'evaluate', script: FIND_INPUT_SCRIPT, timeout: 10000, phase: 'FIND_INPUTS' });
// Find numeric inputs and test boundary values
steps.push({
id: sid++,
name: '숫자 필드 탐색 및 0 입력',
action: 'evaluate',
script: [
`(async()=>{`, H,
`const R={phase:'NUMERIC_ZERO'};`,
`const numInputs=Array.from(document.querySelectorAll('input[type="number"],input[inputmode="numeric"],input[name*="amount"],input[name*="price"],input[name*="qty"],input[name*="quantity"],input[placeholder*="금액"],input[placeholder*="수량"]')).filter(el=>el.offsetParent!==null&&!el.disabled&&!el.readOnly);`,
`if(numInputs.length===0){`,
` const allInputs=Array.from(document.querySelectorAll('input:not([type="hidden"]):not([type="checkbox"]):not([type="radio"])')).filter(el=>el.offsetParent!==null&&!el.disabled&&!el.readOnly);`,
` R.warn='숫자 전용 필드 없음 - 일반 입력 필드 '+allInputs.length+'개 발견';R.ok=true;return JSON.stringify(R);`,
`}`,
`R.numericFieldCount=numInputs.length;`,
// Fill first numeric input with 0
`const target=numInputs[0];`,
`target.focus();`,
`const ns=Object.getOwnPropertyDescriptor(HTMLInputElement.prototype,'value')?.set;`,
`if(ns)ns.call(target,'0');else target.value='0';`,
`target.dispatchEvent(new Event('input',{bubbles:true}));`,
`target.dispatchEvent(new Event('change',{bubbles:true}));`,
`await w(500);`,
`R.valueSet='0';R.ok=true;return JSON.stringify(R);`,
`})()`,
].join(''),
timeout: 10000,
phase: 'NUMERIC_ZERO',
});
steps.push({ id: sid++, name: '0 입력 결과 확인', action: 'evaluate', script: boundaryCheckScript('NUMERIC_ZERO_CHECK'), timeout: 5000, phase: 'NUMERIC_ZERO_CHECK' });
// Negative value
steps.push({
id: sid++,
name: '숫자 필드에 음수 입력',
action: 'evaluate',
script: [
`(async()=>{`, H,
`const R={phase:'NUMERIC_NEGATIVE'};`,
`const numInputs=Array.from(document.querySelectorAll('input[type="number"],input[inputmode="numeric"],input[name*="amount"],input[name*="price"],input[name*="qty"],input[name*="quantity"],input[placeholder*="금액"],input[placeholder*="수량"]')).filter(el=>el.offsetParent!==null&&!el.disabled&&!el.readOnly);`,
`if(numInputs.length===0){R.warn='숫자 필드 없음';R.ok=true;return JSON.stringify(R);}`,
`const target=numInputs[0];target.focus();`,
`const ns=Object.getOwnPropertyDescriptor(HTMLInputElement.prototype,'value')?.set;`,
`if(ns)ns.call(target,'-999999');else target.value='-999999';`,
`target.dispatchEvent(new Event('input',{bubbles:true}));`,
`target.dispatchEvent(new Event('change',{bubbles:true}));`,
`await w(500);`,
`R.valueSet='-999999';R.ok=true;return JSON.stringify(R);`,
`})()`,
].join(''),
timeout: 10000,
phase: 'NUMERIC_NEGATIVE',
});
steps.push({ id: sid++, name: '음수 입력 결과 확인', action: 'evaluate', script: boundaryCheckScript('NUMERIC_NEGATIVE_CHECK'), timeout: 5000, phase: 'NUMERIC_NEGATIVE_CHECK' });
// Max numeric value
steps.push({
id: sid++,
name: '숫자 필드에 최대값 입력',
action: 'evaluate',
script: [
`(async()=>{`, H,
`const R={phase:'NUMERIC_MAX'};`,
`const numInputs=Array.from(document.querySelectorAll('input[type="number"],input[inputmode="numeric"],input[name*="amount"],input[name*="price"],input[name*="qty"],input[name*="quantity"],input[placeholder*="금액"],input[placeholder*="수량"]')).filter(el=>el.offsetParent!==null&&!el.disabled&&!el.readOnly);`,
`if(numInputs.length===0){R.warn='숫자 필드 없음';R.ok=true;return JSON.stringify(R);}`,
`const target=numInputs[0];target.focus();`,
`const ns=Object.getOwnPropertyDescriptor(HTMLInputElement.prototype,'value')?.set;`,
`if(ns)ns.call(target,'99999999999999');else target.value='99999999999999';`,
`target.dispatchEvent(new Event('input',{bubbles:true}));`,
`target.dispatchEvent(new Event('change',{bubbles:true}));`,
`await w(500);`,
`R.valueSet='99999999999999';R.ok=true;return JSON.stringify(R);`,
`})()`,
].join(''),
timeout: 10000,
phase: 'NUMERIC_MAX',
});
steps.push({ id: sid++, name: '최대값 입력 결과 확인', action: 'evaluate', script: boundaryCheckScript('NUMERIC_MAX_CHECK'), timeout: 5000, phase: 'NUMERIC_MAX_CHECK' });
// Try submit
steps.push({ id: sid++, name: '경계값 상태로 저장 시도', action: 'evaluate', script: EMPTY_SUBMIT_SCRIPT, timeout: 15000, phase: 'NUMERIC_SUBMIT_CHECK' });
steps.push({ id: sid++, name: '폼/모달 닫기', action: 'evaluate', script: CLOSE_FORM_SCRIPT, timeout: 10000, phase: 'CLOSE_FORM' });
return {
id,
name,
version: '1.0.0',
auth: { role: 'admin' },
menuNavigation: { level1, level2 },
screenshotPolicy: { captureOnFail: true, captureOnPass: false },
steps,
};
}
function unicodeInputScenario(id, name, level1, level2) {
const steps = [];
let sid = 1;
steps.push({ id: sid++, name: '페이지 로드 대기', action: 'wait', timeout: 3000 });
steps.push({ id: sid++, name: '테이블 로드 대기', action: 'wait_for_table', timeout: 5000 });
steps.push({ id: sid++, name: '등록 폼 열기', action: 'evaluate', script: OPEN_FORM_SCRIPT, timeout: 15000, phase: 'OPEN_FORM' });
steps.push({ id: sid++, name: '폼 렌더링 대기', action: 'wait', timeout: 2000 });
// Unicode: emoji
steps.push({ id: sid++, name: '유니코드: 이모지 입력', action: 'fill_boundary', target: 'input:not([type="hidden"]):not([type="checkbox"]):not([type="radio"]):not([disabled]):not([readonly]), textarea', boundaryType: 'unicode', timeout: 5000 });
steps.push({ id: sid++, name: '이모지 입력 결과 확인', action: 'evaluate', script: boundaryCheckScript('EMOJI_CHECK'), timeout: 5000, phase: 'EMOJI_CHECK' });
// Unicode: custom CJK + RTL
steps.push({
id: sid++,
name: '유니코드: CJK + RTL 입력',
action: 'evaluate',
script: [
`(async()=>{`, H,
`const R={phase:'CJK_RTL_INPUT'};`,
`const inputs=Array.from(document.querySelectorAll('input:not([type="hidden"]):not([type="checkbox"]):not([type="radio"]),textarea')).filter(el=>el.offsetParent!==null&&!el.disabled&&!el.readOnly);`,
`if(inputs.length===0){R.err='입력 필드 없음';R.ok=true;return JSON.stringify(R);}`,
`const target=inputs[0];target.focus();`,
`const val='漢字ひらがなカタカナ한국어العربيةעברית';`,
`const ns=Object.getOwnPropertyDescriptor(HTMLInputElement.prototype,'value')?.set;`,
`if(ns)ns.call(target,val);else target.value=val;`,
`target.dispatchEvent(new Event('input',{bubbles:true}));`,
`target.dispatchEvent(new Event('change',{bubbles:true}));`,
`await w(500);`,
`R.valueSet=val;R.currentValue=target.value;R.matches=target.value===val;`,
`R.ok=true;R.info=R.matches?'✅ CJK/RTL 유니코드 정상 입력':'⚠️ CJK/RTL 유니코드 입력 값 불일치';`,
`return JSON.stringify(R);`,
`})()`,
].join(''),
timeout: 10000,
phase: 'CJK_RTL_INPUT',
});
// Unicode: zero-width characters
steps.push({
id: sid++,
name: '유니코드: 제로폭 문자 입력',
action: 'evaluate',
script: [
`(async()=>{`, H,
`const R={phase:'ZERO_WIDTH_INPUT'};`,
`const inputs=Array.from(document.querySelectorAll('input:not([type="hidden"]):not([type="checkbox"]):not([type="radio"]),textarea')).filter(el=>el.offsetParent!==null&&!el.disabled&&!el.readOnly);`,
`if(inputs.length===0){R.err='입력 필드 없음';R.ok=true;return JSON.stringify(R);}`,
`const target=inputs[0];target.focus();`,
`const val='test\\u200B\\u200C\\u200D\\uFEFF\\u00ADtext';`,
`const ns=Object.getOwnPropertyDescriptor(HTMLInputElement.prototype,'value')?.set;`,
`if(ns)ns.call(target,val);else target.value=val;`,
`target.dispatchEvent(new Event('input',{bubbles:true}));`,
`target.dispatchEvent(new Event('change',{bubbles:true}));`,
`await w(500);`,
`R.valueLength=target.value.length;R.visibleLength=target.value.replace(/[\\u200B\\u200C\\u200D\\uFEFF\\u00AD]/g,'').length;`,
`R.ok=true;R.info='제로폭 문자 포함 입력 완료 (전체: '+R.valueLength+', 가시: '+R.visibleLength+')';`,
`return JSON.stringify(R);`,
`})()`,
].join(''),
timeout: 10000,
phase: 'ZERO_WIDTH_INPUT',
});
steps.push({ id: sid++, name: '유니코드 상태로 저장 시도', action: 'evaluate', script: EMPTY_SUBMIT_SCRIPT, timeout: 15000, phase: 'UNICODE_SUBMIT_CHECK' });
steps.push({ id: sid++, name: '폼/모달 닫기', action: 'evaluate', script: CLOSE_FORM_SCRIPT, timeout: 10000, phase: 'CLOSE_FORM' });
return {
id,
name,
version: '1.0.0',
auth: { role: 'admin' },
menuNavigation: { level1, level2 },
screenshotPolicy: { captureOnFail: true, captureOnPass: false },
steps,
};
}
function concurrentActionScenario(id, name, level1, level2) {
const steps = [];
let sid = 1;
steps.push({ id: sid++, name: '페이지 로드 대기', action: 'wait', timeout: 3000 });
steps.push({ id: sid++, name: '테이블 로드 대기', action: 'wait_for_table', timeout: 5000 });
// Rapid tab/filter switching
steps.push({
id: sid++,
name: '탭/필터 빠른 전환 테스트',
action: 'evaluate',
script: [
`(async()=>{`, H,
`const R={phase:'RAPID_TAB_SWITCH'};`,
`const tabs=Array.from(document.querySelectorAll('[role="tab"],button[class*="tab"],a[class*="tab"]')).filter(el=>el.offsetParent!==null);`,
`R.tabCount=tabs.length;`,
`if(tabs.length<2){`,
` const buttons=Array.from(document.querySelectorAll('button')).filter(b=>b.offsetParent!==null&&!b.disabled);`,
` R.fallbackButtonCount=buttons.length;`,
` if(buttons.length>=2){`,
` for(let i=0;i<3;i++){buttons[0].click();await w(100);buttons[1].click();await w(100);}`,
` R.rapidSwitchCount=6;`,
` }else{R.warn='탭/버튼 2개 미만';R.ok=true;return JSON.stringify(R);}`,
`}else{`,
` for(let i=0;i<3;i++){tabs[0].click();await w(100);tabs[1%tabs.length].click();await w(100);}`,
` R.rapidSwitchCount=6;`,
`}`,
`await w(2000);`,
`const hasError=document.querySelector('[class*="error"],[class*="Error"],[role="alert"]');`,
`R.hasError=!!hasError;`,
`R.pageStable=!document.querySelector('.loading,.spinner,[class*="skeleton"]');`,
`R.ok=true;`,
`R.info=R.hasError?'⚠️ 빠른 전환 후 에러 발생':'✅ 빠른 전환 후 정상 상태';`,
`return JSON.stringify(R);`,
`})()`,
].join(''),
timeout: 15000,
phase: 'RAPID_TAB_SWITCH',
});
// Rapid navigation clicks
steps.push({
id: sid++,
name: '페이지네이션 빠른 클릭',
action: 'evaluate',
script: [
`(async()=>{`, H,
`const R={phase:'RAPID_PAGINATION'};`,
`const pageButtons=Array.from(document.querySelectorAll('[class*="pagination"] button,[class*="Pagination"] button,nav button')).filter(el=>el.offsetParent!==null&&!el.disabled);`,
`R.paginationButtonCount=pageButtons.length;`,
`if(pageButtons.length<2){R.warn='페이지네이션 버튼 부족';R.ok=true;return JSON.stringify(R);}`,
`const nextBtn=pageButtons.find(b=>/다음|next|>||»/.test(b.innerText?.trim()||b.getAttribute('aria-label')||''));`,
`const prevBtn=pageButtons.find(b=>/이전|prev|<||«/.test(b.innerText?.trim()||b.getAttribute('aria-label')||''));`,
`if(nextBtn&&prevBtn){`,
` for(let i=0;i<3;i++){nextBtn.click();await w(50);prevBtn.click();await w(50);}`,
` R.rapidNavCount=6;`,
`}else if(pageButtons.length>=2){`,
` for(let i=0;i<3;i++){pageButtons[0].click();await w(50);pageButtons[1].click();await w(50);}`,
` R.rapidNavCount=6;`,
`}`,
`await w(2000);`,
`const hasError=document.querySelector('[class*="error"],[class*="Error"],[role="alert"]');`,
`R.hasError=!!hasError;`,
`R.pageStable=!document.querySelector('.loading,.spinner,[class*="skeleton"]');`,
`R.ok=true;`,
`R.info=R.hasError?'⚠️ 빠른 페이지 전환 후 에러':'✅ 빠른 페이지 전환 후 정상';`,
`return JSON.stringify(R);`,
`})()`,
].join(''),
timeout: 15000,
phase: 'RAPID_PAGINATION',
});
// Multiple button rapid clicks
steps.push({
id: sid++,
name: '다중 버튼 동시 클릭 시뮬레이션',
action: 'evaluate',
script: [
`(async()=>{`, H,
`const R={phase:'MULTI_BUTTON_CLICK'};`,
`const buttons=Array.from(document.querySelectorAll('button')).filter(b=>b.offsetParent!==null&&!b.disabled);`,
`R.totalButtons=buttons.length;`,
`if(buttons.length<3){R.warn='버튼 3개 미만';R.ok=true;return JSON.stringify(R);}`,
// Click first 3 buttons rapidly
`const clickTargets=buttons.slice(0,3);`,
`for(const btn of clickTargets){btn.click();await w(30);}`,
`await w(2000);`,
`const hasError=document.querySelector('[class*="error"],[class*="Error"],[role="alert"]');`,
`R.hasError=!!hasError;`,
`R.pageStable=!document.querySelector('.loading,.spinner,[class*="skeleton"]');`,
// Close any opened modals
`const modal=document.querySelector('[role="dialog"],[aria-modal="true"]');`,
`if(modal&&modal.offsetParent!==null){`,
` const closeBtn=modal.querySelector('button[class*="close"],[aria-label="닫기"]')||Array.from(modal.querySelectorAll('button')).find(b=>/닫기|취소|Close/.test(b.innerText?.trim()));`,
` if(closeBtn){closeBtn.click();await w(500);}`,
` else{document.dispatchEvent(new KeyboardEvent('keydown',{key:'Escape',bubbles:true}));await w(500);}`,
`}`,
`R.ok=true;`,
`R.info=R.hasError?'⚠️ 다중 버튼 클릭 후 에러':'✅ 다중 버튼 클릭 후 정상';`,
`return JSON.stringify(R);`,
`})()`,
].join(''),
timeout: 15000,
phase: 'MULTI_BUTTON_CLICK',
});
return {
id,
name,
version: '1.0.0',
auth: { role: 'admin' },
menuNavigation: { level1, level2 },
screenshotPolicy: { captureOnFail: true, captureOnPass: false },
steps,
};
}
// ════════════════════════════════════════════════════════════════
// All 15 Edge Case Scenarios
// ════════════════════════════════════════════════════════════════
function generateAllScenarios() {
return [
// 1-4: Empty submit tests
emptySubmitScenario(
'edge-empty-submit-sales',
'엣지 케이스: 빈 폼 제출 (판매 > 거래처관리)',
'판매관리', '거래처관리'
),
emptySubmitScenario(
'edge-empty-submit-accounting',
'엣지 케이스: 빈 폼 제출 (회계 > 입금관리)',
'회계관리', '입금관리'
),
emptySubmitScenario(
'edge-empty-submit-hr',
'엣지 케이스: 빈 폼 제출 (인사 > 사원관리)',
'인사관리', '사원관리'
),
emptySubmitScenario(
'edge-empty-submit-board',
'엣지 케이스: 빈 폼 제출 (게시판 > 자유게시판)',
'게시판', '자유게시판'
),
// 5-7: Boundary input tests
boundaryInputScenario(
'edge-boundary-input-sales',
'엣지 케이스: 경계값 입력 (판매 > 거래처관리)',
'판매관리', '거래처관리'
),
boundaryInputScenario(
'edge-boundary-input-accounting',
'엣지 케이스: 경계값 입력 (회계 > 입금관리)',
'회계관리', '입금관리'
),
boundaryInputScenario(
'edge-boundary-input-hr',
'엣지 케이스: 경계값 입력 (인사 > 사원관리)',
'인사관리', '사원관리'
),
// 8-9: Special chars search tests
specialCharsSearchScenario(
'edge-special-chars-search',
'엣지 케이스: 특수문자 검색 (판매 > 거래처관리)',
'판매관리', '거래처관리'
),
specialCharsSearchScenario(
'edge-special-chars-board',
'엣지 케이스: 특수문자 검색 (게시판 > 자유게시판)',
'게시판', '자유게시판'
),
// 10-11: Rapid click save tests
rapidClickSaveScenario(
'edge-rapid-click-save-sales',
'엣지 케이스: 저장 버튼 연타 (판매 > 거래처관리)',
'판매관리', '거래처관리'
),
rapidClickSaveScenario(
'edge-rapid-click-save-board',
'엣지 케이스: 저장 버튼 연타 (게시판 > 자유게시판)',
'게시판', '자유게시판'
),
// 12: Rapid click delete test
rapidClickDeleteScenario(
'edge-rapid-click-delete',
'엣지 케이스: 삭제 버튼 연타 (게시판 > 자유게시판)',
'게시판', '자유게시판'
),
// 13: Numeric boundary test
numericBoundaryScenario(
'edge-numeric-boundary-accounting',
'엣지 케이스: 숫자 경계값 (회계 > 입금관리)',
'회계관리', '입금관리'
),
// 14: Unicode input test
unicodeInputScenario(
'edge-unicode-input-board',
'엣지 케이스: 유니코드 입력 (게시판 > 자유게시판)',
'게시판', '자유게시판'
),
// 15: Concurrent action test
concurrentActionScenario(
'edge-concurrent-action-hr',
'엣지 케이스: 동시 액션 (인사 > 근태관리)',
'인사관리', '근태관리'
),
];
}
// ════════════════════════════════════════════════════════════════
// Main
// ════════════════════════════════════════════════════════════════
function main() {
if (!fs.existsSync(SCENARIOS_DIR)) fs.mkdirSync(SCENARIOS_DIR, { recursive: true });
const scenarios = generateAllScenarios();
let count = 0;
console.log('\n Edge Case Scenario Generator');
console.log(' ═══════════════════════════════════════\n');
for (const scenario of scenarios) {
const fp = path.join(SCENARIOS_DIR, `${scenario.id}.json`);
fs.writeFileSync(fp, JSON.stringify(scenario, null, 2), 'utf-8');
console.log(`${scenario.id}.json (${scenario.steps.length} steps)`);
count++;
}
console.log(`\n ═══════════════════════════════════════`);
console.log(` Generated ${count} edge case scenarios`);
console.log(` Run: node e2e/runner/run-all.js --filter edge\n`);
}
main();

View File

@@ -0,0 +1,255 @@
#!/usr/bin/env node
/**
* 폼 유효성 검증(Form Validation) 감사 시나리오 생성기
*
* 각 페이지에서 필수 필드를 채우지 않고 등록/저장 버튼 클릭 시
* 에러 메시지(토스트, 인라인, 다이얼로그)가 표시되는지 감사.
*
* ⚠️ 감사(Audit) 모드: 항상 PASS, 결과에 발견된 유효성 검증 정보를 기록.
* 유효성 검증이 없는 경우 경고(warn)로 표시.
*
* Usage:
* node e2e/runner/gen-form-validation.js
*
* Output:
* e2e/scenarios/form-validation-acc.json (회계: 어음/입금/출금)
* e2e/scenarios/form-validation-sales.json (판매: 거래처/수주/견적)
* e2e/scenarios/form-validation-misc.json (생산/게시판)
*/
const fs = require('fs');
const path = require('path');
const SCENARIOS_DIR = path.resolve(__dirname, '..', 'scenarios');
// ════════════════════════════════════════════════════════════════
// COMMON SCRIPTS
// ════════════════════════════════════════════════════════════════
const H = `const w=ms=>new Promise(r=>setTimeout(r,ms));`;
// 등록 폼 열기 (기존 input-fields 패턴 재사용)
const OPEN_FORM = [
`(async()=>{`, H,
`const R={phase:'OPEN_FORM'};`,
`const priorities=['등록','추가','작성','글쓰기','신규'];`,
`const exclude=['신규업체','신규거래'];`,
`let btn=null;`,
`for(const kw of priorities){`,
` btn=Array.from(document.querySelectorAll('button')).find(b=>{`,
` const t=b.innerText?.trim()||'';`,
` if(exclude.some(e=>t.includes(e)))return false;`,
` return t.includes(kw)&&b.offsetParent!==null&&!b.disabled;`,
` });if(btn)break;`,
`}`,
`if(!btn){btn=Array.from(document.querySelectorAll('button')).find(b=>b.innerText?.trim()?.startsWith('+')&&b.offsetParent!==null);}`,
`if(!btn){R.err='등록 버튼 없음';R.ok=true;return JSON.stringify(R);}`,
`R.btnText=btn.innerText?.trim();`,
`btn.click();await w(2500);`,
`R.url=location.pathname+location.search;R.ok=true;`,
`return JSON.stringify(R);`,
`})()`,
].join('');
// 빈 폼 제출 + 유효성 검증 감사
const SUBMIT_EMPTY_AND_AUDIT = [
`(async()=>{`, H,
`const R={phase:'VALIDATION_AUDIT'};`,
// Before state
`const beforeToasts=document.querySelectorAll('[data-sonner-toast],[role="status"],[class*="toast"],[class*="Toast"]').length;`,
`const beforeErrors=document.querySelectorAll('[class*="error"],[class*="Error"],[class*="destructive"],[role="alert"]').length;`,
// Find and click submit button (without filling any fields)
`const submitBtn=Array.from(document.querySelectorAll('button')).find(b=>{`,
` const t=b.innerText?.trim()||'';`,
` return(/등록|저장|확인|제출/.test(t))&&b.offsetParent!==null&&!b.disabled;`,
`});`,
`if(!submitBtn){R.err='등록/저장 버튼 없음';R.ok=true;return JSON.stringify(R);}`,
`R.submitBtnText=submitBtn.innerText?.trim();`,
`submitBtn.click();await w(2000);`,
// After state - check for validation indicators
// 1. Toast messages
`const toasts=document.querySelectorAll('[data-sonner-toast],[role="status"],[class*="toast"],[class*="Toast"],[class*="Toaster"] [data-content]');`,
`R.toastCount=toasts.length;`,
`R.newToasts=toasts.length-beforeToasts;`,
`if(toasts.length>0){R.toastTexts=Array.from(toasts).map(t=>t.innerText?.trim().substring(0,80)).filter(t=>t);}`,
// 2. Inline error messages
`const errors=document.querySelectorAll('[class*="error"],[class*="Error"],[class*="destructive"],[role="alert"],[class*="invalid"]');`,
`R.errorCount=errors.length;`,
`R.newErrors=errors.length-beforeErrors;`,
`if(errors.length>0){R.errorTexts=Array.from(errors).slice(0,5).map(e=>e.innerText?.trim().substring(0,60)).filter(t=>t);}`,
// 3. Required field indicators (aria-invalid, :invalid pseudo)
`const invalidFields=document.querySelectorAll('[aria-invalid="true"]');`,
`R.ariaInvalidCount=invalidFields.length;`,
// 4. Red border fields (visual)
`const redBorders=Array.from(document.querySelectorAll('input,textarea,select,[role="combobox"]')).filter(el=>{`,
` const cs=getComputedStyle(el);`,
` return cs.borderColor?.includes('rgb(239')||cs.borderColor?.includes('rgb(220')||cs.borderColor?.includes('rgb(248')||cs.outlineColor?.includes('rgb(239');`,
`});`,
`R.redBorderCount=redBorders.length;`,
// 5. Dialog/alert for validation
`const dialogs=document.querySelectorAll('[role="alertdialog"],[role="dialog"]');`,
`const validationDialog=Array.from(dialogs).find(d=>d.offsetParent!==null);`,
`R.hasValidationDialog=!!validationDialog;`,
`if(validationDialog){R.dialogText=validationDialog.innerText?.trim().substring(0,100);}`,
// 6. URL changed? (might indicate submission succeeded without validation)
`R.urlAfterSubmit=location.pathname+location.search;`,
`R.urlChanged=R.urlAfterSubmit!==location.pathname+location.search;`,
// Summary
`R.totalValidationSignals=R.newToasts+R.newErrors+R.ariaInvalidCount+R.redBorderCount+(R.hasValidationDialog?1:0);`,
`R.hasValidation=R.totalValidationSignals>0;`,
`if(!R.hasValidation)R.warn='유효성 검증 미감지 - 빈 폼 제출 시 에러 메시지 없음';`,
`R.ok=true;`,
`return JSON.stringify(R);`,
`})()`,
].join('');
// 모달/폼 닫기
const CLOSE_FORM = [
`(async()=>{`, H,
`const R={phase:'CLOSE_FORM'};`,
// Close any validation dialog first
`const dlg=document.querySelector('[role="alertdialog"],[role="dialog"]');`,
`if(dlg&&dlg.offsetParent!==null){`,
` const closeBtn=dlg.querySelector('button[class*="close"]')||Array.from(dlg.querySelectorAll('button')).find(b=>/닫기|확인|취소|Close/.test(b.innerText?.trim()));`,
` if(closeBtn){closeBtn.click();await w(500);}`,
` else{document.dispatchEvent(new KeyboardEvent('keydown',{key:'Escape',bubbles:true}));await w(500);}`,
`}`,
// If on form page (mode=new/edit), go back
`if(location.search.includes('mode=new')||location.search.includes('mode=edit')){`,
` const backBtn=Array.from(document.querySelectorAll('button,a')).find(b=>/목록|취소|뒤로/.test(b.innerText?.trim()));`,
` if(backBtn){backBtn.click();await w(2000);}`,
` else{history.back();await w(2000);}`,
`}`,
// Close modal if still open
`const modal=document.querySelector('[role="dialog"],[aria-modal="true"],[class*="modal"]:not([class*="tooltip"])');`,
`if(modal&&modal.offsetParent!==null){`,
` const xBtn=modal.querySelector('button[class*="close"],[aria-label="닫기"],[aria-label="Close"]')||Array.from(modal.querySelectorAll('button')).find(b=>/닫기|취소|Close/.test(b.innerText?.trim()));`,
` if(xBtn){xBtn.click();await w(500);}`,
` else{document.dispatchEvent(new KeyboardEvent('keydown',{key:'Escape',bubbles:true}));await w(500);}`,
`}`,
`R.url=location.pathname+location.search;R.ok=true;`,
`return JSON.stringify(R);`,
`})()`,
].join('');
// ════════════════════════════════════════════════════════════════
// PAGE GROUPS
// ════════════════════════════════════════════════════════════════
const GROUPS = [
{
id: 'form-validation-acc',
name: '폼 유효성 검증 감사: 회계 (어음/입금/출금)',
pages: [
{ level1: '회계관리', level2: '어음관리' },
{ level1: '회계관리', level2: '입금관리' },
{ level1: '회계관리', level2: '출금관리' },
],
},
{
id: 'form-validation-sales',
name: '폼 유효성 검증 감사: 판매 (거래처/수주/견적)',
pages: [
{ level1: '판매관리', level2: '거래처관리' },
{ level1: '판매관리', level2: '수주관리' },
{ level1: '판매관리', level2: '견적관리' },
],
},
{
id: 'form-validation-misc',
name: '폼 유효성 검증 감사: 생산/게시판',
pages: [
{ level1: '생산관리', level2: '작업지시 관리' },
{ level1: '게시판', level2: '자유게시판' },
],
},
];
// ════════════════════════════════════════════════════════════════
// SCENARIO GENERATOR
// ════════════════════════════════════════════════════════════════
function generateScenario(group) {
const steps = [];
let id = 1;
for (let pi = 0; pi < group.pages.length; pi++) {
const page = group.pages[pi];
const pfx = `[${page.level1} > ${page.level2}]`;
// Menu navigate (except first page which uses scenario menuNavigation)
if (pi > 0) {
steps.push({
id: id++,
name: `${pfx} 메뉴 이동`,
action: 'menu_navigate',
level1: page.level1,
level2: page.level2,
timeout: 10000,
});
}
// Page load
steps.push({ id: id++, name: `${pfx} 페이지 로드 대기`, action: 'wait', timeout: 3000 });
steps.push({ id: id++, name: `${pfx} 테이블 로드 대기`, action: 'wait_for_table', timeout: 5000 });
// Open form
steps.push({
id: id++,
name: `${pfx} 등록 폼 열기`,
action: 'evaluate',
script: OPEN_FORM,
timeout: 15000,
});
// Wait for form render
steps.push({ id: id++, name: `${pfx} 폼 렌더링 대기`, action: 'wait', timeout: 2000 });
// Submit empty + audit validation
steps.push({
id: id++,
name: `${pfx} 빈 폼 제출 유효성 감사`,
action: 'evaluate',
script: SUBMIT_EMPTY_AND_AUDIT,
timeout: 15000,
phase: 'VALIDATE',
});
// Close form
steps.push({
id: id++,
name: `${pfx} 폼 닫기`,
action: 'evaluate',
script: CLOSE_FORM,
timeout: 10000,
});
}
return {
id: group.id,
name: group.name,
version: '1.0.0',
auth: { role: 'admin' },
menuNavigation: group.pages[0], // First page
screenshotPolicy: { captureOnFail: true, captureOnPass: false },
steps,
};
}
// ════════════════════════════════════════════════════════════════
// MAIN
// ════════════════════════════════════════════════════════════════
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, `${scenario.id}.json`);
fs.writeFileSync(filePath, JSON.stringify(scenario, null, 2), 'utf-8');
generated.push({ id: scenario.id, steps: scenario.steps.length });
console.log(` ${scenario.id}.json (${scenario.steps.length} steps)`);
}
console.log(`\n Generated ${generated.length} scenarios`);
console.log(`\n Run: node e2e/runner/run-all.js --filter form-validation`);
}
main();

538
e2e/runner/gen-full-crud.js Normal file
View File

@@ -0,0 +1,538 @@
#!/usr/bin/env node
/**
* Full CRUD 사이클 테스트 시나리오 생성기
*
* 각 페이지에서 Create → Read(상세조회) → Update(수정) → Delete 전체 흐름 + 토스트 검증.
* 기존 create-delete 패턴을 확장하여 READ(상세 진입+데이터 확인)와 UPDATE(수정+저장) 단계 추가.
*
* Usage:
* node e2e/runner/gen-full-crud.js
*
* Output:
* e2e/scenarios/full-crud-board.json
* e2e/scenarios/full-crud-acc-bills.json
* e2e/scenarios/full-crud-acc-deposit.json
*/
const fs = require('fs');
const path = require('path');
const SCENARIOS_DIR = path.resolve(__dirname, '..', 'scenarios');
// ════════════════════════════════════════════════════════════════
// COMMON HELPERS
// ════════════════════════════════════════════════════════════════
const H = [
`const w=ms=>new Promise(r=>setTimeout(r,ms));`,
`const sv=(el,v)=>{const p=el.tagName==='TEXTAREA'?HTMLTextAreaElement.prototype:HTMLInputElement.prototype;const ns=Object.getOwnPropertyDescriptor(p,'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}));};`,
`const ts=window.__E2E_TS__||(()=>{const n=new Date();const p=v=>v.toString().padStart(2,'0');return n.getFullYear()+p(n.getMonth()+1)+p(n.getDate())+'_'+p(n.getHours())+p(n.getMinutes())+p(n.getSeconds());})();window.__E2E_TS__=ts;`,
`const toastInfo=()=>{const ts=document.querySelectorAll('[data-sonner-toast],[role="status"],[class*="toast"],[class*="Toast"],[class*="Toaster"] [data-content]');return{count:ts.length,text:ts.length>0?Array.from(ts).pop()?.innerText?.trim().substring(0,100):''};};`,
].join('');
const BACK_TO_LIST = `(async()=>{const w=ms=>new Promise(r=>setTimeout(r,ms));const onForm=location.search.includes('mode=new')||location.search.includes('mode=edit')||location.search.includes('mode=view')||new RegExp('/(new|[0-9]+|[0-9a-f]{8,})$').test(location.pathname);if(onForm){const btn=Array.from(document.querySelectorAll('button,a')).find(b=>/목록|취소|뒤로/.test(b.innerText?.trim()));if(btn){btn.click();await w(2000);}else{history.back();await w(2000);}}return JSON.stringify({url:location.pathname+location.search});})()`;
// ════════════════════════════════════════════════════════════════
// PAGE CONFIGURATIONS
// ════════════════════════════════════════════════════════════════
const PAGES = {
// ─── 자유게시판 ─────────────────────────────────────────────
board: {
id: 'full-crud-board',
name: 'Full CRUD 테스트: 자유게시판',
menuNavigation: { level1: '게시판', level2: '자유게시판' },
createScript: [
`(async()=>{`, H,
`const R={phase:'CREATE',ts};`,
`const testTitle='E2E_TEST_게시글_'+ts;R.testTitle=testTitle;`,
`const btn=Array.from(document.querySelectorAll('button')).find(b=>b.innerText?.trim()==='글쓰기'||/등록|작성/.test(b.innerText?.trim()));`,
`if(!btn){R.error='글쓰기 버튼 없음';return JSON.stringify(R);}`,
`btn.click();await w(2500);`,
`const titleInput=document.querySelector('input[placeholder*="제목"]')||document.querySelector('input[type="text"]');`,
`if(!titleInput){R.error='제목 입력란 없음';return JSON.stringify(R);}`,
`sv(titleInput,testTitle);await w(200);`,
`const ta=document.querySelector('textarea');`,
`if(ta){sv(ta,'E2E Full CRUD 테스트 게시글. 자동 삭제 예정.');await w(200);}`,
`const sub=Array.from(document.querySelectorAll('button')).find(b=>b.innerText?.trim()==='등록');`,
`if(!sub){R.error='등록 버튼 없음';return JSON.stringify(R);}`,
`sub.click();await w(3000);`,
`R.toast=toastInfo();R.ok=true;`,
`return JSON.stringify(R);`,
`})()`,
].join(''),
verifyCreateScript: [
`(async()=>{`, H,
`const R={phase:'VERIFY_CREATE'};await w(500);`,
`const rows=document.querySelectorAll('table tbody tr');R.rowCount=rows.length;`,
`const found=Array.from(rows).find(r=>r.innerText?.includes('E2E_TEST_'));`,
`R.found=!!found;R.ok=R.found;`,
`if(found)R.foundText=found.innerText?.substring(0,80);`,
`R.toast=toastInfo();`,
`return JSON.stringify(R);`,
`})()`,
].join(''),
readScript: [
`(async()=>{`, H,
`const R={phase:'READ'};`,
`let row;for(let i=0;i<3;i++){const rows=Array.from(document.querySelectorAll('table tbody tr'));row=rows.find(r=>r.innerText?.includes('E2E_TEST_'));if(row)break;await w(1000);}`,
`if(!row){R.error='E2E_TEST_ 행 없음';R.ok=false;return JSON.stringify(R);}`,
`row.click();await w(2500);`,
`R.detailUrl=location.pathname+location.search;`,
`const inputs=Array.from(document.querySelectorAll('input,textarea')).filter(i=>i.offsetParent!==null);`,
`R.hasE2E=document.body.innerText.includes('E2E_TEST_')||inputs.some(i=>i.value?.includes('E2E_TEST_'));`,
`R.ok=R.hasE2E;`,
`return JSON.stringify(R);`,
`})()`,
].join(''),
readVerifyScript: [
`(async()=>{`, H,
`const R={phase:'READ_VERIFY'};`,
`R.url=location.pathname+location.search;`,
`const pageText=document.body.innerText;`,
`R.hasTitle=pageText.includes('E2E_TEST_게시글');`,
`R.hasContent=pageText.includes('Full CRUD 테스트');`,
`R.ok=R.hasTitle;`,
`return JSON.stringify(R);`,
`})()`,
].join(''),
updateScript: [
`(async()=>{`, H,
`const R={phase:'UPDATE'};`,
// Click 수정 button
`const editBtn=Array.from(document.querySelectorAll('button')).find(b=>b.innerText?.trim()==='수정');`,
`if(!editBtn){R.error='수정 버튼 없음';R.ok=false;return JSON.stringify(R);}`,
`editBtn.click();await w(2000);`,
`R.editUrl=location.pathname+location.search;`,
// Find title input and modify
`const titleInput=document.querySelector('input[placeholder*="제목"]')||document.querySelector('input[type="text"]');`,
`if(!titleInput){R.error='제목 입력란 없음';R.ok=false;return JSON.stringify(R);}`,
`const cur=titleInput.value;`,
`sv(titleInput,cur.replace('E2E_TEST_게시글','E2E_TEST_수정됨'));await w(200);`,
// Click save
`const saveBtn=Array.from(document.querySelectorAll('button')).find(b=>/저장|수정완료|확인/.test(b.innerText?.trim())&&b!==editBtn&&b.offsetParent!==null);`,
`if(saveBtn){saveBtn.click();await w(3000);}`,
`else{const sub=Array.from(document.querySelectorAll('button')).find(b=>b.innerText?.trim()==='등록');if(sub){sub.click();await w(3000);}}`,
`R.toast=toastInfo();R.ok=true;`,
`return JSON.stringify(R);`,
`})()`,
].join(''),
verifyUpdateScript: [
`(async()=>{`, H,
`const R={phase:'VERIFY_UPDATE'};`,
`R.url=location.pathname+location.search;`,
`const pageText=document.body.innerText;`,
`const inputs=Array.from(document.querySelectorAll('input,textarea')).filter(i=>i.offsetParent!==null);`,
`R.hasModified=pageText.includes('수정됨')||inputs.some(i=>i.value?.includes('수정됨'));`,
`R.toast=toastInfo();`,
`const toastOk=R.toast.text&&(/수정|완료|저장|성공/.test(R.toast.text));`,
`R.toastOk=toastOk;R.ok=R.hasModified||toastOk;`,
`return JSON.stringify(R);`,
`})()`,
].join(''),
deleteScript: [
`(async()=>{`, H,
`const R={phase:'DELETE'};`,
// If on list, find and click the row first
`const onDetail=location.search.includes('mode=view')||location.search.includes('mode=edit')||new RegExp('/[0-9]+$|/[0-9a-f]{8,}$').test(location.pathname);`,
`if(!onDetail){`,
` const rows=Array.from(document.querySelectorAll('table tbody tr'));`,
` const row=rows.find(r=>r.innerText?.includes(ts))||rows.find(r=>r.innerText?.includes('E2E_TEST_'));`,
` if(!row){R.error='E2E_TEST_ 행 없음';R.ok=false;return JSON.stringify(R);}`,
` row.click();await w(2500);`,
`}`,
`R.detailUrl=location.pathname+location.search;R.ts=ts;`,
`const delBtn=Array.from(document.querySelectorAll('button')).find(b=>b.innerText?.trim()==='삭제');`,
`if(!delBtn){R.error='삭제 버튼 없음';R.ok=false;return JSON.stringify(R);}`,
`delBtn.click();await w(1000);`,
`const cfm=Array.from(document.querySelectorAll('[role="alertdialog"] button,[role="dialog"] button,button')).find(b=>/확인|삭제|예|Yes/.test(b.innerText?.trim())&&b!==delBtn&&b.offsetParent!==null);`,
`if(cfm){cfm.click();await w(3000);}`,
`R.toast=toastInfo();R.ok=true;`,
`return JSON.stringify(R);`,
`})()`,
].join(''),
verifyDeleteScript: [
`(async()=>{`, H,
`const R={phase:'VERIFY_DELETE'};await w(1000);`,
`const onDetail=location.search.includes('mode=view')||new RegExp('/[0-9]+$|/[0-9a-f]{8,}$').test(location.pathname);`,
`if(onDetail){const btn=Array.from(document.querySelectorAll('button,a')).find(b=>/목록/.test(b.innerText?.trim()));if(btn){btn.click();await w(2000);}else{history.back();await w(2000);}}`,
`R.url=location.pathname;`,
`const rows=document.querySelectorAll('table tbody tr');R.rowCount=rows.length;`,
`const found=Array.from(rows).find(r=>r.innerText?.includes(ts));`,
`R.stillExists=!!found;R.ok=!found;`,
`if(found)R.warn='E2E_TEST_ 데이터가 여전히 존재';`,
`R.ts=ts;R.toast=toastInfo();`,
`return JSON.stringify(R);`,
`})()`,
].join(''),
},
// ─── 어음관리 ───────────────────────────────────────────────
bills: {
id: 'full-crud-acc-bills',
name: 'Full CRUD 테스트: 어음관리',
menuNavigation: { level1: '회계관리', level2: '어음관리' },
createScript: [
`(async()=>{`, H,
`const R={phase:'CREATE',ts};`,
`const testId='E2E'+ts.replace(/_/g,'').substring(4,10);R.testId=testId;`,
`const btn=Array.from(document.querySelectorAll('button')).find(b=>/어음.*등록|등록/.test(b.innerText?.trim()));`,
`if(!btn){R.error='등록 버튼 없음';return JSON.stringify(R);}`,
`btn.click();await w(2500);`,
// 어음번호
`const numInput=document.querySelector('input[placeholder*="어음번호"]')||Array.from(document.querySelectorAll('input[type="text"]')).find(i=>i.offsetParent!==null&&!i.readOnly&&!i.disabled);`,
`if(numInput){sv(numInput,'E2E_TEST_'+testId);await w(200);}`,
// 거래처 combobox
`const combos=Array.from(document.querySelectorAll('button[role="combobox"]')).filter(b=>b.offsetParent!==null);`,
`for(let i=0;i<combos.length;i++){const cb=combos[i];const lbl=cb.closest('[class*=field],[class*=Field],[class*=form-item]')?.querySelector('label')?.innerText||'';`,
`if(lbl.includes('거래처')||i===1){cb.click();await w(600);const lb=document.querySelector('[role="listbox"]');`,
`if(lb){const opt=lb.querySelector('[role="option"]');if(opt){opt.click();await w(400);}}`,
`else{document.dispatchEvent(new KeyboardEvent('keydown',{key:'Escape',bubbles:true}));await w(200);}break;}}`,
// 금액
`const amt=document.querySelector('input[placeholder*="금액"]');if(amt){sv(amt,'10000');await w(200);}`,
// 비고
`const note=document.querySelector('input[placeholder*="비고"]');if(note){sv(note,'E2E_TEST_어음_'+ts);await w(200);}`,
// 등록
`const sub=Array.from(document.querySelectorAll('button')).find(b=>b.innerText?.trim()==='등록'&&b.offsetParent!==null);`,
`if(!sub){R.error='등록 버튼 없음';return JSON.stringify(R);}`,
`sub.click();await w(3000);`,
`R.toast=toastInfo();R.ok=true;`,
`return JSON.stringify(R);`,
`})()`,
].join(''),
verifyCreateScript: [
`(async()=>{`, H,
`const R={phase:'VERIFY_CREATE'};await w(500);`,
`const rows=document.querySelectorAll('table tbody tr');R.rowCount=rows.length;`,
`const found=Array.from(rows).find(r=>r.innerText?.includes('E2E_TEST_')||r.innerText?.includes('E2E'));`,
`R.found=!!found;R.ok=R.found;`,
`if(found)R.foundText=found.innerText?.substring(0,80);`,
`R.toast=toastInfo();`,
`return JSON.stringify(R);`,
`})()`,
].join(''),
readScript: [
`(async()=>{`, H,
`const R={phase:'READ'};`,
`let row;for(let i=0;i<3;i++){const rows=Array.from(document.querySelectorAll('table tbody tr'));row=rows.find(r=>r.innerText?.includes('E2E_TEST_')||r.innerText?.includes('E2E'));if(row)break;await w(1000);}`,
`if(!row){R.error='E2E_TEST_ 행 없음';R.ok=false;return JSON.stringify(R);}`,
`row.click();await w(2500);`,
`R.detailUrl=location.pathname+location.search;`,
`const inputs=Array.from(document.querySelectorAll('input,textarea')).filter(i=>i.offsetParent!==null);`,
`R.hasE2E=document.body.innerText.includes('E2E_TEST_')||document.body.innerText.includes('E2E')||inputs.some(i=>i.value?.includes('E2E_TEST_')||i.value?.includes('E2E'));`,
`R.ok=R.hasE2E;`,
`return JSON.stringify(R);`,
`})()`,
].join(''),
readVerifyScript: [
`(async()=>{`, H,
`const R={phase:'READ_VERIFY'};`,
`R.url=location.pathname+location.search;`,
`const inputs=Array.from(document.querySelectorAll('input,textarea')).filter(i=>i.offsetParent!==null);`,
`R.fieldCount=inputs.length;`,
`const hasTestData=inputs.some(i=>i.value?.includes('E2E_TEST_')||i.value?.includes('E2E'));`,
`R.hasTestData=hasTestData||document.body.innerText.includes('E2E_TEST_');`,
`R.ok=R.hasTestData;`,
`return JSON.stringify(R);`,
`})()`,
].join(''),
updateScript: [
`(async()=>{`, H,
`const R={phase:'UPDATE'};`,
`const editBtn=Array.from(document.querySelectorAll('button')).find(b=>b.innerText?.trim()==='수정');`,
`if(!editBtn){R.error='수정 버튼 없음';R.ok=false;return JSON.stringify(R);}`,
`editBtn.click();await w(2000);`,
// Modify 비고 field
`const noteInput=document.querySelector('input[placeholder*="비고"]')||Array.from(document.querySelectorAll('input[type="text"]')).filter(i=>i.offsetParent!==null&&!i.readOnly&&!i.disabled).pop();`,
`if(noteInput){sv(noteInput,'E2E_수정됨_'+ts);await w(200);R.modified='비고';}`,
`else{const inputs=Array.from(document.querySelectorAll('input[type="text"]')).filter(i=>i.offsetParent!==null&&!i.readOnly&&!i.disabled);`,
`if(inputs.length>0){sv(inputs[0],inputs[0].value+'_수정됨');await w(200);R.modified='input[0]';}}`,
// Save
`const saveBtn=Array.from(document.querySelectorAll('button')).find(b=>/저장|수정|확인/.test(b.innerText?.trim())&&b!==editBtn&&b.offsetParent!==null);`,
`if(saveBtn){saveBtn.click();await w(3000);}`,
`R.toast=toastInfo();R.ok=true;`,
`return JSON.stringify(R);`,
`})()`,
].join(''),
verifyUpdateScript: [
`(async()=>{`, H,
`const R={phase:'VERIFY_UPDATE'};`,
`const pageText=document.body.innerText;`,
`const inputs=Array.from(document.querySelectorAll('input,textarea')).filter(i=>i.offsetParent!==null);`,
`const hasModified=pageText.includes('수정됨')||inputs.some(i=>i.value?.includes('수정됨'));`,
`R.toast=toastInfo();`,
`const toastOk=R.toast.text&&(/수정|완료|저장|성공/.test(R.toast.text));`,
`R.hasModified=hasModified;R.toastOk=toastOk;R.ok=hasModified||toastOk;`,
`return JSON.stringify(R);`,
`})()`,
].join(''),
deleteScript: [
`(async()=>{`, H,
`const R={phase:'DELETE'};`,
`if(!location.search.includes('mode=view')&&!location.search.includes('mode=edit')){`,
` const rows=Array.from(document.querySelectorAll('table tbody tr'));`,
` const row=rows.find(r=>r.innerText?.includes('E2E_TEST_')||r.innerText?.includes('E2E'));`,
` if(!row){R.error='E2E_TEST_ 행 없음';R.ok=false;return JSON.stringify(R);}`,
` row.click();await w(2500);`,
`}`,
`const delBtn=Array.from(document.querySelectorAll('button')).find(b=>b.innerText?.trim()==='삭제');`,
`if(!delBtn){R.error='삭제 버튼 없음';R.ok=false;return JSON.stringify(R);}`,
`delBtn.click();await w(1000);`,
`const cfm=Array.from(document.querySelectorAll('[role="alertdialog"] button,[role="dialog"] button,button')).find(b=>/확인|삭제|예|Yes/.test(b.innerText?.trim())&&b!==delBtn&&b.offsetParent!==null);`,
`if(cfm){cfm.click();await w(3000);}`,
`R.toast=toastInfo();R.ok=true;`,
`return JSON.stringify(R);`,
`})()`,
].join(''),
verifyDeleteScript: [
`(async()=>{`, H,
`const R={phase:'VERIFY_DELETE'};await w(1000);`,
`if(location.search.includes('mode=view')){`,
` const btn=Array.from(document.querySelectorAll('button,a')).find(b=>/목록/.test(b.innerText?.trim()));`,
` if(btn){btn.click();await w(2000);}else{history.back();await w(2000);}}`,
`const rows=document.querySelectorAll('table tbody tr');R.rowCount=rows.length;`,
`const found=Array.from(rows).find(r=>r.innerText?.includes(ts));`,
`R.stillExists=!!found;R.ok=!found;R.ts=ts;`,
`R.toast=toastInfo();`,
`return JSON.stringify(R);`,
`})()`,
].join(''),
},
// ─── 입금관리 ───────────────────────────────────────────────
deposit: {
id: 'full-crud-acc-deposit',
name: 'Full CRUD 테스트: 입금관리',
menuNavigation: { level1: '회계관리', level2: '입금관리' },
createScript: [
`(async()=>{`, H,
`const R={phase:'CREATE',ts};`,
`const btn=Array.from(document.querySelectorAll('button')).find(b=>/입금.*등록|입금등록|등록/.test(b.innerText?.trim()));`,
`if(!btn){R.error='등록 버튼 없음';return JSON.stringify(R);}`,
`btn.click();await w(2500);`,
// 입금자명
`const nameIn=document.querySelector('input[placeholder*="입금자명"]')||document.querySelector('input[placeholder*="입금자"]');`,
`if(nameIn){sv(nameIn,'E2E_TEST_입금자_'+ts);await w(200);}`,
// 입금금액
`const amtIn=document.querySelector('input[placeholder*="입금금액"]')||document.querySelector('input[type="number"]');`,
`if(amtIn){sv(amtIn,'50000');await w(200);}`,
// 적요
`const noteIn=document.querySelector('input[placeholder*="적요"]');`,
`if(noteIn){sv(noteIn,'E2E_TEST_입금_'+ts);await w(200);}`,
// 거래처 combobox
`const combos=Array.from(document.querySelectorAll('button[role="combobox"]')).filter(b=>b.offsetParent!==null);`,
`for(const cb of combos){const lbl=cb.closest('[class*=field],[class*=Field],[class*=form-item]')?.querySelector('label')?.innerText||'';`,
`if(lbl.includes('거래처')){cb.click();await w(600);const lb=document.querySelector('[role="listbox"]');`,
`if(lb){const opt=lb.querySelector('[role="option"]');if(opt){opt.click();await w(400);}}`,
`else{document.dispatchEvent(new KeyboardEvent('keydown',{key:'Escape',bubbles:true}));await w(200);}break;}}`,
// 입금유형 combobox
`for(const cb of combos){const lbl=cb.closest('[class*=field],[class*=Field],[class*=form-item]')?.querySelector('label')?.innerText||'';`,
`if(lbl.includes('유형')){cb.click();await w(600);const lb=document.querySelector('[role="listbox"]');`,
`if(lb){const opt=lb.querySelector('[role="option"]');if(opt){opt.click();await w(400);}}`,
`else{document.dispatchEvent(new KeyboardEvent('keydown',{key:'Escape',bubbles:true}));await w(200);}break;}}`,
// 등록
`const sub=Array.from(document.querySelectorAll('button')).find(b=>b.innerText?.trim()==='등록'&&b.offsetParent!==null);`,
`if(!sub){R.error='등록 버튼 없음';return JSON.stringify(R);}`,
`sub.click();await w(3000);`,
`R.toast=toastInfo();R.ok=true;`,
`return JSON.stringify(R);`,
`})()`,
].join(''),
verifyCreateScript: [
`(async()=>{`, H,
`const R={phase:'VERIFY_CREATE'};await w(500);`,
`const rows=document.querySelectorAll('table tbody tr');R.rowCount=rows.length;`,
`const found=Array.from(rows).find(r=>r.innerText?.includes('E2E_TEST_'));`,
`R.found=!!found;R.ok=R.found;`,
`if(found)R.foundText=found.innerText?.substring(0,80);`,
`R.toast=toastInfo();`,
`return JSON.stringify(R);`,
`})()`,
].join(''),
readScript: [
`(async()=>{`, H,
`const R={phase:'READ'};`,
`let row;for(let i=0;i<3;i++){const rows=Array.from(document.querySelectorAll('table tbody tr'));row=rows.find(r=>r.innerText?.includes('E2E_TEST_'));if(row)break;await w(1000);}`,
`if(!row){R.error='E2E_TEST_ 행 없음';R.ok=false;return JSON.stringify(R);}`,
`row.click();await w(2500);`,
`R.detailUrl=location.pathname+location.search;`,
`const inputs=Array.from(document.querySelectorAll('input,textarea')).filter(i=>i.offsetParent!==null);`,
`R.hasE2E=document.body.innerText.includes('E2E_TEST_')||inputs.some(i=>i.value?.includes('E2E_TEST_'));`,
`R.ok=R.hasE2E;`,
`return JSON.stringify(R);`,
`})()`,
].join(''),
readVerifyScript: [
`(async()=>{`, H,
`const R={phase:'READ_VERIFY'};`,
`R.url=location.pathname+location.search;`,
`const inputs=Array.from(document.querySelectorAll('input,textarea')).filter(i=>i.offsetParent!==null);`,
`R.fieldCount=inputs.length;`,
`const hasTestData=inputs.some(i=>i.value?.includes('E2E_TEST_'))||document.body.innerText.includes('E2E_TEST_');`,
`R.hasTestData=hasTestData;R.ok=hasTestData;`,
`return JSON.stringify(R);`,
`})()`,
].join(''),
updateScript: [
`(async()=>{`, H,
`const R={phase:'UPDATE'};`,
`const editBtn=Array.from(document.querySelectorAll('button')).find(b=>b.innerText?.trim()==='수정');`,
`if(!editBtn){R.error='수정 버튼 없음';R.ok=false;return JSON.stringify(R);}`,
`editBtn.click();await w(2000);`,
// Modify 적요 field
`const noteIn=document.querySelector('input[placeholder*="적요"]');`,
`if(noteIn){sv(noteIn,'E2E_수정됨_'+ts);await w(200);R.modified='적요';}`,
`else{const inputs=Array.from(document.querySelectorAll('input[type="text"]')).filter(i=>i.offsetParent!==null&&!i.readOnly&&!i.disabled);`,
`if(inputs.length>0){sv(inputs[0],inputs[0].value+'_수정됨');await w(200);R.modified='input[0]';}}`,
// Save
`const saveBtn=Array.from(document.querySelectorAll('button')).find(b=>/저장|수정|확인/.test(b.innerText?.trim())&&b!==editBtn&&b.offsetParent!==null);`,
`if(saveBtn){saveBtn.click();await w(3000);}`,
`R.toast=toastInfo();R.ok=true;`,
`return JSON.stringify(R);`,
`})()`,
].join(''),
verifyUpdateScript: [
`(async()=>{`, H,
`const R={phase:'VERIFY_UPDATE'};`,
`const pageText=document.body.innerText;`,
`const inputs=Array.from(document.querySelectorAll('input,textarea')).filter(i=>i.offsetParent!==null);`,
`const hasModified=pageText.includes('수정됨')||inputs.some(i=>i.value?.includes('수정됨'));`,
`R.toast=toastInfo();`,
`const toastOk=R.toast.text&&(/수정|완료|저장|성공/.test(R.toast.text));`,
`R.hasModified=hasModified;R.toastOk=toastOk;R.ok=hasModified||toastOk;`,
`return JSON.stringify(R);`,
`})()`,
].join(''),
deleteScript: [
`(async()=>{`, H,
`const R={phase:'DELETE'};`,
`if(!location.search.includes('mode=view')&&!location.search.includes('mode=edit')){`,
` const rows=Array.from(document.querySelectorAll('table tbody tr'));`,
` const row=rows.find(r=>r.innerText?.includes('E2E_TEST_'));`,
` if(!row){R.error='E2E_TEST_ 행 없음';R.ok=false;return JSON.stringify(R);}`,
` row.click();await w(2500);`,
`}`,
`const delBtn=Array.from(document.querySelectorAll('button')).find(b=>b.innerText?.trim()==='삭제');`,
`if(!delBtn){R.error='삭제 버튼 없음';R.ok=false;return JSON.stringify(R);}`,
`delBtn.click();await w(1000);`,
`const cfm=Array.from(document.querySelectorAll('[role="alertdialog"] button,[role="dialog"] button,button')).find(b=>/확인|삭제|예|Yes/.test(b.innerText?.trim())&&b!==delBtn&&b.offsetParent!==null);`,
`if(cfm){cfm.click();await w(3000);}`,
`R.toast=toastInfo();R.ok=true;`,
`return JSON.stringify(R);`,
`})()`,
].join(''),
verifyDeleteScript: [
`(async()=>{`, H,
`const R={phase:'VERIFY_DELETE'};await w(1000);`,
`if(location.search.includes('mode=view')){`,
` const btn=Array.from(document.querySelectorAll('button,a')).find(b=>/목록/.test(b.innerText?.trim()));`,
` if(btn){btn.click();await w(2000);}else{history.back();await w(2000);}}`,
`const rows=document.querySelectorAll('table tbody tr');R.rowCount=rows.length;`,
`const found=Array.from(rows).find(r=>r.innerText?.includes(ts));`,
`R.stillExists=!!found;R.ok=!found;R.ts=ts;`,
`R.toast=toastInfo();`,
`return JSON.stringify(R);`,
`})()`,
].join(''),
},
};
// ════════════════════════════════════════════════════════════════
// SCENARIO GENERATOR
// ════════════════════════════════════════════════════════════════
function generateScenario(pageKey) {
const pg = PAGES[pageKey];
const steps = [];
let id = 1;
const nav = pg.menuNavigation;
const pfx = `[${nav.level1} > ${nav.level2}]`;
// ─── SETUP ───
steps.push({ id: id++, name: `${pfx} 페이지 로드 대기`, action: 'wait', timeout: 3000 });
steps.push({ id: id++, name: `${pfx} 테이블 로드 대기`, action: 'wait_for_table', timeout: 5000 });
// ─── CREATE ───
steps.push({ id: id++, name: `${pfx} [CREATE] 데이터 생성`, action: 'evaluate', script: pg.createScript, timeout: 30000, phase: 'CREATE' });
steps.push({ id: id++, name: `${pfx} [CREATE] 생성 후 대기`, action: 'wait', timeout: 3000 });
steps.push({ id: id++, name: `${pfx} [CREATE] 목록 복귀`, action: 'evaluate', script: BACK_TO_LIST, timeout: 10000, phase: 'CREATE' });
steps.push({ id: id++, name: `${pfx} [CREATE] 목록 안정화 대기`, action: 'wait', timeout: 2000 });
// ─── VERIFY CREATE ───
steps.push({ id: id++, name: `${pfx} [VERIFY] 생성 데이터 확인`, action: 'evaluate', script: pg.verifyCreateScript, timeout: 15000, phase: 'VERIFY' });
// ─── READ (상세 조회) ───
steps.push({ id: id++, name: `${pfx} [READ] 상세 페이지 진입`, action: 'evaluate', script: pg.readScript, timeout: 15000, phase: 'READ' });
steps.push({ id: id++, name: `${pfx} [READ] 상세 페이지 대기`, action: 'wait', timeout: 2000 });
steps.push({ id: id++, name: `${pfx} [READ] 상세 데이터 검증`, action: 'evaluate', script: pg.readVerifyScript, timeout: 15000, phase: 'READ' });
// ─── UPDATE (수정) ───
steps.push({ id: id++, name: `${pfx} [UPDATE] 수정 및 저장`, action: 'evaluate', script: pg.updateScript, timeout: 30000, phase: 'UPDATE' });
steps.push({ id: id++, name: `${pfx} [UPDATE] 저장 후 대기`, action: 'wait', timeout: 3000 });
steps.push({ id: id++, name: `${pfx} [UPDATE] 수정 내용 검증`, action: 'evaluate', script: pg.verifyUpdateScript, timeout: 15000, phase: 'UPDATE' });
// ─── Back to list for DELETE ───
steps.push({ id: id++, name: `${pfx} [UPDATE] 목록 복귀`, action: 'evaluate', script: BACK_TO_LIST, timeout: 10000, phase: 'UPDATE' });
steps.push({ id: id++, name: `${pfx} [UPDATE] 목록 안정화 대기`, action: 'wait', timeout: 2000 });
// ─── DELETE ───
steps.push({ id: id++, name: `${pfx} [DELETE] 데이터 삭제`, action: 'evaluate', script: pg.deleteScript, timeout: 30000, phase: 'DELETE', critical: true });
steps.push({ id: id++, name: `${pfx} [DELETE] 삭제 후 대기`, action: 'wait', timeout: 3000 });
steps.push({ id: id++, name: `${pfx} [DELETE] 목록 복귀`, action: 'evaluate', script: BACK_TO_LIST, timeout: 10000, phase: 'DELETE' });
steps.push({ id: id++, name: `${pfx} [DELETE] 목록 안정화 대기`, action: 'wait', timeout: 2000 });
// ─── VERIFY DELETE ───
steps.push({ id: id++, name: `${pfx} [VERIFY] 삭제 확인`, action: 'evaluate', script: pg.verifyDeleteScript, timeout: 15000, phase: 'VERIFY' });
return {
id: pg.id,
name: pg.name,
version: '1.0.0',
auth: { role: 'admin' },
menuNavigation: pg.menuNavigation,
screenshotPolicy: { captureOnFail: true, captureOnPass: false },
steps,
};
}
// ════════════════════════════════════════════════════════════════
// MAIN
// ════════════════════════════════════════════════════════════════
function main() {
if (!fs.existsSync(SCENARIOS_DIR)) fs.mkdirSync(SCENARIOS_DIR, { recursive: true });
const generated = [];
for (const key of Object.keys(PAGES)) {
const scenario = generateScenario(key);
const filePath = path.join(SCENARIOS_DIR, `${scenario.id}.json`);
fs.writeFileSync(filePath, JSON.stringify(scenario, null, 2), 'utf-8');
generated.push({ id: scenario.id, steps: scenario.steps.length });
console.log(` ${scenario.id}.json (${scenario.steps.length} steps)`);
}
console.log(`\n Generated ${generated.length} scenarios`);
console.log(`\n Run: node e2e/runner/run-all.js --filter full-crud`);
}
main();

View File

@@ -0,0 +1,402 @@
#!/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();

View File

@@ -0,0 +1,221 @@
#!/usr/bin/env node
/**
* 테이블 페이지네이션 & 정렬 검증 시나리오 생성기
*
* 실제 고객이 가장 많이 사용하는 기능: 목록 페이지 이동, 컬럼 정렬.
* 버그 발견 포인트: 중복 데이터, 정렬 미동작, 페이지간 데이터 불일치, 빈 페이지.
*
* Usage: node e2e/runner/gen-pagination-sort.js
*
* Output:
* e2e/scenarios/pagination-sort-acc.json
* e2e/scenarios/pagination-sort-sales.json
* e2e/scenarios/pagination-sort-hr.json
*/
const fs = require('fs');
const path = require('path');
const SCENARIOS_DIR = path.resolve(__dirname, '..', 'scenarios');
const H = `const w=ms=>new Promise(r=>setTimeout(r,ms));`;
// ── 페이지네이션 검증 스크립트 ──────────────────────────────────
// 1) 현재 페이지 행 수 확인
// 2) 다음 페이지 버튼 클릭
// 3) 데이터 변경 확인 (1페이지와 2페이지 첫 행이 달라야 함)
// 4) 이전 페이지 복귀 확인
const PAGINATION_TEST = [
`(async()=>{`, H,
`const R={phase:'PAGINATION'};`,
// Get current page data
`const rows1=Array.from(document.querySelectorAll('table tbody tr'));`,
`R.page1RowCount=rows1.length;`,
`R.page1FirstRow=rows1[0]?.innerText?.substring(0,60)||'';`,
`R.page1LastRow=rows1[rows1.length-1]?.innerText?.substring(0,60)||'';`,
// Find pagination controls
`const paginationBtns=Array.from(document.querySelectorAll('button,a,[role="button"]')).filter(b=>{`,
` const t=b.innerText?.trim()||'';const al=b.getAttribute('aria-label')||'';`,
` return(/^[2-9]$|^\\d{2,}$/.test(t)||/next|다음|chevron.?right||»|>/.test(t+al+b.className))&&b.offsetParent!==null;`,
`});`,
`R.paginationBtnCount=paginationBtns.length;`,
// Also check for shadcn pagination: nav[aria-label] with buttons
`const navPagination=document.querySelector('nav[aria-label*="pagination"],nav[aria-label*="page"]');`,
`if(navPagination){`,
` const navBtns=Array.from(navPagination.querySelectorAll('button,a')).filter(b=>b.offsetParent!==null);`,
` R.navPaginationBtns=navBtns.length;`,
`}`,
// Find "next page" button
`let nextBtn=paginationBtns.find(b=>{const t=(b.innerText?.trim()||'')+(b.getAttribute('aria-label')||'');return/next|다음||»|chevron.?right/i.test(t+b.className);});`,
`if(!nextBtn)nextBtn=paginationBtns.find(b=>b.innerText?.trim()==='2');`,
`if(!nextBtn&&navPagination){nextBtn=Array.from(navPagination.querySelectorAll('button,a')).find(b=>/next|다음||»/i.test((b.innerText||'')+(b.getAttribute('aria-label')||'')+b.className)&&b.offsetParent!==null);}`,
`R.hasNextBtn=!!nextBtn;`,
`if(!nextBtn){R.warn='페이지네이션 버튼 없음 (데이터 부족 또는 미구현)';R.ok=true;return JSON.stringify(R);}`,
// Click next page
`nextBtn.click();await w(2000);`,
`const rows2=Array.from(document.querySelectorAll('table tbody tr'));`,
`R.page2RowCount=rows2.length;`,
`R.page2FirstRow=rows2[0]?.innerText?.substring(0,60)||'';`,
// Verify different data
`R.dataChanged=R.page1FirstRow!==R.page2FirstRow;`,
`R.hasRows=R.page2RowCount>0;`,
`if(!R.dataChanged&&R.hasRows)R.warn='⚠️ 페이지 변경 후 동일 데이터 표시 (페이지네이션 미동작 의심)';`,
`if(!R.hasRows)R.warn='⚠️ 2페이지에 데이터 없음';`,
// Go back to page 1
`const prevBtn=Array.from(document.querySelectorAll('button,a,[role="button"]')).find(b=>{const t=(b.innerText?.trim()||'')+(b.getAttribute('aria-label')||'');return(/prev|이전||«|chevron.?left/i.test(t+b.className)||b.innerText?.trim()==='1')&&b.offsetParent!==null;});`,
`if(!prevBtn&&navPagination){const pb=Array.from(navPagination.querySelectorAll('button,a')).find(b=>/prev|이전||«/i.test((b.innerText||'')+(b.getAttribute('aria-label')||'')+b.className)&&b.offsetParent!==null);if(pb)pb.click();}`,
`else if(prevBtn){prevBtn.click();}`,
`await w(1500);`,
`const rows3=Array.from(document.querySelectorAll('table tbody tr'));`,
`R.backToPage1=rows3[0]?.innerText?.substring(0,60)===R.page1FirstRow;`,
`if(!R.backToPage1)R.warn=(R.warn||'')+' ⚠️ 1페이지 복귀 후 데이터 불일치';`,
`R.ok=true;`,
`return JSON.stringify(R);`,
`})()`,
].join('');
// ── 컬럼 정렬 검증 스크립트 ──────────────────────────────────
// 1) 테이블 헤더 클릭 (정렬)
// 2) 정렬 후 데이터 순서 변경 확인
// 3) 다시 클릭 (역순 정렬) 확인
const SORT_TEST = [
`(async()=>{`, H,
`const R={phase:'SORT'};`,
// Get sortable headers
`const headers=Array.from(document.querySelectorAll('table thead th,table thead td,[role="columnheader"]'));`,
`R.headerCount=headers.length;`,
`R.headerTexts=headers.map(h=>h.innerText?.trim()).filter(t=>t).slice(0,10);`,
// Find a clickable header (exclude checkbox column)
`const sortableHeaders=headers.filter(h=>{`,
` const t=h.innerText?.trim()||'';`,
` return t.length>0&&!h.querySelector('input[type="checkbox"]')&&h.offsetParent!==null;`,
`});`,
`R.sortableCount=sortableHeaders.length;`,
`if(sortableHeaders.length===0){R.warn='정렬 가능한 헤더 없음';R.ok=true;return JSON.stringify(R);}`,
// Get pre-sort data (first column values)
`const getFirstColValues=()=>Array.from(document.querySelectorAll('table tbody tr')).slice(0,5).map(r=>{const cells=r.querySelectorAll('td');return(cells[1]||cells[0])?.innerText?.trim().substring(0,30)||'';});`,
`R.beforeSort=getFirstColValues();`,
// Click first text header to sort
`const targetHeader=sortableHeaders.find(h=>h.innerText?.trim().length>1)||sortableHeaders[0];`,
`R.sortColumn=targetHeader.innerText?.trim();`,
`targetHeader.click();await w(1500);`,
`R.afterSort1=getFirstColValues();`,
`R.sortChanged1=JSON.stringify(R.beforeSort)!==JSON.stringify(R.afterSort1);`,
// Click again for reverse sort
`targetHeader.click();await w(1500);`,
`R.afterSort2=getFirstColValues();`,
`R.sortChanged2=JSON.stringify(R.afterSort1)!==JSON.stringify(R.afterSort2);`,
// Analysis
`if(!R.sortChanged1&&!R.sortChanged2)R.warn='⚠️ 컬럼 클릭 후 정렬 변화 없음 (정렬 미구현 의심)';`,
`else if(R.sortChanged1&&!R.sortChanged2)R.warn='⚠️ 역순 정렬 미동작 (한방향만 정렬)';`,
`R.ok=true;`,
`return JSON.stringify(R);`,
`})()`,
].join('');
// ── 행 수 일관성 검증 (데이터 무결성) ────────────────────────
const ROW_COUNT_CHECK = [
`(async()=>{`, H,
`const R={phase:'ROW_COUNT'};`,
`const rows=document.querySelectorAll('table tbody tr');`,
`R.rowCount=rows.length;`,
// Check for pagination info text (e.g., "1-20 of 150")
`const pageInfo=document.body.innerText.match(new RegExp('(\\\\d+)\\\\s*[-~]\\\\s*(\\\\d+)\\\\s*(of|중|개|건|/|총)\\\\s*(\\\\d+)','i'));`,
`if(pageInfo){R.pageInfoText=pageInfo[0];R.totalItems=parseInt(pageInfo[4]);}`,
// Check for "total" badge or count
`const totalBadge=Array.from(document.querySelectorAll('[class*="badge"],[class*="count"],[class*="total"]')).find(e=>/\\d+/.test(e.innerText));`,
`if(totalBadge)R.totalBadgeText=totalBadge.innerText?.trim();`,
// Check if rows have empty cells (possible rendering bug)
`const emptyRows=Array.from(rows).filter(r=>r.innerText?.trim().length===0);`,
`R.emptyRowCount=emptyRows.length;`,
`if(emptyRows.length>0)R.warn='⚠️ 빈 행 '+emptyRows.length+'개 발견 (렌더링 버그 의심)';`,
// Check for duplicate rows
`const rowTexts=Array.from(rows).map(r=>r.innerText?.trim().substring(0,80));`,
`const duplicates=rowTexts.filter((t,i)=>rowTexts.indexOf(t)!==i);`,
`R.duplicateCount=duplicates.length;`,
`if(duplicates.length>0)R.warn=(R.warn||'')+' ⚠️ 중복 행 '+duplicates.length+'개 발견';`,
`R.ok=true;`,
`return JSON.stringify(R);`,
`})()`,
].join('');
// ════════════════════════════════════════════════════════════════
// PAGE GROUPS
// ════════════════════════════════════════════════════════════════
const GROUPS = [
{
id: 'pagination-sort-acc',
name: '페이지네이션 & 정렬 검증: 회계',
pages: [
{ level1: '회계관리', level2: '어음관리' },
{ level1: '회계관리', level2: '입금관리' },
{ level1: '회계관리', level2: '거래처관리' },
],
},
{
id: 'pagination-sort-sales',
name: '페이지네이션 & 정렬 검증: 판매',
pages: [
{ level1: '판매관리', level2: '거래처관리' },
{ level1: '판매관리', level2: '수주관리' },
{ level1: '판매관리', level2: '견적관리' },
],
},
{
id: 'pagination-sort-hr',
name: '페이지네이션 & 정렬 검증: 인사/게시판',
pages: [
{ level1: '인사관리', level2: '사원관리' },
{ level1: '게시판', level2: '자유게시판' },
],
},
];
// ════════════════════════════════════════════════════════════════
function generateScenario(group) {
const steps = [];
let id = 1;
for (let pi = 0; pi < group.pages.length; pi++) {
const page = group.pages[pi];
const pfx = `[${page.level1} > ${page.level2}]`;
if (pi > 0) {
steps.push({ id: id++, name: `${pfx} 메뉴 이동`, action: 'menu_navigate', level1: page.level1, level2: page.level2, timeout: 10000 });
}
steps.push({ id: id++, name: `${pfx} 페이지 로드 대기`, action: 'wait', timeout: 3000 });
steps.push({ id: id++, name: `${pfx} 테이블 로드 대기`, action: 'wait_for_table', timeout: 5000 });
// 행 수 & 중복 검증
steps.push({ id: id++, name: `${pfx} 행 수/중복 검증`, action: 'evaluate', script: ROW_COUNT_CHECK, timeout: 10000, phase: 'VERIFY' });
// 정렬 테스트
steps.push({ id: id++, name: `${pfx} 컬럼 정렬 검증`, action: 'evaluate', script: SORT_TEST, timeout: 15000, phase: 'SORT' });
// 페이지네이션 테스트
steps.push({ id: id++, name: `${pfx} 페이지네이션 검증`, action: 'evaluate', script: PAGINATION_TEST, timeout: 20000, phase: 'PAGINATION' });
}
return {
id: group.id,
name: group.name,
version: '1.0.0',
auth: { role: 'admin' },
menuNavigation: group.pages[0],
screenshotPolicy: { captureOnFail: true, captureOnPass: false },
steps,
};
}
function main() {
if (!fs.existsSync(SCENARIOS_DIR)) fs.mkdirSync(SCENARIOS_DIR, { recursive: true });
for (const group of GROUPS) {
const scenario = generateScenario(group);
const fp = path.join(SCENARIOS_DIR, `${scenario.id}.json`);
fs.writeFileSync(fp, JSON.stringify(scenario, null, 2), 'utf-8');
console.log(` ${scenario.id}.json (${scenario.steps.length} steps)`);
}
console.log(`\n Generated ${GROUPS.length} scenarios\n Run: node e2e/runner/run-all.js --filter pagination-sort`);
}
main();

View File

@@ -0,0 +1,153 @@
#!/usr/bin/env node
/**
* 성능 측정 베이스라인 시나리오 생성기
*
* 주요 17개 페이지에 대해 성능 측정 시나리오를 자동 생성한다.
* 각 시나리오는:
* 1. 페이지 로드 대기
* 2. 테이블 로드 대기
* 3. measure_performance - Navigation Timing, Resource Timing, DOM 노드, 메모리 수집
* 4. measure_api_performance - ApiMonitor 로그 기반 API 성능 분석
* 5. assert_performance - 임계값 기준 통과/실패 판정
*
* Usage: node e2e/runner/gen-performance-baseline.js
*
* Output:
* e2e/scenarios/perf-{page-id}.json (17개 파일)
*
* Run generated scenarios:
* node e2e/runner/run-all.js --filter perf
*/
const fs = require('fs');
const path = require('path');
const SCENARIOS_DIR = path.resolve(__dirname, '..', 'scenarios');
// ════════════════════════════════════════════════════════════════
// 페이지 매니페스트 (17개)
// ════════════════════════════════════════════════════════════════
const PAGES = [
// 판매관리
{ id: 'perf-sales-client', name: '판매관리 > 거래처관리', level1: '판매관리', level2: '거래처관리' },
{ id: 'perf-sales-price', name: '판매관리 > 단가관리', level1: '판매관리', level2: '단가관리' },
{ id: 'perf-sales-estimate', name: '판매관리 > 견적관리', level1: '판매관리', level2: '견적관리' },
{ id: 'perf-sales-order', name: '판매관리 > 수주관리', level1: '판매관리', level2: '수주관리' },
// 회계관리
{ id: 'perf-acc-client', name: '회계관리 > 거래처관리', level1: '회계관리', level2: '거래처관리' },
{ id: 'perf-acc-sales', name: '회계관리 > 매출관리', level1: '회계관리', level2: '매출관리', tableTimeout: 20000 },
{ id: 'perf-acc-purchase', name: '회계관리 > 매입관리', level1: '회계관리', level2: '매입관리' },
{ id: 'perf-acc-deposit', name: '회계관리 > 입금관리', level1: '회계관리', level2: '입금관리' },
// 인사관리
{ id: 'perf-hr-employee', name: '인사관리 > 사원관리', level1: '인사관리', level2: '사원관리' },
{ id: 'perf-hr-attendance', name: '인사관리 > 근태관리', level1: '인사관리', level2: '근태관리' },
{ id: 'perf-hr-salary', name: '인사관리 > 급여관리', level1: '인사관리', level2: '급여관리' },
{ id: 'perf-hr-department', name: '인사관리 > 부서관리', level1: '인사관리', level2: '부서관리', tableTimeout: 20000 },
// 생산관리
{ id: 'perf-prod-work-order', name: '생산관리 > 작업지시', level1: '생산관리', level2: '작업지시' },
{ id: 'perf-prod-work-result', name: '생산관리 > 작업실적', level1: '생산관리', level2: '작업실적' },
{ id: 'perf-prod-item', name: '생산관리 > 품목관리', level1: '생산관리', level2: '품목관리', tableTimeout: 20000 },
// 자재관리
{ id: 'perf-material-stock', name: '자재관리 > 재고현황', level1: '자재관리', level2: '재고현황' },
{ id: 'perf-material-receiving', name: '자재관리 > 입고관리', level1: '자재관리', level2: '입고관리' },
];
// ════════════════════════════════════════════════════════════════
// 시나리오 생성 함수
// ════════════════════════════════════════════════════════════════
function generateScenario(page) {
const steps = [];
let stepId = 1;
// Step 1: 페이지 로드 대기
steps.push({
id: stepId++,
name: '페이지 로드 대기',
action: 'wait',
timeout: 3000,
});
// Step 2: 테이블 로드 대기
steps.push({
id: stepId++,
name: '테이블 로드 대기',
action: 'wait_for_table',
timeout: page.tableTimeout || 5000,
});
// Step 3: 페이지 성능 측정 (Navigation Timing, Resource Timing, DOM, Memory)
steps.push({
id: stepId++,
name: '페이지 성능 측정',
action: 'measure_performance',
timeout: 10000,
phase: 'PERF_MEASURE',
});
// Step 4: API 성능 측정 (ApiMonitor 로그 분석)
steps.push({
id: stepId++,
name: 'API 성능 측정',
action: 'measure_api_performance',
timeout: 10000,
phase: 'API_PERF',
});
// Step 5: 성능 기준 검증 (임계값 비교)
steps.push({
id: stepId++,
name: '성능 기준 검증',
action: 'assert_performance',
thresholds: {
pageLoad: 3000,
apiAvg: 2000,
domNodes: 5000,
},
timeout: 5000,
phase: 'PERF_ASSERT',
});
return {
id: page.id,
name: `성능 측정: ${page.name}`,
version: '1.0.0',
auth: { role: 'admin' },
menuNavigation: {
level1: page.level1,
level2: page.level2,
},
screenshotPolicy: {
captureOnFail: true,
captureOnPass: false,
},
steps,
};
}
// ════════════════════════════════════════════════════════════════
// 메인 실행
// ════════════════════════════════════════════════════════════════
function main() {
if (!fs.existsSync(SCENARIOS_DIR)) {
fs.mkdirSync(SCENARIOS_DIR, { recursive: true });
}
console.log('\n === Performance Baseline Scenario Generator ===\n');
let count = 0;
for (const page of PAGES) {
const scenario = generateScenario(page);
const fp = path.join(SCENARIOS_DIR, `${scenario.id}.json`);
fs.writeFileSync(fp, JSON.stringify(scenario, null, 2), 'utf-8');
console.log(` ${scenario.id}.json (${scenario.steps.length} steps)`);
count++;
}
console.log(`\n Generated ${count} performance scenarios`);
console.log(` Run: node e2e/runner/run-all.js --filter perf\n`);
}
main();

View File

@@ -0,0 +1,301 @@
#!/usr/bin/env node
/**
* 페이지 새로고침 후 데이터 유지 검증 시나리오 생성기
*
* 고객 시나리오: 데이터 등록 후 브라우저 새로고침(F5) → 데이터가 사라지면 심각한 버그.
* 서버 저장 무결성, 캐시 의존 여부, 세션 관리 검증.
*
* 흐름: CREATE → Reload Page → VERIFY → DELETE
*
* Usage: node e2e/runner/gen-reload-persist.js
*
* Output:
* e2e/scenarios/reload-persist-board.json
* e2e/scenarios/reload-persist-acc-bills.json
* e2e/scenarios/reload-persist-acc-deposit.json
*/
const fs = require('fs');
const path = require('path');
const SCENARIOS_DIR = path.resolve(__dirname, '..', 'scenarios');
const H = [
`const w=ms=>new Promise(r=>setTimeout(r,ms));`,
`const sv=(el,v)=>{const p=el.tagName==='TEXTAREA'?HTMLTextAreaElement.prototype:HTMLInputElement.prototype;const ns=Object.getOwnPropertyDescriptor(p,'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}));};`,
`const ts=window.__E2E_TS__||(()=>{const n=new Date();const p=v=>v.toString().padStart(2,'0');return n.getFullYear()+p(n.getMonth()+1)+p(n.getDate())+'_'+p(n.getHours())+p(n.getMinutes())+p(n.getSeconds());})();window.__E2E_TS__=ts;try{localStorage.setItem('__E2E_TS__',ts);}catch(e){}`,
].join('');
const BACK_TO_LIST = `(async()=>{const w=ms=>new Promise(r=>setTimeout(r,ms));const onForm=location.search.includes('mode=new')||location.search.includes('mode=edit')||location.search.includes('mode=view')||new RegExp('/(new|[0-9]+|[0-9a-f]{8,})$').test(location.pathname);if(onForm){const btn=Array.from(document.querySelectorAll('button,a')).find(b=>/목록|취소|뒤로/.test(b.innerText?.trim()));if(btn){btn.click();await w(2000);}else{history.back();await w(2000);}}return JSON.stringify({url:location.pathname+location.search});})()`;
const VERIFY_IN_LIST = [
`(async()=>{const w=ms=>new Promise(r=>setTimeout(r,ms));`,
`const ts=window.__E2E_TS__||(()=>{try{return localStorage.getItem('__E2E_TS__')}catch(e){return null}})()||'E2E_TEST_';`,
`const R={phase:'VERIFY'};await w(500);`,
`const rows=document.querySelectorAll('table tbody tr');R.rowCount=rows.length;`,
`const found=Array.from(rows).find(r=>r.innerText?.includes(ts))||Array.from(rows).find(r=>r.innerText?.includes('E2E_TEST_'));`,
`R.found=!!found;R.ok=R.found;R.ts=ts;`,
`if(found)R.foundText=found.innerText?.substring(0,80);`,
`if(!found){R.error='E2E_TEST_ 데이터 없음';R.firstRow=rows[0]?.innerText?.substring(0,80)||'(empty)';}`,
`return JSON.stringify(R);`,
`})()`,
].join('');
// ════════════════════════════════════════════════════════════════
const PAGES = {
board: {
id: 'reload-persist-board',
name: '새로고침 데이터 유지 검증: 자유게시판',
menuNavigation: { level1: '게시판', level2: '자유게시판' },
createScript: [
`(async()=>{`, H,
`const R={phase:'CREATE',ts};`,
`const testTitle='E2E_TEST_리로드_'+ts;R.testTitle=testTitle;`,
`const btn=Array.from(document.querySelectorAll('button')).find(b=>b.innerText?.trim()==='글쓰기'||/등록|작성/.test(b.innerText?.trim()));`,
`if(!btn){R.error='글쓰기 버튼 없음';return JSON.stringify(R);}`,
`btn.click();await w(2500);`,
`const ti=document.querySelector('input[placeholder*="제목"]')||document.querySelector('input[type="text"]');`,
`if(!ti){R.error='제목 입력란 없음';return JSON.stringify(R);}`,
`sv(ti,testTitle);await w(200);`,
`const ta=document.querySelector('textarea');if(ta){sv(ta,'새로고침 후 유지되어야 하는 테스트 데이터');await w(200);}`,
`const sub=Array.from(document.querySelectorAll('button')).find(b=>b.innerText?.trim()==='등록');`,
`if(!sub){R.error='등록 버튼 없음';return JSON.stringify(R);}`,
`sub.click();await w(5000);`,
`R.urlAfter=location.pathname+location.search;`,
`R.ok=!R.urlAfter.includes('mode=new')&&!location.pathname.endsWith('/new');if(!R.ok)R.error='등록 제출 후 여전히 폼 페이지 (url='+R.urlAfter+')';`,
`return JSON.stringify(R);`,
`})()`,
].join(''),
deleteScript: [
`(async()=>{const w=ms=>new Promise(r=>setTimeout(r,ms));`,
`const ts=window.__E2E_TS__||(()=>{try{return localStorage.getItem('__E2E_TS__')}catch(e){return null}})()||'E2E_TEST_';`,
`const R={phase:'DELETE'};`,
`const rows=Array.from(document.querySelectorAll('table tbody tr'));`,
`const row=rows.find(r=>r.innerText?.includes(ts))||rows.find(r=>r.innerText?.includes('E2E_TEST_'));`,
`if(!row){R.error='E2E_TEST_ 행 없음 (ts='+ts+')';R.ok=false;return JSON.stringify(R);}`,
`R.targetText=row.innerText?.substring(0,60);`,
`row.click();await w(2500);`,
`const delBtn=Array.from(document.querySelectorAll('button')).find(b=>b.innerText?.trim()==='삭제');`,
`if(!delBtn){R.error='삭제 버튼 없음';R.ok=false;return JSON.stringify(R);}`,
`delBtn.click();await w(1000);`,
`const cfm=Array.from(document.querySelectorAll('[role="alertdialog"] button,[role="dialog"] button,button')).find(b=>/확인|삭제|예|Yes/.test(b.innerText?.trim())&&b!==delBtn&&b.offsetParent!==null);`,
`if(cfm){cfm.click();await w(3000);}`,
`R.ok=true;return JSON.stringify(R);`,
`})()`,
].join(''),
},
bills: {
id: 'reload-persist-acc-bills',
name: '새로고침 데이터 유지 검증: 어음관리',
menuNavigation: { level1: '회계관리', level2: '어음관리' },
createScript: [
`(async()=>{`, H,
`const R={phase:'CREATE',ts};`,
`const testId='E2E'+ts.replace(/_/g,'').substring(4,10);R.testId=testId;`,
`const btn=Array.from(document.querySelectorAll('button')).find(b=>/어음.*등록|등록/.test(b.innerText?.trim()));`,
`if(!btn){R.error='등록 버튼 없음';return JSON.stringify(R);}`,
`btn.click();await w(2500);`,
`R.url=location.pathname+location.search;`,
// Fill 어음번호
`const numInput=document.querySelector('input[placeholder*="어음번호"]')||Array.from(document.querySelectorAll('input[type="text"]')).find(i=>i.offsetParent!==null&&!i.readOnly&&!i.disabled);`,
`if(numInput){sv(numInput,'E2E_TEST_'+testId);await w(200);}`,
// 거래처 combobox (label match or index 1) — proven pattern from gen-create-delete
`const combos=Array.from(document.querySelectorAll('button[role="combobox"]')).filter(b=>b.offsetParent!==null);`,
`R.comboCount=combos.length;`,
`for(let i=0;i<combos.length;i++){`,
` const cb=combos[i];`,
` const label=cb.closest('[class*=field],[class*=Field],[class*=form-item]')?.querySelector('label')?.innerText||'';`,
` if(label.includes('거래처')||i===1){`,
` cb.click();await w(600);`,
` const lb=document.querySelector('[role="listbox"]');`,
` if(lb){const opt=lb.querySelector('[role="option"]');if(opt){opt.click();await w(400);}}`,
` else{document.dispatchEvent(new KeyboardEvent('keydown',{key:'Escape',bubbles:true}));await w(200);}`,
` break;`,
` }`,
`}`,
// Fill 금액
`const amtInput=document.querySelector('input[placeholder*="금액"]');`,
`if(amtInput){sv(amtInput,'10000');await w(200);}`,
// 날짜 선택
`const dateButtons=Array.from(document.querySelectorAll('button')).filter(b=>b.innerText?.trim()==='날짜 선택'&&b.offsetParent!==null);`,
`R.dateCount=dateButtons.length;`,
`for(const db of dateButtons){`,
` db.click();await w(500);`,
` const today=document.querySelector('[aria-selected="true"]')||document.querySelector('button[name="day"].bg-primary')||Array.from(document.querySelectorAll('button[name="day"],td button')).find(b=>b.getAttribute('aria-selected')==='true'||b.classList.contains('bg-primary'));`,
` if(today){today.click();await w(300);}`,
` else{document.dispatchEvent(new KeyboardEvent('keydown',{key:'Escape',bubbles:true}));await w(300);}`,
`}`,
// Fill 비고
`const noteInput=document.querySelector('input[placeholder*="비고"]');`,
`if(noteInput){sv(noteInput,'E2E_TEST_리로드_'+ts);await w(200);}`,
// Submit
`const submitBtn=Array.from(document.querySelectorAll('button')).find(b=>b.innerText?.trim()==='등록'&&b.offsetParent!==null);`,
`if(!submitBtn){R.error='등록 버튼 없음';return JSON.stringify(R);}`,
`submitBtn.click();await w(3000);`,
`R.urlAfter=location.pathname+location.search;`,
`R.navigatedBack=!location.search.includes('mode=new');`,
`R.ok=true;`,
`return JSON.stringify(R);`,
`})()`,
].join(''),
deleteScript: [
`(async()=>{const w=ms=>new Promise(r=>setTimeout(r,ms));`,
`const ts=window.__E2E_TS__||(()=>{try{return localStorage.getItem('__E2E_TS__')}catch(e){return null}})()||'E2E_TEST_';`,
`const R={phase:'DELETE'};`,
`const rows=Array.from(document.querySelectorAll('table tbody tr'));`,
`const row=rows.find(r=>r.innerText?.includes(ts))||rows.find(r=>r.innerText?.includes('E2E_TEST_'));`,
`if(!row){R.error='E2E_TEST_ 행 없음 (ts='+ts+')';R.ok=false;return JSON.stringify(R);}`,
`R.targetText=row.innerText?.substring(0,60);`,
`row.click();await w(2500);`,
`const delBtn=Array.from(document.querySelectorAll('button')).find(b=>b.innerText?.trim()==='삭제');`,
`if(!delBtn){R.error='삭제 버튼 없음';R.ok=false;return JSON.stringify(R);}`,
`delBtn.click();await w(1000);`,
`const cfm=Array.from(document.querySelectorAll('[role="alertdialog"] button,[role="dialog"] button,button')).find(b=>/확인|삭제|예|Yes/.test(b.innerText?.trim())&&b!==delBtn&&b.offsetParent!==null);`,
`if(cfm){cfm.click();await w(3000);}`,
`R.ok=true;return JSON.stringify(R);`,
`})()`,
].join(''),
},
deposit: {
id: 'reload-persist-acc-deposit',
name: '새로고침 데이터 유지 검증: 입금관리',
menuNavigation: { level1: '회계관리', level2: '입금관리' },
createScript: [
`(async()=>{`, H,
`const R={phase:'CREATE',ts};`,
`const btn=Array.from(document.querySelectorAll('button')).find(b=>/입금.*등록|입금등록|등록/.test(b.innerText?.trim()));`,
`if(!btn){R.error='등록 버튼 없음';return JSON.stringify(R);}`,
`btn.click();await w(2500);`,
`R.url=location.pathname+location.search;`,
// Fill 입금자명
`const nameInput=document.querySelector('input[placeholder*="입금자명"]')||document.querySelector('input[placeholder*="입금자"]');`,
`if(nameInput){sv(nameInput,'E2E_TEST_입금자_'+ts);await w(200);}`,
// Fill 입금금액
`const amtInput=document.querySelector('input[placeholder*="입금금액"]')||document.querySelector('input[type="number"]');`,
`if(amtInput){sv(amtInput,'50000');await w(200);}`,
// Fill 적요
`const noteInput=document.querySelector('input[placeholder*="적요"]');`,
`if(noteInput){sv(noteInput,'E2E_TEST_입금_'+ts);await w(200);}`,
// 거래처 combobox (label match) — proven pattern from gen-create-delete
`const combos=Array.from(document.querySelectorAll('button[role="combobox"]')).filter(b=>b.offsetParent!==null);`,
`R.comboCount=combos.length;`,
`for(const cb of combos){`,
` const label=cb.closest('[class*=field],[class*=Field],[class*=form-item]')?.querySelector('label')?.innerText||'';`,
` if(label.includes('거래처')){`,
` cb.click();await w(600);`,
` const lb=document.querySelector('[role="listbox"]');`,
` if(lb){const opt=lb.querySelector('[role="option"]');if(opt){opt.click();await w(400);}}`,
` else{document.dispatchEvent(new KeyboardEvent('keydown',{key:'Escape',bubbles:true}));await w(200);}`,
` break;`,
` }`,
`}`,
// 입금 유형 combobox (label match)
`for(const cb of combos){`,
` const label=cb.closest('[class*=field],[class*=Field],[class*=form-item]')?.querySelector('label')?.innerText||'';`,
` if(label.includes('입금 유형')||label.includes('유형')){`,
` cb.click();await w(600);`,
` const lb=document.querySelector('[role="listbox"]');`,
` if(lb){const opt=lb.querySelector('[role="option"]');if(opt){opt.click();await w(400);}}`,
` else{document.dispatchEvent(new KeyboardEvent('keydown',{key:'Escape',bubbles:true}));await w(200);}`,
` break;`,
` }`,
`}`,
// Submit
`const submitBtn=Array.from(document.querySelectorAll('button')).find(b=>b.innerText?.trim()==='등록'&&b.offsetParent!==null);`,
`if(!submitBtn){R.error='등록 버튼 없음';return JSON.stringify(R);}`,
`submitBtn.click();await w(3000);`,
`R.urlAfter=location.pathname+location.search;`,
`R.navigatedBack=!location.search.includes('mode=new');`,
`R.ok=true;`,
`return JSON.stringify(R);`,
`})()`,
].join(''),
deleteScript: [
`(async()=>{const w=ms=>new Promise(r=>setTimeout(r,ms));`,
`const ts=window.__E2E_TS__||(()=>{try{return localStorage.getItem('__E2E_TS__')}catch(e){return null}})()||'E2E_TEST_';`,
`const R={phase:'DELETE'};`,
`const rows=Array.from(document.querySelectorAll('table tbody tr'));`,
`const row=rows.find(r=>r.innerText?.includes(ts))||rows.find(r=>r.innerText?.includes('E2E_TEST_'));`,
`if(!row){R.error='E2E_TEST_ 행 없음 (ts='+ts+')';R.ok=false;return JSON.stringify(R);}`,
`R.targetText=row.innerText?.substring(0,60);`,
`row.click();await w(2500);`,
`const delBtn=Array.from(document.querySelectorAll('button')).find(b=>b.innerText?.trim()==='삭제');`,
`if(!delBtn){R.error='삭제 버튼 없음';R.ok=false;return JSON.stringify(R);}`,
`delBtn.click();await w(1000);`,
`const cfm=Array.from(document.querySelectorAll('[role="alertdialog"] button,[role="dialog"] button,button')).find(b=>/확인|삭제|예|Yes/.test(b.innerText?.trim())&&b!==delBtn&&b.offsetParent!==null);`,
`if(cfm){cfm.click();await w(3000);}`,
`R.ok=true;return JSON.stringify(R);`,
`})()`,
].join(''),
},
};
// ════════════════════════════════════════════════════════════════
function generateScenario(pageKey) {
const pg = PAGES[pageKey];
const steps = [];
let id = 1;
const nav = pg.menuNavigation;
const pfx = `[${nav.level1} > ${nav.level2}]`;
// SETUP
steps.push({ id: id++, name: `${pfx} 페이지 로드 대기`, action: 'wait', timeout: 3000 });
steps.push({ id: id++, name: `${pfx} 테이블 로드 대기`, action: 'wait_for_table', timeout: 5000 });
// CREATE
steps.push({ id: id++, name: `${pfx} [CREATE] 데이터 생성`, action: 'evaluate', script: pg.createScript, timeout: 30000, phase: 'CREATE' });
steps.push({ id: id++, name: `${pfx} [CREATE] 생성 후 대기`, action: 'wait', timeout: 3000 });
steps.push({ id: id++, name: `${pfx} [CREATE] 목록 복귀`, action: 'evaluate', script: BACK_TO_LIST, timeout: 10000, phase: 'CREATE' });
steps.push({ id: id++, name: `${pfx} [CREATE] 목록 안정화`, action: 'wait', timeout: 2000 });
// VERIFY BEFORE RELOAD
steps.push({ id: id++, name: `${pfx} [VERIFY] 새로고침 전 데이터 확인`, action: 'evaluate', script: VERIFY_IN_LIST, timeout: 10000, phase: 'VERIFY' });
// ★ RELOAD PAGE — 핵심 테스트 ★
steps.push({ id: id++, name: `${pfx} [RELOAD] 페이지 새로고침`, action: 'reload', timeout: 10000 });
steps.push({ id: id++, name: `${pfx} [RELOAD] 새로고침 후 대기`, action: 'wait', timeout: 5000 });
steps.push({ id: id++, name: `${pfx} [RELOAD] SPA 안정화 대기`, action: 'wait', timeout: 5000 });
// VERIFY AFTER RELOAD — 데이터가 여전히 존재해야 함
steps.push({ id: id++, name: `${pfx} [VERIFY] 새로고침 후 데이터 유지 확인`, action: 'evaluate', script: VERIFY_IN_LIST, timeout: 10000, phase: 'VERIFY' });
// CLEANUP: DELETE
steps.push({ id: id++, name: `${pfx} [DELETE] 테스트 데이터 삭제`, action: 'evaluate', script: pg.deleteScript, timeout: 30000, phase: 'DELETE', critical: true });
steps.push({ id: id++, name: `${pfx} [DELETE] 삭제 후 대기`, action: 'wait', timeout: 3000 });
steps.push({ id: id++, name: `${pfx} [DELETE] 목록 복귀`, action: 'evaluate', script: BACK_TO_LIST, timeout: 10000, phase: 'DELETE' });
steps.push({ id: id++, name: `${pfx} [DELETE] 목록 안정화`, action: 'wait', timeout: 2000 });
// VERIFY DELETE
const VERIFY_DELETE = `(async()=>{const w=ms=>new Promise(r=>setTimeout(r,ms));const ts=window.__E2E_TS__||(()=>{try{return localStorage.getItem('__E2E_TS__')}catch(e){return null}})();const R={phase:'VERIFY_DELETE'};await w(500);const rows=document.querySelectorAll('table tbody tr');R.rowCount=rows.length;R.ts=ts||'(no ts)';if(!ts){R.ok=true;R.skipped='no ts available';return JSON.stringify(R);}const found=Array.from(rows).find(r=>r.innerText?.includes(ts));R.stillExists=!!found;R.ok=!found;if(found)R.error='삭제된 데이터(ts='+ts+')가 여전히 존재';return JSON.stringify(R);})()`;
steps.push({ id: id++, name: `${pfx} [VERIFY] 삭제 확인`, action: 'evaluate', script: VERIFY_DELETE, timeout: 10000, phase: 'VERIFY' });
return {
id: pg.id,
name: pg.name,
version: '1.0.0',
auth: { role: 'admin' },
menuNavigation: pg.menuNavigation,
screenshotPolicy: { captureOnFail: true, captureOnPass: false },
steps,
};
}
function main() {
if (!fs.existsSync(SCENARIOS_DIR)) fs.mkdirSync(SCENARIOS_DIR, { recursive: true });
for (const key of Object.keys(PAGES)) {
const scenario = generateScenario(key);
const fp = path.join(SCENARIOS_DIR, `${scenario.id}.json`);
fs.writeFileSync(fp, JSON.stringify(scenario, null, 2), 'utf-8');
console.log(` ${scenario.id}.json (${scenario.steps.length} steps)`);
}
console.log(`\n Generated ${Object.keys(PAGES).length} scenarios\n Run: node e2e/runner/run-all.js --filter reload-persist`);
}
main();

View File

@@ -0,0 +1,252 @@
#!/usr/bin/env node
/**
* 검색 기능 실제 동작 테스트 시나리오 생성기
*
* 기존 search-options는 검색 UI 존재 여부만 감사.
* 이 테스트는 실제로 검색어를 입력하고 결과가 필터링되는지 검증.
*
* 흐름: 기존 데이터 캡처 → 검색어 입력 → 필터링 확인 → 초기화 → 원복 확인
*
* Usage: node e2e/runner/gen-search-function.js
*
* Output:
* e2e/scenarios/search-function-acc.json (어음/입금/거래처)
* e2e/scenarios/search-function-sales.json (거래처/수주/견적)
* e2e/scenarios/search-function-hr-board.json (사원/자유게시판)
*/
const fs = require('fs');
const path = require('path');
const SCENARIOS_DIR = path.resolve(__dirname, '..', 'scenarios');
const H = `const w=ms=>new Promise(r=>setTimeout(r,ms));`;
// ════════════════════════════════════════════════════════════════
// 검색 기능 테스트 스크립트 (모든 페이지 공용)
// ════════════════════════════════════════════════════════════════
// STEP 1: 검색 전 상태 캡처 + 검색어 추출
const CAPTURE_AND_SEARCH = [
`(async()=>{`, H,
`const R={phase:'SEARCH'};`,
// 1. 현재 테이블 상태 캡처
`const rows0=Array.from(document.querySelectorAll('table tbody tr')).filter(r=>r.offsetParent!==null);`,
`R.initialRowCount=rows0.length;`,
`if(rows0.length===0){R.warn='테이블에 데이터 없음 - 검색 테스트 불가';R.ok=true;return JSON.stringify(R);}`,
// 2. 첫 행에서 검색 키워드 추출 (2번째 셀 = 보통 이름/제목)
`const cells=rows0[0].querySelectorAll('td');`,
`let keyword='';`,
`for(let i=1;i<cells.length&&i<5;i++){`,
` const t=cells[i]?.innerText?.trim();`,
` if(t&&t.length>=2&&t.length<=20&&!/^\\d+$/.test(t)&&!/^\\d{4}[-/]/.test(t)){keyword=t;break;}`,
`}`,
`if(!keyword&&cells[0]){keyword=cells[0]?.innerText?.trim().substring(0,10);}`,
`R.keyword=keyword;`,
`if(!keyword||keyword.length<2){R.warn='검색 가능한 키워드 추출 실패';R.ok=true;return JSON.stringify(R);}`,
// 3. 검색 입력란 찾기
`const searchInput=document.querySelector('input[placeholder*="검색"]')||document.querySelector('input[type="search"]')||document.querySelector('input[role="searchbox"]');`,
`R.hasSearchInput=!!searchInput;`,
`if(!searchInput){R.warn='검색 입력란 없음';R.ok=true;return JSON.stringify(R);}`,
`R.placeholder=searchInput.placeholder||'';`,
// 4. 검색어 입력
`searchInput.focus();await w(200);`,
`const nativeSetter=Object.getOwnPropertyDescriptor(HTMLInputElement.prototype,'value')?.set;`,
`if(nativeSetter)nativeSetter.call(searchInput,keyword);else searchInput.value=keyword;`,
`searchInput.dispatchEvent(new Event('input',{bubbles:true}));`,
`searchInput.dispatchEvent(new Event('change',{bubbles:true}));`,
// 5. Enter 키로 검색 트리거
`searchInput.dispatchEvent(new KeyboardEvent('keydown',{key:'Enter',code:'Enter',keyCode:13,bubbles:true}));`,
`searchInput.dispatchEvent(new KeyboardEvent('keyup',{key:'Enter',code:'Enter',keyCode:13,bubbles:true}));`,
`await w(2000);`,
// 6. 필터링 결과 확인
`const rows1=Array.from(document.querySelectorAll('table tbody tr')).filter(r=>r.offsetParent!==null);`,
`R.afterSearchRowCount=rows1.length;`,
// 7. 결과 행이 모두 키워드를 포함하는지 확인
`const matchingRows=rows1.filter(r=>r.innerText?.includes(keyword));`,
`R.matchingRowCount=matchingRows.length;`,
`R.allMatch=rows1.length>0&&matchingRows.length===rows1.length;`,
// 8. 필터링 분석
`R.filtered=R.afterSearchRowCount<=R.initialRowCount;`,
`if(R.afterSearchRowCount===R.initialRowCount&&R.initialRowCount>1){`,
` R.filterWorked=R.allMatch;`,
` if(!R.allMatch)R.info='검색 후 행 수 동일 - 모든 행이 키워드 포함 또는 검색 미동작';`,
`}else if(R.afterSearchRowCount<R.initialRowCount){`,
` R.filterWorked=true;`,
` R.info='검색 필터링 동작 확인 ('+R.initialRowCount+'→'+R.afterSearchRowCount+'행)';`,
`}else if(R.afterSearchRowCount===0){`,
` R.filterWorked=false;`,
` R.warn='⚠️ 검색 후 결과 0건 - 기존 데이터의 키워드인데 결과 없음';`,
`}`,
`R.ok=true;`,
`return JSON.stringify(R);`,
`})()`,
].join('');
// STEP 2: 검색 초기화 + 원복 확인
const CLEAR_AND_VERIFY = [
`(async()=>{`, H,
`const R={phase:'CLEAR'};`,
// 1. 검색 입력란 찾기
`const searchInput=document.querySelector('input[placeholder*="검색"]')||document.querySelector('input[type="search"]')||document.querySelector('input[role="searchbox"]');`,
`if(!searchInput){R.warn='검색 입력란 없음';R.ok=true;return JSON.stringify(R);}`,
// 2. 현재 행 수 기록
`const rowsBefore=Array.from(document.querySelectorAll('table tbody tr')).filter(r=>r.offsetParent!==null).length;`,
`R.beforeClearRows=rowsBefore;`,
// 3. 검색어 지우기
`const nativeSetter=Object.getOwnPropertyDescriptor(HTMLInputElement.prototype,'value')?.set;`,
`if(nativeSetter)nativeSetter.call(searchInput,'');else searchInput.value='';`,
`searchInput.dispatchEvent(new Event('input',{bubbles:true}));`,
`searchInput.dispatchEvent(new Event('change',{bubbles:true}));`,
`searchInput.dispatchEvent(new KeyboardEvent('keydown',{key:'Enter',code:'Enter',keyCode:13,bubbles:true}));`,
`await w(2000);`,
// 4. 초기화 버튼이 있으면 클릭
`const resetBtn=Array.from(document.querySelectorAll('button')).find(b=>/초기화|리셋|전체|Reset|Clear/i.test(b.innerText?.trim())&&b.offsetParent!==null);`,
`if(resetBtn){resetBtn.click();await w(1500);R.resetBtnClicked=true;}`,
// 5. 원복 확인
`const rowsAfter=Array.from(document.querySelectorAll('table tbody tr')).filter(r=>r.offsetParent!==null).length;`,
`R.afterClearRows=rowsAfter;`,
`R.restored=rowsAfter>=rowsBefore;`,
`R.searchInputValue=searchInput.value;`,
`R.inputCleared=searchInput.value===''||searchInput.value.length===0;`,
`if(!R.restored&&rowsAfter<rowsBefore)R.warn='⚠️ 검색 초기화 후 행 수가 줄었음 ('+rowsBefore+'→'+rowsAfter+')';`,
`R.ok=true;`,
`return JSON.stringify(R);`,
`})()`,
].join('');
// STEP 3: 드롭다운 필터 테스트 (combobox 선택 → 필터 확인 → 원복)
const DROPDOWN_FILTER_TEST = [
`(async()=>{`, H,
`const R={phase:'DROPDOWN_FILTER'};`,
// 1. 현재 행 수
`const rows0=Array.from(document.querySelectorAll('table tbody tr')).filter(r=>r.offsetParent!==null);`,
`R.initialRows=rows0.length;`,
// 2. 드롭다운(combobox) 찾기
`const combos=Array.from(document.querySelectorAll('button[role="combobox"]')).filter(b=>{`,
` const inTable=b.closest('table');`,
` const inModal=b.closest('[role="dialog"],[aria-modal="true"]');`,
` return b.offsetParent!==null&&!inTable&&!inModal;`,
`});`,
`R.comboCount=combos.length;`,
`if(combos.length===0){R.warn='필터용 드롭다운 없음';R.ok=true;return JSON.stringify(R);}`,
// 3. 첫 번째 드롭다운 클릭 → 두 번째 옵션 선택 (첫 번째는 보통 "전체")
`const targetCombo=combos[0];`,
`R.comboLabel=targetCombo.closest('[class*=field],[class*=Field],[class*=form-item]')?.querySelector('label')?.innerText?.trim()||'';`,
`R.comboCurrentValue=targetCombo.innerText?.trim().substring(0,30);`,
`targetCombo.click();await w(600);`,
`const listbox=document.querySelector('[role="listbox"]');`,
`if(!listbox){R.warn='드롭다운 열림 실패';R.ok=true;return JSON.stringify(R);}`,
`const options=Array.from(listbox.querySelectorAll('[role="option"]'));`,
`R.optionCount=options.length;`,
`R.optionTexts=options.slice(0,5).map(o=>o.innerText?.trim().substring(0,20));`,
// 4. 두 번째 옵션 선택 (첫 번째가 "전체"일 수 있으므로)
`const targetOpt=options.length>1?options[1]:options[0];`,
`if(!targetOpt){document.dispatchEvent(new KeyboardEvent('keydown',{key:'Escape',bubbles:true}));R.warn='선택 가능한 옵션 없음';R.ok=true;return JSON.stringify(R);}`,
`R.selectedOption=targetOpt.innerText?.trim().substring(0,20);`,
`targetOpt.click();await w(2000);`,
// 5. 필터 결과 확인
`const rows1=Array.from(document.querySelectorAll('table tbody tr')).filter(r=>r.offsetParent!==null);`,
`R.afterFilterRows=rows1.length;`,
`R.filterChanged=R.afterFilterRows!==R.initialRows;`,
`if(!R.filterChanged&&R.initialRows>1)R.info='드롭다운 선택 후 행 수 변화 없음 (해당 필터의 모든 데이터일 수 있음)';`,
// 6. 원복: 첫 번째 옵션("전체")으로 복원
`targetCombo.click();await w(600);`,
`const listbox2=document.querySelector('[role="listbox"]');`,
`if(listbox2){`,
` const allOpt=Array.from(listbox2.querySelectorAll('[role="option"]')).find(o=>/전체|All|선택/i.test(o.innerText?.trim()));`,
` if(allOpt){allOpt.click();await w(1500);R.restored=true;}`,
` else{const firstOpt=listbox2.querySelector('[role="option"]');if(firstOpt){firstOpt.click();await w(1500);R.restored=true;}}`,
`}`,
`if(!listbox2){document.dispatchEvent(new KeyboardEvent('keydown',{key:'Escape',bubbles:true}));await w(500);}`,
// 7. 원복 확인
`const rows2=Array.from(document.querySelectorAll('table tbody tr')).filter(r=>r.offsetParent!==null);`,
`R.afterRestoreRows=rows2.length;`,
`R.ok=true;`,
`return JSON.stringify(R);`,
`})()`,
].join('');
// ════════════════════════════════════════════════════════════════
// PAGE GROUPS
// ════════════════════════════════════════════════════════════════
const GROUPS = [
{
id: 'search-function-acc',
name: '검색 기능 동작 검증: 회계',
pages: [
{ level1: '회계관리', level2: '어음관리' },
{ level1: '회계관리', level2: '입금관리' },
{ level1: '회계관리', level2: '거래처관리' },
],
},
{
id: 'search-function-sales',
name: '검색 기능 동작 검증: 판매',
pages: [
{ level1: '판매관리', level2: '거래처관리' },
{ level1: '판매관리', level2: '수주관리' },
{ level1: '판매관리', level2: '견적관리' },
],
},
{
id: 'search-function-hr-board',
name: '검색 기능 동작 검증: 인사/게시판',
pages: [
{ level1: '인사관리', level2: '사원관리' },
{ level1: '게시판', level2: '자유게시판' },
],
},
];
// ════════════════════════════════════════════════════════════════
function generateScenario(group) {
const steps = [];
let id = 1;
for (let pi = 0; pi < group.pages.length; pi++) {
const page = group.pages[pi];
const pfx = `[${page.level1} > ${page.level2}]`;
if (pi > 0) {
steps.push({ id: id++, name: `${pfx} 메뉴 이동`, action: 'menu_navigate', level1: page.level1, level2: page.level2, timeout: 10000 });
}
steps.push({ id: id++, name: `${pfx} 페이지 로드 대기`, action: 'wait', timeout: 3000 });
steps.push({ id: id++, name: `${pfx} 테이블 로드 대기`, action: 'wait_for_table', timeout: 5000 });
// 텍스트 검색 테스트
steps.push({ id: id++, name: `${pfx} 텍스트 검색 테스트`, action: 'evaluate', script: CAPTURE_AND_SEARCH, timeout: 15000, phase: 'SEARCH' });
// 검색 초기화 테스트
steps.push({ id: id++, name: `${pfx} 검색 초기화 확인`, action: 'evaluate', script: CLEAR_AND_VERIFY, timeout: 10000, phase: 'CLEAR' });
// 안정화 대기
steps.push({ id: id++, name: `${pfx} 초기화 후 안정화`, action: 'wait', timeout: 2000 });
// 드롭다운 필터 테스트
steps.push({ id: id++, name: `${pfx} 드롭다운 필터 테스트`, action: 'evaluate', script: DROPDOWN_FILTER_TEST, timeout: 20000, phase: 'FILTER' });
}
return {
id: group.id,
name: group.name,
version: '1.0.0',
auth: { role: 'admin' },
menuNavigation: group.pages[0],
screenshotPolicy: { captureOnFail: true, captureOnPass: false },
steps,
};
}
function main() {
if (!fs.existsSync(SCENARIOS_DIR)) fs.mkdirSync(SCENARIOS_DIR, { recursive: true });
for (const group of GROUPS) {
const scenario = generateScenario(group);
const fp = path.join(SCENARIOS_DIR, `${scenario.id}.json`);
fs.writeFileSync(fp, JSON.stringify(scenario, null, 2), 'utf-8');
console.log(` ${scenario.id}.json (${scenario.steps.length} steps)`);
}
console.log(`\n Generated ${GROUPS.length} scenarios\n Run: node e2e/runner/run-all.js --filter search-function-`);
}
main();

View File

@@ -0,0 +1,159 @@
/**
* revert-hard-actions.js
*
* fix-scenario-quality.js가 적용한 hard action을 soft action으로 되돌림.
* - DELETE click → click_if_exists
* - DELETE click_dialog_confirm → click_if_exists (target 복원)
* - UPDATE fill (click_if_exists에서 변경된 것) → click_if_exists (value 제거)
* - CREATE fill_form (fields array가 있지만 원래 fill_form이 아닌 것) → click_if_exists
*
* Usage: node e2e/runner/revert-hard-actions.js [--dry-run]
*/
const fs = require('fs');
const path = require('path');
const SCENARIOS_DIR = path.join(__dirname, '..', 'scenarios');
const DRY_RUN = process.argv.includes('--dry-run');
const SKIP_PATTERNS = [
'_backup_before_enhance',
'_global-',
'_templates',
'pdf-download-test.json',
'.claude'
];
const changeLog = [];
function main() {
console.log('='.repeat(60));
console.log(' Revert Hard Actions → Soft Actions');
console.log(' ' + (DRY_RUN ? '[DRY RUN]' : '[LIVE MODE]'));
console.log('='.repeat(60));
console.log();
const files = fs.readdirSync(SCENARIOS_DIR)
.filter(f => f.endsWith('.json'))
.filter(f => !SKIP_PATTERNS.some(p => f.includes(p)))
.sort();
let filesModified = 0;
for (const file of files) {
const filePath = path.join(SCENARIOS_DIR, file);
let content;
try {
content = fs.readFileSync(filePath, 'utf-8');
} catch (err) {
continue;
}
let scenario;
try {
scenario = JSON.parse(content);
} catch (err) {
continue;
}
const steps = scenario.steps;
if (!steps || !Array.isArray(steps)) continue;
const beforeCount = changeLog.length;
for (const step of steps) {
// Revert 1: DELETE click → click_if_exists
if (step.phase === 'DELETE' && step.action === 'click') {
changeLog.push({
file, stepId: step.id, stepName: step.name,
before: 'action: "click"',
after: 'action: "click_if_exists"'
});
step.action = 'click_if_exists';
}
// Revert 2: DELETE click_dialog_confirm → click_if_exists (add target)
if (step.phase === 'DELETE' && step.action === 'click_dialog_confirm') {
const dialogTarget = "[role='alertdialog'] button:has-text('확인'), [role='dialog'] button:has-text('확인'), button:has-text('확인')";
changeLog.push({
file, stepId: step.id, stepName: step.name,
before: 'action: "click_dialog_confirm"',
after: `action: "click_if_exists", target restored`
});
step.action = 'click_if_exists';
if (!step.target) {
step.target = dialogTarget;
}
}
// Revert 3: UPDATE fill on input/textarea → click_if_exists (remove value)
if (step.phase === 'UPDATE' && step.action === 'fill') {
const target = (step.target || '').toLowerCase();
const isInputTarget = target.includes('input[') || target.includes('input:') ||
target.includes('textarea[') || target.includes('textarea:') ||
target.includes('select[') || target.includes('select:');
// Only revert fill steps that target input/textarea (these were changed from click_if_exists)
if (isInputTarget) {
changeLog.push({
file, stepId: step.id, stepName: step.name,
before: `action: "fill", value: "${step.value}"`,
after: 'action: "click_if_exists" (value removed)'
});
step.action = 'click_if_exists';
delete step.value;
}
}
// Revert 4: CREATE fill_form (where fields don't match actual UI) → click_if_exists
// Only revert if step has fields array AND phase is CREATE
// We check if the step name suggests it's a form input step
if (step.phase === 'CREATE' && step.action === 'fill_form' && Array.isArray(step.fields)) {
// Check if this was changed by fix-scenario-quality.js (Issue 3)
// We can identify these by checking if the step was likely not originally fill_form
// Heuristic: if step name includes "정보 입력" and fields exist, it was likely changed
const nameHint = (step.name || '').toLowerCase();
const wasLikelyChanged = nameHint.includes('정보 입력') || nameHint.includes('정보입력');
if (wasLikelyChanged) {
changeLog.push({
file, stepId: step.id, stepName: step.name,
before: 'action: "fill_form" with fields',
after: 'action: "fill_form" (kept - fields exist)'
});
// Actually keep fill_form for CREATE - just log it. The fields need fixing, not the action.
}
}
}
const changesInFile = changeLog.length - beforeCount;
if (changesInFile > 0) {
filesModified++;
console.log(` [FIX] ${file} - ${changesInFile} change(s)`);
if (!DRY_RUN) {
const output = JSON.stringify(scenario, null, 2);
fs.writeFileSync(filePath, output + '\n', 'utf-8');
}
}
}
console.log();
console.log('='.repeat(60));
console.log(` Files modified: ${filesModified}`);
console.log(` Total changes: ${changeLog.length}`);
console.log('='.repeat(60));
console.log();
if (changeLog.length > 0) {
for (const c of changeLog) {
console.log(` ${c.file} (step ${c.stepId}: "${c.stepName}")`);
console.log(` ${c.before}${c.after}`);
}
console.log();
}
if (DRY_RUN && changeLog.length > 0) {
console.log(' *** DRY RUN: Run without --dry-run to apply. ***');
}
}
main();

View File

@@ -0,0 +1,294 @@
#!/usr/bin/env node
/**
* Search Audit Data Collector
* Visits all pages and collects search/filter UI detection results
* Outputs a structured JSON report
*/
const fs = require('fs');
const path = require('path');
const SAM_ROOT = path.resolve(__dirname, '..', '..');
const PW_PATH = path.join(SAM_ROOT, 'react', 'node_modules', 'playwright');
const { chromium } = require(PW_PATH);
const BASE_URL = 'https://dev.codebridge-x.com';
const AUTH = { username: 'TestUser5', password: 'password123!' };
const PAGES = [
{ l1: '회계관리', l2: '거래처관리' },
{ l1: '회계관리', l2: '입금관리' },
{ l1: '회계관리', l2: '출금관리' },
{ l1: '회계관리', l2: '어음관리' },
{ l1: '회계관리', l2: '악성채권추심관리' },
{ l1: '회계관리', l2: '입출금계좌조회' },
{ l1: '회계관리', l2: '카드내역조회' },
{ l1: '회계관리', l2: '매입관리' },
{ l1: '회계관리', l2: '매출관리' },
{ l1: '회계관리', l2: '미수금현황' },
{ l1: '회계관리', l2: '지출예상내역서' },
{ l1: '회계관리', l2: '결제내역' },
{ l1: '회계관리', l2: '거래처원장' },
{ l1: '인사관리', l2: '사원관리' },
{ l1: '인사관리', l2: '부서관리' },
{ l1: '인사관리', l2: '급여관리' },
{ l1: '인사관리', l2: '근태관리' },
{ l1: '인사관리', l2: '근태현황' },
{ l1: '인사관리', l2: '카드관리' },
{ l1: '인사관리', l2: '휴가관리' },
{ l1: '생산관리', l2: '품목관리' },
{ l1: '생산관리', l2: '생산 현황판' },
{ l1: '생산관리', l2: '작업자 화면' },
{ l1: '생산관리', l2: '작업지시 관리' },
{ l1: '생산관리', l2: '작업실적' },
{ l1: '품목관리', l2: '품목기준관리' },
{ l1: '품질관리', l2: '품질인정심사 시스템' },
{ l1: '품질관리', l2: '제품검사관리' },
{ l1: '자재관리', l2: '재고현황' },
{ l1: '자재관리', l2: '입고관리' },
{ l1: '판매관리', l2: '거래처관리' },
{ l1: '판매관리', l2: '수주관리' },
{ l1: '판매관리', l2: '단가관리' },
{ l1: '판매관리', l2: '견적관리' },
{ l1: '출고관리', l2: '출고관리' },
{ l1: '결재관리', l2: '결재함' },
{ l1: '결재관리', l2: '기안함' },
{ l1: '결재관리', l2: '참조함' },
{ l1: '게시판', l2: '자유게시판' },
{ l1: '게시판', l2: '게시판 관리' },
{ l1: '고객센터', l2: '공지사항' },
{ l1: '고객센터', l2: 'FAQ' },
{ l1: '고객센터', l2: '이벤트 게시판' },
{ l1: '설정', l2: '회사정보' },
{ l1: '설정', l2: '계정정보' },
{ l1: '설정', l2: '근태설정' },
{ l1: '설정', l2: '계좌관리' },
{ l1: '설정', l2: '알림설정' },
{ l1: '설정', l2: '권한관리' },
{ l1: '설정', l2: '팝업관리' },
{ l1: '설정', l2: '직책관리' },
{ l1: '설정', l2: '직급관리' },
{ l1: '설정', l2: '구독관리' },
{ l1: '설정', l2: '휴가정책' },
{ l1: '설정', l2: '근무일정' },
];
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const AUDIT_SCRIPT = `(async()=>{
const R={p:location.pathname,url:location.href};
const ss=['input[type="search"]','input[placeholder*="검색"]','input[placeholder*="Search"]','input[role="searchbox"]','[class*="search"] input'];
let si=null;
for(const s of ss){si=document.querySelector(s);if(si)break;}
if(!si) si=Array.from(document.querySelectorAll('input[type="text"],input:not([type])')).find(i=>/검색|search|조회/i.test(i.placeholder||''));
R.hasSearch=!!si;
R.searchPlaceholder=si?(si.placeholder||'').substring(0,60):'';
R.filters=document.querySelectorAll('button[role="combobox"],select').length;
R.filterTexts=Array.from(document.querySelectorAll('button[role="combobox"]')).map(f=>(f.innerText||'').trim().substring(0,20)).filter(Boolean);
R.tabs=document.querySelectorAll('[role="tab"],[role="tablist"] button').length;
const rc=()=>document.querySelectorAll('table tbody tr,[role="row"]').length;
R.rows=rc();
R.hasTable=R.rows>0||!!document.querySelector('table,[role="grid"]');
if(si&&R.rows>0){
const ns=Object.getOwnPropertyDescriptor(HTMLInputElement.prototype,'value')?.set;
if(ns)ns.call(si,'zzz_no_match_e2e');else si.value='zzz_no_match_e2e';
si.dispatchEvent(new Event('input',{bubbles:true}));
si.dispatchEvent(new Event('change',{bubbles:true}));
si.dispatchEvent(new KeyboardEvent('keydown',{key:'Enter',keyCode:13,bubbles:true}));
const sb=document.querySelector('button[class*="search"],button[aria-label*="검색"]');
if(sb)sb.click();
await new Promise(w=>setTimeout(w,1500));
R.afterRows=rc();
R.searchWorked=R.afterRows<R.rows;
if(ns)ns.call(si,'');else si.value='';
si.dispatchEvent(new Event('input',{bubbles:true}));
si.dispatchEvent(new Event('change',{bubbles:true}));
si.dispatchEvent(new KeyboardEvent('keydown',{key:'Enter',keyCode:13,bubbles:true}));
if(sb)sb.click();
await new Promise(w=>setTimeout(w,800));
}
return R;
})()`;
async function navigateViaMenu(page, level1, level2) {
// Collapse all
await page.evaluate(() => {
const btn = Array.from(document.querySelectorAll('button')).find(b => b.innerText?.trim() === '모두 접기');
if (btn) btn.click();
});
await sleep(300);
// Scroll top
await page.evaluate(() => {
const sidebar = document.querySelector('.sidebar-scroll, [data-sidebar="content"], nav');
if (sidebar) sidebar.scrollTo({ top: 0, behavior: 'instant' });
});
await sleep(300);
// Click L1
const l1Found = await page.evaluate(async ({ l1Text }) => {
const sidebar = document.querySelector('.sidebar-scroll, [data-sidebar="content"], nav');
for (let i = 0; i < 20; i++) {
const items = Array.from(document.querySelectorAll('a, button, [role="button"], [role="menuitem"], [role="treeitem"]'));
const match = items.find(el => {
const text = (el.textContent || el.innerText || '').trim();
return text && (text === l1Text || text.startsWith(l1Text));
});
if (match) { match.scrollIntoView({ behavior: 'instant', block: 'center' }); await new Promise(r => setTimeout(r, 100)); match.click(); return true; }
if (sidebar) { sidebar.scrollBy({ top: 150, behavior: 'instant' }); await new Promise(r => setTimeout(r, 100)); }
}
return false;
}, { l1Text: level1 });
if (!l1Found) return false;
await sleep(500);
if (level2) {
const l2Found = await page.evaluate(async ({ l2Text }) => {
const sidebar = document.querySelector('.sidebar-scroll, [data-sidebar="content"], nav');
for (let i = 0; i < 15; i++) {
const items = Array.from(document.querySelectorAll('a, button, [role="button"], [role="menuitem"], [role="treeitem"]'));
let match = items.find(el => (el.textContent || el.innerText || '').trim() === l2Text);
if (!match) match = items.find(el => (el.textContent || el.innerText || '').trim().includes(l2Text));
if (match) { match.scrollIntoView({ behavior: 'instant', block: 'center' }); await new Promise(r => setTimeout(r, 100)); match.click(); return true; }
if (sidebar) { sidebar.scrollBy({ top: 100, behavior: 'instant' }); await new Promise(r => setTimeout(r, 100)); }
}
return false;
}, { l2Text: level2 });
if (!l2Found) return false;
await sleep(2000);
}
return true;
}
(async () => {
const browser = await chromium.launch({
headless: false,
args: ['--window-position=1920,0', '--window-size=1920,1080']
});
const context = await browser.newContext({ viewport: { width: 1920, height: 1080 }, locale: 'ko-KR' });
const page = await context.newPage();
// Login
await page.goto(`${BASE_URL}/ko/login`, { waitUntil: 'domcontentloaded', timeout: 20000 });
await page.fill('#userId', AUTH.username);
await page.fill('#password', AUTH.password);
await page.click("button[type='submit']");
await sleep(3000);
console.log('Logged in');
const results = [];
let failed = 0;
for (let i = 0; i < PAGES.length; i++) {
const pg = PAGES[i];
const label = `${pg.l1} > ${pg.l2}`;
process.stdout.write(`(${i + 1}/${PAGES.length}) ${label} ... `);
try {
// Go to dashboard
await page.goto(`${BASE_URL}/dashboard`, { waitUntil: 'load', timeout: 10000 });
await sleep(1500);
// Navigate
const navOk = await navigateViaMenu(page, pg.l1, pg.l2);
if (!navOk) {
console.log('NAV FAIL');
results.push({ menu: label, error: 'navigation_failed' });
failed++;
continue;
}
// Run audit
const data = await page.evaluate(AUDIT_SCRIPT);
data.menu = label;
results.push(data);
const status = [];
if (data.hasSearch) status.push(`검색:${data.searchWorked === true ? '✅' : data.searchWorked === false ? '❌' : '⬜'}`);
else status.push('검색:없음');
if (data.filters > 0) status.push(`필터:${data.filters}`);
if (data.rows > 0) status.push(`행:${data.rows}`);
if (data.tabs > 0) status.push(`탭:${data.tabs}`);
console.log(status.join(' | '));
} catch (err) {
console.log(`ERROR: ${err.message.substring(0, 60)}`);
results.push({ menu: label, error: err.message });
failed++;
}
}
await browser.close();
// Generate report
const ts = (() => {
const n = new Date();
const p = v => v.toString().padStart(2, '0');
return `${n.getFullYear()}-${p(n.getMonth() + 1)}-${p(n.getDate())}_${p(n.getHours())}-${p(n.getMinutes())}-${p(n.getSeconds())}`;
})();
// Summary
const withSearch = results.filter(r => r.hasSearch);
const searchWorked = results.filter(r => r.searchWorked === true);
const searchFailed = results.filter(r => r.searchWorked === false);
const noSearch = results.filter(r => !r.hasSearch && !r.error);
const withFilters = results.filter(r => (r.filters || 0) > 0);
const withTable = results.filter(r => r.hasTable);
const errors = results.filter(r => r.error);
let md = `# 검색 기능 전체 탐색 감사 리포트
**실행**: ${ts} | **총 페이지**: ${PAGES.length} | **에러**: ${errors.length}
## 요약
| 항목 | 수 | 비율 |
|------|-----|------|
| 검색 입력 있음 | ${withSearch.length} | ${Math.round(withSearch.length / PAGES.length * 100)}% |
| 검색 동작 확인 | ${searchWorked.length} | ${withSearch.length > 0 ? Math.round(searchWorked.length / withSearch.length * 100) : 0}% |
| 검색 미동작 | ${searchFailed.length} | ${withSearch.length > 0 ? Math.round(searchFailed.length / withSearch.length * 100) : 0}% |
| 검색 없음 | ${noSearch.length} | ${Math.round(noSearch.length / PAGES.length * 100)}% |
| 필터 있음 | ${withFilters.length} | ${Math.round(withFilters.length / PAGES.length * 100)}% |
| 테이블 있음 | ${withTable.length} | ${Math.round(withTable.length / PAGES.length * 100)}% |
| 탐색 에러 | ${errors.length} | ${Math.round(errors.length / PAGES.length * 100)}% |
## 전체 페이지별 상세
| # | 메뉴 | URL | 검색 | 필터 | 탭 | 테이블행 | 검색동작 | 비고 |
|---|------|-----|------|------|-----|---------|---------|------|
`;
results.forEach((r, idx) => {
if (r.error) {
md += `| ${idx + 1} | ${r.menu} | - | - | - | - | - | - | ❌ ${r.error.substring(0, 40)} |\n`;
} else {
const searchIcon = r.hasSearch ? '✅' : '❌';
const workedIcon = r.searchWorked === true ? '✅ 동작' : r.searchWorked === false ? '⚠️ 미동작' : r.hasSearch ? '⬜ 테이블없음' : '-';
const rowInfo = r.hasTable ? `${r.rows}${r.afterRows !== undefined ? `${r.afterRows}` : ''}` : '없음';
const note = r.searchPlaceholder || '';
md += `| ${idx + 1} | ${r.menu} | ${(r.p || '').substring(0, 35)} | ${searchIcon} | ${r.filters || 0} | ${r.tabs || 0} | ${rowInfo} | ${workedIcon} | ${note.substring(0, 30)} |\n`;
}
});
if (searchFailed.length > 0) {
md += `\n## ⚠️ 검색 미동작 페이지 (상세)\n\n`;
searchFailed.forEach(r => {
md += `### ${r.menu}\n`;
md += `- URL: ${r.url || r.p}\n`;
md += `- 검색 placeholder: ${r.searchPlaceholder}\n`;
md += `- 검색 전 행: ${r.rows} → 검색 후 행: ${r.afterRows}\n`;
md += `- 필터: ${r.filters}개, 탭: ${r.tabs}\n\n`;
});
}
if (noSearch.length > 0) {
md += `\n## 검색 입력 없는 페이지\n\n`;
noSearch.forEach(r => {
md += `- ${r.menu} (${r.p}) - 필터: ${r.filters || 0}, 탭: ${r.tabs || 0}, 테이블: ${r.hasTable ? r.rows + '행' : '없음'}\n`;
});
}
const reportPath = path.join(SAM_ROOT, 'e2e', 'results', 'hotfix', `Search-Audit-Report_${ts}.md`);
fs.writeFileSync(reportPath, md, 'utf-8');
console.log(`\n=== 감사 완료 ===`);
console.log(`총: ${PAGES.length} | 검색있음: ${withSearch.length} | 동작: ${searchWorked.length} | 미동작: ${searchFailed.length} | 에러: ${errors.length}`);
console.log(`리포트: ${reportPath}`);
})();

View File

@@ -0,0 +1,364 @@
/**
* 검색 버그 상세 검증 수집기
* 급여관리 + 기안함 페이지의 검색 기능을 직접 Playwright로 테스트하고
* 실제 JSON 결과를 캡처한다.
*/
const SAM_ROOT = require('path').resolve(__dirname, '..', '..');
const PW_PATH = require('path').join(SAM_ROOT, 'react', 'node_modules', 'playwright');
const { chromium } = require(PW_PATH);
const fs = require('fs');
const path = require('path');
const BASE = 'https://dev.codebridge-x.com';
const PAGES = [
{ name: '급여관리', level1: '인사관리', level2: '급여관리', expectedUrl: '/hr/salary-management' },
{ name: '기안함', level1: '결재관리', level2: '기안함', expectedUrl: '/approval/draft' },
];
const TESTS = [
{
name: '사전조사: UI 구조 분석',
script: `(async()=>{
const R={};
const si=['input[type="search"]','input[placeholder*="검색"]','input[role="searchbox"]','[class*="search"] input','[class*="Search"] input'];
R.searchInputs=[];
for(const s of si){const els=document.querySelectorAll(s);els.forEach(e=>{R.searchInputs.push({sel:s,ph:e.placeholder||'',type:e.type,cls:e.className?.substring(0,60)||''})});}
const rows=document.querySelectorAll('table tbody tr');
R.rowCount=rows.length;
R.sampleData=Array.from(rows).slice(0,3).map(r=>Array.from(r.querySelectorAll('td')).map(td=>td.innerText?.trim().substring(0,25)));
const btns=Array.from(document.querySelectorAll('button')).filter(b=>['검색','조회','Search','초기화'].some(t=>b.innerText?.includes(t)));
R.searchButtons=btns.map(b=>b.innerText?.trim().substring(0,20));
R.filters=document.querySelectorAll('select,[role="combobox"]').length;
return R;
})()`
},
{
name: '테스트1: nonsense 검색 (input 이벤트)',
script: `(async()=>{
const R={test:'nonsense_input'};
const si=['input[type="search"]','input[placeholder*="검색"]','input[role="searchbox"]','[class*="search"] input'];
let el=null;for(const s of si){el=document.querySelector(s);if(el)break;}
if(!el)return {error:'검색 입력란 없음'};
R.rowsBefore=document.querySelectorAll('table tbody tr').length;
R.placeholder=el.placeholder;
const ns=Object.getOwnPropertyDescriptor(HTMLInputElement.prototype,'value')?.set;
el.focus();
if(ns)ns.call(el,'zzz_no_match_bug');else el.value='zzz_no_match_bug';
el.dispatchEvent(new Event('input',{bubbles:true}));
el.dispatchEvent(new Event('change',{bubbles:true}));
await new Promise(r=>setTimeout(r,2000));
R.rowsAfter=document.querySelectorAll('table tbody tr').length;
R.filtered=R.rowsBefore!==R.rowsAfter;
R.verdict=R.filtered?'PASS':'FAIL: 행수불변('+R.rowsBefore+'→'+R.rowsAfter+')';
return R;
})()`
},
{
name: '테스트2: Enter 키 검색',
script: `(async()=>{
const R={test:'enter_key'};
const si=['input[type="search"]','input[placeholder*="검색"]','input[role="searchbox"]','[class*="search"] input'];
let el=null;for(const s of si){el=document.querySelector(s);if(el)break;}
if(!el)return {error:'검색 입력란 없음'};
R.rowsBefore=document.querySelectorAll('table tbody tr').length;
R.currentValue=el.value;
el.focus();
el.dispatchEvent(new KeyboardEvent('keydown',{key:'Enter',code:'Enter',keyCode:13,bubbles:true}));
el.dispatchEvent(new KeyboardEvent('keypress',{key:'Enter',code:'Enter',keyCode:13,bubbles:true}));
el.dispatchEvent(new KeyboardEvent('keyup',{key:'Enter',code:'Enter',keyCode:13,bubbles:true}));
const form=el.closest('form');
if(form)form.dispatchEvent(new Event('submit',{bubbles:true,cancelable:true}));
await new Promise(r=>setTimeout(r,2000));
R.rowsAfter=document.querySelectorAll('table tbody tr').length;
R.filtered=R.rowsBefore!==R.rowsAfter;
R.verdict=R.filtered?'PASS':'FAIL: Enter후 행수불변('+R.rowsBefore+'→'+R.rowsAfter+')';
return R;
})()`
},
{
name: '테스트3: 검색 버튼 클릭',
script: `(async()=>{
const R={test:'button_click'};
const btns=Array.from(document.querySelectorAll('button'));
const searchBtn=btns.find(b=>['검색','조회','Search'].some(t=>b.innerText?.trim()===t));
const iconBtn=!searchBtn?document.querySelector('button svg[class*="search"],button svg[class*="Search"]')?.closest('button'):null;
const btn=searchBtn||iconBtn;
R.buttonFound=!!btn;
R.buttonText=btn?.innerText?.trim().substring(0,20)||'none';
if(btn){
R.rowsBefore=document.querySelectorAll('table tbody tr').length;
btn.click();
await new Promise(r=>setTimeout(r,2000));
R.rowsAfter=document.querySelectorAll('table tbody tr').length;
R.filtered=R.rowsBefore!==R.rowsAfter;
R.verdict=R.filtered?'PASS':'FAIL: 버튼클릭후 행수불변('+R.rowsBefore+'→'+R.rowsAfter+')';
}else{R.verdict='SKIP: 검색 버튼 없음';}
return R;
})()`
},
{
name: '테스트4: React onChange 직접 호출',
script: `(async()=>{
const R={test:'react_onChange'};
const si=['input[type="search"]','input[placeholder*="검색"]','input[role="searchbox"]','[class*="search"] input'];
let el=null;for(const s of si){el=document.querySelector(s);if(el)break;}
if(!el)return {error:'검색 입력란 없음'};
// clear first
const ns=Object.getOwnPropertyDescriptor(HTMLInputElement.prototype,'value')?.set;
if(ns)ns.call(el,'');
el.dispatchEvent(new Event('input',{bubbles:true}));
await new Promise(r=>setTimeout(r,1000));
R.rowsAfterClear=document.querySelectorAll('table tbody tr').length;
// search
if(ns)ns.call(el,'zzz_react_test');
const rk=Object.keys(el).find(k=>k.startsWith('__reactProps$'));
R.hasReactProps=!!rk;
R.hasOnChange=!!(rk&&el[rk]?.onChange);
if(rk&&el[rk]?.onChange){
el[rk].onChange({target:el,currentTarget:el});
}else{
el.dispatchEvent(new Event('input',{bubbles:true}));
}
await new Promise(r=>setTimeout(r,2000));
R.rowsAfter=document.querySelectorAll('table tbody tr').length;
R.filtered=R.rowsAfterClear!==R.rowsAfter;
R.verdict=R.filtered?'PASS':'FAIL: React onChange후 행수불변('+R.rowsAfterClear+'→'+R.rowsAfter+')';
return R;
})()`
},
{
name: '검색 초기화',
script: `(async()=>{
const si=['input[type="search"]','input[placeholder*="검색"]','input[role="searchbox"]','[class*="search"] input'];
let el=null;for(const s of si){el=document.querySelector(s);if(el)break;}
if(!el)return {msg:'no search input'};
const ns=Object.getOwnPropertyDescriptor(HTMLInputElement.prototype,'value')?.set;
if(ns)ns.call(el,'');else el.value='';
const rk=Object.keys(el).find(k=>k.startsWith('__reactProps$'));
if(rk&&el[rk]?.onChange)el[rk].onChange({target:el,currentTarget:el});
el.dispatchEvent(new Event('input',{bubbles:true}));
el.dispatchEvent(new Event('change',{bubbles:true}));
await new Promise(r=>setTimeout(r,1500));
return {cleared:true,rows:document.querySelectorAll('table tbody tr').length};
})()`
},
{
name: '테스트5: API 호출 모니터링',
script: `(async()=>{
const R={test:'api_monitor'};
const captured=[];
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';
captured.push({url:url.substring(0,120),method,time:Date.now()});
return origFetch.apply(this,args);
};
const si=['input[type="search"]','input[placeholder*="검색"]','input[role="searchbox"]','[class*="search"] input'];
let el=null;for(const s of si){el=document.querySelector(s);if(el)break;}
if(!el){window.fetch=origFetch;return {error:'검색 입력란 없음'};}
const ns=Object.getOwnPropertyDescriptor(HTMLInputElement.prototype,'value')?.set;
captured.length=0;
if(ns)ns.call(el,'api_monitor_query');
el.dispatchEvent(new Event('input',{bubbles:true}));
el.dispatchEvent(new Event('change',{bubbles:true}));
el.dispatchEvent(new KeyboardEvent('keydown',{key:'Enter',code:'Enter',keyCode:13,bubbles:true}));
await new Promise(r=>setTimeout(r,3000));
window.fetch=origFetch;
R.allCalls=captured.filter(c=>!c.url.includes('hot-update')&&!c.url.includes('sockjs')&&!c.url.includes('_next'));
R.apiCount=R.allCalls.length;
R.verdict=R.apiCount>0?'API '+R.apiCount+'건 호출됨':'FAIL: 검색시 API 호출 없음';
return R;
})()`
},
{
name: '테스트6: 실존 데이터 검색',
script: `(async()=>{
const R={test:'real_data'};
// clear first
const si=['input[type="search"]','input[placeholder*="검색"]','input[role="searchbox"]','[class*="search"] input'];
let el=null;for(const s of si){el=document.querySelector(s);if(el)break;}
if(!el)return {error:'검색 입력란 없음'};
const ns=Object.getOwnPropertyDescriptor(HTMLInputElement.prototype,'value')?.set;
if(ns)ns.call(el,'');
const rk=Object.keys(el).find(k=>k.startsWith('__reactProps$'));
if(rk&&el[rk]?.onChange)el[rk].onChange({target:el,currentTarget:el});
el.dispatchEvent(new Event('input',{bubbles:true}));
await new Promise(r=>setTimeout(r,1500));
const rows=document.querySelectorAll('table tbody tr');
R.totalRows=rows.length;
if(!rows.length)return {...R,error:'행 없음'};
// extract search term from first row
const cells=Array.from(rows[0].querySelectorAll('td'));
R.firstRowData=cells.map(c=>c.innerText?.trim().substring(0,30));
let term='';
for(const c of cells){
const t=c.innerText?.trim();
if(t&&t.length>=2&&t.length<15&&!/^[\\d,.\\/\\-]+$/.test(t)&&!t.includes('원')){term=t.substring(0,Math.min(t.length,6));break;}
}
R.searchTerm=term;
if(!term)return {...R,error:'검색어 추출실패'};
if(ns)ns.call(el,term);
if(rk&&el[rk]?.onChange)el[rk].onChange({target:el,currentTarget:el});
el.dispatchEvent(new Event('input',{bubbles:true}));
el.dispatchEvent(new Event('change',{bubbles:true}));
el.dispatchEvent(new KeyboardEvent('keydown',{key:'Enter',code:'Enter',keyCode:13,bubbles:true}));
await new Promise(r=>setTimeout(r,2000));
R.rowsAfter=document.querySelectorAll('table tbody tr').length;
R.filtered=R.totalRows!==R.rowsAfter;
R.verdict=R.filtered?'PASS: 실존데이터 검색동작('+R.totalRows+'→'+R.rowsAfter+')':'FAIL: 실존데이터 검색에도 행수불변('+R.totalRows+'→'+R.rowsAfter+')';
return R;
})()`
},
];
async function navigateViaMenu(page, level1, level2) {
await page.evaluate(async ({l1, l2}) => {
const sidebar = document.querySelector('.sidebar-scroll, nav, [class*="sidebar"], [class*="Sidebar"]');
if (sidebar) sidebar.scrollTo({ top: 0, behavior: 'instant' });
await new Promise(r => setTimeout(r, 300));
for (let i = 0; i < 20; i++) {
const btns = Array.from(document.querySelectorAll('button, [role="button"], a'));
const l1btn = btns.find(b => b.innerText?.trim().startsWith(l1));
if (l1btn) {
l1btn.click();
await new Promise(r => setTimeout(r, 600));
const links = Array.from(document.querySelectorAll('a, button'));
const l2link = links.find(el => el.innerText?.trim() === l2);
if (l2link) {
l2link.click();
await new Promise(r => setTimeout(r, 2000));
return true;
}
}
if (sidebar) sidebar.scrollBy({ top: 150, behavior: 'instant' });
await new Promise(r => setTimeout(r, 150));
}
return false;
}, { l1: level1, l2: level2 });
}
(async () => {
const browser = await chromium.launch({
headless: false,
args: ['--window-position=1920,0', '--window-size=1920,1080']
});
const context = await browser.newContext({ viewport: { width: 1920, height: 1080 }, locale: 'ko-KR' });
const page = await context.newPage();
// Login
await page.goto(`${BASE}/ko/login`, { waitUntil: 'networkidle', timeout: 30000 });
await page.fill('input[type="text"], input[name="username"], #username', 'TestUser5');
await page.fill('input[type="password"]', 'password123!');
await page.click('button[type="submit"]');
await page.waitForTimeout(3000);
console.log('Login OK');
const allResults = {};
for (const pg of PAGES) {
console.log(`\n${'='.repeat(60)}`);
console.log(`PAGE: ${pg.name} (${pg.level1} > ${pg.level2})`);
console.log('='.repeat(60));
// Navigate to dashboard first
await page.goto(`${BASE}/ko/dashboard`, { waitUntil: 'networkidle', timeout: 15000 });
await page.waitForTimeout(1000);
// Navigate via menu
await navigateViaMenu(page, pg.level1, pg.level2);
await page.waitForTimeout(2000);
const pageResults = { name: pg.name, url: page.url(), tests: [] };
for (const test of TESTS) {
try {
const result = await page.evaluate(test.script);
pageResults.tests.push({ name: test.name, result });
const verdict = result?.verdict || result?.msg || result?.error || 'OK';
const isFail = typeof verdict === 'string' && verdict.startsWith('FAIL');
console.log(` ${isFail ? '❌' : '✅'} ${test.name}: ${verdict}`);
if (result?.rowsBefore !== undefined) {
console.log(` rows: ${result.rowsBefore}${result.rowsAfter}`);
}
if (result?.allCalls) {
console.log(` API calls: ${result.allCalls.map(c => c.method + ' ' + c.url).join('\n ')}`);
}
if (result?.searchInputs) {
console.log(` inputs: ${JSON.stringify(result.searchInputs)}`);
console.log(` rows: ${result.rowCount}, buttons: ${JSON.stringify(result.searchButtons)}`);
if (result.sampleData) {
result.sampleData.forEach((row, i) => console.log(` row${i}: ${row.join(' | ')}`));
}
}
} catch (err) {
pageResults.tests.push({ name: test.name, error: err.message });
console.log(` ⚠️ ${test.name}: ERROR - ${err.message}`);
}
}
allResults[pg.name] = pageResults;
}
// Generate report
const now = new Date();
const pad = n => n.toString().padStart(2, '0');
const ts = `${now.getFullYear()}-${pad(now.getMonth()+1)}-${pad(now.getDate())}_${pad(now.getHours())}-${pad(now.getMinutes())}-${pad(now.getSeconds())}`;
let md = `# 검색 버그 상세 검증 리포트\n\n`;
md += `**실행**: ${ts} | **대상**: 급여관리, 기안함\n\n`;
for (const [pageName, data] of Object.entries(allResults)) {
md += `## ${pageName}\n\n`;
md += `**URL**: ${data.url}\n\n`;
md += `| # | 테스트 | 결과 | 상세 |\n`;
md += `|---|--------|------|------|\n`;
data.tests.forEach((t, i) => {
const r = t.result || {};
const verdict = r.verdict || r.msg || r.error || t.error || 'OK';
const isFail = typeof verdict === 'string' && verdict.includes('FAIL');
const icon = t.error ? '⚠️' : isFail ? '❌' : '✅';
let detail = '';
if (r.rowsBefore !== undefined) detail = `${r.rowsBefore}${r.rowsAfter}`;
else if (r.rowCount !== undefined) detail = `${r.rowCount}행, 입력${r.searchInputs?.length||0}개, 필터${r.filters||0}`;
else if (r.apiCount !== undefined) detail = `API ${r.apiCount}`;
else if (r.searchTerm) detail = `검색어: "${r.searchTerm}"`;
else if (r.cleared) detail = `${r.rows}`;
md += `| ${i+1} | ${t.name} | ${icon} ${verdict.substring(0,50)} | ${detail} |\n`;
});
md += `\n`;
// Raw data section
md += `<details><summary>원시 데이터</summary>\n\n`;
md += `\`\`\`json\n${JSON.stringify(data, null, 2).substring(0, 5000)}\n\`\`\`\n\n`;
md += `</details>\n\n`;
}
// Bug summary
md += `## 버그 요약\n\n`;
for (const [pageName, data] of Object.entries(allResults)) {
const fails = data.tests.filter(t => t.result?.verdict?.includes?.('FAIL'));
if (fails.length > 0) {
md += `### BUG: ${pageName} 검색 기능 미동작\n\n`;
md += `- **위치**: ${data.url}\n`;
md += `- **심각도**: HIGH\n`;
md += `- **증상**: 검색 입력란이 존재하지만 어떤 방식으로도 테이블 필터링이 동작하지 않음\n`;
md += `- **테스트 방법**: input이벤트, Enter키, 버튼클릭, React onChange — 모두 실패\n`;
md += `- **실패 항목**:\n`;
fails.forEach(f => {
md += ` - ${f.name}: ${f.result.verdict}\n`;
});
md += `\n`;
}
}
const reportPath = path.join('C:/Users/codeb/sam/e2e/results/hotfix', `Search-Bug-Detail_${ts}.md`);
fs.writeFileSync(reportPath, md, 'utf8');
console.log(`\nReport: ${reportPath}`);
await browser.close();
console.log('Done.');
})();

View File

@@ -0,0 +1,252 @@
/**
* 검색 버튼 유무 전체 감사
* 검색 입력란이 있는 모든 페이지에서 검색 버튼 존재 여부를 확인한다.
*/
const fs = require('fs');
const path = require('path');
const SAM_ROOT = path.resolve(__dirname, '..', '..');
const PW_PATH = path.join(SAM_ROOT, 'react', 'node_modules', 'playwright');
const { chromium } = require(PW_PATH);
const BASE = 'https://dev.codebridge-x.com';
// 검색 입력란이 있는 38개 페이지 (이전 감사 결과 기준)
const PAGES = [
{ name: '거래처관리', l1: '회계관리', l2: '거래처관리' },
{ name: '입금관리', l1: '회계관리', l2: '입금관리' },
{ name: '출금관리', l1: '회계관리', l2: '출금관리' },
{ name: '어음관리', l1: '회계관리', l2: '어음관리' },
{ name: '악성채권추심관리', l1: '회계관리', l2: '악성채권추심관리' },
{ name: '입출금계좌조회', l1: '회계관리', l2: '입출금계좌조회' },
{ name: '카드내역조회', l1: '회계관리', l2: '카드내역조회' },
{ name: '매입관리', l1: '회계관리', l2: '매입관리' },
{ name: '매출관리', l1: '회계관리', l2: '매출관리' },
{ name: '미수금현황', l1: '회계관리', l2: '미수금현황' },
{ name: '지출예상내역서', l1: '회계관리', l2: '지출예상내역서' },
{ name: '거래처원장', l1: '회계관리', l2: '거래처원장' },
{ name: '사원관리', l1: '인사관리', l2: '사원관리' },
{ name: '부서관리', l1: '인사관리', l2: '부서관리' },
{ name: '급여관리', l1: '인사관리', l2: '급여관리' },
{ name: '근태관리', l1: '인사관리', l2: '근태관리' },
{ name: '카드관리', l1: '인사관리', l2: '카드관리' },
{ name: '휴가관리', l1: '인사관리', l2: '휴가관리' },
{ name: '작업지시 관리', l1: '생산관리', l2: '작업지시 관리' },
{ name: '작업실적', l1: '생산관리', l2: '작업실적' },
{ name: '재고현황', l1: '자재관리', l2: '재고현황' },
{ name: '입고관리', l1: '자재관리', l2: '입고관리' },
{ name: '제품검사관리', l1: '품질관리', l2: '제품검사관리' },
{ name: '판매거래처관리', l1: '판매관리', l2: '거래처관리' },
{ name: '수주관리', l1: '판매관리', l2: '수주관리' },
{ name: '단가관리', l1: '판매관리', l2: '단가관리' },
{ name: '견적관리', l1: '판매관리', l2: '견적관리' },
{ name: '결재함', l1: '결재관리', l2: '결재함' },
{ name: '기안함', l1: '결재관리', l2: '기안함' },
{ name: '참조함', l1: '결재관리', l2: '참조함' },
{ name: '자유게시판', l1: '게시판', l2: '자유게시판' },
{ name: '게시판 관리', l1: '게시판', l2: '게시판 관리' },
{ name: '공지사항', l1: '고객센터', l2: '공지사항' },
{ name: 'FAQ', l1: '고객센터', l2: 'FAQ' },
{ name: '이벤트 게시판', l1: '고객센터', l2: '이벤트 게시판' },
{ name: '계좌관리', l1: '설정', l2: '계좌관리' },
{ name: '권한관리', l1: '설정', l2: '권한관리' },
{ name: '팝업관리', l1: '설정', l2: '팝업관리' },
];
const AUDIT_SCRIPT = `(async () => {
const R = { url: location.pathname };
// 검색 입력란 탐색
const si = ['input[type="search"]','input[placeholder*="검색"]','input[placeholder*="Search"]','input[role="searchbox"]','[class*="search"] input','[class*="Search"] input'];
let searchEl = null;
for (const s of si) { searchEl = document.querySelector(s); if (searchEl) break; }
R.hasSearchInput = !!searchEl;
R.placeholder = searchEl?.placeholder || '';
// 검색 버튼 탐색 (여러 패턴)
const allBtns = Array.from(document.querySelectorAll('button, a[role="button"], [role="button"]'));
// 1) 텍스트 기반 검색 버튼
const textSearchBtn = allBtns.find(b => {
const t = b.innerText?.trim();
return t === '검색' || t === '조회' || t === 'Search';
});
// 2) 아이콘 기반 검색 버튼 (돋보기 SVG)
const iconSearchBtn = document.querySelector(
'button svg[class*="search"], button svg[class*="Search"], button svg[class*="magnif"], button [class*="lucide-search"], button [data-icon="search"]'
)?.closest('button');
// 3) aria-label 기반
const ariaSearchBtn = allBtns.find(b => {
const label = b.getAttribute('aria-label') || '';
return label.includes('검색') || label.includes('search') || label.includes('Search');
});
// 4) 검색 입력란 옆의 버튼 (sibling/parent 기준)
let adjacentBtn = null;
if (searchEl) {
const parent = searchEl.closest('div, form, header, [class*="toolbar"], [class*="search"]');
if (parent) {
const btns = parent.querySelectorAll('button');
adjacentBtn = Array.from(btns).find(b => {
const t = b.innerText?.trim();
return !t || t === '검색' || t === '조회' || b.querySelector('svg');
});
}
}
R.textSearchBtn = textSearchBtn ? textSearchBtn.innerText?.trim().substring(0, 20) : null;
R.iconSearchBtn = iconSearchBtn ? (iconSearchBtn.innerText?.trim().substring(0, 20) || '[icon]') : null;
R.ariaSearchBtn = ariaSearchBtn ? (ariaSearchBtn.getAttribute('aria-label') || ariaSearchBtn.innerText?.trim()).substring(0, 20) : null;
R.adjacentBtn = adjacentBtn ? (adjacentBtn.innerText?.trim().substring(0, 20) || '[icon-btn]') : null;
R.hasSearchButton = !!(textSearchBtn || iconSearchBtn || ariaSearchBtn);
R.hasAdjacentBtn = !!adjacentBtn;
// 초기화 버튼 탐색
const resetBtn = allBtns.find(b => {
const t = b.innerText?.trim();
return t === '초기화' || t === '리셋' || t === 'Reset' || t === 'Clear';
});
R.hasResetBtn = !!resetBtn;
R.resetBtnText = resetBtn?.innerText?.trim().substring(0, 20) || null;
// 테이블 행 수
R.tableRows = document.querySelectorAll('table tbody tr').length;
// 검색 트리거 방식 추정 (input에 keydown 핸들러 유무)
if (searchEl) {
const rk = Object.keys(searchEl).find(k => k.startsWith('__reactProps$'));
R.reactProps = {};
if (rk && searchEl[rk]) {
R.reactProps.onChange = !!searchEl[rk].onChange;
R.reactProps.onKeyDown = !!searchEl[rk].onKeyDown;
R.reactProps.onKeyUp = !!searchEl[rk].onKeyUp;
R.reactProps.onKeyPress = !!searchEl[rk].onKeyPress;
R.reactProps.onSubmit = !!searchEl[rk].onSubmit;
}
const form = searchEl.closest('form');
R.insideForm = !!form;
if (form) {
const formRk = Object.keys(form).find(k => k.startsWith('__reactProps$'));
R.formOnSubmit = !!(formRk && form[formRk]?.onSubmit);
}
}
return R;
})()`;
async function navigateViaMenu(page, l1, l2) {
return page.evaluate(async ({ l1, l2 }) => {
const sidebar = document.querySelector('.sidebar-scroll, nav, [class*="sidebar"], [class*="Sidebar"]');
if (sidebar) sidebar.scrollTo({ top: 0, behavior: 'instant' });
await new Promise(r => setTimeout(r, 300));
for (let i = 0; i < 20; i++) {
const btns = Array.from(document.querySelectorAll('button, [role="button"], a'));
const l1btn = btns.find(b => b.innerText?.trim().startsWith(l1));
if (l1btn) {
l1btn.click();
await new Promise(r => setTimeout(r, 600));
const links = Array.from(document.querySelectorAll('a, button'));
const l2link = links.find(el => el.innerText?.trim() === l2);
if (l2link) { l2link.click(); await new Promise(r => setTimeout(r, 2000)); return true; }
}
if (sidebar) sidebar.scrollBy({ top: 150, behavior: 'instant' });
await new Promise(r => setTimeout(r, 150));
}
return false;
}, { l1, l2 });
}
(async () => {
const browser = await chromium.launch({ headless: false, args: ['--window-position=1920,0', '--window-size=1920,1080'] });
const ctx = await browser.newContext({ viewport: { width: 1920, height: 1080 }, locale: 'ko-KR' });
const page = await ctx.newPage();
await page.goto(`${BASE}/ko/login`, { waitUntil: 'networkidle', timeout: 30000 });
await page.fill('input[type="text"], input[name="username"], #username', 'TestUser5');
await page.fill('input[type="password"]', 'password123!');
await page.click('button[type="submit"]');
await page.waitForTimeout(3000);
console.log('Login OK\n');
const results = [];
for (let i = 0; i < PAGES.length; i++) {
const pg = PAGES[i];
try {
await page.goto(`${BASE}/ko/dashboard`, { waitUntil: 'networkidle', timeout: 10000 });
await page.waitForTimeout(500);
await navigateViaMenu(page, pg.l1, pg.l2);
await page.waitForTimeout(2000);
const r = await page.evaluate(AUDIT_SCRIPT);
r.name = pg.name;
r.menu = `${pg.l1} > ${pg.l2}`;
results.push(r);
const btnIcon = r.hasSearchButton ? '✅' : (r.hasAdjacentBtn ? '⚠️' : '❌');
const btnDetail = r.textSearchBtn || r.iconSearchBtn || r.ariaSearchBtn || (r.hasAdjacentBtn ? r.adjacentBtn : '없음');
console.log(`(${i+1}/${PAGES.length}) ${btnIcon} ${pg.name.padEnd(12)} | 검색버튼: ${btnDetail.padEnd(12)} | 초기화: ${r.hasResetBtn ? r.resetBtnText : '없음'} | 행: ${r.tableRows} | onKeyDown: ${r.reactProps?.onKeyDown || false}`);
} catch (err) {
console.log(`(${i+1}/${PAGES.length}) ⚠️ ${pg.name}: ERROR - ${err.message.substring(0, 60)}`);
results.push({ name: pg.name, menu: `${pg.l1} > ${pg.l2}`, error: err.message });
}
}
// Report
const now = new Date();
const pad = n => n.toString().padStart(2, '0');
const ts = `${now.getFullYear()}-${pad(now.getMonth()+1)}-${pad(now.getDate())}_${pad(now.getHours())}-${pad(now.getMinutes())}-${pad(now.getSeconds())}`;
const withBtn = results.filter(r => r.hasSearchButton);
const withAdjOnly = results.filter(r => !r.hasSearchButton && r.hasAdjacentBtn);
const noBtn = results.filter(r => !r.hasSearchButton && !r.hasAdjacentBtn && r.hasSearchInput);
const noSearch = results.filter(r => !r.hasSearchInput && !r.error);
let md = `# 검색 버튼 유무 전체 감사 리포트\n\n`;
md += `**실행**: ${ts} | **대상**: 검색 입력란 보유 ${PAGES.length}개 페이지\n\n`;
md += `## 요약\n\n`;
md += `| 분류 | 수 | 비율 |\n|------|-----|------|\n`;
md += `| 검색 버튼 있음 | ${withBtn.length} | ${Math.round(withBtn.length/PAGES.length*100)}% |\n`;
md += `| 인접 버튼만 있음 | ${withAdjOnly.length} | ${Math.round(withAdjOnly.length/PAGES.length*100)}% |\n`;
md += `| 검색 버튼 없음 (입력란만) | ${noBtn.length} | ${Math.round(noBtn.length/PAGES.length*100)}% |\n`;
md += `| 검색 입력란 자체 없음 | ${noSearch.length} | ${Math.round(noSearch.length/PAGES.length*100)}% |\n\n`;
md += `## 전체 페이지별 상세\n\n`;
md += `| # | 메뉴 | URL | 검색버튼 | 초기화 | 행수 | onKeyDown | 비고 |\n`;
md += `|---|------|-----|---------|--------|------|-----------|------|\n`;
results.forEach((r, i) => {
if (r.error) {
md += `| ${i+1} | ${r.menu} | - | ⚠️ 에러 | - | - | - | ${r.error.substring(0, 30)} |\n`;
return;
}
const btnIcon = r.hasSearchButton ? '✅' : (r.hasAdjacentBtn ? '⚠️' : '❌');
const btnText = r.textSearchBtn || r.iconSearchBtn || r.ariaSearchBtn || (r.hasAdjacentBtn ? `인접: ${r.adjacentBtn}` : '없음');
const kd = r.reactProps?.onKeyDown ? '✅' : '❌';
md += `| ${i+1} | ${r.menu} | ${r.url} | ${btnIcon} ${btnText} | ${r.hasResetBtn ? '✅ '+r.resetBtnText : '❌'} | ${r.tableRows} | ${kd} | ${r.placeholder?.substring(0,25)||''} |\n`;
});
if (noBtn.length > 0) {
md += `\n## ❌ 검색 버튼 없는 페이지 (검색 입력란만 존재)\n\n`;
noBtn.forEach(r => {
md += `### ${r.menu}\n`;
md += `- URL: ${r.url}\n`;
md += `- placeholder: ${r.placeholder}\n`;
md += `- 초기화 버튼: ${r.hasResetBtn ? '있음 ('+r.resetBtnText+')' : '없음'}\n`;
md += `- React onKeyDown: ${r.reactProps?.onKeyDown || false}\n`;
md += `- form 내부: ${r.insideForm || false}\n`;
md += `- 테이블 행: ${r.tableRows}\n\n`;
});
}
const reportPath = path.join('C:/Users/codeb/sam/e2e/results/hotfix', `Search-Button-Audit_${ts}.md`);
fs.writeFileSync(reportPath, md, 'utf8');
console.log(`\n${'='.repeat(60)}`);
console.log(`검색 버튼 있음: ${withBtn.length}`);
console.log(`인접 버튼만: ${withAdjOnly.length}`);
console.log(`검색 버튼 없음: ${noBtn.length}`);
console.log(`검색 입력 없음: ${noSearch.length}`);
console.log(`Report: ${reportPath}`);
await browser.close();
})();