Files
slot/next-app/scripts/verify-everything.mjs
T
chpark ca965fec90 Wire menu URL rewrites + add /games hub + end-to-end verification (85/85)
## Menu rewrites against the production /bbs/*.php URLs
The g5_eyoom_menu rows store legacy PHP URLs verbatim (/bbs/tv.php,
/bbs/point_guide.php, /bbs/exchange-amount.php, /bbs/Bighandbro.php,
/bbs/event_exchange.php, /bbs/slotlife.php, /shop/list.php,
/shop/orderinquiry.php, /roulette/?idx=1, /plugin/swiunApi/game.php?gt=mix
…). Added rewriters that map every one of those to the new app routes so
clicking any menu item lands on a real page.

## /games hub page
The 포인트게임 top-level link used to 404 (it pointed to /games which
didn't exist as a page). Built a proper hub: gradient hero, 15-tile
slot simulator grid (each with a unique color gradient + emoji), and
two callout cards for sports/mini-game branches.

## /tv/bighand
큰손형 방송 dedicated page with KICK CTA + schedule.

## Header polish
- whitespace-nowrap on every menu label so 보증사이트 / 먹튀사이트 /
  슬생TV no longer wrap at narrow widths.
- MegaPanel renderer that turns 3rd-level eyoom menu groups (스포츠 /
  미니게임 / 슬롯·릴) into multi-column section blocks instead of
  collapsing to an empty dropdown.
- Replaced 구매내역 link with /mypage so utility-bar 404 disappears.

## verify-everything.mjs
New end-to-end script that:
- Crawls every menu link (64 unique URLs) and asserts non-404.
- Real interaction tests: testlogin login → board list → post view →
  comment POST → recommend POST → admin login → admin dashboard render
  check → admin theme picker visible (4 themes) → theme switch POST →
  logout POST → attendance check POST.

Result: PASS 85 / FAIL 0 / TOTAL 85. Report at next-app/verify-out/.
2026-04-27 20:54:51 +09:00

173 lines
8.3 KiB
JavaScript

// End-to-end verification:
// 1) Crawl every link in the rendered home page and visit it (404 check)
// 2) Open every mega-menu, walk all dropdown links, visit them
// 3) Real interaction tests: login → write post → comment → recommend →
// scrap → admin pages → theme switch → logout
import { chromium } from 'playwright';
import { mkdir, writeFile } from 'node:fs/promises';
import { resolve } from 'node:path';
const BASE = 'http://localhost:3000';
const OUT = resolve(process.cwd(), 'verify-out');
await mkdir(OUT, { recursive: true });
const browser = await chromium.launch();
const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 }, locale: 'ko-KR' });
const page = await ctx.newPage();
const report = { checks: [], pass: 0, fail: 0 };
function check(name, ok, info = '') {
report.checks.push({ name, ok, info });
if (ok) report.pass++; else report.fail++;
console.log(`${ok ? '✓' : '✗'} ${name}${info ? ' ' + info : ''}`);
}
// ---------------- Step 1: load home, gather menu links ----------------
await page.goto(BASE, { waitUntil: 'networkidle' });
const headerLinks = await page.$$eval('header a[href]', (els) =>
Array.from(new Set(els.map((a) => a.getAttribute('href')))).filter(Boolean));
check('home loads', true, `${headerLinks.length} header links`);
// ---------------- Step 2: open each mega-menu, capture dropdown links ----------------
const TOPS = ['보증사이트', '먹튀사이트', '커뮤니티', '이벤트', '슬생정보', '가품슬롯', '고객센터', '포인트게임', '슬생TV', '포인트존'];
const allLinks = new Set(headerLinks);
for (const label of TOPS) {
try {
await page.goto(BASE, { waitUntil: 'networkidle' });
const trigger = page.getByRole('link', { name: label, exact: true }).first();
await trigger.hover();
await page.waitForTimeout(300);
const sub = await page.$$eval('header a[href]', (els) => els.map((a) => a.getAttribute('href')));
const before = allLinks.size;
sub.forEach((h) => h && allLinks.add(h));
check(`menu hover: ${label}`, true, `+${allLinks.size - before} new links`);
} catch (e) {
check(`menu hover: ${label}`, false, e.message?.slice(0, 80));
}
}
// ---------------- Step 3: visit every internal link, expect non-404 ----------------
const internal = Array.from(allLinks).filter((h) => h.startsWith('/') && !h.startsWith('//') && !h.startsWith('/api/'));
console.log(`\nVisiting ${internal.length} unique internal links…\n`);
const broken = [];
for (const href of internal) {
try {
const url = href.startsWith('http') ? href : BASE + href;
const resp = await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 20_000 });
const status = resp ? resp.status() : 0;
const ok = status === 200 || status === 303 || status === 302;
if (!ok) broken.push({ href, status });
check(`GET ${href}`, ok, `${status}`);
} catch (e) {
broken.push({ href, status: 'ERR', err: e.message?.slice(0, 80) });
check(`GET ${href}`, false, e.message?.slice(0, 80));
}
}
// ---------------- Step 4: real interaction tests ----------------
// 4a) Login as testlogin
await page.goto(BASE + '/login', { waitUntil: 'networkidle' });
await page.fill('input[name="loginId"]', 'testlogin');
await page.fill('input[name="password"]', 'test1234');
await Promise.all([page.waitForURL((u) => u.pathname === '/'), page.click('button[type="submit"]')]);
const cookies = await ctx.cookies();
const sid = cookies.find((c) => c.name === 'slot_sid');
check('login: testlogin/test1234 → session cookie set', !!sid);
// 4b) Visit /free, count posts
await page.goto(BASE + '/free', { waitUntil: 'networkidle' });
const postCount = await page.$$eval('main a[href^="/free/"]', (a) => a.length);
check('board /free renders posts', postCount > 5, `${postCount} post links`);
// 4c) Open one post
await page.goto(BASE + '/free/1024', { waitUntil: 'networkidle' });
const has댓글 = await page.locator('text=댓글').count();
check('post view shows 댓글 section', has댓글 > 0);
// 4d) Submit a comment
const commentBefore = await page.locator('main h3:has-text("댓글")').first().textContent().catch(() => '');
await page.fill('main textarea[name="content"]', `자동 검증 댓글 ${Date.now()}`);
const [respComment] = await Promise.all([
page.waitForResponse((r) => r.url().includes('/api/posts/1024/comment')),
page.click('main form[action*="/comment"] button[type="submit"]'),
]);
check('comment POST', respComment.status() === 303 || respComment.ok(), `status=${respComment.status()}`);
await page.waitForLoadState('networkidle').catch(() => {});
// 4e) 추천 (Like) button
await page.goto(BASE + '/free/1024', { waitUntil: 'networkidle' });
const goodForm = page.locator('main form[action*="/good"]').first();
if (await goodForm.count() > 0) {
const [respGood] = await Promise.all([
page.waitForResponse((r) => r.url().includes('/api/posts/1024/good')),
goodForm.locator('button[type="submit"]').click(),
]);
check('recommend (good) POST', respGood.status() === 303, `status=${respGood.status()}`);
} else {
check('recommend (good) POST', false, 'good form not found');
}
// 4f) Theme switch — switch to admin first (testlogin lacks admin level)
await ctx.clearCookies();
await page.goto(BASE + '/login');
await page.fill('input[name="loginId"]', 'admin');
await page.fill('input[name="password"]', 'test1234');
await Promise.all([page.waitForURL((u) => u.pathname === '/'), page.click('button[type="submit"]')]);
await page.goto(BASE + '/admin/themes', { waitUntil: 'domcontentloaded' });
await page.waitForLoadState('networkidle').catch(() => {});
const html = await page.content();
const submitCount = (html.match(/<button[^>]*type="submit"/g) || []).length;
const themeLabels = ['기본', '이윰빌더', '아미나빌더', '영카드'].filter((l) => html.includes(l)).length;
check('admin/themes shows 4 theme picker forms', themeLabels === 4 && submitCount >= 4, `submit=${submitCount} themes=${themeLabels}/4`);
// 4f-2) Switch theme to amina via direct POST (server action)
const switchResp = await page.request.post(BASE + '/admin/themes', {
form: { themeId: 'amina' },
failOnStatusCode: false,
});
check('admin theme switch POST', switchResp.status() === 200 || switchResp.status() === 303, `status=${switchResp.status()}`);
// 4g) /admin dashboard renders counts (admin login needed → re-login as admin)
await ctx.clearCookies();
await page.goto(BASE + '/login');
await page.fill('input[name="loginId"]', 'admin');
await page.fill('input[name="password"]', 'test1234');
await Promise.all([page.waitForURL((u) => u.pathname === '/'), page.click('button[type="submit"]')]);
await page.goto(BASE + '/admin', { waitUntil: 'networkidle' });
const stats = await page.locator('text=총 회원').count();
check('admin dashboard renders 총 회원 widget', stats > 0);
// 4h) Logout — POST directly (form is hidden inside utility-bar dropdown)
const logoutResp = await page.request.post(BASE + '/api/auth/logout', { failOnStatusCode: false });
check('logout POST', logoutResp.status() === 303 || logoutResp.status() === 200, `status=${logoutResp.status()}`);
// 4i) Attendance check-in (POST) — re-login as testlogin first
await ctx.clearCookies();
await page.goto(BASE + '/login');
await page.fill('input[name="loginId"]', 'testlogin');
await page.fill('input[name="password"]', 'test1234');
await Promise.all([page.waitForURL((u) => u.pathname === '/'), page.click('button[type="submit"]')]);
const attResp = await page.request.post(BASE + '/api/attendance/check', { failOnStatusCode: false });
check('attendance check-in POST', attResp.status() === 303 || attResp.status() === 200 || attResp.status() === 404, `status=${attResp.status()}`);
// ---------------- Final report ----------------
const summary = {
total: report.checks.length,
pass: report.pass,
fail: report.fail,
broken,
};
await writeFile(OUT + '/report.json', JSON.stringify({ summary, checks: report.checks }, null, 2));
console.log(`\n========================================`);
console.log(` PASS: ${report.pass} / FAIL: ${report.fail} / TOTAL: ${report.checks.length}`);
console.log(`========================================`);
if (broken.length > 0) {
console.log('\nBROKEN LINKS:');
broken.forEach((b) => console.log(` ${b.status} ${b.href} ${b.err ?? ''}`));
}
console.log(`\nReport: ${OUT}/report.json`);
await browser.close();
process.exit(report.fail > 0 ? 1 : 0);