import { expect, test } from '@playwright/test' test('대시보드 숫자·섹션 순서와 한 줄 접속자 페이지 목록을 표시한다', async ({ page }) => { const login = await page.request.post('/admin/api/auth/login', { data: { email: 'playwright-admin@example.com', password: 'playwright-admin-password' } }) expect(login.ok()).toBeTruthy() await page.goto('/admin/posts') await page.waitForLoadState('networkidle') await page.route('**/admin/api/posts', (route) => route.fulfill({ json: Array.from({ length: 1234 }, (_, index) => ({ id: String(index), status: 'published' })) })) await page.route('**/admin/api/analytics/summary?*', (route) => route.fulfill({ json: { todayVisitors: 1234, totalVisitors: 10413, trends: [], collectedSince: '2026-06-01' } })) await page.route('**/admin/api/analytics/range-detail?*', (route) => route.fulfill({ json: { summary: { pageViews: 12345, visitors: 3456, avgEngagedSeconds: 90, scroll50Reach: 2345 }, sources: [], devices: [], topPosts: [], topPages: [] } })) const session = { activeKey: 'test-visitor', isLoggedIn: false, ipAddress: '198.51.100.1', durationSeconds: 4920, pageCount: 6, pages: [ { key: 'old', path: '/post/old', postTitle: '이전 페이지', lastSeenAt: '2026-10-21T01:00:00Z' }, { key: 'new', path: '/post/new', postTitle: '가장 최근 페이지', lastSeenAt: '2026-10-21T06:00:00Z' }, { key: 'dup', path: '/post/new', postTitle: '가장 최근 페이지', lastSeenAt: '2026-10-21T05:00:00Z' }, ...[2, 3, 4].map((index) => ({ key: String(index), path: `/post/page-${index}`, postTitle: `열린 페이지 ${index}`, lastSeenAt: `2026-10-21T0${index}:00:00Z` })) ] } const singlePageSession = { activeKey: 'single-page-visitor', isLoggedIn: false, ipAddress: '198.51.100.2', durationSeconds: 60, pageCount: 1, pages: [{ key: 'single', path: '/post/single', postTitle: '한 페이지만 보는 방문자' }] } await page.route('**/admin/api/analytics/realtime?*', (route) => route.fulfill({ json: { summary: { onlineNow: 1234, openPageCount: 12345 }, sessions: [session, singlePageSession] } })) await page.getByRole('link', { name: '대시보드', exact: true }).click() await expect(page.locator('.admin-dashboard__summary')).toContainText('10,413') await expect(page.locator('.admin-dashboard__summary')).toContainText('1,234') await expect(page.locator('.admin-dashboard__traffic-total')).toContainText('12,345') const sections = await page.locator('.admin-dashboard__body > section').evaluateAll((elements) => elements.map((element) => element.className)) expect(sections.map((value) => value.match(/admin-dashboard__(summary|adsense|charts|selected-detail|recent-comments|live)\b/)[1])).toEqual(['summary', 'adsense', 'charts', 'selected-detail', 'recent-comments', 'live']) await expect(page.locator('.admin-dashboard__live-count')).toHaveText('전체 1,234명 중 2명 표시 · 열린 화면 12,345개') const visitor = page.locator('.admin-dashboard__live-item').first() await expect(visitor).toContainText('가장 최근 페이지') await expect(visitor).toContainText('외 4개') await expect(visitor.locator('.admin-realtime-pages__extra')).toHaveCSS('color', 'rgb(37, 99, 235)') await expect(visitor.locator('.admin-realtime-pages__extra-number')).toHaveText('4') await expect(visitor.locator('.admin-realtime-pages__extra-number')).toHaveCSS('font-weight', '700') await expect(visitor.locator('.admin-dashboard__live-duration')).toHaveText('1시간 22분') await expect(visitor.locator('.admin-dashboard__live-duration')).toHaveAttribute('title', /30분 이상 중단되면 새 방문으로 시작/) expect((await visitor.boundingBox()).height).toBeLessThan(45) const trigger = visitor.getByRole('button', { name: '열린 페이지 목록', exact: true }) await trigger.hover() const popover = page.locator('.admin-realtime-pages__popover') await expect(popover).toBeVisible() await expect(popover.locator('a')).toHaveCount(5) await expect(popover.locator('a').first()).toHaveAttribute('href', '/post/new') await popover.hover() await expect(popover).toBeVisible() await page.keyboard.press('Escape') await expect(popover).toHaveCount(0) await trigger.press('Enter') await expect(popover).toBeVisible() await popover.getByRole('button', { name: '열린 페이지 목록 닫기' }).click() await expect(popover).toHaveCount(0) const singleVisitor = page.locator('.admin-dashboard__live-item').last() await singleVisitor.locator('.admin-realtime-pages__single').hover() await expect(singleVisitor.getByRole('button', { name: '열린 페이지 목록', exact: true })).toHaveCount(0) await expect(singleVisitor.locator('.admin-realtime-pages__extra')).toHaveCount(0) await expect(singleVisitor.getByRole('link', { name: '한 페이지만 보는 방문자' })).toHaveAttribute('href', '/post/single') await expect(popover).toHaveCount(0) }) test('공개 페이지는 짧은 실행 중단을 제외하고 긴 중단 후 새 방문으로 시작한다', async ({ page }) => { const login = await page.request.post('/admin/api/auth/login', { data: { email: 'playwright-admin@example.com', password: 'playwright-admin-password' } }) expect(login.ok()).toBeTruthy() const created = await page.request.post('/admin/api/posts', { data: { title: '접속 시간 검증', slug: 'duration-ui-test', content: '시간 측정 본문', status: 'published', tags: [], tagNames: {} } }) expect(created.ok()).toBeTruthy() const post = await created.json() try { const heartbeats = [] const pageviews = [] await page.clock.install({ time: new Date('2026-10-21T00:00:00Z') }) await page.route('**/api/analytics/heartbeat', async (route) => { heartbeats.push(route.request().postDataJSON()) await route.fulfill({ json: { ok: true } }) }) await page.route('**/api/analytics/pageview', async (route) => { pageviews.push(route.request().postDataJSON()) await route.fulfill({ json: { ok: true } }) }) await page.goto('/post/duration-ui-test') await expect.poll(() => heartbeats.length).toBeGreaterThan(0) await page.clock.pauseAt(new Date('2026-10-21T00:01:00Z')) await page.evaluate(() => window.scrollTo(0, document.documentElement.scrollHeight)) await page.evaluate(() => document.dispatchEvent(new Event('visibilitychange'))) await expect.poll(() => heartbeats.at(-1)?.durationSeconds).toBeGreaterThan(0) const initialDuration = heartbeats.at(-1).durationSeconds await page.clock.runFor(40000) await expect.poll(() => heartbeats.at(-1)?.durationSeconds).toBe(initialDuration + 40) const initialContentSession = heartbeats.at(-1).contentSessionId const beforeSleepCount = heartbeats.length await page.clock.fastForward(120000) await expect.poll(() => heartbeats.length).toBeGreaterThan(beforeSleepCount) expect(heartbeats.at(-1).durationSeconds).toBe(initialDuration + 40) expect(heartbeats.at(-1).contentSessionId).toBe(initialContentSession) await page.clock.runFor(20000) await expect.poll(() => heartbeats.at(-1)?.durationSeconds).toBe(initialDuration + 60) const beforeRestartViews = pageviews.filter((entry) => !entry.read).length await page.clock.fastForward(2 * 86400000) await expect.poll(() => heartbeats.at(-1)?.durationSeconds).toBe(0) expect(heartbeats.at(-1).contentSessionId).not.toBe(initialContentSession) await expect.poll(() => pageviews.filter((entry) => !entry.read).length).toBe(beforeRestartViews + 1) expect(pageviews.at(-1).contentSessionId).toBe(heartbeats.at(-1).contentSessionId) const restartedContentSession = heartbeats.at(-1).contentSessionId expect(pageviews.filter((entry) => entry.read && entry.contentSessionId === restartedContentSession)).toHaveLength(0) await page.clock.runFor(20000) await expect.poll(() => heartbeats.at(-1)?.durationSeconds).toBe(20) await expect.poll(() => pageviews.filter((entry) => entry.read && entry.contentSessionId === restartedContentSession).length).toBe(1) } finally { await page.request.delete(`/admin/api/posts/${post.id}`) } })