/** * 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/