#!/usr/bin/env bash set -euo pipefail ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" PORTAL_ROOT="$ROOT_DIR/deploy/tksea-portal" ARTIFACT_DIR="${ARTIFACT_DIR:-$(mktemp -d "/tmp/sub2api-cn-relay-manager-frontend-smoke-XXXXXX")}" WORK_DIR="$(mktemp -d "/tmp/sub2api-cn-relay-manager-frontend-smoke-work-XXXXXX")" PORT_FILE="$WORK_DIR/server-port.txt" SERVER_LOG="$WORK_DIR/server.log" SERVER_SCRIPT="$WORK_DIR/frontend_smoke_server.py" CHROMIUM_BIN="${CHROMIUM_BIN:-}" USER_DATA_DIR="$WORK_DIR/chromium-profile" cleanup() { if [[ -n "${SERVER_PID:-}" ]]; then kill "$SERVER_PID" >/dev/null 2>&1 || true wait "$SERVER_PID" >/dev/null 2>&1 || true fi rm -rf "$WORK_DIR" } trap cleanup EXIT fail() { echo "FAIL: $*" >&2 exit 1 } assert_contains_file() { local file="$1" local needle="$2" if ! grep -Fq "$needle" "$file"; then fail "expected [$needle] in $file" fi } find_chromium() { if [[ -n "$CHROMIUM_BIN" ]]; then printf '%s\n' "$CHROMIUM_BIN" return 0 fi local candidate for candidate in chromium chromium-browser google-chrome google-chrome-stable; do if command -v "$candidate" >/dev/null 2>&1; then command -v "$candidate" return 0 fi done return 1 } CHROMIUM_BIN="$(find_chromium)" || fail "missing chromium-compatible browser; set CHROMIUM_BIN explicitly" [[ -x "$CHROMIUM_BIN" ]] || fail "chromium binary is not executable: $CHROMIUM_BIN" [[ -d "$PORTAL_ROOT" ]] || fail "missing portal root: $PORTAL_ROOT" mkdir -p "$ARTIFACT_DIR" "$USER_DATA_DIR" cat >"$SERVER_SCRIPT" <<'PY' #!/usr/bin/env python3 import json import mimetypes import os from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer from pathlib import Path from urllib.parse import parse_qs, urlparse ROOT = Path(os.environ["PORTAL_ROOT"]).resolve() PORT_FILE = Path(os.environ["PORT_FILE"]) def json_response(handler, payload, status=200): body = json.dumps(payload, ensure_ascii=False).encode("utf-8") handler.send_response(status) handler.send_header("Content-Type", "application/json; charset=utf-8") handler.send_header("Content-Length", str(len(body))) handler.end_headers() handler.wfile.write(body) def text_response(handler, payload, status=200): body = payload.encode("utf-8") handler.send_response(status) handler.send_header("Content-Type", "text/plain; charset=utf-8") handler.send_header("Content-Length", str(len(body))) handler.end_headers() handler.wfile.write(body) def sample_portal_logical_groups(): return { "logical_groups": [ { "logical_group_id": "smoke-portal-group", "display_name": "Smoke Portal Group", "description": "用于最小前端 smoke 的逻辑分组样本。", "public_models": [{"public_model": "gpt-5.4"}], "active_route_count": 1, "visibility_scope": "public", "package_tier": "standard", "usage_scenario": "浏览器级 smoke 验证", "recommendation": "先确认页面可打开,再验证目录与导航。", "next_step_hint": "如需完整验收,再跑真实宿主 acceptance。", "purchase_cta_label": "申请测试 Key", "purchase_cta_url": "/portal/", } ] } def sample_groups_available(): return [ { "id": 101, "name": "OpenAI 中转默认分组", } ] def sample_subscriptions(): return [ { "id": 501, "group_id": 101, "status": "active", "expires_at": "2099-12-31T00:00:00Z", } ] def sample_keys(): return { "items": [ { "id": 1, "name": "Smoke Key", "group_id": 101, "key": "sk-smoke-visible-key", "status": "active", "created_at": "2099-01-01T00:00:00Z", "expires_at": "2099-12-31T00:00:00Z", } ] } def sample_admin_session(): return { "authenticated": True, "login_enabled": True, "username": "smoke-admin", "expires_at": "2099-12-31T00:00:00Z", } def sample_logical_groups(): return { "logical_groups": [ { "logical_group_id": "smoke-lg-001", "display_name": "Smoke Logical Group", "status": "active", "description": "Smoke logical group", "shadow_group_id": "shadow-smoke-group", "shadow_host_id": "shadow-smoke-host", "routes": [], "public_models": [], } ] } def sample_route_health(): return { "route_health": [ { "route_id": "smoke-route-primary", "logical_group_id": "smoke-lg-001", "runtime_status": "healthy", "priority": 10, "weight": 100, "public_model_count": 1, "recent_failover_count": 0, "last_error_class": "", "cooldown_reason": "", } ] } def sample_provider_accounts(): return { "provider_accounts": [ { "id": 2001, "display_name": "Smoke Provider Account", "provider_id": "smoke-provider", "host_id": "host-smoke-001", "status": "active", "binding_state": "assigned", "binding_candidate_count": 1, "logical_group_id": "smoke-lg-001", "route_id": "smoke-route-primary", "shadow_group_id": "shadow-smoke-group", "shadow_host_id": "shadow-smoke-host", } ] } def sample_packs(): return { "packs": [ { "pack_id": "openai-cn-pack", "display_name": "OpenAI CN Pack", "provider_count": 1, } ] } def sample_hosts(): return { "hosts": [ { "host_id": "host-smoke-001", "name": "Smoke Host", "base_url": "https://host-smoke.example.com", } ] } def sample_pack_providers(): return { "providers": [ { "provider_id": "smoke-provider", "display_name": "Smoke Provider", "platform": "openai", "base_url": "https://provider-smoke.example.com/v1", "smoke_test_model": "gpt-5.4", "supported_models": ["gpt-5.4"], "host_overlays": 0, } ] } def sample_provider_drafts(): return {"provider_drafts": []} def sample_batch_run(): return { "run": { "run_id": "smoke-run-001", "status": "succeeded", "matched_account_state_summary": {"created": 1}, } } def sample_batch_items(): return { "items": [ { "provider_id": "smoke-provider", "matched_account_state": "created", "account_resolution": "created", "provision_reused": False, } ] } class Handler(SimpleHTTPRequestHandler): def log_message(self, fmt, *args): return def serve_static(self, rel_path): file_path = (ROOT / rel_path).resolve() if not file_path.exists() or ROOT not in file_path.parents and file_path != ROOT: self.send_error(404, "not found") return content = file_path.read_bytes() mime_type, _ = mimetypes.guess_type(str(file_path)) self.send_response(200) self.send_header("Content-Type", f"{mime_type or 'text/plain'}; charset=utf-8") self.send_header("Content-Length", str(len(content))) self.end_headers() self.wfile.write(content) def do_GET(self): parsed = urlparse(self.path) path = parsed.path query = parse_qs(parsed.query) if path == "/healthz": text_response(self, "ok") return if path == "/portal": self.send_response(302) self.send_header("Location", "/portal/") self.end_headers() return if path == "/portal/admin": self.send_response(302) self.send_header("Location", "/portal/admin/") self.end_headers() return if path == "/portal/": self.serve_static("index.html") return if path == "/portal/admin/": self.serve_static("admin/index.html") return if path.startswith("/portal/"): rel_path = path[len("/portal/"):] self.serve_static(rel_path) return if path == "/portal-proxy/api/v1/auth/me": json_response( self, { "code": 0, "data": { "id": 42, "email": "smoke-user@example.com", "allowed_groups": [101], }, }, ) return if path == "/portal-proxy/api/v1/groups/available": json_response(self, {"code": 0, "data": sample_groups_available()}) return if path == "/portal-proxy/api/v1/subscriptions": json_response(self, {"code": 0, "data": sample_subscriptions()}) return if path == "/portal-proxy/api/v1/keys": json_response(self, {"code": 0, "data": sample_keys()}) return if path == "/portal-admin-api/api/portal/logical-groups": json_response(self, sample_portal_logical_groups()) return if path == "/portal-admin-api/api/admin/session": json_response(self, sample_admin_session()) return if path == "/portal-admin-api/api/logical-groups": json_response(self, sample_logical_groups()) return if path == "/portal-admin-api/api/routing/routes/health": json_response(self, sample_route_health()) return if path == "/portal-admin-api/api/provider-accounts": json_response(self, sample_provider_accounts()) return if path == "/portal-admin-api/api/packs": json_response(self, sample_packs()) return if path == "/portal-admin-api/api/hosts": json_response(self, sample_hosts()) return if path == "/portal-admin-api/api/provider-drafts": json_response(self, sample_provider_drafts()) return if path == "/portal-admin-api/api/batch-import/runs/smoke-run-001": json_response(self, sample_batch_run()) return if path == "/portal-admin-api/api/batch-import/runs/smoke-run-001/items": json_response(self, sample_batch_items()) return if path.startswith("/portal-admin-api/api/packs/") and path.endswith("/providers"): json_response(self, sample_pack_providers()) return if path.startswith("/portal-admin-api/api/provider-accounts/") and path.endswith("/binding-candidates"): json_response( self, { "binding_candidates": [ { "logical_group_id": "smoke-lg-001", "route_id": "smoke-route-primary", } ] }, ) return if path.startswith("/portal-admin-api/api/"): json_response(self, {"ok": True, "path": path, "query": query}) return self.send_error(404, "not found") def do_POST(self): parsed = urlparse(self.path) path = parsed.path if path == "/portal-admin-api/api/admin/session/login": json_response(self, sample_admin_session()) return if path == "/portal-admin-api/api/admin/session/logout": json_response(self, {"ok": True}) return if path == "/portal-admin-api/api/batch-import/runs": json_response( self, { "run": { "run_id": "smoke-run-001", "status": "created", } }, ) return json_response(self, {"ok": True, "path": path}) server = ThreadingHTTPServer(("127.0.0.1", 0), Handler) PORT_FILE.write_text(str(server.server_port), encoding="utf-8") server.serve_forever() PY PORTAL_ROOT="$PORTAL_ROOT" PORT_FILE="$PORT_FILE" python3 "$SERVER_SCRIPT" >"$SERVER_LOG" 2>&1 & SERVER_PID=$! for _ in $(seq 1 50); do if [[ -s "$PORT_FILE" ]]; then break fi sleep 0.1 done if [[ ! -s "$PORT_FILE" ]]; then if [[ -s "$SERVER_LOG" ]]; then cat "$SERVER_LOG" >&2 || true fi fail "frontend smoke server did not start" fi SERVER_PORT="$(cat "$PORT_FILE")" BASE_URL="http://127.0.0.1:$SERVER_PORT" for _ in $(seq 1 50); do if curl -fsS "$BASE_URL/healthz" >/dev/null 2>&1; then break fi sleep 0.1 done curl -fsS "$BASE_URL/healthz" >/dev/null 2>&1 || fail "frontend smoke server is not healthy" dump_dom() { local label="$1" local url="$2" local output="$ARTIFACT_DIR/${label}.dom.html" "$CHROMIUM_BIN" \ --headless \ --disable-gpu \ --no-sandbox \ --user-data-dir="$USER_DATA_DIR/$label" \ --virtual-time-budget=10000 \ --dump-dom \ "$url" >"$output" 2>"$ARTIFACT_DIR/${label}.stderr.txt" printf '%s\n' "$output" } portal_dom="$(dump_dom "00-portal" "$BASE_URL/portal/")" admin_home_dom="$(dump_dom "01-admin-home" "$BASE_URL/portal/admin/")" logical_groups_dom="$(dump_dom "02-logical-groups" "$BASE_URL/portal/admin/logical-groups.html")" route_health_dom="$(dump_dom "03-route-health" "$BASE_URL/portal/admin/route-health.html")" accounts_dom="$(dump_dom "04-accounts" "$BASE_URL/portal/admin/accounts.html")" providers_dom="$(dump_dom "05-providers" "$BASE_URL/portal/admin/providers.html")" batch_dom="$(dump_dom "06-batch-import" "$BASE_URL/portal/admin-batch-import.html")" compat_batch_dom="$(dump_dom "07-batch-import-compat" "$BASE_URL/portal/admin/batch-import.html")" assert_contains_file "$portal_dom" "Sub2API 多模型接入中心" assert_contains_file "$portal_dom" "Smoke Portal Group" assert_contains_file "$portal_dom" "逻辑分组目录" assert_contains_file "$portal_dom" "申请测试 Key" assert_contains_file "$admin_home_dom" "Admin Portal" assert_contains_file "$admin_home_dom" "/portal/admin/providers.html" assert_contains_file "$admin_home_dom" "/portal/admin/accounts.html" assert_contains_file "$logical_groups_dom" "Logical Group Admin" assert_contains_file "$logical_groups_dom" "smoke-admin" assert_contains_file "$logical_groups_dom" "Smoke Logical Group" assert_contains_file "$route_health_dom" "Route Health Admin" assert_contains_file "$route_health_dom" "smoke-admin" assert_contains_file "$route_health_dom" "smoke-route-primary" assert_contains_file "$accounts_dom" "Provider Accounts Admin" assert_contains_file "$accounts_dom" "smoke-admin" assert_contains_file "$accounts_dom" "Smoke Provider Account" assert_contains_file "$providers_dom" "Provider Admin" assert_contains_file "$providers_dom" "smoke-admin" assert_contains_file "$providers_dom" "保存到服务端" assert_contains_file "$batch_dom" "Batch Import Admin" assert_contains_file "$batch_dom" "smoke-admin" assert_contains_file "$batch_dom" "matched_account_state" assert_contains_file "$compat_batch_dom" "Batch Import Admin" assert_contains_file "$compat_batch_dom" "smoke-admin" cat >"$ARTIFACT_DIR/99-summary.json" <