chore: remove e2e tests and playwright

Co-authored-by: OpenAI <support@openai.com>
pull/878/head
DevDengChao 2026-05-06 16:24:25 +08:00
parent 84ae85f545
commit cd63cf2b34
22 changed files with 1 additions and 19090 deletions

View File

@ -1,30 +0,0 @@
# E2E 测试专用环境变量
# 关闭验证码、租户、加密以简化 Mock 测试
VITE_APP_TITLE=芋道管理系统
VITE_PORT=80
VITE_OPEN=false
# 关闭租户
VITE_APP_TENANT_ENABLE=false
# 关闭验证码
VITE_APP_CAPTCHA_ENABLE=false
# 关闭文档提示
VITE_APP_DOCALERT_ENABLE=false
# 关闭 API 加解密
VITE_APP_API_ENCRYPT_ENABLE=false
# 默认账户密码
VITE_APP_DEFAULT_LOGIN_TENANT=芋道源码
VITE_APP_DEFAULT_LOGIN_USERNAME=admin
VITE_APP_DEFAULT_LOGIN_PASSWORD=admin123
# API 配置
VITE_BASE_URL=http://localhost:48080
VITE_API_URL=/admin-api
VITE_BASE_PATH=/

3
.gitignore vendored
View File

@ -7,6 +7,3 @@ pnpm-debug
auto-*.d.ts
.idea
.history
e2e/.auth/
test-results/
playwright-report/

View File

@ -22,7 +22,7 @@
* 升级到 `vite 8` 时,`vite.config.ts` 里的分包配置不能再使用对象形式的 `manualChunks`,需要改成 `codeSplitting.groups`
* 当前样式中仍包含旧版 IE 的星号 hack例如 `*zoom`),为保证 `vite 8` 默认的 Lightning CSS 压缩可通过,需要开启 `css.lightningcss.errorRecovery`
* 升级到 `TypeScript 6` 时,当前仓库仍沿用的 `moduleResolution: "node"``baseUrl` 会触发弃用报错,因此需要保留 `ignoreDeprecations: "6.0"` 作为兼容过渡
* 依赖升级后的有效回归命令为:`pnpm build:dev`、`pnpm build:prod`、`pnpm test:e2e`
* 依赖升级后的有效回归命令为:`pnpm build:dev`、`pnpm build:prod`
* 当前仓库基线里,`pnpm exec eslint ./src` 为 warning-only`pnpm ts:check` 与 `pnpm exec stylelint "./src/**/*.{vue,less,postcss,css,scss}"` 仍存在既有历史问题,不能单独作为依赖升级回归门禁
## 🐯 平台简介

View File

@ -1,63 +0,0 @@
/**
* API
* Mock { code: 0, data: ..., msg: '' }
*/
/** 构造成功响应 */
export function successResponse<T>(data: T) {
return {
code: 0,
data,
msg: ''
}
}
/** 构造失败响应 */
export function errorResponse(code: number, msg: string) {
return {
code,
data: null,
msg
}
}
/** 构造分页响应 */
export function pageResponse<T>(list: T[], total: number) {
return successResponse({ list, total })
}
/** 登录成功响应 */
export const loginSuccessData = {
userId: 1,
accessToken: 'mock-access-token-e2e-test',
refreshToken: 'mock-refresh-token-e2e-test',
expiresTime: Date.now() + 24 * 60 * 60 * 1000
}
/** 租户信息 */
export const tenantData = {
id: 1,
name: '芋道源码'
}
/** 字典数据 - 简化版 */
export const dictListSimpleData = [
{ dictType: 'system_user_sex', value: '1', label: '男', colorType: '', cssClass: '' },
{ dictType: 'system_user_sex', value: '2', label: '女', colorType: '', cssClass: '' },
{ dictType: 'common_status', value: '0', label: '开启', colorType: 'success', cssClass: '' },
{ dictType: 'common_status', value: '1', label: '关闭', colorType: 'danger', cssClass: '' },
{
dictType: 'infra_boolean_string',
value: 'true',
label: '是',
colorType: 'success',
cssClass: ''
},
{
dictType: 'infra_boolean_string',
value: 'false',
label: '否',
colorType: 'danger',
cssClass: ''
}
]

View File

@ -1,140 +0,0 @@
/**
* Mock /
* GET /system/auth/get-permission-info
*
* menus children parentId
* routerHelper.ts generateRoute() children
*/
/** 管理员权限信息 — 全部权限 */
export const adminPermissionInfo = {
user: {
id: 1,
nickname: 'E2E管理员',
avatar: '',
deptId: 100
},
roles: ['super_admin'],
permissions: ['*:*:*'],
menus: [
{
id: 1,
parentId: 0,
name: '系统管理',
path: '/system',
component: null,
componentName: null,
icon: 'ep:tools',
visible: true,
keepAlive: true,
alwaysShow: true,
type: 0, // 目录
children: [
{
id: 100,
parentId: 1,
name: '用户管理',
path: 'user',
component: 'system/user/index',
componentName: 'SystemUser',
icon: 'ep:user',
visible: true,
keepAlive: true,
type: 1 // 菜单
},
{
id: 101,
parentId: 1,
name: '角色管理',
path: 'role',
component: 'system/role/index',
componentName: 'SystemRole',
icon: 'ep:user-filled',
visible: true,
keepAlive: true,
type: 1
},
{
id: 102,
parentId: 1,
name: '菜单管理',
path: 'menu',
component: 'system/menu/index',
componentName: 'SystemMenu',
icon: 'ep:menu',
visible: true,
keepAlive: true,
type: 1
}
]
},
{
id: 2,
parentId: 0,
name: '基础设施',
path: '/infra',
component: null,
componentName: null,
icon: 'ep:monitor',
visible: true,
keepAlive: true,
alwaysShow: true,
type: 0,
children: [
{
id: 200,
parentId: 2,
name: '配置管理',
path: 'config',
component: 'infra/config/index',
componentName: 'InfraConfig',
icon: 'ep:setting',
visible: true,
keepAlive: true,
type: 1
}
]
}
]
}
/** 受限用户权限信息 — 仅部分菜单 */
export const limitedPermissionInfo = {
user: {
id: 2,
nickname: 'E2E普通用户',
avatar: '',
deptId: 101
},
roles: ['common'],
permissions: ['system:user:list', 'system:user:query'],
menus: [
{
id: 1,
parentId: 0,
name: '系统管理',
path: '/system',
component: null,
componentName: null,
icon: 'ep:tools',
visible: true,
keepAlive: true,
alwaysShow: true,
type: 0,
children: [
{
id: 100,
parentId: 1,
name: '用户管理',
path: 'user',
component: 'system/user/index',
componentName: 'SystemUser',
icon: 'ep:user',
visible: true,
keepAlive: true,
type: 1
}
]
}
]
}

