diff --git a/.gitignore b/.gitignore index 5b234fd..3e3cd61 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,6 @@ dist .DS_Store *.log .vite +test-results +playwright-report +.playwright diff --git a/e2e/test-prefill-flow.spec.ts b/e2e/test-prefill-flow.spec.ts new file mode 100644 index 0000000..d87fa26 --- /dev/null +++ b/e2e/test-prefill-flow.spec.ts @@ -0,0 +1,87 @@ +/** + * /test → 프리필 → 분석 시작 → loading phase 폴링 → /report/:id 도달 풀 플로우. + * + * 외부 API(YouTube/Apify/Naver/Firecrawl/Perplexity)에 실호출이라 + * 1 run · serial로 운영. 워커는 `--workers=1` 로 강제. + */ +import { test, expect, type Page } from '@playwright/test' +import { CLINICS } from '../src/features/dev/fixtures/mockUrls' + +test.describe.configure({ mode: 'serial' }) + +async function pickAndSubmit(page: Page) { + await page.goto('/test') + + const heading = page.locator('text=선택된 병원:').first() + await expect(heading).toBeVisible({ timeout: 10_000 }) + const text = await heading.innerText() + const match = text.match(/선택된 병원:\s*(.+?)\s*\(/) + const label = match?.[1] + expect(label, `라벨 파싱 실패: "${text}"`).toBeTruthy() + + const clinic = CLINICS.find((c) => c.label === label) + expect(clinic, `${label} 가 CLINICS에 없음`).toBeTruthy() + console.log(`[e2e] 선택된 병원: ${label}`) + + if (clinic!.urls.homepage) { + await expect(page.getByLabel('홈페이지')).toHaveValue(clinic!.urls.homepage) + } + + const analyzeBtn = page.getByRole('button', { name: /Analyze/i }) + await expect(analyzeBtn).toBeEnabled() + await analyzeBtn.click() + + await page.waitForURL(/\/report\/loading/, { timeout: 10_000 }) + console.log(`[e2e] /report/loading 진입 (${new Date().toISOString()})`) + return label! +} + +test('/test → 프리필 → 분석 시작 → phase 폴링 → /report/:id 도달', async ({ page }) => { + // 외부 API 의존 풀 플로우 — discovery(~20s) + collect(~40s) + report(~60s) + plan(~30s) + test.setTimeout(8 * 60_000) + + const consoleErrors: string[] = [] + const pageErrors: string[] = [] + page.on('pageerror', (e) => pageErrors.push(e.message)) + page.on('console', (msg) => { + if (msg.type() === 'error') consoleErrors.push(msg.text()) + }) + + await pickAndSubmit(page) + + // ── Phase 1: discovering → discovered ──────────────────────────── + // discoverChannels 완료 시점에 URL이 /report/loading/ 로 replace 됨 + await expect(page.locator('text=Channels discovered')).toBeVisible({ timeout: 90_000 }) + console.log(`[e2e] ✓ Phase 1: Channels discovered (${new Date().toISOString()})`) + await expect(page).toHaveURL(/\/report\/loading\/[A-Za-z0-9-]+/, { timeout: 5_000 }) + + // ── Phase 2: collecting → collected ────────────────────────────── + await expect(page.locator('text=Data collected')).toBeVisible({ timeout: 180_000 }) + console.log(`[e2e] ✓ Phase 2: Data collected (${new Date().toISOString()})`) + + // ── Phase 3: generating active 상태 확인 ───────────────────────── + // 'Report generated'(완료 라벨)는 planning → complete → navigate 흐름이 빨라 + // viewport에서 잡히지 않는 경우가 있어, 활성 라벨까지만 확인하고 URL 폴링으로 넘어감 + await expect(page.locator('text=Generating AI marketing report')).toBeVisible({ timeout: 30_000 }) + console.log(`[e2e] ✓ Phase 3: Generating report... (${new Date().toISOString()})`) + + // ── 최종 /report/:id 도달 (Perplexity 호출 + plan 생성 포함 — 최대 6분) ── + await page.waitForURL(/\/report\/[A-Za-z0-9-]+$/, { timeout: 6 * 60_000 }) + const finalUrl = page.url() + console.log(`[e2e] ✓ 최종 navigate: ${finalUrl} (${new Date().toISOString()})`) + expect(finalUrl).not.toContain('/loading') + + // ── Report 페이지 본문 렌더링 확인 ─────────────────────────────── + // GuestReportPage 로딩 스피너 → ReportBody. 'Marketing Intelligence Report' 헤더가 viewport 상단. + await expect(page.locator('text=리포트를 불러오는 중...')).toBeHidden({ timeout: 60_000 }) + await expect(page.locator('text=Marketing Intelligence Report')).toBeVisible({ timeout: 30_000 }) + console.log(`[e2e] ✓ Report 페이지 렌더 완료`) + + // ── 콘솔 / 페이지 에러 0건 ──────────────────────────────────────── + expect(pageErrors, `pageerror: ${pageErrors.join('\n')}`).toHaveLength(0) + // 콘솔 에러는 외부 채널 partial failure 등으로 발생할 수 있어 경고만 출력 + if (consoleErrors.length > 0) { + console.warn(`[e2e] 콘솔 에러 ${consoleErrors.length}건 (non-blocking):`) + consoleErrors.slice(0, 5).forEach((e) => console.warn(` • ${e}`)) + } +}) diff --git a/package-lock.json b/package-lock.json index ccb0d19..57958a3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -23,6 +23,7 @@ "zustand": "^5.0.6" }, "devDependencies": { + "@playwright/test": "^1.60.0", "@tailwindcss/vite": "^4.1.14", "@tanstack/react-query-devtools": "^5.59.0", "@types/node": "^22.14.0", @@ -1185,6 +1186,22 @@ "openapi3-ts": "4.5.0" } }, + "node_modules/@playwright/test": { + "version": "1.60.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.60.0.tgz", + "integrity": "sha512-O71yZIbAh/PxDMNGns37GHBIfrVkEVyn+AXyIa5dOTfb4/xNvRWV+Vv/NMbNCtODB/pO7vLlF2OTmMVLhmr7Ag==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.60.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/@radix-ui/number": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.1.tgz", @@ -7229,6 +7246,53 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/playwright": { + "version": "1.60.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.60.0.tgz", + "integrity": "sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.60.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.60.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.60.0.tgz", + "integrity": "sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/playwright/node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/pony-cause": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/pony-cause/-/pony-cause-1.1.1.tgz", diff --git a/package.json b/package.json index d27f068..a6a88d0 100644 --- a/package.json +++ b/package.json @@ -31,6 +31,7 @@ "zustand": "^5.0.6" }, "devDependencies": { + "@playwright/test": "^1.60.0", "@tailwindcss/vite": "^4.1.14", "@tanstack/react-query-devtools": "^5.59.0", "@types/node": "^22.14.0", diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 0000000..905cbfc --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,25 @@ +import { defineConfig, devices } from '@playwright/test' + +/** + * Playwright config — /test 페이지를 통한 분석 플로우 E2E 검증용. + * + * dev 서버는 직접 띄워놓은 상태에서 BASE_URL 환경변수로 포트 지정 (기본 3000). + * `BASE_URL=http://localhost:3002 npx playwright test` 처럼 사용. + */ +export default defineConfig({ + testDir: './e2e', + fullyParallel: true, + workers: 3, + reporter: [['list']], + use: { + baseURL: process.env.BASE_URL ?? 'http://localhost:3000', + trace: 'retain-on-failure', + screenshot: 'only-on-failure', + }, + projects: [ + { + name: 'chromium', + use: { ...devices['Desktop Chrome'] }, + }, + ], +})