feat: 建立测试留痕与Docker云部署流程
This commit is contained in:
@@ -0,0 +1,39 @@
|
||||
name: verify
|
||||
|
||||
on:
|
||||
push:
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
test-and-build:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.11"
|
||||
cache: pip
|
||||
cache-dependency-path: backend/requirements.txt
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "20"
|
||||
cache: npm
|
||||
- run: python -m pip install -r backend/requirements.txt
|
||||
- run: npm ci
|
||||
- name: Generate test plan, verify and retain evidence
|
||||
run: python scripts/verify.py
|
||||
env:
|
||||
DEEPSEEK_API_KEY: test-only-placeholder
|
||||
XINGBANG_LOCAL_MODEL_URL: ""
|
||||
XINGBANG_REQUIRE_AUTH: "0"
|
||||
- name: Upload test evidence
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: test-evidence-${{ github.sha }}
|
||||
path: test-evidence/
|
||||
if-no-files-found: error
|
||||
retention-days: 30
|
||||
@@ -66,6 +66,7 @@ backend/verify_offline.py
|
||||
_backup*/
|
||||
*.pid
|
||||
*.tsbuildinfo
|
||||
test-evidence/
|
||||
backend/raw_logs/
|
||||
backend/backend/
|
||||
backend/xingbang_data/
|
||||
|
||||
@@ -12,6 +12,8 @@
|
||||
2. `git log --oneline -6` → 接到最近提交那条线上(当前主线:兴邦四 Agent v2 / xingbang 工作台整合)。
|
||||
3. 当前处于**_重构进行中**:`src/app/` 下多个独立页正在向 **xingbang-workbench / review-workbench / report-workbench / collateral-workbench / data-access / trajectory** 等合并。**改代码前先确认该页还在不在、已合并到哪里,别对旧页做无效修改。**
|
||||
|
||||
4. 每次行为修改必须新增或更新对应测试,并运行 `python scripts/verify.py`。测试计划和完整日志保留在 `test-evidence/`;失败时不允许发布镜像。
|
||||
|
||||
⚠️ 关键惯例:本项目也是「多轮迭代、每步有 `_backup_pre_*` 备份目录」。**改哪里先确认现状,不破坏已跑通逻辑;大改动前默认应该先在你的分支/工作区留能回滚的点。**
|
||||
|
||||
## 项目是什么
|
||||
@@ -66,4 +68,4 @@ python test_review_assist.py 2>/dev/null || echo "无该测试,视改动定"
|
||||
|
||||
## 说明
|
||||
|
||||
- 项目有完整 `ARCHITECTURE.md` / `DEV_*` 文档,遇到"这是什么/怎么拼"先去读,别臆造。
|
||||
- 项目有完整 `ARCHITECTURE.md` / `DEV_*` 文档,遇到"这是什么/怎么拼"先去读,别臆造。
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
# 开发、验证与 Docker 部署流程
|
||||
|
||||
这套流程把“修改—测试—证据—本地 Docker—阿里云”串成同一条可回滚链路。任何密钥只放在被 Git 忽略的环境文件中。
|
||||
|
||||
## 1. 本地修改
|
||||
|
||||
```powershell
|
||||
git switch -c codex/<功能名>
|
||||
git status --short
|
||||
```
|
||||
|
||||
修改前先阅读 `AGENTS.md`,一次只处理一个需求。每个行为修改必须同时新增或更新对应的 `backend/test_*.py`;纯前端修改至少必须通过生产构建。
|
||||
|
||||
## 2. 生成测试计划、执行并保留证据
|
||||
|
||||
```powershell
|
||||
python scripts/verify.py
|
||||
```
|
||||
|
||||
验证器会根据 Git 变更文件生成测试计划,并执行:
|
||||
|
||||
1. Git diff 格式检查;
|
||||
2. 全部后端 `unittest`;
|
||||
3. Next.js 生产构建(包含 TypeScript 检查);
|
||||
4. 本地和阿里云 Compose 配置校验。
|
||||
|
||||
每次结果保存在 `test-evidence/<时间>-<commit>/`:`REPORT.md` 供人阅读,`summary.json` 供系统处理,每条命令有独立日志。该目录不提交 Git;GitHub Actions 会保留 30 天构建证据。
|
||||
|
||||
通过后创建可回滚提交:
|
||||
|
||||
```powershell
|
||||
git add <本次文件>
|
||||
git commit -m "feat: 说明修改与结果"
|
||||
```
|
||||
|
||||
## 3. 本地 Docker 部署与真实容器验证
|
||||
|
||||
先启动 Docker Desktop,并确保本机 8002 端口没有原生后端占用。
|
||||
|
||||
```powershell
|
||||
$env:DEEPSEEK_API_KEY="<key>"
|
||||
python scripts/verify.py --docker --keep-docker
|
||||
```
|
||||
|
||||
验证通过后:
|
||||
|
||||
- 前端:`http://localhost:3200`
|
||||
- 后端健康检查:`http://localhost:8002/api/xingbang/health`
|
||||
- 停止:`docker compose down`
|
||||
|
||||
去掉 `--keep-docker` 时,验证结束会自动停止容器,但保留证据。
|
||||
|
||||
## 4. 推送到阿里云 ACR
|
||||
|
||||
在 ACR 控制台创建实例、命名空间和镜像仓库,复制控制台给出的完整 Registry 域名。新版个人版通常为 `crpi-*.cn-<region>.personal.cr.aliyuncs.com`,不要在脚本中硬编码密码。
|
||||
|
||||
```powershell
|
||||
docker login --username=<ACR登录名> <ACR Registry域名>
|
||||
.\scripts\publish-acr.ps1 -Registry <ACR Registry域名> -Namespace <命名空间>
|
||||
```
|
||||
|
||||
脚本只有在全部验证通过后才会构建和推送镜像,默认使用 Git 短 SHA 作为不可变版本标签。Linux/macOS 使用 `scripts/publish-acr.sh`。
|
||||
|
||||
## 5. 在阿里云 ECS 上运行
|
||||
|
||||
1. 在 ECS 安装 Docker Engine 和 Compose V2,并设置开机启动。
|
||||
2. 安全组只开放需要的端口:前端默认 80;当前前端架构还需 8002 API 端口。正式 HTTPS 环境建议在两者之前增加 Nginx/SLB 同源反向代理。
|
||||
3. 把 `deploy/` 和 `scripts/deploy-aliyun.sh` 复制到 ECS。
|
||||
4. 在 ECS 上登录 ACR,并创建私密配置:
|
||||
|
||||
```bash
|
||||
cp deploy/aliyun.env.example deploy/aliyun.env
|
||||
chmod 600 deploy/aliyun.env
|
||||
# 编辑 APP_IMAGE、DEEPSEEK_API_KEY 和可选 ERP 连接
|
||||
docker login --username=<ACR登录名> <ACR Registry域名>
|
||||
bash scripts/deploy-aliyun.sh
|
||||
```
|
||||
|
||||
发布新版本时把 `APP_IMAGE` 改为新 SHA 标签,再执行部署脚本。回滚时把 `APP_IMAGE` 改回上一个 SHA 标签并重新执行,数据卷不会被删除。
|
||||
|
||||
## 发布门禁
|
||||
|
||||
- 没有 `REPORT.md` 或验证失败:不发布。
|
||||
- 不使用 `latest`;镜像必须用 Git SHA 标签。
|
||||
- 不把 `.env`、ACR 密码、DeepSeek Key 或 ERP 密码提交 Git。
|
||||
- 发布前必须确认 CORS、身份认证、TLS 和数据备份策略符合目标环境。
|
||||
@@ -88,8 +88,7 @@ if __name__ == "__main__":
|
||||
else:
|
||||
print("❌ 测试失败,请检查配置")
|
||||
|
||||
print("
|
||||
💡 使用说明:")
|
||||
print("\n💡 使用说明:")
|
||||
print("1. 在 https://openweathermap.org/api 注册并获取 API 密钥")
|
||||
print("2. 在 .env 文件中设置 OPENWEATHER_API_KEY=your_api_key")
|
||||
print("3. 运行:python test_openweather.py [城市名]")
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
# Full image name copied from Alibaba Cloud ACR, including an immutable tag.
|
||||
APP_IMAGE=crpi-example.cn-hangzhou.personal.cr.aliyuncs.com/example/mini-openclaw:git-sha
|
||||
|
||||
FRONTEND_PORT=80
|
||||
BACKEND_PORT=8002
|
||||
|
||||
DEEPSEEK_API_KEY=replace-me
|
||||
DEEPSEEK_MODEL=deepseek-chat
|
||||
DEEPSEEK_BASE_URL=https://api.deepseek.com
|
||||
|
||||
ERP_DB_HOST=
|
||||
ERP_DB_PORT=5432
|
||||
ERP_DB_NAME=
|
||||
ERP_DB_USER=
|
||||
ERP_DB_PASSWORD=
|
||||
ERP_DB_SSL_MODE=require
|
||||
@@ -0,0 +1,32 @@
|
||||
services:
|
||||
xingbang-app:
|
||||
image: ${APP_IMAGE:?Set APP_IMAGE to the full Alibaba Cloud ACR image reference}
|
||||
container_name: mini-openclaw
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "${FRONTEND_PORT:-80}:3000"
|
||||
- "${BACKEND_PORT:-8002}:8002"
|
||||
environment:
|
||||
DEEPSEEK_API_KEY: ${DEEPSEEK_API_KEY:?Set DEEPSEEK_API_KEY}
|
||||
DEEPSEEK_MODEL: ${DEEPSEEK_MODEL:-deepseek-chat}
|
||||
DEEPSEEK_BASE_URL: ${DEEPSEEK_BASE_URL:-https://api.deepseek.com}
|
||||
ERP_DB_HOST: ${ERP_DB_HOST:-}
|
||||
ERP_DB_PORT: ${ERP_DB_PORT:-5432}
|
||||
ERP_DB_NAME: ${ERP_DB_NAME:-}
|
||||
ERP_DB_USER: ${ERP_DB_USER:-}
|
||||
ERP_DB_PASSWORD: ${ERP_DB_PASSWORD:-}
|
||||
ERP_DB_SSL_MODE: ${ERP_DB_SSL_MODE:-require}
|
||||
RISK_AGENT_BACKEND_URL: http://127.0.0.1:8002
|
||||
volumes:
|
||||
- xingbang-data:/app/backend/xingbang_data
|
||||
- xingbang-logs:/app/backend/logs
|
||||
healthcheck:
|
||||
test: ["CMD", "node", "-e", "Promise.all([fetch('http://127.0.0.1:3000/'),fetch('http://127.0.0.1:8002/')]).then(rs=>process.exit(rs.every(r=>r.ok)?0:1)).catch(()=>process.exit(1))"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
start_period: 120s
|
||||
retries: 3
|
||||
|
||||
volumes:
|
||||
xingbang-data:
|
||||
xingbang-logs:
|
||||
+3
-1
@@ -6,7 +6,9 @@
|
||||
"dev": "next dev -H 0.0.0.0",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "next lint"
|
||||
"lint": "next lint",
|
||||
"verify": "python scripts/verify.py",
|
||||
"verify:docker": "python scripts/verify.py --docker"
|
||||
},
|
||||
"dependencies": {
|
||||
"@monaco-editor/react": "^4.6.0",
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
#!/usr/bin/env bash
|
||||
set -Eeuo pipefail
|
||||
|
||||
root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
env_file="${1:-$root/deploy/aliyun.env}"
|
||||
compose_file="$root/deploy/docker-compose.aliyun.yml"
|
||||
|
||||
[[ -f "$env_file" ]] || { echo "Missing environment file: $env_file" >&2; exit 2; }
|
||||
docker compose --env-file "$env_file" -f "$compose_file" config --quiet
|
||||
docker compose --env-file "$env_file" -f "$compose_file" pull
|
||||
docker compose --env-file "$env_file" -f "$compose_file" up -d --wait
|
||||
docker compose --env-file "$env_file" -f "$compose_file" ps
|
||||
|
||||
frontend_port="$(sed -n 's/^FRONTEND_PORT=//p' "$env_file" | tail -1)"
|
||||
backend_port="$(sed -n 's/^BACKEND_PORT=//p' "$env_file" | tail -1)"
|
||||
curl --fail --show-error --silent "http://127.0.0.1:${frontend_port:-80}/" >/dev/null
|
||||
curl --fail --show-error --silent "http://127.0.0.1:${backend_port:-8002}/api/xingbang/health" >/dev/null
|
||||
echo "Alibaba Cloud deployment is healthy"
|
||||
@@ -0,0 +1,26 @@
|
||||
param(
|
||||
[Parameter(Mandatory = $true)][string]$Registry,
|
||||
[Parameter(Mandatory = $true)][string]$Namespace,
|
||||
[string]$Repository = "mini-openclaw",
|
||||
[string]$Tag = ""
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
$root = (Resolve-Path (Join-Path $PSScriptRoot "..")).Path
|
||||
if (-not $Tag) {
|
||||
$Tag = (git -C $root rev-parse --short HEAD).Trim()
|
||||
}
|
||||
if ($Registry -notmatch "\.aliyuncs\.com$") {
|
||||
throw "Registry must be an Alibaba Cloud ACR domain ending in .aliyuncs.com"
|
||||
}
|
||||
$image = "$Registry/$Namespace/$Repository`:$Tag"
|
||||
|
||||
python (Join-Path $root "scripts/verify.py")
|
||||
if ($LASTEXITCODE -ne 0) { throw "Verification failed; image was not published" }
|
||||
|
||||
docker build --pull -t $image $root
|
||||
if ($LASTEXITCODE -ne 0) { throw "Docker build failed" }
|
||||
docker push $image
|
||||
if ($LASTEXITCODE -ne 0) { throw "Docker push failed" }
|
||||
|
||||
Write-Output "Published immutable image: $image"
|
||||
@@ -0,0 +1,20 @@
|
||||
#!/usr/bin/env bash
|
||||
set -Eeuo pipefail
|
||||
|
||||
if [[ $# -lt 2 || $# -gt 4 ]]; then
|
||||
echo "Usage: $0 <acr-registry> <namespace> [repository] [tag]" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
registry="$1"
|
||||
namespace="$2"
|
||||
repository="${3:-mini-openclaw}"
|
||||
tag="${4:-$(git -C "$root" rev-parse --short HEAD)}"
|
||||
[[ "$registry" == *.aliyuncs.com ]] || { echo "ACR registry must end in .aliyuncs.com" >&2; exit 2; }
|
||||
image="${registry}/${namespace}/${repository}:${tag}"
|
||||
|
||||
python "$root/scripts/verify.py"
|
||||
docker build --pull -t "$image" "$root"
|
||||
docker push "$image"
|
||||
echo "Published immutable image: $image"
|
||||
@@ -0,0 +1,233 @@
|
||||
#!/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),
|
||||
("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