功能:扩展云管资源运维、账号授权及监控管理

This commit is contained in:
songyiming committed 2026-11-10 17:52:52 +08:00
1 parent eef485306f
commit 6134d49c16
87 files changed
+29498 -126

No files matched your search

+112
View File
@@ -0,0 +1,112 @@
import { test, expect, type Page } from '@playwright/test'
// Isolated browser fixtures; no live administrator passwords or roles are modified.
async function fixture(page: Page, role = 'super_admin') {
const calls: { method: string; path: string; body: Record<string, unknown> }[] = []
const me = { id: 'self-admin', username: 'root', displayName: '平台管理员', role, enabled: true }
const accounts = [me, { id: 'other-admin', username: 'operations', displayName: '运维人员', role: 'operator', enabled: true }]
await page.addInitScript(() => sessionStorage.setItem('vdi.accessToken', 'admin-fixture'))
await page.route('**/api/v1/**', async route => {
const r = route.request(), path = new URL(r.url()).pathname.replace('/api/v1', ''), method = r.method()
const list = (items: unknown[]) => route.fulfill({ json: { items, page: 1, pageSize: 100, total: items.length, requestId: 'admin-fixture' } })
if (method !== 'GET') {
const body = r.postData() ? r.postDataJSON() as Record<string, unknown> : {}; calls.push({ method, path, body })
if (path === '/administrators') {
if (accounts.some(a => a.username === body.username)) { await route.fulfill({ status: 409, json: { code: 'account_conflict', message: '管理员账号已存在', requestId: 'fixture' } }); return }
const a = { id: 'new-admin', username: String(body.username), displayName: String(body.displayName), role: String(body.role), enabled: true };accounts.push(a)
await route.fulfill({ status: 201, json: a }); return
}
if (path.startsWith('/administrators/') && method === 'PUT') { const a = accounts.find(a => path.endsWith(a.id)); if (a) Object.assign(a, body);await route.fulfill({ json: a }); return }
if (path.endsWith('/password') && path.startsWith('/administrators/')) { await route.fulfill({ status: 204 }); return }
if (path === '/auth/password') {
if (body.currentPassword !== 'Correct-old-password') { await route.fulfill({ status: 400, json: { code: 'current_password_invalid', message: '原密码不正确', requestId: 'fixture' } }); return }
await route.fulfill({ status: 204 }); return
}
}
if (path === '/auth/me') { await route.fulfill({ json: me }); return }
if (path === '/settings') { await route.fulfill({ json: { mode: 'production', pveConfigured: true, computeProvider: 'proxmox', terminalTransport: 'https-poll', capabilities: ['administrators'] } }); return }
if (path === '/overview') { await route.fulfill({ json: { terminalCount: 0, onlineTerminals: 0, desktopCount: 0, runningDesktops: 0, activeSessions: 0, failedTasks: 0, mode: 'production' } }); return }
if (path === '/administrators') { await list(accounts); return }
if (['/desktops', '/terminals', '/users', '/resources', '/tasks', '/audit-logs'].includes(path)) { await list([]); return }
await route.fulfill({ status: 404, json: { code: 'fixture_missing', message: '测试接口未定义', requestId: 'fixture' } })
})
return calls
}
async function openAccounts(page: Page) { await page.goto('/settings'); await page.getByRole('button', { name: '管理员账号', exact: true }).click(); await expect(page.getByRole('heading', { name: '管理员账号', exact: true })).toBeVisible() }
test('新增管理员校验密码确认,账号持久化字段正确', async ({ page }, testInfo) => {
const calls = await fixture(page)
await page.setViewportSize({ width: 1440, height: 1000 })
await openAccounts(page)
await page.screenshot({ path: testInfo.outputPath('administrators-dark.png'), fullPage: true })
await page.getByRole('button', { name: '新增管理员', exact: true }).click()
const dialog = page.getByRole('dialog', { name: '新增管理员' })
await dialog.getByRole('textbox', { name: '管理员账号', exact: true }).fill('finance')
await dialog.getByRole('textbox', { name: '管理员显示名称' }).fill('财务只读人员')
await dialog.getByLabel('管理员初始密码', { exact: true }).fill('Finance-test-2026')
await expect(dialog.getByRole('button', { name: '保存管理员' })).toBeDisabled()
await dialog.getByLabel('确认管理员初始密码', { exact: true }).fill('Finance-test-2026')
await dialog.getByRole('button', { name: '保存管理员' }).click()
await expect(dialog).not.toBeVisible()
await expect(page.getByText('finance', { exact: true })).toBeVisible()
expect(calls.find(c => c.path === '/administrators')?.body).toEqual({ username: 'finance', displayName: '财务只读人员', role: 'viewer', password: 'Finance-test-2026' })
await page.getByRole('button', { name: '切换浅色主题' }).click()
await page.screenshot({ path: testInfo.outputPath('administrators-light.png'), fullPage: true })
await page.setViewportSize({ width: 390, height: 844 })
expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(391)
await page.screenshot({ path: testInfo.outputPath('administrators-mobile.png'), fullPage: true })
})
test('编辑本人禁止角色和停用,取消其他账号停用不提交', async ({ page }) => {
const calls = await fixture(page)
await openAccounts(page)
await page.getByRole('row').filter({ hasText: 'root' }).getByRole('button', { name: '编辑', exact: true }).click()
let dialog = page.getByRole('dialog', { name: '编辑管理员' })
await expect(dialog.getByRole('combobox')).toBeDisabled()
await expect(dialog.getByRole('switch')).toBeDisabled()
await dialog.getByRole('button', { name: '取消', exact: true }).click()
await page.getByRole('row').filter({ hasText: 'operations' }).getByRole('button', { name: '编辑', exact: true }).click()
dialog = page.getByRole('dialog', { name: '编辑管理员' })
await dialog.locator('.el-switch').click()
await dialog.getByRole('button', { name: '保存管理员' }).click()
await page.locator('.el-message-box').getByRole('button', { name: '取消', exact: true }).click()
expect(calls.filter(c => c.method === 'PUT')).toHaveLength(0)
await dialog.getByRole('button', { name: '保存管理员' }).click()
await page.locator('.el-message-box').getByRole('button', { name: '确认提交' }).click()
await expect(dialog).not.toBeVisible()
expect(calls.find(c => c.method === 'PUT')?.body.enabled).toBe(false)
})
test('重置其他账号密码需要确认,取消不提交', async ({ page }) => {
const calls = await fixture(page)
await openAccounts(page)
await page.getByRole('button', { name: '重置密码', exact: true }).click()
const dialog = page.getByRole('dialog', { name: '重置密码 · operations' })
await dialog.getByLabel('重置后的新密码', { exact: true }).fill('Reset-test-2026')
await dialog.getByLabel('确认重置后的新密码', { exact: true }).fill('Reset-test-2026')
await dialog.getByRole('button', { name: '重置密码', exact: true }).click()
await page.locator('.el-message-box').getByRole('button', { name: '取消', exact: true }).click()
expect(calls.filter(c => c.path.endsWith('/password'))).toHaveLength(0)
await dialog.getByRole('button', { name: '重置密码', exact: true }).click()
await page.locator('.el-message-box').getByRole('button', { name: '确认提交' }).click()
await expect(dialog).not.toBeVisible()
expect(calls.find(c => c.path === '/administrators/other-admin/password')?.body).toEqual({ password: 'Reset-test-2026' })
})
test('只读人员可修改本人密码,错误原密码不退出,成功后清理登录', async ({ page }) => {
await fixture(page, 'viewer')
await page.goto('/settings')
await expect(page.getByText('无访问权限', { exact: true })).toBeVisible()
await page.getByRole('button', { name: '修改密码', exact: true }).click()
const dialog = page.getByRole('dialog', { name: '修改本人密码' })
await dialog.getByLabel('原密码', { exact: true }).fill('Incorrect-old-password')
await dialog.getByLabel('新密码', { exact: true }).fill('Updated-test-2026')
await dialog.getByLabel('确认新密码', { exact: true }).fill('Updated-test-2026')
await dialog.getByRole('button', { name: '保存并重新登录' }).click()
await expect(dialog.getByText(/原密码不正确/)).toBeVisible()
await dialog.getByLabel('原密码', { exact: true }).fill('Correct-old-password')
await dialog.getByRole('button', { name: '保存并重新登录' }).click()
await expect(page).toHaveURL(/\/login$/)
await expect(page.getByRole('heading', { name: '登录管理控制台' })).toBeVisible()
expect(await page.evaluate(() => sessionStorage.getItem('vdi.accessToken'))).toBeNull()
})
+98
View File
@@ -0,0 +1,98 @@
import { test, expect, type Page } from '@playwright/test'
// Every API request is intercepted, including all power actions.
async function fixture(page: Page, options: { role?: string; count?: number; blocked?: boolean; retry?: boolean; expired?: boolean } = {}) {
const requests: { path: string; body: Record<string, unknown>; key?: string }[] = [], now = new Date().toISOString()
const desktops = Array.from({ length: options.count || 3 }, (_, i) => ({ id: `desk-b${i + 1}`, name: `批量桌面 ${i + 1}`, vmid: 9000 + i, node: 'test', os: 'Ubuntu', pool: '', poolId: null, provider: 'proxmox', powerState: 'running', serviceState: 'unknown', setupRequired: false, endpoint: `192.0.2.${90 + i}`, app: 'Desktop', width: 1920, height: 1080, fps: 30, updatedAt: now }))
let submitted = false, reconciled = false
const batch = () => ({ id: 'batch-test', action: 'shutdown', actor: 'operator', createdAt: now, totalCount: 3, submittedCount: 2, skippedCount: 1, activeCount: submitted ? 2 : 0, successCount: reconciled ? 1 : 0, failedCount: submitted ? 0 : 1, uncertainCount: submitted || reconciled ? 0 : 1 })
const detail = () => ({ batch: batch(), items: desktops.slice(0, 3).map((d, i) => ({ desktopId: d.id, name: d.name, vmid: d.vmid, taskId: i < 2 ? `task-b${i}` : '', status: i === 2 ? 'skipped' : submitted ? 'queued' : i === 0 ? reconciled ? 'success' : 'timeout' : 'failed', code: i === 2 ? 'active_session' : 'ready', message: i === 2 ? '有活动会话,已跳过' : submitted ? '已提交,等待执行' : i === 0 ? '原任务结果未确认,不会自动重发' : '权限变更,未执行' })) })
await page.addInitScript(() => sessionStorage.setItem('vdi.accessToken', 'batch-fixture'))
await page.route('**/api/v1/**', async route => {
const r = route.request(), path = new URL(r.url()).pathname.replace('/api/v1', '')
const list = (items: unknown[]) => route.fulfill({ json: { items, total: items.length, page: 1, pageSize: 100, requestId: 'batch-ui' } })
if (r.method() !== 'GET') {
requests.push({ path, body: r.postData() ? r.postDataJSON() : {}, key: r.headers()['idempotency-key'] })
if (path === '/desktop-batches/check') {
const selected = (r.postDataJSON().desktopIds as string[]).map(id => desktops.find(d => d.id === id)!)
const items = selected.map(d => ({ desktopId: d.id, name: d.name, vmid: d.vmid, powerState: d.powerState, available: !options.blocked && d.id !== 'desk-b3', code: options.blocked || d.id === 'desk-b3' ? 'active_session' : 'ready', message: options.blocked || d.id === 'desk-b3' ? '有活动会话,已跳过' : '可执行,提交前再次检查' }))
const eligibleCount = items.filter(i => i.available).length
await route.fulfill({ json: { items, eligibleCount, skippedCount: items.length - eligibleCount, previewToken: 'signed-preview', expiresAt: new Date(Date.now() + (options.expired ? -1000 : 300000)).toISOString() } }); return
}
if (path === '/desktop-batches') {
if (options.retry && requests.filter(r => r.path === path).length === 1) { await route.fulfill({ status: 503, json: { code: 'network', message: '网络暂时不可用', requestId: 'batch-ui' } }); return }
submitted = true; await route.fulfill({ status: 202, json: detail() }); return
}
if (path.endsWith('/batch-power-reconcile')) { reconciled = true; await route.fulfill({ json: { accepted: true } }); return }
}
if (path === '/auth/me') { await route.fulfill({ json: { id: 'admin-batch', username: 'operator', displayName: '批量验收员', role: options.role || 'operator' } }); return }
if (path === '/desktop-batches') { await list([batch()]); return }
if (path === '/desktop-batches/batch-test') { await route.fulfill({ json: detail() }); return }
if (path === '/settings') { await route.fulfill({ json: { mode: 'production', computeProvider: 'proxmox', pveConfigured: true, terminalTransport: 'https-poll', capabilities: [] } }); return }
if (path === '/overview') { await route.fulfill({ json: { terminalCount: 0, onlineTerminals: 0, desktopCount: desktops.length, runningDesktops: desktops.length, activeSessions: 0, failedTasks: 0, mode: 'production' } }); return }
if (path === '/desktops') { await list(desktops); return }
if (['/terminals', '/users', '/resources', '/audit-logs'].includes(path)) { await list([]); return }
if (path === '/tasks') { await list([{ id: 'task-b0', kind: 'batch-desktop', action: 'shutdown', targetId: 'desk-b1', targetName: '批量桌面 1', actor: 'operator', status: 'timeout', message: '批量电源结果未确认', providerRef: 'UPID:old', createdAt: now, updatedAt: now }]); return }
await route.fulfill({ status: 404, json: { code: 'missing_fixture', message: '未定义测试接口', requestId: 'batch-ui' } })
})
return requests
}
async function selectCheckbox(page: Page, name: string) {
await page.locator('label.el-checkbox').filter({ has: page.getByRole('checkbox', { name, exact: true }) }).click()
}
async function prepare(page: Page) {
await page.goto('/desktops'); await selectCheckbox(page, '选择本页'); await page.getByRole('button', { name: '批量关机', exact: true }).click()
const dialog = page.getByRole('dialog', { name: '批量正常关机', exact: true }); await dialog.getByRole('button', { name: '预检批量操作', exact: true }).click(); return dialog
}
test('批量关机逐台预检,取消不提交,确认后仅显示已提交', async ({ page }, info) => {
const requests = await fixture(page), dialog = await prepare(page)
await expect(dialog.getByText('有活动会话,已跳过', { exact: true })).toBeVisible(); await expect(dialog.getByText(/可执行/).first()).toBeVisible()
await page.screenshot({ path: info.outputPath('batch-preview-dark.png'), fullPage: true })
await dialog.getByRole('button', { name: '提交批量操作', exact: true }).click(); await page.getByRole('button', { name: '取消', exact: true }).click()
expect(requests.filter(r => r.path === '/desktop-batches')).toHaveLength(0)
await dialog.getByRole('button', { name: '提交批量操作', exact: true }).click(); await page.getByRole('button', { name: '提交批量任务', exact: true }).click()
const result = page.getByRole('dialog', { name: '批量操作结果', exact: true }); await expect(result.getByText('已提交,等待执行', { exact: true })).toHaveCount(2)
const writes = requests.filter(r => r.path === '/desktop-batches'); expect(writes).toHaveLength(1); expect(writes[0]?.key).toBeTruthy(); expect(writes[0]?.body).toEqual({ action: 'shutdown', desktopIds: ['desk-b1', 'desk-b2', 'desk-b3'], previewToken: 'signed-preview' })
await page.screenshot({ path: info.outputPath('batch-submitted-dark.png'), fullPage: true })
})
test('卡片与列表共享选择,跨页保留选择并可清空', async ({ page }) => {
await fixture(page, { count: 13 }); await page.goto('/desktops')
await selectCheckbox(page, '选择桌面 批量桌面 1'); await page.getByRole('button', { name: '列表视图', exact: true }).click()
await expect(page.getByRole('checkbox', { name: '选择桌面 批量桌面 1', exact: true })).toBeChecked()
await page.locator('.el-pagination .btn-next').click(); await selectCheckbox(page, '选择桌面 批量桌面 11')
await expect(page.getByText('已选 2 / 50 台(可跨页)', { exact: true })).toBeVisible()
await page.getByRole('button', { name: '批量重启', exact: true }).click(); const dialog = page.getByRole('dialog', { name: '批量重启', exact: true })
await expect(dialog.getByText('批量桌面 1', { exact: true })).toBeVisible(); await expect(dialog.getByText('批量桌面 11', { exact: true })).toBeVisible()
await dialog.getByRole('button', { name: '关闭', exact: true }).click(); await page.getByRole('button', { name: '清空选择', exact: true }).click(); await expect(page.getByRole('button', { name: '批量开机', exact: true })).toBeDisabled()
})
test('浅色窄屏预检与结果操作区域保持可见', async ({ page }, info) => {
await fixture(page); await page.addInitScript(() => localStorage.setItem('vdi.theme', 'light')); await page.setViewportSize({ width: 390, height: 844 })
const dialog = await prepare(page); await expect(dialog.locator('.el-dialog__header')).toBeInViewport(); await expect(dialog.getByRole('button', { name: '提交批量操作', exact: true })).toBeInViewport()
expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(391)
await page.screenshot({ path: info.outputPath('batch-preview-light-mobile.png'), fullPage: true })
})
test('全部跳过或预检过期不能提交', async ({ page }) => {
let requests = await fixture(page, { blocked: true }); let dialog = await prepare(page)
await expect(dialog.getByRole('button', { name: '提交批量操作', exact: true })).toBeDisabled(); expect(requests.filter(r => r.path === '/desktop-batches')).toHaveLength(0)
await dialog.getByRole('button', { name: '关闭', exact: true }).click(); await page.unroute('**/api/v1/**'); requests = await fixture(page, { expired: true }); dialog = await prepare(page)
await expect(dialog.getByRole('button', { name: '提交批量操作', exact: true })).toBeDisabled(); expect(requests.filter(r => r.path === '/desktop-batches')).toHaveLength(0)
})
test('提交网络失败保留相同幂等标识重试', async ({ page }) => {
const requests = await fixture(page, { retry: true }), dialog = await prepare(page)
for (let i = 0; i < 2; i++) { await dialog.getByRole('button', { name: '提交批量操作', exact: true }).click(); await page.getByRole('button', { name: '提交批量任务', exact: true }).click(); if (!i) await expect(dialog.getByText(/网络暂时不可用/)).toBeVisible() }
await expect(page.getByRole('dialog', { name: '批量操作结果', exact: true })).toBeVisible(); const writes = requests.filter(r => r.path === '/desktop-batches'); expect(writes).toHaveLength(2); expect(writes[0]?.key).toBeTruthy(); expect(writes[0]?.key).toBe(writes[1]?.key)
})
test('批量记录保留失败和跳过原因,运维可只读核对原任务', async ({ page }) => {
const requests = await fixture(page); await page.goto('/desktops'); await page.getByRole('button', { name: '批量记录', exact: true }).click()
await page.getByRole('button', { name: '正常关机 · operator', exact: true }).click(); const dialog = page.getByRole('dialog', { name: '批量操作结果', exact: true })
await expect(dialog.getByText('权限变更,未执行', { exact: true })).toBeVisible(); await dialog.getByRole('button', { name: '核对电源结果', exact: true }).click()
await expect(dialog.getByRole('button', { name: '核对电源结果', exact: true })).toHaveCount(0); expect(requests).toHaveLength(1); expect(requests[0]?.path).toBe('/tasks/task-b0/batch-power-reconcile')
})
test('只读人员无批量选择与提交入口,可查看历史', async ({ page }) => {
await fixture(page, { role: 'viewer' }); await page.goto('/desktops')
await expect(page.getByRole('button', { name: '批量关机', exact: true })).toHaveCount(0); await expect(page.getByRole('checkbox', { name: '选择本页', exact: true })).toHaveCount(0)
await page.getByRole('button', { name: '批量记录', exact: true }).click(); await page.getByRole('button', { name: '正常关机 · operator', exact: true }).click(); await expect(page.getByRole('button', { name: '核对电源结果', exact: true })).toHaveCount(0)
})
test('任务页支持运维只读核对批量电源结果', async ({ page }) => {
const requests = await fixture(page); await page.goto('/tasks'); await page.getByRole('button', { name: '批量桌面 1', exact: true }).click(); await page.getByRole('button', { name: '核对电源结果', exact: true }).click(); await expect.poll(() => requests.length).toBe(1); expect(requests[0]?.path).toBe('/tasks/task-b0/batch-power-reconcile')
})
+106
View File
@@ -0,0 +1,106 @@
import { test, expect, type Page } from '@playwright/test'
// All calls are intercepted: these tests cannot create a real VM or change live assignments.
async function catalogFixture(page: Page, options: { role?: string; empty?: boolean; fail?: boolean } = {}) {
const now = new Date().toISOString(), requests: { endpoint: string; body: Record<string, unknown>; key?: string }[] = []
const templates = options.empty ? [] : [{ id: 'tpl-ui', name: 'Ubuntu 标准镜像', version: '1.0', os: 'Ubuntu', node: 'node-a', vmid: 8000, enabled: true, validatedAt: now }]
const pools = [{ id: 'pool-ui', name: '研发专属池', type: 'dedicated', maxCount: 5, templateId: 'tpl-ui', enabled: true, createdAt: now, desktopCount: 0, reservedCount: 0, assignedCount: 0, activeCount: 0 }]
const desktop = { id: 'desk-ui', name: '待初始化桌面', os: 'Ubuntu', node: 'node-a', vmid: 9000, pool: '', poolId: null, setupRequired: true, powerState: 'stopped', serviceState: 'unknown', provider: 'proxmox', endpoint: '', app: 'Desktop', width: 1920, height: 1080, fps: 30, updatedAt: now }
await page.addInitScript(() => sessionStorage.setItem('vdi.accessToken', 'catalog-fixture'))
await page.route('**/api/v1/**', async route => {
const r = route.request(), endpoint = new URL(r.url()).pathname.replace('/api/v1', '')
const list = (items: unknown[]) => route.fulfill({ json: { items, page: 1, pageSize: 100, total: items.length, requestId: 'catalog-ui' } })
if (r.method() !== 'GET') {
const body = r.postData() ? r.postDataJSON() as Record<string, unknown> : {}
requests.push({ endpoint, body, key: r.headers()['idempotency-key'] })
if (endpoint === '/templates') { const item = { ...templates[0], ...body, id: 'tpl-new', enabled: true, validatedAt: now } as typeof templates[number]; templates.push(item); await route.fulfill({ status: 201, json: item }); return }
if (endpoint === '/pools') { const item = { ...pools[0], ...body, id: 'pool-new' } as typeof pools[number]; pools.push(item); await route.fulfill({ json: item }); return }
if (endpoint === '/desktops/clone/check') { await route.fulfill({ json: { available: true, message: '预检通过;创建后需初始化' } }); return }
if (endpoint === '/desktops/clone') { await route.fulfill({ status: 202, json: { id: 'task-ui-clone', kind: 'provision', action: 'clone', targetId: desktop.id, targetName: '测试克隆桌面', actor: 'root', status: 'queued', message: '等待 PVE', providerRef: '', createdAt: now, updatedAt: now } }); return }
if (endpoint === '/desktops/desk-ui/connection') { Object.assign(desktop, body, { setupRequired: false }); await route.fulfill({ json: desktop }); return }
if (endpoint.endsWith('/members')) { await route.fulfill({ json: { accepted: true } }); return }
}
if (endpoint === '/auth/me') { await route.fulfill({ json: { id: 'admin-ui', username: 'root', displayName: '测试管理员', role: options.role || 'super_admin' } }); return }
if (endpoint === '/overview') { await route.fulfill({ json: { terminalCount: 0, onlineTerminals: 0, desktopCount: 1, runningDesktops: 0, activeSessions: 0, failedTasks: 0, mode: 'production' } }); return }
if (endpoint === '/settings') { await route.fulfill({ json: { mode: 'production', computeProvider: 'proxmox', pveConfigured: true, terminalTransport: 'https-poll', capabilities: [] } }); return }
if (endpoint === '/templates') { if (options.fail) { await route.fulfill({ status: 503, json: { code: 'unavailable', message: '模板读取失败,请重试', requestId: 'catalog-ui' } }); return }; await list(templates); return }
if (endpoint === '/pools') { await list(pools); return }
if (endpoint === '/resources/templates') { await list(options.empty ? [] : [{ name: 'Ubuntu 新模板', node: 'node-a', vmid: 8001, status: 'stopped' }]); return }
if (endpoint === '/resources/storage') { await list([{ storage: 'local', avail: 100 * 1073741824, enabled: 1, active: 1, content: 'images' }]); return }
if (endpoint === '/desktops') { await list([desktop]); return }
if (['/terminals', '/users', '/resources', '/tasks', '/audit-logs'].includes(endpoint)) { await list([]); return }
await route.fulfill({ status: 404, json: { code: 'fixture_missing', message: '测试接口未定义', requestId: 'catalog-ui' } })
})
return requests
}
test('模板克隆向导先预检,再提交带去重标识的任务', async ({ page }, testInfo) => {
const requests = await catalogFixture(page)
await page.setViewportSize({ width: 1440, height: 1000 })
await page.goto('/templates')
await expect(page.getByText('Ubuntu 标准镜像', { exact: true })).toBeVisible()
await page.screenshot({ path: testInfo.outputPath('templates-dark.png'), fullPage: true })
await page.getByRole('button', { name: '创建桌面', exact: true }).click()
const dialog = page.getByRole('dialog', { name: '从模板创建桌面' })
await expect(dialog.getByRole('button', { name: '提交创建任务' })).toBeDisabled()
await dialog.getByPlaceholder('例如:研发桌面 01').fill('测试克隆桌面')
await dialog.locator('.el-select__wrapper').last().click()
await page.getByRole('option', { name: /local · 可用/ }).click()
await dialog.getByRole('button', { name: '检查创建条件' }).click()
await expect(dialog.getByText('预检通过;创建后需初始化')).toBeVisible()
await dialog.getByRole('button', { name: '提交创建任务' }).click()
await expect(dialog.getByText('创建任务已提交', { exact: true })).toBeVisible()
expect(requests.filter(r => r.endpoint === '/desktops/clone')).toHaveLength(1)
expect(requests.find(r => r.endpoint === '/desktops/clone')?.body).toMatchObject({ templateId: 'tpl-ui', name: '测试克隆桌面', vmid: 9000, full: true, storage: 'local' })
expect(requests.find(r => r.endpoint === '/desktops/clone')?.key).toBeTruthy()
})
test('桌面池创建保存业务字段,窄屏与浅色主题可用', async ({ page }, testInfo) => {
const requests = await catalogFixture(page)
await page.goto('/pools')
await expect(page.getByText('0 台桌面 + 0 台创建预留 / 5')).toBeVisible()
await page.getByRole('button', { name: '新建桌面池' }).click()
const dialog = page.getByRole('dialog', { name: '新建桌面池' })
await dialog.getByRole('textbox').fill('财务专属池')
await dialog.getByRole('button', { name: '保存桌面池' }).click()
await expect(page.getByText('财务专属池', { exact: true })).toBeVisible()
expect(requests.find(r => r.endpoint === '/pools')?.body).toEqual({ name: '财务专属池', type: 'dedicated', maxCount: 10, templateId: null, enabled: true })
await page.getByRole('button', { name: '切换浅色主题' }).click()
await page.screenshot({ path: testInfo.outputPath('pools-light.png'), fullPage: true })
await page.setViewportSize({ width: 390, height: 844 })
await page.reload()
await expect(page.getByRole('heading', { name: '桌面池', exact: true })).toBeVisible()
expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(391)
await page.screenshot({ path: testInfo.outputPath('pools-mobile.png'), fullPage: true })
})
test('待初始化桌面必须勾选确认才能保存连接', async ({ page }) => {
const requests = await catalogFixture(page)
await page.goto('/desktops')
await expect(page.getByText('待初始化', { exact: true }).first()).toBeVisible()
await page.getByRole('button', { name: '查看详情' }).click()
await page.getByRole('button', { name: '配置连接', exact: true }).click()
const dialog = page.getByRole('dialog', { name: '配置桌面连接' })
await dialog.getByPlaceholder('例如 192.168.1.200,不含协议和端口').fill('192.0.2.20')
await expect(dialog.getByRole('button', { name: '保存连接配置' })).toBeDisabled()
await dialog.getByText('已完成系统身份、IP、Sunshine 服务和终端配对', { exact: true }).click()
await dialog.getByRole('button', { name: '保存连接配置' }).click()
await expect(dialog).not.toBeVisible()
expect(requests.find(r => r.endpoint.endsWith('/connection'))?.body).toMatchObject({ endpoint: '192.0.2.20', initialized: true, width: 1920, height: 1080, fps: 30 })
})
test('空模板和接口错误显示明确原因,只读角色没有管理按钮', async ({ page }) => {
await catalogFixture(page, { role: 'viewer', empty: true })
await page.goto('/templates')
await expect(page.getByText(/尚未登记模板。请先在 PVE/)).toBeVisible()
await expect(page.getByRole('button', { name: '登记模板' })).toHaveCount(0)
await page.goto('/pools')
await expect(page.getByRole('button', { name: '新建桌面池' })).toHaveCount(0)
await expect(page.getByRole('button', { name: '成员', exact: true })).toHaveCount(0)
})
test('模板读取失败提供错误反馈', async ({ page }) => {
await catalogFixture(page, { fail: true })
await page.goto('/templates')
await expect(page.getByText(/模板读取失败,请重试/)).toBeVisible()
})
+127
View File
@@ -0,0 +1,127 @@
import { test, expect, type Page } from '@playwright/test'
// Every endpoint is intercepted; these tests cannot mutate a live PVE desktop.
async function fixture(page: Page, options: { running?: boolean; role?: string; fail?: boolean } = {}) {
const requests: { path: string; body: Record<string, unknown>; key?: string }[] = [], now = new Date().toISOString()
const desktop = { id: 'desk-hw', name: '规格验收桌面', vmid: 9000, node: 'test', os: 'Windows', pool: '', poolId: null, provider: 'proxmox', powerState: options.running ? 'running' : 'stopped', serviceState: 'unknown', setupRequired: false, endpoint: '192.0.2.90', app: 'Desktop', width: 1920, height: 1080, fps: 30, updatedAt: now }
await page.addInitScript(() => sessionStorage.setItem('vdi.accessToken', 'configuration-fixture'))
await page.route('**/api/v1/**', async route => {
const r = route.request(), path = new URL(r.url()).pathname.replace('/api/v1', '')
const list = (items: unknown[]) => route.fulfill({ json: { items, total: items.length, page: 1, pageSize: 100, requestId: 'config-ui' } })
if (r.method() !== 'GET') {
requests.push({ path, body: r.postData() ? r.postDataJSON() : {}, key: r.headers()['idempotency-key'] })
if (path === '/settings/pve/check') { await route.fulfill(options.fail ? { status: 502, json: { code: 'pve_connection_failed', message: '连接检查失败,请检查证书与权限', requestId: 'config-ui' } } : { json: { available: true, nodes: 1, vms: 3, message: 'TLS、身份与资源读取检查通过' } }); return }
if (path === '/desktops/desk-hw/hardware/check') { await route.fulfill({ json: { available: true, message: '规格预检通过' } }); return }
if (path === '/desktops/desk-hw/hardware') { await route.fulfill({ status: 202, json: { id: 'task-hw', kind: 'hardware', action: 'configure-hardware', targetId: desktop.id, targetName: desktop.name, actor: 'root', status: 'queued', message: '等待执行', providerRef: '', createdAt: now, updatedAt: now } }); return }
if (path === '/settings/pve' || path.endsWith('/hardware-reconcile')) { await route.fulfill({ json: { accepted: true } }); return }
}
if (path === '/auth/me') { await route.fulfill({ json: { id: 'admin-hw', username: 'root', displayName: '验收管理员', role: options.role || 'super_admin' } }); return }
if (path === '/settings/pve') { await route.fulfill({ json: { url: 'https://pve.example:8006', tokenId: 'service@pve!vdi', tokenConfigured: true, caPem: '', revision: 'config-v1', source: 'environment' } }); return }
if (path === '/desktops/desk-hw/hardware') { await route.fulfill({ json: { cores: 2, sockets: 1, memoryMiB: 2048, digest: 'hardware-v1', disks: [{ key: 'scsi0', storage: 'local', sizeGiB: 60 }], powerState: desktop.powerState, blockers: options.running ? ['请先正常关闭桌面,再修改规格'] : [] } }); return }
if (path === '/settings') { await route.fulfill({ json: { mode: 'production', computeProvider: 'proxmox', pveConfigured: true, terminalTransport: 'https-poll', capabilities: [] } }); return }
if (path === '/overview') { await route.fulfill({ json: { terminalCount: 0, onlineTerminals: 0, desktopCount: 1, runningDesktops: 0, activeSessions: 0, failedTasks: 0, mode: 'production' } }); return }
if (path === '/desktops') { await list([desktop]); return }
if (path === '/tasks') { await list([{ id: 'hw-timeout', kind: 'hardware', action: 'expand-disk', targetId: desktop.id, targetName: desktop.name, actor: 'root', status: 'timeout', message: '规格变更结果未确认,不会自动重发', providerRef: 'UPID:old', createdAt: now, updatedAt: now }]); return }
if (['/terminals', '/users', '/resources', '/audit-logs'].includes(path)) { await list([]); return }
await route.fulfill({ status: 404, json: { code: 'missing_fixture', message: '未定义测试接口', requestId: 'config-ui' } })
})
return requests
}
test('接入配置不回显密钥,取消不写入,检查后保留原密钥保存', async ({ page }, info) => {
const requests = await fixture(page)
await page.goto('/settings')
await page.getByRole('button', { name: '配置 Proxmox 接入' }).click()
const dialog = page.getByRole('dialog', { name: 'Proxmox 接入配置' })
await expect(dialog.getByRole('textbox', { name: 'PVE Token 密钥' })).toHaveValue('')
await dialog.getByRole('button', { name: '检查连接' }).click()
await expect(dialog.getByText(/TLS、身份与资源读取检查通过/)).toBeVisible()
await page.screenshot({ path: info.outputPath('pve-connection-dark.png'), fullPage: true })
await dialog.getByRole('button', { name: '保存并生效' }).click()
await page.getByRole('button', { name: '取消', exact: true }).last().click()
expect(requests.filter(r => r.path === '/settings/pve')).toHaveLength(0)
await dialog.getByRole('button', { name: '保存并生效' }).click()
await page.getByRole('button', { name: '确认保存', exact: true }).click()
await expect(dialog).not.toBeVisible()
expect(requests.find(r => r.path === '/settings/pve')?.body).toMatchObject({ tokenSecret: '', revision: 'config-v1' })
})
test('接入检查失败保留输入并支持窄屏', async ({ page }, info) => {
const requests = await fixture(page, { fail: true })
await page.setViewportSize({ width: 390, height: 844 })
await page.goto('/settings')
await page.getByRole('button', { name: '配置 Proxmox 接入' }).click()
const dialog = page.getByRole('dialog', { name: 'Proxmox 接入配置' })
await dialog.getByRole('button', { name: '检查连接' }).click()
await expect(dialog.getByText(/连接检查失败,请检查证书与权限/)).toBeVisible()
expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(391)
await page.screenshot({ path: info.outputPath('pve-connection-mobile.png'), fullPage: true })
await dialog.getByRole('button', { name: '取消', exact: true }).click()
expect(requests.filter(r => r.path === '/settings/pve')).toHaveLength(0)
})
test('CPU 内存调整先预检,修改参数使预检失效,确认后提交一次', async ({ page }, info) => {
const requests = await fixture(page)
await page.goto('/desktops')
await page.getByRole('button', { name: '查看详情' }).click()
await page.getByRole('button', { name: '规格管理' }).click()
const dialog = page.getByRole('dialog', { name: '桌面规格管理' })
await expect(dialog.getByText(/当前:1 插槽 × 2 核/)).toBeVisible()
await dialog.getByRole('spinbutton', { name: '每插槽核心数' }).fill('4')
await dialog.getByRole('button', { name: '预检规格' }).click()
await expect(dialog.getByRole('button', { name: '提交变更' })).toBeEnabled()
await dialog.getByRole('spinbutton', { name: '目标内存' }).fill('4096')
await expect(dialog.getByRole('button', { name: '提交变更' })).toBeDisabled()
await dialog.getByRole('button', { name: '预检规格' }).click()
await page.screenshot({ path: info.outputPath('hardware-compute-dark.png'), fullPage: true })
await dialog.getByRole('button', { name: '提交变更' }).click()
await page.getByRole('button', { name: '提交规格任务', exact: true }).click()
await expect(dialog).not.toBeVisible()
const writes = requests.filter(r => r.path === '/desktops/desk-hw/hardware')
expect(writes).toHaveLength(1)
expect(writes[0]?.body).toEqual({ kind: 'compute', cores: 4, sockets: 1, memoryMiB: 4096, digest: 'hardware-v1' })
expect(writes[0]?.key).toBeTruthy()
})
test('磁盘输入总容量并提示客体扩展,取消确认不提交', async ({ page }, info) => {
const requests = await fixture(page)
await page.goto('/desktops')
await page.getByRole('button', { name: '切换浅色主题' }).click()
await page.getByRole('button', { name: '查看详情' }).click()
await page.getByRole('button', { name: '规格管理' }).click()
const dialog = page.getByRole('dialog', { name: '桌面规格管理' })
await dialog.getByRole('tab', { name: '磁盘扩容' }).click()
await dialog.getByRole('spinbutton', { name: '磁盘目标容量' }).fill('80')
await dialog.getByRole('button', { name: '预检规格' }).click()
await page.screenshot({ path: info.outputPath('hardware-disk-light.png'), fullPage: true })
await dialog.getByRole('button', { name: '提交变更' }).click()
await expect(page.getByText(/scsi0 扩容至 80 GiB;完成后需在客体内扩展/)).toBeVisible()
await page.getByRole('button', { name: '取消', exact: true }).last().click()
expect(requests.find(r => r.path.endsWith('/hardware/check'))?.body).toMatchObject({ kind: 'disk', disk: 'scsi0', sizeGiB: 80 })
expect(requests.filter(r => r.path === '/desktops/desk-hw/hardware')).toHaveLength(0)
})
test('运行中的桌面阻止修改,结果未知任务只读核对', async ({ page }) => {
const requests = await fixture(page, { running: true })
await page.goto('/desktops')
await page.getByRole('button', { name: '查看详情' }).click()
await page.getByRole('button', { name: '规格管理' }).click()
const dialog = page.getByRole('dialog', { name: '桌面规格管理' })
await expect(dialog.getByText('请先正常关闭桌面,再修改规格')).toBeVisible()
await expect(dialog.getByRole('button', { name: '预检规格' })).toBeDisabled()
await dialog.getByRole('button', { name: '关闭', exact: true }).click()
await page.goto('/tasks')
await page.getByRole('button', { name: '规格验收桌面', exact: true }).click()
await page.getByRole('button', { name: '核对规格结果' }).click()
await expect.poll(() => requests.filter(r => r.path.endsWith('/hardware-reconcile')).length).toBe(1)
expect(requests.filter(r => r.path === '/desktops/desk-hw/hardware')).toHaveLength(0)
})
test('只读角色不显示接入与规格管理入口', async ({ page }) => {
await fixture(page, { role: 'viewer' })
await page.goto('/settings')
await expect(page.getByRole('button', { name: '配置 Proxmox 接入' })).toHaveCount(0)
await page.goto('/desktops')
await page.getByRole('button', { name: '查看详情' }).click()
await expect(page.getByRole('button', { name: '规格管理' })).toHaveCount(0)
})
+85
View File
@@ -0,0 +1,85 @@
import { test, expect, type Page } from '@playwright/test'
async function fixture(page: Page, options: { role?: string; partial?: boolean; fail?: boolean; empty?: boolean; configured?: boolean; stale?: boolean } = {}) {
const writes: string[] = [], reads: string[] = [], now = new Date().toISOString()
let fail = !!options.fail
const reference = { vmid: 9000, vmName: 'win-reference', vmStatus: 'stopped', template: false, desktopId: 'desk-gpu', desktopName: '测试 Windows', slot: 'hostpci0', phase: 'current', source: 'direct', mapping: '', mediated: false, pendingRemoval: true }
const inventory = () => ({ node: 'node-gpu', observedAt: new Date(Date.now() - (options.stale ? 120000 : 0)).toISOString(), complete: !options.partial, visibleVmCount: 2, scannedVmCount: options.partial ? 1 : 2, issues: options.partial ? [{ code: 'vm_config_unavailable', vmid: 9001, message: '无法读取虚拟机当前及待应用配置' }] : [], items: options.empty ? [] : [
{ id: 'node-gpu:0000:00:02.0', address: '0000:00:02.0', name: 'Intel Integrated Graphics', vendor: 'Intel', vendorId: '0x8086', deviceId: '0x0416', class: '0x030000', iommuGroup: 0, state: options.partial ? 'unknown' : 'unreferenced', references: [] },
{ id: 'node-gpu:0000:01:00.0', address: '0000:01:00.0', name: 'GeForce GTX Test', vendor: 'NVIDIA', vendorId: '0x10de', deviceId: '0x1234', class: '0x030200', iommuGroup: 2, state: 'multiple', references: [reference, { ...reference, vmid: 9001, vmName: 'ubuntu-unmanaged', desktopId: '', desktopName: '', phase: 'pending', source: 'mapping', mapping: 'gpu-shared-map', mediated: true, template: true, pendingRemoval: false }] },
] })
await page.addInitScript(() => sessionStorage.setItem('vdi.accessToken', 'gpu-fixture'))
await page.route('**/api/v1/**', async route => {
const r = route.request(), path = new URL(r.url()).pathname.replace('/api/v1', '')
if (r.method() !== 'GET') { writes.push(path); await route.fulfill({ status: 403, json: { code: 'readonly_fixture', message: 'GPU 查看不应发送写请求', requestId: 'gpu-ui' } }); return }
const list = (items: unknown[]) => route.fulfill({ json: { items, total: items.length, page: 1, pageSize: 100, requestId: 'gpu-ui' } })
if (path === '/auth/me') { await route.fulfill({ json: { id: 'gpu-admin', username: 'gpu-reader', displayName: 'GPU 查看人员', role: options.role || 'viewer' } }); return }
if (path === '/settings') { await route.fulfill({ json: { mode: 'production', computeProvider: 'proxmox', pveConfigured: options.configured !== false, terminalTransport: 'https-poll', capabilities: ['gpu-inventory'] } }); return }
if (path === '/overview') { await route.fulfill({ json: { terminalCount: 0, onlineTerminals: 0, desktopCount: 0, runningDesktops: 0, activeSessions: 0, failedTasks: 0, mode: 'production' } }); return }
if (path === '/resources') { await list(options.configured === false ? [] : [{ id: 'node-gpu', name: 'node-gpu', status: 'online', cpu: 0.2, memory: 0.5, storage: 0.3, provider: 'proxmox', updatedAt: now }]); return }
if (path === '/resources/gpus') {
reads.push(new URL(r.url()).searchParams.get('node') || '')
if (fail) { fail = false; await route.fulfill({ status: 502, json: { code: 'pci_unavailable', message: '无法读取节点 PCI 清单,请检查 Sys.Audit 权限', requestId: 'gpu-ui' } }); return }
await route.fulfill({ json: inventory() }); return
}
if (['/desktops', '/terminals', '/users', '/tasks', '/audit-logs'].includes(path)) { await list([]); return }
await route.fulfill({ status: 404, json: { code: 'fixture_missing', message: '测试接口未定义', requestId: 'gpu-ui' } })
})
return { writes, reads }
}
test('只读角色可查显卡并筛选配置引用,IOMMU 0 保留', async ({ page }, info) => {
const requests = await fixture(page); await page.goto('/gpus')
await expect(page.getByRole('heading', { name: 'Intel Integrated Graphics', exact: true })).toBeVisible()
const intel = page.locator('.gpu-card').filter({ has: page.getByRole('heading', { name: 'Intel Integrated Graphics', exact: true }) })
await expect(intel.getByText('未发现配置引用', { exact: true })).toBeVisible(); await expect(intel.locator('dd').filter({ hasText: /^0$/ })).toBeVisible()
await expect(page.locator('.gpu-card').getByText('多 VM 引用', { exact: true })).toBeVisible()
await page.getByRole('textbox', { name: '搜索 GPU' }).fill('win-reference'); await expect(page.locator('.gpu-card')).toHaveCount(1)
await page.getByRole('textbox', { name: '搜索 GPU' }).clear()
await page.screenshot({ path: info.outputPath('gpu-inventory-dark.png'), fullPage: true, animations: 'disabled' })
await expect(page.getByText('GPU 清单按需读取,请手动刷新', { exact: true })).toBeVisible()
expect(requests.reads).toEqual(['node-gpu']); expect(requests.writes).toEqual([])
await expect(page.getByRole('button', { name: '绑定 GPU', exact: true })).toHaveCount(0)
})
test('详情区分当前与待应用、待移除、模板及未纳管的映射引用', async ({ page }, info) => {
const requests = await fixture(page); await page.goto('/gpus')
await page.locator('.gpu-card').filter({ has: page.getByRole('heading', { name: 'GeForce GTX Test', exact: true }) }).getByRole('button', { name: '查看 GPU 详情' }).click()
const drawer = page.getByRole('dialog', { name: 'GPU 资源详情', exact: true })
for (const text of ['当前配置', '待应用配置', '待移除', '模板', '介导设备', '尚未纳管为平台桌面']) await expect(drawer.getByText(text, { exact: true })).toBeVisible()
await expect(drawer.getByText('hostpci0 · PCI 映射 gpu-shared-map', { exact: true })).toBeVisible()
await expect(drawer.getByText('已纳管桌面:测试 Windows', { exact: true })).toBeVisible()
await page.screenshot({ path: info.outputPath('gpu-reference-dark.png'), fullPage: true, animations: 'disabled' })
expect(requests.writes).toEqual([])
})
test('配置读取不完整显示待核查,不宣称显卡可用', async ({ page }) => {
const requests = await fixture(page, { partial: true }); await page.goto('/gpus')
await expect(page.getByRole('heading', { name: '检查未完整' })).toBeVisible()
await expect(page.getByText('VM 9001:无法读取虚拟机当前及待应用配置', { exact: true })).toBeVisible()
const intel = page.locator('.gpu-card').filter({ hasText: 'Intel Integrated Graphics' }); await expect(intel.getByText('待核查', { exact: true })).toBeVisible()
await intel.getByRole('button', { name: '查看 GPU 详情' }).click(); await expect(page.getByText('检查范围不完整,引用情况待核查', { exact: true })).toBeVisible()
expect(requests.writes).toEqual([])
})
test('PCI 权限错误提供重试,数据过期有明确提示', async ({ page }) => {
const requests = await fixture(page, { fail: true, stale: true }); await page.goto('/gpus')
await expect(page.getByText(/无法读取节点 PCI 清单,请检查 Sys.Audit 权限/)).toBeVisible()
await page.getByRole('button', { name: '重试读取', exact: true }).click()
await expect(page.getByText('数据已过期,请刷新', { exact: true })).toBeVisible()
await expect(page.locator('.gpu-card')).toHaveCount(2); expect(requests.reads).toHaveLength(2); expect(requests.writes).toEqual([])
})
test('浅色窄屏清单和详情可读且页面不横向溢出', async ({ page }, info) => {
await fixture(page); await page.addInitScript(() => localStorage.setItem('vdi.theme', 'light')); await page.setViewportSize({ width: 390, height: 844 }); await page.goto('/gpus')
await expect(page.locator('.gpu-card')).toHaveCount(2)
expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(391)
await page.screenshot({ path: info.outputPath('gpu-inventory-light-mobile.png'), fullPage: true, animations: 'disabled' })
await page.getByRole('button', { name: '查看 GPU 详情' }).last().click()
const drawer = page.getByRole('dialog', { name: 'GPU 资源详情', exact: true }); await expect(drawer.getByRole('heading', { name: 'GeForce GTX Test', exact: true })).toBeVisible()
expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(391)
await expect.poll(async () => { const box = await drawer.boundingBox(); return box ? box.x + box.width : 999 }).toBeLessThanOrEqual(391)
await page.screenshot({ path: info.outputPath('gpu-detail-light-mobile.png'), animations: 'disabled' })
})
test('空节点清单与尚未接入显示真实空态', async ({ page }) => {
let requests = await fixture(page, { empty: true }); await page.goto('/gpus')
await expect(page.getByText('当前节点未发现显示控制器类 PCI 设备', { exact: true })).toBeVisible(); expect(requests.writes).toEqual([])
await page.unroute('**/api/v1/**'); requests = await fixture(page, { configured: false }); await page.reload()
await expect(page.getByText('尚未接入 Proxmox', { exact: true })).toBeVisible(); await expect(page.getByRole('button', { name: '刷新 GPU 清单', exact: true })).toBeDisabled(); expect(requests.reads).toEqual([])
})
+142
View File
@@ -0,0 +1,142 @@
import { test, expect, type Page } from '@playwright/test'
async function fixture(page: Page, role = 'super_admin', active = false) {
const calls: { path: string; method: string; body: Record<string, unknown> }[] = []
const terminals = [
{ id: 't1', sn: 'TEST-BOX-001', name: '设计工位', group: '设计部', enabled: true, status: 'online', agentVersion: 'qt-test', lastSeen: '2026-11-10T02:00:00Z', sessionDesktopId: active ? 'd1' : null, sessionState: active ? 'connected' : 'idle', configVersion: 1, fps: null, rtt: null },
{ id: 't2', sn: 'TEST-BOX-002', name: '备用终端', group: '备用组', enabled: false, status: 'disabled', agentVersion: '', lastSeen: null, sessionDesktopId: null, sessionState: 'idle', configVersion: 1, fps: null, rtt: null },
]
const users = [
{ id: 'u1', username: 'designer', displayName: '设计人员', enabled: true, terminalId: 't1', desktopIds: ['d1'] },
{ id: 'u2', username: 'office', displayName: '办公人员', enabled: true, terminalId: null, desktopIds: ['d1'] },
]
const desktops = [{ id: 'd1', name: '设计桌面', os: 'Windows', powerState: 'running' }, { id: 'd2', name: '办公桌面', os: 'Ubuntu', powerState: 'running' }]
await page.addInitScript(() => sessionStorage.setItem('vdi.accessToken', 'lifecycle-fixture'))
await page.route('**/api/v1/**', async route => {
const r = route.request(), path = new URL(r.url()).pathname.replace('/api/v1', ''), method = r.method()
const list = (items: unknown[]) => route.fulfill({ json: { items, page: 1, pageSize: 100, total: items.length, requestId: 'lifecycle-fixture' } })
if (method !== 'GET') {
const body = r.postData() ? r.postDataJSON() as Record<string, unknown> : {}; calls.push({ path, method, body })
if (path === '/terminals/t1' && method === 'PUT') { Object.assign(terminals[0]!, body); await route.fulfill({ json: { accepted: true } }); return }
if (path.endsWith('/disable')) { Object.assign(terminals[0]!, { enabled: false, status: 'disabled', lastSeen: null }); await route.fulfill({ json: { accepted: true } }); return }
if (path.endsWith('/reactivate')) { const t = terminals.find(t => path.includes(t.id))!; Object.assign(t, { enabled: true, status: 'unregistered' }); await route.fulfill({ json: { id: t.id, activationSecret: 'activate_fixture_one_time' } }); return }
if (path === '/users/u1') { Object.assign(users[0]!, body); await route.fulfill({ json: users[0] }); return }
if (path === '/users/batch-bindings') {
for (const u of users.filter(u => (body.userIds as string[]).includes(u.id))) u.desktopIds = body.action === 'grant' ? [...new Set([...u.desktopIds, ...body.desktopIds as string[]])] : u.desktopIds.filter(id => !(body.desktopIds as string[]).includes(id))
await route.fulfill({ json: { accepted: true, userCount: (body.userIds as string[]).length } }); return
}
}
if (path === '/auth/me') { await route.fulfill({ json: { id: 'admin', username: 'root', displayName: '平台管理员', role } }); return }
if (path === '/settings') { await route.fulfill({ json: { mode: 'production', pveConfigured: true, capabilities: [] } }); return }
if (path === '/overview') { await route.fulfill({ json: { terminalCount: 2, onlineTerminals: 1, desktopCount: 2, runningDesktops: 2, activeSessions: active ? 1 : 0, failedTasks: 0, mode: 'production' } }); return }
if (path === '/terminals') { await list(terminals); return }
if (path === '/users') { await list(users); return }
if (path === '/desktops') { await list(desktops); return }
if (['/tasks', '/audit-logs', '/resources'].includes(path)) { await list([]); return }
await route.fulfill({ status: 404, json: { code: 'fixture_missing', message: '测试接口未定义', requestId: 'fixture' } })
})
return calls
}
test('终端资料保存名称和分组,深浅主题与窄屏可用', async ({ page }, testInfo) => {
const calls = await fixture(page)
await page.setViewportSize({ width: 1440, height: 1000 }); await page.goto('/terminals')
const row = page.getByRole('row').filter({ hasText: 'TEST-BOX-001' })
await row.getByRole('button', { name: '编辑资料' }).click()
const dialog = page.getByRole('dialog', { name: '编辑终端资料' })
await dialog.getByLabel('编辑终端名称').fill('设计工位 A')
await dialog.getByLabel('编辑终端分组').fill('研发一组')
await dialog.getByRole('button', { name: '保存终端资料' }).click()
await expect(dialog).toBeHidden(); await expect(row).toContainText('研发一组')
expect(calls[0]).toEqual({ path: '/terminals/t1', method: 'PUT', body: { name: '设计工位 A', group: '研发一组' } })
await page.screenshot({ path: testInfo.outputPath('terminals-dark.png'), fullPage: true, animations: 'disabled' })
await page.getByRole('button', { name: '切换浅色主题' }).click()
await page.screenshot({ path: testInfo.outputPath('terminals-light.png'), fullPage: true, animations: 'disabled' })
await row.getByRole('button', { name: '详情', exact: true }).click()
await page.screenshot({ path: testInfo.outputPath('terminal-detail-light.png'), fullPage: true, animations: 'disabled' })
await page.setViewportSize({ width: 390, height: 844 })
expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(391)
await page.screenshot({ path: testInfo.outputPath('terminal-detail-mobile.png'), fullPage: true, animations: 'disabled' })
})
test('停用与重新激活需要确认,旧凭据撤销提示和一次性密钥可见', async ({ page }) => {
const calls = await fixture(page); await page.goto('/terminals')
await page.getByRole('row').filter({ hasText: 'TEST-BOX-001' }).getByRole('button', { name: '详情', exact: true }).click()
await page.getByRole('button', { name: '停用设备', exact: true }).click()
const box = page.locator('.el-message-box')
await box.getByRole('button', { name: '取消', exact: true }).click(); expect(calls).toHaveLength(0)
await page.getByRole('button', { name: '停用设备', exact: true }).click()
await box.getByRole('button', { name: '确认提交' }).click()
await expect(page.getByRole('button', { name: '停用设备', exact: true })).toHaveCount(0)
await expect(page.getByRole('button', { name: '收集日志', exact: true })).toHaveCount(0)
await page.getByRole('button', { name: '重新激活', exact: true }).click()
await box.getByRole('button', { name: '取消', exact: true }).click(); expect(calls).toHaveLength(1)
await page.getByRole('button', { name: '重新激活', exact: true }).click()
await box.getByRole('button', { name: '确认提交' }).click()
const activation = page.getByRole('dialog', { name: '终端激活信息', exact: true })
await expect(activation.getByTestId('activation-secret')).toHaveText('activate_fixture_one_time')
await activation.getByRole('button', { name: '已保存,关闭' }).click()
await expect(activation).toBeHidden(); await expect(page.getByText('activate_fixture_one_time')).toHaveCount(0)
expect(calls.map(c => c.path)).toEqual(['/terminals/t1/disable', '/terminals/t1/reactivate'])
})
test('活动会话保护设备身份,运维和只读人员无设备维护入口', async ({ page }) => {
const calls = await fixture(page, 'super_admin', true); await page.goto('/terminals')
await page.getByRole('row').filter({ hasText: 'TEST-BOX-001' }).getByRole('button', { name: '详情', exact: true }).click()
await expect(page.getByRole('button', { name: '停用设备', exact: true })).toBeDisabled()
await expect(page.getByRole('button', { name: '重新激活', exact: true })).toBeDisabled(); expect(calls).toHaveLength(0)
await page.unroute('**/api/v1/**'); await fixture(page, 'operator'); await page.reload()
await page.getByRole('row').filter({ hasText: 'TEST-BOX-001' }).getByRole('button', { name: '详情', exact: true }).click()
await expect(page.getByRole('button', { name: '停用设备', exact: true })).toHaveCount(0)
await expect(page.getByRole('button', { name: '编辑资料', exact: true })).toHaveCount(0)
await page.unroute('**/api/v1/**'); await fixture(page, 'viewer'); await page.goto('/users')
await expect(page.getByRole('button', { name: '批量授权', exact: true })).toHaveCount(0)
await expect(page.getByRole('button', { name: '编辑资料', exact: true })).toHaveCount(0)
})
test('用户改名与停用保持授权,取消状态变更不提交', async ({ page }) => {
const calls = await fixture(page); await page.goto('/users')
const row = page.getByRole('row').filter({ hasText: 'designer' })
await row.getByRole('button', { name: '编辑资料', exact: true }).click()
const dialog = page.getByRole('dialog', { name: '编辑用户资料', exact: true })
await dialog.getByLabel('编辑用户姓名').fill('新设计人员')
await dialog.locator('.el-switch').click()
await dialog.getByRole('button', { name: '保存用户资料' }).click()
await page.locator('.el-message-box').getByRole('button', { name: '取消', exact: true }).click(); expect(calls).toHaveLength(0)
await dialog.getByRole('button', { name: '保存用户资料' }).click()
await page.locator('.el-message-box').getByRole('button', { name: '确认提交' }).click()
await expect(dialog).toBeHidden(); await expect(row).toContainText('新设计人员'); await expect(row).toContainText('设计桌面')
expect(calls[0]).toEqual({ path: '/users/u1', method: 'PUT', body: { displayName: '新设计人员', enabled: false } })
})
test('批量授权明确目标和追加撤销语义,取消不提交', async ({ page }, testInfo) => {
const calls = await fixture(page); await page.setViewportSize({ width: 1440, height: 1000 }); await page.goto('/users')
for (const username of ['designer', 'office']) await page.getByRole('row').filter({ hasText: username }).locator('label.el-checkbox').click()
await page.getByRole('button', { name: /批量授权/ }).click()
const dialog = page.getByRole('dialog', { name: '批量桌面授权', exact: true })
await expect(dialog).toContainText('目标用户(2 位)')
await expect(dialog.getByRole('button', { name: '提交批量授权' })).toBeDisabled()
await dialog.locator('label.el-checkbox').filter({ hasText: '办公桌面 · Ubuntu' }).click()
await dialog.getByRole('button', { name: '提交批量授权' }).click()
await page.locator('.el-message-box').getByRole('button', { name: '取消', exact: true }).click(); expect(calls).toHaveLength(0)
await dialog.getByRole('button', { name: '提交批量授权' }).click()
await page.locator('.el-message-box').getByRole('button', { name: '确认提交' }).click()
await expect(dialog).toBeHidden()
const row = page.getByRole('row').filter({ hasText: 'designer' })
await expect(row).toContainText('设计桌面'); await expect(row).toContainText('办公桌面')
await page.screenshot({ path: testInfo.outputPath('users-dark.png'), fullPage: true, animations: 'disabled' })
await page.getByRole('button', { name: '切换浅色主题' }).click()
await page.screenshot({ path: testInfo.outputPath('users-light.png'), fullPage: true, animations: 'disabled' })
// Refresh intentionally clears table selection; choose targets again if needed.
if (await page.getByRole('button', { name: /批量授权/ }).isDisabled()) for (const username of ['designer', 'office']) await page.getByRole('row').filter({ hasText: username }).locator('label.el-checkbox').click()
await page.getByRole('button', { name: /批量授权/ }).click()
await dialog.locator('label.el-radio-button').filter({ hasText: '撤销指定' }).click()
await dialog.locator('label.el-checkbox').filter({ hasText: '办公桌面 · Ubuntu' }).click()
await page.setViewportSize({ width: 390, height: 844 })
expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(391)
await page.screenshot({ path: testInfo.outputPath('batch-users-mobile.png'), fullPage: true, animations: 'disabled' })
await dialog.getByRole('button', { name: '提交批量授权' }).click()
await page.locator('.el-message-box').getByRole('button', { name: '确认提交' }).click()
await expect(dialog).toBeHidden(); await expect(row).toContainText('设计桌面'); await expect(row).not.toContainText('办公桌面')
expect(calls.map(c => c.body)).toEqual([{ userIds: ['u1', 'u2'], desktopIds: ['d2'], action: 'grant' }, { userIds: ['u1', 'u2'], desktopIds: ['d2'], action: 'revoke' }])
})
+102
View File
@@ -0,0 +1,102 @@
import { test, expect, type Page } from '@playwright/test'
async function fixture(page: Page, role = 'super_admin') {
const calls: { path: string; body: Record<string, unknown> }[] = []
const now = new Date(), earlier = new Date(now.getTime() - 180000).toISOString()
let acknowledged = false
const rules = [{ id: 'r1', name: '节点 CPU 高水位', metric: 'node_cpu', threshold: 90, holdSeconds: 120, enabled: true }]
await page.addInitScript(() => sessionStorage.setItem('vdi.accessToken', 'operations-fixture'))
await page.route('**/api/v1/**', async route => {
const request = route.request(), path = new URL(request.url()).pathname.replace('/api/v1', '')
const list = (items: unknown[], extra = {}) => route.fulfill({ json: { items, total: items.length, page: 1, pageSize: 20, ...extra } })
if (request.method() !== 'GET') {
const body = request.postData() ? request.postDataJSON() as Record<string, unknown> : {}; calls.push({ path, body })
if (path === '/imports/preview') {
const invalid = String(body.csv).includes('bad'), terminal = body.kind === 'terminals'
await route.fulfill({ json: { kind: body.kind, totalRows: 1, validRows: invalid ? 0 : 1, errorRows: invalid ? 1 : 0, previewToken: invalid ? '' : 'preview-token', rows: [{ line: 2, key: terminal ? 'BULK-001' : 'imported', values: terminal ? { sn: 'BULK-001', name: '批量设备', group: '研发' } : { username: 'imported', displayName: '导入用户' }, errors: invalid ? ['该账号已存在'] : [] }] } }); return
}
if (path === '/imports/commit') { await route.fulfill({ json: { id: 'i1', kind: body.kind, count: 1, replayed: false, items: [{ line: 2, key: 'imported', id: 'new-id' }], activationSecrets: body.kind === 'terminals' ? [{ sn: 'BULK-001', activationSecret: 'activate_test_only' }] : [] } }); return }
if (path.endsWith('/acknowledge')) { acknowledged = true; await route.fulfill({ json: { accepted: true } }); return }
if (path === '/monitor/rules' || path === '/monitor/rules/r1') { Object.assign(rules[0]!, body); await route.fulfill({ json: rules[0] }); return }
}
if (path === '/auth/me') { await route.fulfill({ json: { id: 'admin', username: 'root', displayName: '平台管理员', role } }); return }
if (path === '/settings') { await route.fulfill({ json: { mode: 'production', pveConfigured: true, capabilities: [] } }); return }
if (path === '/overview') { await route.fulfill({ json: { terminalCount: 0, onlineTerminals: 0, desktopCount: 0, runningDesktops: 0, activeSessions: 0, failedTasks: 0, mode: 'production' } }); return }
if (path === '/resources') { await list([{ id: 'node1', name: '计算节点', provider: 'proxmox', status: 'online', cpu: .4, memory: .6, storage: .5 }]); return }
if (path === '/sessions/history') { await list([{ id: 'h1', sessionId: 's1', terminalId: 't1', terminalName: '历史终端', desktopId: 'd1', desktopName: 'Windows 桌面', username: 'imported', displayName: '研发人员', state: 'interrupted', source: 'ticket', reservedAt: earlier, connectedAt: earlier, lastReportedAt: earlier, endedAt: now.toISOString(), endReason: 'heartbeat_timeout' }]); return }
if (path === '/monitor/rules') { await list(rules); return }
if (path === '/monitor/series') { await route.fulfill({ json: { bucketSeconds: 300, retentionDays: 30, items: [{ time: earlier, cpu: 94, memory: 60, storage: null, samples: 1 }, { time: now.toISOString(), cpu: 95, memory: 61, storage: null, samples: 1 }] } }); return }
if (path === '/monitor/alerts') { await list([{ id: 'a1', ruleId: 'r1', ruleName: '节点 CPU 高水位', metric: 'node_cpu', targetId: 'node1', targetName: '计算节点', value: 95, threshold: 90, status: 'firing', startedAt: earlier, openedAt: earlier, resolvedAt: null, resolveReason: '', acknowledgedAt: acknowledged ? now.toISOString() : null, acknowledgedBy: acknowledged ? 'root' : '' }], { firingCount: 1, unacknowledgedCount: acknowledged ? 0 : 1 }); return }
if (['/tasks', '/audit-logs', '/terminals', '/users', '/desktops'].includes(path)) { await list([]); return }
await route.fulfill({ status: 404, json: { code: 'fixture_missing', message: path } })
})
return calls
}
test('CSV 预检有错误不能提交,修改后重新预检并整批导入', async ({ page }) => {
const calls = await fixture(page); await page.goto('/users')
await page.getByRole('button', { name: '导入用户', exact: true }).click()
const dialog = page.getByRole('dialog', { name: '批量新增用户' })
await dialog.getByLabel('CSV 内容').fill('username,displayName\nbad,重复\n')
await dialog.getByRole('button', { name: '预检文件' }).click()
await expect(dialog.getByText('该账号已存在', { exact: true })).toBeVisible()
await expect(dialog.getByRole('button', { name: '提交导入' })).toBeDisabled()
const download = page.waitForEvent('download'); await dialog.getByRole('button', { name: '下载错误行' }).click(); expect((await download).suggestedFilename()).toBe('import-errors.csv')
await dialog.getByLabel('CSV 内容').fill('username,displayName\nimported,导入用户\n')
await dialog.getByRole('button', { name: '预检文件' }).click()
await expect(dialog.getByRole('button', { name: '提交导入' })).toBeEnabled()
await dialog.getByRole('button', { name: '提交导入' }).click()
await page.locator('.el-message-box').getByRole('button', { name: '确认导入' }).click()
await expect(dialog.getByText('导入完成', { exact: true })).toBeVisible()
expect(calls.filter(c => c.path.endsWith('/commit'))).toHaveLength(1)
})
test('批量终端激活信息支持下载,未下载关闭需要确认且密钥不残留', async ({ page }) => {
await fixture(page); await page.goto('/terminals')
await page.getByRole('button', { name: '批量导入', exact: true }).click()
const dialog = page.getByRole('dialog', { name: '批量登记终端' })
await dialog.getByLabel('CSV 内容').fill('sn,name,group\nBULK-001,批量设备,研发\n')
await dialog.getByRole('button', { name: '预检文件' }).click(); await dialog.getByRole('button', { name: '提交导入' }).click()
await page.locator('.el-message-box').getByRole('button', { name: '确认导入' }).click()
await expect(dialog.getByText('导入完成', { exact: true })).toBeVisible()
await dialog.getByRole('button', { name: '关闭', exact: true }).last().click()
await page.locator('.el-message-box').getByRole('button', { name: '返回保存' }).click()
const downloaded = page.waitForEvent('download'); await dialog.getByRole('button', { name: '下载终端激活信息' }).click(); expect((await downloaded).suggestedFilename()).toBe('terminal-activation.csv')
await dialog.getByRole('button', { name: '关闭', exact: true }).last().click(); await expect(dialog).toBeHidden()
await page.getByRole('button', { name: '批量导入', exact: true }).click(); await expect(dialog.getByRole('button', { name: '下载终端激活信息' })).toHaveCount(0)
})
test('会话历史按需查询,展示心跳超时和实际连接时长', async ({ page }, testInfo) => {
await fixture(page); await page.goto('/sessions'); await page.getByRole('tab', { name: '历史记录' }).click()
await expect(page.getByText('历史终端', { exact: true })).toBeVisible(); await expect(page.getByText('心跳超时', { exact: true })).toBeVisible()
await expect(page.getByText('3 分 0 秒')).toBeVisible()
await page.getByLabel('搜索历史会话').fill('历史终端'); await page.getByRole('button', { name: '查询历史' }).click()
await page.screenshot({ path: testInfo.outputPath('session-history.png'), fullPage: true, animations: 'disabled' })
})
test('告警确认保留正在告警状态,规则编辑和深浅窄屏可用', async ({ page }, testInfo) => {
const calls = await fixture(page); await page.setViewportSize({ width: 1440, height: 1050 }); await page.goto('/monitor')
await expect(page.getByRole('img', { name: 'CPU 使用率历史趋势' })).toBeVisible()
await expect(page.getByText('此范围暂无有效采样')).toBeVisible()
await page.getByRole('button', { name: '确认告警' }).click(); await expect(page.getByRole('button', { name: '确认告警' })).toHaveCount(0)
await expect(page.getByRole('cell', { name: '正在告警', exact: true })).toBeVisible()
await page.screenshot({ path: testInfo.outputPath('monitor-dark.png'), fullPage: true, animations: 'disabled' })
await page.getByRole('button', { name: '切换浅色主题' }).click()
await page.screenshot({ path: testInfo.outputPath('monitor-light.png'), fullPage: true, animations: 'disabled' })
await page.getByRole('tab', { name: '告警规则' }).click(); await page.getByRole('button', { name: '编辑规则' }).click()
const dialog = page.getByRole('dialog', { name: '编辑告警规则' })
await dialog.getByLabel('规则名称', { exact: true }).fill('CPU 持续高水位')
await dialog.getByRole('button', { name: '保存规则' }).click(); await expect(dialog).toBeHidden()
expect(calls.find(c => c.path === '/monitor/rules/r1')?.body.name).toBe('CPU 持续高水位')
await page.setViewportSize({ width: 390, height: 844 }); expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(391)
await page.screenshot({ path: testInfo.outputPath('monitor-mobile.png'), fullPage: true, animations: 'disabled' })
})
test('只读人员可查监控及历史,无导入、确认或规则编辑入口', async ({ page }) => {
await fixture(page, 'viewer'); await page.goto('/monitor')
await expect(page.getByText('节点 CPU 高水位', { exact: true })).toBeVisible()
await expect(page.getByRole('button', { name: '确认告警' })).toHaveCount(0)
await page.getByRole('tab', { name: '告警规则' }).click(); await expect(page.getByRole('button', { name: '编辑规则' })).toHaveCount(0)
await expect(page.getByRole('button', { name: '新增规则' })).toHaveCount(0)
await page.goto('/users'); await expect(page.getByRole('button', { name: '导入用户', exact: true })).toHaveCount(0)
})
+99
View File
@@ -0,0 +1,99 @@
import { test, expect, type Page } from '@playwright/test'
async function fixture(page: Page, role = 'super_admin') {
const calls: { path: string; body: Record<string, unknown>; key: string | undefined }[] = []
const templates = [{ id: 'p1', name: '标准办公', version: '1.0', heartbeatInterval: 30, commandPollInterval: 3, desktopRefreshInterval: 15, createdAt: '2026-11-10T03:00:00Z' }]
const states = [
{ id: '', terminalId: 't1', terminalName: '设计工位', group: '设计组', protocol: 1, deploymentId: '', templateName: '', templateVersion: '', configVersion: 1, status: 'unconfigured', errorCode: '', acknowledgedAt: null, online: true, enabled: true, registered: true },
{ id: 'r0', terminalId: 't2', terminalName: '备用终端', group: '设计组', protocol: 1, deploymentId: 'd0', templateName: '标准办公', templateVersion: '1.0', configVersion: 2, status: 'failed', errorCode: 'persist_failed', acknowledgedAt: '2026-11-10T03:10:00Z', online: false, enabled: true, registered: true },
{ id: '', terminalId: 't3', terminalName: '旧客户端', group: '旧设备组', protocol: 0, deploymentId: '', templateName: '', templateVersion: '', configVersion: 1, status: 'unconfigured', errorCode: '', acknowledgedAt: null, online: true, enabled: true, registered: true },
]
const deployments = [{ id: 'd0', templateId: 'p1', templateName: '标准办公', templateVersion: '1.0', actor: 'root', targetCount: 1, createdAt: '2026-11-10T03:00:00Z' }]
await page.addInitScript(() => sessionStorage.setItem('vdi.accessToken', 'policies-fixture'))
await page.route('**/api/v1/**', async route => {
const req = route.request(), path = new URL(req.url()).pathname.replace('/api/v1', '')
const list = (items: unknown[]) => route.fulfill({ json: { items, page: 1, pageSize: 100, total: items.length, requestId: 'policy-fixture' } })
if (req.method() === 'POST') {
const body = req.postDataJSON() as Record<string, unknown>; calls.push({ path, body, key: req.headers()['idempotency-key'] })
if (path === '/terminal-policy-templates') { const t = { ...templates[0]!, ...body, id: 'p2' }; templates.unshift(t); await route.fulfill({ status: 201, json: t }); return }
if (path === '/terminal-policy-deployments') {
const chosen = states.filter(s => body.targetMode === 'group' ? s.group === body.group : (body.terminalIds as string[]).includes(s.terminalId))
const d = { ...deployments[0]!, id: 'd1', targetCount: chosen.length }; deployments.unshift(d)
for (const s of chosen) Object.assign(s, { status: 'pending', deploymentId: 'd1', templateName: '标准办公', templateVersion: '1.0', configVersion: s.configVersion + 1, errorCode: '', acknowledgedAt: null })
await route.fulfill({ status: 202, json: d }); return
}
}
if (path === '/auth/me') { await route.fulfill({ json: { id: 'a', username: 'root', displayName: '管理员', role } }); return }
if (path === '/overview') { await route.fulfill({ json: { terminalCount: 3, onlineTerminals: 2, desktopCount: 0, runningDesktops: 0, activeSessions: 0, failedTasks: 0, mode: 'production' } }); return }
if (path === '/settings') { await route.fulfill({ json: { mode: 'production', pveConfigured: true, capabilities: ['terminal-policies'] } }); return }
if (path === '/terminal-policy-templates') { await list(templates); return }
if (path === '/terminal-policy-states') { await list(states); return }
if (path === '/terminal-policy-deployments') { await list(deployments); return }
if (/\/terminal-policy-deployments\/.*\/results/.test(path)) { await list(states.filter(s => s.deploymentId === path.split('/')[2])); return }
if (['/terminals', '/users', '/resources', '/tasks', '/desktops', '/audit-logs'].includes(path)) { await list([]); return }
await route.fulfill({ status: 404, json: { code: 'fixture_missing', message: path, requestId: 'fixture' } })
})
return calls
}
test('策略版本可保存和复制,深浅主题及手机表单可用', async ({ page }, info) => {
const calls = await fixture(page); await page.setViewportSize({ width: 1440, height: 1000 }); await page.goto('/terminal-policies')
await expect(page.getByRole('heading', { name: '终端策略', exact: true })).toBeVisible()
await page.getByRole('button', { name: '新建策略', exact: true }).click()
const dialog = page.getByRole('dialog', { name: '新建终端策略版本' })
await dialog.getByLabel('策略名称', { exact: true }).fill('研发组')
await dialog.getByLabel('策略版本', { exact: true }).fill('2.0')
await dialog.getByLabel('命令检查间隔', { exact: true }).fill('5')
await dialog.getByRole('button', { name: '保存策略版本' }).click(); await expect(dialog).toBeHidden()
expect(calls[0]?.body).toEqual({ name: '研发组', version: '2.0', heartbeatInterval: 30, commandPollInterval: 5, desktopRefreshInterval: 15 })
await page.screenshot({ path: info.outputPath('policies-dark.png'), fullPage: true, animations: 'disabled' })
await page.getByRole('button', { name: '切换浅色主题' }).click()
await page.screenshot({ path: info.outputPath('policies-light.png'), fullPage: true, animations: 'disabled' })
await page.getByRole('row').filter({ hasText: '研发组' }).getByRole('button', { name: '复制新版本' }).click()
await expect(dialog.getByLabel('策略版本', { exact: true })).toHaveValue('')
await page.setViewportSize({ width: 390, height: 844 })
expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(391)
await page.screenshot({ path: info.outputPath('policy-create-mobile.png'), fullPage: true, animations: 'disabled' })
await dialog.getByRole('button', { name: '取消', exact: true }).click(); expect(calls).toHaveLength(1)
})
test('按分组下发先确认,提交后等待设备回执,不显示已应用', async ({ page }, info) => {
const calls = await fixture(page); await page.goto('/terminal-policies')
await page.getByRole('row').filter({ hasText: '标准办公' }).getByRole('button', { name: '下发', exact: true }).click()
const dialog = page.getByRole('dialog', { name: '下发终端策略', exact: true })
await dialog.getByText('指定分组', { exact: true }).click()
await dialog.locator('.el-select').filter({ has: page.getByLabel('选择策略分组') }).click(); await page.getByRole('option', { name: '设计组', exact: true }).click()
await expect(dialog).toContainText('本次 2 台')
await dialog.getByRole('button', { name: '提交下发' }).click()
const box = page.locator('.el-message-box'); await box.getByRole('button', { name: '取消', exact: true }).click(); expect(calls).toHaveLength(0)
await dialog.getByRole('button', { name: '提交下发' }).click(); await box.getByRole('button', { name: '确认下发', exact: true }).click()
const drawer = page.getByRole('dialog', { name: '策略下发结果', exact: true })
await expect(drawer.getByText('等待设备应用', { exact: true })).toHaveCount(2)
await expect(drawer.getByText('已应用', { exact: true })).toHaveCount(0)
await expect(drawer).toContainText('离线等待')
expect(calls[0]?.body).toEqual({ templateId: 'p1', targetMode: 'group', group: '设计组' }); expect(calls[0]?.key).toBeTruthy()
await page.screenshot({ path: info.outputPath('policy-results-dark.png'), fullPage: true, animations: 'disabled' })
})
test('旧客户端分组禁止下发,失败原因和重试入口可查看', async ({ page }) => {
const calls = await fixture(page); await page.goto('/terminal-policies')
await page.getByRole('tab', { name: '设备结果', exact: true }).click()
await expect(page.getByRole('row').filter({ hasText: '旧客户端' })).toContainText('需更新客户端')
const failed = page.getByRole('row').filter({ hasText: '备用终端' }); await expect(failed).toContainText('设备无法安全保存配置')
await failed.getByRole('button', { name: '重新下发' }).click()
const dialog = page.getByRole('dialog', { name: '下发终端策略', exact: true }); await expect(dialog).toContainText('本次 1 台')
await dialog.getByText('指定分组', { exact: true }).click(); await dialog.locator('.el-select').filter({ has: page.getByLabel('选择策略分组') }).click(); await page.getByRole('option', { name: '旧设备组', exact: true }).click()
await expect(dialog.getByRole('button', { name: '提交下发' })).toBeDisabled(); await expect(dialog).toContainText('旧客户端设备')
await dialog.getByRole('button', { name: '取消', exact: true }).click(); expect(calls).toHaveLength(0)
})
test('只读和运维角色只能查看策略与结果', async ({ page }) => {
for (const role of ['viewer', 'operator']) {
await page.unroute('**/api/v1/**'); const calls = await fixture(page, role); await page.goto('/terminal-policies'); await page.reload()
await expect(page.getByRole('heading', { name: '终端策略', exact: true })).toBeVisible()
await expect(page.getByRole('button', { name: '新建策略', exact: true })).toHaveCount(0)
await expect(page.getByRole('button', { name: '下发', exact: true })).toHaveCount(0)
await page.getByRole('tab', { name: '设备结果', exact: true }).click()
await expect(page.getByRole('button', { name: '重新下发', exact: true })).toHaveCount(0); expect(calls).toHaveLength(0)
}
})
+115
View File
@@ -0,0 +1,115 @@
import { test, expect, type Page } from '@playwright/test'
// All requests are intercepted. No live VM receives snapshot or power actions.
async function fixture(page: Page, options: { running?: boolean; role?: string; deny?: boolean; empty?: boolean; fail?: boolean; retry?: boolean } = {}) {
const requests: { path: string; body: Record<string, unknown>; key?: string }[] = [], now = new Date().toISOString()
const desktop = { id: 'desk-snap', name: '快照验收桌面', vmid: 9000, node: 'test', os: 'Windows', pool: '', poolId: null, provider: 'proxmox', powerState: options.running ? 'running' : 'stopped', serviceState: 'unknown', setupRequired: false, endpoint: '192.0.2.90', app: 'Desktop', width: 1920, height: 1080, fps: 30, updatedAt: now }
const items = options.empty ? [] : [{ name: 'baseline', description: '安装完成', createdAt: 1794268800, parent: '', includesMemory: false, state: '', revision: 'snap-v1' }, { name: 'with_memory', description: '保存内存', createdAt: 1794268801, parent: '', includesMemory: true, state: '', revision: 'memory-v1' }, { name: 'child', description: '后续版本', createdAt: 1794268802, parent: 'with_memory', includesMemory: false, state: '', revision: 'child-v1' }]
await page.addInitScript(() => sessionStorage.setItem('vdi.accessToken', 'snapshot-fixture'))
await page.route('**/api/v1/**', async route => {
const r = route.request(), path = new URL(r.url()).pathname.replace('/api/v1', '')
const list = (values: unknown[]) => route.fulfill({ json: { items: values, total: values.length, page: 1, pageSize: 100, requestId: 'snapshot-ui' } })
if (r.method() !== 'GET') {
requests.push({ path, body: r.postData() ? r.postDataJSON() : {}, key: r.headers()['idempotency-key'] })
if (path.endsWith('/snapshots/check')) { await route.fulfill({ json: { available: true, message: '快照预检通过' } }); return }
if (path.endsWith('/snapshots/actions')) {
if (options.retry && requests.filter(x => x.path === path).length === 1) { await route.fulfill({ status: 503, json: { code: 'unavailable', message: '网络暂时不可用', requestId: 'snapshot-ui' } }); return }
await route.fulfill({ status: 202, json: { id: 'task-snap', kind: 'snapshot', action: 'snapshot-create', targetId: desktop.id, targetName: desktop.name, actor: 'root', status: 'queued', message: '等待执行', providerRef: '', createdAt: now, updatedAt: now } }); return
}
if (path.endsWith('/snapshot-reconcile')) { await route.fulfill({ json: { accepted: true } }); return }
}
if (path === '/auth/me') { await route.fulfill({ json: { id: 'admin-snap', username: 'root', displayName: '验收管理员', role: options.role || 'super_admin' } }); return }
if (path.endsWith('/snapshots')) { await route.fulfill(options.fail ? { status: 502, json: { code: 'snapshots_unavailable', message: '无法读取 PVE 快照', requestId: 'snapshot-ui' } } : { json: { items, digest: 'd1', powerState: desktop.powerState, blockers: options.running ? ['请先正常关闭桌面,再管理快照'] : [], disks: ['scsi0', 'efidisk0'], canCreate: !options.deny, canDelete: !options.deny, canRestore: !options.deny } }); return }
if (path === '/settings') { await route.fulfill({ json: { mode: 'production', computeProvider: 'proxmox', pveConfigured: true, terminalTransport: 'https-poll', capabilities: [] } }); return }
if (path === '/overview') { await route.fulfill({ json: { terminalCount: 0, onlineTerminals: 0, desktopCount: 1, runningDesktops: 0, activeSessions: 0, failedTasks: 0, mode: 'production' } }); return }
if (path === '/desktops') { await list([desktop]); return }
if (path === '/tasks') { await list([{ id: 'snap-timeout', kind: 'snapshot', action: 'snapshot-rollback', targetId: desktop.id, targetName: desktop.name, actor: 'root', status: 'timeout', message: '快照结果未确认,不会自动重发', providerRef: 'UPID:old', createdAt: now, updatedAt: now }]); return }
if (['/terminals', '/users', '/resources', '/audit-logs'].includes(path)) { await list([]); return }
await route.fulfill({ status: 404, json: { code: 'missing_fixture', message: '未定义测试接口', requestId: 'snapshot-ui' } })
})
return requests
}
async function open(page: Page) {
await page.goto('/desktops'); await page.getByRole('button', { name: '查看详情', exact: true }).click(); await page.getByRole('button', { name: '快照与还原', exact: true }).click()
return page.getByRole('dialog', { name: '快照与还原', exact: true })
}
test('创建快照先预检,参数改变失效,取消不写,确认提交', async ({ page }, info) => {
const requests = await fixture(page, { empty: true }), dialog = await open(page)
await expect(dialog.getByText('尚无快照,可在关机后创建恢复点')).toBeVisible()
await dialog.getByRole('textbox', { name: '快照名称', exact: true }).fill('before_update')
await dialog.getByRole('button', { name: '预检快照操作', exact: true }).click()
await expect(dialog.getByRole('button', { name: '提交创建快照', exact: true })).toBeEnabled()
await dialog.getByRole('textbox', { name: '快照描述', exact: true }).fill('升级之前')
await expect(dialog.getByRole('button', { name: '提交创建快照', exact: true })).toBeDisabled()
await dialog.getByRole('button', { name: '预检快照操作', exact: true }).click()
await page.screenshot({ path: info.outputPath('snapshot-create-dark.png'), fullPage: true })
await dialog.getByRole('button', { name: '提交创建快照', exact: true }).click()
await page.getByRole('button', { name: '取消', exact: true }).click()
expect(requests.filter(r => r.path.endsWith('/actions'))).toHaveLength(0)
await dialog.getByRole('button', { name: '提交创建快照', exact: true }).click()
await page.getByRole('button', { name: '提交创建快照任务', exact: true }).click()
await expect(dialog).not.toBeVisible()
const writes = requests.filter(r => r.path.endsWith('/actions'))
expect(writes).toHaveLength(1); expect(writes[0]?.body).toEqual({ action: 'create', name: 'before_update', description: '升级之前', digest: 'd1' }); expect(writes[0]?.key).toBeTruthy()
})
test('还原需输入当前 VMID,保留磁盘范围提示,支持浅色窄屏', async ({ page }, info) => {
const requests = await fixture(page)
await page.addInitScript(() => localStorage.setItem('vdi.theme', 'light'))
await page.setViewportSize({ width: 390, height: 844 }); const dialog = await open(page)
await dialog.locator('.snapshot-item').filter({ hasText: 'baseline' }).getByRole('button', { name: '还原', exact: true }).click()
await expect(dialog.getByText(/包括快照中的数据盘/)).toBeVisible()
await expect(dialog.getByRole('button', { name: '预检快照操作', exact: true })).toBeDisabled()
await dialog.getByRole('textbox', { name: '还原确认 VMID', exact: true }).fill('100')
await expect(dialog.getByRole('button', { name: '预检快照操作', exact: true })).toBeDisabled()
await dialog.getByRole('textbox', { name: '还原确认 VMID', exact: true }).fill('9000')
await dialog.getByRole('button', { name: '预检快照操作', exact: true }).click()
expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(391)
await expect(dialog.locator('.el-dialog__header')).toBeInViewport()
await expect(dialog.getByRole('button', { name: '提交还原快照', exact: true })).toBeInViewport()
await page.screenshot({ path: info.outputPath('snapshot-restore-light-mobile.png'), fullPage: true })
await dialog.getByRole('button', { name: '提交还原快照', exact: true }).click()
await page.getByRole('button', { name: '提交还原快照任务', exact: true }).click()
await expect(dialog).not.toBeVisible()
expect(requests.find(r => r.path.endsWith('/actions'))?.body).toEqual({ action: 'rollback', name: 'baseline', revision: 'snap-v1', digest: 'd1', confirmVmid: 9000 })
})
test('删除叶快照先确认,内存还原和含子快照的删除禁用', async ({ page }) => {
const requests = await fixture(page), dialog = await open(page)
const memory = dialog.locator('.snapshot-item').filter({ hasText: 'with_memory' })
await expect(memory.getByRole('button', { name: '还原', exact: true })).toBeDisabled(); await expect(memory.getByRole('button', { name: '删除', exact: true })).toBeDisabled()
await dialog.locator('.snapshot-item').filter({ hasText: 'baseline' }).getByRole('button', { name: '删除', exact: true }).click()
await dialog.getByRole('button', { name: '预检快照操作', exact: true }).click(); await dialog.getByRole('button', { name: '提交删除快照', exact: true }).click()
await expect(page.getByText(/将永久删除此恢复点/)).toBeVisible(); await page.getByRole('button', { name: '提交删除快照任务', exact: true }).click()
await expect(dialog).not.toBeVisible(); expect(requests.find(r => r.path.endsWith('/actions'))?.body).toEqual({ action: 'delete', name: 'baseline', digest: 'd1', revision: 'snap-v1' })
})
test('运行中或权限不足阻止操作', async ({ page }) => {
const requests = await fixture(page, { running: true }), dialog = await open(page)
await expect(dialog.getByText('请先正常关闭桌面,再管理快照')).toBeVisible()
await expect(dialog.getByRole('button', { name: '预检快照操作', exact: true })).toBeDisabled(); await expect(dialog.getByRole('textbox', { name: '快照名称', exact: true })).toBeDisabled()
await dialog.getByRole('button', { name: '关闭', exact: true }).click(); expect(requests).toHaveLength(0)
})
test('只读角色仅可查看列表', async ({ page }) => {
const requests = await fixture(page, { role: 'viewer' }), dialog = await open(page)
await expect(dialog.getByText(/当前角色可查看快照/)).toBeVisible()
await expect(dialog.getByRole('button', { name: '新建快照' })).toHaveCount(0); await expect(dialog.getByRole('button', { name: '还原', exact: true })).toHaveCount(0); expect(requests).toHaveLength(0)
})
test('PVE 权限不足与读取失败明确显示', async ({ page }) => {
await fixture(page, { deny: true }); const dialog = await open(page)
await expect(dialog.getByText(/PVE Token 缺少当前操作的快照权限/)).toBeVisible()
await expect(dialog.getByRole('button', { name: '新建快照' })).toBeDisabled()
await page.unroute('**/api/v1/**'); await fixture(page, { fail: true }); await dialog.getByRole('button', { name: '重新读取', exact: true }).click()
await expect(dialog.getByText(/无法读取 PVE 快照/)).toBeVisible(); await expect(dialog.getByRole('button', { name: '预检快照操作', exact: true })).toBeDisabled()
})
test('网络失败重试保留相同幂等标识', async ({ page }) => {
const requests = await fixture(page, { retry: true }), dialog = await open(page)
await dialog.getByRole('textbox', { name: '快照名称', exact: true }).fill('retry_point'); await dialog.getByRole('button', { name: '预检快照操作', exact: true }).click()
for (let i = 0; i < 2; i++) {
await dialog.getByRole('button', { name: '提交创建快照', exact: true }).click(); await page.getByRole('button', { name: '提交创建快照任务', exact: true }).click()
if (i === 0) await expect(dialog.getByText(/网络暂时不可用/)).toBeVisible()
}
await expect(dialog).not.toBeVisible(); const writes = requests.filter(r => r.path.endsWith('/actions')); expect(writes).toHaveLength(2); expect(writes[0]?.key).toBeTruthy(); expect(writes[0]?.key).toBe(writes[1]?.key)
})
test('任务页只读核对快照结果', async ({ page }) => {
const requests = await fixture(page); await page.goto('/tasks'); await page.getByRole('button', { name: '快照验收桌面', exact: true }).first().click()
await page.getByRole('button', { name: '核对快照结果', exact: true }).click()
await expect.poll(() => requests.length).toBe(1); expect(requests[0]?.path).toBe('/tasks/snap-timeout/snapshot-reconcile')
})
+98
View File
@@ -0,0 +1,98 @@
import { test, expect, type Page } from '@playwright/test'
async function fixture(page: Page, options: { stale?: boolean; legacy?: boolean; missing?: boolean; fail?: boolean; delayFirst?: boolean } = {}) {
const now = new Date().toISOString(), writes: string[] = [], reads: URL[] = []
let fail = options.fail, firstDelivered = false
await page.addInitScript(() => sessionStorage.setItem('vdi.accessToken', 'metrics-fixture'))
await page.route('**/api/v1/**', async route => {
const request = route.request(), url = new URL(request.url()), path = url.pathname.replace('/api/v1', '')
if (request.method() !== 'GET') { writes.push(path); await route.fulfill({ status: 403, json: { message: '只读页面出现写请求' } }); return }
const list = (items: unknown[]) => route.fulfill({ json: { items, total: items.length, page: 1, pageSize: 100, requestId: 'metrics-fixture' } })
if (path === '/auth/me') { await route.fulfill({ json: { id: 'metrics-viewer', username: 'viewer', displayName: '只读人员', role: 'viewer' } }); return }
if (path === '/settings') { await route.fulfill({ json: { mode: 'production', capabilities: [], pveConfigured: true, computeProvider: 'proxmox' } }); return }
if (path === '/overview') { await route.fulfill({ json: { terminalCount: 1, onlineTerminals: 1, desktopCount: 0, runningDesktops: 0, activeSessions: 0, failedTasks: 0, mode: 'production' } }); return }
if (path === '/terminals') { await list([{ id: 'metrics-a', name: '指标测试盒子', sn: 'METRICS-001', group: '测试', status: options.stale ? 'offline' : 'online', agentVersion: 'qt-platform/0.4.0', lastSeen: now, sessionDesktopId: null, configVersion: 1, fps: null, rtt: null }]); return }
if (path === '/terminals/metrics-a/metrics') {
reads.push(url)
const requestNumber = reads.length
if (options.delayFirst && reads.length === 1) await new Promise(resolve => setTimeout(resolve, 2000))
if (fail) { fail = false; await route.fulfill({ status: 503, json: { code: 'unavailable', message: '指标服务暂不可用', requestId: 'metrics-fixture' } }); return }
const from = url.searchParams.get('from')!, to = url.searchParams.get('to')!, start = Date.parse(from)
await route.fulfill({ json: { latest: { cpuPercent: options.missing ? null : options.delayFirst && requestNumber > 1 ? 25 : 0, memoryPercent: 30, socTemperatureC: 44.37, uptimeSeconds: 90120, sampledAt: now, supported: !options.legacy, fresh: !options.stale && !options.legacy, online: !options.stale }, from, to, bucketSeconds: 60, retentionDays: 30, items: [
{ time: new Date(start + 60000).toISOString(), cpuPercent: 10, memoryPercent: 30, socTemperatureC: 44, uptimeSeconds: 90100, samples: 2 },
{ time: new Date(start + 120000).toISOString(), cpuPercent: null, memoryPercent: null, socTemperatureC: null, uptimeSeconds: null, samples: 0 },
{ time: new Date(start + 180000).toISOString(), cpuPercent: 20, memoryPercent: 30, socTemperatureC: 44.37, uptimeSeconds: 90120, samples: 2 },
] } }); if (requestNumber === 1) firstDelivered = true; return
}
if (['/desktops', '/users', '/tasks', '/audit-logs', '/resources'].includes(path)) { await list([]); return }
await route.fulfill({ status: 404, json: { message: '测试接口未定义' } })
})
return { writes, reads, firstDelivered: () => firstDelivered }
}
async function open(page: Page) {
await page.goto('/terminals')
await page.getByRole('button', { name: '详情', exact: true }).click()
await page.getByRole('button', { name: '查看运行指标', exact: true }).click()
return page.getByRole('region', { name: '终端运行指标' })
}
test('真实指标独立按需加载,零值、SoC 温度、运行时长与缺失断线显示', async ({ page }, info) => {
const requests = await fixture(page)
await page.goto('/terminals'); await page.getByRole('button', { name: '详情', exact: true }).click()
expect(requests.reads).toHaveLength(0)
await page.getByRole('button', { name: '查看运行指标', exact: true }).click()
const panel = page.getByRole('region', { name: '终端运行指标' })
await expect(panel.getByText('0.0 %', { exact: true })).toBeVisible()
await expect(panel.getByText('44.4 °C', { exact: true })).toBeVisible()
await expect(panel.getByText('1 天 1 小时 2 分钟', { exact: true })).toBeVisible()
await expect(panel.getByRole('img', { name: /历史趋势/ })).toHaveCount(3)
const path = await panel.getByRole('img', { name: 'CPU 使用率历史趋势' }).locator('path').getAttribute('d')
expect(path?.match(/M/g)).toHaveLength(2); expect(path).not.toContain('L')
await expect(panel.getByText(/串流 FPS\/RTT 尚未采集/)).toBeVisible()
await page.screenshot({ path: info.outputPath('terminal-metrics-dark.png'), fullPage: true })
expect(requests.writes).toEqual([])
})
test('离线隐藏当前数值并保留历史', async ({ page }) => {
const requests = await fixture(page, { stale: true }); const panel = await open(page)
await expect(panel.getByText(/设备离线或采样已过期/)).toBeVisible()
await expect(panel.locator('.metric-values strong')).toHaveText(['未采集', '未采集', '未采集', '未采集'])
await expect(panel.getByRole('img', { name: /历史趋势/ })).toHaveCount(3); expect(requests.writes).toEqual([])
})
test('旧 Agent 给出升级提示,不冒充零值', async ({ page }) => {
await fixture(page, { legacy: true }); const panel = await open(page)
await expect(panel.getByText('设备尚未上报系统指标,请更新 Agent', { exact: true })).toBeVisible()
await expect(panel.locator('.metric-values strong')).toHaveText(['未采集', '未采集', '未采集', '未采集'])
})
test('单项缺失保持未采集,读取失败可重试', async ({ page }) => {
await fixture(page, { missing: true, fail: true }); const panel = await open(page)
await expect(panel.getByText('指标服务暂不可用', { exact: true })).toBeVisible()
await expect(panel.locator('.metric-values')).toHaveCount(0)
await panel.getByRole('button', { name: '刷新指标' }).click()
await expect(panel.locator('.metric-values strong').first()).toHaveText('未采集')
await expect(panel.getByText('30.0 %', { exact: true })).toBeVisible()
})
test('时间范围切换与窄屏浅色展示', async ({ page }, info) => {
const requests = await fixture(page)
await page.addInitScript(() => localStorage.setItem('vdi.theme', 'light'))
await page.setViewportSize({ width: 390, height: 844 })
const panel = await open(page)
await expect(panel.getByText('44.4 °C', { exact: true })).toBeVisible()
await panel.locator('.el-select').click()
await page.getByRole('option', { name: '最近 7 天' }).click()
await expect.poll(() => requests.reads.length).toBe(2)
const url = requests.reads[1]!
expect(Date.parse(url.searchParams.get('to')!) - Date.parse(url.searchParams.get('from')!)).toBe(7 * 86400000)
await expect(panel.getByText('44.4 °C', { exact: true })).toBeVisible()
expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(391)
await page.screenshot({ path: info.outputPath('terminal-metrics-mobile-light.png'), fullPage: true })
expect(requests.writes).toEqual([])
})
test('切换时间范围时旧响应不能覆盖新响应', async ({ page }) => {
const requests = await fixture(page, { delayFirst: true }); const panel = await open(page)
await expect.poll(() => requests.reads.length).toBe(1)
await panel.locator('.el-select').click(); await page.getByRole('option', { name: '最近 24 小时' }).click()
await expect.poll(() => requests.reads.length).toBe(2)
await expect(panel.getByText('44.4 °C', { exact: true })).toBeVisible()
await expect.poll(requests.firstDelivered).toBe(true)
await expect(panel.getByText('25.0 %', { exact: true })).toBeVisible()
await expect(panel.locator('.el-select')).toHaveText('最近 24 小时')
})