release: bump version to 0.74.4
This commit is contained in:
@@ -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()
|
||||
|
||||
@@ -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):
|
||||
|
||||
Reference in New Issue
Block a user