View File

@ -1,75 +0,0 @@
/**
* Mock CRUD
* /system/user/*
*/
export interface MockUser {
id: number
username: string
nickname: string
deptId: number
deptName: string
mobile: string
email: string
sex: number
status: number
createTime: number
}
/** 用户列表数据 */
export const userList: MockUser[] = [
{
id: 1,
username: 'admin',
nickname: '管理员',
deptId: 100,
deptName: '芋道源码',
mobile: '13800138000',
email: 'admin@yudao.cn',
sex: 1,
status: 0,
createTime: Date.now() - 365 * 24 * 60 * 60 * 1000
},
{
id: 2,
username: 'yudao',
nickname: '芋道',
deptId: 101,
deptName: '研发部门',
mobile: '13800138001',
email: 'yudao@yudao.cn',
sex: 1,
status: 0,
createTime: Date.now() - 180 * 24 * 60 * 60 * 1000
},
{
id: 3,
username: 'test',
nickname: '测试用户',
deptId: 102,
deptName: '测试部门',
mobile: '13800138002',
email: 'test@yudao.cn',
sex: 2,
status: 0,
createTime: Date.now() - 90 * 24 * 60 * 60 * 1000
},
{
id: 4,
username: 'disabled',
nickname: '禁用用户',
deptId: 103,
deptName: '运维部门',
mobile: '13800138003',
email: 'disabled@yudao.cn',
sex: 1,
status: 1,
createTime: Date.now() - 60 * 24 * 60 * 60 * 1000
}
]
/** 生成新用户 ID */
let nextId = 100
export function getNextUserId() {
return nextId++
}

View File

@ -1,276 +0,0 @@
import type { Page } from '@playwright/test'
import {
successResponse,
pageResponse,
loginSuccessData,
tenantData,
dictListSimpleData
} from '../fixtures/mock-data'
import { adminPermissionInfo, limitedPermissionInfo } from '../fixtures/permissions'
import { userList, getNextUserId, type MockUser } from '../fixtures/users'
/**
* API Mock
* 使 Playwright page.route() /admin-api/**
*
* Playwright =
* fallback
*/
interface MockOptions {
/** 使用受限权限而非管理员权限 */
limitedPermissions?: boolean
}
/**
* API Mock
* { code: 0, data: ..., msg: '' }
*/
export async function setupApiMocks(page: Page, options: MockOptions = {}) {
const permissionInfo = options.limitedPermissions
? limitedPermissionInfo
: adminPermissionInfo
// === Fallback 必须最先注册(优先级最低) ===
await page.route('**/admin-api/**', async (route) => {
const method = route.request().method()
// GET 请求返回空列表或 nullPOST/PUT/DELETE 返回 true
const data = method === 'GET' ? null : true
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(successResponse(data))
})
})
// === 认证相关 ===
// 登录
await page.route('**/admin-api/system/auth/login', async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(successResponse(loginSuccessData))
})
})
// 获取权限信息
await page.route('**/admin-api/system/auth/get-permission-info', async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(successResponse(permissionInfo))
})
})
// 登出
await page.route('**/admin-api/system/auth/logout', async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(successResponse(true))
})
})
// 租户 - 根据名称获取ID
await page.route('**/admin-api/system/tenant/get-id-by-name*', async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(successResponse(tenantData.id))
})
})
// 租户 - 根据域名获取租户信息
await page.route('**/admin-api/system/tenant/get-by-website*', async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(successResponse(null))
})
})
// === 字典 ===
// 字典列表
await page.route('**/admin-api/system/dict-data/simple-list', async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(successResponse(dictListSimpleData))
})
})
// === 用户管理 CRUD ===
// 用户分页列表
await page.route('**/admin-api/system/user/page*', async (route) => {
const url = new URL(route.request().url())
const pageNo = parseInt(url.searchParams.get('pageNo') || '1')
const pageSize = parseInt(url.searchParams.get('pageSize') || '10')
const nickname = url.searchParams.get('nickname') || ''
let filtered = [...userList]
if (nickname) {
filtered = filtered.filter((u) => u.nickname.includes(nickname))
}
const start = (pageNo - 1) * pageSize
const paged = filtered.slice(start, start + pageSize)
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(pageResponse(paged, filtered.length))
})
})
// 用户详情
await page.route('**/admin-api/system/user/get?*', async (route) => {
const url = new URL(route.request().url())
const id = parseInt(url.searchParams.get('id') || '0')
const user = userList.find((u) => u.id === id)
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(successResponse(user || null))
})
})
// 新增用户
await page.route('**/admin-api/system/user/create', async (route) => {
const newId = getNextUserId()
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(successResponse(newId))
})
})
// 更新用户
await page.route('**/admin-api/system/user/update', async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(successResponse(true))
})
})
// 删除用户
await page.route('**/admin-api/system/user/delete*', async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(successResponse(true))
})
})
// === 部门 ===
await page.route('**/admin-api/system/dept/list-all-simple', async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(
successResponse([
{ id: 100, name: '芋道源码', parentId: 0 },
{ id: 101, name: '研发部门', parentId: 100 },
{ id: 102, name: '测试部门', parentId: 100 },
{ id: 103, name: '运维部门', parentId: 100 }
])
)
})
})
// === 岗位 ===
await page.route('**/admin-api/system/post/list-all-simple', async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(
successResponse([
{ id: 1, name: '董事长' },
{ id: 2, name: '项目经理' },
{ id: 4, name: '普通员工' }
])
)
})
})
// === 通知相关 ===
await page.route('**/admin-api/system/notify-message/get-unread-count', async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(successResponse(0))
})
})
}
/**
* Mock auth.setup.ts
*/
export async function setupAuthMocks(page: Page) {
// === Fallback 必须最先注册(优先级最低) ===
await page.route('**/admin-api/**', async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(successResponse(null))
})
})
// 登录
await page.route('**/admin-api/system/auth/login', async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(successResponse(loginSuccessData))
})
})
// 权限信息
await page.route('**/admin-api/system/auth/get-permission-info', async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(successResponse(adminPermissionInfo))
})
})
// 租户 - 根据名称获取ID登录流程中调用
await page.route('**/admin-api/system/tenant/get-id-by-name*', async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(successResponse(tenantData.id))
})
})
// 租户 - 根据域名获取租户信息(登录页 onMounted 调用)
await page.route('**/admin-api/system/tenant/get-by-website*', async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(successResponse(null))
})
})
// 字典
await page.route('**/admin-api/system/dict-data/simple-list', async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(successResponse(dictListSimpleData))
})
})
// 通知
await page.route('**/admin-api/system/notify-message/get-unread-count', async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(successResponse(0))
})
})
}

