From a7dc1a2a1ead6155793c39bfd2a572b90f52a7ca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=84=A6=E9=BE=99=E8=A8=80?= Date: Wed, 23 Sep 2026 16:24:03 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=EF=BC=9A=E4=BF=9D=E9=9A=9C?= =?UTF-8?q?=E7=A7=BB=E5=8A=A8=E7=AB=AF=E6=89=B9=E6=AC=A1=E6=A8=A1=E5=BC=8F?= =?UTF-8?q?=E5=8F=AF=E8=BE=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../scripts/verify-business-contract.mjs | 123 ++++++++++++++++++ .../src/layout/components/Navbar.vue | 24 ++-- 2 files changed, 139 insertions(+), 8 deletions(-) diff --git a/shierfeng-fastapi-frontend/scripts/verify-business-contract.mjs b/shierfeng-fastapi-frontend/scripts/verify-business-contract.mjs index 4e931b3..1446c8e 100644 --- a/shierfeng-fastapi-frontend/scripts/verify-business-contract.mjs +++ b/shierfeng-fastapi-frontend/scripts/verify-business-contract.mjs @@ -1,11 +1,14 @@ import assert from 'node:assert/strict' +import { execFile } from 'node:child_process' import { lstat, readFile, realpath } from 'node:fs/promises' import { test } from 'node:test' import { fileURLToPath } from 'node:url' import path from 'node:path' +import { promisify } from 'node:util' import * as testBatchContext from '../src/utils/testBatchContext.js' const frontendRoot = fileURLToPath(new URL('..', import.meta.url)) +const execFileAsync = promisify(execFile) const expectedRequests = { 'src/api/master/strain.js': [ @@ -837,6 +840,108 @@ function hasExpectedRequest(expectedRequest) { const source = (relativePath) => readFile(path.join(frontendRoot, relativePath), 'utf8') +async function runRequestUnavailableInterceptorContract() { + const harness = String.raw` + const { readFile } = require('node:fs/promises') + const vm = require('node:vm') + + async function main() { + const requestPath = process.argv[1] + const requestSource = await readFile(requestPath, 'utf8') + const interceptors = {} + const recoveries = [] + const service = { + interceptors: { + request: { use: (fulfilled, rejected) => { interceptors.request = { fulfilled, rejected } } }, + response: { use: (fulfilled, rejected) => { interceptors.response = { fulfilled, rejected } } } + }, + post: () => Promise.resolve() + } + const context = vm.createContext({ console, Promise, Error, JSON, Object, Date, setTimeout }) + const moduleFromExports = exports => new vm.SyntheticModule( + Object.keys(exports), + function setExports() { + for (const [name, value] of Object.entries(exports)) this.setExport(name, value) + }, + { context } + ) + const modules = { + axios: { default: { defaults: { headers: {} }, create: () => service } }, + 'element-plus': { + ElNotification: { error: () => {} }, + ElMessageBox: { confirm: () => Promise.resolve() }, + ElMessage: () => {}, + ElLoading: { service: () => ({ close: () => {} }) } + }, + '@/utils/auth': { getToken: () => 'token' }, + '@/utils/errorCode': { default: {} }, + '@/utils/shierfeng': { tansParams: () => '', blobValidate: () => false }, + '@/plugins/cache': { default: { session: { getJSON: () => undefined, setJSON: () => {} } } }, + 'file-saver': { saveAs: () => {} }, + '@/store/modules/user': { default: () => ({ logOut: () => Promise.resolve() }) }, + '@/utils/testBatchContext': { + applyTestBatchHeader: config => config, + getStoredTestBatchId: () => '', + recoverUnavailableTestBatch: async ({ notify, message }) => { + recoveries.push(message) + notify(message) + } + }, + '@/utils/transportCrypto': { + decryptTransportErrorResponse: async error => error, + decryptTransportResponse: async response => response, + encryptTransportRequest: async config => config, + invalidateTransportKeyMeta: () => {}, + resetTransportRequestConfig: () => {}, + shouldRetryTransportWithFreshKey: () => false + } + } + const requestModule = new vm.SourceTextModule(requestSource, { + context, + identifier: requestPath, + initializeImportMeta(meta) { + meta.env = { VITE_APP_BASE_API: '' } + } + }) + await requestModule.link(specifier => { + if (!modules[specifier]) throw new Error('未受控依赖: ' + specifier) + return moduleFromExports(modules[specifier]) + }) + await requestModule.evaluate() + + const invoke = async callback => { + try { + await callback() + return { resolved: true } + } catch (error) { + return { resolved: false, message: error.message, reason: error.reason } + } + } + const success = await invoke(() => interceptors.response.fulfilled({ + data: { reason: 'TEST_BATCH_UNAVAILABLE', msg: '成功响应批次不可用' }, + request: { responseType: '' } + })) + const failure = await invoke(() => interceptors.response.rejected({ + response: { data: { reason: 'TEST_BATCH_UNAVAILABLE', msg: '异常响应批次不可用' } }, + message: 'Request failed with status code 400' + })) + process.stdout.write(JSON.stringify({ recoveries, success, failure })) + } + + main().catch(error => { + console.error(error) + process.exitCode = 1 + }) + ` + const { stdout } = await execFileAsync(process.execPath, [ + '--experimental-vm-modules', + '-e', + harness, + path.join(frontendRoot, 'src/utils/request.js') + ]) + return JSON.parse(stdout) +} + test('Web 测试模式统一附加请求头并通过路由重建清空页面状态', async () => { const appMainSource = await source('src/layout/components/AppMain.vue') const navbarSource = await source('src/layout/components/Navbar.vue') @@ -928,6 +1033,22 @@ test('刷新失效批次复用统一回退提示,登出无论远端结果都 assert.equal(testBatchContext.getStoredTestBatchId(), '') }) +test('request.js 的成功与异常响应拦截器都会恢复不可用批次并拒绝原请求', async () => { + const result = await runRequestUnavailableInterceptorContract() + + assert.deepEqual(result.recoveries, ['成功响应批次不可用', '异常响应批次不可用']) + assert.deepEqual(result.success, { + resolved: false, + message: '成功响应批次不可用', + reason: 'TEST_BATCH_UNAVAILABLE' + }) + assert.deepEqual(result.failure, { + resolved: false, + message: '异常响应批次不可用', + reason: 'TEST_BATCH_UNAVAILABLE' + }) +}) + test('窄屏模式控件仍保留正式退出与测试批次切换入口', async () => { const switchSource = await source('src/components/TestBatchModeSwitch/index.vue') const navbarSource = await source('src/layout/components/Navbar.vue') @@ -940,6 +1061,8 @@ test('窄屏模式控件仍保留正式退出与测试批次切换入口', async assert.match(mobileSection, /padding-right:\s*2px/) assert.match(mobileSection, /padding-left:\s*2px/) assert.match(navbarMobileSection, /\.test-batch-mode\s*\{\s*padding:\s*0/) + assert.match(navbarMobileSection, /\.breadcrumb-container\s*\{\s*display:\s*none/) + assert.match(navbarMobileSection, /\.right-menu\s*\{\s*overflow:\s*visible/) }) test('17 个页面的真实权限门禁逐项匹配独立硬编码契约', async () => { diff --git a/shierfeng-fastapi-frontend/src/layout/components/Navbar.vue b/shierfeng-fastapi-frontend/src/layout/components/Navbar.vue index e766a27..30f657b 100644 --- a/shierfeng-fastapi-frontend/src/layout/components/Navbar.vue +++ b/shierfeng-fastapi-frontend/src/layout/components/Navbar.vue @@ -293,17 +293,25 @@ async function toggleTheme(event) { } @media screen and (max-width: 767px) { - .navbar .right-menu { - .test-batch-mode { - padding: 0; - } - - .avatar-container .avatar-wrapper .user-nickname { + .navbar { + .breadcrumb-container { display: none; } - .avatar-container .avatar-wrapper .user-avatar { - margin-right: 0; + .right-menu { + overflow: visible; + + .test-batch-mode { + padding: 0; + } + + .avatar-container .avatar-wrapper .user-nickname { + display: none; + } + + .avatar-container .avatar-wrapper .user-avatar { + margin-right: 0; + } } } }