vdi-cloud/console/e2e/batch-operations.spec.ts

99 lines
12 KiB
TypeScript

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