Files
planet/scripts/harness/frontend-smoke.mjs
rayd1o cee1996809
Some checks failed
ci / backend (push) Has been cancelled
ci / frontend (push) Has been cancelled
release / images (push) Has been cancelled
ci / delivery (push) Has been cancelled
release: bump version to 0.74.5
2026-09-13 14:04:34 +08:00

2137 lines
90 KiB
JavaScript
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env bun
import { readFileSync } from 'node:fs'
import assert from 'node:assert/strict'
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'
const SMOKE_RENDER_TIMEOUT_MS = 30000
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 publicEnglishI18nRoutes = [
'/login',
'/register',
'/verify-email',
'/forgot-password',
'/docs',
'/docs/manual',
'/docs/quickstart',
]
const desktopViewport = { width: 1440, height: 900 }
const mobileViewport = { width: 390, height: 844 }
const zoomLevels = [1.25, 1.5]
const earthGeoJsonPaths = new Set([
'/visualization/geo/cables',
'/visualization/geo/landing-points',
'/visualization/geo/compute-centers',
'/visualization/geo/satellites',
'/visualization/geo/bgp-anomalies',
'/visualization/geo/bgp-incidents',
'/visualization/geo/bgp-collectors',
'/vessels/snapshot',
])
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',
'ai model catalog refresh preserves draft and survives reload',
'ai model catalog refresh failure preserves last list',
'connection test input websearch disabled',
'logs raw tab',
'users add dialog',
'data distribution toggle',
'earth i18n settings',
'earth news source test',
'earth news source draft cancel',
'earth news group create',
'public english i18n sweep',
'english login backend error i18n',
'admin english i18n sweep',
'authenticated unknown route fallback',
]
const authInteractionChecks = [
'register verify login',
'forgot password reset',
'standalone verify email',
]
const adminEnglishI18nRoutes = [
'/admin',
'/data',
'/datasources',
'/bgp',
'/alerts/system',
'/alerts/bgp',
'/alerts/situational',
'/ai',
'/earth-content',
'/collection-management',
'/logs',
'/users',
'/settings',
]
const adminEnglishAllowedCjkText = ['中文']
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*'([^']+)'.*?\bgroup:\s*'([^']+)'/gs
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 <ModuleConsole config=\{configs\.([A-Za-z0-9_]+)\} \/>\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 = /<Route\s+path="([^"*][^"]*)"\s+element=\{<(?:(Navigate)\s+to="([^"]+)"[^>]*|([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*(?:(?<readme>DOCS_README_FILENAME)|"(?<filename>[^"]+\.md)")\s*,\s*(?:(?:DEFAULT_DOCS_SLUG)|"[^"]+")\s*,\s*"(?<access>[^"]+)"/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 = /(?:\[(?<readme>DOCS_README_FILENAME)\]|'(?<filename>[^']+\.md)'):\s*\{\s*zh:\s*\{\s*title:\s*'(?<zhTitle>[^']+)',\s*group:\s*'(?<zhGroup>[^']+)',\s*order:\s*(?<zhOrder>\d+)\s*\},\s*en:\s*\{\s*title:\s*'(?<enTitle>[^']+)',\s*group:\s*'(?<enGroup>[^']+)',\s*order:\s*(?<enOrder>\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 matches = page.getByText(text, { exact: false })
try {
await matches.filter({ visible: true }).first().waitFor({
state: 'visible',
timeout: SMOKE_RENDER_TIMEOUT_MS,
})
} catch (error) {
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 matches.count().catch(() => 0)} match(es) were found but none were visible`, { cause: error })
}
}
async function clickFirstVisible(locator, label) {
const item = await firstVisible(locator, label)
await item.click()
}
async function ensureAdminPreferencesOpen(page, label) {
const drawer = page.locator('.admin__preferences-drawer.is-open').first()
if (await drawer.isVisible().catch(() => false)) return
const toggle = await firstVisible(page.locator('.admin__account-preferences'), label)
await toggle.click()
await page.locator('.admin__preferences-drawer.is-open').waitFor({ state: 'visible', timeout: 5000 })
}
async function getEarthFrame(page, label) {
const iframe = page.locator('iframe[title="3D Earth"]')
await iframe.waitFor({ state: 'visible', timeout: 10000 })
const handle = await iframe.elementHandle()
const frame = await handle?.contentFrame()
if (!frame) {
throw new Error(`${label}: Earth iframe did not expose a content frame`)
}
return frame
}
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) {
let bodyText = await page.locator('body').innerText({ timeout: 5000 }).catch(() => '')
if (!bodyText.trim() && !(await page.locator('iframe').count())) {
await page.waitForFunction(() => (
Boolean(document.body?.innerText?.trim()) || document.querySelectorAll('iframe').length > 0
), null, { timeout: 3000 }).catch(() => {})
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})`,
)
}
}
async function checkNoUnexpectedEnglishCjk(page, route) {
const result = await page.evaluate((allowed) => {
const allowedText = new Set(allowed)
const cjk = /[\u3400-\u9fff]/
const ignoredSelector = [
'script',
'style',
'code',
'pre',
'svg',
'textarea',
'.an-json-viewer',
'.an-log-output',
'.an-detail-json',
'.an-data-json',
].join(', ')
const root = document.querySelector('.admin-theme-root') || document.body
const textNodes = []
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, {
acceptNode(node) {
const parent = node.parentElement
if (!parent || parent.closest(ignoredSelector)) return NodeFilter.FILTER_REJECT
const text = (node.textContent || '').replace(/\s+/g, ' ').trim()
if (!text || allowedText.has(text) || !cjk.test(text)) return NodeFilter.FILTER_REJECT
return NodeFilter.FILTER_ACCEPT
},
})
while (walker.nextNode()) {
const node = walker.currentNode
const parent = node.parentElement
textNodes.push({
text: (node.textContent || '').replace(/\s+/g, ' ').trim(),
tag: parent?.tagName || '',
className: typeof parent?.className === 'string' ? parent.className : '',
})
}
const attributes = []
root.querySelectorAll('*').forEach((element) => {
if (element.closest(ignoredSelector)) return
for (const attributeName of ['aria-label', 'placeholder', 'title']) {
const value = element.getAttribute(attributeName)
if (value && !allowedText.has(value) && cjk.test(value)) {
attributes.push({
attr: attributeName,
value,
tag: element.tagName,
className: typeof element.className === 'string' ? element.className : '',
})
}
}
})
return {
textNodes: textNodes.slice(0, 20),
attributes: attributes.slice(0, 20),
}
}, adminEnglishAllowedCjkText)
if (result.textNodes.length || result.attributes.length) {
throw new Error(`${route}: English UI contains unexpected Chinese text ${JSON.stringify(result)}`)
}
}
async function checkAdminShellOneScreen(page, route, options = {}) {
const { expectAccountVisible = false, expectPreferencesVisible = false } = options
const layout = await page.evaluate(() => {
const rectFor = (selector) => {
const element = document.querySelector(selector)
if (!element) return null
const rect = element.getBoundingClientRect()
return {
top: rect.top,
bottom: rect.bottom,
height: rect.height,
clientHeight: element.clientHeight,
scrollHeight: element.scrollHeight,
}
}
const root = rectFor('#root')
const admin = rectFor('.admin')
const account = rectFor('.admin__account')
const preferences = rectFor('.admin__preferences-panel')
return {
viewportHeight: window.innerHeight,
documentOverflowY: document.documentElement.scrollHeight - document.documentElement.clientHeight,
bodyOverflowY: document.body.scrollHeight - document.body.clientHeight,
rootOverflowY: root ? root.scrollHeight - root.clientHeight : 0,
root,
admin,
account,
preferences,
}
})
if (!layout.admin) return
const tolerance = 2
const failures = []
const adminHeightDelta = Math.abs(layout.admin.height - layout.viewportHeight)
if (adminHeightDelta > tolerance) {
failures.push(
`.admin height ${layout.admin.height.toFixed(2)}px does not match viewport ${layout.viewportHeight}px`,
)
}
if (layout.admin.bottom > layout.viewportHeight + tolerance || layout.admin.top < -tolerance) {
failures.push(
`.admin escapes viewport (top=${layout.admin.top.toFixed(2)}, bottom=${layout.admin.bottom.toFixed(2)})`,
)
}
if (layout.rootOverflowY > tolerance) {
failures.push(`#root vertical overflow ${layout.rootOverflowY}px`)
}
if (layout.documentOverflowY > tolerance || layout.bodyOverflowY > tolerance) {
failures.push(
`document/body vertical overflow document=${layout.documentOverflowY}px body=${layout.bodyOverflowY}px`,
)
}
if (expectAccountVisible && layout.account) {
if (layout.account.top < -tolerance || layout.account.bottom > layout.viewportHeight + tolerance) {
failures.push(
`.admin__account is not fully in the first viewport ` +
`(top=${layout.account.top.toFixed(2)}, bottom=${layout.account.bottom.toFixed(2)})`,
)
}
}
if (expectPreferencesVisible && layout.preferences) {
if (layout.preferences.top < -tolerance || layout.preferences.bottom > layout.viewportHeight + tolerance) {
failures.push(
`.admin__preferences-panel is not fully in the first viewport ` +
`(top=${layout.preferences.top.toFixed(2)}, bottom=${layout.preferences.bottom.toFixed(2)})`,
)
}
}
if (failures.length) {
throw new Error(`${route}: admin shell one-screen check failed: ${failures.join('; ')}`)
}
}
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 === 'GET' && earthGeoJsonPaths.has(path)) {
return jsonResponse({ type: 'FeatureCollection', features: [], count: 0, generated_at: now })
}
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') {
const source = {
id: 'cctv-news',
name: '央视新闻',
provider: '中国媒体',
region: '中国',
language: '中文',
source_type: 'external',
homepage_url: 'https://example.invalid/live',
notes: '默认直播源',
is_enabled: true,
sort_order: 10,
}
return jsonResponse({
generated_at: now,
latest_updated_at: now,
source_count: 1,
default_source_id: source.id,
selected_source: source,
sources: [source],
total: 1,
offset: 0,
limit: 50,
has_more: false,
next_offset: null,
})
}
if (path === '/news/earth-feed') return jsonResponse({
generated_at: now,
focus: { lat: 0, lon: 0, region: 'global', label: 'Global Focus', display_region: 'Global' },
filters: { locale: url.searchParams.get('locale') || 'zh-CN', categories: [], sources: [], limit: 12 },
sources: [
{ id: 'bbc-world', name: 'BBC World', region: 'global', homepage_url: 'https://www.bbc.com/news', source_type: 'rss', enabled: true },
{ id: '36kr', name: '36氪', region: 'asia-pacific', homepage_url: 'https://www.36kr.com/', source_type: 'rss', enabled: true },
],
items: [
{
id: 'bbc-world:smoke-english',
source_id: 'bbc-world',
title: 'Global trade ministers agree on supply-chain safeguards',
summary: 'Officials said the plan focuses on resilient logistics and energy infrastructure.',
content_language: 'en',
localizations: {},
display_title: 'Global trade ministers agree on supply-chain safeguards',
display_summary: 'Officials said the plan focuses on resilient logistics and energy infrastructure.',
url: 'https://example.invalid/news',
source: 'BBC World',
feed_name: 'BBC World',
source_type: 'rss',
category: 'business',
region: 'global',
display_region: 'Global',
published_at: now,
latitude: 0,
longitude: 0,
enrichment_status: 'success',
},
{
id: '36kr:smoke-chinese',
source_id: '36kr',
title: '中国电商平台发布季度增长数据',
summary: '平台表示,跨境电商订单量同比增长。',
content_language: 'zh-CN',
localizations: { 'zh-CN': { title: '中国电商平台发布季度增长数据', summary: '平台表示,跨境电商订单量同比增长。' } },
display_title: '',
display_summary: '',
url: 'https://example.invalid/zh-news',
source: '36氪',
feed_name: '综合资讯',
source_type: 'rss',
category: 'business',
region: 'asia-pacific',
display_region: '亚太',
published_at: now,
latitude: 31,
longitude: 121,
enrichment_status: 'queued',
},
],
cruise_items: [],
errors: [],
stale: false,
})
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, '您好smoke-admin', routeLabel)
}
await checkNoFrameworkOverlay(page, routeLabel)
if (!zoom) {
await checkAdminShellOneScreen(page, routeLabel, { expectAccountVisible: expectAccount })
}
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('/admin'), { waitUntil: 'domcontentloaded' })
await ensureAdminPreferencesOpen(page, 'admin preferences drawer open')
await page.waitForTimeout(350)
await checkAdminShellOneScreen(page, 'admin preferences drawer open', {
expectAccountVisible: true,
expectPreferencesVisible: true,
})
await clickFirstVisible(
page.getByRole('group', { name: '控制台语言' }).getByRole('button', { name: 'EN' }),
'admin language switch english',
)
await expectVisibleText(page, 'Dashboard', 'admin language switch english')
await page.getByLabel('Search features, settings, and text').fill('SMTP')
await clickFirstVisible(page.getByRole('option', { name: /SMTP/i }), 'admin english global search smtp option')
await expectUrl(page, 'admin english global search smtp', (location) => (
location.pathname === '/settings' && location.search.includes('section=smtp')
))
await expectVisibleText(page, 'SMTP Email', 'admin english global search smtp')
smokeProgress('admin english i18n sweep')
for (const route of adminEnglishI18nRoutes) {
const label = `admin english i18n ${route}`
smokeProgress(label, 'verbose')
await page.goto(urlFor(route), { waitUntil: 'domcontentloaded' })
await page.waitForTimeout(450)
await checkNoFrameworkOverlay(page, label)
await checkNoUnexpectedEnglishCjk(page, label)
}
await page.goto(urlFor('/settings?section=smtp'), { waitUntil: 'domcontentloaded' })
await ensureAdminPreferencesOpen(page, 'admin preferences drawer reopen')
await clickFirstVisible(
page.getByRole('group', { name: 'Console language' }).getByRole('button', { name: '中文' }),
'admin language switch chinese',
)
await expectVisibleText(page, 'SMTP 邮件', 'admin language switch chinese')
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 expectVisibleText(page, 'LLM 基础地址', 'connection test input ai provider')
await clickFirstVisible(page.locator('.an-connection-test-input button'), 'connection test input ai provider')
await expectVisibleText(page, 'AI 提供方连通性正常', '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')
const webSearchEnabledInput = page.locator('[data-admin-search-field="enabled"] input[type="checkbox"]')
await firstVisible(webSearchEnabledInput, 'connection test input websearch switch')
if (await webSearchEnabledInput.first().isChecked()) {
await webSearchEnabledInput.first().click()
}
await page.waitForFunction(() => {
const input = document.querySelector('[data-admin-search-field="enabled"] input[type="checkbox"]')
return input instanceof HTMLInputElement && !input.checked
}, null, { timeout: 5000 })
await expectFirstVisibleDisabled(page.getByLabel('API 基础地址'), 'connection test input websearch base url')
await expectFirstVisibleDisabled(page.locator('.an-connection-test-input button'), '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 runAIModelCatalogChecks(browser, failures, consoleErrors) {
for (const viewport of [desktopViewport, mobileViewport]) {
const context = await browser.newContext({ viewport })
await installApiMocks(context)
bindConsoleCollection(context, consoleErrors)
const page = await context.newPage()
await seedAuthenticatedState(page)
let models = ['MiniMax-M2.7']
let failRefresh = false
let failListing = false
let refreshBody = null
let integrationWrites = 0
page.on('request', (request) => {
if (request.method() === 'PUT' && new URL(request.url()).pathname.endsWith('/settings/integrations')) integrationWrites += 1
})
await page.route('**/settings/integrations/ai-provider/presets**', async (route) => {
if (route.request().method() === 'POST') {
refreshBody = route.request().postDataJSON()
if (failRefresh) return route.fulfill(jsonResponse({ detail: '模型列表刷新失败,已保留上次模型列表。' }, 502))
models = ['MiniMax-M3', 'MiniMax-M2.7']
} else if (failListing) {
return route.fulfill(jsonResponse({ detail: 'Catalog unavailable' }, 500))
}
const preset = { provider: 'minimax', label: 'MiniMax', model: models[0], models, provider_api: 'anthropic-messages' }
await route.fulfill(jsonResponse({ data: route.request().method() === 'POST' ? preset : [preset] }))
})
const openProvider = async () => {
await expectVisibleText(page, 'minimax', 'AI provider group')
if (viewport.width < 768) await clickFirstVisible(page.getByRole('button', { name: /minimax/i }), 'mobile AI provider')
await page.getByLabel('默认模型', { exact: true }).waitFor({ state: 'visible' })
}
try {
smokeProgress(`AI model catalog refresh ${viewport.width}px`)
await page.goto(urlFor('/ai?section=integrations'), { waitUntil: 'domcontentloaded' })
await openProvider()
const drafts = { '默认模型': 'custom-model', 'LLM 基础地址': 'https://custom.example.invalid/v1', 'LLM API Key': 'draft-api-key', '代理 Token': 'draft-service-token' }
for (const [label, value] of Object.entries(drafts)) await page.getByLabel(label, { exact: true }).fill(value)
await page.getByRole('button', { name: /刷新当前.*模型配置/ }).click()
await page.getByRole('button', { name: 'MiniMax-M3', exact: true }).waitFor({ state: 'visible' })
for (const [label, value] of Object.entries(drafts)) assert.equal(await page.getByLabel(label, { exact: true }).inputValue(), value, `${label} draft was overwritten`)
assert.equal(refreshBody.base_url, drafts['LLM 基础地址'], 'refresh must use the draft endpoint')
assert.equal(refreshBody.api_key, drafts['LLM API Key'], 'refresh must use the draft credential')
assert.equal(integrationWrites, 0, 'catalog refresh must not save runtime settings')
await page.getByRole('button', { name: 'MiniMax-M3', exact: true }).click()
assert.equal(await page.getByLabel('默认模型', { exact: true }).inputValue(), 'MiniMax-M3')
await page.reload({ waitUntil: 'domcontentloaded' })
await openProvider()
await page.getByRole('button', { name: 'MiniMax-M3', exact: true }).waitFor({ state: 'visible' })
assert.equal(await page.getByLabel('默认模型', { exact: true }).inputValue(), 'MiniMax-M2.7')
if (process.env.PLANET_CATALOG_SCREENSHOT_DIR) {
await page.getByRole('button', { name: 'MiniMax-M3', exact: true }).scrollIntoViewIfNeeded()
await page.screenshot({ path: `${process.env.PLANET_CATALOG_SCREENSHOT_DIR}/catalog-${viewport.width}.png`, fullPage: false })
}
failRefresh = true
await page.getByRole('button', { name: /刷新当前.*模型配置/ }).click()
await expectVisibleText(page, '模型列表刷新失败,已保留上次模型列表。', 'catalog failure')
assert.equal(await page.getByRole('button', { name: 'MiniMax-M3', exact: true }).count(), 1)
assert.equal(await page.locator('.an-toast--success').count(), 0, 'failed refresh must not claim success')
failRefresh = false
failListing = true
await page.getByRole('button', { name: /刷新当前.*模型配置/ }).click()
await expectVisibleText(page, '模型列表加载失败,请重试;已加载的模型列表会保留。', 'catalog listing failure')
assert.equal(await page.getByRole('button', { name: 'MiniMax-M3', exact: true }).count(), 1, 'listing failure must preserve models')
await checkNoFrameworkOverlay(page, 'AI catalog refresh')
await checkNoGlobalHorizontalOverflow(page, 'AI catalog refresh')
} catch (error) {
failures.push(`AI catalog ${viewport.width}px: ${error instanceof Error ? error.message : String(error)}`)
} finally {
await context.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, '您好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, '您好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('public english i18n sweep')
await page.goto(urlFor('/login'), { waitUntil: 'domcontentloaded' })
await page.evaluate(() => {
localStorage.setItem('planet-locale', 'en-US')
localStorage.setItem('docs-lang', 'en')
})
for (const route of publicEnglishI18nRoutes) {
const label = `public english i18n ${route}`
smokeProgress(label, 'verbose')
await page.goto(urlFor(route), { waitUntil: 'domcontentloaded' })
await page.waitForTimeout(450)
await checkNoFrameworkOverlay(page, label)
await checkNoUnexpectedEnglishCjk(page, label)
}
await page.evaluate(() => {
localStorage.setItem('planet-locale', 'zh-CN')
localStorage.setItem('docs-lang', 'zh')
})
} catch (error) {
failures.push(error instanceof Error ? error.message : String(error))
}
try {
smokeProgress('earth public route')
await page.evaluate(() => {
localStorage.setItem('planet-locale', 'en-US')
localStorage.setItem('docs-lang', 'en')
})
await page.goto(urlFor('/earth'), { waitUntil: 'domcontentloaded' })
await checkNoFrameworkOverlay(page, '/earth')
const earthFrame = await getEarthFrame(page, '/earth')
await earthFrame.waitForFunction(() => document.documentElement.lang === 'en-US', null, { timeout: 10000 })
await earthFrame.waitForFunction(() => (
document.querySelector('.earth-brand__title')?.getAttribute('src')?.includes('title-en.png')
), null, { timeout: 10000 })
const brandState = await earthFrame.evaluate(() => {
const brand = document.querySelector('#brand-panel .earth-brand')
const panel = document.getElementById('brand-panel')
const title = document.querySelector('.earth-brand__title')
const subtitle = document.querySelector('.earth-brand__subtitle')
const description = document.querySelector('.earth-brand__description')
if (
!(brand instanceof HTMLElement)
|| !(panel instanceof HTMLElement)
|| !(title instanceof HTMLImageElement)
|| !(subtitle instanceof HTMLElement)
|| !(description instanceof HTMLElement)
) return null
const brandRect = brand.getBoundingClientRect()
const panelRect = panel.getBoundingClientRect()
const titleRect = title.getBoundingClientRect()
const subtitleRect = subtitle.getBoundingClientRect()
const descriptionRect = description.getBoundingClientRect()
return {
brandWidth: brandRect.width,
panelWidth: panelRect.width,
titleWidth: titleRect.width,
titleRight: titleRect.right,
brandRight: brandRect.right,
panelRight: panelRect.right,
subtitleText: subtitle.innerText,
descriptionText: description.innerText,
subtitleRight: subtitleRect.right,
descriptionRight: descriptionRect.right,
viewportWidth: window.innerWidth,
}
})
if (
!brandState
|| brandState.brandWidth > 285
|| brandState.panelWidth > 290
|| brandState.titleWidth < 110
|| brandState.titleRight > brandState.panelRight + 1
|| brandState.brandRight > brandState.panelRight + 1
|| brandState.subtitleRight > brandState.panelRight + 1
|| brandState.descriptionRight > brandState.panelRight + 1
|| !brandState.subtitleText
|| !brandState.descriptionText
|| /[\u4e00-\u9fff]/.test(`${brandState.subtitleText}\n${brandState.descriptionText}`)
|| brandState.brandRight > brandState.viewportWidth - 8
) {
throw new Error(`/earth i18n settings: English brand text is clipped or oversized ${JSON.stringify(brandState)}`)
}
await earthFrame.evaluate(() => document.getElementById('settings-trigger')?.click())
await earthFrame.waitForTimeout(350)
await earthFrame.evaluate(() => (
document.querySelector('.earth-settings-tabs [data-settings-tab="system"]')?.click()
))
await earthFrame.waitForFunction(() => (
!document.querySelector('.earth-settings-sheet [data-settings-tab-panel="system"]')?.hidden
), null, { timeout: 5000 })
await expectVisibleText(earthFrame, 'Earth Language', '/earth i18n settings')
const systemText = await earthFrame.evaluate(() => (
document.querySelector('.earth-settings-sheet [data-settings-tab-panel="system"]')?.innerText || ''
))
if (!systemText.includes('Earth Language')) {
throw new Error('/earth i18n settings: expected English language control')
}
const localeSegmentInitial = await earthFrame.evaluate(() => {
const segmented = document.querySelector('.earth-settings-sheet [data-settings-tab-panel="system"] .earth-settings-segmented')
const english = segmented?.querySelector('[data-earth-locale="en-US"]')
const chinese = segmented?.querySelector('[data-earth-locale="zh-CN"]')
if (!(segmented instanceof HTMLElement) || !(english instanceof HTMLButtonElement) || !(chinese instanceof HTMLButtonElement)) return null
return {
englishActive: english.classList.contains('is-active'),
chineseActive: chinese.classList.contains('is-active'),
activeIndex: getComputedStyle(segmented).getPropertyValue('--active-index').trim(),
itemCount: getComputedStyle(segmented).getPropertyValue('--item-count').trim(),
}
})
if (
!localeSegmentInitial?.englishActive
|| localeSegmentInitial.chineseActive
|| localeSegmentInitial.activeIndex !== '1'
|| localeSegmentInitial.itemCount !== '2'
) {
throw new Error(`/earth i18n settings: English language segmented control is not visually active ${JSON.stringify(localeSegmentInitial)}`)
}
await earthFrame.evaluate(() => document.querySelector('.earth-settings-sheet [data-earth-locale="zh-CN"]')?.click())
await earthFrame.waitForFunction(() => {
const segmented = document.querySelector('.earth-settings-sheet [data-settings-tab-panel="system"] .earth-settings-segmented')
const english = segmented?.querySelector('[data-earth-locale="en-US"]')
const chinese = segmented?.querySelector('[data-earth-locale="zh-CN"]')
return (
document.documentElement.lang === 'zh-CN'
&& segmented instanceof HTMLElement
&& english instanceof HTMLButtonElement
&& chinese instanceof HTMLButtonElement
&& chinese.classList.contains('is-active')
&& !english.classList.contains('is-active')
&& getComputedStyle(segmented).getPropertyValue('--active-index').trim() === '0'
)
}, null, { timeout: 5000 })
const zhSystemText = await earthFrame.evaluate(() => (
document.querySelector('.earth-settings-sheet [data-settings-tab-panel="system"]')?.innerText || ''
))
if (!zhSystemText.includes('星球语言')) {
throw new Error('/earth i18n settings: expected Chinese language control after switching locale')
}
await earthFrame.evaluate(() => document.querySelector('.earth-settings-sheet [data-earth-locale="en-US"]')?.click())
await earthFrame.waitForFunction(() => {
const segmented = document.querySelector('.earth-settings-sheet [data-settings-tab-panel="system"] .earth-settings-segmented')
const english = segmented?.querySelector('[data-earth-locale="en-US"]')
const chinese = segmented?.querySelector('[data-earth-locale="zh-CN"]')
return (
document.documentElement.lang === 'en-US'
&& localStorage.getItem('planet-locale') === 'en-US'
&& localStorage.getItem('docs-lang') === 'en'
&& segmented instanceof HTMLElement
&& english instanceof HTMLButtonElement
&& chinese instanceof HTMLButtonElement
&& english.classList.contains('is-active')
&& !chinese.classList.contains('is-active')
&& getComputedStyle(segmented).getPropertyValue('--active-index').trim() === '1'
)
}, null, { timeout: 5000 })
const hdTextureStatus = await earthFrame.evaluate(async () => {
const { earthMessage, formatEarthMessage } = await import('/earth/js/i18n.js?frontend_smoke_i18n')
const ui = await import('/earth/js/ui.js?frontend_smoke_i18n')
const message = earthMessage('startup.hdTexture')
const translated = formatEarthMessage(message, 'en-US')
ui.setLoading(true)
ui.setLoadingMessage(message)
return {
translated,
rendered: document.querySelector('#status-message .earth-status-text')?.textContent || '',
}
})
if (
/[\u4e00-\u9fff]/.test(`${hdTextureStatus.translated}\n${hdTextureStatus.rendered}`)
|| !hdTextureStatus.translated.includes('Enabling HD texture')
|| !hdTextureStatus.rendered.includes('Enabling HD texture')
) {
throw new Error(`/earth i18n settings: HD texture loading status is not translated: ${JSON.stringify(hdTextureStatus)}`)
}
const earthEnglishCjkAudit = await earthFrame.evaluate(() => {
const cjk = /[\u4e00-\u9fff]/
const allowed = new Set(['中文'])
const skip = 'script,style,svg,canvas,video,iframe,.material-symbols-rounded'
const isVisible = (element) => {
if (!(element instanceof HTMLElement)) return false
if (element.matches(skip) || element.closest(skip)) return false
const style = getComputedStyle(element)
if (style.visibility === 'hidden' || style.display === 'none' || Number(style.opacity) === 0) return false
const rect = element.getBoundingClientRect()
return rect.width > 0 && rect.height > 0
}
const textHits = []
const attrHits = []
document.querySelectorAll('body *').forEach((element) => {
if (!isVisible(element)) return
const ownText = Array.from(element.childNodes)
.filter((node) => node.nodeType === Node.TEXT_NODE)
.map((node) => node.textContent || '')
.join(' ')
.replace(/\s+/g, ' ')
.trim()
if (ownText && cjk.test(ownText) && !allowed.has(ownText)) {
textHits.push({
selector: element.id ? `#${element.id}` : String(element.className || element.tagName.toLowerCase()),
text: ownText,
})
}
;['title', 'aria-label', 'placeholder', 'alt'].forEach((attr) => {
const value = element.getAttribute(attr)
if (value && cjk.test(value) && !allowed.has(value)) {
attrHits.push({
selector: element.id ? `#${element.id}` : String(element.className || element.tagName.toLowerCase()),
attr,
value,
})
}
})
})
return {
textHits: textHits.slice(0, 20),
attrHits: attrHits.slice(0, 20),
}
})
if (earthEnglishCjkAudit.textHits.length || earthEnglishCjkAudit.attrHits.length) {
throw new Error(`/earth i18n settings: English UI still has visible CJK ${JSON.stringify(earthEnglishCjkAudit)}`)
}
await earthFrame.evaluate(() => (
document.querySelector('.earth-settings-tabs [data-settings-tab="panels"]')?.click()
))
await earthFrame.waitForFunction(() => (
!document.querySelector('.earth-settings-sheet [data-settings-tab-panel="panels"]')?.hidden
), null, { timeout: 5000 })
const switchState = await earthFrame.evaluate(() => {
const label = document.querySelector('label[for="toggle-view-legend"]')
const input = document.getElementById('toggle-view-legend')
if (!(label instanceof HTMLElement) || !(input instanceof HTMLInputElement)) return null
const before = input.checked
label.click()
const shell = input.closest('.earth-settings-switch')
return {
before,
checked: input.checked,
visualChecked: shell?.classList.contains('is-checked') === true,
}
})
if (
!switchState
|| switchState.checked === switchState.before
|| switchState.visualChecked !== switchState.checked
) {
throw new Error('/earth i18n settings: system switch visual state did not follow checked state')
}
await earthFrame.evaluate(() => {
const label = document.querySelector('label[for="toggle-view-legend"]')
const input = document.getElementById('toggle-view-legend')
if (input instanceof HTMLInputElement && !input.checked && label instanceof HTMLElement) {
label.click()
}
})
await earthFrame.waitForFunction(() => (
!document.getElementById('legend')?.classList.contains('hud-panel-hidden')
), null, { timeout: 5000 })
const legendState = await earthFrame.evaluate(async () => {
const legend = await import('/earth/js/legend.js')
legend.setLegendItems('satellites', [
{ color: '#ff3333', label: '赤道轨道0-30°' },
{ color: '#ff9933', label: '低倾角轨道30-60°' },
{ color: '#ffff33', label: '中倾角轨道60-90°' },
{ color: '#33ff33', label: '高倾角轨道90-120°' },
{ color: '#3333ff', label: '逆行轨道120-180°' },
{ color: '#5eead4', label: '低轨' },
{ color: '#93c5fd', label: '中轨' },
{ color: '#c4b5fd', label: '高轨' },
{ color: '#f9a8d4', label: '地球静止轨道' },
{ color: '#fcd34d', label: '太阳同步轨道' },
{ color: '#e5e7eb', label: '极轨' },
])
legend.setLegendMode('satellites')
const panel = document.getElementById('legend')
const panelRect = panel?.getBoundingClientRect()
const currentLabel = document.getElementById('legend-current-label')
const labels = Array.from(document.querySelectorAll('#legend-current-label, #legend .legend-label'))
const mobileLabels = Array.from(document.querySelectorAll('#mobile-situation-legend-mode, #mobile-situation-legend-list .legend-label'))
const text = labels.concat(mobileLabels)
.map((element) => `${element.textContent || ''}\n${element.getAttribute('title') || ''}`)
.join('\n')
const overflows = labels
.map((element) => {
const rect = element.getBoundingClientRect()
if (!panelRect) return null
const overflow = rect.left < panelRect.left - 1 || rect.right > panelRect.right + 1
return overflow ? {
text: element.textContent,
left: rect.left,
right: rect.right,
panelLeft: panelRect.left,
panelRight: panelRect.right,
} : null
})
.filter(Boolean)
return {
text,
overflows,
currentText: currentLabel?.textContent || '',
currentTitle: currentLabel?.getAttribute('title') || '',
}
})
if (/[\u4e00-\u9fff]/.test(legendState.text)) {
throw new Error('/earth i18n settings: English satellite legend contains Chinese text')
}
if (!legendState.text.includes('Orbits')) {
throw new Error('/earth i18n settings: English satellite legend should use compact Orbits label')
}
if (!legendState.text.includes('Low Incl.') || !legendState.text.includes('High Incl.') || !legendState.text.includes('Retrograde')) {
throw new Error(`/earth i18n settings: English satellite legend missing translated orbit labels ${JSON.stringify(legendState)}`)
}
for (const compactOrbitLabel of ['LEO', 'MEO', 'HEO', 'GEO', 'SSO', 'Polar']) {
if (!legendState.text.includes(compactOrbitLabel)) {
throw new Error(`/earth i18n settings: English satellite legend missing compact orbit label ${compactOrbitLabel} ${JSON.stringify(legendState)}`)
}
}
if (legendState.currentText !== legendState.currentTitle) {
throw new Error(`/earth i18n settings: legend current title mismatch ${JSON.stringify(legendState)}`)
}
if (legendState.overflows.length > 0) {
throw new Error(`/earth i18n settings: legend labels overflow panel ${JSON.stringify(legendState.overflows)}`)
}
const earthDynamicTranslations = await earthFrame.evaluate(async () => {
const i18n = await import('/earth/js/i18n.js')
return {
satelliteShown: i18n.translateText('卫星已显示', 'en-US'),
cruisePaused: i18n.translateText('巡航已暂停', 'en-US'),
locatedCompute: i18n.translateText('已定位算力中心Smoke Node', 'en-US'),
loadingCables: i18n.translateText('正在加载线缆数据...', 'en-US'),
}
})
if (
earthDynamicTranslations.satelliteShown !== 'Satellites shown'
|| earthDynamicTranslations.cruisePaused !== 'Cruise paused'
|| earthDynamicTranslations.locatedCompute !== 'Located compute centers: Smoke Node'
|| earthDynamicTranslations.loadingCables !== 'Loading cable data...'
) {
throw new Error(`/earth i18n settings: dynamic status translations regressed ${JSON.stringify(earthDynamicTranslations)}`)
}
const earthNotificationState = await earthFrame.evaluate(async () => {
const ui = await import('/earth/js/ui.js')
ui.setLoading(false)
ui.showStatusMessage('成功加载 12 条电缆', 'success')
await new Promise((resolve) => window.setTimeout(resolve, 360))
const status = document.getElementById('status-message')
const brand = document.getElementById('brand-panel')
const ticker = document.getElementById('desktop-news-ticker')
const rect = status?.getBoundingClientRect()
const brandRect = brand?.getBoundingClientRect()
const tickerRect = ticker?.getBoundingClientRect()
return {
text: status?.innerText || '',
visible: status?.classList.contains('visible') === true,
left: rect?.left || 0,
top: rect?.top || 0,
brandRight: brandRect?.right || 0,
tickerBottom: tickerRect?.bottom || 0,
width: rect?.width || 0,
maxWidth: status instanceof HTMLElement ? getComputedStyle(status).maxWidth : '',
}
})
if (
/[\u4e00-\u9fff]/.test(earthNotificationState.text)
|| !earthNotificationState.text.includes('Loaded 12 cables')
|| !earthNotificationState.visible
|| earthNotificationState.left < earthNotificationState.brandRight + 8
|| earthNotificationState.left > earthNotificationState.brandRight + 28
|| earthNotificationState.top < earthNotificationState.tickerBottom + 2
|| earthNotificationState.width < 120
|| earthNotificationState.width > 240
) {
throw new Error(`/earth i18n settings: English notification text or width regressed ${JSON.stringify(earthNotificationState)}`)
}
const earthTooltipState = await earthFrame.evaluate(async () => {
const i18n = await import('/earth/js/i18n.js')
const ui = await import('/earth/js/ui.js')
const countryName = i18n.localizeCountryName({ name: 'China', nameZh: '中国', continent: 'Asia' }, 'en-US')
const content = [
`<strong>${countryName}</strong>`,
`ISO: CHN`,
`${i18n.translateText('大洲', 'en-US')}: ${i18n.translateText('亚洲', 'en-US')}`,
`${i18n.translateText('纬度', 'en-US')}: 31.2300°`,
`${i18n.translateText('经度', 'en-US')}: 121.4700°`,
`${i18n.translateText('海拔', 'en-US')}: 8 m`,
`${i18n.translateText('country', 'en-US')}: ${i18n.localizeCountryName('中国', 'en-US')}`,
].join('<br>')
ui.showTooltip(180, 180, content)
await new Promise((resolve) => requestAnimationFrame(resolve))
const tooltip = document.getElementById('tooltip')
const rect = tooltip?.getBoundingClientRect()
return {
text: tooltip?.innerText || '',
display: tooltip instanceof HTMLElement ? getComputedStyle(tooltip).display : '',
width: rect?.width || 0,
}
})
if (
/[\u4e00-\u9fff]/.test(earthTooltipState.text)
|| !earthTooltipState.text.includes('China')
|| !earthTooltipState.text.includes('Continent: Asia')
|| !earthTooltipState.text.includes('Latitude')
|| !earthTooltipState.text.includes('Longitude')
|| !earthTooltipState.text.includes('Altitude')
|| !earthTooltipState.text.includes('Country: China')
) {
throw new Error(`/earth i18n settings: English tooltip translation regressed ${JSON.stringify(earthTooltipState)}`)
}
await earthFrame.evaluate(() => document.getElementById('settings-close')?.click())
await earthFrame.waitForFunction(() => (
document.getElementById('news-ticker-track')?.innerText.includes('Officials said')
), null, { timeout: 10000 })
await earthFrame.evaluate(() => document.getElementById('desktop-news-ticker')?.click())
await earthFrame.waitForFunction(() => (
!document.getElementById('news-hud-panel')?.classList.contains('hud-panel-hidden')
), null, { timeout: 5000 })
const newsHudText = await earthFrame.evaluate(() => (
document.getElementById('news-hud-panel')?.innerText || ''
))
if (/[\u4e00-\u9fff]/.test(newsHudText)) {
throw new Error('/earth i18n settings: English news panel contains Chinese text')
}
const detailCardText = await earthFrame.evaluate(async () => {
const card = await import('/earth/js/info-card.js')
card.showInfoCard('satellite', {
name: 'SmokeSat',
norad_id: '12345',
constellation: 'Smoke',
footprint_capability: '支持 Starlink 地表覆盖',
current_display: '真实地表覆盖Starlink',
footprint_model: 'Starlink 单星地表覆盖',
inclination: '53.00',
period: '90.0',
perigee: '550',
apogee: '560',
field_sources: { current_display: 'smoke' },
}, { x: 460, y: 180, absolute: true })
await new Promise((resolve) => requestAnimationFrame(resolve))
const panel = document.getElementById('info-panel')
const titleText = Array.from(panel?.querySelectorAll('[title]') || [])
.map((element) => element.getAttribute('title') || '')
.join('\\n')
return [panel?.innerText || '', titleText].join('\\n')
})
if (/[\u4e00-\u9fff]/.test(detailCardText)) {
throw new Error('/earth i18n settings: English detail card contains Chinese text')
}
await earthFrame.evaluate(async () => {
const tv = await import('/earth/js/tv.js')
await tv.ensureTVPanelReady()
})
await earthFrame.waitForFunction(() => (
document.getElementById('tv-source-title')?.innerText.includes('Cctv News')
), null, { timeout: 5000 })
const tvText = await earthFrame.evaluate(() => [
document.getElementById('tv-source-title')?.innerText || '',
document.getElementById('tv-source-origin')?.innerText || '',
document.getElementById('tv-source-status')?.innerText || '',
document.getElementById('tv-source-catalog')?.innerText || '',
document.getElementById('tv-source-notes')?.innerText || '',
document.getElementById('tv-source-select')?.innerText || '',
].join('\n'))
if (!tvText.includes('Default') || !tvText.includes('Built-in')) {
throw new Error('/earth i18n settings: English TV panel is missing localized default/built-in labels')
}
if (/[\u4e00-\u9fff]/.test(tvText)) {
throw new Error('/earth i18n settings: English TV panel contains Chinese text')
}
await page.evaluate(() => {
localStorage.setItem('planet-locale', 'zh-CN')
localStorage.setItem('docs-lang', 'zh')
})
} catch (error) {
failures.push(error instanceof Error ? error.message : String(error))
}
smokeProgress('protected route guards')
await page.evaluate(() => {
localStorage.setItem('planet-locale', 'zh-CN')
localStorage.setItem('docs-lang', 'zh')
}).catch(() => {})
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))
}
try {
smokeProgress('english login backend error i18n')
await page.goto(urlFor('/login'), { waitUntil: 'domcontentloaded' })
await page.evaluate(() => {
localStorage.setItem('planet-locale', 'en-US')
localStorage.setItem('docs-lang', 'en')
})
await page.reload({ waitUntil: 'domcontentloaded' })
await page.getByLabel('Username').fill('smoke-user')
await page.getByLabel('Password').fill('wrong-password')
await page.getByRole('button', { name: 'Log in' }).click()
await expectVisibleText(page, 'Login failed. Check your account or password.', 'english login backend error i18n')
await checkNoUnexpectedEnglishCjk(page, 'english login backend error i18n')
await checkNoFrameworkOverlay(page, 'english login backend error i18n')
await page.evaluate(() => {
localStorage.setItem('planet-locale', 'zh-CN')
localStorage.setItem('docs-lang', 'zh')
})
} 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 runAIModelCatalogChecks(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)
})