瀏覽代碼

test(ui): cover transient API error messages and editor retries

Add browser regressions, runtime error checks and the screenshot report for the global error-message update. Preserve the recorded failures of two legacy password-flow tests separately from the passing message regressions.
main
leiyun 1 周之前
父節點
當前提交
9672e1366c
共有 96 個文件被更改,包括 2727 次插入5 次删除
  1. +26
    -0
      api-error-messages.config.ts
  2. 二進制
     
  3. +10
    -0
      reports/api-error-messages-20260906/authentication/html/data/9292f5c4d6b2dfd83de339568ce711babd89154e.md
  4. 二進制
     
  5. 二進制
     
  6. 二進制
     
  7. 二進制
     
  8. +76
    -0
      reports/api-error-messages-20260906/authentication/html/index.html
  9. 二進制
     
  10. 二進制
     
  11. +76
    -0
      reports/api-error-messages-20260906/authentication/login-message/html/index.html
  12. +130
    -0
      reports/api-error-messages-20260906/authentication/login-message/results.json
  13. 二進制
     
  14. 二進制
     
  15. 二進制
     
  16. 二進制
     
  17. 二進制
     
  18. +307
    -0
      reports/api-error-messages-20260906/authentication/results.json
  19. 二進制
     
  20. 二進制
     
  21. 二進制
     
  22. 二進制
     
  23. +66
    -0
      reports/api-error-messages-20260906/authentication/summary.json
  24. 二進制
     
  25. 二進制
     
  26. +10
    -0
      reports/api-error-messages-20260906/authentication/test-results/zentao-auth-859-861-864-861:首次改密两次输入不一致时立即显示字段提示且不提交-edge-auth-mock/error-context.md
  27. 二進制
     
  28. 二進制
     
  29. +10
    -0
      reports/api-error-messages-20260906/authentication/test-results/zentao-auth-859-861-864-864:首次改密期间账号停用后仅显示一次固定登录提示且不刷新令牌-edge-auth-mock/error-context.md
  30. 二進制
     
  31. +625
    -0
      reports/api-error-messages-20260906/evidence.json
  32. +5
    -0
      reports/api-error-messages-20260906/index.html
  33. 二進制
     
  34. 二進制
     
  35. 二進制
     
  36. 二進制
     
  37. 二進制
     
  38. 二進制
     
  39. 二進制
     
  40. 二進制
     
  41. 二進制
     
  42. 二進制
     
  43. 二進制
     
  44. 二進制
     
  45. 二進制
     
  46. 二進制
     
  47. 二進制
     
  48. 二進制
     
  49. 二進制
     
  50. +76
    -0
      reports/api-error-messages-20260906/playwright/index.html
  51. +159
    -0
      reports/api-error-messages-20260906/report.md
  52. +556
    -0
      reports/api-error-messages-20260906/results.json
  53. 二進制
     
  54. 二進制
     
  55. 二進制
     
  56. 二進制
     
  57. 二進制
     
  58. 二進制
     
  59. 二進制
     
  60. 二進制
     
  61. 二進制
     
  62. 二進制
     
  63. 二進制
     
  64. 二進制
     
  65. 二進制
     
  66. 二進制
     
  67. 二進制
     
  68. 二進制
     
  69. 二進制
     
  70. 二進制
     
  71. 二進制
     
  72. 二進制
     
  73. 二進制
     
  74. 二進制
     
  75. 二進制
     
  76. 二進制
     
  77. 二進制
     
  78. 二進制
     
  79. 二進制
     
  80. 二進制
     
  81. 二進制
     
  82. 二進制
     
  83. 二進制
     
  84. 二進制
     
  85. 二進制
     
  86. 二進制
     
  87. +30
    -0
      reports/api-error-messages-20260906/validation.json
  88. 二進制
     
  89. 二進制
     
  90. +337
    -0
      tests/api-error-messages.spec.ts
  91. +1
    -1
      tests/auth.spec.ts
  92. +30
    -2
      tests/zentao-auth-859-861-864.spec.ts
  93. +88
    -0
      tools/build-api-error-message-report.mjs
  94. +46
    -0
      tools/guide-error-message.test.mjs
  95. +60
    -0
      tools/scene-cover-api.test.mjs
  96. +3
    -2
      tools/training-return-tabs.e2e.mjs

+ 26
- 0
api-error-messages.config.ts 查看文件

@@ -0,0 +1,26 @@
import { defineConfig } from '@playwright/test'

// Real Vue/Element Plus in Edge; every /api/ request is intercepted by the spec.
export default defineConfig({
testDir: './tests',
testMatch: 'api-error-messages.spec.ts',
outputDir: './reports/api-error-messages-20260906/test-results',
fullyParallel: false,
workers: 1,
retries: 0,
timeout: 60_000,
expect: { timeout: 12_000 },
reporter: [
['list'],
['html', { outputFolder: 'reports/api-error-messages-20260906/playwright', open: 'never' }],
['json', { outputFile: 'reports/api-error-messages-20260906/results.json' }],
],
use: {
baseURL: 'http://127.0.0.1:6180',
browserName: 'chromium', channel: 'msedge', headless: true,
locale: 'zh-CN', timezoneId: 'Asia/Shanghai',
viewport: { width: 1440, height: 1000 },
actionTimeout: 12_000, navigationTimeout: 35_000,
screenshot: 'only-on-failure', trace: 'off', video: 'off',
},
})

二進制
查看文件


+ 10
- 0
reports/api-error-messages-20260906/authentication/html/data/9292f5c4d6b2dfd83de339568ce711babd89154e.md 查看文件

@@ -0,0 +1,10 @@
# Page snapshot

```yaml
- status [ref=e3]:
- generic [ref=e4]:
- strong [ref=e7]: 装备数字车间
- generic [ref=e10]:
- generic [ref=e11]: 正在进入系统
- paragraph [ref=e12]: 正在验证登录状态并加载工作台,请稍候…
```

二進制
查看文件


二進制
查看文件


二進制
查看文件


二進制
查看文件


+ 76
- 0
reports/api-error-messages-20260906/authentication/html/index.html
文件差異過大導致無法顯示
查看文件


二進制
查看文件


二進制
查看文件


+ 76
- 0
reports/api-error-messages-20260906/authentication/login-message/html/index.html
文件差異過大導致無法顯示
查看文件


+ 130
- 0
reports/api-error-messages-20260906/authentication/login-message/results.json 查看文件

@@ -0,0 +1,130 @@
{
"config": {
"configFile": "E:\\work\\edit_3d\\.codex-tmp\\auth-login-message.config.ts",
"rootDir": "E:/work/edit_3d/unreal_tran/ute2e/tests",
"forbidOnly": false,
"fullyParallel": false,
"globalSetup": null,
"globalTeardown": null,
"globalTimeout": 0,
"grep": {},
"grepInvert": null,
"maxFailures": 0,
"metadata": {
"actualWorkers": 1
},
"preserveOutput": "always",
"reporter": [
[
"list",
null
],
[
"html",
{
"outputFolder": "E:/work/edit_3d/unreal_tran/ute2e/reports/api-error-messages-20260906/authentication/login-message/html",
"open": "never"
}
],
[
"json",
{
"outputFile": "E:/work/edit_3d/unreal_tran/ute2e/reports/api-error-messages-20260906/authentication/login-message/results.json"
}
]
],
"reportSlowTests": {
"max": 5,
"threshold": 300000
},
"quiet": false,
"projects": [
{
"outputDir": "E:/work/edit_3d/unreal_tran/ute2e/reports/api-error-messages-20260906/authentication/login-message/test-results",
"repeatEach": 1,
"retries": 0,
"metadata": {
"actualWorkers": 1
},
"id": "edge-auth-mock",
"name": "edge-auth-mock",
"testDir": "E:/work/edit_3d/unreal_tran/ute2e/tests",
"testIgnore": [],
"testMatch": [
"zentao-auth-859-861-864.spec.ts"
],
"timeout": 90000
}
],
"shard": null,
"updateSnapshots": "missing",
"updateSourceMethod": "patch",
"version": "1.55.0",
"workers": 1,
"webServer": null
},
"suites": [
{
"title": "zentao-auth-859-861-864.spec.ts",
"file": "zentao-auth-859-861-864.spec.ts",
"column": 0,
"line": 0,
"specs": [
{
"title": "当前登录接口失败只显示临时 Message,自动消失且不留下登录错误条",
"ok": true,
"tags": [],
"tests": [
{
"timeout": 90000,
"annotations": [],
"expectedStatus": "passed",
"projectId": "edge-auth-mock",
"projectName": "edge-auth-mock",
"results": [
{
"workerIndex": 0,
"parallelIndex": 0,
"status": "passed",
"duration": 8478,
"errors": [],
"stdout": [],
"stderr": [],
"retry": 0,
"startTime": "2026-09-05T17:32:36.419Z",
"annotations": [],
"attachments": [
{
"name": "success-login-api-error-message",
"contentType": "image/png",
"path": "E:\\work\\edit_3d\\unreal_tran\\ute2e\\reports\\api-error-messages-20260906\\authentication\\login-message\\test-results\\zentao-auth-859-861-864-当前登录接口失败只显示临时-Message,自动消失且不留下登录错误条-edge-auth-mock\\attachments\\success-login-api-error-message-3f0f8df181e67a1ff135d013664efb82b9b5f8b0.png"
},
{
"name": "success-login-api-error-message-dismissed",
"contentType": "image/png",
"path": "E:\\work\\edit_3d\\unreal_tran\\ute2e\\reports\\api-error-messages-20260906\\authentication\\login-message\\test-results\\zentao-auth-859-861-864-当前登录接口失败只显示临时-Message,自动消失且不留下登录错误条-edge-auth-mock\\attachments\\success-login-api-error-message-dismissed-5f52bcb97a02d086fed56c692ffe02065362ffd1.png"
}
]
}
],
"status": "expected"
}
],
"id": "52adcd77a1936aed13a9-423cf20f38d8d58278a5",
"file": "zentao-auth-859-861-864.spec.ts",
"line": 160,
"column": 1
}
]
}
],
"errors": [],
"stats": {
"startTime": "2026-09-05T17:32:35.175Z",
"duration": 10788.947,
"expected": 1,
"skipped": 0,
"unexpected": 0,
"flaky": 0
}
}

二進制
查看文件


二進制
查看文件


二進制
查看文件


二進制
查看文件


二進制
查看文件


+ 307
- 0
reports/api-error-messages-20260906/authentication/results.json 查看文件

