feat: 服务端集成自动打包流程
This commit is contained in:
+6
-3
@@ -20,15 +20,18 @@ from backend.database import init_db, engine, Base
|
||||
def setup_db():
|
||||
"""每个测试前重建数据库"""
|
||||
Base.metadata.drop_all(bind=engine)
|
||||
Base.metadata.create_all(bind=engine)
|
||||
init_db()
|
||||
yield
|
||||
Base.metadata.drop_all(bind=engine)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client():
|
||||
"""FastAPI 测试客户端"""
|
||||
return TestClient(app)
|
||||
"""已登录的 FastAPI 测试客户端"""
|
||||
test_client = TestClient(app)
|
||||
response = test_client.post("/api/auth/login", json={"username": "admin", "password": "admin123"})
|
||||
test_client.headers.update({"Authorization": f"Bearer {response.json()['token']}"})
|
||||
return test_client
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
|
||||
@@ -5,7 +5,8 @@ def test_login_success(client):
|
||||
resp = client.post("/api/auth/login", json={"username": "admin", "password": "admin123"})
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["token"] == "admin-token"
|
||||
assert isinstance(data["token"], str)
|
||||
assert data["token"]
|
||||
assert data["is_admin"] is True
|
||||
|
||||
|
||||
|
||||
+24
-109
@@ -1,118 +1,33 @@
|
||||
"""打包队列测试"""
|
||||
"""打包队列日志时序测试"""
|
||||
import asyncio
|
||||
|
||||
import pytest
|
||||
|
||||
from backend.services.build_queue import BuildQueue
|
||||
from backend.services.log_streamer import log_streamer
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def queue():
|
||||
return BuildQueue(max_concurrent=2)
|
||||
@pytest.mark.asyncio
|
||||
async def test_submit_creates_log_queue_before_worker_runs():
|
||||
"""前端订阅任务时,应能拿到入队日志和后续执行日志。"""
|
||||
task_id = "queue-log-test"
|
||||
log_streamer.cleanup(task_id)
|
||||
log_streamer._log_lines.pop(task_id, None)
|
||||
|
||||
queue = BuildQueue(max_concurrent=1)
|
||||
|
||||
def test_initial_state(queue):
|
||||
"""初始状态"""
|
||||
assert queue.queue_size == 0
|
||||
assert queue.running_count == 0
|
||||
async def build(task_id: str):
|
||||
await log_streamer.emit(task_id, "构建函数已执行")
|
||||
|
||||
try:
|
||||
await queue.submit(task_id, build)
|
||||
await asyncio.wait_for(queue._queue.join(), timeout=3)
|
||||
|
||||
def test_submit_starts_workers(queue):
|
||||
"""提交任务后自动启动 worker"""
|
||||
|
||||
async def dummy_build(task_id):
|
||||
pass
|
||||
|
||||
async def run():
|
||||
await queue.submit("t1", dummy_build)
|
||||
# 等待任务完成
|
||||
await asyncio.sleep(0.1)
|
||||
assert queue._started
|
||||
assert len(queue._workers) == 2
|
||||
|
||||
asyncio.get_event_loop().run_until_complete(run())
|
||||
|
||||
|
||||
def test_task_executes(queue):
|
||||
"""任务被执行"""
|
||||
executed = []
|
||||
|
||||
async def mock_build(task_id):
|
||||
executed.append(task_id)
|
||||
|
||||
async def run():
|
||||
await queue.submit("t1", mock_build)
|
||||
await asyncio.sleep(0.2)
|
||||
return executed
|
||||
|
||||
result = asyncio.get_event_loop().run_until_complete(run())
|
||||
assert "t1" in result
|
||||
|
||||
|
||||
def test_concurrent_limit(queue):
|
||||
"""并发数不超过限制"""
|
||||
running = []
|
||||
max_concurrent = 0
|
||||
|
||||
async def slow_build(task_id):
|
||||
nonlocal max_concurrent
|
||||
running.append(task_id)
|
||||
max_concurrent = max(max_concurrent, len(running))
|
||||
await asyncio.sleep(0.3)
|
||||
running.remove(task_id)
|
||||
|
||||
async def run():
|
||||
tasks = []
|
||||
for i in range(5):
|
||||
tasks.append(queue.submit(f"t{i}", slow_build))
|
||||
await asyncio.gather(*tasks)
|
||||
await asyncio.sleep(1) # 等所有任务完成
|
||||
return max_concurrent
|
||||
|
||||
result = asyncio.get_event_loop().run_until_complete(run())
|
||||
assert result <= 2 # max_concurrent = 2
|
||||
|
||||
|
||||
def test_cancel_running_task(queue):
|
||||
"""取消运行中的任务"""
|
||||
cancelled = False
|
||||
|
||||
async def long_build(task_id):
|
||||
nonlocal cancelled
|
||||
try:
|
||||
await asyncio.sleep(10)
|
||||
except asyncio.CancelledError:
|
||||
cancelled = True
|
||||
raise
|
||||
|
||||
async def run():
|
||||
await queue.submit("t1", long_build)
|
||||
await asyncio.sleep(0.1) # 等任务开始
|
||||
queue.cancel("t1")
|
||||
await asyncio.sleep(0.2) # 等取消生效
|
||||
return cancelled
|
||||
|
||||
result = asyncio.get_event_loop().run_until_complete(run())
|
||||
assert result is True
|
||||
|
||||
|
||||
def test_cancel_nonexistent_task(queue):
|
||||
"""取消不存在的任务(不应报错)"""
|
||||
queue.cancel("nonexistent")
|
||||
|
||||
|
||||
def test_multiple_tasks(queue):
|
||||
"""多个任务顺序执行"""
|
||||
completed = []
|
||||
|
||||
async def mock_build(task_id):
|
||||
await asyncio.sleep(0.05)
|
||||
completed.append(task_id)
|
||||
|
||||
async def run():
|
||||
for i in range(4):
|
||||
await queue.submit(f"t{i}", mock_build)
|
||||
await asyncio.sleep(1)
|
||||
return completed
|
||||
|
||||
result = asyncio.get_event_loop().run_until_complete(run())
|
||||
assert len(result) == 4
|
||||
assert set(result) == {"t0", "t1", "t2", "t3"}
|
||||
messages = [entry["message"] for entry in log_streamer._log_lines[task_id]]
|
||||
assert messages == ["任务已加入队列,等待执行...", "构建函数已执行"]
|
||||
finally:
|
||||
for worker in queue._workers:
|
||||
worker.cancel()
|
||||
await asyncio.gather(*queue._workers, return_exceptions=True)
|
||||
log_streamer.cleanup(task_id)
|
||||
log_streamer._log_lines.pop(task_id, None)
|
||||
|
||||
+48
-21
@@ -36,6 +36,9 @@ def tmp_dirs(tmp_path):
|
||||
(source_dir / "Podfile.lock").write_text("PODFILE CHECKSUM: abc")
|
||||
(source_dir / "readoor.xcworkspace").mkdir()
|
||||
(source_dir / "AutoPacking").mkdir()
|
||||
vendor_dir = source_dir / "Vendor" / "RDEpubReaderView"
|
||||
vendor_dir.mkdir(parents=True)
|
||||
(vendor_dir / "RDEpubReaderView.podspec").write_text("Pod::Spec.new do |s| end")
|
||||
(source_dir / "readoorTests").mkdir()
|
||||
(source_dir / "podfile").write_text("pod 'AFNetworking'")
|
||||
|
||||
@@ -63,7 +66,7 @@ async def test_update_source_clone(tmp_path, log_streamer):
|
||||
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("backend.services.build_service.get_git_remote_url", return_value="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()
|
||||
@@ -76,7 +79,7 @@ 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 patch("backend.services.build_service.get_git_remote_url", return_value=""):
|
||||
with pytest.raises(Exception, match="GIT_REMOTE_URL"):
|
||||
await update_source("t1", source_dir, "main")
|
||||
|
||||
@@ -95,8 +98,9 @@ async def test_update_source_pull_existing(tmp_path, log_streamer):
|
||||
call_count += 1
|
||||
return _make_mock_process()
|
||||
|
||||
with patch("asyncio.create_subprocess_exec", side_effect=mock_exec):
|
||||
await update_source("t1", source_dir, "dev")
|
||||
with patch("backend.services.build_service.get_git_remote_url", return_value=""):
|
||||
with patch("asyncio.create_subprocess_exec", side_effect=mock_exec):
|
||||
await update_source("t1", source_dir, "dev")
|
||||
|
||||
assert call_count == 3 # fetch, checkout, pull
|
||||
|
||||
@@ -110,9 +114,10 @@ async def test_update_source_fetch_failure(tmp_path, log_streamer):
|
||||
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")
|
||||
with patch("backend.services.build_service.get_git_remote_url", return_value=""):
|
||||
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 ----
|
||||
@@ -132,6 +137,8 @@ async def test_copy_source_code(tmp_dirs, log_streamer):
|
||||
assert (result / "Pods").exists()
|
||||
assert (result / "Podfile.lock").exists()
|
||||
assert (result / "readoor.xcworkspace").exists()
|
||||
assert (result / "Vendor" / "RDEpubReaderView" / "RDEpubReaderView.podspec").exists()
|
||||
assert not (result / "AutoPacking").exists()
|
||||
|
||||
|
||||
async def test_copy_source_code_excludes_git(tmp_dirs, log_streamer):
|
||||
@@ -191,9 +198,8 @@ async def test_generate_config(tmp_path, log_streamer):
|
||||
"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)
|
||||
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"
|
||||
@@ -207,17 +213,11 @@ async def test_generate_config(tmp_path, log_streamer):
|
||||
assert saved["APPID"] == "guid-123"
|
||||
|
||||
|
||||
async def test_generate_config_reads_version(tmp_path, log_streamer):
|
||||
"""从 start_build_app.py 读取版本号"""
|
||||
async def test_generate_config_uses_saved_version(tmp_path, log_streamer):
|
||||
"""从服务配置读取版本号,不依赖分支源码脚本"""
|
||||
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"
|
||||
@@ -227,16 +227,43 @@ async def test_generate_config_reads_version(tmp_path, log_streamer):
|
||||
mock_config = {
|
||||
"apps": {"1": {"name": "App", "certificates": {}}},
|
||||
"schemes": {"1": {"name": "sch", "ossFloder": "f"}},
|
||||
"versions": {"app_ver": "3.0.1", "build_ver": "3.0.1.0"},
|
||||
}
|
||||
|
||||
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)
|
||||
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"
|
||||
|
||||
|
||||
async def test_generate_config_uses_server_theme_not_source_autopacking(tmp_path, log_streamer):
|
||||
"""Web 打包不读取分支源码中的 AutoPacking 目录。"""
|
||||
build_dir = tmp_path / "build"
|
||||
(build_dir / "AutoPacking" / "ymh").mkdir(parents=True)
|
||||
automation_dir = tmp_path / "automation"
|
||||
default_theme = automation_dir / "themes" / "ymh"
|
||||
default_theme.mkdir(parents=True)
|
||||
|
||||
task = MagicMock(app_id="1", scheme_id="1", build_type="Ad_Hoc", obfuscation=False)
|
||||
mock_config = {
|
||||
"apps": {
|
||||
"1": {
|
||||
"name": "App",
|
||||
"certificates": {"Ad_Hoc": {"name": "com.example.app", "theme": "AutoPacking/ymh"}},
|
||||
}
|
||||
},
|
||||
"schemes": {"1": {"name": "sch", "ossFloder": "f"}},
|
||||
}
|
||||
|
||||
with patch("backend.routers.config.load_config", return_value=mock_config), \
|
||||
patch("backend.services.build_service.AUTOMATION_DIR", automation_dir), \
|
||||
patch("backend.services.build_service.SKINS_DIR", tmp_path / "skins"):
|
||||
result = await generate_config("t1", task, build_dir)
|
||||
|
||||
assert result["THEME"] == str(default_theme)
|
||||
|
||||
|
||||
# ---- _cleanup_old_builds ----
|
||||
|
||||
def test_cleanup_old_builds(tmp_path):
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
"""服务端分发产物生成测试。"""
|
||||
import plistlib
|
||||
|
||||
from backend.services.distribution import _write_distribution_files, _write_download_page, _write_manifest
|
||||
|
||||
|
||||
def test_distribution_files_use_current_service_config(tmp_path):
|
||||
ipa = tmp_path / "source.ipa"
|
||||
ipa.write_bytes(b"ipa")
|
||||
config = {
|
||||
"APPID": "100", "VERSION": "2.0.0", "APPID_NAME": "Test App",
|
||||
"BUNDLE_ID": "com.example.test",
|
||||
}
|
||||
ipa_file, manifest, html = _write_distribution_files(config, ipa, tmp_path / "distribution")
|
||||
_write_manifest(config, manifest, "https://files.example.com/app.ipa")
|
||||
_write_download_page(config, html, "https://files.example.com/app.plist")
|
||||
|
||||
with manifest.open("rb") as f:
|
||||
plist = plistlib.load(f)
|
||||
assert ipa_file.read_bytes() == b"ipa"
|
||||
assert plist["items"][0]["metadata"]["bundle-identifier"] == "com.example.test"
|
||||
assert "itms-services://" in html.read_text(encoding="utf-8")
|
||||
@@ -0,0 +1,54 @@
|
||||
"""服务端工程配置替换测试。"""
|
||||
import plistlib
|
||||
|
||||
from backend.services.project_patcher import apply_project_config
|
||||
|
||||
|
||||
def test_apply_project_config_without_branch_autopacking(tmp_path):
|
||||
build_dir = tmp_path / "build"
|
||||
project = build_dir / "readoor.xcodeproj"
|
||||
swift = build_dir / "readoor" / "3.0" / "AppConfig"
|
||||
resources = build_dir / "readoor" / "3.0" / "Resources"
|
||||
assets = build_dir / "readoor" / "BookShelf" / "Resources" / "Images.xcassets"
|
||||
logo = resources / "3.0.xcassets" / "AppLogo.imageset"
|
||||
for path in [project, swift, resources, assets, logo]:
|
||||
path.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
(project / "project.pbxproj").write_text(
|
||||
"MARKETING_VERSION = 1.0;\nCURRENT_PROJECT_VERSION = 1;\n"
|
||||
"PRODUCT_BUNDLE_IDENTIFIER = old.id;\nDEVELOPMENT_TEAM = OLD;\n"
|
||||
'"DEVELOPMENT_TEAM[sdk=iphoneos*]" = OLD;\n'
|
||||
"PROVISIONING_PROFILE_SPECIFIER = old;\n"
|
||||
'"PROVISIONING_PROFILE_SPECIFIER[sdk=iphoneos*]" = old;\n',
|
||||
encoding="utf-8",
|
||||
)
|
||||
(swift / "RDAppConfiguration.swift").write_text(
|
||||
'let RD_APP_GUID: String = "old"\nlet RD_API_DOMAIN: String = "old"\n'
|
||||
'let RD_WECHAT_PAY_ID: String = "old"\nlet RD_SOURCE_VERSION: String = "old"\n',
|
||||
encoding="utf-8",
|
||||
)
|
||||
(build_dir / "readoor" / "3.0" / "readoor31.entitlements").write_text(
|
||||
"<string>applinks:old</string>\n", encoding="utf-8"
|
||||
)
|
||||
with (build_dir / "readoor" / "3.0" / "readoor31.plist").open("wb") as f:
|
||||
plistlib.dump({"CFBundleURLTypes": []}, f)
|
||||
|
||||
theme = tmp_path / "theme" / "AppIcon.appiconset"
|
||||
theme.mkdir(parents=True)
|
||||
(theme / "icon-1024.png").write_bytes(b"icon")
|
||||
|
||||
config = {
|
||||
"VERSION": "2.0.0", "BUILD_VERSION": "2.0.0.1", "APPID": "guid",
|
||||
"API": "https://api.example.com", "weixinpay": "wx-pay", "APPID_NAME": "Test",
|
||||
"BUNDLE_ID": "com.example.test", "TEAM_ID": "TEAM", "PROVISIONING_NAME": "profile",
|
||||
"ASSOCIATED_DOMAINS": "applinks:example.com", "THEME": str(theme.parent), "BUILD_TYPE": "Ad_Hoc",
|
||||
"CERTIFICATE": "Apple Distribution",
|
||||
}
|
||||
|
||||
messages = apply_project_config(build_dir, config)
|
||||
|
||||
assert "工程、签名、版本和分发配置已更新" in messages
|
||||
assert 'let RD_APP_GUID: String = "guid"' in (swift / "RDAppConfiguration.swift").read_text()
|
||||
assert "PRODUCT_BUNDLE_IDENTIFIER = com.example.test;" in (project / "project.pbxproj").read_text()
|
||||
assert (build_dir / "exportOptions.plist").exists()
|
||||
assert (logo / "icon-1024.png").read_bytes() == b"icon"
|
||||
Reference in New Issue
Block a user