Initial AI manga platform
This commit is contained in:
@@ -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`;
|
||||
}
|
||||
Reference in New Issue
Block a user