@@ -0,0 +1,307 @@
{
"config": {
"configFile": "E:\\work\\edit_3d\\.codex-tmp\\auth-message-regression.config.ts",
"rootDir": "E:/work/edit_3d/unreal_tran/ute2e/tests",
"forbidOnly": false,
"fullyParallel": false,
"globalSetup": null,
"globalTeardown": null,
"globalTimeout": 0,
"grep": {},
"grepInvert": null,
"maxFailures": 0,
"metadata": {
"actualWorkers": 1
},
"preserveOutput": "always",
"reporter": [
[
"list",
null
],
[
"html",
{
"outputFolder": "E:/work/edit_3d/unreal_tran/ute2e/reports/api-error-messages-20260906/authentication/html",
"open": "never"
}
],
[
"json",
{
"outputFile": "E:/work/edit_3d/unreal_tran/ute2e/reports/api-error-messages-20260906/authentication/results.json"
}
]
],
"reportSlowTests": {
"max": 5,
"threshold": 300000
},
"quiet": false,
"projects": [
{
"outputDir": "E:/work/edit_3d/unreal_tran/ute2e/reports/api-error-messages-20260906/authentication/test-results",
"repeatEach": 1,
"retries": 0,
"metadata": {
"actualWorkers": 1
},
"id": "edge-auth-mock",
"name": "edge-auth-mock",
"testDir": "E:/work/edit_3d/unreal_tran/ute2e/tests",
"testIgnore": [],
"testMatch": [
"zentao-auth-859-861-864.spec.ts"
],
"timeout": 90000
}
],
"shard": null,
"updateSnapshots": "missing",
"updateSourceMethod": "patch",
"version": "1.55.0",
"workers": 1,
"webServer": null
},
"suites": [
{
"title": "zentao-auth-859-861-864.spec.ts",
"file": "zentao-auth-859-861-864.spec.ts",
"column": 0,
"line": 0,
"specs": [
{
"title": "859:部门没有可登录教员时账号选择器明确禁用且不可展开",
"ok": true,
"tags": [],
"tests": [
{
"timeout": 90000,
"annotations": [],
"expectedStatus": "passed",
"projectId": "edge-auth-mock",
"projectName": "edge-auth-mock",
"results": [
{
"workerIndex": 0,
"parallelIndex": 0,
"status": "passed",
"duration": 3850,
"errors": [],
"stdout": [],
"stderr": [],
"retry": 0,
"startTime": "2026-09-05T17:30:12.947Z",
"annotations": [],
"attachments": [
{
"name": "success-859-empty-teacher-account-disabled",
"contentType": "image/png",
"path": "E:\\work\\edit_3d\\unreal_tran\\ute2e\\reports\\api-error-messages-20260906\\authentication\\test-results\\zentao-auth-859-861-864-859:部门没有可登录教员时账号选择器明确禁用且不可展开-edge-auth-mock\\attachments\\success-859-empty-teacher-account-disabled-2f99562734701ae457b390f2ea2477898d51907c.png"
}
]
}
],
"status": "expected"
}
],
"id": "52adcd77a1936aed13a9-d7d9043f0521ae86a405",
"file": "zentao-auth-859-861-864.spec.ts",
"line": 94,
"column": 1
},
{
"title": "861:首次改密两次输入不一致时立即显示字段提示且不提交",
"ok": false,
"tags": [],
"tests": [
{
"timeout": 90000,
"annotations": [],
"expectedStatus": "passed",
"projectId": "edge-auth-mock",
"projectName": "edge-auth-mock",
"results": [
{
"workerIndex": 0,
"parallelIndex": 0,
"status": "failed",
"duration": 16889,
"error": {
"message": "TimeoutError: locator.fill: Timeout 15000ms exceeded.\nCall log:\n\u001b[2m - waiting for locator('.password-card input[autocomplete=\"new-password\"]').first()\u001b[22m\n",
"stack": "TimeoutError: locator.fill: Timeout 15000ms exceeded.\nCall log:\n\u001b[2m - waiting for locator('.password-card input[autocomplete=\"new-password\"]').first()\u001b[22m\n\n at E:\\work\\edit_3d\\unreal_tran\\ute2e\\tests\\zentao-auth-859-861-864.spec.ts:114:23",
"location": {
"file": "E:\\work\\edit_3d\\unreal_tran\\ute2e\\tests\\zentao-auth-859-861-864.spec.ts",
"column": 23,
"line": 114
},
"snippet": " 112 | await page.goto('/change-password')\n 113 | const inputs = page.locator('.password-card input[autocomplete=\"new-password\"]')\n> 114 | await inputs.nth(0).fill('Strong#Password8')\n | ^\n 115 | await inputs.nth(1).fill('Different#Pass9')\n 116 |\n 117 | await expect(page.getByText('两次输入的新密码不一致', { exact: true })).toBeVisible()"
},
"errors": [
{
"location": {
"file": "E:\\work\\edit_3d\\unreal_tran\\ute2e\\tests\\zentao-auth-859-861-864.spec.ts",
"column": 23,
"line": 114
},
"message": "TimeoutError: locator.fill: Timeout 15000ms exceeded.\nCall log:\n\u001b[2m - waiting for locator('.password-card input[autocomplete=\"new-password\"]').first()\u001b[22m\n\n\n 112 | await page.goto('/change-password')\n 113 | const inputs = page.locator('.password-card input[autocomplete=\"new-password\"]')\n> 114 | await inputs.nth(0).fill('Strong#Password8')\n | ^\n 115 | await inputs.nth(1).fill('Different#Pass9')\n 116 |\n 117 | await expect(page.getByText('两次输入的新密码不一致', { exact: true })).toBeVisible()\n at E:\\work\\edit_3d\\unreal_tran\\ute2e\\tests\\zentao-auth-859-861-864.spec.ts:114:23"
}
],
"stdout": [],
"stderr": [],
"retry": 0,
"startTime": "2026-09-05T17:30:17.307Z",
"annotations": [],
"attachments": [
{
"name": "screenshot",
"contentType": "image/png",
"path": "E:\\work\\edit_3d\\unreal_tran\\ute2e\\reports\\api-error-messages-20260906\\authentication\\test-results\\zentao-auth-859-861-864-861:首次改密两次输入不一致时立即显示字段提示且不提交-edge-auth-mock\\test-failed-1.png"
},
{
"name": "failure-screenshot",
"contentType": "image/png",
"path": "E:\\work\\edit_3d\\unreal_tran\\ute2e\\reports\\api-error-messages-20260906\\authentication\\test-results\\zentao-auth-859-861-864-861:首次改密两次输入不一致时立即显示字段提示且不提交-edge-auth-mock\\attachments\\failure-screenshot-6fd2d23a12532f4a17e77e33991ddeb1f050286e.png"
},
{
"name": "error-context",
"contentType": "text/markdown",
"path": "E:\\work\\edit_3d\\unreal_tran\\ute2e\\reports\\api-error-messages-20260906\\authentication\\test-results\\zentao-auth-859-861-864-861:首次改密两次输入不一致时立即显示字段提示且不提交-edge-auth-mock\\error-context.md"
}
],
"errorLocation": {
"file": "E:\\work\\edit_3d\\unreal_tran\\ute2e\\tests\\zentao-auth-859-861-864.spec.ts",
"column": 23,
"line": 114
}
}
],
"status": "unexpected"
}
],
"id": "52adcd77a1936aed13a9-025f02a9349d3fb78768",
"file": "zentao-auth-859-861-864.spec.ts",
"line": 109,
"column": 1
},
{
"title": "864:首次改密期间账号停用后仅显示一次固定登录提示且不刷新令牌",
"ok": false,
"tags": [],
"tests": [
{
"timeout": 90000,
"annotations": [],
"expectedStatus": "passed",
"projectId": "edge-auth-mock",
"projectName": "edge-auth-mock",
"results": [
{
"workerIndex": 1,
"parallelIndex": 0,
"status": "failed",
"duration": 16450,
"error": {
"message": "TimeoutError: locator.fill: Timeout 15000ms exceeded.\nCall log:\n\u001b[2m - waiting for locator('.password-card input[autocomplete=\"new-password\"]').first()\u001b[22m\n",
"stack": "TimeoutError: locator.fill: Timeout 15000ms exceeded.\nCall log:\n\u001b[2m - waiting for locator('.password-card input[autocomplete=\"new-password\"]').first()\u001b[22m\n\n at E:\\work\\edit_3d\\unreal_tran\\ute2e\\tests\\zentao-auth-859-861-864.spec.ts:128:23",
"location": {
"file": "E:\\work\\edit_3d\\unreal_tran\\ute2e\\tests\\zentao-auth-859-861-864.spec.ts",
"column": 23,
"line": 128
},
"snippet": " 126 | await page.goto('/change-password')\n 127 | const inputs = page.locator('.password-card input[autocomplete=\"new-password\"]')\n> 128 | await inputs.nth(0).fill('Strong#Password8')\n | ^\n 129 | await inputs.nth(1).fill('Strong#Password8')\n 130 | await page.getByRole('button', { name: '确认修改并登录' }).click()\n 131 |"
},
"errors": [
{
"location": {
"file": "E:\\work\\edit_3d\\unreal_tran\\ute2e\\tests\\zentao-auth-859-861-864.spec.ts",
"column": 23,
"line": 128
},
"message": "TimeoutError: locator.fill: Timeout 15000ms exceeded.\nCall log:\n\u001b[2m - waiting for locator('.password-card input[autocomplete=\"new-password\"]').first()\u001b[22m\n\n\n 126 | await page.goto('/change-password')\n 127 | const inputs = page.locator('.password-card input[autocomplete=\"new-password\"]')\n> 128 | await inputs.nth(0).fill('Strong#Password8')\n | ^\n 129 | await inputs.nth(1).fill('Strong#Password8')\n 130 | await page.getByRole('button', { name: '确认修改并登录' }).click()\n 131 |\n at E:\\work\\edit_3d\\unreal_tran\\ute2e\\tests\\zentao-auth-859-861-864.spec.ts:128:23"
}
],
"stdout": [],
"stderr": [],
"retry": 0,
"startTime": "2026-09-05T17:30:35.470Z",
"annotations": [],
"attachments": [
{
"name": "screenshot",
"contentType": "image/png",
"path": "E:\\work\\edit_3d\\unreal_tran\\ute2e\\reports\\api-error-messages-20260906\\authentication\\test-results\\zentao-auth-859-861-864-864:首次改密期间账号停用后仅显示一次固定登录提示且不刷新令牌-edge-auth-mock\\test-failed-1.png"
},
{
"name": "failure-screenshot",
"contentType": "image/png",
"path": "E:\\work\\edit_3d\\unreal_tran\\ute2e\\reports\\api-error-messages-20260906\\authentication\\test-results\\zentao-auth-859-861-864-864:首次改密期间账号停用后仅显示一次固定登录提示且不刷新令牌-edge-auth-mock\\attachments\\failure-screenshot-2a08b4d1dc5dcad4acf34c6b179a1b6ae4b903de.png"
},
{
"name": "error-context",
"contentType": "text/markdown",
"path": "E:\\work\\edit_3d\\unreal_tran\\ute2e\\reports\\api-error-messages-20260906\\authentication\\test-results\\zentao-auth-859-861-864-864:首次改密期间账号停用后仅显示一次固定登录提示且不刷新令牌-edge-auth-mock\\error-context.md"
}
],
"errorLocation": {
"file": "E:\\work\\edit_3d\\unreal_tran\\ute2e\\tests\\zentao-auth-859-861-864.spec.ts",
"column": 23,
"line": 128
}
}
],
"status": "unexpected"
}
],
"id": "52adcd77a1936aed13a9-e99934e8cad148ac65c9",
"file": "zentao-auth-859-861-864.spec.ts",
"line": 123,
"column": 1
},
{
"title": "普通会话过期只跳转登录,不展示后端错误详情或停用账号提示",
"ok": true,
"tags": [],
"tests": [
{
"timeout": 90000,
"annotations": [],
"expectedStatus": "passed",
"projectId": "edge-auth-mock",
"projectName": "edge-auth-mock",
"results": [
{
"workerIndex": 2,
"parallelIndex": 0,
"status": "passed",
"duration": 2493,
"errors": [],
"stdout": [],
"stderr": [],
"retry": 0,
"startTime": "2026-09-05T17:30:53.683Z",
"annotations": [],
"attachments": []
}
],
"status": "expected"
}
],
"id": "52adcd77a1936aed13a9-cdec245d1f279f3a22ad",
"file": "zentao-auth-859-861-864.spec.ts",
"line": 143,
"column": 1
}
]
}
],
"errors": [],
"stats": {
"startTime": "2026-09-05T17:30:11.840Z",
"duration": 45375.929000000004,
"expected": 2,
"skipped": 0,
"unexpected": 2,
"flaky": 0
}
}

二進制
查看文件


二進制
查看文件


二進制
查看文件


二進制
查看文件


+ 66
- 0
reports/api-error-messages-20260906/authentication/summary.json 查看文件

@@ -0,0 +1,66 @@
{
"title": "认证提示附加回归",
"status": "PASS_WITH_LEGACY_BLOCKERS",
"result": "共实际运行 5 项:3 项通过;2 项旧强制改密用例受当前路由契约阻断,未验证到原断言。主报告 9 项结果单独统计。",
"screenshotPathBase": "api-error-messages-20260906",
"environment": "Edge headless,http://127.0.0.1:6180,认证 API 全部由 Playwright 拦截模拟;trace/video 关闭。",
"counts": { "executed": 5, "passed": 3, "legacyBlocked": 2, "skipped": 0 },
"runs": [
{
"command": "pnpm exec playwright test --config E:\\work\\edit_3d\\.codex-tmp\\auth-message-regression.config.ts",
"result": "首轮运行当时文件中的 4 项旧回归,2 passed / 2 failed(均为输入定位超时),用时 45.4 秒;原始结果保留。",
"json": "authentication/results.json",
"html": "authentication/html/index.html",
"log": "authentication/run.log"
},
{
"command": "pnpm exec playwright test --config E:\\work\\edit_3d\\.codex-tmp\\auth-login-message.config.ts",
"result": "新增当前登录 API 错误回归独立执行,1 passed,用时 10.8 秒。",
"json": "authentication/login-message/results.json",
"html": "authentication/login-message/html/index.html",
"log": "authentication/login-message/run.log"
}
],
"checks": [
{
"title": "859:无可登录教员时账号选择器禁用",
"status": "PASS",
"result": "保留业务空态,选择器不可展开。",
"screenshots": [{ "title": "无教员业务空态", "path": "authentication/screenshots/859-passed.png" }]
},
{
"title": "861:旧强制改密不一致校验",
"status": "BLOCKED",
"rawStatus": "failed",
"result": "当前 /change-password 对已认证用户重定向 /dashboard;旧 fixture 只有强制改密标记而无工作台权限,后续导航取消,停留在启动页,未进入改密表单。该旧用例不适用于当前路由契约,本次未改路由、未跳过或记为通过。",
"screenshots": [{ "title": "旧改密入口阻断", "path": "authentication/screenshots/861-failed.png" }]
},
{
"title": "864:旧强制改密停用账号提示",
"status": "BLOCKED",
"rawStatus": "failed",
"result": "与 861 相同的既有路由契约阻断,未能提交初始改密接口,也未验证一次性登录提示。原始失败证据保留。",
"screenshots": [{ "title": "旧停用账号流程阻断", "path": "authentication/screenshots/864-failed.png" }]
},
{
"title": "普通会话过期不泄露后端内部详情",
"status": "PASS",
"result": "跳转登录页,登录错误栏为空,不显示内部会话或刷新令牌详情,仅发起一次刷新请求。",
"screenshots": []
},
{
"title": "当前管理员登录 API 401 错误使用自动消失的 Message",
"status": "PASS",
"result": "实际点击登录;mock POST /auth/login 返回 401。只出现一条 .el-message--error,#login-error 始终为空;未点击关闭,Message 自动消失。登录请求 1 次,刷新请求 0 次。",
"screenshots": [
{ "title": "登录失败 Message", "path": "authentication/login-message/screenshots/login-api-error-message.png" },
{ "title": "Message 自动消失后", "path": "authentication/login-message/screenshots/login-api-error-message-dismissed.png" }
]
}
],
"limitations": [
"仅模拟认证接口与浏览器交互,不使用真实账号,不验证后端认证规则或真实密码修改。",
"未运行依赖真实账号的 auth.spec.ts 全量;仅更新其中接口错误提示的断言选择器。",
"两条旧强制改密用例没有通过,不能作为当前改密功能验证证据;既有路由策略不在本轮提示展示调整范围。"
]
}

二進制
查看文件


二進制
查看文件


+ 10
- 0
reports/api-error-messages-20260906/authentication/test-results/zentao-auth-859-861-864-861:首次改密两次输入不一致时立即显示字段提示且不提交-edge-auth-mock/error-context.md 查看文件

@@ -0,0 +1,10 @@
# Page snapshot

```yaml
- status [ref=e3]:
- generic [ref=e4]:
- strong [ref=e7]: 装备数字车间
- generic [ref=e10]:
- generic [ref=e11]: 正在进入系统
- paragraph [ref=e12]: 正在验证登录状态并加载工作台,请稍候…
```

二進制
查看文件


二進制
查看文件


+ 10
- 0
reports/api-error-messages-20260906/authentication/test-results/zentao-auth-859-861-864-864:首次改密期间账号停用后仅显示一次固定登录提示且不刷新令牌-edge-auth-mock/error-context.md 查看文件

@@ -0,0 +1,10 @@
# Page snapshot

```yaml
- status [ref=e3]:
- generic [ref=e4]:
- strong [ref=e7]: 装备数字车间
- generic [ref=e10]:
- generic [ref=e11]: 正在进入系统
- paragraph [ref=e12]: 正在验证登录状态并加载工作台,请稍候…
```

二進制
查看文件


+ 625
- 0
reports/api-error-messages-20260906/evidence.json 查看文件

