Compare commits

...

2 Commits

Author SHA1 Message Date
Soulter
90ea91884c fix: poll restart status during WebUI update 2026-06-21 17:01:16 +08:00
Soulter
598a739bab fix: improve WebUI update stability 2026-06-21 16:40:11 +08:00
9 changed files with 667 additions and 89 deletions

View File

@@ -71,6 +71,15 @@ jobs:
echo "${{ steps.tag.outputs.tag }}" > dist/assets/version
zip -r "AstrBot-${{ steps.tag.outputs.tag }}-dashboard.zip" dist
- name: Build core package
shell: bash
run: |
git archive \
--format=zip \
--prefix="AstrBot-${{ steps.tag.outputs.tag }}/" \
--output="AstrBot-${{ steps.tag.outputs.tag }}-core.zip" \
HEAD
- name: Upload dashboard artifact
uses: actions/upload-artifact@v7
with:
@@ -78,11 +87,12 @@ jobs:
if-no-files-found: error
path: dashboard/AstrBot-${{ steps.tag.outputs.tag }}-dashboard.zip
- name: Upload dashboard package to Cloudflare R2
- name: Upload release packages to Cloudflare R2
if: ${{ env.R2_ACCOUNT_ID != '' && env.R2_ACCESS_KEY_ID != '' && env.R2_SECRET_ACCESS_KEY != '' }}
env:
R2_BUCKET_NAME: "astrbot"
R2_OBJECT_NAME: "astrbot-webui-latest.zip"
DASHBOARD_LATEST_OBJECT_NAME: "astrbot-webui-latest.zip"
CORE_LATEST_OBJECT_NAME: "astrbot-core-latest.zip"
VERSION_TAG: ${{ steps.tag.outputs.tag }}
shell: bash
run: |
@@ -98,11 +108,18 @@ jobs:
endpoint = https://${R2_ACCOUNT_ID}.r2.cloudflarestorage.com
EOF
cp "dashboard/AstrBot-${VERSION_TAG}-dashboard.zip" "dashboard/${R2_OBJECT_NAME}"
rclone copy "dashboard/${R2_OBJECT_NAME}" "r2:${R2_BUCKET_NAME}" --progress
cp "dashboard/AstrBot-${VERSION_TAG}-dashboard.zip" "dashboard/${DASHBOARD_LATEST_OBJECT_NAME}"
rclone copy "dashboard/${DASHBOARD_LATEST_OBJECT_NAME}" "r2:${R2_BUCKET_NAME}" --progress
cp "dashboard/AstrBot-${VERSION_TAG}-dashboard.zip" "dashboard/astrbot-webui-${VERSION_TAG}.zip"
rclone copy "dashboard/astrbot-webui-${VERSION_TAG}.zip" "r2:${R2_BUCKET_NAME}" --progress
cp "AstrBot-${VERSION_TAG}-core.zip" "${CORE_LATEST_OBJECT_NAME}"
rclone copy "${CORE_LATEST_OBJECT_NAME}" "r2:${R2_BUCKET_NAME}" --progress
cp "AstrBot-${VERSION_TAG}-core.zip" "astrbot-core-${VERSION_TAG}.zip"
rclone copy "astrbot-core-${VERSION_TAG}.zip" "r2:${R2_BUCKET_NAME}" --progress
rclone copyto "AstrBot-${VERSION_TAG}-core.zip" "r2:${R2_BUCKET_NAME}/astrbot-core/${VERSION_TAG}/source.zip" --progress
rclone copyto "AstrBot-${VERSION_TAG}-core.zip" "r2:${R2_BUCKET_NAME}/download/astrbot-core/${VERSION_TAG}/source.zip" --progress
publish-release:
name: Publish GitHub Release
if: github.repository == 'AstrBotDevs/AstrBot'

View File

