357 lines
13 KiB
JavaScript
357 lines
13 KiB
JavaScript
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;
|
|
}
|