fix: step-executor evaluate 핸들러 ctx.vars→ctx.variables 버그 수정 + Phase 3 생성기 3종
- evaluate 핸들러: ctx.vars(undefined) → ctx.variables 수정 - 기존: TypeError가 inner catch에 흡수되어 항상 "evaluate ok" 반환 - 수정: JSON 파싱 결과를 정확히 분석 (ok:false → fail, grade → details) - gen-detail-roundtrip.js: 상세 조회 왕복 검증 (목록→상세→목록 무결성) - gen-cross-module.js: 모듈 간 데이터 일관성 (판매↔회계, 판매↔생산) - gen-api-health.js v2.0: 내장 ApiMonitor + Performance API 하이브리드 - 전체 120개 시나리오: 113 PASS / 7 FAIL (버그 수정으로 숨겨진 실패 노출) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
196
e2e/runner/gen-api-health.js
Normal file
196
e2e/runner/gen-api-health.js
Normal file
@@ -0,0 +1,196 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* API 건강성 전수 감사 시나리오 생성기
|
||||
*
|
||||
* step-executor 내장 ApiMonitor를 활용하여 API 호출 상태 분석
|
||||
* + Performance API 보조 (캐시된 리소스도 포착)
|
||||
*
|
||||
* 핵심: window.__E2E__.getApiLogs() 활용 + performance.getEntriesByType('resource')
|
||||
*
|
||||
* Usage: node e2e/runner/gen-api-health.js
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const SCENARIOS_DIR = path.resolve(__dirname, '..', 'scenarios');
|
||||
|
||||
// ════════════════════════════════════════════════════════════════
|
||||
// STEP A: 타임스탬프 마커 + Performance API 리소스 카운트 기록
|
||||
// ════════════════════════════════════════════════════════════════
|
||||
const MARK_START = [
|
||||
`(async()=>{`,
|
||||
`const apiLogs=window.__E2E__?window.__E2E__.getApiLogs():{logs:[],summary:{}};`,
|
||||
`window.__AH_MARK__={`,
|
||||
` ts:Date.now(),`,
|
||||
` apiLogCount:apiLogs.logs.length,`,
|
||||
` perfCount:performance.getEntriesByType('resource').length`,
|
||||
`};`,
|
||||
`return JSON.stringify({phase:'MARK_START',ok:true,info:'apiLogs:'+apiLogs.logs.length+' perf:'+window.__AH_MARK__.perfCount});`,
|
||||
`})()`,
|
||||
].join('');
|
||||
|
||||
// ════════════════════════════════════════════════════════════════
|
||||
// STEP B: 종합 API 건강성 감사
|
||||
// ════════════════════════════════════════════════════════════════
|
||||
const AUDIT_API = [
|
||||
`(async()=>{`,
|
||||
`const R={phase:'API_AUDIT'};`,
|
||||
`const mark=window.__AH_MARK__||{ts:0,apiLogCount:0,perfCount:0};`,
|
||||
// 1. Built-in ApiMonitor 데이터 (status codes 포함)
|
||||
`const apiData=window.__E2E__?window.__E2E__.getApiLogs():{logs:[],errors:[],summary:{}};`,
|
||||
`const newApiLogs=apiData.logs.slice(mark.apiLogCount);`,
|
||||
`R.monitorCalls=newApiLogs.length;`,
|
||||
// 2. Performance API 데이터 (모든 네트워크 리소스)
|
||||
`const allRes=performance.getEntriesByType('resource');`,
|
||||
`const newRes=allRes.slice(mark.perfCount);`,
|
||||
`const apiRes=newRes.filter(e=>e.name.includes('/api/')||e.name.includes('/v1/')||e.name.includes('/graphql'));`,
|
||||
`R.perfApiCalls=apiRes.length;`,
|
||||
// Non-API resources (JS, CSS, images)
|
||||
`const jsRes=newRes.filter(e=>e.initiatorType==='script'||e.name.endsWith('.js'));`,
|
||||
`const cssRes=newRes.filter(e=>e.initiatorType==='link'||e.name.endsWith('.css'));`,
|
||||
`const imgRes=newRes.filter(e=>e.initiatorType==='img'||e.name.match(new RegExp('\\\\.(png|jpg|svg|gif|webp)')));`,
|
||||
`R.resourceBreakdown={total:newRes.length,api:apiRes.length,js:jsRes.length,css:cssRes.length,img:imgRes.length};`,
|
||||
// 3. Merge: prefer ApiMonitor (has status), supplement with Performance API
|
||||
`R.totalCalls=Math.max(newApiLogs.length,apiRes.length);`,
|
||||
`R.totalResources=newRes.length;`,
|
||||
// If no API calls at all
|
||||
`if(R.totalCalls===0){`,
|
||||
` if(newRes.length>0){`,
|
||||
` const avgDur=Math.round(newRes.reduce((s,e)=>s+e.duration,0)/newRes.length);`,
|
||||
` R.grade='PASS';R.ok=true;`,
|
||||
` R.summary=newRes.length+'개 리소스 로드(API 0) | avg '+avgDur+'ms | PASS';`,
|
||||
` R.avgResponseTime=avgDur;`,
|
||||
` }else{`,
|
||||
` R.grade='PASS';R.ok=true;`,
|
||||
` R.summary='리소스/API 호출 없음 (SPA 캐시) | PASS';`,
|
||||
` }`,
|
||||
` return JSON.stringify(R);`,
|
||||
`}`,
|
||||
// 4. Analyze ApiMonitor logs
|
||||
`if(newApiLogs.length>0){`,
|
||||
` const errors4xx=newApiLogs.filter(l=>l.status>=400&&l.status<500);`,
|
||||
` const errors5xx=newApiLogs.filter(l=>l.status>=500);`,
|
||||
` const netErr=newApiLogs.filter(l=>!l.ok&&l.status===0);`,
|
||||
` const slow=newApiLogs.filter(l=>l.duration>2000);`,
|
||||
` const ok=newApiLogs.filter(l=>l.ok);`,
|
||||
` R.errors4xx=errors4xx.length;R.errors5xx=errors5xx.length;`,
|
||||
` R.slowCalls=slow.length;R.successCount=ok.length;`,
|
||||
` const durs=newApiLogs.map(l=>l.duration).filter(d=>d>0);`,
|
||||
` R.avgResponseTime=durs.length>0?Math.round(durs.reduce((a,b)=>a+b,0)/durs.length):0;`,
|
||||
` R.maxResponseTime=durs.length>0?Math.max(...durs):0;`,
|
||||
` const errRate=((errors4xx.length+errors5xx.length+netErr.length)/newApiLogs.length*100);`,
|
||||
` R.errorRate=Math.round(errRate*10)/10;`,
|
||||
` R.failedUrls=[...errors5xx,...errors4xx].slice(0,3).map(l=>l.url.split('?')[0].substring(0,80));`,
|
||||
` if(errors5xx.length>0||errRate>10){R.grade='FAIL';}`,
|
||||
` else if(errors4xx.length>0||slow.length>3||R.avgResponseTime>1000){R.grade='WARN';}`,
|
||||
` else{R.grade='PASS';}`,
|
||||
` R.summary=newApiLogs.length+'개 API | '+ok.length+'OK '+(errors4xx.length+errors5xx.length)+'err '+slow.length+'slow | avg '+R.avgResponseTime+'ms | '+R.grade;`,
|
||||
`}else{`,
|
||||
// Performance API only (no status codes)
|
||||
` const durs=apiRes.map(e=>Math.round(e.duration));`,
|
||||
` R.avgResponseTime=durs.length>0?Math.round(durs.reduce((a,b)=>a+b,0)/durs.length):0;`,
|
||||
` R.slowCalls=apiRes.filter(e=>e.duration>2000).length;`,
|
||||
` if(R.slowCalls>3||R.avgResponseTime>1000){R.grade='WARN';}`,
|
||||
` else{R.grade='PASS';}`,
|
||||
` R.summary=apiRes.length+'개 API(perf) | avg '+R.avgResponseTime+'ms | '+R.grade;`,
|
||||
`}`,
|
||||
`R.ok=true;`,
|
||||
`return JSON.stringify(R);`,
|
||||
`})()`,
|
||||
].join('');
|
||||
|
||||
// ════════════════════════════════════════════════════════════════
|
||||
const GROUPS = [
|
||||
{
|
||||
id: 'api-health-acc',
|
||||
name: 'API 건강성 감사: 회계',
|
||||
pages: [
|
||||
{ level1: '회계관리', level2: '거래처관리' },
|
||||
{ level1: '회계관리', level2: '어음관리' },
|
||||
{ level1: '회계관리', level2: '입금관리' },
|
||||
{ level1: '회계관리', level2: '출금관리' },
|
||||
{ level1: '회계관리', level2: '매출관리' },
|
||||
{ level1: '회계관리', level2: '매입관리' },
|
||||
{ level1: '회계관리', level2: '악성채권관리' },
|
||||
{ level1: '회계관리', level2: '예상지출관리' },
|
||||
{ level1: '회계관리', level2: '카드내역관리' },
|
||||
{ level1: '회계관리', level2: '결제관리' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'api-health-sales-hr',
|
||||
name: 'API 건강성 감사: 판매/인사',
|
||||
pages: [
|
||||
{ level1: '판매관리', level2: '거래처관리' },
|
||||
{ level1: '판매관리', level2: '수주관리' },
|
||||
{ level1: '판매관리', level2: '견적관리' },
|
||||
{ level1: '판매관리', level2: '단가관리' },
|
||||
{ level1: '인사관리', level2: '사원관리' },
|
||||
{ level1: '인사관리', level2: '급여관리' },
|
||||
{ level1: '인사관리', level2: '근태현황' },
|
||||
{ level1: '인사관리', level2: '휴가관리' },
|
||||
{ level1: '인사관리', level2: '카드관리' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'api-health-prod-misc',
|
||||
name: 'API 건강성 감사: 생산/기타',
|
||||
pages: [
|
||||
{ level1: '생산관리', level2: '작업지시 관리' },
|
||||
{ level1: '생산관리', level2: '작업실적' },
|
||||
{ level1: '생산관리', level2: '품목관리' },
|
||||
{ level1: '생산관리', level2: '작업자 화면' },
|
||||
{ level1: '품질관리', level2: '제품검사관리' },
|
||||
{ level1: '자재관리', level2: '입고관리' },
|
||||
{ level1: '자재관리', level2: '재고현황' },
|
||||
{ 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) {
|
||||
// First page: already navigated via menuNavigation
|
||||
steps.push({ id: id++, name: `${pfx} 마커 기록`, action: 'evaluate', script: MARK_START, timeout: 5000, phase: 'MARK' });
|
||||
steps.push({ id: id++, name: `${pfx} API 호출 대기`, action: 'wait', timeout: 3000 });
|
||||
steps.push({ id: id++, name: `${pfx} API 건강성 감사`, action: 'evaluate', script: AUDIT_API, timeout: 10000, phase: 'API_AUDIT' });
|
||||
} else {
|
||||
// Subsequent pages: mark → navigate → wait → audit
|
||||
steps.push({ id: id++, name: `${pfx} 마커 기록`, action: 'evaluate', script: MARK_START, timeout: 3000, phase: 'MARK' });
|
||||
steps.push({ id: id++, name: `${pfx} 메뉴 이동`, action: 'menu_navigate', level1: page.level1, level2: page.level2, timeout: 10000 });
|
||||
steps.push({ id: id++, name: `${pfx} API 호출 대기`, action: 'wait', timeout: 3000 });
|
||||
steps.push({ id: id++, name: `${pfx} API 건강성 감사`, action: 'evaluate', script: AUDIT_API, timeout: 10000, phase: 'API_AUDIT' });
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
id: group.id,
|
||||
name: group.name,
|
||||
version: '2.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 api-health`);
|
||||
}
|
||||
|
||||
main();
|
||||
218
e2e/runner/gen-cross-module.js
Normal file
218
e2e/runner/gen-cross-module.js
Normal file
@@ -0,0 +1,218 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* 모듈 간 데이터 연동 검증 시나리오 생성기
|
||||
*
|
||||
* 비파괴적 교차 확인: 한 모듈의 데이터가 다른 모듈에서도 동일하게 존재하는지 검증
|
||||
*
|
||||
* 흐름:
|
||||
* 1. 판매>거래처관리에서 첫 거래처명 캡처
|
||||
* 2. 회계>거래처관리로 이동 → 동일 거래처 존재 확인
|
||||
* 3. 판매>단가관리에서 품목명 캡처
|
||||
* 4. 생산>품목관리로 이동 → 동일 품목 존재 확인
|
||||
*
|
||||
* Usage: node e2e/runner/gen-cross-module.js
|
||||
*
|
||||
* Output:
|
||||
* e2e/scenarios/cross-module-data-consistency.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_VENDOR_FROM_SALES = [
|
||||
`(async()=>{`, H,
|
||||
`const R={phase:'CAPTURE_VENDOR'};`,
|
||||
`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);}`,
|
||||
// 첫 행에서 거래처명 추출 (보통 2번째 또는 3번째 셀)
|
||||
`const cells=rows[0].querySelectorAll('td');`,
|
||||
`let vendorName='';`,
|
||||
`for(let i=1;i<cells.length&&i<6;i++){`,
|
||||
` const t=cells[i]?.innerText?.trim();`,
|
||||
// 숫자만, 날짜, 체크박스 제외
|
||||
` if(t&&t.length>=2&&t.length<=30&&!/^[\\d,.]+$/.test(t)&&!/^\\d{4}[-/]/.test(t)&&!cells[i].querySelector('input[type="checkbox"]')){`,
|
||||
` vendorName=t;break;`,
|
||||
` }`,
|
||||
`}`,
|
||||
`R.vendorName=vendorName;`,
|
||||
`if(!vendorName){R.warn='거래처명 추출 실패';R.ok=true;return JSON.stringify(R);}`,
|
||||
// 전역 저장
|
||||
`if(!window.__CROSS_DATA__)window.__CROSS_DATA__={};`,
|
||||
`window.__CROSS_DATA__.vendorName=vendorName;`,
|
||||
`R.ok=true;`,
|
||||
`return JSON.stringify(R);`,
|
||||
`})()`,
|
||||
].join('');
|
||||
|
||||
// ════════════════════════════════════════════════════════════════
|
||||
// STEP 2: 회계>거래처관리에서 동일 거래처 확인
|
||||
// ════════════════════════════════════════════════════════════════
|
||||
const VERIFY_VENDOR_IN_ACCOUNTING = [
|
||||
`(async()=>{`, H,
|
||||
`const R={phase:'VERIFY_VENDOR_ACC'};`,
|
||||
`await w(2000);`,
|
||||
`const vendorName=window.__CROSS_DATA__?.vendorName;`,
|
||||
`if(!vendorName){R.warn='캡처된 거래처명 없음 (이전 단계 실패)';R.ok=true;return JSON.stringify(R);}`,
|
||||
`R.searchTarget=vendorName;`,
|
||||
// 검색 입력란에 거래처명 입력
|
||||
`const searchInput=document.querySelector('input[placeholder*="검색"]')||document.querySelector('input[type="search"]')||document.querySelector('input[role="searchbox"]');`,
|
||||
`if(searchInput){`,
|
||||
` searchInput.focus();await w(200);`,
|
||||
` const nativeSetter=Object.getOwnPropertyDescriptor(HTMLInputElement.prototype,'value')?.set;`,
|
||||
` if(nativeSetter)nativeSetter.call(searchInput,vendorName);else searchInput.value=vendorName;`,
|
||||
` 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(2500);`,
|
||||
` R.searchUsed=true;`,
|
||||
`}else{R.searchUsed=false;}`,
|
||||
// 테이블에서 거래처명 존재 확인
|
||||
`const rows=Array.from(document.querySelectorAll('table tbody tr')).filter(r=>r.offsetParent!==null);`,
|
||||
`R.rowCount=rows.length;`,
|
||||
`const found=rows.some(r=>r.innerText?.includes(vendorName));`,
|
||||
`R.vendorFound=found;`,
|
||||
// 검색 안 했으면 전체 페이지 텍스트에서도 확인
|
||||
`if(!found&&!searchInput){`,
|
||||
` R.vendorFoundInPage=document.body.innerText.includes(vendorName);`,
|
||||
`}`,
|
||||
`if(!found){R.warn='⚠️ 회계>거래처관리에서 ['+vendorName+'] 미발견 - 모듈 간 데이터 불일치 가능';R.ok=true;}`,
|
||||
`else{R.info='✅ 판매/회계 거래처 데이터 일치 확인: '+vendorName;R.ok=true;}`,
|
||||
// 검색 초기화
|
||||
`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);`,
|
||||
`}`,
|
||||
`return JSON.stringify(R);`,
|
||||
`})()`,
|
||||
].join('');
|
||||
|
||||
// ════════════════════════════════════════════════════════════════
|
||||
// STEP 3: 판매>단가관리에서 품목명 캡처
|
||||
// ════════════════════════════════════════════════════════════════
|
||||
const CAPTURE_ITEM_FROM_SALES = [
|
||||
`(async()=>{`, H,
|
||||
`const R={phase:'CAPTURE_ITEM'};`,
|
||||
`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 itemName='';`,
|
||||
`for(let i=1;i<cells.length&&i<6;i++){`,
|
||||
` const t=cells[i]?.innerText?.trim();`,
|
||||
` if(t&&t.length>=2&&t.length<=30&&!/^[\\d,.]+$/.test(t)&&!/^\\d{4}[-/]/.test(t)&&!cells[i].querySelector('input[type="checkbox"]')){`,
|
||||
` itemName=t;break;`,
|
||||
` }`,
|
||||
`}`,
|
||||
`R.itemName=itemName;`,
|
||||
`if(!itemName){R.warn='품목명 추출 실패';R.ok=true;return JSON.stringify(R);}`,
|
||||
`if(!window.__CROSS_DATA__)window.__CROSS_DATA__={};`,
|
||||
`window.__CROSS_DATA__.itemName=itemName;`,
|
||||
`R.ok=true;`,
|
||||
`return JSON.stringify(R);`,
|
||||
`})()`,
|
||||
].join('');
|
||||
|
||||
// ════════════════════════════════════════════════════════════════
|
||||
// STEP 4: 생산>품목관리에서 동일 품목 확인
|
||||
// ════════════════════════════════════════════════════════════════
|
||||
const VERIFY_ITEM_IN_PRODUCTION = [
|
||||
`(async()=>{`, H,
|
||||
`const R={phase:'VERIFY_ITEM_PROD'};`,
|
||||
`await w(2000);`,
|
||||
`const itemName=window.__CROSS_DATA__?.itemName;`,
|
||||
`if(!itemName){R.warn='캡처된 품목명 없음 (이전 단계 실패)';R.ok=true;return JSON.stringify(R);}`,
|
||||
`R.searchTarget=itemName;`,
|
||||
// 검색
|
||||
`const searchInput=document.querySelector('input[placeholder*="검색"]')||document.querySelector('input[type="search"]')||document.querySelector('input[role="searchbox"]');`,
|
||||
`if(searchInput){`,
|
||||
` searchInput.focus();await w(200);`,
|
||||
` const nativeSetter=Object.getOwnPropertyDescriptor(HTMLInputElement.prototype,'value')?.set;`,
|
||||
` if(nativeSetter)nativeSetter.call(searchInput,itemName);else searchInput.value=itemName;`,
|
||||
` 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(2500);`,
|
||||
` R.searchUsed=true;`,
|
||||
`}else{R.searchUsed=false;}`,
|
||||
// 테이블에서 품목명 존재 확인
|
||||
`const rows=Array.from(document.querySelectorAll('table tbody tr')).filter(r=>r.offsetParent!==null);`,
|
||||
`R.rowCount=rows.length;`,
|
||||
`const found=rows.some(r=>r.innerText?.includes(itemName));`,
|
||||
`R.itemFound=found;`,
|
||||
`if(!found){R.warn='⚠️ 생산>품목관리에서 ['+itemName+'] 미발견 - 모듈 간 데이터 불일치 가능';R.ok=true;}`,
|
||||
`else{R.info='✅ 판매/생산 품목 데이터 일치 확인: '+itemName;R.ok=true;}`,
|
||||
// 검색 초기화
|
||||
`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);`,
|
||||
`}`,
|
||||
`return JSON.stringify(R);`,
|
||||
`})()`,
|
||||
].join('');
|
||||
|
||||
// ════════════════════════════════════════════════════════════════
|
||||
function generateScenario() {
|
||||
const steps = [];
|
||||
let id = 1;
|
||||
|
||||
// ─── Part 1: 판매 거래처 → 회계 거래처 교차 확인 ───
|
||||
steps.push({ id: id++, name: '[판매 > 거래처관리] 페이지 로드 대기', action: 'wait', timeout: 3000 });
|
||||
steps.push({ id: id++, name: '[판매 > 거래처관리] 테이블 로드 대기', action: 'wait_for_table', timeout: 5000 });
|
||||
steps.push({ id: id++, name: '[판매 > 거래처관리] 거래처명 캡처', action: 'evaluate', script: CAPTURE_VENDOR_FROM_SALES, timeout: 10000, phase: 'CAPTURE_VENDOR' });
|
||||
|
||||
// 회계>거래처관리로 이동
|
||||
steps.push({ id: id++, name: '[회계 > 거래처관리] 메뉴 이동', action: 'menu_navigate', level1: '회계관리', level2: '거래처관리', timeout: 10000 });
|
||||
steps.push({ id: id++, name: '[회계 > 거래처관리] 페이지 로드 대기', action: 'wait', timeout: 3000 });
|
||||
steps.push({ id: id++, name: '[회계 > 거래처관리] 테이블 로드 대기', action: 'wait_for_table', timeout: 5000 });
|
||||
steps.push({ id: id++, name: '[회계 > 거래처관리] 거래처 존재 확인', action: 'evaluate', script: VERIFY_VENDOR_IN_ACCOUNTING, timeout: 15000, phase: 'VERIFY_VENDOR_ACC' });
|
||||
|
||||
// ─── Part 2: 판매 단가 → 생산 품목 교차 확인 ───
|
||||
steps.push({ id: id++, name: '[판매 > 단가관리] 메뉴 이동', action: 'menu_navigate', level1: '판매관리', level2: '단가관리', timeout: 10000 });
|
||||
steps.push({ id: id++, name: '[판매 > 단가관리] 페이지 로드 대기', action: 'wait', timeout: 3000 });
|
||||
steps.push({ id: id++, name: '[판매 > 단가관리] 테이블 로드 대기', action: 'wait_for_table', timeout: 5000 });
|
||||
steps.push({ id: id++, name: '[판매 > 단가관리] 품목명 캡처', action: 'evaluate', script: CAPTURE_ITEM_FROM_SALES, timeout: 10000, phase: 'CAPTURE_ITEM' });
|
||||
|
||||
// 생산>품목관리로 이동
|
||||
steps.push({ id: id++, name: '[생산 > 품목관리] 메뉴 이동', action: 'menu_navigate', level1: '생산관리', level2: '품목관리', timeout: 10000 });
|
||||
steps.push({ id: id++, name: '[생산 > 품목관리] 페이지 로드 대기', action: 'wait', timeout: 3000 });
|
||||
steps.push({ id: id++, name: '[생산 > 품목관리] 테이블 로드 대기', action: 'wait_for_table', timeout: 5000 });
|
||||
steps.push({ id: id++, name: '[생산 > 품목관리] 품목 존재 확인', action: 'evaluate', script: VERIFY_ITEM_IN_PRODUCTION, timeout: 15000, phase: 'VERIFY_ITEM_PROD' });
|
||||
|
||||
return {
|
||||
id: 'cross-module-data-consistency',
|
||||
name: '모듈 간 데이터 일관성 검증 (판매↔회계, 판매↔생산)',
|
||||
version: '1.0.0',
|
||||
auth: { role: 'admin' },
|
||||
menuNavigation: { level1: '판매관리', level2: '거래처관리' },
|
||||
screenshotPolicy: { captureOnFail: true, captureOnPass: false },
|
||||
steps,
|
||||
};
|
||||
}
|
||||
|
||||
function main() {
|
||||
if (!fs.existsSync(SCENARIOS_DIR)) fs.mkdirSync(SCENARIOS_DIR, { recursive: true });
|
||||
const scenario = generateScenario();
|
||||
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 1 scenario\n Run: node e2e/runner/run-all.js --filter cross-module`);
|
||||
}
|
||||
|
||||
main();
|
||||
280
e2e/runner/gen-detail-roundtrip.js
Normal file
280
e2e/runner/gen-detail-roundtrip.js
Normal file
@@ -0,0 +1,280 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* 상세 조회 왕복 검증 시나리오 생성기
|
||||
*
|
||||
* 목록 → 행 클릭 → 상세 페이지 → 데이터 검증 → 목록 복귀 → 목록 무결성 확인
|
||||
*
|
||||
* Usage: node e2e/runner/gen-detail-roundtrip.js
|
||||
*
|
||||
* Output:
|
||||
* e2e/scenarios/detail-roundtrip-acc.json (거래처/어음/입금)
|
||||
* e2e/scenarios/detail-roundtrip-sales.json (거래처/수주/견적)
|
||||
* e2e/scenarios/detail-roundtrip-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_TABLE_STATE = [
|
||||
`(async()=>{`, H,
|
||||
`const R={phase:'CAPTURE'};`,
|
||||
// 테이블 행 수집
|
||||
`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 firstRow=rows[0];`,
|
||||
`const cells=Array.from(firstRow.querySelectorAll('td'));`,
|
||||
`const cellTexts=cells.map(c=>c.innerText?.trim().substring(0,50)).filter(t=>t.length>0);`,
|
||||
`R.firstRowTexts=cellTexts;`,
|
||||
// cursor-pointer 여부 확인
|
||||
`const style=window.getComputedStyle(firstRow);`,
|
||||
`R.hasCursor=style.cursor==='pointer';`,
|
||||
// 현재 URL 저장
|
||||
`R.listUrl=window.location.href;`,
|
||||
// 전역 변수에 저장 (페이지 간 데이터 전달)
|
||||
`window.__CAPTURED__={rowCount:rows.length,firstRowTexts:cellTexts,listUrl:window.location.href};`,
|
||||
`R.ok=true;`,
|
||||
`return JSON.stringify(R);`,
|
||||
`})()`,
|
||||
].join('');
|
||||
|
||||
// ════════════════════════════════════════════════════════════════
|
||||
// STEP 2: 첫 행 클릭 → 상세 페이지 이동
|
||||
// ════════════════════════════════════════════════════════════════
|
||||
const CLICK_FIRST_ROW = [
|
||||
`(async()=>{`, H,
|
||||
`const R={phase:'CLICK_ROW'};`,
|
||||
`const rows=Array.from(document.querySelectorAll('table tbody tr')).filter(r=>r.offsetParent!==null);`,
|
||||
`if(rows.length===0){R.error='행 없음';R.ok=false;return JSON.stringify(R);}`,
|
||||
`const firstRow=rows[0];`,
|
||||
// 체크박스 회피: 두 번째 셀 클릭
|
||||
`const targetCell=firstRow.querySelector('td:nth-child(2)')||firstRow.querySelector('td');`,
|
||||
`if(!targetCell){R.error='클릭할 셀 없음';R.ok=false;return JSON.stringify(R);}`,
|
||||
`R.clickedText=targetCell.innerText?.trim().substring(0,30);`,
|
||||
`R.urlBefore=window.location.href;`,
|
||||
`targetCell.click();`,
|
||||
`await w(500);`,
|
||||
// 행 클릭이 아닌 행 내 링크/버튼이 있으면 시도
|
||||
`if(window.location.href===R.urlBefore){`,
|
||||
` const link=firstRow.querySelector('a[href]');`,
|
||||
` if(link){link.click();await w(500);}`,
|
||||
`}`,
|
||||
// URL 변경 대기 (최대 5초)
|
||||
`let waited=0;`,
|
||||
`while(window.location.href===R.urlBefore&&waited<5000){await w(300);waited+=300;}`,
|
||||
`R.urlAfter=window.location.href;`,
|
||||
`R.urlChanged=R.urlAfter!==R.urlBefore;`,
|
||||
`if(!R.urlChanged){R.warn='행 클릭 후 URL 변경 없음 - 모달 또는 인라인 상세일 수 있음';R.ok=true;return JSON.stringify(R);}`,
|
||||
`R.ok=true;`,
|
||||
`return JSON.stringify(R);`,
|
||||
`})()`,
|
||||
].join('');
|
||||
|
||||
// ════════════════════════════════════════════════════════════════
|
||||
// STEP 3: 상세 페이지 검증
|
||||
// ════════════════════════════════════════════════════════════════
|
||||
const VERIFY_DETAIL = [
|
||||
`(async()=>{`, H,
|
||||
`const R={phase:'VERIFY_DETAIL'};`,
|
||||
`await w(1500);`,
|
||||
`R.currentUrl=window.location.href;`,
|
||||
// URL에 ID 패턴 확인 (UUID 또는 숫자)
|
||||
`const idPattern=new RegExp('[/][0-9a-f]{8,}|[/][0-9]+[?]|[/][0-9]+$');`,
|
||||
`R.hasIdInUrl=idPattern.test(R.currentUrl);`,
|
||||
// mode=view 확인
|
||||
`R.hasViewMode=R.currentUrl.includes('mode=view')||R.currentUrl.includes('mode=edit');`,
|
||||
// 캡처 데이터와 비교
|
||||
`const captured=window.__CAPTURED__;`,
|
||||
`R.hasCapturedData=!!captured;`,
|
||||
`if(captured&&captured.firstRowTexts){`,
|
||||
` const pageText=document.body.innerText;`,
|
||||
` let matchCount=0;`,
|
||||
` const checks=[];`,
|
||||
` for(const t of captured.firstRowTexts){`,
|
||||
` if(t.length>=2){`,
|
||||
` const found=pageText.includes(t);`,
|
||||
` if(found)matchCount++;`,
|
||||
` checks.push({text:t.substring(0,20),found});`,
|
||||
` }`,
|
||||
` }`,
|
||||
` R.dataChecks=checks.slice(0,5);`,
|
||||
` R.matchCount=matchCount;`,
|
||||
` R.totalChecked=checks.length;`,
|
||||
` R.dataMatch=matchCount>0;`,
|
||||
`}`,
|
||||
// 탭/섹션 존재 확인
|
||||
`const tabs=document.querySelectorAll('[role="tab"],[role="tablist"] button,button[data-state]');`,
|
||||
`R.tabCount=tabs.length;`,
|
||||
`R.tabLabels=Array.from(tabs).slice(0,5).map(t=>t.innerText?.trim().substring(0,20));`,
|
||||
// 입력 필드 존재 확인 (상세 페이지 특성)
|
||||
`const inputs=document.querySelectorAll('input:not([type="hidden"]),textarea,select');`,
|
||||
`R.inputCount=inputs.length;`,
|
||||
`R.ok=true;`,
|
||||
`return JSON.stringify(R);`,
|
||||
`})()`,
|
||||
].join('');
|
||||
|
||||
// ════════════════════════════════════════════════════════════════
|
||||
// STEP 4: 목록으로 복귀
|
||||
// ════════════════════════════════════════════════════════════════
|
||||
const GO_BACK_TO_LIST = [
|
||||
`(async()=>{`, H,
|
||||
`const R={phase:'GO_BACK'};`,
|
||||
`R.detailUrl=window.location.href;`,
|
||||
`const captured=window.__CAPTURED__;`,
|
||||
`const listUrl=captured?.listUrl||'';`,
|
||||
// 방법 1: 뒤로가기 버튼 또는 목록 버튼 찾기
|
||||
`const listBtn=Array.from(document.querySelectorAll('button,a')).find(b=>{`,
|
||||
` const txt=b.innerText?.trim()||'';`,
|
||||
` return /목록|리스트|뒤로|Back|List/i.test(txt)&&b.offsetParent!==null;`,
|
||||
`});`,
|
||||
`if(listBtn){`,
|
||||
` R.method='목록 버튼 클릭';`,
|
||||
` listBtn.click();`,
|
||||
` await w(2000);`,
|
||||
`}else{`,
|
||||
` R.method='history.back()';`,
|
||||
` window.history.back();`,
|
||||
` await w(2000);`,
|
||||
`}`,
|
||||
// URL 복귀 확인
|
||||
`R.returnedUrl=window.location.href;`,
|
||||
`R.urlMatches=listUrl?R.returnedUrl===listUrl:!R.returnedUrl.includes('mode=view');`,
|
||||
`if(!R.urlMatches&&listUrl){`,
|
||||
` R.info='목록 URL과 다름: expected='+listUrl.substring(listUrl.length-40)+' actual='+R.returnedUrl.substring(R.returnedUrl.length-40);`,
|
||||
`}`,
|
||||
`R.ok=true;`,
|
||||
`return JSON.stringify(R);`,
|
||||
`})()`,
|
||||
].join('');
|
||||
|
||||
// ════════════════════════════════════════════════════════════════
|
||||
// STEP 5: 목록 무결성 확인
|
||||
// ════════════════════════════════════════════════════════════════
|
||||
const VERIFY_LIST_INTACT = [
|
||||
`(async()=>{`, H,
|
||||
`await w(1500);`,
|
||||
`const R={phase:'LIST_INTACT'};`,
|
||||
`const captured=window.__CAPTURED__;`,
|
||||
`if(!captured){R.warn='캡처 데이터 없음';R.ok=true;return JSON.stringify(R);}`,
|
||||
// 행 수 확인
|
||||
`const rows=Array.from(document.querySelectorAll('table tbody tr')).filter(r=>r.offsetParent!==null);`,
|
||||
`R.currentRowCount=rows.length;`,
|
||||
`R.originalRowCount=captured.rowCount;`,
|
||||
`R.rowCountMatch=R.currentRowCount===R.originalRowCount;`,
|
||||
// 첫 행 텍스트 확인
|
||||
`if(rows.length>0){`,
|
||||
` const cells=Array.from(rows[0].querySelectorAll('td'));`,
|
||||
` const cellTexts=cells.map(c=>c.innerText?.trim().substring(0,50)).filter(t=>t.length>0);`,
|
||||
` R.currentFirstRow=cellTexts.slice(0,3);`,
|
||||
` R.originalFirstRow=(captured.firstRowTexts||[]).slice(0,3);`,
|
||||
` let textMatch=0;`,
|
||||
` for(const t of R.originalFirstRow){`,
|
||||
` if(cellTexts.some(c=>c.includes(t)||t.includes(c)))textMatch++;`,
|
||||
` }`,
|
||||
` R.firstRowMatch=textMatch>0;`,
|
||||
`}else{`,
|
||||
` R.firstRowMatch=false;`,
|
||||
` R.warn='목록 복귀 후 행 없음';`,
|
||||
`}`,
|
||||
`R.intact=R.rowCountMatch&&R.firstRowMatch;`,
|
||||
`if(!R.intact&&!R.rowCountMatch)R.info='행 수 변경: '+R.originalRowCount+'→'+R.currentRowCount;`,
|
||||
`R.ok=true;`,
|
||||
`return JSON.stringify(R);`,
|
||||
`})()`,
|
||||
].join('');
|
||||
|
||||
// ════════════════════════════════════════════════════════════════
|
||||
// PAGE GROUPS
|
||||
// ════════════════════════════════════════════════════════════════
|
||||
const GROUPS = [
|
||||
{
|
||||
id: 'detail-roundtrip-acc',
|
||||
name: '상세 조회 왕복 검증: 회계',
|
||||
pages: [
|
||||
{ level1: '회계관리', level2: '거래처관리' },
|
||||
{ level1: '회계관리', level2: '어음관리' },
|
||||
{ level1: '회계관리', level2: '입금관리' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'detail-roundtrip-sales',
|
||||
name: '상세 조회 왕복 검증: 판매',
|
||||
pages: [
|
||||
{ level1: '판매관리', level2: '거래처관리' },
|
||||
{ level1: '판매관리', level2: '수주관리' },
|
||||
{ level1: '판매관리', level2: '견적관리' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'detail-roundtrip-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 });
|
||||
|
||||
// 1. 테이블 상태 캡처
|
||||
steps.push({ id: id++, name: `${pfx} 테이블 상태 캡처`, action: 'evaluate', script: CAPTURE_TABLE_STATE, timeout: 10000, phase: 'CAPTURE' });
|
||||
|
||||
// 2. 첫 행 클릭 → 상세 이동
|
||||
steps.push({ id: id++, name: `${pfx} 첫 행 클릭 → 상세 이동`, action: 'evaluate', script: CLICK_FIRST_ROW, timeout: 10000, phase: 'CLICK_ROW' });
|
||||
|
||||
// 3. 상세 페이지 검증
|
||||
steps.push({ id: id++, name: `${pfx} 상세 페이지 데이터 검증`, action: 'evaluate', script: VERIFY_DETAIL, timeout: 10000, phase: 'VERIFY_DETAIL' });
|
||||
|
||||
// 4. 목록 복귀
|
||||
steps.push({ id: id++, name: `${pfx} 목록으로 복귀`, action: 'evaluate', script: GO_BACK_TO_LIST, timeout: 10000, phase: 'GO_BACK' });
|
||||
|
||||
// 5. 목록 무결성 확인
|
||||
steps.push({ id: id++, name: `${pfx} 목록 무결성 확인`, action: 'evaluate', script: VERIFY_LIST_INTACT, timeout: 10000, phase: 'LIST_INTACT' });
|
||||
}
|
||||
|
||||
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 detail-roundtrip`);
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -1291,11 +1291,56 @@
|
||||
|
||||
async evaluate(action, ctx) {
|
||||
try {
|
||||
const result = eval(action.script);
|
||||
if (result instanceof Promise) await result;
|
||||
return pass('evaluate ok');
|
||||
let result = eval(action.script);
|
||||
const isPromise = result instanceof Promise;
|
||||
if (isPromise) result = await result;
|
||||
const resultType = typeof result;
|
||||
// Debug: report what we got from eval
|
||||
if (resultType !== 'string') {
|
||||
return pass('eval_type:' + resultType + '|isPromise:' + isPromise + '|val:' + String(result).substring(0, 80));
|
||||
}
|
||||
// It's a string - try JSON parse
|
||||
let parsed;
|
||||
try {
|
||||
parsed = JSON.parse(result);
|
||||
} catch (jsonErr) {
|
||||
return pass('json_fail:' + jsonErr.message + '|raw:' + result.substring(0, 80));
|
||||
}
|
||||
// Store in ctx.variables
|
||||
if (parsed && typeof parsed === 'object') {
|
||||
const key = action.phase || 'evaluate';
|
||||
ctx.variables['__eval_' + key] = result;
|
||||
}
|
||||
// Check ok field
|
||||
if (parsed.ok === false) {
|
||||
return fail(parsed.error || parsed.warn || 'evaluate returned ok:false');
|
||||
}
|
||||
if (parsed.error) {
|
||||
return fail(parsed.error);
|
||||
}
|
||||
// Build detail string
|
||||
const details = [];
|
||||
if (parsed.phase) details.push(parsed.phase);
|
||||
if (parsed.warn) details.push('W:' + parsed.warn);
|
||||
if (parsed.info) details.push(parsed.info);
|
||||
if (parsed.grade) details.push('grade:' + parsed.grade);
|
||||
if (parsed.vendorFound !== undefined) details.push('vendor:' + parsed.vendorFound);
|
||||
if (parsed.itemFound !== undefined) details.push('item:' + parsed.itemFound);
|
||||
if (parsed.hasIdInUrl !== undefined) details.push('idInUrl:' + parsed.hasIdInUrl);
|
||||
if (parsed.dataMatch !== undefined) details.push('match:' + parsed.dataMatch);
|
||||
if (parsed.intact !== undefined) details.push('intact:' + parsed.intact);
|
||||
if (parsed.urlChanged !== undefined) details.push('urlChg:' + parsed.urlChanged);
|
||||
if (parsed.rowCount !== undefined) details.push('rows:' + parsed.rowCount);
|
||||
if (parsed.totalCalls !== undefined) details.push('api:' + parsed.totalCalls);
|
||||
if (parsed.summary) details.push(parsed.summary);
|
||||
if (parsed.keyword) details.push('kw:' + parsed.keyword);
|
||||
if (parsed.filterWorked !== undefined) details.push('filter:' + parsed.filterWorked);
|
||||
if (parsed.grade === 'FAIL') {
|
||||
return fail('API FAIL: ' + (parsed.summary || JSON.stringify(parsed.failedUrls || [])));
|
||||
}
|
||||
return pass(details.length > 0 ? details.join(' | ') : 'evaluate ok');
|
||||
} catch (err) {
|
||||
return warn(`evaluate error: ${err.message}`);
|
||||
return warn('evaluate error: ' + err.message);
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
Reference in New Issue
Block a user