feat: publish Dever core with project scaffolding and versioned AI skills
This commit is contained in:
1 parent
70fa73cdc2
commit
ac458ffa7f
960 files changed
+221319
-1163
No files matched your search
@@ -0,0 +1,13 @@
|
||||
# CMS 业务验收
|
||||
|
||||
显式运行,使用随机 loopback 端口、真实登录及核心角色授权、临时 SQLite 和正常 Job Worker;不访问部署数据库,不启动压测。
|
||||
|
||||
```sh
|
||||
python3 test/cms/acceptance.py --compiler /absolute/path/to/dever --output /new/report-directory
|
||||
```
|
||||
|
||||
运行器复制 plain CMS,并只在副本追加已有 `test/dever-tests/fixtures/postgres_api` 的真实账号/成员建立 CMD。输出目录保存无配置/凭据的 `cms-app`,可用 `--executable /absolute/path/to/cms-app` 跳过后续重复构建;该程序必须包含同一个 `fixture_member` CMD,`--project` 提供匹配的源项目配置。输入源码、二进制和部署配置均不修改。临时密钥、密码和数据库随工作目录清理。
|
||||
|
||||
覆盖分类、上传与文件清理、关联文章、修订快照、过期/并发版本冲突、作者和角色权限、发布快照、定时/同刻任务、重复执行、失败回滚/重试、排队后撤权和会话撤销/到期。仅在自有 SQLite 加临时拒绝 INSERT 的 trigger 注入晚期故障;重放只重置已完成队列行的执行状态,保留原身份、租户、权限、schema 与 payload;会话到期通过把自有平台库的 admin 会话截止时间设为过去验证,不伪造身份。所有普通业务和授权经真实 API/CMD。不会将数据库回滚等同于已提交存储文件回滚。
|
||||
|
||||
每个 HTTP 请求、构建和进程均有时限;退出时停止并回收自有进程。此验收是有界业务正确性检查,不代表 PostgreSQL、浏览器 HTTPS、长期负载或性能测试。双源码等价由现有编译器 CMS 合同检查负责,不在此复制 Markdown 解析器。
|
||||
@@ -0,0 +1,476 @@
|
||||
"""自有 SQLite/HTTP CMS 业务验收;不启动压测,不使用部署数据库。"""
|
||||
|
||||
import argparse
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from contextlib import closing, contextmanager
|
||||
from datetime import datetime, timezone
|
||||
import http.client
|
||||
import json
|
||||
from pathlib import Path
|
||||
import secrets
|
||||
import shutil
|
||||
import sqlite3
|
||||
import sys
|
||||
import tempfile
|
||||
from threading import Barrier
|
||||
import time
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(ROOT / "test/performance"))
|
||||
from cms import (CmsClient, available_port, catalog_key, copy_cms_project,
|
||||
decode_response, execute, isolated_settings, login_client,
|
||||
provision_identity, require)
|
||||
from process import Process
|
||||
from settings import write_private_json
|
||||
|
||||
ARTICLE = "/news/article/admin/manage"
|
||||
REVISION = "/content/revision/admin/manage/create"
|
||||
CATEGORY = "/content/category/admin/manage"
|
||||
ASSET = "/media/asset/admin/manage/upload"
|
||||
SCHEDULE = "/publishing/schedule/admin/manage/schedule"
|
||||
ROLES = "/user/authorization/admin/manage"
|
||||
BUSINESS_TABLES = ("article", "revision", "publication", "schedule", "operation", "asset", "category")
|
||||
|
||||
|
||||
def append_member_fixture(project):
|
||||
"""复用真实凭据/成员关系 fixture;仅追加到本运行器复制的 plain 项目。"""
|
||||
fixtures = ROOT / "test/dever-tests/fixtures/postgres_api"
|
||||
for target, name in (("user/account/app.dever", "account_app.dever"),
|
||||
("user/membership/app.dever", "membership_app.dever"),
|
||||
("user/account/api.dever", "account_api.dever")):
|
||||
path = project / "module" / target
|
||||
require(path.is_file(), "compiler mode requires the plain CMS project")
|
||||
with path.open("a") as destination:
|
||||
destination.write("\n" + (fixtures / name).read_text())
|
||||
|
||||
|
||||
def sql_read(database, sql, parameters=()):
|
||||
with closing(sqlite3.connect(database.as_uri() + "?mode=ro", uri=True, timeout=5)) as connection:
|
||||
connection.row_factory = sqlite3.Row
|
||||
return [dict(row) for row in connection.execute(sql, parameters)]
|
||||
|
||||
|
||||
def snapshot(database):
|
||||
return {table: sql_read(database, f'SELECT * FROM "{table}" ORDER BY id')
|
||||
for table in BUSINESS_TABLES}
|
||||
|
||||
|
||||
def fixture_write(database, sql, parameters=()):
|
||||
# 此入口只接收自有临时数据库。所有正常业务创建和授权仍走真实 API/CMD。
|
||||
with closing(sqlite3.connect(database, timeout=5)) as connection:
|
||||
with connection:
|
||||
connection.execute(sql, parameters)
|
||||
|
||||
|
||||
@contextmanager
|
||||
def reject_inserts(database, table):
|
||||
require(table in BUSINESS_TABLES, "unsupported failure injection table")
|
||||
fixture_write(database, f'''CREATE TRIGGER cms_reject_insert BEFORE INSERT ON "{table}"
|
||||
BEGIN SELECT RAISE(ABORT, 'owned CMS acceptance failure'); END''')
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
fixture_write(database, "DROP TRIGGER cms_reject_insert")
|
||||
|
||||
|
||||
def wait_for(check, process, label, timeout=15):
|
||||
deadline = time.monotonic() + timeout
|
||||
while time.monotonic() < deadline:
|
||||
require(process.child.poll() is None, f"{label}: child exited; see {process.directory}")
|
||||
value = check()
|
||||
if value:
|
||||
return value
|
||||
time.sleep(0.05)
|
||||
raise TimeoutError(f"{label}: timed out after {timeout}s")
|
||||
|
||||
|
||||
def ready(process, client):
|
||||
def probe():
|
||||
try:
|
||||
client.request("GET", ARTICLE + "/list", expected=401)
|
||||
return True
|
||||
except (OSError, http.client.HTTPException):
|
||||
client.close()
|
||||
return False
|
||||
wait_for(probe, process, "API startup", timeout=20)
|
||||
|
||||
|
||||
def multipart(client, content, *, expected=200, field="file"):
|
||||
boundary = "cms-" + secrets.token_hex(12)
|
||||
body = (f'--{boundary}\r\nContent-Disposition: form-data; name="{field}"; '
|
||||
'filename="article.txt"\r\nContent-Type: text/plain\r\n\r\n').encode()
|
||||
body += content + f"\r\n--{boundary}--\r\n".encode()
|
||||
client.connection.request("POST", ASSET, body, {
|
||||
"Content-Type": f"multipart/form-data; boundary={boundary}",
|
||||
"Origin": client.origin, "Cookie": client.cookies.get("admin", ""),
|
||||
})
|
||||
response = client.connection.getresponse()
|
||||
reply = response.read()
|
||||
require(response.status == expected,
|
||||
f"upload: HTTP {response.status}, expected {expected}: {reply!r}")
|
||||
if expected != 200:
|
||||
require(json.loads(reply)["code"] != 0, "upload rejection returned success")
|
||||
return None
|
||||
return decode_response(reply, response.status)
|
||||
|
||||
|
||||
class Acceptance:
|
||||
def __init__(self, executable, directory, settings, identity, password, member, member_password):
|
||||
self.executable = executable
|
||||
self.directory = directory
|
||||
self.settings = settings
|
||||
self.identity = identity
|
||||
self.password = password
|
||||
self.member_identity = member
|
||||
self.member_password = member_password
|
||||
self.port = settings["http"]["port"]
|
||||
self.owner = CmsClient(self.port)
|
||||
self.member = CmsClient(self.port)
|
||||
self.anonymous = CmsClient(self.port)
|
||||
self.database = directory / "data/tenants" / f"tenant_{identity['tenant_id']}.db"
|
||||
self.worker_number = 0
|
||||
self.checks = []
|
||||
|
||||
def close(self):
|
||||
for client in (self.owner, self.member, self.anonymous):
|
||||
client.close()
|
||||
|
||||
def passed(self, name):
|
||||
self.checks.append(name)
|
||||
print(f"PASS {name}", flush=True)
|
||||
|
||||
def assert_unchanged(self, before, label):
|
||||
require(snapshot(self.database) == before, f"{label}: business rows changed")
|
||||
|
||||
def create(self, client, slug, **fields):
|
||||
return client.request("POST", ARTICLE + "/create", {
|
||||
"slug": slug, "title": f"Title {slug}", "body": "Draft body", **fields,
|
||||
}, site="admin")
|
||||
|
||||
def edit(self, client, article, *, title="Edited title", expected=200):
|
||||
revision = client.request("POST", REVISION, {
|
||||
"selected_article_id": article["id"], "title": title, "body": "Edited body",
|
||||
"expected_version": article["version"], "category_id": article.get("category_id"),
|
||||
"asset_id": article.get("asset_id"),
|
||||
}, site="admin", expected=expected)
|
||||
if expected != 200:
|
||||
return None
|
||||
current = client.request("GET", ARTICLE + "/detail", {"id": article["id"]}, site="admin")
|
||||
require(revision["article_id"] == current["id"] and revision["version"] == current["version"]
|
||||
and revision["title"] == current["title"] and revision["body"] == current["body"],
|
||||
"revision and current article disagree")
|
||||
return current
|
||||
|
||||
def schedule(self, client, article, milliseconds):
|
||||
instant = datetime.fromtimestamp(milliseconds / 1000, timezone.utc)
|
||||
return client.request("POST", SCHEDULE, {
|
||||
"article_id": article["id"],
|
||||
"publish_at": instant.isoformat(timespec="milliseconds").replace("+00:00", "Z"),
|
||||
}, site="admin")
|
||||
|
||||
def job(self, schedule):
|
||||
rows = sql_read(self.database,
|
||||
"SELECT * FROM _dever_jobs WHERE target=? ORDER BY id",
|
||||
("publishing.schedule.job.publish",))
|
||||
matches = [row for row in rows if json.loads(row["payload"])["schedule_id"] == schedule["id"]]
|
||||
require(len(matches) == 1, f"schedule {schedule['id']} must own exactly one Job: {rows}")
|
||||
return matches[0]
|
||||
|
||||
@contextmanager
|
||||
def worker(self):
|
||||
self.worker_number += 1
|
||||
settings = {**self.settings, "runtime": {**self.settings["runtime"], "mode": "worker"}}
|
||||
write_private_json(self.directory / "config/setting.json", settings, replace=True)
|
||||
try:
|
||||
with Process([self.executable], self.directory / f"worker-{self.worker_number}") as process:
|
||||
yield process
|
||||
require(process.child.returncode == 0, f"Worker did not shut down cleanly: {process.directory}")
|
||||
finally:
|
||||
write_private_json(self.directory / "config/setting.json", self.settings, replace=True)
|
||||
|
||||
def authorization(self):
|
||||
login_client(self.owner, self.identity, self.password, "cms")
|
||||
login_client(self.member, self.member_identity, self.member_password, "cms")
|
||||
before = snapshot(self.database)
|
||||
self.anonymous.request("GET", ARTICLE + "/list", expected=401)
|
||||
self.member.request("GET", ARTICLE + "/list", site="admin", expected=403)
|
||||
for client, status in ((self.anonymous, 401), (self.member, 403)):
|
||||
client.request("POST", ARTICLE + "/create", {
|
||||
"slug": "must-not-create", "title": "Rejected", "body": "Rejected",
|
||||
}, site="admin", expected=status)
|
||||
self.assert_unchanged(before, "unauthenticated and ungranted writes")
|
||||
catalog = self.owner.request("GET", ROLES + "/permissions", site="admin")
|
||||
selections = (("news", "article", "create", "POST"),
|
||||
("news", "article", "list", "GET"),
|
||||
("news", "article", "detail", "GET"),
|
||||
("news", "article", "publish", "POST"),
|
||||
("news", "article", "remove", "DELETE"),
|
||||
("content", "revision", "create", "POST"),
|
||||
("publishing", "schedule", "schedule", "POST"))
|
||||
keys = [catalog_key(catalog, component, domain, "admin", action, method)
|
||||
for component, domain, action, method in selections]
|
||||
self.owner.request("POST", ROLES + "/save", {
|
||||
"id": "cms-editor", "name": "CMS editor", "all_permissions": False,
|
||||
"permission_keys": keys,
|
||||
}, site="admin")
|
||||
self.owner.request("POST", ROLES + "/grant", {
|
||||
"user_id": self.member_identity["user_id"], "role_id": "cms-editor",
|
||||
}, site="admin")
|
||||
self.member.request("GET", ARTICLE + "/list", {"size": 20}, site="admin")
|
||||
self.passed("real login, ungranted denial and explicit member role")
|
||||
|
||||
def editorial(self):
|
||||
category = self.owner.request("POST", CATEGORY + "/create", {"name": " CMS News "}, site="admin")
|
||||
upload = multipart(self.owner, b"CMS attachment\n")
|
||||
stored = self.directory / "data/upload" / upload["storage_key"]
|
||||
require(stored.read_bytes() == b"CMS attachment\n", "uploaded bytes changed")
|
||||
files = sorted(path.name for path in stored.parent.iterdir())
|
||||
rows = snapshot(self.database)
|
||||
multipart(self.owner, b"x" * 2049, expected=400)
|
||||
multipart(self.owner, b"bad field", field="unknown", expected=400)
|
||||
require(sorted(path.name for path in stored.parent.iterdir()) == files,
|
||||
"rejected upload persisted a file")
|
||||
require(not list((self.directory / "data/tmp").iterdir()), "upload temporary files leaked")
|
||||
self.assert_unchanged(rows, "rejected uploads")
|
||||
article = self.create(self.owner, "linked-article", category_id=category["id"], asset_id=upload["id"])
|
||||
require(article["category_id"] == category["id"] and article["asset_id"] == upload["id"],
|
||||
"article lost category/asset references")
|
||||
before = snapshot(self.database)
|
||||
self.edit(self.member, article, expected=403)
|
||||
self.member.request("POST", ARTICLE + "/publish", {"article_id": article["id"]},
|
||||
site="admin", expected=403)
|
||||
self.member.request("DELETE", ARTICLE + "/remove", {"id": article["id"]},
|
||||
site="admin", expected=403)
|
||||
self.member.request("POST", SCHEDULE, {
|
||||
"article_id": article["id"],
|
||||
"publish_at": datetime.fromtimestamp(time.time() + 30, timezone.utc).isoformat(timespec="milliseconds"),
|
||||
}, site="admin", expected=403)
|
||||
self.assert_unchanged(before, "non-author mutation")
|
||||
edited = self.edit(self.owner, article)
|
||||
require(edited["version"] == article["version"] + 1, "edit did not advance version")
|
||||
before = snapshot(self.database)
|
||||
self.edit(self.owner, article, expected=409)
|
||||
self.assert_unchanged(before, "stale edit")
|
||||
self.passed("multipart cleanup, attachment references, author and stale-version rejection")
|
||||
|
||||
# 失败发生在文章写入之后,必须连同版本/修订一起回滚。
|
||||
with reject_inserts(self.database, "revision"):
|
||||
self.edit(self.owner, edited, title="Must roll back", expected=500)
|
||||
self.assert_unchanged(before, "revision insert failure")
|
||||
self.passed("HTTP edit failure rolls back article and revision")
|
||||
winner = self.concurrent_edit(edited)
|
||||
before = snapshot(self.database)
|
||||
with reject_inserts(self.database, "publication"):
|
||||
self.owner.request("POST", ARTICLE + "/publish", {"article_id": article["id"]},
|
||||
site="admin", expected=500)
|
||||
self.assert_unchanged(before, "publication insert failure")
|
||||
published = self.owner.request("POST", ARTICLE + "/publish", {"article_id": article["id"]}, site="admin")
|
||||
require(published["title"] == winner["title"] and published["body"] == winner["body"],
|
||||
"publication is not the winning stored revision")
|
||||
before = snapshot(self.database)
|
||||
self.owner.request("POST", ARTICLE + "/publish", {"article_id": article["id"]}, site="admin", expected=409)
|
||||
self.assert_unchanged(before, "repeated manual publish")
|
||||
self.owner.request("DELETE", CATEGORY + "/disable", {"id": category["id"]}, site="admin")
|
||||
before = snapshot(self.database)
|
||||
self.owner.request("POST", ARTICLE + "/create", {
|
||||
"slug": "disabled-category", "title": "Rejected", "body": "Rejected", "category_id": category["id"],
|
||||
}, site="admin", expected=409)
|
||||
self.assert_unchanged(before, "disabled category")
|
||||
self.passed("publish snapshot, late-failure rollback, repeat conflict and disabled category")
|
||||
|
||||
def concurrent_edit(self, article):
|
||||
barrier = Barrier(2, timeout=5)
|
||||
def compete(index):
|
||||
client = CmsClient(self.port)
|
||||
client.cookies = self.owner.cookies.copy()
|
||||
try:
|
||||
barrier.wait()
|
||||
payload = {"selected_article_id": article["id"], "title": f"Concurrent {index}",
|
||||
"body": "Winning body", "expected_version": article["version"],
|
||||
"category_id": article["category_id"], "asset_id": article["asset_id"]}
|
||||
client.connection.request("POST", REVISION, json.dumps(payload), {
|
||||
"Content-Type": "application/json", "Origin": client.origin,
|
||||
"Cookie": client.cookies["admin"],
|
||||
})
|
||||
response = client.connection.getresponse()
|
||||
body = response.read()
|
||||
document = json.loads(body)
|
||||
require(set(document) == {"code", "message", "data"}, "invalid race envelope")
|
||||
require((response.status, document["code"]) in ((200, 0), (409, 409)),
|
||||
f"unexpected concurrent edit: {response.status} {body!r}")
|
||||
return response.status, document["data"]
|
||||
finally:
|
||||
client.close()
|
||||
prior = len(sql_read(self.database, "SELECT id FROM revision WHERE article_id=?", (article["id"],)))
|
||||
with ThreadPoolExecutor(max_workers=2) as pool:
|
||||
results = list(pool.map(compete, (1, 2)))
|
||||
require(sorted(status for status, _ in results) == [200, 409], "concurrent edit must have one winner")
|
||||
winner = next(value for status, value in results if status == 200)
|
||||
require(winner["version"] == article["version"] + 1, "concurrent version advanced twice")
|
||||
revisions = sql_read(self.database, "SELECT * FROM revision WHERE article_id=? ORDER BY version", (article["id"],))
|
||||
require(len(revisions) == prior + 1 and revisions[-1]["title"] == winner["title"],
|
||||
"concurrent edit persisted duplicate or mismatched revisions")
|
||||
self.passed("two simultaneous edits produce one version and one revision")
|
||||
return winner
|
||||
|
||||
def scheduled_publish(self):
|
||||
articles = [self.create(self.owner, f"scheduled-{index}") for index in (1, 2)]
|
||||
due = int(time.time() * 1000) + 3000
|
||||
schedules = [self.schedule(self.owner, article, due) for article in articles]
|
||||
jobs = [self.job(schedule) for schedule in schedules]
|
||||
require(len({job["id"] for job in jobs}) == 2 and all(job["run_at"] == due for job in jobs),
|
||||
"same-time schedules merged or enqueue_at lost the due time")
|
||||
before = snapshot(self.database)
|
||||
with self.worker() as worker:
|
||||
require(time.time() * 1000 < due - 500, "fixture did not leave a pre-due observation window")
|
||||
while time.time() * 1000 < due - 300:
|
||||
require(worker.child.poll() is None, "worker stopped before due time")
|
||||
require(all(self.job(value)["state"] == "pending" for value in schedules), "Job ran early")
|
||||
self.assert_unchanged(before, "not-yet-due schedule")
|
||||
time.sleep(0.05)
|
||||
wait_for(lambda: all(self.job(value)["state"] == "succeeded" for value in schedules), worker,
|
||||
"same-time scheduled publications")
|
||||
for article in articles:
|
||||
require(len(sql_read(self.database, "SELECT id FROM publication WHERE slug=?", (article["slug"],))) == 1,
|
||||
"scheduled article was not published exactly once")
|
||||
self.passed("real future enqueue_at and independent same-time publications")
|
||||
before = snapshot(self.database)
|
||||
for schedule in schedules:
|
||||
# 重放真实已完成任务;保留原身份/租户/权限/指纹/payload,不伪造执行身份。
|
||||
fixture_write(self.database, "UPDATE _dever_jobs SET state='pending',attempt=0,run_at=?,"
|
||||
"claim_token='',lease_until=0,error='' WHERE id=? AND state='succeeded'",
|
||||
(int(time.time() * 1000), self.job(schedule)["id"]))
|
||||
with self.worker() as worker:
|
||||
wait_for(lambda: all(self.job(value)["state"] == "succeeded" for value in schedules), worker,
|
||||
"completed task replay")
|
||||
self.assert_unchanged(before, "completed task replay")
|
||||
self.passed("completed Job replay adds no publication or audit")
|
||||
|
||||
def retry_and_revocation(self):
|
||||
article = self.create(self.owner, "retry-publish")
|
||||
schedule = self.schedule(self.owner, article, int(time.time() * 1000) + 500)
|
||||
before = snapshot(self.database)
|
||||
with reject_inserts(self.database, "publication"):
|
||||
with self.worker() as worker:
|
||||
wait_for(lambda: self.job(schedule)["attempt"] == 1
|
||||
and self.job(schedule)["state"] == "pending", worker, "first failed Job attempt")
|
||||
failed = self.job(schedule)
|
||||
require(failed["error"] == "handler_failed", "fixture did not reach business failure")
|
||||
self.assert_unchanged(before, "failed Job transaction")
|
||||
with self.worker() as worker:
|
||||
wait_for(lambda: self.job(schedule)["state"] == "succeeded", worker, "automatic delayed retry")
|
||||
require(self.job(schedule)["attempt"] == 2, "retry did not use exactly one new claim")
|
||||
self.passed("Job late failure rolls back publication/audit/status and retries successfully")
|
||||
|
||||
article = self.create(self.member, "revoked-publish")
|
||||
schedule = self.schedule(self.member, article, int(time.time() * 1000) + 500)
|
||||
before = snapshot(self.database)
|
||||
self.owner.request("DELETE", ROLES + "/revoke", {
|
||||
"user_id": self.member_identity["user_id"], "role_id": "cms-editor",
|
||||
}, site="admin")
|
||||
self.member.request("GET", ARTICLE + "/list", site="admin", expected=403)
|
||||
with self.worker() as worker:
|
||||
wait_for(lambda: self.job(schedule)["state"] == "blocked", worker, "revoked queued permission")
|
||||
require(self.job(schedule)["error"] == "identity_rejected", "Job blocked for the wrong reason")
|
||||
self.assert_unchanged(before, "permission revoked before Job execution")
|
||||
self.passed("immediate role revocation blocks queued Job before business writes")
|
||||
|
||||
def session_lifecycle(self):
|
||||
before = snapshot(self.database)
|
||||
logged_out = self.owner.request("POST", "/user/account/admin/session/logout", {}, site="admin")
|
||||
require(logged_out["revoked"], "logout did not revoke the session")
|
||||
# 保留旧 Cookie 再请求,证明服务端撤销而非仅清除浏览器 Cookie。
|
||||
self.owner.request("GET", ARTICLE + "/list", site="admin", expected=401)
|
||||
login_client(self.owner, self.identity, self.password, "cms")
|
||||
self.owner.request("GET", ARTICLE + "/list", {"size": 20}, site="admin")
|
||||
control = self.directory / "data/db/platform.db"
|
||||
expired = int(time.time() * 1000) - 1
|
||||
fixture_write(control, "UPDATE session SET expires_at=? WHERE user_id=? AND site='admin'",
|
||||
(expired, self.identity["user_id"]))
|
||||
self.owner.request("GET", ARTICLE + "/list", site="admin", expected=401)
|
||||
self.owner.request("GET", "/news/article/front/browse/list", {"size": 100}, site="front")
|
||||
self.assert_unchanged(before, "revoked and expired sessions")
|
||||
self.passed("logout invalidates the old cookie; expired admin session preserves front isolation")
|
||||
|
||||
def run(self):
|
||||
with Process([self.executable], self.directory / "api") as process:
|
||||
ready(process, self.anonymous)
|
||||
self.authorization()
|
||||
self.editorial()
|
||||
self.scheduled_publish()
|
||||
self.retry_and_revocation()
|
||||
self.session_lifecycle()
|
||||
require(process.child.returncode == 0, f"API did not shut down cleanly: {process.directory}")
|
||||
|
||||
|
||||
def run(args, directory, report):
|
||||
project = directory / "project"
|
||||
copy_cms_project(args.project, project)
|
||||
# 忽略输入工程的任何数据库地址;本验收只生成自有 SQLite 配置。
|
||||
settings = json.loads((project / "config/setting.json").read_text())
|
||||
settings["database"] = {"default": {"type": "sqlite", "path": "data/db/platform.db",
|
||||
"tenant_directory": "data/tenants", "max_connections": 4,
|
||||
"max_page_size": 100}}
|
||||
settings = isolated_settings(settings, available_port())
|
||||
settings["runtime"] = {"mode": "api", "shutdown_ms": 3000}
|
||||
settings["job"] = {"workers": 1, "poll_ms": 50, "lease_ms": 60000,
|
||||
"retry_base_ms": 2000, "retry_max_ms": 2000}
|
||||
settings["http"]["upload"] = {"file_bytes": 2048}
|
||||
write_private_json(project / "config/setting.json", settings, replace=True)
|
||||
if args.compiler:
|
||||
append_member_fixture(project)
|
||||
execute([args.compiler, "check", project], project, "check")
|
||||
execute([args.compiler, "build", project, "--output", project / "cms-app"], project, "build", timeout=300)
|
||||
else:
|
||||
shutil.copy2(args.executable, project / "cms-app")
|
||||
executable = project / "cms-app"
|
||||
# 二进制不含部署配置或fixture凭据,可复用以免调试断言时反复编译。
|
||||
shutil.copy2(executable, args.output / "cms-app")
|
||||
report["executable_bytes"] = executable.stat().st_size
|
||||
identity, password = provision_identity(executable, project, "cms")
|
||||
member_password = secrets.token_urlsafe(24)
|
||||
member = execute([executable, "user.account.fixture_member", json.dumps({
|
||||
"selected_tenant_id": identity["tenant_id"], "email": "member@example.invalid",
|
||||
"display_name": "CMS member", "password": member_password,
|
||||
})], project, "member", envelope=True)
|
||||
acceptance = Acceptance(executable, project, settings, identity, password, member, member_password)
|
||||
report["passed"] = acceptance.checks
|
||||
try:
|
||||
acceptance.run()
|
||||
return {"passed": acceptance.checks, "database": "owned SQLite", "load_test": False}
|
||||
finally:
|
||||
acceptance.close()
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
source = parser.add_mutually_exclusive_group(required=True)
|
||||
source.add_argument("--compiler", type=Path, help="explicit Dever compiler; builds an owned plain source copy")
|
||||
source.add_argument("--executable", type=Path, help="prepared CMS executable including fixture_member CMD")
|
||||
parser.add_argument("--project", type=Path, default=ROOT / "examples/cms/dever")
|
||||
parser.add_argument("--output", type=Path, required=True, help="new directory for credential-free report/logs")
|
||||
args = parser.parse_args()
|
||||
args.project = args.project.resolve(strict=True)
|
||||
for name in ("compiler", "executable"):
|
||||
if getattr(args, name):
|
||||
setattr(args, name, getattr(args, name).resolve(strict=True))
|
||||
args.output.mkdir(parents=True, exist_ok=False)
|
||||
report = {"success": False}
|
||||
with tempfile.TemporaryDirectory(prefix="dever-cms-acceptance-") as temporary:
|
||||
directory = Path(temporary)
|
||||
try:
|
||||
report.update(run(args, directory, report), success=True)
|
||||
except BaseException as error:
|
||||
report["error"] = f"{type(error).__name__}: {error}"
|
||||
raise
|
||||
finally:
|
||||
for log in directory.rglob("*.log"):
|
||||
destination = args.output / log.relative_to(directory)
|
||||
destination.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copy2(log, destination)
|
||||
write_private_json(args.output / "report.json", report)
|
||||
print(json.dumps(report, ensure_ascii=False, indent=2))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in new issue
Block a user