#!/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();