Initial AI manga platform

This commit is contained in:
www
2026-06-15 17:45:28 +08:00
commit 7a8191650f
267 changed files with 105987 additions and 0 deletions
+593
View File
@@ -0,0 +1,593 @@
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`;
}