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="password"]').fill('Uae2e-Invalid-Only') | |||
| await page.getByRole('button', { name: /登录系统/ }).click() | |||
| await expect(page.locator('.login-error')).toContainText('账号、密码或登录身份不匹配') | |||
| await expect(page.locator('.el-message--error')).toContainText('账号、密码或登录身份不匹配') | |||
| await login(page, adminCredentials()) | |||
| await expect(page).toHaveURL(/\/dashboard(?:\?|$)/) | |||
| await captureScreenshot(page, testInfo, 'login-success') | |||
| @@ -43,14 +43,19 @@ const fulfill = (route: Route, data: unknown, status = 200, message = '成功', | |||
| async function mockAuth( | |||
| page: Page, | |||
| options: { me?: 'forced' | 'expired'; initialPassword401?: boolean } = {}, | |||
| options: { me?: 'forced' | 'expired'; initialPassword401?: boolean; loginFailure?: boolean } = {}, | |||
| ) { | |||
| let refreshRequests = 0 | |||
| let initialPasswordRequests = 0 | |||
| let loginRequests = 0 | |||
| await page.route('**/api/auth/v1/**', async (route) => { | |||
| const path = new URL(route.request().url()).pathname | |||
| if (path.endsWith('/auth/login-context')) return fulfill(route, loginContext) | |||
| if (path.endsWith('/auth/login-identities')) return fulfill(route, []) | |||
| if (path.endsWith('/auth/login') && route.request().method() === 'POST') { | |||
| loginRequests += 1 | |||
| if (options.loginFailure) return fulfill(route, null, 401, '账号、密码或登录身份不匹配', 40102) | |||
| } | |||
| if (path.endsWith('/menus/navigation')) return fulfill(route, []) | |||
| if (path.endsWith('/auth/me')) { | |||
| if (options.me === 'expired') { | |||
| @@ -74,6 +79,7 @@ async function mockAuth( | |||
| return { | |||
| refreshRequests: () => refreshRequests, | |||
| initialPasswordRequests: () => initialPasswordRequests, | |||
| loginRequests: () => loginRequests, | |||
| } | |||
| } | |||
| @@ -130,7 +136,7 @@ test('864:首次改密期间账号停用后仅显示一次固定登录提示 | |||
| await page.getByRole('button', { name: '确认修改并登录' }).click() | |||
| await expect(page).toHaveURL(/\/login\?redirect=/) | |||
| await expect(page.locator('#login-error')).toHaveText('账号、密码或登录身份不匹配') | |||
| await expect(page.locator('.el-message--error')).toHaveText('账号、密码或登录身份不匹配') | |||
| await expect(page.getByText(/internal-user-id|refresh-token-internal-detail/)).toHaveCount(0) | |||
| expect(mocked.initialPasswordRequests()).toBe(1) | |||
| expect(mocked.refreshRequests()).toBe(0) | |||
| @@ -150,3 +156,25 @@ test('普通会话过期只跳转登录,不展示后端错误详情或停用 | |||
| await expect(page.getByText(/内部会话详情|refresh-token-internal-detail|账号、密码或登录身份不匹配/)).toHaveCount(0) | |||
| expect(mocked.refreshRequests()).toBe(1) | |||
| }) | |||
| test('当前登录接口失败只显示临时 Message,自动消失且不留下登录错误条', async ({ page }, testInfo) => { | |||
| const mocked = await mockAuth(page, { loginFailure: true }) | |||
| await page.goto('/login') | |||
| await page.locator('[data-role-code="admin"]').click() | |||
| await page.locator('input[name="username"]').fill('__ute2e_message_mock__') | |||
| await page.locator('input[name="password"]').fill('Invalid#Only8') | |||
| await page.getByRole('button', { name: '登录系统', exact: true }).click() | |||
| const message = page.locator('.el-message--error') | |||
| await expect(message).toHaveCount(1) | |||
| await expect(message).toHaveText('账号、密码或登录身份不匹配') | |||
| await expect(page.locator('#login-error')).toHaveText('') | |||
| expect(mocked.loginRequests()).toBe(1) | |||
| expect(mocked.refreshRequests()).toBe(0) | |||
| await captureScreenshot(page, testInfo, 'login-api-error-message') | |||
| await expect(message).toHaveCount(0, { timeout: 7_000 }) | |||
| await expect(page.locator('#login-error')).toHaveText('') | |||
| await expect(page).toHaveURL(/\/login(?:\?|$)/) | |||
| await captureScreenshot(page, testInfo, 'login-api-error-message-dismissed') | |||
| }) | |||
| @@ -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.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)' | |||
| await returnToList(page) | |||
| await page.getByRole('button', { name: '保存并离开', exact: true }).click() | |||
| await expect(page.locator('.el-message-box').filter({ hasText: '保存失败' })).toBeVisible({ timeout: 45_000 }) | |||
| const saveFailureMessage = page.locator('.el-message--error').filter({ hasText: '浏览器模拟保存失败' }) | |||
| await expect(saveFailureMessage).toBeVisible({ timeout: 45_000 }) | |||
| assert.equal(new URL(page.url()).pathname, editPath('161')) | |||
| assert.ok(has(await tabs(page), editPath('161'))) | |||
| assert.ok(evidence.blockedWrites.some(write => write.method === 'PUT' && write.path.endsWith('/projects/161'))) | |||
| passed(currentTitle, { tabs: await tabs(page), writes: evidence.blockedWrites, simulated: true }, [await screenshot(page, '06-save-failure-keeps-editor')]) | |||
| await page.getByRole('button', { name: '知道了', exact: true }).click() | |||
| await saveFailureMessage.locator('.el-message__closeBtn').click() | |||
| currentTitle = '明确放弃修改后返回并关闭当前编辑标签' | |||
| await returnToList(page) | |||