您最多选择25个主题 主题必须以字母或数字开头,可以包含连字符 (-),并且长度不得超过35个字符
 
 
 
 

338 行
21 KiB

  1. import { expect, test as base, type Page, type Route, type TestInfo } from '@playwright/test'
  2. import fs from 'node:fs'
  3. import path from 'node:path'
  4. /** UI/error-contract regression only. Synthetic authentication and API responses;
  5. * no real accounts, real API writes, or training execution are involved.
  6. */
  7. type Json = Record<string, unknown>
  8. const ROOT = path.resolve('reports/api-error-messages-20260906')
  9. const SHOTS = path.join(ROOT, 'screenshots')
  10. fs.mkdirSync(SHOTS, { recursive: true })
  11. const permissions = [
  12. 'content.training', 'content.model', 'content.scene', 'content.ofd', 'content.create', 'content.update', 'content.publish',
  13. 'system.users', 'system.users.create', 'system.roles', 'system.departments',
  14. 'teaching.physical', 'teaching.virtual', 'teaching.confrontation', 'teaching.tasks.create', 'teaching.tasks.publish',
  15. ]
  16. const roles = [
  17. { id: 'mock-teacher', code: 'teacher', name: '教员', shortName: '教', status: 1, builtIn: 1, isSuperAdmin: 0, dataScopeCode: 'ALL', permissions },
  18. { id: 'mock-admin', code: 'admin', name: '系统管理员', shortName: '管', status: 1, builtIn: 1, isSuperAdmin: 0, dataScopeCode: 'ALL', permissions },
  19. ]
  20. const envelope = (data: unknown) => ({ code: 200, message: 'OK', data, timestamp: Date.now(), requestId: 'mock-message-regression' })
  21. const ok = (route: Route, data: unknown) => route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(envelope(data)) })
  22. const fail = (route: Route, message: string, status = 503, code: number | string = 'MOCK_UNAVAILABLE', requestId = 'mock-message-regression') => route.fulfill({
  23. status, contentType: 'application/json', body: JSON.stringify({ code, message, data: null, timestamp: Date.now(), requestId }),
  24. })
  25. const pageOf = (records: unknown[]) => ({ records, total: records.length, page: 1, size: 10 })
  26. const project = {
  27. id: 'message-training-901', type: 'TRAINING', code: 'MESSAGE-REGRESSION-901', name: 'Message 回归训练',
  28. description: '浏览器隔离测试数据', categoryCode: 'MEDIUM_REPAIR', coverUri: '', status: 'DRAFT',
  29. trainingMode: 'VIRTUAL', version: 1, currentVersionId: 'mock-version', currentVersionNo: 1,
  30. currentVersionStatus: 'DRAFT', ownerUserId: 'mock-user', ownerName: '测试教员', departmentId: 'mock-department',
  31. addTime: 1788624000, updateTime: 1788624000, dependencyCount: 0, assetCount: 0,
  32. content: { level: '初级', durationMinutes: 10, totalScore: 100, steps: [] },
  33. }
  34. class ApiMock {
  35. projectError = ''
  36. projectDetailError = ''
  37. systemError = ''
  38. assignmentError = ''
  39. assignmentDetailError = ''
  40. holdProjects = false
  41. readonly heldProjects: Route[] = []
  42. readonly requests: Array<{ method: string; path: string }> = []
  43. readonly unexpected: string[] = []
  44. readonly writes: string[] = []
  45. readonly pageErrors: string[] = []
  46. async install(page: Page) {
  47. page.on('pageerror', error => this.pageErrors.push(error.message))
  48. await page.addInitScript(() => {
  49. sessionStorage.setItem('unreal-tran:web:access-token:v1', 'synthetic-message-test-token-not-valid')
  50. })
  51. await page.route(url => url.pathname.startsWith('/api/'), async route => {
  52. const request = route.request()
  53. const target = new URL(request.url()).pathname
  54. this.requests.push({ method: request.method(), path: target })
  55. if (request.method() !== 'GET') {
  56. this.writes.push(`${request.method()} ${target}`)
  57. return fail(route, '测试禁止业务写入', 405)
  58. }
  59. if (target === '/api/auth/v1/auth/me') return ok(route, {
  60. user: { id: 'mock-user', username: 'message.regression', displayName: '消息回归测试', departmentId: 'mock-department', departmentName: '测试部门', mustChangePassword: false, version: 1 },
  61. roles, activeRoleId: 'mock-teacher', permissions, authorizationMode: 'UNION', loginTime: 1788624000,
  62. })
  63. if (target === '/api/auth/v1/menus/navigation') return ok(route, [])
  64. if (target === '/api/auth/v1/system-config/public') return ok(route, {
  65. systemName: '装备数字车间', loginDescription: '', defaultTheme: 'light', digitalHumanWidget: null,
  66. })
  67. 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 } } })
  68. if (target === '/api/tran/v1/content/projects') {
  69. if (this.holdProjects) { this.heldProjects.push(route); return }
  70. return this.projectError ? fail(route, this.projectError) : ok(route, pageOf([project]))
  71. }
  72. if (target === '/api/tran/v1/content/projects/message-training-901') return fail(route, this.projectDetailError || '测试工程详情不可用')
  73. if (target === '/api/tran/v1/content/catalog') return ok(route, pageOf([]))
  74. if (['/api/auth/v1/users', '/api/auth/v1/users/stats', '/api/auth/v1/departments/tree', '/api/auth/v1/roles/all'].includes(target)) {
  75. if (this.systemError) return fail(route, this.systemError)
  76. if (target.endsWith('/users')) return ok(route, pageOf([]))
  77. if (target.endsWith('/stats')) return ok(route, { total: 0, enabled: 0, disabled: 0, passwordPending: 0 })
  78. if (target.endsWith('/roles/all')) return ok(route, roles)
  79. return ok(route, [{ id: 'mock-department', name: '测试部门', parentId: null, status: 1, children: [] }])
  80. }
  81. if (target === '/api/tran/v1/teaching/assignments') return this.assignmentError ? fail(route, this.assignmentError) : ok(route, pageOf([]))
  82. if (target === '/api/tran/v1/teaching/assignments/summary') return ok(route, { total: 0, draft: 0, published: 0, active: 0, completed: 0, archived: 0, byChannel: {} })
  83. if (target === '/api/tran/v1/teaching/runs') return ok(route, pageOf([]))
  84. if (target === '/api/tran/v1/teaching/assignments/mock-physical') return fail(route, this.assignmentDetailError || '测试任务上下文不可用')
  85. if (target.startsWith('/api/auth/v1/__message_e2e/')) {
  86. if (target.endsWith('/timeout')) {
  87. await new Promise(resolve => setTimeout(resolve, 400))
  88. return ok(route, {}).catch(() => undefined)
  89. }
  90. if (target.endsWith('/forbidden')) return fail(route, '无权访问测试数据', 403, 'ACCESS_DENIED', 'mock-403')
  91. if (target.endsWith('/conflict')) return fail(route, '版本已变化,请重新加载', 409, 'VERSION_CONFLICT', 'mock-409')
  92. if (target.endsWith('/business-text')) return fail(route, 'Network Error', 400, 'ASSET_VALIDATION', 'mock-business')
  93. }
  94. this.unexpected.push(target)
  95. return fail(route, '测试未覆盖的接口', 404)
  96. })
  97. }
  98. }
  99. const test = base.extend<{ api: ApiMock }>({
  100. api: [async ({ page }, use) => {
  101. const api = new ApiMock()
  102. await api.install(page)
  103. await use(api)
  104. }, { auto: true }],
  105. })
  106. const shot = async (page: Page, info: TestInfo, name: string) => {
  107. const target = path.join(SHOTS, `${name}.png`)
  108. await page.screenshot({ path: target, fullPage: true, animations: 'disabled' })
  109. await info.attach(name, { path: target, contentType: 'image/png' })
  110. }
  111. const message = (page: Page, text: string) => page.locator('.el-message--error').filter({ hasText: text })
  112. const noPersistentError = async (page: Page, text: string) => {
  113. await expect(page.locator('.el-alert--error')).toHaveCount(0)
  114. await expect(page.locator('body')).not.toContainText(text)
  115. }
  116. test.afterEach(async ({ api }, info) => {
  117. await info.attach('API 隔离与浏览器错误', { body: JSON.stringify({ requests: api.requests, unexpected: api.unexpected, writes: api.writes, pageErrors: api.pageErrors }, null, 2), contentType: 'application/json' })
  118. expect(api.unexpected, '所有 API 请求都有明确 mock 合同').toEqual([])
  119. expect(api.writes, '没有发起真实或模拟业务写入').toEqual([])
  120. expect(api.pageErrors, '没有未捕获浏览器异常').toEqual([])
  121. })
  122. test('内容列表:错误自动消失、重试恢复、再次失败保留旧数据', async ({ page, api }, info) => {
  123. const text = '测试:训练列表服务暂不可用'
  124. api.projectError = text
  125. await page.goto('/content/training-projects')
  126. await expect(message(page, text)).toBeVisible()
  127. await expect(message(page, text).locator('.el-message__closeBtn')).toBeVisible()
  128. await shot(page, info, '01-content-error-message')
  129. await expect(message(page, text)).toBeHidden({ timeout: 8_000 })
  130. await noPersistentError(page, text)
  131. await expect(page.getByRole('button', { name: '重新加载', exact: true })).toBeVisible()
  132. api.projectError = ''
  133. await page.getByRole('button', { name: '重新加载', exact: true }).click()
  134. await expect(page.locator('.training-project-card')).toContainText(project.name)
  135. api.projectError = text
  136. await page.getByRole('button', { name: '刷新', exact: true }).click()
  137. await expect(message(page, text)).toBeVisible()
  138. await expect(page.locator('.training-project-card')).toContainText(project.name)
  139. await expect(message(page, text)).toBeHidden({ timeout: 8_000 })
  140. await noPersistentError(page, text)
  141. await shot(page, info, '02-content-old-data-retained')
  142. })
  143. test('系统用户:并行同错合并一条,重复搜索可再次提示', async ({ page, api }, info) => {
  144. const text = '测试:用户目录服务暂不可用'
  145. api.systemError = text
  146. await page.goto('/system/users')
  147. await expect(message(page, text)).toHaveCount(1)
  148. await expect(message(page, text)).toBeVisible()
  149. 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)
  150. await expect(message(page, text)).toHaveCount(1)
  151. await shot(page, info, '03-users-grouped-message')
  152. await expect(message(page, text)).toBeHidden({ timeout: 8_000 })
  153. await noPersistentError(page, text)
  154. await page.getByRole('button', { name: '搜索', exact: true }).click()
  155. await expect(message(page, text)).toHaveCount(1)
  156. await expect(message(page, text)).toBeVisible()
  157. await expect(message(page, text)).toBeHidden({ timeout: 8_000 })
  158. await shot(page, info, '04-users-retry-without-banner')
  159. })
  160. test('训练编辑器:加载失败提示消失后保留重新加载按钮', async ({ page, api }, info) => {
  161. const text = '测试:训练工程加载失败'
  162. api.projectDetailError = text
  163. await page.goto('/content/training-projects/message-training-901/legacy')
  164. await expect(message(page, text)).toBeVisible()
  165. await shot(page, info, '05-legacy-training-message')
  166. await expect(message(page, text)).toBeHidden({ timeout: 8_000 })
  167. await noPersistentError(page, text)
  168. await expect(page.getByRole('button', { name: '重新加载', exact: true })).toBeVisible()
  169. await shot(page, info, '06-legacy-training-retry')
  170. })
  171. test('实装预览:任务上下文错误用 Message,离线与教员模式持续可见', async ({ page, api }, info) => {
  172. const text = '测试:实装任务上下文读取失败'
  173. api.assignmentDetailError = text
  174. await page.goto('/teaching/physical-training/preview/mock-physical')
  175. await expect(message(page, text)).toBeVisible()
  176. await expect(page.locator('.legacy-physical-page__warning')).toHaveText('离线识别回放')
  177. await expect(page.locator('.legacy-physical-page__notice')).toContainText('教员预览态')
  178. await shot(page, info, '07-physical-context-message')
  179. await expect(message(page, text)).toBeHidden({ timeout: 8_000 })
  180. await noPersistentError(page, text)
  181. await expect(page.locator('.legacy-physical-page__warning')).toBeVisible()
  182. await shot(page, info, '08-physical-mode-preserved')
  183. })
  184. test('新建训练:业务说明与必填校验保留,不发起创建请求', async ({ page }, info) => {
  185. await page.goto('/content/training-projects')
  186. await expect(page.locator('.training-project-card')).toBeVisible()
  187. await page.getByRole('button', { name: '新建训练项目', exact: true }).click()
  188. const dialog = page.getByRole('dialog')
  189. await expect(dialog.locator('.el-alert--info')).toContainText('项目编码将在创建后由系统自动生成')
  190. await dialog.getByRole('button', { name: '创建并编辑', exact: true }).click()
  191. await expect(dialog.locator('.el-form-item__error').filter({ hasText: '请输入项目名称' })).toBeVisible()
  192. await shot(page, info, '09-create-validation-preserved')
  193. })
  194. test('缓存列表:失活时晚到错误不弹出,返回不重放旧错误', async ({ page, api }, info) => {
  195. const text = '测试:失活页面晚到错误'
  196. api.holdProjects = true
  197. await page.goto('/content/training-projects')
  198. await expect.poll(() => api.heldProjects.length).toBeGreaterThan(0)
  199. await page.evaluate(() => {
  200. const state = window as unknown as { messageObservations: string[] }
  201. state.messageObservations = []
  202. new MutationObserver(() => document.querySelectorAll('.el-message').forEach(node => state.messageObservations.push(node.textContent || '')))
  203. .observe(document.body, { childList: true, subtree: true })
  204. })
  205. await page.getByRole('button', { name: '系统管理', exact: true }).click()
  206. await expect(page.locator('.users-table')).toBeVisible()
  207. api.holdProjects = false
  208. await Promise.all(api.heldProjects.splice(0).map(route => fail(route, text)))
  209. await expect.poll(() => page.evaluate(async () => {
  210. const modulePath = '/src/stores/content.ts'
  211. const { useContentStore } = await import(modulePath)
  212. return useContentStore().errorFor('projects:TRAINING')
  213. })).toBe(text)
  214. await expect(message(page, text)).toHaveCount(0)
  215. await shot(page, info, '10-inactive-page-no-message')
  216. await page.getByRole('tab', { name: '训练编排', exact: true }).click()
  217. await expect(page.locator('.training-project-card')).toBeVisible()
  218. await expect(message(page, text)).toHaveCount(0)
  219. expect(await page.evaluate(value => (window as unknown as { messageObservations: string[] }).messageObservations.filter(entry => entry.includes(value)), text)).toEqual([])
  220. await shot(page, info, '11-reactivated-no-stale-message')
  221. })
  222. test('HTTP 合同:真实 Axios 超时中文化,403/409 元数据保留,静默请求不自动弹窗', async ({ page }, info) => {
  223. await page.goto('/content/training-projects')
  224. await expect(page.locator('.training-project-card')).toBeVisible()
  225. const errors = await page.evaluate(async () => {
  226. const modulePath = '/src/api/http.ts'
  227. const { requestData } = await import(modulePath)
  228. const result = []
  229. for (const endpoint of ['timeout', 'forbidden', 'conflict']) {
  230. try { await requestData({ method: 'GET', url: `/__message_e2e/${endpoint}`, timeout: endpoint === 'timeout' ? 80 : 1_000 }) }
  231. catch (cause) {
  232. const error = cause as { message: string; code: string; status?: number; requestId: string }
  233. result.push({ endpoint, message: error.message, code: error.code, status: error.status ?? null, requestId: error.requestId })
  234. }
  235. }
  236. return result
  237. })
  238. expect(errors).toEqual([
  239. { endpoint: 'timeout', message: '请求超时,请稍后重试', code: 'NETWORK_ERROR', status: null, requestId: '' },
  240. { endpoint: 'forbidden', message: '无权访问测试数据', code: 'ACCESS_DENIED', status: 403, requestId: 'mock-403' },
  241. { endpoint: 'conflict', message: '版本已变化,请重新加载', code: 'VERSION_CONFLICT', status: 409, requestId: 'mock-409' },
  242. ])
  243. await expect(page.locator('.el-message')).toHaveCount(0)
  244. await info.attach('HTTP 合同证据', { body: JSON.stringify(errors, null, 2), contentType: 'application/json' })
  245. const cancellation = await page.evaluate(async () => {
  246. const httpPath = '/src/api/http.ts'
  247. const messagePath = '/src/utils/errorMessage.ts'
  248. const { requestData } = await import(httpPath)
  249. const { showErrorMessage } = await import(messagePath)
  250. const controller = new AbortController()
  251. setTimeout(() => controller.abort(), 20)
  252. try { await requestData({ method: 'GET', url: '/__message_e2e/timeout', signal: controller.signal, timeout: 1_000 }) }
  253. catch (cause) {
  254. showErrorMessage(cause)
  255. const error = cause as { name: string; code: string }
  256. return { name: error.name, code: error.code }
  257. }
  258. return null
  259. })
  260. expect(cancellation).toEqual({ name: 'CanceledError', code: 'ERR_CANCELED' })
  261. await expect(page.locator('.el-message')).toHaveCount(0)
  262. await shot(page, info, '12-http-silent-errors')
  263. const businessError = await page.evaluate(async () => {
  264. const httpPath = '/src/api/http.ts'
  265. const messagePath = '/src/utils/errorMessage.ts'
  266. const { requestData } = await import(httpPath)
  267. const { showErrorMessage } = await import(messagePath)
  268. try { await requestData({ method: 'GET', url: '/__message_e2e/business-text' }) }
  269. catch (cause) {
  270. showErrorMessage(cause)
  271. const error = cause as { message: string; code: string; requestId: string; status: number }
  272. return { message: error.message, code: error.code, requestId: error.requestId, status: error.status }
  273. }
  274. return null
  275. })
  276. expect(businessError).toEqual({ message: 'Network Error', code: 'ASSET_VALIDATION', requestId: 'mock-business', status: 400 })
  277. await expect(message(page, 'Network Error')).toBeVisible()
  278. await shot(page, info, '15-business-message-keeps-original-text')
  279. await info.attach('取消请求与业务原文证据', { body: JSON.stringify({ cancellation, businessError }, null, 2), contentType: 'application/json' })
  280. })
  281. test('局域网教学列表:既有 ElMessage.error 使用全局关闭和自动消失配置', async ({ page, api }, info) => {
  282. const text = '测试:局域网教学列表读取失败'
  283. api.assignmentError = text
  284. await page.goto('http://192.168.31.168:6180/teaching/physical-training')
  285. await expect(message(page, text)).toBeVisible()
  286. await expect(message(page, text).locator('.el-message__closeBtn')).toBeVisible()
  287. await shot(page, info, '13-lan-teaching-message')
  288. await expect(message(page, text)).toBeHidden({ timeout: 8_000 })
  289. await noPersistentError(page, text)
  290. await shot(page, info, '14-lan-teaching-after-dismiss')
  291. })
  292. test('模型桥接 DOM 合同:初次错误与晚写详情转为重试说明,ready 恢复交互', async ({ page }, info) => {
  293. await page.route(url => url.pathname === '/__message_bridge_test', route => route.fulfill({
  294. 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>`,
  295. }))
  296. await page.goto('/__message_bridge_test')
  297. await page.evaluate(async () => {
  298. const state = window as unknown as { editorApp: { renderer: { domElement: Element | null } }; bridgeRetries: number }
  299. state.editorApp = { renderer: { domElement: document.querySelector('canvas') } }
  300. state.bridgeRetries = 0
  301. const modulePath = '/src/features/editors/model/legacy-iframe-loading-scope.ts'
  302. const { installLegacyModelIframeLoadingScope } = await import(modulePath)
  303. installLegacyModelIframeLoadingScope(document, { onRetry: () => { state.bridgeRetries += 1 } })
  304. document.querySelector<HTMLElement>('.editor-shell')!.dataset.modelApiState = 'error'
  305. document.querySelector('#loading-progress')!.textContent = '测试:初次模型接口错误详情'
  306. })
  307. await expect(page.locator('#loading-progress')).toHaveText('请重新加载工程')
  308. await expect(page.locator('body')).not.toContainText('测试:初次模型接口错误详情')
  309. expect(await page.locator('.topbar').evaluate(element => (element as HTMLElement).inert)).toBe(true)
  310. await page.locator('#loading-progress').evaluate(element => { element.textContent = '测试:晚写模型接口错误详情' })
  311. await expect(page.locator('#loading-progress')).toHaveText('请重新加载工程')
  312. await expect(page.locator('body')).not.toContainText('测试:晚写模型接口错误详情')
  313. await page.getByRole('button', { name: '重新加载工程', exact: true }).click()
  314. expect(await page.evaluate(() => (window as unknown as { bridgeRetries: number }).bridgeRetries)).toBe(1)
  315. await shot(page, info, '16-model-bridge-neutral-retry')
  316. await page.locator('.editor-shell').evaluate(element => { (element as HTMLElement).dataset.modelApiState = 'ready' })
  317. await expect(page.locator('#loading-overlay')).toBeHidden()
  318. expect(await page.locator('.topbar').evaluate(element => (element as HTMLElement).inert)).toBe(false)
  319. await expect(page.locator('.editor-shell')).toHaveAttribute('data-model-project-ready', 'true')
  320. await expect(page.locator('.editor-shell')).toHaveAttribute('data-model-interaction-blocked', 'false')
  321. await shot(page, info, '17-model-bridge-ready')
  322. })