// 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(/]*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);