@@ -0,0 +1,625 @@
{
"title": "接口错误 Message 回归报告",
"status": "PASS_WITH_LEGACY_BLOCKERS",
"generatedAt": "2026-09-05T17:36:20.720Z",
"startedAt": "2026-09-05T17:32:19.650Z",
"durationMs": 84090.698,
"passed": 9,
"total": 9,
"screenshotCount": 17,
"requestCount": 58,
"pageErrorCount": 0,
"validation": {
"commands": [
{
"command": "pnpm run build",
"scope": "unreal_tran_web",
"status": "PASS",
"details": "最终 vue-tsc、Vite 与 legacy iframe public/dist 哈希校验通过;仍有既有 chunk 体积提示。",
"log": "validation/build.log"
},
{
"command": "node --test tools/scene-cover-api.test.mjs tools/guide-error-message.test.mjs",
"scope": "ute2e",
"status": "PASS",
"details": "20/20 通过,包含保存失败只提示一次、阻断发布与离开,以及指导书/场景错误提示回归。",
"log": "validation/runtime-tests.log"
},
{
"command": "pnpm exec tsc --noEmit --target ES2022 --module ESNext --moduleResolution Bundler --strict --types 'node,@playwright/test' tests/api-error-messages.spec.ts api-error-messages.config.ts",
"scope": "ute2e",
"status": "PASS",
"details": "本轮新增浏览器测试与配置类型检查通过。"
},
{
"command": "git diff --check",
"scope": "unreal_tran_web、ute2e",
"status": "PASS",
"details": "主任务检查通过;报告生成后再次核验本轮文件。"
}
]
},
"authentication": {
"title": "认证提示附加回归",
"status": "PASS_WITH_LEGACY_BLOCKERS",
"result": "共实际运行 5 项:3 项通过;2 项旧强制改密用例受当前路由契约阻断,未验证到原断言。主报告 9 项结果单独统计。",
"screenshotPathBase": "api-error-messages-20260906",
"environment": "Edge headless,http://127.0.0.1:6180,认证 API 全部由 Playwright 拦截模拟;trace/video 关闭。",
"counts": {
"executed": 5,
"passed": 3,
"legacyBlocked": 2,
"skipped": 0
},
"runs": [
{
"command": "pnpm exec playwright test --config E:\\work\\edit_3d\\.codex-tmp\\auth-message-regression.config.ts",
"result": "首轮运行当时文件中的 4 项旧回归,2 passed / 2 failed(均为输入定位超时),用时 45.4 秒;原始结果保留。",
"json": "authentication/results.json",
"html": "authentication/html/index.html",
"log": "authentication/run.log"
},
{
"command": "pnpm exec playwright test --config E:\\work\\edit_3d\\.codex-tmp\\auth-login-message.config.ts",
"result": "新增当前登录 API 错误回归独立执行,1 passed,用时 10.8 秒。",
"json": "authentication/login-message/results.json",
"html": "authentication/login-message/html/index.html",
"log": "authentication/login-message/run.log"
}
],
"checks": [
{
"title": "859:无可登录教员时账号选择器禁用",
"status": "PASS",
"result": "保留业务空态,选择器不可展开。",
"screenshots": [
{
"title": "无教员业务空态",
"path": "authentication/screenshots/859-passed.png"
}
]
},
{
"title": "861:旧强制改密不一致校验",
"status": "BLOCKED",
"rawStatus": "failed",
"result": "当前 /change-password 对已认证用户重定向 /dashboard;旧 fixture 只有强制改密标记而无工作台权限,后续导航取消,停留在启动页,未进入改密表单。该旧用例不适用于当前路由契约,本次未改路由、未跳过或记为通过。",
"screenshots": [
{
"title": "旧改密入口阻断",
"path": "authentication/screenshots/861-failed.png"
}
]
},
{
"title": "864:旧强制改密停用账号提示",
"status": "BLOCKED",
"rawStatus": "failed",
"result": "与 861 相同的既有路由契约阻断,未能提交初始改密接口,也未验证一次性登录提示。原始失败证据保留。",
"screenshots": [
{
"title": "旧停用账号流程阻断",
"path": "authentication/screenshots/864-failed.png"
}
]
},
{
"title": "普通会话过期不泄露后端内部详情",
"status": "PASS",
"result": "跳转登录页,登录错误栏为空,不显示内部会话或刷新令牌详情,仅发起一次刷新请求。",
"screenshots": []
},
{
"title": "当前管理员登录 API 401 错误使用自动消失的 Message",
"status": "PASS",
"result": "实际点击登录;mock POST /auth/login 返回 401。只出现一条 .el-message--error,#login-error 始终为空;未点击关闭,Message 自动消失。登录请求 1 次,刷新请求 0 次。",
"screenshots": [
{
"title": "登录失败 Message",
"path": "authentication/login-message/screenshots/login-api-error-message.png"
},
{
"title": "Message 自动消失后",
"path": "authentication/login-message/screenshots/login-api-error-message-dismissed.png"
}
]
}
],
"limitations": [
"仅模拟认证接口与浏览器交互,不使用真实账号,不验证后端认证规则或真实密码修改。",
"未运行依赖真实账号的 auth.spec.ts 全量;仅更新其中接口错误提示的断言选择器。",
"两条旧强制改密用例没有通过,不能作为当前改密功能验证证据;既有路由策略不在本轮提示展示调整范围。"
]
},
"stages": [
{
"title": "资料",
"text": "按本轮“接口错误使用 Message,不保留常驻错误横条”的要求检查。沿用已确认的教学业务边界:离线识别回放、观察模式、步骤反馈和表单校验仍需可见。"
},
{
"title": "原型",
"text": "错误发生时顶部短暂提示;可以手动关闭,约 4.5 秒自动消失。同一错误合并展示;失败后仍可重新加载,已成功取得的数据不因后续刷新失败消失。"
},
{
"title": "设计",
"text": "共享 Message 处理负责统一展示与缓存页面生命周期;请求层保留业务错误码、HTTP 状态和 requestId,静默请求不会自行弹窗。未取得数据与空结果保持区分。"
},
{
"title": "开发",
"text": "代表性覆盖内容制作、系统用户与教学实施,包括训练编辑器加载失败的重试入口。接口异常详情仅在 Message 中展示;表单必填错误、项目编码说明和离线运行模式继续保留。"
},
{
"title": "测试",
"text": "使用真实 Vue / Element Plus 页面、Edge 无头浏览器和浏览器级 API mock。覆盖本机与局域网入口。身份及接口数据均为合成测试数据;不登录真实账号、不写业务库,不将本轮测试称为真实服务端或设备联调。"
}
],
"checks": [
{
"title": "内容列表:错误自动消失、重试恢复、再次失败保留旧数据",
"status": "PASS",
"durationMs": 12754,
"screenshots": [
{
"title": "01-content-error-message",
"path": "screenshots/01-content-error-message.png"
},
{
"title": "02-content-old-data-retained",
"path": "screenshots/02-content-old-data-retained.png"
}
],
"isolation": {
"requests": [
{
"method": "GET",
"path": "/api/auth/v1/system-config/public"
},
{
"method": "GET",
"path": "/api/auth/v1/auth/me"
},
{
"method": "GET",
"path": "/api/auth/v1/menus/navigation"
},
{
"method": "GET",
"path": "/api/tran/v1/content/projects/summary"
},
{
"method": "GET",
"path": "/api/tran/v1/content/projects"
},
{
"method": "GET",
"path": "/api/tran/v1/content/projects"
},
{
"method": "GET",
"path": "/api/tran/v1/content/projects/summary"
},
{
"method": "GET",
"path": "/api/tran/v1/content/projects"
}
],
"unexpected": [],
"writes": [],
"pageErrors": []
}
},
{
"title": "系统用户:并行同错合并一条,重复搜索可再次提示",
"status": "PASS",
"durationMs": 12705,
"screenshots": [
{
"title": "03-users-grouped-message",
"path": "screenshots/03-users-grouped-message.png"
},
{
"title": "04-users-retry-without-banner",
"path": "screenshots/04-users-retry-without-banner.png"
}
],
"isolation": {
"requests": [
{
"method": "GET",
"path": "/api/auth/v1/system-config/public"
},
{
"method": "GET",
"path": "/api/auth/v1/auth/me"
},
{
"method": "GET",
"path": "/api/auth/v1/menus/navigation"
},
{
"method": "GET",
"path": "/api/auth/v1/roles/all"
},
{
"method": "GET",
"path": "/api/auth/v1/departments/tree"
},
{
"method": "GET",
"path": "/api/auth/v1/users"
},
{
"method": "GET",
"path": "/api/auth/v1/users/stats"
},
{
"method": "GET",
"path": "/api/auth/v1/users"
},
{
"method": "GET",
"path": "/api/auth/v1/users/stats"
}
],
"unexpected": [],
"writes": [],
"pageErrors": []
}
},
{
"title": "训练编辑器:加载失败提示消失后保留重新加载按钮",
"status": "PASS",
"durationMs": 6385,
"screenshots": [
{
"title": "05-legacy-training-message",
"path": "screenshots/05-legacy-training-message.png"
},
{
"title": "06-legacy-training-retry",
"path": "screenshots/06-legacy-training-retry.png"
}
],
"isolation": {
"requests": [
{
"method": "GET",
"path": "/api/auth/v1/system-config/public"
},
{
"method": "GET",
"path": "/api/auth/v1/auth/me"
},
{
"method": "GET",
"path": "/api/auth/v1/menus/navigation"
},
{
"method": "GET",
"path": "/api/tran/v1/content/projects/message-training-901"
}
],
"unexpected": [],
"writes": [],
"pageErrors": []
}
},
{
"title": "实装预览:任务上下文错误用 Message,离线与教员模式持续可见",
"status": "PASS",
"durationMs": 6450,
"screenshots": [
{
"title": "07-physical-context-message",
"path": "screenshots/07-physical-context-message.png"
},
{
"title": "08-physical-mode-preserved",
"path": "screenshots/08-physical-mode-preserved.png"
}
],
"isolation": {
"requests": [
{
"method": "GET",
"path": "/api/auth/v1/system-config/public"
},
{
"method": "GET",
"path": "/api/auth/v1/auth/me"
},
{
"method": "GET",
"path": "/api/auth/v1/menus/navigation"
},
{
"method": "GET",
"path": "/api/tran/v1/teaching/assignments/mock-physical"
}
],
"unexpected": [],
"writes": [],
"pageErrors": []
}
},
{
"title": "新建训练:业务说明与必填校验保留,不发起创建请求",
"status": "PASS",
"durationMs": 2485,
"screenshots": [
{
"title": "09-create-validation-preserved",
"path": "screenshots/09-create-validation-preserved.png"
}
],
"isolation": {
"requests": [
{
"method": "GET",
"path": "/api/auth/v1/system-config/public"
},
{
"method": "GET",
"path": "/api/auth/v1/auth/me"
},
{
"method": "GET",
"path": "/api/auth/v1/menus/navigation"
},
{
"method": "GET",
"path": "/api/tran/v1/content/projects/summary"
},
{
"method": "GET",
"path": "/api/tran/v1/content/projects"
}
],
"unexpected": [],
"writes": [],
"pageErrors": []
}
},
{
"title": "缓存列表:失活时晚到错误不弹出,返回不重放旧错误",
"status": "PASS",
"durationMs": 1947,
"screenshots": [
{
"title": "10-inactive-page-no-message",
"path": "screenshots/10-inactive-page-no-message.png"
},
{
"title": "11-reactivated-no-stale-message",
"path": "screenshots/11-reactivated-no-stale-message.png"
}
],
"isolation": {
"requests": [
{
"method": "GET",
"path": "/api/auth/v1/system-config/public"
},
{
"method": "GET",
"path": "/api/auth/v1/auth/me"
},
{
"method": "GET",
"path": "/api/auth/v1/menus/navigation"
},
{
"method": "GET",
"path": "/api/tran/v1/content/projects/summary"
},
{
"method": "GET",
"path": "/api/tran/v1/content/projects"
},
{
"method": "GET",
"path": "/api/tran/v1/content/projects/summary"
},
{
"method": "GET",
"path": "/api/tran/v1/content/projects"
},
{
"method": "GET",
"path": "/api/auth/v1/roles/all"
},
{
"method": "GET",
"path": "/api/auth/v1/departments/tree"
},
{
"method": "GET",
"path": "/api/auth/v1/users"
},
{
"method": "GET",
"path": "/api/auth/v1/users/stats"
},
{
"method": "GET",
"path": "/api/tran/v1/content/projects/summary"
},
{
"method": "GET",
"path": "/api/tran/v1/content/projects"
}
],
"unexpected": [],
"writes": [],
"pageErrors": []
}
},
{
"title": "HTTP 合同:真实 Axios 超时中文化,403/409 元数据保留,静默请求不自动弹窗",
"status": "PASS",
"durationMs": 1942,
"screenshots": [
{
"title": "12-http-silent-errors",
"path": "screenshots/12-http-silent-errors.png"
},
{
"title": "15-business-message-keeps-original-text",
"path": "screenshots/15-business-message-keeps-original-text.png"
}
],
"isolation": {
"requests": [
{
"method": "GET",
"path": "/api/auth/v1/system-config/public"
},
{
"method": "GET",
"path": "/api/auth/v1/auth/me"
},
{
"method": "GET",
"path": "/api/auth/v1/menus/navigation"
},
{
"method": "GET",
"path": "/api/tran/v1/content/projects/summary"
},
{
"method": "GET",
"path": "/api/tran/v1/content/projects"
},
{
"method": "GET",
"path": "/api/auth/v1/__message_e2e/timeout"
},
{
"method": "GET",
"path": "/api/auth/v1/__message_e2e/forbidden"
},
{
"method": "GET",
"path": "/api/auth/v1/__message_e2e/conflict"
},
{
"method": "GET",
"path": "/api/auth/v1/__message_e2e/timeout"
},
{
"method": "GET",
"path": "/api/auth/v1/__message_e2e/business-text"
}
],
"unexpected": [],
"writes": [],
"pageErrors": []
},
"http": [
{
"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"
}
],
"httpBoundaries": {
"cancellation": {
"name": "CanceledError",
"code": "ERR_CANCELED"
},
"businessError": {
"message": "Network Error",
"code": "ASSET_VALIDATION",
"requestId": "mock-business",
"status": 400
}
}
},
{
"title": "局域网教学列表:既有 ElMessage.error 使用全局关闭和自动消失配置",
"status": "PASS",
"durationMs": 6935,
"screenshots": [
{
"title": "13-lan-teaching-message",
"path": "screenshots/13-lan-teaching-message.png"
},
{
"title": "14-lan-teaching-after-dismiss",
"path": "screenshots/14-lan-teaching-after-dismiss.png"
}
],
"isolation": {
"requests": [
{
"method": "GET",
"path": "/api/auth/v1/system-config/public"
},
{
"method": "GET",
"path": "/api/auth/v1/auth/me"
},
{
"method": "GET",
"path": "/api/auth/v1/menus/navigation"
},
{
"method": "GET",
"path": "/api/tran/v1/teaching/assignments"
},
{
"method": "GET",
"path": "/api/tran/v1/teaching/assignments/summary"
}
],
"unexpected": [],
"writes": [],
"pageErrors": []
}
},
{
"title": "模型桥接 DOM 合同:初次错误与晚写详情转为重试说明,ready 恢复交互",
"status": "PASS",
"durationMs": 791,
"screenshots": [
{
"title": "16-model-bridge-neutral-retry",
"path": "screenshots/16-model-bridge-neutral-retry.png"
},
{
"title": "17-model-bridge-ready",
"path": "screenshots/17-model-bridge-ready.png"
}
],
"isolation": {
"requests": [],
"unexpected": [],
"writes": [],
"pageErrors": []
}
}
],
"limitations": [
"这是接口失败展示的聚焦浏览器回归,未逐一遍历所有业务页面。",
"页面渲染、Element Plus Message、缓存路由和 Axios 超时均运行于真实浏览器;服务端响应由测试模拟。",
"模型 iframe 项为真实宿主桥接的 DOM 合同测试,使用空画布夹具,不代表完整 GLB 模型加载测试。",
"保留训练业务状态、表单校验与重试入口。未扩大教学流程、权限或接口合同。"
],
"reproduction": "pnpm exec playwright test -c api-error-messages.config.ts"
}

