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

116 lines
12 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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