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

@@ -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,