+ 5
- 0
reports/api-error-messages-20260906/index.html
文件差異過大導致無法顯示
查看文件


二進制
查看文件


二進制
查看文件


二進制
查看文件


二進制
查看文件


二進制
查看文件


二進制
查看文件


二進制
查看文件


二進制
查看文件


二進制
查看文件


二進制
查看文件


二進制
查看文件


二進制
查看文件


二進制
查看文件


二進制
查看文件


二進制
查看文件


二進制
查看文件


二進制
查看文件


+ 76
- 0
reports/api-error-messages-20260906/playwright/index.html
文件差異過大導致無法顯示
查看文件


+ 159
- 0
reports/api-error-messages-20260906/report.md 查看文件

@@ -0,0 +1,159 @@
# 接口错误 Message 回归报告

主回归:**9/9 通过**。17 张截图,58 次模拟 API 请求,0 个未捕获页面异常。

认证附加:3 项通过,2 项旧改密入口受当前路由契约阻断。原始失败保留,未计为通过。总状态:PASS_WITH_LEGACY_BLOCKERS。

## 资料

按本轮“接口错误使用 Message,不保留常驻错误横条”的要求检查。沿用已确认的教学业务边界:离线识别回放、观察模式、步骤反馈和表单校验仍需可见。

## 原型

错误发生时顶部短暂提示;可以手动关闭,约 4.5 秒自动消失。同一错误合并展示;失败后仍可重新加载,已成功取得的数据不因后续刷新失败消失。

## 设计

共享 Message 处理负责统一展示与缓存页面生命周期;请求层保留业务错误码、HTTP 状态和 requestId,静默请求不会自行弹窗。未取得数据与空结果保持区分。

## 开发

代表性覆盖内容制作、系统用户与教学实施,包括训练编辑器加载失败的重试入口。接口异常详情仅在 Message 中展示;表单必填错误、项目编码说明和离线运行模式继续保留。

## 测试

使用真实 Vue / Element Plus 页面、Edge 无头浏览器和浏览器级 API mock。覆盖本机与局域网入口。身份及接口数据均为合成测试数据;不登录真实账号、不写业务库,不将本轮测试称为真实服务端或设备联调。

## 浏览器结果

### 1. 内容列表:错误自动消失、重试恢复、再次失败保留旧数据

状态:PASS。耗时 12.8 秒。

![01-content-error-message](screenshots/01-content-error-message.png)

![02-content-old-data-retained](screenshots/02-content-old-data-retained.png)

### 2. 系统用户:并行同错合并一条,重复搜索可再次提示

状态:PASS。耗时 12.7 秒。

![03-users-grouped-message](screenshots/03-users-grouped-message.png)

![04-users-retry-without-banner](screenshots/04-users-retry-without-banner.png)

### 3. 训练编辑器:加载失败提示消失后保留重新加载按钮

状态:PASS。耗时 6.4 秒。

![05-legacy-training-message](screenshots/05-legacy-training-message.png)

![06-legacy-training-retry](screenshots/06-legacy-training-retry.png)

### 4. 实装预览:任务上下文错误用 Message,离线与教员模式持续可见

状态:PASS。耗时 6.5 秒。

![07-physical-context-message](screenshots/07-physical-context-message.png)

![08-physical-mode-preserved](screenshots/08-physical-mode-preserved.png)

### 5. 新建训练:业务说明与必填校验保留,不发起创建请求

状态:PASS。耗时 2.5 秒。

![09-create-validation-preserved](screenshots/09-create-validation-preserved.png)

### 6. 缓存列表:失活时晚到错误不弹出,返回不重放旧错误

状态:PASS。耗时 1.9 秒。

![10-inactive-page-no-message](screenshots/10-inactive-page-no-message.png)

![11-reactivated-no-stale-message](screenshots/11-reactivated-no-stale-message.png)

### 7. HTTP 合同:真实 Axios 超时中文化,403/409 元数据保留,静默请求不自动弹窗

状态:PASS。耗时 1.9 秒。

![12-http-silent-errors](screenshots/12-http-silent-errors.png)

![15-business-message-keeps-original-text](screenshots/15-business-message-keeps-original-text.png)

### 8. 局域网教学列表:既有 ElMessage.error 使用全局关闭和自动消失配置

状态:PASS。耗时 6.9 秒。

![13-lan-teaching-message](screenshots/13-lan-teaching-message.png)

![14-lan-teaching-after-dismiss](screenshots/14-lan-teaching-after-dismiss.png)

### 9. 模型桥接 DOM 合同:初次错误与晚写详情转为重试说明,ready 恢复交互

状态:PASS。耗时 0.8 秒。

![16-model-bridge-neutral-retry](screenshots/16-model-bridge-neutral-retry.png)

![17-model-bridge-ready](screenshots/17-model-bridge-ready.png)

## 认证附加回归(独立统计)

共实际运行 5 项:3 项通过;2 项旧强制改密用例受当前路由契约阻断,未验证到原断言。主报告 9 项结果单独统计。

### 859:无可登录教员时账号选择器禁用

状态:PASS。保留业务空态,选择器不可展开。

![无教员业务空态](authentication/screenshots/859-passed.png)

### 861:旧强制改密不一致校验

状态:BLOCKED;Playwright 原始状态:failed。当前 /change-password 对已认证用户重定向 /dashboard;旧 fixture 只有强制改密标记而无工作台权限,后续导航取消,停留在启动页,未进入改密表单。该旧用例不适用于当前路由契约,本次未改路由、未跳过或记为通过。

![旧改密入口阻断](authentication/screenshots/861-failed.png)

### 864:旧强制改密停用账号提示

状态:BLOCKED;Playwright 原始状态:failed。与 861 相同的既有路由契约阻断,未能提交初始改密接口,也未验证一次性登录提示。原始失败证据保留。

![旧停用账号流程阻断](authentication/screenshots/864-failed.png)

### 普通会话过期不泄露后端内部详情

状态:PASS。跳转登录页,登录错误栏为空,不显示内部会话或刷新令牌详情,仅发起一次刷新请求。

### 当前管理员登录 API 401 错误使用自动消失的 Message

状态:PASS。实际点击登录;mock POST /auth/login 返回 401。只出现一条 .el-message--error,#login-error 始终为空;未点击关闭,Message 自动消失。登录请求 1 次,刷新请求 0 次。

![登录失败 Message](authentication/login-message/screenshots/login-api-error-message.png)

![Message 自动消失后](authentication/login-message/screenshots/login-api-error-message-dismissed.png)

- 仅模拟认证接口与浏览器交互,不使用真实账号,不验证后端认证规则或真实密码修改。
- 未运行依赖真实账号的 auth.spec.ts 全量;仅更新其中接口错误提示的断言选择器。
- 两条旧强制改密用例没有通过,不能作为当前改密功能验证证据;既有路由策略不在本轮提示展示调整范围。

[认证汇总证据](authentication/summary.json)

## 范围与复现

- 这是接口失败展示的聚焦浏览器回归,未逐一遍历所有业务页面。
- 页面渲染、Element Plus Message、缓存路由和 Axios 超时均运行于真实浏览器;服务端响应由测试模拟。
- 模型 iframe 项为真实宿主桥接的 DOM 合同测试,使用空画布夹具,不代表完整 GLB 模型加载测试。
- 保留训练业务状态、表单校验与重试入口。未扩大教学流程、权限或接口合同。

pnpm run build:PASS。最终 vue-tsc、Vite 与 legacy iframe public/dist 哈希校验通过;仍有既有 chunk 体积提示。

node --test tools/scene-cover-api.test.mjs tools/guide-error-message.test.mjs:PASS。20/20 通过,包含保存失败只提示一次、阻断发布与离开,以及指导书/场景错误提示回归。

pnpm exec tsc --noEmit --target ES2022 --module ESNext --moduleResolution Bundler --strict --types 'node,@playwright/test' tests/api-error-messages.spec.ts api-error-messages.config.ts:PASS。本轮新增浏览器测试与配置类型检查通过。

git diff --check:PASS。主任务检查通过;报告生成后再次核验本轮文件。

```powershell
pnpm exec playwright test -c api-error-messages.config.ts
node tools/build-api-error-message-report.mjs
```

测试开始:2026-09-05T17:32:19.650Z。报告生成:2026-09-05T17:36:20.720Z。

+ 556
- 0
reports/api-error-messages-20260906/results.json 查看文件

