feat(scripts): add update.bat and rollback.bat launchers
Add user-facing launchers for the incremental update system: - update.bat: one-click update launcher - rollback.bat: rollback to previous version - tests/test_update_integration.py: integration tests for the update system - README.md: add incremental update guide (section 10) - .gitignore: add .update_cache/ entry Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
@@ -32,3 +32,7 @@ Thumbs.db
|
||||
|
||||
# OpenCode state
|
||||
.omo/
|
||||
|
||||
# Update cache (auto-generated by build_update.py)
|
||||
.update_cache/
|
||||
RUNTIME_CHANGED.txt
|
||||
|
||||
@@ -386,5 +386,150 @@ Remove-Item -Recurse *.pyc, __pycache__ -Force
|
||||
Remove-Item db.sqlite3-wal, db.sqlite3-shm -Force -ErrorAction SilentlyContinue
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 10. 增量更新(部署到离线机)
|
||||
|
||||
天璇支持**增量更新**,只需将变更代码打包成小 zip 包,复制到离线机双击 `update.bat` 即可完成更新,无需每次传输整个项目(1.5GB)。
|
||||
|
||||
### 10.1 首次部署
|
||||
|
||||
在离线机上第一次部署时,需要完成数据库迁移:
|
||||
|
||||
```bat
|
||||
:: 确保数据库在 %APPDATA%/TianXuan/ 下
|
||||
runtime\python\python.exe scripts\migrate_db_to_appdata.py
|
||||
|
||||
:: 初始化数据库表
|
||||
runtime\python\python.exe manage.py migrate
|
||||
|
||||
:: 导入 TLS 引用数据
|
||||
runtime\python\python.exe manage.py import_tlsdb
|
||||
```
|
||||
|
||||
> 如果之前已在使用天璇,`migrate_db_to_appdata.py` 会自动将已有 `db.sqlite3` 复制到新位置。
|
||||
|
||||
### 10.2 开发机:构建更新包
|
||||
|
||||
在**开发机**上完成代码修改并提交后:
|
||||
|
||||
```bash
|
||||
# 确保工作树干净(无未提交变更)
|
||||
git status
|
||||
|
||||
# 构建增量更新包
|
||||
python scripts/build_update.py
|
||||
```
|
||||
|
||||
脚本会自动:
|
||||
1. 检测当前 git tag 与最近一次 tag 之间的文件变更
|
||||
2. 计算每个文件的 SHA256 校验和
|
||||
3. 打包为 `tianxuan_update_<旧版本>-<新版本>.zip`
|
||||
4. 更新 `VERSION` 文件
|
||||
5. 如果检测到依赖变更,会输出提示信息
|
||||
|
||||
**输出示例**:
|
||||
```
|
||||
==================================================
|
||||
天璇 增量更新包构建工具
|
||||
==================================================
|
||||
|
||||
当前 VERSION: v1.0.0
|
||||
最新 git tag: v1.0
|
||||
|
||||
变更文件: 5 个修改/新增, 1 个删除
|
||||
|
||||
==================================================
|
||||
✅ 增量更新包构建完成!
|
||||
包: tianxuan_update_v1.0-v1.1.zip
|
||||
大小: 0.05 MB
|
||||
从: v1.0
|
||||
到: v1.1
|
||||
文件数: 5
|
||||
删除: 1
|
||||
==================================================
|
||||
```
|
||||
|
||||
### 10.3 离线机:应用更新
|
||||
|
||||
在**离线机**上:
|
||||
|
||||
1. 将 `tianxuan_update_*.zip` 复制到天璇项目目录(与 `run.bat` 同目录)
|
||||
2. **双击 `update.bat`**(或拖拽 zip 到 `update.bat` 上)
|
||||
3. 等待更新完成
|
||||
4. 关闭窗口,重新双击 `run.bat` 启动新版本
|
||||
|
||||
更新脚本会自动:
|
||||
1. ✅ 验证更新包完整性(SHA256 校验)
|
||||
2. ✅ 备份当前配置(`config/config.yaml`)
|
||||
3. ✅ 删除废弃文件
|
||||
4. ✅ 覆盖更新文件
|
||||
5. ✅ 智能合并配置(保留你的设置,仅增加新配置项)
|
||||
6. ✅ 清理 Python 缓存
|
||||
7. ✅ 运行数据库迁移
|
||||
8. ✅ 更新版本号
|
||||
|
||||
### 10.4 回滚
|
||||
|
||||
如果更新后出现问题:
|
||||
|
||||
```
|
||||
双击 rollback.bat
|
||||
→ 输入 y 确认回滚
|
||||
→ 等待恢复完成
|
||||
→ 重新双击 run.bat 启动旧版本
|
||||
```
|
||||
|
||||
回滚会从 `%APPDATA%/TianXuan/backups/` 恢复最近一次备份。
|
||||
|
||||
### 10.5 依赖变更时的特殊处理
|
||||
|
||||
当 `pyproject.toml` 或 `requirements.txt` 变更时(即添加/更新了 Python 依赖),构建脚本会输出警告:
|
||||
|
||||
```
|
||||
⚠️ 依赖已变更!请手动将 runtime/ 目录同步到离线机
|
||||
```
|
||||
|
||||
此时需要额外步骤:
|
||||
|
||||
1. 在开发机上将 `runtime/` 目录**完整压缩**(约 645MB)
|
||||
2. 复制到离线机,覆盖 `runtime/` 目录
|
||||
3. 或者使用差异更新:仅覆盖变更的包目录(`runtime/python/Lib/site-packages/`)
|
||||
|
||||
> 依赖变更是低频操作(通常只在添加新功能时需要),所以这不会影响日常的代码增量更新。
|
||||
|
||||
### 10.6 文件说明
|
||||
|
||||
| 文件 | 用途 | 需要 git 跟踪? |
|
||||
|------|------|----------------|
|
||||
| `VERSION` | 记录当前版本号 | ✅ 是 |
|
||||
| `scripts/build_update.py` | 开发机:构建增量包 | ✅ 是 |
|
||||
| `scripts/apply_update.py` | 离线机:应用更新 | ✅ 是 |
|
||||
| `scripts/merge_config.py` | 合并用户配置与源码配置 | ✅ 是 |
|
||||
| `scripts/migrate_db_to_appdata.py` | 首次迁移数据库到 APPDATA | ✅ 是 |
|
||||
| `scripts/version_utils.py` | 版本工具函数 | ✅ 是 |
|
||||
| `update.bat` | 离线机双击入口 | ✅ 是 |
|
||||
| `rollback.bat` | 回滚入口 | ✅ 是 |
|
||||
| `analysis/appdata.py` | APPDATA 路径共享模块 | ✅ 是 |
|
||||
| `.update_cache/runtime_snapshot.json` | 运行时依赖快照(自动生成)| ❌ 否 |
|
||||
| `RUNTIME_CHANGED.txt` | 依赖变更提示(自动生成)| ❌ 否 |
|
||||
|
||||
### 10.7 目录结构变化
|
||||
|
||||
更新系统将用户数据与项目代码分离:
|
||||
|
||||
```
|
||||
项目目录(可安全覆盖) 用户数据目录(永不覆盖)
|
||||
天璇/ %APPDATA%/TianXuan/
|
||||
├── analysis/ ← 代码 ├── db.sqlite3 ← 数据库
|
||||
├── config/config.yaml ← 配置 ├── data/uploads/ ← 上传的 CSV
|
||||
├── runtime/ ← 运行时 ├── .session_store.json
|
||||
├── templates/ ← 模板 └── backups/ ← 更新备份
|
||||
├── static/ ← 静态资源
|
||||
├── scripts/ ← 工具脚本
|
||||
├── update.bat ← 更新入口
|
||||
└── VERSION ← 版本号
|
||||
```
|
||||
|
||||
|
||||
```
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
@echo off
|
||||
chcp 65001 >nul
|
||||
cd /d "%~dp0"
|
||||
echo ================================================
|
||||
echo 天璇 回滚工具
|
||||
echo ================================================
|
||||
echo.
|
||||
echo 将从 %APPDATA%\TianXuan\backups\ 恢复最近一次备份
|
||||
echo.
|
||||
echo ⚠️ 警告:回滚操作会覆盖当前的项目文件!
|
||||
echo 这包括代码文件、配置等。
|
||||
echo.
|
||||
set /p CONFIRM=确认回滚?(y/N):
|
||||
if /i not "%CONFIRM%"=="y" (
|
||||
echo 已取消回滚
|
||||
pause
|
||||
exit /b 0
|
||||
)
|
||||
|
||||
:: Check portable Python exists
|
||||
if not exist "runtime\python\python.exe" (
|
||||
echo [ERROR] 找不到便携 Python 运行时
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
echo 正在回滚,请勿关闭此窗口...
|
||||
echo.
|
||||
runtime\python\python.exe scripts\apply_update.py --rollback
|
||||
if %ERRORLEVEL% neq 0 (
|
||||
echo.
|
||||
echo [ERROR] 回滚失败!请手动从备份目录恢复
|
||||
echo 备份位于: %APPDATA%\TianXuan\backups\
|
||||
pause
|
||||
exit /b %ERRORLEVEL%
|
||||
)
|
||||
|
||||
echo.
|
||||
echo ================================================
|
||||
echo ✅ 回滚完成!
|
||||
echo ================================================
|
||||
echo.
|
||||
echo 请关闭此窗口,然后重新双击 run.bat 启动回滚前的版本
|
||||
echo.
|
||||
pause
|
||||
@@ -0,0 +1,430 @@
|
||||
"""
|
||||
Integration tests for the 天璇 (TianXuan) incremental update system.
|
||||
|
||||
All tests run in isolated temporary directories and use a mocked APPDATA
|
||||
environment variable, so they never touch the real project data.
|
||||
|
||||
Tests verify the full update lifecycle:
|
||||
1. ZIP build structure (matching build_update.py output)
|
||||
2. File extraction and replacement (matching apply_update.py steps 5-6)
|
||||
3. Config merge with user-edit preservation (matching merge_config.py)
|
||||
4. Version skip / direct-jump updates
|
||||
5. Corrupted package rejection (CRC + SHA256 verification)
|
||||
6. Database migration from project dir to %APPDATA%
|
||||
"""
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
# Real project root from the test file location
|
||||
PROJECT_DIR = Path(__file__).resolve().parent.parent
|
||||
SCRIPTS_DIR = PROJECT_DIR / 'scripts'
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════
|
||||
# Test helpers
|
||||
# ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
def _create_test_project(tmp_dir: Path) -> Path:
|
||||
"""Create a simulated TianXuan project structure within a temp dir."""
|
||||
proj = tmp_dir / 'tianxuan'
|
||||
proj.mkdir()
|
||||
|
||||
# Core modules
|
||||
(proj / 'analysis').mkdir()
|
||||
(proj / 'analysis' / '__init__.py').write_text('# analysis module\n')
|
||||
(proj / 'tianxuan').mkdir()
|
||||
(proj / 'tianxuan' / '__init__.py').write_text('# settings module\n')
|
||||
|
||||
# Config
|
||||
(proj / 'config').mkdir()
|
||||
(proj / 'config' / 'config.yaml').write_text(
|
||||
'server:\n host: 127.0.0.1\n port: 8000\n'
|
||||
'llm:\n api_key: test-key-123\n'
|
||||
)
|
||||
|
||||
# Templates
|
||||
(proj / 'templates').mkdir()
|
||||
(proj / 'templates' / 'base.html').write_text('<html>Old Version</html>\n')
|
||||
|
||||
# Static assets
|
||||
(proj / 'static').mkdir()
|
||||
|
||||
# Scripts directory (placeholder — real scripts live outside temp dir)
|
||||
(proj / 'scripts').mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Version file
|
||||
(proj / 'VERSION').write_text('v1.0.0\n')
|
||||
|
||||
# A file that will be "deleted" in a test update
|
||||
(proj / 'old_module.py').write_text('# This will be removed\n')
|
||||
|
||||
return proj
|
||||
|
||||
|
||||
def _create_update_zip(project_dir: Path,
|
||||
from_ver: str = 'v1.0.0',
|
||||
to_ver: str = 'v1.1.0',
|
||||
add_files: dict | None = None,
|
||||
delete_files: list | None = None,
|
||||
run_migrate: bool = False) -> Path:
|
||||
"""Build a simulated update ZIP matching the build_update.py output format.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
project_dir : Path
|
||||
Directory where the ZIP is written.
|
||||
from_ver, to_ver : str
|
||||
Version range this update covers.
|
||||
add_files : dict[str, str] | None
|
||||
Mapping of relative path → file content for changed/new files.
|
||||
delete_files : list[str] | None
|
||||
Relative paths of files to remove.
|
||||
run_migrate : bool
|
||||
Whether the update advertises a schema migration.
|
||||
|
||||
Returns
|
||||
-------
|
||||
Path
|
||||
Full path to the created update ZIP.
|
||||
"""
|
||||
zip_path = project_dir / f'tianxuan_update_{from_ver}-{to_ver}.zip'
|
||||
|
||||
files = add_files or {'templates/base.html': '<html>New Version</html>\n'}
|
||||
deleted = delete_files or []
|
||||
|
||||
# Build manifest (same schema as build_update.py produces)
|
||||
files_manifest = {}
|
||||
with zipfile.ZipFile(zip_path, 'w', zipfile.ZIP_DEFLATED) as zf:
|
||||
for rel_path, content in files.items():
|
||||
arcname = f'files/{rel_path}'
|
||||
zf.writestr(arcname, content)
|
||||
sha256 = hashlib.sha256(content.encode()).hexdigest()
|
||||
files_manifest[rel_path] = {
|
||||
'sha256': sha256,
|
||||
'size': len(content),
|
||||
}
|
||||
|
||||
manifest = {
|
||||
'from_version': from_ver,
|
||||
'to_version': to_ver,
|
||||
'built_at': '2026-07-20T12:00:00Z',
|
||||
'files': files_manifest,
|
||||
'deleted': deleted,
|
||||
'run_migrate': run_migrate,
|
||||
'run_import_tlsdb': False,
|
||||
'runtime_changed': False,
|
||||
}
|
||||
zf.writestr('version.json', json.dumps(manifest, indent=2))
|
||||
|
||||
return zip_path
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════
|
||||
# Test class
|
||||
# ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
class TestUpdateSystem:
|
||||
"""Integration tests for the TianXuan incremental update system.
|
||||
|
||||
Every test creates its own isolated project tree + APPDATA directory
|
||||
and cleans up on teardown.
|
||||
"""
|
||||
|
||||
def setup_method(self):
|
||||
"""Create a fresh, isolated test environment before each test."""
|
||||
self._tmp_base = Path(tempfile.mkdtemp(suffix='_tianxuan_test'))
|
||||
self.project_dir = _create_test_project(self._tmp_base)
|
||||
|
||||
# Override APPDATA so scripts write to the temp directory
|
||||
self._orig_appdata = os.environ.get('APPDATA')
|
||||
self._test_appdata = self._tmp_base / 'appdata'
|
||||
self._test_appdata.mkdir()
|
||||
os.environ['APPDATA'] = str(self._test_appdata)
|
||||
|
||||
def teardown_method(self):
|
||||
"""Restore the environment and remove temp files."""
|
||||
if self._orig_appdata:
|
||||
os.environ['APPDATA'] = self._orig_appdata
|
||||
else:
|
||||
os.environ.pop('APPDATA', None)
|
||||
shutil.rmtree(str(self._tmp_base), ignore_errors=True)
|
||||
|
||||
# ────────────────────────────────────────────────────────────────
|
||||
# Test 1: Build → Apply simple code change
|
||||
# ────────────────────────────────────────────────────────────────
|
||||
|
||||
def test_simple_code_change(self):
|
||||
"""A single file is updated — verify extraction + replacement."""
|
||||
update_zip = _create_update_zip(
|
||||
self.project_dir,
|
||||
from_ver='v1.0.0', to_ver='v1.1.0',
|
||||
add_files={'templates/base.html': '<html>New Version</html>\n'},
|
||||
)
|
||||
|
||||
# 1) ZIP exists and is non-empty
|
||||
assert update_zip.exists()
|
||||
assert update_zip.stat().st_size > 0
|
||||
|
||||
# 2) Extract and apply (simulating apply_update.py steps 1, 6)
|
||||
with zipfile.ZipFile(update_zip, 'r') as zf:
|
||||
manifest = json.loads(zf.read('version.json'))
|
||||
assert manifest['from_version'] == 'v1.0.0'
|
||||
assert manifest['to_version'] == 'v1.1.0'
|
||||
assert 'templates/base.html' in manifest['files']
|
||||
|
||||
for name in zf.namelist():
|
||||
if not name.startswith('files/'):
|
||||
continue
|
||||
rel_path = name[6:] # strip 'files/' prefix
|
||||
if rel_path == 'config/config.yaml':
|
||||
continue # config is handled by the merge step
|
||||
zf.extract(name, str(self.project_dir))
|
||||
src = self.project_dir / name
|
||||
dst = self.project_dir / rel_path
|
||||
if src != dst:
|
||||
dst.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.move(str(src), str(dst))
|
||||
|
||||
# 3) The updated file has the new content
|
||||
updated = self.project_dir / 'templates' / 'base.html'
|
||||
assert updated.read_text() == '<html>New Version</html>\n'
|
||||
|
||||
# 4) Unchanged files are preserved
|
||||
config = self.project_dir / 'config' / 'config.yaml'
|
||||
assert 'test-key-123' in config.read_text()
|
||||
|
||||
# 5) Clean up the temporary files/ directory
|
||||
files_dir = self.project_dir / 'files'
|
||||
if files_dir.exists():
|
||||
shutil.rmtree(str(files_dir))
|
||||
|
||||
# ────────────────────────────────────────────────────────────────
|
||||
# Test 2: File deletion
|
||||
# ────────────────────────────────────────────────────────────────
|
||||
|
||||
def test_file_deletion(self):
|
||||
"""A file listed in ``deleted`` is removed from the project tree."""
|
||||
old_file = self.project_dir / 'old_module.py'
|
||||
assert old_file.exists()
|
||||
|
||||
update_zip = _create_update_zip(
|
||||
self.project_dir,
|
||||
from_ver='v1.0.0', to_ver='v1.1.0',
|
||||
add_files={},
|
||||
delete_files=['old_module.py'],
|
||||
)
|
||||
|
||||
# Apply deletion (simulating apply_update.py step 5)
|
||||
with zipfile.ZipFile(update_zip, 'r') as zf:
|
||||
manifest = json.loads(zf.read('version.json'))
|
||||
for rel_path in manifest.get('deleted', []):
|
||||
target = self.project_dir / rel_path
|
||||
if target.exists():
|
||||
target.unlink()
|
||||
|
||||
assert not old_file.exists(), 'Deleted file should no longer exist'
|
||||
|
||||
# ────────────────────────────────────────────────────────────────
|
||||
# Test 3: Config protection during update
|
||||
# ────────────────────────────────────────────────────────────────
|
||||
|
||||
def test_config_preservation(self):
|
||||
"""User edits to config.yaml survive an update; new keys are added.
|
||||
|
||||
This exercises the real ``merge_config.py`` script via subprocess.
|
||||
"""
|
||||
# -- Simulate a user who has edited their config.yml -----------
|
||||
config_path = self.project_dir / 'config' / 'config.yaml'
|
||||
user_edited = (
|
||||
'server:\n'
|
||||
' host: 0.0.0.0\n'
|
||||
' port: 9000\n'
|
||||
'llm:\n'
|
||||
' api_key: my-custom-key\n'
|
||||
'clustering:\n'
|
||||
' algorithm: kmeans\n'
|
||||
)
|
||||
config_path.write_text(user_edited)
|
||||
|
||||
# -- Build an update that ships a *different* config -----------
|
||||
source_config = (
|
||||
'server:\n'
|
||||
' host: 127.0.0.1\n'
|
||||
' port: 8000\n'
|
||||
'llm:\n'
|
||||
' api_key: default-key\n'
|
||||
' model: gpt-4\n' # NEW key — should appear after merge
|
||||
'clustering:\n'
|
||||
' algorithm: hdbscan\n'
|
||||
' min_cluster_size: 10\n' # NEW key — should appear after merge
|
||||
)
|
||||
update_zip = _create_update_zip(
|
||||
self.project_dir,
|
||||
add_files={'config/config.yaml': source_config},
|
||||
)
|
||||
|
||||
# -- Back up user config (apply_update.py step 4) --------------
|
||||
backup_dir = self._test_appdata / 'TianXuan' / 'backups'
|
||||
backup_dir.mkdir(parents=True, exist_ok=True)
|
||||
backup_config = backup_dir / 'v1.1.0' / 'config.yaml'
|
||||
backup_config.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copy2(str(config_path), str(backup_config))
|
||||
|
||||
# -- Extract the new config to ``files/`` (step 6) -------------
|
||||
with zipfile.ZipFile(update_zip, 'r') as zf:
|
||||
zf.extract('files/config/config.yaml', str(self.project_dir))
|
||||
extracted_config = self.project_dir / 'files' / 'config' / 'config.yaml'
|
||||
assert extracted_config.exists()
|
||||
|
||||
# -- Merge (step 7): old = user backup, new = extracted source --
|
||||
merge_script = SCRIPTS_DIR / 'merge_config.py'
|
||||
result = subprocess.run(
|
||||
[sys.executable, str(merge_script),
|
||||
'--old', str(backup_config),
|
||||
'--new', str(extracted_config),
|
||||
'--output', str(config_path)],
|
||||
capture_output=True, text=True,
|
||||
cwd=str(PROJECT_DIR),
|
||||
)
|
||||
assert result.returncode == 0, f'merge_config failed: {result.stderr}'
|
||||
|
||||
merged = config_path.read_text()
|
||||
|
||||
# User edits preserved
|
||||
assert 'my-custom-key' in merged, 'User API key should survive'
|
||||
assert '0.0.0.0' in merged, 'User host should survive'
|
||||
assert 'kmeans' in merged, 'User algorithm should survive'
|
||||
|
||||
# New keys from the update added
|
||||
assert 'model: gpt-4' in merged, 'New key from source should appear'
|
||||
assert 'min_cluster_size: 10' in merged, 'New nested key should appear'
|
||||
|
||||
# ────────────────────────────────────────────────────────────────
|
||||
# Test 4: Version skip
|
||||
# ────────────────────────────────────────────────────────────────
|
||||
|
||||
def test_version_skip(self):
|
||||
"""A direct v1.0→v1.2 bundle includes ALL intermediate changes.
|
||||
|
||||
``git diff v1.0..HEAD`` shows every change regardless of whether
|
||||
intermediate tags exist, so the build script naturally produces
|
||||
correct "skipped-version" packages. This test verifies the
|
||||
manifest accurately describes the jump.
|
||||
"""
|
||||
update_zip = _create_update_zip(
|
||||
self.project_dir,
|
||||
from_ver='v1.0.0', to_ver='v1.2.0',
|
||||
add_files={
|
||||
'templates/base.html': '<html>v1.2</html>\n',
|
||||
'analysis/new_feature.py': '# Feature added in v1.2\n',
|
||||
},
|
||||
delete_files=['old_module.py'],
|
||||
)
|
||||
|
||||
with zipfile.ZipFile(update_zip, 'r') as zf:
|
||||
manifest = json.loads(zf.read('version.json'))
|
||||
|
||||
assert manifest['from_version'] == 'v1.0.0'
|
||||
assert manifest['to_version'] == 'v1.2.0'
|
||||
assert len(manifest['files']) == 2
|
||||
assert len(manifest['deleted']) == 1
|
||||
|
||||
# ────────────────────────────────────────────────────────────────
|
||||
# Test 5: Corrupted package rejection
|
||||
# ────────────────────────────────────────────────────────────────
|
||||
|
||||
def test_corrupted_package_rejection(self):
|
||||
"""CRC corruption inside a ZIP is caught by ``testzip()``.
|
||||
|
||||
A file's stored content is modified in-place within the ZIP
|
||||
binary, causing a CRC32 mismatch that ``zipfile.testzip()``
|
||||
reliably detects. The update should be rejected before any
|
||||
files are extracted.
|
||||
"""
|
||||
# Use ZIP_STORED (no compression) so the content appears as
|
||||
# plain text in the archive and can be surgically corrupted.
|
||||
content = b'<html>OK</html>\n'
|
||||
h = hashlib.sha256(content).hexdigest()
|
||||
|
||||
manifest = {
|
||||
'from_version': 'v1.0.0',
|
||||
'to_version': 'v1.1.0',
|
||||
'built_at': '2026-07-20T12:00:00Z',
|
||||
'files': {
|
||||
'templates/base.html': {
|
||||
'sha256': h,
|
||||
'size': len(content),
|
||||
},
|
||||
},
|
||||
'deleted': [],
|
||||
'run_migrate': False,
|
||||
'run_import_tlsdb': False,
|
||||
'runtime_changed': False,
|
||||
}
|
||||
|
||||
zip_path = self.project_dir / 'corrupt_test.zip'
|
||||
with zipfile.ZipFile(zip_path, 'w', zipfile.ZIP_STORED) as zf:
|
||||
zf.writestr('version.json', json.dumps(manifest, indent=2))
|
||||
zf.writestr('files/templates/base.html', content)
|
||||
|
||||
# Corrupt: replace the stored content in-place
|
||||
raw = bytearray(zip_path.read_bytes())
|
||||
idx = raw.find(b'<html>OK</html>')
|
||||
assert idx != -1, 'Should find the target pattern in raw ZIP bytes'
|
||||
raw[idx:idx + len(b'<html>OK</html>')] = b'<html>XX</html>'
|
||||
zip_path.write_bytes(bytes(raw))
|
||||
|
||||
# The CRC check in apply_update.py step 1 must fail
|
||||
with zipfile.ZipFile(zip_path, 'r') as zf:
|
||||
bad_file = zf.testzip()
|
||||
assert bad_file is not None, (
|
||||
'Corrupted ZIP should be detected by testzip()'
|
||||
)
|
||||
|
||||
# ────────────────────────────────────────────────────────────────
|
||||
# Test 6: Database migration from project dir to %APPDATA%
|
||||
# ────────────────────────────────────────────────────────────────
|
||||
|
||||
def test_db_migration_script(self):
|
||||
"""The migration script copies ``db.sqlite3`` to ``%APPDATA%/TianXuan/``.
|
||||
|
||||
Because ``migrate_db_to_appdata.py`` locates its project root
|
||||
from ``__file__`` (the real TianXuan project where ``db.sqlite3``
|
||||
exists), running it with ``APPDATA`` pointing to the temp
|
||||
directory copies the real database into the isolated test tree
|
||||
without modifying any real project files.
|
||||
"""
|
||||
# The real project has a db.sqlite3 that the script will find.
|
||||
migrate_script = SCRIPTS_DIR / 'migrate_db_to_appdata.py'
|
||||
|
||||
# First run — should copy the database
|
||||
result = subprocess.run(
|
||||
[sys.executable, str(migrate_script)],
|
||||
capture_output=True, text=True,
|
||||
cwd=str(PROJECT_DIR),
|
||||
)
|
||||
assert result.returncode == 0, f'Migration failed: {result.stderr}'
|
||||
|
||||
expected_new = self._test_appdata / 'TianXuan' / 'db.sqlite3'
|
||||
assert expected_new.exists(), 'DB should exist at APPDATA location'
|
||||
assert expected_new.stat().st_size > 0, 'Copied DB should not be empty'
|
||||
|
||||
# Second run — should SKIP (already present)
|
||||
result2 = subprocess.run(
|
||||
[sys.executable, str(migrate_script)],
|
||||
capture_output=True, text=True,
|
||||
cwd=str(PROJECT_DIR),
|
||||
)
|
||||
assert result2.returncode == 0
|
||||
assert 'SKIP' in result2.stdout, 'Second run should print SKIP'
|
||||
+56
@@ -0,0 +1,56 @@
|
||||
@echo off
|
||||
chcp 65001 >nul
|
||||
cd /d "%~dp0"
|
||||
echo ================================================
|
||||
echo 天璇 增量更新工具
|
||||
echo ================================================
|
||||
echo.
|
||||
|
||||
:: Auto-detect update package
|
||||
set ZIP_FILE=%1
|
||||
if "%ZIP_FILE%"=="" (
|
||||
for %%f in (tianxuan_update_*.zip) do set ZIP_FILE=%%f
|
||||
)
|
||||
if "%ZIP_FILE%"=="" (
|
||||
echo [ERROR] 未找到任何 tianxuan_update_*.zip 文件
|
||||
echo 请将更新包(tianxuan_update_*.zip)放到本目录后重试
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
if not exist "%ZIP_FILE%" (
|
||||
echo [ERROR] 找不到更新包: %ZIP_FILE%
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
echo 更新包: %ZIP_FILE%
|
||||
echo.
|
||||
|
||||
:: Check portable Python exists
|
||||
if not exist "runtime\python\python.exe" (
|
||||
echo [ERROR] 找不到便携 Python 运行时
|
||||
echo 请确保 runtime\python\python.exe 存在
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
:: Run the update script
|
||||
echo 正在应用更新,请勿关闭此窗口...
|
||||
echo.
|
||||
runtime\python\python.exe scripts\apply_update.py "%ZIP_FILE%"
|
||||
if %ERRORLEVEL% neq 0 (
|
||||
echo.
|
||||
echo [ERROR] 更新失败!错误代码: %ERRORLEVEL%
|
||||
echo 请运行 rollback.bat 回滚到更新前的状态
|
||||
pause
|
||||
exit /b %ERRORLEVEL%
|
||||
)
|
||||
|
||||
echo.
|
||||
echo ================================================
|
||||
echo ✅ 更新完成!
|
||||
echo ================================================
|
||||
echo.
|
||||
echo 请关闭此窗口,然后重新双击 run.bat 启动新版本
|
||||
echo.
|
||||
pause
|
||||
Reference in New Issue
Block a user