- gen-*.js: 시나리오 자동 생성기 12종 (CRUD, edge, a11y, perf 등) - search-*.js: 검색/버튼 감사 수집기 3종 - revert-hard-actions.js: 하드 액션 복원 유틸 - _gen_writer.py: 생성기 보조 스크립트 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
222 lines
11 KiB
JavaScript
222 lines
11 KiB
JavaScript
#!/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();
|