- 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>
197 lines
9.5 KiB
JavaScript
197 lines
9.5 KiB
JavaScript
#!/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();
|