功能:完善通用云管工作台、审计与诊断能力

This commit is contained in:
songyiming committed 2026-11-11 18:46:16 +08:00
1 parent 5ac692724d
commit 9f05aa20ea
225 files changed
+14266 -141

No files matched your search

+89
View File
@@ -0,0 +1,89 @@
import { test, expect, type Page } from '@playwright/test'
async function fixture(page: Page, options: { fail?: boolean; deny?: boolean; slow?: boolean } = {}) {
const revoked: string[] = [], queries: string[] = []
const now = new Date().toISOString(), expires = new Date(Date.now() + 3600000).toISOString()
await page.addInitScript(() => sessionStorage.setItem('vdi.accessToken', 'session-fixture'))
await page.route('**/api/v1/**', async route => {
const request = route.request(), url = new URL(request.url()), path = url.pathname.replace('/api/v1', '')
const list = (items: unknown[], total = items.length) => route.fulfill({ json: { items, total, page: 1, pageSize: 20, requestId: 'sessions-ui' } })
if (path === '/auth/me') { await route.fulfill({ json: { id: 'owner', username: 'root', displayName: '管理员', role: 'super_admin' } }); return }
if (path === '/settings') { await route.fulfill({ json: { mode: 'production', capabilities: ['administrator-sessions'], pveConfigured: true } }); 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([{ id: 'owner', username: 'root', displayName: '管理员', role: 'super_admin', enabled: true }, { id: 'other', username: 'maintainer', displayName: '运维', role: 'operator', enabled: true }]); return }
const match = path.match(/^\/administrators\/(owner|other)\/sessions(?:\/(.+))?$/)
if (match) {
const id = match[1]!, key = match[2]
if (request.method() === 'DELETE') {
revoked.push(`${id}/${key}`)
if (options.deny) { await route.fulfill({ status: 403, json: { code: 'forbidden', message: '当前账号没有该操作权限' } }); return }
await route.fulfill({ status: 204 }); return
}
queries.push(id)
if (options.fail && id === 'other') { await route.fulfill({ status: 500, json: { code: 'database_error', message: '登录列表暂不可用' } }); return }
if (options.slow && id === 'owner') await new Promise(resolve => setTimeout(resolve, 700))
const p = Number(url.searchParams.get('page') || 1), size = 20, total = 21 - revoked.filter(r => r.startsWith(`${id}/`)).length
await list(Array.from({ length: Math.max(0, Math.min(size, total - (p - 1) * size)) }, (_, i) => ({ key: `${id}-${(p - 1) * size + i}`, createdAt: i === 1 ? null : now, expiresAt: expires, clientIp: i === 1 ? '' : `192.0.2.${(p - 1) * size + i + 1}`, userAgent: 'Example browser <script>alert(1)</script>', current: id === 'owner' && p === 1 && i === 0 })), total); return
}
if (['/resources', '/desktops', '/users', '/terminals', '/tasks'].includes(path)) { await list([]); return }
await route.fulfill({ status: 404, json: { message: '测试接口未定义' } })
})
await page.goto('/settings'); await page.getByRole('button', { name: '管理员账号', exact: true }).click()
await expect(page.getByRole('heading', { name: '管理员账号', exact: true })).toBeVisible()
return { revoked, queries }
}
async function open(page: Page, name: string) {
await page.getByRole('row').filter({ hasText: name }).getByRole('button', { name: '在线登录', exact: true }).click()
return page.getByRole('dialog', { name: `在线登录 · ${name}`, exact: true })
}
test('登录来源、旧登录未知信息、分页、当前登录保护及深浅窄屏', async ({ page }, info) => {
const calls = await fixture(page), dialog = await open(page, 'root')
await expect(dialog.getByText('当前登录', { exact: true })).toBeVisible()
await expect(dialog.getByRole('row').filter({ hasText: '当前登录' }).getByRole('button', { name: '撤销登录' })).toBeDisabled()
await expect(dialog.getByText('升级前登录,时间未知', { exact: true })).toBeVisible()
await expect(dialog.getByText('升级前登录,来源未知', { exact: true })).toBeVisible()
await page.screenshot({ path: info.outputPath('admin-sessions-dark.png'), fullPage: true, animations: 'disabled' })
await dialog.locator('.el-pagination .btn-next').click(); await expect(dialog.getByText('192.0.2.21', { exact: true })).toBeVisible()
await dialog.getByRole('button', { name: '关闭', exact: true }).click()
await page.getByRole('button', { name: '切换浅色主题' }).click(); await page.setViewportSize({ width: 390, height: 844 })
await open(page, 'root'); await expect(dialog.getByText('当前登录', { exact: true })).toBeVisible()
expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(391)
await page.screenshot({ path: info.outputPath('admin-sessions-mobile.png'), fullPage: true, animations: 'disabled' })
expect(calls.revoked).toEqual([])
})
test('撤销指定登录需确认,取消不提交,成功刷新', async ({ page }) => {
const calls = await fixture(page), dialog = await open(page, 'maintainer')
await expect(dialog.getByText('共 21 次有效登录', { exact: true })).toBeVisible()
await dialog.getByRole('button', { name: '撤销登录', exact: true }).first().click()
await page.locator('.el-message-box').getByRole('button', { name: '取消', exact: true }).click()
expect(calls.revoked).toEqual([])
await dialog.getByRole('button', { name: '撤销登录', exact: true }).first().click()
await page.locator('.el-message-box').getByRole('button', { name: '确认提交', exact: true }).click()
await expect(dialog.getByText('共 20 次有效登录', { exact: true })).toBeVisible()
expect(calls.revoked).toEqual(['other/other-0'])
})
test('查询和撤销失败清除旧结果并显示错误,不误报成功', async ({ page }) => {
const calls = await fixture(page, { deny: true }), dialog = await open(page, 'maintainer')
await dialog.getByRole('button', { name: '撤销登录', exact: true }).first().click()
await page.locator('.el-message-box').getByRole('button', { name: '确认提交', exact: true }).click()
await expect(dialog.getByText('当前账号没有该操作权限', { exact: true })).toBeVisible()
await expect(dialog.getByRole('button', { name: '撤销登录', exact: true })).toHaveCount(0)
expect(calls.revoked).toHaveLength(1)
await page.unrouteAll(); await fixture(page, { fail: true }); await open(page, 'maintainer')
await expect(page.getByRole('dialog').getByText('登录列表暂不可用', { exact: true })).toBeVisible()
await expect(page.getByRole('dialog').getByRole('button', { name: '撤销登录', exact: true })).toHaveCount(0)
})
test('切换账号后旧请求不能覆盖新的登录列表', async ({ page }) => {
const calls = await fixture(page, { slow: true }), old = await open(page, 'root')
await expect.poll(() => calls.queries.includes('owner')).toBe(true)
await old.getByRole('button', { name: '关闭', exact: true }).click()
const current = await open(page, 'maintainer')
await expect(current.getByText('共 21 次有效登录', { exact: true })).toBeVisible()
await page.waitForTimeout(850)
await expect(current.getByText('当前登录', { exact: true })).toHaveCount(0)
expect(calls.revoked).toEqual([])
})
+92
View File
@@ -0,0 +1,92 @@
import { test, expect, type Page } from '@playwright/test'
async function fixture(page: Page, options: { fail?: boolean; oversized?: boolean; exportFail?: boolean } = {}) {
const queries: URLSearchParams[] = [], exports: URLSearchParams[] = [], writes: string[] = []
const now = new Date().toISOString()
await page.addInitScript(() => sessionStorage.setItem('vdi.accessToken', 'audit-fixture'))
await page.route('**/api/v1/**', async route => {
const request = route.request(), url = new URL(request.url()), path = url.pathname.replace('/api/v1', '')
const list = (items: unknown[], extra = {}) => route.fulfill({ json: { items, total: items.length, page: 1, pageSize: 100, requestId: 'audit-ui', ...extra } })
if (request.method() !== 'GET') { writes.push(path); await route.fulfill({ status: 403, json: { message: '不应发送写请求' } }); return }
if (path === '/auth/me') { await route.fulfill({ json: { id: 'reader', username: 'reader', displayName: '只读人员', role: 'viewer' } }); return }
if (path === '/settings') { await route.fulfill({ json: { mode: 'production', capabilities: ['audit'], pveConfigured: false } }); 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 === '/audit-logs/export') {
exports.push(url.searchParams)
if (options.exportFail) { await route.fulfill({ status: 413, json: { message: '匹配记录超过 10000 条,请缩小时间范围', code: 'export_too_large', requestId: 'audit-ui' } }); return }
await route.fulfill({ contentType: 'text/csv; charset=utf-8', body: '\ufeff时间,操作者\r\n2026-11-11T00:00:00Z,root\r\n' }); return
}
if (path === '/audit-logs') {
queries.push(url.searchParams)
if (options.fail && url.searchParams.get('keyword')) { await route.fulfill({ status: 500, json: { message: '审计查询暂不可用', code: 'database_error', requestId: 'audit-ui' } }); return }
const p = Number(url.searchParams.get('page') || 1), filtered = !!url.searchParams.get('actor'), total = options.oversized ? 10001 : filtered ? 1 : 45
const items = Array.from({ length: Math.min(20, total - (p - 1) * 20) }, (_, i) => ({ id: `audit-${(p - 1) * 20 + i + 1}`, actor: filtered ? 'operator_%' : 'root', action: 'save_alert_rule', target: `node-${(p - 1) * 20 + i + 1}`, result: 'success', requestId: `req-${p}-${i}`, createdAt: now }))
await list(items, { total, page: p, pageSize: 20 }); return
}
if (['/resources', '/desktops', '/users', '/terminals', '/tasks'].includes(path)) { await list([]); return }
await route.fulfill({ status: 404, json: { message: '未定义的测试接口' } })
})
return { queries, exports, writes }
}
test('按需加载审计,真实分页参数和固定截止时间,浅色移动布局', async ({ page }, info) => {
const calls = await fixture(page); await page.goto('/tasks')
await expect(page.getByRole('tab', { name: '执行任务' })).toBeVisible()
expect(calls.queries).toHaveLength(0)
await page.getByRole('tab', { name: '操作审计' }).click()
await expect(page.getByRole('cell', { name: 'node-1', exact: true })).toBeVisible()
await page.locator('.el-pagination .btn-next').click()
await expect(page.getByRole('cell', { name: 'node-21', exact: true })).toBeVisible()
expect(calls.queries[calls.queries.length - 1]?.get('page')).toBe('2')
expect(calls.queries[calls.queries.length - 1]?.get('to')).toBe(calls.queries[0]?.get('to'))
await page.screenshot({ path: info.outputPath('audit-dark.png'), fullPage: true, animations: 'disabled' })
await page.getByRole('button', { name: '切换浅色主题' }).click(); await page.setViewportSize({ width: 390, height: 844 })
await page.screenshot({ path: info.outputPath('audit-mobile-light.png'), fullPage: true, animations: 'disabled' })
expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(391)
expect(calls.writes).toEqual([])
})
test('操作人、动作、结果与时间筛选传到后端,导出使用已查询条件而非未提交的输入', async ({ page }) => {
const calls = await fixture(page); await page.goto('/tasks'); await page.getByRole('tab', { name: '操作审计' }).click()
await expect(page.getByRole('cell', { name: 'node-1', exact: true })).toBeVisible()
await page.getByRole('textbox', { name: '审计操作者', exact: true }).fill('operator_%')
await page.getByRole('textbox', { name: '审计关键词' }).fill('req_trace')
await page.getByRole('combobox', { name: '审计动作' }).click(); await page.getByRole('option', { name: '保存告警规则', exact: true }).click()
await page.getByRole('combobox', { name: '审计结果' }).click(); await page.getByRole('option', { name: '成功', exact: true }).click()
await page.getByRole('combobox', { name: '审计时间范围' }).first().click(); await page.getByRole('button', { name: '最近 7 天', exact: true }).click()
await page.getByRole('button', { name: '查询审计', exact: true }).click()
await expect(page.getByRole('cell', { name: 'operator_%', exact: true })).toBeVisible()
const q = calls.queries[calls.queries.length - 1]!
expect(Object.fromEntries(q)).toMatchObject({ actor: 'operator_%', keyword: 'req_trace', action: 'save_alert_rule', result: 'success', page: '1', pageSize: '20' })
expect(q.get('from')).toBeTruthy(); expect(q.get('to')).toBeTruthy()
await page.getByRole('textbox', { name: '审计关键词' }).fill('还未查询的输入')
const downloaded = page.waitForEvent('download'); await page.getByRole('button', { name: '导出查询结果 CSV' }).click()
expect((await downloaded).suggestedFilename()).toMatch(/操作审计.*\.csv$/)
expect(Object.fromEntries(calls.exports[0]!)).toMatchObject({ actor: 'operator_%', keyword: 'req_trace', action: 'save_alert_rule', result: 'success', from: q.get('from'), to: q.get('to') })
expect(calls.exports[0]?.get('page')).toBeNull()
await page.getByRole('button', { name: '重置', exact: true }).click()
await expect(page.getByRole('cell', { name: 'root', exact: true }).first()).toBeVisible()
expect(calls.queries[calls.queries.length - 1]?.get('actor')).toBe('')
expect(calls.writes).toEqual([])
})
test('查询失败清除旧结果并禁用导出,允许重试', async ({ page }) => {
const calls = await fixture(page, { fail: true }); await page.goto('/tasks'); await page.getByRole('tab', { name: '操作审计' }).click()
await expect(page.getByRole('cell', { name: 'node-1', exact: true })).toBeVisible()
await page.getByRole('textbox', { name: '审计关键词' }).fill('failure'); await page.getByRole('button', { name: '查询审计', exact: true }).click()
await expect(page.getByText(/审计查询暂不可用/)).toBeVisible(); await expect(page.getByRole('cell', { name: 'node-1', exact: true })).toHaveCount(0)
await expect(page.getByRole('button', { name: '导出查询结果 CSV' })).toBeDisabled()
await page.getByRole('button', { name: '重置', exact: true }).click(); await expect(page.getByRole('cell', { name: 'node-1', exact: true })).toBeVisible()
expect(calls.exports).toHaveLength(0)
})
test('导出超量有明确提示,服务端导出错误保留查询结果', async ({ page }) => {
await fixture(page, { oversized: true }); await page.goto('/tasks'); await page.getByRole('tab', { name: '操作审计' }).click()
await expect(page.getByText(/匹配记录超过 10000 条/)).toBeVisible()
await expect(page.getByRole('button', { name: '导出查询结果 CSV' })).toBeDisabled()
await page.unrouteAll(); await fixture(page, { exportFail: true }); await page.reload(); await page.getByRole('tab', { name: '操作审计' }).click()
await expect(page.getByRole('cell', { name: 'node-1', exact: true })).toBeVisible()
await page.getByRole('button', { name: '导出查询结果 CSV' }).click()
await expect(page.getByText(/匹配记录超过 10000 条/)).toBeVisible()
await expect(page.getByRole('cell', { name: 'node-1', exact: true })).toBeVisible()
})
+99
View File
@@ -0,0 +1,99 @@
import { test, expect, type Page } from '@playwright/test'
async function fixture(page: Page, options: { role?: string; queryError?: boolean; exportError?: boolean; slow?: boolean; offline?: boolean; pending?: boolean; collectError?: boolean } = {}) {
const queries: URLSearchParams[] = [], exports: URLSearchParams[] = [], writes: { path: string; key: string | undefined; body: unknown }[] = []
let collected = false, attempts = 0
const now = new Date().toISOString()
const task = () => ({ id: 'collect-1', kind: 'terminal', action: 'collect-logs', targetId: 't1', targetName: '同名盒子', actor: 'operator', status: collected ? 'success' : 'running', message: collected ? '日志已回传' : '等待设备回执', providerRef: '', createdAt: now, updatedAt: now })
await page.addInitScript(() => sessionStorage.setItem('vdi.accessToken', 'diagnostics-fixture'))
await page.route('**/api/v1/**', async route => {
const request = route.request(), url = new URL(request.url()), path = url.pathname.replace('/api/v1', '')
const list = (items: unknown[]) => route.fulfill({ json: { items, total: items.length, page: 1, pageSize: 100 } })
if (request.method() !== 'GET') {
writes.push({ path, key: request.headers()['idempotency-key'], body: request.postDataJSON() })
if (path === '/terminals/t1/commands') {
attempts++
if (options.collectError && attempts === 1) { await route.fulfill({ status: 503, json: { message: '提交结果尚未确认', code: 'unavailable', requestId: 'diag-ui' } }); return }
collected = true; await route.fulfill({ status: 202, json: { ...task(), status: 'queued', message: '采集命令已提交' } }); return
}
await route.abort(); return
}
if (path === '/auth/me') { await route.fulfill({ json: { id: 'operator', username: 'operator', displayName: '运维', role: options.role || 'operator' } }); return }
if (path === '/settings') { await route.fulfill({ json: { mode: 'production', pveConfigured: true, capabilities: [] } }); return }
if (path === '/overview') { await route.fulfill({ json: { activeSessions: 1 } }); return }
if (path === '/desktops') { await list([{ id: 'd1', name: '办公桌面', node: 'pve', vmid: 100, powerState: 'running', serviceState: 'unknown', os: 'Windows' }]); return }
if (path === '/terminals') { await list(['t1', 't2'].map((id, i) => ({ id, name: '同名盒子', sn: `SN-${i + 1}`, status: options.offline ? 'offline' : 'online', enabled: true, lastSeen: now, sessionDesktopId: null }))); return }
if (path === '/resources' || path === '/users') { await list([]); return }
if (path === '/tasks') { await list(collected || options.pending ? [options.pending ? { ...task(), status: 'timeout' } : task()] : []); return }
if (path === '/diagnostics/export') {
exports.push(url.searchParams)
if (options.exportError) { await route.fulfill({ status: 413, json: { code: 'export_too_large', message: '诊断事件超过 2000 条', requestId: 'diag-ui' } }); return }
await route.fulfill({ contentType: 'application/zip', body: 'fixture-download' }); return
}
if (path === '/diagnostics') {
queries.push(url.searchParams)
if (options.queryError && url.searchParams.get('source')) { await route.fulfill({ status: 500, json: { message: '诊断查询暂不可用', requestId: 'diag-ui' } }); return }
const target = url.searchParams.get('targetId') || 't1', kind = url.searchParams.get('targetType') || 'terminal', source = url.searchParams.get('source') || '', p = Number(url.searchParams.get('page') || 1)
if (options.slow && target === 't1') await new Promise(resolve => setTimeout(resolve, 600))
const items = [{ id: `event-${p}`, source: 'logs', time: now, action: 'collect-logs', status: 'success', targetId: target, targetName: target === 't2' ? '第二个同名盒子' : '同名盒子', actor: 'operator', message: target === 't2' ? '第二个设备独立记录' : '终端已回传的日志片段', details: JSON.stringify({ taskId: `task-${p}`, logTail: '<img src=x onerror="window.diagnosticInjected=true">\nnetwork error\n[REDACTED]', bootId: 'test' }) }]
await route.fulfill({ json: { subject: { kind, id: target, name: kind === 'desktop' ? '办公桌面' : target === 't2' ? '第二个同名盒子' : '同名盒子', state: options.offline ? 'offline' : kind === 'desktop' ? 'running' : 'online', node: kind === 'desktop' ? 'pve' : '', vmid: kind === 'desktop' ? 100 : 0, lastSeen: now, agentVersion: 'test' }, from: url.searchParams.get('from') || now, to: url.searchParams.get('to') || now, observedAt: now, items, total: source ? 1 : 25, page: p, pageSize: 20, source, counts: { tasks: 10, sessions: 10, logs: 3, alerts: 1, audits: 1 }, notes: ['按唯一标识关联;无记录不代表设备无故障。', '日志为设备回传片段,当前快照独立于历史时间范围。'] } }); return
}
await route.fulfill({ status: 404, json: { message: '未定义的测试接口' } })
})
return { queries, exports, writes }
}
test('按对象显式查询,关联来源与详情以纯文本展示,支持分页', async ({ page }) => {
const calls = await fixture(page); await page.goto('/diagnostics')
await expect(page.getByText('选择需要排查的设备或桌面,再查询诊断记录')).toBeVisible(); expect(calls.queries).toHaveLength(0)
await page.getByRole('combobox', { name: '诊断对象', exact: true }).click(); await page.getByRole('option', { name: '同名盒子 / SN-1', exact: true }).click(); await page.getByRole('button', { name: '查询诊断', exact: true }).click()
await expect(page.getByRole('heading', { name: '关联事件', exact: true })).toBeVisible(); expect(calls.queries.slice(-1)[0]?.get('targetId')).toBe('t1')
await page.getByText('查看关联标识与详情', { exact: true }).click(); await expect(page.locator('.diagnosis-log')).toContainText('<img src=x'); expect(await page.evaluate(() => (window as Window & { diagnosticInjected?: boolean }).diagnosticInjected)).toBeUndefined()
await expect(page.locator('.diagnosis-event img')).toHaveCount(0)
const cutoff = calls.queries.slice(-1)[0]?.get('to'); await page.locator('.el-pagination .btn-next').click(); await expect.poll(() => calls.queries.slice(-1)[0]?.get('page')).toBe('2'); expect(calls.queries.slice(-1)[0]?.get('to')).toBe(cutoff)
expect(calls.writes).toHaveLength(0)
})
test('导出沿用已查询条件,修改筛选立即清除旧记录并禁用导出', async ({ page }) => {
const calls = await fixture(page); await page.goto('/diagnostics?targetType=terminal&targetId=t1'); await expect(page.getByRole('heading', { name: '关联事件' })).toBeVisible()
const applied = calls.queries.slice(-1)[0]!, download = page.waitForEvent('download'); await page.getByRole('button', { name: '导出诊断包', exact: true }).click(); expect((await download).suggestedFilename()).toContain('.zip')
expect(calls.exports[0]?.get('targetId')).toBe(applied.get('targetId')); expect(calls.exports[0]?.get('to')).toBe(applied.get('to')); expect(calls.exports[0]?.get('page')).toBeNull()
await page.locator('.diagnosis-query > .el-select').last().click(); await page.getByRole('option', { name: '监控告警', exact: true }).click(); await expect(page.getByRole('heading', { name: '关联事件' })).toHaveCount(0); await expect(page.getByRole('button', { name: '导出诊断包' })).toBeDisabled()
await page.getByRole('button', { name: '查询诊断', exact: true }).click(); await expect(page.getByRole('heading', { name: '关联事件' })).toBeVisible(); expect(calls.queries.slice(-1)[0]?.get('source')).toBe('alerts')
})
test('查询失败清除结果,导出超量显示后台错误', async ({ page }) => {
const calls = await fixture(page, { queryError: true, exportError: true }); await page.goto('/diagnostics?targetType=terminal&targetId=t1'); await expect(page.getByRole('heading', { name: '关联事件' })).toBeVisible()
await page.getByRole('button', { name: '导出诊断包' }).click(); await expect(page.getByText(/诊断事件超过 2000 条/)).toBeVisible()
await page.locator('.diagnosis-query > .el-select').last().click(); await page.getByRole('option', { name: '执行任务', exact: true }).click(); await page.getByRole('button', { name: '查询诊断', exact: true }).click(); await expect(page.getByText(/诊断查询暂不可用/)).toBeVisible(); await expect(page.getByRole('heading', { name: '关联事件' })).toHaveCount(0); await expect(page.getByRole('button', { name: '导出诊断包' })).toBeDisabled(); expect(calls.writes).toHaveLength(0)
})
test('切换对象后较晚返回的旧查询不能覆盖新对象', async ({ page }) => {
await fixture(page, { slow: true }); await page.goto('/diagnostics?targetType=terminal&targetId=t1')
await page.getByRole('combobox', { name: '诊断对象', exact: true }).click(); await page.getByRole('option', { name: '同名盒子 / SN-2', exact: true }).click(); await page.getByRole('button', { name: '查询诊断', exact: true }).click()
await expect(page.getByRole('heading', { name: '第二个同名盒子', exact: true })).toBeVisible(); await expect(page.getByText('第二个设备独立记录')).toBeVisible(); await expect(page.getByRole('heading', { name: '同名盒子', exact: true })).toHaveCount(0)
})
test('采集先确认,取消不提交,提交与回执分开,结果失败可复用原幂等标识', async ({ page }) => {
const calls = await fixture(page, { collectError: true }); await page.goto('/diagnostics?targetType=terminal&targetId=t1'); await page.getByRole('button', { name: '收集此设备日志' }).click()
const confirm = page.locator('.el-message-box'); await expect(confirm).toContainText('不会重启设备'); await confirm.getByRole('button', { name: '取消', exact: true }).click(); expect(calls.writes).toHaveLength(0)
await page.getByRole('button', { name: '收集此设备日志' }).click(); await confirm.getByRole('button', { name: '确认提交' }).click(); await expect(page.getByText(/提交结果尚未确认/)).toBeVisible()
await page.getByRole('button', { name: '收集此设备日志' }).click(); await confirm.getByRole('button', { name: '确认提交' }).click(); await expect(page.getByText('本次日志采集 · 同名盒子')).toBeVisible(); await expect(page.getByRole('button', { name: '查看本次采集' })).toBeVisible()
expect(calls.writes).toHaveLength(2); expect(calls.writes[0]?.key).toBeTruthy(); expect(calls.writes[0]?.key).toBe(calls.writes[1]?.key); expect(calls.writes[1]?.body).toEqual({ action: 'collect-logs' })
await page.getByRole('button', { name: '查看本次采集' }).click(); await expect.poll(() => calls.queries.slice(-1)[0]?.get('source')).toBe('logs')
})
for (const options of [{ offline: true }, { pending: true }]) test(`离线或有未知任务时禁止发起新采集 ${JSON.stringify(options)}`, async ({ page }) => {
const calls = await fixture(page, options); await page.goto('/diagnostics?targetType=terminal&targetId=t1'); await expect(page.getByRole('button', { name: '收集此设备日志' })).toBeDisabled(); expect(calls.writes).toHaveLength(0)
})
test('只读角色隐藏高级入口且直接访问被拦截', async ({ page }) => {
const calls = await fixture(page, { role: 'viewer' }); await page.goto('/overview'); await expect(page.getByRole('navigation', { name: '主导航' }).getByRole('link', { name: '日志与诊断' })).toHaveCount(0)
await page.goto('/diagnostics?targetType=terminal&targetId=t1'); await expect(page).toHaveURL(/\/forbidden$/); expect(calls.queries).toHaveLength(0); expect(calls.writes).toHaveLength(0)
})
test('桌面视角与深浅主题、390宽度布局可读', async ({ page }, info) => {
const calls = await fixture(page); await page.goto('/diagnostics?targetType=desktop&targetId=d1'); await expect(page.getByRole('heading', { name: '办公桌面', exact: true })).toBeVisible(); await expect(page.getByRole('button', { name: '收集此设备日志' })).toHaveCount(0)
await page.screenshot({ path: info.outputPath('diagnostics-dark.png'), fullPage: true, animations: 'disabled' }); await page.getByRole('button', { name: '切换浅色主题' }).click(); await page.screenshot({ path: info.outputPath('diagnostics-light.png'), fullPage: true, animations: 'disabled' })
await page.setViewportSize({ width: 390, height: 844 }); expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true); await page.screenshot({ path: info.outputPath('diagnostics-mobile.png'), fullPage: true, animations: 'disabled' }); expect(calls.writes).toHaveLength(0)
})
+131
View File
@@ -0,0 +1,131 @@
import { test, expect, type Page } from '@playwright/test'
async function fixture(page: Page, options: { role?: string; empty?: boolean; fail?: string; id?: string; saved?: string; timeout?: boolean } = {}) {
const writes: { path: string; body: Record<string, unknown> }[] = [], reads: string[] = []
let users = options.empty ? [] : [{ id: 'u1', username: 'zhang', displayName: '张三', enabled: true, terminalId: 't1', desktopIds: ['d1'] }, { id: 'u2', username: 'li', displayName: '李四', enabled: false, terminalId: null, desktopIds: [] as string[] }]
const desktops = options.empty ? [] : ['d1', 'd2'].map((id, i) => ({ id, name: i ? '备用桌面' : '办公桌面', os: 'Windows', node: 'pve', vmid: 100 + i, powerState: i ? 'stopped' : 'running', serviceState: i ? 'unknown' : 'ready', setupRequired: false, updatedAt: new Date().toISOString() }))
const terminals = options.empty ? [] : [{ id: 't1', sn: 'SN-01', name: '前台盒子', enabled: true, status: 'offline', sessionDesktopId: null, lastSeen: new Date().toISOString() }, { id: 't2', sn: 'SN-02', name: '新盒子', enabled: true, status: 'unregistered', sessionDesktopId: null, lastSeen: null }]
const task = { id: 'k1', targetId: 'd2', targetName: '备用桌面', kind: 'desktop', action: 'start', status: options.timeout ? 'timeout' : 'success', message: '等待核对', actor: 'admin', createdAt: new Date().toISOString(), updatedAt: new Date().toISOString() }
await page.addInitScript(({ id, saved }) => { sessionStorage.setItem('vdi.accessToken', 'editions-fixture'); if (saved) localStorage.setItem(`vdi.console-mode:${id}`, saved) }, { id: options.id || 'admin', saved: options.saved })
await page.route('**/api/v1/**', async route => {
const request = route.request(), url = new URL(request.url()), path = url.pathname.replace('/api/v1', ''), method = request.method(), body = request.postData() ? request.postDataJSON() : {}
const list = (items: unknown[], total = items.length) => route.fulfill({ json: { items, total, page: Number(url.searchParams.get('page') || 1), pageSize: 10, requestId: 'edition-ui' } })
if (method !== 'GET') writes.push({ path, body }); else reads.push(path + url.search)
if (options.fail === path && method !== 'GET' || options.fail === 'reads' && path === '/desktops' || options.fail === 'alerts' && path === '/monitor/alerts') { await route.fulfill({ status: 503, json: { code: 'unavailable', message: '当前服务不可用', requestId: 'edition-ui' } }); return }
if (path === '/auth/me') { await route.fulfill({ json: { id: options.id || 'admin', username: 'admin', displayName: '管理员', role: options.role || 'super_admin' } }); return }
if (path === '/settings') { await route.fulfill({ json: { mode: 'production', pveConfigured: true, capabilities: [] } }); return }
if (path === '/overview') { await route.fulfill({ json: { activeSessions: 1, desktopCount: desktops.length, onlineTerminals: 0, terminalCount: terminals.length, runningDesktops: 1, failedTasks: 0 } }); return }
if (path === '/desktops') { await list(desktops); return }
if (path === '/users' && method === 'POST') { const user = { ...body, id: 'new-user', terminalId: null, desktopIds: [], enabled: true }; users.push(user); await route.fulfill({ status: 201, json: user }); return }
if (path === '/users') { await list(users); return }
if (path === '/terminals' && method === 'POST') { await route.fulfill({ status: 201, json: { id: 'new-terminal', activationSecret: 'one-time-test-secret' } }); return }
if (path === '/terminals') { await list(terminals); return }
if (path === '/users/batch-bindings') {
users = users.map(u => body.userIds.includes(u.id) ? { ...u, desktopIds: body.action === 'grant' ? [...new Set([...u.desktopIds, ...body.desktopIds])] : u.desktopIds.filter(id => !body.desktopIds.includes(id)) } : u)
await route.fulfill({ json: { affectedUsers: 1 } }); return
}
if (path.startsWith('/desktops/') && path.endsWith('/actions')) { await route.fulfill({ status: 202, json: task }); return }
if (path === '/tasks') { await list(url.searchParams.get('active') ? options.timeout ? [task] : [] : [task]); return }
if (path === '/audit-logs') { await list([{ id: 'a1', target: '张三', action: 'batch_grant_bindings', result: 'success', actor: 'admin', createdAt: new Date().toISOString() }], 21); return }
if (path === '/monitor/alerts') { await route.fulfill({ json: { items: [], total: 0, firingCount: 0, unacknowledgedCount: 0 } }); return }
if (path === '/resources') { await list([]); return }
await route.fulfill({ status: 404, json: { message: '未定义的测试接口' } })
})
return { writes, reads }
}
test('默认通用版五个入口,切换高级版保留专业深链和账号偏好', async ({ page }) => {
const calls = await fixture(page); await page.goto('/')
await expect(page).toHaveURL(/\/workbench$/); await expect(page.getByRole('heading', { name: '工作台', exact: true })).toBeVisible(); await expect(page.locator('.simple-stats .panel').first()).toContainText('运行中的桌面1 / 2 台桌面')
const nav = page.getByRole('navigation', { name: '主导航' }); await expect(nav.getByRole('link')).toHaveCount(5)
await expect(nav.getByRole('link', { name: 'GPU 资源' })).toHaveCount(0)
await page.getByRole('button', { name: '高级版', exact: true }).click(); await expect(page).toHaveURL(/\/overview$/)
await expect(nav.getByRole('link', { name: 'GPU 资源' })).toBeVisible(); await expect(nav.getByRole('link', { name: '镜像模板' })).toBeVisible()
await page.goto('/'); await expect(page).toHaveURL(/\/overview$/)
await page.goto('/people'); await expect(page.getByRole('button', { name: '通用版', exact: true })).toHaveAttribute('aria-pressed', 'true')
await page.goto('/users'); await expect(page.getByRole('heading', { name: '用户与授权' })).toBeVisible()
expect(calls.writes).toHaveLength(0)
})
test('另一个账号默认通用版,存储不可用时仍可切换', async ({ page }) => {
await fixture(page, { id: 'reader' }); await page.addInitScript(() => { localStorage.setItem('vdi.console-mode:another-account', 'advanced'); Storage.prototype.setItem = () => { throw new Error('storage disabled') } })
await page.goto('/'); await expect(page).toHaveURL(/\/workbench$/)
await page.getByRole('button', { name: '高级版', exact: true }).click(); await expect(page).toHaveURL(/\/overview$/)
})
test('授权先展示固定对象方案,确认才提交,追加和撤销保留其余授权', async ({ page }) => {
const calls = await fixture(page); await page.goto('/people')
const row = page.getByRole('row').filter({ hasText: '张三' }).first()
await row.getByRole('button', { name: '分配桌面', exact: true }).click()
const dialog = page.getByRole('dialog', { name: '分配已有桌面' }); await dialog.locator('.el-checkbox').filter({ hasText: '备用桌面' }).click()
await dialog.getByRole('button', { name: '查看方案' }).click(); await expect(dialog.getByText('确认变更方案')).toBeVisible(); expect(calls.writes).toHaveLength(0)
await dialog.getByRole('button', { name: '确认并保存授权' }).dblclick(); await expect(dialog).not.toBeVisible()
expect(calls.writes).toEqual([{ path: '/users/batch-bindings', body: { userIds: ['u1'], desktopIds: ['d2'], action: 'grant' } }])
await expect(row).toContainText('备用桌面'); await expect(row).toContainText('办公桌面'); await expect(row).toContainText('前台盒子')
await row.getByRole('button', { name: '撤销授权' }).click(); const revoke = page.getByRole('dialog', { name: '撤销指定授权' })
await revoke.locator('.el-checkbox').filter({ hasText: '备用桌面' }).click(); await revoke.getByRole('button', { name: '查看方案' }).click(); await revoke.getByRole('button', { name: '确认并保存授权' }).click()
await expect(revoke).not.toBeVisible(); await expect(row).not.toContainText('备用桌面'); await expect(row).toContainText('办公桌面')
expect(calls.writes[1]?.body).toEqual({ userIds: ['u1'], desktopIds: ['d2'], action: 'revoke' })
})
test('授权失败保留方案,不显示成功或自动重发', async ({ page }) => {
const calls = await fixture(page, { fail: '/users/batch-bindings' }); await page.goto('/people')
await page.getByRole('row').filter({ hasText: '张三' }).first().getByRole('button', { name: '分配桌面' }).click()
const dialog = page.getByRole('dialog', { name: '分配已有桌面' }); await dialog.locator('.el-checkbox').filter({ hasText: '备用桌面' }).click(); await dialog.getByRole('button', { name: '查看方案' }).click(); await dialog.getByRole('button', { name: '确认并保存授权' }).click()
await expect(page.getByText(/当前服务不可用/)).toBeVisible(); await expect(dialog).toBeVisible(); expect(calls.writes).toHaveLength(1)
})
for (const role of ['viewer', 'operator']) test(`${role}切换版本不提升权限`, async ({ page }) => {
const calls = await fixture(page, { role }); await page.goto('/people')
await expect(page.getByRole('button', { name: '添加员工' })).toHaveCount(0); await expect(page.getByRole('button', { name: '分配桌面', exact: true })).toHaveCount(0)
if (role === 'viewer') await expect(page.getByRole('button', { name: '正常关机', exact: true })).toHaveCount(0)
await page.getByRole('button', { name: '高级版', exact: true }).click(); await page.goto('/settings'); await expect(page).toHaveURL(/\/forbidden$/)
expect(calls.writes).toHaveLength(0)
})
test('设备诊断只读,区分离线、等待激活与缺少关联', async ({ page }) => {
const calls = await fixture(page); await page.goto('/help?terminal=t1')
await expect(page.getByText('未收到在线心跳,请检查盒子电源、网络和平台地址。')).toBeVisible(); await expect(page.getByText('使用人:张三')).toBeVisible(); await expect(page.getByText('桌面运行且连接服务就绪', { exact: false })).toBeVisible()
await page.goto('/help?terminal=t2'); await expect(page.getByText('设备尚未激活', { exact: false })).toBeVisible(); await expect(page.getByText('请在员工与桌面中关联此设备。')).toBeVisible()
expect(calls.writes).toHaveLength(0)
})
test('设备登记返回真实接口激活信息,关闭后移除且不持久化', async ({ page }) => {
const calls = await fixture(page); await page.goto('/devices'); await page.getByRole('button', { name: '登记新设备' }).click()
await page.getByLabel('新设备名称').fill('新设备'); await page.getByLabel('新设备 SN').fill('SN-NEW'); await page.getByRole('button', { name: '登记并获取激活信息' }).click()
const dialog = page.getByRole('dialog', { name: '请保存设备激活信息' }); await expect(dialog.getByLabel('设备激活密钥')).toHaveValue('one-time-test-secret')
await dialog.getByRole('button', { name: '已保存,关闭' }).click(); await expect(dialog).not.toBeVisible()
expect(await page.evaluate(() => JSON.stringify({ ...localStorage, ...sessionStorage }))).not.toContain('one-time-test-secret')
expect(calls.writes[0]).toEqual({ path: '/terminals', body: { name: '新设备', sn: 'SN-NEW', group: '' } })
})
test('电源操作取消不提交,结果未知时阻止重复操作', async ({ page }) => {
const calls = await fixture(page, { timeout: true }); await page.goto('/people')
const backup = page.getByRole('row').filter({ hasText: '备用桌面' }).last(); await expect(backup.getByRole('button', { name: '开机', exact: true })).toBeDisabled()
await page.getByRole('button', { name: '重启桌面', exact: true }).click(); const dialog = page.locator('.el-message-box')
await expect(dialog).toContainText('保存文件'); await dialog.getByRole('button', { name: '取消', exact: true }).click(); expect(calls.writes).toHaveLength(0)
await page.getByRole('button', { name: '重启桌面', exact: true }).click(); await dialog.getByRole('button', { name: '确认提交', exact: true }).click()
await expect.poll(() => calls.writes.length).toBe(1); expect(calls.writes[0]).toEqual({ path: '/desktops/d1/actions', body: { action: 'reboot' } })
})
test('通用记录查历史审计和任务,服务端分页', async ({ page }) => {
const calls = await fixture(page); await page.goto('/records'); await expect(page.getByRole('cell', { name: '批量分配桌面' })).toBeVisible()
await page.locator('.el-pagination .btn-next').click(); await expect.poll(() => calls.reads.some(p => p.includes('/audit-logs?') && p.includes('page=2'))).toBe(true)
await page.getByRole('tab', { name: '设备与桌面任务' }).click(); await expect(page.getByRole('cell', { name: '等待核对' })).toBeVisible()
expect(calls.writes).toHaveLength(0)
})
test('空态、告警失败和平台数据失败有明确提示', async ({ page }) => {
await fixture(page, { empty: true, fail: 'alerts' }); await page.goto('/people'); await expect(page.getByText('暂无员工,请先添加员工')).toBeVisible()
await page.goto('/help'); await expect(page.getByText(/告警读取失败,不能判断是否正常/)).toBeVisible(); await expect(page.getByText('当前没有触发中的告警')).toHaveCount(0)
await page.unroute('**/api/v1/**'); await fixture(page, { fail: 'reads' }); await page.goto('/workbench'); await expect(page.getByText('数据刷新失败,当前信息可能已过期')).toBeVisible()
})
test('通用工作台深浅主题与窄屏导航无溢出', async ({ page }, info) => {
await fixture(page); await page.goto('/workbench'); await expect(page.getByRole('heading', { name: '你想完成什么?' })).toBeVisible()
await page.screenshot({ path: info.outputPath('workbench-dark.png'), fullPage: true })
await page.getByRole('button', { name: /切换浅色主题/ }).click(); await page.screenshot({ path: info.outputPath('workbench-light.png'), fullPage: true })
await page.setViewportSize({ width: 390, height: 844 }); await page.getByRole('button', { name: '打开导航', exact: true }).click(); await expect(page.getByRole('navigation', { name: '主导航' }).getByRole('link', { name: '员工与桌面' })).toBeVisible()
await page.getByRole('button', { name: '关闭导航', exact: true }).click(); expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true)
await page.screenshot({ path: info.outputPath('workbench-mobile.png'), fullPage: true })
})
+45
View File
@@ -0,0 +1,45 @@
import { test, expect, type Page } from '@playwright/test'
async function fixture(page: Page, options: { role?: string; fail?: boolean; status?: string } = {}) {
const writes: string[] = []
const task = { id: 'gpu-task', kind: 'gpu-prepare', action: 'prepare-desktop', targetId: 'desk', targetName: '显卡办公桌面', actor: 'device:box', status: options.status || 'timeout', message: '等待核对', providerRef: '{}', createdAt: new Date().toISOString(), updatedAt: new Date().toISOString() }
await page.addInitScript(() => sessionStorage.setItem('vdi.accessToken', 'gpu-fixture'))
await page.route('**/api/v1/**', async route => {
const request = route.request(), url = new URL(request.url()), path = url.pathname.replace('/api/v1', '')
const list = (items: unknown[]) => route.fulfill({ json: { items, total: items.length, page: 1, pageSize: 20 } })
if (request.method() !== 'GET') {
writes.push(path)
if (path !== '/tasks/gpu-task/gpu-reconcile') { await route.abort(); return }
if (options.fail) { await route.fulfill({ status: 409, json: { code: 'unknown_submission', message: '未记录原 PVE 任务编号,需要在 PVE 手工核对,不能自动重发', requestId: 'gpu-ui' } }); return }
task.status = 'failed'; task.message = '管理员已核对原 PVE 任务及实际电源状态,可重新准备桌面'
await route.fulfill({ json: { reconciled: true, vmid: '100' } }); return
}
if (path === '/auth/me') { await route.fulfill({ json: { id: 'admin', username: 'admin', displayName: '管理员', role: options.role || 'operator' } }); return }
if (path === '/settings') { await route.fulfill({ json: { mode: 'production', pveConfigured: true, capabilities: [] } }); return }
if (path === '/overview') { await route.fulfill({ json: {} }); return }
if (path === '/tasks') { await list(url.searchParams.get('active') && task.status !== 'timeout' ? [] : [task]); return }
if (['/desktops', '/users', '/terminals', '/resources'].includes(path)) { await list([]); return }
await route.fulfill({ status: 404, json: { message: '未定义接口' } })
})
return writes
}
test('高级版运维先确认再核对,取消无变更,成功后不再提供核对按钮', async ({ page }) => {
const writes = await fixture(page); await page.goto('/tasks'); await page.getByRole('button', { name: '显卡办公桌面', exact: true }).click()
const drawer = page.getByRole('dialog', { name: '任务详情' }); await drawer.getByRole('button', { name: '核对显卡准备结果', exact: true }).click()
const confirm = page.locator('.el-message-box'); await expect(confirm).toContainText('不重发电源操作'); await expect(confirm).toContainText('原任务标记为失败')
await confirm.getByRole('button', { name: '取消', exact: true }).click(); expect(writes).toHaveLength(0)
await drawer.getByRole('button', { name: '核对显卡准备结果', exact: true }).click(); await confirm.getByRole('button', { name: '确认提交' }).click()
await expect(drawer).not.toBeVisible(); expect(writes).toEqual(['/tasks/gpu-task/gpu-reconcile'])
await page.getByRole('button', { name: '显卡办公桌面', exact: true }).click(); await expect(drawer.getByRole('button', { name: '核对显卡准备结果' })).toHaveCount(0)
})
test('缺少原任务编号显示后台拒绝,保留未知状态且不自动重发', async ({ page }) => {
const writes = await fixture(page, { fail: true }); await page.goto('/tasks'); await page.getByRole('button', { name: '显卡办公桌面', exact: true }).click()
const drawer = page.getByRole('dialog', { name: '任务详情' }); await drawer.getByRole('button', { name: '核对显卡准备结果' }).click(); await page.locator('.el-message-box').getByRole('button', { name: '确认提交' }).click()
await expect(page.getByText(/未记录原 PVE 任务编号/)).toBeVisible(); await expect(drawer).toBeVisible(); expect(writes).toEqual(['/tasks/gpu-task/gpu-reconcile'])
})
for (const options of [{ role: 'viewer' }, { role: 'operator', status: 'success' }]) test(`角色${options.role}与状态${options.status || 'timeout'}限制核对`, async ({ page }) => {
const writes = await fixture(page, options); await page.goto('/tasks'); await page.getByRole('button', { name: '显卡办公桌面', exact: true }).click()
await expect(page.getByRole('dialog', { name: '任务详情' }).getByRole('button', { name: '核对显卡准备结果' })).toHaveCount(0); expect(writes).toHaveLength(0)
})
+98
View File
@@ -0,0 +1,98 @@
import { test, expect, type Page } from '@playwright/test'
async function fixture(page: Page, options: { enabled?: boolean; fail?: boolean; loggedOut?: boolean } = {}) {
const writes: { path: string; body: Record<string, string> }[] = []
let enabled = options.enabled ?? false
const codes = Array.from({ length: 8 }, (_, i) => `AAAAAAAA-BBBBBBBB-CCCCCCCC-${String(i).padStart(8, '0')}`)
if (!options.loggedOut) await page.addInitScript(() => sessionStorage.setItem('vdi.accessToken', 'mfa-fixture'))
await page.route('**/api/v1/**', async route => {
const request = route.request(), path = new URL(request.url()).pathname.replace('/api/v1', ''), body = request.postData() ? request.postDataJSON() : {}
if (request.method() !== 'GET') writes.push({ path, body })
const fail = (code: string, message: string, status = 400) => route.fulfill({ status, json: { code, message, requestId: 'mfa-ui' } })
if (path === '/auth/login') {
if (body.password !== 'Correct-password-2026') { await fail('invalid_credentials', '用户名或密码错误', 401); return }
if (!body.code) { await fail('mfa_required', '请输入身份验证器动态码或一次性恢复码', 401); return }
if (body.code !== '123456' && body.code !== codes[0]) { await fail('mfa_invalid', '验证码错误、已使用或已过期', 401); return }
await route.fulfill({ json: { accessToken: 'logged-in-after-mfa', expiresIn: 3600, user: { id: 'reader', username: 'reader', displayName: '查看员', role: 'viewer' } } }); return
}
if (path === '/auth/me') { await route.fulfill({ json: { id: 'reader', username: 'reader', displayName: '查看员', role: 'viewer' } }); return }
if (path === '/auth/mfa' && request.method() === 'GET') { await route.fulfill({ json: { enabled, recoveryRemaining: enabled ? 8 : 0 } }); return }
if (path === '/auth/mfa/setup') {
if (body.password !== 'Correct-password-2026') { await fail('current_password_invalid', '当前密码不正确'); return }
await route.fulfill({ json: { secret: 'JBSWY3DPEHPK3PXPJBSWY3DPEHPK3PXP', provisioningUri: 'otpauth://totp/VDI%20Cloud:reader?secret=example', expiresAt: new Date(Date.now() + 600000).toISOString() } }); return
}
if (path === '/auth/mfa/confirm') {
if (options.fail || body.code !== '123456') { await fail('mfa_invalid', '验证码错误或已过期'); return }
enabled = true; await route.fulfill({ json: { recoveryCodes: codes } }); return
}
if (path === '/auth/mfa/recovery') { await route.fulfill({ json: { recoveryCodes: codes } }); return }
if (path === '/auth/mfa' && request.method() === 'DELETE') { enabled = false; await route.fulfill({ status: 204 }); return }
if (path === '/settings') { await route.fulfill({ json: { mode: 'production', capabilities: ['admin-mfa'], pveConfigured: true } }); return }
if (path === '/overview') { await route.fulfill({ json: { terminalCount: 0, onlineTerminals: 0, desktopCount: 0, runningDesktops: 0, activeSessions: 0, failedTasks: 0, mode: 'production' } }); return }
if (['/resources', '/desktops', '/users', '/terminals', '/tasks'].includes(path)) { await route.fulfill({ json: { items: [], total: 0, page: 1, pageSize: 100, requestId: 'mfa-ui' } }); return }
await route.fulfill({ status: 404, json: { message: '测试接口未定义' } })
})
return { writes, codes }
}
test('只读人员也可绑定本人验证器,确认后才开启,恢复码下载和关闭后不再显示', async ({ page }, info) => {
const calls = await fixture(page); await page.goto('/overview'); await page.getByRole('button', { name: '双重验证', exact: true }).click()
const dialog = page.getByRole('dialog', { name: '本人双重验证', exact: true })
await expect(dialog.getByText('未开启', { exact: true })).toBeVisible()
await dialog.getByLabel('双重验证当前密码', { exact: true }).fill('Correct-password-2026')
await dialog.getByRole('button', { name: '开始绑定身份验证器' }).click()
await expect(dialog.getByLabel('身份验证器密钥')).toBeVisible()
expect(calls.writes.map(x => x.path)).toEqual(['/auth/mfa/setup'])
await dialog.getByRole('textbox', { name: '绑定动态码' }).fill('123456'); await dialog.getByRole('button', { name: '验证并开启' }).click()
await expect(dialog.getByText('恢复码仅显示这一次,请先保存')).toBeVisible()
await expect(dialog.getByText(calls.codes[0]!, { exact: true })).toBeVisible()
const download = page.waitForEvent('download'); await dialog.getByRole('button', { name: '下载恢复码' }).click(); expect((await download).suggestedFilename()).toBe('vdi-cloud-recovery-codes.txt')
await page.screenshot({ path: info.outputPath('mfa-recovery-dark.png'), fullPage: true, animations: 'disabled' })
await dialog.getByRole('button', { name: '已保存,关闭' }).click(); await page.getByRole('button', { name: '双重验证', exact: true }).click()
await expect(dialog.getByText('已开启', { exact: true })).toBeVisible(); await expect(dialog.getByText(calls.codes[0]!, { exact: true })).toHaveCount(0)
expect(await page.evaluate(() => JSON.stringify({ ...localStorage, ...sessionStorage }))).not.toContain('JBSWY3')
})
test('验证码错误保持未完成绑定,窄屏和浅色布局可用,关闭清除密钥', async ({ page }, info) => {
await fixture(page, { fail: true }); await page.goto('/overview'); await page.getByRole('button', { name: '切换浅色主题' }).click(); await page.setViewportSize({ width: 390, height: 844 })
await page.getByRole('button', { name: '双重验证', exact: true }).click(); const dialog = page.getByRole('dialog', { name: '本人双重验证' })
await dialog.getByLabel('双重验证当前密码', { exact: true }).fill('Correct-password-2026'); await dialog.getByRole('button', { name: '开始绑定身份验证器' }).click()
await dialog.getByRole('textbox', { name: '绑定动态码' }).fill('111111'); await dialog.getByRole('button', { name: '验证并开启' }).click()
await expect(dialog.getByText(/验证码错误或已过期/)).toBeVisible(); await expect(dialog.getByText('恢复码仅显示这一次,请先保存')).toHaveCount(0)
expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(391)
await page.screenshot({ path: info.outputPath('mfa-setup-mobile-light.png'), fullPage: true, animations: 'disabled' })
await dialog.getByRole('button', { name: '关闭', exact: true }).click(); await page.getByRole('button', { name: '双重验证', exact: true }).click(); await expect(dialog.getByText('未开启', { exact: true })).toBeVisible(); await expect(dialog.getByLabel('身份验证器密钥')).toHaveCount(0)
})
test('更新恢复码和关闭需要密码及验证码,取消不提交', async ({ page }) => {
const calls = await fixture(page, { enabled: true }); await page.goto('/overview'); await page.getByRole('button', { name: '双重验证', exact: true }).click()
const dialog = page.getByRole('dialog', { name: '本人双重验证' })
await dialog.getByRole('button', { name: '关闭双重验证', exact: true }).click()
await dialog.getByLabel('双重验证当前密码', { exact: true }).fill('Correct-password-2026'); await dialog.getByRole('textbox', { name: '双重验证确认码' }).fill('123456')
await dialog.getByRole('button', { name: '确认关闭双重验证' }).click(); await page.locator('.el-message-box').getByRole('button', { name: '取消', exact: true }).click(); expect(calls.writes).toHaveLength(0)
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.writes[0]?.body).toEqual({ password: 'Correct-password-2026', code: '123456' })
})
test('重新生成恢复码经过确认后显示新码并支持下载', async ({ page }) => {
const calls = await fixture(page, { enabled: true }); await page.goto('/overview'); await page.getByRole('button', { name: '双重验证', exact: true }).click()
const dialog = page.getByRole('dialog', { name: '本人双重验证' })
await dialog.getByRole('button', { name: '重新生成恢复码', exact: true }).click()
await dialog.getByLabel('双重验证当前密码', { exact: true }).fill('Correct-password-2026'); await dialog.getByRole('textbox', { name: '双重验证确认码' }).fill(calls.codes[0]!)
await dialog.getByRole('button', { name: '生成新恢复码', exact: true }).click(); expect(calls.writes).toHaveLength(0)
await page.locator('.el-message-box').getByRole('button', { name: '确认提交', exact: true }).click()
await expect(dialog.getByText('恢复码仅显示这一次,请先保存')).toBeVisible()
expect(calls.writes[0]?.path).toBe('/auth/mfa/recovery'); expect(calls.writes[0]?.body.code).toBe(calls.codes[0])
const download = page.waitForEvent('download'); await dialog.getByRole('button', { name: '下载恢复码' }).click(); expect((await download).suggestedFilename()).toBe('vdi-cloud-recovery-codes.txt')
})
for (const recovery of [false, true]) test(`登录先验证密码,动态码${recovery ? '使用恢复码' : '错误后重试'}成功前不保存令牌`, async ({ page }) => {
const calls = await fixture(page, { loggedOut: true }); await page.goto('/login')
await page.getByRole('textbox', { name: '用户名', exact: true }).fill('reader'); await page.getByLabel('密码', { exact: true }).fill('incorrect'); await page.getByRole('button', { name: '登录', exact: true }).click()
await expect(page.getByText(/用户名或密码错误/)).toBeVisible(); await expect(page.getByRole('textbox', { name: '登录动态码或恢复码' })).toHaveCount(0)
await page.getByLabel('密码', { exact: true }).fill('Correct-password-2026'); await page.getByRole('button', { name: '登录', exact: true }).click()
const input = page.getByRole('textbox', { name: '登录动态码或恢复码' }); await expect(input).toBeVisible(); expect(await page.evaluate(() => sessionStorage.getItem('vdi.accessToken'))).toBeNull()
await input.fill('111111'); await page.getByRole('button', { name: '登录', exact: true }).click(); await expect(page.getByText(/验证码错误、已使用或已过期/)).toBeVisible(); await expect(input).toHaveValue('')
await input.fill(recovery ? calls.codes[0]! : '123456'); await page.getByRole('button', { name: '登录', exact: true }).click(); await expect(page).toHaveURL(/\/workbench$/)
expect(await page.evaluate(() => sessionStorage.getItem('vdi.accessToken'))).toBe('logged-in-after-mfa')
})
+90
View File
@@ -0,0 +1,90 @@
import { test, expect, type Page } from '@playwright/test'
async function fixture(page: Page, options: { fail?: boolean; pending?: boolean; slow?: boolean } = {}) {
const queries: URLSearchParams[] = [], exports: URLSearchParams[] = [], writes: string[] = [], now = new Date().toISOString()
await page.addInitScript(() => sessionStorage.setItem('vdi.accessToken', 'task-history-fixture'))
const task = (i: number, extra = {}) => ({ id: `task-${i}`, kind: 'desktop', action: 'start', targetId: `desk-${i}`, targetName: `历史桌面 ${i}`, actor: 'root', status: 'success', message: '真实回执已保存', providerRef: '', createdAt: now, updatedAt: now, ...extra })
await page.route('**/api/v1/**', async route => {
const request = route.request(), url = new URL(request.url()), path = url.pathname.replace('/api/v1', '')
const list = (items: unknown[], extra = {}) => route.fulfill({ json: { items, total: items.length, page: 1, pageSize: 100, requestId: 'task-ui', ...extra } })
if (request.method() !== 'GET') { writes.push(path); await route.fulfill({ status: 403, json: { message: '不应发送业务写请求' } }); return }
if (path === '/auth/me') { await route.fulfill({ json: { id: 'reader', username: 'reader', displayName: '任务查看员', role: options.pending ? 'operator' : 'viewer' } }); return }
if (path === '/settings') { await route.fulfill({ json: { mode: 'production', capabilities: ['task-history'], pveConfigured: false } }); return }
if (path === '/overview') { await route.fulfill({ json: { terminalCount: 0, onlineTerminals: 0, desktopCount: options.pending ? 1 : 0, runningDesktops: 0, activeSessions: 0, failedTasks: 0, mode: 'production' } }); return }
if (path === '/tasks/export') { exports.push(url.searchParams); await route.fulfill({ contentType: 'text/csv; charset=utf-8', body: '\ufeff任务标识,状态\r\ntask-1,success\r\n' }); return }
if (path === '/tasks') {
queries.push(url.searchParams)
const p = Number(url.searchParams.get('page') || 1), size = Number(url.searchParams.get('pageSize') || 20), active = url.searchParams.get('active') === 'true'
if (active) {
const total = options.pending ? 101 : 0
await list(Array.from({ length: Math.max(0, Math.min(size, total - (p - 1) * size)) }, (_, i) => task(2000 + (p - 1) * size + i, { status: 'queued', targetId: (p - 1) * size + i === 100 ? 'desk-pending' : `pending-${i}` })), { total, page: p, pageSize: size }); return
}
const keyword = url.searchParams.get('keyword') || ''
if (options.fail && keyword) { await route.fulfill({ status: 500, json: { code: 'database_error', message: '任务查询暂不可用', requestId: 'task-ui' } }); return }
if (options.slow && keyword === 'slow') await new Promise(resolve => setTimeout(resolve, 700))
const filtered = !!url.searchParams.get('actor') || !!keyword, total = options.pending ? 1500 : filtered ? 1 : 45
await list(Array.from({ length: Math.max(0, Math.min(size, total - (p - 1) * size)) }, (_, i) => task((p - 1) * size + i + 1, filtered ? { targetName: keyword || '筛选桌面', actor: url.searchParams.get('actor') || 'root' } : {})), { total, page: p, pageSize: size }); return
}
if (path === '/desktops' && options.pending) { await list([{ id: 'desk-pending', name: '有旧任务的桌面', vmid: 9000, node: 'test', os: 'Ubuntu', pool: '', provider: 'proxmox', powerState: 'stopped', serviceState: 'unknown', setupRequired: false, endpoint: '192.0.2.1', width: 1920, height: 1080, fps: 30, updatedAt: now }]); return }
if (['/resources', '/desktops', '/users', '/terminals', '/audit-logs'].includes(path)) { await list([]); return }
await route.fulfill({ status: 404, json: { message: '未定义的测试接口' } })
})
return { queries, exports, writes }
}
test('任务历史分页、详情和深浅窄屏,历史读取不依赖全局近期任务', async ({ page }, info) => {
const calls = await fixture(page); await page.goto('/tasks')
await expect(page.getByRole('button', { name: '历史桌面 1', exact: true })).toBeVisible()
const initialPages = calls.queries.filter(q => q.get('pageSize') === '20')
const cutoffBeforePaging = initialPages[initialPages.length - 1]?.get('to')
await page.locator('.el-pagination .btn-next').click(); await expect(page.getByRole('button', { name: '历史桌面 21', exact: true })).toBeVisible()
await page.getByRole('button', { name: '历史桌面 21', exact: true }).click()
const drawer = page.getByRole('dialog', { name: '任务详情' })
await expect(drawer.getByText('task-21', { exact: true })).toBeVisible()
await expect(drawer.getByRole('button', { name: /核对/ })).toHaveCount(0)
await drawer.locator('.el-drawer__close-btn').click()
await page.screenshot({ path: info.outputPath('task-history-dark.png'), fullPage: true, animations: 'disabled' })
await page.getByRole('button', { name: '切换浅色主题' }).click(); await page.setViewportSize({ width: 390, height: 844 })
await page.screenshot({ path: info.outputPath('task-history-mobile.png'), fullPage: true, animations: 'disabled' })
expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(391)
const pages = calls.queries.filter(q => q.get('pageSize') === '20')
expect(pages[pages.length - 1]?.get('page')).toBe('2'); expect(pages[pages.length - 1]?.get('to')).toBe(cutoffBeforePaging)
expect(calls.writes).toEqual([])
})
test('任务筛选和导出沿用已应用条件,修改草稿后导出不混用', async ({ page }) => {
const calls = await fixture(page); await page.goto('/tasks'); await expect(page.getByRole('button', { name: '历史桌面 1', exact: true })).toBeVisible()
await page.getByRole('textbox', { name: '任务操作者' }).fill('root_%')
await page.locator('.el-select').filter({ has: page.getByRole('combobox', { name: '任务状态筛选' }) }).click(); await page.getByRole('option', { name: '成功', exact: true }).click()
await page.getByRole('combobox', { name: '任务动作' }).click(); await page.getByRole('option', { name: '开机', exact: true }).click()
await page.getByRole('combobox', { name: '任务类型' }).click(); await page.getByRole('option', { name: '桌面电源', exact: true }).click()
await page.getByRole('combobox', { name: '任务时间范围' }).first().click(); await page.getByRole('button', { name: '最近 7 天', exact: true }).click()
await page.getByRole('button', { name: '查询任务', exact: true }).click(); await expect(page.getByRole('button', { name: '筛选桌面', exact: true })).toBeVisible()
await page.getByRole('textbox', { name: '搜索任务' }).fill('尚未应用')
const wait = page.waitForEvent('download'); await page.getByRole('button', { name: '导出任务 CSV' }).click()
expect((await wait).suggestedFilename()).toMatch(/执行任务.*\.csv$/)
expect(Object.fromEntries(calls.exports[0]!)).toMatchObject({ actor: 'root_%', status: 'success', action: 'start', kind: 'desktop', keyword: '' })
expect(calls.exports[0]?.get('from')).toBeTruthy(); expect(calls.exports[0]?.get('page')).toBeNull()
await page.getByRole('button', { name: '重置', exact: true }).click(); await expect(page.getByRole('button', { name: '历史桌面 1', exact: true })).toBeVisible()
expect(calls.writes).toEqual([])
})
test('查询失败清理旧结果并禁用导出,旧响应不能覆盖新条件', async ({ page }) => {
await fixture(page, { fail: true }); await page.goto('/tasks'); await expect(page.getByRole('button', { name: '历史桌面 1', exact: true })).toBeVisible()
await page.getByRole('textbox', { name: '搜索任务' }).fill('failure'); await page.getByRole('button', { name: '查询任务', exact: true }).click()
await expect(page.getByText(/任务查询暂不可用/)).toBeVisible(); await expect(page.getByRole('button', { name: '导出任务 CSV' })).toBeDisabled()
await expect(page.getByRole('button', { name: '历史桌面 1', exact: true })).toHaveCount(0)
await page.unrouteAll(); await fixture(page, { slow: true }); await page.reload(); await expect(page.getByRole('button', { name: '历史桌面 1', exact: true })).toBeVisible()
await page.getByRole('textbox', { name: '搜索任务' }).fill('slow'); await page.getByRole('textbox', { name: '搜索任务' }).press('Enter')
await page.getByRole('textbox', { name: '搜索任务' }).fill('new'); await page.getByRole('textbox', { name: '搜索任务' }).press('Enter')
await expect(page.getByRole('button', { name: 'new', exact: true })).toBeVisible()
await expect.poll(async () => { await page.waitForTimeout(800); return page.getByRole('button', { name: 'slow', exact: true }).count() }).toBe(0)
})
test('全局只取近期一页并分页拉全旧活动任务,旧任务仍阻止操作桌面', async ({ page }) => {
const calls = await fixture(page, { pending: true }); await page.goto('/desktops')
await expect(page.getByRole('button', { name: '任务执行中', exact: true })).toBeDisabled()
expect(calls.queries.some(q => q.get('active') === 'true' && q.get('page') === '2')).toBe(true)
expect(calls.queries.some(q => q.get('active') !== 'true' && q.get('page') === '2')).toBe(false)
expect(calls.writes).toEqual([])
})
+94
View File
@@ -0,0 +1,94 @@
import { test, expect, type Page } from '@playwright/test'
async function fixture(page: Page, options: { role?: string; stale?: boolean; resolved?: boolean; fail?: boolean } = {}) {
const writes: { path: string; body: Record<string, unknown> }[] = [], now = new Date().toISOString()
let acknowledged = false
await page.addInitScript(() => sessionStorage.setItem('vdi.accessToken', 'terminal-alert-fixture'))
const rules: Record<string, unknown>[] = [{ id: 'temp-rule', name: '终端温度高水位', metric: 'terminal_temperature', threshold: 80, holdSeconds: 120, enabled: true }]
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: 100, requestId: 'alert-ui', ...extra } })
if (request.method() !== 'GET') {
const body = request.postDataJSON(); writes.push({ path, body })
if (path.endsWith('/acknowledge')) { acknowledged = true; await route.fulfill({ json: { accepted: true } }); return }
if (path.startsWith('/monitor/rules')) {
if (options.fail) { await route.fulfill({ status: 400, json: { message: '温度阈值超出范围', code: 'invalid_request', requestId: 'alert-ui' } }); return }
const rule = { ...body, id: path.endsWith('/temp-rule') ? 'temp-rule' : 'created-rule' }
if (path.endsWith('/temp-rule')) rules[0] = rule; else rules.push(rule)
await route.fulfill({ json: rule }); return
}
await route.fulfill({ status: 403, json: { message: '不应出现的写操作' } }); return
}
if (path === '/auth/me') { await route.fulfill({ json: { id: 'alerts-reader', username: 'reader', displayName: '告警人员', role: options.role || 'super_admin' } }); return }
if (path === '/settings') { await route.fulfill({ json: { mode: 'production', capabilities: ['terminal-metric-alerts'], pveConfigured: false } }); 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 === '/monitor/rules') { await list(rules); return }
if (path === '/monitor/alerts') {
await list([{ id: 'temp-alert', ruleId: 'temp-rule', ruleName: '终端温度高水位', metric: 'terminal_temperature', targetId: 'box1', targetName: '测试盒子', value: 86.5, threshold: 80, status: options.resolved ? 'resolved' : 'firing', startedAt: now, openedAt: now, resolvedAt: options.resolved ? now : null, resolveReason: options.resolved ? 'condition_cleared' : '', acknowledgedAt: acknowledged ? now : null, acknowledgedBy: acknowledged ? 'reader' : '', dataFresh: !options.stale }], { firingCount: options.resolved ? 0 : 1, unacknowledgedCount: acknowledged || options.resolved ? 0 : 1 }); return
}
if (['/resources', '/desktops', '/users', '/terminals', '/tasks', '/audit-logs'].includes(path)) { await list([]); return }
await route.fulfill({ status: 404, json: { message: '测试接口未定义', requestId: 'alert-ui' } })
})
return writes
}
test('新增 SoC 温度规则使用摄氏度和独立上限,切换内存使用百分比', async ({ page }, info) => {
const writes = await fixture(page); await page.goto('/monitor')
await expect(page.getByRole('cell', { name: '86.5 / 80 °C', exact: true })).toBeVisible()
await page.getByRole('tab', { name: '告警规则' }).click()
await expect(page.getByRole('cell', { name: '80°C / 120 秒', exact: true })).toBeVisible()
await page.getByRole('button', { name: '新增规则', exact: true }).click()
const dialog = page.getByRole('dialog', { name: '新增告警规则' })
await dialog.locator('.el-select').click(); await page.getByRole('option', { name: '终端 SoC 温度', exact: true }).click()
await expect(dialog.getByText('SoC 温度阈值(°C)', { exact: true })).toBeVisible()
await expect(dialog.getByRole('spinbutton', { name: '告警阈值' })).toHaveAttribute('aria-valuemax', '150')
await expect(dialog.getByText(/重复读取不推进/)).toBeVisible()
await dialog.getByRole('textbox', { name: '规则名称' }).fill('盒子过热')
await page.screenshot({ path: info.outputPath('terminal-alert-create-dark.png'), fullPage: true, animations: 'disabled' })
await dialog.getByRole('button', { name: '保存规则', exact: true }).click()
await expect(dialog).toBeHidden()
expect(writes[0]?.body).toMatchObject({ name: '盒子过热', metric: 'terminal_temperature', threshold: 80, holdSeconds: 120, enabled: true })
await page.getByRole('button', { name: '新增规则', exact: true }).click()
await dialog.locator('.el-select').click(); await page.getByRole('option', { name: '终端内存', exact: true }).click()
await expect(dialog.getByText('使用率阈值(%)', { exact: true })).toBeVisible()
await expect(dialog.getByRole('spinbutton', { name: '告警阈值' })).toHaveAttribute('aria-valuemax', '100')
await dialog.getByRole('button', { name: '取消', exact: true }).click()
expect(writes).toHaveLength(1)
})
test('过期数据保留告警和最近值,确认不表示恢复', async ({ page }, info) => {
const writes = await fixture(page, { role: 'operator', stale: true }); await page.goto('/monitor')
await expect(page.getByText('数据已过期或缺失;显示最近告警值,等待有效采样', { exact: true })).toBeVisible()
await page.getByRole('button', { name: '确认告警' }).click()
await expect(page.getByRole('cell', { name: '正在告警', exact: true })).toBeVisible()
await expect(page.getByRole('button', { name: '确认告警' })).toHaveCount(0)
await page.getByRole('button', { name: '切换浅色主题' }).click()
await page.setViewportSize({ width: 390, height: 844 })
await page.screenshot({ path: info.outputPath('terminal-alert-stale-mobile-light.png'), fullPage: true, animations: 'disabled' })
expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(391)
await page.getByRole('tab', { name: '告警规则' }).click()
await expect(page.getByRole('button', { name: '新增规则' })).toHaveCount(0)
expect(writes).toHaveLength(1)
})
test('只读人员可看恢复事件和摄氏度,无确认或规则写入口', async ({ page }) => {
const writes = await fixture(page, { role: 'viewer', stale: true, resolved: true }); await page.goto('/monitor')
await expect(page.getByRole('cell', { name: '已恢复', exact: true })).toBeVisible()
await expect(page.getByText('指标恢复', { exact: true })).toBeVisible()
await expect(page.getByText(/数据已过期或缺失/)).toHaveCount(0)
await expect(page.getByRole('button', { name: '确认告警' })).toHaveCount(0)
await page.getByRole('tab', { name: '告警规则' }).click()
await expect(page.getByRole('button', { name: '编辑规则' })).toHaveCount(0)
expect(writes).toEqual([])
})
test('保存错误保留表单和数值,取消不改变规则', async ({ page }) => {
const writes = await fixture(page, { fail: true }); await page.goto('/monitor')
await page.getByRole('tab', { name: '告警规则' }).click(); await page.getByRole('button', { name: '编辑规则' }).click()
const dialog = page.getByRole('dialog', { name: '编辑告警规则' })
await dialog.getByRole('button', { name: '保存规则', exact: true }).click()
await expect(page.getByText(/温度阈值超出范围/)).toBeVisible()
await expect(dialog).toBeVisible()
await dialog.getByRole('button', { name: '取消', exact: true }).click(); await expect(dialog).toBeHidden()
expect(writes).toHaveLength(1)
})