From 67d0a4c2fd8471dc2b402577752e483f6cb22e3a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=B3=B4=EA=B3=A4?= Date: Thu, 19 Feb 2026 16:59:15 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20E2E=20=EC=8B=9C=EB=82=98=EB=A6=AC?= =?UTF-8?q?=EC=98=A4=20=EC=83=9D=EC=84=B1=EA=B8=B0=20=EB=B0=8F=20=EA=B0=90?= =?UTF-8?q?=EC=82=AC=20=EC=8A=A4=ED=81=AC=EB=A6=BD=ED=8A=B8=2017=EC=A2=85?= =?UTF-8?q?=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - gen-*.js: 시나리오 자동 생성기 12종 (CRUD, edge, a11y, perf 등) - search-*.js: 검색/버튼 감사 수집기 3종 - revert-hard-actions.js: 하드 액션 복원 유틸 - _gen_writer.py: 생성기 보조 스크립트 Co-Authored-By: Claude Opus 4.6 --- e2e/runner/_gen_writer.py | 2 + e2e/runner/gen-accessibility-audit.js | 143 ++++ e2e/runner/gen-batch-create.js | 442 ++++++++++++ e2e/runner/gen-business-workflow.js | 367 ++++++++++ e2e/runner/gen-create-delete.js | 455 +++++++++++++ e2e/runner/gen-edge-cases.js | 889 +++++++++++++++++++++++++ e2e/runner/gen-form-validation.js | 255 +++++++ e2e/runner/gen-full-crud.js | 538 +++++++++++++++ e2e/runner/gen-input-fields.js | 402 +++++++++++ e2e/runner/gen-pagination-sort.js | 221 ++++++ e2e/runner/gen-performance-baseline.js | 153 +++++ e2e/runner/gen-reload-persist.js | 301 +++++++++ e2e/runner/gen-search-function.js | 252 +++++++ e2e/runner/revert-hard-actions.js | 159 +++++ e2e/runner/search-audit-collector.js | 294 ++++++++ e2e/runner/search-bug-collector.js | 364 ++++++++++ e2e/runner/search-button-audit.js | 252 +++++++ 17 files changed, 5489 insertions(+) create mode 100644 e2e/runner/_gen_writer.py create mode 100644 e2e/runner/gen-accessibility-audit.js create mode 100644 e2e/runner/gen-batch-create.js create mode 100644 e2e/runner/gen-business-workflow.js create mode 100644 e2e/runner/gen-create-delete.js create mode 100644 e2e/runner/gen-edge-cases.js create mode 100644 e2e/runner/gen-form-validation.js create mode 100644 e2e/runner/gen-full-crud.js create mode 100644 e2e/runner/gen-input-fields.js create mode 100644 e2e/runner/gen-pagination-sort.js create mode 100644 e2e/runner/gen-performance-baseline.js create mode 100644 e2e/runner/gen-reload-persist.js create mode 100644 e2e/runner/gen-search-function.js create mode 100644 e2e/runner/revert-hard-actions.js create mode 100644 e2e/runner/search-audit-collector.js create mode 100644 e2e/runner/search-bug-collector.js create mode 100644 e2e/runner/search-button-audit.js diff --git a/e2e/runner/_gen_writer.py b/e2e/runner/_gen_writer.py new file mode 100644 index 0000000..7b4d5fa --- /dev/null +++ b/e2e/runner/_gen_writer.py @@ -0,0 +1,2 @@ +# Auto-generated writer script +MARKER = True diff --git a/e2e/runner/gen-accessibility-audit.js b/e2e/runner/gen-accessibility-audit.js new file mode 100644 index 0000000..4277239 --- /dev/null +++ b/e2e/runner/gen-accessibility-audit.js @@ -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(); diff --git a/e2e/runner/gen-batch-create.js b/e2e/runner/gen-batch-create.js new file mode 100644 index 0000000..0f029f5 --- /dev/null +++ b/e2e/runner/gen-batch-create.js @@ -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;ib.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;ib.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(); diff --git a/e2e/runner/gen-business-workflow.js b/e2e/runner/gen-business-workflow.js new file mode 100644 index 0000000..f39903d --- /dev/null +++ b/e2e/runner/gen-business-workflow.js @@ -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(); diff --git a/e2e/runner/gen-create-delete.js b/e2e/runner/gen-create-delete.js new file mode 100644 index 0000000..f687134 --- /dev/null +++ b/e2e/runner/gen-create-delete.js @@ -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;ib.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(); diff --git a/e2e/runner/gen-edge-cases.js b/e2e/runner/gen-edge-cases.js new file mode 100644 index 0000000..a66bf9d --- /dev/null +++ b/e2e/runner/gen-edge-cases.js @@ -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: '특수문자 검색: "), 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(); diff --git a/e2e/runner/gen-form-validation.js b/e2e/runner/gen-form-validation.js new file mode 100644 index 0000000..1669a2c --- /dev/null +++ b/e2e/runner/gen-form-validation.js @@ -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(); diff --git a/e2e/runner/gen-full-crud.js b/e2e/runner/gen-full-crud.js new file mode 100644 index 0000000..c2a1aa5 --- /dev/null +++ b/e2e/runner/gen-full-crud.js @@ -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;ib.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(); diff --git a/e2e/runner/gen-input-fields.js b/e2e/runner/gen-input-fields.js new file mode 100644 index 0000000..11d584a --- /dev/null +++ b/e2e/runner/gen-input-fields.js @@ -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(); diff --git a/e2e/runner/gen-pagination-sort.js b/e2e/runner/gen-pagination-sort.js new file mode 100644 index 0000000..6b6a408 --- /dev/null +++ b/e2e/runner/gen-pagination-sort.js @@ -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(); diff --git a/e2e/runner/gen-performance-baseline.js b/e2e/runner/gen-performance-baseline.js new file mode 100644 index 0000000..bcaebee --- /dev/null +++ b/e2e/runner/gen-performance-baseline.js @@ -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(); diff --git a/e2e/runner/gen-reload-persist.js b/e2e/runner/gen-reload-persist.js new file mode 100644 index 0000000..736736e --- /dev/null +++ b/e2e/runner/gen-reload-persist.js @@ -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;ib.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(); diff --git a/e2e/runner/gen-search-function.js b/e2e/runner/gen-search-function.js new file mode 100644 index 0000000..24d1f23 --- /dev/null +++ b/e2e/runner/gen-search-function.js @@ -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=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{`, 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{`, 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(); diff --git a/e2e/runner/revert-hard-actions.js b/e2e/runner/revert-hard-actions.js new file mode 100644 index 0000000..2e1fb3e --- /dev/null +++ b/e2e/runner/revert-hard-actions.js @@ -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(); diff --git a/e2e/runner/search-audit-collector.js b/e2e/runner/search-audit-collector.js new file mode 100644 index 0000000..8b3312a --- /dev/null +++ b/e2e/runner/search-audit-collector.js @@ -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.afterRowssetTimeout(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}`); +})(); diff --git a/e2e/runner/search-bug-collector.js b/e2e/runner/search-bug-collector.js new file mode 100644 index 0000000..3605cad --- /dev/null +++ b/e2e/runner/search-bug-collector.js @@ -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 += `
원시 데이터\n\n`; + md += `\`\`\`json\n${JSON.stringify(data, null, 2).substring(0, 5000)}\n\`\`\`\n\n`; + md += `
\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.'); +})(); diff --git a/e2e/runner/search-button-audit.js b/e2e/runner/search-button-audit.js new file mode 100644 index 0000000..eea99b3 --- /dev/null +++ b/e2e/runner/search-button-audit.js @@ -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(); +})();