mirror of
https://github.com/0xJacky/nginx-ui.git
synced 2026-09-03 07:24:52 +08:00
288 lines
12 KiB
TypeScript
288 lines
12 KiB
TypeScript
import type { WebSocket } from '@playwright/test'
|
|
import { expect, test } from '@playwright/test'
|
|
import {
|
|
expectEmptyTable,
|
|
expectPositiveText,
|
|
expectTableRows,
|
|
gotoRoute,
|
|
tableRows,
|
|
waitForApiResponse,
|
|
} from './helpers'
|
|
|
|
interface AnalyticsFrame {
|
|
cpu?: {
|
|
system?: number
|
|
user?: number
|
|
}
|
|
memory?: {
|
|
pressure?: number
|
|
}
|
|
network?: {
|
|
bytesRecv?: number
|
|
bytesSent?: number
|
|
}
|
|
}
|
|
|
|
interface SiteNavigationFrame {
|
|
type?: string
|
|
data?: Array<{
|
|
name?: string
|
|
}>
|
|
}
|
|
|
|
test('server dashboard renders non-zero live gauges over the analytics WebSocket', async ({ page }) => {
|
|
test.setTimeout(120_000)
|
|
|
|
let analyticsSocket: WebSocket | undefined
|
|
const analyticsFrames: AnalyticsFrame[] = []
|
|
|
|
page.on('websocket', socket => {
|
|
if (new URL(socket.url()).pathname !== '/api/analytic')
|
|
return
|
|
|
|
analyticsSocket = socket
|
|
socket.on('framereceived', event => {
|
|
try {
|
|
const payload = typeof event.payload === 'string' ? event.payload : event.payload.toString()
|
|
analyticsFrames.push(JSON.parse(payload) as AnalyticsFrame)
|
|
}
|
|
catch {
|
|
// A malformed frame will be surfaced by the missing metric assertions below.
|
|
}
|
|
})
|
|
})
|
|
|
|
const initResponsePromise = waitForApiResponse(page, '/api/analytic/init', 'GET')
|
|
await gotoRoute(page, '/dashboard/server')
|
|
|
|
const initResponse = await initResponsePromise
|
|
expect(initResponse.ok()).toBe(true)
|
|
const initial = await initResponse.json()
|
|
expect(Number(initial.memory?.pressure)).toBeGreaterThan(0)
|
|
expect(Number(initial.network?.init?.bytesRecv)).toBeGreaterThan(0)
|
|
expect(Number(initial.network?.init?.bytesSent)).toBeGreaterThan(0)
|
|
|
|
await expect.poll(() => Boolean(analyticsSocket)).toBe(true)
|
|
|
|
await page.evaluate(async () => {
|
|
await Promise.all(Array.from({ length: 40 }, (_, index) =>
|
|
fetch(`/healthz?e2e-network-sample=${index}`, { cache: 'no-store' }),
|
|
))
|
|
})
|
|
|
|
await expect.poll(() => analyticsFrames.some(frame =>
|
|
Number(frame.cpu?.user ?? 0) + Number(frame.cpu?.system ?? 0) > 0
|
|
&& Number(frame.memory?.pressure ?? 0) > 0
|
|
&& Number(frame.network?.bytesRecv ?? 0) > 0
|
|
&& Number(frame.network?.bytesSent ?? 0) > 0,
|
|
), { timeout: 60_000 }).toBe(true)
|
|
|
|
const memoryCard = page.locator('.ant-card').filter({
|
|
has: page.getByText('Memory and Storage', { exact: true }),
|
|
}).first()
|
|
await expect(memoryCard).toBeVisible()
|
|
await expectPositiveText(memoryCard.locator('.apexcharts-datalabel-value').first())
|
|
|
|
const cpuCard = page.locator('.ant-card').filter({
|
|
has: page.getByText('CPU Status', { exact: true }),
|
|
}).first()
|
|
await expectPositiveText(cpuCard.locator('.ant-statistic-content-value').first(), 60_000)
|
|
|
|
const networkCard = page.locator('.ant-card').filter({
|
|
has: page.getByText('Network', { exact: true }),
|
|
}).first()
|
|
const networkValues = networkCard.locator('.ant-statistic-content-value')
|
|
await expect(networkValues).toHaveCount(2)
|
|
await expectPositiveText(networkValues.nth(0), 60_000)
|
|
await expectPositiveText(networkValues.nth(1), 60_000)
|
|
})
|
|
|
|
test('nginx log list, raw view, structured view, and geographic dashboards render fabricated traffic', async ({ page }) => {
|
|
test.setTimeout(240_000)
|
|
|
|
await gotoRoute(page, '/nginx_log/list')
|
|
await expectTableRows(page, 1)
|
|
|
|
await gotoRoute(page, '/nginx_log/access')
|
|
const structuredView = page.getByRole('radio', { name: 'Structured', exact: true })
|
|
const rawView = page.getByRole('radio', { name: 'Raw', exact: true })
|
|
const dashboardView = page.getByRole('radio', { name: 'Dashboard', exact: true })
|
|
await expect(structuredView).toBeChecked()
|
|
|
|
const rawResponsePromise = waitForApiResponse(page, '/api/nginx_log/page', 'POST')
|
|
await page.getByRole('main').getByText('Raw', { exact: true }).click()
|
|
await expect(rawView).toBeChecked()
|
|
const rawResponse = await rawResponsePromise
|
|
expect(rawResponse.ok()).toBe(true)
|
|
const rawBody = await rawResponse.json()
|
|
expect(rawBody.content?.split('\n').filter(Boolean).length).toBeGreaterThan(0)
|
|
await expect.poll(() => page.locator('.nginx-log-line').count()).toBeGreaterThan(0)
|
|
|
|
const searchResponsePromise = waitForApiResponse(page, '/api/nginx_log/search', 'POST', 180_000)
|
|
await page.getByRole('main').getByText('Structured', { exact: true }).click()
|
|
await expect(structuredView).toBeChecked()
|
|
const searchResponse = await searchResponsePromise
|
|
expect(searchResponse.ok()).toBe(true)
|
|
const searchBody = await searchResponse.json()
|
|
expect(searchBody.entries?.length).toBeGreaterThan(0)
|
|
await expectTableRows(page.locator('.log-table-container'), 1)
|
|
|
|
const dashboardResponsePromise = waitForApiResponse(page, '/api/nginx_log/dashboard', 'POST', 180_000)
|
|
const worldResponsePromise = waitForApiResponse(page, '/api/nginx_log/geo/world', 'POST', 180_000)
|
|
const chinaResponsePromise = waitForApiResponse(page, '/api/nginx_log/geo/china', 'POST', 180_000)
|
|
await page.getByRole('main').getByText('Dashboard', { exact: true }).click()
|
|
await expect(dashboardView).toBeChecked()
|
|
|
|
const [dashboardResponse, worldResponse, chinaResponse] = await Promise.all([
|
|
dashboardResponsePromise,
|
|
worldResponsePromise,
|
|
chinaResponsePromise,
|
|
])
|
|
expect(dashboardResponse.ok()).toBe(true)
|
|
expect(worldResponse.ok()).toBe(true)
|
|
expect(chinaResponse.ok()).toBe(true)
|
|
|
|
const dashboardBody = await dashboardResponse.json()
|
|
const worldBody = await worldResponse.json()
|
|
const chinaBody = await chinaResponse.json()
|
|
expect(Number(dashboardBody.summary?.total_pv)).toBeGreaterThan(0)
|
|
expect(worldBody.data?.length).toBeGreaterThan(0)
|
|
expect(chinaBody.data?.length).toBeGreaterThan(0)
|
|
|
|
await expectPositiveText(page.locator('.ant-statistic').filter({
|
|
has: page.getByText('Total PV', { exact: true }),
|
|
}).locator('.ant-statistic-content-value'))
|
|
|
|
const worldMap = page.locator('.world-map-container')
|
|
await expect(worldMap).toBeVisible()
|
|
await expect(worldMap.locator('canvas')).toBeVisible()
|
|
await expectTableRows(worldMap, 1)
|
|
|
|
const chinaMap = page.locator('.china-map-container')
|
|
await expect(chinaMap).toBeAttached()
|
|
await expect.poll(() => tableRows(chinaMap).count()).toBeGreaterThan(0)
|
|
})
|
|
|
|
test('sites list is populated and navigation cards include healthy and failing sites', async ({ page }) => {
|
|
test.setTimeout(120_000)
|
|
|
|
let navigationSocket: WebSocket | undefined
|
|
let navigationSocketError: string | undefined
|
|
let initialNavigationFrame: SiteNavigationFrame | undefined
|
|
|
|
page.on('websocket', socket => {
|
|
if (new URL(socket.url()).pathname !== '/api/site_navigation_ws')
|
|
return
|
|
|
|
navigationSocket = socket
|
|
socket.on('socketerror', error => {
|
|
navigationSocketError = error
|
|
})
|
|
socket.on('framereceived', event => {
|
|
try {
|
|
const payload = typeof event.payload === 'string' ? event.payload : event.payload.toString()
|
|
const frame = JSON.parse(payload) as SiteNavigationFrame
|
|
if (frame.type === 'initial')
|
|
initialNavigationFrame = frame
|
|
}
|
|
catch {
|
|
// A malformed initial frame will be surfaced by the assertions below.
|
|
}
|
|
})
|
|
})
|
|
|
|
await gotoRoute(page, '/sites')
|
|
const siteRows = await expectTableRows(page, 2)
|
|
await expect(siteRows.filter({ hasText: 'ojbk.me' }).first()).toBeVisible()
|
|
await expect(siteRows.filter({ hasText: 'Prime Sponsor' }).first()).toBeVisible()
|
|
|
|
const navigationResponsePromise = waitForApiResponse(page, '/api/site_navigation', 'GET')
|
|
await gotoRoute(page, '/dashboard/sites')
|
|
const navigationResponse = await navigationResponsePromise
|
|
expect(navigationResponse.ok()).toBe(true)
|
|
const navigationBody = await navigationResponse.json()
|
|
expect(navigationBody.data?.length).toBeGreaterThanOrEqual(2)
|
|
|
|
await expect.poll(() => Boolean(navigationSocket)).toBe(true)
|
|
expect(new URL(navigationSocket?.url() ?? 'http://invalid').searchParams.get('token')).toBeTruthy()
|
|
await expect.poll(() => initialNavigationFrame?.data?.length ?? 0).toBeGreaterThanOrEqual(2)
|
|
expect(initialNavigationFrame?.type).toBe('initial')
|
|
expect(navigationSocketError).toBeUndefined()
|
|
expect(navigationSocket?.isClosed()).toBe(false)
|
|
|
|
const healthy = page.locator('.site-card').filter({ hasText: 'ojbk.me' }).first()
|
|
const failing = page.locator('.site-card').filter({ hasText: /Prime Sponsor|langgood\.com/ }).first()
|
|
const failingSiteData = navigationBody.data.find((site: { name?: string, title?: string }) =>
|
|
site.name === 'langgood.com' || site.title === 'Prime Sponsor',
|
|
)
|
|
expect(failingSiteData?.error_type).toBe('status_code')
|
|
await expect(healthy).toBeVisible()
|
|
await expect(failing).toBeVisible()
|
|
await expect(healthy.locator('[data-testid="site-health-check-status"]')).toHaveClass(/status-online/, { timeout: 60_000 })
|
|
await expect(healthy).toContainText('200')
|
|
await expect(failing.locator('[data-testid="site-health-check-status"]')).toHaveClass(/status-error/, { timeout: 60_000 })
|
|
await expect(failing.locator('[data-testid="site-health-check-error"]')).toContainText('Unexpected status code: 503')
|
|
await expect(failing).toContainText('503')
|
|
})
|
|
|
|
test('upstream targets render and include the deliberately offline socket', async ({ page }) => {
|
|
const socketsResponsePromise = waitForApiResponse(page, '/api/upstream/sockets', 'GET')
|
|
await gotoRoute(page, '/upstream')
|
|
|
|
const socketsResponse = await socketsResponsePromise
|
|
expect(socketsResponse.ok()).toBe(true)
|
|
const socketsBody = await socketsResponse.json()
|
|
expect(socketsBody.data?.length).toBeGreaterThan(0)
|
|
|
|
const rows = await expectTableRows(page, 1)
|
|
const offline = rows.filter({ hasText: '127.0.0.1:9005' }).first()
|
|
await expect(offline).toBeVisible()
|
|
await expect(offline).toContainText('Offline', { timeout: 60_000 })
|
|
})
|
|
|
|
test('DNS credentials, domains, and fabricated records render their seeded counts', async ({ page }) => {
|
|
await gotoRoute(page, '/dns/credentials')
|
|
await expect.poll(() => tableRows(page).count()).toBe(3)
|
|
await expect(page.getByText('Cloudflare (demo)', { exact: true })).toBeVisible()
|
|
await expect(page.getByText('Aliyun DNS (demo)', { exact: true })).toBeVisible()
|
|
await expect(page.getByText('Tencent Cloud DNS (demo)', { exact: true })).toBeVisible()
|
|
|
|
await gotoRoute(page, '/dns/domains')
|
|
const domainRows = tableRows(page)
|
|
await expect.poll(() => domainRows.count()).toBe(4)
|
|
|
|
const recordsResponsePromise = page.waitForResponse(response =>
|
|
/\/api\/dns\/domains\/\d+\/records$/.test(new URL(response.url()).pathname)
|
|
&& response.request().method() === 'GET',
|
|
)
|
|
await domainRows.first().getByRole('button', { name: 'Manage Records', exact: true }).click()
|
|
|
|
const recordsResponse = await recordsResponsePromise
|
|
expect(recordsResponse.ok()).toBe(true)
|
|
const recordsBody = await recordsResponse.json()
|
|
expect(recordsBody.data).toHaveLength(12)
|
|
await expect(page).toHaveURL(/\/dns\/domains\/\d+\/records$/)
|
|
await expect.poll(() => tableRows(page.locator('.dns-record-table')).count()).toBeGreaterThan(0)
|
|
})
|
|
|
|
test('config and node screens render their real bundled fixtures', async ({ page }) => {
|
|
await gotoRoute(page, '/config')
|
|
const configRows = await expectTableRows(page, 1)
|
|
await expect(configRows.filter({ hasText: 'conf.d' }).first()).toBeVisible()
|
|
|
|
await gotoRoute(page, '/nodes')
|
|
const nodeRows = await expectTableRows(page, 2)
|
|
await expect(nodeRows.filter({ hasText: 'demo-node-2' }).first()).toBeVisible()
|
|
await expect(nodeRows.filter({ hasText: 'demo-node-3' }).first()).toBeVisible()
|
|
})
|
|
|
|
test('certificate and namespace screens render clean empty states', async ({ page }) => {
|
|
await gotoRoute(page, '/certificates')
|
|
await expect(page.getByText('Certificates', { exact: true }).first()).toBeVisible()
|
|
await expectEmptyTable(page)
|
|
|
|
await gotoRoute(page, '/namespaces')
|
|
await expect(page.getByText('Namespaces', { exact: true }).first()).toBeVisible()
|
|
await expectEmptyTable(page)
|
|
})
|