486 lines
20 KiB
JavaScript
486 lines
20 KiB
JavaScript
const { chromium } = require("playwright");
|
|
|
|
const BASE = "http://localhost:3001";
|
|
const TIMEOUT = 20000;
|
|
const RESULTS = { pass: 0, fail: 0, warn: 0, details: [] };
|
|
|
|
function log(category, status, message) {
|
|
const icon = status === "pass" ? "✅" : status === "fail" ? "❌" : "⚠️";
|
|
RESULTS[status === "pass" ? "pass" : status === "fail" ? "fail" : "warn"]++;
|
|
RESULTS.details.push({ category, status, message });
|
|
console.log(` ${icon} [${category}] ${message}`);
|
|
}
|
|
|
|
async function waitForPage(page, url) {
|
|
await page.goto(url, { waitUntil: "domcontentloaded", timeout: TIMEOUT });
|
|
await page.waitForTimeout(4000);
|
|
}
|
|
|
|
function createErrorCollector() {
|
|
const errors = [];
|
|
const listener = (msg) => {
|
|
if (msg.type() === "error") {
|
|
const text = msg.text();
|
|
if (!text.includes("hydration") && !text.includes("Fast Refresh") && !text.includes("React DevTools")) {
|
|
errors.push(text.substring(0, 120));
|
|
}
|
|
}
|
|
};
|
|
return { listener, getErrors: () => [...errors] };
|
|
}
|
|
|
|
(async () => {
|
|
const browser = await chromium.launch({ headless: true });
|
|
const desktop = { width: 1440, height: 900 };
|
|
const mobile = { width: 375, height: 812 };
|
|
|
|
// ========== 1. 首页测试 ==========
|
|
console.log("\n═══════════════════════════════════════");
|
|
console.log(" 1. 首页全面测试");
|
|
console.log("═══════════════════════════════════════");
|
|
|
|
{
|
|
const page = await browser.newPage({ viewport: desktop });
|
|
const errorCollector = createErrorCollector();
|
|
page.on("console", errorCollector.listener);
|
|
await waitForPage(page, BASE);
|
|
|
|
// 1a. 页面加载
|
|
const title = await page.title();
|
|
if (title && title.length > 0) {
|
|
log("首页", "pass", `页面标题: "${title}"`);
|
|
} else {
|
|
log("首页", "fail", "页面标题为空");
|
|
}
|
|
|
|
// 1b. Header 存在
|
|
const header = await page.$("header");
|
|
log("首页", header ? "pass" : "fail", "Header 元素存在性");
|
|
|
|
// 1c. Footer 存在
|
|
const footer = await page.$("footer");
|
|
log("首页", footer ? "pass" : "fail", "Footer 元素存在性");
|
|
|
|
// 1d. 布局对齐检测
|
|
const alignment = await page.evaluate(() => {
|
|
const header = document.querySelector("header .container-wide");
|
|
const footer = document.querySelector("footer .container-wide, footer .container-full");
|
|
const hRect = header?.getBoundingClientRect();
|
|
const fRect = footer?.getBoundingClientRect();
|
|
|
|
const allContainers = document.querySelectorAll(".container-wide, .container-full");
|
|
const positions = [];
|
|
allContainers.forEach((el) => {
|
|
const r = el.getBoundingClientRect();
|
|
if (r.height > 20) positions.push(Math.round(r.left));
|
|
});
|
|
|
|
return {
|
|
headerLeft: hRect ? Math.round(hRect.left) : null,
|
|
footerLeft: fRect ? Math.round(fRect.left) : null,
|
|
allLefts: positions,
|
|
uniqueLefts: [...new Set(positions)],
|
|
};
|
|
});
|
|
|
|
if (alignment.uniqueLefts.length === 1 && alignment.uniqueLefts[0] === alignment.headerLeft) {
|
|
log("首页-对齐", "pass", `所有 ${alignment.allLefts.length} 个容器 left=${alignment.headerLeft}px 对齐`);
|
|
} else {
|
|
log("首页-对齐", "fail", `容器左边距不一致: [${alignment.uniqueLefts.join(", ")}]px,期望 ${alignment.headerLeft}px`);
|
|
}
|
|
|
|
// 1e. Hero 区域
|
|
const heroH1 = await page.$("h1");
|
|
const heroText = heroH1 ? await heroH1.textContent() : "";
|
|
log("首页-Hero", heroH1 ? "pass" : "fail", `主标题: "${heroText?.substring(0, 40)}"`);
|
|
|
|
// 1f. 产品卡片
|
|
const productCards = await page.$$("[class*='product-card'], [class*='ProductCard'], a[href*='/products/']");
|
|
log("首页-产品", productCards.length > 0 ? "pass" : "warn", `产品卡片/链接数量: ${productCards.length}`);
|
|
|
|
// 1g. CTA 按钮
|
|
const ctaButtons = await page.$$("a[href='/contact'], a[href*='contact'], button:has-text('预约'), button:has-text('体验')");
|
|
log("首页-CTA", ctaButtons.length > 0 ? "pass" : "warn", `CTA 按钮数量: ${ctaButtons.length}`);
|
|
|
|
// 1h. 控制台错误
|
|
await page.waitForTimeout(2000);
|
|
if (errorCollector.getErrors().length === 0) {
|
|
log("首页-控制台", "pass", "无真实控制台错误");
|
|
} else {
|
|
log("首页-控制台", "warn", `${errorCollector.getErrors().length} 个控制台错误: ${errorCollector.getErrors()[0]?.substring(0, 60)}`);
|
|
}
|
|
|
|
await page.close();
|
|
}
|
|
|
|
// ========== 2. 产品详情页测试 ==========
|
|
console.log("\n═══════════════════════════════════════");
|
|
console.log(" 2. 产品详情页测试");
|
|
console.log("═══════════════════════════════════════");
|
|
|
|
const productPages = [
|
|
{ id: "novavis", name: "睿视NovaVis" },
|
|
{ id: "erp", name: "睿管ERP" },
|
|
{ id: "crm", name: "睿客CRM" },
|
|
{ id: "bi", name: "睿析BI" },
|
|
];
|
|
|
|
for (const p of productPages) {
|
|
const page = await browser.newPage({ viewport: desktop });
|
|
const errorCollector = createErrorCollector();
|
|
page.on("console", errorCollector.listener);
|
|
|
|
try {
|
|
await waitForPage(page, `${BASE}/products/${p.id}`);
|
|
|
|
const title = await page.title();
|
|
log(p.name, title ? "pass" : "fail", `页面标题: "${title?.substring(0, 40)}"`);
|
|
|
|
// 布局对齐
|
|
const alignment = await page.evaluate(() => {
|
|
const header = document.querySelector("header .container-wide");
|
|
const hRect = header?.getBoundingClientRect();
|
|
const headerLeft = hRect ? Math.round(hRect.left) : null;
|
|
|
|
const sections = document.querySelectorAll("section");
|
|
const misaligned = [];
|
|
sections.forEach((s, i) => {
|
|
const container = s.querySelector(".container-wide, .container-full");
|
|
if (container) {
|
|
const cRect = container.getBoundingClientRect();
|
|
const cLeft = Math.round(cRect.left);
|
|
if (cLeft !== headerLeft && cRect.height > 30) {
|
|
misaligned.push({ index: i, left: cLeft });
|
|
}
|
|
}
|
|
});
|
|
|
|
return { headerLeft, misaligned, sectionCount: sections.length };
|
|
});
|
|
|
|
if (alignment.misaligned.length === 0) {
|
|
log(`${p.name}-对齐`, "pass", `所有 ${alignment.sectionCount} 个 section 容器与 Header 对齐 (left=${alignment.headerLeft})`);
|
|
} else {
|
|
log(`${p.name}-对齐`, "fail", `${alignment.misaligned.length} 个 section 未对齐: ${JSON.stringify(alignment.misaligned)}`);
|
|
}
|
|
|
|
// 内容完整性
|
|
const content = await page.evaluate(() => document.body.innerText);
|
|
const hasContent = content.length > 100;
|
|
log(`${p.name}-内容`, hasContent ? "pass" : "fail", `页面内容长度: ${content.length} 字符`);
|
|
|
|
// CTA 检测
|
|
const hasCTA = content.includes("预约") || content.includes("体验") || content.includes("联系");
|
|
log(`${p.name}-CTA`, hasCTA ? "pass" : "warn", "CTA 文案检测");
|
|
|
|
// 控制台错误
|
|
await page.waitForTimeout(1000);
|
|
if (errorCollector.getErrors().length > 0) {
|
|
log(`${p.name}-控制台`, "warn", `${errorCollector.getErrors().length} 个错误`);
|
|
} else {
|
|
log(`${p.name}-控制台`, "pass", "无真实控制台错误");
|
|
}
|
|
|
|
} catch (e) {
|
|
log(p.name, "fail", `页面加载失败: ${e.message.substring(0, 60)}`);
|
|
}
|
|
await page.close();
|
|
}
|
|
|
|
// ========== 3. 产品列表页测试 ==========
|
|
console.log("\n═══════════════════════════════════════");
|
|
console.log(" 3. 产品列表页测试");
|
|
console.log("═══════════════════════════════════════");
|
|
|
|
{
|
|
const page = await browser.newPage({ viewport: desktop });
|
|
try {
|
|
await waitForPage(page, `${BASE}/products`);
|
|
|
|
const alignment = await page.evaluate(() => {
|
|
const header = document.querySelector("header .container-wide");
|
|
const hRect = header?.getBoundingClientRect();
|
|
const headerLeft = hRect ? Math.round(hRect.left) : null;
|
|
|
|
const containers = document.querySelectorAll(".container-wide, .container-full");
|
|
const positions = [];
|
|
containers.forEach((el) => {
|
|
const r = el.getBoundingClientRect();
|
|
if (r.height > 20) positions.push(Math.round(r.left));
|
|
});
|
|
|
|
return { headerLeft, uniqueLefts: [...new Set(positions)], count: positions.length };
|
|
});
|
|
|
|
if (alignment.uniqueLefts.length === 1 && alignment.uniqueLefts[0] === alignment.headerLeft) {
|
|
log("产品列表-对齐", "pass", `${alignment.count} 个容器对齐 (left=${alignment.headerLeft})`);
|
|
} else {
|
|
log("产品列表-对齐", "fail", `容器左边距不一致: [${alignment.uniqueLefts.join(", ")}]`);
|
|
}
|
|
|
|
const productLinks = await page.$$("a[href*='/products/']");
|
|
log("产品列表-卡片", productLinks.length > 0 ? "pass" : "fail", `产品链接数量: ${productLinks.length}`);
|
|
|
|
} catch (e) {
|
|
log("产品列表", "fail", `加载失败: ${e.message.substring(0, 60)}`);
|
|
}
|
|
await page.close();
|
|
}
|
|
|
|
// ========== 4. 暗色模式测试 ==========
|
|
console.log("\n═══════════════════════════════════════");
|
|
console.log(" 4. 暗色模式测试");
|
|
console.log("═══════════════════════════════════════");
|
|
|
|
{
|
|
const page = await browser.newPage({ viewport: desktop });
|
|
await waitForPage(page, BASE);
|
|
|
|
// 查找主题切换按钮
|
|
const themeToggle = await page.$("button[aria-label='切换主题'], [data-theme-toggle], button[aria-label*='主题'], button[aria-label*='theme']");
|
|
|
|
if (themeToggle) {
|
|
await themeToggle.click();
|
|
await page.waitForTimeout(800);
|
|
|
|
const darkOption = await page.$("button:has-text('深色')");
|
|
if (darkOption) {
|
|
await darkOption.click();
|
|
await page.waitForTimeout(1500);
|
|
} else {
|
|
log("暗色模式", "warn", "未找到深色选项按钮");
|
|
}
|
|
|
|
const darkMode = await page.evaluate(() => {
|
|
const html = document.documentElement;
|
|
const dataTheme = html.getAttribute("data-theme");
|
|
const classList = html.classList.contains("dark");
|
|
const bgColor = getComputedStyle(document.body).backgroundColor;
|
|
return { dataTheme, classList, bgColor };
|
|
});
|
|
|
|
log("暗色模式", darkMode.dataTheme === "dark" || darkMode.classList ? "pass" : "fail",
|
|
`data-theme="${darkMode.dataTheme}", dark class=${darkMode.classList}`);
|
|
|
|
// 暗色模式下 Logo 可见性
|
|
const logoVisible = await page.evaluate(() => {
|
|
const logo = document.querySelector("header img, header svg, header .logo");
|
|
if (!logo) return { found: false };
|
|
const rect = logo.getBoundingClientRect();
|
|
const style = getComputedStyle(logo);
|
|
return {
|
|
found: true,
|
|
visible: rect.width > 0 && rect.height > 0,
|
|
filter: style.filter,
|
|
width: Math.round(rect.width),
|
|
height: Math.round(rect.height),
|
|
};
|
|
});
|
|
|
|
if (logoVisible.found) {
|
|
log("暗色-Logo", logoVisible.visible ? "pass" : "fail",
|
|
`Logo 可见: ${logoVisible.visible}, filter: ${logoVisible.filter?.substring(0, 30)}`);
|
|
} else {
|
|
log("暗色-Logo", "warn", "未找到 Logo 元素");
|
|
}
|
|
|
|
// 暗色模式布局对齐
|
|
const darkAlignment = await page.evaluate(() => {
|
|
const header = document.querySelector("header .container-wide");
|
|
const hRect = header?.getBoundingClientRect();
|
|
const headerLeft = hRect ? Math.round(hRect.left) : null;
|
|
|
|
const containers = document.querySelectorAll(".container-wide, .container-full");
|
|
const positions = [];
|
|
containers.forEach((el) => {
|
|
const r = el.getBoundingClientRect();
|
|
if (r.height > 20) positions.push(Math.round(r.left));
|
|
});
|
|
|
|
return { headerLeft, uniqueLefts: [...new Set(positions)] };
|
|
});
|
|
|
|
if (darkAlignment.uniqueLefts.length === 1 && darkAlignment.uniqueLefts[0] === darkAlignment.headerLeft) {
|
|
log("暗色-对齐", "pass", "暗色模式布局对齐正常");
|
|
} else {
|
|
log("暗色-对齐", "fail", `暗色模式容器不对齐: [${darkAlignment.uniqueLefts.join(", ")}]`);
|
|
}
|
|
|
|
// 暗色模式硬编码白色背景检测
|
|
const whiteBgSections = await page.evaluate(() => {
|
|
const sections = document.querySelectorAll("section");
|
|
const whiteBg = [];
|
|
sections.forEach((s, i) => {
|
|
const bg = getComputedStyle(s).backgroundColor;
|
|
const classes = s.className || "";
|
|
if ((bg === "rgb(255, 255, 255)" || classes.includes("bg-white")) &&
|
|
document.documentElement.getAttribute("data-theme") === "dark") {
|
|
whiteBg.push(i);
|
|
}
|
|
});
|
|
return whiteBg;
|
|
});
|
|
|
|
if (whiteBgSections.length > 0) {
|
|
log("暗色-硬编码", "warn", `${whiteBgSections.length} 个 section 在暗色模式下仍使用白色背景: [${whiteBgSections.join(", ")}]`);
|
|
} else {
|
|
log("暗色-硬编码", "pass", "未检测到暗色模式下的白色硬编码背景");
|
|
}
|
|
|
|
} else {
|
|
log("暗色模式", "warn", "未找到主题切换按钮");
|
|
}
|
|
await page.close();
|
|
}
|
|
|
|
// ========== 5. 移动端响应式测试 ==========
|
|
console.log("\n═══════════════════════════════════════");
|
|
console.log(" 5. 移动端响应式测试");
|
|
console.log("═══════════════════════════════════════");
|
|
|
|
{
|
|
const page = await browser.newPage({ viewport: mobile });
|
|
await waitForPage(page, BASE);
|
|
|
|
// 移动端导航
|
|
const mobileNav = await page.evaluate(() => {
|
|
const hamburger = document.querySelector("button[aria-label*='菜单'], button[aria-label*='menu'], [class*='hamburger'], [class*='mobile-nav']");
|
|
const header = document.querySelector("header");
|
|
const headerRect = header?.getBoundingClientRect();
|
|
return {
|
|
hasHamburger: !!hamburger,
|
|
headerWidth: headerRect ? Math.round(headerRect.width) : null,
|
|
viewportWidth: window.innerWidth,
|
|
};
|
|
});
|
|
|
|
log("移动端-导航", mobileNav.hasHamburger ? "pass" : "warn",
|
|
`汉堡菜单: ${mobileNav.hasHamburger ? "有" : "无"}, header宽: ${mobileNav.headerWidth}px`);
|
|
|
|
// 移动端布局不溢出
|
|
const overflow = await page.evaluate(() => {
|
|
const bodyWidth = document.body.scrollWidth;
|
|
const vpWidth = window.innerWidth;
|
|
return { bodyWidth, vpWidth, overflow: bodyWidth > vpWidth + 2 };
|
|
});
|
|
|
|
log("移动端-溢出", !overflow.overflow ? "pass" : "fail",
|
|
`body=${overflow.bodyWidth}px, viewport=${overflow.vpWidth}px, 溢出=${overflow.overflow}`);
|
|
|
|
// 移动端产品详情页
|
|
try {
|
|
await waitForPage(page, `${BASE}/products/novavis`);
|
|
const mobileContent = await page.evaluate(() => document.body.innerText.length);
|
|
log("移动端-产品页", mobileContent > 100 ? "pass" : "fail", `NovaVis 内容: ${mobileContent} 字符`);
|
|
} catch (e) {
|
|
log("移动端-产品页", "fail", `加载失败: ${e.message.substring(0, 50)}`);
|
|
}
|
|
|
|
await page.close();
|
|
}
|
|
|
|
// ========== 6. 导航与路由测试 ==========
|
|
console.log("\n═══════════════════════════════════════");
|
|
console.log(" 6. 导航与路由测试");
|
|
console.log("═══════════════════════════════════════");
|
|
|
|
{
|
|
const page = await browser.newPage({ viewport: desktop });
|
|
await waitForPage(page, BASE);
|
|
|
|
const navLinks = await page.evaluate(() => {
|
|
const links = document.querySelectorAll("header a[href], nav a[href]");
|
|
return Array.from(links).map((a) => ({
|
|
href: a.getAttribute("href"),
|
|
text: a.textContent?.trim().substring(0, 20),
|
|
})).filter(l => l.href && !l.href.startsWith("http") && !l.href.startsWith("#"));
|
|
});
|
|
|
|
const uniqueLinks = [...new Map(navLinks.map(l => [l.href, l])).values()];
|
|
log("导航-链接", "pass", `发现 ${uniqueLinks.length} 个内部导航链接`);
|
|
|
|
for (const link of uniqueLinks.slice(0, 8)) {
|
|
try {
|
|
const resp = await page.goto(`${BASE}${link.href}`, { timeout: 10000, waitUntil: "domcontentloaded" });
|
|
const status = resp?.status();
|
|
log("导航-路由", status === 200 ? "pass" : "warn", `${link.href} → ${status}`);
|
|
} catch (e) {
|
|
log("导航-路由", "fail", `${link.href} → 加载失败`);
|
|
}
|
|
}
|
|
|
|
await page.close();
|
|
}
|
|
|
|
// ========== 7. 解决方案/服务页面测试 ==========
|
|
console.log("\n═══════════════════════════════════════");
|
|
console.log(" 7. 其他页面测试");
|
|
console.log("═══════════════════════════════════════");
|
|
|
|
{
|
|
const otherPages = ["/solutions", "/services", "/about", "/contact"];
|
|
const page = await browser.newPage({ viewport: desktop });
|
|
|
|
for (const path of otherPages) {
|
|
try {
|
|
const resp = await page.goto(`${BASE}${path}`, { timeout: 10000, waitUntil: "domcontentloaded" });
|
|
await page.waitForTimeout(2000);
|
|
const status = resp?.status();
|
|
|
|
if (status === 200) {
|
|
const alignment = await page.evaluate(() => {
|
|
const header = document.querySelector("header .container-wide");
|
|
const hRect = header?.getBoundingClientRect();
|
|
const headerLeft = hRect ? Math.round(hRect.left) : null;
|
|
|
|
const containers = document.querySelectorAll(".container-wide, .container-full");
|
|
const positions = [];
|
|
containers.forEach((el) => {
|
|
const r = el.getBoundingClientRect();
|
|
if (r.height > 20) positions.push(Math.round(r.left));
|
|
});
|
|
|
|
return { headerLeft, uniqueLefts: [...new Set(positions)], allAligned: [...new Set(positions)].length === 1 };
|
|
});
|
|
|
|
log(`${path}-对齐`, alignment.allAligned ? "pass" : "fail",
|
|
`left=${alignment.headerLeft}, 对齐=${alignment.allAligned}`);
|
|
log(`${path}-加载`, "pass", `HTTP ${status}`);
|
|
} else if (status === 404) {
|
|
log(`${path}`, "warn", `HTTP 404 - 页面不存在`);
|
|
} else {
|
|
log(`${path}`, "warn", `HTTP ${status}`);
|
|
}
|
|
} catch (e) {
|
|
log(`${path}`, "warn", `加载失败: ${e.message.substring(0, 40)}`);
|
|
}
|
|
}
|
|
await page.close();
|
|
}
|
|
|
|
// ========== 汇总 ==========
|
|
console.log("\n═══════════════════════════════════════");
|
|
console.log(" 📊 测试报告汇总");
|
|
console.log("═══════════════════════════════════════");
|
|
console.log(` ✅ 通过: ${RESULTS.pass}`);
|
|
console.log(` ❌ 失败: ${RESULTS.fail}`);
|
|
console.log(` ⚠️ 警告: ${RESULTS.warn}`);
|
|
console.log(` 📋 总计: ${RESULTS.pass + RESULTS.fail + RESULTS.warn}`);
|
|
|
|
if (RESULTS.fail > 0) {
|
|
console.log("\n 🔴 失败项:");
|
|
RESULTS.details.filter(d => d.status === "fail").forEach(d => {
|
|
console.log(` ❌ [${d.category}] ${d.message}`);
|
|
});
|
|
}
|
|
|
|
if (RESULTS.warn > 0) {
|
|
console.log("\n 🟡 警告项:");
|
|
RESULTS.details.filter(d => d.status === "warn").forEach(d => {
|
|
console.log(` ⚠️ [${d.category}] ${d.message}`);
|
|
});
|
|
}
|
|
|
|
await browser.close();
|
|
})();
|