116 lines
12 KiB
TypeScript
116 lines
12 KiB
TypeScript
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')
|
||
})
|