muse-agent-example/tests/集成/test_发布包独立运行.py
zizi 713cc45c63 重构(数据库): 迁移链压扁为单基线并精简测试至保留集
- 51 个增量迁移文件压扁为 V0001__基线.sql 完整快照(结构+种子+授权),
  等价门禁:旧链全量执行库与基线库 pg_dump 逐字节一致
- 剔除 pg_dump 固化的 public schema 超级用户归属断言(muse_maint 无权执行)
- 测试删减至保留集:备份往返 2 + 预算 2 + 租约 2 + 基线建库 1
- 租约/预算夹具改共享库,消除按例克隆建库
- 修复共享库三类既有污染:账本注入残留(系统管理)、失败触发器残留(评测)、
  建表残留(正式变更事务),发布包迁移文件名硬编码改动态核对
- 全量数据库验收 722 passed / 0 failed / 0 errors(main 基线为 24F+291E)
- 运行手册登记基线模式改表流程与账本校验和同步
2026-09-22 10:18:51 +08:00

260 lines
9.5 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""轮子安装到独立环境,真实服务停机重启后候选和导出任务仍可继续。"""
import json
import os
import signal
import socket
import subprocess
import sys
import time
from dataclasses import asdict
from pathlib import Path
from zipfile import ZipFile
import httpx
import pytest
import test_定稿与导出 as 定稿测试
from muse.资源加载 import 加载清单
pytestmark = [pytest.mark.数据库, pytest.mark.timeout(120)]
候选环境 = 定稿测试.候选环境
交付环境 = 定稿测试.交付环境
根 = Path(__file__).resolve().parents[2]
def _命令(args, cwd, *, env=None):
result = subprocess.run(args, cwd=cwd, env=env, capture_output=True, text=True, timeout=60)
assert result.returncode == 0, result.stderr[-3000:]
return result.stdout
def _启动(python, config, root, base, environment):
log = (root / "server.log").open("ab")
process = subprocess.Popen(
[str(python), "-I", "-m", "muse", "服务", str(config)],
cwd=root,
env=environment,
stdout=log,
stderr=subprocess.STDOUT,
)
try:
for _ in range(60):
if process.poll() is not None:
pytest.fail("安装包服务提前退出:" + (root / "server.log").read_text()[-2000:])
try:
if httpx.get(base + "/ready", timeout=1, trust_env=False).status_code == 200:
return process, log
except httpx.TransportError:
pass
time.sleep(0.1)
pytest.fail("安装包服务未就绪")
except BaseException:
_停止(process, log)
raise
def _停止(process, log):
if process.poll() is None:
process.terminate()
try:
process.wait(10)
except subprocess.TimeoutExpired:
process.kill()
process.wait(5)
pytest.fail("安装包服务未能优雅停止")
log.close()
@pytest.fixture
def 独立安装环境(tmp_path):
package = tmp_path / "packages"
_命令(["uv", "build", "--offline", "--wheel", "--out-dir", str(package)], 根)
wheel = next(package.glob("*.whl"))
with ZipFile(wheel) as archive:
names = archive.namelist()
assert len(names) == len(set(names))
assert not any(
part in {".git", "tests", ".agents.local", ".env"}
for name in names
for part in Path(name).parts
)
migrations = [n for n in names if n.startswith("muse/资源/迁移/") and n.endswith(".sql")]
源迁移 = sorted((根 / "数据库/迁移").glob("*.sql"))
assert sorted(Path(n).name for n in migrations) == [p.name for p in 源迁移]
for 源 in 源迁移:
assert archive.read(f"muse/资源/迁移/{源.name}") == 源.read_bytes()
requirements = tmp_path / "runtime.txt"
_命令(
[
"uv",
"export",
"--frozen",
"--no-dev",
"--no-emit-project",
"--output-file",
str(requirements),
],
根,
)
venv = tmp_path / "installed"
_命令(["uv", "venv", "--python", sys.executable, str(venv)], tmp_path)
python = venv / "bin/python"
_命令(
[
"uv",
"pip",
"install",
"--offline",
"--python",
str(python),
"--require-hashes",
"-r",
str(requirements),
],
tmp_path,
)
_命令(
["uv", "pip", "install", "--offline", "--no-deps", "--python", str(python), str(wheel)],
tmp_path,
)
environment = {
k: v for k, v in os.environ.items() if not k.startswith("MUSE_") and k != "PYTHONPATH"
}
where = _命令(
[str(python), "-I", "-c", "import muse; print(muse.__file__)"], tmp_path, env=environment
).strip()
assert Path(where).is_relative_to(venv)
return python, environment
@pytest.mark.case_id(
"NC-w28-28a001",
environment="隔离PG",
given="明确隔离配置、发布包和受控测试资料",
when="经实际入口执行对应操作并读回",
then=["无Git安装包实际写候选且重启继续原导出任务"],
contract="docs/系统架构/新版设计/文件设计/工程配置与部署.md",
)
def test_无Git安装包实际写候选且重启继续原导出任务__28a001(交付环境, tmp_path, 独立安装环境):
app, author, pools, request = 交付环境
python, environment = 独立安装环境
with socket.socket() as sock:
sock.bind(("127.0.0.1", 0))
port = sock.getsockname()[1]
base = f"http://127.0.0.1:{port}"
password = tmp_path / "author-secret"
password.write_text("synthetic-installed-only")
password.chmod(0o600)
config = tmp_path / "installed.toml"
config.write_text(
'["数据库"]\n"取值方式"="受控存储"\n"位置"='
+ json.dumps(pools[author.用途].引用.位置)
+ '\n["资源"]\n"发布身份"='
+ json.dumps(加载清单()["构建身份"])
+ '\n["HTTP"]\n"口令文件"='
+ json.dumps(str(password))
+ '\n"作者ID"='
+ json.dumps(author.作者)
+ '\n"端口"='
+ str(port)
+ '\n"公开地址"='
+ json.dumps(base)
+ '\n"允许来源"=['
+ json.dumps(base)
+ "]\n",
)
config.chmod(0o600)
assert not (tmp_path / ".git").exists()
process, log = _启动(python, config, tmp_path, base, environment)
client = httpx.Client(base_url=base, headers={"origin": base}, trust_env=False, timeout=15)
try:
assert (
client.post("/api/v1/session", json={"password": password.read_text()}).status_code
== 200
)
response = client.post(
"/api/v1/chapters/chapter-candidate/writing-candidates",
json={
"command_id": "installed-candidate",
"chapter_id": "chapter-candidate",
"branch_id": "main",
"expected_revision": 1,
"document": asdict(定稿测试.正文测试.草稿("安装环境的新候选🙂")),
},
)
assert response.status_code == 200, response.text
cid = response.json()["results"][0]["candidate_id"]
original = client.get("/api/v1/writing-candidates/" + cid).json()
frozen = client.post(
"/api/v1/deliveries",
json={
"command_id": "installed-freeze",
"delivery": asdict(request),
},
)
assert frozen.status_code == 200, frozen.text
path = "/api/v1/deliveries/" + request.delivery_id
queued = client.post(
path + "/exports",
json={
"command_id": "installed-export",
"formats": ["txt", "docx"],
},
)
assert queued.status_code == 200, queued.text
tid = queued.json()["task_id"]
_停止(process, log)
# Uvicorn完成优雅关闭后会重发原SIGTERM,子进程返回-SIGTERM是正常终态。
assert process.returncode in {0, -signal.SIGTERM}
assert "Application shutdown complete." in (tmp_path / "server.log").read_text()
process, log = _启动(python, config, tmp_path, base, environment)
restored = client.get("/api/v1/writing-candidates/" + cid)
assert restored.status_code == 200 # 原会话口令和cookie仍然有效。
assert restored.json()["candidate_hash"] == original["candidate_hash"]
assert restored.json()["visible_text"] == original["visible_text"]
assert restored.json()["decision"] == "undecided"
finished = client.post(path + "/exports/" + tid + "/advance")
assert finished.status_code == 200, finished.text
assert finished.json()["state"] == "completed"
downloaded = client.get(path + "/files/txt")
assert downloaded.status_code == 200
assert "原句「保留」" in downloaded.text
assert "安装环境的新候选" not in downloaded.text
html = client.get("/")
assert html.status_code == 200 and "/assets/index-" in html.text
assert app.要求正文().读取正文(author, "chapter-candidate")["revision"] == 1
if os.environ.get("MUSE_RUN_BROWSER") == "1":
output = Path(os.environ.get("MUSE_BROWSER_ARTIFACTS", str(tmp_path / "browser")))
output.mkdir(parents=True, exist_ok=True)
browser_env = {
**os.environ,
"MUSE_WORKBENCH_URL": base,
"MUSE_AUTHOR_PASSWORD_FILE": str(password),
"MUSE_BROWSER_EXECUTABLE": (
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"
),
}
with (output / "浏览器.json").open("w") as log_file:
result = subprocess.run(
[
"pnpm",
"exec",
"playwright",
"test",
"系统管理.spec.ts",
"--reporter=json",
"--output",
str(output / "产物"),
],
cwd=根 / "web",
env=browser_env,
stdout=log_file,
stderr=subprocess.STDOUT,
timeout=90,
)
assert result.returncode == 0, str(output / "浏览器.json")
finally:
client.close()
_停止(process, log)