vdi-cloud/console/e2e/diagnostics.spec.ts

100 lines
12 KiB
TypeScript

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)
})