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