#!/usr/bin/env bun
import { readFileSync } from 'node:fs'
import { chromium } from 'playwright'
const baseUrl = process.env.PLANET_FRONTEND_SMOKE_URL || 'http://127.0.0.1:4173'
const smokeProgressMode = process.env.PLANET_FRONTEND_SMOKE_PROGRESS || 'phase'
function smokeProgress(label, mode = 'phase') {
if (smokeProgressMode === '0' || smokeProgressMode === 'off') return
if (mode === 'verbose' && smokeProgressMode !== 'verbose') return
console.error(`frontend smoke: ${label}`)
}
const publicRoutes = [
{ path: '/login', text: '登录 Planet 控制台' },
{ path: '/register', text: '注册账户' },
{ path: '/verify-email', text: '验证邮箱' },
{ path: '/forgot-password', text: '找回密码' },
{ path: '/docs', text: '技术文档' },
{ path: '/docs/manual', text: '智能星球使用手册' },
{ path: '/docs/quickstart', text: '快速开始' },
]
const desktopViewport = { width: 1440, height: 900 }
const mobileViewport = { width: 390, height: 844 }
const zoomLevels = [1.25, 1.5]
const interactionChecks = [
'root redirects earth',
'unknown route login fallback',
'global search smtp',
'ai settings shortcut',
'section tab ai playground',
'section tab collection credentials',
'section tab settings smtp',
'connection test input ai provider',
'connection test input websearch disabled',
'logs raw tab',
'users add dialog',
'data distribution toggle',
'earth news source test',
'earth news source draft cancel',
'earth news group create',
'authenticated unknown route fallback',
]
const authInteractionChecks = [
'register verify login',
'forgot password reset',
'standalone verify email',
]
function readRepoText(relativePath) {
return readFileSync(new URL(`../../${relativePath}`, import.meta.url), 'utf8')
}
function extractBalanced(text, start, openChar, closeChar) {
let depth = 0
let quote = null
let escape = false
for (let index = start; index < text.length; index += 1) {
const char = text[index]
if (quote) {
if (escape) {
escape = false
} else if (char === '\\') {
escape = true
} else if (char === quote) {
quote = null
}
continue
}
if (char === '\'' || char === '"' || char === '`') {
quote = char
continue
}
if (char === openChar) {
depth += 1
} else if (char === closeChar) {
depth -= 1
if (depth === 0) return text.slice(start, index + 1)
}
}
throw new Error(`could not find balanced ${openChar}${closeChar} block`)
}
function findBalancedAfter(text, marker, openChar, closeChar) {
const markerIndex = text.indexOf(marker)
if (markerIndex === -1) throw new Error(`missing marker: ${marker}`)
const start = text.indexOf(openChar, markerIndex)
if (start === -1) throw new Error(`missing ${openChar} after marker: ${marker}`)
return extractBalanced(text, start, openChar, closeChar)
}
function findArrayInitializerAfter(text, marker) {
const markerIndex = text.indexOf(marker)
if (markerIndex === -1) throw new Error(`missing marker: ${marker}`)
const equalsIndex = text.indexOf('=', markerIndex)
if (equalsIndex === -1) throw new Error(`missing = after marker: ${marker}`)
const start = text.indexOf('[', equalsIndex)
if (start === -1) throw new Error(`missing [ initializer after marker: ${marker}`)
return extractBalanced(text, start, '[', ']')
}
function nestingDepth(text, stop, openChar, closeChar) {
let depth = 0
let quote = null
let escape = false
for (const char of text.slice(0, stop)) {
if (quote) {
if (escape) {
escape = false
} else if (char === '\\') {
escape = true
} else if (char === quote) {
quote = null
}
continue
}
if (char === '\'' || char === '"' || char === '`') {
quote = char
continue
}
if (char === openChar) depth += 1
else if (char === closeChar) depth -= 1
}
return depth
}
function topLevelConfigBlocks(configsBlock) {
const blocks = new Map()
let index = 1
while (index < configsBlock.length - 1) {
const match = /\b([A-Za-z][A-Za-z0-9_]*)\s*:\s*\{/.exec(configsBlock.slice(index))
if (!match) break
const name = match[1]
const start = index + match.index + match[0].length - 1
if (nestingDepth(configsBlock, start, '{', '}') !== 1) {
index = start + 1
continue
}
const block = extractBalanced(configsBlock, start, '{', '}')
blocks.set(name, block)
index = start + block.length
}
return blocks
}
function directSectionEntries(configBlock) {
const sectionsMatch = /\bsections\s*:\s*\[/.exec(configBlock)
if (!sectionsMatch) return []
const sectionsBlock = extractBalanced(configBlock, sectionsMatch.index + sectionsMatch[0].length - 1, '[', ']')
const entries = []
let index = 1
while (index < sectionsBlock.length - 1) {
const match = /\{\s*key\s*:\s*'([^']+)'/.exec(sectionsBlock.slice(index))
if (!match) break
const start = index + match.index
if (
nestingDepth(sectionsBlock, start, '[', ']') === 1
&& nestingDepth(sectionsBlock, start, '{', '}') === 0
) {
const block = extractBalanced(sectionsBlock, start, '{', '}')
const label = /\blabel\s*:\s*'([^']+)'/.exec(block)?.[1] || match[1]
entries.push({ key: match[1], label })
index = start + block.length
} else {
index = start + 1
}
}
return entries
}
function loadAdminManifestNavigationEntries() {
const manifestText = readRepoText('frontend/src/admin/routes/manifest.tsx')
const groupsBlock = findArrayInitializerAfter(manifestText, 'adminRouteGroups')
const routesBlock = findArrayInitializerAfter(manifestText, 'adminRoutes')
const groupLabels = new Map()
const groupPattern = /\{\s*key:\s*'([^']+)',\s*label:\s*'([^']+)'/g
for (const match of groupsBlock.matchAll(groupPattern)) {
groupLabels.set(match[1], match[2])
}
const entries = []
const routePattern = /\{\s*path:\s*'([^']+)',\s*label:\s*'([^']+)',\s*group:\s*'([^']+)'/g
for (const match of routesBlock.matchAll(routePattern)) {
const groupLabel = groupLabels.get(match[3])
if (!groupLabel) {
throw new Error(`frontend smoke found admin manifest route ${match[1]} with unknown group ${match[3]}`)
}
entries.push({
path: match[1],
label: match[2],
group: match[3],
groupLabel,
})
}
if (!entries.length) {
throw new Error('frontend smoke could not parse admin route manifest navigation entries')
}
return entries
}
function loadAdminSurface() {
const plainText = readRepoText('frontend/src/admin/pages/PlainResourcePages.tsx')
const adminText = readRepoText('frontend/src/admin/AdminRoutes.tsx')
const configs = topLevelConfigBlocks(findBalancedAfter(plainText, 'const configs =', '{', '}'))
const componentToConfig = new Map()
const componentPattern = /export function (\w+)\(\) \{\s*return \s*\}/g
for (const match of plainText.matchAll(componentPattern)) {
componentToConfig.set(match[1], match[2])
}
const staticChecks = new Map([
['Dashboard', { text: '仪表盘' }],
['Users', { text: '用户管理' }],
['DataList', { text: '采集数据' }],
])
const protectedRoutes = []
const authenticatedChecks = []
const routePattern = /]*|([A-Za-z0-9_]+)\s*\/)>\}/g
for (const match of adminText.matchAll(routePattern)) {
const path = match[1]
const redirectTarget = match[3]
const component = match[4]
protectedRoutes.push(path)
if (redirectTarget === '/alerts/system') {
authenticatedChecks.push({ path, text: '系统告警' })
continue
}
if (!component) continue
const staticCheck = staticChecks.get(component)
if (staticCheck) {
authenticatedChecks.push({ path, text: staticCheck.text })
continue
}
const configKey = componentToConfig.get(component)
if (!configKey) continue
const configBlock = configs.get(configKey)
if (!configBlock) {
throw new Error(`frontend smoke found unknown admin config ${configKey} for ${path}`)
}
const title = /\btitle\s*:\s*'([^']+)'/.exec(configBlock)?.[1] || path
authenticatedChecks.push({ path, text: title })
for (const section of directSectionEntries(configBlock)) {
authenticatedChecks.push({
path: `${path}?section=${section.key}`,
text: section.label,
})
}
}
authenticatedChecks.push({ path: '/playground', text: 'Playground' })
return { protectedRoutes, authenticatedChecks }
}
const adminSurface = loadAdminSurface()
const adminRoutes = adminSurface.protectedRoutes
const authenticatedAdminChecks = adminSurface.authenticatedChecks
const adminMenuNavigationChecks = loadAdminManifestNavigationEntries().map((entry) => {
if (entry.path === '/earth') return { ...entry, text: null, kind: 'earth' }
if (entry.path === '/docs') return { ...entry, text: '技术文档', kind: 'text' }
const routeCheck = authenticatedAdminChecks.find((check) => check.path === entry.path)
return { ...entry, text: routeCheck?.text || entry.label, kind: 'text' }
})
function slugFromDocsFilename(filename) {
return filename === 'README.md' ? 'overview' : filename.replace(/\.md$/, '')
}
function loadDocsAccessByFilename() {
const text = readRepoText('backend/app/services/docs_gatekeeper.py')
const accessByFilename = new Map()
const pattern = /DocsMetadata\(\s*(?:(?DOCS_README_FILENAME)|"(?[^"]+\.md)")\s*,\s*(?:(?:DEFAULT_DOCS_SLUG)|"[^"]+")\s*,\s*"(?[^"]+)"/g
for (const match of text.matchAll(pattern)) {
const filename = match.groups.readme ? 'README.md' : match.groups.filename
accessByFilename.set(filename, match.groups.access)
}
return accessByFilename
}
function loadDocsCatalogItems() {
const text = readRepoText('frontend/src/pages/Docs/docs-content.ts')
const accessByFilename = loadDocsAccessByFilename()
const entries = []
const pattern = /(?:\[(?DOCS_README_FILENAME)\]|'(?[^']+\.md)'):\s*\{\s*zh:\s*\{\s*title:\s*'(?[^']+)',\s*group:\s*'(?[^']+)',\s*order:\s*(?\d+)\s*\},\s*en:\s*\{\s*title:\s*'(?[^']+)',\s*group:\s*'(?[^']+)',\s*order:\s*(?\d+)\s*\}/gs
for (const match of text.matchAll(pattern)) {
const filename = match.groups.readme ? 'README.md' : match.groups.filename
const slug = slugFromDocsFilename(filename)
const access = accessByFilename.get(filename) || 'public'
entries.push({
slug,
filename,
lang: 'zh',
title: match.groups.zhTitle,
group: match.groups.zhGroup,
order: Number(match.groups.zhOrder),
access,
})
entries.push({
slug,
filename,
lang: 'en',
title: match.groups.enTitle,
group: match.groups.enGroup,
order: Number(match.groups.enOrder),
access,
})
}
if (!entries.length) {
throw new Error('frontend smoke could not parse Docs metadata from docs-content.ts')
}
return entries.sort((a, b) => a.order - b.order || a.title.localeCompare(b.title))
}
function markdownForDocsItem(item) {
if (item.filename === 'manual.md' && item.lang === 'zh') {
return '# 智能星球使用手册\n\n## 登录控制台\n\n从 `/login` 进入控制台。\n\n[快速开始](/docs/technical/zh/quickstart.md)'
}
if (item.filename === 'manual.md' && item.lang === 'en') {
return '# Intelligent Planet Manual\n\n## Console Login\n\nUse `/login` to enter the console.\n\n[Quickstart](/docs/technical/en/quickstart.md)'
}
if (item.filename === 'quickstart.md' && item.lang === 'zh') {
return '# 快速开始\n\n## 控制台入口\n\n阅读使用手册了解控制台流程。'
}
if (item.filename === 'quickstart.md' && item.lang === 'en') {
return '# Quickstart\n\n## Console Entry\n\nRead the manual for the console workflow.'
}
return item.lang === 'zh'
? `# ${item.title}\n\n## Harness Smoke\n\n这是一份用于验证 Docs 路由和渲染状态的模拟内容。`
: `# ${item.title}\n\n## Harness Smoke\n\nThis mocked document verifies Docs routing and rendering state.`
}
const docsCatalogItems = loadDocsCatalogItems()
const docsContentByPath = new Map(docsCatalogItems.map((item) => [
`${item.lang}/${item.slug}`,
{
...item,
markdown: markdownForDocsItem(item),
},
]))
const docsRouteChecks = docsCatalogItems.filter((item) => item.lang === 'zh')
const docsInteractionChecks = [
'docs detail /docs/manual',
'docs language switch english',
'docs theme toggle dark',
'docs search quickstart',
...docsRouteChecks.map((item) => `docs catalog detail /docs/${item.slug}`),
]
function urlFor(path) {
return new URL(path, baseUrl).toString()
}
function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms))
}
async function expectVisibleText(page, text, route) {
const locator = page.getByText(text, { exact: false }).first()
const deadline = Date.now() + 10000
while (Date.now() < deadline) {
const matches = page.getByText(text, { exact: false })
const count = await matches.count().catch(() => 0)
for (let index = 0; index < Math.min(count, 25); index += 1) {
if (await matches.nth(index).isVisible().catch(() => false)) {
return
}
}
await sleep(100)
}
const bodyText = await page.locator('body').innerText({ timeout: 2000 }).catch(() => '')
throw new Error(`${route}: expected visible text ${JSON.stringify(text)}; body was ${JSON.stringify(bodyText.slice(0, 500))}; ${await locator.count().catch(() => 0)} match(es) were found but none were visible`)
}
async function clickFirstVisible(locator, label) {
const item = await firstVisible(locator, label)
await item.click()
}
async function firstVisible(locator, label, timeoutMs = 10000) {
const item = await firstVisibleOrNull(locator, timeoutMs)
if (item) return item
throw new Error(`${label}: no visible target found`)
}
async function firstVisibleOrNull(locator, timeoutMs = 10000) {
const deadline = Date.now() + timeoutMs
while (Date.now() < deadline) {
const count = await locator.count().catch(() => 0)
for (let index = 0; index < Math.min(count, 25); index += 1) {
const item = locator.nth(index)
if (await item.isVisible().catch(() => false)) {
return item
}
}
await sleep(100)
}
return null
}
async function expectFirstVisibleDisabled(locator, label) {
const item = await firstVisible(locator, label)
if (!(await item.isDisabled().catch(() => false))) {
throw new Error(`${label}: expected visible target to be disabled`)
}
}
async function adminNavLink(page, route, scopeSelector, label) {
const scope = page.locator(scopeSelector)
const linkLocator = scope.locator(`.admin__nav a[href="${route.path}"]`)
const visibleLink = await firstVisibleOrNull(linkLocator, 1500)
if (visibleLink) {
return visibleLink
}
await clickFirstVisible(scope.getByRole('button', { name: route.groupLabel }), `${label} group`)
const openedLink = await firstVisibleOrNull(linkLocator, 2000)
if (openedLink) {
return openedLink
}
await clickFirstVisible(scope.getByRole('button', { name: route.groupLabel }), `${label} group retry`)
return firstVisible(linkLocator, `${label} link`, 2000)
}
async function desktopNavLink(page, route) {
return adminNavLink(page, route, '.admin__sider', `desktop nav ${route.path}`)
}
async function mobileNavLink(page, route) {
return adminNavLink(page, route, '.admin__mobile-nav-panel', `mobile nav ${route.path}`)
}
async function expectUrl(page, route, predicate) {
const deadline = Date.now() + 10000
while (Date.now() < deadline) {
const current = new URL(page.url())
if (predicate(current)) {
return
}
await sleep(100)
}
throw new Error(`${route}: URL did not reach expected state; current=${page.url()}`)
}
async function checkNoFrameworkOverlay(page, route) {
const bodyText = await page.locator('body').innerText({ timeout: 5000 }).catch(() => '')
const overlayNeedles = [
'Internal server error',
'Failed to resolve import',
'Uncaught ReferenceError',
'Uncaught TypeError',
'vite/client',
]
for (const needle of overlayNeedles) {
if (bodyText.includes(needle)) {
throw new Error(`${route}: possible framework/runtime overlay detected: ${needle}`)
}
}
if (!bodyText.trim() && !(await page.locator('iframe').count())) {
throw new Error(`${route}: blank page`)
}
}
async function checkNoGlobalHorizontalOverflow(page, route) {
const layout = await page.evaluate(() => {
const documentElement = document.documentElement
const body = document.body
const scrollWidth = Math.max(documentElement.scrollWidth, body.scrollWidth)
const clientWidth = documentElement.clientWidth || window.innerWidth
return {
scrollWidth,
clientWidth,
overflow: scrollWidth - clientWidth,
}
})
if (layout.overflow > 24) {
throw new Error(
`${route}: global horizontal overflow ${layout.overflow}px ` +
`(scrollWidth=${layout.scrollWidth}, clientWidth=${layout.clientWidth})`,
)
}
}
const now = '2026-06-26T00:00:00Z'
const smokeAuthUser = {
id: 1,
username: 'smoke-auth',
email: 'smoke-auth@example.invalid',
role: 'super_admin',
gatekeeper_groups: ['docs_admin', 'docs_developer', 'docs_user'],
}
const datasource = {
id: 1,
source_id: 'open_bgp',
name: 'open_bgp',
display_name: 'Open BGP',
module: 'BGP',
source: 'open_bgp',
source_type: 'builtin',
status: 'success',
last_status: 'success',
last_run_at: now,
collected_records: 12,
is_active: true,
requires_credentials: false,
credential_provider: '',
auth_type: '',
auth_config: {},
auth_configured: {},
endpoint: 'https://example.invalid/bgp',
url: 'https://example.invalid/bgp',
timeout_seconds: 30,
retry_attempts: 2,
}
const integrationSettings = {
integrations: {
ai_provider: {
enabled: true,
provider: 'minimax',
default_provider: 'minimax',
provider_api: 'openai-chat',
base_url: 'https://api.example.invalid/v1',
model: 'MiniMax-M2.7',
api_key: '********',
service_url: 'http://127.0.0.1:11434',
service_token: '********',
max_tokens: 1200,
anthropic_version: '2023-06-01',
timeout_seconds: 60,
retry_attempts: 2,
providers: {
minimax: {
provider: 'minimax',
label: 'MiniMax',
provider_api: 'openai-chat',
base_url: 'https://api.example.invalid/v1',
model: 'MiniMax-M2.7',
api_key: '********',
enabled: true,
},
},
},
web_search: {
enabled: true,
provider: 'tavily',
default_provider: 'tavily',
base_url: 'https://api.tavily.com',
api_key: '********',
max_results: 5,
timeout_seconds: 30,
providers: {
tavily: {
provider: 'tavily',
label: 'Tavily',
enabled: true,
base_url: 'https://api.tavily.com',
api_key: '********',
},
},
},
ocr: {
enabled: false,
provider: 'paddleocr',
base_url: 'http://127.0.0.1:8866',
api_key: '',
model: 'default',
languages: ['zh', 'en'],
timeout_seconds: 30,
max_file_size_mb: 20,
output_format: 'markdown',
},
},
}
function jsonResponse(payload, status = 200) {
return {
status,
contentType: 'application/json',
body: JSON.stringify(payload),
}
}
function tokenResponse() {
return jsonResponse({
access_token: 'frontend-smoke-token',
token_type: 'bearer',
user: smokeAuthUser,
})
}
function apiPayloadFor(requestUrl, method) {
const url = new URL(requestUrl)
const path = url.pathname.replace(/^\/api\/v1/, '') || '/'
if (method === 'POST' && path === '/auth/login') {
return jsonResponse({ detail: '登录失败,请检查账号或密码。' }, 401)
}
if (method === 'POST' && path === '/auth/register') {
return jsonResponse({ message: 'created' }, 201)
}
if (method === 'POST' && path === '/auth/verify-email') {
return tokenResponse()
}
if (method === 'POST' && path === '/auth/resend-code') {
return jsonResponse({ message: 'resent' })
}
if (method === 'POST' && path === '/auth/forgot-password') {
return jsonResponse({ message: 'sent' })
}
if (method === 'POST' && path === '/auth/reset-password') {
return jsonResponse({ message: 'reset' })
}
if (path === '/docs/catalog') {
return jsonResponse({
authenticated: false,
items: docsCatalogItems,
})
}
if (path.startsWith('/docs/')) {
const content = docsContentByPath.get(path.replace('/docs/', ''))
if (content) {
return jsonResponse(content)
}
}
if (path === '/dashboard/stats') {
return jsonResponse({
total_datasources: 3,
active_datasources: 2,
tasks_today: 4,
success_rate: 98,
last_updated: now,
alerts: { critical: 0, warning: 1, info: 2 },
})
}
if (path === '/users') {
return jsonResponse({
data: [
{
id: 1,
username: 'smoke-admin',
email: 'smoke@example.invalid',
role: 'super_admin',
is_active: true,
email_verified: true,
gatekeeper_groups: ['docs_admin'],
},
],
})
}
if (path === '/collected') return jsonResponse({ data: [], total: 0 })
if (path === '/collected/summary') return jsonResponse({ total: 0, source_totals: [], type_totals: [], country_totals: [] })
if (path === '/collected/sources') return jsonResponse({ sources: [{ source: 'open_bgp', source_name: 'Open BGP' }] })
if (path === '/collected/types') return jsonResponse({ data_types: ['bgp_event'] })
if (path === '/collected/countries') return jsonResponse({ countries: ['CN'] })
if (path === '/datasources') return jsonResponse([datasource])
if (path === '/datasources/configs') return jsonResponse([datasource])
if (path === '/datasources/configs/all') return jsonResponse([datasource])
if (path === '/datasources/mappings') return jsonResponse({ mappings: [{ id: 1, name: 'Default Mapping', target_schema: 'collected_data', validation_status: 'valid' }] })
if (path === '/datasources/target-schemas') return jsonResponse({ schemas: [{ id: 'collected_data', name: 'collected_data', status: 'active' }] })
if (path === '/datasources/snapshots') return jsonResponse({ data: [{ id: 1, source: 'open_bgp', status: 'completed', record_count: 12, is_current: true, completed_at: now }] })
if (path === '/realtime-sources') return jsonResponse([{ id: 'aisstream_vessels', name: 'AISStream 实时船舶', connected: false, total_stored: 0, last_24h: 0, last_1h: 0 }])
if (path === '/tasks') return jsonResponse({ data: [] })
if (path === '/bgp/overview/summary') return jsonResponse({ status: 'ok', collectors: 1, incidents: 0, updated_at: now })
if (path === '/bgp/collectors') return jsonResponse([{ id: 'ris-live', name: 'RIS Live', status: 'ok', updated_at: now }])
if (path === '/bgp/incidents') return jsonResponse([{ id: 1, title: 'Smoke Incident', severity: 'low', status: 'open', created_at: now }])
if (path === '/bgp/anomalies') return jsonResponse([{ id: 1, title: 'Smoke Anomaly', severity: 'low', status: 'open', created_at: now }])
if (path === '/bgp/events') return jsonResponse([{ id: 1, title: 'Smoke Event', severity: 'info', status: 'observed', created_at: now }])
if (path === '/ai/bgp/briefs') return jsonResponse([{ id: 1, title: 'Smoke BGP Brief', status: 'ready', created_at: now }])
if (path === '/alerts') return jsonResponse([{ id: 1, title: 'Smoke Alert', severity: 'warning', status: 'open', created_at: now }])
if (path === '/alerts/stats') return jsonResponse({ total: 1, critical: 0, warning: 1, info: 0, updated_at: now })
if (path === '/settings/integrations') return jsonResponse(integrationSettings)
if (method === 'POST' && path === '/settings/integrations/ai-provider/connect') {
return jsonResponse({ ok: true, message: 'AI Provider smoke connection ok' })
}
if (method === 'POST' && path === '/settings/integrations/web-search/connect') {
return jsonResponse({ ok: true, message: 'Web Search smoke connection ok' })
}
if (path === '/ai/provider/status') return jsonResponse({ provider: 'minimax', status: 'ok', model: 'MiniMax-M2.7', updated_at: now })
if (path === '/settings/integrations/ai-provider/presets') return jsonResponse([{ provider: 'minimax', label: 'MiniMax', model: 'MiniMax-M2.7', provider_api: 'openai-chat' }])
if (path === '/settings/integrations/web-search/presets') return jsonResponse([{ provider: 'tavily', label: 'Tavily' }])
if (path === '/settings/ai-prompts') return jsonResponse([{ key: 'bgp_brief', name: 'BGP Brief', group: 'BGP', source: 'default', prompt: 'Summarize BGP status.' }])
if (path === '/ai/playground/session') return jsonResponse({ id: 1, session_key: 'frontend-smoke', updated_at: now })
if (path === '/ai/playground/thread') return jsonResponse({ messages: [], session: { state: {} } })
if (path === '/earth/brand') return jsonResponse({ brand: { title: '智能星球', subtitle: 'Smoke', logo_alt: 'Planet' } })
if (path === '/earth/about') return jsonResponse({ about: { title: '智能星球', version: 'smoke', description: 'Harness smoke.' } })
if (path === '/earth/boundaries/status') return jsonResponse({ status: 'ready', provider: 'low', updated_at: now })
if (path === '/earth/boundaries/build/status') return jsonResponse({ status: 'idle', progress: 0 })
if (path === '/settings/tv') return jsonResponse({ tv: { default_source_id: 'smoke-tv', sources: [{ id: 'smoke-tv', name: 'Smoke TV', source_type: 'hls', stream_url: 'https://example.invalid/live.m3u8', is_enabled: true, sort_order: 10 }] } })
if (path === '/tv/streams') return jsonResponse({ sources: [] })
if (path === '/earth/news-sources') return jsonResponse({
is_default: true,
sources: [
{
id: 'smoke-news',
name: 'Smoke News',
source_type: 'rss',
feeds: [{ id: 'main', name: 'Main Feed', url: 'https://example.invalid/rss.xml', type: 'rss', enabled: true }],
region: 'global',
enabled: true,
default_category: 'technology',
source_tags: ['smoke'],
},
],
categories: [{ id: 'technology', label: 'Technology' }],
source_tags: [{ id: 'smoke', label: 'Smoke' }],
item_tag_rules: [],
health: {},
})
if (path === '/earth/news-groups') return jsonResponse({ groups: [{ id: 'manual-smoke', name: 'Smoke News Group', group_type: 'manual', count: 0, editable: true, items: [] }] })
if (method === 'POST' && path === '/earth/news-sources/test') {
return jsonResponse({ ok: true, status: 'ok', item_count: 3, count: 3, latency_ms: 42 })
}
if (method === 'POST' && path === '/earth/news-groups') {
return jsonResponse({ group: { id: 'manual-created', name: '新建新闻组', group_type: 'manual', count: 0, editable: true, items: [] } }, 201)
}
if (path === '/settings/collectors') return jsonResponse({ collectors: [{ id: 'open_bgp', name: 'Open BGP', is_active: true, frequency_minutes: 60 }] })
if (path === '/settings/system') return jsonResponse({ system: { system_name: '智能星球', refresh_interval: 30, data_retention_days: 90, max_concurrent_tasks: 3, demo_mode: false } })
if (path === '/settings/notifications') return jsonResponse({ notifications: { email_enabled: false, email_address: '', critical_alerts: true, warning_alerts: true, daily_summary: false } })
if (path === '/settings/security') return jsonResponse({ security: { session_timeout: 60, max_login_attempts: 5, password_policy: 'standard' } })
if (path === '/settings/smtp') return jsonResponse({ smtp: { host: 'smtp.example.invalid', port: 587, username: 'smoke', from_email: 'smoke@example.invalid', from_name: 'Planet', use_tls: true, use_ssl: false, timeout_seconds: 10 } })
if (path === '/system/logs/sources') return jsonResponse({ items: [{ source_id: 'system-db', label: '系统数据库日志', status: 'ok', enabled: true, available: true }] })
if (path === '/system/logs/observability/groups') return jsonResponse({ groups: [] })
if (path.startsWith('/system/logs/observability/groups/')) return jsonResponse({ events: [] })
if (path.startsWith('/system/logs/')) return jsonResponse({ source_id: path.split('/').pop() || 'system-db', status: 'ok', line_limit: 200, line_count: 1, lines: [`${now} INFO frontend smoke log`] })
return jsonResponse({ detail: 'mocked by frontend smoke' }, 404)
}
async function installApiMocks(context) {
await context.route('**/api/**', async (route) => {
const response = apiPayloadFor(route.request().url(), route.request().method())
await route.fulfill(response)
})
}
async function seedAuthenticatedState(page) {
await page.goto(urlFor('/login'), { waitUntil: 'domcontentloaded' })
await page.evaluate(() => {
localStorage.setItem('auth-storage', JSON.stringify({
state: {
token: 'frontend-smoke-token',
user: {
id: 1,
username: 'smoke-admin',
role: 'super_admin',
gatekeeper_groups: ['docs_admin', 'docs_developer', 'docs_user'],
},
},
version: 0,
}))
})
}
function bindConsoleCollection(context, consoleErrors) {
context.on('page', (page) => {
page.on('console', (message) => {
if (message.type() === 'error') {
const text = message.text()
if (
!text.includes('Failed to load resource') &&
!(text.includes('WebSocket connection to') && text.includes('/ws'))
) {
consoleErrors.push(text)
}
}
})
page.on('pageerror', (error) => {
consoleErrors.push(error.message)
})
})
}
async function runAuthenticatedAdminChecks(browser, failures, consoleErrors, options) {
const {
label,
viewport,
expectAccount,
checkHorizontalOverflow,
zoom,
} = options
const context = await browser.newContext({ viewport })
await installApiMocks(context)
bindConsoleCollection(context, consoleErrors)
const page = await context.newPage()
await seedAuthenticatedState(page)
smokeProgress(`${label} authenticated admin checks`)
for (const route of authenticatedAdminChecks) {
const routeLabel = `${label} ${route.path}${zoom ? ` @${zoom}x` : ''}`
try {
smokeProgress(routeLabel, 'verbose')
await page.goto(urlFor(route.path), { waitUntil: 'domcontentloaded' })
if (zoom) {
await page.evaluate((nextZoom) => {
document.documentElement.style.zoom = String(nextZoom)
}, zoom)
await page.waitForTimeout(80)
}
await expectVisibleText(page, route.text, routeLabel)
if (expectAccount) {
await expectVisibleText(page, 'Hi, smoke-admin', routeLabel)
}
await checkNoFrameworkOverlay(page, routeLabel)
if (checkHorizontalOverflow) {
await checkNoGlobalHorizontalOverflow(page, routeLabel)
}
} catch (error) {
failures.push(error instanceof Error ? error.message : String(error))
} finally {
if (zoom) {
await page.evaluate(() => {
document.documentElement.style.zoom = ''
}).catch(() => {})
}
}
}
await context.close()
}
async function runAdminInteractionChecks(browser, failures, consoleErrors) {
const context = await browser.newContext({ viewport: desktopViewport })
await installApiMocks(context)
bindConsoleCollection(context, consoleErrors)
const page = await context.newPage()
await seedAuthenticatedState(page)
try {
smokeProgress('desktop admin menu navigation')
for (const route of adminMenuNavigationChecks) {
const label = `desktop nav ${route.path}`
smokeProgress(label, 'verbose')
await page.goto(urlFor('/admin'), { waitUntil: 'domcontentloaded' })
if (route.path === '/admin') {
await expectUrl(page, label, (location) => location.pathname === '/admin')
if (route.text) {
await expectVisibleText(page, route.text, label)
}
await checkNoFrameworkOverlay(page, label)
continue
}
const link = await desktopNavLink(page, route)
await link.click()
await expectUrl(page, label, (location) => location.pathname === route.path)
if (route.kind === 'earth') {
await page.locator('iframe[title="3D Earth"]').waitFor({ state: 'visible', timeout: 10000 })
} else if (route.text) {
await expectVisibleText(page, route.text, label)
}
await checkNoFrameworkOverlay(page, label)
}
smokeProgress('admin focused interactions')
await page.goto(urlFor('/admin'), { waitUntil: 'domcontentloaded' })
await page.getByLabel('搜索功能、配置和文字').fill('SMTP')
await clickFirstVisible(page.getByRole('option', { name: /SMTP/i }), 'global search smtp option')
await expectUrl(page, 'global search smtp', (location) => (
location.pathname === '/settings' && location.search.includes('section=smtp')
))
await expectVisibleText(page, 'SMTP 邮件', 'global search smtp')
await page.goto(urlFor('/ai'), { waitUntil: 'domcontentloaded' })
await clickFirstVisible(page.locator('a[aria-label="设置"][href="/settings"]'), 'ai settings shortcut')
await expectUrl(page, 'ai settings shortcut', (location) => location.pathname === '/settings')
await expectVisibleText(page, '系统设置', 'ai settings shortcut')
await page.goto(urlFor('/ai'), { waitUntil: 'domcontentloaded' })
await clickFirstVisible(page.getByRole('tab', { name: /Playground/ }), 'section tab ai playground')
await expectVisibleText(page, 'Playground 设置', 'section tab ai playground')
await page.goto(urlFor('/collection-management'), { waitUntil: 'domcontentloaded' })
await clickFirstVisible(page.getByRole('tab', { name: /采集器/ }), 'section tab collection credentials')
await expectVisibleText(page, '采集器', 'section tab collection credentials')
await page.goto(urlFor('/settings'), { waitUntil: 'domcontentloaded' })
await clickFirstVisible(page.getByRole('tab', { name: /SMTP 邮件/ }), 'section tab settings smtp')
await expectVisibleText(page, 'SMTP 邮件', 'section tab settings smtp')
await page.goto(urlFor('/ai?section=integrations'), { waitUntil: 'domcontentloaded' })
await clickFirstVisible(page.getByRole('button', { name: '测试 AI Provider 连通性' }), 'connection test input ai provider')
await expectVisibleText(page, 'AI Provider连通性正常', 'connection test input ai provider')
await page.goto(urlFor('/ai?section=tools'), { waitUntil: 'domcontentloaded' })
await clickFirstVisible(page.getByRole('button', { name: /tavily/i }), 'connection test input websearch provider')
await clickFirstVisible(page.getByRole('switch', { name: '停用 WebSearch' }), 'connection test input websearch switch')
await expectVisibleText(page, 'WebSearch 已在草稿中停用', 'connection test input websearch disabled')
await expectFirstVisibleDisabled(page.getByLabel('API 基础地址'), 'connection test input websearch base url')
await expectFirstVisibleDisabled(page.getByRole('button', { name: '测试 Web Search 连通性' }), 'connection test input websearch action')
await page.goto(urlFor('/logs'), { waitUntil: 'domcontentloaded' })
await clickFirstVisible(page.getByRole('tab', { name: /原始日志/ }), 'logs raw tab')
await expectVisibleText(page, '日志源', 'logs raw tab')
await page.goto(urlFor('/users'), { waitUntil: 'domcontentloaded' })
await clickFirstVisible(page.getByRole('button', { name: /添加用户/ }), 'users add dialog button')
await expectVisibleText(page, '添加用户', 'users add dialog')
await page.goto(urlFor('/data'), { waitUntil: 'domcontentloaded' })
await clickFirstVisible(page.getByRole('button', { name: '按类型' }), 'data distribution toggle')
await expectVisibleText(page, '按类型', 'data distribution toggle')
smokeProgress('earth news source test')
await page.goto(urlFor('/earth-content?section=news_sources'), { waitUntil: 'domcontentloaded' })
await expectVisibleText(page, 'Smoke News', 'earth news source test')
await clickFirstVisible(page.getByRole('button', { name: '测试当前新闻源' }), 'earth news source test button')
await expectVisibleText(page, '新闻源测试通过', 'earth news source test')
smokeProgress('earth news source draft cancel')
await page.goto(urlFor('/earth-content?section=news_sources'), { waitUntil: 'domcontentloaded' })
await clickFirstVisible(page.getByRole('button', { name: '新增新闻源' }), 'earth news source draft add')
await expectVisibleText(page, '新增新闻源', 'earth news source draft cancel')
await clickFirstVisible(page.getByRole('button', { name: /取消/ }), 'earth news source draft cancel')
await expectVisibleText(page, '已取消新增新闻源', 'earth news source draft cancel')
smokeProgress('earth news group create')
await page.goto(urlFor('/earth-content?section=news_items'), { waitUntil: 'domcontentloaded' })
await expectVisibleText(page, 'Smoke News Group', 'earth news group create')
await clickFirstVisible(page.getByRole('button', { name: '新增新闻组' }), 'earth news group create button')
await expectVisibleText(page, '新闻组已创建', 'earth news group create')
await page.goto(urlFor('/definitely-not-a-real-admin-route'), { waitUntil: 'domcontentloaded' })
await expectUrl(page, 'authenticated unknown route fallback', (location) => location.pathname === '/admin')
await expectVisibleText(page, '仪表盘', 'authenticated unknown route fallback')
await checkNoFrameworkOverlay(page, 'admin interaction checks')
} catch (error) {
failures.push(error instanceof Error ? error.message : String(error))
} finally {
await context.close()
}
const mobileContext = await browser.newContext({ viewport: mobileViewport })
await installApiMocks(mobileContext)
bindConsoleCollection(mobileContext, consoleErrors)
const mobilePage = await mobileContext.newPage()
await seedAuthenticatedState(mobilePage)
try {
smokeProgress('mobile admin menu navigation')
for (const route of adminMenuNavigationChecks) {
const label = `mobile nav ${route.path}`
smokeProgress(label, 'verbose')
await mobilePage.goto(urlFor('/admin'), { waitUntil: 'domcontentloaded' })
if (route.path === '/admin') {
await expectUrl(mobilePage, label, (location) => location.pathname === '/admin')
if (route.text) {
await expectVisibleText(mobilePage, route.text, label)
}
await checkNoFrameworkOverlay(mobilePage, label)
await checkNoGlobalHorizontalOverflow(mobilePage, label)
continue
}
await clickFirstVisible(mobilePage.getByRole('button', { name: '打开导航' }), `${label} open nav`)
const link = await mobileNavLink(mobilePage, route)
await link.click()
await expectUrl(mobilePage, label, (location) => location.pathname === route.path)
if (route.kind === 'earth') {
await mobilePage.locator('iframe[title="3D Earth"]').waitFor({ state: 'visible', timeout: 10000 })
} else if (route.text) {
await expectVisibleText(mobilePage, route.text, label)
}
await checkNoFrameworkOverlay(mobilePage, label)
await checkNoGlobalHorizontalOverflow(mobilePage, label)
}
} catch (error) {
failures.push(error instanceof Error ? error.message : String(error))
} finally {
await mobileContext.close()
}
}
async function runDocsInteractionChecks(browser, failures, consoleErrors) {
const context = await browser.newContext({ viewport: desktopViewport })
await installApiMocks(context)
bindConsoleCollection(context, consoleErrors)
const page = await context.newPage()
try {
smokeProgress('docs interactions')
for (const item of docsRouteChecks) {
const routePath = `/docs/${item.slug}`
smokeProgress(`docs catalog detail ${routePath}`, 'verbose')
await page.goto(urlFor(routePath), { waitUntil: 'domcontentloaded' })
await expectVisibleText(page, item.title, `docs catalog detail ${routePath}`)
await checkNoFrameworkOverlay(page, `docs catalog detail ${routePath}`)
await checkNoGlobalHorizontalOverflow(page, `docs catalog detail ${routePath}`)
}
await page.goto(urlFor('/docs/manual'), { waitUntil: 'domcontentloaded' })
await expectVisibleText(page, '智能星球使用手册', 'docs detail /docs/manual')
await expectVisibleText(page, '登录控制台', 'docs detail /docs/manual')
await checkNoFrameworkOverlay(page, 'docs detail /docs/manual')
await checkNoGlobalHorizontalOverflow(page, 'docs detail /docs/manual')
await clickFirstVisible(
page.getByRole('group', { name: 'Language' }).getByRole('button', { name: 'EN' }),
'docs language switch english',
)
await expectVisibleText(page, 'Intelligent Planet Manual', 'docs language switch english')
await expectVisibleText(page, 'Console Login', 'docs language switch english')
await expectVisibleText(page, 'Dark', 'docs language switch english')
await clickFirstVisible(
page.getByRole('group', { name: 'Theme' }).getByRole('button', { name: 'Dark' }),
'docs theme toggle dark',
)
const theme = await page.locator('.docs-page').getAttribute('data-theme')
if (theme !== 'dark') {
throw new Error(`docs theme toggle dark: expected data-theme="dark", got ${theme}`)
}
await page.getByLabel('Search docs').fill('quickstart')
await clickFirstVisible(
page.locator('.docs-search__results').getByRole('button', { name: /Quickstart/ }),
'docs search quickstart',
)
await expectUrl(page, 'docs search quickstart', (location) => location.pathname === '/docs/quickstart')
await expectVisibleText(page, 'Quickstart', 'docs search quickstart')
await checkNoFrameworkOverlay(page, 'docs interaction checks')
await checkNoGlobalHorizontalOverflow(page, 'docs interaction checks')
} catch (error) {
failures.push(error instanceof Error ? error.message : String(error))
} finally {
await context.close()
}
}
async function runAuthInteractionChecks(browser, failures, consoleErrors) {
async function withAuthPage(label, callback) {
const context = await browser.newContext({ viewport: desktopViewport })
await installApiMocks(context)
bindConsoleCollection(context, consoleErrors)
const page = await context.newPage()
try {
smokeProgress(label)
await callback(page)
await checkNoFrameworkOverlay(page, label)
await checkNoGlobalHorizontalOverflow(page, label)
} catch (error) {
failures.push(error instanceof Error ? error.message : String(error))
} finally {
await context.close()
}
}
await withAuthPage('register verify login', async (page) => {
await page.goto(urlFor('/register'), { waitUntil: 'domcontentloaded' })
await page.getByLabel('用户名').fill('smoke-register')
await page.getByLabel('邮箱').fill('register@example.invalid')
await page.getByLabel('密码').fill('smoke-password')
await page.getByRole('button', { name: /^注册$/ }).click()
await expectVisibleText(page, '验证码已发送到邮箱', 'register verify login')
await page.getByLabel('验证码').fill('123456')
await page.getByRole('button', { name: '验证并登录' }).click()
await expectUrl(page, 'register verify login', (location) => location.pathname === '/admin')
await expectVisibleText(page, '仪表盘', 'register verify login')
await expectVisibleText(page, 'Hi, smoke-auth', 'register verify login')
})
await withAuthPage('forgot password reset', async (page) => {
await page.goto(urlFor('/forgot-password'), { waitUntil: 'domcontentloaded' })
await page.getByLabel('邮箱').fill('forgot@example.invalid')
await page.getByRole('button', { name: '发送验证码' }).click()
await expectVisibleText(page, '若该邮箱已注册', 'forgot password reset')
await page.getByLabel('验证码').fill('654321')
await page.getByLabel('新密码').fill('new-password')
await page.getByRole('button', { name: '重置密码' }).click()
await expectVisibleText(page, '密码已重置,请用新密码登录', 'forgot password reset')
await expectVisibleText(page, '发送验证码', 'forgot password reset')
})
await withAuthPage('standalone verify email', async (page) => {
await page.goto(urlFor('/verify-email?email=verify@example.invalid'), { waitUntil: 'domcontentloaded' })
const emailValue = await page.getByLabel('邮箱').inputValue()
if (emailValue !== 'verify@example.invalid') {
throw new Error(`standalone verify email: expected email query value, got ${emailValue}`)
}
await page.getByRole('button', { name: '重新发送验证码' }).click()
await expectVisibleText(page, '验证码已重发', 'standalone verify email')
await page.getByLabel('验证码').fill('123456')
await page.getByRole('button', { name: '验证并登录' }).click()
await expectUrl(page, 'standalone verify email', (location) => location.pathname === '/admin')
await expectVisibleText(page, '仪表盘', 'standalone verify email')
await expectVisibleText(page, 'Hi, smoke-auth', 'standalone verify email')
})
}
async function main() {
const browser = await chromium.launch({ headless: true })
const context = await browser.newContext({ viewport: desktopViewport })
await installApiMocks(context)
const failures = []
const consoleErrors = []
bindConsoleCollection(context, consoleErrors)
const page = await context.newPage()
try {
smokeProgress('root redirect')
await page.goto(urlFor('/'), { waitUntil: 'domcontentloaded' })
await expectUrl(page, 'root redirects earth', (location) => location.pathname === '/earth')
await checkNoFrameworkOverlay(page, 'root redirects earth')
await page.locator('iframe[title="3D Earth"]').waitFor({ state: 'visible', timeout: 10000 })
} catch (error) {
failures.push(error instanceof Error ? error.message : String(error))
}
try {
smokeProgress('unknown public route fallback')
await page.goto(urlFor('/definitely-not-a-real-route'), { waitUntil: 'domcontentloaded' })
await expectVisibleText(page, '登录 Planet 控制台', 'unknown route login fallback')
await checkNoFrameworkOverlay(page, 'unknown route login fallback')
} catch (error) {
failures.push(error instanceof Error ? error.message : String(error))
}
smokeProgress('public routes')
for (const route of publicRoutes) {
try {
smokeProgress(route.path, 'verbose')
await page.goto(urlFor(route.path), { waitUntil: 'domcontentloaded' })
await expectVisibleText(page, route.text, route.path)
await checkNoFrameworkOverlay(page, route.path)
} catch (error) {
failures.push(error instanceof Error ? error.message : String(error))
}
}
try {
smokeProgress('earth public route')
await page.goto(urlFor('/earth'), { waitUntil: 'domcontentloaded' })
await checkNoFrameworkOverlay(page, '/earth')
await page.locator('iframe[title="3D Earth"]').waitFor({ state: 'visible', timeout: 10000 })
} catch (error) {
failures.push(error instanceof Error ? error.message : String(error))
}
smokeProgress('protected route guards')
for (const route of adminRoutes) {
try {
smokeProgress(`guard ${route}`, 'verbose')
await page.goto(urlFor(route), { waitUntil: 'domcontentloaded' })
await expectVisibleText(page, '登录 Planet 控制台', route)
await checkNoFrameworkOverlay(page, route)
} catch (error) {
failures.push(error instanceof Error ? error.message : String(error))
}
}
try {
smokeProgress('login failure form')
await page.goto(urlFor('/login'), { waitUntil: 'domcontentloaded' })
await page.getByLabel('用户名').fill('smoke-user')
await page.getByLabel('密码').fill('wrong-password')
await page.getByRole('button', { name: '登录' }).click()
await expectVisibleText(page, '登录失败', '/login submit')
await checkNoFrameworkOverlay(page, '/login submit')
} catch (error) {
failures.push(error instanceof Error ? error.message : String(error))
}
await runAuthenticatedAdminChecks(browser, failures, consoleErrors, {
label: 'desktop',
viewport: desktopViewport,
expectAccount: true,
checkHorizontalOverflow: true,
})
await runAuthenticatedAdminChecks(browser, failures, consoleErrors, {
label: 'mobile',
viewport: mobileViewport,
expectAccount: false,
checkHorizontalOverflow: true,
})
for (const zoom of zoomLevels) {
await runAuthenticatedAdminChecks(browser, failures, consoleErrors, {
label: 'desktop zoom',
viewport: desktopViewport,
expectAccount: true,
checkHorizontalOverflow: false,
zoom,
})
}
await runAdminInteractionChecks(browser, failures, consoleErrors)
await runDocsInteractionChecks(browser, failures, consoleErrors)
await runAuthInteractionChecks(browser, failures, consoleErrors)
await browser.close()
if (consoleErrors.length) {
failures.push(`console/page errors: ${[...new Set(consoleErrors)].join(' | ')}`)
}
if (failures.length) {
for (const failure of failures) {
console.error(`fail: ${failure}`)
}
process.exit(1)
}
const authenticatedCheckCount = authenticatedAdminChecks.length * (2 + zoomLevels.length)
console.log(`frontend smoke passed: ${publicRoutes.length + adminRoutes.length + authenticatedCheckCount + interactionChecks.length + (adminMenuNavigationChecks.length * 2) + docsInteractionChecks.length + authInteractionChecks.length + 2} route/interaction checks`)
}
main().catch((error) => {
console.error(error)
process.exit(1)
})