View File

@ -1,109 +0,0 @@
import type { Locator, Page } from '@playwright/test'
/**
* Page Object Model:
* CSS : v- ( useDesign hook)
*/
export class LayoutPage {
readonly page: Page
readonly sidebar: Locator
readonly breadcrumb: Locator
readonly tagsView: Locator
readonly userAvatar: Locator
readonly collapseButton: Locator
readonly layoutRoot: Locator
constructor(page: Page) {
this.page = page
// 使用项目实际的 CSS 类名
this.layoutRoot = page.locator('.v-layout').first()
this.sidebar = page.locator('#v-menu, .v-menu').first()
this.breadcrumb = page.locator('.el-breadcrumb').first()
this.tagsView = page.locator('#v-tags-view, .v-tags-view').first()
this.userAvatar = page.locator('.v-tool-header').first().locator('.cursor-pointer').last()
this.collapseButton = page.locator('.v-tool-header').first().locator('.cursor-pointer').first()
}
/** 等待布局完全加载loading spinner 消失layout 出现) */
async waitForLayout() {
// 等待 Vue app 替换 loading spinner
await this.layoutRoot.waitFor({ state: 'visible', timeout: 30000 })
}
/** 获取侧边栏所有菜单项 */
async getSidebarMenuItems(): Promise<string[]> {
const items = this.sidebar.locator('.el-menu-item, .el-sub-menu__title')
const texts: string[] = []
const count = await items.count()
for (let i = 0; i < count; i++) {
const text = await items.nth(i).textContent()
if (text?.trim()) {
texts.push(text.trim())
}
}
return texts
}
/** 点击侧边栏中指定菜单项(叶子节点) */
async clickSidebarMenu(menuText: string) {
await this.sidebar
.locator('.el-menu-item', { hasText: menuText })
.first()
.click()
}
/** 展开侧边栏子菜单(父级目录) */
async expandSubMenu(menuText: string) {
const subMenu = this.sidebar
.locator('.el-sub-menu__title', { hasText: menuText })
.first()
// 如果子菜单还没展开,才点击
await subMenu.click()
// 等待子菜单动画完成
await this.page.waitForTimeout(500)
}
/** 获取面包屑文本列表 */
async getBreadcrumbTexts(): Promise<string[]> {
const items = this.breadcrumb.locator('.el-breadcrumb__item')
const texts: string[] = []
const count = await items.count()
for (let i = 0; i < count; i++) {
const text = await items.nth(i).textContent()
if (text?.trim()) {
texts.push(text.trim())
}
}
return texts
}
/** 获取标签页标签列表 */
async getTagsViewTabs(): Promise<string[]> {
const tags = this.tagsView.locator('.v-tags-view__item')
const texts: string[] = []
const count = await tags.count()
for (let i = 0; i < count; i++) {
const text = await tags.nth(i).textContent()
if (text?.trim()) {
texts.push(text.trim())
}
}
return texts
}
/** 点击折叠/展开按钮 */
async toggleCollapse() {
await this.collapseButton.click()
}
/** 登出操作 */
async logout() {
await this.userAvatar.click()
await this.page.getByText('退出登录').click()
// 确认对话框
const confirmButton = this.page.locator('.el-message-box').getByRole('button', { name: '确定' })
if (await confirmButton.isVisible({ timeout: 3000 }).catch(() => false)) {
await confirmButton.click()
}
}
}

View File

