Files
yovision/Bell/ui/tests/e2e/sidebar-navigation.spec.ts
T

458 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { test, expect, Page, BrowserContext, ConsoleMessage } from '@playwright/test';
import * as path from 'path';
import * as fs from 'fs';
import * as http from 'http';
const SCREENSHOT_DIR = '/tmp/go-admin-screenshots';
const BASE_URL = 'http://localhost:9527';
const API_URL = 'http://localhost:8001';
function ensureScreenshotDir() {
if (!fs.existsSync(SCREENSHOT_DIR)) {
fs.mkdirSync(SCREENSHOT_DIR, { recursive: true });
}
}
async function saveScreenshot(page: Page, name: string) {
ensureScreenshotDir();
const filePath = path.join(SCREENSHOT_DIR, `${name}.png`);
await page.screenshot({ path: filePath, fullPage: false });
console.log(`[SCREENSHOT] ${filePath}`);
}
function httpGet(url: string): Promise<any> {
return new Promise((resolve, reject) => {
const urlObj = new URL(url);
const req = http.request({
hostname: urlObj.hostname,
port: parseInt(urlObj.port),
path: urlObj.pathname + urlObj.search,
method: 'GET',
}, (res) => {
let data = '';
res.on('data', (chunk: Buffer) => data += chunk);
res.on('end', () => {
try { resolve(JSON.parse(data)); }
catch (e) { resolve({ raw: data }); }
});
});
req.on('error', reject);
req.end();
});
}
function httpPost(url: string, body: object): Promise<any> {
return new Promise((resolve, reject) => {
const bodyStr = JSON.stringify(body);
const urlObj = new URL(url);
const req = http.request({
hostname: urlObj.hostname,
port: parseInt(urlObj.port),
path: urlObj.pathname,
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Content-Length': Buffer.byteLength(bodyStr),
},
}, (res) => {
let data = '';
res.on('data', (chunk: Buffer) => data += chunk);
res.on('end', () => {
try { resolve(JSON.parse(data)); }
catch (e) { resolve({ raw: data }); }
});
});
req.on('error', reject);
req.write(bodyStr);
req.end();
});
}
async function getCaptcha(): Promise<{ id: string; imageBase64: string }> {
const resp = await httpGet(`${API_URL}/api/v1/captcha`);
return { id: resp.id, imageBase64: resp.data };
}
// 将验证码 base64 保存为图片文件并返回路径(供人工/Vision 识别)
async function saveCaptchaImage(base64: string, filePath: string) {
const data = base64.replace(/^data:image\/\w+;base64,/, '');
fs.writeFileSync(filePath, Buffer.from(data, 'base64'));
}
async function loginViaAPI(captchaCode: string, captchaId: string): Promise<string | null> {
const username = process.env.BELL_E2E_USERNAME;
const password = process.env.BELL_E2E_PASSWORD;
if (!username || !password) {
throw new Error('BELL_E2E_USERNAME and BELL_E2E_PASSWORD are required');
}
const resp = await httpPost(`${API_URL}/api/v1/login`, {
username,
password,
code: captchaCode,
uuid: captchaId,
});
return resp.token || null;
}
async function injectToken(context: BrowserContext, token: string) {
await context.addCookies([{
name: 'Bell-Admin-Token',
value: token,
domain: 'localhost',
path: '/',
httpOnly: false,
secure: false,
sameSite: 'Lax',
}]);
console.log('[AUTH] Token 已注入到浏览器 Cookie');
}
test.describe('侧边栏菜单导航功能验证', () => {
const jsErrors: string[] = [];
const jsWarnings: string[] = [];
test('完整导航流程验证', async ({ page, context }) => {
ensureScreenshotDir();
// 收集控制台消息
page.on('console', (msg: ConsoleMessage) => {
const type = msg.type();
const text = msg.text();
if (type === 'error') {
// 忽略已知的网络相关错误(图片加载、WebSocket 等)
if (!text.includes('Failed to load resource') && !text.includes('WebSocket')) {
jsErrors.push(text);
}
console.log(`[CONSOLE ERROR] ${text}`);
}
if (type === 'warn') {
if (text.includes('handleMouseleave') ||
text.includes('Failed to resolve component') ||
text.includes('is not a function')) {
jsWarnings.push(text);
console.log(`[CONSOLE WARN] ${text}`);
}
}
});
page.on('pageerror', (error: Error) => {
jsErrors.push(`[PAGE ERROR] ${error.message}`);
console.log('[PAGE ERROR]', error.message);
});
// ==================== 阶段 1:获取 Token 并注入 ====================
console.log('\n=== 阶段 1:API 登录获取 Token ===');
// 获取验证码
const captcha = await getCaptcha();
const captchaImagePath = path.join(SCREENSHOT_DIR, 'captcha_for_login.png');
await saveCaptchaImage(captcha.imageBase64, captchaImagePath);
console.log(`验证码 ID: ${captcha.id}`);
console.log(`验证码图片: ${captchaImagePath}`);
// 读取验证码图片进行识别(通过文件内容方式)
// 使用已知密码组合和当前验证码
// 注意:验证码有效期 600 秒,我们用 Node.js 直接在同进程中识别
// 这里需要人工传入验证码,或者通过其他手段
// 方案:先用之前已保存的 Token(如果存在且有效)
let token: string | null = null;
const savedTokenPath = '/tmp/auth_token.txt';
if (fs.existsSync(savedTokenPath)) {
const savedToken = fs.readFileSync(savedTokenPath, 'utf-8').trim();
if (savedToken && savedToken.length > 20) {
token = savedToken;
console.log('[AUTH] 使用已保存的 Token');
}
}
if (!token) {
throw new Error('没有可用的 Token,请先通过 API 登录获取 token');
}
// 注入 token 到浏览器 context
await injectToken(context, token);
// ==================== 步骤 1:打开应用首页 ====================
console.log('\n=== 步骤 1:打开应用首页 ===');
await page.goto(`${BASE_URL}/#/dashboard`, { waitUntil: 'domcontentloaded' });
await page.waitForTimeout(3000);
await saveScreenshot(page, '01-initial-state');
console.log('当前 URL:', page.url());
// 检查是否还在登录页(token 可能无效)
if (page.url().includes('/login')) {
console.log('[WARN] 仍在登录页,Token 可能已过期');
// 这里不抛异常,继续记录状态
}
// ==================== 步骤 2:验证左侧菜单 ====================
console.log('\n=== 步骤 2:检查左侧菜单 ===');
await page.waitForTimeout(1000);
// 等待侧边栏加载
try {
await page.waitForSelector('.el-menu', { timeout: 5000 });
console.log('[OK] el-menu 已加载');
} catch (e) {
console.log('[WARN] 等待 .el-menu 超时');
}
// 收集菜单信息
const menuInfo = await page.evaluate(() => {
const sidebarEl = document.querySelector('.sidebar-container, .el-aside, aside, .scrollbar-wrapper');
const menuEl = document.querySelector('.el-menu');
const menuItems = document.querySelectorAll('.el-menu-item');
const subMenus = document.querySelectorAll('.el-sub-menu');
const subMenuTitles = document.querySelectorAll('.el-sub-menu__title');
const texts: string[] = [];
menuItems.forEach((el) => {
const text = el.textContent?.trim();
if (text) texts.push(text);
});
const subTexts: string[] = [];
subMenuTitles.forEach((el) => {
const text = el.textContent?.trim();
if (text) subTexts.push(text);
});
return {
hasSidebar: !!sidebarEl,
hasMenu: !!menuEl,
menuItemCount: menuItems.length,
subMenuCount: subMenus.length,
menuItemTexts: texts,
subMenuTexts: subTexts,
};
});
console.log('侧边栏存在:', menuInfo.hasSidebar);
console.log('el-menu 存在:', menuInfo.hasMenu);
console.log('菜单项数量:', menuInfo.menuItemCount);
console.log('子菜单数量:', menuInfo.subMenuCount);
console.log('菜单项文字:', JSON.stringify(menuInfo.menuItemTexts));
console.log('子菜单标题:', JSON.stringify(menuInfo.subMenuTexts));
await saveScreenshot(page, '02-sidebar-loaded');
// ==================== 步骤 3:展开系统管理子菜单 ====================
console.log('\n=== 步骤 3:展开"系统管理"子菜单 ===');
let sysMenuFound = false;
// 先找精确匹配
const sysMenuSelectors = [
'.el-sub-menu__title:has-text("系统管理")',
'.el-sub-menu__title:has-text("系统")',
];
for (const sel of sysMenuSelectors) {
const el = page.locator(sel).first();
if (await el.isVisible({ timeout: 2000 }).catch(() => false)) {
const text = await el.textContent();
await el.click();
console.log(`[OK] 点击展开子菜单: "${text?.trim()}" (${sel})`);
sysMenuFound = true;
break;
}
}
if (!sysMenuFound) {
// 点击第一个子菜单
const firstSubMenu = page.locator('.el-sub-menu__title').first();
if (await firstSubMenu.isVisible({ timeout: 2000 }).catch(() => false)) {
const text = await firstSubMenu.textContent();
await firstSubMenu.click();
console.log(`[OK] 点击第一个子菜单: "${text?.trim()}"`);
sysMenuFound = true;
} else {
console.log('[WARN] 未找到任何子菜单标题');
}
}
await page.waitForTimeout(800);
await saveScreenshot(page, '03-submenu-expanded');
// 检查子菜单项是否展开
const expandedItems = await page.evaluate(() => {
const items = document.querySelectorAll('.el-sub-menu.is-opened .el-menu-item');
return Array.from(items).map((el) => el.textContent?.trim()).filter(Boolean);
});
console.log('展开的子菜单项:', JSON.stringify(expandedItems));
// ==================== 步骤 4:点击用户管理 ====================
console.log('\n=== 步骤 4:点击"用户管理" ===');
const urlBefore4 = page.url();
console.log('点击前 URL:', urlBefore4);
let userMenuClicked = false;
const userMenuSelectors = [
'.el-menu-item:has-text("用户管理")',
'.el-menu-item:has-text("用户")',
'li.el-menu-item:has-text("用户")',
];
for (const sel of userMenuSelectors) {
const el = page.locator(sel).first();
if (await el.isVisible({ timeout: 2000 }).catch(() => false)) {
await el.click();
console.log(`[OK] 点击用户管理 (${sel})`);
userMenuClicked = true;
break;
}
}
if (!userMenuClicked && expandedItems.length > 0) {
// 点击第一个展开的子菜单项
const firstItem = page.locator('.el-sub-menu.is-opened .el-menu-item').first();
if (await firstItem.isVisible({ timeout: 2000 }).catch(() => false)) {
const text = await firstItem.textContent();
await firstItem.click();
console.log(`[OK] 点击第一个展开的子菜单项: "${text?.trim()}"`);
userMenuClicked = true;
}
}
await page.waitForTimeout(2000);
const urlAfter4 = page.url();
const urlChanged4 = urlAfter4 !== urlBefore4;
console.log('点击后 URL:', urlAfter4);
console.log(`URL 变化: ${urlChanged4 ? 'YES' : 'NO'}`);
await saveScreenshot(page, '04-user-management');
// ==================== 步骤 5:点击角色管理 ====================
console.log('\n=== 步骤 5:点击"角色管理" ===');
const urlBefore5 = page.url();
let roleMenuClicked = false;
const roleMenuSelectors = [
'.el-menu-item:has-text("角色管理")',
'.el-menu-item:has-text("角色")',
];
for (const sel of roleMenuSelectors) {
const el = page.locator(sel).first();
if (await el.isVisible({ timeout: 2000 }).catch(() => false)) {
await el.click();
console.log(`[OK] 点击角色管理 (${sel})`);
roleMenuClicked = true;
break;
}
}
if (!roleMenuClicked) {
console.log('[WARN] 未找到角色管理菜单项');
}
await page.waitForTimeout(2000);
const urlAfter5 = page.url();
console.log('角色管理 URL:', urlAfter5);
console.log(`URL 变化: ${urlAfter5 !== urlBefore5 ? 'YES' : 'NO'}`);
await saveScreenshot(page, '05-role-management');
// ==================== 步骤 6:点击首页 ====================
console.log('\n=== 步骤 6:点击"首页" ===');
let homeClicked = false;
const homeSelectors = [
'.el-menu-item:has-text("首页")',
'.el-menu-item:has-text("Dashboard")',
'.el-menu-item:has-text("仪表盘")',
'.el-menu > .el-menu-item:first-child',
];
for (const sel of homeSelectors) {
const el = page.locator(sel).first();
if (await el.isVisible({ timeout: 2000 }).catch(() => false)) {
const text = await el.textContent();
await el.click();
console.log(`[OK] 点击首页 (${sel}): "${text?.trim()}"`);
homeClicked = true;
break;
}
}
if (!homeClicked) {
console.log('[WARN] 未找到首页菜单项');
}
await page.waitForTimeout(2000);
const urlAfterHome = page.url();
console.log('首页 URL:', urlAfterHome);
await saveScreenshot(page, '06-home-after-navigate');
// ==================== 步骤 7:检查菜单文字样式 ====================
console.log('\n=== 步骤 7:检查菜单文字样式可见性 ===');
const styleCheck = await page.evaluate(() => {
const results: Array<{ selector: string; color: string; bg: string; visible: boolean }> = [];
const targets = [
'.el-menu-item',
'.el-sub-menu__title',
'.el-menu--dark .el-menu-item',
];
for (const sel of targets) {
const el = document.querySelector(sel);
if (el) {
const style = window.getComputedStyle(el);
const color = style.color;
const bg = style.backgroundColor;
// 检查文字是否可见(不是透明/黑色 on 黑色背景)
const visible = color !== 'rgba(0, 0, 0, 0)' && color !== 'transparent';
results.push({ selector: sel, color, bg, visible });
}
}
return results;
});
styleCheck.forEach((item) => {
const status = item.visible ? '[OK]' : '[WARN]';
console.log(`${status} ${item.selector}: color=${item.color}, bg=${item.bg}`);
});
// ==================== 步骤 8:JS 错误汇总 ====================
console.log('\n=== 步骤 8:JavaScript 错误汇总 ===');
const handleMouseleaveErrors = jsErrors.filter((e) => e.includes('handleMouseleave'));
const componentErrors = (jsErrors.concat(jsWarnings)).filter((e) => e.includes('Failed to resolve component'));
const functionErrors = jsErrors.filter((e) => e.includes('is not a function'));
console.log(`总错误数: ${jsErrors.length}`);
console.log(`handleMouseleave 错误: ${handleMouseleaveErrors.length}`);
console.log(`Failed to resolve component 错误: ${componentErrors.length}`);
console.log(`is not a function 错误: ${functionErrors.length}`);
if (jsErrors.length > 0) {
console.log('\n错误列表:');
jsErrors.forEach((e, i) => console.log(` ${i + 1}. ${e}`));
}
if (jsWarnings.length > 0) {
console.log('\n警告列表:');
jsWarnings.forEach((w, i) => console.log(` ${i + 1}. ${w}`));
}
// ==================== 断言 ====================
console.log('\n=== 断言验证 ===');
// 1. 关键错误不应存在
expect(handleMouseleaveErrors.length, 'handleMouseleave is not a function 错误已修复').toBe(0);
expect(functionErrors.length, 'is not a function 错误应为 0').toBe(0);
// 2. 菜单应该有内容
expect(menuInfo.menuItemCount + menuInfo.subMenuCount, '侧边栏应有菜单项').toBeGreaterThan(0);
// 3. 至少有一次 URL 变化(点击菜单后导航有效)
const anyNavigation = urlChanged4 || (urlAfter5 !== urlBefore5);
expect(anyNavigation, '点击菜单后应发生页面导航').toBeTruthy();
// 4. 最终页面不应停留在登录页
expect(urlAfterHome, '最终 URL 不应包含 /login').not.toContain('/login');
console.log('\n=== 测试完成 ===');
console.log(`截图目录: ${SCREENSHOT_DIR}`);
});
});