95 lines
3.3 KiB
JavaScript
95 lines
3.3 KiB
JavaScript
|
|
/**
|
||
|
|
* Shared Playwright helpers for Nexus SPA E2E.
|
||
|
|
*/
|
||
|
|
import { expect } from '@playwright/test'
|
||
|
|
|
||
|
|
export const BASE = process.env.NEXUS_E2E_BASE || 'https://api.synaglobal.vip'
|
||
|
|
export const USER = process.env.NEXUS_E2E_USER || 'admin'
|
||
|
|
export const PASS = process.env.NEXUS_E2E_PASSWORD || ''
|
||
|
|
export const APP = `${BASE.replace(/\/$/, '')}/app/`
|
||
|
|
|
||
|
|
const SIDEBAR_LINKS = {
|
||
|
|
'/': '仪表盘',
|
||
|
|
'/servers': '服务器',
|
||
|
|
'/terminal': '终端',
|
||
|
|
'/files': '文件管理',
|
||
|
|
'/push': '推送',
|
||
|
|
'/scripts': '脚本库',
|
||
|
|
'/executions': '执行记录',
|
||
|
|
'/schedules': '调度',
|
||
|
|
'/retries': '重试队列',
|
||
|
|
'/commands': '命令日志',
|
||
|
|
'/alerts': '告警中心',
|
||
|
|
'/audit': '审计日志',
|
||
|
|
}
|
||
|
|
|
||
|
|
const E2E_ACCESS_TOKEN = process.env.NEXUS_E2E_ACCESS_TOKEN || ''
|
||
|
|
|
||
|
|
/** Mock refresh + profile so Pinia bootstraps with a pre-minted JWT (bypass login IP allowlist). */
|
||
|
|
export async function loginWithAccessToken(page, accessToken = E2E_ACCESS_TOKEN) {
|
||
|
|
if (!accessToken) {
|
||
|
|
throw new Error('Set NEXUS_E2E_ACCESS_TOKEN for token-based E2E login')
|
||
|
|
}
|
||
|
|
await page.route('**/api/auth/refresh', async (route) => {
|
||
|
|
await route.fulfill({
|
||
|
|
status: 200,
|
||
|
|
contentType: 'application/json',
|
||
|
|
body: JSON.stringify({ access_token: accessToken }),
|
||
|
|
})
|
||
|
|
})
|
||
|
|
await page.route('**/api/auth/me', async (route) => {
|
||
|
|
await route.fulfill({
|
||
|
|
status: 200,
|
||
|
|
contentType: 'application/json',
|
||
|
|
body: JSON.stringify({
|
||
|
|
id: 1,
|
||
|
|
username: USER,
|
||
|
|
totp_enabled: false,
|
||
|
|
created_at: '2020-01-01T00:00:00Z',
|
||
|
|
}),
|
||
|
|
})
|
||
|
|
})
|
||
|
|
await page.goto(`${APP}#/`, { waitUntil: 'domcontentloaded' })
|
||
|
|
await expect(page).not.toHaveURL(/#\/login/, { timeout: 20000 })
|
||
|
|
await expect(page.getByRole('button', { name: USER })).toBeVisible({ timeout: 15000 })
|
||
|
|
}
|
||
|
|
|
||
|
|
/** Restore session from worker refresh cookie (avoids parallel UI logins / lockout). */
|
||
|
|
export async function login(page) {
|
||
|
|
if (E2E_ACCESS_TOKEN) {
|
||
|
|
return loginWithAccessToken(page, E2E_ACCESS_TOKEN)
|
||
|
|
}
|
||
|
|
await page.goto(`${APP}#/`, { waitUntil: 'domcontentloaded' })
|
||
|
|
await expect(page).not.toHaveURL(/#\/login/, { timeout: 20000 })
|
||
|
|
await expect(page.getByRole('button', { name: 'admin' })).toBeVisible({ timeout: 15000 })
|
||
|
|
}
|
||
|
|
|
||
|
|
/** Navigate via drawer clicks (reliable with Vue keep-alive; hash goto alone is flaky). */
|
||
|
|
export async function nav(page, hash) {
|
||
|
|
const path = hash.startsWith('/') ? hash : `/${hash}`
|
||
|
|
const routePath = path.split('?')[0]
|
||
|
|
const escaped = routePath.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||
|
|
|
||
|
|
if (routePath === '/settings') {
|
||
|
|
await page.locator('.v-navigation-drawer').getByText('设置', { exact: true }).click()
|
||
|
|
} else {
|
||
|
|
const label = SIDEBAR_LINKS[routePath]
|
||
|
|
if (!label) throw new Error(`Unknown route for nav(): ${path}`)
|
||
|
|
await page.getByRole('link', { name: label, exact: true }).click()
|
||
|
|
}
|
||
|
|
|
||
|
|
await expect(page).toHaveURL(new RegExp(`#${escaped}(?:\\?|$)`), { timeout: 20000 })
|
||
|
|
|
||
|
|
if (path.includes('?')) {
|
||
|
|
await page.evaluate((p) => {
|
||
|
|
window.location.hash = `#${p}`
|
||
|
|
}, path)
|
||
|
|
await expect(page).toHaveURL(new RegExp(`#${escaped}\\?`), { timeout: 10000 })
|
||
|
|
}
|
||
|
|
|
||
|
|
await page.waitForLoadState('networkidle').catch(() => {})
|
||
|
|
}
|
||
|
|
|
||
|
|
/** ISO UTC bare string e.g. 2026-06-08T12:00:00Z — should not appear in display columns */
|
||
|
|
export const ISO_UTC_RE = /\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z/
|