|
- import { expect, test as base, type Page, type Route, type TestInfo } from '@playwright/test'
- import fs from 'node:fs'
- import path from 'node:path'
-
- /** UI/error-contract regression only. Synthetic authentication and API responses;
- * no real accounts, real API writes, or training execution are involved.
- */
- type Json = Record<string, unknown>
- const ROOT = path.resolve('reports/api-error-messages-20260906')
- const SHOTS = path.join(ROOT, 'screenshots')
- fs.mkdirSync(SHOTS, { recursive: true })
-
- const permissions = [
- 'content.training', 'content.model', 'content.scene', 'content.ofd', 'content.create', 'content.update', 'content.publish',
- 'system.users', 'system.users.create', 'system.roles', 'system.departments',
- 'teaching.physical', 'teaching.virtual', 'teaching.confrontation', 'teaching.tasks.create', 'teaching.tasks.publish',
- ]
- const roles = [
- { id: 'mock-teacher', code: 'teacher', name: '教员', shortName: '教', status: 1, builtIn: 1, isSuperAdmin: 0, dataScopeCode: 'ALL', permissions },
- { id: 'mock-admin', code: 'admin', name: '系统管理员', shortName: '管', status: 1, builtIn: 1, isSuperAdmin: 0, dataScopeCode: 'ALL', permissions },
- ]
- const envelope = (data: unknown) => ({ code: 200, message: 'OK', data, timestamp: Date.now(), requestId: 'mock-message-regression' })
- const ok = (route: Route, data: unknown) => route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(envelope(data)) })
- const fail = (route: Route, message: string, status = 503, code: number | string = 'MOCK_UNAVAILABLE', requestId = 'mock-message-regression') => route.fulfill({
- status, contentType: 'application/json', body: JSON.stringify({ code, message, data: null, timestamp: Date.now(), requestId }),
- })
- const pageOf = (records: unknown[]) => ({ records, total: records.length, page: 1, size: 10 })
- const project = {
- id: 'message-training-901', type: 'TRAINING', code: 'MESSAGE-REGRESSION-901', name: 'Message 回归训练',
- description: '浏览器隔离测试数据', categoryCode: 'MEDIUM_REPAIR', coverUri: '', status: 'DRAFT',
- trainingMode: 'VIRTUAL', version: 1, currentVersionId: 'mock-version', currentVersionNo: 1,
- currentVersionStatus: 'DRAFT', ownerUserId: 'mock-user', ownerName: '测试教员', departmentId: 'mock-department',
- addTime: 1788624000, updateTime: 1788624000, dependencyCount: 0, assetCount: 0,
- content: { level: '初级', durationMinutes: 10, totalScore: 100, steps: [] },
- }
-
- class ApiMock {
- projectError = ''
- projectDetailError = ''
- systemError = ''
- assignmentError = ''
- assignmentDetailError = ''
- holdProjects = false
- readonly heldProjects: Route[] = []
- readonly requests: Array<{ method: string; path: string }> = []
- readonly unexpected: string[] = []
- readonly writes: string[] = []
- readonly pageErrors: string[] = []
-
- async install(page: Page) {
- page.on('pageerror', error => this.pageErrors.push(error.message))
- await page.addInitScript(() => {
- sessionStorage.setItem('unreal-tran:web:access-token:v1', 'synthetic-message-test-token-not-valid')
- })
- await page.route(url => url.pathname.startsWith('/api/'), async route => {
- const request = route.request()
- const target = new URL(request.url()).pathname
- this.requests.push({ method: request.method(), path: target })
- if (request.method() !== 'GET') {
- this.writes.push(`${request.method()} ${target}`)
- return fail(route, '测试禁止业务写入', 405)
- }
- if (target === '/api/auth/v1/auth/me') return ok(route, {
- user: { id: 'mock-user', username: 'message.regression', displayName: '消息回归测试', departmentId: 'mock-department', departmentName: '测试部门', mustChangePassword: false, version: 1 },
- roles, activeRoleId: 'mock-teacher', permissions, authorizationMode: 'UNION', loginTime: 1788624000,
- })
- if (target === '/api/auth/v1/menus/navigation') return ok(route, [])
- if (target === '/api/auth/v1/system-config/public') return ok(route, {
- systemName: '装备数字车间', loginDescription: '', defaultTheme: 'light', digitalHumanWidget: null,
- })
- if (target === '/api/tran/v1/content/projects/summary') return ok(route, { total: 1, draft: 1, review: 0, published: 0, byType: {}, byTrainingMode: { VIRTUAL: { total: 1, draft: 1 } } })
- if (target === '/api/tran/v1/content/projects') {
- if (this.holdProjects) { this.heldProjects.push(route); return }
- return this.projectError ? fail(route, this.projectError) : ok(route, pageOf([project]))
- }
- if (target === '/api/tran/v1/content/projects/message-training-901') return fail(route, this.projectDetailError || '测试工程详情不可用')
- if (target === '/api/tran/v1/content/catalog') return ok(route, pageOf([]))
- if (['/api/auth/v1/users', '/api/auth/v1/users/stats', '/api/auth/v1/departments/tree', '/api/auth/v1/roles/all'].includes(target)) {
- if (this.systemError) return fail(route, this.systemError)
- if (target.endsWith('/users')) return ok(route, pageOf([]))
- if (target.endsWith('/stats')) return ok(route, { total: 0, enabled: 0, disabled: 0, passwordPending: 0 })
- if (target.endsWith('/roles/all')) return ok(route, roles)
- return ok(route, [{ id: 'mock-department', name: '测试部门', parentId: null, status: 1, children: [] }])
- }
- if (target === '/api/tran/v1/teaching/assignments') return this.assignmentError ? fail(route, this.assignmentError) : ok(route, pageOf([]))
- if (target === '/api/tran/v1/teaching/assignments/summary') return ok(route, { total: 0, draft: 0, published: 0, active: 0, completed: 0, archived: 0, byChannel: {} })
- if (target === '/api/tran/v1/teaching/runs') return ok(route, pageOf([]))
- if (target === '/api/tran/v1/teaching/assignments/mock-physical') return fail(route, this.assignmentDetailError || '测试任务上下文不可用')
- if (target.startsWith('/api/auth/v1/__message_e2e/')) {
- if (target.endsWith('/timeout')) {
- await new Promise(resolve => setTimeout(resolve, 400))
- return ok(route, {}).catch(() => undefined)
- }
- if (target.endsWith('/forbidden')) return fail(route, '无权访问测试数据', 403, 'ACCESS_DENIED', 'mock-403')
- if (target.endsWith('/conflict')) return fail(route, '版本已变化,请重新加载', 409, 'VERSION_CONFLICT', 'mock-409')
- if (target.endsWith('/business-text')) return fail(route, 'Network Error', 400, 'ASSET_VALIDATION', 'mock-business')
- }
- this.unexpected.push(target)
- return fail(route, '测试未覆盖的接口', 404)
- })
- }
- }
-
- const test = base.extend<{ api: ApiMock }>({
- api: [async ({ page }, use) => {
- const api = new ApiMock()
- await api.install(page)
- await use(api)
- }, { auto: true }],
- })
-
- const shot = async (page: Page, info: TestInfo, name: string) => {
- const target = path.join(SHOTS, `${name}.png`)
- await page.screenshot({ path: target, fullPage: true, animations: 'disabled' })
- await info.attach(name, { path: target, contentType: 'image/png' })
- }
- const message = (page: Page, text: string) => page.locator('.el-message--error').filter({ hasText: text })
- const noPersistentError = async (page: Page, text: string) => {
- await expect(page.locator('.el-alert--error')).toHaveCount(0)
- await expect(page.locator('body')).not.toContainText(text)
- }
- test.afterEach(async ({ api }, info) => {
- await info.attach('API 隔离与浏览器错误', { body: JSON.stringify({ requests: api.requests, unexpected: api.unexpected, writes: api.writes, pageErrors: api.pageErrors }, null, 2), contentType: 'application/json' })
- expect(api.unexpected, '所有 API 请求都有明确 mock 合同').toEqual([])
- expect(api.writes, '没有发起真实或模拟业务写入').toEqual([])
- expect(api.pageErrors, '没有未捕获浏览器异常').toEqual([])
- })
-
- test('内容列表:错误自动消失、重试恢复、再次失败保留旧数据', async ({ page, api }, info) => {
- const text = '测试:训练列表服务暂不可用'
- api.projectError = text
- await page.goto('/content/training-projects')
- await expect(message(page, text)).toBeVisible()
- await expect(message(page, text).locator('.el-message__closeBtn')).toBeVisible()
- await shot(page, info, '01-content-error-message')
- await expect(message(page, text)).toBeHidden({ timeout: 8_000 })
- await noPersistentError(page, text)
- await expect(page.getByRole('button', { name: '重新加载', exact: true })).toBeVisible()
- api.projectError = ''
- await page.getByRole('button', { name: '重新加载', exact: true }).click()
- await expect(page.locator('.training-project-card')).toContainText(project.name)
- api.projectError = text
- await page.getByRole('button', { name: '刷新', exact: true }).click()
- await expect(message(page, text)).toBeVisible()
- await expect(page.locator('.training-project-card')).toContainText(project.name)
- await expect(message(page, text)).toBeHidden({ timeout: 8_000 })
- await noPersistentError(page, text)
- await shot(page, info, '02-content-old-data-retained')
- })
-
- test('系统用户:并行同错合并一条,重复搜索可再次提示', async ({ page, api }, info) => {
- const text = '测试:用户目录服务暂不可用'
- api.systemError = text
- await page.goto('/system/users')
- await expect(message(page, text)).toHaveCount(1)
- await expect(message(page, text)).toBeVisible()
- await expect.poll(() => api.requests.filter(entry => ['/api/auth/v1/users', '/api/auth/v1/users/stats', '/api/auth/v1/departments/tree', '/api/auth/v1/roles/all'].includes(entry.path)).length).toBe(4)
- await expect(message(page, text)).toHaveCount(1)
- await shot(page, info, '03-users-grouped-message')
- await expect(message(page, text)).toBeHidden({ timeout: 8_000 })
- await noPersistentError(page, text)
- await page.getByRole('button', { name: '搜索', exact: true }).click()
- await expect(message(page, text)).toHaveCount(1)
- await expect(message(page, text)).toBeVisible()
- await expect(message(page, text)).toBeHidden({ timeout: 8_000 })
- await shot(page, info, '04-users-retry-without-banner')
- })
-
- test('训练编辑器:加载失败提示消失后保留重新加载按钮', async ({ page, api }, info) => {
- const text = '测试:训练工程加载失败'
- api.projectDetailError = text
- await page.goto('/content/training-projects/message-training-901/legacy')
- await expect(message(page, text)).toBeVisible()
- await shot(page, info, '05-legacy-training-message')
- await expect(message(page, text)).toBeHidden({ timeout: 8_000 })
- await noPersistentError(page, text)
- await expect(page.getByRole('button', { name: '重新加载', exact: true })).toBeVisible()
- await shot(page, info, '06-legacy-training-retry')
- })
-
- test('实装预览:任务上下文错误用 Message,离线与教员模式持续可见', async ({ page, api }, info) => {
- const text = '测试:实装任务上下文读取失败'
- api.assignmentDetailError = text
- await page.goto('/teaching/physical-training/preview/mock-physical')
- await expect(message(page, text)).toBeVisible()
- await expect(page.locator('.legacy-physical-page__warning')).toHaveText('离线识别回放')
- await expect(page.locator('.legacy-physical-page__notice')).toContainText('教员预览态')
- await shot(page, info, '07-physical-context-message')
- await expect(message(page, text)).toBeHidden({ timeout: 8_000 })
- await noPersistentError(page, text)
- await expect(page.locator('.legacy-physical-page__warning')).toBeVisible()
- await shot(page, info, '08-physical-mode-preserved')
- })
-
- test('新建训练:业务说明与必填校验保留,不发起创建请求', async ({ page }, info) => {
- await page.goto('/content/training-projects')
- await expect(page.locator('.training-project-card')).toBeVisible()
- await page.getByRole('button', { name: '新建训练项目', exact: true }).click()
- const dialog = page.getByRole('dialog')
- await expect(dialog.locator('.el-alert--info')).toContainText('项目编码将在创建后由系统自动生成')
- await dialog.getByRole('button', { name: '创建并编辑', exact: true }).click()
- await expect(dialog.locator('.el-form-item__error').filter({ hasText: '请输入项目名称' })).toBeVisible()
- await shot(page, info, '09-create-validation-preserved')
- })
-
- test('缓存列表:失活时晚到错误不弹出,返回不重放旧错误', async ({ page, api }, info) => {
- const text = '测试:失活页面晚到错误'
- api.holdProjects = true
- await page.goto('/content/training-projects')
- await expect.poll(() => api.heldProjects.length).toBeGreaterThan(0)
- await page.evaluate(() => {
- const state = window as unknown as { messageObservations: string[] }
- state.messageObservations = []
- new MutationObserver(() => document.querySelectorAll('.el-message').forEach(node => state.messageObservations.push(node.textContent || '')))
- .observe(document.body, { childList: true, subtree: true })
- })
- await page.getByRole('button', { name: '系统管理', exact: true }).click()
- await expect(page.locator('.users-table')).toBeVisible()
- api.holdProjects = false
- await Promise.all(api.heldProjects.splice(0).map(route => fail(route, text)))
- await expect.poll(() => page.evaluate(async () => {
- const modulePath = '/src/stores/content.ts'
- const { useContentStore } = await import(modulePath)
- return useContentStore().errorFor('projects:TRAINING')
- })).toBe(text)
- await expect(message(page, text)).toHaveCount(0)
- await shot(page, info, '10-inactive-page-no-message')
- await page.getByRole('tab', { name: '训练编排', exact: true }).click()
- await expect(page.locator('.training-project-card')).toBeVisible()
- await expect(message(page, text)).toHaveCount(0)
- expect(await page.evaluate(value => (window as unknown as { messageObservations: string[] }).messageObservations.filter(entry => entry.includes(value)), text)).toEqual([])
- await shot(page, info, '11-reactivated-no-stale-message')
- })
-
- test('HTTP 合同:真实 Axios 超时中文化,403/409 元数据保留,静默请求不自动弹窗', async ({ page }, info) => {
- await page.goto('/content/training-projects')
- await expect(page.locator('.training-project-card')).toBeVisible()
- const errors = await page.evaluate(async () => {
- const modulePath = '/src/api/http.ts'
- const { requestData } = await import(modulePath)
- const result = []
- for (const endpoint of ['timeout', 'forbidden', 'conflict']) {
- try { await requestData({ method: 'GET', url: `/__message_e2e/${endpoint}`, timeout: endpoint === 'timeout' ? 80 : 1_000 }) }
- catch (cause) {
- const error = cause as { message: string; code: string; status?: number; requestId: string }
- result.push({ endpoint, message: error.message, code: error.code, status: error.status ?? null, requestId: error.requestId })
- }
- }
- return result
- })
- expect(errors).toEqual([
- { endpoint: 'timeout', message: '请求超时,请稍后重试', code: 'NETWORK_ERROR', status: null, requestId: '' },
- { endpoint: 'forbidden', message: '无权访问测试数据', code: 'ACCESS_DENIED', status: 403, requestId: 'mock-403' },
- { endpoint: 'conflict', message: '版本已变化,请重新加载', code: 'VERSION_CONFLICT', status: 409, requestId: 'mock-409' },
- ])
- await expect(page.locator('.el-message')).toHaveCount(0)
- await info.attach('HTTP 合同证据', { body: JSON.stringify(errors, null, 2), contentType: 'application/json' })
- const cancellation = await page.evaluate(async () => {
- const httpPath = '/src/api/http.ts'
- const messagePath = '/src/utils/errorMessage.ts'
- const { requestData } = await import(httpPath)
- const { showErrorMessage } = await import(messagePath)
- const controller = new AbortController()
- setTimeout(() => controller.abort(), 20)
- try { await requestData({ method: 'GET', url: '/__message_e2e/timeout', signal: controller.signal, timeout: 1_000 }) }
- catch (cause) {
- showErrorMessage(cause)
- const error = cause as { name: string; code: string }
- return { name: error.name, code: error.code }
- }
- return null
- })
- expect(cancellation).toEqual({ name: 'CanceledError', code: 'ERR_CANCELED' })
- await expect(page.locator('.el-message')).toHaveCount(0)
- await shot(page, info, '12-http-silent-errors')
- const businessError = await page.evaluate(async () => {
- const httpPath = '/src/api/http.ts'
- const messagePath = '/src/utils/errorMessage.ts'
- const { requestData } = await import(httpPath)
- const { showErrorMessage } = await import(messagePath)
- try { await requestData({ method: 'GET', url: '/__message_e2e/business-text' }) }
- catch (cause) {
- showErrorMessage(cause)
- const error = cause as { message: string; code: string; requestId: string; status: number }
- return { message: error.message, code: error.code, requestId: error.requestId, status: error.status }
- }
- return null
- })
- expect(businessError).toEqual({ message: 'Network Error', code: 'ASSET_VALIDATION', requestId: 'mock-business', status: 400 })
- await expect(message(page, 'Network Error')).toBeVisible()
- await shot(page, info, '15-business-message-keeps-original-text')
- await info.attach('取消请求与业务原文证据', { body: JSON.stringify({ cancellation, businessError }, null, 2), contentType: 'application/json' })
- })
-
- test('局域网教学列表:既有 ElMessage.error 使用全局关闭和自动消失配置', async ({ page, api }, info) => {
- const text = '测试:局域网教学列表读取失败'
- api.assignmentError = text
- await page.goto('http://192.168.31.168:6180/teaching/physical-training')
- await expect(message(page, text)).toBeVisible()
- await expect(message(page, text).locator('.el-message__closeBtn')).toBeVisible()
- await shot(page, info, '13-lan-teaching-message')
- await expect(message(page, text)).toBeHidden({ timeout: 8_000 })
- await noPersistentError(page, text)
- await shot(page, info, '14-lan-teaching-after-dismiss')
- })
-
- test('模型桥接 DOM 合同:初次错误与晚写详情转为重试说明,ready 恢复交互', async ({ page }, info) => {
- await page.route(url => url.pathname === '/__message_bridge_test', route => route.fulfill({
- status: 200, contentType: 'text/html', body: `<!doctype html><html lang="zh-CN"><meta charset="utf-8"><title>模型加载桥接 DOM 测试夹具</title><style>body{font:16px system-ui;background:#f0f5f4;color:#244d45;padding:35px}h1{font-size:24px}.editor-shell{max-width:900px;margin:25px auto;border:1px solid #a1bdb7;border-radius:10px;overflow:hidden}.topbar{padding:16px;background:white}#render-host{height:360px;background:#203b42}canvas{width:100%;height:100%}#loading-overlay{position:absolute;inset:0;display:grid;place-content:center;justify-items:center;gap:15px;background:#13292deb;color:white}.hidden{display:none!important}button{padding:9px 15px}</style><h1>模型加载桥接 DOM 合同</h1><p>浏览器测试夹具,仅验证真实宿主桥接的错误文案、重试与交互恢复;不代表加载真实 GLB。</p><div class="editor-shell" data-model-api-state="loading"><div class="topbar"><button>测试编辑工具</button></div><div id="render-host"><canvas width="900" height="360"></canvas></div><div id="loading-overlay"><span class="loading-ring"></span><b class="loading-title">正在载入工程模型</b><span id="loading-progress">初始化</span></div></div></html>`,
- }))
- await page.goto('/__message_bridge_test')
- await page.evaluate(async () => {
- const state = window as unknown as { editorApp: { renderer: { domElement: Element | null } }; bridgeRetries: number }
- state.editorApp = { renderer: { domElement: document.querySelector('canvas') } }
- state.bridgeRetries = 0
- const modulePath = '/src/features/editors/model/legacy-iframe-loading-scope.ts'
- const { installLegacyModelIframeLoadingScope } = await import(modulePath)
- installLegacyModelIframeLoadingScope(document, { onRetry: () => { state.bridgeRetries += 1 } })
- document.querySelector<HTMLElement>('.editor-shell')!.dataset.modelApiState = 'error'
- document.querySelector('#loading-progress')!.textContent = '测试:初次模型接口错误详情'
- })
- await expect(page.locator('#loading-progress')).toHaveText('请重新加载工程')
- await expect(page.locator('body')).not.toContainText('测试:初次模型接口错误详情')
- expect(await page.locator('.topbar').evaluate(element => (element as HTMLElement).inert)).toBe(true)
- await page.locator('#loading-progress').evaluate(element => { element.textContent = '测试:晚写模型接口错误详情' })
- await expect(page.locator('#loading-progress')).toHaveText('请重新加载工程')
- await expect(page.locator('body')).not.toContainText('测试:晚写模型接口错误详情')
- await page.getByRole('button', { name: '重新加载工程', exact: true }).click()
- expect(await page.evaluate(() => (window as unknown as { bridgeRetries: number }).bridgeRetries)).toBe(1)
- await shot(page, info, '16-model-bridge-neutral-retry')
- await page.locator('.editor-shell').evaluate(element => { (element as HTMLElement).dataset.modelApiState = 'ready' })
- await expect(page.locator('#loading-overlay')).toBeHidden()
- expect(await page.locator('.topbar').evaluate(element => (element as HTMLElement).inert)).toBe(false)
- await expect(page.locator('.editor-shell')).toHaveAttribute('data-model-project-ready', 'true')
- await expect(page.locator('.editor-shell')).toHaveAttribute('data-model-interaction-blocked', 'false')
- await shot(page, info, '17-model-bridge-ready')
- })
|