/// /** * A2-QA-01 · 角色管理端到端用例(真实 API + 真实 PG/Redis + 真实浏览器) * * 覆盖(对应开发计划 §6.3 A2-QA-01 / A2-FE-01 / A2-FE-02 验收要点): * ① 管理员登录 → /system/roles → 新建角色(填名称、勾 2 个权限、选「本部门」)→ 保存 → 进入编辑器 * ② 编辑器改权限点 + 授予特殊权限:特殊权限二次确认(开关一次 + 保存前一次) * ③ SoD 冲突:同时勾选 script:write × script:review → 顶部拦截文案 + 底部「保存不可用」禁用保存 * ④ 保存后 API 断言权限即时生效:绑定用户同一 access token 的 /me 立即包含新权限与特殊权限 * ⑤ 无 role:view 的账号访问 /system/roles → 浏览器 403 页(权限三态 UI 侧) * ⑥ 停用确认(勾选知晓 + 停用原因)→ 列表状态变为「停用」;API 断言用户下一次请求失去权限(40301) * * 运行前置(本地): * - API:http://127.0.0.1:3000/api/v1(真实 PG + Redis) * - Web:http://127.0.0.1:5173(`pnpm --filter @lingxi/web dev`,非 demo 会话) * - 门控:仅在 A2_E2E=1 时执行,否则整文件 skip,不影响 CI 与 e2e/smoke.spec.ts * * 运行命令: * A2_E2E=1 pnpm exec playwright test e2e/a2-roles.spec.ts --reporter=list * * 测试数据:管理员 actor 默认种子超管 jiang.lan(初始口令 lingxi2026 → 首次运行改为 * QaE2eAdmin2026,与 A1 e2e 共用约定,不要用 admin);用例自建 1 个 `qa2.roles.*` 用户与 * 1 个 `A2E2E-角色-*` 角色,结束后解绑 / 停用用户并删除角色(自建自清),不修改 admin 密码。 */ import { mkdir } from 'node:fs/promises'; import { resolve } from 'node:path'; import { type APIRequestContext, type APIResponse, expect, type Locator, type Page, request, test, } from '@playwright/test'; const API_BASE = process.env.A2_E2E_API_BASE_URL ?? 'http://127.0.0.1:3000/api/v1'; const WEB_BASE = process.env.A2_E2E_BASE_URL ?? 'http://127.0.0.1:5173'; const ENABLED = process.env.A2_E2E === '1'; const ACTOR_ACCOUNT = process.env.A2_E2E_ACTOR_ACCOUNT ?? 'jiang.lan'; const ACTOR_PASSWORD = process.env.A2_E2E_ACTOR_PASSWORD ?? 'QaE2eAdmin2026'; const SEED_PASSWORD = process.env.A2_E2E_SEED_PASSWORD ?? 'lingxi2026'; const QA_INITIAL_PASSWORD = 'QaInit2026x'; const QA_PASSWORD = 'QaRole2026x'; const SCREENSHOT_DIR = resolve('design/a2-ui-v1/shots/impl/e2e'); /** 角色 / 用户前缀:用于自清理与识别遗留数据(不影响 seed 与其他用例) */ const ROLE_PREFIX = 'A2E2E-角色-'; const QA_ACCOUNT_PREFIX = 'qa2.roles.'; test.skip(!ENABLED, 'A2_E2E=1 才运行角色管理 e2e(依赖 API :3000 + web :5173 + PG/Redis)'); test.use({ baseURL: WEB_BASE, viewport: { width: 1440, height: 900 } }); // --------------------------------------------------------------------------- // 类型与断言辅助(仅依赖 @playwright/test,避免 e2e 引入额外运行时依赖) // --------------------------------------------------------------------------- interface ApiResult { status: number; body: unknown; } interface QaUser { id: string; account: string; } let apiContext: APIRequestContext | null = null; let actorToken: string | null = null; let bootstrapError: string | null = null; let qaUser: QaUser | null = null; let departmentId = ''; let materialOperatorRoleId = ''; let createdRoleId = ''; let createdRoleName = ''; function isRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null && !Array.isArray(value); } function requireRecord(value: unknown, label: string): Record { if (!isRecord(value)) { throw new Error(`${label} 不是 JSON 对象`); } return value; } function requireString(source: Record, key: string, label: string): string { const value = source[key]; if (typeof value !== 'string' || value.length === 0) { throw new Error(`${label}.${key} 缺失或不是字符串`); } return value; } function optionalNumber(source: Record, key: string): number | null { const value = source[key]; return typeof value === 'number' ? value : null; } function stringArray(value: unknown): string[] { return Array.isArray(value) ? value.filter((item): item is string => typeof item === 'string') : []; } function requireContext(): APIRequestContext { if (apiContext === null) { throw new Error('APIRequestContext 未初始化'); } return apiContext; } function requireActor(): string { if (actorToken === null) { throw new Error('管理员会话未就绪'); } return actorToken; } function requireQaUser(): QaUser { if (qaUser === null) { throw new Error('QA 测试用户未创建'); } return qaUser; } /** 发送 API 请求:body JSON 字符串化,避免把 unknown 直接塞进 Playwright 的 Serializable 类型 */ async function apiCall( method: 'GET' | 'POST' | 'PATCH' | 'DELETE', path: string, options: { data?: unknown; token?: string } = {}, ): Promise { const context = requireContext(); const headers: Record = {}; if (options.token !== undefined) { headers.Authorization = `Bearer ${options.token}`; } let body: string | undefined; if (options.data !== undefined) { headers['Content-Type'] = 'application/json'; body = JSON.stringify(options.data); } const url = `${API_BASE}${path}`; let response: APIResponse; if (method === 'GET') { response = await context.get(url, { headers }); } else if (method === 'POST') { response = await context.post(url, { headers, data: body }); } else if (method === 'PATCH') { response = await context.patch(url, { headers, data: body }); } else { response = await context.delete(url, { headers }); } return { status: response.status(), body: await readBody(response) }; } async function readBody(response: APIResponse): Promise { const text = await response.text(); if (text.length === 0) { return null; } try { return JSON.parse(text) as unknown; } catch { return text; } } function errorInfo(body: unknown): { code: number | null; message: string } { if (!isRecord(body)) { return { code: null, message: '' }; } const message = typeof body.message === 'string' ? body.message : ''; return { code: optionalNumber(body, 'code'), message }; } interface LoginResult { status: number; accessToken: string; refreshToken: string; mustChangePassword: boolean; permissions: string[]; } function parseLoginBody(body: unknown): LoginResult { const record = requireRecord(body, '登录响应'); const me = requireRecord(record.me, '登录响应.me'); return { status: 200, accessToken: requireString(record, 'accessToken', '登录响应'), refreshToken: requireString(record, 'refreshToken', '登录响应'), mustChangePassword: me.mustChangePassword === true, permissions: stringArray(me.permissions), }; } async function loginRaw(account: string, password: string): Promise { const result = await apiCall('POST', '/auth/login', { data: { account, password } }); if (result.status !== 200) { return { status: result.status, accessToken: '', refreshToken: '', mustChangePassword: false, permissions: [], }; } return parseLoginBody(result.body); } async function changePassword( token: string, currentPassword: string, newPassword: string, ): Promise { const result = await apiCall('POST', '/auth/change-password', { token, data: { currentPassword, newPassword }, }); expect(result.status, `修改密码(HTTP ${result.status})`).toBe(204); } interface MeResult { status: number; code: number; permissions: string[]; roles: string[]; } async function getMe(token: string): Promise { const result = await apiCall('GET', '/me', { token }); if (result.status !== 200) { return { status: result.status, code: errorInfo(result.body).code ?? 0, permissions: [], roles: [], }; } const record = requireRecord(result.body, '/me'); const roles = Array.isArray(record.roles) ? record.roles .map((item) => (isRecord(item) && typeof item.code === 'string' ? item.code : null)) .filter((code): code is string => code !== null) : []; return { status: 200, code: 0, permissions: stringArray(record.permissions), roles, }; } /** 管理员 actor:优先用已改密口令,其次用种子初始口令完成一次性首登改密(与 A1 e2e 同一约定) */ async function ensureActorToken(): Promise { const attempts: { password: string; label: string }[] = [ { password: ACTOR_PASSWORD, label: '已改密口令' }, { password: SEED_PASSWORD, label: '种子初始口令' }, ]; for (const attempt of attempts) { const login = await loginRaw(ACTOR_ACCOUNT, attempt.password); if (login.status === 429) { throw new Error(`actor ${ACTOR_ACCOUNT} 登录被限流(42901),请等待锁定窗口或重建 seed`); } if (login.status !== 200) { continue; } if (!login.mustChangePassword) { return login.accessToken; } await changePassword(login.accessToken, attempt.password, ACTOR_PASSWORD); const relogin = await loginRaw(ACTOR_ACCOUNT, ACTOR_PASSWORD); expect(relogin.status, 'actor 改密后重新登录').toBe(200); return relogin.accessToken; } throw new Error( `无法用 ${ACTOR_ACCOUNT} 取得管理员会话:本地已改密时请设置 A2_E2E_ACTOR_ACCOUNT / A2_E2E_ACTOR_PASSWORD`, ); } /** 角色列表 + 内置角色 id(material_operator 用作清理时给用户解绑后的兜底角色) */ async function fetchRoleIds(token: string): Promise { const result = await apiCall('GET', '/roles?status=all', { token }); expect(result.status, 'GET /roles?status=all').toBe(200); const items = requireRecord(result.body, '角色列表').items; if (!Array.isArray(items)) { throw new Error('角色列表 items 不是数组'); } for (const item of items) { if (!isRecord(item)) { continue; } if (item.code === 'material_operator' && typeof item.id === 'string') { materialOperatorRoleId = item.id; } } if (materialOperatorRoleId === '') { throw new Error('内置角色 material_operator 缺失'); } } async function fetchDepartmentId(token: string): Promise { const result = await apiCall('GET', '/departments/tree', { token }); expect(result.status, 'GET /departments/tree').toBe(200); const items = requireRecord(result.body, '部门树').items; if (!Array.isArray(items) || items.length === 0) { throw new Error('部门树为空'); } const root = items.at(0); const children = isRecord(root) ? root.children : undefined; if (Array.isArray(children) && children.length > 0) { const leaf = children.at(0); if (isRecord(leaf) && typeof leaf.id === 'string') { departmentId = leaf.id; return; } } if (isRecord(root) && typeof root.id === 'string') { departmentId = root.id; return; } throw new Error('无法从部门树确定部门 ID'); } function randomSuffix(): string { return Math.random().toString(36).slice(2, 8); } function randomPhone(): string { const tail = String(Math.floor(10_000_000 + Math.random() * 89_999_999)); return `137${tail}`; } async function createQaUser(): Promise { const account = `${QA_ACCOUNT_PREFIX}${randomSuffix()}`; const result = await apiCall('POST', '/users', { token: requireActor(), data: { name: 'A2 角色 e2e 用户', phone: randomPhone(), account, password: QA_INITIAL_PASSWORD, departmentId, roleIds: [materialOperatorRoleId], }, }); expect(result.status, `创建 QA 用户 ${account}(HTTP ${result.status})`).toBe(201); const id = requireString(requireRecord(result.body, '创建用户响应'), 'id', '创建用户响应'); return { id, account }; } /** 首登强制改密一次性完成,后续用例可使用固定测试口令 */ async function preparePassword(user: QaUser): Promise { const first = await loginRaw(user.account, QA_INITIAL_PASSWORD); expect(first.status, `首登 ${user.account}`).toBe(200); await changePassword(first.accessToken, QA_INITIAL_PASSWORD, QA_PASSWORD); } async function updateUserRoles(userId: string, roleIds: string[]): Promise { const result = await apiCall('PATCH', `/users/${encodeURIComponent(userId)}`, { token: requireActor(), data: { roleIds }, }); expect(result.status, `更新用户角色(HTTP ${result.status})`).toBe(200); } async function disableUser(userId: string): Promise { await apiCall('POST', `/users/${encodeURIComponent(userId)}/disable`, { token: requireActor(), data: { reason: 'A2 角色 e2e 自动清理' }, }); } async function deleteRole(roleId: string): Promise { await apiCall('DELETE', `/roles/${encodeURIComponent(roleId)}`, { token: requireActor() }); } /** 清理上一次异常中断遗留的 e2e 用户与角色(前缀隔离,尽力而为) */ async function cleanupLeftovers(): Promise { const token = requireActor(); const users = await apiCall('GET', '/users?status=all&limit=100&keyword=qa2.roles.', { token }); if (users.status === 200) { const items = requireRecord(users.body, '遗留用户').items; if (Array.isArray(items)) { for (const item of items) { if (!isRecord(item) || typeof item.id !== 'string' || typeof item.account !== 'string') { continue; } if (!item.account.startsWith(QA_ACCOUNT_PREFIX)) { continue; } try { await updateUserRoles(item.id, [materialOperatorRoleId]); await disableUser(item.id); } catch { // 尽力而为:遗留数据清理失败不阻断用例,真实断言仍会暴露问题。 } } } } const roles = await apiCall( 'GET', `/roles?status=all&limit=100&keyword=${encodeURIComponent(ROLE_PREFIX)}`, { token, }, ); if (roles.status === 200) { const items = requireRecord(roles.body, '遗留角色').items; if (Array.isArray(items)) { for (const item of items) { if (!isRecord(item) || typeof item.id !== 'string' || typeof item.name !== 'string') { continue; } if (!item.name.startsWith(ROLE_PREFIX)) { continue; } try { await deleteRole(item.id); } catch { // 同上。 } } } } } // --------------------------------------------------------------------------- // 浏览器辅助 // --------------------------------------------------------------------------- async function screenshot(page: Page, name: string): Promise { await mkdir(SCREENSHOT_DIR, { recursive: true }); await page.screenshot({ path: resolve(SCREENSHOT_DIR, `${name}.png`), fullPage: false }); } async function fillLogin(page: Page, account: string, password: string): Promise { await page.goto('/login'); await page.getByPlaceholder('请输入账号或手机号').fill(account); await page.getByPlaceholder('请输入密码').fill(password); } async function submitLogin(page: Page): Promise { await page.getByTestId('login-submit').click(); } async function loginAsAdmin(page: Page): Promise { await fillLogin(page, ACTOR_ACCOUNT, ACTOR_PASSWORD); await submitLogin(page); await expect(page).toHaveURL(/\/system\/users/, { timeout: 15_000 }); } /** 权限点行(编辑器 / 新建页共用:div[role=checkbox][data-code]) */ function permissionRow(page: Page, code: string): Locator { return page.locator(`[data-code="${code}"]`); } // --------------------------------------------------------------------------- // 测试数据编排 // --------------------------------------------------------------------------- test.beforeAll(async () => { if (!ENABLED) { return; } await mkdir(SCREENSHOT_DIR, { recursive: true }); apiContext = await request.newContext(); try { const web = await apiContext.get(`${WEB_BASE}/login`); expect(web.status(), `web 未就绪:请在 ${WEB_BASE} 启动前端`).toBe(200); actorToken = await ensureActorToken(); const me = await getMe(actorToken); expect(me.status, 'actor GET /me').toBe(200); await fetchRoleIds(actorToken); await fetchDepartmentId(actorToken); await cleanupLeftovers(); qaUser = await createQaUser(); await preparePassword(qaUser); } catch (error) { bootstrapError = error instanceof Error ? error.message : String(error); } }); test.afterAll(async () => { if (!ENABLED) { return; } try { if (qaUser !== null) { // 先解绑(角色删除前必须 0 绑定用户),再停用用户 try { await updateUserRoles(qaUser.id, [materialOperatorRoleId]); } catch { // 用户可能已不存在 / 已被停用,继续尝试停用与删除角色。 } try { await disableUser(qaUser.id); } catch { // 已停用时重复停用会返回 40903。 } } if (createdRoleId !== '') { await deleteRole(createdRoleId); } } catch { // 清理失败不覆盖真实断言结果;残留数据由下一次运行 beforeAll 按前缀清理。 } await apiContext?.dispose(); apiContext = null; }); test.beforeEach(() => { test.skip(bootstrapError !== null, bootstrapError ?? '测试环境未就绪'); }); // --------------------------------------------------------------------------- // ①–⑥ 角色管理主流程(单条旅程,保证 UI 状态与 API 断言同源) // --------------------------------------------------------------------------- test('角色管理:新建 → 编辑器 → SoD 拦截 → 特殊权限二次确认 → 保存即时生效 → 停用与列表状态', async ({ page, browser, }) => { test.setTimeout(180_000); createdRoleName = `${ROLE_PREFIX}内容运营${randomSuffix()}`; // ① 管理员登录 → /system/roles await loginAsAdmin(page); await page.goto('/system/roles'); await expect(page.getByRole('heading', { name: '角色权限' })).toBeVisible(); await expect(page.getByTestId('app-shell')).toBeVisible(); await screenshot(page, '01-roles-list'); // ① 新建角色:填名称 / 说明、勾 2 个权限、选「本部门」 await page.getByRole('button', { name: '新建角色' }).first().click(); await expect(page).toHaveURL(/\/system\/roles\/new/); await expect(page.getByRole('heading', { name: '新建角色' })).toBeVisible(); await page.getByPlaceholder('例如:审核员').fill(createdRoleName); await page .getByPlaceholder('一句话说明该角色的职责') .fill('A2 e2e 自动创建,用于验证角色管理与权限即时生效'); await expect(permissionRow(page, 'script:read')).toBeVisible({ timeout: 15_000 }); await permissionRow(page, 'script:read').click(); await permissionRow(page, 'material:read').click(); // 数据范围:本部门(六类单选卡片,role=radio) await page.getByRole('radio', { name: /仅用户所属部门/ }).click(); await expect(page.getByText('当前范围:本部门')).toBeVisible(); await screenshot(page, '02-role-new-filled'); await page.getByRole('button', { name: '创建角色' }).click(); await expect(page).toHaveURL(/\/system\/roles\/(?!new)[0-9A-Za-z]{26}/, { timeout: 15_000 }); const matched = /\/system\/roles\/(?[0-9A-Za-z]{26})/.exec(page.url()); createdRoleId = matched?.groups?.id ?? ''; expect(createdRoleId, '创建成功后应进入角色编辑器并拿到角色 id').not.toBe(''); await expect(page.locator('.lx-role-editor')).toBeVisible({ timeout: 15_000 }); await expect(page.getByRole('button', { name: '停用', exact: true })).toBeVisible(); await screenshot(page, '03-role-editor-created'); // 绑定 QA 用户并登录(为「权限即时生效」准备同一 access token 的观察者) const user = requireQaUser(); await updateUserRoles(user.id, [createdRoleId]); const userLogin = await loginRaw(user.account, QA_PASSWORD); expect(userLogin.status, 'QA 用户登录').toBe(200); const tokenBefore = userLogin.accessToken; const meBefore = await getMe(tokenBefore); expect(meBefore.permissions).toContain('script:read'); expect(meBefore.permissions).toContain('material:read'); expect(meBefore.permissions).not.toContain('user:view'); // ② / ③ 编辑器权限点:先制造 SoD 冲突(script:write × script:review) await page.getByRole('tab', { name: /^权限点/ }).click(); await expect(permissionRow(page, 'script:write')).toBeVisible({ timeout: 15_000 }); await permissionRow(page, 'script:write').click(); await permissionRow(page, 'script:review').click(); await expect(page.getByText(/命中职责互斥:脚本生成 × 脚本审核/)).toBeVisible(); await expect(page.getByText('存在职责互斥,保存不可用')).toBeVisible(); const footerSave = page .locator('.lx-role-editor__foot-actions') .getByRole('button', { name: '保存', exact: true }); await expect(footerSave).toBeDisabled(); await screenshot(page, '04-sod-conflict-blocked'); // 解除冲突,改为给角色追加 user:view(普通权限) await permissionRow(page, 'script:review').click(); await permissionRow(page, 'script:write').click(); await expect(page.getByText(/命中职责互斥/)).toHaveCount(0); // 回到基线后编辑器「未做修改」、保存禁用;再加 user:view 制造新的可保存变更 await expect(footerSave).toBeDisabled(); const treeSearch = page.getByPlaceholder('搜索权限点名称或编码'); await treeSearch.fill('user:view'); await expect(permissionRow(page, 'user:view')).toBeVisible(); await permissionRow(page, 'user:view').click(); await treeSearch.fill(''); await expect(footerSave).toBeEnabled(); // ② 特殊权限:开关触发第一次二次确认(不落库,仅更新草稿) // 注:Semi Modal 复用 `semi-modal-title` id 且关闭动画期间两个弹窗会同时挂载, // getByRole('dialog', { name }) 的 aria-labelledby 会指向先挂载的旧标题, // 因此这里用「按钮 / 标题文案」定位,避免动画窗口内的可访问名串台。 await page.getByRole('tab', { name: /^特殊权限/ }).click(); await page.getByRole('switch', { name: '发起导出' }).click(); const confirmGrant = page.getByRole('button', { name: '确认授予', exact: true }); await expect(confirmGrant).toHaveCount(1); await expect(page.getByText('授予特殊权限')).toBeVisible(); await screenshot(page, '05-special-permission-confirm'); await confirmGrant.click(); // 等第一次确认弹窗卸载,避免遮罩残留拦截下一次点击 await expect(confirmGrant).toHaveCount(0); // 保存:命中特殊权限变更 → 第二次二次确认 → 保存前影响面确认 await footerSave.click(); await expect(confirmGrant).toHaveCount(1); await confirmGrant.click(); const saveConfirm = page.getByRole('button', { name: '保存并生效', exact: true }); await expect(saveConfirm).toBeVisible(); await expect(page.getByText('保存角色变更')).toBeVisible(); await expect(page.getByText('新增', { exact: true })).toBeVisible(); await screenshot(page, '06-save-confirm'); await saveConfirm.click(); await expect(page.getByText(/角色已保存/).first()).toBeVisible({ timeout: 15_000 }); await expect(page.locator('.lx-role-editor__foot').getByText('未做修改')).toBeVisible(); await screenshot(page, '07-saved'); // ④ API 断言即时生效:同一 access token 的 /me 在下一次请求即包含新权限与特殊权限 const meAfter = await getMe(tokenBefore); expect(meAfter.status).toBe(200); expect(meAfter.permissions).toContain('user:view'); expect(meAfter.permissions).toContain('delivery:export'); expect(meAfter.permissions).not.toContain('script:write'); expect(meAfter.permissions).not.toContain('script:review'); const usersAfter = await apiCall('GET', '/users', { token: tokenBefore }); expect(usersAfter.status, '授予 user:view 后绑定用户下一次请求即可访问 /users').toBe(200); const roleDetail = await apiCall('GET', `/roles/${createdRoleId}`, { token: requireActor(), }); expect(roleDetail.status).toBe(200); const roleRecord = requireRecord(roleDetail.body, '角色详情'); expect(stringArray(roleRecord.permissions).sort()).toEqual([ 'material:read', 'script:read', 'user:view', ]); expect(stringArray(roleRecord.specialPermissions)).toEqual(['delivery:export']); expect(roleRecord.userCount).toBe(1); // ⑤ 权限三态 UI 侧:绑定用户有 user:view 但没有 role:view → /system/roles 显示 403 页 const scopedContext = await browser.newContext({ baseURL: WEB_BASE, viewport: { width: 1440, height: 900 }, }); try { const scopedPage = await scopedContext.newPage(); await fillLogin(scopedPage, user.account, QA_PASSWORD); await submitLogin(scopedPage); await expect(scopedPage).toHaveURL(/\/system\/users/, { timeout: 15_000 }); await scopedPage.goto('/system/roles'); await expect(scopedPage).toHaveURL(/\/403/, { timeout: 15_000 }); await expect(scopedPage.getByTestId('error-403')).toBeVisible(); await screenshot(scopedPage, '08-roles-forbidden'); } finally { await scopedContext.close(); } // ⑥ 停用确认:勾选知晓 + 填原因 → 停用 await page.getByRole('button', { name: '停用', exact: true }).click(); const disableDialog = page.getByRole('dialog', { name: '停用角色' }); await expect(disableDialog).toBeVisible(); await screenshot(page, '09-disable-confirm'); await disableDialog.getByPlaceholder('例如:岗位调整、角色合并').fill('A2 e2e 验证停用即时生效'); await disableDialog.getByText('我已知晓停用后这些用户下一次请求即失去该角色权限').click(); await disableDialog.getByRole('button', { name: '停用角色', exact: true }).click(); await expect(page.getByText(/已停用,1 名用户下一次请求生效/).first()).toBeVisible({ timeout: 15_000, }); await expect(page.locator('.lx-status-tag.is-disabled').first()).toBeVisible(); await screenshot(page, '10-role-disabled-editor'); // ⑥ 停用后 API:绑定用户下一次请求失去权限(40301),/me 权限清空 const meDisabled = await getMe(tokenBefore); expect(meDisabled.status).toBe(200); expect(meDisabled.permissions).toEqual([]); const usersDenied = await apiCall('GET', '/users', { token: tokenBefore }); expect(usersDenied.status, '停用角色后绑定用户访问 /users').toBe(403); expect(errorInfo(usersDenied.body).code).toBe(40301); // ⑥ 列表状态变化:停用筛选下可见该角色,且行内标记「停用」 await page.goto('/system/roles'); await expect(page.getByRole('heading', { name: '角色权限' })).toBeVisible(); const disabledSeg = page .getByRole('group', { name: '角色状态' }) .getByRole('button', { name: /^停用/ }); await disabledSeg.click(); const roleRow = page.locator('.lx-role-row', { hasText: createdRoleName }); await expect(roleRow).toBeVisible(); await expect(roleRow).toContainText('停用'); await screenshot(page, '11-roles-list-disabled'); });