release: bump version to 0.74.4
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:
rayd1o
2026-09-13 10:27:00 +08:00
parent a54fcdbeed
commit 58671e7bc3
71 changed files with 2999 additions and 791 deletions

View File

@@ -1,11 +1,13 @@
#!/usr/bin/env bun
import { readFileSync } from 'node:fs'
import assert from 'node:assert/strict'
import { chromium } from 'playwright'
const baseUrl = process.env.PLANET_FRONTEND_SMOKE_URL || 'http://127.0.0.1:4173'
const smokeProgressMode = process.env.PLANET_FRONTEND_SMOKE_PROGRESS || 'phase'
const SMOKE_RENDER_TIMEOUT_MS = 30000
function smokeProgress(label, mode = 'phase') {
if (smokeProgressMode === '0' || smokeProgressMode === 'off') return
@@ -35,6 +37,16 @@ const publicEnglishI18nRoutes = [
const desktopViewport = { width: 1440, height: 900 }
const mobileViewport = { width: 390, height: 844 }
const zoomLevels = [1.25, 1.5]
const earthGeoJsonPaths = new Set([
'/visualization/geo/cables',
'/visualization/geo/landing-points',
'/visualization/geo/compute-centers',
'/visualization/geo/satellites',
'/visualization/geo/bgp-anomalies',
'/visualization/geo/bgp-incidents',
'/visualization/geo/bgp-collectors',
'/vessels/snapshot',
])
const interactionChecks = [
'root redirects earth',
'unknown route login fallback',
@@ -44,6 +56,8 @@ const interactionChecks = [
'section tab collection credentials',
'section tab settings smtp',
'connection test input ai provider',
'ai model catalog refresh preserves draft and survives reload',
'ai model catalog refresh failure preserves last list',
'connection test input websearch disabled',
'logs raw tab',
'users add dialog',
@@ -388,21 +402,16 @@ function sleep(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 matches = page.getByText(text, { exact: false })
try {
await matches.filter({ visible: true }).first().waitFor({
state: 'visible',
timeout: SMOKE_RENDER_TIMEOUT_MS,
})
} catch (error) {
const bodyText = await page.locator('body').innerText({ timeout: 2000 }).catch(() => '')
throw new Error(`${route}: expected visible text ${JSON.stringify(text)}; body was ${JSON.stringify(bodyText.slice(0, 500))}; ${await matches.count().catch(() => 0)} match(es) were found but none were visible`, { cause: error })
}
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) {
@@ -795,6 +804,10 @@ function apiPayloadFor(requestUrl, method) {
const url = new URL(requestUrl)
const path = url.pathname.replace(/^\/api\/v1/, '') || '/'
if (method === 'GET' && earthGeoJsonPaths.has(path)) {
return jsonResponse({ type: 'FeatureCollection', features: [], count: 0, generated_at: now })
}
if (method === 'POST' && path === '/auth/login') {
return jsonResponse({ detail: '登录失败,请检查账号或密码。' }, 401)
}
@@ -904,13 +917,8 @@ 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({
generated_at: now,
latest_updated_at: now,
source_count: 1,
default_source_id: 'cctv-news',
selected_source: { id: 'cctv-news' },
sources: [{
if (path === '/tv/streams') {
const source = {
id: 'cctv-news',
name: '央视新闻',
provider: '中国媒体',
@@ -921,8 +929,21 @@ function apiPayloadFor(requestUrl, method) {
notes: '默认直播源',
is_enabled: true,
sort_order: 10,
}],
})
}
return jsonResponse({
generated_at: now,
latest_updated_at: now,
source_count: 1,
default_source_id: source.id,
selected_source: source,
sources: [source],
total: 1,
offset: 0,
limit: 50,
has_more: false,
next_offset: null,
})
}
if (path === '/news/earth-feed') return jsonResponse({
generated_at: now,
focus: { lat: 0, lon: 0, region: 'global', label: 'Global Focus', display_region: 'Global' },
@@ -1309,6 +1330,67 @@ async function runAdminInteractionChecks(browser, failures, consoleErrors) {
}
}
async function runAIModelCatalogChecks(browser, failures, consoleErrors) {
for (const viewport of [desktopViewport, mobileViewport]) {
const context = await browser.newContext({ viewport })
await installApiMocks(context)
bindConsoleCollection(context, consoleErrors)
const page = await context.newPage()
await seedAuthenticatedState(page)
let models = ['MiniMax-M2.7']
let failRefresh = false
let integrationWrites = 0
page.on('request', (request) => {
if (request.method() === 'PUT' && new URL(request.url()).pathname.endsWith('/settings/integrations')) integrationWrites += 1
})
await page.route('**/settings/integrations/ai-provider/presets**', async (route) => {
if (route.request().method() === 'POST') {
if (failRefresh) return route.fulfill(jsonResponse({ detail: '模型列表刷新失败,已保留上次模型列表。' }, 502))
models = ['MiniMax-M3', 'MiniMax-M2.7']
}
const preset = { provider: 'minimax', label: 'MiniMax', model: models[0], models, provider_api: 'anthropic-messages' }
await route.fulfill(jsonResponse({ data: route.request().method() === 'POST' ? preset : [preset] }))
})
const openProvider = async () => {
await expectVisibleText(page, 'minimax', 'AI provider group')
if (viewport.width < 768) await clickFirstVisible(page.getByRole('button', { name: /minimax/i }), 'mobile AI provider')
await page.getByLabel('默认模型', { exact: true }).waitFor({ state: 'visible' })
}
try {
smokeProgress(`AI model catalog refresh ${viewport.width}px`)
await page.goto(urlFor('/ai?section=integrations'), { waitUntil: 'domcontentloaded' })
await openProvider()
const drafts = { '默认模型': 'custom-model', 'LLM 基础地址': 'https://custom.example.invalid/v1', 'LLM API Key': 'draft-api-key', '代理 Token': 'draft-service-token' }
for (const [label, value] of Object.entries(drafts)) await page.getByLabel(label, { exact: true }).fill(value)
await page.getByRole('button', { name: /刷新当前.*模型配置/ }).click()
await page.getByRole('button', { name: 'MiniMax-M3', exact: true }).waitFor({ state: 'visible' })
for (const [label, value] of Object.entries(drafts)) assert.equal(await page.getByLabel(label, { exact: true }).inputValue(), value, `${label} draft was overwritten`)
assert.equal(integrationWrites, 0, 'catalog refresh must not save runtime settings')
await page.getByRole('button', { name: 'MiniMax-M3', exact: true }).click()
assert.equal(await page.getByLabel('默认模型', { exact: true }).inputValue(), 'MiniMax-M3')
await page.reload({ waitUntil: 'domcontentloaded' })
await openProvider()
await page.getByRole('button', { name: 'MiniMax-M3', exact: true }).waitFor({ state: 'visible' })
assert.equal(await page.getByLabel('默认模型', { exact: true }).inputValue(), 'MiniMax-M2.7')
if (process.env.PLANET_CATALOG_SCREENSHOT_DIR) {
await page.getByRole('button', { name: 'MiniMax-M3', exact: true }).scrollIntoViewIfNeeded()
await page.screenshot({ path: `${process.env.PLANET_CATALOG_SCREENSHOT_DIR}/catalog-${viewport.width}.png`, fullPage: false })
}
failRefresh = true
await page.getByRole('button', { name: /刷新当前.*模型配置/ }).click()
await expectVisibleText(page, '模型列表刷新失败,已保留上次模型列表。', 'catalog failure')
assert.equal(await page.getByRole('button', { name: 'MiniMax-M3', exact: true }).count(), 1)
assert.equal(await page.locator('.an-toast--success').count(), 0, 'failed refresh must not claim success')
await checkNoFrameworkOverlay(page, 'AI catalog refresh')
await checkNoGlobalHorizontalOverflow(page, 'AI catalog refresh')
} catch (error) {
failures.push(`AI catalog ${viewport.width}px: ${error instanceof Error ? error.message : String(error)}`)
} finally {
await context.close()
}
}
}
async function runDocsInteractionChecks(browser, failures, consoleErrors) {
const context = await browser.newContext({ viewport: desktopViewport })
await installApiMocks(context)
@@ -1559,6 +1641,7 @@ async function main() {
await earthFrame.waitForFunction(() => (
!document.querySelector('.earth-settings-sheet [data-settings-tab-panel="system"]')?.hidden
), null, { timeout: 5000 })
await expectVisibleText(earthFrame, 'Earth Language', '/earth i18n settings')
const systemText = await earthFrame.evaluate(() => (
document.querySelector('.earth-settings-sheet [data-settings-tab-panel="system"]')?.innerText || ''
))
@@ -1927,7 +2010,7 @@ async function main() {
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(' '),
document.getElementById('tv-source-select')?.innerText || '',
].join('\n'))
if (!tvText.includes('Default') || !tvText.includes('Built-in')) {
throw new Error('/earth i18n settings: English TV panel is missing localized default/built-in labels')
@@ -2015,6 +2098,7 @@ async function main() {
})
}
await runAdminInteractionChecks(browser, failures, consoleErrors)
await runAIModelCatalogChecks(browser, failures, consoleErrors)
await runDocsInteractionChecks(browser, failures, consoleErrors)
await runAuthInteractionChecks(browser, failures, consoleErrors)
await browser.close()

View File

@@ -8,6 +8,7 @@ from pathlib import Path
import re
import subprocess
import sys
import tempfile
import unittest
from unittest.mock import AsyncMock, MagicMock, patch
@@ -43,6 +44,163 @@ def run_shell(functions: list[str], setup: str, action: str) -> subprocess.Compl
class DatabaseLifecycleTests(unittest.TestCase):
def test_ai_start_uses_host_readiness_without_waiting_for_docker_probe_schedule(self) -> None:
for recreate in (0, 1):
with self.subTest(recreate=recreate):
result = run_shell(
["start_ai_provider_service"],
f"""
AI_PROVIDER_RECREATE_REQUIRED={recreate}
AI_PROVIDER_START_MAX_RETRIES=1
AI_PROVIDER_HEALTH_CHECK_ATTEMPTS=10
AI_PROVIDER_HEALTH_CHECK_INTERVAL=2
AI_PROVIDER_CONTAINER_NAME=planet_aiprovider
for fn in set_wait_detail write_ai_provider_runtime_env_file \
ensure_ai_provider_image_current recreate_ai_provider_container docker; do
functions[$fn]='return 0'
done
wait_for_container_health() {{ echo WAIT_FOR_DOCKER_SCHEDULE; return 1; }}
wait_for_http() {{ echo "HOST_CHECK $1"; return 0; }}
log_error() {{ :; }}
""",
"start_ai_provider_service 18010",
)
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
self.assertIn("HOST_CHECK http://localhost:18010/health", result.stdout)
self.assertNotIn("WAIT_FOR_DOCKER_SCHEDULE", result.stdout)
def test_fatal_backend_startup_does_not_launch_three_identical_attempts(self) -> None:
result = run_shell(
["start_backend_with_retry"],
f"""
SCRIPT_DIR={ROOT}
BACKEND_LOG_FILE=/dev/null
BACKEND_PID_FILE=/dev/null
BACKEND_MAX_RETRIES=3
BACKEND_HEALTH_CHECK_ATTEMPTS=60
BACKEND_HEALTH_CHECK_INTERVAL=2
VERBOSE=0
cleanup_backend_processes() {{ :; }}
start_detached_command() {{ echo STARTED >&2; echo 42; }}
write_pid_file() {{ :; }}
wait_for_http() {{ return 2; }}
kill() {{ :; }}
backend_log_indicates_port_conflict() {{ return 1; }}
animate_wait_spinner() {{ echo WAITED; }}
log_error() {{ :; }}
""",
"start_backend_with_retry 8000",
)
self.assertEqual(result.returncode, 2, result.stdout + result.stderr)
self.assertEqual(result.stderr.count("STARTED"), 1)
self.assertNotIn("WAITED", result.stdout)
def test_start_keeps_stopped_containers_available_for_reuse(self) -> None:
result = run_shell(
["start"],
"""
BACKEND_PORT=8000
FRONTEND_PORT=3000
AI_PROVIDER_PORT=8010
BACKEND_PORT_REQUESTED=0
FRONTEND_PORT_REQUESTED=0
FRONTEND_LAN_ENABLED=0
MOTION_AGENT_REQUESTED=0
for fn in parse_service_args prepare_allow_lan_public_ports print_splash \
start_backend_service start_frontend_service write_port_state \
verify_allow_lan_access log_success log_note; do
functions[$fn]='return 0'
done
current_frontend_scheme() { echo http; }
cleanup_exit_containers() { echo DELETED_STOPPED_CONTAINERS; }
""",
"start --non-motion-agent",
)
self.assertEqual(result.returncode, 0, result.stderr)
self.assertNotIn("DELETED_STOPPED_CONTAINERS", result.stdout)
def test_start_checks_database_before_preparing_ai_and_starting_backend(self) -> None:
for connection_status in (0, 1):
with self.subTest(connection_status=connection_status):
result = run_shell(
["start_backend_service"],
f"""
BACKEND_MAX_RETRIES=3
BACKEND_LOG_FILE=/dev/null
for fn in set_wait_detail log_success log_note log_error; do
functions[$fn]='return 0'
done
ensure_uv_backend_deps() {{ echo DEPS; }}
ensure_database_services_healthy() {{ echo CONTAINERS; }}
verify_backend_database_connection() {{ echo DB_CHECK; return {connection_status}; }}
ai_provider_service_healthy() {{ return 1; }}
start_ai_provider_service() {{ echo AI_STARTED; }}
start_backend_with_retry() {{ echo BACKEND_STARTED; }}
""",
"start_backend_service 8000 0 8010",
)
self.assertIn("DB_CHECK", result.stdout)
if connection_status:
self.assertNotEqual(result.returncode, 0)
self.assertNotIn("AI_STARTED", result.stdout)
self.assertNotIn("BACKEND_STARTED", result.stdout)
else:
self.assertEqual(result.returncode, 0, result.stderr)
self.assertLess(result.stdout.index("DB_CHECK"), result.stdout.index("AI_STARTED"))
self.assertEqual(result.stdout.count("DEPS"), 1)
def test_backend_fatal_startup_log_stops_waiting_while_reloader_is_alive(self) -> None:
with tempfile.TemporaryDirectory() as directory:
log_file = Path(directory) / "backend.log"
log_file.write_text("Application startup failed. Exiting.\n")
result = run_shell(
["wait_for_http", "backend_startup_failed", "log_matches"],
f"""
BACKEND_PID=$$
BACKEND_LOG_FILE={log_file}
set_wait_detail() {{ :; }}
clear_wait_spinner() {{ :; }}
http_ok() {{ echo HTTP_PROBE; return 1; }}
animate_wait_spinner() {{ echo WAIT; }}
""",
"wait_for_http http://localhost:8000/health 60 2 backend backend_startup_failed",
)
self.assertEqual(result.returncode, 2, result.stdout + result.stderr)
self.assertNotIn("WAIT", result.stdout)
self.assertNotIn("HTTP_PROBE", result.stdout)
def test_readiness_without_failure_probe_still_waits_for_slow_healthy_services(self) -> None:
result = run_shell(
["wait_for_http"],
"""
probes=0
set_wait_detail() { :; }
clear_wait_spinner() { :; }
finish_wait_spinner() { :; }
format_wait_elapsed_seconds() { echo 0; }
http_ok() { probes=$((probes + 1)); [ $probes -eq 3 ]; }
animate_wait_spinner() { echo WAIT; }
""",
"wait_for_http http://localhost:8010/health 10 2 provider",
)
self.assertEqual(result.returncode, 0, result.stderr)
self.assertEqual(result.stdout.count("WAIT"), 2)
def test_backend_worker_import_failure_is_distinct_from_optional_collector_errors(self) -> None:
for contents, expected_status in (
("Process SpawnProcess-1:\nModuleNotFoundError: missing_app\n", 0),
("Collector unavailable\nImportError: optional_dependency\n", 1),
):
with self.subTest(contents=contents), tempfile.TemporaryDirectory() as directory:
log_file = Path(directory) / "backend.log"
log_file.write_text(contents)
result = run_shell(
["backend_startup_failed", "log_matches"],
f"BACKEND_PID=$$\nBACKEND_LOG_FILE={log_file}",
"backend_startup_failed",
)
self.assertEqual(result.returncode, expected_status, result.stderr)
def test_existing_container_gets_current_compose_configuration(self) -> None:
for function in ("start_database_services", "start_postgres_service"):
with self.subTest(function=function):