测试:建立设备登记浏览器验收基线
使用 Playwright 编排隔离 MySQL、Redis、完整后端和开发模拟器,覆盖多设备登记、重新发现与重启恢复。移除设备业务事实的本地演示存储并同步协作说明。
| | |
| | | dist |
| | | dist-ssr |
| | | *.local |
| | | .npm-cache |
| | | playwright-report |
| | | test-results |
| | | tests/e2e/.artifacts |
| | | |
| | | # Editor directories and files |
| | | .vscode/* |
| | |
| | | ## 测试与监测界面 |
| | | |
| | | - 正式测试状态只展示:`待测试`、`检测中`、`暂停`、`检测中(异常)`、`检测完成`、`已终止`。“完成确认中”是后端内部阶段,前端可用进度文案表达但不新增正式测试状态。 |
| | | - 监测状态单独展示:`未监测`、`等待设备启动`、`监测中`、`补采中`、`监测结束`、`已取消`。不要把两套状态合并到一个字段或筛选项。 |
| | | - “开始监测”表示开始观察和认领,不表示启动 BTS。暂停、继续、结束等入口在第一里程碑保持禁用或明确为未启用设备控制,不能伪造成功状态。 |
| | | - 设备状态和接收状态单独并列展示。接收状态使用:`未开始接收`、`接收中`、`暂停接收`、`已结束接收`;不要把设备、接收和测试三套状态合并到一个字段或筛选项。 |
| | | - 开始、暂停、继续和结束测试的页面动作只改变数据接收区间,不表示控制 BTS 或采集器。确认文案必须明确不控制设备,暂停期间实时快照继续更新并标记“不纳入结果”。 |
| | | - 实时快照与历史结果在类型、接口和页面文案上分开;实时点不能加入最终原始数据列表。 |
| | | - 采集器是可选辅助设备。创建测试允许只选择检测柜通道,不把采集器做成必填项。 |
| | | - 第一版每个测试电池号必须选择一个已登记采集器,以及主温度、正电位和负电位通道;可选次要通道和动态测量项保持现有交互。`S-01...S-N` 仅属于演示合同。 |
| | | |
| | | ## API 与 SSE |
| | | |
| | |
| | | |
| | | - 修改 API 类型、状态映射或实时流时,检查加载、空数据、断线、重连、无权限和后端异常状态。 |
| | | - 完成前运行 `npm run build`。若新增测试脚本,再同时运行受影响测试。 |
| | | - 设备登记浏览器主接缝运行 `npm run test:e2e:ticket04`;该命令必须使用隔离端口和临时 MySQL/Redis,结束后不得遗留进程或容器。 |
| | | - 需要浏览器交互验证时启动 `npm run dev`,检查控制台和网络请求;完成后说明仍依赖模拟数据的页面。 |
| | | |
| | |
| | | ```powershell |
| | | npm run build |
| | | ``` |
| | | |
| | | Ticket04 设备登记浏览器冒烟测试会自行启动隔离的 MySQL 8、Redis、Spring Boot、Vite 和开发模拟器,完成后停止并删除临时进程与容器: |
| | | |
| | | ```powershell |
| | | npm run test:e2e:ticket04 |
| | | ``` |
| | | |
| | | 测试默认使用端口 `15174`、`18001`、`63306` 和 `56379`,端口已占用时会直接失败而不复用现有服务。可通过 `TICKET04_WEB_PORT`、`TICKET04_API_PORT`、`TICKET04_MYSQL_PORT`、`TICKET04_REDIS_PORT` 覆盖;镜像仓库可通过 `TICKET04_MYSQL_IMAGE` 和 `TICKET04_REDIS_IMAGE` 覆盖。 |
| | |
| | | "xlsx": "^0.18.5" |
| | | }, |
| | | "devDependencies": { |
| | | "@playwright/test": "^1.62.1", |
| | | "@types/node": "^24.13.2", |
| | | "@vitejs/plugin-vue": "^6.0.7", |
| | | "@vue/tsconfig": "^0.9.1", |
| | |
| | | "license": "MIT", |
| | | "funding": { |
| | | "url": "https://github.com/sponsors/Boshen" |
| | | } |
| | | }, |
| | | "node_modules/@playwright/test": { |
| | | "version": "1.62.1", |
| | | "resolved": "https://registry.npmmirror.com/@playwright/test/-/test-1.62.1.tgz", |
| | | "integrity": "sha512-DTcUc8qii+cpHvtOwggMtBRMjKZHXYWdw8syRYu2vtzuq4Wxphqq4NfCs5Zt44L6mA8rfDfj+PHnxFc/FeK6mQ==", |
| | | "dev": true, |
| | | "license": "Apache-2.0", |
| | | "dependencies": { |
| | | "playwright": "1.62.1" |
| | | }, |
| | | "bin": { |
| | | "playwright": "cli.js" |
| | | }, |
| | | "engines": { |
| | | "node": ">=20" |
| | | } |
| | | }, |
| | | "node_modules/@popperjs/core": { |
| | |
| | | "url": "https://github.com/sponsors/jonschlinkert" |
| | | } |
| | | }, |
| | | "node_modules/playwright": { |
| | | "version": "1.62.1", |
| | | "resolved": "https://registry.npmmirror.com/playwright/-/playwright-1.62.1.tgz", |
| | | "integrity": "sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==", |
| | | "dev": true, |
| | | "license": "Apache-2.0", |
| | | "dependencies": { |
| | | "playwright-core": "1.62.1" |
| | | }, |
| | | "bin": { |
| | | "playwright": "cli.js" |
| | | }, |
| | | "engines": { |
| | | "node": ">=20" |
| | | }, |
| | | "optionalDependencies": { |
| | | "fsevents": "2.3.2" |
| | | } |
| | | }, |
| | | "node_modules/playwright-core": { |
| | | "version": "1.62.1", |
| | | "resolved": "https://registry.npmmirror.com/playwright-core/-/playwright-core-1.62.1.tgz", |
| | | "integrity": "sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==", |
| | | "dev": true, |
| | | "license": "Apache-2.0", |
| | | "bin": { |
| | | "playwright-core": "cli.js" |
| | | }, |
| | | "engines": { |
| | | "node": ">=20" |
| | | } |
| | | }, |
| | | "node_modules/playwright/node_modules/fsevents": { |
| | | "version": "2.3.2", |
| | | "resolved": "https://registry.npmmirror.com/fsevents/-/fsevents-2.3.2.tgz", |
| | | "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", |
| | | "dev": true, |
| | | "hasInstallScript": true, |
| | | "license": "MIT", |
| | | "optional": true, |
| | | "os": [ |
| | | "darwin" |
| | | ], |
| | | "engines": { |
| | | "node": "^8.16.0 || ^10.6.0 || >=11.0.0" |
| | | } |
| | | }, |
| | | "node_modules/postcss": { |
| | | "version": "8.5.23", |
| | | "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.23.tgz", |
| | |
| | | "scripts": { |
| | | "dev": "vite", |
| | | "build": "vue-tsc -b && vite build", |
| | | "preview": "vite preview" |
| | | "preview": "vite preview", |
| | | "test:e2e:ticket04": "playwright test tests/e2e/device-registration.spec.ts" |
| | | }, |
| | | "dependencies": { |
| | | "@element-plus/icons-vue": "^2.3.2", |
| | |
| | | "xlsx": "^0.18.5" |
| | | }, |
| | | "devDependencies": { |
| | | "@playwright/test": "^1.62.1", |
| | | "@types/node": "^24.13.2", |
| | | "@vitejs/plugin-vue": "^6.0.7", |
| | | "@vue/tsconfig": "^0.9.1", |
| New file |
| | |
| | | import { defineConfig, devices } from '@playwright/test' |
| | | |
| | | const webPort = Number(process.env.TICKET04_WEB_PORT || 15174) |
| | | const apiPort = Number(process.env.TICKET04_API_PORT || 18001) |
| | | |
| | | export default defineConfig({ |
| | | testDir: './tests/e2e', |
| | | fullyParallel: false, |
| | | workers: 1, |
| | | timeout: 10 * 60 * 1000, |
| | | expect: { timeout: 20_000 }, |
| | | reporter: [['list']], |
| | | use: { |
| | | ...devices['Desktop Chrome'], |
| | | baseURL: `http://127.0.0.1:${webPort}`, |
| | | channel: 'chrome', |
| | | trace: 'retain-on-failure', |
| | | screenshot: 'only-on-failure', |
| | | }, |
| | | webServer: { |
| | | command: `npm run dev -- --host 127.0.0.1 --port ${webPort}`, |
| | | env: { E2E_API_TARGET: `http://127.0.0.1:${apiPort}` }, |
| | | url: `http://127.0.0.1:${webPort}/login`, |
| | | reuseExistingServer: false, |
| | | timeout: 120_000, |
| | | }, |
| | | }) |
| | |
| | | import { ref } from 'vue' |
| | | |
| | | export type CabinetOnlineStatus = '在线' | '离线' |
| | | export type CabinetTestStatus = '待检测' | '测试中' | '空闲' |
| | | export type CabinetStatus = '启用' | '禁用' |
| | |
| | | } |
| | | |
| | | export type CabinetPayload = Pick<DeviceCabinetRecord, 'deviceCode' | 'ip' | 'mac' | 'name' | 'manufacturer' | 'model' | 'channelCount' | 'specs' | 'remark' | 'onlineStatus' | 'testStatus' | 'currentTest' | 'status'> |
| | | |
| | | const STORAGE_KEY = 'battery_detection_device_cabinets_v1' |
| | | |
| | | const seedRecords: DeviceCabinetRecord[] = [ |
| | | { id: 4012, deviceCode: '45643166', ip: '192.1.1.152', mac: '41:F5:D6:N1', name: '测试柜1', manufacturer: 'XXX', model: 'A1', channelCount: 12, specs: 120, remark: '一号实验区', onlineStatus: '在线', testStatus: '待检测', currentTest: 'SP2电池寿命循环', status: '启用', createdAt: '2026-08-05 09:26' }, |
| | | { id: 4011, deviceCode: '45643167', ip: '192.1.1.153', mac: '41:F5:D6:N2', name: '测试柜2', manufacturer: 'XXX', model: 'A1', channelCount: 12, specs: 120, remark: '一号实验区', onlineStatus: '在线', testStatus: '测试中', currentTest: '高温循环性能测试', status: '启用', createdAt: '2026-08-04 16:18' }, |
| | | { id: 4010, deviceCode: '45643168', ip: '192.1.1.154', mac: '41:F5:D6:N3', name: '测试柜3', manufacturer: 'XXX', model: 'A2', channelCount: 24, specs: 240, remark: '二号实验区', onlineStatus: '在线', testStatus: '空闲', currentTest: '-', status: '启用', createdAt: '2026-08-04 10:42' }, |
| | | { id: 4009, deviceCode: '45643169', ip: '192.1.1.155', mac: '41:F5:D6:N4', name: '测试柜4', manufacturer: 'XXX', model: 'A2', channelCount: 24, specs: 240, remark: '二号实验区', onlineStatus: '离线', testStatus: '空闲', currentTest: '-', status: '禁用', createdAt: '2026-08-03 15:06' }, |
| | | { id: 4008, deviceCode: '45643170', ip: '192.1.1.156', mac: '41:F5:D6:N5', name: '测试柜5', manufacturer: 'YYY', model: 'B1', channelCount: 8, specs: 80, remark: '三号实验区', onlineStatus: '在线', testStatus: '待检测', currentTest: '-', status: '启用', createdAt: '2026-08-02 11:35' }, |
| | | { id: 4007, deviceCode: '45643171', ip: '192.1.1.157', mac: '41:F5:D6:N6', name: '测试柜6', manufacturer: 'YYY', model: 'B1', channelCount: 8, specs: 80, remark: '三号实验区', onlineStatus: '在线', testStatus: '空闲', currentTest: '-', status: '启用', createdAt: '2026-08-01 09:18' }, |
| | | { id: 4006, deviceCode: '45643172', ip: '192.1.1.158', mac: '41:F5:D6:N7', name: '测试柜7', manufacturer: 'YYY', model: 'B2', channelCount: 16, specs: 160, remark: '四号实验区', onlineStatus: '离线', testStatus: '待检测', currentTest: '-', status: '禁用', createdAt: '2026-07-30 14:20' }, |
| | | { id: 4005, deviceCode: '45643173', ip: '192.1.1.159', mac: '41:F5:D6:N8', name: '测试柜8', manufacturer: 'YYY', model: 'B2', channelCount: 16, specs: 160, remark: '四号实验区', onlineStatus: '在线', testStatus: '测试中', currentTest: '低温充放电测试', status: '启用', createdAt: '2026-07-29 17:12' }, |
| | | { id: 4004, deviceCode: '45643174', ip: '192.1.1.160', mac: '41:F5:D6:N9', name: '测试柜9', manufacturer: 'ZZZ', model: 'C1', channelCount: 32, specs: 320, remark: '五号实验区', onlineStatus: '在线', testStatus: '空闲', currentTest: '-', status: '启用', createdAt: '2026-07-28 13:46' }, |
| | | { id: 4003, deviceCode: '45643175', ip: '192.1.1.161', mac: '41:F5:D6:N0', name: '测试柜10', manufacturer: 'ZZZ', model: 'C1', channelCount: 32, specs: 320, remark: '五号实验区', onlineStatus: '离线', testStatus: '空闲', currentTest: '-', status: '禁用', createdAt: '2026-07-26 10:16' }, |
| | | ] |
| | | |
| | | function loadRecords() { |
| | | try { |
| | | const stored = localStorage.getItem(STORAGE_KEY) |
| | | if (stored) { |
| | | const parsed = JSON.parse(stored) as Array<Omit<DeviceCabinetRecord, 'specs'> & { specs: number | string }> |
| | | return parsed.map(item => ({ |
| | | ...item, |
| | | specs: typeof item.specs === 'number' ? item.specs : Number(item.specs) || item.channelCount * 10, |
| | | })) |
| | | } |
| | | } catch { |
| | | localStorage.removeItem(STORAGE_KEY) |
| | | } |
| | | return seedRecords |
| | | } |
| | | |
| | | const records = ref<DeviceCabinetRecord[]>(loadRecords()) |
| | | |
| | | function persist() { |
| | | localStorage.setItem(STORAGE_KEY, JSON.stringify(records.value)) |
| | | } |
| | | |
| | | function nowString() { |
| | | const date = new Date() |
| | | const pad = (value: number) => String(value).padStart(2, '0') |
| | | return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}` |
| | | } |
| | | |
| | | export function useDeviceCabinets() { |
| | | function createCabinet(data: CabinetPayload) { |
| | | const id = Math.max(4000, ...records.value.map(item => item.id)) + 1 |
| | | records.value.unshift({ ...data, id, createdAt: nowString() }) |
| | | persist() |
| | | } |
| | | |
| | | function updateCabinet(id: number, data: CabinetPayload) { |
| | | const target = records.value.find(item => item.id === id) |
| | | if (!target) return false |
| | | Object.assign(target, data) |
| | | persist() |
| | | return true |
| | | } |
| | | |
| | | function removeCabinet(id: number) { |
| | | records.value = records.value.filter(item => item.id !== id) |
| | | persist() |
| | | } |
| | | |
| | | function saveChanges() { |
| | | persist() |
| | | } |
| | | |
| | | return { records, createCabinet, updateCabinet, removeCabinet, saveChanges } |
| | | } |
| | |
| | | import { ref } from 'vue' |
| | | |
| | | export type CollectorOnlineStatus = '在线' | '离线' |
| | | export type CollectorTestStatus = '待检测' | '测试中' | '空闲' |
| | | export type CollectorStatus = '启用' | '禁用' |
| | |
| | | } |
| | | |
| | | export type CollectorPayload = Pick<DeviceCollectorRecord, 'deviceCode' | 'ip' | 'mac' | 'name' | 'manufacturer' | 'model' | 'channelCount' | 'remark' | 'onlineStatus' | 'testStatus' | 'currentTest' | 'status'> |
| | | |
| | | const STORAGE_KEY = 'battery_detection_device_collectors_v1' |
| | | |
| | | const seedRecords: DeviceCollectorRecord[] = [ |
| | | { id: 4012, deviceCode: '45643166', ip: '192.1.1.152', mac: '41:F5:D6:N1', name: '采集器1', manufacturer: 'XXX', model: 'A1', channelCount: 12, remark: '一号实验区', onlineStatus: '在线', testStatus: '待检测', currentTest: 'SP2电池寿命循环', status: '启用', createdAt: '2026-08-05 09:26' }, |
| | | { id: 4011, deviceCode: '45643167', ip: '192.1.1.153', mac: '41:F5:D6:N2', name: '采集器2', manufacturer: 'XXX', model: 'A1', channelCount: 12, remark: '一号实验区', onlineStatus: '在线', testStatus: '测试中', currentTest: '高温循环性能测试', status: '启用', createdAt: '2026-08-04 16:18' }, |
| | | { id: 4010, deviceCode: '45643168', ip: '192.1.1.154', mac: '41:F5:D6:N3', name: '采集器3', manufacturer: 'XXX', model: 'A2', channelCount: 24, remark: '二号实验区', onlineStatus: '在线', testStatus: '空闲', currentTest: '-', status: '启用', createdAt: '2026-08-04 10:42' }, |
| | | { id: 4009, deviceCode: '45643169', ip: '192.1.1.155', mac: '41:F5:D6:N4', name: '采集器4', manufacturer: 'XXX', model: 'A2', channelCount: 24, remark: '二号实验区', onlineStatus: '离线', testStatus: '空闲', currentTest: '-', status: '禁用', createdAt: '2026-08-03 15:06' }, |
| | | { id: 4008, deviceCode: '45643170', ip: '192.1.1.156', mac: '41:F5:D6:N5', name: '采集器5', manufacturer: 'YYY', model: 'B1', channelCount: 8, remark: '三号实验区', onlineStatus: '在线', testStatus: '待检测', currentTest: '-', status: '启用', createdAt: '2026-08-02 11:35' }, |
| | | { id: 4007, deviceCode: '45643171', ip: '192.1.1.157', mac: '41:F5:D6:N6', name: '采集器6', manufacturer: 'YYY', model: 'B1', channelCount: 8, remark: '三号实验区', onlineStatus: '在线', testStatus: '空闲', currentTest: '-', status: '启用', createdAt: '2026-08-01 09:18' }, |
| | | { id: 4006, deviceCode: '45643172', ip: '192.1.1.158', mac: '41:F5:D6:N7', name: '采集器7', manufacturer: 'YYY', model: 'B2', channelCount: 16, remark: '四号实验区', onlineStatus: '离线', testStatus: '待检测', currentTest: '-', status: '禁用', createdAt: '2026-07-30 14:20' }, |
| | | { id: 4005, deviceCode: '45643173', ip: '192.1.1.159', mac: '41:F5:D6:N8', name: '采集器8', manufacturer: 'YYY', model: 'B2', channelCount: 16, remark: '四号实验区', onlineStatus: '在线', testStatus: '测试中', currentTest: '低温充放电测试', status: '启用', createdAt: '2026-07-29 17:12' }, |
| | | { id: 4004, deviceCode: '45643174', ip: '192.1.1.160', mac: '41:F5:D6:N9', name: '采集器9', manufacturer: 'ZZZ', model: 'C1', channelCount: 32, remark: '五号实验区', onlineStatus: '在线', testStatus: '空闲', currentTest: '-', status: '启用', createdAt: '2026-07-28 13:46' }, |
| | | { id: 4003, deviceCode: '45643175', ip: '192.1.1.161', mac: '41:F5:D6:N0', name: '采集器10', manufacturer: 'ZZZ', model: 'C1', channelCount: 32, remark: '五号实验区', onlineStatus: '离线', testStatus: '空闲', currentTest: '-', status: '禁用', createdAt: '2026-07-26 10:16' }, |
| | | ] |
| | | |
| | | function loadRecords() { |
| | | try { |
| | | const stored = localStorage.getItem(STORAGE_KEY) |
| | | if (stored) return JSON.parse(stored) as DeviceCollectorRecord[] |
| | | } catch { |
| | | localStorage.removeItem(STORAGE_KEY) |
| | | } |
| | | return seedRecords |
| | | } |
| | | |
| | | const records = ref<DeviceCollectorRecord[]>(loadRecords()) |
| | | |
| | | function persist() { |
| | | localStorage.setItem(STORAGE_KEY, JSON.stringify(records.value)) |
| | | } |
| | | |
| | | function nowString() { |
| | | const date = new Date() |
| | | const pad = (value: number) => String(value).padStart(2, '0') |
| | | return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}` |
| | | } |
| | | |
| | | export function useDeviceCollectors() { |
| | | function createCollector(data: CollectorPayload) { |
| | | const id = Math.max(4000, ...records.value.map(item => item.id)) + 1 |
| | | records.value.unshift({ ...data, id, createdAt: nowString() }) |
| | | persist() |
| | | } |
| | | |
| | | function updateCollector(id: number, data: CollectorPayload) { |
| | | const target = records.value.find(item => item.id === id) |
| | | if (!target) return false |
| | | Object.assign(target, data) |
| | | persist() |
| | | return true |
| | | } |
| | | |
| | | function removeCollector(id: number) { |
| | | records.value = records.value.filter(item => item.id !== id) |
| | | persist() |
| | | } |
| | | |
| | | function saveChanges() { |
| | | persist() |
| | | } |
| | | |
| | | return { records, createCollector, updateCollector, removeCollector, saveChanges } |
| | | } |
| | | |
| | | |
| New file |
| | |
| | | import { expect, test, type Page } from '@playwright/test' |
| | | import { Ticket04Stack } from './support/ticket04-stack' |
| | | |
| | | const stack = new Ticket04Stack() |
| | | |
| | | test.beforeAll(async () => stack.start()) |
| | | test.afterAll(async () => stack.stop()) |
| | | |
| | | async function login(page: Page) { |
| | | await page.goto('/login') |
| | | await page.locator('input[autocomplete="username"]').fill('admin') |
| | | await page.locator('input[autocomplete="current-password"]').fill('admin123') |
| | | await page.getByRole('button', { name: '登录', exact: true }).click() |
| | | await expect(page).toHaveURL(/\/dashboard$/) |
| | | } |
| | | |
| | | async function registerCabinet(page: Page, name: string) { |
| | | await page.goto('/device-cabinet/new') |
| | | await page.getByRole('button', { name: '选择设备' }).click() |
| | | const row = page.locator('.device-table tbody tr').filter({ has: page.getByRole('button', { name: '选择' }) }).first() |
| | | await expect(row).toBeVisible() |
| | | const code = (await row.locator('td').first().textContent())?.trim() || '' |
| | | await row.getByRole('button', { name: '选择' }).click() |
| | | await page.locator('label').filter({ hasText: '检测柜名称' }).locator('input').fill(name) |
| | | await page.locator('label').filter({ hasText: '规格/容量' }).locator('input').fill('120') |
| | | await page.getByRole('button', { name: '保存', exact: true }).click() |
| | | await expect(page).toHaveURL(/\/device-cabinet$/) |
| | | await expect(page.locator('tbody tr').filter({ hasText: code })).toBeVisible() |
| | | return code |
| | | } |
| | | |
| | | async function registerCollector(page: Page, name: string) { |
| | | await page.goto('/device-collector/new') |
| | | await page.getByRole('button', { name: '选择设备' }).click() |
| | | const row = page.locator('.device-table tbody tr').filter({ has: page.getByRole('button', { name: '选择' }) }).first() |
| | | await expect(row).toBeVisible() |
| | | const code = (await row.locator('td').first().textContent())?.trim() || '' |
| | | await row.getByRole('button', { name: '选择' }).click() |
| | | await page.locator('label').filter({ hasText: '采集器名称' }).locator('input').fill(name) |
| | | await page.getByRole('button', { name: '保存', exact: true }).click() |
| | | await expect(page).toHaveURL(/\/device-collector$/) |
| | | await expect(page.locator('tbody tr').filter({ hasText: code })).toBeVisible() |
| | | return code |
| | | } |
| | | |
| | | async function deleteRegisteredDevice(page: Page, path: string, code: string) { |
| | | await page.goto(path) |
| | | const row = page.locator('tbody tr').filter({ hasText: code }) |
| | | await row.getByRole('button', { name: '删除' }).click() |
| | | await page.getByRole('button', { name: '确认删除' }).click() |
| | | await expect(row).toHaveCount(0) |
| | | } |
| | | |
| | | async function expectRediscovered(page: Page, path: string, code: string) { |
| | | await page.goto(path) |
| | | await page.getByRole('button', { name: '选择设备' }).click() |
| | | await expect(page.locator('.device-table tbody tr').filter({ hasText: code })).toBeVisible() |
| | | await page.getByRole('button', { name: '关闭' }).click() |
| | | } |
| | | |
| | | test('管理员通过页面完成双设备登记并从后端事实恢复', async ({ page }) => { |
| | | await test.step('配置多台模拟检测柜和采集器', async () => { |
| | | await page.goto(`${stack.backendUrl}/dev/bts-simulator.html`) |
| | | page.once('dialog', dialog => dialog.accept()) |
| | | await page.getByRole('button', { name: '重置模拟器' }).click() |
| | | await expect(page.getByRole('heading', { name: '检测柜 1' })).toBeVisible() |
| | | await page.locator('#new-ip').fill('127.0.0.3') |
| | | await page.locator('#new-channel-count').fill('2') |
| | | await page.locator('#add-device').getByRole('button', { name: '添加检测柜' }).click() |
| | | await page.locator('#new-collector-ip').fill('127.0.1.3') |
| | | await page.locator('#new-collector-channel-count').fill('3') |
| | | await page.locator('#add-collector').getByRole('button', { name: '添加采集器' }).click() |
| | | await expect(page.getByRole('heading', { name: '检测柜 2' })).toBeVisible() |
| | | await expect(page.getByRole('heading', { name: 'dev-sim 采集器 2' })).toBeVisible() |
| | | }) |
| | | |
| | | await login(page) |
| | | const cabinetCodes = [await registerCabinet(page, '验收检测柜一'), await registerCabinet(page, '验收检测柜二')] |
| | | const collectorCodes = [await registerCollector(page, '验收采集器一'), await registerCollector(page, '验收采集器二')] |
| | | |
| | | await deleteRegisteredDevice(page, '/device-cabinet', cabinetCodes[0]) |
| | | await expectRediscovered(page, '/device-cabinet/new', cabinetCodes[0]) |
| | | await deleteRegisteredDevice(page, '/device-collector', collectorCodes[0]) |
| | | await expectRediscovered(page, '/device-collector/new', collectorCodes[0]) |
| | | |
| | | await page.goto('/device-cabinet') |
| | | await page.reload() |
| | | await expect(page.locator('tbody tr').filter({ hasText: cabinetCodes[1] })).toBeVisible() |
| | | await page.getByRole('button', { name: '退出当前账号' }).click() |
| | | await page.getByRole('button', { name: '退出', exact: true }).click() |
| | | await expect(page).toHaveURL(/\/login$/) |
| | | await login(page) |
| | | await page.goto('/device-collector') |
| | | await expect(page.locator('tbody tr').filter({ hasText: collectorCodes[1] })).toBeVisible() |
| | | |
| | | await stack.restartBackend() |
| | | await page.goto('/device-cabinet') |
| | | await expect(page.locator('tbody tr').filter({ hasText: cabinetCodes[1] })).toBeVisible() |
| | | await page.goto('/device-collector') |
| | | await expect(page.locator('tbody tr').filter({ hasText: collectorCodes[1] })).toBeVisible() |
| | | const businessKeys = await page.evaluate(() => Object.keys(localStorage) |
| | | .filter(key => key.includes('device_cabinet') || key.includes('device_collector'))) |
| | | expect(businessKeys).toEqual([]) |
| | | |
| | | await page.goto(`${stack.backendUrl}/dev/bts-simulator.html`) |
| | | await expect(page.getByRole('heading', { name: '检测柜 2' })).toBeVisible() |
| | | await expect(page.getByRole('heading', { name: 'dev-sim 采集器 2' })).toBeVisible() |
| | | const commands = await page.locator('#requests .request-command').allTextContents() |
| | | expect(commands.length).toBeGreaterThan(0) |
| | | expect(commands.every(command => command === 'getdevinfo' || command === 'inquire')).toBe(true) |
| | | }) |
| New file |
| | |
| | | import { createWriteStream, existsSync, mkdirSync } from 'node:fs' |
| | | import { createServer } from 'node:net' |
| | | import { resolve } from 'node:path' |
| | | import { spawn, spawnSync, type ChildProcess } from 'node:child_process' |
| | | |
| | | interface DockerCommand { |
| | | command: string |
| | | prefix: string[] |
| | | } |
| | | |
| | | const webRepo = resolve(import.meta.dirname, '../../..') |
| | | const backendRepo = resolve(webRepo, '../BatteryDetection') |
| | | const artifactDir = resolve(webRepo, 'tests/e2e/.artifacts') |
| | | const backendJar = resolve(backendRepo, 'ruoyi-admin/target/ruoyi-admin.jar') |
| | | const apiPort = Number(process.env.TICKET04_API_PORT || 18001) |
| | | const mysqlPort = Number(process.env.TICKET04_MYSQL_PORT || 63306) |
| | | const redisPort = Number(process.env.TICKET04_REDIS_PORT || 56379) |
| | | const mysqlImage = process.env.TICKET04_MYSQL_IMAGE || 'public.ecr.aws/docker/library/mysql:8.0.36' |
| | | const redisImage = process.env.TICKET04_REDIS_IMAGE || 'public.ecr.aws/docker/library/redis:7.2-alpine' |
| | | const mysqlPassword = 'ticket04-mysql' |
| | | const redisPassword = 'ticket04-redis' |
| | | const suffix = `${process.pid}` |
| | | |
| | | function commandAvailable(command: string, args: string[]) { |
| | | const result = spawnSync(command, args, { encoding: 'utf8', windowsHide: true }) |
| | | return result.status === 0 |
| | | } |
| | | |
| | | function resolveDocker(): DockerCommand { |
| | | const candidates: DockerCommand[] = process.platform === 'win32' |
| | | ? [{ command: 'docker.exe', prefix: [] }, { command: 'wsl.exe', prefix: ['-e', 'docker'] }] |
| | | : [{ command: 'docker', prefix: [] }] |
| | | const docker = candidates.find(item => commandAvailable(item.command, [...item.prefix, 'version'])) |
| | | if (!docker) throw new Error('Ticket04 浏览器验收需要可用的 Docker 或 WSL Docker') |
| | | return docker |
| | | } |
| | | |
| | | async function assertPortAvailable(port: number) { |
| | | await new Promise<void>((resolvePromise, reject) => { |
| | | const server = createServer() |
| | | server.once('error', () => reject(new Error(`Ticket04 隔离端口 ${port} 已被占用`))) |
| | | server.listen(port, '127.0.0.1', () => server.close(() => resolvePromise())) |
| | | }) |
| | | } |
| | | |
| | | function run(command: string, args: string[], cwd: string) { |
| | | const executable = process.platform === 'win32' ? 'cmd.exe' : command |
| | | const executableArgs = process.platform === 'win32' |
| | | ? ['/d', '/s', '/c', command, ...args] |
| | | : args |
| | | const result = spawnSync(executable, executableArgs, { |
| | | cwd, encoding: 'utf8', stdio: 'inherit', windowsHide: true, |
| | | }) |
| | | if (result.status !== 0) throw new Error(`${command} ${args.join(' ')} 执行失败`) |
| | | } |
| | | |
| | | async function waitUntil(check: () => boolean | Promise<boolean>, message: string, timeoutMs = 120_000) { |
| | | const deadline = Date.now() + timeoutMs |
| | | while (Date.now() < deadline) { |
| | | if (await check()) return |
| | | await new Promise(resolvePromise => setTimeout(resolvePromise, 1_000)) |
| | | } |
| | | throw new Error(message) |
| | | } |
| | | |
| | | export class Ticket04Stack { |
| | | private docker!: DockerCommand |
| | | private backend: ChildProcess | null = null |
| | | private readonly mysqlContainer = `battery-ticket04-mysql-${suffix}` |
| | | private readonly redisContainer = `battery-ticket04-redis-${suffix}` |
| | | |
| | | readonly backendUrl = `http://127.0.0.1:${apiPort}` |
| | | |
| | | async start() { |
| | | await Promise.all([mysqlPort, redisPort, apiPort].map(assertPortAvailable)) |
| | | this.docker = resolveDocker() |
| | | mkdirSync(artifactDir, { recursive: true }) |
| | | try { |
| | | this.dockerRun([ |
| | | 'run', '--detach', '--rm', '--name', this.mysqlContainer, |
| | | '-e', `MYSQL_ROOT_PASSWORD=${mysqlPassword}`, |
| | | '-e', 'MYSQL_DATABASE=battery_detection', |
| | | '-p', `127.0.0.1:${mysqlPort}:3306`, |
| | | mysqlImage, '--default-authentication-plugin=mysql_native_password', |
| | | ]) |
| | | this.dockerRun([ |
| | | 'run', '--detach', '--rm', '--name', this.redisContainer, |
| | | '-p', `127.0.0.1:${redisPort}:6379`, |
| | | redisImage, 'redis-server', '--requirepass', redisPassword, '--appendonly', 'yes', |
| | | ]) |
| | | await this.waitForContainers() |
| | | this.buildBackend() |
| | | await this.startBackend() |
| | | this.disableCaptchaInIsolatedDatabase() |
| | | } catch (error) { |
| | | await this.stop() |
| | | throw error |
| | | } |
| | | } |
| | | |
| | | async restartBackend() { |
| | | await this.stopBackend() |
| | | await this.startBackend() |
| | | } |
| | | |
| | | async stop() { |
| | | await this.stopBackend() |
| | | if (!this.docker) return |
| | | this.dockerRun(['rm', '--force', this.mysqlContainer], true) |
| | | this.dockerRun(['rm', '--force', this.redisContainer], true) |
| | | } |
| | | |
| | | private dockerRun(args: string[], allowFailure = false) { |
| | | const result = spawnSync(this.docker.command, [...this.docker.prefix, ...args], { |
| | | encoding: 'utf8', windowsHide: true, |
| | | }) |
| | | if (!allowFailure && result.status !== 0) { |
| | | throw new Error(`Docker 命令失败: ${result.stderr || result.stdout}`) |
| | | } |
| | | return result |
| | | } |
| | | |
| | | private async waitForContainers() { |
| | | await waitUntil(() => this.dockerRun([ |
| | | 'exec', this.mysqlContainer, 'mysqladmin', 'ping', '-uroot', `-p${mysqlPassword}`, '--silent', |
| | | ], true).status === 0, '隔离 MySQL 8 未就绪') |
| | | await waitUntil(() => this.dockerRun([ |
| | | 'exec', this.redisContainer, 'redis-cli', '-a', redisPassword, 'ping', |
| | | ], true).status === 0, '隔离 Redis 未就绪') |
| | | } |
| | | |
| | | private buildBackend() { |
| | | if (process.env.TICKET04_SKIP_BACKEND_BUILD === '1' && existsSync(backendJar)) return |
| | | run(process.platform === 'win32' ? 'mvn.cmd' : 'mvn', |
| | | ['-pl', 'ruoyi-admin', '-am', 'package', '-DskipTests'], backendRepo) |
| | | if (!existsSync(backendJar)) throw new Error(`未生成后端可执行包: ${backendJar}`) |
| | | } |
| | | |
| | | private async startBackend() { |
| | | const logPath = resolve(artifactDir, 'backend.log') |
| | | const log = createWriteStream(logPath, { flags: 'a' }) |
| | | const jdbcUrl = `jdbc:mysql://127.0.0.1:${mysqlPort}/battery_detection?useUnicode=true&characterEncoding=utf8&zeroDateTimeBehavior=convertToNull&useSSL=false&serverTimezone=GMT%2B8` |
| | | this.backend = spawn('java', [ |
| | | '-jar', backendJar, |
| | | `--server.port=${apiPort}`, |
| | | `--spring.datasource.druid.master.url=${jdbcUrl}`, |
| | | '--spring.datasource.druid.master.username=root', |
| | | `--spring.datasource.druid.master.password=${mysqlPassword}`, |
| | | '--spring.data.redis.host=127.0.0.1', |
| | | `--spring.data.redis.port=${redisPort}`, |
| | | `--spring.data.redis.password=${redisPassword}`, |
| | | '--spring.data.redis.database=0', |
| | | '--spring.devtools.restart.enabled=false', |
| | | `--ruoyi.profile=${artifactDir}`, |
| | | ], { |
| | | cwd: backendRepo, |
| | | env: { ...process.env, LOG_PATH: artifactDir }, |
| | | windowsHide: true, |
| | | stdio: ['ignore', 'pipe', 'pipe'], |
| | | }) |
| | | this.backend.stdout?.pipe(log) |
| | | this.backend.stderr?.pipe(log) |
| | | await waitUntil(async () => { |
| | | if (this.backend?.exitCode !== null) throw new Error(`后端提前退出,详见 ${logPath}`) |
| | | try { |
| | | return (await fetch(`${this.backendUrl}/dev/bts-simulator/state`)).ok |
| | | } catch { |
| | | return false |
| | | } |
| | | }, `后端未就绪,详见 ${logPath}`, 180_000) |
| | | } |
| | | |
| | | private disableCaptchaInIsolatedDatabase() { |
| | | this.dockerRun([ |
| | | 'exec', this.mysqlContainer, 'mysql', '-uroot', `-p${mysqlPassword}`, |
| | | '-e', "UPDATE battery_detection.sys_config SET config_value='false' WHERE config_key='sys.account.captchaEnabled'", |
| | | ]) |
| | | } |
| | | |
| | | private async stopBackend() { |
| | | const backend = this.backend |
| | | this.backend = null |
| | | if (!backend || backend.exitCode !== null) return |
| | | backend.kill('SIGTERM') |
| | | await Promise.race([ |
| | | new Promise<void>(resolvePromise => backend.once('exit', () => resolvePromise())), |
| | | new Promise<void>(resolvePromise => setTimeout(resolvePromise, 15_000)), |
| | | ]) |
| | | if (backend.exitCode === null) { |
| | | if (process.platform === 'win32') { |
| | | spawnSync('taskkill.exe', ['/pid', String(backend.pid), '/t', '/f'], { windowsHide: true }) |
| | | } else { |
| | | backend.kill('SIGKILL') |
| | | } |
| | | } |
| | | } |
| | | } |
| | |
| | | strictPort: true, |
| | | proxy: { |
| | | '/dev-api': { |
| | | target: 'http://localhost:8001', |
| | | target: process.env.E2E_API_TARGET || 'http://localhost:8001', |
| | | changeOrigin: true, |
| | | rewrite: (path) => path.replace(/^\/dev-api/, ''), |
| | | }, |