chore(v2): 从兴邦预审v2白名单抽取独立可运行基座
按 docs/v2/05-implementation-plan.md §1 候选迁移表与 claude_use/T00 白名单, 从 55e2cdb 抽取 224 个文件(2.48 MB)到独立目录 F:/mini-OpenClaw-v2。 - 排除:凭据(.env/server-deploy.conf/旧DELIVERY.md)、运行数据(xingbang_data 等 4 个 SQLite)、依赖(venv/node_modules 符号链接)、归档(_backup*/test-evidence/ ori_file)、Office 临时文件 - 目标目录独立 git 仓库,初始恢复点;Gitea 远程与分支待 OQ-06 Q2 定案 - 未部署、未启动服务、未调用模型 Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,83 @@
|
||||
"""Reproducible offline acceptance and latency evidence in an isolated database."""
|
||||
import argparse
|
||||
import base64
|
||||
import json
|
||||
import os
|
||||
import statistics
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT / "backend"))
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--output", type=Path, default=ROOT / "test-evidence" / ("assistant-acceptance-" + datetime.now().strftime("%Y%m%d-%H%M%S")))
|
||||
args = parser.parse_args()
|
||||
output = args.output.resolve()
|
||||
if not output.is_relative_to(ROOT / "test-evidence"):
|
||||
raise ValueError("验收输出必须位于当前仓库 test-evidence")
|
||||
output.mkdir(parents=True, exist_ok=False)
|
||||
os.environ.update(XINGBANG_LOCAL_MODEL_URL="", XINGBANG_AUTH_USERS="", XINGBANG_REQUIRE_AUTH="0")
|
||||
from api import risk_agent_config as catalog
|
||||
from services.project_assistant import ProjectAssistantService
|
||||
from services.assistant_worker import DurableWorker
|
||||
from services.assistant_store import listing
|
||||
identity = {"id": "offline-acceptance", "role": "admin", "projects": ["*"], "preview": True}
|
||||
records = []
|
||||
with tempfile.TemporaryDirectory(dir=output, ignore_cleanup_errors=True) as tmp:
|
||||
catalog.DB_PATH = Path(tmp) / "catalog.sqlite3"
|
||||
service = ProjectAssistantService(Path(tmp) / "assistant.sqlite3")
|
||||
worker = DurableWorker(service)
|
||||
def run(project_id):
|
||||
start = time.perf_counter()
|
||||
queued = service.enqueue(project_id, identity, "核对现金流与来源")
|
||||
queued_ms = (time.perf_counter() - start) * 1000
|
||||
worker.process_next()
|
||||
result = service.resource("run", queued["id"])
|
||||
records.append({"project": project_id, "run_id": result["id"], "enqueue_ms": round(queued_ms, 2), "total_ms": round((time.perf_counter() - start) * 1000, 2), "status": result["status"], "summary": result["summary"]})
|
||||
return result
|
||||
first = run("DEMO-B")
|
||||
assert first["summary"]["coverage"] == "1.0800" and first["summary"]["open"] == 4
|
||||
project = service.resource("project", "DEMO-B")
|
||||
service.supplement("DEMO-B", identity["id"], {"due_diligence_liabilities": "13200", "duplicate_asset_count": 0}, [], project["revision"], "离线验收补件")
|
||||
second = run("DEMO-B")
|
||||
assert len(second["change_summary"]["resolved"]) == 2
|
||||
scenario = service.compare_scenario("DEMO-B", "4000", identity["id"])
|
||||
assert scenario["scenario"]["coverage"] == "1.3500" and len(scenario["unresolved"]) == 1
|
||||
feedback = service.feedback("DEMO-B", identity["id"], {"category": "rule_omission", "target_kind": "run", "target_id": second["id"], "note": "追加回款凭证检查"})
|
||||
experience = service.save_experience("DEMO-B", identity["id"], {"title": "回款凭证检查", "trigger": "存在回款依赖", "action": "补充到账依据", "exceptions": "已有到账凭证", "scope": "team", "team": "demo", "feedback_id": feedback["id"], "conditions": {"collection.dependency": True}, "required_field": "collection.evidence", "exception_field": "collection.evidence", "expires_at": "2099-01-01T00:00:00+00:00"})
|
||||
experience = service.experience_action("DEMO-B", experience["id"], identity, "evaluate", experience["revision"], "隔离正反例评测")
|
||||
service.experience_action("DEMO-B", experience["id"], identity, "publish", experience["revision"], "本机模拟独立审核")
|
||||
c = run("DEMO-C")
|
||||
a = run("DEMO-A")
|
||||
assert any(f["code"].startswith("experience.") for f in c["findings"])
|
||||
assert not any(f["code"].startswith("experience.") for f in a["findings"])
|
||||
reports = []
|
||||
for template in ("template.due-diligence", "template.review", "template.committee"):
|
||||
report = service.create_report("DEMO-B", second["id"], identity["id"], template)
|
||||
target = output / (template + ".docx")
|
||||
target.write_bytes(service.export_report("DEMO-B", report["id"]))
|
||||
reports.append({"path": target.name, "template": template, "snapshot_id": report["snapshot_id"], "visual_qa": "pending-renderer"})
|
||||
times = []
|
||||
for _ in range(30):
|
||||
start = time.perf_counter()
|
||||
service.resource("run", second["id"])
|
||||
times.append((time.perf_counter() - start) * 1000)
|
||||
with service.connect() as db:
|
||||
for document in listing(db, "document"):
|
||||
(output / document["name"]).write_bytes(base64.b64decode(document["raw_base64"]))
|
||||
summary = {"mode": "offline-deterministic-no-live-model", "records": records, "scenario": scenario,
|
||||
"poll_ms_p95": round(sorted(times)[28], 2), "poll_ms_median": round(statistics.median(times), 2),
|
||||
"reports": reports, "experience_positive_and_negative": "passed", "preserved_live_database": True}
|
||||
(output / "summary.json").write_text(json.dumps(summary, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
print(json.dumps({"output": str(output), "mode": summary["mode"], "poll_ms_p95": summary["poll_ms_p95"], "records": records}, ensure_ascii=False, indent=2))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,86 @@
|
||||
"""Read-only migration audit by default; apply creates a consistent backup first.
|
||||
|
||||
Use backend/venv/Scripts/python.exe scripts/assistant_migrate.py --dry-run.
|
||||
Restore produces a separate recovered database, never overwrites post-release writes.
|
||||
"""
|
||||
import argparse
|
||||
import json
|
||||
import sqlite3
|
||||
import sys
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT / "backend"))
|
||||
|
||||
|
||||
def audit(path):
|
||||
if not path.is_file():
|
||||
return {"database": str(path), "exists": False, "action": "initialize-demo-instance", "dangling_references": []}
|
||||
with sqlite3.connect(path.as_uri() + "?mode=ro", uri=True) as db:
|
||||
tables = {r[0] for r in db.execute("SELECT name FROM sqlite_master WHERE type='table'")}
|
||||
counts = {name: db.execute('SELECT count(*) FROM "' + name + '"').fetchone()[0] for name in tables if name in ("projects", "runs", "snapshots", "reports", "experiences", "assistant_objects", "assistant_jobs")}
|
||||
dangling = []
|
||||
if "assistant_objects" in tables:
|
||||
objects = {(r[0], r[1]): json.loads(r[2]) for r in db.execute("SELECT kind,id,body FROM assistant_objects")}
|
||||
for (kind, key), body in objects.items():
|
||||
for field, target in (("snapshot_id", "snapshot"), ("input_snapshot_id", "input_snapshot"), ("run_id", "run"), ("document_ids", "document")):
|
||||
if kind not in ("run", "report", "report_final", "project", "experience_application"):
|
||||
continue
|
||||
values = body.get(field)
|
||||
for value in (values if isinstance(values, list) else [values]):
|
||||
if value and (target, value) not in objects:
|
||||
dangling.append({"kind": kind, "id": key, "field": field, "missing": value})
|
||||
return {"database": str(path), "exists": True, "counts": counts, "integrity": db.execute("PRAGMA integrity_check").fetchone()[0], "dangling_references": dangling}
|
||||
|
||||
|
||||
def backup(source, target):
|
||||
if target.exists() or source == target:
|
||||
raise ValueError("备份目标已存在或与源相同,拒绝覆盖")
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
original = sqlite3.connect(source.as_uri() + "?mode=ro", uri=True)
|
||||
output = sqlite3.connect(target)
|
||||
try:
|
||||
original.backup(output)
|
||||
finally:
|
||||
output.close()
|
||||
original.close()
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--database", type=Path, default=ROOT / "backend/xingbang_data/project_assistant.sqlite3")
|
||||
parser.add_argument("--dry-run", action="store_true")
|
||||
parser.add_argument("--apply", action="store_true")
|
||||
parser.add_argument("--restore-copy", type=Path, help="Output recovered DB alongside current DB; never replace live data")
|
||||
args = parser.parse_args()
|
||||
path = args.database.resolve()
|
||||
if not path.is_relative_to(ROOT):
|
||||
raise ValueError("迁移数据库须位于当前工作区")
|
||||
before = audit(path)
|
||||
if before.get("integrity", "ok") != "ok" or before["dangling_references"]:
|
||||
print(json.dumps(before, ensure_ascii=False, indent=2))
|
||||
return 1
|
||||
if args.restore_copy:
|
||||
target = args.restore_copy.resolve()
|
||||
if not target.is_relative_to(ROOT) or target == path:
|
||||
raise ValueError("恢复副本须为工作区内的新路径")
|
||||
backup(path, target)
|
||||
print(json.dumps({"restored_copy": str(target), "live_data_untouched": True, "audit": audit(target)}, ensure_ascii=False, indent=2))
|
||||
return 0
|
||||
if args.apply and not args.dry_run:
|
||||
if path.exists():
|
||||
target = ROOT / ("_backup_pre_assistant_migration_" + datetime.now().strftime("%Y%m%d_%H%M%S")) / path.name
|
||||
backup(path, target)
|
||||
before["backup"] = str(target)
|
||||
from services.project_assistant import ProjectAssistantService
|
||||
ProjectAssistantService(path)
|
||||
result = {"before": before, "after": audit(path), "legacy_tables": "preserved-read-only"}
|
||||
else:
|
||||
result = {"mode": "dry-run", **before, "writes": 0}
|
||||
print(json.dumps(result, ensure_ascii=False, indent=2))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,38 @@
|
||||
const assert = require("node:assert/strict");
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
|
||||
const root = path.resolve(__dirname, "..");
|
||||
const read = (file) => fs.readFileSync(path.join(root, file), "utf8");
|
||||
|
||||
const assistant = read("src/components/xingbang/BusinessAssistant.tsx");
|
||||
const message = read("src/components/chat/ChatMessage.tsx");
|
||||
const coreProxy = read("src/app/api/core/[...path]/route.ts");
|
||||
|
||||
assert.match(
|
||||
assistant,
|
||||
/<section className="ba-workspace">/,
|
||||
"业务助手应保留单一、直接的项目操作入口",
|
||||
);
|
||||
assert.doesNotMatch(
|
||||
assistant,
|
||||
/mini-openclaw|ChatPanel|AppProvider|nativeAccess|ba-mode-tabs/,
|
||||
"业务人员页面不应继续展示或加载重复的 mini-openclaw 入口",
|
||||
);
|
||||
assert.match(
|
||||
assistant,
|
||||
/当前身份只会调用本项目授权的 Skills、规则和业务工具/,
|
||||
"受限工作台应明确说明权限边界",
|
||||
);
|
||||
assert.match(
|
||||
message,
|
||||
/authentication\.\?fail[\s\S]*return "provider";[\s\S]*\/401\|unauthorized[\s\S]*return "identity";/,
|
||||
"401 身份错误不能继续误报为模型 API Key 失败",
|
||||
);
|
||||
assert.match(
|
||||
coreProxy,
|
||||
/user\.role !== "admin"/,
|
||||
"修复不得放开原版终端、文件和网页工具的管理员边界",
|
||||
);
|
||||
|
||||
console.log("Cloud business-assistant single-entry checks passed");
|
||||
@@ -0,0 +1,15 @@
|
||||
const assert = require("node:assert/strict");
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
|
||||
const root = path.resolve(__dirname, "..");
|
||||
const nav = fs.readFileSync(path.join(root, "src/components/xingbang/AgentNav.tsx"), "utf8");
|
||||
const selector = fs.readFileSync(path.join(root, "src/components/xingbang/IdentitySelector.tsx"), "utf8");
|
||||
|
||||
assert.match(nav, /response\.status===401/, "导航应识别无效访问令牌");
|
||||
assert.match(nav, /removeItem\("xingbang-access-token"\)/, "无效旧令牌应自动清理");
|
||||
assert.match(nav, /setItem\("xingbang-demo-role","manager"\)/, "清理后应恢复到客户经理演示身份");
|
||||
assert.match(nav, /pending=\{!identity\}/, "身份连接失败时仍应显示身份选择入口");
|
||||
assert.match(selector, /pending&&<option value="" disabled>选择身份<\/option>/, "待连接状态应给出明确的身份选择提示");
|
||||
|
||||
console.log("Identity recovery behavior checks passed");
|
||||
@@ -0,0 +1,21 @@
|
||||
const assert = require("node:assert/strict");
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
|
||||
const root = path.resolve(__dirname, "..");
|
||||
const component = fs.readFileSync(path.join(root, "src/components/xingbang/ReviewerWorkspace.tsx"), "utf8");
|
||||
const page = fs.readFileSync(path.join(root, "src/app/xingbang-workbench/page.tsx"), "utf8");
|
||||
|
||||
assert.match(component, /aria-label=\{`\$\{topic\.title\}评审意见`\}/, "每个评审节点应提供独立意见输入框");
|
||||
assert.match(component, /onChange=\{\(event\)=>setDraft\(event\.target\.value\)\}/, "输入框应允许修改节点意见");
|
||||
assert.match(component, /draft\.trim\(\)\|\|"已复核本章节数据来源和AI检查结果"/, "正常节点应保存人工输入或明确的默认复核说明");
|
||||
assert.match(component, /disabled=\{busy\|\|requiredOpinion\}/, "需要说明依据的评审动作不应允许空意见提交");
|
||||
assert.match(component, /aria-label="退回补充材料原因"/, "退回操作旁应直接提供原因输入框");
|
||||
assert.match(component, /disabled=\{busy\|\|!hasRun\|\|!returnReason\.trim\(\)\}/, "未填写退回原因时不应允许退回");
|
||||
assert.match(component, /onComplete\("退回",returnReason\.trim\(\)\)/, "退回原因应随退回决定一并保存");
|
||||
assert.match(page, /setReviewOpinion\]=useState\(""\)/, "常用模板默认不应冒充人工意见");
|
||||
assert.match(page, /x\.workflow_state==="returned"/, "客户经理工作台应识别评审退回项目");
|
||||
assert.match(page, /xb-project-card\$\{returned\?" is-returned":""\}/, "退回项目卡应使用醒目的独立样式");
|
||||
assert.match(page, /x\.return_reason\|\|"请打开项目查看评审退回要求"/, "退回项目卡应直接展示退回原因");
|
||||
|
||||
console.log("Reviewer workspace input behavior checks passed");
|
||||
@@ -0,0 +1,31 @@
|
||||
const assert = require("node:assert/strict");
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
|
||||
const root = path.resolve(__dirname, "..");
|
||||
const read = (file) => fs.readFileSync(path.join(root, file), "utf8");
|
||||
|
||||
const globals = read("src/app/globals.css");
|
||||
const navigation = read("src/components/xingbang/navigation.css");
|
||||
const workspace = read("src/app/data-access/workspace.css");
|
||||
const assistant = read("src/components/xingbang/business-assistant.css");
|
||||
const assistantComponent = read("src/components/xingbang/BusinessAssistant.tsx");
|
||||
const operations = read("src/app/operations-center/operations.css");
|
||||
|
||||
assert.match(globals, /font-size:\s*16px/, "全局正文应提供清晰的基础字号");
|
||||
assert.match(globals, /:focus-visible/, "键盘操作应有明显焦点提示");
|
||||
assert.match(navigation, /\.xb-agent-nav a\{min-height:42px[^}]*font-size:14px/, "主导航应扩大文字和点击区域");
|
||||
assert.match(navigation, /\.xb-role-switch\{min-height:42px/, "身份选择器应保留足够大的点击区域");
|
||||
assert.match(workspace, /\.ra-button\{min-height:42px[^}]*font-size:14px[^}]*font-weight:700/, "工作台按钮应清晰且易点击");
|
||||
assert.match(workspace, /\.ra-button\.primary\{background:#17634c/, "主操作按钮应采用稳定的主色强调");
|
||||
assert.match(workspace, /\.xb-review-final>\.ra-button:not\(\.primary\)\{border-color:#d7837b/, "退回操作应与批准操作清楚区分");
|
||||
assert.match(workspace, /\.xb-panel>p,.xb-panel p,.xb-panel details\{font-size:14px/, "业务面板正文不应继续使用过小字号");
|
||||
assert.match(assistant, /\.ba-messages article p\{font-size:15px/, "助手对话正文应易于阅读");
|
||||
assert.match(assistant, /\.ba-messages article\.user\{width:100%;max-width:100%;margin-left:0\}/, "用户消息应占据整行以便贴右显示");
|
||||
assert.match(assistant, /\.ba-messages article\.user>div\{width:100%;justify-content:flex-end/, "用户气泡内容应靠右对齐");
|
||||
assert.match(assistant, /@media\(max-width:820px\)\{\.ba-messages article\.user p\{max-width:88%\}\}/, "移动端用户气泡应保留可读宽度");
|
||||
assert.match(assistantComponent, /className="ba-user-icon" aria-label="本人"><UserRound/, "用户消息应显示清晰的人类图标");
|
||||
assert.match(assistant, /\.ba-workspace aside button\{min-height:46px/, "助手快捷操作应有明显点击区域");
|
||||
assert.match(operations, /\.td-hero-actions button,.td-hero-actions select\{height:46px/, "时效看板操作控件应易于辨认和点击");
|
||||
|
||||
console.log("Business UI readability checks passed");
|
||||
@@ -0,0 +1,237 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Run project verification and retain machine-readable test evidence."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
import urllib.request
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
BACKEND = ROOT / "backend"
|
||||
|
||||
|
||||
def executable(name: str) -> str:
|
||||
found = shutil.which(name)
|
||||
if not found:
|
||||
raise RuntimeError(f"Required executable is unavailable: {name}")
|
||||
return found
|
||||
|
||||
|
||||
def project_python() -> str:
|
||||
candidates = [
|
||||
BACKEND / "venv" / "Scripts" / "python.exe",
|
||||
BACKEND / "venv" / "bin" / "python",
|
||||
]
|
||||
return str(next((p for p in candidates if p.exists()), Path(sys.executable)))
|
||||
|
||||
|
||||
def git_output(*args: str) -> str:
|
||||
result = subprocess.run(
|
||||
[executable("git"), *args], cwd=ROOT, text=True,
|
||||
stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, check=False,
|
||||
)
|
||||
return result.stdout.strip()
|
||||
|
||||
|
||||
def changed_files(base: str | None) -> list[str]:
|
||||
command = ["diff", "--name-only"]
|
||||
if base:
|
||||
command.append(f"{base}...HEAD")
|
||||
files = set(filter(None, git_output(*command).splitlines()))
|
||||
files.update(filter(None, git_output("ls-files", "--others", "--exclude-standard").splitlines()))
|
||||
return sorted(files)
|
||||
|
||||
|
||||
def make_plan(files: list[str], docker_runtime: bool) -> list[dict[str, str]]:
|
||||
plan = [
|
||||
{"case": "repository-diff", "reason": "Reject whitespace and patch formatting errors"},
|
||||
{"case": "backend-unit", "reason": "Run all deterministic API and agent unit tests"},
|
||||
{"case": "frontend-build", "reason": "Type-check and create the production Next.js bundle"},
|
||||
{"case": "compose-config", "reason": "Validate local and Alibaba Cloud Compose manifests"},
|
||||
]
|
||||
if any(path.startswith("backend/") for path in files):
|
||||
plan.append({"case": "backend-change-coverage", "reason": "Backend files changed; full backend suite is mandatory"})
|
||||
if any(path.startswith("src/") for path in files):
|
||||
plan.append({"case": "frontend-change-coverage", "reason": "Frontend files changed; production build is mandatory"})
|
||||
if any("docker" in path.lower() or path.startswith("deploy/") for path in files):
|
||||
plan.append({"case": "deployment-change-coverage", "reason": "Deployment files changed; both manifests are validated"})
|
||||
if docker_runtime:
|
||||
plan.append({"case": "docker-runtime-smoke", "reason": "Build, start and probe the real container"})
|
||||
return plan
|
||||
|
||||
|
||||
def run_case(name: str, command: list[str], cwd: Path, evidence_dir: Path) -> dict:
|
||||
started = time.monotonic()
|
||||
result = subprocess.run(
|
||||
command, cwd=cwd, text=True, encoding="utf-8", errors="replace",
|
||||
stdout=subprocess.PIPE, stderr=subprocess.STDOUT, check=False,
|
||||
)
|
||||
duration = round(time.monotonic() - started, 3)
|
||||
log_path = evidence_dir / f"{name}.log"
|
||||
log_path.write_text(result.stdout, encoding="utf-8")
|
||||
print(f"[{name}] {'PASS' if result.returncode == 0 else 'FAIL'} ({duration}s)")
|
||||
return {
|
||||
"name": name,
|
||||
"status": "passed" if result.returncode == 0 else "failed",
|
||||
"exit_code": result.returncode,
|
||||
"duration_seconds": duration,
|
||||
"command": command,
|
||||
"working_directory": str(cwd),
|
||||
"log": log_path.name,
|
||||
}
|
||||
|
||||
|
||||
def wait_for(url: str, timeout: int = 150) -> None:
|
||||
deadline = time.monotonic() + timeout
|
||||
last_error = "not attempted"
|
||||
while time.monotonic() < deadline:
|
||||
try:
|
||||
with urllib.request.urlopen(url, timeout=5) as response:
|
||||
if 200 <= response.status < 400:
|
||||
return
|
||||
except Exception as exc: # evidence captures the eventual timeout
|
||||
last_error = str(exc)
|
||||
time.sleep(2)
|
||||
raise RuntimeError(f"Timed out waiting for {url}: {last_error}")
|
||||
|
||||
|
||||
def docker_smoke(docker: str, evidence_dir: Path, keep: bool) -> dict:
|
||||
started = time.monotonic()
|
||||
output: list[str] = []
|
||||
status = "passed"
|
||||
exit_code = 0
|
||||
compose = [docker, "compose", "-f", str(ROOT / "docker-compose.yml")]
|
||||
try:
|
||||
for args in (["build"], ["up", "-d", "--wait"]):
|
||||
result = subprocess.run(compose + args, cwd=ROOT, text=True, encoding="utf-8",
|
||||
errors="replace", stdout=subprocess.PIPE,
|
||||
stderr=subprocess.STDOUT, check=False)
|
||||
output.append(f"$ {' '.join(compose + args)}\n{result.stdout}")
|
||||
if result.returncode:
|
||||
raise RuntimeError(f"Docker Compose {' '.join(args)} failed with {result.returncode}")
|
||||
wait_for("http://127.0.0.1:3200/")
|
||||
wait_for("http://127.0.0.1:8002/api/xingbang/health")
|
||||
output.append("HTTP smoke checks passed for frontend and backend")
|
||||
except Exception as exc:
|
||||
status, exit_code = "failed", 1
|
||||
output.append(f"ERROR: {exc}")
|
||||
finally:
|
||||
ps = subprocess.run(compose + ["ps"], cwd=ROOT, text=True, encoding="utf-8",
|
||||
errors="replace", stdout=subprocess.PIPE,
|
||||
stderr=subprocess.STDOUT, check=False)
|
||||
output.append(f"$ {' '.join(compose + ['ps'])}\n{ps.stdout}")
|
||||
if not keep:
|
||||
down = subprocess.run(compose + ["down"], cwd=ROOT, text=True, encoding="utf-8",
|
||||
errors="replace", stdout=subprocess.PIPE,
|
||||
stderr=subprocess.STDOUT, check=False)
|
||||
output.append(f"$ {' '.join(compose + ['down'])}\n{down.stdout}")
|
||||
log_path = evidence_dir / "docker-runtime-smoke.log"
|
||||
log_path.write_text("\n\n".join(output), encoding="utf-8")
|
||||
duration = round(time.monotonic() - started, 3)
|
||||
print(f"[docker-runtime-smoke] {status.upper()} ({duration}s)")
|
||||
return {"name": "docker-runtime-smoke", "status": status, "exit_code": exit_code,
|
||||
"duration_seconds": duration, "command": compose, "working_directory": str(ROOT),
|
||||
"log": log_path.name}
|
||||
|
||||
|
||||
def write_report(evidence_dir: Path, summary: dict) -> None:
|
||||
(evidence_dir / "summary.json").write_text(
|
||||
json.dumps(summary, ensure_ascii=False, indent=2), encoding="utf-8"
|
||||
)
|
||||
rows = "\n".join(
|
||||
f"| {item['name']} | {item['status']} | {item['duration_seconds']}s | [{item['log']}]({item['log']}) |"
|
||||
for item in summary["results"]
|
||||
)
|
||||
report = f"""# Verification Evidence
|
||||
|
||||
- Run ID: `{summary['run_id']}`
|
||||
- Commit: `{summary['commit']}`
|
||||
- Branch: `{summary['branch']}`
|
||||
- Started: `{summary['started_at']}`
|
||||
- Result: **{summary['status'].upper()}**
|
||||
|
||||
## Generated test plan
|
||||
|
||||
```json
|
||||
{json.dumps(summary['test_plan'], ensure_ascii=False, indent=2)}
|
||||
```
|
||||
|
||||
## Results
|
||||
|
||||
| Check | Result | Duration | Evidence |
|
||||
| --- | --- | ---: | --- |
|
||||
{rows}
|
||||
|
||||
## Changed files used to generate the plan
|
||||
|
||||
```text
|
||||
{os.linesep.join(summary['changed_files']) or '(clean working tree)'}
|
||||
```
|
||||
"""
|
||||
(evidence_dir / "REPORT.md").write_text(report, encoding="utf-8")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--base", help="Git base revision used to generate the change-aware test plan")
|
||||
parser.add_argument("--docker", action="store_true", help="Build and smoke-test the local Docker deployment")
|
||||
parser.add_argument("--keep-docker", action="store_true", help="Keep containers running after Docker smoke tests")
|
||||
args = parser.parse_args()
|
||||
|
||||
run_id = datetime.now().strftime("%Y%m%d-%H%M%S")
|
||||
evidence_dir = ROOT / "test-evidence" / f"{run_id}-{git_output('rev-parse', '--short', 'HEAD') or 'uncommitted'}"
|
||||
evidence_dir.mkdir(parents=True, exist_ok=False)
|
||||
files = changed_files(args.base)
|
||||
summary = {
|
||||
"run_id": run_id,
|
||||
"started_at": datetime.now(timezone.utc).isoformat(),
|
||||
"commit": git_output("rev-parse", "HEAD"),
|
||||
"branch": git_output("branch", "--show-current"),
|
||||
"changed_files": files,
|
||||
"test_plan": make_plan(files, args.docker),
|
||||
"results": [],
|
||||
}
|
||||
|
||||
try:
|
||||
git = executable("git")
|
||||
npm = executable("npm")
|
||||
docker = executable("docker")
|
||||
checks = [
|
||||
("repository-diff", [git, "diff", "--check"], ROOT),
|
||||
("backend-unit", [project_python(), "-m", "unittest", "discover", "-s", ".", "-p", "test_*.py", "-v"], BACKEND),
|
||||
("reviewer-workspace-ui", [executable("node"), "scripts/test_reviewer_workspace.cjs"], ROOT),
|
||||
("identity-recovery-ui", [executable("node"), "scripts/test_identity_recovery.cjs"], ROOT),
|
||||
("cloud-assistant-access-ui", [executable("node"), "scripts/test_cloud_assistant_access.cjs"], ROOT),
|
||||
("ui-readability", [executable("node"), "scripts/test_ui_readability.cjs"], ROOT),
|
||||
("frontend-build", [npm, "run", "build"], ROOT),
|
||||
("compose-local-config", [docker, "compose", "-f", "docker-compose.yml", "config", "--quiet"], ROOT),
|
||||
("compose-aliyun-config", [docker, "compose", "--env-file", "deploy/aliyun.env.example", "-f", "deploy/docker-compose.aliyun.yml", "config", "--quiet"], ROOT),
|
||||
]
|
||||
for name, command, cwd in checks:
|
||||
summary["results"].append(run_case(name, command, cwd, evidence_dir))
|
||||
if args.docker:
|
||||
summary["results"].append(docker_smoke(docker, evidence_dir, args.keep_docker))
|
||||
except Exception as exc:
|
||||
error_log = evidence_dir / "runner-error.log"
|
||||
error_log.write_text(str(exc), encoding="utf-8")
|
||||
summary["results"].append({"name": "runner", "status": "failed", "exit_code": 1,
|
||||
"duration_seconds": 0, "command": [], "working_directory": str(ROOT),
|
||||
"log": error_log.name})
|
||||
|
||||
summary["status"] = "passed" if all(x["status"] == "passed" for x in summary["results"]) else "failed"
|
||||
write_report(evidence_dir, summary)
|
||||
print(f"Evidence: {evidence_dir / 'REPORT.md'}")
|
||||
return 0 if summary["status"] == "passed" else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Reference in New Issue
Block a user