release: bump version to 0.74.0
Some checks failed
ci / backend (push) Has been cancelled
ci / frontend (push) Has been cancelled
ci / delivery (push) Has been cancelled
release / images (push) Has been cancelled

This commit is contained in:
linkong
2026-06-30 13:52:52 +08:00
parent fbecf30513
commit 5bdb55f3f1
61 changed files with 4788 additions and 753 deletions

View File

@@ -142,6 +142,12 @@ def iter_frontend_src_files() -> list[Path]:
]
def iter_style_family_css_files() -> list[Path]:
files = sorted((root / "frontend/src").rglob("*.css"))
files.extend(sorted((root / "frontend/public/earth/css").glob("*.css")))
return files
def collect_inline_style_context(lines: list[str], start_index: int) -> str:
block = []
for line in lines[start_index:start_index + 8]:
@@ -255,6 +261,48 @@ def css_has_declaration(body: str, prop: str, value_pattern: str) -> bool:
return re.search(rf"(^|[;\n])\s*{re.escape(prop)}\s*:\s*{value_pattern}\s*;", body) is not None
STYLE_FAMILY_OWNER_FILES = {
"frontend/src/admin/styles.css",
"frontend/src/components/tactile-ui/styles.css",
"frontend/src/pages/Docs/Docs.css",
"frontend/public/earth/css/base.css",
"frontend/public/earth/css/coordinates-display.css",
"frontend/public/earth/css/earth-stats.css",
"frontend/public/earth/css/hud.css",
"frontend/public/earth/css/info-panel.css",
"frontend/public/earth/css/layer-panel.css",
"frontend/public/earth/css/legend.css",
"frontend/public/earth/css/news-panel.css",
"frontend/public/earth/css/toolbar.css",
"frontend/public/earth/css/tv-panel.css",
}
STYLE_FAMILY_PATTERN = re.compile(r"\b(?:badge|chip|pill|tag|status)\b", re.I)
def style_family_selector(selector: str) -> str | None:
for class_name in re.findall(r"\.([A-Za-z][A-Za-z0-9_-]*)", selector):
if STYLE_FAMILY_PATTERN.search(class_name):
return class_name
return None
def check_same_category_style_ownership() -> None:
for path in iter_style_family_css_files():
rel = str(path.relative_to(root))
if rel in STYLE_FAMILY_OWNER_FILES:
continue
text = path.read_text(encoding="utf-8")
for line_no, selector, _body in iter_css_blocks(text):
class_name = style_family_selector(selector)
if class_name is None:
continue
warn(
f"{rel}:{line_no}: same-category {class_name!r} styles should reuse "
"an existing shared style owner instead of introducing a page-local visual variant"
)
def check_admin_shell_height_chain() -> None:
text = read_text("frontend/src/admin/styles.css")
required: dict[str, dict[str, str]] = {
@@ -324,6 +372,160 @@ def check_debug_output() -> None:
fail(f"{rel}:{line_no}: console output must not include token material")
EARTH_RUNTIME_COPY_FILES = (
"frontend/public/earth/js/main.js",
"frontend/public/earth/js/controls.js",
"frontend/public/earth/js/cables.js",
"frontend/public/earth/js/news.js",
"frontend/public/earth/js/tv.js",
"frontend/public/earth/js/info-card.js",
"frontend/public/earth/js/layer-startup-tasks.js",
"frontend/public/earth/js/i18n.js",
)
EARTH_RUNTIME_COPY_ENTRYPOINTS = (
"showStatusMessage",
"queueStatusMessage",
"showGestureStatusMessage",
"showError",
"setLoadingMessage",
"resolveStartupMessage",
)
CJK_PATTERN = re.compile(r"[\u3400-\u9fff]")
def collect_balanced_js(text: str, start_index: int, opener: str, closer: str) -> str:
depth = 0
quote: str | None = None
template_depth = 0
cursor = start_index
while cursor < len(text):
char = text[cursor]
if quote:
if char == "\\":
cursor += 2
continue
if quote == "`" and char == "$" and cursor + 1 < len(text) and text[cursor + 1] == "{":
template_depth += 1
cursor += 2
continue
if template_depth > 0 and char == "}":
template_depth -= 1
cursor += 1
continue
if char == quote and template_depth == 0:
quote = None
cursor += 1
continue
if char in ("'", '"', "`"):
quote = char
elif char == opener:
depth += 1
elif char == closer:
depth -= 1
if depth == 0:
return text[start_index:cursor + 1]
cursor += 1
return text[start_index:]
def collect_js_call(text: str, start_index: int) -> str:
open_index = text.find("(", start_index)
if open_index == -1:
return text[start_index:]
return collect_balanced_js(text, open_index, "(", ")")
def collect_js_value(text: str, start_index: int) -> str:
cursor = start_index
while cursor < len(text) and text[cursor].isspace():
cursor += 1
if cursor >= len(text):
return ""
char = text[cursor]
if char == "{":
return collect_balanced_js(text, cursor, "{", "}")
if char == "[":
return collect_balanced_js(text, cursor, "[", "]")
if text.startswith("earthMessage", cursor):
open_index = text.find("(", cursor)
if open_index == -1:
return text[cursor:]
return text[cursor:open_index] + collect_balanced_js(text, open_index, "(", ")")
if char in ("'", '"', "`"):
quote = char
cursor += 1
while cursor < len(text):
if text[cursor] == "\\":
cursor += 2
continue
if text[cursor] == quote:
return text[start_index:cursor + 1]
cursor += 1
return text[start_index:]
end_candidates = [
index for index in (text.find(",", cursor), text.find("\n", cursor)) if index != -1
]
end_index = min(end_candidates) if end_candidates else len(text)
return text[start_index:end_index]
def check_earth_runtime_copy_entrypoints() -> None:
i18n_text = read_text("frontend/public/earth/js/i18n.js")
for token in ("EARTH_MESSAGE_TEMPLATES", "export function earthMessage", "export function formatEarthMessage"):
if token not in i18n_text:
fail(f"frontend/public/earth/js/i18n.js: missing centralized Earth runtime copy token {token}")
direct_literal_pattern = re.compile(r"^\(\s*['\"`]")
for file_path in EARTH_RUNTIME_COPY_FILES:
text = read_text(file_path)
for entrypoint in EARTH_RUNTIME_COPY_ENTRYPOINTS:
pattern = re.compile(rf"\b{re.escape(entrypoint)}\s*\(")
for match in pattern.finditer(text):
call = collect_js_call(text, match.start())
if direct_literal_pattern.search(call):
fail(
f"{file_path}:{jsx_line_number(text, match.start())}: "
f"Earth runtime copy entrypoint {entrypoint} must use earthMessage(...), not a direct string literal"
)
if CJK_PATTERN.search(call) and "earthMessage(" not in call:
fail(
f"{file_path}:{jsx_line_number(text, match.start())}: "
f"Earth runtime copy entrypoint {entrypoint} contains CJK without earthMessage(...)"
)
for match in re.finditer(r"\bstartupMessage\s*:\s*", text):
value = collect_js_value(text, match.end()).strip()
if value in {'""', "''", "``"}:
continue
if "earthMessage(" not in value:
fail(
f"{file_path}:{jsx_line_number(text, match.start())}: "
"Earth layer startupMessage must use earthMessage(...) so startup copy has one i18n entrypoint"
)
elif CJK_PATTERN.search(value) and "earthMessage(" not in value:
fail(
f"{file_path}:{jsx_line_number(text, match.start())}: "
"Earth layer startupMessage contains CJK outside the centralized runtime copy map"
)
for match in re.finditer(r"new\s+CustomEvent\(\s*['\"]earth:status['\"]", text):
call = collect_js_call(text, match.start())
if re.search(r"\bmessage\s*:\s*['\"`]", call):
fail(
f"{file_path}:{jsx_line_number(text, match.start())}: "
"earth:status event messages must use earthMessage(...) instead of direct strings"
)
if CJK_PATTERN.search(call) and "earthMessage(" not in call:
fail(
f"{file_path}:{jsx_line_number(text, match.start())}: "
"earth:status event contains CJK without earthMessage(...)"
)
def jsx_line_number(text: str, index: int) -> int:
return text.count("\n", 0, index) + 1
@@ -512,12 +714,14 @@ def main() -> None:
check_literal_internal_links()
check_admin_search_route_targets()
check_debug_output()
check_earth_runtime_copy_entrypoints()
check_native_button_safety()
check_icon_button_accessibility()
check_no_nested_cards()
check_no_antd_layout_primitives()
check_connection_test_input_pattern()
check_admin_shell_height_chain()
check_same_category_style_ownership()
check_uiux_static_warnings()
for message in warnings:

View File

@@ -22,6 +22,15 @@ const publicRoutes = [
{ 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 }
@@ -39,9 +48,13 @@ const interactionChecks = [
'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 = [
@@ -49,6 +62,22 @@ const authInteractionChecks = [
'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')
@@ -381,6 +410,26 @@ async function clickFirstVisible(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
@@ -448,7 +497,13 @@ async function expectUrl(page, route, predicate) {
}
async function checkNoFrameworkOverlay(page, route) {
const bodyText = await page.locator('body').innerText({ timeout: 5000 }).catch(() => '')
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',
@@ -487,6 +542,70 @@ async function checkNoGlobalHorizontalOverflow(page, route) {
}
}
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(() => {
@@ -785,7 +904,81 @@ function apiPayloadFor(requestUrl, method) {
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 === '/tv/streams') return jsonResponse({
generated_at: now,
latest_updated_at: now,
source_count: 1,
default_source_id: 'cctv-news',
selected_source: { id: 'cctv-news' },
sources: [{
id: 'cctv-news',
name: '央视新闻',
provider: '中国媒体',
region: '中国',
language: '中文',
source_type: 'external',
homepage_url: 'https://example.invalid/live',
notes: '默认直播源',
is_enabled: true,
sort_order: 10,
}],
})
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: [
@@ -964,7 +1157,7 @@ async function runAdminInteractionChecks(browser, failures, consoleErrors) {
await expectVisibleText(page, 'SMTP 邮件', 'global search smtp')
await page.goto(urlFor('/admin'), { waitUntil: 'domcontentloaded' })
await clickFirstVisible(page.getByRole('button', { name: '展开偏好设置' }), 'admin preferences drawer open')
await ensureAdminPreferencesOpen(page, 'admin preferences drawer open')
await page.waitForTimeout(350)
await checkAdminShellOneScreen(page, 'admin preferences drawer open', {
expectAccountVisible: true,
@@ -981,10 +1174,17 @@ async function runAdminInteractionChecks(browser, failures, consoleErrors) {
location.pathname === '/settings' && location.search.includes('section=smtp')
))
await expectVisibleText(page, 'SMTP Email', 'admin english global search smtp')
const reopenEnglishPreferences = await firstVisibleOrNull(page.getByRole('button', { name: 'Expand preferences' }), 500)
if (reopenEnglishPreferences) {
await reopenEnglishPreferences.click()
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',
@@ -1009,15 +1209,23 @@ async function runAdminInteractionChecks(browser, failures, consoleErrors) {
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, 'LLM 基础地址', 'connection test input ai provider')
await clickFirstVisible(page.locator('.an-connection-test-input button'), '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')
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.getByRole('button', { name: '测试 Web Search 连通性' }), 'connection test input websearch action')
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')
@@ -1259,16 +1467,487 @@ async function main() {
}
}
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')
await page.locator('iframe[title="3D Earth"]').waitFor({ state: 'visible', timeout: 10000 })
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 })
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 || '',
Array.from(document.querySelectorAll('#tv-source-select option')).map((option) => option.textContent || '').join(' '),
].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')
@@ -1292,6 +1971,28 @@ async function main() {
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,