import { createRequire } from 'node:module'; import { mkdir, writeFile } from 'node:fs/promises'; import { resolve } from 'node:path'; const require = createRequire(import.meta.url); const { chromium } = require('playwright'); const outputDir = resolve(process.env.FRONTEND_E2E_DIR || 'storage/private/frontend-business-e2e'); const userUrl = process.env.USER_APP_URL || 'http://127.0.0.1:5174/'; const adminUrl = process.env.ADMIN_URL || 'http://127.0.0.1:5175/'; const apiBase = process.env.API_BASE_URL || 'http://127.0.0.1:3000/api'; const userEmail = process.env.FRONTEND_E2E_USER_EMAIL || 'business-e2e@example.com'; const userPassword = process.env.FRONTEND_E2E_USER_PASSWORD || 'Business123!'; const adminEmail = process.env.FRONTEND_E2E_ADMIN_EMAIL || 'admin@example.com'; const adminPassword = process.env.FRONTEND_E2E_ADMIN_PASSWORD || 'Admin123!'; const runId = process.env.FRONTEND_E2E_RUN_ID || new Date().toISOString().replace(/[-:.TZ]/g, '').slice(0, 14); const reportPath = `${outputDir}/report-${runId}.json`; const markdownPath = `${outputDir}/report-${runId}.md`; const latestReportPath = `${outputDir}/latest-report.json`; const latestMarkdownPath = `${outputDir}/latest-report.md`; const report = { run_id: runId, generated_at: new Date().toISOString(), user_url: userUrl, admin_url: adminUrl, api_base: apiBase, project_id: null, episode_id: null, video_asset_id: null, status: 'running', steps: [], failures: [], artifacts: { report_json: reportPath, report_markdown: markdownPath, screenshots: [] }, summary: {} }; let browser; let userPage; let adminPage; let userAuth; let adminAuth; let project; let episode; let videoAsset; let activeStep = null; try { await mkdir(outputDir, { recursive: true }); userAuth = await loginOrRegister(userEmail, userPassword, '业务E2E用户'); adminAuth = await login(adminEmail, adminPassword); browser = await chromium.launch({ headless: true }); const userContext = await browser.newContext({ viewport: { width: 1440, height: 1000 } }); const adminContext = await browser.newContext({ viewport: { width: 1440, height: 1000 } }); await userContext.addInitScript(({ token }) => { localStorage.setItem('ai_manga_user_token', token); }, { token: userAuth.access_token }); await adminContext.addInitScript(({ token, user, email }) => { localStorage.setItem('admin_token', token); localStorage.setItem('admin_user', JSON.stringify(user)); localStorage.setItem('admin_email', email); }, { token: adminAuth.access_token, user: adminAuth.user, email: adminEmail }); userPage = await userContext.newPage(); adminPage = await adminContext.newPage(); wirePageDiagnostics(userPage, 'user'); wirePageDiagnostics(adminPage, 'admin'); await runStep('user.open', async () => { await userPage.goto(userUrl, { waitUntil: 'domcontentloaded', timeout: 30000 }); await userPage.waitForTimeout(1000); await screenshot(userPage, '01-user-open'); }); await runStep('user.create_project_ui', async () => { const title = `业务E2E上传小说闭环-${runId}`; await clickButton(userPage, '新建'); await fillByLabel(userPage, '项目名', title); await selectByLabel(userPage, '类型', 'upload'); await selectByLabel(userPage, '生成类型', 'image_manga'); await fillByLabel(userPage, '题材', '都市逆袭业务验收'); await fillByLabel(userPage, '目标集数', '1'); await fillByLabel(userPage, '单集秒数', '20'); await clickButton(userPage, '创建项目'); project = await waitForProjectByTitle(title); report.project_id = project.id; await userPage.evaluate((projectId) => { localStorage.setItem('ai_manga_selected_project_id', projectId); }, project.id); await userPage.waitForTimeout(1000); await screenshot(userPage, '02-user-project-created'); return { project_id: project.id, title: project.title }; }); await runStep('api.prepare_quota_mock_pay', async () => { const packages = await api('/billing/packages'); const selectedPackage = packages.packages.find((item) => item.code === 'standard_3ep') ?? packages.packages[0]; if (!selectedPackage) throw new Error('No billing package is available'); const order = await api('/billing/orders', { method: 'POST', token: userAuth.access_token, body: { package_code: selectedPackage.code, project_id: project.id, payment_method: 'mock_pay' } }); const paid = await api(`/billing/orders/${order.order.id}/mock-pay`, { method: 'POST', token: userAuth.access_token, body: {} }); return { package_code: selectedPackage.code, quota_amount: selectedPackage.quota_amount, available_quota: paid.account.available_quota }; }); await runStep('user.paste_confirm_parse_ui', async () => { await ensureStudio(userPage); await fillByLabel(userPage, '书名', `测试小说-${runId}`); await fillByLabel(userPage, '作者', 'Codex E2E'); await fillByLabel(userPage, '粘贴文本', sampleNovelText(runId)); await clickButton(userPage, '保存粘贴文本'); await waitForActionFinished(userPage); await clickInPanel(userPage, '版权确认', '确认'); await waitForActionFinished(userPage); await clickButton(userPage, '解析小说'); await waitForActionFinished(userPage, 30000); const parsed = await api(`/projects/${project.id}/novel/parse-result`, { token: userAuth.access_token }); if (!parsed.chapters?.length) throw new Error('Novel parse produced no chapters'); await screenshot(userPage, '03-user-novel-parsed'); return { chapter_count: parsed.chapters.length, source_id: parsed.source?.id ?? null }; }); await runStep('user.story_bible_ui', async () => { await clickInPanel(userPage, '故事圣经', '生成'); await waitForActionFinished(userPage, 30000); await clickInPanel(userPage, '故事圣经', '确认'); await waitForActionFinished(userPage, 30000); const story = await api(`/projects/${project.id}/story-bible`, { token: userAuth.access_token }); if (story.story_bible?.status !== 'confirmed') throw new Error('Story bible was not confirmed'); await screenshot(userPage, '04-user-story-bible'); return { story_bible_id: story.story_bible.id, status: story.story_bible.status }; }); await runStep('user.characters_ui', async () => { await clickInPanel(userPage, '角色库', '抽取'); await waitForActionFinished(userPage, 30000); await clickInPanel(userPage, '角色库', '确认'); await waitForActionFinished(userPage, 30000); const characters = await api(`/projects/${project.id}/characters`, { token: userAuth.access_token }); if (!characters.length) throw new Error('Character extraction produced no characters'); await screenshot(userPage, '05-user-characters'); return { character_count: characters.length, confirmed_count: characters.filter((item) => item.status === 'locked').length }; }); await runStep('user.memory_episodes_script_storyboard_ui', async () => { await clickInPanel(userPage, '长篇记忆', '生成'); await waitForActionFinished(userPage, 30000); await clickInPanel(userPage, '分集计划', '生成'); await waitForActionFinished(userPage, 30000); await clickInPanel(userPage, '分集计划', '确认'); await waitForActionFinished(userPage, 30000); const episodes = await api(`/projects/${project.id}/episodes`, { token: userAuth.access_token }); episode = episodes[0]; if (!episode) throw new Error('Episode generation produced no episode'); report.episode_id = episode.id; await clickInPanel(userPage, '脚本和分镜', '脚本'); await waitForActionFinished(userPage, 30000); await clickInPanel(userPage, '脚本和分镜', '确认脚本'); await waitForActionFinished(userPage, 30000); await clickInPanel(userPage, '脚本和分镜', '分镜'); await waitForActionFinished(userPage, 30000); await clickInPanel(userPage, '脚本和分镜', '确认分镜'); await waitForActionFinished(userPage, 30000); const shots = await api(`/episodes/${episode.id}/storyboard`, { token: userAuth.access_token }); if (!shots.length) throw new Error('Storyboard generation produced no shots'); await screenshot(userPage, '06-user-storyboard'); return { episode_id: episode.id, shot_count: shots.length }; }); await runStep('user.generate_media_render_review_preview_ui', async () => { await clickInPanel(userPage, '图片、音频和视频', '分镜图'); await waitForActionFinished(userPage, 60000); await clickInPanel(userPage, '图片、音频和视频', '多角色音频'); await waitForActionFinished(userPage, 60000, [/TTS 超出分配时长/]); await clickInPanel(userPage, '图片、音频和视频', '合成'); await waitForActionFinished(userPage, 90000, [/TTS 超出分配时长/]); const mediaRows = await api(`/episodes/${episode.id}/media-assets`, { token: userAuth.access_token }); const videoRow = mediaRows.find((row) => row.task_type === 'video_render' && row.asset?.asset_type === 'video'); if (!videoRow?.asset?.id) throw new Error('Video render did not produce a video asset'); videoAsset = videoRow.asset; report.video_asset_id = videoAsset.id; const autoPreviewVisible = await userPage.locator('.asset-preview-modal').isVisible().catch(() => false); if (autoPreviewVisible) { await screenshot(userPage, '07-user-render-auto-preview'); await closePreviewIfOpen(userPage); } await clickButton(userPage, '审核'); await clickButton(userPage, '文本审核'); await waitForActionFinished(userPage, 30000); await clickButton(userPage, '视频审核'); await waitForActionFinished(userPage, 30000); await clickButton(userPage, '成品'); await userPage.getByRole('button', { name: '预览', exact: true }).first().click({ timeout: 10000 }); await userPage.waitForTimeout(1500); const previewVisible = await userPage.locator('.asset-preview-modal').isVisible().catch(() => false); if (!previewVisible) throw new Error('Asset preview modal did not open'); await screenshot(userPage, '07-user-result-preview'); const reviews = await api(`/projects/${project.id}/reviews?limit=50`, { token: userAuth.access_token }); return { video_asset_id: videoAsset.id, media_count: mediaRows.length, review_count: reviews.reviews.length }; }); await runStep('admin.tasks_audit_ui', async () => { await adminPage.goto(adminUrl, { waitUntil: 'domcontentloaded', timeout: 30000 }); await adminPage.waitForTimeout(1000); await clickButton(adminPage, '任务管理'); await adminPage.waitForTimeout(1000); await screenshot(adminPage, '08-admin-tasks'); await clickButton(adminPage, '内容审核'); await adminPage.waitForTimeout(1000); await screenshot(adminPage, '09-admin-reviews'); await clickButton(adminPage, '审计日志'); await adminPage.waitForTimeout(1000); await screenshot(adminPage, '10-admin-audit'); const tasks = await api(`/projects/${project.id}/tasks?limit=100`, { token: userAuth.access_token }); const adminTasks = await api(`/admin/tasks?project_id=${encodeURIComponent(project.id)}&limit=100`, { token: adminAuth.access_token }); const adminReviews = await api(`/admin/content-reviews?project_id=${encodeURIComponent(project.id)}&limit=100`, { token: adminAuth.access_token }); const audit = await api(`/admin/operation-logs?target_type=project&target_id=${encodeURIComponent(project.id)}&limit=100`, { token: adminAuth.access_token }); if (!tasks.tasks.length) throw new Error('Project task list is empty'); if (!adminTasks.tasks.length) throw new Error('Admin task list is empty for project'); if (!adminReviews.reviews?.length) throw new Error('Admin content review list is empty for project'); if (!audit.logs?.length) { const step = report.steps.at(-1); step?.warnings.push({ type: 'operation_log', message: 'No project-scoped operation_logs were written for normal user generation actions.' }); } return { user_task_count: tasks.tasks.length, admin_task_count: adminTasks.tasks.length, admin_review_count: adminReviews.reviews.length, operation_log_count: audit.logs?.length ?? 0, failed_tasks: tasks.tasks.filter((task) => task.status === 'failed').length }; }); report.status = report.failures.length ? 'failed' : 'passed'; } catch (error) { report.status = 'failed'; const normalized = normalizeError(error); const duplicated = report.failures.some((item) => item.step === currentStepName() && item.message === normalized.message ); if (!duplicated) { report.failures.push({ step: currentStepName(), message: normalized.message, stack: normalized.stack }); } if (userPage) { await screenshot(userPage, 'failure-user').catch(() => {}); } if (adminPage) { await screenshot(adminPage, 'failure-admin').catch(() => {}); } process.exitCode = 1; } finally { report.summary = summarizeReport(); await writeReports(); if (browser) await browser.close(); console.log(reportPath); console.log(markdownPath); console.log(JSON.stringify(report.summary)); } async function runStep(name, fn) { activeStep = name; const startedAt = new Date(); const step = { name, status: 'running', started_at: startedAt.toISOString(), finished_at: null, duration_ms: null, result: null, errors: [], warnings: [], screenshots: [] }; report.steps.push(step); console.log(`e2e ${name}`); try { const result = await fn(); step.status = 'passed'; step.result = result ?? null; } catch (error) { const normalized = normalizeError(error); step.status = 'failed'; step.errors.push({ message: normalized.message, stack: normalized.stack }); report.failures.push({ step: name, message: normalized.message, stack: normalized.stack }); throw error; } finally { step.finished_at = new Date().toISOString(); step.duration_ms = new Date(step.finished_at).getTime() - startedAt.getTime(); activeStep = null; } } function currentStepName() { return activeStep ?? report.steps.at(-1)?.name ?? 'unknown'; } function wirePageDiagnostics(page, target) { page.on('console', (message) => { if (message.type() !== 'error') return; const step = report.steps.at(-1); const error = { target, type: 'console', message: message.text() }; if (step) step.errors.push(error); report.failures.push({ step: currentStepName(), ...error }); }); page.on('pageerror', (error) => { const step = report.steps.at(-1); const item = { target, type: 'pageerror', message: error.message }; if (step) step.errors.push(item); report.failures.push({ step: currentStepName(), ...item }); }); } async function screenshot(page, name) { const file = `${outputDir}/${runId}-${name}.png`; await page.screenshot({ path: file, fullPage: false, timeout: 10000 }); report.artifacts.screenshots.push(file); const step = report.steps.at(-1); if (step) step.screenshots.push(file); return file; } async function ensureStudio(page) { await clickButton(page, '制作'); await page.waitForTimeout(600); } async function closePreviewIfOpen(page) { const modal = page.locator('.asset-preview-modal').first(); const visible = await modal.isVisible().catch(() => false); if (!visible) return; await modal.getByRole('button', { name: '关闭', exact: true }).click({ timeout: 10000 }); await page.waitForTimeout(500); } async function clickButton(page, name) { const button = page.getByRole('button', { name, exact: true }).first(); await button.waitFor({ state: 'visible', timeout: 15000 }); await button.click({ timeout: 15000 }); } async function clickInPanel(page, heading, buttonName) { const panel = page.locator('section.panel, div.panel').filter({ has: page.getByRole('heading', { name: heading, exact: true }) }).first(); await panel.waitFor({ state: 'visible', timeout: 15000 }); await panel.scrollIntoViewIfNeeded(); const button = panel.getByRole('button', { name: buttonName, exact: true }).first(); await button.waitFor({ state: 'visible', timeout: 15000 }); await button.click({ timeout: 15000 }); } async function fillByLabel(page, label, value) { const field = formFieldByLabel(page, label); await field.waitFor({ state: 'visible', timeout: 15000 }); await field.fill(String(value)); } async function selectByLabel(page, label, value) { const field = formFieldByLabel(page, label); await field.waitFor({ state: 'visible', timeout: 15000 }); await field.selectOption(value); } function formFieldByLabel(page, label) { return page .locator('label') .filter({ has: page.locator('span', { hasText: new RegExp(`^${escapeRegExp(label)}$`) }) }) .locator('input, select, textarea') .first(); } function escapeRegExp(value) { return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); } async function waitForActionFinished(page, timeout = 45000, allowedDangerPatterns = []) { await page.waitForFunction(() => { const text = document.body.innerText || ''; return !text.includes('创建中') && !text.includes('正在生成') && !text.includes('正在合成') && !text.includes('正在解析') && !text.includes('已等待'); }, { timeout }); await page.waitForTimeout(800); const errors = await page.locator('.notice.danger').allTextContents().catch(() => []); const visibleErrors = errors.map((item) => item.trim()).filter(Boolean); if (visibleErrors.length) { const blockingErrors = visibleErrors.filter((message) => !allowedDangerPatterns.some((pattern) => pattern.test(message)) ); const allowedWarnings = visibleErrors.filter((message) => allowedDangerPatterns.some((pattern) => pattern.test(message)) ); const step = report.steps.at(-1); if (step && allowedWarnings.length) { step.warnings.push(...allowedWarnings.map((message) => ({ type: 'ui_notice', message }))); } if (blockingErrors.length) { throw new Error(`UI error notice: ${blockingErrors.join(' / ')}`); } } } async function waitForProjectByTitle(title) { const timeoutAt = Date.now() + 20000; while (Date.now() < timeoutAt) { const rows = await api('/projects', { token: userAuth.access_token }); const found = rows.find((item) => item.title === title); if (found) return found; await delay(800); } throw new Error(`Project was not created: ${title}`); } async function loginOrRegister(email, password, nickname) { try { return await login(email, password); } catch { return api('/auth/register', { method: 'POST', body: { email, password, nickname } }); } } async function login(email, password) { return api('/auth/login', { method: 'POST', body: { email, password } }); } async function api(path, options = {}) { const response = await fetch(`${apiBase}${path}`, { method: options.method || 'GET', headers: { 'content-type': 'application/json', ...(options.token ? { authorization: `Bearer ${options.token}` } : {}) }, body: options.body ? JSON.stringify(options.body) : undefined }); const payload = await response.json().catch(() => null); if (!response.ok || !payload || payload.code !== 0) { throw new Error(payload?.message || `API ${path} failed: HTTP ${response.status}`); } return payload.data; } function sampleNovelText(id) { const paragraphs = [ `第1章 重回低谷 ${id}`, '林澈被合伙人当众抢走项目,所有人都以为他会低头认输。', '他没有争辩,只是收起旧电脑,回到那间漏雨的出租屋。', '半夜,母亲的病危电话打来,林澈终于决定启用自己封存三年的算法系统。', '第二天,城市最大的短剧平台突然崩溃,只有林澈留下的备份模型能恢复数据。', '昔日看不起他的投资人排队等在楼下,合伙人也带着合同跪求合作。', '林澈只说了一句话:这一次,规则由我来写。', '第2章 第一场反击', '女主沈知夏发现林澈真正的能力,主动提出帮他重新搭建团队。', '两人在废弃会议室里用一台旧服务器,完成了足以改变行业的 Demo。', '当晚发布会上,反派准备再次羞辱林澈,却被大屏幕上的实时数据彻底打脸。', '所有镜头都对准林澈,他终于从阴影里走到光下。' ]; return paragraphs.join('\n\n'); } function normalizeError(error) { return error instanceof Error ? error : new Error(String(error)); } function delay(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); } function summarizeReport() { const failedSteps = report.steps.filter((step) => step.status === 'failed'); const passedSteps = report.steps.filter((step) => step.status === 'passed'); return { status: report.status, total_steps: report.steps.length, passed_steps: passedSteps.length, failed_steps: failedSteps.length, failure_count: report.failures.length, project_id: report.project_id, episode_id: report.episode_id, video_asset_id: report.video_asset_id, screenshot_count: report.artifacts.screenshots.length }; } async function writeReports() { const markdown = createMarkdownReport(); await writeFile(reportPath, `${JSON.stringify(report, null, 2)}\n`); await writeFile(markdownPath, markdown); await writeFile(latestReportPath, `${JSON.stringify(report, null, 2)}\n`); await writeFile(latestMarkdownPath, markdown); } function createMarkdownReport() { const lines = [ `# Frontend Business E2E Report`, '', `- Run ID: ${report.run_id}`, `- Status: ${report.status}`, `- Project ID: ${report.project_id ?? '-'}`, `- Episode ID: ${report.episode_id ?? '-'}`, `- Video Asset ID: ${report.video_asset_id ?? '-'}`, `- Generated At: ${report.generated_at}`, '', '## Summary', '', '```json', JSON.stringify(report.summary, null, 2), '```', '', '## Steps', '' ]; for (const step of report.steps) { lines.push(`### ${step.status === 'passed' ? 'PASS' : 'FAIL'} ${step.name}`); lines.push(''); lines.push(`- Duration: ${step.duration_ms ?? '-'} ms`); if (step.result) { lines.push('- Result:'); lines.push('```json'); lines.push(JSON.stringify(step.result, null, 2)); lines.push('```'); } if (step.errors.length) { lines.push('- Errors:'); lines.push('```json'); lines.push(JSON.stringify(step.errors, null, 2)); lines.push('```'); } if (step.screenshots.length) { lines.push(`- Screenshots: ${step.screenshots.join(', ')}`); } lines.push(''); } if (report.failures.length) { lines.push('## Failures'); lines.push(''); lines.push('```json'); lines.push(JSON.stringify(report.failures, null, 2)); lines.push('```'); } return `${lines.join('\n')}\n`; }