chore: remove e2e tests and playwright
Co-authored-by: OpenAI <support@openai.com>pull/878/head
parent
84ae85f545
commit
cd63cf2b34
30
.env.e2e
30
.env.e2e
|
|
@ -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=/
|
||||
|
|
@ -7,6 +7,3 @@ pnpm-debug
|
|||
auto-*.d.ts
|
||||
.idea
|
||||
.history
|
||||
e2e/.auth/
|
||||
test-results/
|
||||
playwright-report/
|
||||
|
|
|
|||
|
|
@ -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}"` 仍存在既有历史问题,不能单独作为依赖升级回归门禁
|
||||
|
||||
## 🐯 平台简介
|
||||
|
|
|
|||
|
|
@ -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: ''
|
||||
}
|
||||
]
|
||||
|
|
@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -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++
|
||||
}
|
||||
|
|
@ -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 请求返回空列表或 null,POST/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))
|
||||
})
|
||||
})
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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. 在页面脚本执行前注入 localStorage(web-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 })
|
||||
})
|
||||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
|
|
@ -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)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
|
|
@ -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()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
|
@ -17,7 +17,6 @@ export default tseslint.config(
|
|||
'test/unit/coverage/',
|
||||
'node_modules/',
|
||||
'src/main.ts',
|
||||
'e2e/',
|
||||
'src/types/auto-components.d.ts'
|
||||
]
|
||||
},
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
})
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
Loading…
Reference in New Issue