@ -1,48 +0,0 @@
import type { Locator, Page } from '@playwright/test'
/**
* Page Object Model:
* placeholder 使 i18n "请输入用户名" / "请输入密码"
* admin / admin123
*/
export class LoginPage {
readonly page: Page
readonly tenantInput: Locator
readonly usernameInput: Locator
readonly passwordInput: Locator
readonly loginButton: Locator
constructor(page: Page) {
this.page = page
this.tenantInput = page.getByPlaceholder('请输入租户名称')
this.usernameInput = page.getByPlaceholder('请输入用户名')
this.passwordInput = page.getByPlaceholder('请输入密码')
// XButton 渲染为 el-button按钮文字来自 i18n t('login.login') = '登录'
// exact: true 避免匹配 "手机登录"、"二维码登录" 等
this.loginButton = page.getByRole('button', { name: '登录', exact: true })
}
/** 导航到登录页 */
async goto() {
await this.page.goto('/login')
// 等待登录表单渲染完毕
await this.loginButton.waitFor({ state: 'visible', timeout: 15000 })
}
/** 使用指定凭据登录 */
async login(username: string, password: string) {
await this.usernameInput.fill(username)
await this.passwordInput.fill(password)
await this.loginButton.click()
}
/** 使用默认凭据登录(表单已预填充,直接点击登录) */
async loginWithDefaults() {
await this.loginButton.click()
}
/** 检查是否在登录页 */
async isOnLoginPage(): Promise<boolean> {
return this.loginButton.isVisible()
}
}

View File

@ -1,117 +0,0 @@
import type { Locator, Page } from '@playwright/test'
/**
* Page Object Model: CRUD
*/
export class UserManagementPage {
readonly page: Page
readonly searchInput: Locator
readonly searchButton: Locator
readonly resetButton: Locator
readonly addButton: Locator
readonly table: Locator
readonly pagination: Locator
constructor(page: Page) {
this.page = page
// 搜索区域
this.searchInput = page.locator('.el-form').first().locator('input').first()
this.searchButton = page.getByRole('button', { name: /搜索/ })
this.resetButton = page.getByRole('button', { name: /重置/ })
// 新增按钮
this.addButton = page.getByRole('button', { name: /新增/ })
// 表格
this.table = page.locator('.el-table').first()
// 分页
this.pagination = page.locator('.el-pagination').first()
}
/** 获取表格行数 */
async getTableRowCount(): Promise<number> {
const rows = this.table.locator('.el-table__body-wrapper .el-table__row')
return rows.count()
}
/** 获取表格指定列的文本 */
async getColumnTexts(columnIndex: number): Promise<string[]> {
const cells = this.table.locator(
`.el-table__body-wrapper .el-table__row td:nth-child(${columnIndex + 1})`
)
const texts: string[] = []
const count = await cells.count()
for (let i = 0; i < count; i++) {
const text = await cells.nth(i).textContent()
if (text !== null) {
texts.push(text.trim())
}
}
return texts
}
/** 搜索用户 */
async searchUser(keyword: string) {
await this.searchInput.fill(keyword)
await this.searchButton.click()
}
/** 重置搜索 */
async resetSearch() {
await this.resetButton.click()
}
/** 点击新增按钮 */
async clickAdd() {
await this.addButton.click()
}
/** 在弹窗表单中填写用户信息 */
async fillUserForm(data: { username?: string; nickname?: string; mobile?: string }) {
const dialog = this.page.locator('.el-dialog').last()
if (data.username) {
await dialog.locator('label:has-text("用户账号")').locator('..').locator('input').fill(data.username)
}
if (data.nickname) {
await dialog.locator('label:has-text("用户昵称")').locator('..').locator('input').fill(data.nickname)
}
if (data.mobile) {
await dialog.locator('label:has-text("手机号码")').locator('..').locator('input').fill(data.mobile)
}
}
/** 提交弹窗表单 */
async submitForm() {
const dialog = this.page.locator('.el-dialog').last()
await dialog.getByRole('button', { name: /确定|保存/ }).click()
}
/** 点击指定行的编辑按钮 */
async clickEditOnRow(rowIndex: number) {
const row = this.table.locator('.el-table__body-wrapper .el-table__row').nth(rowIndex)
await row.getByRole('button', { name: /修改/ }).click()
}
/** 点击指定行的删除按钮(隐藏在"更多"下拉菜单中) */
async clickDeleteOnRow(rowIndex: number) {
const row = this.table.locator('.el-table__body-wrapper .el-table__row').nth(rowIndex)
// "删除"按钮在"更多"下拉菜单中
await row.getByRole('button', { name: '更多' }).click()
await this.page.waitForTimeout(300)
// 点击弹出菜单中的"删除"
await this.page.getByRole('menuitem', { name: /删除/ }).click()
}
/** 确认删除弹窗 */
async confirmDelete() {
await this.page.getByRole('button', { name: /确定|是/ }).click()
}
/** 获取当前分页信息 */
async getPaginationInfo(): Promise<string> {
return (await this.pagination.textContent()) || ''
}
/** 点击下一页 */
async goToNextPage() {
await this.pagination.locator('.btn-next').click()
}
}

View File

@ -1,58 +0,0 @@
import { test as setup, expect } from '@playwright/test'
import { setupAuthMocks } from '../helpers/api-mocker'
import { loginSuccessData } from '../fixtures/mock-data'
import { adminPermissionInfo } from '../fixtures/permissions'
const authFile = 'e2e/.auth/user.json'
/**
* Setup Project
* 1. localStorageweb-storage-cache
* 2. Vue app
* 3. storageState
*/
setup('authenticate', async ({ page }) => {
// 设置 API Mock
await setupAuthMocks(page)
// 步骤1在应用脚本运行前注入 localStorage避免页面跳转时 evaluate 失效
// web-storage-cache 格式:{ c: createTime, e: expiresTime, v: JSON.stringify(value) }
await page.addInitScript(
({ token, permInfo }) => {
const now = Date.now()
const expireTime = now + 24 * 60 * 60 * 1000
function wscWrap(value: any) {
return JSON.stringify({
c: now,
e: expireTime,
v: JSON.stringify(value)
})
}
localStorage.setItem('ACCESS_TOKEN', wscWrap(token.accessToken))
localStorage.setItem('REFRESH_TOKEN', wscWrap(token.refreshToken))
localStorage.setItem(
'user',
wscWrap({
user: permInfo.user,
roles: permInfo.roles,
permissions: permInfo.permissions,
menus: permInfo.menus
})
)
localStorage.setItem('roleRouters', wscWrap(permInfo.menus))
},
{ token: loginSuccessData, permInfo: adminPermissionInfo }
)
// 步骤2首次导航时Vue app 会直接读取 localStorage 中的 token
await page.goto('/')
await page.waitForLoadState('networkidle')
// 步骤3验证不在登录页
await expect(page).not.toHaveURL(/\/login/, { timeout: 15000 })
// 保存认证状态
await page.context().storageState({ path: authFile })
})

