2137 lines
90 KiB
JavaScript
Executable File
2137 lines
90 KiB
JavaScript
Executable File
#!/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)
|
||
})
|