yeg
2026-08-28 ec8cb16e917bf31b036a0d168fa469349ded4980
测试:建立设备登记浏览器验收基线

使用 Playwright 编排隔离 MySQL、Redis、完整后端和开发模拟器,覆盖多设备登记、重新发现与重启恢复。移除设备业务事实的本地演示存储并同步协作说明。
3个文件已添加
8个文件已修改
565 ■■■■■ 已修改文件
.gitignore 4 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
AGENTS.md 8 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
README.md 8 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
package-lock.json 64 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
package.json 4 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
playwright.config.ts 27 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/stores/deviceCabinets.ts 72 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/stores/deviceCollectors.ts 68 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tests/e2e/device-registration.spec.ts 111 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tests/e2e/support/ticket04-stack.ts 197 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
vite.config.ts 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
.gitignore
@@ -11,6 +11,10 @@
dist
dist-ssr
*.local
.npm-cache
playwright-report
test-results
tests/e2e/.artifacts
# Editor directories and files
.vscode/*
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`,检查控制台和网络请求;完成后说明仍依赖模拟数据的页面。
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` 覆盖。
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",
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",
playwright.config.ts
New file
@@ -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,
  },
})
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 }
}
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 }
}
tests/e2e/device-registration.spec.ts
New file
@@ -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)
})
tests/e2e/support/ticket04-stack.ts
New file
@@ -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')
      }
    }
  }
}
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/, ''),
      },