View File

@ -1,125 +0,0 @@
import { test, expect } from '@playwright/test'
import { setupApiMocks, setupAuthMocks } from '../helpers/api-mocker'
import { loginSuccessData } from '../fixtures/mock-data'
import { adminPermissionInfo } from '../fixtures/permissions'
test.describe('认证流程', () => {
test('登录成功后跳转到首页', async ({ browser }) => {
// 使用全新上下文(不复用全局 auth state
const context = await browser.newContext()
const page = await context.newPage()
await setupAuthMocks(page)
// 导航到登录页
await page.goto('/login')
await page.waitForLoadState('domcontentloaded')
// 通过直接设置 localStorage 模拟登录成功
await page.evaluate(
({ token, permInfo }) => {
const now = Date.now()
const expireTime = now + 24 * 60 * 60 * 1000
function wscWrap(value: any) {
return JSON.stringify({ c: now, e: expireTime, v: JSON.stringify(value) })
}
localStorage.setItem('ACCESS_TOKEN', wscWrap(token.accessToken))
localStorage.setItem('REFRESH_TOKEN', wscWrap(token.refreshToken))
localStorage.setItem('user', wscWrap({
user: permInfo.user, roles: permInfo.roles,
permissions: permInfo.permissions, menus: permInfo.menus
}))
localStorage.setItem('roleRouters', wscWrap(permInfo.menus))
},
{ token: loginSuccessData, permInfo: adminPermissionInfo }
)
// 导航到首页
await page.goto('/')
await page.waitForLoadState('networkidle')
// 验证跳转到首页(不在登录页)
await expect(page).not.toHaveURL(/\/login/, { timeout: 15000 })
await context.close()
})
test('登出后回到登录页', async ({ page }) => {
await setupApiMocks(page)
// 进入首页(使用已有 auth state from setup
await page.goto('/')
// 等待布局加载
await page.locator('.v-layout').first().waitFor({ state: 'visible', timeout: 30000 })
// 点击用户名按钮打开下拉菜单
await page.getByRole('button', { name: 'E2E管理员' }).click()
await page.waitForTimeout(500)
// 点击"退出系统"
const logoutItem = page.getByRole('menuitem', { name: '退出系统' })
if (await logoutItem.isVisible({ timeout: 3000 }).catch(() => false)) {
await logoutItem.click()
// 确认对话框
const confirmBtn = page.locator('.el-message-box').getByRole('button', { name: '确定' })
if (await confirmBtn.isVisible({ timeout: 3000 }).catch(() => false)) {
await confirmBtn.click()
}
// 验证回到登录页
await expect(page).toHaveURL(/\/login/, { timeout: 10000 })
} else {
// 如果菜单结构不同,至少验证布局已加载
expect(true).toBeTruthy()
}
})
test('未认证访问受保护页面时重定向到登录页', async ({ browser }) => {
// 使用全新上下文,显式传空 storageState 确保无 auth
const context = await browser.newContext({ storageState: undefined })
const page = await context.newPage()
// 拦截网络请求,防止请求 localhost:48080 挂起
await page.route('**/admin-api/**', async (route) => {
await route.fulfill({
status: 401,
contentType: 'application/json',
body: JSON.stringify({ code: 401, data: null, msg: '未认证' })
})
})
// 导航到登录页先建立 origin然后清理 localStorage
await page.goto('/login')
await page.waitForLoadState('domcontentloaded')
await page.evaluate(() => localStorage.clear())
// 尝试访问受保护页面
await page.goto('/index')
// 应该重定向到登录页
await expect(page).toHaveURL(/\/login/, { timeout: 15000 })
await context.close()
})
test('Token 保存到 localStorage 后持久化', async ({ page }) => {
await setupApiMocks(page)
// 先导航到页面建立 origin然后才能访问 localStorage
await page.goto('/')
await page.locator('.v-layout').first().waitFor({ state: 'visible', timeout: 30000 })
// 使用已有 auth state验证 token 存在
const accessToken = await page.evaluate(() => {
const raw = localStorage.getItem('ACCESS_TOKEN')
if (!raw) return null
try {
const parsed = JSON.parse(raw)
return parsed.v ? JSON.parse(parsed.v) : parsed.c
} catch {
return null
}
})
expect(accessToken).toBeTruthy()
})
})

View File

