feat: 重建 Bell GoAdmin 产品骨架 (#62)

This commit is contained in:
QiuSW
2026-08-27 18:20:40 +08:00
parent b37c350096
commit f5a3dd796b
715 changed files with 75278 additions and 0 deletions
@@ -0,0 +1,499 @@
/**
* go-admin-ui Navigation and Content Rendering Validation
*
* Verifies the following fixes:
* 1. permission.js loadView: require.context now extracts .default (fixes component loading)
* 2. main.js: registers both `this.getDicts` and `this.$getDicts` (fixes 107 usage sites)
* 3. main.js: registers both `Pagination` and `AppPagination` component names
*
* Auth: JWT token pre-obtained via captcha-solving, injected via context.addCookies()
* before page navigation, so Vue router guard reads the cookie correctly.
*/
import { test, expect, Page, BrowserContext } from '@playwright/test';
const BASE_URL = 'http://localhost:9527';
// Set via an ephemeral test account and environment variable when running the test.
const ADMIN_TOKEN = process.env.BELL_E2E_TOKEN || '';
// Setup authenticated browser context by adding cookie before first navigation
async function setupAuth(context: BrowserContext): Promise<boolean> {
if (!ADMIN_TOKEN) {
console.log('No ADMIN_TOKEN provided');
return false;
}
await context.addCookies([{
name: 'Bell-Admin-Token',
value: ADMIN_TOKEN,
domain: 'localhost',
path: '/',
httpOnly: false,
secure: false,
}]);
return true;
}
test.describe('go-admin-ui Navigation and Content Rendering Validation', () => {
test('01 - Initial page load redirects to login', async ({ page }) => {
const errors: string[] = [];
page.on('console', msg => {
if (msg.type() === 'error') errors.push(msg.text());
});
await page.goto(BASE_URL, { waitUntil: 'networkidle' });
await page.waitForTimeout(1000);
const url = page.url();
const title = await page.title();
const bodyLen = (await page.locator('body').innerHTML()).length;
console.log('URL:', url);
console.log('Title:', title);
console.log('Body length:', bodyLen, 'chars');
expect(url).toContain('login');
expect(title).toContain('Bell');
expect(bodyLen).toBeGreaterThan(10000);
const jsErrors = errors.filter(e =>
!e.includes('net::ERR') &&
!e.includes('Failed to load resource') &&
!e.includes('ERR_CERT')
);
console.log('JS errors on login page:', jsErrors.length);
jsErrors.forEach((e, i) => console.log(` ${i + 1}. ${e}`));
await page.screenshot({ path: 'test-results/01-initial-load.png', fullPage: true });
console.log('Screenshot: test-results/01-initial-load.png');
});
test('02 - Login page form elements and captcha rendering', async ({ page }) => {
await page.goto(BASE_URL, { waitUntil: 'networkidle' });
await page.waitForURL(/login/, { timeout: 10000 });
// Note: both username and captcha inputs have name="username" (bug in source)
const usernameInput = page.locator('input[placeholder="用户名"]');
const passwordInput = page.locator('input[name="password"]');
const codeInput = page.locator('input[placeholder="验证码"]');
const loginBtn = page.locator('button').filter({ hasText: /登/ }).first();
const captchaImg = page.locator('.login-code img');
await expect(usernameInput).toBeVisible({ timeout: 5000 });
await expect(passwordInput).toBeVisible({ timeout: 5000 });
await expect(codeInput).toBeVisible({ timeout: 5000 });
await expect(loginBtn).toBeVisible({ timeout: 5000 });
const captchaCount = await captchaImg.count();
console.log('Captcha image count:', captchaCount);
if (captchaCount > 0) {
const src = await captchaImg.getAttribute('src');
const isBase64 = src?.startsWith('data:image/');
console.log('Captcha rendered as base64:', isBase64);
expect(isBase64).toBeTruthy();
}
console.log('PASS: All login form elements present and functional');
await page.screenshot({ path: 'test-results/02-login-form.png', fullPage: true });
});
test('03 - Dashboard layout (with auth token)', async ({ page, context }) => {
const errors: string[] = [];
page.on('console', msg => {
if (msg.type() === 'error') errors.push(msg.text());
});
const authOk = await setupAuth(context);
if (!authOk) {
console.log('SKIP: No ADMIN_TOKEN provided');
console.log('Run with: ADMIN_TOKEN=<token> npx playwright test');
return;
}
// Navigate directly to dashboard (cookie was set before this)
await page.goto(`${BASE_URL}/#/dashboard`, { waitUntil: 'networkidle' });
await page.waitForTimeout(3000);
const url = page.url();
console.log('Dashboard URL:', url);
const isDashboard = url.includes('dashboard') || (!url.includes('login'));
console.log('On dashboard (not login):', isDashboard);
// Check layout elements
const hasSidebar = await page.locator('.sidebar-container').count() > 0;
const hasNavbar = await page.locator('.navbar').count() > 0;
const hasAppMain = await page.locator('.app-main').count() > 0;
const hasTagsView = await page.locator('.tags-view-container').count() > 0;
console.log('=== Dashboard Layout ===');
console.log('Sidebar (.sidebar-container):', hasSidebar);
console.log('Navbar (.navbar):', hasNavbar);
console.log('App main (.app-main):', hasAppMain);
console.log('Tags view:', hasTagsView);
await page.screenshot({ path: 'test-results/03-dashboard.png', fullPage: true });
console.log('Screenshot: test-results/03-dashboard.png');
expect(isDashboard).toBeTruthy();
expect(hasSidebar).toBeTruthy();
const jsErrors = errors.filter(e =>
!e.includes('net::ERR') && !e.includes('Failed to load resource') && !e.includes('ERR_CERT')
);
if (jsErrors.length > 0) {
console.log('JS errors on dashboard:', jsErrors);
}
});
test('04 - System management menu expansion', async ({ page, context }) => {
const errors: string[] = [];
page.on('console', msg => {
if (msg.type() === 'error') errors.push(msg.text());
});
const authOk = await setupAuth(context);
if (!authOk) {
console.log('SKIP: No ADMIN_TOKEN provided');
return;
}
await page.goto(`${BASE_URL}/#/dashboard`, { waitUntil: 'networkidle' });
await page.waitForTimeout(2000);
// List all menu items
const menuItems = await page.locator('.el-menu-item, .el-sub-menu__title').allTextContents();
console.log('Menu items found:', menuItems.length);
console.log('Menu items:', menuItems.slice(0, 15).map(t => t.trim()).filter(t => t));
// Find and click 系统管理
const sysMenu = page.locator('.el-sub-menu__title').filter({ hasText: '系统管理' }).first();
const sysMenuCount = await sysMenu.count();
console.log('系统管理 sub-menu found:', sysMenuCount > 0);
if (sysMenuCount > 0) {
await sysMenu.click();
await page.waitForTimeout(1000);
const subItems = await page.locator('.el-menu-item').allTextContents();
console.log('Sub-menu items after expansion:', subItems.slice(0, 10).map(t => t.trim()).filter(t => t));
}
await page.screenshot({ path: 'test-results/04-system-menu-expanded.png', fullPage: true });
console.log('Screenshot: test-results/04-system-menu-expanded.png');
const jsErrors = errors.filter(e =>
!e.includes('net::ERR') && !e.includes('Failed to load resource') && !e.includes('ERR_CERT')
);
if (jsErrors.length > 0) {
console.log('JS errors:', jsErrors);
}
});
test('05 - User management page: content and fix verification', async ({ page, context }) => {
const errors: string[] = [];
page.on('console', msg => {
if (msg.type() === 'error') errors.push(msg.text());
});
const authOk = await setupAuth(context);
if (!authOk) {
console.log('SKIP: No ADMIN_TOKEN provided');
return;
}
await page.goto(`${BASE_URL}/#/dashboard`, { waitUntil: 'networkidle' });
await page.waitForTimeout(2000);
// Navigate through sidebar menu
const sysMenu = page.locator('.el-sub-menu__title').filter({ hasText: '系统管理' }).first();
if (await sysMenu.count() > 0) {
await sysMenu.click();
await page.waitForTimeout(800);
}
const userMenu = page.locator('.el-menu-item').filter({ hasText: '用户管理' }).first();
const userMenuCount = await userMenu.count();
console.log('用户管理 menu item found:', userMenuCount > 0);
if (userMenuCount > 0) {
await userMenu.click();
} else {
await page.goto(`${BASE_URL}/#/admin/sys-user`, { waitUntil: 'networkidle' });
}
// Wait 3 seconds as specified in the task
await page.waitForTimeout(3000);
const url = page.url();
const hasCorrectUrl = url.includes('sys-user');
const elCardCount = await page.locator('.el-card').count();
const elTableCount = await page.locator('.el-table').count();
const elTableRowCount = await page.locator('.el-table__body tr').count();
const loadingVisible = await page.locator('.el-loading-mask:visible').count() > 0;
// Check for red error overlays
const errorMsgCount = await page.locator('.el-message--error:visible').count();
console.log('=== User Management Page ===');
console.log('URL:', url);
console.log('URL contains sys-user:', hasCorrectUrl);
console.log('el-card count:', elCardCount);
console.log('el-table count:', elTableCount);
console.log('el-table rows:', elTableRowCount);
console.log('Loading spinner visible:', loadingVisible);
console.log('Error messages visible:', errorMsgCount);
await page.screenshot({ path: 'test-results/05-user-management.png', fullPage: true });
console.log('Screenshot: test-results/05-user-management.png');
// Fix verification - check for specific error patterns
const criticalErrors = errors.filter(e =>
e.includes('Cannot find module') ||
e.includes('getDicts is not a function') ||
e.includes('this.getDicts') ||
e.includes('is not a function') ||
e.includes('Cannot read properties of undefined')
);
const allJsErrors = errors.filter(e =>
!e.includes('net::ERR') && !e.includes('Failed to load resource') && !e.includes('ERR_CERT')
);
console.log('\n=== Fix Verification - User Management ===');
const fix1Pass = !criticalErrors.some(e => e.includes('Cannot find module'));
const fix2Pass = !criticalErrors.some(e => e.includes('getDicts'));
const fix3Pass = elTableCount > 0 || elCardCount > 0; // component rendered
console.log('Fix 1 (loadView .default - no "Cannot find module"):', fix1Pass ? 'PASS' : 'FAIL');
console.log('Fix 2 (getDicts registration - no getDicts errors):', fix2Pass ? 'PASS' : 'FAIL');
console.log('Fix 3 (Pagination/AppPagination - page renders):', fix3Pass ? 'PASS' : 'FAIL');
console.log('el-card present:', elCardCount > 0 ? 'PASS' : 'FAIL');
console.log('el-table present:', elTableCount > 0 ? 'PASS' : 'FAIL');
console.log('Data loaded (rows > 0):', elTableRowCount > 0 ? 'YES' : 'NO (may be empty or still loading)');
if (criticalErrors.length > 0) {
console.log('\nCRITICAL JS ERRORS:');
criticalErrors.forEach((e, i) => console.log(` ${i + 1}. ${e}`));
}
if (allJsErrors.length > 0) {
console.log('\nAll JS errors:');
allJsErrors.forEach((e, i) => console.log(` ${i + 1}. ${e}`));
} else {
console.log('\nNo JS errors on user management page');
}
// Assertions
expect(hasCorrectUrl || url.includes('admin')).toBeTruthy();
});
test('06 - Role management page: content and fix verification', async ({ page, context }) => {
const errors: string[] = [];
page.on('console', msg => {
if (msg.type() === 'error') errors.push(msg.text());
});
const authOk = await setupAuth(context);
if (!authOk) {
console.log('SKIP: No ADMIN_TOKEN provided');
return;
}
await page.goto(`${BASE_URL}/#/dashboard`, { waitUntil: 'networkidle' });
await page.waitForTimeout(2000);
// Navigate to role management
const sysMenu = page.locator('.el-sub-menu__title').filter({ hasText: '系统管理' }).first();
if (await sysMenu.count() > 0) {
await sysMenu.click();
await page.waitForTimeout(800);
}
const roleMenu = page.locator('.el-menu-item').filter({ hasText: '角色管理' }).first();
const roleMenuCount = await roleMenu.count();
console.log('角色管理 menu item found:', roleMenuCount > 0);
if (roleMenuCount > 0) {
await roleMenu.click();
} else {
await page.goto(`${BASE_URL}/#/admin/sys-role`, { waitUntil: 'networkidle' });
}
await page.waitForTimeout(3000);
const url = page.url();
const elCardCount = await page.locator('.el-card').count();
const elTableCount = await page.locator('.el-table').count();
const elTableRowCount = await page.locator('.el-table__body tr').count();
console.log('=== Role Management Page ===');
console.log('URL:', url);
console.log('el-card count:', elCardCount);
console.log('el-table count:', elTableCount);
console.log('el-table rows:', elTableRowCount);
await page.screenshot({ path: 'test-results/06-role-management.png', fullPage: true });
console.log('Screenshot: test-results/06-role-management.png');
const criticalErrors = errors.filter(e =>
e.includes('Cannot find module') ||
e.includes('getDicts is not a function') ||
e.includes('is not a function')
);
const allJsErrors = errors.filter(e =>
!e.includes('net::ERR') && !e.includes('Failed to load resource') && !e.includes('ERR_CERT')
);
console.log('el-card present:', elCardCount > 0 ? 'PASS' : 'FAIL');
console.log('el-table present:', elTableCount > 0 ? 'PASS' : 'FAIL');
if (criticalErrors.length > 0) {
console.log('CRITICAL ERRORS:', criticalErrors);
} else {
console.log('No critical JS errors on role management page');
}
if (allJsErrors.length > 0) {
console.log('All JS errors:', allJsErrors);
}
});
test('07 - Return to dashboard via 首页 menu', async ({ page, context }) => {
const errors: string[] = [];
page.on('console', msg => {
if (msg.type() === 'error') errors.push(msg.text());
});
const authOk = await setupAuth(context);
if (!authOk) {
console.log('SKIP: No ADMIN_TOKEN provided');
return;
}
await page.goto(`${BASE_URL}/#/dashboard`, { waitUntil: 'networkidle' });
await page.waitForTimeout(2000);
// Navigate to role management first
await page.goto(`${BASE_URL}/#/admin/sys-role`, { waitUntil: 'networkidle' });
await page.waitForTimeout(1500);
console.log('On role management:', page.url());
// Click 首页 in sidebar
const homeMenu = page.locator('.el-menu-item').filter({ hasText: '首页' }).first();
const homeCount = await homeMenu.count();
console.log('首页 menu item found:', homeCount > 0);
if (homeCount > 0) {
await homeMenu.click();
await page.waitForTimeout(2000);
} else {
await page.goto(`${BASE_URL}/#/dashboard`, { waitUntil: 'networkidle' });
}
const url = page.url();
const isDashboard = url.includes('dashboard') || url.includes('index') || !url.includes('sys-');
console.log('URL after returning home:', url);
console.log('On dashboard:', isDashboard);
await page.screenshot({ path: 'test-results/07-back-to-dashboard.png', fullPage: true });
console.log('Screenshot: test-results/07-back-to-dashboard.png');
const allJsErrors = errors.filter(e =>
!e.includes('net::ERR') && !e.includes('Failed to load resource') && !e.includes('ERR_CERT')
);
if (allJsErrors.length > 0) {
console.log('JS errors:', allJsErrors);
}
});
test('08 - Complete console error analysis (all pages)', async ({ page, context }) => {
const loginErrors: string[] = [];
const dashboardErrors: string[] = [];
const userMgmtErrors: string[] = [];
// Collect login page errors first
page.on('console', msg => {
if (msg.type() === 'error') loginErrors.push(msg.text());
});
await page.goto(BASE_URL, { waitUntil: 'networkidle' });
await page.waitForTimeout(2000);
const jsLoginErrors = loginErrors.filter(e =>
!e.includes('net::ERR') && !e.includes('Failed to load resource') && !e.includes('ERR_CERT')
);
// Collect dashboard + user management errors if we have auth
const authOk = await setupAuth(context);
if (authOk) {
page.removeAllListeners('console');
page.on('console', msg => {
if (msg.type() === 'error') dashboardErrors.push(msg.text());
});
await page.goto(`${BASE_URL}/#/dashboard`, { waitUntil: 'networkidle' });
await page.waitForTimeout(2000);
page.removeAllListeners('console');
page.on('console', msg => {
if (msg.type() === 'error') userMgmtErrors.push(msg.text());
});
await page.goto(`${BASE_URL}/#/admin/sys-user`, { waitUntil: 'networkidle' });
await page.waitForTimeout(3000);
}
const jsDashboardErrors = dashboardErrors.filter(e =>
!e.includes('net::ERR') && !e.includes('Failed to load resource') && !e.includes('ERR_CERT')
);
const jsUserMgmtErrors = userMgmtErrors.filter(e =>
!e.includes('net::ERR') && !e.includes('Failed to load resource') && !e.includes('ERR_CERT')
);
const allErrors = [...jsLoginErrors, ...jsDashboardErrors, ...jsUserMgmtErrors];
console.log('=== Complete Console Error Analysis ===\n');
console.log('Login Page JS errors:', jsLoginErrors.length);
jsLoginErrors.forEach((e, i) => console.log(` ${i + 1}. ${e}`));
console.log('\nDashboard JS errors:', jsDashboardErrors.length);
jsDashboardErrors.forEach((e, i) => console.log(` ${i + 1}. ${e}`));
console.log('\nUser Management JS errors:', jsUserMgmtErrors.length);
jsUserMgmtErrors.forEach((e, i) => console.log(` ${i + 1}. ${e}`));
// Fix-specific verification
const hasModuleError = allErrors.some(e => e.includes('Cannot find module'));
const hasGetDictsError = allErrors.some(e =>
e.includes('getDicts is not a function') ||
(e.includes('getDicts') && e.includes('not a function'))
);
const hasPaginationError = allErrors.some(e =>
e.toLowerCase().includes('pagination') ||
e.includes('AppPagination')
);
console.log('\n=== Fix Verification Summary ===');
console.log('Fix 1 - loadView .default (no "Cannot find module"):', hasModuleError ? 'FAIL' : 'PASS');
console.log('Fix 2 - getDicts registration (no getDicts errors):', hasGetDictsError ? 'FAIL' : 'PASS');
console.log('Fix 3 - Pagination component (no Pagination errors):', hasPaginationError ? 'FAIL' : 'PASS');
console.log('\nTotal JS errors across all pages:', allErrors.length);
if (allErrors.length === 0) {
console.log('No JS errors detected on any page!');
}
await page.screenshot({ path: 'test-results/08-error-analysis-final.png', fullPage: true });
console.log('Screenshot: test-results/08-error-analysis-final.png');
// All three fixes should pass
expect(hasModuleError).toBeFalsy();
expect(hasGetDictsError).toBeFalsy();
expect(hasPaginationError).toBeFalsy();
});
});
@@ -0,0 +1,457 @@
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}`);
});
});
+5
View File
@@ -0,0 +1,5 @@
module.exports = {
env: {
jest: true
}
}
@@ -0,0 +1,16 @@
import { shallowMount } from '@vue/test-utils'
import Hamburger from '@/components/Hamburger/index.vue'
describe('Hamburger.vue', () => {
it('toggle click', async() => {
const wrapper = shallowMount(Hamburger)
await wrapper.find('.hamburger').trigger('click')
expect(wrapper.emitted('toggleClick')).toBeTruthy()
})
it('prop isActive', async() => {
const wrapper = shallowMount(Hamburger)
await wrapper.setProps({ isActive: true })
expect(wrapper.find('.is-active').exists()).toBe(true)
await wrapper.setProps({ isActive: false })
expect(wrapper.find('.is-active').exists()).toBe(false)
})
})
@@ -0,0 +1,22 @@
import { shallowMount } from '@vue/test-utils'
import SvgIcon from '@/components/SvgIcon/index.vue'
describe('SvgIcon.vue', () => {
it('iconClass', () => {
const wrapper = shallowMount(SvgIcon, {
props: {
iconClass: 'test'
}
})
expect(wrapper.find('use').attributes().href).toBe('#icon-test')
})
it('className', async() => {
const wrapper = shallowMount(SvgIcon, {
props: {
iconClass: 'test'
}
})
expect(wrapper.classes().length).toBe(1)
await wrapper.setProps({ className: 'test' })
expect(wrapper.classes().includes('test')).toBe(true)
})
})
@@ -0,0 +1,36 @@
import { mount } from '@vue/test-utils'
import permisaction from '@/directive/permission/permisaction'
jest.mock('@/store', () => ({
getters: {
permisaction: ['admin:sysUser:add', 'admin:sysUser:edit']
}
}))
// 指令通过 el.parentNode.removeChild(el) 移除元素,因此被测元素必须有父节点
const factory = value =>
mount(
{
template: '<div><button v-permisaction="value">操作</button></div>',
data: () => ({ value })
},
{
global: { directives: { permisaction } }
}
)
describe('v-permisaction', () => {
it('拥有权限时保留元素', () => {
const wrapper = factory(['admin:sysUser:add'])
expect(wrapper.find('button').exists()).toBe(true)
})
it('缺少权限时移除元素', () => {
const wrapper = factory(['admin:sysUser:delete'])
expect(wrapper.find('button').exists()).toBe(false)
})
it('权限值为空数组时抛出错误', () => {
expect(() => factory([])).toThrow('请设置操作权限标签值')
})
})
@@ -0,0 +1,36 @@
import { mount } from '@vue/test-utils'
import permission from '@/directive/permission/permission'
jest.mock('@/store', () => ({
getters: {
roles: ['editor']
}
}))
// 指令通过 el.parentNode.removeChild(el) 移除元素,因此被测元素必须有父节点
const factory = value =>
mount(
{
template: '<div><section v-permission="value">内容</section></div>',
data: () => ({ value })
},
{
global: { directives: { permission } }
}
)
describe('v-permission', () => {
it('角色匹配时保留元素', () => {
const wrapper = factory(['editor'])
expect(wrapper.find('section').exists()).toBe(true)
})
it('角色不匹配时移除元素', () => {
const wrapper = factory(['admin'])
expect(wrapper.find('section').exists()).toBe(false)
})
it('角色值为空数组时抛出错误', () => {
expect(() => factory([])).toThrow('need roles!')
})
})
@@ -0,0 +1,29 @@
import { formatTime } from '@/utils/index.js'
describe('Utils:formatTime', () => {
const d = new Date('2018-07-13 17:54:01') // "2018-07-13 17:54:01"
const retrofit = 5 * 1000
it('ten digits timestamp', () => {
expect(formatTime((d / 1000).toFixed(0))).toBe('7月13日17时54分')
})
it('test now', () => {
expect(formatTime(+new Date() - 1)).toBe('刚刚')
})
it('less two minute', () => {
expect(formatTime(+new Date() - 60 * 2 * 1000 + retrofit)).toBe('2分钟前')
})
it('less two hour', () => {
expect(formatTime(+new Date() - 60 * 60 * 2 * 1000 + retrofit)).toBe('2小时前')
})
it('less one day', () => {
expect(formatTime(+new Date() - 60 * 60 * 24 * 1 * 1000)).toBe('1天前')
})
it('more than one day', () => {
expect(formatTime(d)).toBe('7月13日17时54分')
})
it('format', () => {
expect(formatTime(d, '{y}-{m}-{d} {h}:{i}')).toBe('2018-07-13 17:54')
expect(formatTime(d, '{y}-{m}-{d}')).toBe('2018-07-13')
expect(formatTime(d, '{y}/{m}/{d} {h}-{i}')).toBe('2018/07/13 17-54')
})
})
@@ -0,0 +1,27 @@
import { parseTime } from '@/utils/index.js'
describe('Utils:parseTime', () => {
const d = new Date('2018-07-13 17:54:01') // "2018-07-13 17:54:01"
it('timestamp', () => {
expect(parseTime(d)).toBe('2018-07-13 17:54:01')
})
it('ten digits timestamp', () => {
expect(parseTime((d / 1000).toFixed(0))).toBe('2018-07-13 17:54:01')
})
it('new Date', () => {
expect(parseTime(new Date(d))).toBe('2018-07-13 17:54:01')
})
it('format', () => {
expect(parseTime(d, '{y}-{m}-{d} {h}:{i}')).toBe('2018-07-13 17:54')
expect(parseTime(d, '{y}-{m}-{d}')).toBe('2018-07-13')
expect(parseTime(d, '{y}/{m}/{d} {h}-{i}')).toBe('2018/07/13 17-54')
})
it('get the day of the week', () => {
expect(parseTime(d, '{a}')).toBe('五') // 星期五
})
it('get the day of the week', () => {
expect(parseTime(+d + 1000 * 60 * 60 * 24 * 2, '{a}')).toBe('日') // 星期日
})
it('empty argument', () => {
expect(parseTime()).toBeNull()
})
})
+28
View File
@@ -0,0 +1,28 @@
import { validUsername, validURL, validLowerCase, validUpperCase, validAlphabets } from '@/utils/validate.js'
describe('Utils:validate', () => {
it('validUsername', () => {
expect(validUsername('admin')).toBe(true)
expect(validUsername('editor')).toBe(true)
expect(validUsername('xxxx')).toBe(false)
})
it('validURL', () => {
expect(validURL('https://github.com/PanJiaChen/vue-element-admin')).toBe(true)
expect(validURL('http://github.com/PanJiaChen/vue-element-admin')).toBe(true)
expect(validURL('github.com/PanJiaChen/vue-element-admin')).toBe(false)
})
it('validLowerCase', () => {
expect(validLowerCase('abc')).toBe(true)
expect(validLowerCase('Abc')).toBe(false)
expect(validLowerCase('123abc')).toBe(false)
})
it('validUpperCase', () => {
expect(validUpperCase('ABC')).toBe(true)
expect(validUpperCase('Abc')).toBe(false)
expect(validUpperCase('123ABC')).toBe(false)
})
it('validAlphabets', () => {
expect(validAlphabets('ABC')).toBe(true)
expect(validAlphabets('Abc')).toBe(true)
expect(validAlphabets('123aBC')).toBe(false)
})
})