Files
sam-hotfix/e2e/runner/gen-batch-create.js
김보곤 48eba1e716 refactor: E2E 시나리오 생성기 8종 품질 개선 (false positive 제거 + flaky 패턴 수정)
Phase 1: R.ok=true 무조건 반환 → 조건부 검증으로 교체 (36개 시나리오 영향)
- gen-edge-cases.js: R.ok=R.validationTriggered, R.ok=R.allConsistent 등
- gen-pagination-sort.js: R.ok=R.sortWorked!==false
- gen-search-function.js: R.ok=R.searchWorked!==false
- gen-form-validation.js: R.ok=R.validationTriggered||R.hasValidation
- gen-batch-create.js: R.ok=R.created!==false
- gen-reload-persist.js: R.ok=R.persisted!==false
- gen-detail-roundtrip.js: R.ok=R.matched!==false
- gen-business-workflow.js: R.ok=!R.error&&R.phaseCompleted!==false

Phase 2: rows[0] 맹목적 접근 → E2E_TEST_ 스마트 타겟팅 추가
- gen-detail-roundtrip.js, gen-business-workflow.js에 testRow 탐색 패턴 적용

결과: 184 시나리오 중 9개 정당한 FAIL 노출 (실제 버그 5건 발견)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 21:54:57 +09:00