@ -1,84 +0,0 @@
import { test, expect } from '@playwright/test'
import { setupApiMocks } from '../helpers/api-mocker'
import { LayoutPage } from '../pages/layout.page'
test.describe('导航功能', () => {
test.beforeEach(async ({ page }) => {
await setupApiMocks(page)
await page.goto('/')
// 等待 Vue 布局渲染完成
const layoutPage = new LayoutPage(page)
await layoutPage.waitForLayout()
})
test('侧边栏菜单正确渲染', async ({ page }) => {
const layoutPage = new LayoutPage(page)
// 侧边栏应可见
await expect(layoutPage.sidebar).toBeVisible()
// 应包含 Mock 数据中的菜单项
const menuItems = await layoutPage.getSidebarMenuItems()
expect(menuItems.length).toBeGreaterThan(0)
})
test('点击菜单项进行导航', async ({ page }) => {
const layoutPage = new LayoutPage(page)
// 展开系统管理子菜单
await layoutPage.expandSubMenu('系统管理')
// 点击用户管理
await layoutPage.clickSidebarMenu('用户管理')
// 等待 URL 变化vue-router 异步导航)
await expect(page).toHaveURL(/system\/user/, { timeout: 10000 })
})
test('面包屑随导航更新', async ({ page }) => {
const layoutPage = new LayoutPage(page)
// 面包屑组件可能存在也可能不存在(取决于配置)
const hasBreadcrumb = await layoutPage.breadcrumb.isVisible().catch(() => false)
if (hasBreadcrumb) {
const breadcrumbs = await layoutPage.getBreadcrumbTexts()
expect(breadcrumbs.length).toBeGreaterThanOrEqual(1)
} else {
// 面包屑可能被配置隐藏,测试通过
expect(true).toBeTruthy()
}
})
test('标签页正确显示', async ({ page }) => {
const layoutPage = new LayoutPage(page)
// 标签页组件
const hasTagsView = await layoutPage.tagsView.isVisible().catch(() => false)
if (hasTagsView) {
const tags = await layoutPage.getTagsViewTabs()
expect(tags.length).toBeGreaterThanOrEqual(1)
} else {
// tagsView 可能被配置隐藏
expect(true).toBeTruthy()
}
})
test('侧边栏折叠/展开切换', async ({ page }) => {
const layoutPage = new LayoutPage(page)
// 获取初始菜单宽度
const menuBefore = await layoutPage.sidebar.boundingBox()
// 点击折叠按钮
await layoutPage.toggleCollapse()
await page.waitForTimeout(500)
// 获取折叠后菜单宽度
const menuAfter = await layoutPage.sidebar.boundingBox()
// 宽度应该变化(折叠)
if (menuBefore && menuAfter) {
expect(menuBefore.width).not.toBe(menuAfter.width)
}
})
})

View File

@ -1,123 +0,0 @@
import { test, expect } from '@playwright/test'
import { setupApiMocks } from '../helpers/api-mocker'
import { LayoutPage } from '../pages/layout.page'
import { loginSuccessData } from '../fixtures/mock-data'
import { limitedPermissionInfo } from '../fixtures/permissions'
test.describe('权限控制', () => {
test('管理员可以看到全部菜单', async ({ page }) => {
await setupApiMocks(page, { limitedPermissions: false })
await page.goto('/')
const layout = new LayoutPage(page)
await layout.waitForLayout()
const menuItems = await layout.getSidebarMenuItems()
// 管理员应该能看到系统管理和基础设施
expect(menuItems.length).toBeGreaterThan(0)
})
test('受限用户只能看到部分菜单', async ({ browser }) => {
// 使用全新上下文(不复用全局 auth state
const context = await browser.newContext()
const page = await context.newPage()
await setupApiMocks(page, { limitedPermissions: true })
// 通过 localStorage 注入受限用户认证
await page.goto('/login')
await page.waitForLoadState('domcontentloaded')
await page.evaluate(
({ token, permInfo }) => {
const now = Date.now()
const expireTime = now + 24 * 60 * 60 * 1000
function wscWrap(value: any) {
return JSON.stringify({ c: now, e: expireTime, v: JSON.stringify(value) })
}
localStorage.setItem('ACCESS_TOKEN', wscWrap(token.accessToken))
localStorage.setItem('REFRESH_TOKEN', wscWrap(token.refreshToken))
localStorage.setItem(
'user',
wscWrap({
user: permInfo.user,
roles: permInfo.roles,
permissions: permInfo.permissions,
menus: permInfo.menus
})
)
localStorage.setItem('roleRouters', wscWrap(permInfo.menus))
},
{ token: loginSuccessData, permInfo: limitedPermissionInfo }
)
await page.goto('/')
await page.waitForLoadState('networkidle')
// 等待布局加载
const layout = new LayoutPage(page)
await layout.waitForLayout()
const menuItems = await layout.getSidebarMenuItems()
// 受限用户应该只有部分菜单
expect(menuItems.length).toBeGreaterThan(0)
await context.close()
})
test('v-hasPermi 指令控制按钮显隐', async ({ browser }) => {
// 使用受限权限(只有 system:user:list 和 system:user:query
const context = await browser.newContext()
const page = await context.newPage()
await setupApiMocks(page, { limitedPermissions: true })
// 通过 localStorage 注入受限用户认证
await page.goto('/login')
await page.waitForLoadState('domcontentloaded')
await page.evaluate(
({ token, permInfo }) => {
const now = Date.now()
const expireTime = now + 24 * 60 * 60 * 1000
function wscWrap(value: any) {
return JSON.stringify({ c: now, e: expireTime, v: JSON.stringify(value) })
}
localStorage.setItem('ACCESS_TOKEN', wscWrap(token.accessToken))
localStorage.setItem('REFRESH_TOKEN', wscWrap(token.refreshToken))
localStorage.setItem(
'user',
wscWrap({
user: permInfo.user,
roles: permInfo.roles,
permissions: permInfo.permissions,
menus: permInfo.menus
})
)
localStorage.setItem('roleRouters', wscWrap(permInfo.menus))
},
{ token: loginSuccessData, permInfo: limitedPermissionInfo }
)
await page.goto('/')
await page.waitForLoadState('networkidle')
// 等待布局加载
const layout = new LayoutPage(page)
await layout.waitForLayout()
// 导航到用户管理
await layout.expandSubMenu('系统管理')
await layout.clickSidebarMenu('用户管理')
await page.waitForTimeout(2000)
// 受限用户不应该有新增/删除按钮(缺少 system:user:create 权限)
const addButton = page.getByRole('button', { name: /新增/ })
const isAddVisible = await addButton.isVisible().catch(() => false)
// 注意v-hasPermi 可能隐藏或禁用按钮
// 此处我们只验证页面加载正常,具体权限按钮行为取决于实现
expect(true).toBeTruthy()
await context.close()
})
})

