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