import { test, expect, type Page, type TestInfo } from '@playwright/test' import fs from 'node:fs' import path from 'node:path' // Local helper reads credentials in memory. Reports and Playwright traces never contain them. // @ts-ignore JS helper is also executable independently for API checks. import { call, login, work } from '../tools/role-permissions-live.mjs' const shots = path.resolve('reports/role-permissions-20260906/screenshots') fs.mkdirSync(shots, { recursive: true }) const fixture = () => JSON.parse(fs.readFileSync(path.join(work, 'fixtures.json'), 'utf8')) const networkFailures = new WeakMap>() test.beforeEach(async ({ page }) => { const failures: Array<{ path: string; status?: number; error?: string }> = [] networkFailures.set(page, failures) page.on('requestfailed', request => { if (new URL(request.url()).pathname.startsWith('/api/')) failures.push({ path: new URL(request.url()).pathname, error: request.failure()?.errorText }) }) page.on('response', response => { if (response.status() >= 400 && new URL(response.url()).pathname.startsWith('/api/')) failures.push({ path: new URL(response.url()).pathname, status: response.status() }) }) }) test.afterEach(async ({ page }, info) => { await info.attach('sanitized-api-failures', { body: JSON.stringify(networkFailures.get(page) ?? []), contentType: 'application/json' }) const token = await page.evaluate(() => sessionStorage.getItem('unreal-tran:web:access-token:v1') || localStorage.getItem('unreal-tran:web:access-token:v1')).catch(() => null) if (token) await call('auth/v1/auth/logout', token, {}).catch(() => undefined) }) const shot = async (page: Page, info: TestInfo, name: string) => { const filename = path.join(shots, `${name}.png`) await expect(page.locator('.el-loading-mask:visible')).toHaveCount(0) await page.screenshot({ path: filename, fullPage: true, animations: 'disabled' }) await info.attach(name, { path: filename, contentType: 'image/png' }) } async function signIn(page: Page, role: 'admin' | 'reader' | 'editor' | 'teacher' = 'admin') { let session if (role === 'admin') session = await login() else { const data = fixture() const result = await call('auth/v1/auth/login', null, { userId: data.user.id, password: data.user.password, roleCode: 'teacher', departmentId: data.user.departmentId, rememberMe: false }) expect(result.status).toBe(200) const selected = await call('auth/v1/auth/active-role', result.data.accessToken, { roleId: role === 'teacher' ? data.teacherRoleId : data[role].id }, 'PUT') expect(selected.status).toBe(200) session = selected.data?.accessToken ? selected.data : result.data } await page.addInitScript(({ accessToken, refreshToken }) => { // Only initialize once: a role switch may rotate tokens before a full app reload. if (!sessionStorage.getItem('role-permissions-test-initialized')) { sessionStorage.setItem('unreal-tran:web:access-token:v1', accessToken) if (refreshToken) sessionStorage.setItem('unreal-tran:web:refresh-token:v1', refreshToken) sessionStorage.setItem('role-permissions-test-initialized', '1') } }, { accessToken: session.accessToken, refreshToken: session.refreshToken }) } async function openSwitcher(page: Page) { await page.getByRole('button', { name: /^账号菜单:/ }).click() await page.getByRole('menuitem', { name: '切换角色', exact: true }).click() const dialog = page.getByRole('dialog', { name: '切换角色', exact: true }) await expect(dialog).toBeVisible() return dialog } const treeNode = (container: ReturnType, code: string) => container.locator('.el-tree-node__content').filter({ has: container.page().locator('code').filter({ hasText: new RegExp(`^${code.replaceAll('.', '\\.')}$$`) }) }) test('管理员授权固定,权限树细分到页面操作', async ({ page }, info) => { await signIn(page) await page.goto('/system/roles') const adminRow = page.locator('.el-table__row').filter({ has: page.locator('.role-identity code').filter({ hasText: /^admin$/ }) }) await adminRow.getByRole('button', { name: '配置权限', exact: true }).click() const drawer = page.getByRole('dialog', { name: '配置角色权限' }) await expect(drawer).toContainText('该角色采用固定授权') await drawer.getByPlaceholder('搜索权限名称或编码').fill('content.model') await expect(drawer.locator('code').filter({ hasText: /^content\.model\.create$/ })).toBeVisible() await expect(drawer.getByRole('button', { name: '保存授权' })).toBeDisabled() await expect(treeNode(drawer, 'content.model.create').getByRole('checkbox')).toBeDisabled() await shot(page, info, '01-admin-fixed-permissions') }) test('权限依赖:勾选操作自动补齐查看,取消查看需要确认', async ({ page }, info) => { await signIn(page) const data = fixture() await page.goto('/system/roles') const row = page.locator('.el-table__row').filter({ has: page.locator('.role-identity code').filter({ hasText: data.reader.code }) }) await row.getByRole('button', { name: '配置权限', exact: true }).click() const drawer = page.getByRole('dialog', { name: '配置角色权限' }) const editor = drawer.locator('.permission-tree-editor') await editor.getByPlaceholder('搜索权限名称或编码').fill('content.model') const read = treeNode(editor, 'content.model').getByRole('checkbox') const create = treeNode(editor, 'content.model.create').getByRole('checkbox') await expect(read).toBeChecked() await expect(create).not.toBeChecked() await treeNode(editor, 'content.model').locator('.el-checkbox').click() await expect(read).not.toBeChecked() await treeNode(editor, 'content.model.create').locator('.el-checkbox').click() await expect(read).toBeChecked() await expect(create).toBeChecked() await shot(page, info, '02-action-adds-page-permission') await treeNode(editor, 'content.model').locator('.el-checkbox').click() const confirm = page.getByRole('dialog', { name: '确认取消关联权限' }) await expect(confirm).toBeVisible() await shot(page, info, '03-removing-page-confirms-dependent-actions') await confirm.getByRole('button', { name: '保留权限' }).click() await expect(read).toBeChecked() await expect(create).toBeChecked() await drawer.getByRole('button', { name: '保存授权' }).click() await expect(drawer).not.toBeVisible() await row.getByRole('button', { name: '配置权限', exact: true }).click() await editor.getByPlaceholder('搜索权限名称或编码').fill('content.model') await expect(create).toBeChecked() await treeNode(editor, 'content.model.create').locator('.el-checkbox').click() await expect(create).not.toBeChecked() await drawer.getByRole('button', { name: '保存授权' }).click() await expect(drawer).not.toBeVisible() }) test('头像弹窗切换自定义角色,清理旧缓存并改变操作能力', async ({ page }, info) => { await signIn(page, 'reader') await page.goto('/content/models') await expect(page.getByText('只读权限', { exact: true })).toBeVisible() await shot(page, info, '04-custom-reader-model-list') const dialog = await openSwitcher(page) await expect(dialog.getByRole('button', { name: /权限实测·模型只读/ })).toContainText('当前角色') await shot(page, info, '05-avatar-role-switch-dialog') await dialog.getByRole('button', { name: /权限实测·模型编辑/ }).click() await expect(page).toHaveURL(/\/dashboard$/) await expect(page.getByRole('button', { name: /^账号菜单:/ })).toContainText('权限实测·模型编辑') await page.goto('/content/models') await expect(page.getByText('只读权限', { exact: true })).toHaveCount(0) await expect(page.getByRole('button', { name: /新建模型|新增模型/ }).first()).toBeVisible() await shot(page, info, '06-custom-editor-model-list') const scopes = await page.evaluate(() => Object.keys(JSON.parse(localStorage.getItem('unreal-tran:web:tabs:v2') || '{}'))) const data = fixture() expect(scopes).toContain(`${data.user.id}:${data.reader.id}`) expect(scopes).toContain(`${data.user.id}:${data.editor.id}`) await page.goto('/system/roles') await expect(page).not.toHaveURL(/\/system\/roles$/) }) test('角色切换接口失败保留原身份与页面', async ({ page }, info) => { await signIn(page, 'reader') await page.goto('/content/models') await expect(page.getByText('只读权限', { exact: true })).toBeVisible() await page.route('**/api/auth/v1/auth/active-role', route => route.fulfill({ status: 503, contentType: 'application/json', body: JSON.stringify({ code: 'UNAVAILABLE', message: '角色服务临时不可用', data: null }) })) const dialog = await openSwitcher(page) await dialog.getByRole('button', { name: /权限实测·模型编辑/ }).click() await expect(page.locator('.el-message--error')).toContainText('角色服务临时不可用') await expect(page.getByRole('button', { name: /^账号菜单:/ })).toContainText('权限实测·模型只读') await expect(page).toHaveURL(/\/content\/models$/) await shot(page, info, '07-failed-switch-keeps-current-role') }) test('切换已提交但身份加载失败,不展示旧角色业务页面', async ({ page }, info) => { await signIn(page, 'reader') await page.goto('/content/models') await expect(page.getByText('只读权限', { exact: true })).toBeVisible() await page.route('**/api/auth/v1/auth/me', route => route.fulfill({ status: 503, contentType: 'application/json', body: JSON.stringify({ code: 'UNAVAILABLE', message: '身份信息临时不可用', data: null }) })) const dialog = await openSwitcher(page) await dialog.getByRole('button', { name: /权限实测·模型编辑/ }).click() await expect(page).toHaveURL(/\/login/) await expect(page.getByRole('button', { name: /^账号菜单:/ })).toHaveCount(0) await shot(page, info, '08-role-refresh-failure-clears-old-view') }) test('训练编排未保存时,取消切换保留当前内容与身份', async ({ page }, info) => { await signIn(page, 'teacher') await page.goto('/content/training-projects/164/legacy') await page.getByRole('button', { name: '项目设置', exact: true }).click() const name = page.locator('[data-field="name"][data-scope="project"]') await expect(name).toBeVisible() await name.fill('权限切换未保存验证') await name.blur() await page.locator('[data-action="close-dialog"]').filter({ hasText: '完成' }).click() const switcher = await openSwitcher(page) let submitted = 0 page.on('request', request => { if (request.url().endsWith('/auth/active-role') && request.method() === 'PUT') submitted++ }) await switcher.getByRole('button', { name: /权限实测·模型只读/ }).click() const warning = page.getByRole('dialog', { name: '离开训练编排?', exact: true }) await expect(warning).toBeVisible() await shot(page, info, '09-unsaved-training-blocks-role-switch') await warning.getByRole('button', { name: /close|关闭/i }).click() await expect(warning).not.toBeVisible() await expect(page.getByRole('button', { name: /^账号菜单:/ })).toContainText('教员') expect(submitted).toBe(0) await switcher.getByRole('button', { name: '关闭', exact: true }).click() await page.getByRole('button', { name: '项目设置', exact: true }).click() await expect(name).toHaveValue('权限切换未保存验证') }) test('未保存训练的保存请求失败时,阻止提交角色切换', async ({ page }, info) => { await signIn(page, 'teacher') await page.goto('/content/training-projects/164/legacy') await page.getByRole('button', { name: '项目设置', exact: true }).click() const name = page.locator('[data-field="name"][data-scope="project"]') await name.fill('保存失败角色切换验证') await name.blur() await page.locator('[data-action="close-dialog"]').filter({ hasText: '完成' }).click() let roleWrites = 0 let failedSaves = 0 page.on('request', request => { if (request.url().endsWith('/auth/active-role') && request.method() === 'PUT') roleWrites++ }) await page.route('**/api/tran/v1/content/projects/164', async route => { if (route.request().method() !== 'PUT') return route.continue() failedSaves++ return route.fulfill({ status: 503, contentType: 'application/json', body: JSON.stringify({ code: 'UNAVAILABLE', message: '实测保存失败,请稍后重试', data: null }) }) }) const switcher = await openSwitcher(page) await switcher.getByRole('button', { name: /权限实测·模型只读/ }).click() await page.getByRole('dialog', { name: '离开训练编排?', exact: true }).getByRole('button', { name: '保存并离开' }).click() await expect.poll(() => failedSaves).toBe(1) await expect(page.locator('.el-message--error')).toContainText('实测保存失败') await expect(page.getByRole('button', { name: /^账号菜单:/ })).toContainText('教员') expect(roleWrites).toBe(0) await shot(page, info, '09b-save-failure-blocks-role-switch') await switcher.getByRole('button', { name: '关闭', exact: true }).click() await page.getByRole('button', { name: '项目设置', exact: true }).click() await expect(name).toHaveValue('保存失败角色切换验证') })