Commit 06e7d9f9 by DaiJiezhang

test: add vite lifecycle regression coverage

parent 7c16c06a
# Vite 迁移闭环验证记录
## 自动验证证据
| 需求/任务 | 验证方式 | 实际证据 | 状态 |
|---|---|---|---|
| 单一模块渲染责任(3.3a、3.3b) | Playwright Chromium,直达与刷新 | 修复前 `#/wechat``#/phone-card` 的初始列表 GET 均为 2;修复后每个场景为 1 | 通过 |
| 路由切换生命周期(3.3c、5.2b) | `npm run test:e2e -- --reporter=line` | 2026-07-26:13/13 通过,覆盖两个目标页的直达、刷新、四个来源路由切换,以及替换 pageBody 后的行点击/抽屉关闭 | 通过 |
| 自动测试独立于本机后端(4.2) | Playwright route 拦截 | 两个列表 GET 均返回受控 `{ code: 200, message: "ok", data: [] }`,测试执行无后端代理错误 | 通过 |
| Vite 构建(4.3) | `npm run build` | 2026-07-26:退出码 0;存在第三方 `@vueuse/core` 注释和产物体积警告,未阻断构建 | 通过 |
| 五路由真实联调(5.1、5.2a) | 本机后端 8888 + Vite + Browser Network | 本地 Spring Boot 于 2026-07-26 启动后未监听 8888,未执行 | 阻塞 |
| `VITE-SMOKE-` 写入、编辑、按 ID 清理(5.3) | 本机后端 API | 因本地后端未监听 8888,未创建任何测试数据 | 阻塞 |
## 审查整改
- 2026-07-26:新增断言,要求被拦截的列表请求保持 GET;补齐 Playwright 业务回调的新手注释;新增替换 DOM 的事件回归。
- 变异检查:临时移除 `RouterView.mounted()``preparePageShell()` 调用后,替换 DOM 事件用例超时失败;随后已恢复源码。
- 依赖安全审计:`npm audit fix --package-lock-only` 将间接依赖 `postcss``8.5.16` 升至 `8.5.23``nanoid` 升至 `3.3.16`;随后 `npm ci``npm audit` 均报告 0 个漏洞。
## 执行环境
- Node:项目要求 `>=20.19.0 || >=22.12.0`
- 浏览器:Playwright Chromium(本机安装)
- 前端:Vite `5173`;后端真实联调端口:`8888`
## 后续人工验证与清理路径
1. 启动 `./start-backend-jdk17.bat``npm run dev`
2. 分别访问并刷新 `#/overview``#/domain``#/wechat``#/phone-card``#/alerts`,记录 Console 与 Network。
3. 仅创建名称或备注以 `VITE-SMOKE-` 开头的手机号卡;记录返回 ID。
4. 编辑并刷新确认可见后,仅调用该 ID 的删除操作;确认列表或按 ID 查询不再返回该记录。
5. 若删除失败,记录 ID、接口响应与人工清理路径;不得删除非本轮创建的数据。
\ No newline at end of file
import { defineConfig } from '@playwright/test';
/**
* 代码作用(白话):让浏览器回归测试自动启动本地 Vite,并固定使用 Chromium 与 tests 目录;关联文件:F:/Project/xyw_console/package.json、F:/Project/xyw_console/tests/vite-lifecycle.spec.js;关联逻辑(调用链/消息链/数据流):npm run test:e2e -> Playwright 配置 -> Vite :5173 -> 生命周期测试。
*/
export default defineConfig({
testDir: './tests',
timeout: 30_000,
use: {
baseURL: 'http://127.0.0.1:5173',
browserName: 'chromium'
},
webServer: {
command: 'npm run dev -- --host 127.0.0.1',
url: 'http://127.0.0.1:5173',
reuseExistingServer: false,
timeout: 30_000
}
});
import { expect, test } from '@playwright/test';
const targetPages = [
{ module: 'wechat', hash: '#/wechat', requestPath: '/api/wechat-records', rootSelector: '#wechatListRoot .table-area' },
{ module: 'phone', hash: '#/phone-card', requestPath: '/api/wx-phones', rootSelector: '#phoneListRoot .table-area' }
];
const sourcePages = ['#/overview', '#/domain', '#/alerts', '#/phone-card'];
/**
* 代码作用(白话):为每个测试页面注入仅测试使用的 Runtime 挂载计数容器,使测试能观察真实页面是否重复挂载;关联文件:F:/Project/xyw_console/app-v2.js、F:/Project/xyw_console/tests/vite-lifecycle.spec.js;关联逻辑(调用链/消息链/数据流):Playwright addInitScript -> 页面启动 -> app-v2.js 读取计数容器 -> 测试断言。
*/
async function installMountCounters(page) {
/**
* 代码作用(白话):在业务脚本执行前建立测试计数对象,让生产代码仅在该对象存在时上报挂载次数;关联文件:F:/Project/xyw_console/app-v2.js;关联逻辑(调用链/消息链/数据流):addInitScript 回调 -> window.__viteLifecycleCounters -> recordRuntimeMount() -> readMountCount()。
*/
await page.addInitScript(() => {
window.__viteLifecycleCounters = { mounts: { wechat: 0, phone: 0 } };
});
}
/**
* 代码作用(白话):拦截两个列表请求并返回与真实接口一致的成功空列表,只统计当前目标接口,隔离本机后端数据但保留页面真实渲染流程;关联文件:F:/Project/xyw_console/src/modules/shared/wechat-api-client.js、F:/Project/xyw_console/src/modules/shared/phone-api-client.js;关联逻辑(调用链/消息链/数据流):页面 Runtime -> fetch 相对 /api 地址 -> Playwright route -> 成功 JSON -> 列表根节点。
*/
async function mockInitialListRequest(page, requestPath) {
let requestCount = 0;
const unexpectedMethods = [];
for (const listPath of ['/api/wechat-records', '/api/wx-phones']) {
/**
* 代码作用(白话):拦截单个列表接口,记录其 HTTP 方法,并返回受控空列表;关联文件:F:/Project/xyw_console/src/modules/shared/wechat-api-client.js、F:/Project/xyw_console/src/modules/shared/phone-api-client.js;关联逻辑(调用链/消息链/数据流):浏览器 fetch -> route 回调 -> 方法检查/目标计数 -> fulfill JSON -> Runtime 列表渲染。
*/
await page.route(`**${listPath}`, async (route) => {
const method = route.request().method();
if (method !== 'GET') {
unexpectedMethods.push({ listPath, method });
}
if (listPath === requestPath) {
requestCount += 1;
}
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ code: 200, message: 'ok', data: [] })
});
});
}
return {
/**
* 代码作用(白话):返回当前目标列表的初始请求次数;关联文件:F:/Project/xyw_console/tests/vite-lifecycle.spec.js;关联逻辑(调用链/消息链/数据流):route 回调累加 -> count() -> 每个生命周期场景断言。
*/
count: () => requestCount,
/**
* 代码作用(白话):确认被拦截的列表请求没有从既有 GET 契约变成其他方法;关联文件:F:/Project/xyw_console/src/modules/shared/wechat-api-client.js、F:/Project/xyw_console/src/modules/shared/phone-api-client.js;关联逻辑(调用链/消息链/数据流):route 回调记录非 GET -> assertOnlyGet() -> 生命周期场景失败或通过。
*/
assertOnlyGet: () => expect(unexpectedMethods).toEqual([]),
/**
* 代码作用(白话):在刷新前清除上一轮页面加载的目标请求计数,单独验证刷新这一轮;关联文件:F:/Project/xyw_console/tests/vite-lifecycle.spec.js;关联逻辑(调用链/消息链/数据流):首次加载 -> reset() -> page.reload() -> count()。
*/
reset: () => { requestCount = 0; }
};
}
/**
* 代码作用(白话):读取页面在测试容器中记录的目标 Runtime 挂载次数,直接验证真实挂载结果;关联文件:F:/Project/xyw_console/app-v2.js、F:/Project/xyw_console/tests/vite-lifecycle.spec.js;关联逻辑(调用链/消息链/数据流):renderExternalRuntime() -> 测试计数容器 -> readMountCount() -> 断言。
*/
async function readMountCount(page, module) {
/**
* 代码作用(白话):在浏览器页面里取出指定模块的测试挂载计数,缺失时按 0 处理;关联文件:F:/Project/xyw_console/app-v2.js;关联逻辑(调用链/消息链/数据流):page.evaluate 回调 -> window.__viteLifecycleCounters -> Playwright 期望值。
*/
return page.evaluate((moduleKey) => window.__viteLifecycleCounters?.mounts?.[moduleKey] ?? 0, module);
}
for (const target of targetPages) {
/**
* 代码作用(白话):验证直接打开目标 Hash 页只会挂载一次 Runtime、读取一次 GET 列表;关联文件:F:/Project/xyw_console/app-v2.js、F:/Project/xyw_console/src/router/index.js;关联逻辑(调用链/消息链/数据流):page.goto -> RouterView.mounted() -> renderModule() -> Runtime/列表请求 -> 断言。
*/
test(`${target.module} direct entry mounts once and requests its list once`, async ({ page }) => {
await installMountCounters(page);
const listRequest = await mockInitialListRequest(page, target.requestPath);
await page.goto(`/${target.hash}`);
await expect(page.locator(target.rootSelector)).toBeVisible();
listRequest.assertOnlyGet();
expect(listRequest.count()).toBe(1);
expect(await readMountCount(page, target.module)).toBe(1);
});
/**
* 代码作用(白话):验证刷新目标 Hash 页后,新的一轮页面加载仍只有一次 Runtime 和一次 GET;关联文件:F:/Project/xyw_console/app-v2.js、F:/Project/xyw_console/src/router/index.js;关联逻辑(调用链/消息链/数据流):首次加载 -> reset -> page.reload -> RouterView.mounted() -> Runtime/列表请求 -> 断言。
*/
test(`${target.module} refresh mounts once and requests its list once`, async ({ page }) => {
await installMountCounters(page);
const listRequest = await mockInitialListRequest(page, target.requestPath);
await page.goto(`/${target.hash}`);
await expect(page.locator(target.rootSelector)).toBeVisible();
listRequest.reset();
await page.reload();
await expect(page.locator(target.rootSelector)).toBeVisible();
listRequest.assertOnlyGet();
expect(listRequest.count()).toBe(1);
expect(await readMountCount(page, target.module)).toBe(1);
});
}
for (const sourceHash of sourcePages) {
/**
* 代码作用(白话):验证从每个其余 Hash 路由切到企微页时只有一次目标 Runtime 和 GET;关联文件:F:/Project/xyw_console/src/router/index.js、F:/Project/xyw_console/app-v2.js;关联逻辑(调用链/消息链/数据流):来源路由 -> 侧栏点击 -> router.push -> RouterView.mounted -> 企微 Runtime -> 断言。
*/
test(`wechat route transition from ${sourceHash} mounts once and requests its list once`, async ({ page }) => {
await installMountCounters(page);
const listRequest = await mockInitialListRequest(page, '/api/wechat-records');
await page.goto(`/${sourceHash}`);
await page.locator('[data-module="wechat"]').click();
await expect(page.locator('#wechatListRoot .table-area')).toBeVisible();
listRequest.assertOnlyGet();
expect(listRequest.count()).toBe(1);
expect(await readMountCount(page, 'wechat')).toBe(1);
});
}
for (const sourceHash of ['#/overview', '#/domain', '#/wechat', '#/alerts']) {
/**
* 代码作用(白话):验证从每个其余 Hash 路由切到手机号页时只有一次目标 Runtime 和 GET;关联文件:F:/Project/xyw_console/src/router/index.js、F:/Project/xyw_console/app-v2.js;关联逻辑(调用链/消息链/数据流):来源路由 -> 侧栏点击 -> router.push -> RouterView.mounted -> 手机号 Runtime -> 断言。
*/
test(`phone route transition from ${sourceHash} mounts once and requests its list once`, async ({ page }) => {
await installMountCounters(page);
const listRequest = await mockInitialListRequest(page, '/api/wx-phones');
await page.goto(`/${sourceHash}`);
await page.locator('[data-module="phone"]').click();
await expect(page.locator('#phoneListRoot .table-area')).toBeVisible();
listRequest.assertOnlyGet();
expect(listRequest.count()).toBe(1);
expect(await readMountCount(page, 'phone')).toBe(1);
});
}
/**
* 代码作用(白话):验证路由替换 pageBody 后,新的行点击和抽屉关闭事件仍绑定在新 DOM 上;关联文件:F:/Project/xyw_console/app-v2.js、F:/Project/xyw_console/src/router/index.js;关联逻辑(调用链/消息链/数据流):路由切换 -> preparePageShell() -> bindLayoutEvents() -> 行点击打开抽屉 -> 关闭按钮隐藏抽屉。
*/
test('route replacement keeps page body and drawer close events bound', async ({ page }) => {
await page.goto('/#/overview');
await page.locator('[data-module="domain"]').click();
await page.locator('#pageBody [data-record-id][data-source-key]').first().click();
await expect(page.locator('#detailDrawer')).not.toHaveClass(/hidden/);
await page.locator('#drawerClose').click();
await expect(page.locator('#detailDrawer')).toHaveClass(/hidden/);
});
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment