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 targets = [ { name: 'user-pc', url: process.env.USER_APP_URL || 'http://127.0.0.1:5174/', width: 1440, height: 1000 }, { name: 'user-h5', url: process.env.USER_APP_URL || 'http://127.0.0.1:5174/', width: 390, height: 844, isMobile: true }, { name: 'admin-pc', url: process.env.ADMIN_URL || 'http://127.0.0.1:5175/', width: 1440, height: 1000 }, { name: 'admin-h5', url: process.env.ADMIN_URL || 'http://127.0.0.1:5175/', width: 390, height: 844, isMobile: true } ]; const outputDir = resolve(process.env.FRONTEND_AUDIT_DIR || 'storage/private/frontend-visual-audit'); const apiBase = process.env.API_BASE_URL || 'http://127.0.0.1:3000/api'; const userEmail = process.env.FRONTEND_AUDIT_USER_EMAIL || 'visual-audit@example.com'; const userPassword = process.env.FRONTEND_AUDIT_USER_PASSWORD || 'Audit123!'; const adminEmail = process.env.FRONTEND_AUDIT_ADMIN_EMAIL || 'admin@example.com'; const adminPassword = process.env.FRONTEND_AUDIT_ADMIN_PASSWORD || 'Admin123!'; const browser = await chromium.launch({ headless: true }); const report = []; try { await mkdir(outputDir, { recursive: true }); const userAuth = await loginOrRegister(userEmail, userPassword, '视觉验收'); const adminAuth = await login(adminEmail, adminPassword); const project = await ensureLiveActionProject(userAuth.access_token); for (const target of targets) { const context = await browser.newContext({ viewport: { width: target.width, height: target.height }, isMobile: Boolean(target.isMobile), deviceScaleFactor: target.isMobile ? 2 : 1 }); if (target.name.startsWith('user')) { await context.addInitScript(({ token, projectId }) => { localStorage.setItem('ai_manga_user_token', token); localStorage.setItem('ai_manga_selected_project_id', projectId); }, { token: userAuth.access_token, projectId: project.id }); await auditInteractiveTarget(context, target, [ { label: 'create', action: async (page) => clickButton(page, '新建') }, { label: 'projects', action: async (page) => clickButton(page, '项目') }, { label: 'studio', action: async (page) => clickButton(page, '制作') }, { label: 'quota', action: async (page) => clickButton(page, '额度') }, { label: 'review', action: async (page) => clickButton(page, '审核') }, { label: 'progress', action: async (page) => clickButton(page, '进度') }, { label: 'result', action: async (page) => clickButton(page, '成品') }, { label: 'tutorial', action: async (page) => clickButton(page, '教程') }, { label: 'profile', action: async (page) => clickButton(page, '我的') } ]); } else { await context.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 }); await auditInteractiveTarget(context, target, [ { label: 'dashboard' }, { label: 'projects', action: async (page) => clickButton(page, '项目管理') }, { label: 'tasks', action: async (page) => clickButton(page, '任务管理') }, { label: 'routerAudit', action: async (page) => clickButton(page, 'Router 审计') }, { label: 'hitAnalysis', action: async (page) => clickButton(page, '爆款诊断') }, { label: 'aiPlatforms', action: async (page) => clickButton(page, 'AI 平台入口') }, { label: 'providers', action: async (page) => clickButton(page, 'AI 接入') }, { label: 'costs', action: async (page) => clickButton(page, '成本日志') }, { label: 'audit', action: async (page) => clickButton(page, '审计日志') } ]); } await context.close(); } const reportPath = `${outputDir}/report.json`; await writeFile(reportPath, `${JSON.stringify(report, null, 2)}\n`); console.log(reportPath); for (const item of report) { console.log([ item.target.name, item.step, `pageOverflow=${item.audit.horizontalPageOverflow}`, `textOverflow=${item.audit.overflowElements.length}`, `overlaps=${item.audit.overlaps.length}`, `consoleErrors=${item.consoleErrors.length}`, `screenshot=${item.screenshot}` ].join(' | ')); } } finally { await browser.close(); } async function auditInteractiveTarget(context, target, steps) { const page = await context.newPage(); const consoleErrors = []; const pageErrors = []; page.on('console', (message) => { if (message.type() === 'error') consoleErrors.push(message.text()); }); page.on('pageerror', (error) => pageErrors.push(error.message)); await page.goto(target.url, { waitUntil: 'domcontentloaded', timeout: 30000 }); await page.waitForTimeout(900); for (const step of steps) { console.log(`audit ${target.name}:${step.label}`); if (step.action) { await step.action(page).catch((error) => { consoleErrors.push(`step ${step.label}: ${error.message}`); }); await page.waitForTimeout(900); } const screenshot = `${outputDir}/${target.name}-${step.label}.png`; await page.screenshot({ path: screenshot, fullPage: false, timeout: 5000 }).catch((error) => { consoleErrors.push(`screenshot ${step.label}: ${error.message}`); }); const audit = await page.evaluate(() => { const viewportWidth = document.documentElement.clientWidth; const viewportHeight = document.documentElement.clientHeight; const bodyWidth = Math.max(document.body.scrollWidth, document.documentElement.scrollWidth); const interactiveSelector = [ 'button', 'a[href]', 'input', 'select', 'textarea', '[role="button"]', '.primary-action', '.ghost-button', '.nav-item', '.rail-nav button' ].join(','); const textSelector = [ 'button', 'a', 'span', 'strong', 'small', 'p', 'h1', 'h2', 'h3', 'label' ].join(','); function visible(element) { const style = window.getComputedStyle(element); const rect = element.getBoundingClientRect(); return style.display !== 'none' && style.visibility !== 'hidden' && Number(style.opacity) !== 0 && rect.bottom >= 0 && rect.top <= viewportHeight && rect.right >= 0 && rect.left <= viewportWidth && rect.width > 1 && rect.height > 1; } function pathOf(element) { const parts = []; let current = element; while (current && current.nodeType === Node.ELEMENT_NODE && parts.length < 4) { const id = current.id ? `#${current.id}` : ''; const cls = current.className && typeof current.className === 'string' ? `.${current.className.trim().split(/\s+/).slice(0, 2).join('.')}` : ''; parts.unshift(`${current.tagName.toLowerCase()}${id}${cls}`); current = current.parentElement; } return parts.join(' > '); } const overflowElements = [...document.querySelectorAll(textSelector)] .filter((element) => visible(element)) .map((element) => { const rect = element.getBoundingClientRect(); const style = window.getComputedStyle(element); const horizontalOverflow = element.scrollWidth - element.clientWidth; const verticalOverflow = element.scrollHeight - element.clientHeight; const viewportOverflow = Math.max(0, rect.right - viewportWidth, -rect.left); const text = (element.textContent || '').trim().replace(/\s+/g, ' ').slice(0, 120); return { selector: pathOf(element), tag: element.tagName.toLowerCase(), text, className: typeof element.className === 'string' ? element.className : '', rect: { x: Math.round(rect.x), y: Math.round(rect.y), width: Math.round(rect.width), height: Math.round(rect.height) }, overflow: { horizontal: Math.round(horizontalOverflow), vertical: Math.round(verticalOverflow), viewport: Math.round(viewportOverflow) }, whiteSpace: style.whiteSpace, webkitLineClamp: style.webkitLineClamp, textOverflow: style.textOverflow, fontSize: style.fontSize }; }) .filter((item) => { const intentionallyClamped = item.webkitLineClamp && item.webkitLineClamp !== 'none'; const intentionallyEllipsized = item.textOverflow === 'ellipsis'; if ((intentionallyClamped || intentionallyEllipsized) && item.overflow.viewport <= 2) { return false; } return item.overflow.horizontal > 2 || item.overflow.vertical > 4 || item.overflow.viewport > 2; }) .slice(0, 80); const interactive = [...document.querySelectorAll(interactiveSelector)] .filter((element) => visible(element)) .slice(0, 220) .map((element) => { const rect = element.getBoundingClientRect(); return { element, selector: pathOf(element), text: (element.textContent || element.getAttribute('aria-label') || '').trim().replace(/\s+/g, ' ').slice(0, 80), rect: { left: rect.left, right: rect.right, top: rect.top, bottom: rect.bottom, width: rect.width, height: rect.height } }; }); const overlaps = []; for (let i = 0; i < interactive.length; i += 1) { for (let j = i + 1; j < interactive.length; j += 1) { if (overlaps.length >= 80) break; const a = interactive[i]; const b = interactive[j]; if (a.element.contains(b.element) || b.element.contains(a.element)) continue; const xOverlap = Math.min(a.rect.right, b.rect.right) - Math.max(a.rect.left, b.rect.left); const yOverlap = Math.min(a.rect.bottom, b.rect.bottom) - Math.max(a.rect.top, b.rect.top); if (xOverlap > 2 && yOverlap > 2) { overlaps.push({ a: { selector: a.selector, text: a.text }, b: { selector: b.selector, text: b.text }, overlap: { x: Math.round(xOverlap), y: Math.round(yOverlap) } }); } } if (overlaps.length >= 80) break; } return { title: document.title, url: window.location.href, viewport: { width: viewportWidth, height: viewportHeight }, bodyWidth, horizontalPageOverflow: Math.max(0, bodyWidth - viewportWidth), overflowElements, overlaps: overlaps.slice(0, 80) }; }); report.push({ target, step: step.label, screenshot, consoleErrors: [...consoleErrors], pageErrors: [...pageErrors], audit }); } await page.close(); } async function clickButton(page, name) { const button = page.getByRole('button', { name, exact: true }).first(); await button.waitFor({ state: 'visible', timeout: 5000 }); await button.click({ timeout: 5000 }); } 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 ensureLiveActionProject(token) { const projects = await api('/projects', { token }); const existing = projects.find((item) => item.output_mode === 'live_action_ai' && String(item.title || '').includes('视觉验收') ); if (existing) return existing; return api('/projects', { method: 'POST', token, body: { title: '真人小样视觉验收项目-超长标题用于检测按钮标签重叠与二号字体换行', input_mode: 'ai_original', output_mode: 'live_action_ai', genre: '都市逆袭爽剧-长标签-用于压力测试', style_code: 'photorealistic_short_drama', target_episode_count: 3, episode_duration: 60, quality_level: 'mvp' } }); } 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; }