@@ -0,0 +1,556 @@
{
"config": {
"configFile": "E:\\work\\edit_3d\\unreal_tran\\ute2e\\api-error-messages.config.ts",
"rootDir": "E:/work/edit_3d/unreal_tran/ute2e/tests",
"forbidOnly": false,
"fullyParallel": false,
"globalSetup": null,
"globalTeardown": null,
"globalTimeout": 0,
"grep": {},
"grepInvert": null,
"maxFailures": 0,
"metadata": {
"actualWorkers": 1
},
"preserveOutput": "always",
"reporter": [
[
"list",
null
],
[
"html",
{
"outputFolder": "reports/api-error-messages-20260906/playwright",
"open": "never"
}
],
[
"json",
{
"outputFile": "reports/api-error-messages-20260906/results.json"
}
]
],
"reportSlowTests": {
"max": 5,
"threshold": 300000
},
"quiet": false,
"projects": [
{
"outputDir": "E:/work/edit_3d/unreal_tran/ute2e/reports/api-error-messages-20260906/test-results",
"repeatEach": 1,
"retries": 0,
"metadata": {
"actualWorkers": 1
},
"id": "",
"name": "",
"testDir": "E:/work/edit_3d/unreal_tran/ute2e/tests",
"testIgnore": [],
"testMatch": [
"api-error-messages.spec.ts"
],
"timeout": 60000
}
],
"shard": null,
"updateSnapshots": "missing",
"updateSourceMethod": "patch",
"version": "1.55.0",
"workers": 1,
"webServer": null
},
"suites": [
{
"title": "api-error-messages.spec.ts",
"file": "api-error-messages.spec.ts",
"column": 0,
"line": 0,
"specs": [
{
"title": "内容列表:错误自动消失、重试恢复、再次失败保留旧数据",
"ok": true,
"tags": [],
"tests": [
{
"timeout": 60000,
"annotations": [],
"expectedStatus": "passed",
"projectId": "",
"projectName": "",
"results": [
{
"workerIndex": 0,
"parallelIndex": 0,
"status": "passed",
"duration": 12754,
"errors": [],
"stdout": [],
"stderr": [],
"retry": 0,
"startTime": "2026-09-05T17:32:20.820Z",
"annotations": [],
"attachments": [
{
"name": "01-content-error-message",
"contentType": "image/png",
"path": "E:\\work\\edit_3d\\unreal_tran\\ute2e\\reports\\api-error-messages-20260906\\test-results\\api-error-messages-内容列表:错误自动消失、重试恢复、再次失败保留旧数据\\attachments\\01-content-error-message-ac439d2f66329cf28022531899833b96ec5b23da.png"
},
{
"name": "02-content-old-data-retained",
"contentType": "image/png",
"path": "E:\\work\\edit_3d\\unreal_tran\\ute2e\\reports\\api-error-messages-20260906\\test-results\\api-error-messages-内容列表:错误自动消失、重试恢复、再次失败保留旧数据\\attachments\\02-content-old-data-retained-0cbf8d445ed5097f84d18d5be7de9ba814a92620.png"
},
{
"name": "API 隔离与浏览器错误",
"contentType": "application/json",
"body": "ewogICJyZXF1ZXN0cyI6IFsKICAgIHsKICAgICAgIm1ldGhvZCI6ICJHRVQiLAogICAgICAicGF0aCI6ICIvYXBpL2F1dGgvdjEvc3lzdGVtLWNvbmZpZy9wdWJsaWMiCiAgICB9LAogICAgewogICAgICAibWV0aG9kIjogIkdFVCIsCiAgICAgICJwYXRoIjogIi9hcGkvYXV0aC92MS9hdXRoL21lIgogICAgfSwKICAgIHsKICAgICAgIm1ldGhvZCI6ICJHRVQiLAogICAgICAicGF0aCI6ICIvYXBpL2F1dGgvdjEvbWVudXMvbmF2aWdhdGlvbiIKICAgIH0sCiAgICB7CiAgICAgICJtZXRob2QiOiAiR0VUIiwKICAgICAgInBhdGgiOiAiL2FwaS90cmFuL3YxL2NvbnRlbnQvcHJvamVjdHMvc3VtbWFyeSIKICAgIH0sCiAgICB7CiAgICAgICJtZXRob2QiOiAiR0VUIiwKICAgICAgInBhdGgiOiAiL2FwaS90cmFuL3YxL2NvbnRlbnQvcHJvamVjdHMiCiAgICB9LAogICAgewogICAgICAibWV0aG9kIjogIkdFVCIsCiAgICAgICJwYXRoIjogIi9hcGkvdHJhbi92MS9jb250ZW50L3Byb2plY3RzIgogICAgfSwKICAgIHsKICAgICAgIm1ldGhvZCI6ICJHRVQiLAogICAgICAicGF0aCI6ICIvYXBpL3RyYW4vdjEvY29udGVudC9wcm9qZWN0cy9zdW1tYXJ5IgogICAgfSwKICAgIHsKICAgICAgIm1ldGhvZCI6ICJHRVQiLAogICAgICAicGF0aCI6ICIvYXBpL3RyYW4vdjEvY29udGVudC9wcm9qZWN0cyIKICAgIH0KICBdLAogICJ1bmV4cGVjdGVkIjogW10sCiAgIndyaXRlcyI6IFtdLAogICJwYWdlRXJyb3JzIjogW10KfQ=="
}
]
}
],
"status": "expected"
}
],
"id": "a01d8293b6a423e3d18f-2038675176804df1ab00",
"file": "api-error-messages.spec.ts",
"line": 129,
"column": 1
},
{
"title": "系统用户:并行同错合并一条,重复搜索可再次提示",
"ok": true,
"tags": [],
"tests": [
{
"timeout": 60000,
"annotations": [],
"expectedStatus": "passed",
"projectId": "",
"projectName": "",
"results": [
{
"workerIndex": 0,
"parallelIndex": 0,
"status": "passed",
"duration": 12705,
"errors": [],
"stdout": [],
"stderr": [],
"retry": 0,
"steps": [
{
"title": "Expect \"poll toBe\"",
"duration": 1
}
],
"startTime": "2026-09-05T17:32:34.098Z",
"annotations": [],
"attachments": [
{
"name": "03-users-grouped-message",
"contentType": "image/png",
"path": "E:\\work\\edit_3d\\unreal_tran\\ute2e\\reports\\api-error-messages-20260906\\test-results\\api-error-messages-系统用户:并行同错合并一条,重复搜索可再次提示\\attachments\\03-users-grouped-message-0575ee194c8ced5662a122dcc67aa169686ac8a6.png"
},
{
"name": "04-users-retry-without-banner",
"contentType": "image/png",
"path": "E:\\work\\edit_3d\\unreal_tran\\ute2e\\reports\\api-error-messages-20260906\\test-results\\api-error-messages-系统用户:并行同错合并一条,重复搜索可再次提示\\attachments\\04-users-retry-without-banner-e04f7c4569a80610fc43865d44b28f1d75fb8df9.png"
},
{
"name": "API 隔离与浏览器错误",
"contentType": "application/json",
"body": "ewogICJyZXF1ZXN0cyI6IFsKICAgIHsKICAgICAgIm1ldGhvZCI6ICJHRVQiLAogICAgICAicGF0aCI6ICIvYXBpL2F1dGgvdjEvc3lzdGVtLWNvbmZpZy9wdWJsaWMiCiAgICB9LAogICAgewogICAgICAibWV0aG9kIjogIkdFVCIsCiAgICAgICJwYXRoIjogIi9hcGkvYXV0aC92MS9hdXRoL21lIgogICAgfSwKICAgIHsKICAgICAgIm1ldGhvZCI6ICJHRVQiLAogICAgICAicGF0aCI6ICIvYXBpL2F1dGgvdjEvbWVudXMvbmF2aWdhdGlvbiIKICAgIH0sCiAgICB7CiAgICAgICJtZXRob2QiOiAiR0VUIiwKICAgICAgInBhdGgiOiAiL2FwaS9hdXRoL3YxL3JvbGVzL2FsbCIKICAgIH0sCiAgICB7CiAgICAgICJtZXRob2QiOiAiR0VUIiwKICAgICAgInBhdGgiOiAiL2FwaS9hdXRoL3YxL2RlcGFydG1lbnRzL3RyZWUiCiAgICB9LAogICAgewogICAgICAibWV0aG9kIjogIkdFVCIsCiAgICAgICJwYXRoIjogIi9hcGkvYXV0aC92MS91c2VycyIKICAgIH0sCiAgICB7CiAgICAgICJtZXRob2QiOiAiR0VUIiwKICAgICAgInBhdGgiOiAiL2FwaS9hdXRoL3YxL3VzZXJzL3N0YXRzIgogICAgfSwKICAgIHsKICAgICAgIm1ldGhvZCI6ICJHRVQiLAogICAgICAicGF0aCI6ICIvYXBpL2F1dGgvdjEvdXNlcnMiCiAgICB9LAogICAgewogICAgICAibWV0aG9kIjogIkdFVCIsCiAgICAgICJwYXRoIjogIi9hcGkvYXV0aC92MS91c2Vycy9zdGF0cyIKICAgIH0KICBdLAogICJ1bmV4cGVjdGVkIjogW10sCiAgIndyaXRlcyI6IFtdLAogICJwYWdlRXJyb3JzIjogW10KfQ=="
}
]
}
],
"status": "expected"
}
],
"id": "a01d8293b6a423e3d18f-ff7858551a9850a38ecf",
"file": "api-error-messages.spec.ts",
"line": 151,
"column": 1
},
{
"title": "训练编辑器:加载失败提示消失后保留重新加载按钮",
"ok": true,
"tags": [],
"tests": [
{
"timeout": 60000,
"annotations": [],
"expectedStatus": "passed",
"projectId": "",
"projectName": "",
"results": [
{
"workerIndex": 0,
"parallelIndex": 0,
"status": "passed",
"duration": 6385,
"errors": [],
"stdout": [],
"stderr": [],
"retry": 0,
"startTime": "2026-09-05T17:32:46.827Z",
"annotations": [],
"attachments": [
{
"name": "05-legacy-training-message",
"contentType": "image/png",
"path": "E:\\work\\edit_3d\\unreal_tran\\ute2e\\reports\\api-error-messages-20260906\\test-results\\api-error-messages-训练编辑器:加载失败提示消失后保留重新加载按钮\\attachments\\05-legacy-training-message-c8e11a1d6b9a8f408d0a84d20b4223c775b7c413.png"
},
{
"name": "06-legacy-training-retry",
"contentType": "image/png",
"path": "E:\\work\\edit_3d\\unreal_tran\\ute2e\\reports\\api-error-messages-20260906\\test-results\\api-error-messages-训练编辑器:加载失败提示消失后保留重新加载按钮\\attachments\\06-legacy-training-retry-ecc6b9f71cbe20b889313296c4d899241b950a74.png"
},
{
"name": "API 隔离与浏览器错误",
"contentType": "application/json",
"body": "ewogICJyZXF1ZXN0cyI6IFsKICAgIHsKICAgICAgIm1ldGhvZCI6ICJHRVQiLAogICAgICAicGF0aCI6ICIvYXBpL2F1dGgvdjEvc3lzdGVtLWNvbmZpZy9wdWJsaWMiCiAgICB9LAogICAgewogICAgICAibWV0aG9kIjogIkdFVCIsCiAgICAgICJwYXRoIjogIi9hcGkvYXV0aC92MS9hdXRoL21lIgogICAgfSwKICAgIHsKICAgICAgIm1ldGhvZCI6ICJHRVQiLAogICAgICAicGF0aCI6ICIvYXBpL2F1dGgvdjEvbWVudXMvbmF2aWdhdGlvbiIKICAgIH0sCiAgICB7CiAgICAgICJtZXRob2QiOiAiR0VUIiwKICAgICAgInBhdGgiOiAiL2FwaS90cmFuL3YxL2NvbnRlbnQvcHJvamVjdHMvbWVzc2FnZS10cmFpbmluZy05MDEiCiAgICB9CiAgXSwKICAidW5leHBlY3RlZCI6IFtdLAogICJ3cml0ZXMiOiBbXSwKICAicGFnZUVycm9ycyI6IFtdCn0="
}
]
}
],
"status": "expected"
}
],
"id": "a01d8293b6a423e3d18f-7ef94e791c36ee1ddedd",
"file": "api-error-messages.spec.ts",
"line": 169,
"column": 1
},
{
"title": "实装预览:任务上下文错误用 Message,离线与教员模式持续可见",
"ok": true,
"tags": [],
"tests": [
{
"timeout": 60000,
"annotations": [],
"expectedStatus": "passed",
"projectId": "",
"projectName": "",
"results": [
{
"workerIndex": 0,
"parallelIndex": 0,
"status": "passed",
"duration": 6450,
"errors": [],
"stdout": [],
"stderr": [],
"retry": 0,
"startTime": "2026-09-05T17:32:53.229Z",
"annotations": [],
"attachments": [
{
"name": "07-physical-context-message",
"contentType": "image/png",
"path": "E:\\work\\edit_3d\\unreal_tran\\ute2e\\reports\\api-error-messages-20260906\\test-results\\api-error-messages-实装预览:任务上下文错误用-Message,离线与教员模式持续可见\\attachments\\07-physical-context-message-63fb383ff8eca8d595add99a6198d4d2882e9aaf.png"
},
{
"name": "08-physical-mode-preserved",
"contentType": "image/png",
"path": "E:\\work\\edit_3d\\unreal_tran\\ute2e\\reports\\api-error-messages-20260906\\test-results\\api-error-messages-实装预览:任务上下文错误用-Message,离线与教员模式持续可见\\attachments\\08-physical-mode-preserved-350411ad11a7f8a6727bd50289e51b171f32550b.png"
},
{
"name": "API 隔离与浏览器错误",
"contentType": "application/json",
"body": "ewogICJyZXF1ZXN0cyI6IFsKICAgIHsKICAgICAgIm1ldGhvZCI6ICJHRVQiLAogICAgICAicGF0aCI6ICIvYXBpL2F1dGgvdjEvc3lzdGVtLWNvbmZpZy9wdWJsaWMiCiAgICB9LAogICAgewogICAgICAibWV0aG9kIjogIkdFVCIsCiAgICAgICJwYXRoIjogIi9hcGkvYXV0aC92MS9hdXRoL21lIgogICAgfSwKICAgIHsKICAgICAgIm1ldGhvZCI6ICJHRVQiLAogICAgICAicGF0aCI6ICIvYXBpL2F1dGgvdjEvbWVudXMvbmF2aWdhdGlvbiIKICAgIH0sCiAgICB7CiAgICAgICJtZXRob2QiOiAiR0VUIiwKICAgICAgInBhdGgiOiAiL2FwaS90cmFuL3YxL3RlYWNoaW5nL2Fzc2lnbm1lbnRzL21vY2stcGh5c2ljYWwiCiAgICB9CiAgXSwKICAidW5leHBlY3RlZCI6IFtdLAogICJ3cml0ZXMiOiBbXSwKICAicGFnZUVycm9ycyI6IFtdCn0="
}
]
}
],
"status": "expected"
}
],
"id": "a01d8293b6a423e3d18f-364cf753ab5214963c3e",
"file": "api-error-messages.spec.ts",
"line": 181,
"column": 1
},
{
"title": "新建训练:业务说明与必填校验保留,不发起创建请求",
"ok": true,
"tags": [],
"tests": [
{
"timeout": 60000,
"annotations": [],
"expectedStatus": "passed",
"projectId": "",
"projectName": "",
"results": [
{
"workerIndex": 0,
"parallelIndex": 0,
"status": "passed",
"duration": 2485,
"errors": [],
"stdout": [],
"stderr": [],
"retry": 0,
"startTime": "2026-09-05T17:32:59.693Z",
"annotations": [],
"attachments": [
{
"name": "09-create-validation-preserved",
"contentType": "image/png",
"path": "E:\\work\\edit_3d\\unreal_tran\\ute2e\\reports\\api-error-messages-20260906\\test-results\\api-error-messages-新建训练:业务说明与必填校验保留,不发起创建请求\\attachments\\09-create-validation-preserved-4c333aadab5e6c9da93d389a099dd07c104ac11f.png"
},
{
"name": "API 隔离与浏览器错误",
"contentType": "application/json",
"body": "ewogICJyZXF1ZXN0cyI6IFsKICAgIHsKICAgICAgIm1ldGhvZCI6ICJHRVQiLAogICAgICAicGF0aCI6ICIvYXBpL2F1dGgvdjEvc3lzdGVtLWNvbmZpZy9wdWJsaWMiCiAgICB9LAogICAgewogICAgICAibWV0aG9kIjogIkdFVCIsCiAgICAgICJwYXRoIjogIi9hcGkvYXV0aC92MS9hdXRoL21lIgogICAgfSwKICAgIHsKICAgICAgIm1ldGhvZCI6ICJHRVQiLAogICAgICAicGF0aCI6ICIvYXBpL2F1dGgvdjEvbWVudXMvbmF2aWdhdGlvbiIKICAgIH0sCiAgICB7CiAgICAgICJtZXRob2QiOiAiR0VUIiwKICAgICAgInBhdGgiOiAiL2FwaS90cmFuL3YxL2NvbnRlbnQvcHJvamVjdHMvc3VtbWFyeSIKICAgIH0sCiAgICB7CiAgICAgICJtZXRob2QiOiAiR0VUIiwKICAgICAgInBhdGgiOiAiL2FwaS90cmFuL3YxL2NvbnRlbnQvcHJvamVjdHMiCiAgICB9CiAgXSwKICAidW5leHBlY3RlZCI6IFtdLAogICJ3cml0ZXMiOiBbXSwKICAicGFnZUVycm9ycyI6IFtdCn0="
}
]
}
],
"status": "expected"
}
],
"id": "a01d8293b6a423e3d18f-31297312774d5e164ef8",
"file": "api-error-messages.spec.ts",
"line": 195,
"column": 1
},
{
"title": "缓存列表:失活时晚到错误不弹出,返回不重放旧错误",
"ok": true,
"tags": [],
"tests": [
{
"timeout": 60000,
"annotations": [],
"expectedStatus": "passed",
"projectId": "",
"projectName": "",
"results": [
{
"workerIndex": 0,
"parallelIndex": 0,
"status": "passed",
"duration": 1947,
"errors": [],
"stdout": [],
"stderr": [],
"retry": 0,
"steps": [
{
"title": "Expect \"poll toBeGreaterThan\"",
"duration": 356
},
{
"title": "Expect \"poll toBe\"",
"duration": 17
}
],
"startTime": "2026-09-05T17:33:02.191Z",
"annotations": [],
"attachments": [
{
"name": "10-inactive-page-no-message",
"contentType": "image/png",
"path": "E:\\work\\edit_3d\\unreal_tran\\ute2e\\reports\\api-error-messages-20260906\\test-results\\api-error-messages-缓存列表:失活时晚到错误不弹出,返回不重放旧错误\\attachments\\10-inactive-page-no-message-8860efdb888160db1c16587fccb41079a6a3face.png"
},
{
"name": "11-reactivated-no-stale-message",
"contentType": "image/png",
"path": "E:\\work\\edit_3d\\unreal_tran\\ute2e\\reports\\api-error-messages-20260906\\test-results\\api-error-messages-缓存列表:失活时晚到错误不弹出,返回不重放旧错误\\attachments\\11-reactivated-no-stale-message-d113250e56afaff950f860ca351e366466cfcdea.png"
},
{
"name": "API 隔离与浏览器错误",
"contentType": "application/json",
"body": "ewogICJyZXF1ZXN0cyI6IFsKICAgIHsKICAgICAgIm1ldGhvZCI6ICJHRVQiLAogICAgICAicGF0aCI6ICIvYXBpL2F1dGgvdjEvc3lzdGVtLWNvbmZpZy9wdWJsaWMiCiAgICB9LAogICAgewogICAgICAibWV0aG9kIjogIkdFVCIsCiAgICAgICJwYXRoIjogIi9hcGkvYXV0aC92MS9hdXRoL21lIgogICAgfSwKICAgIHsKICAgICAgIm1ldGhvZCI6ICJHRVQiLAogICAgICAicGF0aCI6ICIvYXBpL2F1dGgvdjEvbWVudXMvbmF2aWdhdGlvbiIKICAgIH0sCiAgICB7CiAgICAgICJtZXRob2QiOiAiR0VUIiwKICAgICAgInBhdGgiOiAiL2FwaS90cmFuL3YxL2NvbnRlbnQvcHJvamVjdHMvc3VtbWFyeSIKICAgIH0sCiAgICB7CiAgICAgICJtZXRob2QiOiAiR0VUIiwKICAgICAgInBhdGgiOiAiL2FwaS90cmFuL3YxL2NvbnRlbnQvcHJvamVjdHMiCiAgICB9LAogICAgewogICAgICAibWV0aG9kIjogIkdFVCIsCiAgICAgICJwYXRoIjogIi9hcGkvdHJhbi92MS9jb250ZW50L3Byb2plY3RzL3N1bW1hcnkiCiAgICB9LAogICAgewogICAgICAibWV0aG9kIjogIkdFVCIsCiAgICAgICJwYXRoIjogIi9hcGkvdHJhbi92MS9jb250ZW50L3Byb2plY3RzIgogICAgfSwKICAgIHsKICAgICAgIm1ldGhvZCI6ICJHRVQiLAogICAgICAicGF0aCI6ICIvYXBpL2F1dGgvdjEvcm9sZXMvYWxsIgogICAgfSwKICAgIHsKICAgICAgIm1ldGhvZCI6ICJHRVQiLAogICAgICAicGF0aCI6ICIvYXBpL2F1dGgvdjEvZGVwYXJ0bWVudHMvdHJlZSIKICAgIH0sCiAgICB7CiAgICAgICJtZXRob2QiOiAiR0VUIiwKICAgICAgInBhdGgiOiAiL2FwaS9hdXRoL3YxL3VzZXJzIgogICAgfSwKICAgIHsKICAgICAgIm1ldGhvZCI6ICJHRVQiLAogICAgICAicGF0aCI6ICIvYXBpL2F1dGgvdjEvdXNlcnMvc3RhdHMiCiAgICB9LAogICAgewogICAgICAibWV0aG9kIjogIkdFVCIsCiAgICAgICJwYXRoIjogIi9hcGkvdHJhbi92MS9jb250ZW50L3Byb2plY3RzL3N1bW1hcnkiCiAgICB9LAogICAgewogICAgICAibWV0aG9kIjogIkdFVCIsCiAgICAgICJwYXRoIjogIi9hcGkvdHJhbi92MS9jb250ZW50L3Byb2plY3RzIgogICAgfQogIF0sCiAgInVuZXhwZWN0ZWQiOiBbXSwKICAid3JpdGVzIjogW10sCiAgInBhZ2VFcnJvcnMiOiBbXQp9"
}
]
}
],
"status": "expected"
}
],
"id": "a01d8293b6a423e3d18f-4825b4e93934bda5a681",
"file": "api-error-messages.spec.ts",
"line": 206,
"column": 1
},
{
"title": "HTTP 合同:真实 Axios 超时中文化,403/409 元数据保留,静默请求不自动弹窗",
"ok": true,
"tags": [],
"tests": [
{
"timeout": 60000,
"annotations": [],
"expectedStatus": "passed",
"projectId": "",
"projectName": "",
"results": [
{
"workerIndex": 0,
"parallelIndex": 0,
"status": "passed",
"duration": 1942,
"errors": [],
"stdout": [],
"stderr": [],
"retry": 0,
"startTime": "2026-09-05T17:33:04.152Z",
"annotations": [],
"attachments": [
{
"name": "HTTP 合同证据",
"contentType": "application/json",
"body": "WwogIHsKICAgICJlbmRwb2ludCI6ICJ0aW1lb3V0IiwKICAgICJtZXNzYWdlIjogIuivt+axgui2heaXtu+8jOivt+eojeWQjumHjeivlSIsCiAgICAiY29kZSI6ICJORVRXT1JLX0VSUk9SIiwKICAgICJzdGF0dXMiOiBudWxsLAogICAgInJlcXVlc3RJZCI6ICIiCiAgfSwKICB7CiAgICAiZW5kcG9pbnQiOiAiZm9yYmlkZGVuIiwKICAgICJtZXNzYWdlIjogIuaXoOadg+iuv+mXrua1i+ivleaVsOaNriIsCiAgICAiY29kZSI6ICJBQ0NFU1NfREVOSUVEIiwKICAgICJzdGF0dXMiOiA0MDMsCiAgICAicmVxdWVzdElkIjogIm1vY2stNDAzIgogIH0sCiAgewogICAgImVuZHBvaW50IjogImNvbmZsaWN0IiwKICAgICJtZXNzYWdlIjogIueJiOacrOW3suWPmOWMlu+8jOivt+mHjeaWsOWKoOi9vSIsCiAgICAiY29kZSI6ICJWRVJTSU9OX0NPTkZMSUNUIiwKICAgICJzdGF0dXMiOiA0MDksCiAgICAicmVxdWVzdElkIjogIm1vY2stNDA5IgogIH0KXQ=="
},
{
"name": "12-http-silent-errors",
"contentType": "image/png",
"path": "E:\\work\\edit_3d\\unreal_tran\\ute2e\\reports\\api-error-messages-20260906\\test-results\\api-error-messages-HTTP-合同-39274-中文化,403-409-元数据保留,静默请求不自动弹窗\\attachments\\12-http-silent-errors-95eb3df2d354eee7c7755066bf80544b01b9e891.png"
},
{
"name": "15-business-message-keeps-original-text",
"contentType": "image/png",
"path": "E:\\work\\edit_3d\\unreal_tran\\ute2e\\reports\\api-error-messages-20260906\\test-results\\api-error-messages-HTTP-合同-39274-中文化,403-409-元数据保留,静默请求不自动弹窗\\attachments\\15-business-message-keeps-original-text-3e1f30e539433cd6d073a3a94a6b616d1b7f9691.png"
},
{
"name": "取消请求与业务原文证据",
"contentType": "application/json",
"body": "ewogICJjYW5jZWxsYXRpb24iOiB7CiAgICAibmFtZSI6ICJDYW5jZWxlZEVycm9yIiwKICAgICJjb2RlIjogIkVSUl9DQU5DRUxFRCIKICB9LAogICJidXNpbmVzc0Vycm9yIjogewogICAgIm1lc3NhZ2UiOiAiTmV0d29yayBFcnJvciIsCiAgICAiY29kZSI6ICJBU1NFVF9WQUxJREFUSU9OIiwKICAgICJyZXF1ZXN0SWQiOiAibW9jay1idXNpbmVzcyIsCiAgICAic3RhdHVzIjogNDAwCiAgfQp9"
},
{
"name": "API 隔离与浏览器错误",
"contentType": "application/json",
"body": "ewogICJyZXF1ZXN0cyI6IFsKICAgIHsKICAgICAgIm1ldGhvZCI6ICJHRVQiLAogICAgICAicGF0aCI6ICIvYXBpL2F1dGgvdjEvc3lzdGVtLWNvbmZpZy9wdWJsaWMiCiAgICB9LAogICAgewogICAgICAibWV0aG9kIjogIkdFVCIsCiAgICAgICJwYXRoIjogIi9hcGkvYXV0aC92MS9hdXRoL21lIgogICAgfSwKICAgIHsKICAgICAgIm1ldGhvZCI6ICJHRVQiLAogICAgICAicGF0aCI6ICIvYXBpL2F1dGgvdjEvbWVudXMvbmF2aWdhdGlvbiIKICAgIH0sCiAgICB7CiAgICAgICJtZXRob2QiOiAiR0VUIiwKICAgICAgInBhdGgiOiAiL2FwaS90cmFuL3YxL2NvbnRlbnQvcHJvamVjdHMvc3VtbWFyeSIKICAgIH0sCiAgICB7CiAgICAgICJtZXRob2QiOiAiR0VUIiwKICAgICAgInBhdGgiOiAiL2FwaS90cmFuL3YxL2NvbnRlbnQvcHJvamVjdHMiCiAgICB9LAogICAgewogICAgICAibWV0aG9kIjogIkdFVCIsCiAgICAgICJwYXRoIjogIi9hcGkvYXV0aC92MS9fX21lc3NhZ2VfZTJlL3RpbWVvdXQiCiAgICB9LAogICAgewogICAgICAibWV0aG9kIjogIkdFVCIsCiAgICAgICJwYXRoIjogIi9hcGkvYXV0aC92MS9fX21lc3NhZ2VfZTJlL2ZvcmJpZGRlbiIKICAgIH0sCiAgICB7CiAgICAgICJtZXRob2QiOiAiR0VUIiwKICAgICAgInBhdGgiOiAiL2FwaS9hdXRoL3YxL19fbWVzc2FnZV9lMmUvY29uZmxpY3QiCiAgICB9LAogICAgewogICAgICAibWV0aG9kIjogIkdFVCIsCiAgICAgICJwYXRoIjogIi9hcGkvYXV0aC92MS9fX21lc3NhZ2VfZTJlL3RpbWVvdXQiCiAgICB9LAogICAgewogICAgICAibWV0aG9kIjogIkdFVCIsCiAgICAgICJwYXRoIjogIi9hcGkvYXV0aC92MS9fX21lc3NhZ2VfZTJlL2J1c2luZXNzLXRleHQiCiAgICB9CiAgXSwKICAidW5leHBlY3RlZCI6IFtdLAogICJ3cml0ZXMiOiBbXSwKICAicGFnZUVycm9ycyI6IFtdCn0="
}
]
}
],
"status": "expected"
}
],
"id": "a01d8293b6a423e3d18f-eae6f6dfb8332cc93a68",
"file": "api-error-messages.spec.ts",
"line": 235,
"column": 1
},
{
"title": "局域网教学列表:既有 ElMessage.error 使用全局关闭和自动消失配置",
"ok": true,
"tags": [],
"tests": [
{
"timeout": 60000,
"annotations": [],
"expectedStatus": "passed",
"projectId": "",
"projectName": "",
"results": [
{
"workerIndex": 0,
"parallelIndex": 0,
"status": "passed",
"duration": 6935,
"errors": [],
"stdout": [],
"stderr": [],
"retry": 0,
"startTime": "2026-09-05T17:33:06.107Z",
"annotations": [],
"attachments": [
{
"name": "13-lan-teaching-message",
"contentType": "image/png",
"path": "E:\\work\\edit_3d\\unreal_tran\\ute2e\\reports\\api-error-messages-20260906\\test-results\\api-error-messages-局域网教学列表:既有-ElMessage-error-使用全局关闭和自动消失配置\\attachments\\13-lan-teaching-message-bf868d286d097355c25d44a190d448de0fac2dcf.png"
},
{
"name": "14-lan-teaching-after-dismiss",
"contentType": "image/png",
"path": "E:\\work\\edit_3d\\unreal_tran\\ute2e\\reports\\api-error-messages-20260906\\test-results\\api-error-messages-局域网教学列表:既有-ElMessage-error-使用全局关闭和自动消失配置\\attachments\\14-lan-teaching-after-dismiss-cca08e9d67eaa7575f446c040ea339316e6f8b8b.png"
},
{
"name": "API 隔离与浏览器错误",
"contentType": "application/json",
"body": "ewogICJyZXF1ZXN0cyI6IFsKICAgIHsKICAgICAgIm1ldGhvZCI6ICJHRVQiLAogICAgICAicGF0aCI6ICIvYXBpL2F1dGgvdjEvc3lzdGVtLWNvbmZpZy9wdWJsaWMiCiAgICB9LAogICAgewogICAgICAibWV0aG9kIjogIkdFVCIsCiAgICAgICJwYXRoIjogIi9hcGkvYXV0aC92MS9hdXRoL21lIgogICAgfSwKICAgIHsKICAgICAgIm1ldGhvZCI6ICJHRVQiLAogICAgICAicGF0aCI6ICIvYXBpL2F1dGgvdjEvbWVudXMvbmF2aWdhdGlvbiIKICAgIH0sCiAgICB7CiAgICAgICJtZXRob2QiOiAiR0VUIiwKICAgICAgInBhdGgiOiAiL2FwaS90cmFuL3YxL3RlYWNoaW5nL2Fzc2lnbm1lbnRzIgogICAgfSwKICAgIHsKICAgICAgIm1ldGhvZCI6ICJHRVQiLAogICAgICAicGF0aCI6ICIvYXBpL3RyYW4vdjEvdGVhY2hpbmcvYXNzaWdubWVudHMvc3VtbWFyeSIKICAgIH0KICBdLAogICJ1bmV4cGVjdGVkIjogW10sCiAgIndyaXRlcyI6IFtdLAogICJwYWdlRXJyb3JzIjogW10KfQ=="
}
]
}
],
"status": "expected"
}
],
"id": "a01d8293b6a423e3d18f-bc26053607f6ace6ff92",
"file": "api-error-messages.spec.ts",
"line": 295,
"column": 1
},
{
"title": "模型桥接 DOM 合同:初次错误与晚写详情转为重试说明,ready 恢复交互",
"ok": true,
"tags": [],
"tests": [
{
"timeout": 60000,
"annotations": [],
"expectedStatus": "passed",
"projectId": "",
"projectName": "",
"results": [
{
"workerIndex": 0,
"parallelIndex": 0,
"status": "passed",
"duration": 791,
"errors": [],
"stdout": [],
"stderr": [],
"retry": 0,
"startTime": "2026-09-05T17:33:13.059Z",
"annotations": [],
"attachments": [
{
"name": "16-model-bridge-neutral-retry",
"contentType": "image/png",
"path": "E:\\work\\edit_3d\\unreal_tran\\ute2e\\reports\\api-error-messages-20260906\\test-results\\api-error-messages-模型桥接-DOM-合同:初次错误与晚写详情转为重试说明,ready-恢复交互\\attachments\\16-model-bridge-neutral-retry-9e8444c1b83af1d3b6be9d12b894d0ced4d17299.png"
},
{
"name": "17-model-bridge-ready",
"contentType": "image/png",
"path": "E:\\work\\edit_3d\\unreal_tran\\ute2e\\reports\\api-error-messages-20260906\\test-results\\api-error-messages-模型桥接-DOM-合同:初次错误与晚写详情转为重试说明,ready-恢复交互\\attachments\\17-model-bridge-ready-80cdec397323585143136b2d8ae522630c30257e.png"
},
{
"name": "API 隔离与浏览器错误",
"contentType": "application/json",
"body": "ewogICJyZXF1ZXN0cyI6IFtdLAogICJ1bmV4cGVjdGVkIjogW10sCiAgIndyaXRlcyI6IFtdLAogICJwYWdlRXJyb3JzIjogW10KfQ=="
}
]
}
],
"status": "expected"
}
],
"id": "a01d8293b6a423e3d18f-c8d259ab5093a7d5f9c8",
"file": "api-error-messages.spec.ts",
"line": 307,
"column": 1
}
]
}
],
"errors": [],
"stats": {
"startTime": "2026-09-05T17:32:19.650Z",
"duration": 84090.698,
"expected": 9,
"skipped": 0,
"unexpected": 0,
"flaky": 0
}
}

