From ec8cb16e917bf31b036a0d168fa469349ded4980 Mon Sep 17 00:00:00 2001
From: yeg <1347747024@qq.com>
Date: 星期五, 28 八月 2026 17:51:33 +0800
Subject: [PATCH] 测试:建立设备登记浏览器验收基线
---
src/stores/deviceCollectors.ts | 68 ------
vite.config.ts | 2
tests/e2e/device-registration.spec.ts | 111 +++++++++++
.gitignore | 4
src/stores/deviceCabinets.ts | 72 -------
package-lock.json | 64 ++++++
AGENTS.md | 8
package.json | 4
playwright.config.ts | 27 ++
tests/e2e/support/ticket04-stack.ts | 197 +++++++++++++++++++
README.md | 8
11 files changed, 419 insertions(+), 146 deletions(-)
diff --git a/.gitignore b/.gitignore
index a547bf3..e2f083a 100644
--- a/.gitignore
+++ b/.gitignore
@@ -11,6 +11,10 @@
dist
dist-ssr
*.local
+.npm-cache
+playwright-report
+test-results
+tests/e2e/.artifacts
# Editor directories and files
.vscode/*
diff --git a/AGENTS.md b/AGENTS.md
index 44b79a8..21bd533 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -12,10 +12,10 @@
## 测试与监测界面
- 正式测试状态只展示:`待测试`、`检测中`、`暂停`、`检测中(异常)`、`检测完成`、`已终止`。“完成确认中”是后端内部阶段,前端可用进度文案表达但不新增正式测试状态。
-- 监测状态单独展示:`未监测`、`等待设备启动`、`监测中`、`补采中`、`监测结束`、`已取消`。不要把两套状态合并到一个字段或筛选项。
-- “开始监测”表示开始观察和认领,不表示启动 BTS。暂停、继续、结束等入口在第一里程碑保持禁用或明确为未启用设备控制,不能伪造成功状态。
+- 设备状态和接收状态单独并列展示。接收状态使用:`未开始接收`、`接收中`、`暂停接收`、`已结束接收`;不要把设备、接收和测试三套状态合并到一个字段或筛选项。
+- 开始、暂停、继续和结束测试的页面动作只改变数据接收区间,不表示控制 BTS 或采集器。确认文案必须明确不控制设备,暂停期间实时快照继续更新并标记“不纳入结果”。
- 实时快照与历史结果在类型、接口和页面文案上分开;实时点不能加入最终原始数据列表。
-- 采集器是可选辅助设备。创建测试允许只选择检测柜通道,不把采集器做成必填项。
+- 第一版每个测试电池号必须选择一个已登记采集器,以及主温度、正电位和负电位通道;可选次要通道和动态测量项保持现有交互。`S-01...S-N` 仅属于演示合同。
## API 与 SSE
@@ -35,5 +35,5 @@
- 修改 API 类型、状态映射或实时流时,检查加载、空数据、断线、重连、无权限和后端异常状态。
- 完成前运行 `npm run build`。若新增测试脚本,再同时运行受影响测试。
+- 设备登记浏览器主接缝运行 `npm run test:e2e:ticket04`;该命令必须使用隔离端口和临时 MySQL/Redis,结束后不得遗留进程或容器。
- 需要浏览器交互验证时启动 `npm run dev`,检查控制台和网络请求;完成后说明仍依赖模拟数据的页面。
-
diff --git a/README.md b/README.md
index 7deb17a..094459d 100644
--- a/README.md
+++ b/README.md
@@ -23,3 +23,11 @@
```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` 覆盖。
diff --git a/package-lock.json b/package-lock.json
index 52ce38e..2b0636b 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -17,6 +17,7 @@
"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",
@@ -205,6 +206,22 @@
"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": {
@@ -1285,6 +1302,53 @@
"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",
diff --git a/package.json b/package.json
index 730397e..2482752 100644
--- a/package.json
+++ b/package.json
@@ -6,7 +6,8 @@
"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",
@@ -18,6 +19,7 @@
"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",
diff --git a/playwright.config.ts b/playwright.config.ts
new file mode 100644
index 0000000..a297c81
--- /dev/null
+++ b/playwright.config.ts
@@ -0,0 +1,27 @@
+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,
+ },
+})
diff --git a/src/stores/deviceCabinets.ts b/src/stores/deviceCabinets.ts
index f8ff636..dc89e6f 100644
--- a/src/stores/deviceCabinets.ts
+++ b/src/stores/deviceCabinets.ts
@@ -1,5 +1,3 @@
-import { ref } from 'vue'
-
export type CabinetOnlineStatus = '在线' | '离线'
export type CabinetTestStatus = '待检测' | '测试中' | '空闲'
export type CabinetStatus = '启用' | '禁用'
@@ -23,73 +21,3 @@
}
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 }
-}
diff --git a/src/stores/deviceCollectors.ts b/src/stores/deviceCollectors.ts
index eb57166..2ccf07a 100644
--- a/src/stores/deviceCollectors.ts
+++ b/src/stores/deviceCollectors.ts
@@ -1,5 +1,3 @@
-import { ref } from 'vue'
-
export type CollectorOnlineStatus = '在线' | '离线'
export type CollectorTestStatus = '待检测' | '测试中' | '空闲'
export type CollectorStatus = '启用' | '禁用'
@@ -22,69 +20,3 @@
}
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 }
-}
-
-
diff --git a/tests/e2e/device-registration.spec.ts b/tests/e2e/device-registration.spec.ts
new file mode 100644
index 0000000..695d629
--- /dev/null
+++ b/tests/e2e/device-registration.spec.ts
@@ -0,0 +1,111 @@
+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)
+})
diff --git a/tests/e2e/support/ticket04-stack.ts b/tests/e2e/support/ticket04-stack.ts
new file mode 100644
index 0000000..f27855f
--- /dev/null
+++ b/tests/e2e/support/ticket04-stack.ts
@@ -0,0 +1,197 @@
+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')
+ }
+ }
+ }
+}
diff --git a/vite.config.ts b/vite.config.ts
index bbe86c8..72c99c9 100644
--- a/vite.config.ts
+++ b/vite.config.ts
@@ -10,7 +10,7 @@
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/, ''),
},
--
Gitblit v1.7.1