Files
sam-hotfix/e2e/runner/gen-pagination-sort.js
김보곤 67d0a4c2fd feat: E2E 시나리오 생성기 및 감사 스크립트 17종 추가
- 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>
2026-02-19 16:59:15 +09:00

222 lines
11 KiB
JavaScript
Raw 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
/**
* 테이블 페이지네이션 & 정렬 검증 시나리오 생성기
*
* 실제 고객이 가장 많이 사용하는 기능: 목록 페이지 이동, 컬럼 정렬.
* 버그 발견 포인트: 중복 데이터, 정렬 미동작, 페이지간 데이터 불일치, 빈 페이지.
*
* 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();