二進制
查看文件


二進制
查看文件


二進制
查看文件


二進制
查看文件


二進制
查看文件


二進制
查看文件


二進制
查看文件


二進制
查看文件


二進制
查看文件


二進制
查看文件


二進制
查看文件


二進制
查看文件


二進制
查看文件


二進制
查看文件


二進制
查看文件


二進制
查看文件


二進制
查看文件


二進制
查看文件


二進制
查看文件


二進制
查看文件


二進制
查看文件


二進制
查看文件


二進制
查看文件


二進制
查看文件


二進制
查看文件


二進制
查看文件


二進制
查看文件


二進制
查看文件


二進制
查看文件


二進制
查看文件


二進制
查看文件


二進制
查看文件


二進制
查看文件


二進制
查看文件


+ 30
- 0
reports/api-error-messages-20260906/validation.json 查看文件

@@ -0,0 +1,30 @@
{
"commands": [
{
"command": "pnpm run build",
"scope": "unreal_tran_web",
"status": "PASS",
"details": "最终 vue-tsc、Vite 与 legacy iframe public/dist 哈希校验通过;仍有既有 chunk 体积提示。",
"log": "validation/build.log"
},
{
"command": "node --test tools/scene-cover-api.test.mjs tools/guide-error-message.test.mjs",
"scope": "ute2e",
"status": "PASS",
"details": "20/20 通过,包含保存失败只提示一次、阻断发布与离开,以及指导书/场景错误提示回归。",
"log": "validation/runtime-tests.log"
},
{
"command": "pnpm exec tsc --noEmit --target ES2022 --module ESNext --moduleResolution Bundler --strict --types 'node,@playwright/test' tests/api-error-messages.spec.ts api-error-messages.config.ts",
"scope": "ute2e",
"status": "PASS",
"details": "本轮新增浏览器测试与配置类型检查通过。"
},
{
"command": "git diff --check",
"scope": "unreal_tran_web、ute2e",
"status": "PASS",
"details": "主任务检查通过;报告生成后再次核验本轮文件。"
}
]
}