446 lines
27 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/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.deleted=!document.body.innerText?.includes(R.targetText?.substring(0,20));`,
`R.ok=R.deleted!==false;`,
`return JSON.stringify(R);`,
`})()`,
].join('');
},
},
// ─── 어음관리 ───────────────────────────────────────────────
'bills': {
id: 'batch-create-acc-bills',
name: '연속 등록 테스트: 어음관리',
menuNavigation: { level1: '회계관리', level2: '어음관리' },
batchCount: 3,
makeCreateScript(n) {
return [
`(async()=>{`, H,
`const R={phase:'CREATE_${n}',ts,n:${n}};`,
`const testId='EB${n}'+ts.replace(/_/g,'').substring(4,10);`,
// Click 등록
`const btn=Array.from(document.querySelectorAll('button')).find(b=>/어음.*등록|등록/.test(b.innerText?.trim()));`,
`if(!btn){R.error='등록 버튼 없음';return JSON.stringify(R);}`,
`btn.click();await w(2500);`,
`R.url=location.pathname+location.search;`,
`const formArea=document.querySelector('main')||document.querySelector('[class*="content"]')||document.body;`,
// ── PHASE 1: Comboboxes FIRST (triggers React re-renders) ──
`const combos=Array.from(formArea.querySelectorAll('button[role="combobox"]')).filter(b=>b.offsetParent!==null&&!b.closest('nav,[class*=sidebar],[class*=Sidebar]'));`,
`R.comboCount=combos.length;`,
`for(let i=0;i<combos.length;i++){`,
` document.body.click();await w(200);`,
` combos[i].scrollIntoView({block:'center'});`,
` combos[i].click();await w(700);`,
` const lb=document.querySelector('[role="listbox"]');`,
` if(lb){const opt=lb.querySelector('[role="option"]');if(opt){opt.click();await w(500);}}`,
` else{document.dispatchEvent(new KeyboardEvent('keydown',{key:'Escape',bubbles:true}));await w(200);}`,
`}`,
`await w(300);`,
// ── PHASE 2: Date pickers SECOND (also triggers re-renders) ──
`const dateButtons=Array.from(formArea.querySelectorAll('button')).filter(b=>b.innerText?.trim()==='날짜 선택'&&b.offsetParent!==null);`,
`for(const db of dateButtons){`,
` db.scrollIntoView({block:'center'});await w(200);`,
` db.click();await w(600);`,
` if(!document.querySelector('table[class*="rdp"],.rdp-month,[role="grid"]')){db.click();await w(800);}`,
` const today=document.querySelector('[aria-selected="true"]')||document.querySelector('button[name="day"].bg-primary')||document.querySelector('.rdp-day_today button')||Array.from(document.querySelectorAll('button[name="day"],td[role="gridcell"] button,.rdp-day button')).find(b=>b.getAttribute('aria-selected')==='true'||b.classList.contains('bg-primary')||b.tabIndex===0)||document.querySelector('button[name="day"]')||document.querySelector('td[role="gridcell"] button');`,
` if(today){today.click();await w(400);}`,
` else{document.dispatchEvent(new KeyboardEvent('keydown',{key:'Escape',bubbles:true}));await w(300);}`,
`}`,
`await w(300);`,
// ── PHASE 3: Text inputs LAST (after React re-renders settle) ──
`const numInput=document.querySelector('input[placeholder*="어음번호"]')||Array.from(formArea.querySelectorAll('input[type="text"]')).find(i=>i.offsetParent!==null&&!i.readOnly&&!i.disabled);`,
`if(numInput){sv(numInput,'E2E_TEST_'+testId);await w(200);}`,
`R.numFound=!!numInput;`,
`const amtInput=document.querySelector('input[placeholder*="금액"]');`,
`if(amtInput){sv(amtInput,'${(n + 1) * 10000}');await w(200);}`,
`const noteInput=document.querySelector('input[placeholder*="비고"]')||document.querySelector('textarea[placeholder*="비고"]');`,
`if(noteInput){sv(noteInput,'E2E_TEST_어음_'+ts);await w(200);}`,
`R.noteFound=!!noteInput;`,
// ── PHASE 4: Re-verify key field before submit ──
`if(noteInput&&!noteInput.value?.includes('E2E_TEST_')){sv(noteInput,'E2E_TEST_어음_'+ts);await w(200);R.refilled=true;}`,
// ── PHASE 5: Submit ──
`const submitBtn=Array.from(document.querySelectorAll('button')).find(b=>/^등록$|^저장$/.test(b.innerText?.trim())&&b.offsetParent!==null);`,
`if(!submitBtn){R.error='등록 버튼 없음';return JSON.stringify(R);}`,
`submitBtn.click();await w(3000);`,
`R.urlAfter=location.pathname+location.search;`,
`const valErrs=Array.from((formArea||document.body).querySelectorAll('[class*="error"],[class*="invalid"],.text-red-500,.text-destructive')).filter(e=>e.offsetParent!==null&&e.innerText?.trim()&&!e.closest('nav,[class*=sidebar],[class*=Sidebar]')).map(e=>e.innerText.trim()).slice(0,3);`,
`if(valErrs.length)R.valErrs=valErrs;`,
`R.ok=!R.urlAfter.includes('mode=new')&&!location.pathname.endsWith('/new');`,
`if(!R.ok)R.error='등록실패 url='+R.urlAfter+' errs='+JSON.stringify(valErrs);`,
`return JSON.stringify(R);`,
`})()`,
].join('');
},
makeVerifyScript(expectedCount) {
return [
`(async()=>{`, H,
`const R={phase:'VERIFY_BATCH',expected:${expectedCount},ts};`,
`await w(1000);`,
`R.url=location.pathname;`,
`const rows=Array.from(document.querySelectorAll('table tbody tr'));`,
`R.rowCount=rows.length;`,
// Search with ts first, fallback to any E2E_TEST_어음_ if expectedCount > 0
`let batchRows=rows.filter(r=>r.innerText?.includes('E2E_TEST_어음_')&&r.innerText?.includes(ts));`,
`if(!batchRows.length&&${expectedCount}>0){batchRows=rows.filter(r=>r.innerText?.includes('E2E_TEST_어음_'));R.usedFallback=true;}`,
`R.batchCount=batchRows.length;`,
`R.countMatch=${expectedCount}===0?R.batchCount===0:R.batchCount>=${expectedCount};`,
`if(!R.countMatch){R.row0=rows[0]?.innerText?.substring(0,80);R.bodyHas=document.body.innerText.includes('E2E_TEST_어음_');R.warn='기대 ${expectedCount}건, 실제 '+R.batchCount+'건 rows='+R.rowCount+' body='+R.bodyHas+' row0=['+R.row0+']';}`,
`R.ok=R.countMatch;`,
`return JSON.stringify(R);`,
`})()`,
].join('');
},
makeDeleteScript(n) {
return [
`(async()=>{`, H,
`const R={phase:'DELETE_${n}'};`,
`const rows=Array.from(document.querySelectorAll('table tbody tr'));`,
// Search with ts, fallback to any E2E_TEST_어음_
`const targetRow=rows.find(r=>r.innerText?.includes('E2E_TEST_어음_')&&r.innerText?.includes(ts))||rows.find(r=>r.innerText?.includes('E2E_TEST_어음_'));`,
`if(!targetRow){R.error='E2E_TEST_어음_ 데이터 없음 (ts='+ts+')';R.ok=false;return JSON.stringify(R);}`,
`R.targetText=targetRow.innerText?.substring(0,60);`,
`targetRow.click();await w(2500);`,
`const delBtn=Array.from(document.querySelectorAll('button')).find(b=>b.innerText?.trim()==='삭제');`,
`if(!delBtn){R.error='삭제 버튼 없음';R.ok=false;return JSON.stringify(R);}`,
`delBtn.click();await w(1000);`,
`const confirmBtn=Array.from(document.querySelectorAll('[role="alertdialog"] button,[role="dialog"] button,button')).find(b=>/확인|삭제|예|Yes/.test(b.innerText?.trim())&&b!==delBtn&&b.offsetParent!==null);`,
`if(confirmBtn){confirmBtn.click();await w(3000);}`,
`R.urlAfter=location.pathname+location.search;`,
`R.deleted=!document.body.innerText?.includes(R.targetText?.substring(0,20));`,
`R.ok=R.deleted!==false;`,
`return JSON.stringify(R);`,
`})()`,
].join('');
},
},
// ─── 입금관리 ───────────────────────────────────────────────
'deposit': {
id: 'batch-create-acc-deposit',
name: '연속 등록 테스트: 입금관리',
menuNavigation: { level1: '회계관리', level2: '입금관리' },
batchCount: 3,
makeCreateScript(n) {
return [
`(async()=>{`, H,
`const R={phase:'CREATE_${n}',ts,n:${n}};`,
// Click 등록
`const btn=Array.from(document.querySelectorAll('button')).find(b=>/입금.*등록|입금등록|등록/.test(b.innerText?.trim()));`,
`if(!btn){R.error='등록 버튼 없음';return JSON.stringify(R);}`,
`btn.click();await w(2500);`,
`R.url=location.pathname+location.search;`,
`const formArea=document.querySelector('main')||document.querySelector('[class*="content"]')||document.body;`,
// ── PHASE 1: Comboboxes FIRST (triggers React re-renders) ──
`const combos=Array.from(formArea.querySelectorAll('button[role="combobox"]')).filter(b=>b.offsetParent!==null&&!b.closest('nav,[class*=sidebar],[class*=Sidebar]'));`,
`R.comboCount=combos.length;`,
`for(let i=0;i<combos.length;i++){`,
` document.body.click();await w(200);`,
` combos[i].scrollIntoView({block:'center'});`,
` combos[i].click();await w(700);`,
` const lb=document.querySelector('[role="listbox"]');`,
` if(lb){const opt=lb.querySelector('[role="option"]');if(opt){opt.click();await w(500);}}`,
` else{document.dispatchEvent(new KeyboardEvent('keydown',{key:'Escape',bubbles:true}));await w(200);}`,
`}`,
`await w(500);`,
// ── PHASE 2: Date pickers SECOND (also triggers re-renders) ──
`const dateButtons=Array.from(formArea.querySelectorAll('button')).filter(b=>b.innerText?.trim()==='날짜 선택'&&b.offsetParent!==null);`,
`R.dateButtonCount=dateButtons.length;`,
`for(const db of dateButtons){`,
` db.scrollIntoView({block:'center'});await w(200);`,
` db.click();await w(600);`,
` if(!document.querySelector('table[class*="rdp"],.rdp-month,[role="grid"]')){db.click();await w(800);}`,
` const today=document.querySelector('[aria-selected="true"]')||document.querySelector('button[name="day"].bg-primary')||document.querySelector('.rdp-day_today button')||Array.from(document.querySelectorAll('button[name="day"],td[role="gridcell"] button,.rdp-day button')).find(b=>b.getAttribute('aria-selected')==='true'||b.classList.contains('bg-primary')||b.tabIndex===0)||document.querySelector('button[name="day"]')||document.querySelector('td[role="gridcell"] button');`,
` if(today){today.click();await w(400);}`,
` else{document.dispatchEvent(new KeyboardEvent('keydown',{key:'Escape',bubbles:true}));await w(300);}`,
`}`,
`await w(300);`,
// ── PHASE 3: Text inputs LAST (after React re-renders settle) ──
`const nameInput=document.querySelector('input[placeholder*="입금자명"]')||document.querySelector('input[placeholder*="입금자"]');`,
`if(nameInput){sv(nameInput,'E2E_TEST_입금자_'+ts);await w(200);}`,
`R.nameFound=!!nameInput;`,
`const amtInput=document.querySelector('input[placeholder*="입금금액"]')||document.querySelector('input[type="number"]');`,
`if(amtInput){sv(amtInput,'${(n + 1) * 50000}');await w(200);}`,
`const noteInput=document.querySelector('input[placeholder*="적요"]')||document.querySelector('textarea[placeholder*="적요"]');`,
`if(noteInput){sv(noteInput,'E2E_TEST_입금_'+ts);await w(200);}`,
`R.noteFound=!!noteInput;`,
// ── PHASE 4: Re-verify key fields before submit ──
`if(nameInput&&!nameInput.value?.includes('E2E_TEST_')){sv(nameInput,'E2E_TEST_입금자_'+ts);await w(200);R.refilledName=true;}`,
`if(noteInput&&!noteInput.value?.includes('E2E_TEST_')){sv(noteInput,'E2E_TEST_입금_'+ts);await w(200);R.refilledNote=true;}`,
// ── PHASE 5: Submit ──
`const submitBtn=Array.from(document.querySelectorAll('button')).find(b=>/^등록$|^저장$/.test(b.innerText?.trim())&&b.offsetParent!==null);`,
`if(!submitBtn){R.error='등록 버튼 없음';return JSON.stringify(R);}`,
`submitBtn.click();await w(3000);`,
`R.urlAfter=location.pathname+location.search;`,
`const valErrs=Array.from((formArea||document.body).querySelectorAll('[class*="error"],[class*="invalid"],.text-red-500,.text-destructive')).filter(e=>e.offsetParent!==null&&e.innerText?.trim()&&!e.closest('nav,[class*=sidebar],[class*=Sidebar]')).map(e=>e.innerText.trim()).slice(0,3);`,
`if(valErrs.length)R.valErrs=valErrs;`,
`R.ok=!R.urlAfter.includes('mode=new')&&!location.pathname.endsWith('/new');`,
`if(!R.ok)R.error='등록실패 url='+R.urlAfter+' errs='+JSON.stringify(valErrs);`,
`return JSON.stringify(R);`,
`})()`,
].join('');
},
makeVerifyScript(expectedCount) {
return [
`(async()=>{`, H,
`const R={phase:'VERIFY_BATCH',expected:${expectedCount},ts};`,
`await w(1000);`,
`R.url=location.pathname;`,
`const rows=Array.from(document.querySelectorAll('table tbody tr'));`,
`R.rowCount=rows.length;`,
// Search with ts first, fallback to any E2E_TEST_ if expectedCount > 0
`let batchRows=rows.filter(r=>r.innerText?.includes('E2E_TEST_')&&r.innerText?.includes(ts));`,
`if(!batchRows.length&&${expectedCount}>0){batchRows=rows.filter(r=>r.innerText?.includes('E2E_TEST_입금'));R.usedFallback=true;}`,
`R.batchCount=batchRows.length;`,
`R.countMatch=${expectedCount}===0?R.batchCount===0:R.batchCount>=${expectedCount};`,
`if(!R.countMatch){R.row0=rows[0]?.innerText?.substring(0,80);R.bodyHas=document.body.innerText.includes('E2E_TEST_');R.warn='기대 ${expectedCount}건, 실제 '+R.batchCount+'건 rows='+R.rowCount+' body='+R.bodyHas+' row0=['+R.row0+']';}`,
`R.ok=R.countMatch;`,
`return JSON.stringify(R);`,
`})()`,
].join('');
},
makeDeleteScript(n) {
return [
`(async()=>{`, H,
`const R={phase:'DELETE_${n}'};`,
`const rows=Array.from(document.querySelectorAll('table tbody tr'));`,
// Search with ts, fallback to any E2E_TEST_
`const targetRow=rows.find(r=>r.innerText?.includes('E2E_TEST_')&&r.innerText?.includes(ts))||rows.find(r=>r.innerText?.includes('E2E_TEST_입금'));`,
`if(!targetRow){R.error='E2E_TEST_ 데이터 없음 (ts='+ts+')';R.ok=false;return JSON.stringify(R);}`,
`R.targetText=targetRow.innerText?.substring(0,60);`,
`targetRow.click();await w(2500);`,
`const delBtn=Array.from(document.querySelectorAll('button')).find(b=>b.innerText?.trim()==='삭제');`,
`if(!delBtn){R.error='삭제 버튼 없음';R.ok=false;return JSON.stringify(R);}`,
`delBtn.click();await w(1000);`,
`const confirmBtn=Array.from(document.querySelectorAll('[role="alertdialog"] button,[role="dialog"] button,button')).find(b=>/확인|삭제|예|Yes/.test(b.innerText?.trim())&&b!==delBtn&&b.offsetParent!==null);`,
`if(confirmBtn){confirmBtn.click();await w(3000);}`,
`R.urlAfter=location.pathname+location.search;`,
`R.deleted=!document.body.innerText?.includes(R.targetText?.substring(0,20));`,
`R.ok=R.deleted!==false;`,
`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();