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
| @@ -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', | |||||
| }, | |||||
| }) | |||||
| @@ -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]: 正在验证登录状态并加载工作台,请稍候… | |||||
| ``` | |||||
| @@ -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 | |||||
| } | |||||
| } | |||||
| @@ -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 | |||||
| } | |||||
| } | |||||
| @@ -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 全量;仅更新其中接口错误提示的断言选择器。", | |||||
| "两条旧强制改密用例没有通过,不能作为当前改密功能验证证据;既有路由策略不在本轮提示展示调整范围。" | |||||
| ] | |||||
| } | |||||
| @@ -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]: 正在验证登录状态并加载工作台,请稍候… | |||||
| ``` | |||||
| @@ -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]: 正在验证登录状态并加载工作台,请稍候… | |||||
| ``` | |||||
| @@ -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" | |||||
| } | |||||
| @@ -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 秒。 | |||||
|  | |||||
|  | |||||
| ### 2. 系统用户:并行同错合并一条,重复搜索可再次提示 | |||||
| 状态:PASS。耗时 12.7 秒。 | |||||
|  | |||||
|  | |||||
| ### 3. 训练编辑器:加载失败提示消失后保留重新加载按钮 | |||||
| 状态:PASS。耗时 6.4 秒。 | |||||
|  | |||||
|  | |||||
| ### 4. 实装预览:任务上下文错误用 Message,离线与教员模式持续可见 | |||||
| 状态:PASS。耗时 6.5 秒。 | |||||
|  | |||||
|  | |||||
| ### 5. 新建训练:业务说明与必填校验保留,不发起创建请求 | |||||
| 状态:PASS。耗时 2.5 秒。 | |||||
|  | |||||
| ### 6. 缓存列表:失活时晚到错误不弹出,返回不重放旧错误 | |||||
| 状态:PASS。耗时 1.9 秒。 | |||||
|  | |||||
|  | |||||
| ### 7. HTTP 合同:真实 Axios 超时中文化,403/409 元数据保留,静默请求不自动弹窗 | |||||
| 状态:PASS。耗时 1.9 秒。 | |||||
|  | |||||
|  | |||||
| ### 8. 局域网教学列表:既有 ElMessage.error 使用全局关闭和自动消失配置 | |||||
| 状态:PASS。耗时 6.9 秒。 | |||||
|  | |||||
|  | |||||
| ### 9. 模型桥接 DOM 合同:初次错误与晚写详情转为重试说明,ready 恢复交互 | |||||
| 状态:PASS。耗时 0.8 秒。 | |||||
|  | |||||
|  | |||||
| ## 认证附加回归(独立统计) | |||||
| 共实际运行 5 项:3 项通过;2 项旧强制改密用例受当前路由契约阻断,未验证到原断言。主报告 9 项结果单独统计。 | |||||
| ### 859:无可登录教员时账号选择器禁用 | |||||
| 状态:PASS。保留业务空态,选择器不可展开。 | |||||
|  | |||||
| ### 861:旧强制改密不一致校验 | |||||
| 状态:BLOCKED;Playwright 原始状态:failed。当前 /change-password 对已认证用户重定向 /dashboard;旧 fixture 只有强制改密标记而无工作台权限,后续导航取消,停留在启动页,未进入改密表单。该旧用例不适用于当前路由契约,本次未改路由、未跳过或记为通过。 | |||||
|  | |||||
| ### 864:旧强制改密停用账号提示 | |||||
| 状态:BLOCKED;Playwright 原始状态:failed。与 861 相同的既有路由契约阻断,未能提交初始改密接口,也未验证一次性登录提示。原始失败证据保留。 | |||||
|  | |||||
| ### 普通会话过期不泄露后端内部详情 | |||||
| 状态:PASS。跳转登录页,登录错误栏为空,不显示内部会话或刷新令牌详情,仅发起一次刷新请求。 | |||||
| ### 当前管理员登录 API 401 错误使用自动消失的 Message | |||||
| 状态:PASS。实际点击登录;mock POST /auth/login 返回 401。只出现一条 .el-message--error,#login-error 始终为空;未点击关闭,Message 自动消失。登录请求 1 次,刷新请求 0 次。 | |||||
|  | |||||
|  | |||||
| - 仅模拟认证接口与浏览器交互,不使用真实账号,不验证后端认证规则或真实密码修改。 | |||||
| - 未运行依赖真实账号的 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。 | |||||
| @@ -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 | |||||
| } | |||||
| } | |||||
| @@ -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": "主任务检查通过;报告生成后再次核验本轮文件。" | |||||
| } | |||||
| ] | |||||
| } | |||||
| @@ -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') | |||||
| }) | |||||
| @@ -27,7 +27,7 @@ test('管理员可以登录并安全退出', async ({ page }, testInfo) => { | |||||
| await page.locator('input[name="username"]').fill('__ute2e_missing_account__') | await page.locator('input[name="username"]').fill('__ute2e_missing_account__') | ||||
| await page.locator('input[name="password"]').fill('Uae2e-Invalid-Only') | await page.locator('input[name="password"]').fill('Uae2e-Invalid-Only') | ||||
| await page.getByRole('button', { name: /登录系统/ }).click() | 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 login(page, adminCredentials()) | ||||
| await expect(page).toHaveURL(/\/dashboard(?:\?|$)/) | await expect(page).toHaveURL(/\/dashboard(?:\?|$)/) | ||||
| await captureScreenshot(page, testInfo, 'login-success') | await captureScreenshot(page, testInfo, 'login-success') | ||||
| @@ -43,14 +43,19 @@ const fulfill = (route: Route, data: unknown, status = 200, message = '成功', | |||||
| async function mockAuth( | async function mockAuth( | ||||
| page: Page, | page: Page, | ||||
| options: { me?: 'forced' | 'expired'; initialPassword401?: boolean } = {}, | |||||
| options: { me?: 'forced' | 'expired'; initialPassword401?: boolean; loginFailure?: boolean } = {}, | |||||
| ) { | ) { | ||||
| let refreshRequests = 0 | let refreshRequests = 0 | ||||
| let initialPasswordRequests = 0 | let initialPasswordRequests = 0 | ||||
| let loginRequests = 0 | |||||
| await page.route('**/api/auth/v1/**', async (route) => { | await page.route('**/api/auth/v1/**', async (route) => { | ||||
| const path = new URL(route.request().url()).pathname | const path = new URL(route.request().url()).pathname | ||||
| if (path.endsWith('/auth/login-context')) return fulfill(route, loginContext) | if (path.endsWith('/auth/login-context')) return fulfill(route, loginContext) | ||||
| if (path.endsWith('/auth/login-identities')) return fulfill(route, []) | 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('/menus/navigation')) return fulfill(route, []) | ||||
| if (path.endsWith('/auth/me')) { | if (path.endsWith('/auth/me')) { | ||||
| if (options.me === 'expired') { | if (options.me === 'expired') { | ||||
| @@ -74,6 +79,7 @@ async function mockAuth( | |||||
| return { | return { | ||||
| refreshRequests: () => refreshRequests, | refreshRequests: () => refreshRequests, | ||||
| initialPasswordRequests: () => initialPasswordRequests, | initialPasswordRequests: () => initialPasswordRequests, | ||||
| loginRequests: () => loginRequests, | |||||
| } | } | ||||
| } | } | ||||
| @@ -130,7 +136,7 @@ test('864:首次改密期间账号停用后仅显示一次固定登录提示 | |||||
| await page.getByRole('button', { name: '确认修改并登录' }).click() | await page.getByRole('button', { name: '确认修改并登录' }).click() | ||||
| await expect(page).toHaveURL(/\/login\?redirect=/) | 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) | await expect(page.getByText(/internal-user-id|refresh-token-internal-detail/)).toHaveCount(0) | ||||
| expect(mocked.initialPasswordRequests()).toBe(1) | expect(mocked.initialPasswordRequests()).toBe(1) | ||||
| expect(mocked.refreshRequests()).toBe(0) | expect(mocked.refreshRequests()).toBe(0) | ||||
| @@ -150,3 +156,25 @@ test('普通会话过期只跳转登录,不展示后端错误详情或停用 | |||||
| await expect(page.getByText(/内部会话详情|refresh-token-internal-detail|账号、密码或登录身份不匹配/)).toHaveCount(0) | await expect(page.getByText(/内部会话详情|refresh-token-internal-detail|账号、密码或登录身份不匹配/)).toHaveCount(0) | ||||
| expect(mocked.refreshRequests()).toBe(1) | 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') | |||||
| }) | |||||
| @@ -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 => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[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 => [``, ''])]), | |||||
| ...(authentication ? ['## 认证附加回归(独立统计)', '', authentication.result, '', ...authentication.checks.flatMap(check => [`### ${check.title}`, '', `状态:${check.status}${check.rawStatus ? `;Playwright 原始状态:${check.rawStatus}` : ''}。${check.result}`, '', ...check.screenshots.flatMap(shot => [``, ''])]), ...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 })) | |||||
| @@ -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) | |||||
| }) | |||||
| @@ -205,3 +205,63 @@ test('changes made while cover uploads remain dirty after the earlier snapshot s | |||||
| assert.equal(editor.dirty, true) | assert.equal(editor.dirty, true) | ||||
| assert.match(editor.messages.at(-1).message, /新修改仍待保存/) | 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, /保存失败|未保存成功/) | |||||
| }) | |||||
| } | |||||
| @@ -188,12 +188,13 @@ try { | |||||
| currentTitle = '保存并离开失败时不导航、不删除编辑标签(浏览器模拟 503)' | currentTitle = '保存并离开失败时不导航、不删除编辑标签(浏览器模拟 503)' | ||||
| await returnToList(page) | await returnToList(page) | ||||
| await page.getByRole('button', { name: '保存并离开', exact: true }).click() | 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.equal(new URL(page.url()).pathname, editPath('161')) | ||||
| assert.ok(has(await tabs(page), editPath('161'))) | assert.ok(has(await tabs(page), editPath('161'))) | ||||
| assert.ok(evidence.blockedWrites.some(write => write.method === 'PUT' && write.path.endsWith('/projects/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')]) | 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 = '明确放弃修改后返回并关闭当前编辑标签' | currentTitle = '明确放弃修改后返回并关闭当前编辑标签' | ||||
| await returnToList(page) | await returnToList(page) | ||||