@@ -5,7 +5,7 @@ import os
from astrbot.core.computer.booters.cua_defaults import CUA_DEFAULT_CONFIG
from astrbot.core.utils.astrbot_path import get_astrbot_data_path
VERSION = "4.25.5"
VERSION = "4.25.6-rc.2"
DB_PATH = os.path.join(get_astrbot_data_path(), "data_v4.db")
PERSONAL_WECHAT_CONFIG_METADATA = {
"weixin_oc_base_url": {

View File

@@ -1,12 +1,15 @@
import os
import sys
import time
import zipfile
from pathlib import Path
import psutil
from astrbot.core import logger
from astrbot.core.config.default import VERSION
from astrbot.core.utils.astrbot_path import get_astrbot_path
from astrbot.core.utils.io import ensure_dir
from .zip_updator import ReleaseInfo, RepoZipUpdator
@@ -21,6 +24,30 @@ class AstrBotUpdator(RepoZipUpdator):
super().__init__(repo_mirror, verify=verify)
self.MAIN_PATH = get_astrbot_path()
self.ASTRBOT_RELEASE_API = "https://api.soulter.top/releases"
self.CORE_PACKAGE_BASE_URL = (
"https://astrbot-registry.soulter.top/download/astrbot-core"
)
def _build_core_package_url(self, version: str | None) -> str | None:
"""Build the hosted core package URL for a release tag.
Args:
version: Release tag, such as ``v4.26.6``.
Returns:
Public package URL, or None when hosted package download is disabled.
"""
if not version or not str(version).startswith("v"):
return None
base_url = os.environ.get(
"ASTRBOT_CORE_PACKAGE_BASE_URL",
self.CORE_PACKAGE_BASE_URL,
).strip()
if not base_url:
return None
return f"{base_url.rstrip('/')}/{version}/source.zip"
def terminate_child_processes(self) -> None:
"""终止当前进程的所有子进程
@@ -151,6 +178,41 @@ class AstrBotUpdator(RepoZipUpdator):
proxy="",
progress_callback=None,
) -> None:
zip_path = await self.download_update_package(
latest=latest,
version=version,
proxy=proxy,
progress_callback=progress_callback,
)
self.apply_update_package(zip_path)
if reboot:
self._reboot()
async def download_update_package(
self,
latest=True,
version=None,
proxy="",
path: str | Path = "temp.zip",
progress_callback=None,
) -> Path:
"""Download an AstrBot core update package without applying it.
Args:
latest: Whether to download the latest release.
version: Specific release tag or commit hash to download.
proxy: Optional GitHub proxy prefix.
path: Destination zip path.
progress_callback: Optional callback for download progress payloads.
Returns:
Path to the downloaded update package.
Raises:
Exception: If update metadata cannot resolve a package URL.
"""
update_data = await self.fetch_release_info(self.ASTRBOT_RELEASE_API, latest)
file_url = None
@@ -159,15 +221,18 @@ class AstrBotUpdator(RepoZipUpdator):
"Error: You are running AstrBot via CLI, please use `pip` or `uv tool upgrade` to update AstrBot."
) # 避免版本管理混乱
target_version = None
if latest:
latest_version = update_data[0]["tag_name"]
if self.compare_version(VERSION, latest_version) >= 0:
raise Exception("当前已经是最新版本。")
target_version = latest_version
file_url = update_data[0]["zipball_url"]
elif str(version).startswith("v"):
# 更新到指定版本
for data in update_data:
if data["tag_name"] == version:
target_version = data["tag_name"]
file_url = data["zipball_url"]
if not file_url:
raise Exception(f"未找到版本号为 {version} 的更新文件。")
@@ -181,16 +246,49 @@ class AstrBotUpdator(RepoZipUpdator):
proxy = proxy.removesuffix("/")
file_url = f"{proxy}/{file_url}"
try:
await self._download_file(
file_url,
"temp.zip",
progress_callback=progress_callback,
)
logger.info("下载 AstrBot Core 更新文件完成,正在执行解压...")
self.unzip_file("temp.zip", self.MAIN_PATH)
except BaseException as e:
raise e
zip_path = Path(path)
ensure_dir(zip_path.parent)
hosted_package_url = self._build_core_package_url(target_version)
if hosted_package_url:
try:
logger.info(
f"优先从托管存储下载 AstrBot Core 更新包: {hosted_package_url}"
)
await self._download_file(
hosted_package_url,
str(zip_path),
progress_callback=progress_callback,
)
if not zipfile.is_zipfile(zip_path):
raise RuntimeError(
"Downloaded hosted package is not a valid ZIP file"
)
return zip_path
except Exception as exc:
logger.warning(
f"从托管存储下载 AstrBot Core 更新包失败: {exc}"
"将回退到当前更新源。"
)
if reboot:
self._reboot()
await self._download_file(
file_url,
str(zip_path),
progress_callback=progress_callback,
)
return zip_path
def apply_update_package(self, zip_path: str | Path) -> None:
"""Apply a previously downloaded AstrBot core update package.
Args:
zip_path: Core update zip archive path.
Returns:
None.
Raises:
Exception: If the archive cannot be extracted or applied.
"""
logger.info("下载 AstrBot Core 更新文件完成,正在执行解压...")
self.unzip_file(str(zip_path), self.MAIN_PATH)

View File

@@ -398,12 +398,27 @@ async def download_dashboard(
version: str | None = None,
proxy: str | None = None,
progress_callback=None,
extract: bool = True,
) -> None:
"""下载管理面板文件"""
"""Download dashboard assets and optionally extract them.
Args:
path: Destination zip path. Defaults to the AstrBot data directory.
extract_path: Directory where assets should be extracted.
latest: Whether to download the latest dashboard build.
version: Specific release tag or commit hash to download.
proxy: Optional download proxy prefix.
progress_callback: Optional callback for download progress payloads.
extract: Whether to extract the archive after download.
Returns:
None.
"""
if path is None:
zip_path = Path(get_astrbot_data_path()).absolute() / "dashboard.zip"
else:
zip_path = Path(path).absolute()
ensure_dir(zip_path.parent)
if latest or len(str(version)) != 40:
ver_name = "latest" if latest else version
@@ -456,5 +471,28 @@ async def download_dashboard(
show_progress=True,
progress_callback=progress_callback,
)
if extract:
extract_dashboard(zip_path, extract_path)
def extract_dashboard(zip_path: str | Path, extract_path: str | Path = "data") -> None:
"""Extract a downloaded dashboard archive.
Args:
zip_path: Dashboard zip archive path.
extract_path: Directory where the archive contents should be extracted.
Returns:
None.
"""
extract_root = Path(extract_path).resolve()
ensure_dir(extract_root)
with zipfile.ZipFile(zip_path, "r") as z:
z.extractall(extract_path)
for member in z.infolist():
target_path = (extract_root / member.filename).resolve()
if not target_path.is_relative_to(extract_root):
raise ValueError(
f"Unsafe dashboard archive path: {member.filename}",
)
z.extract(member, extract_root)

View File

@@ -1,5 +1,8 @@
import asyncio
import traceback
import uuid
import zipfile
from pathlib import Path
from quart import request
@@ -8,7 +11,15 @@ from astrbot.core.config.default import VERSION
from astrbot.core.core_lifecycle import AstrBotCoreLifecycle
from astrbot.core.db.migration.helper import check_migration_needed_v4, do_migration_v4
from astrbot.core.updator import AstrBotUpdator
from astrbot.core.utils.io import download_dashboard, get_dashboard_version
from astrbot.core.utils.astrbot_path import (
get_astrbot_data_path,
get_astrbot_system_tmp_path,
)
from astrbot.core.utils.io import (
download_dashboard,
extract_dashboard,
get_dashboard_version,
)
from .route import Response, Route, RouteContext
@@ -35,6 +46,7 @@ class UpdateRoute(Route):
self.astrbot_updator = astrbot_updator
self.core_lifecycle = core_lifecycle
self.update_progress: dict[str, dict] = {}
self._update_tasks: dict[str, asyncio.Task] = {}
self.register_routes()
def _init_update_progress(self, progress_id: str, version: str) -> None:
@@ -198,7 +210,62 @@ class UpdateRoute(Route):
if proxy:
proxy = proxy.removesuffix("/")
existing_task = self._update_tasks.get(progress_id)
if existing_task and not existing_task.done():
return (
Response()
.ok(
{"id": progress_id, "status": "running"},
"更新任务正在进行中。",
)
.__dict__,
200,
CLEAR_SITE_DATA_HEADERS,
)
self._init_update_progress(progress_id, version)
task = asyncio.create_task(
self._run_update_project(progress_id, version, latest, reboot, proxy),
)
self._update_tasks[progress_id] = task
task.add_done_callback(lambda _task: self._update_tasks.pop(progress_id, None))
return (
Response()
.ok(
{"id": progress_id, "status": "running"},
"更新任务已开始。",
)
.__dict__,
200,
CLEAR_SITE_DATA_HEADERS,
)
async def _run_update_project(
self,
progress_id: str,
version: str,
latest: bool,
reboot: bool,
proxy: str | None,
) -> None:
"""Run an update task outside the request lifecycle.
Args:
progress_id: Progress record id reported to the frontend.
version: Target version without the latest sentinel.
latest: Whether to install the latest release.
reboot: Whether to restart AstrBot after applying files.
proxy: Optional GitHub proxy URL.
Returns:
None.
"""
update_temp_dir = Path(get_astrbot_system_tmp_path()) / "updates"
update_temp_dir.mkdir(parents=True, exist_ok=True)
update_token = uuid.uuid4().hex
dashboard_zip_path = update_temp_dir / f"{update_token}-dashboard.zip"
core_zip_path = update_temp_dir / f"{update_token}-core.zip"
try:
self._set_update_stage(
progress_id,
@@ -208,15 +275,17 @@ class UpdateRoute(Route):
0,
)
await download_dashboard(
path=str(dashboard_zip_path),
latest=latest,
version=version,
proxy=proxy,
proxy=proxy or "",
progress_callback=self._make_progress_callback(
progress_id,
"dashboard",
0,
45,
),
extract=False,
)
self._set_update_stage(
progress_id,
@@ -233,16 +302,19 @@ class UpdateRoute(Route):
"正在下载 AstrBot 项目代码...",
45,
)
await self.astrbot_updator.update(
latest=latest,
version=version,
proxy=proxy,
progress_callback=self._make_progress_callback(
progress_id,
"core",
45,
45,
),
core_zip_path = Path(
await self.astrbot_updator.download_update_package(
latest=latest,
version=version,
proxy=proxy or "",
path=core_zip_path,
progress_callback=self._make_progress_callback(
progress_id,
"core",
45,
45,
),
)
)
self._set_update_stage(
progress_id,
@@ -252,6 +324,50 @@ class UpdateRoute(Route):
90,
)
self._set_update_stage(
progress_id,
"verify",
"running",
"下载完成,正在校验更新包...",
90,
)
for zip_path in (dashboard_zip_path, core_zip_path):
with zipfile.ZipFile(zip_path, "r") as archive:
corrupt_member = archive.testzip()
if corrupt_member:
raise RuntimeError(f"更新包校验失败: {corrupt_member}")
self._set_update_stage(
progress_id,
"verify",
"done",
"更新包校验完成。",
91,
)
self._set_update_stage(
progress_id,
"apply",
"running",
"下载完成,正在应用更新...",
91,
)
await asyncio.to_thread(
self.astrbot_updator.apply_update_package,
core_zip_path,
)
await asyncio.to_thread(
extract_dashboard,
dashboard_zip_path,
Path(get_astrbot_data_path()),
)
self._set_update_stage(
progress_id,
"apply",
"done",
"更新文件应用完成。",
92,
)
# pip 更新依赖
self._set_update_stage(
progress_id,
@@ -290,12 +406,7 @@ class UpdateRoute(Route):
"overall_percent": 100,
},
)
ret = (
Response()
.ok(None, "更新成功AstrBot 将在 2 秒内全量重启以应用新的代码。")
.__dict__
)
return ret, 200, CLEAR_SITE_DATA_HEADERS
return
self.update_progress[progress_id].update(
{
"status": "success",
@@ -304,12 +415,14 @@ class UpdateRoute(Route):
"overall_percent": 100,
},
)
ret = (
Response()
.ok(None, "更新成功AstrBot 将在下次启动时应用新的代码。")
.__dict__
except asyncio.CancelledError:
self.update_progress[progress_id].update(
{
"status": "error",
"message": "更新任务已取消。",
},
)
return ret, 200, CLEAR_SITE_DATA_HEADERS
logger.warning(f"Update task was cancelled: {progress_id}")
except Exception as e:
self.update_progress[progress_id].update(
{
@@ -318,7 +431,13 @@ class UpdateRoute(Route):
},
)
logger.error(f"/api/update_project: {traceback.format_exc()}")
return Response().error(e.__str__()).__dict__
finally:
for zip_path in (dashboard_zip_path, core_zip_path):
try:
if zip_path.exists():
zip_path.unlink()
except Exception as cleanup_exc:
logger.warning(f"清理更新临时文件失败: {zip_path}, {cleanup_exc}")
async def update_dashboard(self):
try:

View File

@@ -55,6 +55,7 @@ let showAdvancedUpdateSettings = ref(false);
let restartWaiting = ref(false);
let restartStartTime = ref<number | string | null>(null);
let restartPollTimer: ReturnType<typeof setInterval> | null = null;
const RESTART_START_TIME_POLL_INTERVAL_MS = 2000;
type DownloadStageStatus = "pending" | "running" | "done" | "error";
type DownloadStage = {
status: DownloadStageStatus;
@@ -569,20 +570,25 @@ async function fetchAstrBotStartTime() {
return startTime;
}
function waitForAstrBotRestart(initialStartTime: number | string | null) {
if (restartWaiting.value) {
function waitForAstrBotRestart(
initialStartTime: number | string | null,
showWaiting = true,
) {
if (showWaiting && !restartWaiting.value) {
restartWaiting.value = true;
updateProgress.value = {
...updateProgress.value,
stage: "restart",
status: "success",
message: t("core.header.updateDialog.progress.restarting"),
overall_percent: 100,
};
}
if (restartPollTimer) {
return;
}
stopRestartPolling();
restartWaiting.value = true;
restartStartTime.value = initialStartTime;
updateProgress.value = {
...updateProgress.value,
stage: "restart",
status: "success",
message: t("core.header.updateDialog.progress.restarting"),
overall_percent: 100,
};
const poll = async () => {
try {
@@ -601,9 +607,10 @@ function waitForAstrBotRestart(initialStartTime: number | string | null) {
}
};
void poll();
restartPollTimer = setInterval(() => {
void poll();
}, 1000);
}, RESTART_START_TIME_POLL_INTERVAL_MS);
}
function applyUpdateProgress(payload: UpdateProgress) {
@@ -616,8 +623,15 @@ function applyUpdateProgress(payload: UpdateProgress) {
},
};
if (payload.status === "success" || payload.status === "error") {
installLoading.value = false;
stopUpdateProgressPolling();
}
if (payload.status === "error") {
stopRestartPolling();
}
if (payload.stage === "restart") {
waitForAstrBotRestart(restartStartTime.value);
}
if (payload.status === "success") {
waitForAstrBotRestart(restartStartTime.value);
}
@@ -663,6 +677,7 @@ async function switchVersion(targetVersion: string) {
initialStartTime = commonStore.getStartTime();
}
restartStartTime.value = initialStartTime;
waitForAstrBotRestart(initialStartTime, false);
startUpdateProgressPolling(progressId);
axios
@@ -673,20 +688,27 @@ async function switchVersion(targetVersion: string) {
})
.then((res) => {
updateStatus.value = res.data.message;
updateProgress.value = {
...updateProgress.value,
status:
res.data.status === "ok" ? "success" : updateProgress.value.status,
message: res.data.message,
overall_percent:
res.data.status === "ok" ? 100 : updateProgress.value.overall_percent,
};
if (res.data.status == "ok") {
waitForAstrBotRestart(initialStartTime);
if (res.data.status === "error") {
stopUpdateProgressPolling();
installLoading.value = false;
updateProgress.value = {
...updateProgress.value,
status: "error",
message:
res.data.message || t("core.header.updateDialog.progress.failed"),
};
}
})
.catch((err) => {
console.log(err);
stopUpdateProgressPolling();
if (!err?.response && restartPollTimer) {
waitForAstrBotRestart(restartStartTime.value);
updateStatus.value = t("core.header.updateDialog.progress.restarting");
return;
}
stopRestartPolling();
installLoading.value = false;
updateStatus.value = err;
updateProgress.value = {
...updateProgress.value,
@@ -696,10 +718,6 @@ async function switchVersion(targetVersion: string) {
err?.message ||
t("core.header.updateDialog.progress.failed"),
};
})
.finally(() => {
installLoading.value = false;
stopUpdateProgressPolling();
});
}

View File

@@ -1,6 +1,6 @@
[project]
name = "AstrBot"
version = "4.25.5"
version = "4.25.6-rc.2"
description = "Easy-to-use multi-platform LLM chatbot and development framework"
readme = "README.md"
license = { text = "AGPL-3.0-or-later" }
@@ -120,7 +120,7 @@ exclude = ["dashboard", "node_modules", "dist", "data", "tests"]
allow-direct-references = true
# Include bundled dashboard dist even though it is not tracked by VCS.
[tool.hatch.build.targets.wheel]
[tool.hatch.build]
artifacts = ["astrbot/dashboard/dist/**"]
# Custom build hook: builds the Vue dashboard and copies dist into the package.

View File

@@ -59,6 +59,34 @@ def _strip_query(url: str) -> str:
return urlunsplit(("", "", parsed.path, "", parsed.fragment))
async def _wait_for_update_progress(
test_client,
authenticated_header: dict,
progress_id: str,
) -> dict:
"""Wait until an update task reaches a terminal status.
Args:
test_client: Quart test client.
authenticated_header: Headers for authenticated dashboard requests.
progress_id: Update progress id to poll.
Returns:
The update progress response payload.
"""
for _ in range(100):
response = await test_client.get(
f"/api/update/progress?id={progress_id}",
headers=authenticated_header,
)
data = await response.get_json()
if data["data"].get("status") in {"success", "error"}:
return data
await asyncio.sleep(0.01)
pytest.fail(f"Update task did not finish: {progress_id}")
@pytest.fixture
def registered_plugin_page(core_lifecycle_td: AstrBotCoreLifecycle, monkeypatch):
plugin_root = (
@@ -2463,40 +2491,77 @@ async def test_do_update(
authenticated_header: dict,
core_lifecycle_td: AstrBotCoreLifecycle,
monkeypatch,
tmp_path_factory,
tmp_path,
):
test_client = app.test_client()
# Use a temporary path for the mock update to avoid side effects
temp_release_dir = tmp_path_factory.mktemp("release")
release_path = temp_release_dir / "astrbot"
calls = []
release_path = tmp_path / "astrbot"
calls: list[str] = []
async def mock_update(*args, **kwargs):
"""Mocks the update process by creating a directory in the temp path."""
calls.append("core")
async def mock_download_update_package(*args, **kwargs):
"""Mock the core package download by writing a valid ZIP archive."""
calls.append("download-core")
callback = kwargs.get("progress_callback")
if callback:
callback({"downloaded": 10, "total": 10, "percent": 1, "speed": 1})
zip_path = Path(kwargs["path"])
with zipfile.ZipFile(zip_path, "w") as archive:
archive.writestr("AstrBot-v3.4.0/README.md", "core")
return zip_path
def mock_apply_update_package(zip_path):
"""Mock applying the core package."""
calls.append("apply-core")
assert zipfile.is_zipfile(zip_path)
os.makedirs(release_path, exist_ok=True)
async def mock_download_dashboard(*args, **kwargs):
"""Mocks the dashboard download to prevent network access."""
calls.append("dashboard")
"""Mock the dashboard download by writing a valid ZIP archive."""
calls.append("download-dashboard")
callback = kwargs.get("progress_callback")
if callback:
callback({"downloaded": 10, "total": 10, "percent": 1, "speed": 1})
return
zip_path = Path(kwargs["path"])
with zipfile.ZipFile(zip_path, "w") as archive:
archive.writestr("dist/index.html", "dashboard")
def mock_extract_dashboard(zip_path, extract_path):
"""Mock applying the dashboard package."""
calls.append("apply-dashboard")
assert zipfile.is_zipfile(zip_path)
assert Path(extract_path) == tmp_path / "data"
async def mock_pip_install(*args, **kwargs):
"""Mocks pip install to prevent actual installation."""
calls.append("pip")
return
monkeypatch.setattr(core_lifecycle_td.astrbot_updator, "update", mock_update)
monkeypatch.setattr(
core_lifecycle_td.astrbot_updator,
"download_update_package",
mock_download_update_package,
)
monkeypatch.setattr(
core_lifecycle_td.astrbot_updator,
"apply_update_package",
mock_apply_update_package,
)
monkeypatch.setattr(
"astrbot.dashboard.routes.update.download_dashboard",
mock_download_dashboard,
)
monkeypatch.setattr(
"astrbot.dashboard.routes.update.extract_dashboard",
mock_extract_dashboard,
)
monkeypatch.setattr(
"astrbot.dashboard.routes.update.get_astrbot_system_tmp_path",
lambda: str(tmp_path / "tmp"),
)
monkeypatch.setattr(
"astrbot.dashboard.routes.update.get_astrbot_data_path",
lambda: str(tmp_path / "data"),
)
monkeypatch.setattr(
"astrbot.dashboard.routes.update.pip_installer.install",
mock_pip_install,
@@ -2510,19 +2575,101 @@ async def test_do_update(
assert response.status_code == 200
data = await response.get_json()
assert data["status"] == "ok"
assert os.path.exists(release_path)
assert calls[:2] == ["dashboard", "core"]
assert data["data"]["id"] == "test-progress"
assert data["data"]["status"] == "running"
progress_response = await test_client.get(
"/api/update/progress?id=test-progress",
headers=authenticated_header,
progress_data = await _wait_for_update_progress(
test_client,
authenticated_header,
"test-progress",
)
progress_data = await progress_response.get_json()
assert os.path.exists(release_path)
assert calls == [
"download-dashboard",
"download-core",
"apply-core",
"apply-dashboard",
"pip",
]
assert progress_data["status"] == "ok"
assert progress_data["data"]["status"] == "success"
assert progress_data["data"]["overall_percent"] == 100
@pytest.mark.asyncio
async def test_do_update_does_not_apply_files_when_core_download_fails(
app: Quart,
authenticated_header: dict,
core_lifecycle_td: AstrBotCoreLifecycle,
monkeypatch,
tmp_path,
):
test_client = app.test_client()
calls: list[str] = []
async def mock_download_dashboard(*args, **kwargs):
"""Mock the dashboard download by writing a valid ZIP archive."""
calls.append("download-dashboard")
zip_path = Path(kwargs["path"])
with zipfile.ZipFile(zip_path, "w") as archive:
archive.writestr("dist/index.html", "dashboard")
async def mock_download_update_package(*args, **kwargs):
"""Mock a core package download failure."""
calls.append("download-core")
raise RuntimeError("core download failed")
def fail_apply_update_package(*args, **kwargs):
"""Ensure core files are not applied after a download failure."""
calls.append("apply-core")
raise AssertionError("core package should not be applied")
def fail_extract_dashboard(*args, **kwargs):
"""Ensure dashboard files are not applied after a download failure."""
calls.append("apply-dashboard")
raise AssertionError("dashboard package should not be applied")
monkeypatch.setattr(
"astrbot.dashboard.routes.update.get_astrbot_system_tmp_path",
lambda: str(tmp_path / "tmp"),
)
monkeypatch.setattr(
"astrbot.dashboard.routes.update.download_dashboard",
mock_download_dashboard,
)
monkeypatch.setattr(
"astrbot.dashboard.routes.update.extract_dashboard",
fail_extract_dashboard,
)
monkeypatch.setattr(
core_lifecycle_td.astrbot_updator,
"download_update_package",
mock_download_update_package,
)
monkeypatch.setattr(
core_lifecycle_td.astrbot_updator,
"apply_update_package",
fail_apply_update_package,
)
response = await test_client.post(
"/api/update/do",
headers=authenticated_header,
json={"version": "v3.4.0", "reboot": False, "progress_id": "atomic-fail"},
)
data = await response.get_json()
assert response.status_code == 200
assert data["status"] == "ok"
progress_data = await _wait_for_update_progress(
test_client,
authenticated_header,
"atomic-fail",
)
assert progress_data["data"]["status"] == "error"
assert calls == ["download-dashboard", "download-core"]
@pytest.mark.asyncio
async def test_install_pip_package_returns_pip_install_error_message(
app: Quart,

View File

@@ -1,14 +1,17 @@
import ntpath
import posixpath
import zipfile
from dataclasses import dataclass, field
from pathlib import Path
from types import SimpleNamespace
from urllib.parse import urlparse
import certifi
import httpx
import pytest
from astrbot.core.star.updator import PluginUpdator
from astrbot.core.updator import AstrBotUpdator
from astrbot.core.zip_updator import RepoZipUpdator
@@ -286,6 +289,144 @@ async def test_plugin_updator_install_prefers_download_url(
assert calls["unzip"] == (str(expected_path) + ".zip", str(expected_path))
@pytest.mark.asyncio
async def test_astrbot_updator_prefers_hosted_core_package(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
monkeypatch.delenv("ASTRBOT_CLI", raising=False)
monkeypatch.delenv("ASTRBOT_LAUNCHER", raising=False)
monkeypatch.setenv("ASTRBOT_CORE_PACKAGE_BASE_URL", "https://cdn.example/core")
updator = AstrBotUpdator()
calls: list[str] = []
async def fake_fetch_release_info(url: str, latest: bool = True): # noqa: ARG001
return [
{
"version": "AstrBot v99.0.0",
"published_at": "2026-06-19T00:00:00Z",
"body": "hosted core package",
"tag_name": "v99.0.0",
"zipball_url": "https://github.example/archive.zip",
}
]
async def fake_download_file(url: str, path: str, progress_callback=None): # noqa: ARG001
calls.append(url)
with zipfile.ZipFile(path, "w") as archive:
archive.writestr("AstrBot-v99.0.0/README.md", "hosted-core")
monkeypatch.setattr(updator, "fetch_release_info", fake_fetch_release_info)
monkeypatch.setattr(updator, "_download_file", fake_download_file)
zip_path = await updator.download_update_package(
latest=False,
version="v99.0.0",
path=tmp_path / "core.zip",
)
assert zip_path == tmp_path / "core.zip"
assert zipfile.is_zipfile(zip_path)
assert calls == ["https://cdn.example/core/v99.0.0/source.zip"]
@pytest.mark.asyncio
async def test_astrbot_updator_falls_back_when_hosted_core_package_fails(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
monkeypatch.delenv("ASTRBOT_CLI", raising=False)
monkeypatch.delenv("ASTRBOT_LAUNCHER", raising=False)
monkeypatch.setenv("ASTRBOT_CORE_PACKAGE_BASE_URL", "https://cdn.example/core")
updator = AstrBotUpdator()
calls: list[str] = []
async def fake_fetch_release_info(url: str, latest: bool = True): # noqa: ARG001
return [
{
"version": "AstrBot v99.0.0",
"published_at": "2026-06-19T00:00:00Z",
"body": "hosted core package",
"tag_name": "v99.0.0",
"zipball_url": "https://github.example/archive.zip",
}
]
async def fake_download_file(url: str, path: str, progress_callback=None): # noqa: ARG001
calls.append(url)
parsed = urlparse(url)
if parsed.scheme == "https" and parsed.hostname == "cdn.example":
raise RuntimeError("404")
Path(path).write_bytes(b"github-core")
monkeypatch.setattr(updator, "fetch_release_info", fake_fetch_release_info)
monkeypatch.setattr(updator, "_download_file", fake_download_file)
zip_path = await updator.download_update_package(
latest=False,
version="v99.0.0",
path=tmp_path / "core.zip",
)
assert zip_path == tmp_path / "core.zip"
assert zip_path.read_bytes() == b"github-core"
assert calls == [
"https://cdn.example/core/v99.0.0/source.zip",
"https://github.example/archive.zip",
]
@pytest.mark.asyncio
async def test_astrbot_updator_falls_back_when_hosted_core_package_is_not_zip(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
monkeypatch.delenv("ASTRBOT_CLI", raising=False)
monkeypatch.delenv("ASTRBOT_LAUNCHER", raising=False)
monkeypatch.setenv("ASTRBOT_CORE_PACKAGE_BASE_URL", "https://cdn.example/core")
updator = AstrBotUpdator()
calls: list[str] = []
async def fake_fetch_release_info(url: str, latest: bool = True): # noqa: ARG001
return [
{
"version": "AstrBot v99.0.0",
"published_at": "2026-06-19T00:00:00Z",
"body": "hosted core package",
"tag_name": "v99.0.0",
"zipball_url": "https://github.example/archive.zip",
}
]
async def fake_download_file(url: str, path: str, progress_callback=None): # noqa: ARG001
calls.append(url)
parsed = urlparse(url)
if parsed.scheme == "https" and parsed.hostname == "cdn.example":
Path(path).write_bytes(b"not a zip")
return
with zipfile.ZipFile(path, "w") as archive:
archive.writestr("AstrBot-v99.0.0/README.md", "github-core")
monkeypatch.setattr(updator, "fetch_release_info", fake_fetch_release_info)
monkeypatch.setattr(updator, "_download_file", fake_download_file)
zip_path = await updator.download_update_package(
latest=False,
version="v99.0.0",
path=tmp_path / "core.zip",
)
assert zip_path == tmp_path / "core.zip"
assert zipfile.is_zipfile(zip_path)
assert calls == [
"https://cdn.example/core/v99.0.0/source.zip",
"https://github.example/archive.zip",
]
@pytest.mark.asyncio
async def test_fetch_release_info_uses_httpx_client_with_env_proxy_support(
monkeypatch: pytest.MonkeyPatch,