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 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: `模型加载桥接 DOM 测试夹具

模型加载桥接 DOM 合同

浏览器测试夹具,仅验证真实宿主桥接的错误文案、重试与交互恢复;不代表加载真实 GLB。

正在载入工程模型初始化
`, })) 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('.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') })