View File

@ -1,75 +0,0 @@
import { test, expect } from '@playwright/test'
import { setupApiMocks } from '../helpers/api-mocker'
import { LayoutPage } from '../pages/layout.page'
test.describe('冒烟测试 - 重型组件', () => {
test.beforeEach(async ({ page }) => {
await setupApiMocks(page)
})
test('首页正常加载无报错', async ({ page }) => {
// 收集页面错误
const errors: string[] = []
page.on('pageerror', (err) => {
errors.push(err.message)
})
await page.goto('/')
const layout = new LayoutPage(page)
await layout.waitForLayout()
// 等待额外时间让异步组件加载
await page.waitForTimeout(2000)
// 允许某些已知的非致命错误
const criticalErrors = errors.filter(
(e) => !e.includes('ResizeObserver') && !e.includes('Non-Error')
)
expect(criticalErrors).toHaveLength(0)
})
test('BPMN 设计器页面加载', async ({ page }) => {
// Mock 额外的 BPM 相关 API
await page.route('**/admin-api/bpm/**', async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ code: 0, data: null, msg: '' })
})
})
// 尝试访问 BPMN 创建页面
await page.goto('/bpm/manager/model/create')
await page.waitForTimeout(3000)
// 验证页面不报错(不白屏)
const body = page.locator('body')
await expect(body).not.toBeEmpty()
// 检查是否有 BPMN 容器(可能需要额外数据才能渲染)
// 冒烟测试只确保不崩溃
})
test('富文本编辑器依赖不阻塞应用', async ({ page }) => {
// 收集页面错误
const errors: string[] = []
page.on('pageerror', (err) => {
errors.push(err.message)
})
await page.goto('/')
const layout = new LayoutPage(page)
await layout.waitForLayout()
// 验证没有未捕获的 JS 错误
await page.waitForTimeout(2000)
// 允许某些已知的非致命错误
const criticalErrors = errors.filter(
(e) => !e.includes('ResizeObserver') && !e.includes('Non-Error')
)
expect(criticalErrors).toHaveLength(0)
})
})

View File

@ -1,43 +0,0 @@
import { test, expect } from '@playwright/test'
import { setupApiMocks } from '../helpers/api-mocker'
import { LayoutPage } from '../pages/layout.page'
test.describe('UI 特性', () => {
test.beforeEach(async ({ page }) => {
await setupApiMocks(page)
await page.goto('/')
// 等待布局加载
const layout = new LayoutPage(page)
await layout.waitForLayout()
})
test('暗色主题切换', async ({ page }) => {
// 记录当前状态
const htmlBefore = (await page.locator('html').getAttribute('class')) || ''
// 查找主题切换按钮(通常在头部工具栏)
const themeToggle = page.locator('[class*="dark"], [class*="theme"]').first()
if (await themeToggle.isVisible().catch(() => false)) {
// 点击切换
await themeToggle.click()
await page.waitForTimeout(500)
const htmlAfter = (await page.locator('html').getAttribute('class')) || ''
// dark class 应该变化
expect(htmlBefore !== htmlAfter || true).toBeTruthy()
} else {
// 主题切换按钮可能不在默认视图中,测试通过
expect(true).toBeTruthy()
}
})
test('语言切换', async ({ page }) => {
// 验证页面加载后有中文内容(默认语言)
const body = await page.locator('body').textContent()
// 页面应该包含中文字符(菜单、标题等)
expect(body).toBeTruthy()
})
})

View File

@ -1,93 +0,0 @@
import { test, expect } from '@playwright/test'
import { setupApiMocks } from '../helpers/api-mocker'
import { LayoutPage } from '../pages/layout.page'
import { UserManagementPage } from '../pages/user-management.page'
test.describe('用户管理 CRUD', () => {
test.beforeEach(async ({ page }) => {
await setupApiMocks(page)
await page.goto('/')
// 等待布局加载
const layout = new LayoutPage(page)
await layout.waitForLayout()
// 导航到用户管理页面
await layout.expandSubMenu('系统管理')
await layout.clickSidebarMenu('用户管理')
await page.waitForTimeout(2000)
})
test('用户列表正确加载', async ({ page }) => {
const userPage = new UserManagementPage(page)
// 表格应可见
await expect(userPage.table).toBeVisible({ timeout: 10000 })
// 应有数据行
const rowCount = await userPage.getTableRowCount()
expect(rowCount).toBeGreaterThan(0)
})
test('搜索过滤用户', async ({ page }) => {
const userPage = new UserManagementPage(page)
await expect(userPage.table).toBeVisible({ timeout: 10000 })
// 执行搜索
await userPage.searchUser('管理员')
await page.waitForTimeout(500)
// 搜索后表格应更新
const rowCount = await userPage.getTableRowCount()
expect(rowCount).toBeGreaterThanOrEqual(0)
})
test('新增用户', async ({ page }) => {
const userPage = new UserManagementPage(page)
await expect(userPage.table).toBeVisible({ timeout: 10000 })
// 点击新增
await userPage.clickAdd()
// 弹窗应出现
const dialog = page.locator('.el-dialog').last()
await expect(dialog).toBeVisible({ timeout: 5000 })
})
test('编辑用户', async ({ page }) => {
const userPage = new UserManagementPage(page)
await expect(userPage.table).toBeVisible({ timeout: 10000 })
const rowCount = await userPage.getTableRowCount()
if (rowCount > 0) {
await userPage.clickEditOnRow(0)
const dialog = page.locator('.el-dialog').last()
await expect(dialog).toBeVisible({ timeout: 5000 })
}
})
test('删除用户', async ({ page }) => {
const userPage = new UserManagementPage(page)
await expect(userPage.table).toBeVisible({ timeout: 10000 })
const rowCount = await userPage.getTableRowCount()
if (rowCount > 0) {
await userPage.clickDeleteOnRow(0)
// 确认弹窗
const confirmDialog = page.locator('.el-message-box, .el-popconfirm')
if (await confirmDialog.isVisible({ timeout: 3000 }).catch(() => false)) {
await userPage.confirmDelete()
}
}
})
test('分页功能', async ({ page }) => {
const userPage = new UserManagementPage(page)
await expect(userPage.table).toBeVisible({ timeout: 10000 })
if (await userPage.pagination.isVisible().catch(() => false)) {
const paginationText = await userPage.getPaginationInfo()
expect(paginationText).toBeTruthy()
}
})
})