二進制
查看文件


二進制
查看文件


+ 337
- 0
tests/api-error-messages.spec.ts 查看文件

@@ -0,0 +1,337 @@
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')
})

+ 1
- 1
tests/auth.spec.ts 查看文件

@@ -27,7 +27,7 @@ test('管理员可以登录并安全退出', async ({ page }, testInfo) => {
await page.locator('input[name="username"]').fill('__ute2e_missing_account__')
await page.locator('input[name="password"]').fill('Uae2e-Invalid-Only')
await page.getByRole('button', { name: /登录系统/ }).click()
await expect(page.locator('.login-error')).toContainText('账号、密码或登录身份不匹配')
await expect(page.locator('.el-message--error')).toContainText('账号、密码或登录身份不匹配')
await login(page, adminCredentials())
await expect(page).toHaveURL(/\/dashboard(?:\?|$)/)
await captureScreenshot(page, testInfo, 'login-success')


+ 30
- 2
tests/zentao-auth-859-861-864.spec.ts 查看文件

@@ -43,14 +43,19 @@ const fulfill = (route: Route, data: unknown, status = 200, message = '成功',

async function mockAuth(
page: Page,
options: { me?: 'forced' | 'expired'; initialPassword401?: boolean } = {},
options: { me?: 'forced' | 'expired'; initialPassword401?: boolean; loginFailure?: boolean } = {},
) {
let refreshRequests = 0
let initialPasswordRequests = 0
let loginRequests = 0
await page.route('**/api/auth/v1/**', async (route) => {
const path = new URL(route.request().url()).pathname
if (path.endsWith('/auth/login-context')) return fulfill(route, loginContext)
if (path.endsWith('/auth/login-identities')) return fulfill(route, [])
if (path.endsWith('/auth/login') && route.request().method() === 'POST') {
loginRequests += 1
if (options.loginFailure) return fulfill(route, null, 401, '账号、密码或登录身份不匹配', 40102)
}
if (path.endsWith('/menus/navigation')) return fulfill(route, [])
if (path.endsWith('/auth/me')) {
if (options.me === 'expired') {
@@ -74,6 +79,7 @@ async function mockAuth(
return {
refreshRequests: () => refreshRequests,
initialPasswordRequests: () => initialPasswordRequests,
loginRequests: () => loginRequests,
}
}

@@ -130,7 +136,7 @@ test('864:首次改密期间账号停用后仅显示一次固定登录提示
await page.getByRole('button', { name: '确认修改并登录' }).click()

await expect(page).toHaveURL(/\/login\?redirect=/)
await expect(page.locator('#login-error')).toHaveText('账号、密码或登录身份不匹配')
await expect(page.locator('.el-message--error')).toHaveText('账号、密码或登录身份不匹配')
await expect(page.getByText(/internal-user-id|refresh-token-internal-detail/)).toHaveCount(0)
expect(mocked.initialPasswordRequests()).toBe(1)
expect(mocked.refreshRequests()).toBe(0)
@@ -150,3 +156,25 @@ test('普通会话过期只跳转登录,不展示后端错误详情或停用
await expect(page.getByText(/内部会话详情|refresh-token-internal-detail|账号、密码或登录身份不匹配/)).toHaveCount(0)
expect(mocked.refreshRequests()).toBe(1)
})

test('当前登录接口失败只显示临时 Message,自动消失且不留下登录错误条', async ({ page }, testInfo) => {
const mocked = await mockAuth(page, { loginFailure: true })
await page.goto('/login')
await page.locator('[data-role-code="admin"]').click()
await page.locator('input[name="username"]').fill('__ute2e_message_mock__')
await page.locator('input[name="password"]').fill('Invalid#Only8')
await page.getByRole('button', { name: '登录系统', exact: true }).click()

const message = page.locator('.el-message--error')
await expect(message).toHaveCount(1)
await expect(message).toHaveText('账号、密码或登录身份不匹配')
await expect(page.locator('#login-error')).toHaveText('')
expect(mocked.loginRequests()).toBe(1)
expect(mocked.refreshRequests()).toBe(0)
await captureScreenshot(page, testInfo, 'login-api-error-message')

await expect(message).toHaveCount(0, { timeout: 7_000 })
await expect(page.locator('#login-error')).toHaveText('')
await expect(page).toHaveURL(/\/login(?:\?|$)/)
await captureScreenshot(page, testInfo, 'login-api-error-message-dismissed')
})

+ 88
- 0
tools/build-api-error-message-report.mjs 查看文件

@@ -0,0 +1,88 @@
import fs from 'node:fs'
import path from 'node:path'
import { fileURLToPath } from 'node:url'

const repo = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
const root = path.join(repo, 'reports/api-error-messages-20260906')
const result = JSON.parse(fs.readFileSync(path.join(root, 'results.json'), 'utf8'))
const validationPath = path.join(root, 'validation.json')
const validation = fs.existsSync(validationPath) ? JSON.parse(fs.readFileSync(validationPath, 'utf8')) : null
const authenticationPath = path.join(root, 'authentication/summary.json')
const authentication = fs.existsSync(authenticationPath) ? JSON.parse(fs.readFileSync(authenticationPath, 'utf8')) : null
const escape = value => String(value ?? '').replace(/[&<>"']/g, char => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[char])
const decode = attachment => {
if (!attachment?.body) return null
try { return JSON.parse(Buffer.from(attachment.body, 'base64').toString('utf8')) } catch { return null }
}
const specs = []
function visit(suite) {
specs.push(...(suite.specs || []))
for (const child of suite.suites || []) visit(child)
}
for (const suite of result.suites || []) visit(suite)
const checks = specs.flatMap(spec => (spec.tests || []).map(test => {
const run = test.results?.at(-1) || {}
const screenshots = (run.attachments || []).filter(item => item.contentType === 'image/png' && fs.existsSync(path.join(root, 'screenshots', `${item.name}.png`)))
.map(item => ({ title: item.name, path: `screenshots/${item.name}.png` }))
const isolation = decode((run.attachments || []).find(item => item.name === 'API 隔离与浏览器错误'))
const http = decode((run.attachments || []).find(item => item.name === 'HTTP 合同证据'))
const httpBoundaries = decode((run.attachments || []).find(item => item.name === '取消请求与业务原文证据'))
return {
title: spec.title, status: run.status === 'passed' ? 'PASS' : String(run.status || 'NOT_RUN').toUpperCase(),
durationMs: run.duration || 0, screenshots, isolation, ...(http ? { http } : {}), ...(httpBoundaries ? { httpBoundaries } : {}),
}
}))
const passed = checks.filter(item => item.status === 'PASS').length
const failed = checks.length - passed
const screenshotCount = checks.reduce((sum, item) => sum + item.screenshots.length, 0)
const requestCount = checks.reduce((sum, item) => sum + (item.isolation?.requests.length || 0), 0)
const pageErrorCount = checks.reduce((sum, item) => sum + (item.isolation?.pageErrors.length || 0), 0)
for (const check of checks) {
if (check.status === 'PASS' && !check.isolation) throw new Error(`缺少隔离证据:${check.title}`)
}
const authenticationShots = authentication?.checks?.flatMap(check => check.screenshots || []) || []
for (const shot of authenticationShots) if (!fs.existsSync(path.join(root, shot.path))) throw new Error(`认证报告缺图:${shot.path}`)
const stages = [
['资料', '按本轮“接口错误使用 Message,不保留常驻错误横条”的要求检查。沿用已确认的教学业务边界:离线识别回放、观察模式、步骤反馈和表单校验仍需可见。'],
['原型', '错误发生时顶部短暂提示;可以手动关闭,约 4.5 秒自动消失。同一错误合并展示;失败后仍可重新加载,已成功取得的数据不因后续刷新失败消失。'],
['设计', '共享 Message 处理负责统一展示与缓存页面生命周期;请求层保留业务错误码、HTTP 状态和 requestId,静默请求不会自行弹窗。未取得数据与空结果保持区分。'],
['开发', '代表性覆盖内容制作、系统用户与教学实施,包括训练编辑器加载失败的重试入口。接口异常详情仅在 Message 中展示;表单必填错误、项目编码说明和离线运行模式继续保留。'],
['测试', '使用真实 Vue / Element Plus 页面、Edge 无头浏览器和浏览器级 API mock。覆盖本机与局域网入口。身份及接口数据均为合成测试数据;不登录真实账号、不写业务库,不将本轮测试称为真实服务端或设备联调。'],
]
const limitations = [
'这是接口失败展示的聚焦浏览器回归,未逐一遍历所有业务页面。',
'页面渲染、Element Plus Message、缓存路由和 Axios 超时均运行于真实浏览器;服务端响应由测试模拟。',
'模型 iframe 项为真实宿主桥接的 DOM 合同测试,使用空画布夹具,不代表完整 GLB 模型加载测试。',
'保留训练业务状态、表单校验与重试入口。未扩大教学流程、权限或接口合同。',
]
const evidence = {
title: '接口错误 Message 回归报告', status: failed ? 'FAILED' : authentication?.counts?.legacyBlocked ? 'PASS_WITH_LEGACY_BLOCKERS' : 'PASS',
generatedAt: new Date().toISOString(), startedAt: result.stats?.startTime,
durationMs: result.stats?.duration, passed, total: checks.length, screenshotCount, requestCount, pageErrorCount,
validation, authentication, stages: stages.map(([title, text]) => ({ title, text })), checks, limitations,
reproduction: 'pnpm exec playwright test -c api-error-messages.config.ts',
}
fs.writeFileSync(path.join(root, 'evidence.json'), JSON.stringify(evidence, null, 2) + '\n', 'utf8')

const validationText = validation ? validation.commands.map(item => `${item.command}:${item.status}。${item.details}`).join('\n\n') : '构建与源代码审查由主任务单独记录;本报告统计浏览器回归结果。'
const validationHtml = validation ? `<h2>构建与聚焦逻辑验证</h2><section class="scope">${validation.commands.map(item => `<p><strong>${escape(item.status)} · ${escape(item.scope)}</strong><br><code>${escape(item.command)}</code><br>${escape(item.details)}${item.log ? ` <a href="${escape(item.log)}">日志</a>` : ''}</p>`).join('')}</section>` : ''
const checkHtml = checks.map((check, index) => `<section class="check"><div class="check-title"><span class="badge ${check.status === 'PASS' ? 'pass' : 'fail'}">${escape(check.status)}</span><h3>${index + 1}. ${escape(check.title)}</h3><small>${(check.durationMs / 1000).toFixed(1)} 秒</small></div><div class="shots">${check.screenshots.map(shot => `<figure><a href="${escape(shot.path)}" target="_blank" rel="noopener"><img src="${escape(shot.path)}" alt="${escape(shot.title)}"></a><figcaption>${escape(shot.title)}</figcaption></figure>`).join('')}</div></section>`).join('')
const authenticationHtml = authentication ? `<h2>认证附加回归(独立统计)</h2><section class="scope"><p><strong>${escape(authentication.result)}</strong></p><p>${escape(authentication.environment)}</p>${authentication.limitations.map(text => `<p>${escape(text)}</p>`).join('')}<div class="links"><a href="authentication/summary.json">认证汇总证据</a>${authentication.runs.map((run, index) => `<a href="${escape(run.html)}">第 ${index + 1} 轮原始报告</a>`).join('')}</div></section>${authentication.checks.map(check => `<section class="check"><div class="check-title"><span class="badge ${check.status === 'PASS' ? 'pass' : 'fail'}">${escape(check.status)}</span><h3>${escape(check.title)}</h3></div><p>${escape(check.result)}</p>${check.rawStatus ? `<p>Playwright 原始状态:${escape(check.rawStatus)}。</p>` : ''}<div class="shots">${check.screenshots.map(shot => `<figure><a href="${escape(shot.path)}" target="_blank" rel="noopener"><img src="${escape(shot.path)}" alt="${escape(shot.title)}"></a><figcaption>${escape(shot.title)}</figcaption></figure>`).join('')}</div></section>`).join('')}` : ''
fs.writeFileSync(path.join(root, 'index.html'), `<!doctype html>
<html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>接口错误 Message 回归报告</title>
<style>*{box-sizing:border-box}body{margin:0;background:#f3f6f7;color:#183739;font-family:"Microsoft YaHei",system-ui,sans-serif;line-height:1.65}main{max-width:1220px;margin:auto;padding:28px 24px 60px}header{padding:30px;border-radius:18px;background:#084d46;color:white}h1{margin:0 0 10px;font-size:28px}h2{font-size:20px;margin:28px 0 12px}h3{font-size:16px;margin:0;flex:1;min-width:0}p{margin:10px 0}.muted{color:#c9e8e1}.stats{display:flex;gap:12px;flex-wrap:wrap;margin-top:20px}.stats span{border:1px solid #67958c;border-radius:9px;padding:8px 14px;background:#ffffff0b}.links{display:flex;gap:18px;flex-wrap:wrap;margin:14px 0}a{color:#126f62}.stages{display:grid;grid-template-columns:repeat(5,minmax(0,1fr));gap:12px}.stage,.check,.scope{background:white;border:1px solid #dce7e4;border-radius:12px;padding:18px}.stage strong{color:#147160}.stage p{font-size:14px;color:#45615f}.check{margin:14px 0}.check-title{display:flex;align-items:flex-start;gap:12px}.badge{font-size:12px;border-radius:6px;padding:3px 8px;white-space:nowrap}.pass{background:#e6f5ed;color:#197344}.fail{background:#ffebeb;color:#a72e2e}small{color:#69817e;white-space:nowrap}.shots{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:14px;margin-top:16px}figure{margin:0;min-width:0}img{display:block;width:100%;height:auto;border:1px solid #dce7e4;border-radius:7px}figcaption{font-size:12px;color:#617b76;overflow-wrap:anywhere;margin-top:5px}.scope p{color:#45615f}code{overflow-wrap:anywhere;font-size:13px}footer{margin-top:24px;color:#69817e;font-size:12px;overflow-wrap:anywhere}@media(max-width:900px){.stages{grid-template-columns:repeat(2,minmax(0,1fr))}}@media(max-width:620px){main{padding:14px 12px 35px}header{padding:20px}h1{font-size:23px}.stages,.shots{grid-template-columns:1fr}.check-title{flex-wrap:wrap}.check-title h3{flex-basis:75%}.check-title small{margin-left:60px}.check{padding:13px}}
</style></head><body><main><header><h1>接口错误 Message 回归报告</h1><p class="muted">资料 → 原型 → 设计 → 开发 → 测试</p><p>${failed ? '主回归存在未通过项,详见下方结果。' : '主回归:代表性接口错误按统一 Message 展示;自动消失、重试与业务状态保留已通过浏览器验证。'}</p>${authentication ? `<p>认证附加:${authentication.counts.passed} 项通过,${authentication.counts.legacyBlocked} 项旧改密入口受当前路由契约阻断;原始失败保留,未计为通过。</p>` : ''}<div class="stats"><span>主回归 ${passed} / ${checks.length} 通过</span><span>主回归 ${screenshotCount} 张截图</span><span>${requestCount} 次模拟 API 请求</span><span>${pageErrorCount} 个未捕获页面异常</span></div></header>
<div class="links"><a href="report.md">Markdown 报告</a><a href="evidence.json">结构化证据</a><a href="playwright/index.html">Playwright 报告</a></div><h2>审查与实施</h2><div class="stages">${stages.map(([title, text]) => `<section class="stage"><strong>${title}</strong><p>${escape(text)}</p></section>`).join('')}</div><h2>浏览器验证(主回归)</h2>${checkHtml}${authenticationHtml}${validationHtml}<h2>范围与复现</h2><section class="scope">${limitations.map(text => `<p>${escape(text)}</p>`).join('')}<p><code>${escape(evidence.reproduction)}</code></p><p>报告重新生成:<code>node tools/build-api-error-message-report.mjs</code></p></section><footer>测试开始:${escape(evidence.startedAt)} · 报告生成:${escape(evidence.generatedAt)}</footer></main></body></html>`, 'utf8')
const md = [
'# 接口错误 Message 回归报告', '', `主回归:**${passed}/${checks.length} 通过**。${screenshotCount} 张截图,${requestCount} 次模拟 API 请求,${pageErrorCount} 个未捕获页面异常。`, '',
...(authentication ? [`认证附加:${authentication.counts.passed} 项通过,${authentication.counts.legacyBlocked} 项旧改密入口受当前路由契约阻断。原始失败保留,未计为通过。总状态:${evidence.status}。`, ''] : []),
...stages.flatMap(([title, text]) => [`## ${title}`, '', text, '']),
'## 浏览器结果', '',
...checks.flatMap((check, index) => [`### ${index + 1}. ${check.title}`, '', `状态:${check.status}。耗时 ${(check.durationMs / 1000).toFixed(1)} 秒。`, '', ...check.screenshots.flatMap(shot => [`![${shot.title}](${shot.path})`, ''])]),
...(authentication ? ['## 认证附加回归(独立统计)', '', authentication.result, '', ...authentication.checks.flatMap(check => [`### ${check.title}`, '', `状态:${check.status}${check.rawStatus ? `;Playwright 原始状态:${check.rawStatus}` : ''}。${check.result}`, '', ...check.screenshots.flatMap(shot => [`![${shot.title}](${shot.path})`, ''])]), ...authentication.limitations.map(text => `- ${text}`), '', '[认证汇总证据](authentication/summary.json)', ''] : []),
'## 范围与复现', '', ...limitations.map(text => `- ${text}`), '', validationText, '',
'```powershell', evidence.reproduction, 'node tools/build-api-error-message-report.mjs', '```', '',
`测试开始:${evidence.startedAt}。报告生成:${evidence.generatedAt}。`, '',
]
fs.writeFileSync(path.join(root, 'report.md'), md.join('\n'), 'utf8')
console.log(JSON.stringify({ status: evidence.status, passed, total: checks.length, screenshotCount, requestCount, pageErrorCount }))

+ 46
- 0
tools/guide-error-message.test.mjs 查看文件

@@ -0,0 +1,46 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import { readFileSync } from 'node:fs'

const source = readFileSync(new URL('../../unreal_tran_web/src/features/guide-legacy/demo-src/ofd-render-interactive.js', import.meta.url), 'utf8')
const handlerSource = source.slice(source.indexOf(' const reportedLoadErrors = new WeakSet();'), source.indexOf(' const syncAnimationControls ='))

function fixture(destroyed = false) {
const statuses = []
const messages = []
const report = new Function('destroyed', 'setStatus', 'onError', `${handlerSource}\nreturn reportLoadError`)(
destroyed,
(text, error) => statuses.push({ text, error }),
error => messages.push(error),
)
return { report, statuses, messages }
}

test('GLB load and ready rejection handlers report the same failure only once', () => {
const f = fixture()
const error = new Error('HTTP 503: asset service unavailable')
f.report(error)
f.report(error)
assert.deepEqual(f.messages, [error])
assert.ok(f.statuses.length > 0)
assert.ok(f.statuses.every(status => status.error && !status.text.includes(error.message)))
assert.match(f.statuses.at(-1).text, /重新加载/)
})

test('retrying an unavailable GLB still reports the new failed request', () => {
const f = fixture()
f.report(new Error('HTTP 503'))
f.report(new Error('HTTP 503'))
assert.equal(f.messages.length, 2)
})

test('cancelled and destroyed model viewers do not surface stale load errors', () => {
const f = fixture()
f.report(Object.assign(new Error('cancelled'), { name: 'AbortError' }))
assert.equal(f.messages.length, 0)
assert.equal(f.statuses.length, 0)
const destroyed = fixture(true)
destroyed.report(new Error('late HTTP 503'))
assert.equal(destroyed.messages.length, 0)
assert.equal(destroyed.statuses.length, 0)
})

+ 60
- 0
tools/scene-cover-api.test.mjs 查看文件

@@ -205,3 +205,63 @@ test('changes made while cover uploads remain dirty after the earlier snapshot s
assert.equal(editor.dirty, true)
assert.match(editor.messages.at(-1).message, /新修改仍待保存/)
})

const publishMethod = editorSource.slice(editorSource.indexOf(' async publishScene() {'), editorSource.indexOf(' /*\n * 原实现只设置原生 hidden')).trim()
const { publishScene } = new Function(`return ({ ${publishMethod} })`)()
function publishingEditor(saveDocument) {
const editor = editorFixture(saveDocument)
editor.canPublish = true
editor.objects = []
editor.assetStore = { validateReferences: async () => ({ valid: true }) }
editor.verifyScene = () => [{ ok: true }]
editor.saveProject = saveProject
editor.publishCalls = 0
editor.context.publishDocument = async () => { editor.publishCalls += 1 }
return editor
}

const hostSource = readFileSync(new URL('../../unreal_tran_web/src/views/content/LegacyScenePageView.vue', import.meta.url), 'utf8')
const leaveSource = hostSource.slice(hostSource.indexOf('async function confirmLeave()'), hostSource.indexOf('function resetProjectSession()'))
const leaveCompiled = ts.transpileModule(leaveSource, { compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.None } }).outputText
function leaveEditor(editor) {
return new Function('editorHandle', 'ElMessageBox', 'ElMessage', `${leaveCompiled}\nreturn confirmLeave()`)(
editor,
{ confirm: async () => undefined },
{ error: message => editor.toast(message, 'error') },
)
}

for (const status of [409, 503]) {
test(`publish after PUT ${status} stays blocked and reports the save failure only once`, async () => {
const editor = publishingEditor(async () => { throw Object.assign(new Error(`HTTP ${status}`), { status }) })
await publishScene.call(editor)
assert.equal(editor.publishCalls, 0)
assert.equal(editor.dirty, true)
assert.equal(editor.publishing, false)
assert.equal(editor.messages.length, 1)
assert.match(editor.messages[0].message, new RegExp(`HTTP ${status}`))
})
}

test('save-and-leave keeps the editor open with one failure message', async () => {
const editor = publishingEditor(async () => { throw new Error('version conflict') })
editor.dirty = true
assert.equal(await leaveEditor(editor), false)
assert.equal(editor.dirty, true)
assert.equal(editor.messages.length, 1)
assert.match(editor.messages[0].message, /version conflict/)
})

for (const action of ['publish', 'leave']) {
test(`${action} stays blocked when edits arrive during save, without claiming save failed`, async () => {
const editor = publishingEditor(async () => { editor.changeRevision += 1; return {} })
editor.dirty = true
if (action === 'publish') await publishScene.call(editor)
else assert.equal(await leaveEditor(editor), false)
assert.equal(editor.publishCalls, 0)
assert.equal(editor.dirty, true)
assert.equal(editor.messages.length, 1)
assert.match(editor.messages[0].message, /新修改仍待保存/)
assert.doesNotMatch(editor.messages[0].message, /保存失败|未保存成功/)
})
}

+ 3
- 2
tools/training-return-tabs.e2e.mjs 查看文件

@@ -188,12 +188,13 @@ try {
currentTitle = '保存并离开失败时不导航、不删除编辑标签(浏览器模拟 503)'
await returnToList(page)
await page.getByRole('button', { name: '保存并离开', exact: true }).click()
await expect(page.locator('.el-message-box').filter({ hasText: '保存失败' })).toBeVisible({ timeout: 45_000 })
const saveFailureMessage = page.locator('.el-message--error').filter({ hasText: '浏览器模拟保存失败' })
await expect(saveFailureMessage).toBeVisible({ timeout: 45_000 })
assert.equal(new URL(page.url()).pathname, editPath('161'))
assert.ok(has(await tabs(page), editPath('161')))
assert.ok(evidence.blockedWrites.some(write => write.method === 'PUT' && write.path.endsWith('/projects/161')))
passed(currentTitle, { tabs: await tabs(page), writes: evidence.blockedWrites, simulated: true }, [await screenshot(page, '06-save-failure-keeps-editor')])
await page.getByRole('button', { name: '知道了', exact: true }).click()
await saveFailureMessage.locator('.el-message__closeBtn').click()

currentTitle = '明确放弃修改后返回并关闭当前编辑标签'
await returnToList(page)


Loading…
取消
儲存