Initial commit: iOS Build Server

- FastAPI backend with build queue, WebSocket logs, task management
- Vue 3 frontend with build/config/history views
- Xcode project build automation with IPA export
- Fix: initialize build_dir before try block to ensure cleanup on early failure
This commit is contained in:
shen
2026-06-06 17:42:27 +08:00
commit 6f4f625c56
51 changed files with 9968 additions and 0 deletions
+292
View File
@@ -0,0 +1,292 @@
"""打包服务核心逻辑测试"""
import asyncio
import json
import os
import time
import shutil
import tempfile
from pathlib import Path
from unittest.mock import patch, AsyncMock, MagicMock
import pytest
from backend.services.build_service import (
update_source,
copy_source_code,
generate_config,
_cleanup_old_builds,
)
from backend.services.log_streamer import LogStreamer
@pytest.fixture
def log_streamer():
return LogStreamer()
@pytest.fixture
def tmp_dirs(tmp_path):
"""创建临时源码目录和打包目录"""
source_dir = tmp_path / "source"
source_dir.mkdir()
(source_dir / "readoor").mkdir()
(source_dir / "readoor" / "AppDelegate.swift").write_text("// app")
(source_dir / "readoor.xcodeproj").mkdir()
(source_dir / "Pods").mkdir()
(source_dir / "Podfile.lock").write_text("PODFILE CHECKSUM: abc")
(source_dir / "readoor.xcworkspace").mkdir()
(source_dir / "AutoPacking").mkdir()
(source_dir / "readoorTests").mkdir()
(source_dir / "podfile").write_text("pod 'AFNetworking'")
build_dir = tmp_path / "build"
return source_dir, build_dir
def _make_mock_process(returncode=0, output=b"ok\n"):
"""创建 mock 进程,支持 async for stdout"""
proc = AsyncMock()
proc.returncode = returncode
proc.wait = AsyncMock(return_value=returncode)
async def iter_stdout():
yield output
proc.stdout = iter_stdout()
return proc
# ---- update_source ----
async def test_update_source_clone(tmp_path, log_streamer):
"""目录不存在时执行 clone"""
source_dir = tmp_path / "branches" / "main"
source_dir.parent.mkdir()
with patch("backend.services.build_service.GIT_REMOTE_URL", "git@github.com:test/repo.git"):
with patch("asyncio.create_subprocess_exec", return_value=_make_mock_process()) as mock_exec:
await update_source("t1", source_dir, "main")
mock_exec.assert_called_once()
args = mock_exec.call_args[0]
assert "git" in args
assert "clone" in args
async def test_update_source_clone_no_remote(tmp_path, log_streamer):
"""目录不存在且未配置远程仓库时抛异常"""
source_dir = tmp_path / "branches" / "main"
with patch("backend.services.build_service.GIT_REMOTE_URL", ""):
with pytest.raises(Exception, match="GIT_REMOTE_URL"):
await update_source("t1", source_dir, "main")
async def test_update_source_pull_existing(tmp_path, log_streamer):
"""目录已存在时执行 fetch + checkout + pull"""
source_dir = tmp_path / "branches" / "dev"
source_dir.parent.mkdir(parents=True)
source_dir.mkdir()
(source_dir / ".git").mkdir()
call_count = 0
async def mock_exec(*args, **kwargs):
nonlocal call_count
call_count += 1
return _make_mock_process()
with patch("asyncio.create_subprocess_exec", side_effect=mock_exec):
await update_source("t1", source_dir, "dev")
assert call_count == 3 # fetch, checkout, pull
async def test_update_source_fetch_failure(tmp_path, log_streamer):
"""fetch 失败时抛异常"""
source_dir = tmp_path / "branches" / "dev"
source_dir.parent.mkdir(parents=True)
source_dir.mkdir()
async def mock_exec(*args, **kwargs):
return _make_mock_process(returncode=1, output=b"error\n")
with patch("asyncio.create_subprocess_exec", side_effect=mock_exec):
with pytest.raises(Exception, match="git fetch 失败"):
await update_source("t1", source_dir, "dev")
# ---- copy_source_code ----
async def test_copy_source_code(tmp_dirs, log_streamer):
"""正常拷贝源码"""
source_dir, build_dir_parent = tmp_dirs
task = MagicMock()
task.branch = "main"
with patch("backend.services.build_service.BUILD_BASE_DIR", build_dir_parent):
result = await copy_source_code("t1", task, source_dir)
assert result.exists()
assert (result / "readoor" / "AppDelegate.swift").exists()
assert (result / "Pods").exists()
assert (result / "Podfile.lock").exists()
assert (result / "readoor.xcworkspace").exists()
async def test_copy_source_code_excludes_git(tmp_dirs, log_streamer):
"""拷贝时排除 .git 目录"""
source_dir, build_dir_parent = tmp_dirs
(source_dir / "readoor" / ".git").mkdir()
task = MagicMock()
with patch("backend.services.build_service.BUILD_BASE_DIR", build_dir_parent):
result = await copy_source_code("t1", task, source_dir)
assert not (result / "readoor" / ".git").exists()
async def test_copy_source_code_overwrites_existing(tmp_dirs, log_streamer):
"""打包目录已存在时覆盖"""
source_dir, build_dir_parent = tmp_dirs
old_dir = build_dir_parent / "build_readoor_t1"
old_dir.mkdir(parents=True)
(old_dir / "old_file.txt").write_text("old")
task = MagicMock()
with patch("backend.services.build_service.BUILD_BASE_DIR", build_dir_parent):
result = await copy_source_code("t1", task, source_dir)
assert not (result / "old_file.txt").exists()
assert (result / "readoor").exists()
# ---- generate_config ----
async def test_generate_config(tmp_path, log_streamer):
"""生成打包配置"""
build_dir = tmp_path / "build"
build_dir.mkdir()
task = MagicMock()
task.app_id = "1"
task.scheme_id = "1"
task.build_type = "Ad_Hoc"
task.obfuscation = True
mock_config = {
"apps": {
"1": {
"name": "测试App",
"server": "测试环境",
"AppGuid": "guid-123",
"API": "https://test.api.com",
"certificates": {
"Ad_Hoc": {"name": "com.test.app", "cer": "cert", "pro": "pro", "theme": "t"}
},
}
},
"schemes": {"1": {"name": "testScheme", "ossFloder": "test"}},
}
with patch("backend.services.build_service.AUTOPACKING_DIR", tmp_path / "AutoPacking"):
with patch("backend.routers.config.load_config", return_value=mock_config):
result = await generate_config("t1", task, build_dir)
assert result["APPID"] == "guid-123"
assert result["SCHEME"] == "testScheme"
assert result["BUILD_TYPE"] == "Ad_Hoc"
assert result["BUNDLE_ID"] == "com.test.app"
assert result["CERTIFICATE"] == "cert"
config_file = build_dir / "config_output.json"
assert config_file.exists()
saved = json.loads(config_file.read_text())
assert saved["APPID"] == "guid-123"
async def test_generate_config_reads_version(tmp_path, log_streamer):
"""从 start_build_app.py 读取版本号"""
build_dir = tmp_path / "build"
build_dir.mkdir()
autopacking = tmp_path / "AutoPacking"
autopacking.mkdir()
(autopacking / "start_build_app.py").write_text(
'App_Ver = "3.0.1"\nBuild_Ver = "3.0.1.0"\n'
)
task = MagicMock()
task.app_id = "1"
task.scheme_id = "1"
task.build_type = "Ad_Hoc"
task.obfuscation = False
mock_config = {
"apps": {"1": {"name": "App", "certificates": {}}},
"schemes": {"1": {"name": "sch", "ossFloder": "f"}},
}
with patch("backend.services.build_service.AUTOPACKING_DIR", autopacking):
with patch("backend.routers.config.load_config", return_value=mock_config):
result = await generate_config("t1", task, build_dir)
assert result["VERSION"] == "3.0.1"
assert result["BUILD_VERSION"] == "3.0.1.0"
# ---- _cleanup_old_builds ----
def test_cleanup_old_builds(tmp_path):
"""清理过期打包目录"""
from backend.database import SessionLocal, init_db
from backend.models import BuildConfig
init_db()
db = SessionLocal()
config = db.query(BuildConfig).filter(BuildConfig.key == "build_dir_retention_hours").first()
if config:
config.value = "1"
else:
db.add(BuildConfig(key="build_dir_retention_hours", value="1"))
db.commit()
old_dir = tmp_path / "build_readoor_old"
old_dir.mkdir()
# 设置修改时间为 24 小时前(确保超过 1 小时保留期)
old_time = time.time() - 86400
os.utime(old_dir, (old_time, old_time))
with patch("backend.services.build_service.BUILD_BASE_DIR", tmp_path):
_cleanup_old_builds(db)
assert not old_dir.exists()
db.close()
def test_cleanup_keeps_recent(tmp_path):
"""保留未过期的打包目录"""
from backend.database import SessionLocal, init_db
from backend.models import BuildConfig
init_db()
db = SessionLocal()
config = db.query(BuildConfig).filter(BuildConfig.key == "build_dir_retention_hours").first()
if config:
config.value = "24"
else:
db.add(BuildConfig(key="build_dir_retention_hours", value="24"))
db.commit()
recent_dir = tmp_path / "build_readoor_recent"
recent_dir.mkdir()
with patch("backend.services.build_service.BUILD_BASE_DIR", tmp_path):
_cleanup_old_builds(db)
assert recent_dir.exists()
db.close()