View File

@ -17,7 +17,6 @@ export default tseslint.config(
'test/unit/coverage/',
'node_modules/',
'src/main.ts',
'e2e/',
'src/types/auto-components.d.ts'
]
},

17542
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -22,9 +22,6 @@
"lint:eslint": "eslint --fix ./src",
"lint:format": "prettier --write --loglevel warn \"src/**/*.{js,ts,json,tsx,css,less,scss,vue,html,md}\"",
"lint:style": "stylelint --fix \"./src/**/*.{vue,less,postcss,css,scss}\" --cache --cache-location node_modules/.cache/stylelint/",
"test:e2e": "playwright test",
"test:e2e:ui": "playwright test --ui",
"test:e2e:headed": "playwright test --headed",
"lint:lint-staged": "lint-staged -c "
},
"dependencies": {
@ -94,7 +91,6 @@
"@commitlint/config-conventional": "^20.5.3",
"@iconify/json": "^2.2.470",
"@intlify/unplugin-vue-i18n": "^11.1.2",
"@playwright/test": "^1.59.1",
"@types/jsoneditor": "^9.9.6",
"@types/lodash-es": "^4.17.12",
"@types/node": "^25.6.0",

View File

@ -1,42 +0,0 @@
import { defineConfig, devices } from '@playwright/test'
/**
* Playwright E2E
* 使 .env.e2e // Mock
*/
export default defineConfig({
testDir: './e2e/tests',
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 1 : undefined,
reporter: 'html',
use: {
baseURL: 'http://localhost:80',
trace: 'on-first-retry',
screenshot: 'only-on-failure'
},
projects: [
// Setup project: performs login and saves auth state
{
name: 'setup',
testMatch: /auth\.setup\.ts/
},
// Main test project: depends on setup for auth state
{
name: 'chromium',
use: {
...devices['Desktop Chrome'],
storageState: 'e2e/.auth/user.json'
},
dependencies: ['setup'],
testIgnore: /auth\.setup\.ts/
}
],
webServer: {
command: 'vite --mode e2e',
url: 'http://localhost:80',
reuseExistingServer: !process.env.CI,
timeout: 120_000
}
})

View File

@ -201,9 +201,6 @@ importers:
'@intlify/unplugin-vue-i18n':
specifier: ^11.1.2
version: 11.1.2(@vue/compiler-dom@3.5.34)(eslint@10.3.0(jiti@2.6.1))(rollup@4.60.3)(typescript@6.0.3)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.3)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.2)(yaml@2.8.4))(vue-i18n@11.4.0(vue@3.5.34(typescript@6.0.3)))(vue@3.5.34(typescript@6.0.3))
'@playwright/test':
specifier: ^1.59.1
version: 1.59.1
'@types/jsoneditor':
specifier: ^9.9.6
version: 9.9.6
@ -1953,11 +1950,6 @@ packages:
resolution: {integrity: sha512-QNqXyfVS2wm9hweSYD2O7F0G06uurj9kZ96TRQE5Y9hU7+tgdZwIkbAKc5Ocy1HxEY2kuDQa6cQ1WRs/O5LFKA==}
engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0}
'@playwright/test@1.59.1':
resolution: {integrity: sha512-PG6q63nQg5c9rIi4/Z5lR5IVF7yU5MqmKaPOe0HSc0O2cX1fPi96sUQu5j7eo4gKCkB2AnNGoWt7y4/Xx3Kcqg==}
engines: {node: '>=18'}
hasBin: true
'@polka/url@1.0.0-next.29':
resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==}
@ -3965,11 +3957,6 @@ packages:
fs.realpath@1.0.0:
resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==}
fsevents@2.3.2:
resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==}
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
os: [darwin]
fsevents@2.3.3:
resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
@ -4956,16 +4943,6 @@ packages:
pkg-types@2.3.0:
resolution: {integrity: sha512-SIqCzDRg0s9npO5XQ3tNZioRY1uK06lA41ynBC1YmFTmnY6FjUjVt6s4LoADmwoig1qqD0oK8h1p/8mlMx8Oig==}
playwright-core@1.59.1:
resolution: {integrity: sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg==}
engines: {node: '>=18'}
hasBin: true
playwright@1.59.1:
resolution: {integrity: sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw==}
engines: {node: '>=18'}
hasBin: true
pngjs@5.0.0:
resolution: {integrity: sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==}
engines: {node: '>=10.13.0'}
@ -7708,10 +7685,6 @@ snapshots:
'@pkgr/core@0.2.9': {}
'@playwright/test@1.59.1':
dependencies:
playwright: 1.59.1
'@polka/url@1.0.0-next.29': {}
'@quansync/fs@1.0.0':
@ -10017,9 +9990,6 @@ snapshots:
fs.realpath@1.0.0: {}
fsevents@2.3.2:
optional: true
fsevents@2.3.3:
optional: true
@ -10967,14 +10937,6 @@ snapshots:
exsolve: 1.0.8
pathe: 2.0.3
playwright-core@1.59.1: {}
playwright@1.59.1:
dependencies:
playwright-core: 1.59.1
optionalDependencies:
fsevents: 2.3.2
pngjs@5.0.0: {}
postcss-html@1.8.1: